@bongos/core 1.19.491 → 1.19.493

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 (41) hide show
  1. package/.bongos-core.json +68 -43
  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 +56 -34
  12. package/docs/copy-registry.json +237 -39
  13. package/docs/design/mocks/hall/task/v1.html +7 -11
  14. package/docs/design/mocks/hall/task/v3.html +7 -11
  15. package/docs/file-map.md +2 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/copy-desk/module.json +2 -2
  18. package/modules/copy-desk/proposals.js +278 -0
  19. package/modules/copy-desk/routes/copy-desk.js +173 -9
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
  21. package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
  22. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  23. package/modules/hall-ui/public/copy-desk.css +9 -1
  24. package/modules/hall-ui/public/copy-desk.html +33 -0
  25. package/modules/hall-ui/public/copy-desk.js +134 -3
  26. package/modules/hall-ui/public/copy-desk.states.json +18 -1
  27. package/modules/hall-ui/public/task.css +71 -1
  28. package/modules/hall-ui/public/task.js +132 -13
  29. package/modules/hall-ui/public/task.states.json +1 -1
  30. package/modules/lifecycle/db-claim-reads.js +40 -0
  31. package/modules/lifecycle/db.js +4 -0
  32. package/modules/lifecycle/lifecycle.js +6 -0
  33. package/modules/lifecycle/routes/tasks.js +12 -1
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/scripts/gds/copy-apply.js +338 -0
  37. package/scripts/gds/copy-inventory.js +128 -30
  38. package/src/module-api.js +1 -1
  39. package/tests/hall_collab_world.mjs +24 -4
  40. package/tests/hall_record_world.mjs +183 -2
  41. package/tests/task_detail_includes.mjs +108 -0
@@ -185,8 +185,6 @@ h1.recTitle{margin:0 0 18px;font-family:var(--font-display);font-weight:300;font
185
185
  .btn--primary:hover{background:var(--accent-lift);border-color:var(--accent-lift);}
186
186
  .actNote{margin:0;font-size:14.5px;color:var(--ink-soft);flex:1;min-width:240px;}
187
187
 
188
- /* the proposed regions: their own panel, edged dashed, with a mono eyebrow */
189
- .proposed{border-style:dashed;}
190
188
  .axes{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--rule-soft);}
191
189
  .axis{padding:16px 18px 18px;border-left:1px solid var(--rule-soft);}
192
190
  .axis:first-child{border-left:0;padding-left:0;}
@@ -291,7 +289,7 @@ document.documentElement.setAttribute('data-theme',m);}catch(e){}})();
291
289
  THESIS the hall gets a ground and a chrome: in dark the hall's own rendered plate sits behind the whole shell under its veil, the sidebar and topbar are glass over it, and the record is one calm bg-card panel at 16px radius with the loosest air of the three.
292
290
  COMPOSITION first viewport at 1440: a 64px glass rail at the far left that opens to 248px over the content on hover or focus-within, a glass topbar, then one panel holding the TASK eyebrow with its pill, the title at 38px, the meta and goal lines, four figures on hairlines, and the five beat lifecycle rail with credit lit. Everything below is one column at 64ch inside the same panel.
293
291
  LIGHT there is no plate in light and the veil resolves to none, so the ground is plain paper, the glass drops to a 5% ink tint with zero blur (a tint, not a frost), and the page becomes the same composition with the atmosphere removed. Light is the same room on paper, never a black panel.
294
- RISK the plate token ships as none until the hall shoots its own, so today v3's atmosphere is theoretical and the version reads as v2 with rounder corners and more air; once a plate lands, a photographic ground under twenty seven dense pages costs legibility and adds a per page cost the other two do not have. The rail by default trades discoverability for calm: a newcomer sees twenty two unlabelled icons. Regions 15 and 16 (grade, session log) do NOT exist on the shipped page and carry a "proposed" eyebrow; region 9 (the ship visual) draws nothing because task 1003322 shipped without one.
292
+ RISK the plate token ships as none until the hall shoots its own, so today v3's atmosphere is theoretical and the version reads as v2 with rounder corners and more air; once a plate lands, a photographic ground under twenty seven dense pages costs legibility and adds a per page cost the other two do not have. The rail by default trades discoverability for calm: a newcomer sees twenty two unlabelled icons. Regions 15 and 16 (grade, session log) were proposed in this round and built by task 1003437; region 9 (the ship visual) draws nothing because task 1003322 shipped without one.
295
293
  ?state=populated the page with real data
296
294
  ?state=loading skeletons in the SHAPE of the final content
297
295
  ?state=empty a real URL for a task that does not exist
@@ -604,10 +602,9 @@ Ships with: the manifest, the apex-root web surface, a nested CLAUDE.md a newcom
604
602
  </section>
605
603
  </section>
606
604
 
607
- <!-- region 15: PROPOSED. There is no grade panel on the shipped page. Grades live on
608
- Standing (#recent-grades). This round asks for the task's own grade to sit here. -->
609
- <section class="panel proposed">
610
- <p class="secH">Grade <em>proposed</em></p>
605
+ <!-- region 15: the grade panel — proposed in this round, built by task 1003437. -->
606
+ <section class="panel">
607
+ <p class="secH">Grade</p>
611
608
  <p class="body" style="margin-bottom:20px">The adversarial panel of four workers. Every worker must independently report pass before confirmed becomes credits; one concerns falls the task through to the manual confirm path.</p>
612
609
  <div class="axes">
613
610
  <div class="axis"><span class="axisN">narc</span><span class="pill pill--sm pill--ok">pass</span></div>
@@ -622,10 +619,9 @@ Ships with: the manifest, the apex-root web surface, a nested CLAUDE.md a newcom
622
619
  </div>
623
620
  </section>
624
621
 
625
- <!-- region 16: PROPOSED. There is no session log on the shipped page either. Session logs
626
- live on /sessions. This round asks for the session that shipped this task to sit here. -->
627
- <section class="panel proposed">
628
- <p class="secH">Session log <em>proposed</em></p>
622
+ <!-- region 16: the session log — proposed in this round, built by task 1003437. -->
623
+ <section class="panel">
624
+ <p class="secH">Session log</p>
629
625
  <div class="ledger">
630
626
  <div class="lrow"><span class="lk">Session</span><span class="lv">30 August 2026, Percy Jackson, 37m</span></div>
631
627
  <div class="lrow"><span class="lk">Credits awarded</span><span class="lv">60</span></div>
package/docs/file-map.md CHANGED
@@ -132,7 +132,7 @@
132
132
  │ ├── status-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R84, default-on): the public status dashboard at status.<apex>, moved from public-status/. NO routes/db/port — declares contributes.webSurfaces:[{host:"status.",dir:"public"}], served by the loader web-surface seam (moduleWebSurfaces → serve-internal). CLAUDE.md, module.json, public/{index.html,status.js,style.css,cursors/,snapshots/}.
