@bongos/core 1.19.492 → 1.19.494

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 (110) hide show
  1. package/.bongos-core.json +449 -34
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
  8. package/docs/adr/README.md +2 -0
  9. package/docs/api/openapi.json +106 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +26 -14
  12. package/docs/copy-registry.json +123 -15
  13. package/docs/design/reviews/hall-v2/README.md +124 -0
  14. package/docs/design/reviews/hall-v2/atlas--archon.webp +0 -0
  15. package/docs/design/reviews/hall-v2/atlas--metic.webp +0 -0
  16. package/docs/design/reviews/hall-v2/atlas--xenos.webp +0 -0
  17. package/docs/design/reviews/hall-v2/collab--archon.webp +0 -0
  18. package/docs/design/reviews/hall-v2/collab--metic.webp +0 -0
  19. package/docs/design/reviews/hall-v2/collab--xenos.webp +0 -0
  20. package/docs/design/reviews/hall-v2/copy-desk--archon.webp +0 -0
  21. package/docs/design/reviews/hall-v2/copy-desk--metic.webp +0 -0
  22. package/docs/design/reviews/hall-v2/copy-desk--xenos.webp +0 -0
  23. package/docs/design/reviews/hall-v2/diagrams--archon.webp +0 -0
  24. package/docs/design/reviews/hall-v2/diagrams--metic.webp +0 -0
  25. package/docs/design/reviews/hall-v2/diagrams--xenos.webp +0 -0
  26. package/docs/design/reviews/hall-v2/drachmae--archon.webp +0 -0
  27. package/docs/design/reviews/hall-v2/drachmae--metic.webp +0 -0
  28. package/docs/design/reviews/hall-v2/drachmae--xenos.webp +0 -0
  29. package/docs/design/reviews/hall-v2/gate--archon.webp +0 -0
  30. package/docs/design/reviews/hall-v2/gate--metic.webp +0 -0
  31. package/docs/design/reviews/hall-v2/gate--xenos.webp +0 -0
  32. package/docs/design/reviews/hall-v2/goals--archon.webp +0 -0
  33. package/docs/design/reviews/hall-v2/goals--metic.webp +0 -0
  34. package/docs/design/reviews/hall-v2/goals--xenos.webp +0 -0
  35. package/docs/design/reviews/hall-v2/government--archon.webp +0 -0
  36. package/docs/design/reviews/hall-v2/government--metic.webp +0 -0
  37. package/docs/design/reviews/hall-v2/government--xenos.webp +0 -0
  38. package/docs/design/reviews/hall-v2/harbor--archon.webp +0 -0
  39. package/docs/design/reviews/hall-v2/harbor--metic.webp +0 -0
  40. package/docs/design/reviews/hall-v2/harbor--xenos.webp +0 -0
  41. package/docs/design/reviews/hall-v2/idea--archon.webp +0 -0
  42. package/docs/design/reviews/hall-v2/idea--metic.webp +0 -0
  43. package/docs/design/reviews/hall-v2/idea--xenos.webp +0 -0
  44. package/docs/design/reviews/hall-v2/ideas--archon.webp +0 -0
  45. package/docs/design/reviews/hall-v2/ideas--metic.webp +0 -0
  46. package/docs/design/reviews/hall-v2/ideas--xenos.webp +0 -0
  47. package/docs/design/reviews/hall-v2/index--archon.webp +0 -0
  48. package/docs/design/reviews/hall-v2/index--metic.webp +0 -0
  49. package/docs/design/reviews/hall-v2/index--xenos.webp +0 -0
  50. package/docs/design/reviews/hall-v2/modules--archon.webp +0 -0
  51. package/docs/design/reviews/hall-v2/modules--metic.webp +0 -0
  52. package/docs/design/reviews/hall-v2/modules--xenos.webp +0 -0
  53. package/docs/design/reviews/hall-v2/not-ready--archon.webp +0 -0
  54. package/docs/design/reviews/hall-v2/not-ready--metic.webp +0 -0
  55. package/docs/design/reviews/hall-v2/not-ready--xenos.webp +0 -0
  56. package/docs/design/reviews/hall-v2/pair--archon.webp +0 -0
  57. package/docs/design/reviews/hall-v2/pair--metic.webp +0 -0
  58. package/docs/design/reviews/hall-v2/pair--xenos.webp +0 -0
  59. package/docs/design/reviews/hall-v2/people--archon.webp +0 -0
  60. package/docs/design/reviews/hall-v2/people--metic.webp +0 -0
  61. package/docs/design/reviews/hall-v2/people--xenos.webp +0 -0
  62. package/docs/design/reviews/hall-v2/primer--archon.webp +0 -0
  63. package/docs/design/reviews/hall-v2/primer--metic.webp +0 -0
  64. package/docs/design/reviews/hall-v2/primer--xenos.webp +0 -0
  65. package/docs/design/reviews/hall-v2/profile--archon.webp +0 -0
  66. package/docs/design/reviews/hall-v2/profile--metic.webp +0 -0
  67. package/docs/design/reviews/hall-v2/profile--xenos.webp +0 -0
  68. package/docs/design/reviews/hall-v2/ranks--archon.webp +0 -0
  69. package/docs/design/reviews/hall-v2/ranks--metic.webp +0 -0
  70. package/docs/design/reviews/hall-v2/ranks--xenos.webp +0 -0
  71. package/docs/design/reviews/hall-v2/roadmap--archon.webp +0 -0
  72. package/docs/design/reviews/hall-v2/roadmap--metic.webp +0 -0
  73. package/docs/design/reviews/hall-v2/roadmap--xenos.webp +0 -0
  74. package/docs/design/reviews/hall-v2/roster--archon.webp +0 -0
  75. package/docs/design/reviews/hall-v2/roster--metic.webp +0 -0
  76. package/docs/design/reviews/hall-v2/roster--xenos.webp +0 -0
  77. package/docs/design/reviews/hall-v2/sessions--archon.webp +0 -0
  78. package/docs/design/reviews/hall-v2/sessions--metic.webp +0 -0
  79. package/docs/design/reviews/hall-v2/sessions--xenos.webp +0 -0
  80. package/docs/design/reviews/hall-v2/settings--archon.webp +0 -0
  81. package/docs/design/reviews/hall-v2/settings--metic.webp +0 -0
  82. package/docs/design/reviews/hall-v2/settings--xenos.webp +0 -0
  83. package/docs/design/reviews/hall-v2/task--archon.webp +0 -0
  84. package/docs/design/reviews/hall-v2/task--metic.webp +0 -0
  85. package/docs/design/reviews/hall-v2/task--xenos.webp +0 -0
  86. package/docs/design/reviews/hall-v2/watch--archon.webp +0 -0
  87. package/docs/design/reviews/hall-v2/watch--metic.webp +0 -0
  88. package/docs/design/reviews/hall-v2/watch--xenos.webp +0 -0
  89. package/docs/design/reviews/hall-v2/work--archon.webp +0 -0
  90. package/docs/design/reviews/hall-v2/work--metic.webp +0 -0
  91. package/docs/design/reviews/hall-v2/work--xenos.webp +0 -0
  92. package/docs/file-map.md +2 -1
  93. package/docs/module-api-changelog.md +4 -0
  94. package/modules/copy-desk/module.json +2 -2
  95. package/modules/copy-desk/proposals.js +278 -0
  96. package/modules/copy-desk/routes/copy-desk.js +173 -9
  97. package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
  98. package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
  99. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  100. package/modules/hall-ui/public/copy-desk.css +9 -1
  101. package/modules/hall-ui/public/copy-desk.html +33 -0
  102. package/modules/hall-ui/public/copy-desk.js +134 -3
  103. package/modules/hall-ui/public/copy-desk.states.json +18 -1
  104. package/modules/lifecycle/lifecycle.js +6 -0
  105. package/package-lock.json +2 -2
  106. package/package.json +1 -1
  107. package/scripts/gds/copy-apply.js +338 -0
  108. package/scripts/gds/copy-inventory.js +128 -30
  109. package/src/module-api.js +1 -1
  110. package/tests/hall_collab_world.mjs +24 -4
