@bongos/core 1.21.69 → 1.21.71

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 (68) hide show
  1. package/.bongos-core.json +119 -64
  2. package/.claude/skills/bongos-feedback/SKILL.md +10 -46
  3. package/.claude/skills/goal-create/SKILL.md +21 -3
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +8 -2
  6. package/clients/bongos-client/index.cjs +8 -2
  7. package/clients/bongos-client/index.d.ts +15 -6
  8. package/clients/bongos-client/index.mjs +8 -2
  9. package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +196 -19
  12. package/docs/api-reference.md +9 -6
  13. package/docs/copy-inventory.md +25 -21
  14. package/docs/copy-registry.json +76 -30
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/docs/page-readings.json +8 -8
  18. package/docs/recipes/core-release-pipeline.md +6 -0
  19. package/docs/recipes/upgrading-the-core.md +2 -0
  20. package/modules/hall-ui/public/settings-software-update.js +77 -2
  21. package/modules/hall-ui/public/settings.html +1 -1
  22. package/modules/lifecycle/db-tasks.js +12 -0
  23. package/modules/lifecycle/goal-spec-task.js +129 -0
  24. package/modules/lifecycle/routes/goals.js +7 -9
  25. package/modules/lifecycle/routes/task-write-routes.js +11 -1
  26. package/modules/lifecycle/upkeep-home.js +55 -0
  27. package/modules/platform-identity/instance-feedback.js +108 -7
  28. package/modules/platform-identity/routes/sso.js +71 -0
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +42 -0
  32. package/scripts/gds/claude-materialize.js +125 -31
  33. package/scripts/gds/claude-md-template.js +47 -10
  34. package/scripts/gds/cli-lib.js +4 -4
  35. package/scripts/gds/feedback-send.js +146 -73
  36. package/scripts/gds/paste-token.js +1 -1
  37. package/scripts/gds/provision-net.js +1 -8
  38. package/scripts/gds/provision-units.js +28 -2
  39. package/scripts/gds/reauth.js +1 -1
  40. package/scripts/gds/run-unit-tests.js +3 -0
  41. package/scripts/gds/setup.js +1 -1
  42. package/scripts/gds/start.js +4 -4
  43. package/scripts/gds/surface.js +15 -3
  44. package/scripts/gds/task.js +32 -5
  45. package/scripts/gds/upgrade-operator.js +73 -0
  46. package/scripts/gds/upgrade.js +7 -2
  47. package/scripts/hall-preview/server.js +3 -0
  48. package/src/bongos/platform-visibility-gate.js +3 -0
  49. package/src/bongos/routes/core-update.js +98 -4
  50. package/src/bongos/routes/feedback.js +100 -76
  51. package/src/bongos/upgrade-request.js +158 -0
  52. package/src/module-api.js +1 -1
  53. package/tests/claude_materialize.mjs +195 -18
  54. package/tests/cli_allowlist_permissions.mjs +6 -2
  55. package/tests/cli_surface.mjs +2 -2
  56. package/tests/fixtures/page-ledger.mjs +5 -0
  57. package/tests/goal_admission_override.mjs +1 -1
  58. package/tests/goal_category.mjs +1 -1
  59. package/tests/goal_create_spec_entry.mjs +61 -0
  60. package/tests/goal_spec_task.mjs +197 -0
  61. package/tests/goal_spec_task_db.mjs +95 -0
  62. package/tests/instance_feedback.mjs +267 -68
  63. package/tests/provision_settings_apply.mjs +25 -1
  64. package/tests/software_update.mjs +18 -1
  65. package/tests/start_env_guard.mjs +2 -2
  66. package/tests/upgrade_operator.mjs +105 -0
  67. package/tests/upgrade_request.mjs +348 -0
  68. package/tests/upkeep_home.mjs +156 -0
