@bongos/core 1.19.1063 → 1.19.1064

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 (37) hide show
  1. package/.bongos-core.json +72 -37
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +6 -0
  6. package/clients/bongos-client/index.cjs +6 -0
  7. package/clients/bongos-client/index.d.ts +10 -0
  8. package/clients/bongos-client/index.mjs +6 -0
  9. package/docs/api/openapi.json +258 -3
  10. package/docs/api-reference.md +5 -2
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +2 -0
  13. package/modules/lifecycle/db-grade.js +16 -0
  14. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  15. package/modules/lifecycle/page-tweak-hold.js +74 -0
  16. package/modules/lifecycle/page-tweak-reads.js +33 -1
  17. package/modules/lifecycle/routes/tasks.js +4 -2
  18. package/modules/lifecycle/routes/visuals.js +82 -0
  19. package/modules/lifecycle/task-visual-db.js +40 -1
  20. package/modules/lifecycle/task-visuals.js +24 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +6 -0
  24. package/scripts/gds/copy-apply.js +277 -1
  25. package/scripts/gds/page-reader.js +2 -0
  26. package/scripts/gds/ship-finish.js +27 -2
  27. package/scripts/gds/ship-flow.js +17 -1
  28. package/scripts/gds/ship-land.js +30 -7
  29. package/scripts/gds/ship-merge.js +43 -15
  30. package/scripts/gds/strand-watch.js +41 -1
  31. package/scripts/gds/task.js +23 -5
  32. package/scripts/gds/tweak-renders.js +200 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/page_tweak_hold.mjs +265 -0
  35. package/tests/publish_status_branch.mjs +23 -0
  36. package/tests/task_visual_slots.mjs +161 -0
  37. package/tests/tweak_batch_apply.mjs +265 -0
@@ -14,6 +14,12 @@
14
14
  // DELETE /tasks/:id/visual take it back off (claim holder | Archon)
15
15
  // GET /task-visuals/:name serve one (see the audience note)
16
16
  //
17
+ // NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013) — the same store, the same
18
+ // gate, the same audience, one picture per name:
19
+ // POST /tasks/:id/visuals/:slot attach or replace a named one (claim holder | Archon)
20
+ // DELETE /tasks/:id/visuals/:slot take it back off (claim holder | Archon)
21
+ // GET /tasks/:id/visuals list them by name (any builder)
22
+ //
17
23
  // The twin of routes/bug-attachments.js, for the image a builder attaches to
18
24
  // their own shipped task instead of one the bot pulled out of #bugs. Storage +
19
25
  // screening live in ../task-visuals.js; all three routes live here rather than
@@ -191,6 +197,82 @@ module.exports = function buildTaskVisualsRouter() {
191
197
  }
192
198
  });
193
199
 
