@bongos/core 1.19.497 → 1.19.499

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.
@@ -75,6 +75,20 @@
75
75
  let draftTimer = null;
76
76
  let filing = false;
77
77
 
78
+ // QUICK FILING (task 1003069 / R08, goal 1000071 "Idea routing"). A second,
79
+ // independent capture path on this page: title + kind + optional body + a
80
+ // goal picker fed by the R07 suggester (GET /goals/suggestions). It lives
81
+ // beside the Full Idea form above rather than inside it — a routed capture
82
+ // must never carry grade='full' (routing.js NEVER_ROUTE_GRADE), so the two
83
+ // cannot share one submit. Quick-mode only: hidden while developing a spark.
84
+ const QUICK_SUGGEST_DEBOUNCE_MS = 300;
85
+ let buildingVersionIds = []; // fetched once — the suggester is scoped per version
86
+ let allOpenGoals = []; // the picker's "browse everything" half
87
+ let allGoalsById = new Map(); // for naming the goal a routed capture landed in
88
+ let goalTouchedByUser = false; // once the builder picks, stop clobbering it with fresh suggestions
89
+ let quickSuggestTimer = null;
90
+ let quickFiling = false;
91
+
78
92
  // ---------------------------------------------------------------------------
79
93
  // draft persistence
80
94
  // ---------------------------------------------------------------------------
@@ -347,6 +361,155 @@
347
361
  toast('Draft discarded.');
348
362
  }
349
363
 