@@ -41,24 +41,24 @@ function t(name, fn) {
41
41
 
42
42
  // ---- bongosify -------------------------------------------------------------
43
43
  t('bongosify: a script WITH a dedicated verb → bongos <verb> (args preserved)', () => {
44
- assert.equal(m.bongosify('run `node scripts/gds/ship.js 1885 --force`'), 'run `bongos ship 1885 --force`');
45
- assert.equal(m.bongosify('node scripts/gds/start.js --json'), 'bongos start --json');
44
+ assert.equal(m.bongosify('run `node scripts/gds/ship.js 1885 --force`'), 'run `npx bongos ship 1885 --force`');
45
+ assert.equal(m.bongosify('node scripts/gds/start.js --json'), 'npx bongos start --json');
46
46
  });
47
47
 
48
48
  t('bongosify: a script WITHOUT a dedicated verb → bongos exec (args preserved)', () => {
49
- assert.equal(m.bongosify('node scripts/gds/api.js GET /api/gds/me'), 'bongos exec scripts/gds/api.js GET /api/gds/me');
50
- assert.equal(m.bongosify('node scripts/gds/capture.js "x"'), 'bongos exec scripts/gds/capture.js "x"');
49
+ assert.equal(m.bongosify('node scripts/gds/api.js GET /api/gds/me'), 'npx bongos exec scripts/gds/api.js GET /api/gds/me');
50
+ assert.equal(m.bongosify('node scripts/gds/capture.js "x"'), 'npx bongos exec scripts/gds/capture.js "x"');
51
51
  });
52
52
 
53
53
  t('bongosify: $CLAUDE_PROJECT_DIR + surrounding quotes are stripped', () => {
54
- assert.equal(m.bongosify('node "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"'), 'bongos exec scripts/gds/session-cost.js');
54
+ assert.equal(m.bongosify('node "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"'), 'npx bongos exec scripts/gds/session-cost.js');
55
55
  });
56
56
 
57
57
  t('bongosify: the run-node.sh resolver form (task 1003548) collapses to the same bongos form', () => {
58
58
  const wrap = 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/run-node.sh"';
59
- assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"`), 'bongos exec scripts/gds/session-cost.js');
60
- assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/deploy-divergence.js" --line --timeout-ms 2500`), 'bongos exec scripts/gds/deploy-divergence.js --line --timeout-ms 2500');
61
- assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/ship.js" 1885`), 'bongos ship 1885');
59
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"`), 'npx bongos exec scripts/gds/session-cost.js');
60
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/deploy-divergence.js" --line --timeout-ms 2500`), 'npx bongos exec scripts/gds/deploy-divergence.js --line --timeout-ms 2500');
61
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/ship.js" 1885`), 'npx bongos ship 1885');
62
62
  // …and a resolver call whose target is a .claude/hooks/*.js stays instance-local, like the bare-node form.
63
63
  const hook = `${wrap} "$CLAUDE_PROJECT_DIR/.claude/hooks/secret-gate.js"`;
64
64
  assert.equal(m.bongosify(hook), hook);
@@ -100,7 +100,7 @@ t('transformSettings: rewrites scripts/gds hook commands, keeps .claude/hooks/ o
100
100
  const out = JSON.parse(m.transformSettings(p));
101
101
  const cmds = out.hooks.Stop[0].hooks.map((h) => h.command);
102
102
  assert.deepEqual(cmds, [
103
- 'bongos exec scripts/gds/session-cost.js',
103
+ 'npx bongos exec scripts/gds/session-cost.js',
104
104
  'node "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-sound.js"',
105
105
  ]);
106
106
  rmSync(dir, { recursive: true, force: true });
@@ -121,12 +121,12 @@ t('transformSettings: permission rules are rewritten to the form the instance\'s
121
121
  },
122
122
  }, null, 2));
123
123
  const out = JSON.parse(m.transformSettings(p)).permissions;
124
- assert.deepEqual(out.allow, ['Bash(bongos ship:*)', 'Bash(bongos exec scripts/gds/me.js:*)', 'Bash(git status:*)'],
124
+ assert.deepEqual(out.allow, ['Bash(npx bongos ship:*)', 'Bash(npx bongos exec scripts/gds/me.js:*)', 'Bash(git status:*)'],
125
125
  'allow takes the ONE spelling the skills use, and two spellings of a command collapse to one rule');
126
126
  // An instance can reach a script two ways — `bongos release` and `bongos exec scripts/gds/release.js` —
127
127
  // so a restrictive rule that named only one would still LOOK like a guard while guarding half.
128
- assert.deepEqual(out.deny, ['Bash(bongos release:*)', 'Bash(bongos exec scripts/gds/release.js:*)', 'Bash(bongos exec scripts/gds/me.js:*)', 'Read(./.env)']);
129
- assert.deepEqual(out.ask, ['Bash(bongos claim:*)', 'Bash(bongos exec scripts/gds/claim.js:*)']);
128
+ assert.deepEqual(out.deny, ['Bash(npx bongos release:*)', 'Bash(npx bongos exec scripts/gds/release.js:*)', 'Bash(npx bongos exec scripts/gds/me.js:*)', 'Read(./.env)']);
129
+ assert.deepEqual(out.ask, ['Bash(npx bongos claim:*)', 'Bash(npx bongos exec scripts/gds/claim.js:*)']);
130
130
  assert.equal(out.defaultMode, 'acceptEdits', 'a non-list permission key passes through untouched');
131
131
  rmSync(dir, { recursive: true, force: true });
132
132
  });
@@ -222,7 +222,7 @@ t('copyTreeTransformed: overwrite:false LEAVES a pre-existing dest file (reports
222
222
  const res = m.copyTreeTransformed(src, dest, { overwrite: false });
223
223
  assert.equal(res.files, 1, 'only the absent file was written');
224
224
  assert.deepEqual(res.skipped, ['b.md']);
225
- assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'bongos ship 1', 'absent file materialized + rewritten');
225
+ assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'npx bongos ship 1', 'absent file materialized + rewritten');
226
226
  assert.equal(readFileSync(join(dest, 'b.md'), 'utf8'), ownerB, 'pre-existing file left byte-untouched');
227
227
  rmSync(src, { recursive: true, force: true });
228
228
  rmSync(dest, { recursive: true, force: true });
@@ -236,7 +236,7 @@ t('copyTreeTransformed: overwrite defaults true → greenfield still clobbers (r
236
236
  const res = m.copyTreeTransformed(src, dest); // default overwrite:true
237
237
  assert.equal(res.files, 1);
238
238
  assert.deepEqual(res.skipped, []);
239
- assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'bongos start', 'greenfield overwrites as before');
239
+ assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'npx bongos start', 'greenfield overwrites as before');
240
240
  rmSync(src, { recursive: true, force: true });
241
241
  rmSync(dest, { recursive: true, force: true });
242
242
  });