200
+ // ==========================================================================
201
+ // NAMED SLOTS (task 1004322 / BV2.TW11; ADR 0341 D7, builder pick 9)
202
+ // --------------------------------------------------------------------------
203
+ // A page tweak carries eight renders of the applied page (before and after,
204
+ // desktop and phone, light and dark), and the Approval queue shows them by
205
+ // name. Each slot is stored and screened exactly as the one visual is, and
206
+ // served by the same GET /task-visuals/:name, whose audience comes from the
207
+ // task. The slot name is validated before any byte is read.
208
+
209
+ // POST /tasks/:id/visuals/:slot — attach (or replace) one named visual.
210
+ router.post('/tasks/:id/visuals/:slot', auth.requireBuilder, visualUpload, async (req, res) => {
211
+ const id = parseId(req, res, { code: 'bad_task_id' });
212
+ if (id === null) return;
213
+ const slot = req.params.slot;
214
+ if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400, { max_length: visuals.MAX_SLOT_LENGTH, example: visuals.TWEAK_RENDER_SLOTS[0] });
215
+ if (validateOrRespond(req, res, {
216
+ image_b64: { required: true, type: 'string', maxLength: VISUAL_B64_MAX, minLength: 1 },
217
+ content_type: { required: true, type: 'string', maxLength: 100, minLength: 1 },
218
+ alt: { type: 'string', maxLength: visuals.MAX_ALT_LENGTH },
219
+ })) return;
220
+ if (!(await gateTaskOwnership(req, res, id))) return;
221
+ const buf = Buffer.from(req.body.image_b64, 'base64');
222
+ const screened = visuals.screenImage(buf, req.body.content_type);
223
+ if (!screened.ok) {
224
+ return res.fail(`visual_${screened.reason}`, screened.reason === 'too_big' ? 413 : 400, {
225
+ max_bytes: visuals.MAX_BYTES,
226
+ accepted: Object.keys(visuals.EXT_BY_TYPE),
227
+ });
228
+ }
229
+ const task = await db.getTaskVisualAccess(id);
230
+ if (!task) return res.fail('task_not_found', 404);
231
+ try {
232
+ const stored = await visuals.persistImageBuffer(buf, { taskId: id, contentType: req.body.content_type });
233
+ if (!stored.ok) return res.fail(`visual_${stored.reason}`, 400);
234
+ const row = await db.setTaskVisualSlot({ taskId: id, slot, visualUrl: stored.url, visualAlt: visuals.normalizeAlt(req.body.alt) });
235
+ if (row && row.replaced_url && row.replaced_url !== stored.url) await visuals.deleteStored(row.replaced_url);
236
+ return res.status(201).json({ ok: true, slot, visual_url: row.visual_url, visual_alt: row.visual_alt, bytes: stored.bytes });
237
+ } catch (err) {
238
+ log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot upload failed');
239
+ return res.fail('visual_store_failed', { status: 500, message: 'internal error' });
240
+ }
241
+ });
242
+
243
+ // DELETE /tasks/:id/visuals/:slot — remove one named visual. Idempotent.
244
+ router.delete('/tasks/:id/visuals/:slot', auth.requireBuilder, async (req, res) => {
245
+ const id = parseId(req, res, { code: 'bad_task_id' });
246
+ if (id === null) return;
247
+ const slot = req.params.slot;
248
+ if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400);
249
+ if (!(await gateTaskOwnership(req, res, id))) return;
250
+ try {
251
+ const url = await db.deleteTaskVisualSlot({ taskId: id, slot });
252
+ if (url) await visuals.deleteStored(url);
253
+ return res.json({ ok: true, slot, visual_url: null });
254
+ } catch (err) {
255
+ log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot delete failed');
256
+ return res.fail('visual_delete_failed', { status: 500, message: 'internal error' });
257
+ }
258
+ });
259
+
260
+ // GET /tasks/:id/visuals — the task's named visuals. Any signed-in builder: it
261
+ // lists URLs only, and each image is still served under its own task's audience.
262
+ router.get('/tasks/:id/visuals', auth.requireBuilder, async (req, res) => {
263
+ const id = parseId(req, res, { code: 'bad_task_id' });
264
+ if (id === null) return;
265
+ try {
266
+ const task = await db.getTaskVisualAccess(id);
267
+ if (!task) return res.fail('task_not_found', 404);
268
+ const rows = await db.listTaskVisualSlots(id);
269
+ return res.json({ ok: true, task_id: String(id), slots: rows.map((x) => ({ slot: x.slot, visual_url: x.visual_url, visual_alt: x.visual_alt, updated_at: x.updated_at })) });
270
+ } catch (err) {
271
+ log.error({ taskId: id, err: err && err.message }, 'task-visual slot list failed');
272
+ return res.fail('visual_list_failed', { status: 500, message: 'internal error' });
273
+ }
274
+ });
275
+
194
276
  // DELETE /tasks/:id/visual — remove it again. The counterpart to "always
195
277
  // optional": a builder who attached the wrong screenshot must be able to take
196
278
  // it back, and an image is not prose (the write-once rule of task 1003102
@@ -43,4 +43,43 @@ async function getTaskVisualAccess(taskId) {
43
43
  return rows[0] ?? null;
44
44
  }
45
45
 
46
- module.exports = { setTaskVisual, getTaskVisualAccess };
46
+ // NAMED SLOTS (task 1004322, lifecycle_013). Status-blind, like the two above:
47
+ // the routes decide who may write and who may see.
48
+ //
49
+ // Upsert one slot. Returns the stored row and the URL it REPLACED (null when the
50
+ // slot was empty), read in the same statement so the route can unlink exactly the
51
+ // superseded file.
52
+ async function setTaskVisualSlot({ taskId, slot, visualUrl, visualAlt = null }) {
53
+ const { rows } = await pool.query(
54
+ `WITH prev AS (
55
+ SELECT visual_url FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2
56
+ )
57
+ INSERT INTO lifecycle_task_visual_slots (task_id, slot, visual_url, visual_alt, updated_at)
58
+ VALUES ($1, $2, $3, $4, now())
59
+ ON CONFLICT (task_id, slot) DO UPDATE
60
+ SET visual_url = EXCLUDED.visual_url, visual_alt = EXCLUDED.visual_alt, updated_at = now()
61
+ RETURNING slot, visual_url, visual_alt, (SELECT visual_url FROM prev) AS replaced_url`,
62
+ [taskId, slot, visualUrl, visualAlt]
63
+ );
64
+ return rows[0] ?? null;
65
+ }
66
+
67
+ // Remove one slot. Returns the URL it held, or null when it was already empty.
68
+ async function deleteTaskVisualSlot({ taskId, slot }) {
69
+ const { rows } = await pool.query(
70
+ `DELETE FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2 RETURNING visual_url`,
71
+ [taskId, slot]
72
+ );
73
+ return rows[0] ? rows[0].visual_url : null;
74
+ }
75
+
76
+ // Every slot a task carries, by name.
77
+ async function listTaskVisualSlots(taskId) {
78
+ const { rows } = await pool.query(
79
+ `SELECT slot, visual_url, visual_alt, updated_at FROM lifecycle_task_visual_slots WHERE task_id = $1 ORDER BY slot`,
80
+ [taskId]
81
+ );
82
+ return rows;
83
+ }
84
+
85
+ module.exports = { setTaskVisual, getTaskVisualAccess, setTaskVisualSlot, deleteTaskVisualSlot, listTaskVisualSlots };
@@ -55,6 +55,24 @@ const TYPE_BY_EXT = {
55
55
  };