133
133
  │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,harbor,gate,pair,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Four ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages), `oversight.css` (the OVERSIGHT family — government, watch, harbor, gate, task 1003469: the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface) and `panel.css` (the PANEL family — settings, profile, pair, not-ready, task 1003308: the row, the switch, the field with its label above, the chip control, the four voices and the sign-in gate; the hall's my-projects page was DELETED in the same task — "my projects" is the hub's user-level page, and the hall keeps no link or redirect). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `settings.css` the rail layout, the group head, the token ladder and the voices grid, `profile.css` the identity band and the shelf, `pair.css` the device row, `people.css` the directory's identity cell (task 1003470 made the legacy door `/scouting` ship the same body and script as `/people`; task 1003440 then deleted the door — `scouting.html` and `scouting.states.json` are gone and the URL 301s to `/people`).
134
134
  │ ├── lifecycle/ ← CORE-DOMAIN carve (BV1.R86, default-on): the build state machine — tasks · claims · versions · done-when · goals · dependencies · claim→ship→grade→credit · github-push + merge-lock + conflict-resolve + publish-reconciler. The LAST, most-coupled carve; registers the `lifecycle` kernel port (createTask, classifyTaskKind, tallyPeerVotes, work-tracking reads). CLAUDE.md, module.json, routes/{tasks,claims,versions,done-when,goals,dependencies,gate-approvals,analytics}.js, lifecycle.js, github-push.js, merge-lock.js, conflict-resolve.js, publish-reconciler.js, task-visuals.js + task-visual-db.js + routes/visuals.js (the optional ship-time visual, task 1003109 — the route file is NOT named task-*.js on purpose; see the module CLAUDE.md).
135
- │ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. CLAUDE.md, module.json, registry.js, flags.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
135
+ │ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. R03 (task 1003114, ADR 0233) added the PROPOSAL: an artist writes the replacement wording and it becomes a GDS task carrying a fenced copy-proposal patch — no row, NO second migration (a proposals table is the forbidden column with extra steps), and scripts/gds/copy-apply.js lands it as a real diff under a claim. CLAUDE.md, module.json, registry.js, flags.js, proposals.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms,copy_proposals}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
136
136
  │ ├── ui-design/ ← CORE-DOMAIN module (task 1003324 / ADR 0197, default-on): the design capability every builder on every instance gets — ships METHOD, never a world. Absorbs ADR 0081's layer: adapters/{claude-design,figma}/index.js (moved from src/ui/adapters/), scripts/{design-sync,figma-design-sync,validate-design}.js (moved from scripts/gds/, which keeps thin shims), docs/design-contract.md (moved from docs/). Declaration-only: contributes.skills design (the /design playbook, new) + design-sync + figma-design-sync; no routes/port/migrations, so disabling it touches no served surface. CLAUDE.md separates the platform FLOORS (the Fifteen Rule, the dark twins, no page :root, the 24px floor, AA in both modes, the kill switch) from the instance's WORLD (its packs + DESIGN.md). kit/ (task 1003320) is the look-before-you-ship kit: lib.js (the floors, the states/probes contracts, the action + expectation vocabularies, the WCAG maths, the stub spawner), serve.js (any surface through the doorway's real transforms + fixtures/), render.js (states × 1440/390/320 × dark/light, audited), probe.js (PASS/FAIL contracts), check-mock.js (platform rules + the instance's DESIGN.md taste bans, then the detector), tells.js (task 1003329 / ADR 0223: the anti-pattern detector as two tiers — floors FAIL, craft tells WARN, kit-ignore waivers per file; the rendered twins live in lib.js factsInPage), contrast.js + png.js; a page's <page>.states.json / <page>.probes.json sit BESIDE the surface (modules/public-landing/public/, modules/hall-ui/public/). Recipe: docs/recipes/ui-look-before-you-ship.md. skills/ (task 1003321 / ADR 0198) is the vendoring policy: policy.json (licence allowlist, verdicts that may land, sidecar fields, preamble markers), PREAMBLE.md (the platform preamble every vendored SKILL.md opens with), README.md (the roster — both origins scanned dangerous on 2026-08-28, landed as rebuild specs; nothing vendored — and the landing recipe); a landable copy goes under skills/vendor/<name>/ with PROVENANCE.md + LICENSE and is materialised into an instance's .claude/skills/ by claude-materialize.js step 1b. skills/<name>/ (task 1003325 / ADR 0220) are the in-house rebuilds from the specs, first-party and counted: design-taste-frontend (the default taste skill), design-taste-frontend-v1 (the earlier generation, kept distinct), high-end-visual-design (the polish pass), redesign-existing-projects (the in-place upgrade); each opens with the same preamble and carries a "Rebuilt from" section instead of a sidecar. impeccable/ (task 1003326 / ADR 0221) is the design-craft method as ONE skill with sub-commands: SKILL.md (the router, the menu, the craft floor, the bounded finish) + reference/<sub-command>.md (eighteen playbooks, one loaded per request); no hooks, no bypass agent, no telemetry, no live server. minimalist-ui/, industrial-brutalist-ui/, gpt-taste/, stitch-design-taste/ (task 1003327 / ADR 0230) are the four looks, composition grammars with no colours of their own: the palette is the instance's pack and the texture is a named entry in styles/; a substrate is an archetype that still ships both modes, gpt-taste's selection is derived from the route rather than rolled, and stitch-design-taste writes a DESIGN.md-shaped contract document whose thirteen are transcribed from the pack. image-to-code/, imagegen-frontend-web/, imagegen-frontend-mobile/, brandkit/ (task 1003328 / ADR 0222) are the image family under the hero-only rule: plates and hero objects only, the pair (JPEG dark plate + WebP alpha cutout) with a <file>.json sidecar beside each, through the instance's art pipeline key; the grove specimen's styles/grove/assets/ is the worked example. style/ (task 1003475 / ADR 0231) is the module's first FIRST-PARTY skill (no upstream, so a `## First party` section instead of `## Rebuilt from`): the /style session in which an owner authors their own look in four bounded steps, rendering the nearest library entries before asking, drafting inside the contract, retuning by measurement, and handing over the one-step adoption without ever writing config/branding.json. styles/ (task 1003323 / ADR 0219) is the style library: <name>/pack.json (the theme.ui overlay, exactly the fifteen) + DESIGN.md (the look's derived recipes, thirteen in lockstep with the pack) + mock.html (the specimen); chrome-world (pinned to the neutral pack), expedition, grove, blueprint (the first cool look, authored through /style at task 1003475); STYLE=<name> on the kit lays a look over the instance's pack through GDS_BRANDING_FILE; tests/ui_design_styles.mjs runs the world/cosmos/hall token suites once per alternate (UI_DESIGN_PACK). config/design-tokens.* + design-sources.json STAY in config/ as the host boundary; public-landing keeps its own key (first customer, not owner).
