@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.
@@ -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
- return p.draft ? 'draft' : 'unknown';
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 |compose/.test(closeSrc), false, 'the auto-close sweep abandons and writes nothing');
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 }), 'blank');
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(6, 'builders:f', { status: 'ready', updated_at: ago(0.2) }),
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
- if (!r || r.state === 'draft' || (r.state === 'writing' && !r.holder)) {
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.4",
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.4",
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.4",
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",
@@ -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.4": [
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), and .bongos-core.json (the manifest of a
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 manifests — written by package-core.js, never hand-edited.
619
- '.bongos-core.json': 'generated package manifest (scripts/bongos/package-core.js)',
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.
@@ -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
- interaction: lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null),
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.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
- const wanted = new Set(params[0].map(String));
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
- // KNOWN BUG (task 1005004, filed by task 1004983): releaseClaimsBatch compares
219
- // claim.builder_id !== builderId strictly, so a STRING row id (node-pg bigint)
220
- // against a NUMBER caller id refuses the caller's OWN claim as NOT_YOUR_CLAIM.
221
- // Production is unaffected today only because req.builder.id is also a string.
222
- // It fails closed (never releases a stranger's claim), so this pins CURRENT
223
- // behaviour; when the compare is fixed, flip this to expect a release.
224
- test('KNOWN BUG: own claim as string builder_id vs a NUMBER caller id is refused NOT_YOUR_CLAIM (fails closed)', async () => {
225
- caller = { id: 42, rank: 'thetes' };
226
- claimFixture = [row({ builder_id: '42' })];
227
- const r = await post(RELEASE, { claim_ids: [101] });
228
- assert.deepEqual(r.json.released, []);
229
- assert.deepEqual(r.json.failures, [{ claim_id: 101, reason: 'NOT_YOUR_CLAIM' }]);
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: 'ready' }), 'appliers', 0],
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';