56
56
 
57
57
  const MAX_BYTES = 4 * 1024 * 1024; // 4 MB — a screenshot, not an asset library.
58
+
59
+ // NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013). A task may carry named
60
+ // visuals beside its one ship-time picture. The shape is the column's own CHECK,
61
+ // restated so a bad name is refused before any byte is stored.
62
+ const SLOT_RE = /^[a-z0-9]+(-[a-z0-9]+){0,5}$/;
63
+ const MAX_SLOT_LENGTH = 48;
64
+ function isSafeSlot(slot) {
65
+ return typeof slot === 'string' && slot.length <= MAX_SLOT_LENGTH && SLOT_RE.test(slot);
66
+ }
67
+ // The eight renders /tweak attaches to a page tweak (ADR 0341 D7, builder pick 9):
68
+ // the applied page before and after, desktop (1440) and phone (390), light and
69
+ // dark. Declared ONCE: the renderer names its files with it, the attach step
70
+ // uploads by it, and the Approval queue (TW12) reads the same names.
71
+ const TWEAK_RENDER_PHASES = Object.freeze(['before', 'after']);
72
+ const TWEAK_RENDER_DEVICES = Object.freeze({ desktop: 1440, phone: 390 });
73
+ const TWEAK_RENDER_MODES = Object.freeze(['light', 'dark']);
74
+ const TWEAK_RENDER_SLOTS = Object.freeze(TWEAK_RENDER_PHASES.flatMap((phase) =>
75
+ Object.keys(TWEAK_RENDER_DEVICES).flatMap((device) => TWEAK_RENDER_MODES.map((mode) => `${phase}-${device}-${mode}`))));
58
76
  const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
59
77
 
60
78
  // Stored filenames are SERVER-generated, never user-supplied: a fixed shape we
@@ -249,6 +267,12 @@ async function deleteStored(nameOrUrl, { dir = visualsDir() } = {}) {
249
267
  }
250
268
 
251
269
  module.exports = {
270
+ isSafeSlot,
271
+ MAX_SLOT_LENGTH,
272
+ TWEAK_RENDER_PHASES,
273
+ TWEAK_RENDER_DEVICES,
274
+ TWEAK_RENDER_MODES,
275
+ TWEAK_RENDER_SLOTS,
252
276
  EXT_BY_TYPE,
253
277
  TYPE_BY_EXT,
254
278
  MAX_BYTES,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1063",
3
+ "version": "1.19.1064",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1063",
9
+ "version": "1.19.1064",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1063",
3
+ "version": "1.19.1064",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -7679,5 +7679,11 @@
7679
7679
  "id": "1004321",
7680
7680
  "text": "Artists now have their own editor for a page. It shows every word of the page as one sheet of paper over the studio at night. You type over any line and it saves as you go. A changed line gets a small orange mark and shows wha"
7681
7681
  }
7682
+ ],
7683
+ "1.19.1064": [
7684
+ {
7685
+ "id": "1004322",
7686
+ "text": "A session can now apply an artist's rewritten page. The new /tweak skill takes the next submitted page, applies every line in one pass, and photographs the page before and after, on a desktop and a phone, in light and dark, at"
7687
+ }
7682
7688
  ]
7683
7689
  }
@@ -30,7 +30,9 @@
30
30
  // arithmetic does not hold, or the file is outside a registry surface. None of
31
31
  // them means the wording was wrong — they mean a person should look.
32
32
  //
33
- // Run: node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
33
+ // Run: node scripts/gds/copy-apply.js --batch (a PAGE TWEAK round you hold: every line, task 1004322)
34
+ // node scripts/gds/copy-apply.js --batch --batch-file round.md --dry-run (a local round; writes nothing)
35
+ // node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
34
36
  // node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
35
37
  // node scripts/gds/copy-apply.js --task 1234 (a claim you hold, when you hold more than one)
36
38
  // node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
@@ -53,9 +55,12 @@
53
55
 
54
56
  const fs = require('node:fs');
55
57
  const path = require('node:path');
58
+ const { spawnSync } = require('node:child_process');
56
59
 
57
60
  const ci = require('./copy-inventory.js');
58
61
  const proposals = require('../../modules/copy-desk/proposals.js');
62
+ // The page tweak format's one reader and writer (ADR 0341 D4), for --batch.
63
+ const pages = require('../../modules/copy-desk/pages.js');
59
64
  const { apiCall, arg, hasFlag, cliExit } = require('./cli-lib.js');
60
65
 
61
66
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -305,11 +310,275 @@ async function loadPatch({ taskId, patchFile }) {
305
310
  return { ok: true, patch: parsed.patch, task };
306
311
  }
