@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
@@ -1,5 +1,6 @@
1
1
  // modules/copy-desk/tests/copy_no_cms.mjs — the goal's hard NON-GOAL, made
2
- // testable (task 1003113 / R02 of goal 1000074).
2
+ // testable (task 1003113 / R02, brought to its full version by task 1003114 /
3
+ // R03 of goal 1000074).
3
4
  //
4
5
  // Goal 1000074, verbatim: "NO LIVE CMS. Every copy change is a claimed, graded,
5
6
  // shipped GDS task (the methodology's hard rule). The registry is an inventory
@@ -10,16 +11,25 @@
10
11
  // this one erodes is not a dramatic "add a CMS" commit — it is one plausible
11
12
  // column ("just store the suggested wording so the artist doesn't lose it"),
12
13
  // then one plausible read of that column, and the ledger rule is dead with
13
- // nobody having decided to kill it. So the shape is pinned here:
14
+ // nobody having decided to kill it.
14
15
  //
15
- // * the write surface is EXACTLY the two verbs R02 defines,
16
- // * no column on copy_desk_flags could hold replacement text, and
17
- // * nothing in this module ever hands a renderer a string as authoritative copy.
16
+ // R02 pinned the shape while the module could not edit anything at all. R03 is
17
+ // the harder case and the reason this file exists: the module now DOES carry an
18
+ // editing affordance, so "no CMS" has to survive contact with a feature whose
19
+ // whole job is proposing new words. It survives because of where the words go.
20
+ // A proposal becomes a TASK — the ledger every change already passes through,
21
+ // which no renderer reads — and the wording reaches a person only after somebody
22
+ // claims that task and ships the diff. So the four things pinned here are:
18
23
  //
19
- // R03 (task 1003114) builds the editing affordance, and its own done-when asks
20
- // for the full version of this test. This is the R02-scoped seed of it: when R03
21
- // adds a proposal path, the expected route list below changes DELIBERATELY, in a
22
- // diff a reviewer can see, rather than drifting.
24
+ // * the write surface is EXACTLY the three verbs the module defines,
25
+ // * no column on copy_desk_flags could hold replacement text,
26
+ // * nothing in this module writes a file, and
27
+ // * the ONLY thing that writes a surface file is the applier, it lives outside
28
+ // the request path, and it cannot write outside a registry surface.
29
+ //
30
+ // R02's version of this file said: "when R03 adds a proposal path, the expected
31
+ // route list below changes DELIBERATELY, in a diff a reviewer can see, rather
32
+ // than drifting." This is that diff.
23
33
 
24
34
  import { strict as assert } from 'node:assert';
25
35
  import fs from 'node:fs';
@@ -28,6 +38,7 @@ import url from 'node:url';
28
38
 
29
39
  const HERE = path.dirname(url.fileURLToPath(import.meta.url));
30
40
  const MODULE_DIR = path.join(HERE, '..');
41
+ const REPO_ROOT = path.join(MODULE_DIR, '..', '..');
31
42
 
32
43
  let passed = 0;
33
44
  let failed = 0;
@@ -37,6 +48,7 @@ async function test(name, fn) {
37
48
  }
38
49
 
39
50
  const read = (rel) => fs.readFileSync(path.join(MODULE_DIR, rel), 'utf8');
51
+ const readRepo = (rel) => fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8');
40
52
 
41
53
  // Strip comments so the prose ABOUT the rule (which necessarily names the
42
54
  // forbidden things — the migration header says in so many words that a flag
@@ -49,7 +61,7 @@ const stripComments = (src) => src
49
61
 
50
62
  console.log('copy-desk: NO LIVE CMS');
51
63
 
