@bongos/core 1.21.106 → 1.21.108

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 (50) hide show
  1. package/.bongos-core.json +72 -52
  2. package/.claude/skills/goal-create/SKILL.md +8 -3
  3. package/.claude/skills/idea-triage/SKILL.md +1 -1
  4. package/.claude/skills/planning-session/SKILL.md +6 -4
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +6 -1
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/api/openapi.json +91 -2
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +25 -19
  13. package/docs/copy-registry.json +92 -37
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +189 -188
  16. package/migrations/core_275_claims_auto_pickup.sql +24 -0
  17. package/modules/copy-desk/docx.js +27 -7
  18. package/modules/copy-desk/page-status.js +3 -1
  19. package/modules/copy-desk/pages.js +44 -5
  20. package/modules/copy-desk/routes/copy-desk.js +4 -3
  21. package/modules/copy-desk/tests/copy_docx.mjs +38 -0
  22. package/modules/copy-desk/tests/copy_page_draft.mjs +79 -0
  23. package/modules/government/decision-rules.js +1 -0
  24. package/modules/hall-ui/public/paintbrush.js +152 -12
  25. package/modules/hall-ui/public/studio.js +3 -1
  26. package/modules/hall-ui/public/style.css +16 -0
  27. package/modules/hall-ui/public/tweak-editor-lib.js +24 -12
  28. package/modules/hall-ui/records/craft-mode.md +5 -0
  29. package/modules/hall-ui/records/tweak-editor.md +1 -1
  30. package/modules/lifecycle/autopickup.js +94 -0
  31. package/modules/lifecycle/db-claims.js +18 -4
  32. package/modules/lifecycle/routes/claims.js +12 -1
  33. package/modules/lifecycle/routes/project-settings.js +59 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +16 -0
  37. package/scripts/gds/government-walk.js +1 -0
  38. package/scripts/gds/run-unit-tests.js +3 -0
  39. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
  40. package/scripts/gds/seed-verify-criteria.js +73 -18
  41. package/src/bongos/project-settings.js +79 -0
  42. package/src/module-api.js +8 -1
  43. package/tests/autopickup_cap.mjs +236 -0
  44. package/tests/autopickup_cap_db.mjs +115 -0
  45. package/tests/copy_desk_page_submit.mjs +36 -0
  46. package/tests/government_act_alone_buildings.mjs +1 -0
  47. package/tests/hall_tweak_editor.mjs +19 -3
  48. package/tests/paintbrush.mjs +151 -2
  49. package/tests/planning_session_skill.mjs +40 -0
  50. package/tests/seed_verify_criteria.mjs +130 -0