137
137
  │ ├── character-anim/ ← consistent-character generative animation (ADR 0088) — graded painterly frame sets + manifest, the otb-character-review skill. CLAUDE.md, module.json.
138
138
  │ ├── provisioning/ ← stands up + operates a Cloud Bongos instance's own infra (server/DB/domain/DNS/TLS) via the API (ADR 0111); web-tier request+state only, no cloud tokens — a separate control-plane runner drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute workloads. CLAUDE.md, module.json.
@@ -194,6 +194,7 @@
194
194
  │ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
195
195
  │ ├── doc-comment-coverage.js ← ADR 0095 / task 1001654: the ADVISORY doc-comment nudge (fitness Check 28) — reports source files under src/·scripts/·modules/ that open with no module header. Never hard-fails: a doc-comment gate that blocks a ship buys filler prose, and making it blocking needs owner sign-off + its own ADR. CRLF-safe (unlike docs-entropy.js); an empty enumeration throws as a scan defect
196
196
  │ ├── copy-inventory.js ← R01 of goal 1000074 (the artist's loop): the COPY REGISTRY — every user-facing string across the hall / status / landing surfaces, each row naming the render path that shows it (html-text · html-attr · js-markup · js-notice · js-text-assign) + a stable id that survives a move. Writes docs/copy-registry.json + docs/copy-inventory.md; `--check` fails on drift. Precision over recall: a JS literal with no render path is not listed
197
+ │ ├── copy-apply.js ← R03 of goal 1000074 (ADR 0233): lands a copy proposal as a REAL DIFF under a claim — the design half’s adapter.export() applied to text. Reads the fenced copy-proposal patch off a task (--task N; --dry-run, --patch-file), RE-DERIVES the string’s position by re-running copy-inventory’s own extractor over the working tree (the registry commits no offsets — they go stale, the id does not), re-injects the source’s own ${expr} into the proposal’s {…} holes, writes, regenerates the registry and verifies its own edit. Refuses BY NAME on target_gone / ambiguous_target / no_exact_span / placeholder_mismatch / path_outside_surface. The ONLY writer in the copy feature, and deliberately a command rather than a route
197
198
  │ ├── gen-diagrams.js ← generates the 5 onboarding diagrams (`.mmd`/`.svg`/`.png` + `assertions.json`) from templates in this file — never hand-edit a `.mmd`/assertions.json; runs on deploy
198
199
  │ ├── gen-api-docs.js ← ADR 0109: generates `docs/api/openapi.json` + `docs/api-reference.md` from the live route files via `route-rank-check.js` introspection, so the API reference cannot drift from what the server actually mounts
199
200
  │ ├── architect-audit.js ← MAS Phase 5 (ADR 0024): the Architect's bounded first-principles `--scope`/`--paths` audit CLI → `docs/audits/`
@@ -1419,5 +1419,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1419
1419
  landed since 1.19.489 with no explicit bump. run 33691767867. (task 1002620)
1420
1420
  1.19.491 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1421
1421
  landed since 1.19.490 with no explicit bump. run 33692191645. (task 1002620)
1422
+ 1.19.492 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1423
+ landed since 1.19.491 with no explicit bump. run 33693742388. (task 1002620)
1424
+ 1.19.493 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1425
+ landed since 1.19.492 with no explicit bump. run 33694576680. (task 1002620)
1422
1426
  ---------------------------------------------------------------------------
1423
1427
  ```
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "key": "copy-desk",
3
3
  "title": "Copy desk (the artist's loop over user-facing text)",
4
- "description": "The COPY half of the design contract (goal 1000074, the artist's loop). Named for the editorial desk copy passes over before it is published — which is exactly the job, and which keeps the module key off the generic word 'copy' that the branding contract already owns (branding.copy.tagline/hallName/notFound). ADR 0081 made the repo the source of truth for design TOKENS and component surfaces; there was no equivalent for user-facing TEXT. This module owns that half: the FLAG — anyone, in any role, marks a string or a whole surface as needing an artist's attention, with a required reason — and the QUEUE an artist reads it from. The inventory a flag points at is the committed registry docs/copy-registry.json, generated by scripts/gds/copy-inventory.js (R01); this module reads it and never writes it. NO LIVE CMS is the goal's hard non-goal: nothing here can change a rendered string. A flag is a work request; the fix ships as a claimed, graded GDS task like every other change. default-on — every instance shows a person words, so every instance can flag them.",
4
+ "description": "The COPY half of the design contract (goal 1000074, the artist's loop). Named for the editorial desk copy passes over before it is published — which is exactly the job, and which keeps the module key off the generic word 'copy' that the branding contract already owns (branding.copy.tagline/hallName/notFound). ADR 0081 made the repo the source of truth for design TOKENS and component surfaces; there was no equivalent for user-facing TEXT. This module owns that half: the FLAG — anyone, in any role, marks a string or a whole surface as needing an artist's attention, with a required reason — and the QUEUE an artist reads it from. The inventory a flag points at is the committed registry docs/copy-registry.json, generated by scripts/gds/copy-inventory.js (R01); this module reads it and never writes it. R03 (task 1003114) added the PROPOSAL: an artist writes the replacement wording and it becomes a GDS task carrying a patch, which scripts/gds/copy-apply.js lands as a real diff under a claim — the design half's adapter.export() contract (ADR 0081) applied to text. NO LIVE CMS is the goal's hard non-goal: nothing here can change a rendered string. A flag is a work request; a proposal is a work request with a suggested fix; the fix ships as a claimed, graded GDS task like every other change. default-on — every instance shows a person words, so every instance can flag them.",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
7
  "maintenance": { "status": "core-maintained" },
8
8
  "contributes": { "routes": ["copy-desk"], "migrations": true },
9
9
  "provides": [],
10
- "consumes": []
10
+ "consumes": ["lifecycle"]
11
11
  }
@@ -0,0 +1,278 @@
1
+ // modules/copy-desk/proposals.js — the PROPOSAL: an artist's replacement wording
2
+ // for one string, carried as a patch on a GDS task (task 1003114 / R03 of goal
3
+ // 1000074, the artist's loop).
4
+ //
5
+ // WHY THIS FILE EXISTS. R02 gave the artist a way to say "this sentence is
6
+ // wrong" (the flag). It deliberately gave them no way to say what it should say
7
+ // instead, because the obvious place to put that — a column next to the flag —
8
+ // is the exact shape goal 1000074 forbids: "NO LIVE CMS ... never a write path
9
+ // that bypasses the ledger" (and ADR 0178 §3, which pins that copy_desk_flags
10
+ // has no column that could hold replacement text and must never gain one).
11
+ //
12
+ // THE SHAPE THAT SATISFIES BOTH HALVES, and it is borrowed rather than invented:
13
+ // ADR 0081 already solved this problem for the DESIGN half. A tool authors, an
14
+ // adapter EXPORTS its output into the repo as a committed diff under a claimed
15
+ // task, and the repo stays the source of truth (modules/ui-design/adapters/*,
16
+ // `adapter.export()`). This is that contract for TEXT. The artist is the tool;
17
+ // the copy desk is where they author; the proposal is the export. So a proposal
18
+ // is not a row this module keeps — it is a PATCH, rendered into the description
19
+ // of a task, which is the ledger the methodology already requires every change
20
+ // to pass through. Nothing renders from it; scripts/gds/copy-apply.js is the
21
+ // only reader, and it runs under a claim.
22
+ //
23
+ // WHERE THE PATCH LIVES, AND WHY IT IS A FENCED BLOCK. It has to be readable by
24
+ // a person reviewing the task (the whole point is that the change is arguable
25
+ // before it lands) and parseable by the applier. A fenced ```copy-proposal block
26
+ // in the task description is both, renders as itself in the hall, and survives a
27
+ // person editing the prose around it. It is deliberately NOT a database column
28
+ // anywhere: a column is one join away from being read as copy, which is the
29
+ // erosion ADR 0178 describes.
30
+ //
31
+ // PURE — no DB, no fs, no clock. Both callers import it: the route
32
+ // (routes/copy-desk.js) composes, the applier (scripts/gds/copy-apply.js)
33
+ // parses, and neither owns a second copy of the format.
34
+
35
+ 'use strict';
36
+
37
+ // The patch format's version. The applier refuses anything else BY NAME rather
38
+ // than doing its best with a shape it does not know — a half-understood patch
39
+ // edits the wrong characters of a real file.
40
+ const PROPOSAL_SCHEMA = 1;
41
+
42
+ // The fence tag. Chosen to be unmistakable in a task description and impossible
43
+ // to produce by accident.
44
+ const FENCE = 'copy-proposal';
45
+
46
+ // The same judgement floor R02 put on a flag's reason, for the same reason
47
+ // (ADR 0178 §2): a proposal with no argument hands a reviewer a diff and no way
48
+ // to decide it, which costs them more than not proposing. The DB CHECK that
49
+ // backs the flag's floor has no equivalent here because there is no row — the
50
+ // floor is enforced at the one write vector, which is this file's caller.
51
+ const MIN_WHY_CHARS = 10;
52
+ const MAX_WHY_CHARS = 2000;
53
+
54
+ // 400 is the registry's own upper bound on what counts as one string
55
+ // (`looksLikeCopy` in scripts/gds/copy-inventory.js). Proposing something longer
56
+ // than the inventory can hold would produce a patch that lands and then vanishes
57
+ // from the next registry — a change that silently un-inventories itself.
58
+ const MAX_TEXT_CHARS = 400;
59
+
60
+ // The interpolation hole the inventory writes for `${...}`. A proposal must keep
61
+ // exactly as many as the current text has, in the same order: the applier maps
62
+ // the k-th hole back to the k-th expression it found in the source. Reordering
63
+ // or dropping one is a code change, not a copy change, and is refused by name.
64
+ const PLACEHOLDER = '{…}';
65
+
66
+ const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
67
+
68
+ // Collapse whitespace runs and trim — the same normalization the inventory
69
+ // applies when it records a string, so a proposal is measured and stored in the
70
+ // form it will actually be compared against.
71
+ function tidy(raw) {
72
+ return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
73
+ }
74
+
75
+ function placeholderCount(text) {
76
+ return String(text || '').split(PLACEHOLDER).length - 1;
77
+ }
78
+
79
+ // Validate one POSTed proposal into the patch the task will carry.
80
+ //
81
+ // `entry` is the registry occurrence being changed, and it is REQUIRED and
82
+ // server-side. Every fact about the TARGET — surface, file, line, render path,
83
+ // and the current wording — is copied from it and never from the request body,
84
+ // which is R02's rule for a flag's snapshot (ADR 0178 §6) and matters more here:
85
+ // a caller-supplied "current text" would let a proposal describe a change to a
86
+ // sentence that does not exist, and the applier would then be editing a file on
87
+ // the strength of a claim nobody checked.
88
+ //
89
+ // PURE. The caller resolves `entry` from the loaded registry first.
90
+ function normalizeProposalPayload({ body = {}, entry, builderId = null } = {}) {
91
+ if (!entry || !entry.file || !entry.text) return fail('unknown_target');
92
+
93
+ const proposed = tidy(body.proposed_text);
94
+ const why = tidy(body.why);
95
+ const current = tidy(entry.text);
96
+
97
+ if (!proposed) {
98
+ return fail('proposed_text_missing', {
99
+ what_this_means: 'A proposal is a replacement wording. Without one there is nothing to land — file a flag instead if you only want to mark this as wrong.',
100
+ });
101
+ }
102
+ if (proposed.length > MAX_TEXT_CHARS) {
103
+ return fail('proposed_text_too_long', { max: MAX_TEXT_CHARS, was: proposed.length });
104
+ }
105
+ if (proposed === current) {
106
+ return fail('proposed_text_unchanged', {
107
+ what_this_means: 'The proposed wording is the current wording. Nothing would change.',
108
+ });
109
+ }
110
+ if (why.length < MIN_WHY_CHARS) {
111
+ return fail('why_too_thin', {
112
+ min: MIN_WHY_CHARS,
113
+ what_this_means: 'A reviewer has to be able to decide this without asking you. Say what is wrong with the current wording and what the new one does better — the same floor a flag’s reason carries.',
114
+ });
115
+ }
116
+ if (why.length > MAX_WHY_CHARS) {
117
+ return fail('why_too_long', { max: MAX_WHY_CHARS, was: why.length });
118
+ }
119
+
120
+ // The placeholder arithmetic. This is the one rule that makes the patch
121
+ // MECHANICALLY applicable rather than a note for an engineer: the applier
122
+ // re-injects the source's own interpolations into the holes, so the count has
123
+ // to match and the order has to be the author's intent.
124
+ const want = placeholderCount(current);
125
+ const got = placeholderCount(proposed);
126
+ if (want !== got) {
127
+ return fail('placeholder_mismatch', {
128
+ expected: want,
129
+ got,
130
+ placeholder: PLACEHOLDER,
131
+ what_this_means: want === 0
132
+ ? `This string has no ${PLACEHOLDER} holes, so the new wording must not introduce any.`
133
+ : `This string is assembled at render time and has ${want} ${PLACEHOLDER} hole(s) where a value is dropped in. Keep all ${want}, in the order the sentence needs them — moving or removing one is a code change, not a copy change.`,
134
+ });
135
+ }
136
+
137
+ return {
138
+ ok: true,
139
+ patch: {
140
+ schema: PROPOSAL_SCHEMA,
141
+ string_id: entry.id,
142
+ surface: entry.surface,
143
+ file: entry.file,
144
+ line: entry.line,
145
+ origin: entry.origin,
146
+ current,
147
+ proposed,
148
+ why,
149
+ proposed_by: builderId ?? null,
150
+ },
151
+ };
152
+ }
153
+
154
+ // Render the patch as the fenced block that goes in the task's description.
155
+ // JSON with a stable key order and two-space indent: a reviewer reads it, and a
156
+ // diff of two proposals against the same string lines up.
157
+ function composeProposalBlock(patch) {
158
+ const ordered = {
159
+ schema: patch.schema,
160
+ string_id: patch.string_id,
161
+ surface: patch.surface,
162
+ file: patch.file,
163
+ line: patch.line,
164
+ origin: patch.origin,
165
+ current: patch.current,
166
+ proposed: patch.proposed,
167
+ };
168
+ return ['```' + FENCE, JSON.stringify(ordered, null, 2), '```'].join('\n');
169
+ }
170
+
171
+ // The reverse. Returns the FIRST block in the text: a task description carries
172
+ // exactly one proposal, and if someone has pasted a second the applier must not
173
+ // silently pick one — so a second block is a named refusal, not a coin flip.
174
+ function parseProposalBlock(markdown) {
175
+ const src = String(markdown || '');
176
+ const re = new RegExp('```' + FENCE + '\\s*\\n([\\s\\S]*?)\\n```', 'g');
177
+ const found = [...src.matchAll(re)];
178
+ if (!found.length) return fail('no_proposal_block', { fence: FENCE });
179
+ if (found.length > 1) {
180
+ return fail('multiple_proposal_blocks', {
181
+ count: found.length,
182
+ what_this_means: 'This task carries more than one proposal. Split them: one task changes one string, so the grade and the diff are about one decision.',
183
+ });
184
+ }
185
+ let patch;
186
+ try {
187
+ patch = JSON.parse(found[0][1]);
188
+ } catch (err) {
189
+ return fail('proposal_block_unreadable', { reason: err.message });
190
+ }
191
+ if (Number(patch && patch.schema) !== PROPOSAL_SCHEMA) {
192
+ return fail('proposal_schema_unknown', { expected: PROPOSAL_SCHEMA, got: patch && patch.schema });
193
+ }
194
+ for (const field of ['string_id', 'surface', 'file', 'origin', 'current', 'proposed']) {
195
+ if (typeof patch[field] !== 'string' || !patch[field]) {
196
+ return fail('proposal_field_missing', { field });
197
+ }
198
+ }
199
+ if (!Number.isInteger(patch.line) || patch.line < 1) {
200
+ return fail('proposal_field_missing', { field: 'line' });
201
+ }
202
+ return { ok: true, patch };
203
+ }
204
+
205
+ // The task a proposal becomes. Title names the surface and quotes enough of the
206
+ // current wording to be recognizable in a board list; the body carries the
207
+ // artist's argument, the patch, and the one command that lands it.
208
+ function proposalTaskTitle(patch) {
209
+ const snippet = patch.current.length > 60 ? `${patch.current.slice(0, 57)}…` : patch.current;
210
+ return `Copy: reword “${snippet}” on ${patch.surface}`;
211
+ }
212
+
213
+ // The task's own instructions. They must be RUNNABLE AS WRITTEN — a placeholder
214
+ // a claimer has to substitute is a step to get wrong, and this is the one
215
+ // handoff between the person who wrote the words and the person who lands them.
216
+ //
217
+ // The task cannot know its own id at the moment its description is composed, so
218
+ // the command takes none: `copy-apply.js` with no argument acts on the task you
219
+ // are HOLDING. That is not a convenience, it is the rule — a copy change lands
220
+ // under a claim, and a claim already names a task.
221
+ function proposalTaskBody(patch) {
222
+ return [
223
+ `An artist proposed new wording for one string on the \`${patch.surface}\` surface.`,
224
+ '',
225
+ '**Why**',
226
+ '',
227
+ patch.why,
228
+ '',
229
+ '**The change**',
230
+ '',
231
+ `- current: ${JSON.stringify(patch.current)}`,
232
+ `- proposed: ${JSON.stringify(patch.proposed)}`,
233
+ `- where: \`${patch.file}:${patch.line}\` (${patch.origin})`,
234
+ '',
235
+ '**How it lands**',
236
+ '',
237
+ 'This is a proposal, not a change — nothing has been written. Claim this task and run:',
238
+ '',
239
+ '```bash',
240
+ 'node scripts/gds/copy-apply.js',
241
+ '```',
242
+ '',
243
+ 'That re-derives the string’s position in the working tree, replaces exactly those characters, regenerates the copy registry and prints the diff for review. Then ship it like any other change — the grader sees the real diff.',
244
+ '',
245
+ 'If the applier refuses, it says why in one line; a refusal means the file moved on under the proposal and a person should look, not that the wording was wrong.',
246
+ '',
247
+ composeProposalBlock(patch),
248
+ ].join('\n');
249
+ }
250
+
251
+ // The (source, source_ref) pair the create-vector idempotency read keys on
252
+ // (db.findTaskBySourceRef). Keyed on the string AND the proposed wording, so a
253
+ // double-submitted proposal resolves to the task it already made, while a
254
+ // genuinely different second attempt at the same string is a new task — which is
255
+ // right: two people can disagree about the fix, and the board should hold both.
256
+ function proposalSourceRef(patch) {
257
+ let hash = 5381;
258
+ const material = `${patch.string_id}${patch.file}${patch.line}${patch.proposed}`;
259
+ for (let i = 0; i < material.length; i += 1) hash = (((hash << 5) + hash) ^ material.charCodeAt(i)) >>> 0;
260
+ return `copy-proposal-${patch.string_id}-${hash.toString(36)}`;
261
+ }
262
+
263
+ module.exports = {
264
+ PROPOSAL_SCHEMA,
265
+ FENCE,
266
+ PLACEHOLDER,
267
+ MIN_WHY_CHARS,
268
+ MAX_WHY_CHARS,
269
+ MAX_TEXT_CHARS,
270
+ tidy,
271
+ placeholderCount,
272
+ normalizeProposalPayload,
273
+ composeProposalBlock,
274
+ parseProposalBlock,
275
+ proposalTaskTitle,
276
+ proposalTaskBody,
277
+ proposalSourceRef,
278
+ };
@@ -1,12 +1,14 @@
1
1
  // modules/copy-desk/routes/copy.js — the copy-flag surface (task 1003113 / R02 of
2
2
  // goal 1000074, the artist's loop).
3
3
  //
4
- // Three jobs:
4
+ // Four jobs (the fourth added by task 1003114 / R03 — see below):
5
5
  // GET /copy-desk/queue — the artist's working list: open flags with
6
6
  // live registry state attached, plus the
7
7
  // deterministic duplicate candidates.
8
8
  // POST /copy-desk/flags — file a flag. ANY signed-in builder, any role.
9
9
  // POST /copy-desk/flags/:id/close — resolve or dismiss one, with a note.
10
+ // POST /copy-desk/proposals — propose new wording. Creates a TASK carrying
11
+ // the patch; writes no file and stores no copy.
10
12
  //
11
13
  // THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
12
14
  //
@@ -24,10 +26,14 @@
24
26
  // The refusal names both paths rather than dead-ending on a bare 403.
25
27
  //
26
28
  // WHAT DOES NOT EXIST HERE, AND MUST NOT: any route that writes a user-facing
27
- // string. NO LIVE CMS is the goal's hard non-goal (task 1003114 / R03 makes the
28
- // editing affordance produce a TASK, not a write). A flag is a work request.
29
- // modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this router's write
30
- // surface stays exactly these two verbs.
29
+ // string. NO LIVE CMS is the goal's hard non-goal, and R03 kept it while adding
30
+ // the editing affordance the goal also asked for — by making the affordance
31
+ // produce a TASK carrying a patch, never a write. A flag is a work request; a
32
+ // proposal is a work request with a suggested fix attached. Neither reaches a
33
+ // reader. modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this
34
+ // router's write surface stays exactly these three verbs, that none of them
35
+ // touches the filesystem, and that the one place a proposed wording is allowed
36
+ // to rest is the GDS ledger.
31
37
  //
32
38
  // Same idioms as modules/security/routes/*.js: doorway-only requires, res.fail
33
39
  // codes, factory export mounted from module.json contributes.
@@ -38,9 +44,16 @@ const express = require('express');
38
44
  const api = require('../../../src/module-api');
39
45
  const flags = require('../flags');
40
46
  const registry = require('../registry');
47
+ const proposals = require('../proposals');
41
48
 
42
49
  const { validateOrRespond } = api;
43
50
 
51
+ // The doorway's namespaced logger, not console.error (task 1003114). Same seam
52
+ // builder-settings / dev-box / ideas / government already use: a module's log
53
+ // lines come out structured and attributable, and an error object logged as
54
+ // `{ err }` keeps its stack instead of being stringified into one line.
55
+ const log = api.logger('copy-desk');
56
+
44
57
  // The write schemas (ADR 0118): strict by default, so an unknown or typo'd field
45
58
  // is REJECTED rather than silently dropped. Built once at module scope — the
46
59
  // [...flags.KINDS] spreads mean a renamed export crashes at boot with a named
@@ -61,6 +74,19 @@ const CLOSE_BODY_SCHEMA = Object.freeze({
61
74
  resolution_note: { required: true, type: 'string', maxLength: flags.MAX_NOTE_CHARS, minLength: 1 },
62
75
  resolved_task_id: { type: 'integer', min: 1 },
63
76
  });
77
+ // The proposal body (task 1003114 / R03). `file` and `line` are OPTIONAL and only
78
+ // disambiguate a string that appears at several sites — see the route. Notice
79
+ // what is absent and must stay absent: nothing here names a destination. The
80
+ // target is resolved from the registry by `string_id`, so a caller can propose a
81
+ // change to a string the product actually shows and to nothing else.
82
+ const PROPOSAL_BODY_SCHEMA = Object.freeze({
83
+ string_id: { required: true, type: 'string', maxLength: 64, minLength: 1 },
84
+ proposed_text: { required: true, type: 'string', maxLength: proposals.MAX_TEXT_CHARS, minLength: 1 },
85
+ why: { required: true, type: 'string', maxLength: proposals.MAX_WHY_CHARS, minLength: 1 },
86
+ file: { type: 'string', maxLength: 400 },
87
+ line: { type: 'integer', min: 1 },
88
+ flag_id: { type: 'integer', min: 1 },
89
+ });
64
90
 
65
91
  // Load the registry or answer the named "no registry" state. Returns null when
66
92
  // it has already responded, so every handler is `if (!reg) return;`.
@@ -127,7 +153,7 @@ module.exports = function buildCopyDeskRouter() {
127
153
  res.set('Cache-Control', 'no-store');
128
154
  res.json(queue);
129
155
  } catch (err) {
130
- console.error('[gds] GET /copy-desk/queue', err);
156
+ log.error({ err }, 'GET /copy-desk/queue failed');
131
157
  if (!res.headersSent) res.fail('copy_queue_failed', 500);
132
158
  }
133
159
  });
