@bongos/core 1.19.1064 → 1.19.1066

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 (49) hide show
  1. package/.bongos-core.json +96 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +11 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +1 -0
  8. package/docs/api/openapi.json +237 -4
  9. package/docs/api-reference.md +5 -2
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +11 -2
  15. package/docs/page-readings.json +45 -22
  16. package/modules/copy-desk/page-decisions.js +71 -0
  17. package/modules/copy-desk/page-status.js +100 -2
  18. package/modules/copy-desk/pages.js +30 -0
  19. package/modules/copy-desk/routes/copy-desk.js +156 -2
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  21. package/modules/economy/credits.js +77 -0
  22. package/modules/economy/reward-policy.js +7 -1
  23. package/modules/economy/reward.js +4 -0
  24. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  25. package/modules/hall-ui/public/approval-queue.css +286 -0
  26. package/modules/hall-ui/public/approval-queue.js +338 -0
  27. package/modules/hall-ui/public/studio.css +33 -0
  28. package/modules/hall-ui/public/studio.html +18 -1
  29. package/modules/hall-ui/public/studio.js +30 -0
  30. package/modules/hall-ui/public/studio.states.json +198 -0
  31. package/modules/hall-ui/records/approval-queue.md +32 -0
  32. package/modules/lifecycle/db-grade.js +80 -58
  33. package/modules/lifecycle/db-ship.js +14 -5
  34. package/modules/lifecycle/github-pr-close.js +41 -0
  35. package/modules/lifecycle/github-push.js +5 -2
  36. package/modules/lifecycle/lifecycle.js +12 -0
  37. package/modules/lifecycle/page-tweak-approve.js +242 -0
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +12 -0
  42. package/scripts/hall-preview/server.js +18 -0
  43. package/src/module-api.js +1 -1
  44. package/tests/artist_page_credit.mjs +272 -0
  45. package/tests/copy_desk_page_approve.mjs +307 -0
  46. package/tests/fixtures/page-ledger.mjs +41 -1
  47. package/tests/gate_approvals.mjs +93 -0
  48. package/tests/hall_approval_queue.mjs +327 -0
  49. package/tests/hall_audit.mjs +4 -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).
@@ -17,6 +17,80 @@ const { logGradeAttempt } = require('./db-analytics.js');
17
17
  const { mostRecentClaimHolderId } = require('./db-shared.js');
18
18
  const tweakHold = require('./page-tweak-hold.js');
19
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
+ }
93
+
20
94
  // applyGrade — V3.R18 (#209). Writes the subagent grader result to task_grades
21
95
  // and, if the grade passed, promotes completed→confirmed + awards credits.
22
96
  //
@@ -125,7 +199,6 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
125
199
  let creditsMultiplier = 1.0;
126
200
  let ideaBonus = null; // { ideaBonusAwarded, bonusBuilderId, ideaId } | null
127
201
  let netNegativeBonus = 0;
128
- let subtractorUnlocks = [];
129
202
  let justUnlocked = [];
130
203
  // Credit allocation + gamification carved to modules/economy/ (BV1.R77 / ADR
131
204
  // 0093 §1-2): resolve the `reward` kernel port ONCE; every credit/achievement
@@ -145,64 +218,12 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
145
218
  held = tweakHold.isPageTweak(srcRows[0] || null);
146
219
  }
