@bongos/core 1.19.1063 → 1.19.1065

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 (65) hide show
  1. package/.bongos-core.json +136 -56
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +21 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/api/openapi.json +492 -4
  10. package/docs/api-reference.md +9 -3
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +11 -2
  16. package/docs/page-readings.json +45 -22
  17. package/modules/copy-desk/page-decisions.js +71 -0
  18. package/modules/copy-desk/page-status.js +96 -0
  19. package/modules/copy-desk/pages.js +30 -0
  20. package/modules/copy-desk/routes/copy-desk.js +153 -0
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  22. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  23. package/modules/hall-ui/public/approval-queue.css +286 -0
  24. package/modules/hall-ui/public/approval-queue.js +338 -0
  25. package/modules/hall-ui/public/studio.css +33 -0
  26. package/modules/hall-ui/public/studio.html +18 -1
  27. package/modules/hall-ui/public/studio.js +30 -0
  28. package/modules/hall-ui/public/studio.states.json +198 -0
  29. package/modules/hall-ui/records/approval-queue.md +32 -0
  30. package/modules/lifecycle/db-grade.js +96 -58
  31. package/modules/lifecycle/github-pr-close.js +41 -0
  32. package/modules/lifecycle/github-push.js +5 -2
  33. package/modules/lifecycle/lifecycle.js +12 -0
  34. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  35. package/modules/lifecycle/page-tweak-approve.js +242 -0
  36. package/modules/lifecycle/page-tweak-hold.js +74 -0
  37. package/modules/lifecycle/page-tweak-reads.js +33 -1
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/modules/lifecycle/routes/tasks.js +4 -2
  40. package/modules/lifecycle/routes/visuals.js +82 -0
  41. package/modules/lifecycle/task-visual-db.js +40 -1
  42. package/modules/lifecycle/task-visuals.js +24 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/gds/copy-apply.js +277 -1
  47. package/scripts/gds/page-reader.js +2 -0
  48. package/scripts/gds/ship-finish.js +27 -2
  49. package/scripts/gds/ship-flow.js +17 -1
  50. package/scripts/gds/ship-land.js +30 -7
  51. package/scripts/gds/ship-merge.js +43 -15
  52. package/scripts/gds/strand-watch.js +41 -1
  53. package/scripts/gds/task.js +23 -5
  54. package/scripts/gds/tweak-renders.js +200 -0
  55. package/scripts/hall-preview/server.js +18 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/copy_desk_page_approve.mjs +307 -0
  58. package/tests/fixtures/page-ledger.mjs +41 -1
  59. package/tests/gate_approvals.mjs +93 -0
  60. package/tests/hall_approval_queue.mjs +327 -0
  61. package/tests/hall_audit.mjs +4 -0
  62. package/tests/page_tweak_hold.mjs +265 -0
  63. package/tests/publish_status_branch.mjs +23 -0
  64. package/tests/task_visual_slots.mjs +161 -0
  65. package/tests/tweak_batch_apply.mjs +265 -0