@@ -34,6 +34,10 @@
34
34
  let queue = null;
35
35
  // The composer's current target, set when a "Flag" button is pressed.
36
36
  let target = null;
37
+ // The proposal composer's target (task 1003114 / R03), set from a "Propose
38
+ // wording" button. Kept apart from `target` because the two dialogs can carry
39
+ // different things: a flag can name a whole surface, a proposal never can.
40
+ let proposing = null;
37
41
  let closing = null;
38
42
  let dupePager = null;
39
43
 
@@ -123,8 +127,22 @@
123
127
  const note = f.status !== 'open' && f.resolution_note
124
128
  ? `<p class="cd-closed"><b>${escapeHtml(f.status)}</b> — ${escapeHtml(f.resolution_note)}</p>`
125
129
  : '';
126
- const actions = f.status === 'open' && canClose(f)
127
- ? `<div class="ov-row__actions"><button class="btn-ghost cd-close-btn" data-flag="${escapeHtml(String(f.id))}">Close…</button></div>`
130
+ // The queue is where the artist actually works, so the answer to a flag is
131
+ // offered right on it — but only for an open flag whose target is still
132
+ // there. A flag whose string has since been reworded (target_state 'gone')
133
+ // gets no propose button: there is nothing to patch, and the honest next
134
+ // step is a person deciding whether the flag is still live.
135
+ const proposable = f.status === 'open' && f.scope === 'string' && f.current
136
+ ? proposeButton({
137
+ id: f.string_id, surface: f.surface, text: f.current.text,
138
+ file: f.current.file, line: f.current.line, flag: f.id,
139
+ })
140
+ : '';
141
+ const closeable = f.status === 'open' && canClose(f)
142
+ ? `<button class="btn-ghost cd-close-btn" data-flag="${escapeHtml(String(f.id))}">Close…</button>`
143
+ : '';
144
+ const actions = proposable || closeable
145
+ ? `<div class="ov-row__actions">${proposable}${closeable}</div>`
128
146
  : '';
