@bongos/core 1.19.814 → 1.19.816

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.814",
6
- "core_contract": "1.19.814",
7
- "source_commit": "1f01e14964d5ea0ba6bc679b6545e33b7858829c",
5
+ "core_version": "1.19.816",
6
+ "core_contract": "1.19.816",
7
+ "source_commit": "aa1a7485bebfc27a8b19c0acbff29287f1212828",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T07:24:36.013Z",
9
+ "built_at": "2026-09-17T08:14:24.758Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 489,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2214,
14
+ "functional_verbatim": 2215,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2729,
20
- "tree_sha256": "7a2511fd82d8a030199059a73c74669bc0c447fb5860e4eca010231226cd32a1",
19
+ "file_count": 2730,
20
+ "tree_sha256": "78e804a2a5bda8a926f2f33af27c70ca3a6e0b4b5b3aa9a5b3bd8c66dd5cec24",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2542,7 +2542,7 @@
2542
2542
  {
2543
2543
  "path": "docs/module-api-changelog.md",
2544
2544
  "mode": "0000644",
2545
- "sha256": "2e01c98f75c74643cc843b80f507635d1d674749fed9185414cfce51eeac3cf8"
2545
+ "sha256": "c86194915e38b871c476151bdf00719d190411407942cfc8dd9f0a7734630ab0"
2546
2546
  },
