@bongos/core 1.24.3 → 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 +142 -67
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/config/branding.neutral.json +0 -1
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
- package/docs/api/openapi.json +121 -4
- package/docs/api-reference.md +8 -1
- package/docs/branding-contract.md +1 -1
- 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/discord/ship-broadcast.js +7 -0
- package/modules/economy/credit-wording.js +93 -0
- package/modules/economy/credits.js +26 -12
- package/modules/economy/reward-policy.js +49 -3
- package/modules/economy/reward.js +5 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/hall-ui/public/tweak-editor.js +2 -1
- package/modules/ideas/routes/inbox.js +6 -0
- package/modules/lifecycle/db-claims.js +2 -2
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/routes/project-settings.js +6 -4
- package/modules/market-watch/ask.js +425 -0
- package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
- package/modules/market-watch/module.json +10 -3
- package/modules/market-watch/pollers/weekly-ask.js +89 -0
- package/modules/market-watch/public/hall-widget.js +271 -0
- package/modules/market-watch/public/weekly-ask.css +39 -0
- package/modules/market-watch/routes/ask.js +95 -0
- package/modules/onboarding/routes/access-requests.js +38 -10
- package/modules/platform-identity/account-logins.js +13 -0
- package/modules/platform-identity/routes/my-projects.js +10 -7
- package/modules/platform-identity/routes/sso.js +11 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +37 -1
- package/scripts/bongos/backfill-subagent-rewards.js +7 -7
- 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/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/hall-preview/server.js +13 -0
- package/src/bongos/agent-standing-rules.js +44 -0
- package/src/bongos/auth-idp.js +17 -5
- package/src/bongos/db-kernel.js +14 -1
- package/src/bongos/module-scope-map.js +4 -2
- package/src/bongos/routes/me.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/account_logins.mjs +6 -4
- package/tests/agent_standing_rules.mjs +142 -0
- package/tests/backfill_subagent_rewards.mjs +7 -7
- package/tests/claims_batch_routes.mjs +36 -13
- package/tests/copy_stuck_page_tweaks.mjs +3 -1
- package/tests/credit_grant.mjs +7 -7
- package/tests/credit_wording.mjs +154 -0
- package/tests/google_halls.mjs +211 -0
- 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/market_watch_ask.mjs +369 -0
- package/tests/market_watch_ask_db.mjs +122 -0
- package/tests/market_watch_data_db.mjs +4 -0
- package/tests/market_watch_module.mjs +9 -5
- package/tests/project_settings_page.mjs +1 -1
- package/tests/reward_cost_scale.mjs +200 -0
- package/tests/run_node_reroute.mjs +136 -0
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
// hand. Two shapes hold nothing:
|
|
9
9
|
//
|
|
10
10
|
// blank `ready`, no draft and no frozen batch: opened in the
|
|
11
|
-
// studio, nothing written, the claim dropped.
|
|
11
|
+
// studio, nothing written, the claim dropped. Closes once
|
|
12
|
+
// untouched for page-status's BLANK_CLOSES_AFTER (1 day, the
|
|
13
|
+
// owner's answer on task 1004971; task 1004972). Until then
|
|
14
|
+
// it reads `blank` ("Opened, nothing written yet"), never
|
|
15
|
+
// `unknown`.
|
|
12
16
|
// nothing_to_apply `ready` with a frozen batch whose `lines` is empty: every
|
|
13
17
|
// rewrite left the batch for an engineer task at submit
|
|
14
18
|
// (unplaced, shared or removed lines), so /tweak has no line
|
|
@@ -32,18 +36,47 @@ const pageStatus = require('./page-status');
|
|
|
32
36
|
const GRACE_MS = Number(process.env.COPY_AUTO_CLOSE_GRACE_MS) || 60 * 60_000;
|
|
33
37
|
|
|
34
38
|
const NOTES = Object.freeze({
|
|
35
|
-
blank: 'Closed
|
|
39
|
+
blank: 'Closed: nothing was written. The round was opened and left blank for a day; claim the page again to start one.',
|
|
36
40
|
nothing_to_apply: 'Closed by itself: every rewritten line went to an engineer task, so nothing was left to apply on the page.',
|
|
37
41
|
draft_expired: 'Closed by itself: the draft sat untouched for two weeks. Its words stay on this task; claim the page again to start over.',
|
|
42
|
+
orphaned_apply: 'Released: the apply stopped without finishing. Nobody holds this round any more, so it is back in the queue.',
|
|
38
43
|
});
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
// task 1004972 T2 (task 1004973): an ORPHANED round is `active` with no open
|
|
46
|
+
// claim. The applier's claim was released (the runner's reconcile, a crashed
|
|
47
|
+
// worker, a dropped session) but the status never moved, so it read `unknown`
|
|
48
|
+
// and nothing ever picked it up again. A round someone still holds, a live and
|
|
49
|
+
// heartbeating apply, is never touched: holder_id is the open claim, and the
|
|
50
|
+
// lifecycle port re-checks that no claim was taken under its row lock.
|
|
51
|
+
function orphanReason(task, { now = Date.now(), graceMs = GRACE_MS } = {}) {
|
|
52
|
+
if (!task || task.status !== 'active' || task.holder_id != null) return null;
|
|
53
|
+
const touched = Date.parse(task.updated_at || task.created_at || '');
|
|
54
|
+
if (!Number.isFinite(touched) || now - touched < graceMs) return null;
|
|
55
|
+
return 'orphaned_apply';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The orphan goes back to `ready` with its description kept whole plus the
|
|
59
|
+
// reason, as a sent-back block (pages.appendSentBack, the one writer of that
|
|
60
|
+
// block, which only APPENDS). A frozen batch then reads `submitted` again and the
|
|
61
|
+
// next applier reads why; a draft stays a draft. No word of the draft or the
|
|
62
|
+
// batch is changed.
|
|
63
|
+
function releaseKeeping(note, at, by) {
|
|
64
|
+
return (task) => pages.appendSentBack(task.description, { at, by, note });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function closeReason(task, {
|
|
68
|
+
now = Date.now(), graceMs = GRACE_MS,
|
|
69
|
+
draftExpiresMs = pageStatus.DRAFT_EXPIRES_AFTER, blankClosesMs = pageStatus.BLANK_CLOSES_AFTER,
|
|
70
|
+
} = {}) {
|
|
41
71
|
if (!task || task.status !== 'ready' || task.holder_id != null) return null;
|
|
42
72
|
const touched = Date.parse(task.updated_at || task.created_at || '');
|
|
43
73
|
if (!Number.isFinite(touched) || now - touched < graceMs) return null;
|
|
44
74
|
const p = pages.blockPresence(task.description);
|
|
45
|
-
|
|
46
|
-
|
|
75
|
+
const age = now - touched;
|
|
76
|
+
// task 1004901: a draft its artist left untouched expires. task 1004972: a
|
|
77
|
+
// blank round closes after a day. A batch with lines is never touched here,
|
|
78
|
+
// and a draft only by its own expiry.
|
|
79
|
+
if (!p.batch) return p.draft ? (age >= draftExpiresMs ? 'draft_expired' : null) : (age >= blankClosesMs ? 'blank' : null);
|
|
47
80
|
const b = pages.parseBatchBlock(task.description);
|
|
48
81
|
if (b.ok && b.batch.lines.length === 0) return 'nothing_to_apply';
|
|
49
82
|
return null;
|
|
@@ -58,16 +91,21 @@ async function runAutoClose({ now = Date.now(), resolve = (k) => (api.resolveOpt
|
|
|
58
91
|
return { closed: [], skipped: 'no_lifecycle' };
|
|
59
92
|
}
|
|
60
93
|
const tasks = await lifecycle.listPageTweakTasks();
|
|
61
|
-
const due = tasks.map((t) => ({ t, why: closeReason(t, { now }) })).filter((x) => x.why);
|
|
94
|
+
const due = tasks.map((t) => ({ t, why: closeReason(t, { now }) || orphanReason(t, { now }) })).filter((x) => x.why);
|
|
62
95
|
if (!due.length) return { closed: [] };
|
|
63
96
|
const owner = await founder();
|
|
64
97
|
if (!owner) return { closed: [], skipped: 'no_owner' };
|
|
65
98
|
const closed = [];
|
|
99
|
+
const at = new Date(now).toISOString();
|
|
66
100
|
for (const { t, why } of due) {
|
|
67
|
-
const
|
|
101
|
+
const orphan = why === 'orphaned_apply';
|
|
102
|
+
const r = await lifecycle.releaseStuckTweak({
|
|
103
|
+
taskId: t.id, builderId: owner.id, expectedStatus: orphan ? 'active' : 'ready', note: NOTES[why],
|
|
104
|
+
...(orphan ? { compose: releaseKeeping(NOTES[why], at, String(owner.id)) } : {}),
|
|
105
|
+
});
|
|
68
106
|
closed.push({ task_id: String(t.id), why, outcome: r.outcome });
|
|
69
107
|
}
|
|
70
108
|
return { closed };
|
|
71
109
|
}
|
|
72
110
|
|
|
73
|
-
module.exports = { closeReason, runAutoClose };
|
|
111
|
+
module.exports = { closeReason, orphanReason, runAutoClose };
|
|
@@ -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
|
];
|
|
@@ -96,6 +96,13 @@ async function postShipBroadcast(task, deps = {}) {
|
|
|
96
96
|
|
|
97
97
|
const postWebhook = deps.postWebhook || require('./webhook').postWebhook;
|
|
98
98
|
const log = deps.log || console.log;
|
|
99
|
+
// task 1004969 (ADR 0385 decision 9): an AI-written summary that calls credits
|
|
100
|
+
// or models an investment is FLAGGED in the log and posted unchanged — recorded,
|
|
101
|
+
// never blocked or rewritten. No economy module, no check.
|
|
102
|
+
const reward = deps.reward !== undefined ? deps.reward : require('../../src/module-api').resolveOptional('reward');
|
|
103
|
+
if (reward && typeof reward.flagInvestmentWording === 'function') {
|
|
104
|
+
result.wording_flags = reward.flagInvestmentWording(summary, { where: 'ship summary', ref: `task ${task.id}`, log }).length;
|
|
105
|
+
}
|
|
99
106
|
const pageTweak = task.source === PAGE_TWEAK_SOURCE;
|
|
100
107
|
const pageArtist = pageTweak && sec === false ? await pageArtistName(task, deps) : null;
|
|
101
108
|
const targets = chooseBroadcastTargets({ securitySensitive: sec, taskId: task.id, summary, kind: task.kind, pageTweak, pageArtist });
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/credit-wording.js — finds investment wording next to credits
|
|
4
|
+
// or models (task 1004969; ADR 0385 decision 9).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Credits are a contribution meter, not money and not a stake (ADR 0385,
|
|
7
|
+
// ADR 0387), and a module or a model is not something a builder invests in.
|
|
8
|
+
// Wording that says otherwise keeps coming back — in code comments, onboarding
|
|
9
|
+
// docs and AI-written ship summaries — and every time it does the project reads
|
|
10
|
+
// as if it were selling a security. This is the one check for it.
|
|
11
|
+
//
|
|
12
|
+
// THE RULE. A sentence is flagged when an investment word (investment, return,
|
|
13
|
+
// appreciation, passive income, equity, ownership) sits within NEAR_WORDS words
|
|
14
|
+
// of a credit word or a model word. A sentence that DENIES it ("credits are not
|
|
15
|
+
// an investment", "never equity") is the wording we want, so a negation shortly
|
|
16
|
+
// before the investment word clears it.
|
|
17
|
+
//
|
|
18
|
+
// It only FINDS. Callers decide what to do: the wording test fails on a hit, and
|
|
19
|
+
// the server publish paths log a flag and publish the text unchanged — never a
|
|
20
|
+
// silent rewrite, never a block. PURE: no I/O, so it is safe on any path.
|
|
21
|
+
|
|
22
|
+
const NEAR_WORDS = 12;
|
|
23
|
+
const NEGATION_WORDS = 4;
|
|
24
|
+
|
|
25
|
+
const ANCHOR_RE = /^(credits?|drachmae|models?)$/i;
|
|
26
|
+
const NEGATION_RE = /^(not|never|no|nor|isn't|aren't|without|neither)$/i;
|
|
27
|
+
|
|
28
|
+
// One entry per investment idea; `words` is the phrase as lower-case tokens.
|
|
29
|
+
// "return" is the noun only: "returns" alone, or "return on"/"a return" — the
|
|
30
|
+
// verb ("the task returns to ready") is everywhere and says nothing about money.
|
|
31
|
+
const TERMS = [
|
|
32
|
+
{ term: 'investment', match: (w) => /^invest(ment|ments|ing|or|ors|ed|s)?$/.test(w[0]) && 1 },
|
|
33
|
+
{ term: 'return', match: (w, prev) => {
|
|
34
|
+
if (w[0] === 'return' && w[1] === 'on') return 2;
|
|
35
|
+
if (/^returns?$/.test(w[0]) && /^(a|any|financial|guaranteed|expected|your|their|high|higher)$/.test(prev || '')) return 1;
|
|
36
|
+
return 0;
|
|
37
|
+
} },
|
|
38
|
+
{ term: 'appreciation', match: (w) => (w[0] === 'appreciation' ? 1 : (/^appreciat(e|es|ing)$/.test(w[0]) && w[1] === 'in' && w[2] === 'value' ? 3 : 0)) },
|
|
39
|
+
{ term: 'passive income', match: (w) => (w[0] === 'passive' && w[1] === 'income' ? 2 : 0) },
|
|
40
|
+
{ term: 'equity', match: (w) => (w[0] === 'equity' ? 1 : 0) },
|
|
41
|
+
{ term: 'ownership', match: (w) => (w[0] === 'ownership' ? 1 : 0) },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function sentences(text) {
|
|
45
|
+
return String(text == null ? '' : text).split(/(?<=[.!?;])\s+|\n\s*\n|\n(?=\s*[-*|#>])/);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tokens(sentence) {
|
|
49
|
+
return (sentence.toLowerCase().match(/[a-z][a-z']*/g) || []).map((t) => t.replace(/'s$/, ''));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Every offending sentence in `text`: [{ term, phrase, anchor, sentence }]. Pure.
|
|
53
|
+
function findInvestmentWording(text) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const raw of sentences(text)) {
|
|
56
|
+
const sentence = raw.replace(/\s+/g, ' ').trim();
|
|
57
|
+
if (!sentence) continue;
|
|
58
|
+
const w = tokens(sentence);
|
|
59
|
+
const anchors = [];
|
|
60
|
+
w.forEach((t, i) => { if (ANCHOR_RE.test(t)) anchors.push(i); });
|
|
61
|
+
if (!anchors.length) continue;
|
|
62
|
+
for (let i = 0; i < w.length; i++) {
|
|
63
|
+
for (const T of TERMS) {
|
|
64
|
+
const len = T.match(w.slice(i, i + 3), w[i - 1]);
|
|
65
|
+
if (!len) continue;
|
|
66
|
+
const near = anchors.find((a) => Math.abs(a - i) <= NEAR_WORDS);
|
|
67
|
+
if (near === undefined) continue;
|
|
68
|
+
const negated = w.slice(Math.max(0, i - NEGATION_WORDS), i).some((t) => NEGATION_RE.test(t));
|
|
69
|
+
if (negated) continue;
|
|
70
|
+
out.push({ term: T.term, phrase: w.slice(i, i + len).join(' '), anchor: w[near], sentence });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The server publish paths' half: find, write one log line per hit, return the
|
|
78
|
+
// hits. The text is published unchanged whatever this finds, and it never
|
|
79
|
+
// throws — a wording check must not be the reason a summary or an idea is lost.
|
|
80
|
+
function flagInvestmentWording(text, { where = 'text', ref = null, log = console.warn } = {}) {
|
|
81
|
+
try {
|
|
82
|
+
const hits = findInvestmentWording(text);
|
|
83
|
+
for (const h of hits) {
|
|
84
|
+
log(`[credit-wording] flagged ${where}${ref == null ? '' : ` ${ref}`}: "${h.phrase}" near "${h.anchor}" in "${h.sentence.slice(0, 200)}" `
|
|
85
|
+
+ '(ADR 0385 decision 9; published unchanged)');
|
|
86
|
+
}
|
|
87
|
+
return hits;
|
|
88
|
+
} catch (_) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { findInvestmentWording, flagInvestmentWording, NEAR_WORDS, NEGATION_WORDS };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (db.applyGrade/confirmTask/upsertSessionRecordWithReward) resolves these
|
|
10
10
|
// through the `reward` kernel PORT rather than importing this module.
|
|
11
11
|
|
|
12
|
-
const { pool, branding, resolveOptional } = require('../../src/module-api');
|
|
12
|
+
const { pool, branding, resolveOptional, logger } = require('../../src/module-api');
|
|
13
13
|
const { priceModelUsage, PRICES } = require('../../src/module-api').llmPricing; // cost-plus session reward (task 1005)
|
|
14
14
|
const achievements = require('./achievements');
|
|
15
15
|
|
|
@@ -143,12 +143,13 @@ const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on
|
|
|
143
143
|
// Both accrue to builders.total_credits (the contribution meter); ADR 0054 (+ its
|
|
144
144
|
// 2026-06-15 amendment) records the scale and why the cents form existed.
|
|
145
145
|
//
|
|
146
|
-
// REWARD_MARGIN_PCT (and the 1:1
|
|
146
|
+
// REWARD_MARGIN_PCT (and the 1:1 sizing factor, `costScale` — formerly `usdPeg`,
|
|
147
|
+
// task 1004968) are the code-default reward
|
|
147
148
|
// POLICY. They stay a code-reviewed default — not a live UI knob — for the same
|
|
148
149
|
// reasons KIND_MULTIPLIERS does: a policy decision deserves a commit trail and
|
|
149
150
|
// the grader reads it deterministically. But the reward policy is also
|
|
150
151
|
// PER-INSTANCE config (task 2009 / BONGOS-V1): an instance overrides the margin
|
|
151
|
-
// + the
|
|
152
|
+
// + the sizing factor in `config/branding.json` `reward.{marginPct,costScale}`
|
|
152
153
|
// (still a committed, code-reviewed change — just in the instance's config, not
|
|
153
154
|
// the vendored core module). `rewardConfig()` resolves the instance value over
|
|
154
155
|
// these defaults; the pure helper keeps its code defaults so tests stay
|
|
@@ -161,8 +162,13 @@ const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on
|
|
|
161
162
|
// (ADR 0146) and every existing caller is unchanged.
|
|
162
163
|
const {
|
|
163
164
|
REWARD_MARGIN_PCT, REWARD_MODE_DEFAULT, REWARD_MODE_COST_PLUS_ONLY, IDEA_DESCENT_DECAY, rewardPolicy,
|
|
165
|
+
rewardPolicyDeprecation,
|
|
164
166
|
} = require('./reward-policy');
|
|
165
167
|
|
|
168
|
+
// The usdPeg deprecation note prints ONCE per process, not once per award: this
|
|
169
|
+
// resolver runs on every ship, and a line per ship would bury the logs.
|
|
170
|
+
let usdPegNoteShown = false;
|
|
171
|
+
|
|
166
172
|
// Per-instance reward policy, resolved over the code defaults. Stays HERE, not in
|
|
167
173
|
// reward-policy.js: the branding read must go through the module port (ADR 0083),
|
|
168
174
|
// and a file the CLI can share may not import the port. Best-effort — a branding
|
|
@@ -170,6 +176,11 @@ const {
|
|
|
170
176
|
function rewardConfig() {
|
|
171
177
|
let r = {};
|
|
172
178
|
try { r = (branding().reward) || {}; } catch { r = {}; }
|
|
179
|
+
const note = rewardPolicyDeprecation(r);
|
|
180
|
+
if (note && !usdPegNoteShown) {
|
|
181
|
+
usdPegNoteShown = true;
|
|
182
|
+
logger('economy').warn(note);
|
|
183
|
+
}
|
|
173
184
|
return rewardPolicy(r);
|
|
174
185
|
}
|
|
175
186
|
|
|
@@ -205,22 +216,25 @@ function rewardConfig() {
|
|
|
205
216
|
// absent/any-non-false → the default TRUE (a pre-ADR-0172 config keeps paying).
|
|
206
217
|
|
|
207
218
|
// Pure helper — exported for tests. Returns { trueCostUsd, marginPct, drachmae,
|
|
208
|
-
// description }. drachmae = round(cost × (1+margin) ×
|
|
219
|
+
// description }. drachmae = round(cost × (1+margin) × costScale); at the 1:1 scale
|
|
209
220
|
// one drachma is earned per dollar of cost-plus (task 1138) — sized by USD, not
|
|
210
221
|
// worth USD (ADR 0387), e.g.
|
|
211
222
|
// "$15.00 true cost × 1.20 = 18 drachmae (token reward)"
|
|
212
|
-
// NOTE a sub-$0.42 session (at the 1:1
|
|
223
|
+
// NOTE a sub-$0.42 session (at the 1:1 scale) rounds to 0 drachmae — intended:
|
|
213
224
|
// sub-dollar sessions earn nothing. Kept pure (code-default params); the call
|
|
214
225
|
// site resolves per-instance policy via rewardConfig() and passes it in.
|
|
215
|
-
|
|
226
|
+
// A non-1 factor is written into the description as "× N scale" (it said
|
|
227
|
+
// "× N peg" before task 1004968; only NEW rows carry the new word — no existing
|
|
228
|
+
// credit_log row is rewritten). At the default 1 the description is unchanged.
|
|
229
|
+
function computeSessionTokenReward(trueCostUsd, marginPct = REWARD_MARGIN_PCT, costScale = 1) {
|
|
216
230
|
const cost = Number(trueCostUsd) || 0;
|
|
217
231
|
const pct = Number(marginPct) || 0;
|
|
218
|
-
const
|
|
232
|
+
const scale = Number.isFinite(Number(costScale)) && Number(costScale) > 0 ? Number(costScale) : 1;
|
|
219
233
|
const mult = 1 + pct / 100;
|
|
220
|
-
const drachmae = Math.round(cost * mult *
|
|
221
|
-
const
|
|
234
|
+
const drachmae = Math.round(cost * mult * scale);
|
|
235
|
+
const scaleNote = scale !== 1 ? ` × ${scale} scale` : '';
|
|
222
236
|
const description =
|
|
223
|
-
`$${cost.toFixed(2)} true cost × ${mult.toFixed(2)}${
|
|
237
|
+
`$${cost.toFixed(2)} true cost × ${mult.toFixed(2)}${scaleNote} = ${drachmae} drachmae (token reward)`;
|
|
224
238
|
return { trueCostUsd: cost, marginPct: pct, drachmae, description };
|
|
225
239
|
}
|
|
226
240
|
|
|
@@ -1026,8 +1040,8 @@ async function awardSessionTokenReward(
|
|
|
1026
1040
|
const extraRaw = Number(extraCostUsd);
|
|
1027
1041
|
const extra = Number.isFinite(extraRaw) ? Math.max(0, extraRaw) : 0;
|
|
1028
1042
|
const trueCostUsd = Math.round((pricedCostUsd + extra) * 10000) / 10000;
|
|
1029
|
-
const { marginPct,
|
|
1030
|
-
const { drachmae, description } = computeSessionTokenReward(trueCostUsd, marginPct,
|
|
1043
|
+
const { marginPct, costScale } = rewardConfig();
|
|
1044
|
+
const { drachmae, description } = computeSessionTokenReward(trueCostUsd, marginPct, costScale);
|
|
1031
1045
|
|
|
1032
1046
|
// Nothing priced (no real tokens) — record nothing, report cleanly.
|
|
1033
1047
|
if (!(drachmae > 0)) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (task 1003676).
|
|
3
3
|
//
|
|
4
4
|
// What it is: the reward defaults and the PURE function that resolves a branding
|
|
5
|
-
// pack's `reward` block over them — margin,
|
|
5
|
+
// pack's `reward` block over them — margin, cost scale, mode, whether the per-task
|
|
6
6
|
// credit and the ideator lane are on.
|
|
7
7
|
//
|
|
8
8
|
// Why it is its own file, and why it imports NOTHING: this is the one piece of
|
|
@@ -50,13 +50,56 @@ const REWARD_MODE_COST_PLUS_ONLY = 'cost-plus-only';
|
|
|
50
50
|
// code path to disable separately.
|
|
51
51
|
const IDEA_DESCENT_DECAY = 0.5;
|
|
52
52
|
|
|
53
|
+
// THE SIZING FACTOR IS `costScale` (task 1004968). It multiplies a session's
|
|
54
|
+
// cost-plus figure into credits and is 1 by default. It used to be called
|
|
55
|
+
// `usdPeg`, which read as an exchange rate between a credit and a dollar —
|
|
56
|
+
// exactly what ADR 0387 rules out (a credit has no redemption price). The OLD
|
|
57
|
+
// key is still accepted so no host pack breaks before every hosted pin has moved
|
|
58
|
+
// past this release (ADR 0161); when both are set, `costScale` wins. Removing
|
|
59
|
+
// `usdPeg` support is a later task. The formula itself does not change, and no
|
|
60
|
+
// existing credit_log row is touched — this only renames how a pack spells it.
|
|
61
|
+
// config/branding.neutral.json carries NEITHER key, on purpose: a host pack
|
|
62
|
+
// deep-merges over neutral, so a neutral `costScale: 1` would beat a host's old
|
|
63
|
+
// `usdPeg` and silently reset its awards. The default 1 lives here instead.
|
|
64
|
+
const LEGACY_SCALE_KEY = 'usdPeg';
|
|
65
|
+
const USD_PEG_DEPRECATION =
|
|
66
|
+
'branding reward.usdPeg is deprecated: rename it to reward.costScale (same value, same awards; ADR 0387).';
|
|
67
|
+
|
|
68
|
+
function validScale(v) {
|
|
69
|
+
if (v == null) return NaN;
|
|
70
|
+
const n = Number(v);
|
|
71
|
+
return Number.isFinite(n) && n > 0 ? n : NaN;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// PURE. Which sizing factor a raw block carries: costScale if it is valid, else
|
|
75
|
+
// the legacy usdPeg, else the default 1. An INVALID costScale falls through to
|
|
76
|
+
// usdPeg rather than to 1, so a typo in the new key cannot silently change the
|
|
77
|
+
// awards of a pack that still carries a working old one.
|
|
78
|
+
function resolveCostScale(rawReward) {
|
|
79
|
+
const r = rawReward || {};
|
|
80
|
+
const fromNew = validScale(r.costScale);
|
|
81
|
+
if (Number.isFinite(fromNew)) return fromNew;
|
|
82
|
+
const fromOld = validScale(r[LEGACY_SCALE_KEY]);
|
|
83
|
+
if (Number.isFinite(fromOld)) return fromOld;
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// PURE. The one-line deprecation note for a pack that still spells the factor the
|
|
88
|
+
// old way, or null. Only when costScale is absent: a pack that has moved and kept
|
|
89
|
+
// the old key beside it is not using it, because costScale wins.
|
|
90
|
+
function rewardPolicyDeprecation(rawReward) {
|
|
91
|
+
const r = rawReward || {};
|
|
92
|
+
if (r[LEGACY_SCALE_KEY] != null && r.costScale == null) return USD_PEG_DEPRECATION;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
53
96
|
// PURE. Resolves a raw branding `reward` block over the code defaults. Every field
|
|
54
97
|
// is validated rather than trusted: a malformed pack degrades to the defaults
|
|
55
98
|
// instead of poisoning an award.
|
|
56
99
|
function rewardPolicy(rawReward) {
|
|
57
100
|
const r = rawReward || {};
|
|
58
101
|
const marginPct = Number.isFinite(Number(r.marginPct)) ? Number(r.marginPct) : REWARD_MARGIN_PCT;
|
|
59
|
-
const
|
|
102
|
+
const costScale = resolveCostScale(r);
|
|
60
103
|
const mode = r.mode === REWARD_MODE_COST_PLUS_ONLY ? REWARD_MODE_COST_PLUS_ONLY : REWARD_MODE_DEFAULT;
|
|
61
104
|
const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
|
|
62
105
|
// Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
|
|
@@ -77,7 +120,7 @@ function rewardPolicy(rawReward) {
|
|
|
77
120
|
const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
|
|
78
121
|
? rawDecay
|
|
79
122
|
: IDEA_DESCENT_DECAY;
|
|
80
|
-
return { marginPct,
|
|
123
|
+
return { marginPct, costScale, mode, perTaskCredit, ideatorCredit, artistCredit, ideaDescentDecay };
|
|
81
124
|
}
|
|
82
125
|
|
|
83
126
|
module.exports = {
|
|
@@ -85,5 +128,8 @@ module.exports = {
|
|
|
85
128
|
REWARD_MODE_DEFAULT,
|
|
86
129
|
REWARD_MODE_COST_PLUS_ONLY,
|
|
87
130
|
IDEA_DESCENT_DECAY,
|
|
131
|
+
USD_PEG_DEPRECATION,
|
|
132
|
+
resolveCostScale,
|
|
133
|
+
rewardPolicyDeprecation,
|
|
88
134
|
rewardPolicy,
|
|
89
135
|
};
|
|
@@ -24,6 +24,7 @@ const ideaSizeHistorical = require('./idea-size-historical');
|
|
|
24
24
|
const ideaBrightnessLib = require('./idea-brightness');
|
|
25
25
|
const creditsByCraftLib = require('./credits-by-craft');
|
|
26
26
|
const goalReward = require('./goal-reward');
|
|
27
|
+
const creditWording = require('./credit-wording');
|
|
27
28
|
|
|
28
29
|
module.exports = {
|
|
29
30
|
// credit allocation — transaction-participant: caller passes its tx `client`
|
|
@@ -147,4 +148,8 @@ module.exports = {
|
|
|
147
148
|
wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
|
|
148
149
|
// task 1004853: /me's batched form — one streak + one task query per request.
|
|
149
150
|
streakPreviewsForTasks: (...a) => streak.streakPreviewsForTasks(...a),
|
|
151
|
+
// task 1004969 (ADR 0385 decision 9): investment wording next to credits or
|
|
152
|
+
// models. The publish paths flag and log it; they never block or rewrite.
|
|
153
|
+
findInvestmentWording: (...a) => creditWording.findInvestmentWording(...a),
|
|
154
|
+
flagInvestmentWording: (...a) => creditWording.flagInvestmentWording(...a),
|
|
150
155
|
};
|
|
@@ -988,6 +988,13 @@ module.exports = function buildGovernmentRouter() {
|
|
|
988
988
|
}
|
|
989
989
|
return res.fail('invalid_proposal', 400, { reason: out.reason, ...(out.field ? { field: out.field } : {}), ...(out.expected ? { expected: out.expected } : {}) });
|
|
990
990
|
}
|
|
991
|
+
// task 1004969 (ADR 0385 decision 9): a board rationale that calls credits
|
|
992
|
+
// or models an investment is FLAGGED in the log; the item opens unchanged.
|
|
993
|
+
// Optional and undeclared (the ideas.descent precedent): no economy, no check.
|
|
994
|
+
const wording = api.resolveOptional && api.resolveOptional('reward');
|
|
995
|
+
if (wording && typeof wording.flagInvestmentWording === 'function' && typeof req.body.rationale_md === 'string') {
|
|
996
|
+
wording.flagInvestmentWording(req.body.rationale_md, { where: 'board item', ref: out.item && out.item.id });
|
|
997
|
+
}
|
|
991
998
|
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item });
|
|
992
999
|
} catch (err) {
|
|
993
1000
|
log.error('[gds] POST /government/board/items', err);
|
|
@@ -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;
|
|
@@ -287,6 +287,12 @@ module.exports = function buildInboxRouter() {
|
|
|
287
287
|
// hand every untrusted door the same power by typing one word.
|
|
288
288
|
vector: 'api',
|
|
289
289
|
});
|
|
290
|
+
// task 1004969 (ADR 0385 decision 9): an idea that calls credits or models
|
|
291
|
+
// an investment is FLAGGED in the log; the idea is stored unchanged.
|
|
292
|
+
const wording = api.resolveOptional && api.resolveOptional('reward');
|
|
293
|
+
if (wording && typeof wording.flagInvestmentWording === 'function') {
|
|
294
|
+
wording.flagInvestmentWording([body.title, body.body_md, ...Object.values(body.field_values || {})].filter((v) => typeof v === 'string').join('\n\n'), { where: 'idea', ref: idea.id });
|
|
295
|
+
}
|
|
290
296
|
let grade_warning;
|
|
291
297
|
if (grade === 'full') {
|
|
292
298
|
// The idea is captured; from here every failure is FAIL-OPEN (the
|
|
@@ -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
|