129
147
 
130
148
  return `<li class="ov-row" data-flag="${escapeHtml(String(f.id))}">
@@ -147,6 +165,20 @@
147
165
  data-id="${escapeHtml(d.id)}" data-surface="${escapeHtml(d.surface)}" data-text="${escapeHtml(d.text)}">Flag this…</button></div>`;
148
166
  }
149
167
 
168
+ // The propose button (task 1003114 / R03), offered ONLY where the row names one
169
+ // occurrence — file and line. That is not a UI shortcut: a proposal patches a
170
+ // specific place in a specific file, and a row that cannot say which place
171
+ // would be asking the artist to write a change nobody can land. The duplicates
172
+ // list is exactly that case, and it keeps just "Flag this…" — a sentence said
173
+ // in seven places usually wants saying once, not rewording seven times.
174
+ function proposeButton(d) {
175
+ if (!d.id || !d.file || !Number.isInteger(d.line)) return '';
176
+ return `<button class="btn-ghost cd-propose-btn"
177
+ data-id="${escapeHtml(d.id)}" data-surface="${escapeHtml(d.surface)}" data-text="${escapeHtml(d.text)}"
178
+ data-file="${escapeHtml(d.file)}" data-line="${escapeHtml(String(d.line))}"
179
+ ${d.flag ? `data-flag="${escapeHtml(String(d.flag))}"` : ''}>Propose wording…</button>`;
180
+ }
181
+
150
182
  // The duplicate's state. Colour marks the EXCEPTION here, the inverse of the
151
183
  // queue: most of this list is untouched work, so the untouched row is the
152
184
  // plain pill and colour goes to the few already in the queue or looked at.
@@ -182,7 +214,13 @@
182
214
  ${e.origin ? `<span class="ov-fact">${escapeHtml(e.origin)}</span>` : ''}
183
215
  </div>
184
216
  </div>
