@bongos/core 1.23.20 → 1.23.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.bongos-core.json +137 -67
  2. package/.claude/skills/goal-create/SKILL.md +5 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +5 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
  9. package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
  10. package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
  11. package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
  12. package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
  13. package/docs/adr/README.md +5 -0
  14. package/docs/api/openapi.json +59 -4
  15. package/docs/api-reference.md +7 -1
  16. package/docs/file-map.md +3 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/docs/page-readings.json +1 -1
  19. package/modules/ai-studio/CLAUDE.md +15 -0
  20. package/modules/ai-studio/module.json +26 -0
  21. package/modules/ai-studio/routes/ai-studio.js +22 -0
  22. package/modules/copy-desk/chrome-pages.js +178 -0
  23. package/modules/copy-desk/page-data.js +34 -2
  24. package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
  25. package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
  26. package/modules/government/db.js +74 -0
  27. package/modules/government/routes/government.js +28 -18
  28. package/modules/grading/grader-subagent.js +30 -11
  29. package/modules/hall-ui/public/city.html +15 -15
  30. package/modules/lifecycle/db-analytics.js +10 -3
  31. package/modules/lifecycle/db-goals.js +30 -13
  32. package/modules/lifecycle/db-shared.js +9 -0
  33. package/modules/lifecycle/db-versions.js +21 -8
  34. package/modules/lifecycle/ship-assurance.js +4 -1
  35. package/modules/lifecycle/ship-card.js +8 -2
  36. package/modules/onboarding/howto-moments.js +8 -1
  37. package/modules/provisioning/starter-bundles.js +4 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +72 -0
  41. package/scripts/bongos/copy-apply.js +3 -1
  42. package/scripts/bongos/publish-manifest.js +1 -0
  43. package/scripts/bongos/strand-watch.js +106 -10
  44. package/scripts/bongos/tweak-renders.js +9 -2
  45. package/src/bongos/module-scope-map.js +4 -0
  46. package/src/module-api.js +1 -1
  47. package/src/platform-server.js +27 -1
  48. package/tests/batched_writes_parity.mjs +340 -0
  49. package/tests/conductor_main_e2e.mjs +4 -0
  50. package/tests/copy_desk_page_docx.mjs +3 -3
  51. package/tests/copy_desk_page_editor.mjs +6 -6
  52. package/tests/copy_desk_page_next.mjs +3 -2
  53. package/tests/copy_desk_page_reads.mjs +2 -1
  54. package/tests/copy_desk_page_submit.mjs +30 -2
  55. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  56. package/tests/goal_archive_disposition.mjs +5 -5
  57. package/tests/government_routes.mjs +145 -22
  58. package/tests/grader.mjs +31 -0
  59. package/tests/hall_city.mjs +8 -0
  60. package/tests/hook_shell_tools.mjs +8 -0
  61. package/tests/hooks_drain.mjs +1 -1
  62. package/tests/host_topology_skips.mjs +2 -0
  63. package/tests/howto_moments.mjs +17 -0
  64. package/tests/module_loader.mjs +1 -1
  65. package/tests/module_skills_hook.mjs +69 -0
  66. package/tests/profile_role_stats.mjs +2 -1
  67. package/tests/public_landing_projects.mjs +37 -0
  68. package/tests/session_start_freshness.mjs +66 -1
  69. package/tests/shell_hooks.mjs +209 -0
  70. package/tests/ship_assurance.mjs +28 -0
  71. package/tests/ship_card.mjs +20 -5
  72. package/tests/strand_watch.mjs +48 -0
  73. package/tests/version_close_route.mjs +1 -1
