@bongos/core 1.19.1062 → 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 (56) hide show
  1. package/.bongos-core.json +123 -48
  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 +8 -0
  6. package/clients/bongos-client/index.cjs +8 -0
  7. package/clients/bongos-client/index.d.ts +12 -0
  8. package/clients/bongos-client/index.mjs +8 -0
  9. package/docs/api/openapi.json +301 -3
  10. package/docs/api-reference.md +7 -3
  11. package/docs/copy-inventory.md +44 -7
  12. package/docs/copy-registry.json +393 -26
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +36 -4
  16. package/docs/page-readings.json +34 -1
  17. package/modules/copy-desk/page-status.js +63 -0
  18. package/modules/copy-desk/pages.js +1 -0
  19. package/modules/copy-desk/routes/copy-desk.js +30 -0
  20. package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
  21. package/modules/hall-ui/public/tweak-editor.css +437 -0
  22. package/modules/hall-ui/public/tweak-editor.html +111 -0
  23. package/modules/hall-ui/public/tweak-editor.js +447 -0
  24. package/modules/hall-ui/public/tweak-editor.states.json +99 -0
  25. package/modules/hall-ui/records/tweak-editor.md +32 -0
  26. package/modules/lifecycle/db-grade.js +16 -0
  27. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  28. package/modules/lifecycle/page-tweak-hold.js +74 -0
  29. package/modules/lifecycle/page-tweak-reads.js +33 -1
  30. package/modules/lifecycle/routes/tasks.js +4 -2
  31. package/modules/lifecycle/routes/visuals.js +82 -0
  32. package/modules/lifecycle/task-visual-db.js +40 -1
  33. package/modules/lifecycle/task-visuals.js +24 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/scripts/gds/copy-apply.js +277 -1
  38. package/scripts/gds/page-reader.js +2 -0
  39. package/scripts/gds/ship-finish.js +27 -2
  40. package/scripts/gds/ship-flow.js +17 -1
  41. package/scripts/gds/ship-land.js +30 -7
  42. package/scripts/gds/ship-merge.js +43 -15
  43. package/scripts/gds/strand-watch.js +41 -1
  44. package/scripts/gds/task.js +23 -5
  45. package/scripts/gds/tweak-renders.js +200 -0
  46. package/scripts/hall-preview/server.js +21 -0
  47. package/src/bongos/serve-internal.js +5 -1
  48. package/src/module-api.js +1 -1
  49. package/tests/copy_desk_page_editor.mjs +181 -0
  50. package/tests/hall_audit.mjs +5 -0
  51. package/tests/hall_page_gate_map.mjs +3 -0
  52. package/tests/hall_tweak_editor.mjs +432 -0
  53. package/tests/page_tweak_hold.mjs +265 -0
  54. package/tests/publish_status_branch.mjs +23 -0
  55. package/tests/task_visual_slots.mjs +161 -0
  56. package/tests/tweak_batch_apply.mjs +265 -0