147
220
  if (gradeResult.passed && task.status === 'completed' && !held) {
148
- await client.query(
149
- `UPDATE tasks
150
- SET status = 'confirmed',
151
- shipped_by = COALESCE(shipped_by, $2),
152
- updated_at = now()
153
- WHERE id = $1 AND status = 'completed'`,
154
- [taskId, creditedBuilderId]
155
- );
221
+ const promoted = await promoteToConfirmed(client, {
222
+ taskId, creditedBuilderId, diffStats: gradeResult.signals?.diff_stats, committedFiles, reward,
223
+ });
156
224
  finalStatus = 'confirmed';
157
-
158
- // V3.R93 (#354): reward simplification. A ship whose diff removed more
159
- // lines than it added earns a small flat bonus, written as its own
160
- // credit_log row (reason='ship.net_negative') with a self-describing
161
- // description. Idempotent on (task_id, reason) so a re-grade is a no-op.
162
- // diff_stats arrives via gradeResult.signals (ship.js decorates it).
163
- //
164
- // Awarded BEFORE the achievement evaluators so the Subtractor achievement
165
- // (which counts the builder's net-negative ships) sees the current ship's
166
- // bonus row in this same transaction.
167
- const nn = await reward.awardNetNegativeBonus(
168
- client, creditedBuilderId, taskId, gradeResult.signals?.diff_stats
169
- );
170
- netNegativeBonus = nn.bonus;
171
- subtractorUnlocks = nn.justUnlocked;
172
-
173
- // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
174
- // NO LONGER awarded at grader-confirm. They land at the confirmed→shipped
175
- // flip (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task
176
- // that strands at 'confirmed' pays nothing until its code is on `main`.
177
- // The net-negative (simplification) bonus above STAYS here — it derives
178
- // from grade-time diff_stats that only exist at grade time, so it cannot
179
- // move to shipTask (and is outside ADR 0120's two named reward writes).
180
- // creditsAwarded/creditsRaw/creditsMultiplier/ideaBonus stay 0/null at
181
- // confirm for a stable return contract; the credit lands at ship. The
182
- // credit-crossing achievement evaluator (evaluateAfterCreditAward) moves
183
- // with the credit to shipTask's reconcile; here we still fire the
184
- // ship/subtractor unlocks that DON'T depend on the per-task credit.
185
- const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
186
- justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
187
-
188
- // task 1679: state-based safety net. The granular evaluators above are
189
- // boundary-/path-sensitive — breaking-the-100c-bar only fires when a
190
- // per-task confirm credit crosses 100 (the session cost-plus reward never
191
- // hits it), and streaks key off current_streak. reconcile re-checks every
192
- // achievement against current state and unlocks any straggler. It is
193
- // idempotent: anything the granular calls just unlocked is visible in this
194
- // txn, so reconcile's alreadyUnlocked guard skips it (no dup awards).
195
- // SAVEPOINT-wrapped so it can never fail the grade-confirm.
196
- if (reward && reward.reconcileBuilderAchievements) {
197
- await client.query('SAVEPOINT ach_reconcile');
198
- try {
199
- const reconciled = await reward.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
200
- await client.query('RELEASE SAVEPOINT ach_reconcile');
201
- if (reconciled.length) justUnlocked = [...justUnlocked, ...reconciled];
202
- } catch (_) {
203
- await client.query('ROLLBACK TO SAVEPOINT ach_reconcile');
204
- }
205
- }
225
+ netNegativeBonus = promoted.netNegativeBonus;
226
+ justUnlocked = promoted.justUnlocked;
206
227
  }
207
228
  // !passed → task_grades row written above; task stays at completed; no
208
229
  // credits, no achievements. Builder can manual-confirm via /confirm if the
@@ -246,4 +267,5 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
246
267
 
247
268
  module.exports = {
248
269
  applyGrade,
270
+ promoteToConfirmed,
249
271
  };
@@ -18,6 +18,7 @@ const doneWhen = require('./done-when.js');
18
18
  const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName } = require('./db-rank-authz.js');
19
19
  const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
20
20
  const { LIVE_STATUSES } = require('./dead-deps.js');
21
+ const { isPageTweak } = require('./page-tweak-hold.js');
21
22
 
22
23
  // shouldRefuseClaimResolve — may THIS caller resolve THIS claim? (task 1003767)
23
24
  //
@@ -346,8 +347,15 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
346
347
  // row, re-landing books a FRESH 'task.shipped' row — the reward is fully
347
348
  // re-earnable, exactly as the ADR promises. Caller passes its ship txn client
348
349
  // so the credit commits atomically with the status flip.