185
- <div class="ov-row__right">${e.flagged ? pill('fact-pill--open', 'already flagged') : flagButton(e)}</div>
217
+ <div class="ov-row__right">
218
+ <div class="ov-row__actions">
219
+ ${e.flagged ? pill('fact-pill--open', 'already flagged') : `<button class="btn-ghost cd-flag-btn"
220
+ data-id="${escapeHtml(e.id)}" data-surface="${escapeHtml(e.surface)}" data-text="${escapeHtml(e.text)}">Flag this…</button>`}
221
+ ${proposeButton(e)}
222
+ </div>
223
+ </div>
186
224
  </li>`;
187
225
  }
188
226
 
@@ -355,6 +393,85 @@
355
393
  if ($('#cd-search').value.trim()) await runSearch();
356
394
  }
357
395
 
396
+ // ---------------------------------------------------------------------------
397
+ // the proposal composer (task 1003114 / R03)
398
+ //
399
+ // The affordance the goal asked for, shaped so it does not become the thing
400
+ // the goal forbids. Nothing here writes a string: it fills in a change, the
401
+ // server turns it into a task, and the wording reaches a reader only after
402
+ // somebody claims that task and ships the diff.
403
+ // ---------------------------------------------------------------------------
404
+
405
+ const HOLE = '{…}';
406
+ const holes = (s) => String(s || '').split(HOLE).length - 1;
407
+
408
+ function openProposer(t) {
409
+ proposing = t;
410
+ $('#cd-propose-target').innerHTML = `${quote(t.text)}<br>${where(t.file, t.line)}`;
411
+ // PREFILLED with the current wording. An artist is editing a sentence, not
412
+ // composing one from nothing, and retyping it from the quote above is how a
413
+ // proposal accidentally changes a word nobody meant to change.
414
+ $('#cd-propose-text').value = t.text;
415
+ $('#cd-propose-why').value = '';
416
+ $('#cd-propose-err').textContent = '';
417
+
418
+ const n = holes(t.text);
419
+ const hint = $('#cd-propose-holes');
420
+ hint.hidden = n === 0;
421
+ if (n > 0) {
422
+ hint.textContent = `This sentence has ${n} ${HOLE} where a value is dropped in when it is shown. `
423
+ + `Keep all ${n} — move them if the new sentence needs them elsewhere, but do not remove one: `
424
+ + 'the values come from the code, and dropping one is a code change.';
425
+ }
426
+ $('#cd-propose-dialog').showModal();
427
+ }
428
+
429
+ async function submitProposal() {
430
+ const proposed = $('#cd-propose-text').value.replace(/\s+/g, ' ').trim();
431
+ const why = $('#cd-propose-why').value.trim();
432
+ const err = $('#cd-propose-err');
433
+
434
+ // The server and its floors are the real gates; these mirror them so the
435
+ // common mistakes answer instantly instead of costing a round trip.
436
+ if (!proposed) { err.textContent = 'Write the wording you want instead.'; return; }
437
+ if (proposed === String(proposing.text).replace(/\s+/g, ' ').trim()) {
438
+ err.textContent = 'That is the wording it already has. Change something, or file a flag instead if you only want to mark it.';
439
+ return;
440
+ }
441
+ if (holes(proposed) !== holes(proposing.text)) {
442
+ err.textContent = `Keep all ${holes(proposing.text)} ${HOLE} — they are the values the code drops in, and the change cannot land without them.`;
443
+ return;
444
+ }
445
+ if (why.replace(/\s+/g, ' ').length < 10) {
446
+ err.textContent = 'Say why it is better. Whoever reviews this decides it from your reason, without asking you.';
447
+ return;
448
+ }
449
+
450
+ const body = {
451
+ string_id: proposing.string_id,
452
+ proposed_text: proposed,
453
+ why,
454
+ file: proposing.file,
455
+ line: proposing.line,
456
+ };
457
+ if (proposing.flag_id) body.flag_id = Number(proposing.flag_id);
458
+
459
+ const res = await api.request('POST', `${API}/copy-desk/proposals`, { body });
460
+ if (!res.ok) {
461
+ const e = (res.data && res.data.error) || {};
462
+ const d = e.details || {};
463
+ err.textContent = d.what_this_means || d.why || e.message || 'Could not file the change.';
464
+ return;
465
+ }
466
+ $('#cd-propose-dialog').close();
467
+ const task = (res.data && res.data.task) || {};
468
+ toast(res.data && res.data.created === false
469
+ ? `Already filed — task #${task.id} carries this exact change.`
470
+ : `Filed as task #${task.id}. It lands when someone claims it.`);
471
+ await loadQueue();
472
+ if ($('#cd-search').value.trim()) await runSearch();
473
+ }
474
+
358
475
  function openCloser(id) {
359
476
  closing = queue.flags.find((f) => String(f.id) === String(id));
360
477
  if (!closing) return;
@@ -427,12 +544,26 @@
427
544
  });
428
545
  return;
429
546
  }
547
+ const proposeBtn = ev.target.closest('.cd-propose-btn');
548
+ if (proposeBtn) {
549
+ openProposer({
550
+ string_id: proposeBtn.dataset.id,
551
+ surface: proposeBtn.dataset.surface,
552
+ text: proposeBtn.dataset.text,
553
+ file: proposeBtn.dataset.file,
554
+ line: Number(proposeBtn.dataset.line),
555
+ flag_id: proposeBtn.dataset.flag || null,
556
+ });
557
+ return;
558
+ }
430
559
  const closeBtn = ev.target.closest('.cd-close-btn');
431
560
  if (closeBtn) openCloser(closeBtn.dataset.flag);
432
561
  });
433
562
 
434
563
  $('#cd-cancel').addEventListener('click', () => $('#cd-dialog').close());
435
564
  $('#cd-submit').addEventListener('click', submitFlag);
565
+ $('#cd-propose-cancel').addEventListener('click', () => $('#cd-propose-dialog').close());
566
+ $('#cd-propose-submit').addEventListener('click', submitProposal);
436
567
  $('#cd-close-cancel').addEventListener('click', () => $('#cd-close-dialog').close());
437
568
  $('#cd-close-submit').addEventListener('click', submitClose);
