@bongos/core 1.19.1015 → 1.19.1016

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 (53) hide show
  1. package/.bongos-core.json +98 -73
  2. package/.claude/skills/planning-session/SKILL.md +1 -1
  3. package/clients/bongos-client/index.d.ts +1 -1
  4. package/docs/adr/0331-the-sky-is-the-ideators-home.md +12 -0
  5. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
  6. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
  7. package/docs/adr/README.md +2 -0
  8. package/docs/api/openapi.json +4 -0
  9. package/docs/api-reference.md +1 -1
  10. package/docs/architecture.md +4 -1
  11. package/docs/file-map.md +0 -1
  12. package/docs/module-api-changelog.md +3 -1
  13. package/migrations/core_253_idea_filed_via.sql +59 -0
  14. package/modules/agents/pollers/event-router.js +2 -0
  15. package/modules/discord/discord-bugs.js +2 -0
  16. package/modules/discord/discord-inbound.js +3 -0
  17. package/modules/hall-ui/public/sky-data.js +22 -8
  18. package/modules/hall-ui/public/sky-draw.js +3 -2
  19. package/modules/hall-ui/public/sky-panel.js +1 -1
  20. package/modules/ideas/born-of.js +55 -0
  21. package/modules/ideas/inbox.js +23 -8
  22. package/modules/ideas/projection.js +48 -0
  23. package/modules/ideas/routes/inbox.js +20 -0
  24. package/modules/lifecycle/sky.js +25 -4
  25. package/modules/platform-identity/activity-snapshots.js +47 -0
  26. package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
  27. package/modules/platform-identity/platform-identity.js +13 -0
  28. package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
  29. package/modules/platform-identity/tests/platform-identity.mjs +21 -0
  30. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/scripts/gds/capture.js +11 -0
  34. package/scripts/gds/run-unit-tests.js +4 -0
  35. package/src/module-api.js +1 -1
  36. package/tests/activity_snapshots_db.mjs +97 -0
  37. package/tests/discord_bugs.mjs +1 -0
  38. package/tests/discord_inbound.mjs +1 -0
  39. package/tests/goal_map_page.mjs +14 -0
  40. package/tests/guild_namespace_reserved.mjs +76 -0
  41. package/tests/hall_sky.mjs +33 -16
  42. package/tests/idea_filed_via.mjs +185 -0
  43. package/tests/landing_page.mjs +8 -0
  44. package/tests/profile_rollup_consent.mjs +5 -1
  45. package/tests/project_admin_console_model.mjs +105 -0
  46. package/tests/sky_read.mjs +19 -2
  47. package/tests/ui_design_skills.mjs +9 -0
  48. package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
  49. package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
  50. package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
  51. package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
  52. package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
  53. package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
@@ -596,6 +596,27 @@ await ta('upsertActivity keeps a legitimate core rank key', async () => {
596
596
  assert.equal(pool.calls[0].params[5], 'archon');
597
597
  });
598
598
 
599
+ await ta('upsertActivity also appends to the snapshot history (task 1002977, community spec D2) — deleting that call would leave this red', async () => {
600
+ const pool = mockPool([{ rows: [{ id: '1' }] }, { rows: [{ id: 's1', reported_at: 'T' }] }]);
601
+ await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
602
+ assert.equal(pool.calls.length, 2, 'the rollup upsert, then the snapshot statement');
603
+ assert.match(pool.calls[1].text, /INSERT INTO platform_identity_activity_snapshots/);
604
+ assert.deepEqual(pool.calls[1].params, ['c1', 42, 100, 5, 3]);
605
+ });
606
+
607
+ await ta('upsertActivity survives a snapshot-write failure (best-effort — the application-echo.js precedent): the rollup report still succeeds', async () => {
608
+ const pool = {
609
+ calls: [],
610
+ query: async (text, params) => {
611
+ pool.calls.push({ text: String(text), params });
612
+ if (/INSERT INTO platform_identity_activity_snapshots/.test(text)) throw new Error('relation "platform_identity_activity_snapshots" does not exist');
613
+ return { rows: [{ id: '1' }] };
614
+ },
615
+ };
616
+ const out = await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
617
+ assert.equal(out.id, '1', 'the rollup upsert result still returns despite the snapshot write throwing');
618
+ });
619
+
599
620
  await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
600
621
  const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member', signin_verified_at: 'T' };
601
622
  const pool = mockPool([{ rows: [row] }]);
@@ -42,7 +42,7 @@ What changed in translation, on purpose. The method's **three persisted homes**
42
42
  Read four live signals, then print two or three recommended next commands with a one-line reason each, followed by the full list grouped by category. Signals, all read locally and none written:
43
43
 
44
44
  1. **The world files**: does `DESIGN.md` exist at the repo root; is `config/branding.json` present over the neutral pack; is `docs/project-context.template.md` filled or still the scaffold. A scaffold with no answers recommends `shape` first; a missing `DESIGN.md` alone does not make the project greenfield (the neutral pack is the world).