307
312
 
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // --batch: a whole PAGE at once (task 1004322 / BV2.TW11; ADR 0341 D4).
316
+ //
317
+ // A page tweak's round carries a ```page-tweak``` block: every line the artist
318
+ // rewrote, frozen at submit, each with its reading key, its section, the text the
319
+ // page showed (`before`), the artist's wording (`after`) and where the registry
320
+ // placed it (`file`, `line`, `string_id`). --batch lands every line through the
321
+ // SAME rules one proposal lands by (ADR 0233): the span is re-derived from the
322
+ // tree as it stands, the `${expr}` holes go back in, and every doubt is a named
323
+ // refusal. What is new is only that there are many lines, and so:
324
+ //
325
+ // * A REFUSED LINE IS NAMED, NEVER SKIPPED. It goes in the applied block's
326
+ // `refused` list with its code, and the artist sees it at approval. The rest
327
+ // of the batch still lands (D4: "the batch lands with the lines that applied").
328
+ // * ORDER IS BY SOURCE POSITION, BOTTOM UP, per file. A rewrite can change how
329
+ // many lines a span covers, which would move every later line number; the
330
+ // line number is only ever a tie-breaker between duplicates, but applying from
331
+ // the bottom keeps even the tie-breaker true for every line still to come.
332
+ // * A READING LINE MAY BE PART OF A REGISTRY STRING. The page reader places a
333
+ // visible fragment on the registry row that contains it (a word inside a
334
+ // sentence built around a hole), so `before` is either the row's whole text
335
+ // or appears in it exactly once; the rewrite replaces that one occurrence.
336
+ // Anything else is `no_exact_span` (absent) or `ambiguous_target` (twice).
337
+ // * TWO LINES ON ONE STRING compose: the second is resolved against the text the
338
+ // first one left, never against the stale registry row.
339
+ //
340
+ // Then the command regenerates the registry, re-reads the changed pages
341
+ // (page-reader.js --stale, which opens a browser) and writes the
342
+ // ```page-tweak-applied``` block from the NEW reading: reading_hash_after and
343
+ // lines_after (the page's non-shared lines, the baseline drift is measured from,
344
+ // D8) plus the refused list.
345
+ // ---------------------------------------------------------------------------
346
+
347
+ const REFUSAL_CODES = Object.freeze(['target_gone', 'ambiguous_target', 'no_exact_span', 'placeholder_mismatch', 'path_outside_surface', 'unknown_surface', 'file_unreadable']);
348
+
349
+ function countOccurrences(haystack, needle) {
350
+ if (!needle) return 0;
351
+ let n = 0;
352
+ for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) n += 1;
353
+ return n;
354
+ }
355
+
356
+ // Which registry row a batch line means. A string id is a hash of (surface,
357
+ // text), so the same sentence in two files shares one id: the row in the line's
358
+ // own file wins, the nearest to its line if that file has it twice. Pure.
359
+ function registryRowFor(line, registryById) {
360
+ const rows = (line && line.string_id && registryById.get(line.string_id)) || [];
361
+ if (rows.length <= 1) return rows[0] || null;
362
+ const inFile = rows.filter((e) => e.file === line.file);
363
+ const pool = inFile.length ? inFile : rows;
364
+ if (pool.length === 1) return pool[0];
365
+ const near = (e) => Math.abs((e.line || 0) - (line.line || 0));
366
+ return pool.slice().sort((a, b) => near(a) - near(b))[0];
367
+ }
368
+
369
+ const rowKey = (e) => `${e.id}|${e.file}|${e.line}`;
370
+
371
+ // One batch line -> the single-proposal patch shape (proposals.js), resolved
372
+ // against the registry row its string_id names. `texts` carries a row's text as
373
+ // earlier lines of this batch left it. Pure.
374
+ function patchForBatchLine(line, registryById, texts = new Map()) {
375
+ const entry = registryRowFor(line, registryById);
376
+ if (!entry) {
377
+ return { ok: false, code: 'target_gone', detail: { string_id: line && line.string_id, what_this_means: 'The copy registry no longer has the string this line was placed on: the page changed after the artist submitted.' } };
378
+ }
379
+ const want = proposals.placeholderCount(line.before);
380
+ const got = proposals.placeholderCount(line.after);
381
+ if (want !== got) {
382
+ return { ok: false, code: 'placeholder_mismatch', detail: { expected: want, got, what_this_means: 'The rewrite does not keep the values the page drops into this line.' } };
383
+ }
384
+ const current = texts.has(rowKey(entry)) ? texts.get(rowKey(entry)) : entry.text;
385
+ let proposed;
386
+ if (current === line.before) proposed = line.after;
387
+ else {
388
+ const n = countOccurrences(current, line.before);
389
+ if (n === 0) return { ok: false, code: 'no_exact_span', detail: { file: entry.file, looked_for: line.before, in: current, what_this_means: 'The line the page shows cannot be found inside the string the registry placed it on, so no exact span exists.' } };
390
+ if (n > 1) return { ok: false, code: 'ambiguous_target', detail: { file: entry.file, occurrences: n, what_this_means: 'The line appears more than once inside its string, and the batch cannot say which one it meant.' } };
391
+ proposed = current.replace(line.before, () => line.after);
392
+ }
393
+ return {
394
+ ok: true,
395
+ patch: { row: rowKey(entry), surface: entry.surface, file: entry.file, line: entry.line, origin: entry.origin, current, proposed },
396
+ };
397
+ }
398
+
399
+ // applyBatchToSources({ batch, registry, readSource }) -> { applied, refused, sources }
400
+ //
401
+ // The whole batch, as a pure function of the registry and the files' current
402
+ // contents (readSource(rel) returns a file's text or throws). Writes nothing.
403
+ // `applied` and `refused` come back in PAGE order (the batch's own order);
404
+ // `sources` maps each changed file to its new contents.
405
+ function applyBatchToSources({ batch, registry, readSource }) {
406
+ const registryById = new Map();
407
+ for (const e of (registry && registry.entries) || []) {
408
+ if (!registryById.has(e.id)) registryById.set(e.id, []);
409
+ registryById.get(e.id).push(e);
410
+ }
411
+ const lines = ((batch && batch.lines) || []).map((l, i) => ({ ...l, order: i }));
412
+ const byId = (l) => registryRowFor(l, registryById);
413
+ // Bottom up per file, by the registry's line (the tree's truth, not the batch's).
414
+ const work = lines.slice().sort((a, b) => {
415
+ const ea = byId(a), eb = byId(b);
416
+ const fa = ea ? ea.file : '', fb = eb ? eb.file : '';
417
+ if (fa !== fb) return fa < fb ? -1 : 1;
418
+ return ((eb && eb.line) || 0) - ((ea && ea.line) || 0) || a.order - b.order;
419
+ });
420
+ const sources = new Map();
421
+ const texts = new Map();
422
+ const applied = [];
423
+ const refused = [];
424
+ const refuse = (l, r) => refused.push({ key: l.key, section: l.section == null ? null : l.section, before: l.before, code: r.code, detail: r.detail || {} });
425
+ for (const l of work) {
426
+ const p = patchForBatchLine(l, registryById, texts);
427
+ if (!p.ok) { refuse(l, p); continue; }
428
+ const guard = checkPatchPath(p.patch);
429
+ if (!guard.ok) { refuse(l, guard); continue; }
430
+ let source = sources.get(guard.rel);
431
+ if (source === undefined) {
432
+ try { source = readSource(guard.rel); } catch (err) { refuse(l, { code: 'file_unreadable', detail: { file: guard.rel, reason: err.message } }); continue; }
433
+ }
434
+ const out = applyPatchToSource(source, guard.rel, p.patch);
435
+ if (!out.ok) { refuse(l, out); continue; }
436
+ sources.set(guard.rel, out.source);
437
+ texts.set(p.patch.row, p.patch.proposed);
438
+ applied.push({ key: l.key, section: l.section == null ? null : l.section, file: guard.rel, line: out.line, before: out.before, after: out.after, proposed: p.patch.proposed, order: l.order });
439
+ }
440
+ const byOrder = (x, y) => x.order - y.order;
441
+ const orderOf = new Map(lines.map((l) => [l.key, l.order]));
442
+ refused.sort((x, y) => orderOf.get(x.key) - orderOf.get(y.key));
443
+ return { applied: applied.sort(byOrder).map(({ order, ...rest }) => rest), refused, sources };
444
+ }
445
+
446
+ // The ```page-tweak-applied``` block's content, from the page's NEW reading.
447
+ // lines_after is the non-shared lines' texts: shared shell lines are left out of
448
+ // drift on both sides (D8, builder pick 7), so they must be left out here too or
449
+ // every one of them would read as "removed" on the next drift check.
450
+ function appliedBlockFor({ pageId, readingPage, refused, appliedAt }) {
451
+ return {
452
+ page_id: pageId,
453
+ applied_at: appliedAt,
454
+ reading_hash_after: readingPage ? readingPage.reading_hash : null,
455
+ lines_after: readingPage ? (readingPage.lines || []).filter((l) => l && l.placement !== 'shared').map((l) => String(l.text)) : [],
456
+ refused: (refused || []).map((x) => ({ key: x.key, code: x.code })),
457
+ };
458
+ }
459
+
460
+ // Load the round's batch: the task you hold, or a local file (the tests' and the
461
+ // dry run's entry point). A file may hold a whole round description or a bare
462
+ // batch object; either way it goes through pages.parseBatchBlock, the one reader.
463
+ async function loadBatch({ taskId, batchFile, readFile = (p) => fs.readFileSync(p, 'utf8'), api = apiCall }) {
464
+ let description;
465
+ if (batchFile) {
466
+ let raw;
467
+ try { raw = readFile(path.resolve(process.cwd(), batchFile)); } catch (err) { return { ok: false, code: 'batch_file_unreadable', detail: { reason: err.message } }; }
468
+ description = raw.trim().startsWith('{') ? ['```' + pages.FENCES.batch, raw.trim(), '```'].join('\n') : raw;
469
+ } else {
470
+ const res = await api('GET', `/api/bongos/tasks/${taskId}`);
471
+ if (!res.ok) return { ok: false, code: 'task_unreadable', detail: { task: taskId, status: res.status } };
472
+ const task = (res.data && res.data.task) || {};
473
+ if (task.source !== pages.SOURCE) return { ok: false, code: 'not_a_page_tweak', detail: { task: taskId, source: task.source || null, what_this_means: '--batch applies a page tweak round. For a single copy proposal, run this without --batch.' } };
474
+ description = task.description || '';
475
+ }
476
+ const parsed = pages.parseBatchBlock(description);
477
+ if (!parsed.ok) return parsed;
478
+ return { ok: true, batch: parsed.batch, description };
479
+ }
480
+
481
+ // Re-read the pages whose files the batch changed (page-reader.js --stale: this
482
+ // page, and any other page sharing a changed file). Opens a browser.
483
+ function rereadStalePages({ spawn = spawnSync } = {}) {
484
+ const r = spawn(process.execPath, [path.join(__dirname, 'page-reader.js'), '--stale'], { cwd: REPO_ROOT, stdio: 'inherit' });
485
+ return r.status === 0 ? { ok: true } : { ok: false, code: r.status === 3 ? 'no_browser' : 'reread_failed', detail: { exit: r.status } };
486
+ }
487
+
488
+ function readingFor(pageId, { readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
489
+ try {
490
+ const doc = JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'page-readings.json')));
491
+ return (doc.pages || []).find((p) => p.id === pageId) || null;
492
+ } catch { return null; }
493
+ }
494
+
495
+ // Write the applied block into the round's description (replacing an earlier one:
496
+ // a re-apply after a send-back supersedes it), through PATCH /tasks/:id.
497
+ async function writeAppliedBlock({ taskId, description, block, api = apiCall }) {
498
+ const next = pages.replaceBlock(description, pages.FENCES.applied, pages.composeAppliedBlock(block));
499
+ if (!next.ok) return next;
500
+ const res = await api('PATCH', `/api/bongos/tasks/${taskId}`, { description: next.description });
501
+ if (!res.ok) return { ok: false, code: 'task_write_failed', detail: { task: taskId, status: res.status, what_this_means: 'The files are applied, but the round does not yet record it. PATCH /tasks/:id needs the task.edit permission (Metic+).' } };
502
+ return { ok: true };
503
+ }
504
+
505
+ // The --batch command. `deps` is the test seam: every read, write, re-read and API
506
+ // call can be replaced, so the whole path runs against a fixture round.
507
+ async function batchMain({ taskId, batchFile, dryRun, now = () => new Date().toISOString() }, deps = {}) {
508
+ const readFile = deps.readFile || ((p) => fs.readFileSync(p, 'utf8'));
509
+ const writeFile = deps.writeFile || ((p, s) => fs.writeFileSync(p, s));
510
+ const api = deps.api || apiCall;
511
+ const log = deps.log || console.log;
512
+ const loaded = await loadBatch({ taskId, batchFile, readFile, api });
513
+ if (!loaded.ok) return refuse(loaded);
514
+ const { batch } = loaded;
515
+ const registry = deps.registry || JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'copy-registry.json')));
516
+ const result = applyBatchToSources({ batch, registry, readSource: (rel) => readFile(path.join(REPO_ROOT, rel)) });
517
+
518
+ log(`copy-apply --batch: ${batch.page_id}, ${batch.lines.length} line(s): ${result.applied.length} applied, ${result.refused.length} refused.`);
519
+ for (const a of result.applied) {
520
+ log(` ${a.key} ${a.file}:${a.line}`);
521
+ log(` - ${a.before}`);
522
+ log(` + ${a.after}`);
523
+ }
524
+ for (const x of result.refused) {
525
+ log(` ${x.key} REFUSED (${x.code})${x.detail && x.detail.what_this_means ? `: ${x.detail.what_this_means}` : ''}`);
526
+ log(` line: ${JSON.stringify(x.before)}`);
527
+ }
528
+ if (dryRun) { log('copy-apply --batch: --dry-run, nothing written.'); return 0; }
529
+
530
+ for (const [rel, source] of result.sources) writeFile(path.join(REPO_ROOT, rel), source);
531
+ const { reg } = (deps.regenerateRegistry || (() => ci.write()))();
532
+ const missing = result.applied.filter((a) => !reg.entries.some((e) => e.file === a.file && e.text === a.proposed));
533
+ if (missing.length) {
534
+ console.error(`copy-apply --batch: WROTE THE FILES, but the regenerated registry does not contain the new wording for ${missing.map((m) => m.key).join(', ')}.`);
535
+ console.error(' An edit landed somewhere the inventory does not read as copy. Review `git diff` before doing anything else.');
536
+ return 3;
537
+ }
538
+ log(`copy-apply --batch: files written, registry regenerated (${reg.counts ? reg.counts.strings : reg.entries.length} strings).`);
539
+
540
+ const reread = (deps.reread || rereadStalePages)();
541
+ if (!reread.ok) {
542
+ console.error(`copy-apply --batch: the page could not be re-read (${reread.code}). The files are applied; run node scripts/gds/page-reader.js --stale, then this command again to write the applied block.`);
543
+ return 4;
544
+ }
545
+ const readingPage = (deps.readingFor || ((id) => readingFor(id, { readFile })))(batch.page_id);
546
+ const block = appliedBlockFor({ pageId: batch.page_id, readingPage, refused: result.refused, appliedAt: now() });
547
+ if (batchFile) {
548
+ log('copy-apply --batch: the page-tweak-applied block (a --batch-file run writes no task):');
549
+ log(pages.composeAppliedBlock(block));
550
+ } else {
551
+ const wrote = await writeAppliedBlock({ taskId, description: loaded.description, block, api });
552
+ if (!wrote.ok) return refuse(wrote);
553
+ log(`copy-apply --batch: recorded the page-tweak-applied block on task ${taskId} (reading ${block.reading_hash_after}, ${block.lines_after.length} lines, ${block.refused.length} refused).`);
554
+ }
555
+ if (!result.applied.length) {
556
+ console.error('copy-apply --batch: NO line applied. Every refusal is recorded; there is nothing to ship until they are resolved.');
557
+ return 2;
558
+ }
559
+ log(' Review with `git diff`, render the page, then ship this task the normal way (it waits for the artist).');
560
+ return 0;
561
+ }
562
+
308
563
  async function main() {
309
564
  let taskId = arg('--task');
310
565
  const patchFile = arg('--patch-file');
311
566
  const dryRun = hasFlag('--dry-run');
312
567
 
568
+ // --batch (task 1004322): a page tweak round. The claim gate is the same one the
569
+ // single proposal uses; --batch-file is the local, claim-free entry point, and
570
+ // like --patch-file it lands nothing a task authorised.
571
+ if (hasFlag('--batch')) {
572
+ const batchFile = arg('--batch-file');
573
+ if (!batchFile) {
574
+ const held = taskId ? await claimOnTask(taskId) : await claimedTaskId();
575
+ if (!held.ok) return cliExit(refuse(held));
576
+ taskId = held.taskId;
577
+ console.log(`copy-apply --batch: the round you are holding — task ${taskId}`);
578
+ }
579
+ return cliExit(await batchMain({ taskId, batchFile, dryRun }));
580
+ }
581
+
313
582
  if (!taskId && !patchFile) {
314
583
  const held = await claimedTaskId();
315
584
  if (!held.ok) return cliExit(refuse(held));
@@ -379,6 +648,13 @@ if (require.main === module) {
379
648
  }
380
649
 
381
650
  module.exports = {
651
+ // task 1004322: --batch, a page tweak round.
652
+ REFUSAL_CODES,
653
+ registryRowFor,
654
+ patchForBatchLine,
655
+ applyBatchToSources,
656
+ appliedBlockFor,
657
+ batchMain,
382
658
  surfaceRootFor,
383
659
  checkPatchPath,
384
660
  locateOccurrence,
@@ -671,4 +671,6 @@ module.exports = {
671
671
  filesHash, readingHash, buildMatcher, placeText, collectStrings, holeData,
672
672
  mergeStates, assembleReading, serialize, buildDoc, checkReadings, planRenders,
673
673
  captureInPage, mergeReadings,
674
+ // task 1004322: /tweak renders the page the reader reads, through the same harness.
675
+ startHarness, SURFACE_MODULE, PINNED_CLOCK,
674
676
  };
@@ -17,7 +17,8 @@ const { uploadSessionOnShip } = require('./ship-session-upload.js');
17
17
  const { gitOk, shellOk } = require('./ship-git.js');
18
18
  const { fetchDeployedVersion } = require('./ship-deploy-target.js');
19
19
  const { landBailed, reconciledCardStatus } = require('./ship-land.js');
20
- const { autoMerge } = require('./ship-merge.js');
20
+ const { autoMerge, publishHeld } = require('./ship-merge.js');
21
+ const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
21
22
  const { normalizeApiError, playShipBell, postLandShipOutcome } = require('./ship-io.js');
22
23
  const { getServerReward, noteServerReward } = require('./ship-state.js');
23
24
 
@@ -176,7 +177,10 @@ function postShipCloseout(taskId) {
176
177
  }
177
178
  }
178
179
 
179
- async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine, card, dbOnly = false }) {
180
+ async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine, card, dbOnly = false, held = false }) {
181
+ // task 1004322 (ADR 0341 D7): a page tweak whose grade passed is HELD for its
182
+ // artist. It publishes its branch and PR and stops; nothing merges.
183
+ if (held) return finishHeld({ taskId, summary, postGradeStatus, card });
180
184
  // task 1002541: the land outcome carries WHY a land didn't happen. Without it
181
185
  // the card had only the status to go on and printed 'landing automatically'
182
186
  // over a merge that had already bailed.
@@ -226,6 +230,26 @@ async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGrad
226
230
  }