349
- async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsReward, kind }) {
350
+ async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsReward, kind, source = null }) {
350
351
  const reward = seams.resolveOptional('reward');
352
+ // ADR 0341 D10 (task 1004324): a page tweak's per-task stream is its ARTIST's, on the
353
+ // artist lane, in place of the shipper's estimate (economy reads the payee); the
354
+ // applier keeps its cost-plus session reward, which is booked elsewhere.
355
+ if (reward && isPageTweak({ source })) {
356
+ const artist = reward.awardArtistPageCredit ? await reward.awardArtistPageCredit(client, { taskId }) : null;
357
+ return { creditsAwarded: 0, ideaBonus: null, artist };
358
+ }
351
359
  if (!reward || creditedBuilderId == null || !(Number(creditsReward) > 0)) {
352
360
  return { creditsAwarded: 0, ideaBonus: null };
353
361
  }
@@ -510,7 +518,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
510
518
  // under NODE_ENV==='test', so a regression test can drive the txn with a
511
519
  // SQL-aware fakeClient. Production callers omit deps → module pool.
512
520
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
513
- const { shipRow, graduationBuilder, rankGraduation, actualShipperId, closedClaimCount, creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked } = await withTx(async (client) => {
521
+ const { shipRow, graduationBuilder, rankGraduation, actualShipperId, closedClaimCount, creditedBuilderId, creditsAwarded, ideaBonus, artistCredit, justUnlocked } = await withTx(async (client) => {
514
522
  const { rows } = await client.query(
515
523
  `UPDATE tasks
516
524
  SET status = 'shipped',
@@ -521,7 +529,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
521
529
  needs_rebase_at = NULL,
522
530
  updated_at = now()
523
531
  WHERE id = $1 AND status = 'confirmed'
524
- RETURNING id, title, status, shipped_at, shipped_by, security_sensitive, value_summary, credits_reward, kind`,
532
+ RETURNING id, title, status, shipped_at, shipped_by, security_sensitive, value_summary, credits_reward, kind, source`,
525
533
  [taskId, builderId]
526
534
  );
527
535
  if (rows.length === 0) {
@@ -626,6 +634,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
626
634
  creditedBuilderId,
627
635
  creditsReward: rows[0].credits_reward,
628
636
  kind: rows[0].kind,
637
+ source: rows[0].source,
629
638
  });
630
639
  // task 1679 pattern: state-based achievement reconcile now that the credit
631
640
  // (above) + the ship count are reflected in this txn. SAVEPOINT-wrapped so
@@ -647,7 +656,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
647
656
  return {
648
657
  shipRow: rows[0], graduationBuilder, rankGraduation, actualShipperId,
649
658
  closedClaimCount: closedClaims.length,
650
- creditedBuilderId, creditsAwarded: landing.creditsAwarded, ideaBonus: landing.ideaBonus, justUnlocked,
659
+ creditedBuilderId, creditsAwarded: landing.creditsAwarded, ideaBonus: landing.ideaBonus, artistCredit: landing.artist || null, justUnlocked,
651
660
  };
652
661
  }, { pool: activePool });
653
662
  if (closedClaimCount > 0) {
@@ -677,7 +686,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
677
686
  // ADR 0120: the per-task reward that landed on THIS ship (0 on a re-ship /
678
687
  // reconciler re-sweep — idempotent). The ship route surfaces it so the
679
688
  // builder sees "credits landed" at ship, not at confirm.
680
- creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked,
689
+ creditedBuilderId, creditsAwarded, ideaBonus, artistCredit, justUnlocked,
681
690
  };
682
691
  }
683
692
 
@@ -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,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 };
@@ -24,6 +24,11 @@ const express = require('express');
24
24
  const api = require('../../../src/module-api');
25
25
  const auth = api;
26
26
  const githubPush = require('../github-push');
27
+ // The page-tweak hold (task 1004323 / BV2.TW12, ADR 0341 D7): the approve below
28
+ // re-arms auto-merge on the reopened PR, and a page tweak held for its artist must
29
+ // never be armed from here. Called through the module object so tests can stub it.
30
+ const pageTweakReads = require('../page-tweak-reads');
31
+ const { HELD_NOT_ARMED } = require('../page-tweak-hold');
27
32
  const { parseId } = api;
28
33
  // The SAME pure classifier the CI gate runs — reused (never duplicated) so the hall
29
34
  // shows identical verdict + reasons. Safe to require: gate-review.js guards its CLI
@@ -168,9 +173,16 @@ module.exports = function buildGateApprovalsRouter() {
168
173
 
169
174
  // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
170
175
  // required check re-evaluates the new approval; re-arm auto-merge.
176
+ // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
177
+ // 1004323): clearing the gate check is the owner's act on the PR, and
178
+ // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
179
+ // read that throws answers held, and an ordinary confirmed task left
180
+ // un-armed still lands through the publish reconciler's sweep.
181
+ const prTask = taskIdFromPr(pr);
182
+ const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
171
183
  let recheck = { reopened: false, auto_merge: false };
172
184
  try {
173
- recheck = await githubPush.recheckPullRequest({ number });
185
+ recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
174
186
  } catch (e) {
175
187
  // The status IS set; if the re-trigger failed, the next push or a manual
176
188
  // re-run still clears it, and the publish reconciler sweep merges once green.
@@ -187,7 +199,10 @@ module.exports = function buildGateApprovalsRouter() {
187
199
  head_sha: pr.head_sha,
188
200
  status_set: true,
189
201
  recheck,
190
- message: 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
202
+ held_for_artist: held,
203
+ message: held
204
+ ? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
205
+ : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
191
206
  });
192
207
  } catch (err) {
193
208
  console.error('[gds] POST /gate-approvals/:pr/approve', err);