438
569
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "Copy desk — the one-voice review queue. Its two dialogs are real <dialog>s, so their ::backdrop is the sheet's single sanctioned literal (black with alpha as a scrim). Written with the re-skin (task 1003307) so this page is MEASURED at three widths in both modes rather than looked at once; the redesign (task 1003471) put a queue, ten duplicates and a search's hits in the harness so the rows and both dialogs can be looked at.",
2
+ "_": "Copy desk — the one-voice review queue. Its three dialogs are real <dialog>s, so their ::backdrop is the sheet's single sanctioned literal (black with alpha as a scrim). Written with the re-skin (task 1003307) so this page is MEASURED at three widths in both modes rather than looked at once; the redesign (task 1003471) put a queue, ten duplicates and a search's hits in the harness so the rows and the dialogs can be looked at; task 1003114 (R03) added the proposal composer and the `proposer` state that opens it.",
3
3
  "page": "/builders/copy-desk",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
@@ -102,6 +102,23 @@
102
102
  250
103
103
  ]
104
104
  ]
105
+ },
106
+ "proposer": {
107
+ "auth": true,
108
+ "actions": [
109
+ [
110
+ "wait",
111
+ 700
112
+ ],
113
+ [
114
+ "clickIfVisible",
115
+ ".cd-propose-btn"
116
+ ],
117
+ [
118
+ "wait",
119
+ 250
120
+ ]
121
+ ]
105
122
  }
106
123
  },
