@bongos/core 1.21.107 → 1.21.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.bongos-core.json +43 -38
  2. package/.claude/skills/goal-create/SKILL.md +9 -4
  3. package/.claude/skills/idea-triage/SKILL.md +1 -1
  4. package/.claude/skills/planning-session/SKILL.md +22 -9
  5. package/docs/copy-inventory.md +25 -19
  6. package/docs/copy-registry.json +92 -37
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/page-readings.json +189 -188
  9. package/modules/copy-desk/docx.js +27 -7
  10. package/modules/copy-desk/page-status.js +3 -1
  11. package/modules/copy-desk/pages.js +44 -5
  12. package/modules/copy-desk/routes/copy-desk.js +4 -3
  13. package/modules/copy-desk/tests/copy_docx.mjs +38 -0
  14. package/modules/copy-desk/tests/copy_page_draft.mjs +79 -0
  15. package/modules/hall-ui/public/paintbrush.js +152 -12
  16. package/modules/hall-ui/public/studio.js +3 -1
  17. package/modules/hall-ui/public/style.css +16 -0
  18. package/modules/hall-ui/public/tweak-editor-lib.js +24 -12
  19. package/modules/hall-ui/records/craft-mode.md +5 -0
  20. package/modules/hall-ui/records/tweak-editor.md +1 -1
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +20 -0
  24. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
  25. package/scripts/gds/seed-verify-criteria.js +73 -18
  26. package/scripts/hall-preview/server.js +12 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/copy_desk_page_submit.mjs +36 -0
  29. package/tests/hall_preview_encoded_paths.mjs +81 -0
  30. package/tests/hall_tweak_editor.mjs +19 -3
  31. package/tests/paintbrush.mjs +151 -2
  32. package/tests/planning_session_skill.mjs +102 -4
  33. package/tests/seed_verify_criteria.mjs +130 -0
@@ -12,21 +12,48 @@
12
12
  // (some use cli-lib's apiCall, others a local throttled fetch — see the
13
13
  // scripts/gds/CLAUDE.md roster), so this takes a `fetchGoal(goalId)` callback
14
14
  // rather than assuming a client shape.
15
+ //
16
+ // WHY A BASELINE (task 1004668). A seed into a goal that already carries linked
17
+ // work used to cry wolf: every criterion the seed did not touch was compared
18
+ // against an expected count of zero, so goal 1000095 (six criteria already linked)
19
+ // would have reported six false mismatches and exit(1) on a correct run — and a
20
+ // check that fails correct runs trains the next builder to ignore it. A seed now
21
+ // snapshots the goal's counts BEFORE its first write (baselineSeedCriteria) and
22
+ // verifies `baseline + plan`, which still catches a stray link on a criterion the
23
+ // seed was never meant to touch. Without a baseline only the plan's own criteria
24
+ // are checked; nothing else is assumed to be zero.
25
+ //
26
+ // With a baseline, the plan is the links THIS RUN CREATED, not every task the seed
27
+ // names. A seed is resumable (POST /tasks is idempotent on source_ref and replays
28
+ // an existing task with `idempotent: true`), and a replayed task's links are
29
+ // already inside the baseline — tally it again and a correct re-run expects them
30
+ // twice. So a baselined seed skips replays when it tallies (see
31
+ // seed-bv2-ideator-surface-tasks.js).
32
+
33
+ // countsByCriterion — { criterion_id: linked_tasks } from GET /goals/:id rows.
34
+ function countsByCriterion(criteriaRows) {
35
+ const counts = {};
36
+ for (const row of criteriaRows || []) counts[row.criterion_id] = Number(row.linked_tasks) || 0;
37
+ return counts;
38
+ }
15
39
 
16
40
  // diffCriterionLinks — pure comparison: expected (slug -> count, tallied from
17
41
  // the criterion_ids actually POSTed) vs actual (the `criteria` rows
18
42
  // GET /goals/:id returns, each carrying criterion_id + linked_tasks). Returns