227
231
  }
228
232
 
233
+ // finishHeld — the tail of a held pass (task 1004322). The session is finished
234
+ // work, so it syncs exactly as a confirmed ship does (the applier's session
235
+ // reward is computed from the upload, and pays on land). No bell and no tree
236
+ // close-out: nothing landed, and a send-back brings the round back to be
237
+ // re-applied. `deps` is a test seam only.
238
+ async function finishHeld({ taskId, summary, postGradeStatus, card }, deps = {}) {
239
+ console.log('');
240
+ console.log('Publishing the applied branch for the artist (ADR 0341 D7):');
241
+ const land = await (deps.publishHeld || publishHeld)(taskId, summary);
242
+ if (land && land.held) {
243
+ for (const line of tweakHold.heldLines(taskId, { prUrl: land.prUrl })) console.log(line);
244
+ } else {
245
+ console.error(` The branch did not publish${land && land.reason ? `: ${land.reason}` : ''}. The task stays at completed with its passed grade.`);
246
+ console.error(` Re-run to publish it again (the grade is not re-run): node scripts/gds/ship.js ${taskId}`);
247
+ }
248
+ const usage = await (deps.postShipSync || postShipSync)(taskId, 'confirmed');
249
+ await (deps.printCompletionCard || printCompletionCard)({ taskId, postGradeStatus, card, usage, land });
250
+ return land;
251
+ }
252
+
229
253
  // task 1275: sum the committed diffstat (added/removed lines) over baseline...HEAD.