52
- await test('the write surface is exactly the two verbs R02 defines', () => {
64
+ await test('the write surface is exactly the three verbs the module defines', () => {
53
65
  const src = stripComments(read('routes/copy-desk.js'));
54
66
  const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
55
67
  .map((m) => `${m[1].toUpperCase()} ${m[2]}`)
@@ -57,6 +69,7 @@ await test('the write surface is exactly the two verbs R02 defines', () => {
57
69
  assert.deepEqual(found, [
58
70
  'POST /copy-desk/flags',
59
71
  'POST /copy-desk/flags/:id/close',
72
+ 'POST /copy-desk/proposals',
60
73
  ], 'a new write route on this module must be a deliberate, reviewed change to this list');
61
74
  });
62
75
 
@@ -69,6 +82,16 @@ await test('no column on copy_desk_flags could hold replacement text', () => {
69
82
  `copy_desk_flags must carry a judgement and a pointer, never replacement copy (found "${hit && hit[0]}")`);
70
83
  });
71
84
 
85
+ await test('R03 added no migration — a proposal is a task, not a row here', () => {
86
+ // The load-bearing one. R03's obvious-but-wrong implementation is a
87
+ // `copy_desk_proposals` table, which is the forbidden column with extra steps:
88
+ // once the wording lives in this module's schema, one plausible read of it
89
+ // turns the module into the copy source. The proposal lives on the TASK.
90
+ const migrations = fs.readdirSync(path.join(MODULE_DIR, 'migrations')).sort();
91
+ assert.deepEqual(migrations, ['copy_desk_001_flags.sql'],
92
+ 'a second migration in this module needs a hard look at whether it stores copy');
93
+ });
94
+
72
95
  await test('the only text column is the historical snapshot, and it is never the live source', () => {
73
96
  const sql = stripComments(read('migrations/copy_desk_001_flags.sql'));
74
97
  const textish = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
@@ -87,17 +110,72 @@ await test('this module never writes to the surfaces it inventories', () => {
87
110
  // The registry is generated by R01 and committed. If this module ever gained a
88
111
  // write to docs/copy-registry.json — or to any surface file — the inventory
89
112
  // would stop describing the tree and start describing itself.
90
- for (const rel of ['registry.js', 'flags.js', 'routes/copy-desk.js']) {
113
+ for (const rel of ['registry.js', 'flags.js', 'proposals.js', 'routes/copy-desk.js']) {
91
114
  const src = stripComments(read(rel));
92
115
  assert.equal(/fs\.(writeFile|writeFileSync|appendFile|appendFileSync|rm|unlink|mkdir)/.test(src), false,
93
116
  `${rel} performs a filesystem WRITE — this module reads the registry and never writes it`);
94
117
  }
95
118
  });
96
119
 
120
+ await test('the proposal domain holds no path to a renderer — it composes text and parses it back', () => {
121
+ // proposals.js is the file that handles replacement wording, so it is the file
122
+ // most able to become a copy source by accident. It must be PURE: no fs, no
123
+ // DB, no network. Its output goes into a task description and nowhere else.
124
+ const src = stripComments(read('proposals.js'));
125
+ for (const forbidden of ['require(', 'fs.', 'pool', 'fetch(']) {
126
+ assert.equal(src.includes(forbidden), false,
127
+ `proposals.js must stay pure — it must not reach for ${forbidden}`);
128
+ }
129
+ });
130
+
131
+ await test('the proposal route creates a TASK and does not write a string anywhere', () => {
132
+ const src = stripComments(read('routes/copy-desk.js'));
133
+ const route = src.slice(src.indexOf("router.post('/copy-desk/proposals'"));
134
+ assert.ok(/lifecycle\.createTask\(/.test(route),
135
+ 'the proposal path must land in the ledger via lifecycle.createTask');
136
+ // No INSERT/UPDATE of this module's own tables on the proposal path — the
137
+ // wording must not come to rest in copy_desk_*.
138
+ assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE /i.test(route), false,
139
+ 'the proposal path must not write a row in this module — the task IS the record');
140
+ });
141
+
97
142
  await test('the module manifest declares no seam another module could write copy through', () => {
98
143
  const manifest = JSON.parse(read('module.json'));
99
144
  assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
100
- assert.deepEqual(manifest.consumes, []);
145
+ // R03 consumes `lifecycle` to create the proposal task. That direction is the
146
+ // safe one and the point: this module can put a work request INTO the ledger,
147
+ // and nothing can pull copy OUT of this module.
148
+ assert.deepEqual(manifest.consumes, ['lifecycle']);
149
+ });
150
+
151
+ await test('the applier is the only writer, it lives outside the request path, and it is path-guarded', () => {
152
+ // The one thing in this feature that does write a surface file. Three
153
+ // properties make that safe, and all three are pinned here.
154
+ const applier = readRepo('scripts/gds/copy-apply.js');
155
+ const stripped = stripComments(applier);
156
+
157
+ // 1. It is a COMMAND, not a route: nothing in the module's request path
158
+ // IMPORTS it, so no HTTP call can cause a file write. Naming it is fine and
159
+ // in fact required — the task a proposal creates tells its claimer the
160
+ // exact line to run, and that instruction is the whole handoff. What must
161
+ // not exist is a require/import edge, which would put a file write one
162
+ // function call away from a request handler.
163
+ for (const rel of ['registry.js', 'flags.js', 'proposals.js', 'routes/copy-desk.js']) {
164
+ const src = stripComments(read(rel));
165
+ assert.equal(/(?:require|import)\s*\(?\s*['"][^'"]*copy-apply/.test(src), false,
166
+ `${rel} must not IMPORT the applier — it runs under a claim, not under a request`);
167
+ }
168
+
169
+ // 2. It writes only inside a surface the REGISTRY defines, and the guard is
170
+ // derived from R01's own SURFACES table rather than a second list.
171
+ assert.ok(/path_outside_surface/.test(stripped), 'the applier must refuse a path outside its surface');
172
+ assert.ok(/ci\.SURFACES/.test(stripped), 'the path guard must derive from the inventory, not a hardcoded root');
173
+
174
+ // 3. It verifies its own work against a freshly regenerated registry, so an
175
+ // edit that lands somewhere the inventory does not read as copy is
176
+ // reported rather than committed quietly.
177
+ assert.ok(/regenerated registry does not contain the new wording/.test(applier),
178
+ 'the applier must check that what it wrote is what the inventory now reads');
101
179
  });
102
180
 
103
181
  console.log(`\n${passed} passed, ${failed} failed`);
@@ -0,0 +1,279 @@
1
+ // modules/copy-desk/tests/copy_proposals.mjs — the proposal, end to end as far
2
+ // as a DB-free test can take it (task 1003114 / R03 of goal 1000074).
3
+ //
4
+ // copy_no_cms.mjs pins what this feature must NEVER do. This file pins what it
5
+ // must DO, and the interesting half is the applier: an artist's wording has to
6
+ // land on exactly the characters that string occupies and on nothing else. A
7
+ // copy change that lands on the wrong bytes is worse than one that never lands,
8
+ // so most of what follows is the refusals.
9
+ //
10
+ // The two halves are tested against each other deliberately — `composeProposal
11
+ // Block` writes what `parseProposalBlock` reads, and the applier consumes the
12
+ // parse — because a format with one writer and one reader is exactly the kind
13
+ // that drifts silently until a real file gets edited wrong.
14
+ //
15
+ // DB-free and fs-free: the applier's pure layer takes a source STRING, so every
16
+ // case below is a hand-written file that never touches the tree.
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import path from 'node:path';
20
+ import url from 'node:url';
21
+ import { createRequire } from 'node:module';
22
+
23
+ const HERE = path.dirname(url.fileURLToPath(import.meta.url));
24
+ const require_ = createRequire(import.meta.url);
25
+ const proposals = require_(path.join(HERE, '..', 'proposals.js'));
26
+ const applier = require_(path.join(HERE, '..', '..', '..', 'scripts', 'gds', 'copy-apply.js'));
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ async function test(name, fn) {
31
+ try { await fn(); passed++; console.log(` ok ${name}`); }
32
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
33
+ }
34
+
35
+ // A registry entry, the shape registry.occurrencesOf hands back.
36
+ const entry = (over = {}) => ({
37
+ id: 'abc123abc123',
38
+ surface: 'builders-hall',
39
+ file: 'modules/hall-ui/public/copy-desk.js',
40
+ line: 42,
41
+ origin: 'js-markup',
42
+ text: 'Loading the copy desk…',
43
+ ...over,
44
+ });
45
+
46
+ const HALL = 'modules/hall-ui/public/t.js';
47
+ const HALL_HTML = 'modules/hall-ui/public/t.html';
48
+
49
+ console.log('copy-desk: proposals');
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // The judgement floor. Same argument as a flag's reason (ADR 0178 §2): a
53
+ // proposal with no case for it costs a reviewer more than no proposal.
54
+ // ---------------------------------------------------------------------------
55
+
56
+ await test('a proposal needs a reason a reviewer can decide on', () => {
57
+ const r = proposals.normalizeProposalPayload({
58
+ body: { proposed_text: 'Opening the copy desk…', why: 'better' },
59
+ entry: entry(),
60
+ });
61
+ assert.equal(r.ok, false);
62
+ assert.equal(r.code, 'why_too_thin');
63
+ assert.ok(r.detail.what_this_means.length > 0, 'the refusal has to tell the person what to do');
64
+ });
65
+
66
+ await test('a proposal that changes nothing is refused rather than filed', () => {
67
+ const r = proposals.normalizeProposalPayload({
68
+ body: { proposed_text: 'Loading the copy desk…', why: 'This wording reads as machine-written to me.' },
69
+ entry: entry(),
70
+ });
71
+ assert.equal(r.ok, false);
72
+ assert.equal(r.code, 'proposed_text_unchanged');
73
+ });
74
+
75
+ await test('the target facts are taken from the registry, never from the body', () => {
76
+ const r = proposals.normalizeProposalPayload({
77
+ // A caller trying to describe a different target than the one they named.
78
+ body: {
79
+ proposed_text: 'Opening the copy desk…',
80
+ why: 'The current line describes the machine, not the person waiting.',
81
+ file: 'migrations/core_001_secrets.sql',
82
+ line: 1,
83
+ current: 'something else entirely',
84
+ surface: 'not-a-surface',
85
+ },
86
+ entry: entry(),
87
+ builderId: 7,
88
+ });
89
+ assert.equal(r.ok, true);
90
+ assert.equal(r.patch.file, 'modules/hall-ui/public/copy-desk.js', 'file must come from the registry entry');
91
+ assert.equal(r.patch.surface, 'builders-hall');
92
+ assert.equal(r.patch.line, 42);
93
+ assert.equal(r.patch.current, 'Loading the copy desk…', 'the current wording is a server-side fact');
94
+ });
95
+
96
+ await test('the placeholder count must hold, and the refusal explains why', () => {
97
+ const bad = proposals.normalizeProposalPayload({
98
+ body: { proposed_text: 'Welcome back to the hall.', why: 'Shorter and warmer than the current line.' },
99
+ entry: entry({ text: 'Hello {…}, welcome back to the hall.' }),
100
+ });
101
+ assert.equal(bad.ok, false);
102
+ assert.equal(bad.code, 'placeholder_mismatch');
103
+ assert.equal(bad.detail.expected, 1);
104
+ assert.equal(bad.detail.got, 0);
105
+
106
+ const good = proposals.normalizeProposalPayload({
107
+ body: { proposed_text: 'Good to see you, {…} — welcome back.', why: 'Shorter and warmer than the current line.' },
108
+ entry: entry({ text: 'Hello {…}, welcome back to the hall.' }),
109
+ });
110
+ assert.equal(good.ok, true, 'keeping the hole, even in a new position, is allowed');
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // The patch format. One writer, one reader, tested against each other.
115
+ // ---------------------------------------------------------------------------
116
+
117
+ await test('a composed proposal block parses back to the same patch', () => {
118
+ const { patch } = proposals.normalizeProposalPayload({
119
+ body: { proposed_text: 'Opening the copy desk…', why: 'The current line describes the machine, not the person waiting.' },
120
+ entry: entry(),
121
+ });
122
+ const body = proposals.proposalTaskBody(patch);
123
+ const parsed = proposals.parseProposalBlock(body);
124
+ assert.equal(parsed.ok, true);
125
+ for (const field of ['string_id', 'surface', 'file', 'line', 'origin', 'current', 'proposed']) {
126
+ assert.deepEqual(parsed.patch[field], patch[field], `${field} must survive the round trip`);
127
+ }
128
+ // RUNNABLE AS WRITTEN. The task cannot know its own id while its description is
129
+ // being composed, so the command takes none and acts on the claim you hold —
130
+ // a placeholder here would be a step for the claimer to get wrong.
131
+ assert.match(body, /```bash\nnode scripts\/gds\/copy-apply\.js\n```/,
132
+ 'the task must tell its claimer the exact line that lands it, with nothing to substitute');
133
+ assert.ok(!/<this task id>|--task <|\$\{/.test(body), 'and no placeholder survives into the instructions');
134
+ });
135
+
136
+ await test('a description with two proposals is refused, not silently halved', () => {
137
+ const { patch } = proposals.normalizeProposalPayload({
138
+ body: { proposed_text: 'Opening the copy desk…', why: 'The current line describes the machine, not the person.' },
139
+ entry: entry(),
140
+ });
141
+ const two = `${proposals.composeProposalBlock(patch)}\n\n${proposals.composeProposalBlock(patch)}`;
142
+ const parsed = proposals.parseProposalBlock(two);
143
+ assert.equal(parsed.ok, false);
144
+ assert.equal(parsed.code, 'multiple_proposal_blocks');
145
+ });
146
+
147
+ await test('a patch from an unknown schema is refused rather than half-understood', () => {
148
+ const parsed = proposals.parseProposalBlock('```copy-proposal\n{"schema":99,"string_id":"x"}\n```');
149
+ assert.equal(parsed.ok, false);
150
+ assert.equal(parsed.code, 'proposal_schema_unknown');
151
+ });
152
+
153
+ await test('a task with no proposal in it is a named refusal', () => {
154
+ const parsed = proposals.parseProposalBlock('An ordinary task about something else.');
155
+ assert.equal(parsed.ok, false);
156
+ assert.equal(parsed.code, 'no_proposal_block');
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // The applier. This is where wrong is expensive.
161
+ // ---------------------------------------------------------------------------
162
+
163
+ const patchFor = (over = {}) => ({
164
+ schema: 1,
165
+ string_id: 'abc123abc123',
166
+ surface: 'builders-hall',
167
+ file: HALL,
168
+ line: 1,
169
+ origin: 'js-markup',
170
+ current: 'Loading the copy desk…',
171
+ proposed: 'Opening the copy desk…',
172
+ ...over,
173
+ });
174
+
175
+ await test('it replaces exactly the string, and leaves the markup around it alone', () => {
176
+ const src = 'const h = `<p class="lede">Loading the copy desk…</p>`;';
177
+ const r = applier.applyPatchToSource(src, HALL, patchFor());
178
+ assert.equal(r.ok, true);
179
+ assert.equal(r.source, 'const h = `<p class="lede">Opening the copy desk…</p>`;');
180
+ });
181
+
182
+ await test('it puts the source’s own interpolations back, in the order the new sentence needs', () => {
183
+ const src = 'const h = `<p>Hello ${builder.name}, welcome back to the hall.</p>`;';
184
+ const r = applier.applyPatchToSource(src, HALL, patchFor({
185
+ current: 'Hello {…}, welcome back to the hall.',
186
+ proposed: 'Good to see you, {…} — welcome back.',
187
+ }));
188
+ assert.equal(r.ok, true);
189
+ assert.equal(r.source, 'const h = `<p>Good to see you, ${builder.name} — welcome back.</p>`;',
190
+ 'the expression must survive verbatim, at its new position in the sentence');
191
+ });
192
+
193
+ await test('an html attribute is replaced inside its quotes and nowhere else', () => {
194
+ const src = '<button title="Flag this copy" aria-label="Flag this copy">Flag</button>';
195
+ const r = applier.applyPatchToSource(src, HALL_HTML, patchFor({
196
+ origin: 'html-attr:title',
197
+ current: 'Flag this copy',
198
+ proposed: 'Mark this wording for review',
199
+ }));
200
+ assert.equal(r.ok, true);
201
+ assert.equal(r.source, '<button title="Mark this wording for review" aria-label="Flag this copy">Flag</button>',
202
+ 'only the attribute the patch named may change');
203
+ });
204
+
205
+ await test('a string that has already been reworded is refused, and nothing is written', () => {
206
+ const src = 'const h = `<p>Opening the copy desk…</p>`;';
207
+ const r = applier.applyPatchToSource(src, HALL, patchFor());
208
+ assert.equal(r.ok, false);
209
+ assert.equal(r.code, 'target_gone');
210
+ assert.ok(r.detail.what_this_means.includes('Nothing was changed'));
211
+ });
212
+
213
+ await test('a string that appears twice in one file is refused unless the line disambiguates', () => {
214
+ const src = [
215
+ 'const a = `<p>Could not save.</p>`;',
216
+ 'const b = `<p>Could not save.</p>`;',
217
+ ].join('\n');
218
+ const ambiguous = applier.applyPatchToSource(src, HALL, patchFor({
219
+ current: 'Could not save.', proposed: 'That did not save.', line: 9,
220
+ }));
221
+ assert.equal(ambiguous.ok, false);
222
+ assert.equal(ambiguous.code, 'ambiguous_target');
223
+ assert.deepEqual(ambiguous.detail.lines, [1, 2]);
224
+
225
+ const picked = applier.applyPatchToSource(src, HALL, patchFor({
226
+ current: 'Could not save.', proposed: 'That did not save.', line: 2,
227
+ }));
228
+ assert.equal(picked.ok, true);
229
+ assert.equal(picked.source, [
230
+ 'const a = `<p>Could not save.</p>`;',
231
+ 'const b = `<p>That did not save.</p>`;',
232
+ ].join('\n'), 'only the named line may change');
233
+ });
234
+
235
+ await test('a string whose literal is escaped has no exact span, and is refused by name', () => {
236
+ // The inventory unescapes a quoted literal before reading it, which shifts
237
+ // every character after the backslash — so no exact span exists and the
238
+ // applier must not guess at one.
239
+ const src = "const h = '<p>She said \\'no\\' to the wording, twice.</p>';";
240
+ const r = applier.applyPatchToSource(src, HALL, patchFor({
241
+ current: "She said 'no' to the wording, twice.",
242
+ proposed: 'She turned the wording down, twice.',
243
+ }));
244
+ assert.equal(r.ok, false);
245
+ assert.equal(r.code, 'no_exact_span');
246
+ });
247
+
248
+ await test('the path guard refuses anything outside the surface the registry defines', () => {
249
+ for (const file of ['../../../etc/passwd', 'migrations/core_001.sql', 'config/branding.json', 'modules/hall-ui/public/../../../server.js']) {
250
+ const r = applier.checkPatchPath(patchFor({ file }));
251
+ assert.equal(r.ok, false, `${file} must be refused`);
252
+ assert.equal(r.code, 'path_outside_surface');
253
+ }
254
+ const ok = applier.checkPatchPath(patchFor({ file: 'modules/hall-ui/public/copy-desk.js' }));
255
+ assert.equal(ok.ok, true);
256
+ });
257
+
258
+ await test('an unknown surface is refused before any file is read', () => {
259
+ const r = applier.checkPatchPath(patchFor({ surface: 'somewhere-else' }));
260
+ assert.equal(r.ok, false);
261
+ assert.equal(r.code, 'unknown_surface');
262
+ assert.ok(Array.isArray(r.detail.known) && r.detail.known.length > 0, 'the refusal names the surfaces that do exist');
263
+ });
264
+
265
+ await test('the applier re-checks the placeholder arithmetic against the real source', () => {
266
+ // The proposal path already checked it, but the source is what is about to be
267
+ // written and may have changed since. Belt and braces, on the one rule where
268
+ // being wrong corrupts a file rather than annoying a person.
269
+ const src = 'const h = `<p>Hello ${name} and ${other}, welcome.</p>`;';
270
+ const r = applier.applyPatchToSource(src, HALL, patchFor({
271
+ current: 'Hello {…} and {…}, welcome.',
272
+ proposed: 'Welcome, {…}.',
273
+ }));
274
+ assert.equal(r.ok, false);
275
+ assert.equal(r.code, 'placeholder_mismatch');
276
+ });
277
+
278
+ console.log(`\n${passed} passed, ${failed} failed`);
279
+ process.exit(failed ? 1 : 0);
@@ -286,6 +286,8 @@ function createClient(opts = {}) {
286
286
  postCopyDeskFlags: (args) => request("POST", "/copy-desk/flags", { hasBody: true }, args),
287
287
  // POST /copy-desk/flags/{id}/close — rank: any-builder — POST /copy-desk/flags/:id/close
288
288
  postCopyDeskFlagsIdClose: (args) => request("POST", "/copy-desk/flags/{id}/close", { hasBody: true }, args),
289
+ // POST /copy-desk/proposals — rank: any-builder — POST /copy-desk/proposals
290
+ postCopyDeskProposals: (args) => request("POST", "/copy-desk/proposals", { hasBody: true }, args),
289
291
  // GET /copy-desk/queue — rank: any-builder — GET /copy-desk/queue
290
292
  getCopyDeskQueue: (args) => request("GET", "/copy-desk/queue", { hasBody: false }, args),
291
293
  // GET /copy-desk/strings — rank: any-builder — GET /copy-desk/strings
@@ -67,7 +67,7 @@
67
67
  .cd-find .filter input[type="search"] { width: 100%; }
68
68
  .cd-search-count { margin: 0 0 14px; }
69
69
 
70
- /* The two dialogs are the family modal on a native <dialog>: bg-card, one rule
70
+ /* The three dialogs are the family modal on a native <dialog>: bg-card, one rule
71
71
  hairline, no shadow. The backdrop is the one literal this sheet keeps — pure
72
72
  black with alpha as a SCRIM is the exception the Fifteen Rule names, and a
73
73
  backdrop cannot take a ground token: it has to darken whatever is behind it
@@ -77,3 +77,11 @@
77
77
  .cd-dialog .ov-form { margin: 14px 0 0; }
78
78
  .cd-dialog textarea { resize: vertical; line-height: 1.5; }
79
79
  .ov-modal .cd-dialog__err { min-height: 18px; margin: 8px 0 0; font-size: 13px; line-height: 1.45; color: var(--danger); }
80
+
81
+ /* The placeholder rule, shown only for a sentence that has holes in it (task
82
+ 1003114 / R03). It is a CONSTRAINT, not a footnote — losing a hole is the one
83
+ edit the applier refuses outright — so it is the note voice raised one ink
84
+ step, and nothing more. No band, no tint: this family marks state with a pill
85
+ and never with a bar, and a rule the reader must obey is not an exception to
86
+ that, it is the reason to keep it. */
87
+ .cd-holes { color: var(--ink-soft); }
@@ -142,6 +142,39 @@
142
142
  </form>
143
143
  </dialog>
144
144
 
145
+ <!-- Proposing new wording (task 1003114 / R03). A third dialog rather than a mode
146
+ on the flag composer, because the two acts are different: a flag says this is
147
+ wrong, a proposal says this is what it should say instead, and one of them
148
+ creates work on the board. The textarea opens PREFILLED with the current
149
+ wording — an artist edits a sentence, they do not retype it from memory. -->
150
+ <dialog id="cd-propose-dialog" class="ov-modal cd-dialog">
151
+ <form method="dialog" id="cd-propose-form">
152
+ <h3 class="scroll__h">Propose new wording</h3>
153
+ <p class="cd-dialog__target" id="cd-propose-target"></p>
154
+ <div class="ov-form">
155
+ <div class="ov-field ov-field--wide">
156
+ <label class="ov-field__l" for="cd-propose-text">What should it say?</label>
157
+ <textarea id="cd-propose-text" rows="3" required></textarea>
158
+ </div>
159
+ <!-- Shown only when the sentence has holes in it. The arithmetic is a real
160
+ rule, not a formality: the applier puts the source's own values back
161
+ into these, so losing one is a code change and gets refused. -->
162
+ <p class="ov-form__why cd-holes" id="cd-propose-holes" hidden></p>
163
+ <div class="ov-field ov-field--wide">
164
+ <label class="ov-field__l" for="cd-propose-why">Why is this better?</label>
165
+ <textarea id="cd-propose-why" rows="4" minlength="10" required
166
+ placeholder="What the current wording gets wrong, and what yours does about it."></textarea>
167
+ </div>
168
+ <p class="ov-form__why">Nothing changes yet. This files a task carrying your wording; whoever claims it lands the diff, and it ships graded like every other change.</p>
169
+ </div>
170
+ <p class="cd-dialog__err" id="cd-propose-err" role="alert"></p>
171
+ <div class="ov-modal__actions">
172
+ <button type="button" class="btn-ghost" id="cd-propose-cancel">Cancel</button>
173
+ <button type="button" class="btn-accent" id="cd-propose-submit">File the change</button>
174
+ </div>
175
+ </form>
176
+ </dialog>
177
+
145
178
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
146
179
 
147
180
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>