@@ -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.
@@ -46,6 +46,12 @@ const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('../../modules/lifecycl
46
46
  function landed(o = {}) { return { ok: true, pending: false, reason: null, nextStep: null, noArtifact: o.noArtifact === true, noArtifactReason: o.noArtifactReason || null }; }
47
47
  function landPending() { return { ok: false, pending: true, reason: null, nextStep: null }; }
48
48
  function landBailed(reason, nextStep = null, bailId = null) { return { ok: false, pending: false, reason, nextStep, bailId }; }
49
+ // task 1004322 (ADR 0341 D7): the FOURTH outcome, and only a page tweak reaches it.
50
+ // Its grade passed and its branch and PR are published, but nothing is landing and
51
+ // nothing should: the artist approves the applied page first. Not a bail (nothing
52
+ // failed, and the card must not say "Land it") and not pending (no auto-merge is
53
+ // armed, so it does not land on its own).
54
+ function landHeld({ prUrl = null } = {}) { return { ok: false, pending: false, held: true, reason: null, nextStep: null, prUrl }; }
49
55
 
50
56
  // ---------- which bails a PR can still land (task 1002572, part 2) ----------
51
57
  //
@@ -79,12 +85,17 @@ function strandCommand(taskId, via) {
79
85
 
80
86
  // ciLand — dispatch the 'ci' confirmed->shipped land to the local or the
81
87
  // server-mediated path. Async because the server path awaits GDS API calls.
82
- async function ciLand(taskId, branch, valueSummary) {
83
- console.log(' deploy mode: ci — landing via PR auto-merge (no droplet key needed)');
88
+ // opts.hold (task 1004322): PUBLISH ONLY — push the branch and open the PR, never
89
+ // arm auto-merge and never poll for a land. A page tweak held for its artist takes
90
+ // it; every other caller passes nothing and gets the unchanged path.
91
+ async function ciLand(taskId, branch, valueSummary, opts = {}) {
92
+ console.log(opts.hold
93
+ ? ' held for the artist — publishing the branch and its PR only (no auto-merge, no land)'
94
+ : ' deploy mode: ci — landing via PR auto-merge (no droplet key needed)');
84
95
  if (pushVia() === 'server') {
85
- return ciLandServer(taskId, branch, valueSummary);
96
+ return ciLandServer(taskId, branch, valueSummary, opts);
86
97
  }
87
- return await ciLandLocal(taskId, branch, valueSummary);
98
+ return await ciLandLocal(taskId, branch, valueSummary, opts);
88
99
  }
89
100
 
90
101
  // task 1001916: render a FAILED POST /tasks/:id/publish-branch for the builder.
@@ -165,7 +176,7 @@ function formatPublishFailure(data, status, branch, opts = {}) {
165
176
  // hand the branch to the GDS, which pushes it + opens the PR + auto-merges with
166
177
  // the server-side credential. Requires a RE-AUTHED (non-box) cli session — a
167
178
  // box-scoped session is 403'd by the endpoint (it must `/builder-reauth` first).
168
- async function ciLandServer(taskId, branch, valueSummary) {
179
+ async function ciLandServer(taskId, branch, valueSummary, { hold = false } = {}) {
169
180
  console.log(' ci-land: server-mediated publish (this machine has no GitHub push credential)');
170
181
  const headSha = gitOk('git rev-parse HEAD');
171
182
  // Make sure origin/main is current so the THIN bundle's `origin/main..HEAD`
@@ -195,9 +206,14 @@ async function ciLandServer(taskId, branch, valueSummary) {
195
206
  console.log(` ci-land: server publish failed — ${e}. Task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
196
207
  return landBailed(`server publish failed — ${e}`, strandCommand(taskId, 'server'));
197
208
  }
198
- console.log(` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
209
+ // task 1004322: the server does not arm a held page tweak (it reads the hold
210
+ // itself), so do not tell the builder to merge it on GitHub.
211
+ console.log(hold
212
+ ? ` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge NOT armed: waiting for the artist).`
213
+ : ` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
199
214
  const assigneeLine = prAssigneeLine(r.data.pr_assignee);
200
215
  if (assigneeLine) console.log(assigneeLine);
216
+ if (hold) return landHeld({ prUrl: r.data.pr_url || null });
201
217
  return pollServerPublish(taskId, branch);
202
218
  } finally {
203
219
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
@@ -858,7 +874,7 @@ function pushCapturing(branch, deps = {}) {
858
874
  // ciLandLocal — today's local path: this machine pushes the branch + opens the PR
859
875
  // + enables auto-merge via git + gh. async since task 1169 — it asks the server to
860
876
  // post the gate-author-trust attestation between the push and the PR open.
861
- async function ciLandLocal(taskId, branch, valueSummary) {
877
+ async function ciLandLocal(taskId, branch, valueSummary, { hold = false } = {}) {
862
878
  const headSha = gitOk('git rev-parse HEAD');
863
879
 
864
880
  console.log(` pushing ${branch} to origin...`);
@@ -924,6 +940,12 @@ async function ciLandLocal(taskId, branch, valueSummary) {
924
940
  console.log(` ci-land: reusing open PR ${existing.url}`);
925
941
  }
926
942
 
943
+ // task 1004322: a held page tweak stops here — published, never armed.
944
+ if (hold) {
945
+ const pr = ghJson(['pr', 'view', branch, '--json', 'number,url,state']);
946
+ console.log(` ci-land: PR ${(pr && pr.url) || 'opened'} — auto-merge NOT armed: waiting for the artist.`);
947
+ return landHeld({ prUrl: (pr && pr.url) || null });
948
+ }
927
949
  console.log(' ci-land: enabling auto-merge (lands when required checks pass)...');
928
950
  // task 1718: an enable-failure is NOT a dead-end — the PR is open and the server
929
951
  // still lands green PRs. Don't return false (which stranded the builder at
@@ -1075,6 +1097,7 @@ module.exports = {
1075
1097
  LAND_BAIL_NO_TASK_BRANCH,
1076
1098
  landBailRecoverableByPr,
1077
1099
  landBailed,
1100
+ landHeld,
1078
1101
  landPending,
1079
1102
  landPendingLines,
1080
1103
  landPollAfterAutoMerge,
@@ -86,6 +86,46 @@ async function autoMerge(taskId, valueSummary, opts = {}, deps = {}) {
86
86
  return await land(taskId, branch, valueSummary);
87
87
  }
88
88
 
89
+ // regenerateShipArtifacts — every generated artifact a branch must carry fresh,
90
+ // in the order the land has always refreshed them. Moved out of mergeLocally
91
+ // unchanged (task 1004322) so the held publish below carries the same set.
92
+ function regenerateShipArtifacts() {
93
+ regenerateDiagrams();
94
+ // Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
95
+ regenerateRepoMap();
96
+ // Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
97
+ regenerateSessionIndex();
98
+ // Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
99
+ regenerateFileMap();
100
+ // Refresh the copy registry + inventory on the same beat (task 1003556). Its rows
101
+ // carry line numbers, so ANY edit to a scanned UI file staled it — and nothing in
102
+ // the ship path regenerated it, so the PR was born stale and CI said so.
103
+ regenerateCopyInventory();
104
+ // Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
105
+ regenerateApiDocs();
106
+ // ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
107
+ regenerateApiClient();
108
+ }
109
+
110
+ // publishHeld — the held pass's whole "land" (task 1004322, ADR 0341 D7). A page
111
+ // tweak whose grade passed waits at completed for its artist, and the Approval
112
+ // queue shows the APPLIED BRANCH, so the branch and its PR must exist now: a
113
+ // grade-parked ship otherwise never publishes, and the later approve would strand
114
+ // at no_branch_no_tip. This publishes, in every deploy mode, through the PR path
115
+ // (never a local merge to main) and stops: no auto-merge, no poll, no land.
116
+ // `deps` is a test seam only.
117
+ async function publishHeld(taskId, valueSummary, deps = {}) {
118
+ const branch = (deps.currentBranch || currentBranch)();
119
+ if (!branch || branch === 'main' || branch === 'HEAD') {
120
+ return landBailed(
121
+ `nothing to publish — this checkout is on ${branch}, not the task branch`,
122
+ onMainNextStep(taskId, (deps.localTaskBranchExists || localTaskBranchExists)(taskId)),
123
+ ); // untagged on purpose: only autoMerge routes by bail id
124
+ }
125
+ (deps.regenerate || regenerateShipArtifacts)();
126
+ return await (deps.ciLand || ciLand)(taskId, branch, valueSummary, { hold: true });
127
+ }
128
+
89
129
  async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
90
130
  const branch = currentBranch();
91
131
  if (!branch || branch === 'main' || branch === 'HEAD') {
@@ -116,21 +156,7 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
116
156
 
117
157
  // Refresh the data-driven diagrams before computing commits-ahead, so a
118
158
  // diagram-only refresh still counts as something to deploy.
119
- regenerateDiagrams();
120
- // Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
121
- regenerateRepoMap();
122
- // Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
123
- regenerateSessionIndex();
124
- // Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
125
- regenerateFileMap();
126
- // Refresh the copy registry + inventory on the same beat (task 1003556). Its rows
127
- // carry line numbers, so ANY edit to a scanned UI file staled it — and nothing in
128
- // the ship path regenerated it, so the PR was born stale and CI said so.
129
- regenerateCopyInventory();
130
- // Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
131
- regenerateApiDocs();
132
- // ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
133
- regenerateApiClient();
159
+ regenerateShipArtifacts();
134
160
 
135
161
  // Resolve the deploy mode ONCE (task 1093 / C9) — it reads config/deploy.json;
136
162
  // reused at the fetch-bail check + the ci-land branch below.
@@ -422,6 +448,8 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
422
448
 
423
449
  module.exports = {
424
450
  autoMerge,
451
+ // task 1004322: the held page tweak's publish-only path.
452
+ publishHeld,
425
453
  // task 1002572 (PART 2): the pure recovery command for a ship run from main —
426
454
  // exported so the "don't point back at the dead end" wording is pinned.
427
455
  onMainNextStep,