@@ -772,8 +772,8 @@ t('every allow-rule names a verb the CLI actually registers', () => {
772
772
  const checkedByTool = {};
773
773
  for (const rule of m.STARTER_ALLOW) {
774
774
  const [, tool, inner] = rule.match(/^(\w+)\((.*?)(?::\*)?\)$/); // any shell tool (task 1004014)
775
- if (!inner.startsWith('bongos ')) continue; // the git/gh reads (task 1003992) name no verb
776
- const verb = inner.split(' ')[1];
775
+ if (!inner.startsWith('npx bongos ')) continue; // the git/gh reads (task 1003992) name no verb
776
+ const verb = inner.split(' ')[2];
777
777
  assert.ok(cliVerbs[verb], `allow-rule "${rule}" names "${verb}", which is not a registered bongos verb`);
778
778
  checkedByTool[tool] = (checkedByTool[tool] || 0) + 1;
779
779
  }
@@ -784,7 +784,7 @@ t('every allow-rule names a verb the CLI actually registers', () => {
784
784
  t('the escape hatch is NOT blanket-allowed', () => {
785
785
  // `bongos exec` runs any script inside the core by path. Allow-listing it bare
786
786
  // would be a blanket grant wearing a narrow rule's clothes.
787
- assert.deepEqual(m.STARTER_ALLOW.filter((r) => /^\w+\(bongos exec(:\*| \*)?\)$/.test(r)), [],
787
+ assert.deepEqual(m.STARTER_ALLOW.filter((r) => /^\w+\((?:npx )?bongos exec(:\*| \*)?\)$/.test(r)), [],
788
788
  'bongos exec must never be allowed bare — under any shell tool');
789
789
  const execRules = m.STARTER_ALLOW.filter((r) => r.includes('bongos exec'));
790
790
  for (const r of execRules) {
@@ -1065,7 +1065,7 @@ t('a checkout-materialized instance gets allow-rules in the bongos form, end to
1065
1065
  const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
1066
1066
  assert.equal(res.settings, true);
1067
1067
  const allow = JSON.parse(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8')).permissions.allow;
1068
- assert.deepEqual(allow, ['Bash(bongos claim:*)', 'Bash(bongos exec scripts/gds/api.js:*)', 'Bash(gh pr list:*)']);
1068
+ assert.deepEqual(allow, ['Bash(npx bongos claim:*)', 'Bash(npx bongos exec scripts/gds/api.js:*)', 'Bash(gh pr list:*)']);
1069
1069
  for (const rule of allow) assert.ok(m.STARTER_ALLOW.includes(rule), `${rule} is the starter's spelling too — one form, two sources`);
1070
1070
  rmSync(core, { recursive: true, force: true });
1071
1071
  rmSync(inst, { recursive: true, force: true });
@@ -1154,5 +1154,182 @@ t('materializeClaude: a broken core settings.json reaches the instance unchanged
1154
1154
  rmSync(inst, { recursive: true, force: true });
1155
1155
  });
1156
1156
 
1157
+ // ---- task 1004621: a project runs the CLI as `npx bongos`, never a bare `bongos` ---------------
1158
+ // The bin ships at node_modules/.bin/bongos, which is not on PATH, so every bare `bongos …` a
1159
+ // project's assistant read was a guaranteed `command not found` on the session's first call.
1160
+
1161
+ // A bare `bongos <verb>` (not preceded by `npx `) anywhere in text an assistant reads.
1162
+ const BARE_VERB = new RegExp(String.raw`(?<![\w@/.\-])(?<!npx )bongos (${Object.keys(cliVerbs).join('|')})(?![\w-])`);
1163
+
1164
+ t('bongosify: a hand-written bare `bongos <verb>` takes the npx spelling too', () => {
1165
+ assert.equal(m.bongosify('paste `bongos paste-token <token>` back'), 'paste `npx bongos paste-token <token>` back');
1166
+ assert.equal(m.bongosify('run bongos upgrade --to 1.2.3'), 'run npx bongos upgrade --to 1.2.3');
1167
+ assert.equal(m.bongosify('`bongos exec scripts/gds/api.js GET /x`'), '`npx bongos exec scripts/gds/api.js GET /x`');
1168
+ });
1169
+
1170
+ t('bongosify: never double-prefixes, and is idempotent over its own output', () => {
1171
+ for (const s of ['npx bongos ship 1', 'node scripts/gds/ship.js 1', 'bongos ship 1', '`bongos dev` then `node scripts/gds/start.js`']) {
1172
+ const once = m.bongosify(s);
1173
+ assert.ok(!once.includes('npx npx'), `double prefix from: ${s}`);
1174
+ assert.equal(m.bongosify(once), once, `a second run changed: ${s}`);
1175
+ assert.ok(!BARE_VERB.test(once), `a bare bongos verb survived: ${once}`);
1176
+ }
1177
+ });
1178
+
1179
+ t('bongosify: names and prose that merely contain "bongos" are left alone', () => {
1180
+ for (const s of ['`@bongos/core`', '/bongos-feedback', '`bongos: command not found`', 'the Bongos status page',
1181
+ 'the bongos CLI', 'the bongos database', 'src/bongos/routes/x.js', 'GET /api/bongos/me', 'bongos-ship-card']) {
1182
+ assert.equal(m.bongosify(s), s, s);
1183
+ }
1184
+ });
1185
+
1186
+ t('every core skill + routine, as a project receives it, carries no bare `bongos <verb>`', () => {
1187
+ const offenders = [];
1188
+ const walk = (dir) => {
1189
+ if (!existsSync(dir)) return;
1190
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1191
+ const p = join(dir, e.name);
1192
+ if (e.isDirectory()) { walk(p); continue; }
1193
+ if (!/\.(md|txt|sh)$/.test(e.name)) continue;
1194
+ const out = m.bongosify(readFileSync(p, 'utf8'));
1195
+ if (out.includes('npx npx')) offenders.push(`${relative(ROOT, p)}: npx npx`);
1196
+ const hit = out.match(BARE_VERB);
1197
+ if (hit) offenders.push(`${relative(ROOT, p)}: ${hit[0]}`);
1198
+ }
1199
+ };
1200
+ walk(join(ROOT, '.claude', 'skills'));
1201
+ walk(join(ROOT, '.claude', 'scheduled-tasks'));
1202
+ assert.deepEqual(offenders, []);
1203
+ });
1204
+
1205
+ t('the core checkout\'s OWN skills are not rewritten: they still run node scripts/gds/*.js', () => {
1206
+ const ship = readFileSync(join(ROOT, '.claude', 'skills', 'builder-ship', 'SKILL.md'), 'utf8');
1207
+ assert.match(ship, /node scripts\/gds\/ship\.js/, 'the source skill keeps the checkout spelling');
1208
+ assert.ok(!ship.includes('npx bongos ship'), 'npx only appears on the way INTO a project');
1209
+ });
1210
+
1211
+ t('the starter allow-list is the npx spelling: no rule for a bare `bongos` nothing runs', () => {
1212
+ const bongosRules = m.STARTER_ALLOW.filter((r) => /bongos /.test(r));
1213
+ assert.ok(bongosRules.length > 10, 'the blessed commands are there');
1214
+ for (const r of bongosRules) assert.match(r, /^\w+\(npx bongos [a-z]/, `not the npx spelling: ${r}`);
1215
+ assert.ok(m.STARTER_ALLOW.includes('Bash(npx bongos ship:*)') && m.STARTER_ALLOW.includes('PowerShell(npx bongos ship:*)'));
1216
+ });
1217
+
1218
+ t('the starter CLAUDE.md names only `npx bongos …` commands and keeps the npm install first-run note', () => {
1219
+ const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Acme' } });
1220
+ assert.equal(md.match(BARE_VERB), null, 'a bare bongos command is a command-not-found in a project');
1221
+ assert.ok(md.includes('npx bongos start') && md.includes('npx bongos ship') && md.includes('npx bongos doctor'));
1222
+ assert.ok(md.indexOf('npm install') < md.indexOf('npx bongos start'), 'npm install still comes first');
1223
+ assert.match(md, /not on your PATH/, 'says WHY it runs through npx');
1224
+ });
1225
+
1226
+ // The pre-1004621 starter, rebuilt from the template by undoing each refresh pair. If the template
1227
+ // changes a CLI sentence without adding its pair, this round trip stops being exact and fails.
1228
+ const legacyStarter = (branding) => {
1229
+ let md = tmpl.renderInstanceClaudeMd(branding);
1230
+ for (const [legacy, current] of tmpl.LEGACY_CLI_LINES) md = md.split(current).join(legacy);
1231
+ return md;
1232
+ };
1233
+
1234
+ t('refreshStarterCliLines: an unedited old starter comes back byte-identical to today\'s', () => {
1235
+ const b = { identity: { productName: 'Acme' }, domains: { buildersOrigin: 'https://b.acme.example.org' } };
1236
+ const old = legacyStarter(b);
1237
+ assert.ok(BARE_VERB.test(old), 'the fixture really is the old bare-bongos starter');
1238
+ const r = tmpl.refreshStarterCliLines(old);
1239
+ assert.equal(r.text, tmpl.renderInstanceClaudeMd(b));
1240
+ assert.equal(r.changed, tmpl.LEGACY_CLI_LINES.length);
1241
+ assert.deepEqual(tmpl.refreshStarterCliLines(r.text), { text: r.text, changed: 0 }, 'idempotent');
1242
+ });
1243
+
1244
+ t('refreshStarterCliLines: an owner-reworded sentence and the rest of the file are left alone; CRLF works', () => {
1245
+ const old = legacyStarter({ identity: { productName: 'Acme' } })
1246
+ .replace('- **`bongos claim <id>`** locks a task to your session.', '- We claim with `bongos claim <id>` (our wording).')
1247
+ + '\n## Our own notes\n\nKeep `bongos start` here verbatim.\n';
1248
+ const r = tmpl.refreshStarterCliLines(old);
1249
+ assert.ok(r.text.includes('- We claim with `bongos claim <id>` (our wording).'), 'an edited line is the owner\'s');
1250
+ assert.ok(r.text.endsWith('\n## Our own notes\n\nKeep `bongos start` here verbatim.\n'), 'owner prose untouched');
1251
+ assert.equal(r.changed, tmpl.LEGACY_CLI_LINES.length - 1);
1252
+ const crlf = tmpl.refreshStarterCliLines(legacyStarter({ identity: { productName: 'Acme' } }).replace(/\n/g, '\r\n'));
1253
+ assert.equal(crlf.changed, tmpl.LEGACY_CLI_LINES.length, 'a Windows (autocrlf) checkout is matched too');
1254
+ assert.ok(!/(?<!\r)\n/.test(crlf.text), 'and keeps its CRLF endings');
1255
+ });
1256
+
1257
+ // The upgrade path. `bongos upgrade` calls materializeClaude({ coreRoot, instanceDir, log }) —
1258
+ // default overwrite — after moving the pin (scripts/gds/upgrade.js), so an EXISTING project picks
1259
+ // all of this up on its next upgrade. This drives exactly that call against a project scaffolded
1260
+ // before task 1004621: old CLAUDE.md, the bare-`bongos` starter settings.json, old skills.
1261
+ function oldProjectFixture() {
1262
+ const core = mkdtempSync(join(tmpdir(), 'npx-core-'));
1263
+ const inst = mkdtempSync(join(tmpdir(), 'npx-inst-'));
1264
+ mkdirSync(join(core, '.claude', 'skills', 'alpha'), { recursive: true }); // package-only: no settings.json at the core root
1265
+ writeFileSync(join(core, '.claude', 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nrun `node scripts/gds/ship.js 1`, or paste `bongos paste-token <token>`\n');
1266
+ mkdirSync(join(inst, '.claude', 'skills', 'alpha'), { recursive: true });
1267
+ writeFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nrun `bongos ship 1`\n'); // what the old materialize wrote
1268
+ writeFileSync(join(inst, 'CLAUDE.md'), legacyStarter({ identity: { productName: 'Acme' } }));
1269
+ const oldAllow = m.STARTER_ALLOW.map((r) => r.replace('(npx bongos ', '(bongos '))
1270
+ .filter((r) => r !== 'Bash(bongos recall:*)' && r !== 'PowerShell(bongos recall:*)'); // the owner removed one
1271
+ writeFileSync(join(inst, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: oldAllow, deny: [] } }, null, 2));
1272
+ const settings = () => JSON.parse(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'));
1273
+ return { core, inst, oldAllow, settings, cleanup: () => { rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true }); } };
1274
+ }
1275
+
1276
+ t('upgrade path: an existing project\'s skills, CLAUDE.md and allow-list all move to `npx bongos`', () => {
1277
+ const f = oldProjectFixture();
1278
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, log: () => {} });
1279
+ const skill = readFileSync(join(f.inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8');
1280
+ assert.match(skill, /`npx bongos ship 1`/);
1281
+ assert.match(skill, /`npx bongos paste-token <token>`/);
1282
+ assert.equal(skill.match(BARE_VERB), null);
1283
+ const md = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1284
+ assert.equal(md, tmpl.renderInstanceClaudeMd({ identity: { productName: 'Acme' } }), 'the unedited starter sentences were refreshed');
1285
+ assert.equal(res.claudeMdCliLines, tmpl.LEGACY_CLI_LINES.length);
1286
+ assert.ok(res.writtenPaths.includes('CLAUDE.md'), 'reported, so the pin commit carries it');
1287
+ const allow = f.settings().permissions.allow;
1288
+ for (const r of f.oldAllow) assert.ok(allow.includes(r), `the owner's own rule is kept: ${r}`);
1289
+ assert.ok(allow.includes('Bash(npx bongos ship:*)') && allow.includes('PowerShell(npx bongos ship:*)'), 'each bare rule gained its npx twin');
1290
+ assert.ok(!allow.includes('Bash(npx bongos recall:*)'), 'a rule the owner removed is not brought back by its twin');
1291
+ assert.equal(res.settingsAllowTwinsAdded.length, f.oldAllow.filter((r) => /\(bongos /.test(r)).length);
1292
+ f.cleanup();
1293
+ });
1294
+
1295
+ t('upgrade path: a second upgrade adds nothing more (no npx npx, no duplicate rule)', () => {
1296
+ const f = oldProjectFixture();
1297
+ m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, log: () => {} });
1298
+ const after1 = readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8');
1299
+ const md1 = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1300
+ const res2 = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, log: () => {} });
1301
+ assert.equal(readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8'), after1);
1302
+ assert.equal(readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8'), md1);
1303
+ assert.equal(res2.settingsAllowTwinsAdded, undefined);
1304
+ assert.ok(!after1.includes('npx npx') && !md1.includes('npx npx'));
1305
+ f.cleanup();
1306
+ });
1307
+
1308
+ t('upgrade path: a dry run reports the changes and writes nothing', () => {
1309
+ const f = oldProjectFixture();
1310
+ const before = readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8');
1311
+ const mdBefore = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1312
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: true, log: () => {} });
1313
+ assert.ok(res.settingsAllowTwinsAdded.length > 0 && res.claudeMdCliLines > 0);
1314
+ assert.equal(readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8'), before);
1315
+ assert.equal(readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8'), mdBefore);
1316
+ assert.ok(!res.writtenPaths.includes('CLAUDE.md'), 'a content-preserving rewrite is not waived for the pre-flight');
1317
+ f.cleanup();
1318
+ });
1319
+
1320
+ t('adopt mode (overwrite:false) never touches the owner\'s CLAUDE.md', () => {
1321
+ const f = oldProjectFixture();
1322
+ const mdBefore = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1323
+ m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, overwrite: false, log: () => {} });
1324
+ assert.equal(readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8'), mdBefore);
1325
+ f.cleanup();
1326
+ });
1327
+
1328
+ t('topUpNpxAllowTwins: an unreadable shape is reported, never rewritten', () => {
1329
+ assert.match(m.topUpNpxAllowTwins('{nope').reason, /not valid JSON/);
1330
+ assert.match(m.topUpNpxAllowTwins('{"permissions":{"allow":"x"}}').reason, /permissions\.allow/);
1331
+ assert.deepEqual(m.topUpNpxAllowTwins('{"permissions":{"allow":["Bash(git status:*)"]}}'), { text: null, added: [], reason: null });
1332
+ });
1333
+
1157
1334
  console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
1158
1335
  process.exit(failed ? 1 : 0);
@@ -308,8 +308,12 @@ perTool('the read-only subset is pre-allowed', (tool) => {
308
308
  }
309
309
  });
310
310
 
311
- test('the instance starter mirrors this list in verb form (claude-materialize.js STARTER_ALLOW)', () => {
312
- assert.deepEqual([...STARTER_ALLOW].sort(), allowList().map((rule) => bongosify(rule)).sort());
311
+ // Compared as SETS: the core carries both `node scripts/gds/paste-token.js:*` (what its skills
312
+ // run) and `npx bongos paste-token:*` (what Settings → CLI access hands out, task 1004575), and
313
+ // in a project both are the one `npx bongos paste-token:*` rule (task 1004621).
314
+ test('the instance starter mirrors this list in its `npx bongos` form (claude-materialize.js STARTER_ALLOW)', () => {
315
+ assert.equal(new Set(STARTER_ALLOW).size, STARTER_ALLOW.length, 'the starter carries no duplicate rule');
316
+ assert.deepEqual([...STARTER_ALLOW].sort(), [...new Set(allowList().map((rule) => bongosify(rule)))].sort());
313
317
  });
314
318
 
315
319
  console.log('');
@@ -49,9 +49,9 @@ test('the surface is never inferred from isTTY', () => {
49
49
 
50
50
  test('actions name a command the reader can actually run', () => {
51
51
  assert.equal(surface.action('ship', 123, AGENT_ENV), '/builder-ship 123');
52
- assert.equal(surface.action('ship', 123, TERM_ENV), 'bongos ship 123');
52
+ assert.equal(surface.action('ship', 123, TERM_ENV), 'npx bongos ship 123'); // not on PATH in a project (task 1004621)
53
53
  assert.equal(surface.action('start', null, AGENT_ENV), '/builder-start');
54
- assert.equal(surface.action('start', null, TERM_ENV), 'bongos start');
54
+ assert.equal(surface.action('start', null, TERM_ENV), 'npx bongos start');
55
55
  });
56
56
 
57
57
  test('task refs stay hall links for agents and go plain for terminals', () => {
@@ -231,6 +231,11 @@ export function installPageLedger(api, { builders }) {
231
231
  for (const x of hit) Object.assign(x, { status: 'resolved', resolved_task_id: String(p[1]), resolved_by: String(p[2]), resolution_note: p[4] });
232
232
  return { rows: hit.map((x) => ({ id: x.id })) };
233
233
  }
234
+ // createTask's spec-task lookup (task 1004585, goal-spec-task.js): a page's tweak
235
+ // goal is never made in the hall form, so it has no spec task and gates nothing.
236
+ if (/^SELECT id FROM tasks WHERE goal_id = \$1 AND source = \$2 AND source_ref = \$3/.test(q)) {
237
+ return { rows: db.tasks.filter((t) => t.goal_id === p[0] && t.source === p[1] && t.source_ref === p[2] && !['shipped', 'abandoned'].includes(t.status)).map((t) => ({ id: t.id })) };
238
+ }
234
239
  throw new Error(`unexpected SQL: ${q.slice(0, 160)}`);
235
240
  }
236
241
 
@@ -156,7 +156,7 @@ test('reading a goal shows whether it was admitted', () => {
156
156
  });
157
157
 
158
158
  test('the create response says whether the override took effect', () => {
159
- assert.match(postGoals(), /admitted \}\);/);
159
+ assert.match(postGoals(), /admitted(, spec_task: specTask)? \}\);/);
160
160
  });
161
161
 
162
162
  // ---- ADR 0250 §3: the exception is counted, or it is not an exception --------
@@ -169,7 +169,7 @@ test('the advisory is advice on a 201, never a refusal', () => {
169
169
  // and never fails the create over it (best-effort, wrapped in try).
170
170
  const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
171
171
  assert.match(routes, /category_advisory = goalCategory\.adviseCategory/);
172
- assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted \}\)/,
172
+ assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted(, spec_task: specTask)? \}\)/,
173
173
  'it rides the 201 — advice appended to a request that already succeeded');
174
174
  });
175
175
 
@@ -0,0 +1,61 @@
1
+ // tests/goal_create_spec_entry.mjs — /goal-create's "finish a hall-made goal's spec
2
+ // task" entry (task 1004586, ADR 0366 D3) names real routes, fields and identifiers.
3
+ // A skill is prose an agent follows literally; when the code it cites moves, the agent
4
+ // follows a path that no longer exists. So every load-bearing claim in the new section
5
+ // is held against the code it describes, not against a copy of it.
6
+ //
7
+ // Run: node --test tests/goal_create_spec_entry.mjs
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const require_ = createRequire(import.meta.url);
18
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
19
+ const skill = read('.claude/skills/goal-create/SKILL.md');
20
+ const section = skill.slice(skill.indexOf("## Finishing a hall-made goal's spec task"), skill.indexOf('## The sibling advisory'));
21
+ const spec = require_(path.join(ROOT, 'modules/lifecycle/goal-spec-task.js'));
22
+ const goalsRoutes = read('modules/lifecycle/routes/goals.js');
23
+
24
+ test('the section exists and the description carries its trigger', () => {
25
+ assert.ok(section.length > 500, 'the spec-task section is present');
26
+ assert.match(skill.split('\n---')[0], /"spec goal N"/, 'the trigger phrase is in the description');
27
+ });
28
+
29
+ test('the spec task it tells you to find is the one the server files', () => {
30
+ assert.match(section, /`source: goal-spec`/);
31
+ assert.equal(spec.SPEC_SOURCE, 'goal-spec');
32
+ assert.match(section, /`source_ref: goal-<N>-spec`/);
33
+ assert.equal(spec.specSourceRef(7), 'goal-7-spec');
34
+ assert.match(spec.specTaskFields({ id: 7, version_id: 'V', title: 't' }).title, /^Spec goal 7: its scope, criteria and first tasks$/);
35
+ assert.match(section, /"Spec goal N: its scope, criteria and first tasks"/);
36
+ });
37
+
38
+ test('the scope write it names is the real route and body', () => {
39
+ assert.match(section, /POST \/api\/bongos\/goals\/<N>\/scope --body '\{"add":\["<key>"\]\}'/);
40
+ assert.match(goalsRoutes, /router\.post\('\/goals\/:id\/scope'/);
41
+ const scopeRoute = goalsRoutes.slice(goalsRoutes.indexOf("router.post('/goals/:id/scope'"));
42
+ assert.match(scopeRoute.slice(0, 3000), /add:/, 'the route reads an `add` list');
43
+ });
44
+
45
+ test('"do not promote them" is true: new tasks in the goal wait on the open spec task', () => {
46
+ assert.match(section, /do not promote them/);
47
+ assert.match(read('modules/lifecycle/db-tasks.js'), /await gateOnSpecTask\(rows\[0\], deps\.client \|\| pool\)/);
48
+ });
49
+
50
+ test('the strict-fields note lists spec_task and the validator accepts it', () => {
51
+ assert.match(skill, /plus admit_reason[\s\S]{0,40}and spec_task, which ONLY the hall form sends/);
52
+ assert.match(goalsRoutes, /spec_task: \{ type: 'boolean' \}/);
53
+ assert.match(goalsRoutes, /admit_reason: \{ type: 'string'/);
54
+ });
55
+
56
+ test('skill-lint passes on the edited skill (the same lint fitness runs in CI)', async () => {
57
+ const { spawnSync } = await import('node:child_process');
58
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts/gds/skill-lint.js')], { cwd: ROOT, encoding: 'utf8' });
59
+ assert.equal(r.status, 0, `skill-lint failed:\n${r.stdout}\n${r.stderr}`);
60
+ assert.match(r.stdout, /0 error\(s\)/);
61
+ });
@@ -0,0 +1,197 @@
1
+ // tests/goal_spec_task.mjs — a goal made in the hall carries its own spec task, and
2
+ // its other work waits behind it (task 1004585, ADR 0366 D3). DB-free: the gate
3
+ // against a SQL-aware fake client, createTask's hook through its own client seam, and
4
+ // POST /goals through the assembled router. The real-Postgres half (the claim gate's
5
+ // read and the ship-time auto-promotion) is tests/goal_spec_task_db.mjs.
6
+ //
7
+ // Run: node --test tests/goal_spec_task.mjs
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ process.env.NODE_ENV = 'test';
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const require_ = createRequire(import.meta.url);
18
+ const spec = require_(path.join(ROOT, 'modules/lifecycle/goal-spec-task.js'));
19
+
20
+ // A fake client that knows one goal's spec task and records every edge written.
21
+ function fakeClient({ specId = null, specStatus = 'ready' } = {}) {
22
+ const edges = [];
23
+ return {
24
+ edges,
25
+ async query(sql, params) {
26
+ const s = String(sql).replace(/\s+/g, ' ');
27
+ if (/SELECT id FROM tasks WHERE goal_id = \$1 AND source = \$2 AND source_ref = \$3/.test(s)) {
28
+ assert.match(s, /status NOT IN \('shipped', 'abandoned'\)/, 'only an OPEN spec task gates');
29
+ const open = specId != null && !['shipped', 'abandoned'].includes(specStatus);
30
+ return { rows: open && params[1] === spec.SPEC_SOURCE && params[2] === spec.specSourceRef(params[0]) ? [{ id: String(specId) }] : [] };
31
+ }
32
+ if (/INSERT INTO dependencies/.test(s)) {
33
+ assert.match(s, /VALUES \('task', \$1, 'task', \$2\)/, 'a plain task→task edge, the shape unsatisfiedDeps reads');
34
+ edges.push([Number(params[0]), Number(params[1])]);
35
+ return { rows: [] };
36
+ }
37
+ return { rows: [] };
38
+ },
39
+ };
40
+ }
41
+
42
+ test('the spec task is a ready spike in the goal, keyed for idempotency', () => {
43
+ const f = spec.specTaskFields({ id: 42, version_id: 'V9', title: 'Hall goal' }, { createdBy: 7 });
44
+ assert.equal(f.status, 'ready');
45
+ assert.equal(f.kind, 'spike');
46
+ assert.equal(f.goalId, 42);
47
+ assert.equal(f.versionId, 'V9');
48
+ assert.equal(f.source, 'goal-spec');
49
+ assert.equal(f.sourceRef, 'goal-42-spec');
50
+ assert.equal(f.createdBy, 7);
51
+ assert.match(f.title, /^Spec goal 42: /);
52
+ assert.match(f.description, /POST \/goals\/42\/scope/);
53
+ assert.match(f.description, /POST \/versions\/V9\/done-when/);
54
+ });
55
+
56
+ test('a task entering a goal with an open spec task waits on it', async () => {
57
+ const c = fakeClient({ specId: 500 });
58
+ assert.equal(await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, c), 500);
59
+ assert.deepEqual(c.edges, [[501, 500]]);
60
+ });
61
+
62
+ test('the spec task never waits on itself', async () => {
63
+ const c = fakeClient({ specId: 500 });
64
+ assert.equal(await spec.gateOnSpecTask({ id: 500, goal_id: 42, source: 'goal-spec', source_ref: 'goal-42-spec' }, c), null);
65
+ assert.deepEqual(c.edges, []);
66
+ });
67
+
68
+ test('no edge when the goal has no spec task, or it shipped, or it was abandoned, or the task has no goal', async () => {
69
+ for (const c of [fakeClient(), fakeClient({ specId: 500, specStatus: 'shipped' }), fakeClient({ specId: 500, specStatus: 'abandoned' })]) {
70
+ assert.equal(await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, c), null);
71
+ assert.deepEqual(c.edges, []);
72
+ }
73
+ const c = fakeClient({ specId: 500 });
74
+ assert.equal(await spec.gateOnSpecTask({ id: 501, goal_id: null }, c), null);
75
+ });
76
+
77
+ test('a MOVED task goes through the cycle-checked path, and a cycle leaves the move without the edge', async () => {
78
+ const calls = [];
79
+ const ok = await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, fakeClient({ specId: 500 }), {
80
+ addTaskDependency: async (a, b) => { calls.push([a, b]); },
81
+ });
82
+ assert.equal(ok, 500);
83
+ assert.deepEqual(calls, [[501, 500]]);
84
+ const cyc = await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, fakeClient({ specId: 500 }), {
85
+ addTaskDependency: async () => { throw { code: 'CYCLE', path: [500, 501] }; },
86
+ });
87
+ assert.equal(cyc, null);
88
+ await assert.rejects(spec.gateOnSpecTask({ id: 501, goal_id: 42 }, fakeClient({ specId: 500 }), {
89
+ addTaskDependency: async () => { throw { code: 'TASK_NOT_FOUND' }; },
90
+ }), (e) => e.code === 'TASK_NOT_FOUND', 'any other failure is not swallowed');
91
+ });
92
+
93
+ test('createTask itself adds the edge — every create vector inherits it', async () => {
94
+ const db = require_(path.join(ROOT, 'modules/lifecycle/db-tasks.js'));
95
+ const c = fakeClient({ specId: 500 });
96
+ const inner = c.query.bind(c);
97
+ c.query = async (sql, params) => {
98
+ if (/INSERT INTO tasks/.test(sql)) return { rows: [{ id: '777', goal_id: params[22], source: params[11], source_ref: params[12] }] };
99
+ return inner(sql, params);
100
+ };
101
+ const row = await db.createTask({ versionId: 'V9', goalId: 42, title: 'Build the page', kind: 'feature', discipline: 'engineer', creditsReward: 5 }, { client: c });
102
+ assert.equal(row.id, '777');
103
+ assert.deepEqual(c.edges, [[777, 500]]);
104
+ });
105
+
106
+ // ── POST /goals through the assembled router ─────────────────────────────────
107
+
108
+ async function withGoalsServer(fn) {
109
+ const r2 = createRequire(import.meta.url);
110
+ const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
111
+ const routePath = r2.resolve(path.join(ROOT, 'modules/lifecycle/routes/goals.js'));
112
+ const auth = r2(authPath);
113
+ const api = r2(path.join(ROOT, 'src/module-api.js'));
114
+ const db = r2(path.join(ROOT, 'modules/lifecycle/db.js'));
115
+ const saved = { rb: auth.requireBuilder, rp: auth.requirePermission, tx: api.withTx, gv: db.getVersion, cg: db.createGoal, ct: db.createTask };
116
+ const calls = { createGoal: [], createTask: [], tx: 0 };
117
+ auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank: 'metic' }; next(); };
118
+ auth.requirePermission = () => (_req, _res, next) => next();
119
+ const txClient = { query: async () => ({ rows: [] }) };
120
+ api.withTx = async (f) => { calls.tx += 1; return f(txClient); };
121
+ db.getVersion = async (id) => ({ id, status: 'planning' });
122
+ db.createGoal = async (fields, deps = {}) => { calls.createGoal.push({ fields, client: deps.client || null }); return { id: 42, version_id: fields.versionId, title: fields.title }; };
123
+ db.createTask = async (fields, deps = {}) => { calls.createTask.push({ fields, client: deps.client || null }); return { id: 500, ...fields }; };
124
+ delete r2.cache[routePath];
125
+ const express = r2('express');
126
+ const { attachFail } = r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
127
+ const app = express();
128
+ app.use(express.json());
129
+ app.use(attachFail);
130
+ app.use(r2(routePath)());
131
+ const server = app.listen(0);
132
+ await new Promise((res) => server.once('listening', res));
133
+ const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/goals`, {
134
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
135
+ });
136
+ try { await fn(post, calls, txClient); } finally {
137
+ server.close();
138
+ auth.requireBuilder = saved.rb; auth.requirePermission = saved.rp; api.withTx = saved.tx;
139
+ db.getVersion = saved.gv; db.createGoal = saved.cg; db.createTask = saved.ct;
140
+ delete r2.cache[routePath];
141
+ }
142
+ }
143
+
144
+ test('POST /goals with spec_task creates the goal and its spec task in ONE transaction', async () => {
145
+ await withGoalsServer(async (post, calls, txClient) => {
146
+ const res = await post({ version_id: 'V9', title: 'Hall goal', category_id: undefined, spec_task: true });
147
+ assert.equal(res.status, 201, await res.clone().text());
148
+ const body = await res.json();
149
+ assert.equal(calls.tx, 1);
150
+ assert.equal(calls.createGoal[0].client, txClient, 'the goal is written inside the transaction');
151
+ assert.equal(calls.createTask.length, 1);
152
+ assert.equal(calls.createTask[0].client, txClient, 'and so is its spec task');
153
+ assert.equal(calls.createTask[0].fields.sourceRef, 'goal-42-spec');
154
+ assert.equal(body.spec_task.id, 500);
155
+ });
156
+ });
157
+
158
+ test('POST /goals without spec_task behaves exactly as before', async () => {
159
+ await withGoalsServer(async (post, calls) => {
160
+ const res = await post({ version_id: 'V9', title: 'CLI goal' });
161
+ assert.equal(res.status, 201);
162
+ assert.equal(calls.tx, 0);
163
+ assert.equal(calls.createGoal[0].client, null);
164
+ assert.equal(calls.createTask.length, 0);
165
+ assert.equal((await res.json()).spec_task, null);
166
+ });
167
+ });
168
+
169
+ test('spec_task must be a boolean', async () => {
170
+ await withGoalsServer(async (post, calls) => {
171
+ const res = await post({ version_id: 'V9', title: 'Hall goal', spec_task: 'yes' });
172
+ assert.equal(res.status, 400);
173
+ assert.equal(calls.createGoal.length, 0);
174
+ });
175
+ });
176
+
177
+ test('ensureSpecTask is safe to run twice: the second run returns the first spec task and writes nothing', async () => {
178
+ // A stateful fake: the spec task the first run creates is what the second run's read finds.
179
+ const created = [];
180
+ const client = {
181
+ async query(sql, params) {
182
+ if (/SELECT id FROM tasks WHERE goal_id = \$1 AND source = \$2 AND source_ref = \$3/.test(String(sql).replace(/\s+/g, ' '))) {
183
+ const hit = created.find((t) => t.goalId === params[0] && t.source === params[1] && t.sourceRef === params[2]);
184
+ return { rows: hit ? [{ id: String(hit.id) }] : [] };
185
+ }
186
+ return { rows: [] };
187
+ },
188
+ };
189
+ const db = { createTask: async (fields) => { const t = { id: 600 + created.length, ...fields }; created.push(t); return t; } };
190
+ const goal = { id: 42, version_id: 'V9', title: 'Hall goal' };
191
+ const first = await spec.ensureSpecTask({ db, goal, client, createdBy: 7 });
192
+ const second = await spec.ensureSpecTask({ db, goal, client, createdBy: 7 });
193
+ assert.equal(first.created, true);
194
+ assert.equal(second.created, false);
195
+ assert.equal(second.specTask.id, first.specTask.id);
196
+ assert.equal(created.length, 1, 'exactly one spec task for the goal');
197
+ });