@@ -163,7 +189,7 @@ module.exports = function buildCopyDeskRouter() {
163
189
  res.set('Cache-Control', 'no-store');
164
190
  res.json(result);
165
191
  } catch (err) {
166
- console.error('[gds] GET /copy-desk/strings', err);
192
+ log.error({ err }, 'GET /copy-desk/strings failed');
167
193
  if (!res.headersSent) res.fail('copy_search_failed', 500);
168
194
  }
169
195
  });
@@ -200,7 +226,7 @@ module.exports = function buildCopyDeskRouter() {
200
226
  }
201
227
  res.status(201).json({ ok: true, flag });
202
228
  } catch (err) {
203
- console.error('[gds] POST /copy-desk/flags', err);
229
+ log.error({ err }, 'POST /copy-desk/flags failed');
204
230
  if (!res.headersSent) res.fail('copy_flag_failed', 500);
205
231
  }
206
232
  });
@@ -263,10 +289,148 @@ module.exports = function buildCopyDeskRouter() {
263
289
 
264
290
  res.json({ ok: true, flag });
265
291
  } catch (err) {
266
- console.error('[gds] POST /copy-desk/flags/:id/close', err);
292
+ log.error({ err }, 'POST /copy-desk/flags/:id/close failed');
267
293
  if (!res.headersSent) res.fail('copy_flag_close_failed', 500);
268
294
  }