@@ -0,0 +1,69 @@
1
+ // tests/module_skills_hook.mjs
2
+ //
3
+ // Task 1004915 — session start puts module skills in place for an artist-craft builder.
4
+ // Pins the pure decision and the printed line; the network leg is a thin raw fetch (as in
5
+ // recall_hints.mjs) and the copy itself is pinned by claude_materialize.mjs.
6
+ //
7
+ // Run: node tests/module_skills_hook.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+ import { requireHostModule } from './helpers.mjs';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { wantsModuleSkills, missingModuleSkills, newlyLanded, formatLanded, DEADLINE_MS } =
15
+ requireHostModule(require, '../.claude/hooks/module-skills.js');
16
+
17
+ let passed = 0;
18
+ let failed = 0;
19
+ function t(name, fn) {
20
+ try { fn(); passed++; console.log(` PASS ${name}`); }
21
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
22
+ }
23
+
24
+ t('an artist-craft builder gets the skills', () => {
25
+ assert.equal(wantsModuleSkills({ builder: { preferred_disciplines: ['artist'] } }), true);
26
+ assert.equal(wantsModuleSkills({ builder: { preferred_disciplines: ['engineer', 'artist'] } }), true);
27
+ });
28
+
29
+ t('anyone else gets nothing (the 2026-09-02 rule holds)', () => {
30
+ assert.equal(wantsModuleSkills({ builder: { preferred_disciplines: ['engineer'] } }), false);
31
+ assert.equal(wantsModuleSkills({ builder: { preferred_disciplines: [] } }), false);
32
+ assert.equal(wantsModuleSkills({ builder: {} }), false);
33
+ });
34
+
35
+ t('a missing or malformed lookup copies nothing', () => {
36
+ for (const bad of [null, undefined, {}, { builder: null }, { builder: { preferred_disciplines: 'artist' } }]) {
37
+ assert.equal(wantsModuleSkills(bad), false);
38
+ }
39
+ });
40
+
41
+ t('the line names each new skill and says when it shows up; silent when none landed', () => {
42
+ const line = formatLanded(['tweak']);
43
+ assert.ok(line.includes('/tweak') && line.includes('next session'));
44
+ assert.equal(formatLanded([]), null);
45
+ assert.equal(formatLanded(undefined), null);
46
+ });
47
+
48
+ t('only an enabled module absent skills are missing; with all in place nothing is (steady state: no lookup, no copy)', () => {
49
+ const roster = [
50
+ { key: 'copy-desk', enabled: true, skills: ['tweak'] },
51
+ { key: 'pixel-art', enabled: false, skills: ['otb-tile-generate'] },
52
+ ];
53
+ assert.deepEqual(missingModuleSkills(roster, []), ['tweak']);
54
+ assert.deepEqual(missingModuleSkills(roster, ['tweak', 'design']), []);
55
+ assert.deepEqual(missingModuleSkills(undefined, undefined), []);
56
+ });
57
+
58
+ t('only what was missing AND landed is announced', () => {
59
+ assert.deepEqual(newlyLanded(['tweak'], ['tweak', 'other']), ['tweak']);
60
+ assert.deepEqual(newlyLanded(['tweak'], []), []);
61
+ assert.deepEqual(newlyLanded([], ['tweak']), []);
62
+ });
63
+
64
+ t('the network budget stays under the session-start ceiling', () => {
65
+ assert.ok(DEADLINE_MS <= 2000);
66
+ });
67
+
68
+ console.log(`\n${passed} passed, ${failed} failed`);
69
+ process.exit(failed ? 1 : 0);
@@ -69,7 +69,8 @@ const LEDGER_ROWS = [
69
69
  // ---- the page-tweak ledger (Ines and one other artist) ----------------------
70
70
 
71
71
  const pages = require('../modules/copy-desk/pages.js');
72
- const inventory = require('../docs/page-inventory.json');
72
+ // The studio's own inventory: the real pages plus each surface's chrome pages.
73
+ const inventory = require('../modules/copy-desk/page-data.js').loadInventory().data;
73
74
  const onSurface = (s) => inventory.pages.filter((p) => p.surface === s).map((p) => p.id);
74
75
  const LANDING = onSurface('landing');
75
76
  const BUILDERS = onSurface('builders');
@@ -458,6 +458,43 @@ test('a stamped asset still serves — the query string is not part of the filen
458
458
  assert.match(res.body, /\.shell\{/, 'and it must be the real stylesheet');
459
459
  });
460
460
 
461
+ /* ── How long a landing asset may be kept (task 1004925) ──────────────────────
462
+ A URL carrying the file's CURRENT stamp can never name other bytes, so it is
463
+ kept for a year; a stale or made-up stamp, or a bare URL, must not be — that
464
+ is how a changed file still reaches a returning visitor. ── */
465
+
466
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
467
+
468
+ test('a stamped landing asset is cached for a year', async () => {
469
+ const page = await request('/projects');
470
+ const urls = [...page.body.matchAll(/(?:href|src)="(\/assets\/[^"?]+\?v=[0-9a-f]{8})"/g)].map((m) => m[1]);
471
+ assert.ok(urls.some((u) => /\.css\?/.test(u)), 'the page stamps at least one stylesheet');
472
+ assert.ok(urls.some((u) => /\.js\?/.test(u)), 'the page stamps at least one script');
473
+ for (const u of urls) {
474
+ const res = await request(u);
475
+ assert.equal(res.status, 200, u);
476
+ assert.equal(res.headers['cache-control'], IMMUTABLE, `${u} carries its content stamp`);
477
+ }
478
+ });
479
+
480
+ test('a bare, stale or made-up stamp is never cached for a year', async () => {
481
+ const page = await request('/projects');
482
+ const real = (page.body.match(/\/assets\/cosmos\.css\?v=([0-9a-f]{8})/) || [])[1];
483
+ assert.ok(real, 'need the real stamp');
484
+ const stale = real === '00000000' ? '11111111' : '00000000';
485
+ for (const u of ['/assets/cosmos.css', `/assets/cosmos.css?v=${stale}`, '/assets/cosmos.css?v=3', `/assets/cosmos.css?v=${real}x`]) {
486
+ const res = await request(u);
487
+ assert.equal(res.status, 200, u);
488
+ assert.doesNotMatch(String(res.headers['cache-control'] || ''), /immutable|31536000/, `${u} must stay short-lived`);
489
+ }
490
+ });
491
+
492
+ test('an unstamped landing image is kept for a day', async () => {
493
+ const res = await request('/assets/world/r2-chrome-blob-one-cut.webp');
494
+ assert.equal(res.status, 200);
495
+ assert.equal(res.headers['cache-control'], 'public, max-age=86400');
496
+ });
497
+
461
498
  // ── #view-mine: the ways to /invitations and /account ──────────────────────────
462
499
  // The hall's my-projects page was deleted in task 1003308 on the owner's own
463
500
  // decision that this is a hub surface, and task 1003774 brought its lists here.
@@ -16,10 +16,15 @@
16
16
 
17
17
  import { strict as assert } from 'node:assert';
18
18
  import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
19
22
  import { requireHostModule } from './helpers.mjs';
20
23
 
21
24
  const require = createRequire(import.meta.url);
22
- const { decideFreshness } = requireHostModule(require, '../.claude/hooks/session-start.js');
25
+ const {
26
+ decideFreshness, planFetch, viewNote, staleViewLine, lastMainFetchMs, FETCH_FRESH_MS, FETCH_OLD_NOTE_MS,
27
+ } = requireHostModule(require, '../.claude/hooks/session-start.js');
23
28
 
24
29
  let passed = 0;
25
30
  let failed = 0;
@@ -120,5 +125,65 @@ test('dirty and ahead → still just warn (no pull)', () => {
120
125
  assert.equal(d.action, 'warn');
121
126
  });
122
127
 
128
+ // ---------- the fetch is off the critical path (task 1004928) ----------
129
+ // The hook used to block on `git fetch` (4 s timeout). It now judges against the local
130
+ // origin/main, fetches in the background when that view is old, and says how old it is.
131
+
132
+ const NOW = Date.parse('2026-10-28T12:00:00Z');
133
+ const MIN = 60000;
134
+
135
+ test('planFetch: a view fetched under 10 min ago is used as-is, no fetch', () => {
136
+ const p = planFetch({ lastFetchMs: NOW - 9 * MIN, nowMs: NOW });
137
+ assert.equal(p.background, false);
138
+ assert.equal(p.ageMs, 9 * MIN);
139
+ });
140
+
141
+ test('planFetch: an older view starts a background fetch', () => {
142
+ assert.equal(planFetch({ lastFetchMs: NOW - FETCH_FRESH_MS, nowMs: NOW }).background, true);
143
+ });
144
+
145
+ test('planFetch: never fetched (null) → background fetch, age unknown', () => {
146
+ const p = planFetch({ lastFetchMs: null, nowMs: NOW });
147
+ assert.equal(p.background, true);
148
+ assert.equal(p.ageMs, null);
149
+ });
150
+
151
+ test('viewNote: the behind-count names the age of its origin/main', () => {
152
+ assert.equal(viewNote({ ageMs: 4 * MIN, background: false }), 'as fetched 4m ago');
153
+ assert.equal(viewNote({ ageMs: 3 * 60 * MIN, background: true }), 'as fetched 3h ago; a fresh fetch is running in the background');
154
+ assert.match(viewNote({ ageMs: null, background: true }), /time unknown/);
155
+ });
156
+
157
+ test('staleViewLine: silent when the view is fresh or merely minutes old', () => {
158
+ assert.equal(staleViewLine({ ageMs: 2 * MIN, background: false }), null);
159
+ assert.equal(staleViewLine({ ageMs: 20 * MIN, background: true }), null);
160
+ });
161
+
162
+ test('staleViewLine: an old or unknown view says "up to date" is unconfirmed instead of staying silent', () => {
163
+ assert.match(staleViewLine({ ageMs: FETCH_OLD_NOTE_MS, background: true }), /last fetched 1h ago.*unconfirmed/);
164
+ assert.match(staleViewLine({ ageMs: null, background: true }), /never been fetched/);
165
+ });
166
+
167
+ test('lastMainFetchMs: the newest FETCH_HEAD across worktrees that recorded main wins', () => {
168
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ss-fetch-'));
169
+ try {
170
+ fs.mkdirSync(path.join(dir, 'worktrees', 'a'), { recursive: true });
171
+ fs.mkdirSync(path.join(dir, 'worktrees', 'b'), { recursive: true });
172
+ const main = "abc\t\tbranch 'main' of https://example.invalid/repo\n";
173
+ const other = "def\t\tbranch 'feature' of https://example.invalid/repo\n";
174
+ const put = (rel, body, t) => { const f = path.join(dir, rel); fs.writeFileSync(f, body); fs.utimesSync(f, t / 1000, t / 1000); };
175
+ put('FETCH_HEAD', main, NOW - 60 * MIN);
176
+ put(path.join('worktrees', 'a', 'FETCH_HEAD'), main, NOW - 5 * MIN);
177
+ put(path.join('worktrees', 'b', 'FETCH_HEAD'), other, NOW - 1 * MIN); // newest, but not main
178
+ assert.equal(Math.round(lastMainFetchMs(dir) / 1000), Math.round((NOW - 5 * MIN) / 1000));
179
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
180
+ });
181
+
182
+ test('lastMainFetchMs: no FETCH_HEAD anywhere → null (never fetched)', () => {
183
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ss-fetch-'));
184
+ try { assert.equal(lastMainFetchMs(dir), null); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
185
+ assert.equal(lastMainFetchMs(null), null);
186
+ });
187
+
123
188
  console.log(`\n${passed} passed, ${failed} failed`);
124
189
  process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,209 @@
1
+ // tests/shell_hooks.mjs — the per-shell-call hooks run in ONE node process
2
+ // (.claude/hooks/shell-hooks.js, task 1004931) and conclude exactly what the separate
3
+ // processes did. Every case SPAWNS the dispatcher with a real payload (the contract
4
+ // Claude Code sees: exit code, stdout JSON, stderr) and, where it matters, spawns the
5
+ // same hooks ALONE and compares — so a drift between a hook's run() and its own main()
6
+ // reds here.
7
+ //
8
+ // .claude/hooks/ is package-excluded, so a clean core checkout skips this file
9
+ // (tests/host_topology_skips.mjs carries the allowlist entry).
10
+ //
11
+ // Run: node tests/shell_hooks.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { requireHostModule } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const dispatcher = requireHostModule(require, '../.claude/hooks/shell-hooks.js');
25
+ const { plainCardLines } = require('../scripts/bongos/plain-cards.js');
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ function t(name, fn) {
30
+ try { fn(); passed++; console.log(` PASS ${name}`); }
31
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+
34
+ const HOOKS = path.join(ROOT, '.claude', 'hooks');
35
+ const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-hooks-'));
36
+ const ENV = {
37
+ ...process.env, OTB_STUCK_DIR: DIR, CLAUDE_PROJECT_DIR: ROOT,
38
+ BONGOS_STUCK_DETECTOR_OFF: '', BONGOS_GIT_GUARD_OFF: '', BONGOS_SECRET_GATE_OFF: '', OTB_SECRET_GATE_OFF: '',
39
+ BONGOS_ALLOW_RULE_GUARD_OFF: '', OTB_SESSION_CARDS_OFF: '', OTB_SUBAGENT: '',
40
+ };
41
+ const PRE = ['secret-gate.js', 'git-guard.js', 'allow-rule-guard.js'];
42
+ const POST = ['lifecycle-card.js', 'stuck-detector.js'];
43
+
44
+ function spawn(file, args, input, env = {}) {
45
+ const r = spawnSync(process.execPath, [path.join(HOOKS, file), ...args], {
46
+ input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 15000, env: { ...ENV, ...env },
47
+ });
48
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
49
+ }
50
+ const together = (names, payload, env) => spawn('shell-hooks.js', names, payload, env);
51
+ const alone = (name, payload, env) => spawn(name, [], payload, env);
52
+ const out = (r) => (r.stdout.trim() ? JSON.parse(r.stdout).hookSpecificOutput : null);
53
+ // A credential-shaped string built at runtime, so this file never holds one literally.
54
+ const FAKE_KEY = ['gh', 'p_'].join('') + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
55
+ const shell = (tool, command, extra = {}) => ({ session_id: `s-${Math.random()}`, tool_name: tool, tool_input: { command }, ...extra });
56
+
57
+ // ---- PreToolUse -----------------------------------------------------------
58
+ t('a clean command: exit 0, nothing on stdout or stderr — same as each hook alone', () => {
59
+ for (const tool of ['Bash', 'PowerShell']) {
60
+ const p = shell(tool, 'ls -la');
61
+ const r = together(PRE, p);
62
+ assert.deepEqual(r, { status: 0, stdout: '', stderr: '' }, tool);
63
+ for (const h of PRE) assert.equal(alone(h, p).status, 0, `${h} alone`);
64
+ }
65
+ });
66
+
67
+ t('git-guard blocks through the dispatcher with the exact stderr it prints alone', () => {
68
+ const p = shell('Bash', 'git stash push -m x');
69
+ const a = alone('git-guard.js', { ...p, session_id: 's-gg-alone' });
70
+ const r = together(PRE, { ...p, session_id: 's-gg-together' });
71
+ assert.equal(a.status, 2);
72
+ assert.equal(r.status, 2, 'a block is still exit 2');
73
+ assert.equal(r.stderr, a.stderr, 'the block reason is the one git-guard gives on its own');
74
+ assert.equal(r.stdout, '');
75
+ });
76
+
77
+ t('secret-gate blocks a credential in a shell command through the dispatcher', () => {
78
+ const p = shell('PowerShell', `echo ${FAKE_KEY}`);
79
+ const a = alone('secret-gate.js', p);
80
+ const r = together(PRE, p);
81
+ assert.equal(a.status, 2);
82
+ assert.equal(r.status, 2);
83
+ assert.equal(r.stderr, a.stderr);
84
+ });
85
+
86
+ t('two hooks blocking at once: exit 2 and BOTH reasons, never a JSON answer beside them', () => {
87
+ const p = shell('PowerShell', `git stash push -m ${FAKE_KEY}`);
88
+ const r = together(PRE, p);
89
+ assert.equal(r.status, 2);
90
+ assert.match(r.stderr, /secret-gate: blocked/);
91
+ assert.match(r.stderr, /git-guard: blocked/);
92
+ assert.equal(r.stdout, '', 'a block outranks the allow-rule-guard ask');
93
+ });
94
+
95
+ t('allow-rule-guard asks on PowerShell through the dispatcher, with its own JSON', () => {
96
+ const p = shell('PowerShell', 'git status $(Remove-Item x)');
97
+ const a = alone('allow-rule-guard.js', p);
98
+ const r = together(PRE, p);
99
+ assert.equal(r.status, 0);
100
+ assert.deepEqual(out(r), out(a));
101
+ assert.equal(out(r).permissionDecision, 'ask');
102
+ assert.equal(together(PRE, shell('Bash', 'git status $(rm x)')).stdout, '', 'Bash is not its tool');
103
+ });
104
+
105
+ t('kill switches still work through the dispatcher', () => {
106
+ assert.equal(together(PRE, shell('Bash', 'git stash push -m x'), { BONGOS_GIT_GUARD_OFF: '1' }).status, 0);
107
+ assert.equal(together(PRE, shell('Bash', `echo ${FAKE_KEY}`), { BONGOS_SECRET_GATE_OFF: '1' }).status, 0);
108
+ assert.equal(together(PRE, shell('PowerShell', 'git status $(x)'), { BONGOS_ALLOW_RULE_GUARD_OFF: '1' }).stdout, '');
109
+ });
110
+
111
+ t('the third git-guard block in a session still carries the stuck line (the shared counter runs)', () => {
112
+ const p = { ...shell('Bash', 'git branch -D x'), session_id: 's-third' };
113
+ together(PRE, p); together(PRE, p);
114
+ assert.match(together(PRE, p).stderr, /\[otb-stuck\]/);
115
+ });
116
+
117
+ // ---- PostToolUse ----------------------------------------------------------
118
+ t('stuck-detector context comes through the dispatcher on the third wall, as alone', () => {
119
+ const card = plainCardLines('TASK_NOT_READY', {}).join('\n');
120
+ const mk = (s) => ({ ...shell('Bash', 'node scripts/bongos/claim.js 9'), session_id: s, hook_event_name: 'PostToolUse', tool_response: { stdout: card, stderr: '' } });
121
+ for (let i = 0; i < 2; i++) { alone('stuck-detector.js', mk('s-post-alone')); together(POST, mk('s-post-together')); }
122
+ const a = alone('stuck-detector.js', mk('s-post-alone'));
123
+ const r = together(POST, mk('s-post-together'));
124
+ assert.equal(r.status, 0);
125
+ assert.deepEqual(out(r), out(a));
126
+ assert.match(out(r).additionalContext, /\[otb-stuck\]/);
127
+ });
128
+
129
+ t('lifecycle-card context comes through the dispatcher, as alone', () => {
130
+ const cardPath = path.join(DIR, 'otb-ship-card-77.html');
131
+ fs.writeFileSync(cardPath, '<div>card</div>');
132
+ const p = { ...shell('Bash', 'node scripts/bongos/claim.js 77'), hook_event_name: 'PostToolUse', tool_response: { stdout: `[otb-card-html] ${cardPath}\n`, stderr: '' } };
133
+ const a = alone('lifecycle-card.js', p);
134
+ const r = together(POST, p);
135
+ assert.ok(out(a), 'fixture: lifecycle-card alone injects a card');
136
+ assert.deepEqual(out(r), out(a));
137
+ assert.equal(together(POST, p, { OTB_SESSION_CARDS_OFF: '1' }).stdout, '', 'its kill switch holds');
138
+ });
139
+
140
+ t('two context producers in one call are merged into one JSON, both texts kept', () => {
141
+ const merged = dispatcher.merge([
142
+ { code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'one' } }) },
143
+ { code: 0, stdout: '' },
144
+ { code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'two' } }) },
145
+ ]);
146
+ assert.equal(merged.code, 0);
147
+ assert.deepEqual(JSON.parse(merged.stdout).hookSpecificOutput, { hookEventName: 'PostToolUse', additionalContext: 'one\n\ntwo' });
148
+ });
149
+
150
+ t('the most restrictive permission decision wins, with its own reason', () => {
151
+ const ans = (d, why) => ({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: d, permissionDecisionReason: why } }) });
152
+ const h = JSON.parse(dispatcher.merge([ans('ask', 'a'), ans('deny', 'd'), ans('allow', 'x')]).stdout).hookSpecificOutput;
153
+ assert.equal(h.permissionDecision, 'deny');
154
+ assert.equal(h.permissionDecisionReason, 'd');
155
+ });
156
+
157
+ // ---- failure modes --------------------------------------------------------
158
+ t('unparseable stdin: every hook fails open — exit 0, silent', () => {
159
+ assert.deepEqual(together([...PRE, ...POST], 'not json'), { status: 0, stdout: '', stderr: '' });
160
+ });
161
+
162
+ t('a missing hook is visible (exit 1, one line) and never blocks; the others still run', () => {
163
+ const r = together(['no-such-hook.js', 'git-guard.js'], shell('Bash', 'ls'));
164
+ assert.equal(r.status, 1);
165
+ assert.match(r.stderr, /no-such-hook\.js/);
166
+ const b = together(['no-such-hook.js', 'git-guard.js'], shell('Bash', 'git stash push -m x'));
167
+ assert.equal(b.status, 2, 'a real block still blocks beside a missing hook');
168
+ });
169
+
170
+ t('only plain sibling names load — no paths, not itself', () => {
171
+ for (const bad of ['../hooks/git-guard.js', 'sub/x.js', 'shell-hooks.js', 'git-guard', '']) {
172
+ assert.equal(dispatcher.loadHook(bad), null, bad);
173
+ }
174
+ assert.equal(typeof dispatcher.loadHook('git-guard.js'), 'function');
175
+ });
176
+
177
+ t('a hook that throws fails open; the rest of the roster still answers', () => {
178
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-hooks-throw-'));
179
+ fs.writeFileSync(path.join(tmp, 'boom.js'), 'module.exports = { run() { throw new Error("x"); } };');
180
+ fs.writeFileSync(path.join(tmp, 'ctx.js'), 'module.exports = { run: () => ({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: "kept" } }) }) };');
181
+ const r = dispatcher.dispatch(['boom.js', 'ctx.js'], '{}', process.env, tmp);
182
+ assert.equal(r.code, 0);
183
+ assert.equal(JSON.parse(r.stdout).hookSpecificOutput.additionalContext, 'kept');
184
+ fs.rmSync(tmp, { recursive: true, force: true });
185
+ });
186
+
187
+ // ---- wiring ---------------------------------------------------------------
188
+ t('settings.json: one process per shell call on each side, rostered by file name, through run-node.sh', () => {
189
+ const settings = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'));
190
+ const shellEntries = (event) => (settings.hooks[event] || [])
191
+ .filter((g) => (g.matcher || '').split('|').includes('Bash'))
192
+ .flatMap((g) => g.hooks.map((h) => h.command));
193
+ const pre = shellEntries('PreToolUse');
194
+ const post = shellEntries('PostToolUse');
195
+ assert.equal(pre.length, 1, `PreToolUse spawns ${pre.length} processes per Bash call`);
196
+ assert.equal(post.length, 1, `PostToolUse spawns ${post.length} processes per Bash call`);
197
+ for (const [cmd, roster] of [[pre[0], PRE], [post[0], POST]]) {
198
+ assert.match(cmd, /run-node\.sh/);
199
+ assert.match(cmd, /shell-hooks\.js/);
200
+ for (const h of roster) assert.ok(cmd.includes(` ${h}`), `${h} missing from "${cmd}"`);
201
+ }
202
+ for (const h of [...PRE, ...POST]) {
203
+ assert.equal(typeof dispatcher.loadHook(h), 'function', `${h} exports run()`);
204
+ }
205
+ });
206
+
207
+ fs.rmSync(DIR, { recursive: true, force: true });
208
+ console.log(`\nshell_hooks: ${passed} passed, ${failed} failed`);
209
+ process.exitCode = failed ? 1 : 0;
@@ -385,5 +385,33 @@ await t('the gate sends the cause as the error MESSAGE, which is what the CLI pr
385
385
  assert.match(src, /res\.fail\(code, \{ status, message, details \}\)/);
386
386
  });