364
+ // ---------------------------------------------------------------------------
365
+ // quick filing — kind + goal picker (task 1003069 / R08)
366
+ // ---------------------------------------------------------------------------
367
+
368
+ const fqTitleEl = () => $('#fq-title');
369
+ const fqKindEl = () => $('#fq-kind');
370
+ const fqBodyEl = () => $('#fq-body');
371
+ const fqGoalEl = () => $('#fq-goal');
372
+
373
+ // The picker's options: "no goal" first (the default — a filer never has to
374
+ // know the goal map, C5), then whatever the suggester found, then every open
375
+ // goal it didn't. Suggested ids are excluded from the second list rather than
376
+ // shown twice.
377
+ function renderGoalOptions(suggested) {
378
+ const sel = fqGoalEl();
379
+ const suggestedIds = new Set((suggested || []).map((g) => String(g.id)));
380
+ const rest = allOpenGoals.filter((g) => !suggestedIds.has(String(g.id)));
381
+ const optionHtml = (g, why) => `<option value="${escapeHtml(String(g.id))}">${escapeHtml(g.title || `goal ${g.id}`)}${why ? ` — matched: ${escapeHtml(why)}` : ''}</option>`;
382
+ let html = `<option value="" selected>No goal (homeless)</option>`;
383
+ if (suggested && suggested.length) {
384
+ html += `<optgroup label="Suggested">${suggested.map((g) => optionHtml(g, (g.matched_terms || []).join(', '))).join('')}</optgroup>`;
385
+ }
386
+ html += `<optgroup label="All open goals">${rest.map((g) => optionHtml(g)).join('')}</optgroup>`;
387
+ sel.innerHTML = html;
388
+ }
389
+
390
+ // The suggester is ranked per version (GET /goals/suggestions requires one),
391
+ // so a filer whose instance has more than one version building gets both
392
+ // consulted and merged by score. An outage — or no building version at all —
393
+ // degrades to "no suggestions"; the homeless option and the full goal list
394
+ // are still there either way (goal-suggest.js's own fail-open contract).
395
+ async function refreshGoalSuggestions() {
396
+ if (goalTouchedByUser) return; // never yank a manual pick out from under someone
397
+ const title = fqTitleEl().value.trim();
398
+ if (!title || !buildingVersionIds.length) { renderGoalOptions([]); return; }
399
+ const kind = fqKindEl().value;
400
+ try {
401
+ const results = await Promise.all(buildingVersionIds.map((versionId) => api.goals.getGoalsSuggestions({
402
+ query: { version: versionId, title, kind, limit: 5 },
403
+ })));
404
+ const byId = new Map();
405
+ for (const r of results) {
406
+ if (!r.ok || !r.data || !Array.isArray(r.data.suggestions)) continue;
407
+ for (const g of r.data.suggestions) {
408
+ const prev = byId.get(String(g.id));
409
+ if (!prev || g.score > prev.score) byId.set(String(g.id), g);
410
+ }
411
+ }
412
+ const merged = Array.from(byId.values()).sort((a, b) => b.score - a.score).slice(0, 5);
413
+ renderGoalOptions(merged);
414
+ } catch (_) {
415
+ renderGoalOptions([]);
416
+ }
417
+ }
418
+
419
+ function onQuickTitleEdit() {
420
+ clearTimeout(quickSuggestTimer);
421
+ quickSuggestTimer = setTimeout(refreshGoalSuggestions, QUICK_SUGGEST_DEBOUNCE_MS);
422
+ }
423
+
424
+ function showQuickErr(msg) {
425
+ $('#fq-err').textContent = msg || '';
426
+ }
427
+
428
+ // The server names why a routed capture was refused (task 1003066's landing
429
+ // gate) or names the refusal code when it doesn't — read it, never re-derive
430
+ // the landing matrix here (routing.js stays the one place that decides).
431
+ function explainQuickFiling(status, data) {
432
+ const err = data && data.error;
433
+ const code = err && (typeof err === 'object' ? err.code : err);
434
+ const details = (err && typeof err === 'object' && err.details) || {};
435
+ if (details.reason) return `Not filed — ${details.reason}`;
436
+ if (code) return `Not filed — ${code}.`;
437
+ if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
438
+ return `Not filed (${status}). Nothing was saved — try again.`;
439
+ }
440
+
441
+ async function fileQuick(ev) {
442
+ ev.preventDefault();
443
+ if (quickFiling) return;
444
+ const title = fqTitleEl().value.trim();
445
+ showQuickErr('');
446
+ $('#fq-result').innerHTML = '';
447
+ if (!title) { showQuickErr('Not filed — a title is required.'); return; }
448
+ quickFiling = true;
449
+ $('#fq-submit').disabled = true;
450
+
451
+ const goalId = fqGoalEl().value;
452
+ const body = { title, body_md: fqBodyEl().value, kind: fqKindEl().value };
453
+ if (goalId) body.goal_id = Number(goalId);
454
+
455
+ const r = await api.inbox.postInbox({ body });
456
+ quickFiling = false;
457
+ $('#fq-submit').disabled = false;
458
+
459
+ if (!r.ok) {
460
+ showQuickErr(explainQuickFiling(r.status, r.data));
461
+ return;
462
+ }
463
+
464
+ // The routing verdict travels on idea.routing; a routed capture also
465
+ // carries the task it became at the top level (routed_task) — see
466
+ // routes/inbox.js. Not routed (no goal picked, or the matrix declined
467
+ // one) is exactly today's inbox behaviour.
468
+ const routedTask = r.data && r.data.routed_task;
469
+ const routing = r.data && r.data.idea && r.data.idea.routing;
470
+ if (routedTask && routedTask.id && routing && routing.routed) {
471
+ const href = window.OTBTaskDetail && typeof window.OTBTaskDetail.href === 'function'
472
+ ? window.OTBTaskDetail.href(routedTask.id)
473
+ : `/task/${encodeURIComponent(routedTask.id)}`;
474
+ const goal = allGoalsById.get(String(routing.goal_id));
475
+ const goalName = goal ? goal.title : `goal ${routing.goal_id}`;
476
+ $('#fq-result').innerHTML = `Filed as <a href="${href}">task ${escapeHtml(String(routedTask.id))}</a> under ${escapeHtml(goalName)}.`;
477
+ } else {
478
+ $('#fq-result').textContent = 'Filed to the inbox.';
479
+ }
480
+
481
+ fqTitleEl().value = '';
482
+ fqBodyEl().value = '';
483
+ fqKindEl().value = 'bug';
484
+ goalTouchedByUser = false;
485
+ renderGoalOptions([]);
486
+ }
487
+
488
+ async function bootQuickComposer() {
489
+ fqKindEl().value = 'bug';
490
+ renderGoalOptions([]);
491
+ fqTitleEl().addEventListener('input', onQuickTitleEdit);
492
+ fqKindEl().addEventListener('change', onQuickTitleEdit);
493
+ fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; });
494
+ $('#fq-form').addEventListener('submit', fileQuick);
495
+
496
+ try {
497
+ const [versionsRes, goalsRes] = await Promise.all([
498
+ api.versions.getVersions({}),
499
+ api.goals.getGoals({ query: { status: 'open' } }),
500
+ ]);
501
+ buildingVersionIds = (versionsRes.ok && versionsRes.data && Array.isArray(versionsRes.data.versions))
502
+ ? versionsRes.data.versions.filter((v) => v.status === 'building').map((v) => v.id)
503
+ : [];
504
+ allOpenGoals = (goalsRes.ok && goalsRes.data && Array.isArray(goalsRes.data.goals)) ? goalsRes.data.goals : [];
505
+ allGoalsById = new Map(allOpenGoals.map((g) => [String(g.id), g]));
506
+ renderGoalOptions([]);
507
+ } catch (_) {
508
+ // The picker degrades to "homeless only" — filing itself never depends
509
+ // on this read succeeding.
510
+ }
511
+ }
512
+
350
513
  // ---------------------------------------------------------------------------
