@bongos/core 1.23.21 → 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.
- package/.bongos-core.json +117 -57
- package/.claude/skills/goal-create/SKILL.md +5 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
- package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
- package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
- package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
- package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
- package/docs/adr/README.md +5 -0
- package/docs/api/openapi.json +59 -4
- package/docs/api-reference.md +7 -1
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +1 -1
- package/modules/ai-studio/CLAUDE.md +15 -0
- package/modules/ai-studio/module.json +26 -0
- package/modules/ai-studio/routes/ai-studio.js +22 -0
- package/modules/government/db.js +74 -0
- package/modules/government/routes/government.js +28 -18
- package/modules/grading/grader-subagent.js +30 -11
- package/modules/hall-ui/public/city.html +15 -15
- package/modules/lifecycle/db-analytics.js +10 -3
- package/modules/lifecycle/db-goals.js +30 -13
- package/modules/lifecycle/db-shared.js +9 -0
- package/modules/lifecycle/db-versions.js +21 -8
- package/modules/lifecycle/ship-assurance.js +4 -1
- package/modules/lifecycle/ship-card.js +8 -2
- package/modules/onboarding/howto-moments.js +8 -1
- package/modules/provisioning/starter-bundles.js +4 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +66 -0
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/strand-watch.js +106 -10
- package/src/bongos/module-scope-map.js +4 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +27 -1
- package/tests/batched_writes_parity.mjs +340 -0
- package/tests/conductor_main_e2e.mjs +4 -0
- package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
- package/tests/goal_archive_disposition.mjs +5 -5
- package/tests/government_routes.mjs +145 -22
- package/tests/grader.mjs +31 -0
- package/tests/hall_city.mjs +8 -0
- package/tests/hook_shell_tools.mjs +8 -0
- package/tests/hooks_drain.mjs +1 -1
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/howto_moments.mjs +17 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/module_skills_hook.mjs +69 -0
- package/tests/public_landing_projects.mjs +37 -0
- package/tests/session_start_freshness.mjs +66 -1
- package/tests/shell_hooks.mjs +209 -0
- package/tests/ship_assurance.mjs +28 -0
- package/tests/ship_card.mjs +20 -5
- package/tests/strand_watch.mjs +48 -0
- 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);
|
|
@@ -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 {
|
|
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;
|
package/tests/ship_assurance.mjs
CHANGED
|
@@ -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);
|
package/tests/ship_card.mjs
CHANGED
|
@@ -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'),
|
|
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'),
|
|
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
|
|
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'),
|
|
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
|
|
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
|
|
package/tests/strand_watch.mjs
CHANGED
|
@@ -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.
|