@bongos/core 1.24.4 → 1.24.5
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 +49 -34
- package/docs/copy-inventory.md +11 -11
- package/docs/copy-registry.json +12 -12
- package/docs/file-map.md +3 -2
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-readings.json +1 -1
- package/modules/copy-desk/auto-close.js +46 -8
- package/modules/copy-desk/page-status.js +11 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +64 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
- package/modules/hall-ui/public/tweak-editor.js +2 -1
- package/modules/lifecycle/db-claims.js +2 -2
- package/modules/lifecycle/db-ship.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +21 -1
- package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
- package/scripts/bongos/dev-box-guard.js +0 -1
- package/scripts/bongos/publish-manifest.js +1 -1
- package/src/bongos/agent-standing-rules.js +44 -0
- package/src/bongos/module-scope-map.js +3 -2
- package/src/bongos/routes/me.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/agent_standing_rules.mjs +142 -0
- package/tests/claims_batch_routes.mjs +36 -13
- package/tests/copy_stuck_page_tweaks.mjs +3 -1
- package/tests/government_laws_session.mjs +2 -1
- package/tests/hall_tweak_editor.mjs +11 -0
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/run_node_reroute.mjs +136 -0
|
@@ -193,7 +193,11 @@ function roundState(round) {
|
|
|
193
193
|
if (p.last_sent_back_at && (!p.applied_at || p.applied_at <= p.last_sent_back_at)) return 'submitted';
|
|
194
194
|
return 'unknown';
|
|
195
195
|
}
|
|
196
|
-
|
|
196
|
+
// task 1004972: opened and nothing written is a state of its own, not one
|
|
197
|
+
// the reads cannot name. It waits on its artist and closes itself after
|
|
198
|
+
// BLANK_CLOSES_AFTER (the auto-close sweep), so it never reads "Needs to be
|
|
199
|
+
// reviewed" or asks an applier to release it.
|
|
200
|
+
return p.draft ? 'draft' : 'blank';
|
|
197
201
|
case 'completed':
|
|
198
202
|
// A held pass waits for the artist; a failed grade stays with the applier.
|
|
199
203
|
return round.grade_passed === true ? 'waiting_for_artist' : 'applying';
|
|
@@ -319,7 +323,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
319
323
|
// Home and in the daily digest). A draft nobody touches also EXPIRES
|
|
320
324
|
// (expires_after): the auto-close sweep abandons it, its words kept on the task.
|
|
321
325
|
const DRAFT_EXPIRES_AFTER = 14 * DAY_MS;
|
|
326
|
+
// task 1004972 (owner, task 1004971): a round opened and left blank closes after a day.
|
|
327
|
+
const BLANK_CLOSES_AFTER = DAY_MS;
|
|
322
328
|
const IN_FLIGHT = Object.freeze({
|
|
329
|
+
blank: { word: 'Opened, nothing written yet', stuck_after: BLANK_CLOSES_AFTER, waits_on: 'artist', expires_after: BLANK_CLOSES_AFTER },
|
|
323
330
|
draft: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist', expires_after: DRAFT_EXPIRES_AFTER },
|
|
324
331
|
writing: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist' },
|
|
325
332
|
words_review: { word: 'Words waiting for review', stuck_after: 3 * DAY_MS, waits_on: 'artist' },
|
|
@@ -330,7 +337,7 @@ const IN_FLIGHT = Object.freeze({
|
|
|
330
337
|
landing: { word: 'Approved, going live', stuck_after: DAY_MS, waits_on: 'appliers' },
|
|
331
338
|
unknown: { word: 'Needs to be reviewed', stuck_after: 0, waits_on: 'appliers' },
|
|
332
339
|
});
|
|
333
|
-
const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'words_review', 'writing', 'draft'];
|
|
340
|
+
const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'words_review', 'writing', 'draft', 'blank'];
|
|
334
341
|
|
|
335
342
|
function inFlightFor(list, byPage, nowMs) {
|
|
336
343
|
const out = [];
|
|
@@ -980,4 +987,6 @@ module.exports = {
|
|
|
980
987
|
stuckDigestText,
|
|
981
988
|
// task 1004901: when an untouched draft expires (the auto-close sweep reads it).
|
|
982
989
|
DRAFT_EXPIRES_AFTER,
|
|
990
|
+
// task 1004972: when a blank round closes (the auto-close sweep reads it).
|
|
991
|
+
BLANK_CLOSES_AFTER,
|
|
983
992
|
};
|
|
@@ -312,8 +312,15 @@ await test('the module manifest declares no seam another module could write copy
|
|
|
312
312
|
assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(stuckSrc), false, 'the stuck-tweaks port writes nothing');
|
|
313
313
|
// task 1004899: the auto-close sweep only ABANDONS an empty round through the
|
|
314
314
|
// lifecycle port; it never hands a compose, so it cannot write a description.
|
|
315
|
+
// task 1004973: it may hand ONE compose, for an orphaned apply released back to
|
|
316
|
+
// the queue, and that compose is pages.appendSentBack: it appends the reason and
|
|
317
|
+
// changes no word (copy_page_auto_close.mjs proves the description is kept whole).
|
|
315
318
|
const closeSrc = stripComments(read('auto-close.js'));
|
|
316
|
-
assert.equal(/writeFile|\.query\(|UPDATE |INSERT
|
|
319
|
+
assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(closeSrc), false, 'the auto-close sweep writes nothing itself');
|
|
320
|
+
const composes = closeSrc.match(/\bcompose\b[^\n]*/g) || [];
|
|
321
|
+
assert.deepEqual(composes.length, 1, 'one compose, for the orphan release');
|
|
322
|
+
assert.match(closeSrc, /\(task\) => pages\.appendSentBack\(task\.description, \{ at, by, note \}\)/, 'and it only appends the sent-back note');
|
|
323
|
+
assert.equal(/composeDraftBlock|composeBatchBlock|saveDraftInto|replaceBlock/.test(closeSrc), false, 'no draft or batch writer');
|
|
317
324
|
const stats = read('artist-stats.js');
|
|
318
325
|
assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
|
|
319
326
|
'the artist-stats port reads through other ports and writes nothing');
|
|
@@ -65,9 +65,35 @@ await test('a draft left untouched for two weeks expires; a younger one stays (t
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
await test('the grace period is honoured: a round touched within it stays', () => {
|
|
68
|
-
const t = round({ updated_at: new Date(NOW - 59 * 60_000).toISOString() });
|
|
68
|
+
const t = round({ description: `${draft}\n\n${batch([])}`, updated_at: new Date(NOW - 59 * 60_000).toISOString() });
|
|
69
69
|
assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 60 * 60_000 }), null);
|
|
70
|
-
assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), '
|
|
70
|
+
assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), 'nothing_to_apply');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// task 1004972 (owner, task 1004971): a blank round closes after one day, not an hour.
|
|
74
|
+
await test('a blank round closes once a day old, and a round with a draft or a batch never closes as blank', () => {
|
|
75
|
+
const D = 86400000;
|
|
76
|
+
const at = (days, over = {}) => round({ updated_at: new Date(NOW - days * D).toISOString(), ...over });
|
|
77
|
+
assert.equal(autoClose.closeReason(at(0.9), { now: NOW }), null, 'under a day: it stays, reading "Opened, nothing written yet"');
|
|
78
|
+
assert.equal(autoClose.closeReason(at(1.1), { now: NOW }), 'blank');
|
|
79
|
+
assert.equal(autoClose.closeReason(at(1.1, { holder_id: '7' }), { now: NOW }), null, 'a held round is being written');
|
|
80
|
+
for (const [name, description] of [['draft', draft], ['batch with lines', `${draft}\n\n${batch([line])}`],
|
|
81
|
+
['approved batch', `${batch([line])}\n\n${pages.composeWordsApprovedBlock({ at: OLD, by: '7' })}`]]) {
|
|
82
|
+
assert.notEqual(autoClose.closeReason(at(1.1, { description }), { now: NOW }), 'blank', `${name} is not blank`);
|
|
83
|
+
}
|
|
84
|
+
assert.equal(autoClose.closeReason(at(30, { description: `${draft}\n\n${batch([line])}` }), { now: NOW }), null, 'a batch with lines is never closed, however old');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('the blank note says nothing was written, and the sweep never writes words', async () => {
|
|
88
|
+
const calls = [];
|
|
89
|
+
const lifecycle = {
|
|
90
|
+
listPageTweakTasks: async () => [ROUNDS.blank],
|
|
91
|
+
releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'abandoned' }; },
|
|
92
|
+
};
|
|
93
|
+
await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
|
|
94
|
+
assert.equal(calls.length, 1);
|
|
95
|
+
assert.match(calls[0].note, /^Closed: nothing was written\./);
|
|
96
|
+
assert.deepEqual(Object.keys(calls[0]).sort(), ['builderId', 'expectedStatus', 'note', 'taskId'], 'no compose, no description, no wording');
|
|
71
97
|
});
|
|
72
98
|
|
|
73
99
|
await test('the sweep abandons through the lifecycle port, as the owner, with a plain note and no rewrite', async () => {
|
|
@@ -86,6 +112,42 @@ await test('the sweep abandons through the lifecycle port, as the owner, with a
|
|
|
86
112
|
}
|
|
87
113
|
});
|
|
88
114
|
|
|
115
|
+
// task 1004973: an apply that stopped without finishing is released with a reason.
|
|
116
|
+
await test('an orphaned apply (active, nobody holds it) is released back to the queue with the reason; a live apply is not', async () => {
|
|
117
|
+
const approved = `${draft}\n\n${batch([line])}\n\n${pages.composeWordsApprovedBlock({ at: OLD, by: '7' })}`;
|
|
118
|
+
const orphan = round({ id: '21', status: 'active', holder_id: null, description: approved });
|
|
119
|
+
const orphanDraft = round({ id: '22', status: 'active', holder_id: null, description: draft });
|
|
120
|
+
const live = round({ id: '23', status: 'active', holder_id: '7', holder_worktree: true, description: approved });
|
|
121
|
+
const fresh = round({ id: '24', status: 'active', holder_id: null, description: approved, updated_at: new Date(NOW - 5 * 60_000).toISOString() });
|
|
122
|
+
assert.deepEqual([orphan, orphanDraft, live, fresh].map((t) => autoClose.orphanReason(t, { now: NOW })), ['orphaned_apply', 'orphaned_apply', null, null]);
|
|
123
|
+
assert.equal(autoClose.closeReason(orphan, { now: NOW }), null, 'the ready-round rules never see an active round');
|
|
124
|
+
|
|
125
|
+
const calls = [];
|
|
126
|
+
const lifecycle = {
|
|
127
|
+
listPageTweakTasks: async () => [orphan, orphanDraft, live, fresh, ROUNDS.real_batch],
|
|
128
|
+
releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'released' }; },
|
|
129
|
+
};
|
|
130
|
+
const r = await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
|
|
131
|
+
assert.deepEqual(r.closed.map((c) => [c.task_id, c.why, c.outcome]), [['21', 'orphaned_apply', 'released'], ['22', 'orphaned_apply', 'released']],
|
|
132
|
+
'after one sweep no round sits active with nobody holding it; the live apply and the fresh one are left alone');
|
|
133
|
+
for (const c of calls) {
|
|
134
|
+
assert.equal(c.expectedStatus, 'active');
|
|
135
|
+
assert.match(c.note, /^Released: the apply stopped without finishing\./);
|
|
136
|
+
assert.equal(typeof c.compose, 'function', 'released back to the queue, not abandoned');
|
|
137
|
+
}
|
|
138
|
+
// The reason is on the round, and the words are kept whole: the compose only appends.
|
|
139
|
+
const [batchOut, draftOut] = calls.map((c, i) => c.compose([orphan, orphanDraft][i]));
|
|
140
|
+
assert.equal(batchOut.ok, true);
|
|
141
|
+
assert.ok(batchOut.description.startsWith(approved), 'the draft, batch and approval are unchanged');
|
|
142
|
+
assert.ok(draftOut.description.startsWith(draft), 'a draft stays as it was');
|
|
143
|
+
const p = pages.blockPresence(batchOut.description);
|
|
144
|
+
assert.equal(p.batch && p.words_approved && !!p.last_sent_back_at, true, 'the batch carries the sent-back reason');
|
|
145
|
+
const pageStatus = require('../page-status.js');
|
|
146
|
+
assert.equal(pageStatus.roundState({ status: 'ready', holder_id: null, description: batchOut.description }), 'submitted', 'back in the /tweak queue');
|
|
147
|
+
assert.equal(pageStatus.roundState({ status: 'ready', holder_id: null, description: draftOut.description }), 'draft', 'a draft is its artist\'s again');
|
|
148
|
+
assert.match(draftOut.description, /the apply stopped without finishing/);
|
|
149
|
+
});
|
|
150
|
+
|
|
89
151
|
await test('nothing due, no owner read; no owner, no sweep; no lifecycle, no sweep', async () => {
|
|
90
152
|
let asked = 0;
|
|
91
153
|
const quiet = { listPageTweakTasks: async () => [ROUNDS.real_batch], releaseStuckTweak: async () => { throw new Error('must not run'); } };
|
|
@@ -199,6 +199,8 @@ await test('every row of the D7 state table', () => {
|
|
|
199
199
|
${pages.composeWordsApprovedBlock({ at: 't2', by: '7' })}` }, 'submitted'],
|
|
200
200
|
[{ status: 'ready', description: sentBackAfterApply }, 'submitted'],
|
|
201
201
|
[{ status: 'ready', description: applied }, 'unknown'],
|
|
202
|
+
// task 1004972: opened and nothing written is its own state, never "Needs to be reviewed".
|
|
203
|
+
[{ status: 'ready', description: 'Page tweak round 1.' }, 'blank'],
|
|
202
204
|
[{ status: 'completed', grade_passed: true }, 'waiting_for_artist'],
|
|
203
205
|
[{ status: 'completed', grade_passed: false }, 'applying'],
|
|
204
206
|
[{ status: 'confirmed' }, 'landing'],
|
|
@@ -258,7 +260,9 @@ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
|
|
|
258
260
|
round(3, 'landing:c', { status: 'confirmed', updated_at: ago(17) }),
|
|
259
261
|
round(4, 'builders:d', { status: 'completed', grade_passed: true, updated_at: ago(20) }),
|
|
260
262
|
round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(5) }),
|
|
261
|
-
round
|
|
263
|
+
// An active round nobody holds is one the reads cannot name. (A blank ready
|
|
264
|
+
// round is no longer that: it reads 'blank', task 1004972.)
|
|
265
|
+
round(6, 'builders:f', { status: 'active', holder_id: null, updated_at: ago(0.2) }),
|
|
262
266
|
round(7, 'builders:g', { status: 'shipped', shipped_at: ago(3), updated_at: ago(3) }),
|
|
263
267
|
round(8, 'status:h', { status: 'active', holder_id: '8', holder_worktree: true, updated_at: ago(2) }),
|
|
264
268
|
];
|
|
@@ -319,7 +319,8 @@
|
|
|
319
319
|
notice('<p>The page changed after your draft was saved, so the draft is not shown over its new words. Rewrite the lines again here.</p>');
|
|
320
320
|
}
|
|
321
321
|
if (st.writable) { if (!st.ed.draft_on_older_reading) notice(''); return; }
|
|
322
|
-
|
|
322
|
+
// A blank round (opened, nothing written; task 1004972) is free to take, like a draft.
|
|
323
|
+
if (!r || r.state === 'draft' || r.state === 'blank' || (r.state === 'writing' && !r.holder)) {
|
|
323
324
|
notice('<p>Nobody is writing this page. Take it, and every line here is yours to type over.</p>'
|
|
324
325
|
+ '<button class="tw-notice__act" id="tw-claim" type="button">take this page</button>');
|
|
325
326
|
return;
|
|
@@ -39,7 +39,7 @@ const { unsatisfiedDeps } = require('./db-deps-criteria.js');
|
|
|
39
39
|
const { depRefusalRow } = require('./dead-deps.js');
|
|
40
40
|
const { siblingOverlaps, RESOLVED_STATUSES } = require('./goal-conflicts.js');
|
|
41
41
|
const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
|
|
42
|
-
const { sealSessionLog } = require('./db-shared.js');
|
|
42
|
+
const { idKey, sealSessionLog } = require('./db-shared.js');
|
|
43
43
|
const { gatingRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
44
44
|
const { splitRebaseGate, rebaseRefusalTasks } = require('./rebase-gate.js');
|
|
45
45
|
const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
|
|
@@ -898,7 +898,7 @@ async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, reso
|
|
|
898
898
|
for (const claimId of uniqIds) {
|
|
899
899
|
const claim = byId.get(claimId);
|
|
900
900
|
if (!claim) { failures.push({ claim_id: claimId, reason: 'CLAIM_NOT_FOUND' }); continue; }
|
|
901
|
-
if (claim.builder_id !== builderId) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
|
|
901
|
+
if (idKey(claim.builder_id) !== idKey(builderId)) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
|
|
902
902
|
if (claim.released_at) { failures.push({ claim_id: claimId, reason: 'ALREADY_RESOLVED' }); continue; }
|
|
903
903
|
// task 1003767: the SAME decision resolveClaim uses, not a third copy of
|
|
904
904
|
// the auth-session compare (this was the second of three). Batch release
|
|
@@ -16,7 +16,7 @@ const log = api.logger('lifecycle');
|
|
|
16
16
|
const seams = api;
|
|
17
17
|
const doneWhen = require('./done-when.js');
|
|
18
18
|
const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName } = require('./db-rank-authz.js');
|
|
19
|
-
const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
19
|
+
const { idKey, mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
20
20
|
const { LIVE_STATUSES } = require('./dead-deps.js');
|
|
21
21
|
const { isPageTweak } = require('./page-tweak-hold.js');
|
|
22
22
|
const pageTweakArtist = require('./page-tweak-artist.js');
|
|
@@ -138,7 +138,7 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
|
|
|
138
138
|
);
|
|
139
139
|
if (claimRows.length === 0) throw { code: 'CLAIM_NOT_FOUND' };
|
|
140
140
|
const claim = claimRows[0];
|
|
141
|
-
if (claim.builder_id !== builderId) throw { code: 'NOT_YOUR_CLAIM' };
|
|
141
|
+
if (idKey(claim.builder_id) !== idKey(builderId)) throw { code: 'NOT_YOUR_CLAIM' }; // task 1005004: pg bigint string vs a number
|
|
142
142
|
if (claim.released_at) throw { code: 'ALREADY_RESOLVED' };
|
|
143
143
|
// Resolve lock: only the WORKING TREE that created this claim can resolve it
|
|
144
144
|
// (task 1003767) — falling back to the creating session where no tree is
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.5",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.5",
|
|
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.24.
|
|
3
|
+
"version": "1.24.5",
|
|
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
|
@@ -10812,7 +10812,27 @@
|
|
|
10812
10812
|
"text": "The payments decision record now carries the owner's answers: credits can be tuned but not switched off, pay rules live on the settings page, and Verro is Verro Labs."
|
|
10813
10813
|
}
|
|
10814
10814
|
],
|
|
10815
|
-
"1.24.
|
|
10815
|
+
"1.24.5": [
|
|
10816
|
+
{
|
|
10817
|
+
"id": "1004973",
|
|
10818
|
+
"text": "A page tweak whose apply stopped partway, with nobody holding it any more, now goes back in the queue by itself with a note saying the apply stopped, instead of sitting stuck forever."
|
|
10819
|
+
},
|
|
10820
|
+
{
|
|
10821
|
+
"id": "1005004",
|
|
10822
|
+
"text": "Fixed a check that could wrongly stop a builder from handing back or finishing their own task when their ID arrived written as a number instead of text."
|
|
10823
|
+
},
|
|
10824
|
+
{
|
|
10825
|
+
"id": "1005015",
|
|
10826
|
+
"text": "AI agents now reliably flag when they need you (HUMAN TASK / HUMAN DECISION) and own up when a doc or tool misled them, on every machine, even ones running out-of-date copies."
|
|
10827
|
+
},
|
|
10828
|
+
{
|
|
10829
|
+
"id": "1004972",
|
|
10830
|
+
"text": "A page tweak someone opened but never wrote in now shows as \"Opened, nothing written yet\" instead of \"Needs to be reviewed\", and closes itself after one day with a note saying nothing was written."
|
|
10831
|
+
},
|
|
10832
|
+
{
|
|
10833
|
+
"id": "1004978",
|
|
10834
|
+
"text": "Deleted an old, out-of-date manifest file from the repository root that nothing used, so old scripts it listed no longer look like they are still in use."
|
|
10835
|
+
},
|
|
10816
10836
|
{
|
|
10817
10837
|
"id": "1004968",
|
|
10818
10838
|
"text": "The setting that sizes credit rewards is now called a cost scale instead of a dollar peg, so it no longer reads like an exchange rate; old project settings keep working and nobody's rewards change."
|
|
@@ -52,8 +52,7 @@
|
|
|
52
52
|
// - files that deliberately name BOTH spellings or the old one: this codemod, forwarder.js,
|
|
53
53
|
// the forwarder test, modules/lifecycle/executed-closure.js (lists both on purpose),
|
|
54
54
|
// rename-ledger-prose.js (masks `scripts/gds` as a protected identifier in ledger prose),
|
|
55
|
-
// the task-2004 codemod (a historical tool)
|
|
56
|
-
// core build cut from an older commit — its paths describe THAT build).
|
|
55
|
+
// and the task-2004 codemod (a historical tool).
|
|
57
56
|
//
|
|
58
57
|
// Usage:
|
|
59
58
|
// node scripts/bongos/codemod-rename-scripts-gds.js # apply
|
|
@@ -81,7 +80,6 @@ const EXCLUDE = new Set([
|
|
|
81
80
|
'tests/scripts_gds_forwarders.mjs',
|
|
82
81
|
'modules/lifecycle/executed-closure.js',
|
|
83
82
|
'scripts/bongos/rename-ledger-prose.js',
|
|
84
|
-
'.bongos-core.json',
|
|
85
83
|
]);
|
|
86
84
|
|
|
87
85
|
const FROZEN_PATHS = [
|
|
@@ -85,7 +85,6 @@ const DEV_BOX_EXEMPT = new Map([
|
|
|
85
85
|
// Captured data and seed history that name real records.
|
|
86
86
|
['scripts/bongos/seed-', 'one-shot task seeders: their subject is the historical tasks they filed'],
|
|
87
87
|
['scripts/hall-preview/fixtures/', 'captured API responses: the ledger still holds these task and goal rows'],
|
|
88
|
-
['.bongos-core.json', 'a 1.0.0 seed manifest from July (commit b6ee5d29) that this repo never regenerates'],
|
|
89
88
|
]);
|
|
90
89
|
|
|
91
90
|
const TEXT_EXT = /\.(js|mjs|cjs|json|md|sql|sh|html|css|yml|yaml|txt|toml)$/;
|
|
@@ -316,7 +316,7 @@ const INSTANCE_EXCLUDE = [
|
|
|
316
316
|
// the instance-recipe / game-design carve-outs above. (The portable ADRs + the README index still publish.)
|
|
317
317
|
// The instance's OWN name + trademark decision. Its prose redacts cleanly, but the FILENAME
|
|
318
318
|
// carries the brand and the publish manifest lists every path — so the literal shipped in
|
|
319
|
-
// .bongos-core.json no matter how well the body was redacted. It also genericizes to nothing:
|
|
319
|
+
// the tarball's inner .bongos-core.json no matter how well the body was redacted. It also genericizes to nothing:
|
|
320
320
|
// "accepting trademark risk on <our name>" is not a decision another instance inherits.
|
|
321
321
|
// (task 1003869 — the last literal in the artifact.)
|
|
322
322
|
'docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md',
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/bongos/agent-standing-rules.js — the rules every AI session obeys whoever the
|
|
3
|
+
// builder is, served by the SERVER so a stale checkout still hears them (task 1005015).
|
|
4
|
+
//
|
|
5
|
+
// WHY THE SERVER. The human signal (task 1004922) lived only in the local Conductor
|
|
6
|
+
// hook, told once on a session's first prompt. A hook runs from whatever checkout the
|
|
7
|
+
// machine has, and on the owner's box that was 823 commits behind, so the markers never
|
|
8
|
+
// appeared at all. Slop detection lived only as a CLAUDE.md table row nothing ever
|
|
9
|
+
// triggered. GET /me's interaction contract is relayed verbatim by EVERY Conductor
|
|
10
|
+
// since task 2010, stale ones included, so appending these rules to it reaches every
|
|
11
|
+
// active user the moment the instance serving them runs this code.
|
|
12
|
+
//
|
|
13
|
+
// The wording is the rule's home for sessions; docs/voice.md is its home for people.
|
|
14
|
+
// conductor.js and stop-slopcheck.js carry their own copies (they must work on a
|
|
15
|
+
// checkout whose core cannot load), and tests/agent_standing_rules.mjs holds every
|
|
16
|
+
// copy equal to the contract this file serves. Only withStandingRules is exported:
|
|
17
|
+
// the hooks' dynamic core resolve is invisible to the dead-code scan.
|
|
18
|
+
|
|
19
|
+
const HUMAN_SIGNAL_TEXT = 'When you need the person, start that line with `🙋 **HUMAN TASK:**` if they must DO something '
|
|
20
|
+
+ 'only a person can do (try it on the live site, add a key, sign in, approve), or `🔀 **HUMAN DECISION:**` if they must '
|
|
21
|
+
+ 'CHOOSE (list the options and mark your pick). Put the same marker at the start of a question-box question. Never use them for anything else.';
|
|
22
|
+
|
|
23
|
+
const SLOP_TEXT = 'The moment you find you were wrong because something told you so (a doc, a hook\'s output, a script\'s message, '
|
|
24
|
+
+ 'a stale file) — or the person corrects you — say `🐛 Bongo Slop Detected!!, Filing Bug 🐛`, name the source that misled you, '
|
|
25
|
+
+ 'and file it: `node scripts/bongos/capture.js "<file>: says X, truth is Y" --kind bug`. The misleading source is the bug. '
|
|
26
|
+
+ 'Your own plain mistake, with nothing misleading behind it, is not slop.';
|
|
27
|
+
|
|
28
|
+
const HEADER = 'Standing rules for every session:';
|
|
29
|
+
|
|
30
|
+
function standingRulesBlock() {
|
|
31
|
+
return `${HEADER}\n- ${HUMAN_SIGNAL_TEXT}\n- ${SLOP_TEXT}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The interaction slice of GET /me with the standing rules appended. `interaction` is
|
|
35
|
+
// describeInteraction's object or null (builder-settings module off); either way the
|
|
36
|
+
// result injects, because these rules do not depend on any preference. Idempotent.
|
|
37
|
+
function withStandingRules(interaction) {
|
|
38
|
+
const base = interaction && typeof interaction === 'object' ? interaction : {};
|
|
39
|
+
const contract = typeof base.contract === 'string' ? base.contract : '';
|
|
40
|
+
if (contract.includes(HEADER)) return { ...base, inject: true };
|
|
41
|
+
return { ...base, contract: contract ? `${contract}\n${standingRulesBlock()}` : standingRulesBlock(), inject: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { withStandingRules };
|
|
@@ -615,8 +615,9 @@ const SHARED_GLOBS = Object.freeze(
|
|
|
615
615
|
// Assigning nineteen roots in one pass is the one-at-a-time churn task 1004132 was
|
|
616
616
|
// filed to avoid; freezing the set and naming the arguable ones is the cheap half.
|
|
617
617
|
const UNOWNED_ROOTS = Object.freeze({
|
|
618
|
-
// Generated packaging
|
|
619
|
-
|
|
618
|
+
// Generated packaging manifest — written by package-core.js, never hand-edited.
|
|
619
|
+
// (The repo-root .bongos-core.json, a stale July 1.0.0 seed, was deleted in task 1004978;
|
|
620
|
+
// the live one is the INNER manifest package-core writes into the tarball.)
|
|
620
621
|
'.cloudbongos-core.json': 'generated package manifest (scripts/bongos/package-core.js)',
|
|
621
622
|
// Governance + legal. Deliberately not methodology: methodology owns docs/, and
|
|
622
623
|
// these are the project's constitutional documents rather than its handbook.
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -34,6 +34,7 @@ const db = require('../db');
|
|
|
34
34
|
// prefs-module-less instance so the slices simply render their unset/default shape.
|
|
35
35
|
const { validateOrRespond } = require('./_helpers');
|
|
36
36
|
const { readModes: readCraftModes, describeCraftMode } = require('../craft-mode'); // task 1002992
|
|
37
|
+
const { withStandingRules } = require('../agent-standing-rules'); // task 1005015
|
|
37
38
|
const { branding } = require('../../branding'); // task 2011: instance model-allocation default
|
|
38
39
|
// The onboarding state machine carved into modules/onboarding/ (BV1.R81 / ADR
|
|
39
40
|
// 0093 §1): the GET /me aggregator + PATCH /me/disciplines resolve the
|
|
@@ -565,7 +566,9 @@ module.exports = function buildMeRouter() {
|
|
|
565
566
|
// contract. The old §2 "The Prompter" owner-defaults, now per-builder. The
|
|
566
567
|
// session-start injection + /settings UI read this; describeInteraction is
|
|
567
568
|
// the SSOT (builder-settings port). null when the prefs module is off.
|
|
568
|
-
|
|
569
|
+
// task 1005015: the standing rules (human-task markers, slop detection) ride
|
|
570
|
+
// this contract because every Conductor, stale ones included, relays it.
|
|
571
|
+
interaction: withStandingRules(lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null)),
|
|
569
572
|
// task 1004707: the project's LAWS, read into every AI session. The
|
|
570
573
|
// Conductor hook relays `contract` verbatim beside the interaction profile
|
|
571
574
|
// (inject:false, and no block, when no law is in force). `laws` is the book
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.5'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// tests/agent_standing_rules.mjs — the human-task markers and the Slop Bongo rule reach
|
|
2
|
+
// every session (task 1005015).
|
|
3
|
+
//
|
|
4
|
+
// They used to live only in the local Conductor hook (markers, first prompt only) and
|
|
5
|
+
// a CLAUDE.md table row (slop, never triggered), so a stale checkout or a long session
|
|
6
|
+
// lost them. Now: the SERVER appends them to the interaction contract every Conductor
|
|
7
|
+
// relays; the Conductor re-says them when the person corrects the agent or is about to
|
|
8
|
+
// be asked something; a Stop hook asks once when the agent reverses itself.
|
|
9
|
+
//
|
|
10
|
+
// .claude/hooks/ is package-excluded, so a clean core checkout skips this file
|
|
11
|
+
// (tests/host_topology_skips.mjs carries the allowlist entry).
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/agent_standing_rules.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { requireHostModule } from './helpers.mjs';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const rules = require('../src/bongos/agent-standing-rules.js');
|
|
26
|
+
const conductor = requireHostModule(require, '../.claude/hooks/conductor.js');
|
|
27
|
+
const slopcheck = requireHostModule(require, '../.claude/hooks/stop-slopcheck.js');
|
|
28
|
+
|
|
29
|
+
// The words as the server serves them: a header line, then one '- ' line per rule.
|
|
30
|
+
const [HEADER, HUMAN, SLOP] = rules.withStandingRules(null).contract.split('\n').map((l, i) => (i ? l.slice(2) : l));
|
|
31
|
+
|
|
32
|
+
let passed = 0;
|
|
33
|
+
let failed = 0;
|
|
34
|
+
function t(name, fn) {
|
|
35
|
+
try { fn(); passed++; console.log(` PASS ${name}`); } catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
t('the server appends both rules to a builder\'s interaction contract and keeps it injecting', () => {
|
|
39
|
+
const out = rules.withStandingRules({ inject: true, contract: 'Interaction profile — P', fields: [1] });
|
|
40
|
+
assert.ok(out.contract.startsWith('Interaction profile — P\n'), 'the profile stays first');
|
|
41
|
+
assert.ok(out.contract.includes(HUMAN));
|
|
42
|
+
assert.ok(out.contract.includes(SLOP));
|
|
43
|
+
assert.equal(out.inject, true);
|
|
44
|
+
assert.deepEqual(out.fields, [1], 'the rest of the slice is untouched');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
t('with the settings module off (null) the rules still reach the session', () => {
|
|
48
|
+
const out = rules.withStandingRules(null);
|
|
49
|
+
assert.equal(out.inject, true);
|
|
50
|
+
assert.ok(out.contract.startsWith(HEADER));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
t('appending is idempotent', () => {
|
|
54
|
+
const once = rules.withStandingRules({ inject: true, contract: 'P' });
|
|
55
|
+
assert.equal(rules.withStandingRules(once).contract, once.contract);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
t('GET /me wraps the interaction slice (wiring pin)', () => {
|
|
59
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'routes', 'me.js'), 'utf8');
|
|
60
|
+
assert.match(src, /interaction: withStandingRules\(/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
t('the rules mark both markers and the slop line exactly as the owner set them', () => {
|
|
64
|
+
assert.ok(HUMAN.includes('🙋 **HUMAN TASK:**'));
|
|
65
|
+
assert.ok(HUMAN.includes('🔀 **HUMAN DECISION:**'));
|
|
66
|
+
assert.ok(SLOP.includes('🐛 Bongo Slop Detected!!, Filing Bug 🐛'));
|
|
67
|
+
assert.ok(fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8').includes('🐛 Bongo Slop Detected!!, Filing Bug 🐛'), 'CLAUDE.md says the same line');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
t('the Conductor\'s copy says exactly what the server sends', () => {
|
|
71
|
+
assert.equal(conductor.HUMAN_SIGNAL_TEXT, HUMAN);
|
|
72
|
+
assert.equal(conductor.SLOP_TEXT, SLOP);
|
|
73
|
+
assert.equal(conductor.STANDING_HEADER, HEADER);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
t('the first-prompt marker line is skipped when the server already sent the rules', () => {
|
|
77
|
+
const served = rules.withStandingRules({ inject: true, contract: 'P' });
|
|
78
|
+
const a = conductor.evaluateTriggers({ firstPrompt: true, interaction: served });
|
|
79
|
+
assert.ok(!a.some((x) => x.id === 'human-signal'));
|
|
80
|
+
assert.ok(a.some((x) => x.id === 'interaction' && x.text.includes('HUMAN TASK')));
|
|
81
|
+
const old = conductor.evaluateTriggers({ firstPrompt: true, interaction: { inject: true, contract: 'P' } });
|
|
82
|
+
assert.ok(old.some((x) => x.id === 'human-signal'), 'an older server still gets the local line');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
t('an ask names the markers', () => {
|
|
86
|
+
const a = conductor.evaluateTriggers({ prompt: 'file a blocker for this' }).find((x) => x.id === 'ask-shape');
|
|
87
|
+
assert.ok(a && a.text.includes('🔀 **HUMAN DECISION:**') && a.text.includes('🙋 **HUMAN TASK:**'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
t('a person correcting the agent triggers the slop check, every time', () => {
|
|
91
|
+
for (const p of ["that's wrong, it is live", 'you said it needs a dev box', 'actually it already works', "it's already deployed", 'not true', 'is slop detection firing?']) {
|
|
92
|
+
const a = conductor.evaluateTriggers({ prompt: p }).find((x) => x.id === 'slop-check');
|
|
93
|
+
assert.ok(a, `no slop check for: ${p}`);
|
|
94
|
+
assert.equal(a.always, true, 'renders even when the advisory set repeats');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
t('ordinary prompts do not trigger the slop check', () => {
|
|
99
|
+
for (const p of ['add a dark mode toggle', 'run the tests', 'what can I work on', 'make the button wider']) {
|
|
100
|
+
assert.ok(!conductor.evaluateTriggers({ prompt: p }).some((x) => x.id === 'slop-check'), p);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
t('the Stop hook asks once when the agent reverses itself', () => {
|
|
105
|
+
for (const m of ['Turns out the deploy needs no ssh.', 'I was wrong about the branch.', 'My mistake — it is live.', 'I said it was stale but it is current.']) {
|
|
106
|
+
const r = slopcheck.evaluate(m);
|
|
107
|
+
assert.equal(r.block, true, m);
|
|
108
|
+
assert.ok(r.reason.includes('Bongo Slop Detected'));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
t('the Stop hook stays quiet on ordinary text and when the rule was already followed', () => {
|
|
113
|
+
assert.equal(slopcheck.evaluate('Done. All 12 tests pass.').block, false);
|
|
114
|
+
assert.equal(slopcheck.evaluate('The old test was wrong about the count; I fixed it.').block, false);
|
|
115
|
+
assert.equal(slopcheck.evaluate('🐛 Bongo Slop Detected!!, Filing Bug 🐛 — turns out the doc was stale.').block, false);
|
|
116
|
+
assert.equal(slopcheck.evaluate('').block, false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
t('the Stop hook\'s copy says exactly what the server sends', () => {
|
|
120
|
+
assert.equal(slopcheck.SLOP_TEXT, SLOP);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
t('the Stop hook reads only the transcript tail, and still finds the last message past a cut line', () => {
|
|
124
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slopcheck-'));
|
|
125
|
+
const tp = path.join(dir, 't.jsonl');
|
|
126
|
+
const filler = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(1000) } });
|
|
127
|
+
const lines = Array.from({ length: 600 }, () => filler); // ~600 KB, past the 256 KB tail
|
|
128
|
+
lines.push(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'Turns out it was live.' }] } }));
|
|
129
|
+
lines.push(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'x' }] } }));
|
|
130
|
+
fs.writeFileSync(tp, lines.join('\n') + '\n');
|
|
131
|
+
assert.equal(slopcheck.lastAssistantText(tp), 'Turns out it was live.');
|
|
132
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
t('the Stop hook is wired through the node resolver', () => {
|
|
136
|
+
const s = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'));
|
|
137
|
+
const cmds = s.hooks.Stop.flatMap((e) => e.hooks.map((h) => h.command));
|
|
138
|
+
assert.ok(cmds.some((c) => /run-node\.sh.*stop-slopcheck\.js/.test(c)));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
console.log(`\nagent_standing_rules: ${passed} passed, ${failed} failed`);
|
|
142
|
+
process.exitCode = failed ? 1 : 0;
|
|
@@ -47,7 +47,8 @@ function answer(sql, params, via) {
|
|
|
47
47
|
// The release's locked read (FOR UPDATE) — distinct from the newcomer gate's
|
|
48
48
|
// active-claims read, which joins the same tables.
|
|
49
49
|
if (/FROM claims c JOIN tasks t ON t\.id = c\.task_id/.test(sql) && /FOR UPDATE/.test(sql)) {
|
|
50
|
-
|
|
50
|
+
// An array for the batch release; a single id for resolveClaim.
|
|
51
|
+
const wanted = new Set([].concat(params[0]).map(String));
|
|
51
52
|
return { rows: claimFixture.filter((r) => wanted.has(String(r.id))) };
|
|
52
53
|
}
|
|
53
54
|
if (/FROM claims c/.test(sql) && /released_at IS NULL/.test(sql)) {
|
|
@@ -215,19 +216,41 @@ test('own claim with matching id types (both strings, as production sends them)
|
|
|
215
216
|
assert.equal(claimReleases().length, 1);
|
|
216
217
|
});
|
|
217
218
|
|
|
218
|
-
//
|
|
219
|
-
// claim.builder_id !== builderId
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
219
|
+
// task 1005004 (found by task 1004983): the ownership compare was a strict
|
|
220
|
+
// claim.builder_id !== builderId, so a STRING row id (node-pg bigint) against a
|
|
221
|
+
// NUMBER caller id refused the caller's OWN claim as NOT_YOUR_CLAIM. It now
|
|
222
|
+
// compares one spelling of each side (db-shared idKey). The stranger matrix
|
|
223
|
+
// above still refuses in every combination.
|
|
224
|
+
test('own claim is released whatever the id types — string or number on either side', async () => {
|
|
225
|
+
for (const [rowId, callerId] of [['42', 42], [42, '42'], [42, 42], ['42', '42']]) {
|
|
226
|
+
calls = [];
|
|
227
|
+
caller = { id: callerId, rank: 'thetes' };
|
|
228
|
+
claimFixture = [row({ builder_id: rowId })];
|
|
229
|
+
const r = await post(RELEASE, { claim_ids: [101] });
|
|
230
|
+
const label = `row builder_id ${JSON.stringify(rowId)} vs caller ${JSON.stringify(callerId)}`;
|
|
231
|
+
assert.deepEqual(r.json.released.map((x) => x.claim_id), [101], label);
|
|
232
|
+
assert.deepEqual(r.json.failures, [], label);
|
|
233
|
+
assert.equal(claimReleases().length, 1, label);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// The same compare in resolveClaim (db-ship.js), the single-claim ship/release
|
|
238
|
+
// path: a number caller id must reach past the ownership check on its own
|
|
239
|
+
// claim, and a stranger's claim is still NOT_YOUR_CLAIM before any write.
|
|
240
|
+
test('resolveClaim: own claim with a NUMBER caller id passes the ownership check; a stranger\'s is refused, nothing written', async () => {
|
|
241
|
+
const resolve = (builderId) => db.resolveClaim({
|
|
242
|
+
claimId: 101, builderId, outcome: 'abandoned', resolverSessionId: 'sess-A', force: true,
|
|
243
|
+
}).then(() => null, (e) => e);
|
|
244
|
+
|
|
245
|
+
claimFixture = [row({ builder_id: '7', creator_session_id: 'sess-Z' })];
|
|
246
|
+
const stranger = await resolve(42);
|
|
247
|
+
assert.equal(stranger && stranger.code, 'NOT_YOUR_CLAIM');
|
|
230
248
|
assert.deepEqual(writes(), []);
|
|
249
|
+
|
|
250
|
+
calls = [];
|
|
251
|
+
claimFixture = [row({ builder_id: '42' })];
|
|
252
|
+
const own = await resolve(42);
|
|
253
|
+
assert.notEqual(own && own.code, 'NOT_YOUR_CLAIM', `own claim refused: ${own && (own.code || own.message)}`);
|
|
231
254
|
});
|
|
232
255
|
|
|
233
256
|
// ── the session check, and force ─────────────────────────────────────────────
|
|
@@ -63,7 +63,9 @@ await test('every state has an owner and a deadline: overdue only past it, told
|
|
|
63
63
|
['applying', () => ({ status: 'active', holder_id: '99', holder_worktree: true }), 'appliers', 1],
|
|
64
64
|
['waiting_for_artist', () => ({ status: 'completed', grade_passed: true }), 'artist', 3],
|
|
65
65
|
['landing', () => ({ status: 'confirmed' }), 'appliers', 1],
|
|
66
|
-
['unknown', () => ({ status: '
|
|
66
|
+
['unknown', () => ({ status: 'active', holder_id: null }), 'appliers', 0],
|
|
67
|
+
// task 1004972: a blank round waits on its artist and is due when it closes itself (1 day).
|
|
68
|
+
['blank', () => ({ status: 'ready' }), 'artist', 1],
|
|
67
69
|
];
|
|
68
70
|
for (const [state, shape, owner, days] of table) {
|
|
69
71
|
const id = 'landing:projects';
|