@@ -0,0 +1,236 @@
1
+ // tests/autopickup_cap.mjs — tweak auto-pickup's setting, monthly cap and claim mark
2
+ // (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
3
+ //
4
+ // Pinned here (DB-free; the race lives in tests/autopickup_cap_db.mjs):
5
+ // 1. the two parsers and the resolver: off by default, cap 20 by default, an
6
+ // unusable stored value reported and never thrown, a dead DB answers defaults;
7
+ // 2. the refusal ladder: not a tweak, switch off, round limit, monthly cap, and the
8
+ // lock is taken BEFORE either count;
9
+ // 3. claimTask: a marked claim runs the gate before the insert and names the
10
+ // column; an unmarked claim's insert is unchanged;
11
+ // 4. the routes: any builder reads (with this month's count), only
12
+ // project.settings.manage writes, and a half-valid body writes nothing.
13
+ //
14
+ // Run: node --test tests/autopickup_cap.mjs
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const poolMod = require(path.join(ROOT, 'src', 'bongos', 'pool.js'));
25
+ const {
26
+ DEFAULT_AUTOPICKUP, DEFAULT_AUTOPICKUP_CAP, parseAutopickup, parseAutopickupCap,
27
+ resolveTweakAutopickup, describeTweakAutopickup, SETTING_KEYS, AUTOPICKUP_MODES, MAX_AUTOPICKUP_CAP,
28
+ } = require('../src/bongos/project-settings.js');
29
+ const { autoPickupRefusal, LOCK_KEY, MAX_AUTO_APPLIES_PER_ROUND } = require('../modules/lifecycle/autopickup.js');
30
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
31
+
32
+ async function withPool(query, fn) {
33
+ const pool = poolMod.pool;
34
+ const saved = pool.query;
35
+ pool.query = query;
36
+ try { return await fn(); } finally { pool.query = saved; }
37
+ }
38
+ const settingsRows = (map) => async (_sql, [key]) => ({ rows: key in map ? [{ key, value: map[key] }] : [] });
39
+
40
+ // ---- 1. the setting ---------------------------------------------------------
41
+
42
+ test('the parsers accept exactly off|on and a whole positive cap', () => {
43
+ assert.deepEqual([...AUTOPICKUP_MODES], ['off', 'on']);
44
+ assert.equal(api.projectSettings.MAX_AUTOPICKUP_CAP, MAX_AUTOPICKUP_CAP, 'the doorway hands out the same bound');
45
+ assert.deepEqual(parseAutopickup(' On '), { mode: 'on' });
46
+ assert.match(parseAutopickup('maybe').reason, /off \| on/);
47
+ assert.deepEqual(parseAutopickupCap('5'), { cap: 5 });
48
+ assert.match(parseAutopickupCap('0').reason, /positive/);
49
+ assert.match(parseAutopickupCap('2.5').reason, /whole/);
50
+ assert.match(parseAutopickupCap('100000').reason, /out of range/);
51
+ });
52
+
53
+ test('an untouched project is off with a cap of 20', async () => {
54
+ assert.equal(DEFAULT_AUTOPICKUP, 'off');
55
+ assert.equal(DEFAULT_AUTOPICKUP_CAP, 20);
56
+ const r = await withPool(settingsRows({}), () => resolveTweakAutopickup());
57
+ assert.deepEqual(r, { mode: 'off', cap: 20, source: { mode: 'default', cap: 'default' }, invalid: [] });
58
+ assert.match(describeTweakAutopickup(r), /waits until a person/);
59
+ });
60
+
61
+ test('stored values are read; an unusable one is reported and falls back', async () => {
62
+ const r = await withPool(settingsRows({ [SETTING_KEYS.TWEAK_AUTOPICKUP]: 'on', [SETTING_KEYS.TWEAK_AUTOPICKUP_CAP]: 'lots' }),
63
+ () => resolveTweakAutopickup());
64
+ assert.equal(r.mode, 'on');
65
+ assert.equal(r.source.mode, 'project_settings');
66
+ assert.equal(r.cap, 20);
67
+ assert.equal(r.invalid.length, 1);
68
+ assert.equal(r.invalid[0].raw, 'lots');
69
+ assert.match(describeTweakAutopickup({ mode: 'on', cap: 7 }), /up to 7 a month/);
70
+ });
71
+
72
+ test('a database that cannot be read answers the defaults', async () => {
73
+ const r = await withPool(async () => { throw new Error('db down'); }, () => resolveTweakAutopickup());
74
+ assert.equal(r.mode, 'off');
75
+ assert.equal(r.cap, 20);
76
+ });
77
+
78
+ // ---- 2. the refusal ladder --------------------------------------------------
79
+
80
+ function fakeClient({ round = 0, month = 0 } = {}) {
81
+ const calls = [];
82
+ return {
83
+ calls,
84
+ async query(sql, params) {
85
+ calls.push(sql.includes('pg_advisory_xact_lock') ? `lock:${params[0]}` : sql.includes('date_trunc') ? 'month' : 'round');
86
+ if (sql.includes('pg_advisory_xact_lock')) return { rows: [] };
87
+ return { rows: [{ n: sql.includes('date_trunc') ? month : round }] };
88
+ },
89
+ };
90
+ }
91
+ const tweak = { id: 9, source: 'page-tweak' };
92
+ const on = { mode: 'on', cap: 3 };
93
+
94
+ test('a marked claim on anything but a page tweak is refused, before any lock', async () => {
95
+ const c = fakeClient();
96
+ assert.equal((await autoPickupRefusal(c, { id: 9, source: 'manual' }, on)).code, 'AUTO_PICKUP_NOT_A_TWEAK');
97
+ assert.deepEqual(c.calls, []);
98
+ });
99
+
100
+ test('the switch off refuses a marked claim', async () => {
101
+ assert.equal((await autoPickupRefusal(fakeClient(), tweak, { mode: 'off', cap: 3 })).code, 'AUTO_PICKUP_OFF');
102
+ });
103
+
104
+ test('the lock is taken before either count, on the one project-wide key', async () => {
105
+ const c = fakeClient();
106
+ assert.equal(await autoPickupRefusal(c, tweak, on), null);
107
+ assert.equal(LOCK_KEY, 'page-tweak/autopickup');
108
+ assert.deepEqual(c.calls, ['lock:page-tweak/autopickup', 'round', 'month']);
109
+ });
110
+
111
+ test('a third automatic apply of one round is refused', async () => {
112
+ assert.equal(MAX_AUTO_APPLIES_PER_ROUND, 2);
113
+ assert.equal(await autoPickupRefusal(fakeClient({ round: 1 }), tweak, on), null);
114
+ const r = await autoPickupRefusal(fakeClient({ round: 2 }), tweak, on);
115
+ assert.equal(r.code, 'AUTO_PICKUP_ROUND_LIMIT');
116
+ assert.match(r.message, /waits for a person/);
117
+ });
118
+
119
+ test('the monthly cap refuses at the cap and admits below it', async () => {
120
+ assert.equal(await autoPickupRefusal(fakeClient({ month: 2 }), tweak, on), null);
121
+ const r = await autoPickupRefusal(fakeClient({ month: 3 }), tweak, on);
122
+ assert.equal(r.code, 'AUTO_PICKUP_CAP_REACHED');
123
+ assert.equal(r.cap, 3);
124
+ });
125
+
126
+ // ---- 3. claimTask wiring ----------------------------------------------------
127
+
128
+ test('claimTask runs the gate before the insert, and names the column only on a marked claim', () => {
129
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
130
+ const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
131
+ const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings)');
132
+ const insert = body.indexOf('INSERT INTO claims');
133
+ assert.ok(gate > 0 && insert > gate, 'the gate runs inside the transaction, before the insert');
134
+ assert.match(body.slice(gate, insert), /if \(refusal\) throw refusal;/, 'a refusal stops the claim');
135
+ assert.match(body, /\$\{autoPickup \? ', auto_pickup' : ''\}/);
136
+ assert.match(body, /goal_id, source FROM tasks WHERE id = \$1 FOR UPDATE/, 'the task row carries its source');
137
+ });
138
+
139
+ test('POST /claims accepts auto_pickup and maps every refusal to a 409', () => {
140
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'claims.js'), 'utf8');
141
+ assert.match(src, /auto_pickup: \{ type: 'boolean' \}/);
142
+ assert.match(src, /autoPickup: autoPickup === true/);
143
+ for (const code of ['AUTO_PICKUP_NOT_A_TWEAK', 'AUTO_PICKUP_OFF', 'AUTO_PICKUP_CAP_REACHED', 'AUTO_PICKUP_ROUND_LIMIT']) {
144
+ assert.match(src, new RegExp(`err.code === '${code}' \\? 409`));
145
+ }
146
+ });
147
+
148
+ // ---- 4. the routes ----------------------------------------------------------
149
+
150
+ const fakeRes = () => ({
151
+ code: null, statusCode: 200, payload: null, headers: {},
152
+ fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code, ...(opts || {}) }; return this; },
153
+ status(c) { this.statusCode = c; return this; },
154
+ set(k, v) { this.headers[k] = v; return this; },
155
+ json(p) { this.payload = p; return this; },
156
+ });
157
+
158
+ function buildRouter({ used = 4 } = {}) {
159
+ const ps = api.projectSettings;
160
+ const saved = { resolve: ps.resolveTweakAutopickup, set: ps.set };
161
+ const writes = [];
162
+ const state = { mode: 'off', cap: 20 };
163
+ ps.resolveTweakAutopickup = async () => ({ ...state, source: { mode: 'default', cap: 'default' }, invalid: [] });
164
+ ps.set = async (key, value, by) => {
165
+ writes.push({ key, value, by });
166
+ if (key === 'tweak.autopickup') state.mode = value; else state.cap = value;
167
+ };
168
+ const autopickup = require('../modules/lifecycle/autopickup.js');
169
+ const savedCount = autopickup.countThisMonth;
170
+ autopickup.countThisMonth = async () => used;
171
+ const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
172
+ delete require.cache[require.resolve(file)];
173
+ const router = require(file)();
174
+ const route = (method, p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]).route;
175
+ const restore = () => { ps.resolveTweakAutopickup = saved.resolve; ps.set = saved.set; autopickup.countThisMonth = savedCount; };
176
+ return { route, writes, restore };
177
+ }
178
+ const handler = (r) => r.stack[r.stack.length - 1].handle;
179
+
180
+ test('the read is open to any builder and reports this month\'s count against the cap', async () => {
181
+ const { route, restore } = buildRouter({ used: 4 });
182
+ try {
183
+ const get = route('get', '/project-settings/tweak-autopickup');
184
+ assert.equal(get.stack.length, 2, 'requireBuilder + the handler');
185
+ const res = fakeRes();
186
+ await handler(get)({}, res);
187
+ assert.equal(res.payload.mode, 'off');
188
+ assert.equal(res.payload.used_this_month, 4);
189
+ assert.equal(res.payload.remaining_this_month, 16);
190
+ assert.equal(res.payload.per_round_limit, 2);
191
+ assert.equal(res.headers['Cache-Control'], 'no-store');
192
+ } finally { restore(); }
193
+ });
194
+
195
+ test('the write is gated on project.settings.manage and the Rules decision rule', () => {
196
+ const { route, restore } = buildRouter();
197
+ try {
198
+ const write = route('patch', '/project-settings/tweak-autopickup').stack;
199
+ assert.equal(write.length, 4);
200
+ assert.equal(write[2].handle.name, 'actAloneGate');
201
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'), 'utf8');
202
+ assert.match(src, /router\.patch\('\/project-settings\/tweak-autopickup', api\.requireBuilder, api\.requirePermission\('project\.settings\.manage'\)/);
203
+ } finally { restore(); }
204
+ });
205
+
206
+ test('a half-valid body writes nothing; a good one writes both and echoes the new read', async () => {
207
+ const { route, writes, restore } = buildRouter({ used: 0 });
208
+ try {
209
+ const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
210
+ const bad = fakeRes();
211
+ await patch({ body: { mode: 'on', cap: 0 }, builder: { id: 7 } }, bad);
212
+ assert.equal(bad.code, 'bad_tweak_autopickup');
213
+ assert.equal(writes.length, 0);
214
+
215
+ const empty = fakeRes();
216
+ await patch({ body: {}, builder: { id: 7 } }, empty);
217
+ assert.equal(empty.code, 'bad_tweak_autopickup');
218
+
219
+ const good = fakeRes();
220
+ await patch({ body: { mode: 'ON', cap: 5 }, builder: { id: 7 } }, good);
221
+ assert.equal(good.code, null);
222
+ assert.deepEqual(writes, [
223
+ { key: 'tweak.autopickup', value: 'on', by: 7 },
224
+ { key: 'tweak.autopickup_monthly_cap', value: 5, by: 7 },
225
+ ]);
226
+ assert.equal(good.payload.mode, 'on');
227
+ assert.equal(good.payload.cap, 5);
228
+ } finally { restore(); }
229
+ });
230
+
231
+ test('core_275 is additive: one nullable column, recorded in schema_migrations', () => {
232
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8');
233
+ assert.match(sql, /ALTER TABLE claims ADD COLUMN IF NOT EXISTS auto_pickup boolean;/);
234
+ assert.doesNotMatch(sql, /NOT NULL|DROP /);
235
+ assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_275_claims_auto_pickup'\)/);
236
+ });
@@ -0,0 +1,115 @@
1
+ // tests/autopickup_cap_db.mjs — the auto-pickup cap holds under a race, against a
2
+ // REAL Postgres (task 1004677 / BV2.TW19a, ADR 0371 D5).
3
+ //
4
+ // tests/autopickup_cap.mjs proves the refusal ladder DB-free. What it cannot prove
5
+ // is the claim this file exists for: two runners racing for the LAST slot cannot
6
+ // both get it. That is a property of pg_advisory_xact_lock plus the count running
7
+ // inside the claim's transaction, so it needs a real database and two real
8
+ // connections.
9
+ //
10
+ // It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
11
+ // run-unit-tests.js's INTEGRATION set so the DB-free lane never runs it. It builds
12
+ // throwaway `tasks` and `claims` tables, so point it at a scratch database.
13
+ //
14
+ // Run: DATABASE_URL=postgres://... node tests/autopickup_cap_db.mjs
15
+
16
+ import pg from 'pg';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import url from 'node:url';
20
+ import { createRequire } from 'node:module';
21
+ import { strict as assert } from 'node:assert';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
25
+
26
+ try {
27
+ await pool.query('SELECT 1');
28
+ } catch (e) {
29
+ console.log(`autopickup_cap_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
30
+ await pool.end().catch(() => {});
31
+ process.exit(0);
32
+ }
33
+ const { rows: realRows } = await pool.query(`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
34
+ if (realRows[0].has_builders) {
35
+ console.log('autopickup_cap_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB.');
36
+ await pool.end().catch(() => {});
37
+ process.exit(0);
38
+ }
39
+
40
+ let passed = 0, failed = 0;
41
+ async function t(name, fn) {
42
+ try { await fn(); passed++; console.log(` ok ${name}`); }
43
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
44
+ }
45
+
46
+ await pool.query(`
47
+ CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
48
+ CREATE TABLE IF NOT EXISTS tasks (id bigint PRIMARY KEY, source text);
49
+ CREATE TABLE IF NOT EXISTS claims (
50
+ id bigserial PRIMARY KEY, task_id bigint, builder_id bigint,
51
+ claimed_at timestamptz DEFAULT now(), released_at timestamptz);
52
+ `);
53
+ const HERE = path.dirname(url.fileURLToPath(import.meta.url));
54
+ await pool.query(fs.readFileSync(path.join(HERE, '..', 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8'));
55
+
56
+ const { autoPickupRefusal, countThisMonth } = require('../modules/lifecycle/autopickup.js');
57
+
58
+ async function reset() {
59
+ await pool.query('TRUNCATE claims, tasks');
60
+ await pool.query(`INSERT INTO tasks (id, source) VALUES (1,'page-tweak'),(2,'page-tweak'),(3,'page-tweak'),(4,'manual')`);
61
+ }
62
+
63
+ // One marked claim the way claimTask makes it: BEGIN, the gate (which takes the
64
+ // lock and counts), the insert, COMMIT. `pause` holds the transaction open after
65
+ // the count so the other racer's count is forced to happen while it is in flight.
66
+ async function markedClaim(taskId, settings, pause = 0) {
67
+ const client = await pool.connect();
68
+ try {
69
+ await client.query('BEGIN');
70
+ const refusal = await autoPickupRefusal(client, { id: taskId, source: 'page-tweak' }, settings);
71
+ if (refusal) { await client.query('ROLLBACK'); return refusal.code; }
72
+ if (pause) await new Promise((r) => setTimeout(r, pause));
73
+ await client.query('INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES ($1, 1, TRUE)', [taskId]);
74
+ await client.query('COMMIT');
75
+ return 'claimed';
76
+ } catch (e) {
77
+ await client.query('ROLLBACK').catch(() => {});
78
+ throw e;
79
+ } finally { client.release(); }
80
+ }
81
+
82
+ await t('core_275 adds a nullable auto_pickup column', async () => {
83
+ const { rows } = await pool.query(`SELECT is_nullable, data_type FROM information_schema.columns WHERE table_name='claims' AND column_name='auto_pickup'`);
84
+ assert.equal(rows[0].data_type, 'boolean');
85
+ assert.equal(rows[0].is_nullable, 'YES');
86
+ });
87
+
88
+ await t('two runners racing for the last slot: exactly one wins', async () => {
89
+ await reset();
90
+ await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES (3, 1, TRUE)`); // cap 2, one used
91
+ const settings = { mode: 'on', cap: 2 };
92
+ const results = await Promise.all([markedClaim(1, settings, 300), markedClaim(2, settings, 300)]);
93
+ assert.deepEqual(results.sort(), ['AUTO_PICKUP_CAP_REACHED', 'claimed'], JSON.stringify(results));
94
+ assert.equal(await countThisMonth(pool), 2);
95
+ });
96
+
97
+ await t('a round applied twice automatically refuses a third marked claim', async () => {
98
+ await reset();
99
+ const settings = { mode: 'on', cap: 20 };
100
+ assert.equal(await markedClaim(1, settings), 'claimed');
101
+ assert.equal(await markedClaim(1, settings), 'claimed');
102
+ assert.equal(await markedClaim(1, settings), 'AUTO_PICKUP_ROUND_LIMIT');
103
+ });
104
+
105
+ await t('unmarked claims and last month\'s marked claims do not count', async () => {
106
+ await reset();
107
+ await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup, claimed_at) VALUES
108
+ (1, 1, NULL, now()), (2, 1, FALSE, now()), (3, 1, TRUE, date_trunc('month', now()) - interval '1 day'),
109
+ (4, 1, TRUE, now())`); // task 4 is not a page tweak
110
+ assert.equal(await countThisMonth(pool), 0);
111
+ });
112
+
113
+ await pool.end();
114
+ console.log(`\n${passed} passed, ${failed} failed`);
115
+ process.exit(failed ? 1 : 0); // the doorway's own pool would otherwise hold the process open
@@ -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);
@@ -242,6 +242,7 @@ const BAD = 'x';
242
242
  const REQUESTS = {
243
243
  'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
244
244
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
245
+ 'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
245
246
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
246
247
  'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
247
248
  'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
@@ -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. ';