269
295
  });
270
296
 
297
+ // -------------------------------------------------------------------------
298
+ // POST /copy-desk/proposals — an artist proposes new wording, and it becomes a
299
+ // TASK (task 1003114 / R03 of goal 1000074).
300
+ //
301
+ // Body: { string_id, proposed_text, why, file?, line?, flag_id? }
302
+ // rank: any signed-in builder — the same floor as filing, for the same reason
303
+ // (an artist is a craft, not a rank; see the header).
304
+ //
305
+ // THIS IS THE ROUTE THE NON-GOAL IS ABOUT, so read what it does NOT do. It
306
+ // writes no file. It stores no wording in any table of this module — ADR 0178
307
+ // §3 pins that copy_desk_flags can never hold replacement text, and this
308
+ // route's whole design is what makes that pin survivable rather than merely
309
+ // strict. What it produces is a TASK carrying a patch, in the ledger every
310
+ // change already has to pass through, which nothing renders from. The wording
311
+ // reaches a person only after somebody claims that task, runs
312
+ // `node scripts/gds/copy-apply.js --task <id>`, and ships the resulting diff
313
+ // through the normal pipeline with the grader reading it.
314
+ //
315
+ // The shape is borrowed, not invented: it is `adapter.export()` from the
316
+ // design half (ADR 0081) applied to text. See modules/copy-desk/proposals.js.
317
+ // -------------------------------------------------------------------------
318
+ router.post('/copy-desk/proposals', api.requireBuilder, async (req, res) => {
319
+ try {
320
+ if (validateOrRespond(req, res, PROPOSAL_BODY_SCHEMA)) return;
321
+
322
+ const loaded = registryOrFail(res);
323
+ if (!loaded) return;
324
+
325
+ // Resolve the TARGET from the registry. Everything the patch says about
326
+ // what is being changed comes from here and never from the body — R02's
327
+ // snapshot rule (ADR 0178 §6), which matters more for a proposal than for
328
+ // a flag: a caller-supplied "current text" would let someone describe an
329
+ // edit to a sentence that does not exist, and the applier would then be
330
+ // editing a real file on the strength of a claim nobody checked.
331
+ const occurrences = registry.occurrencesOf(loaded.index, req.body.string_id);
332
+ if (!occurrences.length) {
333
+ return res.fail('unknown_string', 400, {
334
+ string_id: req.body.string_id,
335
+ what_this_means: 'The registry does not contain that string. It may have been reworded since this page loaded — reload the copy desk and look again.',
336
+ });
337
+ }
338
+
339
+ // One proposal changes ONE occurrence. When a string is shown in several
340
+ // places the caller must say which, because picking for them is exactly
341
+ // the silent wrong this goal exists to stop — and "the same sentence in
342
+ // seven places" is already its own signal on this page, with its own
343
+ // answer (change them all, deliberately, under a claim).
344
+ let entry = occurrences[0];
345
+ if (occurrences.length > 1) {
346
+ const file = req.body.file ? String(req.body.file) : null;
347
+ const line = Number.isInteger(req.body.line) ? req.body.line : null;
348
+ const picked = occurrences.filter((e) => e.file === file && e.line === line);
349
+ if (picked.length !== 1) {
350
+ return res.fail('ambiguous_string', 400, {
351
+ string_id: req.body.string_id,
352
+ occurrences: occurrences.map((e) => ({ file: e.file, line: e.line })),
353
+ what_this_means: 'This exact sentence is shown in more than one place, so a proposal has to name which one it means. That duplication is itself worth a look — the copy desk lists it under “Said more than once”.',
354
+ });
355
+ }
356
+ [entry] = picked;
357
+ }
358
+
359
+ const norm = proposals.normalizeProposalPayload({
360
+ body: req.body,
361
+ entry,
362
+ builderId: req.builder.id,
363
+ });
364
+ if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
365
+ const { patch } = norm;
366
+
367
+ // An optional link back to the flag that prompted this. Validated so a
368
+ // typo'd id is a named 400 rather than a task quietly claiming a lineage
369
+ // it does not have. The flag is NOT auto-closed: closing is a person's
370
+ // verdict on someone else's judgement (ADR 0178 §4/§6), and a proposal is
371
+ // an argument for a change, not proof the change happened.
372
+ let flagRef = null;
373
+ if (Number.isInteger(req.body.flag_id)) {
374
+ const flag = await flags.getFlag(req.body.flag_id);
375
+ if (!flag) return res.fail('flag_not_found', 404, { flag_id: req.body.flag_id });
376
+ flagRef = flag.id;
377
+ }
378
+
379
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
380
+ if (!lifecycle || !lifecycle.createTask) {
381
+ // An instance without the lifecycle module has no tasks to land into, so
382
+ // there is no honest way to accept a proposal. Refuse loudly rather than
383
+ // take the wording and drop it — a proposal that vanishes is worse than
384
+ // one that was never accepted.
385
+ return res.fail('lifecycle_unavailable', 503, {
386
+ what_this_means: 'This instance cannot hold tasks, and a copy proposal is a task. Nothing was recorded.',
387
+ });
388
+ }
389
+
390
+ // The version the work lands in: the one currently being built, matching
391
+ // how every other unattributed create-vector picks. `createTask` then
392
+ // defaults the goal to that version's catch-all, so a copy change is
393
+ // ordinary work on the board rather than a special case.
394
+ const versions = await lifecycle.listVersions();
395
+ const building = (versions || []).find((v) => v.status === 'building') || (versions || [])[0];
396
+ if (!building) return res.fail('no_open_version', 503);
397
+
398
+ const sourceRef = proposals.proposalSourceRef(patch);
399
+ const existing = lifecycle.findTaskBySourceRef
400
+ ? await lifecycle.findTaskBySourceRef({ source: 'copy-proposal', sourceRef })
401
+ : null;
402
+ if (existing) {
403
+ // A double-submit resolves to the task it already made. 200, not 201:
404
+ // nothing was created this time, and the caller gets the same task back.
405
+ return res.json({ ok: true, task: existing, created: false });
406
+ }
407
+
408
+ const body = proposals.proposalTaskBody(patch);
409
+ const task = await lifecycle.createTask({
410
+ versionId: building.id,
411
+ title: proposals.proposalTaskTitle(patch),
412
+ description: flagRef
413
+ ? `${body}\n\nRaised from copy-desk flag ${flagRef}.`
414
+ : body,
415
+ // BACKLOG, deliberately. A proposal is a request, and the board's triage
416
+ // step is where a person decides it is worth doing — the same gate a
417
+ // captured idea passes. Landing it `ready` would let anyone with an
418
+ // opinion mint claimable work.
419
+ status: 'backlog',
420
+ kind: 'feature',
421
+ priority: 4,
422
+ touches: [patch.file],
423
+ source: 'copy-proposal',
424
+ sourceRef,
425
+ createdBy: req.builder.id,
426
+ });
427
+
428
+ res.status(201).json({ ok: true, task, created: true, patch });
429
+ } catch (err) {
430
+ log.error({ err }, 'POST /copy-desk/proposals failed');
431
+ if (!res.headersSent) res.fail('copy_proposal_failed', 500);
432
+ }
433
+ });
434
+
271
435
  return router;
272
436
  };