351
514
  // boot
352
515
  // ---------------------------------------------------------------------------
@@ -433,6 +596,16 @@
433
596
  // A surface that does not depend on the failure must not be hostage to it.
434
597
  loadQueue();
435
598
 
599
+ // Quick filing (R08) is a THIRD independent surface on this page, offered
600
+ // only outside develop mode — developing a spark always produces a Full
601
+ // Idea, never a fresh quick report (see the section's own header comment).
602
+ if (DEVELOP_ID) {
603
+ const scroll = $('#fq-compose-scroll');
604
+ if (scroll) scroll.hidden = true;
605
+ } else {
606
+ bootQuickComposer();
607
+ }
608
+
436
609
  const r = await api.inbox.getInboxTemplate({});
437
610
  if (!r.ok || !r.data || !r.data.template) {
438
611
  $('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.497",
3
+ "version": "1.19.499",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.497",
9
+ "version": "1.19.499",
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.497",
3
+ "version": "1.19.499",
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
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.497'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.499'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -48,7 +48,19 @@ const QUEUE = [
48
48
  { id: '78', title: 'A shared changelog', status: 'open', grade: 'spark', captured_at: '2026-07-30T00:00:00Z', filed_by_login: null },
49
49
  ];
50
50
 
51
- function boot({ search = '', sparks = QUEUE, count = null, idea = SPARK, ideaOk = true, store = {} } = {}) {
51
+ // task 1003069 (R08, goal 1000071) — the quick filing form's fixtures: one
52
+ // building version and two open goals, so the suggester + full-list halves of
53
+ // the picker both have something real to render against.
54
+ const VERSIONS = [{ id: 'BV1', status: 'building' }];
55
+ const GOALS = [
56
+ { id: '77', title: 'Goal Seventy Seven', version_id: 'BV1' },
57
+ { id: '88', title: 'Another goal', version_id: 'BV1' },
58
+ ];
59
+
60
+ function boot({
61
+ search = '', sparks = QUEUE, count = null, idea = SPARK, ideaOk = true, store = {},
62
+ versions = VERSIONS, goals = GOALS, suggestions = [],
63
+ } = {}) {
52
64
  const els = new Map();
53
65
  const makeEl = () => ({
54
66
  dataset: {}, style: {}, textContent: '', value: '', hidden: false, disabled: false,
@@ -77,9 +89,36 @@ function boot({ search = '', sparks = QUEUE, count = null, idea = SPARK, ideaOk
77
89
  getInboxSparks: async () => { calls.push(['sparks', null]); return { status: 200, ok: true, data: { sparks, count: count == null ? sparks.length : count } }; },
78
90
  getInboxId: async (a) => { calls.push(['idea', a]); return { status: ideaOk ? 200 : 404, ok: ideaOk, data: ideaOk ? { idea } : null }; },
79
91
  postInboxScore: async (a) => { calls.push(['score', a]); return { status: 200, ok: true, data: { score: 60, fields: TEMPLATE.fields.map((f) => ({ key: f.key, substance: 24, floor: 40, ratio: 0.6 })) } }; },
80
- postInbox: async (a) => { calls.push(['capture', a]); return { status: 201, ok: true, data: { idea: { id: '900', grade: 'full', completeness_score: 80 } } }; },
92
+ // task 1003069 (R08) — a quick capture that named a goal comes back
93
+ // ROUTED (the shape captureIdea/routeIdeaToGoal actually returns:
94
+ // idea.routing + a top-level routed_task); one with no goal_id is a
95
+ // quick capture that stayed homeless; anything else is the existing
96
+ // Full Idea fixture the earlier tests in this file already assert on.
97
+ postInbox: async (a) => {
98
+ calls.push(['capture', a]);
99
+ if (a.body && a.body.goal_id != null) {
100
+ return { status: 201, ok: true, data: {
101
+ idea: { id: '910', routing: { routed: true, goal_id: a.body.goal_id, status: 'ready' } },
102
+ routed_task: { id: '5555', goal_id: a.body.goal_id },
103
+ } };
104
+ }
105
+ if (a.body && a.body.kind && !('grade' in a.body)) {
106
+ return { status: 201, ok: true, data: { idea: { id: '911', routing: { routed: false, reason: 'no_goal' } } } };
107
+ }
108
+ return { status: 201, ok: true, data: { idea: { id: '900', grade: 'full', completeness_score: 80 } } };
109
+ },
81
110
  postInboxIdDevelop: async (a) => { calls.push(['develop', a]); return { status: 200, ok: true, data: { idea: { ...idea, grade: 'full', completeness_score: 80, ideator_credit: 5 } } }; },
82
111
  },
112
+ // task 1003069 (R08) — the quick-filing goal picker's reads: the building
113
+ // versions (to scope the suggester) and the open goal list (the picker's
114
+ // browse-all half). Overridable per test via boot({ versions, goals, suggestions }).
115
+ versions: {
116
+ getVersions: async () => { calls.push(['versions', null]); return { status: 200, ok: true, data: { versions } }; },
117
+ },
118
+ goals: {
119
+ getGoals: async () => { calls.push(['goals', null]); return { status: 200, ok: true, data: { goals } }; },
120
+ getGoalsSuggestions: async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; },
121
+ },
83
122
  };
84
123
 
85
124
  const toasts = [];
@@ -111,6 +150,7 @@ function boot({ search = '', sparks = QUEUE, count = null, idea = SPARK, ideaOk
111
150
  queueHtml: () => el('fi-queue').innerHTML,
112
151
  fill: () => { for (const f of TEMPLATE.fields) el(`fi-f-${f.key}`).value = `an answer for ${f.key} long enough to count`; },
113
152
  submit: () => el('fi-form')._handlers.submit({ preventDefault() {} }),
153
+ submitQuick: () => el('fq-form')._handlers.submit({ preventDefault() {} }),
114
154
  };
115
155
  }
116
156
 
@@ -196,6 +236,10 @@ await test('filing in develop mode calls the DEVELOP route with no title and no
196
236
  assert.ok(call[1].body.field_values, 'it carries the five answers');
197
237
  assert.ok(!('title' in call[1].body), 'and no title');
198
238
  assert.ok(!('grade' in call[1].body), 'and no grade');
239
+ // task 1003069 (R08) — routing is a QUICK-mode-only affordance; developing a
240
+ // spark always produces a Full Idea (routing.js NEVER_ROUTE_GRADE), so this
241
+ // path must never carry a goal_id even if one happened to be in view.
242
+ assert.ok(!('goal_id' in call[1].body), 'and no goal_id — develop mode never routes');
199
243
  assert.ok(!r.calls.some((c) => c[0] === 'capture'), 'and never files a second idea');
200
244
  });
201
245
 
@@ -259,5 +303,95 @@ await test('the plain composer is unaffected — develop mode is never entered',
259
303
  assert.equal(call[1].body.title, 'a new idea');
260
304
  });
261
305
 
306
+ // --- quick filing: kind + goal picker (task 1003069 / R08, goal 1000071) ---
307
+ //
308
+ // A second, independent capture path on this same page: title + kind +
309
+ // optional body + a goal picker fed by the R07 suggester. It exists beside the
310
+ // Full Idea form rather than inside it — a routed capture must never carry
311
+ // grade='full' (routing.js NEVER_ROUTE_GRADE) — so quick and full cannot share
312
+ // one submit, and it is offered only outside develop mode (developing a spark
313
+ // always produces a Full Idea from someone else's thought, never a fresh
314
+ // lightweight report).
315
+
316
+ await test('the quick-filing section is offered in plain mode and hidden while developing a spark', async () => {
317
+ const plain = boot();
318
+ await settle(); await settle();
319
+ assert.equal(plain.el('fq-compose-scroll').hidden, false);
320
+
321
+ const developing = boot({ search: '?develop=77' });
322
+ await settle(); await settle(); await settle();
323
+ assert.equal(developing.el('fq-compose-scroll').hidden, true);
324
+ });
325
+
326
+ await test('typing a title debounces a call to the goal suggester, scoped to the building version and the kind control', async () => {
327
+ const r = boot({ suggestions: [{ id: '77', title: 'Goal Seventy Seven', score: 4, matched_terms: ['goal'] }] });
328
+ await settle(); await settle();
329
+ r.el('fq-title').value = 'a goal seventy seven problem';
330
+ r.el('fq-title')._handlers.input();
331
+ await new Promise((res) => setTimeout(res, 400));
332
+ const call = r.calls.find((c) => c[0] === 'suggest');
333
+ assert.ok(call, 'the suggester was called');
334
+ assert.equal(call[1].query.version, 'BV1');
335
+ assert.equal(call[1].query.title, 'a goal seventy seven problem');
336
+ assert.equal(call[1].query.kind, 'bug', 'the kind control feeds the suggester too');
337
+ const html = r.el('fq-goal').innerHTML;
338
+ assert.match(html, /No goal \(homeless\)/);
339
+ assert.match(html, /Suggested/);
340
+ assert.match(html, /Goal Seventy Seven/);
341
+ assert.match(html, /Another goal/, 'the full open-goal list is still offered below the suggestion');
342
+ });
343
+
344
+ await test('an empty title refuses locally, without hitting the network', async () => {
345
+ const r = boot();
346
+ await settle(); await settle();
347
+ await r.submitQuick();
348
+ await settle();
349
+ assert.ok(!r.calls.some((c) => c[0] === 'capture'), 'no request was made');
350
+ assert.match(r.el('fq-err').textContent, /title is required/);
351
+ });
352
+
353
+ await test('submitting with no goal picked sends no goal_id — an unrouted response says it went to the inbox', async () => {
354
+ const r = boot();
355
+ await settle(); await settle();
356
+ r.el('fq-title').value = 'a quiet feature idea';
357
+ await r.submitQuick();
358
+ await settle();
359
+ const call = r.calls.find((c) => c[0] === 'capture');
360
+ assert.ok(call, 'POST /inbox was called');
361
+ assert.equal(call[1].body.kind, 'bug', 'bug is the default kind');
362
+ assert.ok(!('goal_id' in call[1].body), 'homeless sends no goal_id at all');
363
+ assert.ok(!('grade' in call[1].body), 'a quick capture carries no grade either');
364
+ assert.equal(r.el('fq-result').textContent, 'Filed to the inbox.');
365
+ });
366
+
367
+ await test('submitting with a goal picked sends goal_id as a NUMBER, and a routed response renders the task link', async () => {
368
+ const r = boot();
369
+ await settle(); await settle();
370
+ r.el('fq-title').value = 'the widget is broken';
371
+ r.el('fq-kind').value = 'bug';
372
+ r.el('fq-goal').value = '77';
373
+ r.el('fq-goal')._handlers.change();
374
+ await r.submitQuick();
375
+ await settle();
376
+ const call = r.calls.find((c) => c[0] === 'capture');
377
+ assert.ok(call);
378
+ assert.equal(call[1].body.goal_id, 77, 'sent as a number — the option carries a string value');
379
+ const html = r.el('fq-result').innerHTML;
380
+ assert.match(html, /task 5555/);
381
+ assert.match(html, /href="\/task\/5555"/);
382
+ assert.match(html, /Goal Seventy Seven/, 'names the goal the task landed in');
383
+ });
384
+
385
+ await test('the kind control travels with the filing', async () => {
386
+ const r = boot();
387
+ await settle(); await settle();
388
+ r.el('fq-title').value = 'clean up the queue module';
389
+ r.el('fq-kind').value = 'refactor';
390
+ await r.submitQuick();
391
+ await settle();
392
+ const call = r.calls.find((c) => c[0] === 'capture');
393
+ assert.equal(call[1].body.kind, 'refactor');
394
+ });
395
+
262
396
  console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
263
397
  if (failed > 0) process.exit(1);
@@ -53,5 +53,43 @@ test('step 5 (run the seed script) points at the self-check rather than treating
53
53
  assert.ok(step5.slice(0, 400).includes('verifySeedCriteriaLinks'), 'step 5 references the self-check by name');
54
54
  });
55
55
 
56
+ // --- task 1003075: the mechanics-map subagent becomes a named step ---------
57
+
58
+ test('a named "Map the mechanism" step exists between Phase 1 and Phase 2, in order', () => {
59
+ const phase1 = SKILL.indexOf('### Phase 1 —');
60
+ const mapStep = SKILL.indexOf('### Phase 1.5 — Map the mechanism');
61
+ const phase2 = SKILL.indexOf('### Phase 2 —');
62
+ assert.ok(phase1 !== -1 && mapStep !== -1 && phase2 !== -1, 'all three headings must exist');
63
+ assert.ok(phase1 < mapStep && mapStep < phase2, 'Phase 1.5 must sit between Phase 1 and Phase 2');
64
+ });
65
+
66
+ test('the mechanics-map step names its input, its output artifact, and where the output is consumed', () => {
67
+ const start = NORM.indexOf('Phase 1.5 — Map the mechanism');
68
+ const end = NORM.indexOf('Phase 2 — Criteria', start);
69
+ const section = NORM.slice(start, end === -1 ? undefined : end);
70
+ assert.ok(section.length > 0, 'the Phase 1.5 section must be non-empty');
71
+ assert.ok(/\bInput\b/.test(section), 'names its input');
72
+ assert.ok(section.includes('mechanics map'), 'names its output artifact (the mechanics map)');
73
+ assert.ok(/\bConsumed by\b/.test(section), 'names where the output is consumed');
74
+ assert.ok(section.includes('Phase 2') && section.includes('Phase 3'), 'names the specific later steps that consume it');
75
+ });
76
+
77
+ test('the mechanics-map step names the subagent mechanism and the retro precedent that motivated it', () => {
78
+ const start = NORM.indexOf('Phase 1.5 — Map the mechanism');
79
+ const end = NORM.indexOf('Phase 2 — Criteria', start);
80
+ const section = NORM.slice(start, end === -1 ? undefined : end);
81
+ assert.ok(section.includes('subagent'), 'names a subagent as the mechanism');
82
+ assert.ok(section.includes('goal 1000071'), 'cites the idea-routing session as the precedent');
83
+ assert.ok(section.includes('task 1003075'));
84
+ });
85
+
86
+ test('the step is scoped to process-change sessions, not every planning session', () => {
87
+ const start = NORM.indexOf('Phase 1.5 — Map the mechanism');
88
+ const end = NORM.indexOf('Phase 2 — Criteria', start);
89
+ const section = NORM.slice(start, end === -1 ? undefined : end);
90
+ assert.ok(/process-change/i.test(section));
91
+ assert.ok(/skip/i.test(section), 'says explicitly when to skip it');
92
+ });
93
+
56
94
  console.log(`\nplanning_session_skill: ${passed} passed, ${failed} failed`);
57
95
  if (failed > 0) process.exit(1);