387
387
 
388
+ // Task 1004879: the audit row used method 'SHIP', which audit_log's CHECK
389
+ // refuses, so every write failed into a warning. Read the allowed list from the
390
+ // migration itself, so a future verb change is checked against the real rule.
391
+ await t('the ship-assurance audit row uses a method audit_log accepts', async () => {
392
+ const { recordShipAssurance } = require(path.join(ROOT, 'modules/lifecycle/ship-assurance.js'));
393
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations/033_audit_log.sql'), 'utf8');
394
+ const m = sql.match(/method\s+text NOT NULL CHECK \(method IN \(([^)]*)\)\)/);
395
+ assert.ok(m, 'migration 033 still declares the method CHECK');
396
+ const allowed = m[1].split(',').map((s) => s.trim().replace(/'/g, ''));
397
+ assert.ok(allowed.length >= 4, `parsed the allowed list: ${allowed}`);
398
+ const rows = [];
399
+ const warns = [];
400
+ await recordShipAssurance({
401
+ insertAuditLog: async (row) => {
402
+ if (!allowed.includes(row.method)) throw new Error('violates check constraint "audit_log_method_check"');
403
+ rows.push(row);
404
+ },
405
+ taskId: 7,
406
+ builderId: 1,
407
+ assurance: { kind: 'proof', sha: TIP },
408
+ log: { warn: (...a) => warns.push(a.join(' ')) },
409
+ });
410
+ assert.deepEqual(warns, []);
411
+ assert.equal(rows.length, 1);
412
+ assert.equal(rows[0].route, '/tasks/7/ship');
413
+ assert.deepEqual(rows[0].requestBodyRedacted, { ship_assurance: { kind: 'proof', sha: TIP } });
414
+ });
415
+
388
416
  console.log(`\n${passed} passed, ${failed} failed`);
389
417
  process.exit(failed ? 1 : 0);
@@ -54,6 +54,10 @@ test('fmtTokens / fmtUsd / fmtMins', () => {
54
54
  });
55
55
 
56
56
  // ---- shipped ----
57
+ // task 1004741: shipped means MERGED. With no reading of the live build the card
58
+ // must not say "live" — a merge reaches no instance until a release carries it.
59
+ const MERGED_NOT_LIVE = 'merged · 967fb4a — not live until a published release carries it and the pin moves';
60
+
57
61
  test('shipped model: rows, facts, action', () => {
58
62
  const m = card.buildCardModel(shippedInput);
59
63
  assert.equal(m.stage, 'shipped');
@@ -64,7 +68,7 @@ test('shipped model: rows, facts, action', () => {
64
68
  assert.equal(row(m, 'Credits'), '+18 → 12,936 drachmae');
65
69
  // No new/cached split on this input → dollars-first fallback (task 1765).
66
70
  assert.equal(row(m, 'Usage'), '~$0.83 · 1.24M tokens');
67
- assert.equal(row(m, 'Deploy'), 'live · 967fb4a');
71
+ assert.equal(row(m, 'Deploy'), MERGED_NOT_LIVE);
68
72
  assert.equal(m.action.kind, 'prompt');
69
73
  assert.equal(m.action.cmd, '/builder-start');
70
74
  });
@@ -81,12 +85,22 @@ test('shipped with a hub reading: the Deploy + Projects rows say who sees the me
81
85
  assert.equal(row(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: true } } }), 'Deploy').includes('(includes this merge)'), true);