45
- 2. **The last kit report** for the target, if any: `report.json` under `%TMP%/ui-design-kit/<surface>/<page>/` (or the `--out` dir the last run named). Problems listed recommend `audit` on that page; a clean report with no critique yet recommends `critique`; no report at all recommends `audit`, because the render is the read.
45
+ 2. **The last kit report** for the target, if any: `report.json` under the `--out` dir the last `render.js` run named — pass one explicitly on a shared machine, since the un-named default falls back to a world-readable OS temp dir (`os.tmpdir()`'s `ui-design-kit/<surface>/<page>/`: `%TMP%` on Windows, `$TMPDIR` on mac/linux). This is only ever read to shape the printed suggestion below, never trusted as authority. Problems listed recommend `audit` on that page; a clean report with no critique yet recommends `critique`; no report at all recommends `audit`, because the render is the read.
46
46
  3. **Dirty files in version control**: `git status --porcelain` over the surfaces (`modules/*/public/`, `src/ui/`, `public*/`). Dirty UI files recommend `polish` on that path before anything new.
47
47
  4. **A stub already listening**: `kit/serve.js` prints its port when it starts; a page already served means the live loop (the playwright plugin) is available without a second server. Never start a duplicate on an occupied port.
48
48
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1015",
3
+ "version": "1.19.1016",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1015",
9
+ "version": "1.19.1016",
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.19.1015",
3
+ "version": "1.19.1016",
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",
@@ -269,6 +269,7 @@ async function main() {
269
269
  console.error(' ... [--spark] offer it up for someone else to develop');
270
270
  console.error(' ... [--full --fields-file f.json] file a Full Idea (five fields; see spark.js --template)');
271
271
  console.error(' ... [--no-prompt] never ask anything (default when not a terminal)');
272
+ console.error(' ... [--via planning] a planning session\'s capture — the sky records the door (task 1004233)');
272
273
  process.exit(2);
273
274
  }
274
275
 
@@ -281,6 +282,15 @@ async function main() {
281
282
  const fieldsFile = arg('--fields-file', args);
282
283
  const noPrompt = hasFlag('--no-prompt', args);
283
284
  const goalArg = arg('--goal', args);
285
+ // task 1004233 (sky part 5): WHICH DOOR. The server reads 'cli' off this
286
+ // session's token, so this CLI names nothing — except a planning session's
287
+ // capture, which no session can tell from any other CLI filing. Only that one
288
+ // value is accepted here; the server refuses every other claim the same way.
289
+ const viaFromArg = arg('--via', args);
290
+ if (viaFromArg !== null && viaFromArg !== 'planning') {
291
+ console.error(`--via "${viaFromArg}" is not a door this CLI may name. Where a filing came from is read off your session; the one value you may add is --via planning.`);
292
+ process.exit(2);
293
+ }
284
294
 
285
295
  // A BAD --kind used to reach the server and come back a 400 (task 1003694).
286
296
  // That is not merely late, it is invisible where it matters: a SCHEDULED routine
@@ -376,6 +386,7 @@ async function main() {
376
386
  suggested_priority: priority,
377
387
  };
378
388
  if (kindFromArg) payload.kind = kindFromArg;
389
+ if (viaFromArg) payload.filed_via = viaFromArg;
379
390
  if (goalResolution.goalId != null) payload.goal_id = goalResolution.goalId;
380
391
  if (wantFull) {
381
392
  // The server validates these against the R141 declaration and scores them
@@ -83,6 +83,10 @@ const INTEGRATION = new Set([
83
83
  // rows is NULL rather than a free ship. Only a transaction engine settles
84
84
  // those, so this runs here and the DB-free sibling covers the renderer.
85
85
  'acclaim_feed_evidence_db',
86
+ // task 1002977: proves the changed-totals skip rule (a single INSERT ...
87
+ // WHERE NOT EXISTS statement) and the upsertActivity wiring against real SQL —
88
+ // a fake pool can prove the query shape but not which reports it inserts.
89
+ 'activity_snapshots_db',
86
90
  'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
87
91
  'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
88
92
  // consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1015'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1016'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,97 @@
1
+ // tests/activity_snapshots_db.mjs — task 1002977 (community spec D2; real DB).
2
+ //
3
+ // Proves the windowed data plane's append-only history against real SQL. The
4
+ // skip-on-unchanged rule lives entirely inside one INSERT ... WHERE NOT EXISTS
5
+ // statement (activity-snapshots.js), so a fake pool can only prove the query
6
+ // SHAPE (modules/platform-identity/tests/activity-snapshots.mjs); only a real
7
+ // Postgres can prove WHICH reports it actually inserts, and that upsertActivity
8
+ // (the existing POST /sso/activity/rollup path) really calls it end to end.
9
+ //
10
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
11
+ // INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
12
+ // never runs it.
13
+ //
14
+ // Run: DATABASE_URL=... node tests/activity_snapshots_db.mjs
15
+
16
+ import { createRequire } from 'node:module';
17
+ import { readFileSync } from 'node:fs';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join } from 'node:path';
20
+ import { strict as assert } from 'node:assert';
21
+ const require = createRequire(import.meta.url);
22
+ const { Pool } = require('pg');
23
+ const pi = require('../modules/platform-identity/platform-identity.js');
24
+ const { recordActivitySnapshot } = require('../modules/platform-identity/activity-snapshots.js');
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
28
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
29
+
30
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
31
+ try {
32
+ await pool.query('SELECT 1');
33
+ } catch (e) {
34
+ console.log(`activity_snapshots_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
35
+ await pool.end().catch(() => {});
36
+ process.exit(0);
37
+ }
38
+
39
+ // High-numbered fixture ids so a shared DB can never collide with real data.
40
+ const BUILDER = 90002977, OTHER_BUILDER = 90002978;
41
+ const IDS = [BUILDER, OTHER_BUILDER];
42
+ const CLIENT = 't1002977-client';
43
+ const q = (sql, params) => pool.query(sql, params);
44
+
45
+ async function cleanup() {
46
+ await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
47
+ await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
48
+ }
49
+
50
+ let pass = 0;
51
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
52
+
53
+ try {
54
+ await q(migrationSql('platform_identity_005_builder_activity.sql'));
55
+ // upsertActivity's INSERT always names recent_ships (012_recent_ships adds the
56
+ // column); without it here, step 2 below fails 42703 on a fresh database.
57
+ await q(migrationSql('platform_identity_012_recent_ships.sql'));
58
+ await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
59
+ await cleanup();
60
+
61
+ // 1 — recordActivitySnapshot directly: the changed-totals skip rule.
62
+ const first = await recordActivitySnapshot({ clientId: CLIENT, githubId: BUILDER, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
63
+ ok(first !== null, 'a first-ever report inserts — nothing to be unchanged from');
64
+
65
+ const unchanged = await recordActivitySnapshot({ clientId: CLIENT, githubId: BUILDER, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
66
+ ok(unchanged === null, 'an identical re-report inserts nothing');
67
+
68
+ const changed = await recordActivitySnapshot({ clientId: CLIENT, githubId: BUILDER, credits: 150, tasksShipped: 5, karma: 3 }, { pool });
69
+ ok(changed !== null, 'a changed report (credits differ) inserts a new row');
70
+
71
+ const { rows: history } = await q(
72
+ `SELECT credits FROM platform_identity_activity_snapshots
73
+ WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
74
+ [CLIENT, BUILDER],
75
+ );
76
+ ok(history.length === 2, 'exactly two rows exist — the unchanged report left no trace');
77
+ assert.deepEqual(history.map((r) => r.credits), [100, 150]);
78
+ pass++; console.log(' ok - the history is in report order');
79
+
80
+ // 2 — upsertActivity (the real POST /sso/activity/rollup path) really calls it.
81
+ await pi.upsertActivity({ clientId: CLIENT, githubId: OTHER_BUILDER, credits: 10, tasksShipped: 1, karma: 0 }, { pool });
82
+ await pi.upsertActivity({ clientId: CLIENT, githubId: OTHER_BUILDER, credits: 10, tasksShipped: 1, karma: 0 }, { pool });
83
+ await pi.upsertActivity({ clientId: CLIENT, githubId: OTHER_BUILDER, credits: 20, tasksShipped: 1, karma: 0 }, { pool });
84
+ const { rows: viaRollup } = await q(
85
+ `SELECT credits FROM platform_identity_activity_snapshots
86
+ WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
87
+ [CLIENT, OTHER_BUILDER],
88
+ );
89
+ ok(viaRollup.length === 2, 'upsertActivity appends a snapshot per changed report — two calls, one repeat, one row skipped');
90
+ assert.deepEqual(viaRollup.map((r) => r.credits), [10, 20]);
91
+ pass++; console.log(' ok - deleting the call inside upsertActivity would leave this red');
92
+
93
+ console.log(`\nactivity_snapshots_db.mjs: ${pass} passed`);
94
+ } finally {
95
+ await cleanup();
96
+ await pool.end().catch(() => {});
97
+ }
@@ -115,6 +115,7 @@ await t('recordInboundBug: linked Metic → filed at ready, kind=bug, attributed
115
115
  assert.equal(calls.create[0].kind, 'bug', 'the channel IS the classification');
116
116
  assert.equal(calls.create[0].filerRank, 'metic', 'the LIVE rank is handed to the matrix');
117
117
  assert.equal(calls.create[0].capturedBy, 9, 'and the reporter is attached to the capture');
118
+ assert.equal(calls.create[0].filedVia, 'discord', 'the door is a fact on the row (task 1004233)');
118
119
  assert.equal(calls.create[0].goalId, 7, 'the suggester found it a home');
119
120
  // task 1003067: no versionId is passed AT ALL. It used to be the hardcoded
120
121
  // 'GDS-V3' — a shipped version, which is idea 1000771. The goal carries it now.
@@ -83,6 +83,7 @@ await t('recordInboundIdea: linked author → idea attributed to builder + audit
83
83
  assert.equal(r.outcome, 'filed');
84
84
  assert.equal(r.ideaId, 77);
85
85
  assert.equal(calls.capture[0].capturedBy, 9);
86
+ assert.equal(calls.capture[0].filedVia, 'discord', 'the door is a fact on the row (task 1004233): this path calls the port, so it names it');
86
87
  assert.match(calls.capture[0].bodyMd, /filed from Discord #ideas by mona/);
87
88
  assert.equal(calls.audit[0].surface, 'discord_bot');
88
89
  assert.equal(calls.audit[0].builderId, 9);
@@ -354,6 +354,20 @@ await test('the tour walks all three lenses here too, and hands the sky back to
354
354
  assert.equal(first.timers.pending(), 1, 'a first visit schedules the tour here as well');
355
355
  });
356
356
 
357
+ // ═══ born of a thought (part 5) ══════════════════════════════════════════════
358
+
359
+ await test('a well born of a thought wears it in the ledger and the panel — the fact from GET /sky\'s born, never a guess', async () => {
360
+ const sk = FOUR(); sk.born = { goals: { 9: 21 }, criteria: {} };
361
+ const p = await boot({ sky: sk, versions: versions() });
362
+ assert.equal(goal(p, '9').bornOf, '21');
363
+ assert.equal(goal(p, '7').bornOf, null);
364
+ p.key('l');
365
+ const row = (new RegExp('data-id="9"[\\s\\S]*?</button>').exec(p.rows()) || [''])[0];
366
+ assert.match(row, /born of a thought/);
367
+ p.SKY().pick({ kind: 'goal', ref: goal(p, '9') }, false);
368
+ assert.match(p.panel(), /born of a thought/);
369
+ });
370
+
357
371
  // ═══ the words are never markup ══════════════════════════════════════════════
358
372
 
359
373
  await test('a goal title never becomes markup — in the ledger, the panel or the hover card', async () => {
@@ -0,0 +1,76 @@
1
+ // tests/guild_namespace_reserved.mjs — the guild URL namespace is RESERVED where
2
+ // project slugs are decided (task 1002299, R23 guild design; task 1002912's rule).
3
+ //
4
+ // Project profiles live at the top level (`cloudbongos.com/<slug>`), so every
5
+ // top-level segment the hub uses for another kind of page must be on the reserved
6
+ // project-slug list in the SEO spec, or a project can register the word and shadow
7
+ // the namespace. `u` guards `/u/<handle>`; the guild design names its own segment,
8
+ // and task 1002912 asked for the word to be reserved there, not a route squatted.
9
+ //
10
+ // No slug validator exists yet (the SEO goal's R02 builds it from that list), so the
11
+ // spec IS the contract today. This pins the one property that can drift silently:
12
+ // the segment the guild ADR declares and the segment the spec reserves are the same.
13
+ //
14
+ // Run: node tests/guild_namespace_reserved.mjs
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { makeRunner } from './helpers.mjs';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const ADR_DIR = path.join(ROOT, 'docs', 'adr');
23
+ const SEO_SPEC = path.join(ROOT, 'docs', 'specs', 'bongos-v1-seo-public-discoverability.md');
24
+ const GUILD_TASK = '1002299';
25
+
26
+ const { test, summary } = makeRunner();
27
+
28
+ // The guild design ADR, found by the task it records rather than by number: ADR
29
+ // numbers get renumbered when two sessions collide, the task id never changes.
30
+ function guildAdr() {
31
+ const hits = fs.readdirSync(ADR_DIR)
32
+ .filter((f) => /^\d{4}-.*\.md$/.test(f))
33
+ .filter((f) => {
34
+ const head = fs.readFileSync(path.join(ADR_DIR, f), 'utf8').replace(/\r\n/g, '\n').split('\n').slice(0, 12).join('\n');
35
+ return new RegExp(`^- \\*\\*Task:\\*\\*.*task ${GUILD_TASK}\\b`, 'm').test(head);
36
+ });
37
+ assert.equal(hits.length, 1, `exactly one ADR records task ${GUILD_TASK}; found ${JSON.stringify(hits)}`);
38
+ return fs.readFileSync(path.join(ADR_DIR, hits[0]), 'utf8').replace(/\r\n/g, '\n');
39
+ }
40
+
41
+ // The segment the ADR declares on its machine-readable `Namespace:` line.
42
+ function declaredSegment(adr) {
43
+ const m = adr.match(/^- \*\*Namespace:\*\* `\/([a-z0-9-]+)\/<slug>`/m);
44
+ assert.ok(m, 'the guild ADR carries a `- **Namespace:** `/<segment>/<slug>`` header line');
45
+ return m[1];
46
+ }
47
+
48
+ // The reserved words in the SEO spec's Routing bullet: the backticked list after "Reserved:".
49
+ function reservedWords() {
50
+ const spec = fs.readFileSync(SEO_SPEC, 'utf8').replace(/\r\n/g, '\n');
51
+ const routing = spec.split('\n').find((l) => l.startsWith('- **Routing:**'));
52
+ assert.ok(routing, 'the SEO spec still has its Routing bullet');
53
+ const m = routing.match(/Reserved: `([^`]+)`/);
54
+ assert.ok(m, 'the Routing bullet still names its reserved list as `Reserved: `…``');
55
+ return m[1].split(',').map((w) => w.trim()).filter(Boolean);
56
+ }
57
+
58
+ await test('the guild ADR declares one top-level namespace segment', () => {
59
+ const seg = declaredSegment(guildAdr());
60
+ assert.match(seg, /^[a-z0-9-]+$/);
61
+ });
62
+
63
+ await test('the SEO spec reserves the segment the guild ADR declares', () => {
64
+ const seg = declaredSegment(guildAdr());
65
+ const reserved = reservedWords();
66
+ assert.ok(reserved.includes(seg),
67
+ `a project could register "${seg}" and shadow /${seg}/<slug>; reserved list is ${JSON.stringify(reserved)}`);
68
+ });
69
+
70
+ await test('the builder-profile segment it was modelled on is still reserved', () => {
71
+ // The precedent the task names. If `u` ever leaves the list, the guild reservation
72
+ // above has lost the rule it was copied from, and both need a second look.
73
+ assert.ok(reservedWords().includes('u'));
74
+ });
75
+
76
+ summary();
@@ -303,27 +303,44 @@ await test('the claude layer is HIDDEN until filed_via exists, and every thought
303
303
  assert.ok(p.S().ideas.every((i) => i.origin === 'human'));
304
304
  });
305
305
 
306
- await test('once filed_via is on a row the pill appears, that thought waits behind it, and historic rows still show', async () => {
307
- const p = await boot({ mine: mine([star(), idea({ id: 2, title: 'A session filed this', filed_via: 'session' }), idea({ id: 3, title: 'A person filed this', filed_via: 'person' })]) });
306
+ await test('filed_via IS A LEDGER FACT (part 5): cli / api / discord wait behind the claude layer, hall / planning always show, and a row from before the column shows and says so', async () => {
307
+ const p = await boot({ mine: mine([star(), idea({ id: 2, title: 'A session filed this', filed_via: 'cli' }), idea({ id: 3, title: 'A person filed this', filed_via: 'hall' }), idea({ id: 4, title: 'Planned', filed_via: 'planning' }), idea({ id: 5, title: 'A poller filed this', filed_via: 'api' }), idea({ id: 6, title: 'From Discord', filed_via: 'discord' })]), sky: sky([well()]) });
308
308
  assert.equal(p.S().hasFiledVia, true);
309
309
  assert.equal(p.el('claudeBtn').hidden, false);
310
310
  const SKY = p.SKY();
311
- assert.equal(SKY.inLayer(findIdea(p, 2)), false, 'off by default: only what a person filed');
312
- assert.equal(SKY.inLayer(findIdea(p, 10)), true, 'a historic row with no filed_via shows (the owner\'s rule)');
313
- assert.equal(SKY.inLayer(findIdea(p, 3)), true);
311
+ same(SKY.SESSION_VIAS, ['cli', 'api', 'discord'], 'the doors a session files through');
312
+ for (const id of [2, 5, 6]) assert.equal(SKY.inLayer(findIdea(p, id)), false, `${id} waits behind the pill — off by default`);
313
+ for (const id of [10, 3, 4]) assert.equal(SKY.inLayer(findIdea(p, id)), true, `${id} shows`);
314
+ assert.equal(findIdea(p, 10).filedVia, null, 'a historic row carries NULL, not a guess');
314
315
  p.click('claudeBtn');
315
- assert.equal(SKY.inLayer(findIdea(p, 2)), true, 'the pill reveals it');
316
- });
317
-
318
- await test('NO BORN-OF MARK IS DRAWN — the read does not exist yet, so nothing is guessed', async () => {
319
- const p = await boot({ mine: mine(FIVE()), sky: sky([well()]) });
320
- const S = p.S();
321
- assert.equal(S.becameGoal.size, 0);
322
- assert.equal(S.becameCrit.size, 0);
323
- assert.ok(S.ideas.every((i) => i.shaped === null));
324
- assert.ok(S.goals.every((g) => g.bornOf === null && g.crit.every((c) => c.bornOf === null)));
316
+ for (const id of [2, 5, 6]) assert.equal(SKY.inLayer(findIdea(p, id)), true, 'the pill reveals them');
317
+ SKY.pick({ kind: 'idea', ref: findIdea(p, 10) }, false);
318
+ assert.match(p.panel(), /filed before the ledger recorded where/, 'and the panel says so, in those words');
319
+ SKY.pick({ kind: 'idea', ref: findIdea(p, 3) }, false);
320
+ assert.match(p.panel(), /from the hall/);
321
+ SKY.pick({ kind: 'idea', ref: findIdea(p, 2) }, false);
322
+ assert.match(p.panel(), /from the command line · by a session, under your name/);
323
+ });
324
+
325
+ await test('BORN OF A THOUGHT IS READ FROM GET /sky (part 5), never guessed: the goal wears the nucleus, the ring the notch, and the thought sits where it became', async () => {
326
+ const sk = sky([well()]); sk.born = { goals: { 7: 10 }, criteria: { 70: 12 } };
327
+ const p = await boot({ mine: mine([star(), coldBody({ id: 12, title: 'It became a ring' })]), sky: sk });
328
+ const S = p.S(); const SKY = p.SKY(); const g = S.goalById.get('7');
329
+ same(S.BORN, { goals: { 7: '10' }, criteria: { 70: '12' } }, 'ids are strings whatever the wire sent');
330
+ assert.equal(g.bornOf, '10'); assert.equal(g.crit[0].bornOf, '12');
331
+ assert.equal(S.becameGoal.get('10'), '7'); assert.equal(S.becameCrit.get('12'), g.crit[0]);
332
+ const shaped = findIdea(p, 10); assert.equal(shaped.shaped.kind, 'goal'); assert.equal(shaped.x, g.x); assert.equal(shaped.y, g.y);
333
+ const ring = findIdea(p, 12); assert.equal(ring.shaped.kind, 'ring');
334
+ assert.ok(Math.abs(Math.hypot(ring.x - g.x, ring.y - g.y) - SKY.ringR(g, 0, 2)) < 1e-6, 'on the first ring');
335
+ assert.equal(SKY.became(shaped), 'a goal — it shaped the space');
336
+ assert.match(SKY.became(ring), /^a criterion — a ring in /);
325
337
  p.key('l');
326
- assert.doesNotMatch(p.rows(), /born of a thought/);
338
+ assert.match(p.rows(), /shaped the space/);
339
+ SKY.pick({ kind: 'goal', ref: g }, false);
340
+ assert.match(p.panel(), /born of a thought/);
341
+ const none = await boot({ mine: mine(FIVE()), sky: sky([well()]) });
342
+ assert.equal(none.S().becameGoal.size, 0, 'no born read, no mark — nothing is guessed');
343
+ assert.ok(none.S().goals.every((x) => x.bornOf === null && x.crit.every((c) => c.bornOf === null)));
327
344
  });
328
345
 
329
346
  // ═══ the bands — derived, never named by hand ════════════════════════════════
@@ -0,0 +1,185 @@
1
+ // tests/idea_filed_via.mjs — WHERE AN IDEA WAS FILED FROM, and WHICH THOUGHTS
2
+ // BECAME THE STRUCTURE (task 1004233 / sky part 5 of 5, goal 1000095; ADR 0331 §4).
3
+ //
4
+ // The sky's prototype guessed two things from text: whether a session or a
5
+ // person filed a thought, and which thoughts became goals or rings. This suite
6
+ // pins the two ledger facts that replaced those guesses:
7
+ // - the column: additive, nullable, no default, NO BACKFILL — a row older
8
+ // than the column stays NULL, because NULL is not 'cli';
9
+ // - the vocabulary: one closed set, spelled in projection.js and mirrored by
10
+ // the migration's CHECK, so a bad value is a named 400 and never a 500;
11
+ // - each writer's value: the route reads the door off the SESSION (web is the
12
+ // hall, cli is the CLI, anything else the API) and lets the body add only
13
+ // 'planning'; capture.js names 'planning' and nothing else; the Discord
14
+ // paths and the agents' sink name their own on the port; a new row always
15
+ // records a door;
16
+ // - the born read: first ratification wins, ids as strings, through the
17
+ // ideas.bornOf port so lifecycle never reads an ideas table itself.
18
+ //
19
+ // DB-free: captureIdea runs against a recording pool (the idea_quick_guard
20
+ // harness), readBornOf against a fake pool, the rest is pure or read from source.
21
+ //
22
+ // Run: node tests/idea_filed_via.mjs
23
+ process.env.NODE_ENV = 'test';
24
+ import { strict as assert } from 'node:assert';
25
+ import { createRequire } from 'node:module';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { makeRunner } from './helpers.mjs';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
34
+ const api = require(path.join(ROOT, 'src/module-api.js'));
35
+ let queries = [];
36
+ let nextRows = [];
37
+ api.pool.query = async (sql, params = []) => { queries.push({ sql, params }); return { rows: nextRows }; };
38
+ const inbox = require(path.join(ROOT, 'modules/ideas/inbox.js'));
39
+ const projection = require(path.join(ROOT, 'modules/ideas/projection.js'));
40
+ const { readBornOf } = require(path.join(ROOT, 'modules/ideas/born-of.js'));
41
+
42
+ const { test, summary } = makeRunner();
43
+ const reset = () => { queries = []; nextRows = [{ id: '41', title: 'an idea', status: 'open', grade: 'quick' }]; };
44
+ const FIVE = ['hall', 'cli', 'discord', 'planning', 'api'];
45
+ const migrationFile = fs.readdirSync(path.join(ROOT, 'migrations')).find((f) => /^core_\d+_idea_filed_via\.sql$/.test(f));
46
+
47
+ // ═══ the migration ═══════════════════════════════════════════════════════════
48
+
49
+ await test('the column is a CORE migration: additive, nullable, no default, and the CHECK names exactly the five doors', () => {
50
+ assert.ok(migrationFile, 'migrations/core_NNN_idea_filed_via.sql exists (idea_inbox is a core table — ADR 0083 §Decision #5)');
51
+ const sql = read(path.join('migrations', migrationFile));
52
+ assert.match(sql, /ALTER TABLE idea_inbox\s+ADD COLUMN IF NOT EXISTS filed_via text;/);
53
+ assert.equal(/filed_via text\s+(NOT NULL|DEFAULT)/i.test(sql), false, 'nullable, no default');
54
+ const check = /CHECK \(filed_via IS NULL OR filed_via IN \(([^)]*)\)\)/.exec(sql);
55
+ assert.ok(check, 'a CHECK closes the set');
56
+ assert.deepEqual(check[1].split(',').map((s) => s.trim().replace(/'/g, '')), FIVE);
57
+ assert.match(sql, /IF NOT EXISTS \(\s*SELECT 1 FROM pg_constraint/, 'the CHECK add is guarded — replay-safe');
58
+ assert.ok(sql.includes(`INSERT INTO schema_migrations (version) VALUES ('${migrationFile.replace(/\.sql$/, '')}')`), 'the stem is the version');
59
+ });
60
+
61
+ await test('NULL IS NOT \'cli\': the migration backfills nothing, and no writer guesses a door from text', () => {
62
+ const sql = read(path.join('migrations', migrationFile));
63
+ assert.equal(/UPDATE idea_inbox/i.test(sql), false, 'no backfill — a historic row stays NULL');
64
+ assert.equal(/title|body_md|ILIKE|~\*/.test(sql.replace(/^--.*$/gm, '')), false, 'nothing in the SQL reads the idea\'s words');
65
+ const src = read('modules/ideas/inbox.js') + read('modules/ideas/projection.js') + read('modules/ideas/routes/inbox.js');
66
+ assert.equal(/filed_via\s*=\s*CASE|classify\([^)]*\)\s*===?\s*'(cli|api)'/.test(src), false, 'no code derives a door from the text either');
67
+ });
68
+
69
+ // ═══ the vocabulary ══════════════════════════════════════════════════════════
70
+
71
+ await test('ONE closed set, spelled in projection.js, carried by every read', () => {
72
+ assert.deepEqual([...projection.FILED_VIA_VALUES], FIVE);
73
+ assert.ok(Object.isFrozen(projection.FILED_VIA_VALUES));
74
+ assert.ok(projection.IDEA_COL_NAMES.includes('filed_via'), 'on the shared column list — so the trail (and GET /sky/mine, which is the trail) returns it');
75
+ assert.match(projection.IDEA_COLS_Q, /\bi\.filed_via\b/);
76
+ for (const v of FIVE) assert.equal(projection.isValidFiledVia(v), true);
77
+ for (const v of ['session', 'person', '', null, undefined, 'HALL']) assert.equal(projection.isValidFiledVia(v), false, `${v} is not a door`);
78
+ assert.deepEqual([inbox.FILED_VIA_VALUES, inbox.resolveFiledVia], [projection.FILED_VIA_VALUES, projection.resolveFiledVia], 'the facade re-exports the one declaration');
79
+ });
80
+
81
+ // ═══ the door is read off the session ════════════════════════════════════════
82
+
83
+ await test('the session says which door: a web cookie is the hall, a device-flow token is the CLI, anything else is the API', () => {
84
+ const { filedViaForSource } = projection;
85
+ assert.equal(filedViaForSource('web'), 'hall');
86
+ assert.equal(filedViaForSource('cli'), 'cli');
87
+ assert.equal(filedViaForSource('box'), 'api');
88
+ assert.equal(filedViaForSource(null), 'api', 'a legacy sourceless session is the API, not a guess');
89
+ assert.equal(filedViaForSource(undefined), 'api');
90
+ });
91
+
92
+ await test('the body may ADD only \'planning\'; repeating the session\'s own door is harmless; any other claim is refused with the reason', () => {
93
+ const { resolveFiledVia } = projection;
94
+ assert.deepEqual(resolveFiledVia('cli', undefined), { ok: true, value: 'cli' });
95
+ assert.deepEqual(resolveFiledVia('web', null), { ok: true, value: 'hall' });
96
+ assert.deepEqual(resolveFiledVia('cli', 'planning'), { ok: true, value: 'planning' }, 'the planning session is a CLI filing no session can tell apart');
97
+ assert.deepEqual(resolveFiledVia('web', 'planning'), { ok: true, value: 'planning' });
98
+ assert.deepEqual(resolveFiledVia('web', 'hall'), { ok: true, value: 'hall' }, 'naming your own door changes nothing');
99
+ const spoof = resolveFiledVia('cli', 'hall');
100
+ assert.equal(spoof.ok, false, 'a CLI cannot say it came through the hall');
101
+ assert.deepEqual(spoof.detail.allowed, ['planning', 'cli']);
102
+ assert.match(spoof.detail.reason, /read off your session \(cli\)/);
103
+ const junk = resolveFiledVia('web', 'session');
104
+ assert.equal(junk.ok, false);
105
+ assert.match(junk.detail.reason, /must be one of hall, cli, discord, planning, api/);
106
+ });
107
+
108
+ await test('POST /inbox reads the door off req.gdsSession, refuses a bad claim as a named 400, and hands captureIdea the value', () => {
109
+ const src = read('modules/ideas/routes/inbox.js');
110
+ assert.match(src, /filed_via: \{ type: 'string', maxLength: 16 \}/, 'the body field is declared (strict validation would 400 it otherwise)');
111
+ assert.match(src, /inbox\.resolveFiledVia\(req\.gdsSession && req\.gdsSession\.source, body\.filed_via\)/, 'the SESSION source, never the body alone');
112
+ assert.match(src, /res\.fail\('bad_filed_via', 400, door\.detail\)/);
113
+ assert.match(src, /filedVia: door\.value,/);
114
+ assert.match(src, /err\.code === 'BAD_FILED_VIA' \? 400 :/, 'a value that slipped past the route is still a 400, never a constraint 500');
115
+ });
116
+
117
+ // ═══ every writer ════════════════════════════════════════════════════════════
118
+
119
+ await test('captureIdea WRITES the door on the row, defaults a silent port caller to \'api\', and refuses a value outside the set', async () => {
120
+ reset();
121
+ await inbox.captureIdea({ title: 'from the bot', capturedBy: '7', filedVia: 'discord' });
122
+ assert.equal(queries.length, 1);
123
+ assert.match(queries[0].sql, /INSERT INTO idea_inbox[\s\S]*filed_via\)/, 'the column is in the INSERT');
124
+ assert.equal(queries[0].params.at(-1), 'discord', 'and the value is the last bind');
125
+ reset();
126
+ await inbox.captureIdea({ title: 'a program said nothing', capturedBy: null });
127
+ assert.equal(queries[0].params.at(-1), 'api', 'a NEW row always records a door — NULL is for rows older than the column');
128
+ reset();
129
+ await assert.rejects(inbox.captureIdea({ title: 'x', capturedBy: '7', filedVia: 'session' }), (e) => e.code === 'BAD_FILED_VIA' && e.filedVia === 'session' && Array.isArray(e.allowed));
130
+ assert.equal(queries.length, 0, 'refused before any write');
131
+ });
132
+
133
+ await test('the ROUTED insert (a filing that becomes a task in the same act) writes the door too', () => {
134
+ const src = read('modules/ideas/inbox.js');
135
+ const inserts = [...src.matchAll(/INSERT INTO idea_inbox[\s\S]*?RETURNING \$\{IDEA_COLS\}/g)].map((m) => m[0]);
136
+ assert.equal(inserts.length, 2, 'the inbox insert and the routed insert');
137
+ for (const ins of inserts) assert.match(ins, /filed_via/, 'both name the column');
138
+ assert.match(src, /capturedBy, goalId, landing, filerRank, touches, filedVia,\s*\}, activePool\)/, 'and the routed path is handed the value');
139
+ });
140
+
141
+ await test('capture.js names \'planning\' and nothing else — every other CLI filing\'s door is read off the session', () => {
142
+ const src = read('scripts/gds/capture.js');
143
+ assert.match(src, /const viaFromArg = arg\('--via', args\);/);
144
+ assert.match(src, /if \(viaFromArg !== null && viaFromArg !== 'planning'\)[\s\S]{0,400}process\.exit\(2\)/, 'a door the CLI may not name is refused before the request');
145
+ assert.match(src, /if \(viaFromArg\) payload\.filed_via = viaFromArg;/, 'sent only when given — a pinned core that predates the field never sees it');
146
+ assert.equal(/filed_via: 'cli'/.test(src), false, 'never claims cli — the session already says so');
147
+ assert.equal(/postInbox\(/.test(read('scripts/gds/spark.js')), false, 'spark.js files no idea row of its own (a spark is made from an existing idea), so its door is the row\'s');
148
+ assert.match(read('.claude/skills/planning-session/SKILL.md'), /--via planning/, 'the planning session is told to name its door');
149
+ });
150
+
151
+ await test('the Discord doors and the agents\' sink name their own door on the port', () => {
152
+ assert.match(read('modules/discord/discord-inbound.js'), /filedVia: 'discord',/);
153
+ assert.match(read('modules/discord/discord-bugs.js'), /filedVia: 'discord',/);
154
+ assert.match(read('modules/agents/pollers/event-router.js'), /filedVia: 'api',/);
155
+ });
156
+
157
+ // ═══ born of a thought ═══════════════════════════════════════════════════════
158
+
159
+ function bornPool({ goals = [], criteria = [] } = {}) {
160
+ const calls = [];
161
+ return { calls, async query(sql) { calls.push(sql); return { rows: /ideas_goal_ratifications/.test(sql) ? goals : criteria }; } };
162
+ }
163
+
164
+ await test('readBornOf: goals from the goal ratifications, criteria from the proposal audit, the FIRST ratification wins, ids as strings', async () => {
165
+ const pool = bornPool({
166
+ goals: [{ goal_id: '7', idea_id: '10' }, { goal_id: '7', idea_id: '99' }, { goal_id: 8, idea_id: 11 }],
167
+ criteria: [{ criterion_id: '70', idea_id: '12' }, { criterion_id: '71', idea_id: null }],
168
+ });
169
+ const born = await readBornOf({ pool });
170
+ assert.deepEqual(born, { goals: { 7: '10', 8: '11' }, criteria: { 70: '12' } });
171
+ assert.equal(pool.calls.length, 2);
172
+ assert.match(pool.calls[0], /FROM ideas_goal_ratifications\s+ORDER BY ratified_at, id/);
173
+ assert.match(pool.calls[1], /FROM criterion_proposal_audit\s+ORDER BY ratified_at, id/);
174
+ assert.deepEqual(await readBornOf({ pool: bornPool() }), { goals: {}, criteria: {} }, 'nothing ratified is an empty map');
175
+ });
176
+
177
+ await test('the read is served through the ideas.bornOf port, beside ideas.capture, so lifecycle\'s GET /sky never reads an ideas table', () => {
178
+ assert.match(read('modules/ideas/routes/inbox.js'), /api\.registerProvider\('ideas\.bornOf', bornOf\.readBornOf\)/);
179
+ const sky = read('modules/lifecycle/sky.js');
180
+ assert.match(sky, /resolveOptional\('ideas\.bornOf'\)/);
181
+ assert.equal(/ideas_goal_ratifications|criterion_proposal_audit|require\(['"][^'"]*ideas/.test(sky), false);
182
+ });
183
+
184
+ console.log('');
185
+ summary();
@@ -600,3 +600,11 @@ test('the chip is the profile trigger, built with createElement; Sign out is the
600
600
  assert.match(CODE, /var signout = document\.querySelector\('#menuProfile a\.signout'\);\s*if \(signout\) signout\.addEventListener\('click', function \(e\) \{ e\.preventDefault\(\); doLogout\(\); \}\);/, 'the honest href, upgraded to the POST');
601
601
  assert.doesNotMatch(CODE, /className = 'pill signout'/, 'no sign-out pill in the bar any more');
602
602
  });
603
+
604
+ test('the retired gate assets (world/assets/clouds|creatures|plates) are gone — the chrome landing ships its plates from assets/world/ instead (task 1003650)', () => {
605
+ assert.ok(!fs.existsSync(path.join(LANDING, 'world')), 'modules/public-landing/public/world/ no longer exists');
606
+ assert.doesNotMatch(HTML, /\bworld\/assets\//, 'the page links no path under the retired world/assets/ folder');
607
+ assert.doesNotMatch(WORLD, /\bworld\/assets\//, 'world.css references no path under the retired folder');
608
+ // the chrome landing's own plates (task 1003298) live at assets/world/ and are untouched by this cleanup
609
+ assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-bongos-chrome-b.jpg')), 'the chrome plate at assets/world/ is untouched (task 1003649 converts it separately)');
610
+ });