107
124
  "reducedMotion": {
@@ -53,6 +53,12 @@ module.exports = {
53
53
  // nobody surfaces is merely stored.
54
54
  openTaskRecommendationsFor: (...a) => taskRecommendations.countOpenForRecipient(...a),
55
55
  getTask: (...a) => db.getTask(...a),
56
+ // findTaskBySourceRef: the create-vector idempotency read (task 1003114 / R03
57
+ // of goal 1000074). copy-desk turns an artist's copy proposal into a task and
58
+ // needs the same "this (source, source_ref) already minted a task" check the
59
+ // core POST /tasks vector does, so a double-submitted proposal resolves to the
60
+ // task it already made instead of littering the board with twins.
61
+ findTaskBySourceRef: (...a) => db.findTaskBySourceRef(...a),
56
62
  listVersions: (...a) => db.listVersions(...a),
57
63
  versionProgress: (...a) => db.versionProgress(...a),
58
64
  recentShippedTasks: (...a) => db.recentShippedTasks(...a),
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.492",
3
+ "version": "1.19.494",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.492",
9
+ "version": "1.19.494",
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.492",
3
+ "version": "1.19.494",
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",
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/copy-apply.js — land a copy proposal as a real diff (R03 of goal
5
+ // 1000074, the artist's loop; task 1003114).
6
+ //
7
+ // WHAT IT IS FOR. R03's done-when is that "an artist with no terminal can propose
8
+ // a copy change that reaches main through a claim". The copy desk gives them the
9
+ // first half: a proposal becomes a GDS task carrying a patch
10
+ // (modules/copy-desk/proposals.js). This command is the second half — the thing
11
+ // whoever claims that task runs. It is the direct analogue of `adapter.export()`
12
+ // in the design half (ADR 0081, modules/ui-design/adapters/*): tool output lands
13
+ // in the repo as a committed diff under a claim, with a path guard, and the repo
14
+ // stays the source of truth. Nothing about the copy path bypasses the ledger,
15
+ // because this command IS the ledger's hands.
16
+ //
17
+ // WHY IT RE-DERIVES INSTEAD OF TRUSTING THE PATCH. The patch names a file and a
18
+ // line, but a line number is stale the moment anything above it changes, and the
19
+ // registry's committed rows deliberately carry no character offsets (see the
20
+ // note in copy-inventory.js's buildRegistry). So this re-runs R01's OWN
21
+ // extractor over the working tree and finds the occurrence there. One set of
22
+ // matching rules, in one file, used by both the inventory and the applier —
23
+ // which is what stops this command from editing a byte the inventory would never
24
+ // have called copy.
25
+ //
26
+ // THE REFUSALS ARE THE FEATURE. A copy change that lands on the wrong characters
27
+ // is worse than one that does not land, so every ambiguity is a named refusal
28
+ // with the reason on one line: the string is gone, it moved and is no longer
29
+ // unique, its literal is escaped so no exact span exists, the placeholder
30
+ // arithmetic does not hold, or the file is outside a registry surface. None of
31
+ // them means the wording was wrong — they mean a person should look.
32
+ //
33
+ // Run: node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
34
+ // node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
35
+ // node scripts/gds/copy-apply.js --task 1234 (a named task, when you hold more than one)
36
+ // node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
37
+ //
38
+ // WHY THE BARE FORM DEFAULTS TO YOUR CLAIM rather than taking an id. The task a
39
+ // proposal creates has to print the exact line that lands it, and it cannot know
40
+ // its own id at the moment its description is written. Every workaround for that
41
+ // is worse than the rule it would paper over: this command may only be run under
42
+ // a claim (that is the whole point of the feature), and a claim already names a
43
+ // task. So the bare call reads the claim, and holding no claim is a refusal that
44
+ // says to go and claim the task — the methodology's own rule, enforced by the
45
+ // command instead of restated in its output.
46
+
47
+ const fs = require('node:fs');
48
+ const path = require('node:path');
49
+
50
+ const ci = require('./copy-inventory.js');
51
+ const proposals = require('../../modules/copy-desk/proposals.js');
52
+ const { apiCall, arg, hasFlag, cliExit } = require('./cli-lib.js');
53
+
54
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
55
+ const PLACEHOLDER = proposals.PLACEHOLDER;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // PURE below this line, down to `main`. The tests drive these directly with
59
+ // string sources — no fs, no network, no task.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ // THE PATH GUARD, and it is the same rule the design adapter enforces: an export
63
+ // may only write inside the surfaces the contract knows about. Here the surface
64
+ // roots come from R01's own SURFACES table, so a patch can only ever touch a
65
+ // directory the inventory actually walks. A patch naming anything else — a
66
+ // traversal, a config file, a migration — is refused before a single byte is
67
+ // read, not after.
68
+ function surfaceRootFor(surfaceId) {
69
+ const surface = ci.SURFACES.find((s) => s.id === surfaceId);
70
+ return surface ? surface.at : null;
71
+ }
72
+
73
+ function checkPatchPath(patch) {
74
+ const root = surfaceRootFor(patch.surface);
75
+ if (!root) return { ok: false, code: 'unknown_surface', detail: { surface: patch.surface, known: ci.SURFACES.map((s) => s.id) } };
76
+ // Compare normalized, repo-relative, forward-slash paths — the same form the
77
+ // registry stores — and require the file to sit under its surface's root.
78
+ const rel = String(patch.file).replace(/\\/g, '/');
79
+ const abs = path.resolve(REPO_ROOT, rel);
80
+ const rootAbs = path.resolve(REPO_ROOT, root);
81
+ if (!abs.startsWith(rootAbs + path.sep)) {
82
+ return {
83
+ ok: false,
84
+ code: 'path_outside_surface',
85
+ detail: { file: rel, surface: patch.surface, must_be_under: root },
86
+ };
87
+ }
88
+ return { ok: true, rel, abs };
89
+ }
90
+
91
+ // Find the one occurrence this patch is about, in the tree as it stands now.
92
+ //
93
+ // Matching is on (origin, current text) within the file, NOT on the line: a line
94
+ // number is the first thing to go stale, and the registry id is explicitly
95
+ // designed to survive a string moving. The line is used only to disambiguate
96
+ // when the same sentence appears at several sites in one file — which is the
97
+ // duplicate case R01 already reports, and where guessing would be indefensible.
98
+ function locateOccurrence(source, rel, patch) {
99
+ const rows = (path.extname(rel) === '.html'
100
+ ? ci.fromHtml({ source, rel })
101
+ : ci.fromJs({ source, rel }))
102
+ .filter((r) => r.origin === patch.origin && r.text === patch.current);
103
+
104
+ if (!rows.length) {
105
+ return {
106
+ ok: false,
107
+ code: 'target_gone',
108
+ detail: {
109
+ file: rel,
110
+ looked_for: patch.current,
111
+ what_this_means: 'That exact wording is no longer in this file — it has already been edited, or the proposal was written against an older tree. Nothing was changed. Re-read the string on the copy desk and propose again if it still needs work.',
112
+ },
113
+ };
114
+ }
115
+ let row = rows[0];
116
+ if (rows.length > 1) {
117
+ const onLine = rows.filter((r) => r.line === patch.line);
118
+ if (onLine.length !== 1) {
119
+ return {
120
+ ok: false,
121
+ code: 'ambiguous_target',
122
+ detail: {
123
+ file: rel,
124
+ occurrences: rows.length,
125
+ lines: rows.map((r) => r.line),
126
+ what_this_means: 'This sentence appears more than once in this file and the proposal cannot say which one it meant. That is the duplicate case the copy desk reports — decide whether they should all change, and land it as an ordinary edit under this claim.',
127
+ },
128
+ };
129
+ }
130
+ row = onLine[0];
131
+ }
132
+ if (row.start === undefined) {
133
+ return {
134
+ ok: false,
135
+ code: 'no_exact_span',
136
+ detail: {
137
+ file: rel,
138
+ line: row.line,
139
+ what_this_means: 'This string lives inside an escaped quoted literal, so the inventory cannot point at its exact characters and this command will not guess. Edit it by hand under this claim.',
140
+ },
141
+ };
142
+ }
143
+ return { ok: true, row };
144
+ }
145
+
146
+ // Rebuild the replacement text, re-injecting the source's own interpolations.
147
+ //
148
+ // The registry writes `{…}` where the source has `${expr}`, so the artist edits a
149
+ // readable sentence with holes in it. Landing it means putting the ORIGINAL
150
+ // expressions back, in order — the k-th hole takes the k-th expression. The
151
+ // count was already checked when the proposal was made; it is checked again here
152
+ // against the real source, because the source is what is about to be written.
153
+ function reinjectInterpolations(rawSpan, proposedText) {
154
+ const expressions = String(rawSpan).match(/\$\{[^{}]*\}/g) || [];
155
+ const holes = proposals.placeholderCount(proposedText);
156
+ if (holes !== expressions.length) {
157
+ return {
158
+ ok: false,
159
+ code: 'placeholder_mismatch',
160
+ detail: {
161
+ expected: expressions.length,
162
+ got: holes,
163
+ placeholder: PLACEHOLDER,
164
+ what_this_means: 'The values this sentence drops in at render time do not line up with the proposed wording. Nothing was changed.',
165
+ },
166
+ };
167
+ }
168
+ let out = '';
169
+ let rest = String(proposedText);
170
+ for (const expr of expressions) {
171
+ const at = rest.indexOf(PLACEHOLDER);
172
+ out += rest.slice(0, at) + expr;
173
+ rest = rest.slice(at + PLACEHOLDER.length);
174
+ }
175
+ return { ok: true, text: out + rest };
176
+ }
177
+
178
+ // The whole edit, as a pure function of the file's current contents and a patch.
179
+ // Returns the new source — or a named refusal. Writes nothing.
180
+ function applyPatchToSource(source, rel, patch) {
181
+ const located = locateOccurrence(source, rel, patch);
182
+ if (!located.ok) return located;
183
+ const { row } = located;
184
+ const rawSpan = source.slice(row.start, row.end);
185
+ const rebuilt = reinjectInterpolations(rawSpan, patch.proposed);
186
+ if (!rebuilt.ok) return rebuilt;
187
+ return {
188
+ ok: true,
189
+ line: row.line,
190
+ before: rawSpan,
191
+ after: rebuilt.text,
192
+ source: source.slice(0, row.start) + rebuilt.text + source.slice(row.end),
193
+ };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // The command.
198
+ // ---------------------------------------------------------------------------
199
+
200
+ function refuse(result) {
201
+ const detail = result.detail || {};
202
+ console.error(`copy-apply: REFUSED (${result.code})`);
203
+ if (detail.what_this_means) console.error(` ${detail.what_this_means}`);
204
+ for (const [k, v] of Object.entries(detail)) {
205
+ if (k === 'what_this_means') continue;
206
+ console.error(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`);
207
+ }
208
+ return 2;
209
+ }
210
+
211
+ // The task this session is holding. Returns a named refusal rather than an id
212
+ // when there is nothing to act under — "claim it first" is the correct answer,
213
+ // not an inconvenience to route around.
214
+ async function claimedTaskId() {
215
+ const me = await apiCall('GET', '/api/gds/me');
216
+ if (!me.ok) return { ok: false, code: 'no_session', detail: { status: me.status, run: 'node scripts/gds/setup.js' } };
217
+ const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
218
+ if (!claims.length) {
219
+ return {
220
+ ok: false,
221
+ code: 'no_active_claim',
222
+ detail: {
223
+ what_this_means: 'A copy change lands under a claim like every other change. Claim the proposal task first (node scripts/gds/claim.js <id>), then run this again — or name one with --task <id>.',
224
+ },
225
+ };
226
+ }
227
+ if (claims.length > 1) {
228
+ return {
229
+ ok: false,
230
+ code: 'several_active_claims',
231
+ detail: {
232
+ held: claims.map((c) => c.task_id),
233
+ what_this_means: 'You hold more than one claim, so this command will not guess which proposal you meant. Name it with --task <id>.',
234
+ },
235
+ };
236
+ }
237
+ return { ok: true, taskId: claims[0].task_id };
238
+ }
239
+
240
+ async function loadPatch({ taskId, patchFile }) {
241
+ if (patchFile) {
242
+ let raw;
243
+ try {
244
+ raw = fs.readFileSync(path.resolve(process.cwd(), patchFile), 'utf8');
245
+ } catch (err) {
246
+ return { ok: false, code: 'patch_file_unreadable', detail: { reason: err.message } };
247
+ }
248
+ // A bare patch file is still parsed through the SAME reader the task path
249
+ // uses, so the local shortcut can never accept a shape the real path rejects.
250
+ return proposals.parseProposalBlock(raw.trim().startsWith('{')
251
+ ? ['```' + proposals.FENCE, raw.trim(), '```'].join('\n')
252
+ : raw);
253
+ }
254
+ const res = await apiCall('GET', `/api/gds/tasks/${taskId}`);
255
+ if (!res.ok) {
256
+ return { ok: false, code: 'task_unreadable', detail: { task: taskId, status: res.status } };
257
+ }
258
+ const task = (res.data && res.data.task) || {};
259
+ const parsed = proposals.parseProposalBlock(task.description || '');
260
+ if (!parsed.ok) return parsed;
261
+ return { ok: true, patch: parsed.patch, task };
262
+ }
263
+
264
+ async function main() {
265
+ let taskId = arg('--task');
266
+ const patchFile = arg('--patch-file');
267
+ const dryRun = hasFlag('--dry-run');
268
+
269
+ if (!taskId && !patchFile) {
270
+ const held = await claimedTaskId();
271
+ if (!held.ok) return cliExit(refuse(held));
272
+ taskId = held.taskId;
273
+ console.log(`copy-apply: the task you are holding — #${taskId}`);
274
+ }
275
+
276
+ const loaded = await loadPatch({ taskId, patchFile });
277
+ if (!loaded.ok) return cliExit(refuse(loaded));
278
+ const { patch } = loaded;
279
+
280
+ const guard = checkPatchPath(patch);
281
+ if (!guard.ok) return cliExit(refuse(guard));
282
+
283
+ let source;
284
+ try {
285
+ source = fs.readFileSync(guard.abs, 'utf8');
286
+ } catch (err) {
287
+ return cliExit(refuse({ code: 'file_unreadable', detail: { file: guard.rel, reason: err.message } }));
288
+ }
289
+
290
+ const applied = applyPatchToSource(source, guard.rel, patch);
291
+ if (!applied.ok) return cliExit(refuse(applied));
292
+
293
+ console.log(`copy-apply: ${guard.rel}:${applied.line} (${patch.origin})`);
294
+ console.log(` - ${applied.before}`);
295
+ console.log(` + ${applied.after}`);
296
+
297
+ if (dryRun) {
298
+ console.log('copy-apply: --dry-run, nothing written.');
299
+ return cliExit(0);
300
+ }
301
+
302
+ fs.writeFileSync(guard.abs, applied.source);
303
+
304
+ // Regenerate the inventory in the same breath. The registry is a committed
305
+ // artifact whose CI check fails on drift, so an applier that edited a surface
306
+ // and left the registry describing the old tree would hand the builder a
307
+ // red build and a puzzle. It also gives us the verification below.
308
+ const { reg } = ci.write();
309
+
310
+ // VERIFY OUR OWN WORK, because a copy change that lands on the wrong characters
311
+ // is the failure this whole command is shaped to avoid — and the registry we
312
+ // just rebuilt is an independent read of the file we just wrote.
313
+ const nowThere = reg.entries.some((e) => e.file === guard.rel && e.text === patch.proposed);
314
+ if (!nowThere) {
315
+ console.error('copy-apply: WROTE THE FILE, but the regenerated registry does not contain the new wording at that path.');
316
+ console.error(' The edit landed somewhere the inventory does not read as copy. Review `git diff` before doing anything else.');
317
+ return cliExit(3);
318
+ }
319
+
320
+ console.log(`copy-apply: applied, registry regenerated (${reg.counts.strings} strings).`);
321
+ console.log(' Review with `git diff`, then ship this task the normal way.');
322
+ return cliExit(0);
323
+ }
324
+
325
+ if (require.main === module) {
326
+ main().catch((err) => {
327
+ console.error(`copy-apply: ${err && err.message ? err.message : err}`);
328
+ process.exitCode = 1;
329
+ });
330
+ }
331
+
332
+ module.exports = {
333
+ surfaceRootFor,
334
+ checkPatchPath,
335
+ locateOccurrence,
336
+ reinjectInterpolations,
337
+ applyPatchToSource,
338
+ };