@bongos/core 1.21.107 → 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.
- package/.bongos-core.json +35 -35
- package/.claude/skills/goal-create/SKILL.md +8 -3
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +6 -4
- package/docs/copy-inventory.md +25 -19
- package/docs/copy-registry.json +92 -37
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +189 -188
- package/modules/copy-desk/docx.js +27 -7
- package/modules/copy-desk/page-status.js +3 -1
- package/modules/copy-desk/pages.js +44 -5
- package/modules/copy-desk/routes/copy-desk.js +4 -3
- package/modules/copy-desk/tests/copy_docx.mjs +38 -0
- package/modules/copy-desk/tests/copy_page_draft.mjs +79 -0
- package/modules/hall-ui/public/paintbrush.js +152 -12
- package/modules/hall-ui/public/studio.js +3 -1
- package/modules/hall-ui/public/style.css +16 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +24 -12
- package/modules/hall-ui/records/craft-mode.md +5 -0
- package/modules/hall-ui/records/tweak-editor.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
- package/scripts/gds/seed-verify-criteria.js +73 -18
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_submit.mjs +36 -0
- package/tests/hall_tweak_editor.mjs +19 -3
- package/tests/paintbrush.mjs +151 -2
- package/tests/planning_session_skill.mjs +40 -0
- 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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
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 = (
|
|
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
|
|
50
|
-
// where tasks were expected is the shape
|
|
51
|
-
// RETRO2 incident, but any drift is worth stopping
|
|
52
|
-
|
|
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
|
|
55
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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 };
|
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.
|
|
78
|
+
const CORE_VERSION = '1.21.108'; // 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);
|
|
@@ -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,
|
|
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 = '';
|
|
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');
|
package/tests/paintbrush.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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. ';
|
|
@@ -161,5 +161,45 @@ test('Phase 5 re-probes the session before the first write, and on a 401 re-auth
|
|
|
161
161
|
assert.match(p5, /idempotent/i, 'and says why a re-run is safe');
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
// ---- task 1004668: a new task does not wait at backlog, and ready is not claimable ----
|
|
165
|
+
// POST /tasks answers 'backlog', then the reconciler's sweep promotes any task with
|
|
166
|
+
// no unmet dependency within about a minute. Three playbooks said a new task sits at
|
|
167
|
+
// backlog until promoted; one triage told the owner 15 tasks awaited their go and then
|
|
168
|
+
// hit 409 cannot_promote on every one.
|
|
169
|
+
|
|
170
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\s+/g, ' ');
|
|
171
|
+
const PLAYBOOKS = ['planning-session', 'idea-triage', 'goal-create', 'owner-review', 'backlog-review']
|
|
172
|
+
.map((s) => [s, read(`.claude/skills/${s}/SKILL.md`)]);
|
|
173
|
+
|
|
174
|
+
test('no playbook says a newly created task lands at backlog and waits for a promote', () => {
|
|
175
|
+
for (const [name, text] of PLAYBOOKS) {
|
|
176
|
+
assert.ok(!/Every newly-seeded task lands `status='backlog'`/.test(text), `${name}: the seed-lands-at-backlog claim`);
|
|
177
|
+
assert.ok(!/Every new task lands status='backlog'/.test(text), `${name}: the new-task-lands-at-backlog claim`);
|
|
178
|
+
assert.ok(!/lands the new task in `backlog`, \*\*not\*\* `active`/.test(text), `${name}: the triage claim`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('the Phase 5 promote step reads the statuses first and names the claim gate as the enforcement', () => {
|
|
183
|
+
const step = promoteStep();
|
|
184
|
+
assert.match(step, /Do \*\*not\*\* assume the seed landed at `backlog`/, 'replaces the assumption with a check');
|
|
185
|
+
assert.match(step, /backlog sweep/, 'names what promotes them');
|
|
186
|
+
assert.match(step, /`ready` is not "claimable"/, 'says ready is not claimable');
|
|
187
|
+
assert.match(step, /DEPS_NOT_SHIPPED/, 'names the claim-time dependency gate');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('idea-triage and goal-create say to re-read the status and promote only what is still at backlog', () => {
|
|
191
|
+
const triage = PLAYBOOKS.find(([n]) => n === 'idea-triage')[1];
|
|
192
|
+
assert.match(triage, /auto-promoted to `ready` within about a minute/);
|
|
193
|
+
assert.match(triage, /Re-read its status after creating/);
|
|
194
|
+
assert.match(triage, /409 cannot_promote/);
|
|
195
|
+
const goal = PLAYBOOKS.find(([n]) => n === 'goal-create')[1];
|
|
196
|
+
assert.match(goal, /promote only a non-spike still at 'backlog'/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('the seeding contract names the pre-seed baseline for a populated goal', () => {
|
|
200
|
+
assert.ok(NORM.includes('baselineSeedCriteria'), 'names the baseline helper');
|
|
201
|
+
assert.match(NORM, /BEFORE the first write/);
|
|
202
|
+
});
|
|
203
|
+
|
|
164
204
|
console.log(`\nplanning_session_skill: ${passed} passed, ${failed} failed`);
|
|
165
205
|
if (failed > 0) process.exit(1);
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import { strict as assert } from 'node:assert';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
18
19
|
|
|
19
20
|
const require = createRequire(import.meta.url);
|
|
20
21
|
const {
|
|
21
22
|
diffCriterionLinks,
|
|
22
23
|
expectedCriterionCounts,
|
|
24
|
+
baselineSeedCriteria,
|
|
23
25
|
verifySeedCriteriaLinks,
|
|
24
26
|
} = require('../scripts/gds/seed-verify-criteria.js');
|
|
25
27
|
|
|
@@ -145,5 +147,133 @@ await testAsync('a failed/malformed GET is treated as a verification failure, no
|
|
|
145
147
|
assert.ok(logs.some((l) => l.includes('VERIFY FAILED')));
|
|
146
148
|
});
|
|
147
149
|
|
|
150
|
+
// --- task 1004668: seeding into a goal that already carries linked work -----
|
|
151
|
+
//
|
|
152
|
+
// Goal 1000095 held six criteria with live links (1, 9, 4, 3, 6, 10) before a
|
|
153
|
+
// seed touched it. The old diff unioned those into the check with an expected
|
|
154
|
+
// count of zero, so a CORRECT seed exited 1 six times over.
|
|
155
|
+
|
|
156
|
+
const POPULATED = [
|
|
157
|
+
{ criterion_id: 'old-1', linked_tasks: 1 }, { criterion_id: 'old-2', linked_tasks: 9 },
|
|
158
|
+
{ criterion_id: 'old-3', linked_tasks: 4 }, { criterion_id: 'old-4', linked_tasks: 3 },
|
|
159
|
+
{ criterion_id: 'old-5', linked_tasks: 6 }, { criterion_id: 'old-6', linked_tasks: 10 },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
test('THE INCIDENT: without a baseline, criteria the seed does not cite are not assumed to be zero', () => {
|
|
163
|
+
const rows = [...POPULATED, { criterion_id: 'ours', linked_tasks: 2 }];
|
|
164
|
+
assert.deepEqual(diffCriterionLinks({ ours: 2 }, rows), []);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('without a baseline the plan\'s own criteria are still checked', () => {
|
|
168
|
+
const rows = [...POPULATED, { criterion_id: 'ours', linked_tasks: 0 }];
|
|
169
|
+
assert.deepEqual(diffCriterionLinks({ ours: 2 }, rows), [{ criterion_id: 'ours', expected: 2, actual: 0 }]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('with a baseline, an untouched criterion at its pre-seed count passes', () => {
|
|
173
|
+
const baseline = { 'old-1': 1, 'old-2': 9, 'old-3': 4, 'old-4': 3, 'old-5': 6, 'old-6': 10, ours: 0 };
|
|
174
|
+
const rows = [...POPULATED, { criterion_id: 'ours', linked_tasks: 2 }];
|
|
175
|
+
assert.deepEqual(diffCriterionLinks({ ours: 2 }, rows, { baseline }), []);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('with a baseline, a stray link onto a criterion the seed never cites is caught', () => {
|
|
179
|
+
// The RETRO2 mislink lands tasks on the WRONG criterion. With a baseline that
|
|
180
|
+
// excess is visible even though the wrong criterion is not in the plan.
|
|
181
|
+
const baseline = { 'old-1': 1, ours: 0 };
|
|
182
|
+
const rows = [{ criterion_id: 'old-1', linked_tasks: 3 }, { criterion_id: 'ours', linked_tasks: 0 }];
|
|
183
|
+
assert.deepEqual(diffCriterionLinks({ ours: 2 }, rows, { baseline }), [
|
|
184
|
+
{ criterion_id: 'old-1', expected: 1, actual: 3 },
|
|
185
|
+
{ criterion_id: 'ours', expected: 2, actual: 0 },
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('with a baseline, a planned criterion that already had links expects baseline + plan', () => {
|
|
190
|
+
const rows = [{ criterion_id: 'shared', linked_tasks: 7 }];
|
|
191
|
+
assert.deepEqual(diffCriterionLinks({ shared: 2 }, rows, { baseline: { shared: 5 } }), []);
|
|
192
|
+
assert.deepEqual(diffCriterionLinks({ shared: 2 }, rows, { baseline: { shared: 4 } }),
|
|
193
|
+
[{ criterion_id: 'shared', expected: 6, actual: 7 }]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a criterion created after the baseline starts at zero, so a link on it is caught', () => {
|
|
197
|
+
const rows = [{ criterion_id: 'brand-new', linked_tasks: 1 }];
|
|
198
|
+
assert.deepEqual(diffCriterionLinks({}, rows, { baseline: {} }),
|
|
199
|
+
[{ criterion_id: 'brand-new', expected: 0, actual: 1 }]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const goalWith = (criteria) => async () => ({ ok: true, status: 200, json: { criteria } });
|
|
203
|
+
|
|
204
|
+
// A RESUMED seed (grade finding on task 1004668). The first run created task A
|
|
205
|
+
// (linked to `ours`) and died; the re-run baselines AFTER that, replays A
|
|
206
|
+
// (`idempotent: true`) and creates B. The plan must count only B — tallying the
|
|
207
|
+
// replay too expects A's link twice and fails a correct resume.
|
|
208
|
+
function tallyCreated(responses) {
|
|
209
|
+
// The reference seed's tally rule, exactly: skip a replayed create.
|
|
210
|
+
return expectedCriterionCounts(responses.filter((r) => !r.idempotent).map((r) => r.crit));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
test('a resumed seed tallies only what THIS run created, and verifies OK', () => {
|
|
214
|
+
const baseline = { ours: 1 }; // A's link, from the first run
|
|
215
|
+
const responses = [{ crit: ['ours'], idempotent: true }, { crit: ['ours'] }];
|
|
216
|
+
const rows = [{ criterion_id: 'ours', linked_tasks: 2 }];
|
|
217
|
+
assert.deepEqual(diffCriterionLinks(tallyCreated(responses), rows, { baseline }), []);
|
|
218
|
+
// The bug the finding names: counting the replay expects 3.
|
|
219
|
+
const naive = expectedCriterionCounts(responses.map((r) => r.crit));
|
|
220
|
+
assert.deepEqual(diffCriterionLinks(naive, rows, { baseline }), [{ criterion_id: 'ours', expected: 3, actual: 2 }]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('a fully re-run seed (every create a replay) plans nothing and verifies OK', () => {
|
|
224
|
+
const responses = [{ crit: ['ours'], idempotent: true }, { crit: ['ours'], idempotent: true }];
|
|
225
|
+
assert.deepEqual(diffCriterionLinks(tallyCreated(responses), [{ criterion_id: 'ours', linked_tasks: 2 }], { baseline: { ours: 2 } }), []);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('the baselined reference seed skips replays when it tallies', () => {
|
|
229
|
+
const src = readFileSync(new URL('../scripts/gds/seed-bv2-ideator-surface-tasks.js', import.meta.url), 'utf8');
|
|
230
|
+
assert.match(src, /baselineSeedCriteria\(/, 'it takes a baseline');
|
|
231
|
+
assert.match(src, /if \(!r\.json\.idempotent\) criterionIdListsPerTask\.push\(t\.crit\);/, 'and tallies only created tasks');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await testAsync('baselineSeedCriteria snapshots the goal before any write', async () => {
|
|
235
|
+
const logs = [];
|
|
236
|
+
const baseline = await baselineSeedCriteria({
|
|
237
|
+
goalId: 1000095, fetchGoal: goalWith(POPULATED), log: (l) => logs.push(l), exit: () => { throw new Error('should not exit'); },
|
|
238
|
+
});
|
|
239
|
+
assert.deepEqual(baseline, { 'old-1': 1, 'old-2': 9, 'old-3': 4, 'old-4': 3, 'old-5': 6, 'old-6': 10 });
|
|
240
|
+
assert.ok(logs.some((l) => l.includes('6 already carrying linked tasks')));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await testAsync('baselineSeedCriteria refuses to seed blind when the goal cannot be read', async () => {
|
|
244
|
+
let exitCode = null; const logs = [];
|
|
245
|
+
await baselineSeedCriteria({
|
|
246
|
+
goalId: 1000095, fetchGoal: async () => ({ ok: false, status: 401, json: null }),
|
|
247
|
+
log: (l) => logs.push(l), exit: (c) => { exitCode = c; },
|
|
248
|
+
});
|
|
249
|
+
assert.equal(exitCode, 1);
|
|
250
|
+
assert.ok(logs.some((l) => l.includes('Refusing to seed blind')));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await testAsync('a correct seed into a populated goal verifies OK end to end, baseline passed through', async () => {
|
|
254
|
+
const logs = []; let exitCalled = false;
|
|
255
|
+
const baseline = await baselineSeedCriteria({ goalId: 1000095, fetchGoal: goalWith(POPULATED), log: () => {} });
|
|
256
|
+
await verifySeedCriteriaLinks({
|
|
257
|
+
goalId: 1000095,
|
|
258
|
+
expectedCounts: expectedCriterionCounts([['ours'], ['ours']]),
|
|
259
|
+
baseline,
|
|
260
|
+
fetchGoal: goalWith([...POPULATED, { criterion_id: 'ours', linked_tasks: 2 }]),
|
|
261
|
+
log: (l) => logs.push(l),
|
|
262
|
+
exit: () => { exitCalled = true; },
|
|
263
|
+
});
|
|
264
|
+
assert.equal(exitCalled, false, logs.join('\n'));
|
|
265
|
+
assert.ok(logs.some((l) => l.includes('VERIFY OK') && l.includes('pre-seed count')));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await testAsync('without a baseline the OK line says what it did not check', async () => {
|
|
269
|
+
const logs = [];
|
|
270
|
+
await verifySeedCriteriaLinks({
|
|
271
|
+
goalId: 1000095, expectedCounts: { ours: 2 },
|
|
272
|
+
fetchGoal: goalWith([...POPULATED, { criterion_id: 'ours', linked_tasks: 2 }]),
|
|
273
|
+
log: (l) => logs.push(l), exit: () => { throw new Error('should not exit'); },
|
|
274
|
+
});
|
|
275
|
+
assert.ok(logs.some((l) => /were not checked/.test(l) && l.includes('baselineSeedCriteria')));
|
|
276
|
+
});
|
|
277
|
+
|
|
148
278
|
console.log(`\nseed_verify_criteria: ${passed} passed, ${failed} failed`);
|
|
149
279
|
if (failed > 0) process.exit(1);
|