19
- // every mismatching slug, including one present on only one side.
20
- function diffCriterionLinks(expectedCounts, criteriaRows) {
21
- const actual = {};
22
- for (const row of criteriaRows || []) {
23
- actual[row.criterion_id] = Number(row.linked_tasks) || 0;
24
- }
25
-
26
- const slugs = new Set([...Object.keys(expectedCounts || {}), ...Object.keys(actual)]);
43
+ // every mismatching slug.
44
+ //
45
+ // With `baseline` (the pre-seed snapshot) every criterion on the goal is checked
46
+ // against baseline + plan — a criterion created after the snapshot starts at 0.
47
+ // Without it, only the plan's own criteria are checked: a criterion the seed
48
+ // does not cite has an unknown prior count, and guessing zero is the false alarm.
49
+ function diffCriterionLinks(expectedCounts, criteriaRows, { baseline = null } = {}) {
50
+ const actual = countsByCriterion(criteriaRows);
51
+ const plan = expectedCounts || {};
52
+ const slugs = new Set(Object.keys(plan));
53
+ if (baseline) for (const slug of [...Object.keys(baseline), ...Object.keys(actual)]) slugs.add(slug);
27
54
  const mismatches = [];
28
55
  for (const slug of slugs) {
29
- const expected = (expectedCounts && expectedCounts[slug]) || 0;
56
+ const expected = ((baseline && baseline[slug]) || 0) + (plan[slug] || 0);
30
57
  const got = actual[slug] || 0;
31
58
  if (expected !== got) mismatches.push({ criterion_id: slug, expected, actual: got });
32
59
  }
@@ -45,25 +72,53 @@ function expectedCriterionCounts(criterionIdListsPerTask) {
45
72
  return counts;
46
73
  }
47
74
 
75
+ function readCriteria(r) {
76
+ const body = (r && (r.json || r.data)) || null;
77
+ return r && r.ok && body && Array.isArray(body.criteria) ? body.criteria : null;
78
+ }
79
+
80
+ // baselineSeedCriteria — call BEFORE the seed's first write. Returns the goal's
81
+ // current { criterion_id: linked_tasks } snapshot to hand to
82
+ // verifySeedCriteriaLinks, or exit(1)s: a seed that cannot read the goal it is
83
+ // about to write into must not seed blind.
84
+ async function baselineSeedCriteria({ goalId, fetchGoal, log = console.log, exit = process.exit }) {
85
+ const r = await fetchGoal(goalId);
86
+ const criteria = readCriteria(r);
87
+ if (!criteria) {
88
+ log(`BASELINE FAILED: GET goal ${goalId} did not return a criteria list (status ${r && r.status}). Refusing to seed blind.`);
89
+ return exit(1);
90
+ }
91
+ const baseline = countsByCriterion(criteria);
92
+ const linked = Object.values(baseline).filter((n) => n > 0).length;
93
+ log(`Baselined goal ${goalId}: ${criteria.length} criteria, ${linked} already carrying linked tasks.`);
94
+ return baseline;
95
+ }
96
+
48
97
  // verifySeedCriteriaLinks — Phase-5 self-check: fetch the goal, diff its
49
- // linked_tasks against the plan, and exit(1) loudly on any mismatch (a zero
50
- // where tasks were expected is the shape that shipped silently in the
51
- // RETRO2 incident, but any drift is worth stopping the seed for).
52
- async function verifySeedCriteriaLinks({ goalId, expectedCounts, fetchGoal, log = console.log, exit = process.exit }) {
98
+ // linked_tasks against the plan (plus the pre-seed `baseline` when given), and
99
+ // exit(1) loudly on any mismatch (a zero where tasks were expected is the shape
100
+ // that shipped silently in the RETRO2 incident, but any drift is worth stopping
101
+ // the seed for).
102
+ async function verifySeedCriteriaLinks({ goalId, expectedCounts, baseline = null, fetchGoal, log = console.log, exit = process.exit }) {
53
103
  const r = await fetchGoal(goalId);
54
- const body = (r && (r.json || r.data)) || null;
55
- if (!r || !r.ok || !body || !Array.isArray(body.criteria)) {
104
+ const criteria = readCriteria(r);
105
+ if (!criteria) {
56
106
  log(`VERIFY FAILED: GET goal ${goalId} did not return a criteria list (status ${r && r.status}). Cannot confirm criterion links landed.`);
57
107
  return exit(1);
58
108
  }
59
- const mismatches = diffCriterionLinks(expectedCounts, body.criteria);
109
+ const mismatches = diffCriterionLinks(expectedCounts, criteria, { baseline });
60
110
  if (mismatches.length) {
61
111
  log(`VERIFY FAILED: criterion links on goal ${goalId} do not match the plan (${mismatches.length} mismatch${mismatches.length === 1 ? '' : 'es'}):`);
62
112
  for (const m of mismatches) log(` ${m.criterion_id}: expected ${m.expected} linked task(s), got ${m.actual}`);
63
113
  log('Likely cause: a criterion_ids mapping bug (e.g. a positional "Cn" token resolved against the wrong scope — learning 1000188). Fix and re-run before trusting this seed.');
64
114
  return exit(1);
65
115
  }
66
- log(`VERIFY OK: all ${Object.keys(expectedCounts || {}).length} criteria on goal ${goalId} carry their planned task count.`);
116
+ if (baseline) {
117
+ log(`VERIFY OK: all ${criteria.length} criteria on goal ${goalId} carry their pre-seed count plus the planned links.`);
118
+ } else {
119
+ log(`VERIFY OK: all ${Object.keys(expectedCounts || {}).length} planned criteria on goal ${goalId} carry their planned task count.`);
120
+ log(' (No baseline was taken, so criteria this seed does not cite were not checked — pass baselineSeedCriteria() output as `baseline` to cover them.)');
121
+ }
67
122
  }
68
123
 
69
- module.exports = { diffCriterionLinks, expectedCriterionCounts, verifySeedCriteriaLinks };
124
+ module.exports = { diffCriterionLinks, expectedCriterionCounts, baselineSeedCriteria, verifySeedCriteriaLinks };
@@ -271,7 +271,18 @@ const app = express();
271
271
  app.use((req, res, next) => {
272
272
  const m = req.path.match(/^\/api\/(?:gds|bongos)(?:\/v1)?\/(.+)$/);
273
273
  if (!m) return next();
274
- const apiPath = m[1].replace(/\/$/, '');
274
+ // Decoded before any matching (task 1004700): the paintbrush sends its page id
275
+ // through encodeURIComponent, so builders:studio arrives as builders%3Astudio
276
+ // and every copy-desk fixture and canned write missed. A malformed escape keeps
277
+ // the raw path, which then simply finds no fixture.
278
+ let apiPath = m[1].replace(/\/$/, '');
279
+ try { apiPath = decodeURIComponent(apiPath); } catch { /* keep raw */ }
280
+ // Decoding can mint a backslash, NUL or `..` segment that the fixture-name
281
+ // mapper does not neutralise (path.join honours `\` on Windows), so a decoded
282
+ // path that could leave the fixtures dir is refused outright.
283
+ if (/[\\\0]|(^|\/)\.\.(\/|$)/.test(apiPath)) {
284
+ return res.status(400).json({ error: { code: 'bad_path', message: 'path escapes the fixtures' } });
285
+ }
275
286
 
276
287
  // SSE live channel: emit one nudge (so "live refresh" paths run once), then
277
288
  // keep-alive comments. Pages treat this exactly like prod's stream.
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.107'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.109'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -282,6 +282,42 @@ try {
282
282
  assert.equal((t.description.match(/```page-tweak\n/g) || []).length, 1);
283
283
  assert.equal(t.status, 'ready');
284
284
  });
285
+ // Task 1004696: the artist recommends a line be removed. The real routes:
286
+ // the save stores it, the editor read returns it, and the submit hands it to
287
+ // an engineer as a removal, never into the applier's batch.
288
+ await test('a removal is saved, read back, and submitted as an engineer task to take the line out', async () => {
289
+ const PROJECTS = '/copy-desk/pages/landing:projects';
290
+ const PROJECT_LINES = [
291
+ L('L0001', 'hero', 'Projects', 'placed', { file: 'modules/public-landing/public/projects.html', line: 5, string_id: 'pj0001' }),
292
+ L('L0002', 'hero', 'Every project on this instance, newest first', 'placed', { file: 'modules/public-landing/public/projects.html', line: 6, string_id: 'pj0002' }),
293
+ ];
294
+ const r0 = readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, { hash: 'index-h2', lines: INDEX_LINES });
295
+ r0.pages.push({ id: 'landing:projects', reading_hash: 'projects-h1', lines: PROJECT_LINES });
296
+ writeReadings(r0);
297
+ assert.equal((await call('POST', `${PROJECTS}/claim`, ALICE)).status, 201);
298
+
299
+ const empty = await call('PUT', `${PROJECTS}/draft`, ALICE, { reading_hash: 'projects-h1', lines: [{ key: 'L0002', after: '' }] });
300
+ assert.equal(errCode(empty.body), 'line_empty', 'empty words alone never remove a line');
301
+ assert.match(errDetail(empty.body).what_this_means, /remove: true/, 'the refusal says how to ask for a removal');
302
+
303
+ const saved = await call('PUT', `${PROJECTS}/draft`, ALICE, { reading_hash: 'projects-h1', lines: [{ key: 'L0001', after: 'All projects' }, { key: 'L0002', remove: true }] });
304
+ assert.equal(saved.status, 200, JSON.stringify(saved.body));
305
+ assert.deepEqual(saved.body.counter, { changed: 2, total: 2 });
306
+ const read = await call('GET', `${PROJECTS}/draft`, ALICE);
307
+ assert.deepEqual(read.body.draft.lines, [{ key: 'L0001', after: 'All projects' }, { key: 'L0002', after: '', remove: true }]);
308
+
309
+ const r = await call('POST', `${PROJECTS}/submit`, ALICE);
310
+ assert.equal(r.status, 200, JSON.stringify(r.body));
311
+ assert.equal(r.body.batch.line_count, 1, 'only the rewrite is the applier\'s');
312
+ const round = db.tasks.find((t) => t.source_ref === 'page-tweak/landing:projects/r1');
313
+ assert.deepEqual(pages.parseBatchBlock(round.description).batch.lines.map((l) => l.key), ['L0001']);
314
+ const eng = db.tasks.find((t) => t.source_ref === `page-tweak-unplaced/${round.id}/L0002`);
315
+ assert.ok(eng, 'the removal is an engineer task, keyed like any engineer line');
316
+ assert.equal(eng.title, 'Remove "Every project on this instance, newest first" (landing:projects)');
317
+ assert.match(eng.description, /stop showing a line/);
318
+ assert.match(eng.description, /take it out/);
319
+ assert.deepEqual([eng.status, eng.discipline], ['backlog', 'engineer']);
320
+ });
285
321
  } finally {
286
322
  server.close();
287
323
  pageData._setRoot(null);
@@ -0,0 +1,81 @@
1
+ // tests/hall_preview_encoded_paths.mjs — the hall preview decodes the API path
2
+ // before matching fixtures and canned writes (task 1004700).
3
+ //
4
+ // The paintbrush sends its page id through encodeURIComponent, so builders:studio
5
+ // arrives as builders%3Astudio. Matched raw, every copy-desk read and canned write
6
+ // 404'd and the bar said "That page could not be taken just now". This boots the
7
+ // real harness and asks for both spellings.
8
+ //
9
+ // Run: node --test tests/hall_preview_encoded_paths.mjs
10
+
11
+ import { test, before, after } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { spawn } from 'node:child_process';
14
+ import net from 'node:net';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ let child;
20
+ let base;
21
+
22
+ function freePort() {
23
+ return new Promise((resolve, reject) => {
24
+ const s = net.createServer();
25
+ s.once('error', reject);
26
+ s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
27
+ });
28
+ }
29
+
30
+ before(async () => {
31
+ const port = await freePort();
32
+ base = `http://127.0.0.1:${port}/api/bongos/copy-desk/pages`;
33
+ child = spawn(process.execPath, ['scripts/hall-preview/server.js', '--port', String(port), '--fixture-me'], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
34
+ await new Promise((resolve, reject) => {
35
+ const t = setTimeout(() => reject(new Error('hall-preview did not start')), 60000);
36
+ child.stdout.on('data', (b) => { if (String(b).includes('hall-preview:')) { clearTimeout(t); resolve(); } });
37
+ child.once('exit', (code) => { clearTimeout(t); reject(new Error(`hall-preview exited ${code}`)); });
38
+ });
39
+ });
40
+
41
+ after(() => { if (child) child.kill(); });
42
+
43
+ async function call(method, id, tail) {
44
+ const r = await fetch(`${base}/${id}/${tail}`, { method, headers: { 'content-type': 'application/json' }, body: method === 'GET' ? undefined : '{}' });
45
+ return { status: r.status, body: await r.json() };
46
+ }
47
+
48
+ test('GET draft: an encoded page id gets the same fixture as the plain one', async () => {
49
+ const plain = await call('GET', 'builders:studio', 'draft');
50
+ const encoded = await call('GET', encodeURIComponent('builders:studio'), 'draft');
51
+ assert.equal(plain.status, 200);
52
+ assert.equal(encoded.status, 200);
53
+ assert.deepEqual(encoded.body, plain.body);
54
+ // and the catch-all, for a page with no fixture of its own
55
+ const other = await call('GET', encodeURIComponent('builders:work'), 'draft');
56
+ assert.equal(other.status, 200);
57
+ assert.deepEqual(other.body, (await call('GET', 'builders:work', 'draft')).body);
58
+ });
59
+
60
+ test('canned PUT draft: an encoded page id gets the same canned reply as the plain one', async () => {
61
+ const plain = await call('PUT', 'builders:studio', 'draft');
62
+ const encoded = await call('PUT', encodeURIComponent('builders:studio'), 'draft');
63
+ assert.equal(plain.status, 200);
64
+ assert.equal(encoded.status, 200);
65
+ assert.deepEqual(encoded.body, plain.body);
66
+ });
67
+
68
+ test('a decoded backslash or .. segment cannot walk out of the fixtures dir', async () => {
69
+ for (const id of ['..%5c..%5c..%5cpackage', '..%2F..%2Fpackage', 'a%00b']) {
70
+ const r = await call('GET', id, 'draft');
71
+ assert.equal(r.status, 400, id);
72
+ assert.equal(r.body.error.code, 'bad_path', id);
73
+ }
74
+ // the PUT path is checked before CANNED_WRITES too
75
+ assert.equal((await call('PUT', '..%5cx', 'draft')).status, 400);
76
+ });
77
+
78
+ test('a malformed escape does not crash the harness', async () => {
79
+ const r = await call('GET', 'builders%E0%A4%A', 'draft');
80
+ assert.equal(r.status, 404);
81
+ });
@@ -188,21 +188,37 @@ try {
188
188
  assert.deepEqual(lib.noteFor(byKey.L0002, 'Projects', ed.notes), { kind: 'engineer', text: 'goes to an engineer' }, 'shared with no registry row goes to an engineer, the submit\'s rule');
189
189
  assert.deepEqual(lib.noteFor(byKey.L0004, 'Agents write the code.', ed.notes), { kind: 'was', text: 'was: AI agents claim tasks and ship the code.' });
190
190
  assert.equal(lib.noteFor(byKey.L0003, 'Building Digitally,', ed.notes), null);
191
+ // An emptied line says it will be removed, ahead of any other note (task 1004696).
192
+ assert.deepEqual(lib.noteFor(byKey.L0004, ' ', ed.notes), { kind: 'removed', text: 'to be removed · goes to an engineer · was: AI agents claim tasks and ship the code.' });
193
+ assert.equal(lib.noteFor(byKey.L0001, '', ed.notes).kind, 'removed');
191
194
  });
192
195
 
193
- await test('the autosave body is the WHOLE draft: every changed, non-empty line, and nothing else', () => {
196
+ await test('the autosave body is the WHOLE draft: every changed line, an emptied one as a removal, and nothing else', () => {
194
197
  const values = lib.initialValues(READING.lines, { lines: [{ key: 'L0004', after: 'Agents write the code.' }] });
195
- values.L0003 = ''; // emptied: removing words is a code change, never saved
198
+ values.L0003 = ' '; // emptied: the artist recommends removing it (task 1004696)
196
199
  values.L0006 = 'Built by agents.'; // back to the page's words: not a rewrite
197
200
  values.L0007 = 'Log in';
198
201
  assert.deepEqual(lib.draftBody('rh-1', READING.lines, values), {
199
202
  reading_hash: 'rh-1',
200
- lines: [{ key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }],
203
+ lines: [{ key: 'L0003', remove: true }, { key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }],
201
204
  });
205
+ assert.equal(lib.changedCount(READING.lines, values), 3, 'a removal counts as a change');
202
206
  assert.equal(lib.counterText(2, 7, 'saving'), '2 of 7 rewritten · saving…');
203
207
  assert.equal(lib.counterText(2, 7, 'saved'), '2 of 7 rewritten · saved');
204
208
  });
205
209
 
210
+ await test('a removal the server holds reads back as an empty value, so the next save from ANY editor keeps it (task 1004696)', () => {
211
+ const draft = { lines: [{ key: 'L0003', after: '', remove: true }, { key: 'L0004', after: 'Agents write the code.' }] };
212
+ const values = lib.initialValues(READING.lines, draft);
213
+ assert.equal(values.L0003, '');
214
+ assert.equal(lib.isRemoved(READING.lines.find((l) => l.key === 'L0003'), values.L0003), true);
215
+ values.L0007 = 'Log in'; // the tweak editor or the studio saves something else
216
+ assert.deepEqual(lib.draftBody('rh-1', READING.lines, values).lines,
217
+ [{ key: 'L0003', remove: true }, { key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }]);
218
+ values.L0003 = 'Building, digitally'; // typed over: the rewrite replaces the removal
219
+ assert.deepEqual(lib.draftBody('rh-1', READING.lines, values).lines[0], { key: 'L0003', after: 'Building, digitally' });
220
+ });
221
+
206
222
  await test('names, addresses and the page id', () => {
207
223
  assert.equal(lib.displayTitle(PAGE), 'Home', 'a templated title on "/" is the page called Home');
208
224
  assert.equal(lib.displayTitle(NEXT.recommended), 'Projects');
@@ -9,6 +9,8 @@
9
9
  // else's view changes — the page's words come back when editing ends, and the
10
10
  // only writes are the copy desk's own page-round routes (owner, 2026-10-04: the
11
11
  // edits are real wording changes, kept apart from the palette's feedback docs).
12
+ // Task 1004696 adds removing a line (saved as { key, remove: true }, sent to an
13
+ // engineer) and the artist review one press away, while editing and once sent.
12
14
  import assert from 'node:assert/strict';
13
15
  import { test } from 'node:test';
14
16
  import fs from 'node:fs';
@@ -176,7 +178,16 @@ function boot({ round = null, draft = null, claim = { status: 200, body: { ok: t
176
178
  };
177
179
  const type = (el, text) => { el.textContent = text; fire('input', { target: el }); };
178
180
  const editable = () => p.body.querySelectorAll('*').filter((n) => n.hasAttribute('contenteditable'));
179
- return { ...p, leave: () => (winListeners.pagehide || []).forEach((fn) => fn()), brush: window.OTBPaintbrush, calls, bar, press, type, fire, editable, html: document.documentElement, writes: () => calls.filter((c) => c.method !== 'GET') };
181
+ // The review sheet's script, as the page would load it: answer the tag the
182
+ // paintbrush appended with a stand-in OTBPageNotes that records its opening.
183
+ const notesOpened = [];
184
+ const loadNotes = () => {
185
+ const tag = head.children.find((c) => /\/builders\/page-notes\.js\?v=2026-10-04-test$/.test(c.src || ''));
186
+ assert.ok(tag, 'the review sheet\'s own script was asked for, at the paintbrush\'s ?v');
187
+ window.OTBPageNotes = { open: () => notesOpened.push('open') };
188
+ tag.onload();
189
+ };
190
+ return { ...p, leave: () => (winListeners.pagehide || []).forEach((fn) => fn()), brush: window.OTBPaintbrush, calls, bar, press, type, fire, editable, html: document.documentElement, head, loadNotes, notesOpened, writes: () => calls.filter((c) => c.method !== 'GET') };
180
191
  }
181
192
  const settle = () => new Promise((r) => setTimeout(r, 5));
182
193
 
@@ -310,7 +321,10 @@ test('the only routes it writes are the copy desk\'s page round — never page-n
310
321
  const routes = [...BRUSH.matchAll(/request\('(POST|PUT|PATCH|DELETE)', [^,]*?'\/([a-z-]+)'/g)].map((m) => m[1] + ' ' + m[2]);
311
322
  assert.deepEqual(routes.sort(), ['POST claim', 'POST submit', 'PUT draft']);
312
323
  assert.match(BRUSH, /var API = '\/api\/bongos\/copy-desk\/pages\/';/);
313
- assert.doesNotMatch(BRUSH, /page-notes/);
324
+ assert.doesNotMatch(BRUSH, /\/api\/bongos\/page-notes|\/page-notes\//, 'no feedback-doc route');
325
+ // Task 1004696: it may OPEN the artist review sheet (which writes its own
326
+ // doc); that script is the only page-notes thing it names.
327
+ assert.deepEqual([...BRUSH.matchAll(/page-notes[\w.-]*/g)].map((m) => m[0]).filter((w) => w !== 'page-notes.js'), []);
314
328
  });
315
329
 
316
330
  test('its styles live in style.css, on the token tier', () => {
@@ -342,6 +356,141 @@ test('words only: bold, italic and underline keys do nothing in the words; elsew
342
356
  assert.equal(prevented, 3, 'a key outside the edited words is not touched');
343
357
  });
344
358
 
359
+ // ---------- removing a line (task 1004696) ----------
360
+ const held = () => boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
361
+
362
+ test('REMOVE THIS LINE acts on the line the artist is in: saved as a removal, struck through, and KEEP puts it back', async () => {
363
+ const p = held();
364
+ p.brush.open();
365
+ await settle();
366
+ assert.match(p.bar().innerHTML, /Where the build is going<\/span><button[^>]*data-pb-remove>/, 'the first line is offered from the start, even when the window fires no focus');
367
+ p.fire('focusin', { target: p.lede });
368
+ assert.match(p.bar().innerHTML, /Every version, from first idea to shipped\.<\/span><button[^>]*data-pb-remove>Remove this line/);
369
+ p.press('data-pb-remove');
370
+ await settle();
371
+ const put = p.writes().filter((c) => c.method === 'PUT').pop();
372
+ assert.deepEqual(JSON.parse(JSON.stringify(put.body.lines)), [{ key: 'L0003', remove: true }], 'saved as a removal, never as empty words');
373
+ assert.ok(p.lede.classList.contains('pb-word--removed'));
374
+ assert.ok(!p.lede.hasAttribute('contenteditable'), 'a removed line is not typed into');
375
+ assert.equal(p.lede.getAttribute('tabindex'), '0', 'but it can still be reached, to keep it');
376
+ assert.equal(p.lede.textContent, 'Every version, from first idea to shipped.', 'it shows the page\'s words, struck through');
377
+ assert.match(p.bar().innerHTML, /<strong>1 change<\/strong>/);
378
+ assert.match(p.bar().innerHTML, /data-pb-keep>Keep this line/);
379
+ p.press('data-pb-keep');
380
+ await settle();
381
+ assert.ok(!p.lede.classList.contains('pb-word--removed'));
382
+ assert.equal(p.lede.getAttribute('contenteditable'), 'plaintext-only', 'editable again');
383
+ assert.deepEqual(JSON.parse(JSON.stringify(p.writes().filter((c) => c.method === 'PUT').pop().body.lines)), [], 'and the removal leaves the draft');
384
+ });
385
+
386
+ test('emptying a line\'s words and leaving it marks it to be removed, instead of silently dropping it', async () => {
387
+ const p = held();
388
+ p.brush.open();
389
+ await settle();
390
+ p.type(p.h1, ' ');
391
+ assert.equal(p.h1.getAttribute('contenteditable'), 'plaintext-only', 'still editable while the artist is in it: they may be retyping');
392
+ p.fire('focusout', { target: p.h1 });
393
+ await settle();
394
+ assert.ok(p.h1.classList.contains('pb-word--removed'));
395
+ assert.equal(p.h1.textContent, 'Where the build is going');
396
+ const put = p.writes().filter((c) => c.method === 'PUT').pop();
397
+ assert.deepEqual(JSON.parse(JSON.stringify(put.body.lines)), [{ key: 'L0002', remove: true }]);
398
+ });
399
+
400
+ test('REVIEW lists a removal as its own row, says it goes to an engineer, and SEND submits it with the rewrites', async () => {
401
+ const p = held();
402
+ p.brush.open();
403
+ await settle();
404
+ p.type(p.h1, 'Where we are going');
405
+ p.fire('focusin', { target: p.linkWords });
406
+ p.press('data-pb-remove');
407
+ p.press('data-pb-review');
408
+ await settle();
409
+ const html = p.bar().innerHTML;
410
+ assert.match(html, /pb__change--remove[\s\S]*Remove<\/span> See every goal/);
411
+ assert.match(html, /A line you remove goes to an engineer to take out\./);
412
+ p.press('data-pb-send');
413
+ await settle();
414
+ const lastPut = p.writes().filter((c) => c.method === 'PUT').pop();
415
+ assert.deepEqual(JSON.parse(JSON.stringify(lastPut.body.lines)), [{ key: 'L0002', after: 'Where we are going' }, { key: 'L0004', remove: true }]);
416
+ assert.match(p.bar().innerHTML, /Sent for approval: 2 changes/);
417
+ assert.equal(p.linkWords.textContent, 'See every goal', 'the page shows its own words again');
418
+ assert.ok(!p.linkWords.classList.contains('pb-word--removed') && !p.linkWords.hasAttribute('tabindex'), 'and nothing of the mark is left behind');
419
+ });
420
+
421
+ test('a removal saved earlier comes back struck through when editing resumes', async () => {
422
+ const p = boot({
423
+ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } },
424
+ draft: { reading_hash: 'abc123', lines: [{ key: 'L0003', after: '', remove: true }] },
425
+ });
426
+ p.brush.open();
427
+ await settle();
428
+ assert.ok(p.lede.classList.contains('pb-word--removed'));
429
+ assert.equal(p.lede.textContent, 'Every version, from first idea to shipped.');
430
+ assert.match(p.bar().innerHTML, /1 change/);
431
+ });
432
+
433
+ // ---------- the artist review alongside (task 1004696) ----------
434
+ test('ARTIST REVIEW in the editing bar saves the words, opens the review sheet for this page, and editing carries on', async () => {
435
+ const p = held();
436
+ p.brush.open();
437
+ await settle();
438
+ assert.match(p.bar().innerHTML, /data-pb-notes[^>]*>Artist review</);
439
+ p.type(p.h1, 'A heading, mid-edit');
440
+ p.press('data-pb-notes');
441
+ await settle();
442
+ assert.ok(p.writes().some((c) => c.method === 'PUT'), 'the words were saved before the sheet opened');
443
+ p.loadNotes();
444
+ await settle();
445
+ assert.deepEqual(p.notesOpened, ['open']);
446
+ assert.ok(p.bar(), 'the paintbrush stays');
447
+ assert.equal(p.h1.getAttribute('contenteditable'), 'plaintext-only', 'and the words are still being edited');
448
+ assert.ok(!p.writes().some((c) => /page-notes/.test(c.url)), 'the paintbrush itself writes no feedback doc');
449
+ });
450
+
451
+ test('once SENT, the bar asks whether to review how the page looks too: one press opens the sheet, Not now closes', async () => {
452
+ const p = held();
453
+ p.brush.open();
454
+ await settle();
455
+ p.type(p.h1, 'Where we are going');
456
+ p.press('data-pb-review');
457
+ p.press('data-pb-send');
458
+ await settle();
459
+ const html = p.bar().innerHTML;
460
+ assert.match(html, /Review how this page looks too\?/);
461
+ assert.match(html, /each point becomes an idea under your name/);
462
+ assert.match(html, /data-pb-notes data-pb-focus>Start an artist review/);
463
+ p.press('data-pb-notes');
464
+ await settle();
465
+ p.loadNotes();
466
+ await settle();
467
+ assert.deepEqual(p.notesOpened, ['open']);
468
+ assert.equal(p.bar(), null, 'the sent bar\'s work is done, so it closes');
469
+
470
+ const q = held();
471
+ q.brush.open();
472
+ await settle();
473
+ q.type(q.h1, 'Another heading');
474
+ q.press('data-pb-review');
475
+ q.press('data-pb-send');
476
+ await settle();
477
+ q.press('data-pb-close');
478
+ assert.equal(q.bar(), null);
479
+ assert.equal(q.head.children.filter((c) => /page-notes/.test(c.src || '')).length, 0, 'Not now loads nothing');
480
+ });
481
+
482
+ test('a review sheet that cannot load says so in the bar and points to the top bar\'s Review', async () => {
483
+ const p = held();
484
+ p.brush.open();
485
+ await settle();
486
+ p.press('data-pb-notes');
487
+ await settle();
488
+ const tag = p.head.children.find((c) => /page-notes\.js/.test(c.src || ''));
489
+ tag.onerror();
490
+ await settle();
491
+ assert.match(p.bar().innerHTML, /The artist review could not open\. Press Review in the top bar instead\./);
492
+ });
493
+
345
494
  test('a word on the page matches its line however its spaces wrap, and one table serves every element', async () => {
346
495
  const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
347
496
  p.lede.textContent = ' Every version,\n from first idea to shipped. ';
@@ -83,12 +83,70 @@ test('the mechanics-map step names the subagent mechanism and the retro preceden
83
83
  assert.ok(section.includes('task 1003075'));
84
84
  });
85
85
 
86
- test('the step is scoped to process-change sessions, not every planning session', () => {
86
+ // task 1004675 (idea 1001074): the step used to read as an opt-in for
87
+ // process-change sessions only, so the sessions with a confident owner model
88
+ // skipped it. It is now the default; skipping is what needs a stated reason.
89
+ function phase15() {
87
90
  const start = NORM.indexOf('Phase 1.5 — Map the mechanism');
88
91
  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
+ return NORM.slice(start, end === -1 ? undefined : end);
93
+ }
94
+
95
+ test('the mapping step is the default for any existing surface, and skipping it needs a stated reason', () => {
96
+ const section = phase15();
97
+ assert.ok(!/process-change sessions only/i.test(section), 'no longer framed as opt-in for process-change sessions');
98
+ assert.match(section, /by default for any session that touches an existing surface/);
99
+ assert.match(section, /Skipping it is the thing that needs a stated reason/);
100
+ assert.match(section, /confident model/, 'names the confident-owner case as the one most likely to skip');
101
+ });
102
+
103
+ test('the mapping subagent is always asked the computable-quantity question', () => {
104
+ assert.match(phase15(), /Name any quantity this design assumes is computable, and say whether it actually is/);
105
+ });
106
+
107
+ // --- task 1004675: tie criteria to the agreed design --------------------------
108
+
109
+ function criteriaPhase() {
110
+ const start = NORM.indexOf('### Phase 2 — Criteria');
111
+ const end = NORM.indexOf('### Phase 2.5', start);
112
+ return NORM.slice(start, end);
113
+ }
114
+
115
+ test('Phase 2 says the grader reads the description and diff, not the spec', () => {
116
+ const s = criteriaPhase();
117
+ assert.match(s, /grader reads the \*\*task description and the diff\*\*/);
118
+ assert.match(s, /does \*\*not\*\* read `docs\/specs\/`/);
119
+ });
120
+
121
+ test('Phase 2 names the three bindings: model vs rendering, a proof a wrong build fails, cite the design of record', () => {
122
+ const s = criteriaPhase();
123
+ assert.match(s, /Split the settled design into MODEL and RENDERING/);
124
+ assert.match(s, /goes \*\*into the criterion text\*\*/, 'the model goes in the criterion');
125
+ assert.match(s, /stays in the spec, free to change/, 'the rendering stays in the spec');
126
+ assert.match(s, /proof whose scenario a wrong build fails/);
127
+ assert.match(s, /Cite the design of record in every task description, and make sure the builder can open it/);
128
+ assert.match(s, /private artifact link/, 'warns that an unopenable design of record is decorative');
129
+ });
130
+
131
+ test('Phase 3 carries the binding into the proof task and every task description', () => {
132
+ const start = NORM.indexOf('### Phase 3');
133
+ const s = NORM.slice(start, NORM.indexOf('### Phase 4', start));
134
+ assert.match(s, /The proof's scenario must be one a wrong build fails/);
135
+ assert.match(s, /every description cites the design of record/);
136
+ });
137
+
138
+ test('Phase 4 lays naming options side by side before recommending one', () => {
139
+ const start = NORM.indexOf('### Phase 4');
140
+ const s = NORM.slice(start, NORM.indexOf('### Phase 5', start));
141
+ assert.match(s, /show the options side by side before recommending one/);
142
+ assert.match(s, /never one name with "or something else\?"/);
143
+ });
144
+
145
+ test('big design-driven asks are routed to /planning-session before seeding, from both playbooks', () => {
146
+ const when = NORM.slice(NORM.indexOf('## When to invoke'), NORM.indexOf('## When NOT to invoke'));
147
+ assert.match(when, /big design-driven ask arrives — route it here BEFORE any task is seeded/);
148
+ const goal = fs.readFileSync(path.join(ROOT, '.claude/skills/goal-create/SKILL.md'), 'utf8').replace(/\s+/g, ' ');
149
+ assert.match(goal, /big design-driven ask\*\* .* route it to `\/planning-session` before seeding anything/);
92
150
  });
93
151
 
94
152
  // --- task 1001438: the Phase 5 promote must be scoped to the seeded set -----
@@ -161,5 +219,45 @@ test('Phase 5 re-probes the session before the first write, and on a 401 re-auth
161
219
  assert.match(p5, /idempotent/i, 'and says why a re-run is safe');
162
220
  });
163
221
 
222
+ // ---- task 1004668: a new task does not wait at backlog, and ready is not claimable ----
223
+ // POST /tasks answers 'backlog', then the reconciler's sweep promotes any task with
224
+ // no unmet dependency within about a minute. Three playbooks said a new task sits at
225
+ // backlog until promoted; one triage told the owner 15 tasks awaited their go and then
226
+ // hit 409 cannot_promote on every one.
227
+
228
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\s+/g, ' ');
229
+ const PLAYBOOKS = ['planning-session', 'idea-triage', 'goal-create', 'owner-review', 'backlog-review']
230
+ .map((s) => [s, read(`.claude/skills/${s}/SKILL.md`)]);
231
+
232
+ test('no playbook says a newly created task lands at backlog and waits for a promote', () => {
233
+ for (const [name, text] of PLAYBOOKS) {
234
+ assert.ok(!/Every newly-seeded task lands `status='backlog'`/.test(text), `${name}: the seed-lands-at-backlog claim`);
235
+ assert.ok(!/Every new task lands status='backlog'/.test(text), `${name}: the new-task-lands-at-backlog claim`);
236
+ assert.ok(!/lands the new task in `backlog`, \*\*not\*\* `active`/.test(text), `${name}: the triage claim`);
237
+ }
238
+ });
239
+
240
+ test('the Phase 5 promote step reads the statuses first and names the claim gate as the enforcement', () => {
241
+ const step = promoteStep();
242
+ assert.match(step, /Do \*\*not\*\* assume the seed landed at `backlog`/, 'replaces the assumption with a check');
243
+ assert.match(step, /backlog sweep/, 'names what promotes them');
244
+ assert.match(step, /`ready` is not "claimable"/, 'says ready is not claimable');
245
+ assert.match(step, /DEPS_NOT_SHIPPED/, 'names the claim-time dependency gate');
246
+ });
247
+
248
+ test('idea-triage and goal-create say to re-read the status and promote only what is still at backlog', () => {
249
+ const triage = PLAYBOOKS.find(([n]) => n === 'idea-triage')[1];
250
+ assert.match(triage, /auto-promoted to `ready` within about a minute/);
251
+ assert.match(triage, /Re-read its status after creating/);
252
+ assert.match(triage, /409 cannot_promote/);
253
+ const goal = PLAYBOOKS.find(([n]) => n === 'goal-create')[1];
254
+ assert.match(goal, /promote only a non-spike still at 'backlog'/);
255
+ });
256
+
257
+ test('the seeding contract names the pre-seed baseline for a populated goal', () => {
258
+ assert.ok(NORM.includes('baselineSeedCriteria'), 'names the baseline helper');
259
+ assert.match(NORM, /BEFORE the first write/);
260
+ });
261
+
164
262
  console.log(`\nplanning_session_skill: ${passed} passed, ${failed} failed`);
165
263
  if (failed > 0) process.exit(1);