@bongos/core 1.21.108 → 1.21.110
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 +63 -28
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +16 -5
- package/docs/copy-inventory.md +14 -10
- package/docs/copy-registry.json +48 -12
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +195 -195
- package/modules/copy-desk/skills/tweak/SKILL.md +28 -0
- package/modules/hall-ui/public/craft-mode.js +276 -21
- package/modules/hall-ui/public/craft-review.css +227 -0
- package/modules/hall-ui/public/craft-review.js +64 -0
- package/modules/hall-ui/public/style.css +1 -0
- package/modules/hall-ui/records/craft-mode.md +8 -0
- package/modules/lifecycle/autopickup-files.js +140 -0
- package/modules/lifecycle/db-ship.js +2 -1
- package/modules/lifecycle/routes/tasks.js +4 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/copy-apply.js +17 -2
- package/scripts/gds/tweak-halt.js +69 -0
- package/scripts/hall-preview/server.js +12 -1
- package/src/module-api.js +1 -1
- package/tests/craft_review_mode.mjs +307 -0
- package/tests/hall_preview_encoded_paths.mjs +81 -0
- package/tests/page_notes_panel.mjs +4 -1
- package/tests/planning_session_skill.mjs +62 -4
- package/tests/tweak_batch_apply.mjs +16 -0
- package/tests/tweak_unattended.mjs +274 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// craft-review.js — the scribble wall of artist review mode (task 1004686,
|
|
2
|
+
// goal 1000095). Loaded on demand by craft-mode.js, only while an artist is
|
|
3
|
+
// reviewing an experience: wallHtml() returns small doodles a pen scribbles,
|
|
4
|
+
// one after another, into the top bar's empty space, so the reviewer can see
|
|
5
|
+
// at a glance that the hall they are looking at is under review.
|
|
6
|
+
//
|
|
7
|
+
// The doodles are drawn in the sky's hand (thinking.css, ADR 0331): hairlines,
|
|
8
|
+
// small stars, dashed orbits and constellations, in ink alone — black on the
|
|
9
|
+
// light bar, white on the dark one (owner, 2026-10-09: "small black white
|
|
10
|
+
// graffiti scribbling", not the first cut's big painted tags).
|
|
11
|
+
//
|
|
12
|
+
// Markup only, and decorative (the wall is aria-hidden; the Review button and
|
|
13
|
+
// its menu say the state in words). Every motion is CSS in craft-review.css:
|
|
14
|
+
// no motion in JavaScript (DESIGN.md, the Kill Switch Rule), so a reader who
|
|
15
|
+
// asks for reduced motion sees three finished doodles standing still.
|
|
16
|
+
(function () {
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
var H = 24;
|
|
20
|
+
|
|
21
|
+
// The six doodles, in the order they are scribbled. Each is { w, wide, svg }:
|
|
22
|
+
// `wide` ones fold away when the wall is too tight for them.
|
|
23
|
+
var DOODLES = [
|
|
24
|
+
// A constellation: five stars joined by a hairline, one with a halo.
|
|
25
|
+
{ w: 56, wide: true, svg:
|
|
26
|
+
'<path class="gx-ln gx-ln--soft" d="M3 17 16 8l12 5 13-9 12 7"/>' +
|
|
27
|
+
'<circle class="gx-pt" cx="3" cy="17" r="1.5"/><circle class="gx-pt" cx="16" cy="8" r="1.5"/>' +
|
|
28
|
+
'<circle class="gx-pt" cx="28" cy="13" r="1.5"/><circle class="gx-pt" cx="53" cy="11" r="1.5"/>' +
|
|
29
|
+
'<circle class="gx-pt" cx="41" cy="4" r="2"/><circle class="gx-halo" cx="41" cy="4" r="4.5"/>' },
|
|
30
|
+
// A ringed protostar on a dashed orbit, a moon riding it.
|
|
31
|
+
{ w: 40, svg:
|
|
32
|
+
'<circle class="gx-ln" cx="20" cy="12" r="5"/>' +
|
|
33
|
+
'<ellipse class="gx-ln gx-ln--dash" cx="20" cy="12" rx="16" ry="4.5" transform="rotate(-16 20 12)"/>' +
|
|
34
|
+
'<circle class="gx-pt" cx="35" cy="6" r="1.3"/>' },
|
|
35
|
+
// A shooting star: a scribbled trail and a four-point head.
|
|
36
|
+
{ w: 52, wide: true, svg:
|
|
37
|
+
'<path class="gx-ln gx-ln--soft" d="M2 21c9-2 18-6 31-12"/><path class="gx-ln gx-ln--soft" d="M10 22c6-1.5 12-4 19-7.5"/>' +
|
|
38
|
+
'<path class="gx-ln" d="M42 1.5v9M37.5 6h9"/><circle class="gx-pt" cx="42" cy="6" r="1.4"/>' },
|
|
39
|
+
// A crescent and a spark beside it.
|
|
40
|
+
{ w: 32, svg:
|
|
41
|
+
'<path class="gx-ln" d="M16 3.5a8.5 8.5 0 1 0 9.5 12.6A7 7 0 0 1 16 3.5z"/>' +
|
|
42
|
+
'<path class="gx-ln" d="M27 2.5v5M24.5 5h5"/>' },
|
|
43
|
+
// A scribbled orbit loop, the pen going round and round.
|
|
44
|
+
{ w: 40, svg:
|
|
45
|
+
'<path class="gx-ln" d="M3 15c3-8 11-9 13-4s-5 8-7 4 4-9 11-8 10 6 7 9-8 1-5-4 9-6 13 0"/>' },
|
|
46
|
+
// A cluster of sparks, big to small.
|
|
47
|
+
{ w: 30, svg:
|
|
48
|
+
'<path class="gx-ln" d="M8 2.5v11M2.5 8h11"/><path class="gx-ln gx-ln--soft" d="M21 13v7M17.5 16.5h7"/>' +
|
|
49
|
+
'<circle class="gx-pt" cx="27" cy="5" r="1.2"/><circle class="gx-pt" cx="14" cy="20" r="1"/>' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function wallHtml() {
|
|
53
|
+
return DOODLES.map(function (d, i) {
|
|
54
|
+
return '<span class="gx-tag gx-tag--' + (i + 1) + (d.wide ? ' gx-tag--wide' : '') + '" style="--gx-w:' + d.w + 'px">' +
|
|
55
|
+
'<span class="gx-win"><span class="gx-ink">' +
|
|
56
|
+
'<svg class="gx-svg" viewBox="0 0 ' + d.w + ' ' + H + '" width="' + d.w + '" height="' + H + '" aria-hidden="true">' + d.svg + '</svg>' +
|
|
57
|
+
'</span></span>' +
|
|
58
|
+
'<span class="gx-track"><span class="gx-nib"></span></span>' +
|
|
59
|
+
'</span>';
|
|
60
|
+
}).join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
window.OTBCraftReview = { wallHtml: wallHtml, COUNT: DOODLES.length };
|
|
64
|
+
})();
|
|
@@ -57,3 +57,11 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
|
|
|
57
57
|
- **The panel lists the ideas already filed from this page** (`GET /page-notes/ideas?page_path=`): open and promoted ideas whose body carries the page's link, from the lightbulb (`](/page)`) or the palette (`](/page?note=`). That link is the only record of where an idea came from, so `notes.pageLinks` and the two idea bodies are pinned together in `tests/lightbulb.mjs`.
|
|
58
58
|
- **Unsent text waits in this browser only**, per page (`localStorage 'hall-lightbulb:<path>'`), and is forgotten once it files.
|
|
59
59
|
- `tests/lightbulb.mjs` runs the real routes and the real script over a small DOM.
|
|
60
|
+
|
|
61
|
+
## Artist review mode (task 1004686)
|
|
62
|
+
|
|
63
|
+
- **Review is a split pill** (owner, 2026-10-09): pressing Review still opens the feedback doc; hovering it with a mouse, or pressing its chevron, opens a menu: "Write feedback on this page", then the four experiences in the owner's order and words (Ideator, Governing, Building, Artistry experience), then "Stop reviewing" while a review is on. Hover is asked of the pointer (`pointerType === 'mouse'`), never `(hover: hover)`: a touch laptop can report no hover at all. On a phone (<=640px) the chevron folds away and a tap on Review opens the menu.
|
|
64
|
+
- **Picking one makes the hall wear that mode** (`html[data-craft]`, so its tabs, look, eyebrow and tallies follow) and stamps `html[data-craft-review]`. The artist's own mode stays Artistry: `sessionStorage['hall-craft-review']` holds the experience for the session only, and is ignored unless the mode is Artistry. Choosing any mode from the switch or the picker ends it. The artist keeps Review and Edit words; the experience's own tools follow them, marked `craft-tool--guest` so a phone under 360px can fold them away (three tools pushed 320px 36px sideways; Artistry alone overflows 4px there today, before and after).
|
|
65
|
+
- **The scribble wall** (`craft-review.js`, loaded on demand; `craft-review.css`, imported by `style.css`): a flexible box in the top bar's empty space where a pen tip scribbles six small doodles in the sky's hand (thinking.css: hairlines, stars, a dashed orbit, a constellation, a crescent, a shooting star, a looped scribble, sparks), each at its own spot and height, a new one every 5s of a 30s round and each up about 10s, so two or three show at once. **Ink alone** (owner, 2026-10-09, after a first cut of big painted colour tags: "small black white graffiti scribbling"): every mark is `--ink`, black on the light bar and white on the dark, with the sky's fainter ladder for trails and orbits; the test holds the wall section to that one token. No doodle is wider than 56px or taller than 24px; the two widest fold away on a wall under 58px (Governing at 1440px keeps about 60), all of them under 34px and on a phone. The wipe is two opposite `translateX` moves, so every motion is transform or opacity in CSS, and the kill switch leaves three doodles standing, finished, with no pen. A doodle not yet begun keeps its pen unseen (`gx-track` starts at opacity 0), or each waiting tip shows as a stray dot.
|
|
66
|
+
- **An @import lands before style.css's own rules**, so a rule in `craft-review.css` that restyles `.craft-tool` needs two classes, or `.craft-tool` silently wins (it did, for the reviewing wash and the guest fold, until measured).
|
|
67
|
+
- `tests/craft_review_mode.mjs` runs the real `craft-mode.js` and `craft-review.js` over a small DOM, and reads the sheet's motion rules.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/autopickup-files.js — an automatic page tweak may change only
|
|
4
|
+
// its own page (task 1004678 / BV2.TW19b, ADR 0371 D7, D8).
|
|
5
|
+
//
|
|
6
|
+
// The Autobongos tweak lane claims a round with the auto_pickup mark (task
|
|
7
|
+
// 1004677) and runs `/tweak --page <id> --unattended` with nobody watching. The
|
|
8
|
+
// skill tells the worker to stay inside the page; this file is why the server
|
|
9
|
+
// does not have to TRUST that. At publish, the route asks `autoPickupFileRefusal`
|
|
10
|
+
// for the task; when the round's latest claim carries the mark, the server reads
|
|
11
|
+
// the pull request's files itself (GitHub, never the client's list) and refuses
|
|
12
|
+
// the publish if any path is
|
|
13
|
+
//
|
|
14
|
+
// - outside the page's own files (docs/page-inventory.json `files` +
|
|
15
|
+
// `states_files`) and not one of the files a ship regenerates anyway, or
|
|
16
|
+
// - a protected path (ADR 0043's matcher, the same one decideTask runs).
|
|
17
|
+
//
|
|
18
|
+
// FAIL CLOSED. A page id that does not parse, a page the inventory does not know,
|
|
19
|
+
// a file read that throws, or a list at GitHub's cap (it stops at 300 and says
|
|
20
|
+
// nothing) all refuse: an unattended round that cannot be checked is a round a
|
|
21
|
+
// person applies. An UNMARKED claim is never read here, so every other publish is
|
|
22
|
+
// unchanged.
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const api = require('../../src/module-api');
|
|
27
|
+
const githubPush = require('./github-push');
|
|
28
|
+
const { PR_FILES_CAP } = require('./ship-files');
|
|
29
|
+
const { isPageTweak } = require('./page-tweak-hold');
|
|
30
|
+
|
|
31
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
32
|
+
const INVENTORY_REL = 'docs/page-inventory.json';
|
|
33
|
+
|
|
34
|
+
// What a /tweak ship writes besides the page's own files: copy-apply regenerates
|
|
35
|
+
// the copy registry and re-reads the page, and ship.js refreshes the generated
|
|
36
|
+
// maps. Not CLAUDE.md: it is protected, and its §13 snippet moves only when the
|
|
37
|
+
// branch adds a session-log file, which the unattended mode never writes. A path
|
|
38
|
+
// here still runs through the protected-path matcher.
|
|
39
|
+
const SHIP_WRITES = Object.freeze([
|
|
40
|
+
'docs/copy-registry.json',
|
|
41
|
+
'docs/copy-inventory.md',
|
|
42
|
+
'docs/page-readings.json',
|
|
43
|
+
'docs/repo-map.md',
|
|
44
|
+
'docs/file-map.md',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// 'page-tweak/landing:index/r2' -> 'landing:index' (copy-desk's parseRoundRef,
|
|
48
|
+
// restated: a module never requires another module).
|
|
49
|
+
function pageIdFromRef(sourceRef) {
|
|
50
|
+
const m = /^page-tweak\/([a-z0-9-]+:[a-z0-9-]+)\/r[1-9][0-9]*$/.exec(String(sourceRef || ''));
|
|
51
|
+
return m ? m[1] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readInventory(root = REPO_ROOT) {
|
|
55
|
+
return JSON.parse(fs.readFileSync(path.join(root, INVENTORY_REL), 'utf8'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pageFiles(inventory, pageId) {
|
|
59
|
+
const page = ((inventory && inventory.pages) || []).find((p) => p && p.id === pageId);
|
|
60
|
+
if (!page) return null;
|
|
61
|
+
return [...(page.files || []), ...(page.states_files || [])];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// PURE. The verdict over one round's changed paths: null when every path may
|
|
65
|
+
// change, else { code, page_id, outside[], protected[] }.
|
|
66
|
+
function decideFiles({ pageId, allowed, changed, truncated = false, matchProtected }) {
|
|
67
|
+
if (!pageId || !Array.isArray(allowed)) {
|
|
68
|
+
return { code: 'auto_pickup_page_unknown', page_id: pageId || null, outside: [], protected: [] };
|
|
69
|
+
}
|
|
70
|
+
if (truncated) return { code: 'auto_pickup_files_unreadable', page_id: pageId, outside: [], protected: [] };
|
|
71
|
+
const ok = new Set([...allowed, ...SHIP_WRITES]);
|
|
72
|
+
const outside = changed.filter((p) => !ok.has(p));
|
|
73
|
+
const prot = (matchProtected(changed) || []).map((m) => (m && m.file) || m);
|
|
74
|
+
if (!outside.length && !prot.length) return null;
|
|
75
|
+
return { code: 'auto_pickup_outside_page', page_id: pageId, outside, protected: prot };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The round as the check needs it, in ONE read: its source, its ref, and whether
|
|
79
|
+
// its latest claim (released or not: a publish runs after the ship resolved it)
|
|
80
|
+
// carried the mark. A read that FAILS answers null and the publish goes on as
|
|
81
|
+
// usual: an ordinary publish must not depend on this read, and a worker cannot
|
|
82
|
+
// make the database fail to see its own claim.
|
|
83
|
+
const ROUND_SQL = `
|
|
84
|
+
SELECT t.id, t.source, t.source_ref,
|
|
85
|
+
(SELECT c.auto_pickup FROM claims c WHERE c.task_id = t.id
|
|
86
|
+
ORDER BY c.claimed_at DESC, c.id DESC LIMIT 1) AS auto_pickup
|
|
87
|
+
FROM tasks t WHERE t.id = $1`;
|
|
88
|
+
async function readRound(taskId, exec = api.pool) {
|
|
89
|
+
try {
|
|
90
|
+
const { rows } = await exec.query(ROUND_SQL, [taskId]);
|
|
91
|
+
return rows[0] || null;
|
|
92
|
+
} catch (_) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The route's one call. Returns null (publish as usual) or the refusal.
|
|
98
|
+
// `deps` is the test seam.
|
|
99
|
+
async function autoPickupFileRefusal({ taskId, prNumber }, deps = {}) {
|
|
100
|
+
const round = await (deps.readRound || readRound)(taskId);
|
|
101
|
+
if (!isPageTweak(round) || round.auto_pickup !== true) return null;
|
|
102
|
+
const pageId = pageIdFromRef(round.source_ref);
|
|
103
|
+
let allowed = null;
|
|
104
|
+
try { allowed = pageId ? pageFiles((deps.readInventory || readInventory)(), pageId) : null; } catch (_) { allowed = null; }
|
|
105
|
+
let rows;
|
|
106
|
+
try {
|
|
107
|
+
rows = await (deps.getPullRequestFiles || ((a) => githubPush.getPullRequestFiles(a)))({ number: Number(prNumber) });
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return { code: 'auto_pickup_files_unreadable', page_id: pageId, outside: [], protected: [] };
|
|
110
|
+
}
|
|
111
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
112
|
+
return decideFiles({
|
|
113
|
+
pageId, allowed,
|
|
114
|
+
changed: list.map((x) => x && x.path).filter((p) => typeof p === 'string' && p),
|
|
115
|
+
truncated: list.length >= PR_FILES_CAP,
|
|
116
|
+
matchProtected: deps.matchProtected || ((files) => api.permissionPathCheck.matchProtected(files)),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function refusalMessage(r) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
if (r.outside && r.outside.length) parts.push(`outside the page: ${r.outside.slice(0, 10).join(', ')}`);
|
|
123
|
+
if (r.protected && r.protected.length) parts.push(`protected: ${r.protected.slice(0, 10).join(', ')}`);
|
|
124
|
+
const why = {
|
|
125
|
+
auto_pickup_outside_page: `An automatic page tweak may change only its own page's files (${parts.join('; ')}).`,
|
|
126
|
+
auto_pickup_page_unknown: 'An automatic page tweak names a page the inventory does not know, so its files cannot be checked.',
|
|
127
|
+
auto_pickup_files_unreadable: "The pull request's files could not be read in full, so an automatic round cannot be checked.",
|
|
128
|
+
}[r.code];
|
|
129
|
+
return `${why} The pull request was closed; the round waits for a person (ADR 0371 D8).`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
SHIP_WRITES,
|
|
134
|
+
pageIdFromRef,
|
|
135
|
+
pageFiles,
|
|
136
|
+
decideFiles,
|
|
137
|
+
readRound,
|
|
138
|
+
autoPickupFileRefusal,
|
|
139
|
+
refusalMessage,
|
|
140
|
+
};
|
|
@@ -1038,7 +1038,8 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
|
|
|
1038
1038
|
// degenerate state, e.g. status set directly via SQL) so a legitimate
|
|
1039
1039
|
// confirm is never refused. Mirrors the #538 fix on the override path.
|
|
1040
1040
|
const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? builderId;
|
|
1041
|
-
|
|
1041
|
+
const { rows: srcRows } = await client.query('SELECT source FROM tasks WHERE id = $1', [taskId]); // ADR 0371 D8 (task 1004678): never past the artist
|
|
1042
|
+
if (isPageTweak(srcRows[0] || null)) throw Object.assign(new Error('a page tweak is confirmed only by its artist'), { code: 'PAGE_TWEAK_NEEDS_ARTIST' });
|
|
1042
1043
|
const { rows } = await client.query(
|
|
1043
1044
|
`UPDATE tasks
|
|
1044
1045
|
SET status = 'confirmed',
|
|
@@ -36,7 +36,7 @@ const { gradeQualityWhere } = require('../db-shared');
|
|
|
36
36
|
const { helperJoinSql } = require('../page-tweak-artist'); // task 1004432: a page tweak's applier as a helper
|
|
37
37
|
const auth = api;
|
|
38
38
|
const db = require('../db');
|
|
39
|
-
const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED, prMergeSentence } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
|
|
39
|
+
const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED, prMergeSentence } = require('../page-tweak-hold'); const autoPickupFiles = require('../autopickup-files'); const githubPrClose = require('../github-pr-close'); // ADR 0341 D7 (tests stub the read); ADR 0371 D8
|
|
40
40
|
const hierarchy = require('../hierarchy');
|
|
41
41
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
42
42
|
// The orphan-task advisory (task 1002539) — pure, fail-open; see goal-advisory.js.
|
|
@@ -696,9 +696,8 @@ module.exports = function buildTasksRouter() {
|
|
|
696
696
|
const result = await db.confirmTask({ taskId: id, builderId: req.builder.id });
|
|
697
697
|
res.json({ ok: true, ...result });
|
|
698
698
|
} catch (err) {
|
|
699
|
-
if (err.code === 'TASK_NOT_COMPLETED')
|
|
700
|
-
|
|
701
|
-
}
|
|
699
|
+
if (err.code === 'TASK_NOT_COMPLETED') return res.fail('task_not_completed', 409);
|
|
700
|
+
if (err.code === 'PAGE_TWEAK_NEEDS_ARTIST') return res.fail('page_tweak_needs_artist', { status: 409, message: `Task #${id} is a page tweak: it is confirmed only when its artist approves it in the studio, or by the owner's override request (ADR 0371 D8).` });
|
|
702
701
|
log.error('[gds] POST /tasks/:id/confirm', err);
|
|
703
702
|
res.fail('confirm_failed', { status: 500, message: 'internal error' });
|
|
704
703
|
}
|
|
@@ -1184,6 +1183,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1184
1183
|
`Merging triggers .github/workflows/deploy-prod.yml -> ${branding().domains.publicOrigin}. ` +
|
|
1185
1184
|
prMergeSentence(held);
|
|
1186
1185
|
const pr = await githubPush.openOrFindPullRequest({ branch, title, body });
|
|
1186
|
+
const apRefusal = await autoPickupFiles.autoPickupFileRefusal({ taskId: id, prNumber: pr.number }); if (apRefusal) { await githubPrClose.closePullRequest({ number: pr.number }).catch(() => null); return res.fail(apRefusal.code, 409, { ...apRefusal, message: autoPickupFiles.refusalMessage(apRefusal) }); } // ADR 0371 D8 (task 1004678)
|
|
1187
1187
|
// Best-effort by contract (pr-assign.js never rejects); the catch is for a future
|
|
1188
1188
|
// edit that forgets — an assignment must not be able to fail a pushed ship.
|
|
1189
1189
|
const assignment = await prAssign.assignPullRequest({ number: pr.number, login: builderLogin })
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.110",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.110",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.110",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9498,5 +9498,25 @@
|
|
|
9498
9498
|
"id": "1004696",
|
|
9499
9499
|
"text": "Artists can now recommend removing a line while editing a page's words: mark it with \"Remove this line\" or just delete its words, see it crossed out, keep it after all, and send it with their rewrites. Each removal goes to an"
|
|
9500
9500
|
}
|
|
9501
|
+
],
|
|
9502
|
+
"1.21.109": [
|
|
9503
|
+
{
|
|
9504
|
+
"id": "1004700",
|
|
9505
|
+
"text": "The \"Edit words\" paintbrush now works in the local hall preview instead of saying the page could not be taken."
|
|
9506
|
+
},
|
|
9507
|
+
{
|
|
9508
|
+
"id": "1004675",
|
|
9509
|
+
"text": "Planning sessions now keep the work tied to the design the owner agreed. Each goal's done-when line must carry the agreed model (the named states and rules), and each proof task must describe a scenario a wrong build would fai"
|
|
9510
|
+
}
|
|
9511
|
+
],
|
|
9512
|
+
"1.21.110": [
|
|
9513
|
+
{
|
|
9514
|
+
"id": "1004686",
|
|
9515
|
+
"text": "Artists can now review the hall through anyone's eyes. In Artistry mode, hovering the Review button opens a menu: Ideator, Governing, Building or Artistry experience. Picking one shows every page the way a builder in that mode"
|
|
9516
|
+
},
|
|
9517
|
+
{
|
|
9518
|
+
"id": "1004678",
|
|
9519
|
+
"text": "The page-tweak helper can now run on its own: given one page, it applies the artist's words without asking anything, and if anything goes wrong it hands the page back with a plain note. The server enforces the safety rules its"
|
|
9520
|
+
}
|
|
9501
9521
|
]
|
|
9502
9522
|
}
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
//
|
|
33
33
|
// Run: node scripts/gds/copy-apply.js --batch (a PAGE TWEAK round you hold: every line, task 1004322)
|
|
34
34
|
// node scripts/gds/copy-apply.js --batch --batch-file round.md --dry-run (a local round; writes nothing)
|
|
35
|
+
// node scripts/gds/copy-apply.js --batch --page <id> --unattended (the auto-pickup worker: exit 5 = wrong page; any non-zero exit releases the round, task 1004678)
|
|
35
36
|
// node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
|
|
36
37
|
// node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
|
|
37
38
|
// node scripts/gds/copy-apply.js --task 1234 (a claim you hold, when you hold more than one)
|
|
@@ -518,7 +519,7 @@ async function writeAppliedBlock({ taskId, description, block, api = apiCall })
|
|
|
518
519
|
|
|
519
520
|
// The --batch command. `deps` is the test seam: every read, write, re-read and API
|
|
520
521
|
// call can be replaced, so the whole path runs against a fixture round.
|
|
521
|
-
async function batchMain({ taskId, batchFile, dryRun, now = () => new Date().toISOString() }, deps = {}) {
|
|
522
|
+
async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () => new Date().toISOString() }, deps = {}) {
|
|
522
523
|
const readFile = deps.readFile || ((p) => fs.readFileSync(p, 'utf8'));
|
|
523
524
|
const writeFile = deps.writeFile || ((p, s) => fs.writeFileSync(p, s));
|
|
524
525
|
const api = deps.api || apiCall;
|
|
@@ -526,6 +527,12 @@ async function batchMain({ taskId, batchFile, dryRun, now = () => new Date().toI
|
|
|
526
527
|
const loaded = await loadBatch({ taskId, batchFile, readFile, api });
|
|
527
528
|
if (!loaded.ok) return refuse(loaded);
|
|
528
529
|
const { batch } = loaded;
|
|
530
|
+
// --page (task 1004678, ADR 0371 D8): the unattended worker names the page it was
|
|
531
|
+
// given, and a round for any other page is refused before a file is touched.
|
|
532
|
+
if (pageId && batch.page_id !== pageId) {
|
|
533
|
+
refuse({ ok: false, code: 'wrong_page', detail: { task: taskId, expected: pageId, round_page: batch.page_id, what_this_means: 'This round belongs to a different page than the one named with --page. Nothing was written.' } });
|
|
534
|
+
return 5;
|
|
535
|
+
}
|
|
529
536
|
const registry = deps.registry || JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'copy-registry.json')));
|
|
530
537
|
const result = applyBatchToSources({ batch, registry, readSource: (rel) => readFile(path.join(REPO_ROOT, rel)) });
|
|
531
538
|
|
|
@@ -590,7 +597,15 @@ async function main() {
|
|
|
590
597
|
taskId = held.taskId;
|
|
591
598
|
console.log(`copy-apply --batch: the round you are holding — task ${taskId}`);
|
|
592
599
|
}
|
|
593
|
-
|
|
600
|
+
// --page <id> --unattended (task 1004678, ADR 0371 D8): never asks, and any exit
|
|
601
|
+
// that is not a clean apply releases the round with a plain note (tweak-halt.js).
|
|
602
|
+
const pageId = arg('--page') || null;
|
|
603
|
+
const code = await batchMain({ taskId, batchFile, dryRun, pageId });
|
|
604
|
+
if (hasFlag('--unattended') && code !== 0 && taskId && !batchFile && !dryRun) {
|
|
605
|
+
const halt = require('./tweak-halt');
|
|
606
|
+
halt.releaseWithNote(taskId, halt.haltForCopyApplyExit(code));
|
|
607
|
+
}
|
|
608
|
+
return cliExit(code);
|
|
594
609
|
}
|
|
595
610
|
|
|
596
611
|
if (!taskId && !patchFile) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/tweak-halt.js — an unattended /tweak stops, releases its round with a
|
|
3
|
+
// plain note, and exits (task 1004678 / BV2.TW19b, ADR 0371 D8).
|
|
4
|
+
//
|
|
5
|
+
// WHY. `/tweak --page <id> --unattended` runs with nobody to ask. Every halt it can
|
|
6
|
+
// meet must end the same way: the round goes back to the queue with a sentence an
|
|
7
|
+
// artist can read, and the worker exits. Writing that once here means the skill,
|
|
8
|
+
// copy-apply's --unattended path and the runner's lane (TW19c) cannot each word a
|
|
9
|
+
// halt differently, or leave one claimed.
|
|
10
|
+
//
|
|
11
|
+
// Run: node scripts/gds/tweak-halt.js <task-id> --code <halt-code>
|
|
12
|
+
//
|
|
13
|
+
// The note is fixed vocabulary per code, never free text from the worker: the
|
|
14
|
+
// worker is a model reading pages an artist wrote, so nothing it says rides out
|
|
15
|
+
// onto the round.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { spawnSync } = require('node:child_process');
|
|
21
|
+
|
|
22
|
+
// The halts ADR 0371 D8 names, plus the copy-apply exits that mean "do not ship".
|
|
23
|
+
const HALT_NOTES = Object.freeze({
|
|
24
|
+
claim_refused: 'The automatic apply could not take this round (the claim was refused). It waits for a person.',
|
|
25
|
+
nothing_applied: 'The automatic apply placed none of the lines, so there is nothing to show. Each refused line is recorded on the round.',
|
|
26
|
+
wrong_page: 'The automatic apply was pointed at a different page than this round. Nothing was changed.',
|
|
27
|
+
registry_mismatch: 'The automatic apply wrote the files but the copy registry did not read the new words back, so it stopped before shipping.',
|
|
28
|
+
reread_failed: 'The automatic apply could not re-read the page after applying it, so it stopped before shipping.',
|
|
29
|
+
ship_check_stale: 'The automatic apply stopped because the pre-ship checks named something stale it may not fix by itself.',
|
|
30
|
+
grader_unavailable: 'The quality review could not run, so the automatic apply stopped. The round waits for the next try or a person.',
|
|
31
|
+
grade_failed: 'The quality review did not pass the automatic apply. The round waits for a person.',
|
|
32
|
+
outside_page: 'The automatic apply would have changed a file outside this page, so it stopped.',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function haltNote(code) {
|
|
36
|
+
return HALT_NOTES[code] || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// copy-apply --batch's exit codes, as halts. 0 is not a halt. 2 is "nothing
|
|
40
|
+
// applied" and also every refusal copy-apply names before writing (an unreadable
|
|
41
|
+
// round): in both, nothing reached the page.
|
|
42
|
+
function haltForCopyApplyExit(exit) {
|
|
43
|
+
if (exit === 0) return null;
|
|
44
|
+
return { 2: 'nothing_applied', 3: 'registry_mismatch', 4: 'reread_failed', 5: 'wrong_page' }[exit] || 'nothing_applied';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Release the round with the note. `spawn` is the test seam. Returns the exit.
|
|
48
|
+
function releaseWithNote(taskId, code, { spawn = spawnSync } = {}) {
|
|
49
|
+
const note = haltNote(code);
|
|
50
|
+
if (!note) throw new Error(`unknown halt code: ${code}`);
|
|
51
|
+
const r = spawn(process.execPath, [path.join(__dirname, 'release.js'), String(taskId), '--reason', `[tweak-unattended:${code}] ${note}`], { stdio: 'inherit' });
|
|
52
|
+
return r && typeof r.status === 'number' ? r.status : 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function main(argv = process.argv.slice(2)) {
|
|
56
|
+
const taskId = argv[0];
|
|
57
|
+
const i = argv.indexOf('--code');
|
|
58
|
+
const code = i >= 0 ? argv[i + 1] : null;
|
|
59
|
+
if (!/^[1-9][0-9]*$/.test(String(taskId || '')) || !haltNote(code)) {
|
|
60
|
+
console.error(`usage: node scripts/gds/tweak-halt.js <task-id> --code <${Object.keys(HALT_NOTES).join('|')}>`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
console.log(`tweak-halt: task ${taskId} — ${haltNote(code)}`);
|
|
64
|
+
return releaseWithNote(taskId, code);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (require.main === module) process.exitCode = main();
|
|
68
|
+
|
|
69
|
+
module.exports = { HALT_NOTES, haltNote, haltForCopyApplyExit, releaseWithNote };
|
|
@@ -271,7 +271,18 @@ const app = express();
|
|
|
271
271
|
app.use((req, res, next) => {
|
|
272
272
|
const m = req.path.match(/^\/api\/(?:gds|bongos)(?:\/v1)?\/(.+)$/);
|
|
273
273
|
if (!m) return next();
|
|
274
|
-
|
|
274
|
+
// Decoded before any matching (task 1004700): the paintbrush sends its page id
|
|
275
|
+
// through encodeURIComponent, so builders:studio arrives as builders%3Astudio
|
|
276
|
+
// and every copy-desk fixture and canned write missed. A malformed escape keeps
|
|
277
|
+
// the raw path, which then simply finds no fixture.
|
|
278
|
+
let apiPath = m[1].replace(/\/$/, '');
|
|
279
|
+
try { apiPath = decodeURIComponent(apiPath); } catch { /* keep raw */ }
|
|
280
|
+
// Decoding can mint a backslash, NUL or `..` segment that the fixture-name
|
|
281
|
+
// mapper does not neutralise (path.join honours `\` on Windows), so a decoded
|
|
282
|
+
// path that could leave the fixtures dir is refused outright.
|
|
283
|
+
if (/[\\\0]|(^|\/)\.\.(\/|$)/.test(apiPath)) {
|
|
284
|
+
return res.status(400).json({ error: { code: 'bad_path', message: 'path escapes the fixtures' } });
|
|
285
|
+
}
|
|
275
286
|
|
|
276
287
|
// SSE live channel: emit one nudge (so "live refresh" paths run once), then
|
|
277
288
|
// keep-alive comments. Pages treat this exactly like prod's stream.
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.110'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|