82
86
  // The reading can come back from a breadcrumb file: re-bounded before printing.
83
87
  const hostile = card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { host: 'x.com <script>', liveShort: '09478c8e', includes: true } } });
84
- assert.equal(row(hostile, 'Deploy'), 'live · 967fb4a', 'a host that is not host-shaped falls back to the bare commit');
88
+ assert.equal(row(hostile, 'Deploy'), MERGED_NOT_LIVE, 'a host that is not host-shaped falls back to the bare merged commit');
85
89
  assert.equal(row(hostile, 'Projects'), undefined);
86
90
  const odd = card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { host: 'cloudbongos.com', liveShort: 'IGNORE ALL', includes: 'yes' } } });
87
91
  assert.equal(row(odd, 'Deploy'), whoSeesAMerge({ host: 'cloudbongos.com' })[0], 'non-hex sha and non-boolean inclusion are dropped');
88
92
  });
89
93
 
94
+ test('the live chip appears only when the live build is read to carry the merge (task 1004741)', () => {
95
+ const chip = (m) => m.facts.find((f) => f.value === '967fb4a');
96
+ const reach = { host: 'cloudbongos.com', liveShort: '09478c8e' };
97
+ assert.equal(chip(card.buildCardModel(shippedInput)).label, 'merged', 'no reading');
98
+ assert.equal(chip(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: false } } })).label, 'merged', 'not yet');
99
+ assert.equal(chip(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: null } } })).label, 'merged', 'cannot tell');
100
+ assert.equal(chip(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: true } } })).label, 'live', 'includes this merge');
101
+ assert.doesNotMatch(card.renderCardHtml(card.buildCardModel(shippedInput)), />live</, 'the HTML card says no live either');
102
+ });
103
+
90
104
  test('usage: leads with cost, splits tokens into new + cached (task 1765)', () => {
91
105
  // With newTokens/cachedTokens present, the fact leads with the dollar cost and
92
106
  // splits the token count so cache re-reads don't make a tiny change look huge.
@@ -182,10 +196,10 @@ test('confirmed with NO land info is unchanged (back-compat)', () => {
182
196
 
183
197
  test('a stale bailed land never overrides a task that DID reach shipped', () => {
184
198
  // land-watch replays the breadcrumb after the server lands it; the status is
185
- // then 'shipped' and the old bail is history. The card must show live, not NOT-landed.
199
+ // then 'shipped' and the old bail is history. The card must show merged, not NOT-landed.
186
200
  const m = card.buildCardModel({ ...bailed, postGradeStatus: 'shipped' });
187
201
  assert.equal(m.stage, 'shipped');
188
- assert.equal(row(m, 'Deploy'), 'live · 967fb4a');
202
+ assert.equal(row(m, 'Deploy'), MERGED_NOT_LIVE);
189
203
  assert.equal(m.cfg.tone, 'success');
190
204
  });
191
205
 
@@ -460,7 +474,8 @@ test('confirmLine: every stage yields one line naming the stage and the task', (
460
474
  test('confirmLine: a shipped line carries the facts the owner asks for ("did it land?")', () => {
461
475
  const line = card.confirmLine(card.buildCardModel(shippedInput));
462
476
  assert.match(line, /^shipped: task 1266/, 'leads with the outcome, not chrome');
463
- assert.match(line, /deploy live/, 'says it actually deployed — the commit rides in this row');
477
+ assert.match(line, /deploy merged · 967fb4a/, 'says it merged — the commit rides in this row');
478
+ assert.doesNotMatch(line, /\blive ·/, 'never claims live without a reading of the live build (task 1004741)');
464
479
  assert.match(line, /credits/, 'carries the credit outcome');
465
480
  });
466
481
 
@@ -206,5 +206,53 @@ await t('both status queues are fetched CONCURRENTLY, not serially', async () =>
206
206
  assert.equal(maxInFlight, sw.WATCHED.length, `expected ${sw.WATCHED.length} concurrent fetches, saw ${maxInFlight}`);
207
207
  });
208
208
 
209
+ // ---- the SessionStart line stays off the critical path (task 1004928) --------
210
+ // The audit timed --line at 8.2 s, the floor for every session start. The answer is now kept
211
+ // 15 min, served with its age up to 2 h while a detached child refreshes it, and a cold read is
212
+ // bounded as a whole.
213
+
214
+ const M = 60000;
215
+ await t('linePlan: a record under 15 min is fresh — printed with no ledger read', () => {
216
+ assert.equal(sw.linePlan({ at: NOW - 14 * M, value: 'x' }, NOW), 'fresh');
217
+ assert.equal(sw.linePlan({ at: NOW - 1, value: null }, NOW), 'fresh', 'a cached clean board is an answer too');
218
+ });
219
+
220
+ await t('linePlan: 15 min to 2 h is stale — printed with its age, refreshed in the background', () => {
221
+ assert.equal(sw.linePlan({ at: NOW - sw.LINE_FRESH_MS, value: 'x' }, NOW), 'stale');
222
+ assert.equal(sw.linePlan({ at: NOW - 119 * M, value: 'x' }, NOW), 'stale');
223
+ });
224
+
225
+ await t('linePlan: past 2 h, missing, malformed or from the future is a miss — read the ledger', () => {
226
+ assert.equal(sw.linePlan({ at: NOW - sw.LINE_STALE_MAX_MS, value: 'x' }, NOW), 'miss');
227
+ assert.equal(sw.linePlan(null, NOW), 'miss');
228
+ assert.equal(sw.linePlan({ value: 'x' }, NOW), 'miss');
229
+ assert.equal(sw.linePlan({ at: NOW + 5 * M, value: 'x' }, NOW), 'miss', 'a clock that moved must not freeze an answer forever');
230
+ });
231
+
232
+ await t('labelStale: a stale strand line says how old it is; a stale clean board stays silent', () => {
233
+ const out = sw.labelStale('Stranded ship: task 1002459 (confirmed, 4d).', 40 * M);
234
+ assert.match(out, /^Stranded ship: task 1002459/);
235
+ assert.match(out, /Checked 40m ago; refreshing for the next session\.\)$/);
236
+ assert.equal(sw.labelStale(null, 40 * M), null);
237
+ });
238
+
239
+ await t('produceLine: returns the line from a real read', async () => {
240
+ const call = async (_m, url) => ({ tasks: url.includes('confirmed')
241
+ ? [{ id: '1002459', status: 'confirmed', updated_at: ago(4 * D), needs_rebase: true, needs_rebase_reason: 'strand:land_not_proven' }] : [] });
242
+ const out = await sw.produceLine({ nowMs: NOW, call }, 1000);
243
+ assert.match(out, /task 1002459/);
244
+ });
245
+
246
+ await t('produceLine: an undetermined read THROWS, so a silence is never cached as a clean board', async () => {
247
+ await assert.rejects(sw.produceLine({ nowMs: NOW, call: async () => { throw new Error('ECONNREFUSED'); } }, 1000));
248
+ });
249
+
250
+ await t('produceLine: the deadline bounds the WHOLE read, not each call', async () => {
251
+ // Every call hangs; check() alone would wait timeoutMs per stage. The deadline must cut it.
252
+ const started = Date.now();
253
+ await assert.rejects(sw.produceLine({ nowMs: NOW, call: () => new Promise(() => {}) }, 150));
254
+ assert.ok(Date.now() - started < 1500, `took ${Date.now() - started}ms`);
255
+ });
256
+
209
257
  console.log(`\nstrand_watch: ${passed} passed, ${failed} failed`);
210
258
  process.exit(failed ? 1 : 0);
@@ -166,7 +166,7 @@ function closePool({ status = 'building', goalStatus = 'open', remaining = 0, st
166
166
  return txPool((sql) => {
167
167
  const q = String(sql).replace(/\s+/g, ' ');
168
168
  if (/FROM versions WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 'V1', status }] };
169
- if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'g', status: goalStatus }] };
169
+ if (/FROM goals WHERE id = ANY\(\$1::bigint\[\]\) AND version_id = \$2 ORDER BY id FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'g', status: goalStatus }] };
170
170
  if (/UPDATE tasks SET status = 'abandoned'/.test(q)) return { rows: [{ id: 11 }, { id: 12 }] };
171
171
  // R17 (task 1003604) — the roll_forward writes: the successor INSERT, the
172
172
  // membership copy, and the task re-point.