230
254
  // Shared by the completion card and the in-review card. Best-effort: returns
231
255
  // nulls when there's no baseline or git is unavailable.
@@ -377,6 +401,7 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
377
401
  module.exports = {
378
402
  diffstatFor,
379
403
  finishShip,
404
+ finishHeld,
380
405
  mergeAndDeploy,
381
406
  onboardingNudgeBestEffort,
382
407
  postShipSync,
@@ -27,6 +27,7 @@ const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./
27
27
  const { pushVia } = require('./ship-deploy-target.js');
28
28
 
29
29
  const { diffstatFor, finishShip } = require('./ship-finish.js');
30
+ const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
30
31
  const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge, shouldConfirmWithoutGrade } = require('./ship-resume.js');
31
32
  const { fileNoArtifactEscalation, noArtifactRefusalLines } = require('./ship-escalation.js');
32
33
  const { noteServerReward } = require('./ship-state.js');
@@ -583,6 +584,7 @@ async function shipMain() {
583
584
  let postGradeJustUnlocked = r.data.just_unlocked || [];
584
585
  let gradePassed = false;
585
586
  let advanceToMerge = false;
587
+ let held = false; // task 1004322: a page tweak's pass, held for its artist (ADR 0341 D7)
586
588
  let gradeObj = null; // task 1270: the grade result, hoisted for the completion card
587
589
  // V3.R27 (#247): credit breakdown surfaced from the grade or confirm
588
590
  // response. Kept separate from `credits` so we only print the multiplier
@@ -696,6 +698,7 @@ async function shipMain() {
696
698
  gradeObj = g;
697
699
  gradePassed = g.passed;
698
700
  advanceToMerge = gradePost.data.advanceToMerge;
701
+ held = gradePost.data.held === true;
699
702
  postGradeStatus = gradePost.data.taskStatus;
700
703
  credits = gradePost.data.creditsAwarded;
701
704
  postGradeJustUnlocked = gradePost.data.just_unlocked || [];
@@ -726,7 +729,7 @@ async function shipMain() {
726
729
  markShipProgress(postGradeStatus === 'shipped' ? 'landed' : 'graded', taskId);
727
730
  }
728
731
  await finishShip({
729
- taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly,
732
+ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly, held,
730
733
  // task 1270: data for the deterministic completion card.
731
734
  card: {
732
735
  title: ac.title,
@@ -895,6 +898,18 @@ async function regradeMain() {
895
898
  console.error(` node scripts/gds/ship.js ${taskId} --verified`);
896
899
  process.exit(1);
897
900
  }
901
+ // task 1004322 (ADR 0341 D7): a page tweak already HELD for its artist has a
902
+ // passed grade. A plain re-run lands here (completed -> resume-grade), and
903
+ // re-grading would spend the panel again for the verdict it already has, so it
904
+ // only re-publishes the branch (the recovery for a publish that failed).
905
+ if (tweakHold.isHeldForArtist(task, tRes.data.grade ? tRes.data.grade.passed : null)) {
906
+ console.log(`Task #${taskId} already passed its grade and is waiting for the artist: re-publishing its branch, not re-grading.`);
907
+ await finishShip({
908
+ taskId, summary: summaryArg || task.value_summary || '', held: true, postGradeStatus: 'completed', doneLine: 'Done.',
909
+ card: { title: task.title, valueSummary: summaryArg || task.value_summary || '', notes: '' },
910
+ });
911
+ return;
912
+ }
898
913
  console.log(`Re-grading task #${taskId}: ${task.title}`);
899
914
  console.log(` eligibility: ${elig.reason} (${elig.via})`);
900
915
 
@@ -1101,6 +1116,7 @@ async function regradeMain() {
1101
1116
  // normal ship (finishShip, #1093/C9) so a re-grade lands identically.
1102
1117
  await finishShip({
1103
1118
  taskId, summary, advanceToMerge: gradePost.data.advanceToMerge, skipMerge, dbOnly,
1119
+ held: gradePost.data.held === true, // task 1004322: a page tweak's pass waits for its artist
1104
1120
  postGradeStatus,
1105
1121
  doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
1106
1122
  // task 1270: completion card for the re-grade path too.