@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.
- package/.bongos-core.json +449 -34
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +106 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +26 -14
- package/docs/copy-registry.json +123 -15
- package/docs/design/reviews/hall-v2/README.md +124 -0
- package/docs/design/reviews/hall-v2/atlas--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/atlas--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/atlas--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/collab--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/collab--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/collab--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/copy-desk--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/copy-desk--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/copy-desk--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/diagrams--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/diagrams--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/diagrams--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/drachmae--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/drachmae--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/drachmae--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/gate--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/gate--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/gate--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/goals--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/goals--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/goals--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/government--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/government--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/government--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/harbor--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/harbor--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/harbor--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/idea--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/idea--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/idea--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/ideas--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/ideas--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/ideas--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/index--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/index--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/index--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/modules--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/modules--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/modules--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/not-ready--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/not-ready--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/not-ready--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/pair--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/pair--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/pair--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/people--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/people--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/people--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/primer--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/primer--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/primer--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/profile--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/profile--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/profile--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/ranks--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/ranks--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/ranks--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/roadmap--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/roadmap--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/roadmap--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/roster--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/roster--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/roster--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/sessions--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/sessions--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/sessions--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/settings--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/settings--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/settings--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/task--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/task--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/task--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/watch--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/watch--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/watch--xenos.webp +0 -0
- package/docs/design/reviews/hall-v2/work--archon.webp +0 -0
- package/docs/design/reviews/hall-v2/work--metic.webp +0 -0
- package/docs/design/reviews/hall-v2/work--xenos.webp +0 -0
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/proposals.js +278 -0
- package/modules/copy-desk/routes/copy-desk.js +173 -9
- package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
- package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/copy-desk.css +9 -1
- package/modules/hall-ui/public/copy-desk.html +33 -0
- package/modules/hall-ui/public/copy-desk.js +134 -3
- package/modules/hall-ui/public/copy-desk.states.json +18 -1
- package/modules/lifecycle/lifecycle.js +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +338 -0
- package/scripts/gds/copy-inventory.js +128 -30
- package/src/module-api.js +1 -1
- 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
|
-
|
|
127
|
-
|
|
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"
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
};
|