@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.
- package/.bongos-core.json +43 -38
- package/.claude/skills/goal-create/SKILL.md +9 -4
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +22 -9
- package/docs/copy-inventory.md +25 -19
- package/docs/copy-registry.json +92 -37
- package/docs/module-api-changelog.md +4 -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 +20 -0
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
- package/scripts/gds/seed-verify-criteria.js +73 -18
- package/scripts/hall-preview/server.js +12 -1
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_submit.mjs +36 -0
- package/tests/hall_preview_encoded_paths.mjs +81 -0
- package/tests/hall_tweak_editor.mjs +19 -3
- package/tests/paintbrush.mjs +151 -2
- package/tests/planning_session_skill.mjs +102 -4
- 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 };
|
|
@@ -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
|
-
|
|
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.
|
|
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,
|
|
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. ';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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);
|