2547
2547
  {
2548
2548
  "path": "docs/modules-contract.md",
@@ -3672,7 +3672,7 @@
3672
3672
  {
3673
3673
  "path": "modules/agents/lib/validate.js",
3674
3674
  "mode": "0000644",
3675
- "sha256": "cd3659ad5831c767fe6600fabbe883f33b3429107a0b999092158dfa7c95b4e7"
3675
+ "sha256": "3ff56e388e9ae4cccb09fb072793b5544f771aeae51e8477f8a221f0c78b9515"
3676
3676
  },
3677
3677
  {
3678
3678
  "path": "modules/agents/migrations/agents_001_definitions.sql",
@@ -4167,7 +4167,7 @@
4167
4167
  {
4168
4168
  "path": "modules/discord/discord-channels.js",
4169
4169
  "mode": "0000644",
4170
- "sha256": "aeeb86c5d61b4a06ae5429fb69bb4720d54f4782ead5d23cdce640344a568246"
4170
+ "sha256": "683174f64823c296d74acbdedff3c67085336aeae067d8014873a43c23eace68"
4171
4171
  },
4172
4172
  {
4173
4173
  "path": "modules/discord/discord-inbound.js",
@@ -4217,7 +4217,7 @@
4217
4217
  {
4218
4218
  "path": "modules/discord/routes/discord.js",
4219
4219
  "mode": "0000644",
4220
- "sha256": "2229a563e11bd05f0eb047f0afc0e1d66eaddb6a7fa2fb07c429e162684246f0"
4220
+ "sha256": "6e771cafa4330cd6cba274040e8d0881f6a9b99dd566366fce266bd6f30b1ebf"
4221
4221
  },
4222
4222
  {
4223
4223
  "path": "modules/discord/ship-broadcast.js",
@@ -5412,7 +5412,7 @@
5412
5412
  {
5413
5413
  "path": "modules/ideas/inbox.js",
5414
5414
  "mode": "0000644",
5415
- "sha256": "d70b4564c8178f4752c5bd08c5cbb19e495828b5a3dd5081f71d3d7743609ab2"
5415
+ "sha256": "36481a27a5921e3eb930268bf96bccd178259d3f7cce67b19b0dc4246f7d9bf1"
5416
5416
  },
5417
5417
  {
5418
5418
  "path": "modules/ideas/migrations/ideas_001_grades.sql",
@@ -5497,7 +5497,7 @@
5497
5497
  {
5498
5498
  "path": "modules/lifecycle/cascade.js",
5499
5499
  "mode": "0000644",
5500
- "sha256": "507fe63cf677dbe1a536d5fae1a55518f6fe5c8ec4d908ac1941164029b17593"
5500
+ "sha256": "0f7c3c6b75216ddca36d49e9a112fce98de2e4123e9756879ba51b44b77c0f06"
5501
5501
  },
5502
5502
  {
5503
5503
  "path": "modules/lifecycle/claim-eligibility.js",
@@ -5557,7 +5557,7 @@
5557
5557
  {
5558
5558
  "path": "modules/lifecycle/db-goals.js",
5559
5559
  "mode": "0000644",
5560
- "sha256": "b4d1c8bd2254f93519a88302072c2f524a1acb4152382fb09bdc22b1ef95f0ed"
5560
+ "sha256": "1b229bd4bf18563dd772c0be46f320ae533e71a5c7ba32aaa4b523ac9f97a4dc"
5561
5561
  },
5562
5562
  {
5563
5563
  "path": "modules/lifecycle/db-grade.js",
@@ -5597,7 +5597,7 @@
5597
5597
  {
5598
5598
  "path": "modules/lifecycle/db.js",
5599
5599
  "mode": "0000644",
5600
- "sha256": "a568b425560d8d8f4523995442454233b69016a31934810c5d4b7d83baab0b09"
5600
+ "sha256": "30c8884948a8dee8213c55279c12f06e104c7d95ab3d7fad9dc67506ed1ac1ab"
5601
5601
  },
5602
5602
  {
5603
5603
  "path": "modules/lifecycle/dead-deps.js",
@@ -5657,7 +5657,7 @@
5657
5657
  {
5658
5658
  "path": "modules/lifecycle/goal-suggest.js",
5659
5659
  "mode": "0000644",
5660
- "sha256": "85117db645accd023f4f61735f9f22a8c5f6ef08a29c61db99fbade51d081147"
5660
+ "sha256": "cdbfa36b58dc26b7ce9d7a3d1f2812edd0ea050c2b27fe3837be6b6edf7d9ac5"
5661
5661
  },
5662
5662
  {
5663
5663
  "path": "modules/lifecycle/help-requests.js",
@@ -5817,7 +5817,7 @@
5817
5817
  {
5818
5818
  "path": "modules/lifecycle/routes/lifecycle.js",
5819
5819
  "mode": "0000644",
5820
- "sha256": "09b84f7f8a39be03b690e220d10af135f43d845db67c5f86b7f846d548d6361f"
5820
+ "sha256": "e09bbf8000ada41c6eac7ab64dcfe476650a246a0b561a8b693992885136b3bd"
5821
5821
  },
5822
5822
  {
5823
5823
  "path": "modules/lifecycle/routes/mingle.js",
@@ -7792,12 +7792,12 @@
7792
7792
  {
7793
7793
  "path": "package-lock.json",
7794
7794
  "mode": "0000644",
7795
- "sha256": "bc7f846af00793ffc9d682071391f110593648a25297cd15050385b9c3a8828f"
7795
+ "sha256": "aa6749d880220869386e00488652da7ae32a0116855dba519e6b943c47f7180c"
7796
7796
  },
7797
7797
  {
7798
7798
  "path": "package.json",
7799
7799
  "mode": "0000644",
7800
- "sha256": "db09d4c1609d9b4b0547b052326785367cfe19a5838cd3f9d458dac40ef70685"
7800
+ "sha256": "966ab00e5b62d0280043e13e156ce400cd3afa3f7c5bc04f02eea79392fd26f3"
7801
7801
  },
7802
7802
  {
7803
7803
  "path": "public-docs/index.html",
@@ -8057,7 +8057,7 @@
8057
8057
  {
8058
8058
  "path": "scripts/gds/capture.js",
8059
8059
  "mode": "0000644",
8060
- "sha256": "6779ebc36d51762777cb55352489a05ca9974f29c49d874b40c7486be83496ff"
8060
+ "sha256": "7320809dacf61454d114d5c55c244e7eac3314f36302eab879950355540573f6"
8061
8061
  },
8062
8062
  {
8063
8063
  "path": "scripts/gds/category-advisory-guard.js",
@@ -9687,7 +9687,7 @@
9687
9687
  {
9688
9688
  "path": "src/module-api.js",
9689
9689
  "mode": "0000644",
9690
- "sha256": "2b104850c999f4a9c71f7f205c640b9525a9a812031c1bad6d13cdcd06c05706"
9690
+ "sha256": "575b32eed8f62dad0302816f97e77604488891d13188bf5b690a867c37a962ac"
9691
9691
  },
9692
9692
  {
9693
9693
  "path": "src/module-loader/catalog.js",
@@ -9847,7 +9847,7 @@
9847
9847
  {
9848
9848
  "path": "tests/agents_validate.mjs",
9849
9849
  "mode": "0000644",
9850
- "sha256": "82c7bcef316f5c0068cb53a43b2ef4a01367ff285939f132f01b0d8633d36353"
9850
+ "sha256": "9376185bc6e11571b050dcf61a993faf780cea66aa4c0844d114b1cb738043e2"
9851
9851
  },
9852
9852
  {
9853
9853
  "path": "tests/agents_write_routes.mjs",
@@ -10312,7 +10312,7 @@
10312
10312
  {
10313
10313
  "path": "tests/capture_goal_routing.mjs",
10314
10314
  "mode": "0000644",
10315
- "sha256": "30c86ef5ae46c698b621dbe90e15210d162cc88d8b33bd0889330ff8686a065e"
10315
+ "sha256": "2ddceb37d46eda5c3ab2a56b54c86f3cf55596479b5fe7f30646f45b1ac11ac2"
10316
10316
  },
10317
10317
  {
10318
10318
  "path": "tests/cascade-dispatch.mjs",
@@ -10732,7 +10732,7 @@
10732
10732
  {
10733
10733
  "path": "tests/discord_channels.mjs",
10734
10734
  "mode": "0000644",
10735
- "sha256": "981d34c2fb5ecb6e9b9f38ece947c6c7a400dceadf8bbcf09546c8b3a92eb899"
10735
+ "sha256": "02e03ca2651755084e134ba0c4568c76d5eb7d1d66d0cec1fdd7cb3b4901f106"
10736
10736
  },
10737
10737
  {
10738
10738
  "path": "tests/discord_channels_snapshot.mjs",
@@ -11102,7 +11102,7 @@
11102
11102
  {
11103
11103
  "path": "tests/goal_suggest.mjs",
11104
11104
  "mode": "0000644",
11105
- "sha256": "d61e2bd5b5a65239f1410011a5035b124bf8e20c41eac08018635ec8cd3a49c7"
11105
+ "sha256": "41cf7774b86acabe7621563430fc4784152dea05f6cd3136b00081eca876e74d"
11106
11106
  },
11107
11107
  {
11108
11108
  "path": "tests/goal_tier.mjs",
@@ -11584,6 +11584,11 @@
11584
11584
  "mode": "0000644",
11585
11585
  "sha256": "fb129f6ee327d946ba2e7ee46bab3b9795621f7f405637e1e8ed2b29807a84cf"
11586
11586
  },
11587
+ {
11588
+ "path": "tests/idea_cascade.mjs",
11589
+ "mode": "0000644",
11590
+ "sha256": "e14c3157cd9c1a5889fa2d5cc90bc036d5b9f3aa1e062bb13623a01adb81d20c"
11591
+ },
11587
11592
  {
11588
11593
  "path": "tests/idea_completeness.mjs",
11589
11594
  "mode": "0000644",
@@ -13357,7 +13362,7 @@
13357
13362
  {
13358
13363
  "path": "tests/task_goal_required.mjs",
13359
13364
  "mode": "0000644",
13360
- "sha256": "2e311d02f87c562c2e8ed7cec24100efe3d1c750cb145e9e9e6bb1dac8043228"
13365
+ "sha256": "04e7a36f7c2f5a94f847fcb4b77f017f6399fc96041cdaee1123c9a6fecb3d5d"
13361
13366
  },
13362
13367
  {
13363
13368
  "path": "tests/task_lib.mjs",
@@ -2102,5 +2102,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2102
2102
  landed since 1.19.812 with no explicit bump. run 35193306517. (task 1002620)
2103
2103
  1.19.814 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2104
2104
  landed since 1.19.813 with no explicit bump. run 35194334679. (task 1002620)
2105
+ 1.19.815 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2106
+ landed since 1.19.814 with no explicit bump. run 35197139985. (task 1002620)
2107
+ 1.19.816 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2108
+ landed since 1.19.815 with no explicit bump. run 35198566737. (task 1002620)
2105
2109
  ---------------------------------------------------------------------------
2106
2110
  ```
@@ -45,7 +45,7 @@
45
45
 
46
46
  // The WIRED event set for v1. Widening this is a deliberate act that must land
47
47
  // together with something that actually dispatches the new event.
48
- const EVENT_CATALOG = Object.freeze(['task.shipped']);
48
+ const EVENT_CATALOG = Object.freeze(['task.shipped', 'idea.graded.full']);
49
49
 
50
50
  // Mirrors agents_definitions_trigger_type_chk. 'event' consumes the catalog above;
51
51
  // 'on-demand' is the historian shape (a builder asks, the agent answers).
@@ -40,7 +40,46 @@ const VIEW_CHANNEL = 1024n;
40
40
  // `view: "<rank>"` grants that rank and everyone above it.
41
41
  const RANK_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
42
42
 
43
- const CONFIG_PATH = path.resolve(__dirname, '..', '..', 'docs', 'discord', 'channels.json');
43
+ // WHERE channels.json LIVES (task 1003973). The layout file describes ONE
44
+ // guild's channels, names and topics — instance identity, which ADR 0062 §7
45
+ // puts on the HOST side of the core/host cut. So it is NOT published in the
46
+ // neutral core, and resolving it against the core package (as this module used
47
+ // to) means that on a deployed instance — where the module lives under
48
+ // node_modules/@bongos/core/modules/discord/ — the file is simply not there and
49
+ // every plan/apply died as a bare 500.
50
+ //
51
+ // Resolution order, first hit wins:
52
+ // 1. <instance root>/docs/discord/channels.json (ADR 0108's instance-root
53
+ // injection: <PREFIX>_INSTANCE_ROOT env, a bongos.config.json pointer, or
54
+ // process.cwd()) — each instance reads its OWN layout.
55
+ // 2. <core root>/docs/discord/channels.json — the historical path, kept so a
56
+ // core checkout that is not the instance root still resolves.
57
+ // In a single checkout (the live instance today) instance root === core root, so
58
+ // this is byte-identical to the old constant.
59
+ const CORE_CONFIG_PATH = path.resolve(__dirname, '..', '..', 'docs', 'discord', 'channels.json');
60
+
61
+ function instanceConfigPath() {
62
+ // Through the published doorway (ADR 0083), which is where resolveInstanceRoot
63
+ // lives — the same core/host resolution the government pack already uses.
64
+ try {
65
+ const root = require('../../src/module-api').resolveInstanceRoot();
66
+ return path.resolve(root, 'docs', 'discord', 'channels.json');
67
+ } catch (e) { return null; }
68
+ }
69
+
70
+ // The path channels.json is actually read from, or the instance-root candidate
71
+ // when neither exists (so the error names where the operator should put it).
72
+ function resolveConfigPath() {
73
+ const inst = instanceConfigPath();
74
+ for (const c of [inst, CORE_CONFIG_PATH]) {
75
+ if (c && fs.existsSync(c)) return c;
76
+ }
77
+ return inst || CORE_CONFIG_PATH;
78
+ }
79
+
80
+ // Back-compat alias for the old export. Callers that want the live answer use
81
+ // resolveConfigPath(); this stays the core-package path it always named.
82
+ const CONFIG_PATH = CORE_CONFIG_PATH;
44
83
 
45
84
  // ---------------------------------------------------------------------------
46
85
  // PURE helpers (no Discord, no IO) — the load-bearing, fully-tested logic.
@@ -340,11 +379,41 @@ function planChannelReconcile(snapshot, config, ctx) {
340
379
  // IO + Discord REST (impure) — invoked by the bot sweep and the Archon command.
341
380
  // ---------------------------------------------------------------------------
342
381
 
343
- function loadConfig(configPath = CONFIG_PATH) {
344
- const raw = fs.readFileSync(configPath, 'utf8');
382
+ // Throws a TAGGED error when the layout file is absent (code CONFIG_NOT_FOUND,
383
+ // carrying the path it looked at) so the callers below can report 'channels.json
384
+ // not readable at <path>' instead of collapsing into a bare 500 — the silent
385
+ // failure half of task 1003973.
386
+ function loadConfig(configPath) {
387
+ const p = configPath || resolveConfigPath();
388
+ let raw;
389
+ try {
390
+ raw = fs.readFileSync(p, 'utf8');
391
+ } catch (e) {
392
+ if (e && e.code === 'ENOENT') {
393
+ const err = new Error(`channels.json not readable at ${p}`);
394
+ err.code = 'CONFIG_NOT_FOUND';
395
+ err.configPath = p;
396
+ throw err;
397
+ }
398
+ throw e;
399
+ }
345
400
  return JSON.parse(raw);
346
401
  }
347
402
 
403
+ // Run a deps.loadConfig() that may be missing its file, and turn the tagged
404
+ // CONFIG_NOT_FOUND into the same {ok:false, reason} shape the no_guild /
405
+ // no_token paths already use, so every caller reports it the same way.
406
+ function tryLoadConfig(deps) {
407
+ try {
408
+ return { config: deps.loadConfig() };
409
+ } catch (err) {
410
+ if (err && err.code === 'CONFIG_NOT_FOUND') {
411
+ return { fail: { ok: false, reason: 'no_config', configPath: err.configPath, detail: err.message } };
412
+ }
413
+ throw err;
414
+ }
415
+ }
416
+
348
417
  // The managing bot's own user id, used to grant it an explicit VIEW overwrite on
349
418
  // any channel it restricts so it never locks itself out. Best-effort: returns
350
419
  // null if the client/user isn't resolvable (then the self-lockout guard is a
@@ -407,7 +476,9 @@ async function discordRest(token, method, urlPath, body) {
407
476
  function computePlan(deps = realDeps) {
408
477
  const guild = deps.getGuild();
409
478
  if (!guild) return { ok: false, reason: 'no_guild', ops: [], warnings: [] };
410
- const config = deps.loadConfig();
479
+ const loaded = tryLoadConfig(deps);
480
+ if (loaded.fail) return { ...loaded.fail, ops: [], warnings: [] };
481
+ const config = loaded.config;
411
482
  const roleMap = deps.loadRoleMap();
412
483
  const ctx = { everyoneId: String(guild.id), roleMap, botId: botIdFromGuild(guild) };
413
484
  const snapshot = snapshotFromGuild(guild);
@@ -435,7 +506,9 @@ async function reconcileChannels(deps = realDeps) {
435
506
  const token = deps.loadBotToken();
436
507
  if (!token) return { ok: false, reason: 'no_token', applied: 0, failed: 0 };
437
508
 
438
- const config = deps.loadConfig();
509
+ const loaded = tryLoadConfig(deps);
510
+ if (loaded.fail) return { ...loaded.fail, applied: 0, failed: 0 };
511
+ const config = loaded.config;
439
512
  const roleMap = deps.loadRoleMap();
440
513
  const ctx = { everyoneId: String(guild.id), roleMap, botId: botIdFromGuild(guild) };
441
514
  const snapshot = snapshotFromGuild(guild);
@@ -509,11 +582,13 @@ module.exports = {
509
582
  planChannelReconcile,
510
583
  // io
511
584
  loadConfig,
585
+ resolveConfigPath,
512
586
  snapshotFromGuild,
513
587
  computePlan,
514
588
  computeSnapshotConfig,
515
589
  reconcileChannels,
516
590
  CONFIG_PATH,
591
+ CORE_CONFIG_PATH,
517
592
  VIEW_CHANNEL,
518
593
  RANK_LADDER,
519
594
  };
@@ -79,6 +79,18 @@ function registerSeams() {
79
79
  console.log('[discord] role-sync (non-blocking):', e && e.message)));
80
80
  }
81
81
 
82
+ // A !ok result from the channels module is either 'the bot is not connected'
83
+ // or 'the layout file is not where I looked'. The second used to surface as a
84
+ // bare 500 with the reason only in the server log, which made a missing
85
+ // channels.json indistinguishable from a bot outage (task 1003973) — so it gets
86
+ // its own code and names the path it tried.
87
+ function failFor(res, r) {
88
+ if (r.reason === 'no_config') {
89
+ return res.fail('config_not_found', 503, { reason: r.reason, detail: r.detail, config_path: r.configPath });
90
+ }
91
+ return res.fail('bot_unavailable', 503, { reason: r.reason });
92
+ }
93
+
82
94
  module.exports = function buildDiscordRouter() {
83
95
  registerSeams();
84
96
  const router = express.Router();
@@ -92,7 +104,7 @@ module.exports = function buildDiscordRouter() {
92
104
  const channels = require('../discord-channels');
93
105
  const plan = channels.computePlan();
94
106
  if (!plan.ok) {
95
- return res.fail('bot_unavailable', 503, { reason: plan.reason });
107
+ return failFor(res, plan);
96
108
  }
97
109
  return res.json({ ok: true, guild_id: plan.guildId, op_count: plan.ops.length, ops: plan.ops, warnings: plan.warnings });
98
110
  } catch (err) {
@@ -131,7 +143,7 @@ module.exports = function buildDiscordRouter() {
131
143
  const channels = require('../discord-channels');
132
144
  const result = await channels.reconcileChannels();
133
145
  if (!result.ok) {
134
- return res.fail('bot_unavailable', 503, { reason: result.reason });
146
+ return failFor(res, result);
135
147
  }
136
148
  return res.json({ ok: true, applied: result.applied, failed: result.failed, total: result.total, warnings: result.warnings });
137
149
  } catch (err) {
@@ -1198,7 +1198,35 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
1198
1198
  // null = "this row was not a developable spark when we looked". THREE ways to
1199
1199
  // get here, and the caller re-reads to say which: it was never a spark,
1200
1200
  // somebody else developed it first, or a triager closed the idea underneath us.
1201
- return rows[0] ?? null;
1201
+ const graded = rows[0] ?? null;
1202
+
1203
+ // task 1003856: a Full Idea is the ideator's hand-off point, and the
1204
+ // cross-role cascade that turns it into an engineer's scoping request needs a
1205
+ // transition to hang on. Emitted only on a REAL transition — the UPDATE's
1206
+ // `AND grade = 'spark'` means a no-op re-grade returns null and fires nothing,
1207
+ // so the event cannot repeat for one idea.
1208
+ //
1209
+ // Wrapped and awaited-free by the same reasoning as every other kernel emit
1210
+ // here: a listener must never be able to fail the write that preceded it. The
1211
+ // cascade runner catches everything of its own accord as well.
1212
+ if (graded) {
1213
+ // The payload carries the WHOLE idea, not just its id. The listener lives in
1214
+ // the lifecycle module, which owns no idea table and has no read port for one
1215
+ // (ideas provides only `ideas.capture`) — so the read happens here, inside the
1216
+ // module that owns the row, rather than by widening another module's surface.
1217
+ try {
1218
+ const idea = await getIdea(ideaId);
1219
+ api.emit('idea.graded.full', {
1220
+ ...(idea || {}),
1221
+ id: ideaId,
1222
+ idea_id: ideaId,
1223
+ grade: 'full',
1224
+ completeness_score: graded.completeness_score,
1225
+ developed_by: graded.developed_by,
1226
+ });
1227
+ } catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
1228
+ }
1229
+ return graded;
1202
1230
  }
1203
1231
 
1204
1232
  // The grade row on its own. null = no row, which reads as grade='quick'
@@ -168,6 +168,68 @@ const CASCADES = Object.freeze([
168
168
  }),
169
169
  ]);
170
170
 
171
+ // THE TRIGGERS. A cascade reacts to a state transition; there are two, and this
172
+ // table is what lets one runner serve both without either pretending to be the
173
+ // other. `subject` names what the rule's when/title/body receive, and the pair is
174
+ // what the generated task's preamble says happened. Adding a third trigger is a
175
+ // row here plus a rule list — deliberately not a condition language.
176
+ const TRIGGERS = Object.freeze({
177
+ ship: Object.freeze({ subject: 'task', happened: (s) => `task ${s.id} shipped` }),
178
+ idea: Object.freeze({ subject: 'idea', happened: (s) => `idea ${s.id} was graded Full` }),
179
+ });
180
+
181
+ // Cascades triggered by an IDEA reaching grade 'full' (the ideator's finished
182
+ // article), rather than by a ship. Kept as its own list rather than a `trigger`
183
+ // field on one list, so a rule cannot be evaluated against a subject shape it was
184
+ // never written for — an idea has no touches[], a task has no completeness score.
185
+ const IDEA_CASCADES = Object.freeze([
186
+ Object.freeze({
187
+ key: 'engineer-scoping-on-full-idea',
188
+ discipline: 'engineer',
189
+ kind: 'unclassified',
190
+ // A Full Idea is the ideator's hand-off point: the five questions are
191
+ // answered and the thing is described well enough to cost. What it still
192
+ // lacks is an engineer's read on what building it would actually mean, and
193
+ // that read is the scoping task this files.
194
+ //
195
+ // The anti-recursion guard is FIRST, as in the ship rule. An idea that a
196
+ // cascade itself filed cannot file another.
197
+ when: (idea) => !!idea
198
+ && idea.grade === 'full'
199
+ && idea.source !== CASCADE_SOURCE
200
+ && String(idea.status || 'open') === 'open',
201
+ title: (idea) => `Scope the Full Idea: ${String(idea.title || '').trim() || `idea ${idea.id}`}`,
202
+ body: fullIdeaScopingBody,
203
+ }),
204
+ ]);
205
+
206
+ // The body of the engineer's scoping request. Says what the ideator already
207
+ // settled so the engineer does not re-ask it, and what the scoping is FOR.
208
+ function fullIdeaScopingBody(idea) {
209
+ const score = idea.completeness_score != null ? ` (completeness ${idea.completeness_score})` : '';
210
+ const author = idea.captured_by ? ` by builder ${idea.captured_by}` : '';
211
+ // The idea's goal HINT, surfaced for a triager rather than acted on. It is
212
+ // advisory by its column's own definition, so it may inform a human decision and
213
+ // may not silently place this task.
214
+ const hint = idea.suggested_goal_id != null
215
+ ? `\nThe idea carried a routing hint — it may belong to goal ${idea.suggested_goal_id}. That is advisory; place this task wherever triage decides.\n`
216
+ : '';
217
+ return [
218
+ `Idea ${idea.id} reached **Full**${score}${author} — the ideator's five questions are answered, so the WHAT is settled.`,
219
+ hint,
220
+ '',
221
+ 'What this asks for is the engineer\'s read the idea cannot give itself:',
222
+ '',
223
+ '- What would building it actually touch, and roughly what size is it?',
224
+ '- What already exists that it should sit on rather than duplicate?',
225
+ '- What is genuinely unknown, and what would cheaply settle it?',
226
+ '',
227
+ 'Answer by turning it into real tasks, or by saying plainly why it should not be built — a scoping that concludes "not worth it, because X" is a complete answer and a useful one.',
228
+ '',
229
+ `The idea's own body is the brief; read it at the hall rather than re-deriving it here.`,
230
+ ].join('\n');
231
+ }
232
+
171
233
  // The (source, source_ref) pair the generated task is keyed on. `source` is a
172
234
  // constant so every cascade-generated row is findable as a class; the ref pins
173
235
  // it to one rule and one originating ship.
@@ -218,7 +280,7 @@ function validateCascades(rules = CASCADES) {
218
280
  // A rule whose `when` THROWS is treated as no-match and reported in `errors`,
219
281
  // never propagated: one broken predicate must not cost the other rules their
220
282
  // tasks, and it certainly must not reach the ship.
221
- function cascadesFor(task, rules = CASCADES, ctx = {}) {
283
+ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship) {
222
284
  const matched = [];
223
285
  const errors = [];
224
286
  if (!task || task.id == null) return { matched, errors: ['no task'] };
@@ -234,7 +296,7 @@ function cascadesFor(task, rules = CASCADES, ctx = {}) {
234
296
  }
235
297
  if (!hit) continue;
236
298
  try {
237
- matched.push({ rule, spec: cascadeTaskSpec(rule, task) });
299
+ matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger) });
238
300
  } catch (e) {
239
301
  errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
240
302
  }
@@ -244,17 +306,26 @@ function cascadesFor(task, rules = CASCADES, ctx = {}) {
244
306
 
245
307
  // The createTask payload for one matched rule. The attribution preamble is
246
308
  // prepended here rather than left to each rule, so no rule can forget it.
247
- function cascadeTaskSpec(rule, task) {
309
+ function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship) {
248
310
  const body = String(rule.body(task) || '').trim();
249
311
  const preamble =
250
- `Filed automatically when task ${task.id} shipped — the "${rule.key}" cascade `
312
+ `Filed automatically when ${trigger.happened(task)} — the "${rule.key}" cascade `
251
313
  + `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
252
314
  + `and it goes through the board's ordinary triage. If the rule that made it is wrong, fix `
253
315
  + `the rule rather than only this task.`;
254
316
  return {
255
- versionId: task.version_id,
317
+ // An idea carries a GUESS where a task carries a fact. `suggested_version` is
318
+ // inherited because a version is a label; `suggested_goal_id` is NOT, because
319
+ // its own column header forbids exactly that read — see goalId below.
320
+ versionId: task.version_id != null ? task.version_id : (task.suggested_version ?? null),
256
321
  // A cascade INHERITS its parent's goal — the follow-up belongs wherever the
257
322
  // work that triggered it belongs.
323
+ // NOT inherited from an idea's `suggested_goal_id`. That column documents
324
+ // itself as "Strictly advisory: no rank check, no scope wall, and no payout
325
+ // may read it... The BINDING home for 'which goal' is tasks.goal_id" — and a
326
+ // generated task's goal is binding. So an idea-triggered cascade lands in the
327
+ // catch-all and gets triaged like any other homeless work; the hint travels in
328
+ // the body instead, where a human can act on it.
258
329
  goalId: task.goal_id != null ? Number(task.goal_id) : null,
259
330
  // BV1.R11 (task 1003598): …and when the parent has none, says so. A shipped
260
331
  // task can still be goal-less — the 53 legacy catch-all rows, or any task
@@ -283,6 +354,59 @@ function cascadeTaskSpec(rule, task) {
283
354
  // The runner — the only part that touches a database
284
355
  // ---------------------------------------------------------------------------
285
356
 
357
+ // FILE ONE SUBJECT'S MATCHES. Shared by both runners so the idempotency read,
358
+ // the per-rule isolation and the reporting cannot drift apart between triggers —
359
+ // the two runners differ only in how they FETCH a subject and which rule list and
360
+ // context they bring.
361
+ async function fileMatches({ matched, deps, report, trigger, subject }) {
362
+ for (const { rule, spec } of matched) {
363
+ try {
364
+ const existing = deps.findTaskBySourceRef
365
+ ? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
366
+ : null;
367
+ if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
368
+ const created = await deps.createTask(spec);
369
+ report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
370
+ deps.log?.info?.(`[cascade] ${trigger.happened(subject)} → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
371
+ } catch (e) {
372
+ // One rule failing costs that rule its task and nothing else.
373
+ report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
374
+ }
375
+ }
376
+ }
377
+
378
+ // React to one idea reaching grade 'full'. Same guarantees as the ship runner —
379
+ // returns a report, never throws, never rejects — because an ideator grading
380
+ // their own idea must not be able to fail on a follow-up task's account.
381
+ //
382
+ // deps.getIdea(id) -> the full idea row (the event payload may
383
+ // carry only the id)
384
+ // deps.findTaskBySourceRef({...}) -> an existing row or null
385
+ // deps.createTask(spec) -> the created row
386
+ // deps.log -> optional { info, error }
387
+ async function runCascadesForIdea(graded, deps = {}) {
388
+ const report = { created: [], skipped: [], errors: [] };
389
+ try {
390
+ const id = graded && (graded.id ?? graded.idea_id);
391
+ if (id == null) { report.errors.push('cascade: idea payload carried no idea id'); return report; }
392
+
393
+ // The emitter sends the whole idea (it owns the row; this module does not),
394
+ // so the payload IS the subject. deps.getIdea stays supported for a caller
395
+ // that would rather re-read — and when one is injected and returns nothing,
396
+ // that is an error rather than a silent fall-back to a payload it rejected.
397
+ const idea = deps.getIdea ? await deps.getIdea(id) : graded;
398
+ if (!idea) { report.errors.push(`cascade: idea ${id} not readable after grading`); return report; }
399
+
400
+ const { matched, errors } = cascadesFor(idea, deps.rules || IDEA_CASCADES, {}, TRIGGERS.idea);
401
+ report.errors.push(...errors);
402
+ await fileMatches({ matched, deps, report, trigger: TRIGGERS.idea, subject: idea });
403
+ } catch (e) {
404
+ report.errors.push(`cascade: ${e && e.message}`);
405
+ }
406
+ if (report.errors.length) deps.log?.error?.(`[cascade] ${report.errors.length} problem(s): ${report.errors.join(' | ')}`);
407
+ return report;
408
+ }
409
+
286
410
  // React to one shipped task. Returns a report, NEVER throws and never rejects:
287
411
  // property 1 in the header. `deps` is injected so the whole runner is testable
288
412
  // with two stubs and no database.
@@ -323,20 +447,7 @@ async function runCascadesForShip(shipped, deps = {}) {
323
447
  const { matched, errors } = cascadesFor(task, deps.rules || CASCADES, ctx);
324
448
  report.errors.push(...errors);
325
449
 
326
- for (const { rule, spec } of matched) {
327
- try {
328
- const existing = deps.findTaskBySourceRef
329
- ? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
330
- : null;
331
- if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
332
- const created = await deps.createTask(spec);
333
- report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
334
- log?.info?.(`[cascade] task ${id} shipped → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
335
- } catch (e) {
336
- // One rule failing costs that rule its task and nothing else.
337
- report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
338
- }
339
- }
450
+ await fileMatches({ matched, deps: { ...deps, log }, report, trigger: TRIGGERS.ship, subject: task });
340
451
  } catch (e) {
341
452
  report.errors.push(`cascade: ${e && e.message}`);
342
453
  }
@@ -346,6 +457,10 @@ async function runCascadesForShip(shipped, deps = {}) {
346
457
 
347
458
  module.exports = {
348
459
  CASCADES,
460
+ IDEA_CASCADES,
461
+ TRIGGERS,
462
+ fullIdeaScopingBody,
463
+ runCascadesForIdea,
349
464
  CASCADE_SOURCE,
350
465
  COPY_VISUAL_MODULES,
351
466
  hasCopyOrVisualWork,