@@ -0,0 +1,32 @@
1
+ # The Approval queue — a glass panel on /studio, the applied page before and after (task 1004323)
2
+
3
+ **BV2.TW12 (goal 1000095; [ADR 0341](../../../docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md) D7, D11).** Built 1:1 on the Approval queue dialog of the owner-approved master design [`docs/design/mocks/tweak-mode/Main.dc.html`](../../../docs/design/mocks/tweak-mode/README.md): the queue position ("approval queue · 1 of 2"), the page name and "· landing · N lines rewritten, applied", the before/after pills, close, the applied page on its light frame, "approve and ship", "send it back" (a one-line note and "send"), "highlighted: the words you changed", and the approved and sent-back lines.
4
+
5
+ Files: `approval-queue-lib.js` (the pure rules, `window.OTBApprovalQueueLib`, node-require-safe: every word the panel says, the render pick, the note floor), `approval-queue.js` (the panel, `window.OTBApprovalQueue.mount`) and `approval-queue.css` (the artboard's inline styles, one rule per element). The studio loads all three and mounts the panel behind a quick action (`#studio-approvals`, "Approval queue", "2 pages applied, waiting for you").
6
+
7
+ ## A component, so TW16 places it without rebuilding it
8
+
9
+ `OTBApprovalQueue.mount({ api, apiBase, writes: { approve, sendBack }, onCount })` returns `{ open, close, refresh, count }`. The panel appends its own overlay to `body`, reads `GET /copy-desk/approvals` and `GET /tasks/:id/visuals`, and writes ONLY through the `writes` it is handed. The studio hands it `WRITE_ROUTES.approve` and `WRITE_ROUTES.sendBack` from its own allowlist, so the studio's no-live-CMS test still names every write the room can make. **TW16 (the lofi home) keeps the three files as they are:** it calls `mount` with the same two writes and turns its own "Approval queue" quick action into `open()`, with `onCount` feeding the action's detail line.
10
+
11
+ ## The page is a picture of the applied branch
12
+
13
+ The frame shows the renders `/tweak` attached (TW11's named slots, `before|after-desktop|phone-light|dark`), never a page rebuilt from the batch (ADR 0341 builder pick 9, which keeps ADR 0233's "nothing renders a task description as product copy" true). Only server-minted `task-visuals` URLs are put in a `src`. A missing view falls back to the other colour mode, then the other device, and says what it shows. With no render at all, approve is disabled (there is nothing to approve from) and send-back stays. On a phone (760px and under) the phone render is the default.
14
+
15
+ **A word cannot be highlighted inside an image**, so the changed lines stand beside the render (under it on a phone): the section in mono, the new words in the artboard's accent mark (after) or the old words unmarked (before), "was:" / "becomes:" under each, and a line the applier refused says why ("not applied: the page no longer shows it"). The hint "highlighted: the words you changed" points at that list.
16
+
17
+ ## Where it differs from the artboard, and what forced it
18
+
19
+ - **The page is an image of the real page**, not the artboard's hand-drawn light landing page: ADR 0341 pick 9 and the task. So the highlight is on the list beside it, not on the words in the page.
20
+ - **A second switch, desktop · phone and light · dark**, sits above the lines: the task says the eight renders fill the view switch. The header keeps the artboard's before/after pills and close exactly.
21
+ - **The page frame is 300px narrower** to make room for the lines.
22
+ - **The header counts refused lines** ("· 1 not applied"), which the artboard's sample never had: ADR 0341 D4, a refused line is shown to the artist at approval.
23
+ - **After a decision, "open <next page>"** sits beside the outcome line when another page waits; the artboard names the next page and stops.
24
+ - **The scrim is .94 over the light hall and .88 on a phone** (the artboard's .42 over the dark room otherwise): at .42 over a light page, or through the full-screen phone glass, the grey words fall below the AA floor.
25
+ - **A viewer who may not decide** (neither the author nor an artist) reads the queue with one sentence in place of the two buttons, the studio's `studio-verdict__sealed` precedent.
26
+ - **Not on the artboard at all:** the phone layout, light mode, the empty queue ("Nothing waiting"), the could-not-read line, the refusal line (the server's `what_this_means`), and the studio door itself (the artboard's 72px quick-action row, in the hall's tokens; the night room is TW16's).
27
+
28
+ The overlay root is `.aq-overlay` and its palette is its own token tier (`tests/hall_audit.mjs` `LITERAL_EXEMPT`), the tweak editor's precedent: glass over the night studio in both modes, so it cannot spend the hall's mode-flipping tier or its `--scrim`.
29
+
30
+ ## The proof
31
+
32
+ `studio.states.json` renders six panel states (approvals, approvals-before, approvals-phone, approvals-note, approved, sent-back) beside the studio's own. Populated, against the hall-preview harness (`node scripts/hall-preview/server.js --port 4634 --fixture-me`), which answers the queue from `copy-desk__approvals.json` (built by the real `composeApprovals`), the renders from `tasks__visuals.json`, and the two decisions from its CANNED WRITES. `HALL_PREVIEW_VISUALS=<dir>` makes the harness serve real images by the fixture's names (the review sheet used TW11's real renders of landing:index). All clean at 1440, 390 and 320, dark and light. Behaviour: `tests/hall_approval_queue.mjs` (the real panel in a vm over the real composer's answer) and `tests/copy_desk_page_approve.mjs` (the routes and the lifecycle port, whole path).
@@ -15,6 +15,81 @@ const { pool, withTx } = api;
15
15
  const seams = api;
16
16
  const { logGradeAttempt } = require('./db-analytics.js');
17
17
  const { mostRecentClaimHolderId } = require('./db-shared.js');
18
+ const tweakHold = require('./page-tweak-hold.js');
19
+
20
+ // promoteToConfirmed — completed -> confirmed, and the grade-time rewards that
21
+ // go with it: the net-negative bonus, the ship achievements and the achievement
22
+ // reconcile. Runs inside the CALLER's transaction, on its client, after the task
23
+ // row is locked. Two callers: applyGrade (an ordinary pass) and approveHeldTweak
24
+ // (task 1004323 / BV2.TW12, ADR 0341 D7), which performs for a page tweak the
25
+ // promotion its pass was held from. One body, so an approved page is promoted
26
+ // and rewarded exactly as any passed grade is, and the two cannot drift.
27
+ //
28
+ // diffStats the ship's diff_stats (the grade's signals carry them)
29
+ // committedFiles the ship's committed paths, or null (touches[] then)
30
+ // reward the resolved `reward` port
31
+ //
32
+ // Returns { netNegativeBonus, justUnlocked }.
33
+ async function promoteToConfirmed(client, { taskId, creditedBuilderId, diffStats = null, committedFiles = null, reward }) {
34
+ await client.query(
35
+ `UPDATE tasks
36
+ SET status = 'confirmed',
37
+ shipped_by = COALESCE(shipped_by, $2),
38
+ updated_at = now()
39
+ WHERE id = $1 AND status = 'completed'`,
40
+ [taskId, creditedBuilderId]
41
+ );
42
+
43
+ // V3.R93 (#354): reward simplification. A ship whose diff removed more
44
+ // lines than it added earns a small flat bonus, written as its own
45
+ // credit_log row (reason='ship.net_negative') with a self-describing
46
+ // description. Idempotent on (task_id, reason) so a re-grade is a no-op.
47
+ // diff_stats arrives via the grade's signals (ship.js decorates it).
48
+ //
49
+ // Awarded BEFORE the achievement evaluators so the Subtractor achievement
50
+ // (which counts the builder's net-negative ships) sees the current ship's
51
+ // bonus row in this same transaction.
52
+ const nn = await reward.awardNetNegativeBonus(
53
+ client, creditedBuilderId, taskId, diffStats
54
+ );
55
+ const netNegativeBonus = nn.bonus;
56
+ const subtractorUnlocks = nn.justUnlocked;
57
+
58
+ // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
59
+ // NO LONGER awarded at grader-confirm. They land at the confirmed→shipped
60
+ // flip (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task
61
+ // that strands at 'confirmed' pays nothing until its code is on `main`.
62
+ // The net-negative (simplification) bonus above STAYS here — it derives
63
+ // from grade-time diff_stats that only exist at grade time, so it cannot
64
+ // move to shipTask (and is outside ADR 0120's two named reward writes).
65
+ // creditsAwarded/creditsRaw/creditsMultiplier/ideaBonus stay 0/null at
66
+ // confirm for a stable return contract; the credit lands at ship. The
67
+ // credit-crossing achievement evaluator (evaluateAfterCreditAward) moves
68
+ // with the credit to shipTask's reconcile; here we still fire the
69
+ // ship/subtractor unlocks that DON'T depend on the per-task credit.
70
+ const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
71
+ let justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
72
+
73
+ // task 1679: state-based safety net. The granular evaluators above are
74
+ // boundary-/path-sensitive — breaking-the-100c-bar only fires when a
75
+ // per-task confirm credit crosses 100 (the session cost-plus reward never
76
+ // hits it), and streaks key off current_streak. reconcile re-checks every
77
+ // achievement against current state and unlocks any straggler. It is
78
+ // idempotent: anything the granular calls just unlocked is visible in this
79
+ // txn, so reconcile's alreadyUnlocked guard skips it (no dup awards).
80
+ // SAVEPOINT-wrapped so it can never fail the grade-confirm.
81
+ if (reward && reward.reconcileBuilderAchievements) {
82
+ await client.query('SAVEPOINT ach_reconcile');
83
+ try {
84
+ const reconciled = await reward.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
85
+ await client.query('RELEASE SAVEPOINT ach_reconcile');
86
+ if (reconciled.length) justUnlocked = [...justUnlocked, ...reconciled];
87
+ } catch (_) {
88
+ await client.query('ROLLBACK TO SAVEPOINT ach_reconcile');
89
+ }
90
+ }
91
+ return { netNegativeBonus, justUnlocked };
92
+ }
18
93
 
19
94
  // applyGrade — V3.R18 (#209). Writes the subagent grader result to task_grades
20
95
  // and, if the grade passed, promotes completed→confirmed + awards credits.
@@ -124,7 +199,6 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
124
199
  let creditsMultiplier = 1.0;
125
200
  let ideaBonus = null; // { ideaBonusAwarded, bonusBuilderId, ideaId } | null
126
201
  let netNegativeBonus = 0;
127
- let subtractorUnlocks = [];
128
202
  let justUnlocked = [];
129
203
  // Credit allocation + gamification carved to modules/economy/ (BV1.R77 / ADR
130
204
  // 0093 §1-2): resolve the `reward` kernel port ONCE; every credit/achievement
@@ -132,69 +206,29 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
132
206
  // state-machine SQL (FOR UPDATE, task_grades upsert, UPDATE tasks status,
133
207
  // claim-holder lookup, logGradeAttempt) stays here in the lifecycle.
134
208
  const reward = seams.resolveOptional('reward');
209
+
210
+ // THE HOLD (ADR 0341 D7; task 1004322 / BV2.TW11). A page tweak whose grade
211
+ // passes WAITS at completed for its artist: the grade row above is written,
212
+ // and the promotion below is withheld. TW12's approve performs it. Asked only
213
+ // on a pass at completed, and false for every task whose source is not
214
+ // exactly 'page-tweak', so every other grade takes the unchanged path below.
215
+ let held = false;
135
216
  if (gradeResult.passed && task.status === 'completed') {
136
- await client.query(
137
- `UPDATE tasks
138
- SET status = 'confirmed',
139
- shipped_by = COALESCE(shipped_by, $2),
140
- updated_at = now()
141
- WHERE id = $1 AND status = 'completed'`,
142
- [taskId, creditedBuilderId]
143
- );
217
+ const { rows: srcRows } = await client.query(`SELECT source FROM tasks WHERE id = $1`, [taskId]);
218
+ held = tweakHold.isPageTweak(srcRows[0] || null);
219
+ }
220
+ if (gradeResult.passed && task.status === 'completed' && !held) {
221
+ const promoted = await promoteToConfirmed(client, {
222
+ taskId, creditedBuilderId, diffStats: gradeResult.signals?.diff_stats, committedFiles, reward,
223
+ });
144
224
  finalStatus = 'confirmed';
145
-
146
- // V3.R93 (#354): reward simplification. A ship whose diff removed more
147
- // lines than it added earns a small flat bonus, written as its own
148
- // credit_log row (reason='ship.net_negative') with a self-describing
149
- // description. Idempotent on (task_id, reason) so a re-grade is a no-op.
150
- // diff_stats arrives via gradeResult.signals (ship.js decorates it).
151
- //
152
- // Awarded BEFORE the achievement evaluators so the Subtractor achievement
153
- // (which counts the builder's net-negative ships) sees the current ship's
154
- // bonus row in this same transaction.
155
- const nn = await reward.awardNetNegativeBonus(
156
- client, creditedBuilderId, taskId, gradeResult.signals?.diff_stats
157
- );
158
- netNegativeBonus = nn.bonus;
159
- subtractorUnlocks = nn.justUnlocked;
160
-
161
- // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
162
- // NO LONGER awarded at grader-confirm. They land at the confirmed→shipped
163
- // flip (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task
164
- // that strands at 'confirmed' pays nothing until its code is on `main`.
165
- // The net-negative (simplification) bonus above STAYS here — it derives
166
- // from grade-time diff_stats that only exist at grade time, so it cannot
167
- // move to shipTask (and is outside ADR 0120's two named reward writes).
168
- // creditsAwarded/creditsRaw/creditsMultiplier/ideaBonus stay 0/null at
169
- // confirm for a stable return contract; the credit lands at ship. The
170
- // credit-crossing achievement evaluator (evaluateAfterCreditAward) moves
171
- // with the credit to shipTask's reconcile; here we still fire the
172
- // ship/subtractor unlocks that DON'T depend on the per-task credit.
173
- const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
174
- justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
175
-
176
- // task 1679: state-based safety net. The granular evaluators above are
177
- // boundary-/path-sensitive — breaking-the-100c-bar only fires when a
178
- // per-task confirm credit crosses 100 (the session cost-plus reward never
179
- // hits it), and streaks key off current_streak. reconcile re-checks every
180
- // achievement against current state and unlocks any straggler. It is
181
- // idempotent: anything the granular calls just unlocked is visible in this
182
- // txn, so reconcile's alreadyUnlocked guard skips it (no dup awards).
183
- // SAVEPOINT-wrapped so it can never fail the grade-confirm.
184
- if (reward && reward.reconcileBuilderAchievements) {
185
- await client.query('SAVEPOINT ach_reconcile');
186
- try {
187
- const reconciled = await reward.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
188
- await client.query('RELEASE SAVEPOINT ach_reconcile');
189
- if (reconciled.length) justUnlocked = [...justUnlocked, ...reconciled];
190
- } catch (_) {
191
- await client.query('ROLLBACK TO SAVEPOINT ach_reconcile');
192
- }
193
- }
225
+ netNegativeBonus = promoted.netNegativeBonus;
226
+ justUnlocked = promoted.justUnlocked;
194
227
  }
195
228
  // !passed → task_grades row written above; task stays at completed; no
196
229
  // credits, no achievements. Builder can manual-confirm via /confirm if the
197
230
  // grade was wrong.
231
+ // held → the same row, the same completed; the pass is recorded and waits.
198
232
 
199
233
  return {
200
234
  taskId,
@@ -207,6 +241,9 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
207
241
  ideaBonus, // V3.R27 (#247): { ideaBonusAwarded, bonusBuilderId, ideaId } | null
208
242
  netNegativeBonus,
209
243
  advanceToMerge: finalStatus === 'confirmed',
244
+ // task 1004322: true only for a page tweak whose pass is waiting for its
245
+ // artist. ship.js reads it to publish the branch without landing it.
246
+ held,
210
247
  justUnlocked,
211
248
  grade: {
212
249
  score: gradeResult.score,
@@ -230,4 +267,5 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
230
267
 
231
268
  module.exports = {
232
269
  applyGrade,
270
+ promoteToConfirmed,
233
271
  };
@@ -0,0 +1,41 @@
1
+ // modules/lifecycle/github-pr-close.js — close an open PR without merging it
2
+ // (task 1004323 / BV2.TW12, ADR 0341 D7).
3
+ //
4
+ // The Approval queue's send-back returns a page tweak to the /tweak queue, and
5
+ // the applied branch's PR must not sit open to be merged by anything that sweeps
6
+ // green PRs. Same server credential and the same PATCH as github-push.js
7
+ // recheckPullRequest's close half, never reopened here. Its own file because
8
+ // github-push.js sits at the 1,500-line ratchet; it reuses that file's token and
9
+ // headers rather than a second copy of either.
10
+
11
+ 'use strict';
12
+
13
+ const githubPush = require('./github-push');
14
+ const { repoInfo } = require('../../src/module-api');
15
+
16
+ function fail(code, extra = {}) {
17
+ return Object.assign(new Error(code), { code, ...extra });
18
+ }
19
+
20
+ // closePullRequest({ number }, deps) -> { closed: true, number }
21
+ // deps: token / repoInfo / fetchImpl, the github-push test seams.
22
+ async function closePullRequest({ number }, deps = {}) {
23
+ const token = await githubPush.resolveToken(deps);
24
+ if (!token) throw fail('PUSH_UNCONFIGURED');
25
+ if (!Number.isInteger(number) || number < 1) throw fail('BAD_PR');
26
+ const info = deps.repoInfo || repoInfo.loadRepoInfo();
27
+ if (!info || info.error) throw fail('NO_REPO_INFO');
28
+ const doFetch = deps.fetchImpl || fetch;
29
+ const res = await doFetch(`https://api.github.com/repos/${info.owner}/${info.repo}/pulls/${number}`, {
30
+ method: 'PATCH',
31
+ headers: { ...githubPush.ghHeaders(token), 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ state: 'closed' }),
33
+ });
34
+ if (!res.ok) {
35
+ const detail = await res.text().catch(() => '');
36
+ throw fail('PR_PATCH_FAILED', { status: res.status, detail: githubPush.scrubCredential(detail).slice(0, 200) });
37
+ }
38
+ return { closed: true, number };
39
+ }
40
+
41
+ module.exports = { closePullRequest };
@@ -1392,7 +1392,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1392
1392
  // `reopened` event carrying full PR context. Stays inside the App's existing
1393
1393
  // `pull_requests:write` — no new permission (the deterministic Actions rerun API would
1394
1394
  // need `actions:write`, deliberately not granted; see blocker 37).
1395
- async function recheckPullRequest({ number }, deps = {}) {
1395
+ // `arm: false` (task 1004323): reopen WITHOUT re-arming, for a page tweak held for its
1396
+ // artist (ADR 0341 D7); `heldReason` becomes the auto_merge_reason.
1397
+ async function recheckPullRequest({ number, arm = true, heldReason = null }, deps = {}) {
1396
1398
  const token = await resolveToken(deps);
1397
1399
  if (!token) throw err('PUSH_UNCONFIGURED');
1398
1400
  if (!Number.isInteger(number) || number < 1) throw err('BAD_PR');
@@ -1426,7 +1428,8 @@ async function recheckPullRequest({ number }, deps = {}) {
1426
1428
  let autoMerge = false;
1427
1429
  let autoMergeReason = null;
1428
1430
  try {
1429
- if (reopened && reopened.node_id) {
1431
+ if (!arm) autoMergeReason = heldReason || 'auto-merge was not re-armed (held)';
1432
+ else if (reopened && reopened.node_id) {
1430
1433
  // The DETAILED variant: the boolean façade discarded the cause here, and no
1431
1434
  // response body carried it either — the quietest of the four (task 1003511).
1432
1435
  const armed = await enableAutoMergeDetailed(
@@ -27,6 +27,7 @@ const mingle = require('./mingle');
27
27
  const pageTweakReads = require('./page-tweak-reads');
28
28
  const pageTweakClaim = require('./page-tweak-claim');
29
29
  const pageTweakWrite = require('./page-tweak-write');
30
+ const pageTweakApprove = require('./page-tweak-approve');
30
31
 
31
32
  module.exports = {
32
33
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -126,6 +127,17 @@ module.exports = {
126
127
  // updateTaskDescription and releaseClaim; see modules/lifecycle/page-tweak-write.js.
127
128
  updateHeldTaskDescription: (...a) => pageTweakWrite.updateHeldTaskDescription(...a),
128
129
  submitPageTweak: (...a) => pageTweakWrite.submitPageTweak(...a),
130
+ // The Approval queue's two decisions (task 1004323 / BV2.TW12, ADR 0341 D7,
131
+ // D11): approve a page tweak held for its artist (completed -> confirmed with
132
+ // the grade-time rewards its pass skipped, then the server lands it), or send
133
+ // it back (a sent-back block, the claim released, the grade row dropped, the
134
+ // PR closed, the round back to ready). Only the author or an artist decides.
135
+ // See modules/lifecycle/page-tweak-approve.js.
136
+ approveHeldTweak: (...a) => pageTweakApprove.approveHeldTweak(...a),
137
+ sendBackHeldTweak: (...a) => pageTweakApprove.sendBackHeldTweak(...a),
138
+ // A builder's crafts (help-requests' reader, from builders.preferred_disciplines),
139
+ // so the queue read can say whether the caller may decide before they try.
140
+ builderCrafts: (...a) => helpRequests.craftsForBuilder(...a),
129
141
  listVersions: (...a) => db.listVersions(...a),
130
142
  versionProgress: (...a) => db.versionProgress(...a),
131
143
  recentShippedTasks: (...a) => db.recentShippedTasks(...a),
@@ -0,0 +1,52 @@
1
+ -- lifecycle_013_task_visual_slots.sql — a task can carry NAMED visuals, not only
2
+ -- the one (task 1004322 / BV2.TW11, goal 1000095; ADR 0341 D7 and its builder
3
+ -- pick 9).
4
+ --
5
+ -- WHY. core_223 gave a task ONE optional image (tasks.visual_url + visual_alt),
6
+ -- the picture beside its value summary. A page tweak needs eight: the applied
7
+ -- page before and after, on desktop and on a phone, in light and in dark. The
8
+ -- Approval queue (TW12) shows the artist exactly those renders, images of the
9
+ -- applied branch, never a page built from the batch, which is what keeps ADR
10
+ -- 0233's "nothing renders a task description as product copy" true. One column
11
+ -- cannot hold eight pictures, and eight columns would be a schema for one
12
+ -- feature, so a visual gets a NAME and a row.
13
+ --
14
+ -- THE SINGLE VISUAL IS UNTOUCHED. tasks.visual_url stays the ship-time picture
15
+ -- every existing reader renders (the hall, the status feed); a slot is a
16
+ -- separate, named picture a reader asks for by name. Nothing here moves,
17
+ -- copies or backfills the existing column.
18
+ --
19
+ -- A MODULE TABLE, because ADR 0083 lets a module migration create only its own
20
+ -- lifecycle_-namespaced tables; it references the core tasks table the way
21
+ -- lifecycle_010 does, and never alters it.
22
+ --
23
+ -- THE SAME AUDIENCE AS THE ONE VISUAL. A slot's file is stored and served by the
24
+ -- existing task-visuals store (task-<id>-<hex>.<ext>, GET /task-visuals/:name),
25
+ -- whose serve route derives who may see an image from the TASK it names. So a
26
+ -- slot adds no privacy axis, exactly as core_223 promised of the column.
27
+ --
28
+ -- PORTABLE and replay-safe: IF NOT EXISTS throughout, no instance data.
29
+
30
+ BEGIN;
31
+
32
+ CREATE TABLE IF NOT EXISTS lifecycle_task_visual_slots (
33
+ task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
34
+
35
+ -- The slot's name: lowercase words joined by dashes, such as
36
+ -- 'after-phone-dark'. The vocabulary a page tweak uses is declared once in
37
+ -- modules/lifecycle/task-visuals.js (TWEAK_RENDER_SLOTS); the column checks only
38
+ -- the SHAPE, so a later feature can name its own slots without a migration.
39
+ slot text NOT NULL CHECK (slot ~ '^[a-z0-9]+(-[a-z0-9]+){0,5}$' AND length(slot) <= 48),
40
+
41
+ -- The served URL (/api/bongos/task-visuals/<file>), minted by the store.
42
+ visual_url text NOT NULL,
43
+ visual_alt text,
44
+
45
+ updated_at timestamptz NOT NULL DEFAULT now(),
46
+
47
+ -- One picture per (task, slot): attaching a slot again REPLACES it, and the
48
+ -- route unlinks the superseded file.
49
+ PRIMARY KEY (task_id, slot)
50
+ );
51
+
52
+ COMMIT;
@@ -0,0 +1,242 @@
1
+ // modules/lifecycle/page-tweak-approve.js — the two decisions of the Approval
2
+ // queue: approve a held page tweak so it lands, or send it back to the /tweak
3
+ // queue with a note (task 1004323 / BV2.TW12, ADR 0341 D7 and D11).
4
+ //
5
+ // On the `lifecycle` port because they move `tasks.status`, and the lifecycle
6
+ // owns the state machine; copy-desk calls them through the port and never
7
+ // writes a status itself (ADR 0341 D7). What the sent-back block SAYS is
8
+ // copy-desk's (modules/copy-desk/pages.js, its one writer) and is handed in as
9
+ // a function; the transaction, the hold check and the authority are here.
10
+ //
11
+ // THE HOLD THEY RELEASE. A page tweak whose grade passes waits at `completed`
12
+ // (page-tweak-hold.js, applyGrade): its branch and PR are published, not armed.
13
+ // Both decisions are refused (`not_waiting`) unless the round is exactly that:
14
+ // source 'page-tweak', status 'completed', and a strict passed grade. So a round
15
+ // still being applied, one already landing, or an ordinary task cannot be moved
16
+ // through here.
17
+ //
18
+ // WHO DECIDES (ADR 0341 D7, builder pick 3): the round's author (the builder of
19
+ // its latest WEB claim, the D10 payee rule, read from `claims`, never the
20
+ // request), or any other builder whose crafts include artist (help-requests'
21
+ // own reader), so an absent author does not strand a page. The project owner's
22
+ // escape hatch is the existing override-request path (confirmTask is not held),
23
+ // recorded and audit-logged there. Everyone else is refused `not_an_artist`.
24
+ //
25
+ // APPROVE performs the promotion the grade withheld, through the SAME body
26
+ // applyGrade runs (db-grade.promoteToConfirmed): completed -> confirmed, the
27
+ // net-negative bonus from the grade's own diff_stats, the ship achievements and
28
+ // the reconcile, all credited to the applier (the task's most recent claim
29
+ // holder, applyGrade's rule). Then the server lands it as it lands any
30
+ // confirmed task: after the commit it nudges the PR's merge once (mergeIfGreen,
31
+ // best-effort), and the publish reconciler's sweep merges it otherwise. Nothing
32
+ // here arms or forces anything GitHub's own gate refuses.
33
+ //
34
+ // SEND-BACK appends the page-tweak-sent-back block, releases any claim still
35
+ // open on the round (the applier's CLI claim is normally released by its ship
36
+ // already), DELETES the round's grade row and returns the task to `ready`, so
37
+ // the round reads `submitted` again: back in the /tweak queue. After the commit
38
+ // it closes the unmerged PR (best-effort; a ready task's PR is never swept).
39
+ //
40
+ // WHY THE GRADE ROW GOES. task_grades keeps one row per task, the latest. Left
41
+ // in place, the re-applied round would reach `completed` at its next ship with
42
+ // the OLD pass still on it, and read as waiting for the artist, approvable,
43
+ // before its new work was graded; ship.js would also take that stale pass as a
44
+ // held pass and re-publish instead of grading (ship-flow's hold check). The
45
+ // attempt stays in the append-only grade-attempts log.
46
+ //
47
+ // Outcomes are returned, not thrown, so the route maps each to a named answer:
48
+ // approved | sent_back
49
+ // not_found no such task
50
+ // wrong_source not a page tweak
51
+ // not_waiting not held for its artist (status and grade named)
52
+ // not_an_artist the caller is neither the author nor an artist
53
+ // builder_inactive
54
+ // refused copy-desk's compose refused (its refusal passed back)
55
+
56
+ 'use strict';
57
+
58
+ const api = require('../../src/module-api');
59
+ const hold = require('./page-tweak-hold');
60
+ const helpRequests = require('./help-requests');
61
+ const githubPush = require('./github-push');
62
+ const prClose = require('./github-pr-close');
63
+
64
+ // The GitHub reads and writes the two decisions make, resolved at call time so a
65
+ // test's stub of either file bites. A test hands its own as deps.push.
66
+ const PUSH = Object.freeze({
67
+ isConfigured: () => githubPush.isConfigured(),
68
+ findTaskPullRequest: (a) => githubPush.findTaskPullRequest(a),
69
+ mergeIfGreen: (a) => githubPush.mergeIfGreen(a),
70
+ closePullRequest: (a) => prClose.closePullRequest(a),
71
+ });
72
+ const { mostRecentClaimHolderId } = require('./db-shared.js');
73
+ const { promoteToConfirmed } = require('./db-grade.js');
74
+
75
+ const log = api.logger('lifecycle');
76
+
77
+ function activePoolFor(deps) {
78
+ return (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
79
+ }
80
+
81
+ // Lock the round, check it is held for its artist, and check the caller may
82
+ // decide it. Returns { task, grade, authorId } or an outcome.
83
+ async function lockWaitingRound(client, { taskId, builderId }) {
84
+ const { rows } = await client.query(
85
+ `SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches
86
+ FROM tasks WHERE id = $1 FOR UPDATE`,
87
+ [taskId]
88
+ );
89
+ const task = rows[0];
90
+ if (!task) return { outcome: 'not_found' };
91
+ if (!hold.isPageTweak(task)) return { outcome: 'wrong_source', task };
92
+ const { rows: g } = await client.query('SELECT passed, signals FROM task_grades WHERE task_id = $1', [taskId]);
93
+ const grade = g[0] || null;
94
+ if (!hold.isHeldForArtist(task, grade ? grade.passed : null)) {
95
+ return { outcome: 'not_waiting', task, grade_passed: grade ? grade.passed : null };
96
+ }
97
+ const { rows: b } = await client.query(
98
+ 'SELECT id, status, github_login, display_name FROM builders WHERE id = $1', [builderId]
99
+ );
100
+ if (!b[0] || b[0].status === 'inactive') return { outcome: 'builder_inactive', task };
101
+ const { rows: w } = await client.query(
102
+ `SELECT builder_id FROM claims
103
+ WHERE task_id = $1 AND worktree_name IS NULL AND creator_session_id IS NULL
104
+ ORDER BY claimed_at DESC, id DESC LIMIT 1`,
105
+ [taskId]
106
+ );
107
+ const authorId = w[0] ? String(w[0].builder_id) : null;
108
+ if (authorId !== String(builderId)) {
109
+ const crafts = await helpRequests.craftsForBuilder(builderId, { pool: client });
110
+ if (!crafts.includes('artist')) return { outcome: 'not_an_artist', task, author_id: authorId };
111
+ }
112
+ return { task, grade, authorId };
113
+ }
114
+
115
+ async function inTx(deps, body) {
116
+ const client = await activePoolFor(deps).connect();
117
+ try {
118
+ await client.query('BEGIN');
119
+ const out = await body(client);
120
+ await client.query(out && out.commit ? 'COMMIT' : 'ROLLBACK');
121
+ return out;
122
+ } catch (err) {
123
+ await client.query('ROLLBACK').catch(() => {});
124
+ throw err;
125
+ } finally {
126
+ client.release();
127
+ }
128
+ }
129
+
130
+ // The round's PR, from the server's own GitHub reads (the reconciler's
131
+ // adopt-by-task lookup). Null when there is no credential or no such PR.
132
+ async function roundPullRequest(taskId, push) {
133
+ if (!push.isConfigured()) return null;
134
+ return push.findTaskPullRequest({ taskId: Number(taskId) });
135
+ }
136
+
137
+ // After an approve commits: one merge nudge, the reconciler's own first step.
138
+ // Best-effort and never thrown: the task is confirmed either way, and the
139
+ // sweep lands it when this misses.
140
+ async function nudgeLand(taskId, push = PUSH) {
141
+ try {
142
+ const pr = await roundPullRequest(taskId, push);
143
+ if (!pr) return { nudged: false, reason: 'no_pr_found', lands_via: 'reconciler' };
144
+ if (pr.merged) return { nudged: false, merged: true, pr_number: pr.pr_number };
145
+ if (pr.pr_state !== 'open') return { nudged: false, reason: `pr_${pr.pr_state || 'unknown'}`, pr_number: pr.pr_number, lands_via: 'reconciler' };
146
+ const m = await push.mergeIfGreen({ branch: pr.branch });
147
+ return { nudged: true, merged: !!(m && m.merged), merged_now: !!(m && m.merged_now), reason: (m && m.reason) || null, pr_number: pr.pr_number, lands_via: m && m.merged ? 'merged' : 'reconciler' };
148
+ } catch (err) {
149
+ log.warn({ task_id: String(taskId), err: err && err.message }, 'approve: merge nudge failed (the reconciler lands it)');
150
+ return { nudged: false, reason: 'nudge_failed', lands_via: 'reconciler' };
151
+ }
152
+ }
153
+
154
+ // After a send-back commits: close the applied branch's unmerged PR.
155
+ async function closeRoundPr(taskId, push = PUSH) {
156
+ try {
157
+ const pr = await roundPullRequest(taskId, push);
158
+ if (!pr) return { closed: false, reason: 'no_pr_found' };
159
+ if (pr.merged) return { closed: false, reason: 'already_merged', pr_number: pr.pr_number };
160
+ if (pr.pr_state !== 'open') return { closed: false, reason: `pr_${pr.pr_state || 'unknown'}`, pr_number: pr.pr_number };
161
+ await push.closePullRequest({ number: Number(pr.pr_number) });
162
+ return { closed: true, pr_number: pr.pr_number };
163
+ } catch (err) {
164
+ log.warn({ task_id: String(taskId), err: err && err.message }, 'send-back: closing the PR failed (it stays un-armed)');
165
+ return { closed: false, reason: 'close_failed' };
166
+ }
167
+ }
168
+
169
+ // approveHeldTweak({ taskId, builderId }, deps)
170
+ //
171
+ // deps.pool (NODE_ENV=test only), deps.reward (the resolved reward port),
172
+ // deps.push (github-push, for the post-commit nudge).
173
+ async function approveHeldTweak({ taskId, builderId }, deps = {}) {
174
+ const reward = deps.reward || api.resolveOptional('reward');
175
+ const out = await inTx(deps, async (client) => {
176
+ const held = await lockWaitingRound(client, { taskId, builderId });
177
+ if (held.outcome) return held;
178
+ const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? builderId;
179
+ const signals = (held.grade && held.grade.signals) || {};
180
+ const promoted = await promoteToConfirmed(client, {
181
+ taskId,
182
+ creditedBuilderId,
183
+ diffStats: signals.diff_stats || null,
184
+ committedFiles: null,
185
+ reward,
186
+ });
187
+ return {
188
+ commit: true,
189
+ outcome: 'approved',
190
+ task: { id: String(held.task.id), status: 'confirmed', source_ref: held.task.source_ref, title: held.task.title },
191
+ advanceToMerge: true,
192
+ author_id: held.authorId,
193
+ credited_builder_id: creditedBuilderId == null ? null : String(creditedBuilderId),
194
+ net_negative_bonus: promoted.netNegativeBonus,
195
+ just_unlocked: promoted.justUnlocked,
196
+ };
197
+ });
198
+ delete out.commit;
199
+ if (out.outcome !== 'approved') return out;
200
+ out.land = await nudgeLand(taskId, deps.push || PUSH);
201
+ return out;
202
+ }
203
+
204
+ // sendBackHeldTweak({ taskId, builderId, compose }, deps)
205
+ //
206
+ // compose(task) -> { ok: true, description } | { ok: false, code, detail }
207
+ //
208
+ // copy-desk's compose appends the page-tweak-sent-back block ({ at, by, note });
209
+ // the note's floor is checked there and at the route.
210
+ async function sendBackHeldTweak({ taskId, builderId, compose }, deps = {}) {
211
+ const out = await inTx(deps, async (client) => {
212
+ const held = await lockWaitingRound(client, { taskId, builderId });
213
+ if (held.outcome) return held;
214
+ const r = compose(held.task);
215
+ if (!r || !r.ok) return { outcome: 'refused', refusal: r, task: held.task };
216
+ const { rows } = await client.query(
217
+ `UPDATE tasks SET description = $2, status = 'ready', updated_at = now() WHERE id = $1
218
+ RETURNING id, status, description, source_ref, updated_at`,
219
+ [taskId, r.description]
220
+ );
221
+ const { rows: released } = await client.query(
222
+ `UPDATE claims SET released_at = now(), outcome = 'partial'
223
+ WHERE task_id = $1 AND released_at IS NULL
224
+ RETURNING id`,
225
+ [taskId]
226
+ );
227
+ await client.query('DELETE FROM task_grades WHERE task_id = $1', [taskId]);
228
+ return {
229
+ commit: true,
230
+ outcome: 'sent_back',
231
+ task: rows[0],
232
+ author_id: held.authorId,
233
+ released_claim_ids: released.map((x) => String(x.id)),
234
+ };
235
+ });
236
+ delete out.commit;
237
+ if (out.outcome !== 'sent_back') return out;
238
+ out.pr = await closeRoundPr(taskId, deps.push || PUSH);
239
+ return out;
240
+ }
241
+
242
+ module.exports = { approveHeldTweak, sendBackHeldTweak };