@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.
- package/.bongos-core.json +68 -43
- 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 +56 -34
- package/docs/copy-registry.json +237 -39
- package/docs/design/mocks/hall/task/v1.html +7 -11
- package/docs/design/mocks/hall/task/v3.html +7 -11
- 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/hall-ui/public/task.css +71 -1
- package/modules/hall-ui/public/task.js +132 -13
- package/modules/hall-ui/public/task.states.json +1 -1
- package/modules/lifecycle/db-claim-reads.js +40 -0
- package/modules/lifecycle/db.js +4 -0
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/tasks.js +12 -1
- 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
- package/tests/hall_record_world.mjs +183 -2
- 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)
|
|
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:
|
|
608
|
-
|
|
609
|
-
|
|
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:
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
//
|
|
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
|
|
28
|
-
// editing affordance
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|