@bongos/core 1.19.814 → 1.19.815
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 +25 -20
- package/docs/module-api-changelog.md +2 -0
- package/modules/agents/lib/validate.js +1 -1
- package/modules/discord/discord-channels.js +80 -5
- package/modules/discord/routes/discord.js +14 -2
- package/modules/ideas/inbox.js +29 -1
- package/modules/lifecycle/cascade.js +134 -19
- package/modules/lifecycle/routes/lifecycle.js +18 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/agents_validate.mjs +33 -2
- package/tests/discord_channels.mjs +166 -0
- package/tests/idea_cascade.mjs +211 -0
- package/tests/task_goal_required.mjs +15 -3
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.815",
|
|
6
|
+
"core_contract": "1.19.815",
|
|
7
|
+
"source_commit": "49e12ef5bf0e0f642be16e2d07f3a8e4126088a3",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-17T07:
|
|
9
|
+
"built_at": "2026-09-17T07:58:43.512Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 489,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2215,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2730,
|
|
20
|
+
"tree_sha256": "8f4f7be2444b6e33f3772cc07af105a80fdd6a416ab122c91973dcb9600c0cf4",
|
|
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": "
|
|
2545
|
+
"sha256": "0f668e27938e73ba4769f2c5a1ee40205ffb350b25ad5c09bc7d660e2bb90b1d"
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
5500
|
+
"sha256": "0f7c3c6b75216ddca36d49e9a112fce98de2e4123e9756879ba51b44b77c0f06"
|
|
5501
5501
|
},
|
|
5502
5502
|
{
|
|
5503
5503
|
"path": "modules/lifecycle/claim-eligibility.js",
|
|
@@ -5817,7 +5817,7 @@
|
|
|
5817
5817
|
{
|
|
5818
5818
|
"path": "modules/lifecycle/routes/lifecycle.js",
|
|
5819
5819
|
"mode": "0000644",
|
|
5820
|
-
"sha256": "
|
|
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": "
|
|
7795
|
+
"sha256": "48fba5427217aab544717eef126aa155f404dc6ba71ad21e2b2c4db586da9be7"
|
|
7796
7796
|
},
|
|
7797
7797
|
{
|
|
7798
7798
|
"path": "package.json",
|
|
7799
7799
|
"mode": "0000644",
|
|
7800
|
-
"sha256": "
|
|
7800
|
+
"sha256": "abc68f18878f7339c9dba180f3b965c6b2cb253f91255b509ccb9fbd47c3b21b"
|
|
7801
7801
|
},
|
|
7802
7802
|
{
|
|
7803
7803
|
"path": "public-docs/index.html",
|
|
@@ -9687,7 +9687,7 @@
|
|
|
9687
9687
|
{
|
|
9688
9688
|
"path": "src/module-api.js",
|
|
9689
9689
|
"mode": "0000644",
|
|
9690
|
-
"sha256": "
|
|
9690
|
+
"sha256": "15e7fcbcb8377bea7245c66e4784accaecd09d92bd9baf3ecda20aab73faadac"
|
|
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": "
|
|
9850
|
+
"sha256": "9376185bc6e11571b050dcf61a993faf780cea66aa4c0844d114b1cb738043e2"
|
|
9851
9851
|
},
|
|
9852
9852
|
{
|
|
9853
9853
|
"path": "tests/agents_write_routes.mjs",
|
|
@@ -10732,7 +10732,7 @@
|
|
|
10732
10732
|
{
|
|
10733
10733
|
"path": "tests/discord_channels.mjs",
|
|
10734
10734
|
"mode": "0000644",
|
|
10735
|
-
"sha256": "
|
|
10735
|
+
"sha256": "02e03ca2651755084e134ba0c4568c76d5eb7d1d66d0cec1fdd7cb3b4901f106"
|
|
10736
10736
|
},
|
|
10737
10737
|
{
|
|
10738
10738
|
"path": "tests/discord_channels_snapshot.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": "
|
|
13365
|
+
"sha256": "04e7a36f7c2f5a94f847fcb4b77f017f6399fc96041cdaee1123c9a6fecb3d5d"
|
|
13361
13366
|
},
|
|
13362
13367
|
{
|
|
13363
13368
|
"path": "tests/task_lib.mjs",
|
|
@@ -2102,5 +2102,7 @@ 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)
|
|
2105
2107
|
---------------------------------------------------------------------------
|
|
2106
2108
|
```
|
|
@@ -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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -44,12 +44,30 @@ const cascade = require('../cascade');
|
|
|
44
44
|
// leave it. Nothing here can affect the ship: the emitters fire post-commit and
|
|
45
45
|
// already wrap the emit, and `runCascadesForShip` resolves rather than throws.
|
|
46
46
|
let _cascadeListenerOff = null;
|
|
47
|
+
let _ideaCascadeListenerOff = null;
|
|
47
48
|
|
|
48
49
|
function registerSeams() {
|
|
49
50
|
if (!api.hasProvider('lifecycle')) {
|
|
50
51
|
api.registerProvider('lifecycle', lifecycle);
|
|
51
52
|
}
|
|
52
53
|
if (_cascadeListenerOff) _cascadeListenerOff();
|
|
54
|
+
// Torn down with its sibling: the factory runs again every time a test rebuilds
|
|
55
|
+
// the router, and a leaked subscription would fire this cascade twice.
|
|
56
|
+
if (_ideaCascadeListenerOff) _ideaCascadeListenerOff();
|
|
57
|
+
// task 1003856: the second cross-role cascade. Same module, same runner
|
|
58
|
+
// guarantees, different trigger — an idea reaching Full rather than a ship.
|
|
59
|
+
_ideaCascadeListenerOff = api.on('idea.graded.full', (graded) => cascade.runCascadesForIdea(graded, {
|
|
60
|
+
// No getIdea: the ideas module owns that row and sends it whole. Injecting a
|
|
61
|
+
// reader here would mean lifecycle querying another module's table.
|
|
62
|
+
findTaskBySourceRef: (q) => db.findTaskBySourceRef(q),
|
|
63
|
+
createTask: (spec) => db.createTask(spec),
|
|
64
|
+
// INVOKED, like its sibling below. api.logger is a FACTORY (api.logger(ns)),
|
|
65
|
+
// so passing it bare left every log?.info?.() call resolving to undefined and
|
|
66
|
+
// silently doing nothing — the optional chaining that makes the runner safe is
|
|
67
|
+
// also what hid this.
|
|
68
|
+
log: api.logger ? api.logger('lifecycle') : null,
|
|
69
|
+
}));
|
|
70
|
+
|
|
53
71
|
_cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
|
|
54
72
|
getTask: (id) => db.getTask(id),
|
|
55
73
|
findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.815",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.815",
|
|
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.
|
|
3
|
+
"version": "1.19.815",
|
|
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",
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.815'; // 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');
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Run: node tests/agents_validate.mjs
|
|
13
13
|
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
14
17
|
import { strict as assert } from 'node:assert';
|
|
15
18
|
import { createRequire } from 'node:module';
|
|
16
19
|
|
|
@@ -73,9 +76,37 @@ await test('a failed validation returns NO value — nothing downstream can inse
|
|
|
73
76
|
|
|
74
77
|
// --- THE EVENT CATALOG — the fail-closed rule -------------------------------
|
|
75
78
|
|
|
76
|
-
await test('EVENT_CATALOG is
|
|
77
|
-
assert.deepEqual(EVENT_CATALOG, ['task.shipped']);
|
|
79
|
+
await test('EVENT_CATALOG is frozen, and every entry is REALLY dispatched', () => {
|
|
78
80
|
assert.equal(Object.isFrozen(EVENT_CATALOG), true, 'a caller must not be able to widen the catalog at runtime');
|
|
81
|
+
assert.ok(EVENT_CATALOG.includes('task.shipped'), 'the original wired event stays wired');
|
|
82
|
+
|
|
83
|
+
// The catalog's own comment is the rule: widening it "must land together with
|
|
84
|
+
// something that actually dispatches the new event". Pinned as that rule rather
|
|
85
|
+
// than as a frozen list, because a list cannot tell a legitimate widening from
|
|
86
|
+
// one that ships an entry nothing emits — and the second produces an agent that
|
|
87
|
+
// looks armed and never fires, which is the failure the catalog exists to stop.
|
|
88
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
89
|
+
const files = [];
|
|
90
|
+
const walk = (dir) => {
|
|
91
|
+
let ents = [];
|
|
92
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
93
|
+
for (const e of ents) {
|
|
94
|
+
const full = path.join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(full); continue; }
|
|
96
|
+
if (e.name.endsWith('.js')) files.push(full);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const d of ['src', 'modules']) walk(path.join(ROOT, d));
|
|
100
|
+
const tree = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
|
|
101
|
+
|
|
102
|
+
for (const event of EVENT_CATALOG) {
|
|
103
|
+
// Any quote style, emit or emitAsync. A plain substring scan rather than a
|
|
104
|
+
// built regex: the thing being asserted is "somebody emits this string".
|
|
105
|
+
const dispatched = ['emit(', 'emitAsync('].some((call) =>
|
|
106
|
+
[String.fromCharCode(39), '"', String.fromCharCode(96)].some((q) => tree.includes(call + q + event + q)));
|
|
107
|
+
assert.ok(dispatched,
|
|
108
|
+
`"${event}" is in the catalog but nothing in src/ or modules/ emits it — an agent armed on it would never fire`);
|
|
109
|
+
}
|
|
79
110
|
});
|
|
80
111
|
|
|
81
112
|
await test('an UNWIRED event name is REFUSED, never silently accepted', () => {
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
// Run: node tests/discord_channels.mjs
|
|
25
25
|
|
|
26
26
|
import { strict as assert } from 'node:assert';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import os from 'node:os';
|
|
27
30
|
import { createRequire } from 'node:module';
|
|
28
31
|
const require = createRequire(import.meta.url);
|
|
29
32
|
const ch = require('../modules/discord/discord-channels.js');
|
|
@@ -365,5 +368,168 @@ await t('the real config plans cleanly against an empty guild and never deletes'
|
|
|
365
368
|
assert.ok(!ops.some((o) => String(o.op).startsWith('delete')), 'reconcile must never delete');
|
|
366
369
|
});
|
|
367
370
|
|
|
371
|
+
// ---- seeding channels.json from a live guild (task 1003841) ----------------
|
|
372
|
+
//
|
|
373
|
+
// WHY THESE EXIST. Seeding hangs on one subtle rule, and I read it wrong the
|
|
374
|
+
// first time: an omitted `view` has TWO possible causes that look identical in
|
|
375
|
+
// the config, and only one of them is a problem.
|
|
376
|
+
//
|
|
377
|
+
// • the channel's view EQUALS its category's -> omitted, NO warning (fine)
|
|
378
|
+
// • the channel has no managed VIEW overwrite -> omitted, WITH a warning
|
|
379
|
+
//
|
|
380
|
+
// configFromSnapshot emits `view` only when it differs from the parent, because
|
|
381
|
+
// the planner reads `ch.view || cat.view` and repeating the value is noise that
|
|
382
|
+
// drifts later. So "the field is missing" is NOT evidence that a channel is
|
|
383
|
+
// unmanaged — the WARNING is. These pin that distinction, because acting on the
|
|
384
|
+
// wrong one means writing view values into the file that the module deliberately
|
|
385
|
+
// omits, which is a diff against the guild rather than a record of it.
|
|
386
|
+
|
|
387
|
+
const V_ALLOW = String(ch.VIEW_CHANNEL);
|
|
388
|
+
const allowEveryone = [{ id: EVERYONE, type: 0, allow: V_ALLOW, deny: '0' }];
|
|
389
|
+
const publicCat = (catOverwrites, chOverwrites) => ({
|
|
390
|
+
categories: [{ id: 'CAT_PUB', name: 'PUBLIC', overwrites: catOverwrites }],
|
|
391
|
+
channels: [{ id: 'CH_NEWS', type: 0, name: '📯-ship-news', parentId: 'CAT_PUB', topic: 'Ships land here.', overwrites: chOverwrites }],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await t('an omitted view means INHERITS when the channel matches its category — and warns nothing (task 1003841)', () => {
|
|
395
|
+
const { config, warnings } = ch.configFromSnapshot(publicCat(allowEveryone, allowEveryone), CTX);
|
|
396
|
+
const news = config.categories[0].channels[0];
|
|
397
|
+
assert.equal(news.view, undefined, 'a view identical to the category is deliberately omitted');
|
|
398
|
+
assert.deepEqual(warnings, [], 'and nothing is wrong, so nothing is warned about');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await t('an omitted view means UNMANAGED when the channel has no overwrite — and that WARNS (task 1003841)', () => {
|
|
402
|
+
// The distinguishing signal. Same missing field, different meaning.
|
|
403
|
+
const { config, warnings } = ch.configFromSnapshot(publicCat(allowEveryone, []), CTX);
|
|
404
|
+
assert.equal(config.categories[0].channels[0].view, undefined, 'also omitted — the config alone cannot tell them apart');
|
|
405
|
+
assert.equal(warnings.length, 1, 'but the warning does');
|
|
406
|
+
assert.match(warnings[0], /no VIEW overwrite/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await t('the seeded file round-trips to ZERO ops for every channel that exists (task 1003841)', () => {
|
|
410
|
+
// The done-when, demonstrated rather than argued. Feeding the snapshot-derived
|
|
411
|
+
// config back against the same guild must plan to nothing — that is the whole
|
|
412
|
+
// correctness property of configFromSnapshot, and it is what makes committing
|
|
413
|
+
// the snapshot safe: applying it cannot edit a live channel.
|
|
414
|
+
const guild = publicCat(allowEveryone, allowEveryone);
|
|
415
|
+
const { config } = ch.configFromSnapshot(guild, CTX);
|
|
416
|
+
assert.deepEqual(ch.planChannelReconcile(guild, config, CTX).ops, [], 'a faithful snapshot is a no-op plan');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await t('writing view:everyone where the snapshot omitted it would ADD an op — which is why it is not written (task 1003841)', () => {
|
|
420
|
+
// The mistake this file avoids. Spelling out a value identical to the category
|
|
421
|
+
// is harmless to READ, but it is not what the module emits, and on a channel
|
|
422
|
+
// that genuinely lacks an overwrite it turns a record into an edit of the live
|
|
423
|
+
// guild. Pinned so nobody "tidies" the omitted fields back in.
|
|
424
|
+
const guild = publicCat(allowEveryone, []);
|
|
425
|
+
const spelled = {
|
|
426
|
+
categories: [{
|
|
427
|
+
name: 'PUBLIC', view: 'everyone',
|
|
428
|
+
channels: [{ key: 'ship-news', name: '📯-ship-news', view: 'everyone', topic: 'Ships land here.' }],
|
|
429
|
+
}],
|
|
430
|
+
};
|
|
431
|
+
const { ops } = ch.planChannelReconcile(guild, spelled, CTX);
|
|
432
|
+
assert.ok(ops.some((o) => o.op === 'edit-channel'), 'it edits the live channel');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await t('the committed file is the guild, plus board-room (task 1003841)', () => {
|
|
436
|
+
const cfg = ch.loadConfig();
|
|
437
|
+
const keys = cfg.categories.flatMap((c) => c.channels.map((x) => x.key));
|
|
438
|
+
assert.ok(keys.length >= 15, `expected the seeded guild, got ${keys.length} channels`);
|
|
439
|
+
assert.ok(keys.includes('board-room'), 'board-room stays declared — the bot never deletes');
|
|
440
|
+
for (const cat of cfg.categories) {
|
|
441
|
+
assert.ok(cat.view, `category ${cat.name} needs a view`);
|
|
442
|
+
for (const c of cat.channels) assert.ok(c.key && c.name, 'every channel carries its key and real name');
|
|
443
|
+
}
|
|
444
|
+
// The four PUBLIC channels inherit deliberately; asserting they have no view
|
|
445
|
+
// is what stops a later edit from spelling them out and creating a diff.
|
|
446
|
+
const pub = cfg.categories.find((c) => c.name === 'PUBLIC');
|
|
447
|
+
assert.equal(pub.view, 'everyone');
|
|
448
|
+
for (const c of pub.channels) assert.equal(c.view, undefined, `${c.key} must inherit, not repeat, its category view`);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---- the config path on a DEPLOYED instance (task 1003973) ---------------
|
|
452
|
+
//
|
|
453
|
+
// channels.json describes ONE guild, so it is instance identity and is NOT
|
|
454
|
+
// published in the neutral core (ADR 0062 §7). The module used to resolve it
|
|
455
|
+
// against its OWN directory, which on a deployed instance is
|
|
456
|
+
// node_modules/@bongos/core/modules/discord/ — the file is not there, readFileSync
|
|
457
|
+
// threw ENOENT, computePlan() threw, and the route returned a bare 500 that was
|
|
458
|
+
// indistinguishable from a bot outage. These pin both halves of the fix.
|
|
459
|
+
|
|
460
|
+
await t('resolveConfigPath prefers the INSTANCE root over the core package', () => {
|
|
461
|
+
// Point the instance root somewhere that is NOT the core checkout and give it
|
|
462
|
+
// its own channels.json. If resolution still lands on the core copy, a deployed
|
|
463
|
+
// instance would read cloudbongos.com's channel names — the thing ADR 0062 §7
|
|
464
|
+
// forbids — so this must fail loudly rather than pass because the two roots
|
|
465
|
+
// happen to coincide in a single checkout.
|
|
466
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-inst-1003973-'));
|
|
467
|
+
const own = path.join(root, 'docs', 'discord', 'channels.json');
|
|
468
|
+
fs.mkdirSync(path.dirname(own), { recursive: true });
|
|
469
|
+
fs.writeFileSync(own, JSON.stringify({ categories: [{ name: 'X', channels: [] }] }));
|
|
470
|
+
const prev = process.env.BONGOS_INSTANCE_ROOT;
|
|
471
|
+
process.env.BONGOS_INSTANCE_ROOT = root;
|
|
472
|
+
try {
|
|
473
|
+
assert.equal(ch.resolveConfigPath(), own,
|
|
474
|
+
'a deployed instance must read its OWN docs/discord/channels.json, not the core package copy');
|
|
475
|
+
assert.notEqual(ch.resolveConfigPath(), ch.CORE_CONFIG_PATH);
|
|
476
|
+
assert.deepEqual(ch.loadConfig().categories.map((c) => c.name), ['X'],
|
|
477
|
+
'loadConfig must follow the same resolution, not a stale module-scope constant');
|
|
478
|
+
} finally {
|
|
479
|
+
if (prev === undefined) delete process.env.BONGOS_INSTANCE_ROOT;
|
|
480
|
+
else process.env.BONGOS_INSTANCE_ROOT = prev;
|
|
481
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
// and with no pointer set, a single checkout still resolves to the repo file
|
|
484
|
+
assert.ok(fs.existsSync(ch.resolveConfigPath()), 'the single-checkout path must still resolve');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
await t('loadConfig on a missing file throws a TAGGED error naming the path', () => {
|
|
488
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
489
|
+
assert.throws(() => ch.loadConfig(missing), (err) => {
|
|
490
|
+
assert.equal(err.code, 'CONFIG_NOT_FOUND', 'ENOENT must be tagged, not left as a raw fs error');
|
|
491
|
+
assert.equal(err.configPath, missing);
|
|
492
|
+
assert.ok(err.message.includes(missing), 'the message must name the path the operator should fix');
|
|
493
|
+
return true;
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await t('computePlan with no config returns {ok:false, no_config} instead of throwing', () => {
|
|
498
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
499
|
+
const deps = {
|
|
500
|
+
getGuild: () => ({ id: '1', channels: { cache: new Map() } }),
|
|
501
|
+
loadConfig: () => ch.loadConfig(missing),
|
|
502
|
+
loadRoleMap: () => ({}),
|
|
503
|
+
};
|
|
504
|
+
const r = ch.computePlan(deps);
|
|
505
|
+
assert.equal(r.ok, false);
|
|
506
|
+
assert.equal(r.reason, 'no_config', 'a missing layout file must be its own reason, never a thrown 500');
|
|
507
|
+
assert.equal(r.configPath, missing, 'the caller needs the path to report it');
|
|
508
|
+
assert.deepEqual(r.ops, []);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await t('reconcileChannels with no config returns {ok:false, no_config} and writes nothing', async () => {
|
|
512
|
+
const missing = path.join(os.tmpdir(), 'bongos-no-such-channels-1003973.json');
|
|
513
|
+
const deps = {
|
|
514
|
+
getGuild: () => ({ id: '1', channels: { cache: new Map() } }),
|
|
515
|
+
loadBotToken: () => 'tok',
|
|
516
|
+
loadConfig: () => ch.loadConfig(missing),
|
|
517
|
+
loadRoleMap: () => ({}),
|
|
518
|
+
};
|
|
519
|
+
const r = await ch.reconcileChannels(deps);
|
|
520
|
+
assert.equal(r.ok, false);
|
|
521
|
+
assert.equal(r.reason, 'no_config');
|
|
522
|
+
assert.equal(r.applied, 0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await t('a non-ENOENT read failure still propagates — only ENOENT is tagged', () => {
|
|
526
|
+
// A directory reads as EISDIR: a genuine internal error must NOT be laundered
|
|
527
|
+
// into the friendly 'put the file here' path.
|
|
528
|
+
assert.throws(() => ch.loadConfig(os.tmpdir()), (err) => {
|
|
529
|
+
assert.notEqual(err.code, 'CONFIG_NOT_FOUND');
|
|
530
|
+
return true;
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
368
534
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
369
535
|
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// tests/idea_cascade.mjs — the SECOND cross-role cascade: an ideator lands a Full
|
|
2
|
+
// Idea, an engineer gets a scoping task (task 1003856, part 1's residue).
|
|
3
|
+
//
|
|
4
|
+
// The first cascade (artist-review-on-ship, task 1003549) is covered by
|
|
5
|
+
// modules/lifecycle/tests/cascade.mjs. This file covers what the second one added:
|
|
6
|
+
// a trigger that is not a ship. The mechanism was ship-only, and an idea reaching
|
|
7
|
+
// grade 'full' emitted nothing at all — so the rule could not be a table entry.
|
|
8
|
+
//
|
|
9
|
+
// The runner guarantees are the same four the ship runner's header names, and the
|
|
10
|
+
// one that matters most here is the first: an ideator grading their own idea must
|
|
11
|
+
// never be able to fail on a follow-up task's account.
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const C = require(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'));
|
|
22
|
+
|
|
23
|
+
const idea = (over = {}) => ({
|
|
24
|
+
id: '1001030',
|
|
25
|
+
title: 'Price swaps from mechanism',
|
|
26
|
+
grade: 'full',
|
|
27
|
+
status: 'open',
|
|
28
|
+
completeness_score: 4,
|
|
29
|
+
captured_by: '1000025',
|
|
30
|
+
suggested_goal_id: '1000095',
|
|
31
|
+
suggested_version: 'BONGOS-V2',
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const deps = (over = {}) => {
|
|
36
|
+
const created = [];
|
|
37
|
+
return {
|
|
38
|
+
created,
|
|
39
|
+
findTaskBySourceRef: async () => null,
|
|
40
|
+
createTask: async (spec) => { created.push(spec); return { id: '9001' }; },
|
|
41
|
+
...over,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------- the rule ----------
|
|
46
|
+
test('the declaration is well-formed — a malformed rule is a wiring bug, not a silent skip', () => {
|
|
47
|
+
assert.deepEqual(C.validateCascades(C.IDEA_CASCADES), []);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a Full Idea matches, and files for the ENGINEER', () => {
|
|
51
|
+
const { matched, errors } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
52
|
+
assert.deepEqual(errors, []);
|
|
53
|
+
assert.equal(matched.length, 1);
|
|
54
|
+
assert.equal(matched[0].rule.discipline, 'engineer');
|
|
55
|
+
assert.match(matched[0].spec.title, /Scope the Full Idea: Price swaps from mechanism/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a spark or quick idea does NOT match — Full is the hand-off point', () => {
|
|
59
|
+
for (const grade of ['quick', 'spark', undefined]) {
|
|
60
|
+
const { matched } = C.cascadesFor(idea({ grade }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
61
|
+
assert.equal(matched.length, 0, `grade=${grade} should not file a scoping task`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a CLOSED idea does not file — triage got there first', () => {
|
|
66
|
+
for (const status of ['discarded', 'merged', 'promoted']) {
|
|
67
|
+
const { matched } = C.cascadesFor(idea({ status }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
68
|
+
assert.equal(matched.length, 0, `status=${status} should not file`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a cascade-sourced idea cannot file another cascade — the anti-recursion guard', () => {
|
|
73
|
+
const { matched } = C.cascadesFor(idea({ source: C.CASCADE_SOURCE }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
74
|
+
assert.equal(matched.length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------- the generated task ----------
|
|
78
|
+
test('the task inherits the version but NEVER the advisory goal hint', () => {
|
|
79
|
+
// idea_inbox.suggested_goal_id documents itself as "Strictly advisory: no rank
|
|
80
|
+
// check, no scope wall, and no payout may read it... The BINDING home for
|
|
81
|
+
// 'which goal' is tasks.goal_id". A generated task's goal IS binding, so
|
|
82
|
+
// inheriting it from the hint would be exactly the read that column forbids.
|
|
83
|
+
// A version is a label and carries no such rule.
|
|
84
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
85
|
+
const spec = matched[0].spec;
|
|
86
|
+
assert.equal(spec.versionId, 'BONGOS-V2');
|
|
87
|
+
assert.equal(spec.goalId, null, 'an advisory hint must not place a task');
|
|
88
|
+
assert.equal(spec.allowCatchAll, true, 'so it lands in the catch-all and gets triaged like any other homeless work');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the hint is surfaced for a human rather than silently dropped', () => {
|
|
92
|
+
// Not acting on it is the rule; losing it would be a different mistake.
|
|
93
|
+
const body = C.fullIdeaScopingBody(idea());
|
|
94
|
+
assert.match(body, /routing hint/);
|
|
95
|
+
assert.match(body, /goal 1000095/);
|
|
96
|
+
assert.match(body, /advisory/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an idea with no hint says nothing about goals at all', () => {
|
|
100
|
+
const body = C.fullIdeaScopingBody(idea({ suggested_goal_id: null }));
|
|
101
|
+
assert.doesNotMatch(body, /routing hint/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a goal-less idea still opts into the catch-all rather than vanishing', () => {
|
|
105
|
+
// The exact regression task 1003598 closed for ships: under the goal guard a
|
|
106
|
+
// null goalId THROWS, and the throw is caught per-rule — so the task would
|
|
107
|
+
// disappear with only a console line.
|
|
108
|
+
const { matched } = C.cascadesFor(idea({ suggested_goal_id: null }), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
109
|
+
assert.equal(matched[0].spec.allowCatchAll, true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the preamble says what actually happened — an idea was graded, not a task shipped', () => {
|
|
113
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
114
|
+
const d = matched[0].spec.description;
|
|
115
|
+
assert.match(d, /idea 1001030 was graded Full/);
|
|
116
|
+
assert.doesNotMatch(d, /shipped/, 'the ship preamble would be a lie on this trigger');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the body tells the engineer what is already settled and what is being asked', () => {
|
|
120
|
+
const body = C.fullIdeaScopingBody(idea());
|
|
121
|
+
assert.match(body, /reached \*\*Full\*\*/);
|
|
122
|
+
assert.match(body, /completeness 4/);
|
|
123
|
+
assert.match(body, /What would building it actually touch/);
|
|
124
|
+
// A scoping that concludes "don't build it" is a real answer; say so.
|
|
125
|
+
assert.match(body, /why it should not be built/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('the source_ref pins one rule to one idea, so a re-grade cannot double-file', () => {
|
|
129
|
+
const { matched } = C.cascadesFor(idea(), C.IDEA_CASCADES, {}, C.TRIGGERS.idea);
|
|
130
|
+
assert.equal(matched[0].spec.sourceRef, C.cascadeSourceRef('engineer-scoping-on-full-idea', '1001030'));
|
|
131
|
+
assert.equal(matched[0].spec.source, C.CASCADE_SOURCE);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------- the runner ----------
|
|
135
|
+
test('the runner files the task from the event payload alone', async () => {
|
|
136
|
+
const d = deps();
|
|
137
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
138
|
+
assert.deepEqual(report.errors, []);
|
|
139
|
+
assert.equal(report.created.length, 1);
|
|
140
|
+
assert.equal(d.created.length, 1);
|
|
141
|
+
assert.equal(d.created[0].discipline, 'engineer');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('an already-filed cascade is SKIPPED, not filed twice', async () => {
|
|
145
|
+
const d = deps({ findTaskBySourceRef: async () => ({ id: '8001' }) });
|
|
146
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
147
|
+
assert.equal(report.created.length, 0);
|
|
148
|
+
assert.deepEqual(report.skipped.map((s) => s.reason), ['already filed']);
|
|
149
|
+
assert.equal(d.created.length, 0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a payload with no id is reported, never thrown', async () => {
|
|
153
|
+
const report = await C.runCascadesForIdea({}, deps());
|
|
154
|
+
assert.equal(report.created.length, 0);
|
|
155
|
+
assert.match(report.errors.join(' '), /carried no idea id/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('createTask throwing costs that rule its task and NOTHING else', async () => {
|
|
159
|
+
// Property 1: the ideator's grade is already written. A follow-up task failing
|
|
160
|
+
// must never surface as a failure of the thing that triggered it.
|
|
161
|
+
const d = deps({ createTask: async () => { throw new Error('db down'); } });
|
|
162
|
+
const report = await C.runCascadesForIdea(idea(), d);
|
|
163
|
+
assert.equal(report.created.length, 0);
|
|
164
|
+
assert.match(report.errors.join(' '), /db down/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the runner never rejects, whatever the deps do', async () => {
|
|
168
|
+
const report = await C.runCascadesForIdea(idea(), { findTaskBySourceRef: async () => { throw new Error('boom'); }, createTask: async () => ({ id: '1' }) });
|
|
169
|
+
assert.ok(Array.isArray(report.errors) && report.errors.length > 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('an injected reader that finds nothing is an error, not a silent fall-back', async () => {
|
|
173
|
+
const report = await C.runCascadesForIdea(idea(), deps({ getIdea: async () => null }));
|
|
174
|
+
assert.match(report.errors.join(' '), /not readable after grading/);
|
|
175
|
+
assert.equal(report.created.length, 0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------- the wiring, which fails silently when it breaks ----------
|
|
179
|
+
const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
180
|
+
|
|
181
|
+
test('the event is in the catalog AND something dispatches it', () => {
|
|
182
|
+
// The catalog's own comment: widening it is deliberate and must land together
|
|
183
|
+
// with a real dispatcher. Both halves asserted, because either alone is dead.
|
|
184
|
+
assert.match(src('modules/agents/lib/validate.js'), /'idea\.graded\.full'/);
|
|
185
|
+
assert.match(src('modules/ideas/inbox.js'), /api\.emit\('idea\.graded\.full'/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('the emit carries the whole idea, not just an id', () => {
|
|
189
|
+
// The listener lives in lifecycle, which owns no idea table and has no read
|
|
190
|
+
// port for one — so a bare {id} payload would leave the rules nothing to match.
|
|
191
|
+
const inbox = src('modules/ideas/inbox.js');
|
|
192
|
+
assert.match(inbox, /const idea = await getIdea\(ideaId\)/);
|
|
193
|
+
assert.match(inbox, /\.\.\.\(idea \|\| \{\}\)/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('lifecycle subscribes to the idea trigger and tears it down again', () => {
|
|
197
|
+
const lc = src('modules/lifecycle/routes/lifecycle.js');
|
|
198
|
+
assert.match(lc, /api\.on\('idea\.graded\.full'/);
|
|
199
|
+
assert.match(lc, /if \(_ideaCascadeListenerOff\) _ideaCascadeListenerOff\(\)/);
|
|
200
|
+
// It must NOT reach into the ideas module's table to do it.
|
|
201
|
+
assert.doesNotMatch(lc, /getIdeaForCascade/, 'lifecycle must not query another module\'s rows');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the emit only fires on a REAL transition', () => {
|
|
205
|
+
// The UPDATE carries `AND grade = 'spark'`, so a no-op re-grade returns null.
|
|
206
|
+
// The emit is inside `if (graded)`, so it cannot repeat for one idea.
|
|
207
|
+
const inbox = src('modules/ideas/inbox.js');
|
|
208
|
+
const at = inbox.indexOf("api.emit('idea.graded.full'");
|
|
209
|
+
assert.ok(at > 0);
|
|
210
|
+
assert.match(inbox.slice(0, at).split('\n').slice(-25).join('\n'), /if \(graded\) \{/);
|
|
211
|
+
});
|
|
@@ -134,11 +134,23 @@ test('cascade opts in ONLY when inheritance actually failed', () => {
|
|
|
134
134
|
// general. A blanket `true` here would quietly re-open the hole for every
|
|
135
135
|
// follow-up task the system files.
|
|
136
136
|
const body = src('modules/lifecycle/cascade.js');
|
|
137
|
-
|
|
137
|
+
// Pinned on the RULE, not on one spelling of it: task 1003856 added an
|
|
138
|
+
// idea-triggered cascade whose subject carries its goal as `suggested_goal_id`,
|
|
139
|
+
// so the condition legitimately grew a term. What must stay true is that the
|
|
140
|
+
// hatch is conditional and covers every source the spec can inherit from.
|
|
141
|
+
const hatch = (body.match(/allowCatchAll:.*$/m) || [''])[0];
|
|
142
|
+
assert.match(hatch, /task\.goal_id == null/, 'the hatch still turns on a missing inherited goal');
|
|
138
143
|
assert.equal(/allowCatchAll: true/.test(body), false,
|
|
139
144
|
'an unconditional hatch here would cover every cascade, not just the legacy ones');
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
|
|
146
|
+
// Every field the spec inherits a goal FROM must also be a field the hatch
|
|
147
|
+
// checks, or a subject shape could arrive with a real goal and still opt into
|
|
148
|
+
// the catch-all — the hole this test exists to keep shut.
|
|
149
|
+
const goalLine = (body.match(/^\s*goalId:[\s\S]*?,$/m) || [''])[0];
|
|
150
|
+
assert.match(goalLine, /task\.goal_id != null \? Number\(task\.goal_id\)/, 'inheritance stays the primary path');
|
|
151
|
+
for (const field of (goalLine.match(/task\.[a-z_]*goal[a-z_]*/g) || [])) {
|
|
152
|
+
assert.ok(hatch.includes(field), `${field} is inherited as a goal but is not checked by the catch-all hatch`);
|
|
153
|
+
}
|
|
142
154
|
});
|
|
143
155
|
|
|
144
156
|
test('a goal-less parent does not silently lose its cascade task', async () => {
|