@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.
- package/.bongos-core.json +119 -64
- package/.claude/skills/bongos-feedback/SKILL.md +10 -46
- package/.claude/skills/goal-create/SKILL.md +21 -3
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -2
- package/clients/bongos-client/index.cjs +8 -2
- package/clients/bongos-client/index.d.ts +15 -6
- package/clients/bongos-client/index.mjs +8 -2
- package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +196 -19
- package/docs/api-reference.md +9 -6
- package/docs/copy-inventory.md +25 -21
- package/docs/copy-registry.json +76 -30
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +8 -8
- package/docs/recipes/core-release-pipeline.md +6 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/hall-ui/public/settings-software-update.js +77 -2
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/lifecycle/db-tasks.js +12 -0
- package/modules/lifecycle/goal-spec-task.js +129 -0
- package/modules/lifecycle/routes/goals.js +7 -9
- package/modules/lifecycle/routes/task-write-routes.js +11 -1
- package/modules/lifecycle/upkeep-home.js +55 -0
- package/modules/platform-identity/instance-feedback.js +108 -7
- package/modules/platform-identity/routes/sso.js +71 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +42 -0
- package/scripts/gds/claude-materialize.js +125 -31
- package/scripts/gds/claude-md-template.js +47 -10
- package/scripts/gds/cli-lib.js +4 -4
- package/scripts/gds/feedback-send.js +146 -73
- package/scripts/gds/paste-token.js +1 -1
- package/scripts/gds/provision-net.js +1 -8
- package/scripts/gds/provision-units.js +28 -2
- package/scripts/gds/reauth.js +1 -1
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/setup.js +1 -1
- package/scripts/gds/start.js +4 -4
- package/scripts/gds/surface.js +15 -3
- package/scripts/gds/task.js +32 -5
- package/scripts/gds/upgrade-operator.js +73 -0
- package/scripts/gds/upgrade.js +7 -2
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/platform-visibility-gate.js +3 -0
- package/src/bongos/routes/core-update.js +98 -4
- package/src/bongos/routes/feedback.js +100 -76
- package/src/bongos/upgrade-request.js +158 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +195 -18
- package/tests/cli_allowlist_permissions.mjs +6 -2
- package/tests/cli_surface.mjs +2 -2
- package/tests/fixtures/page-ledger.mjs +5 -0
- package/tests/goal_admission_override.mjs +1 -1
- package/tests/goal_category.mjs +1 -1
- package/tests/goal_create_spec_entry.mjs +61 -0
- package/tests/goal_spec_task.mjs +197 -0
- package/tests/goal_spec_task_db.mjs +95 -0
- package/tests/instance_feedback.mjs +267 -68
- package/tests/provision_settings_apply.mjs +25 -1
- package/tests/software_update.mjs +18 -1
- package/tests/start_env_guard.mjs +2 -2
- package/tests/upgrade_operator.mjs +105 -0
- package/tests/upgrade_request.mjs +348 -0
- 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(' ')[
|
|
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
|
-
|
|
312
|
-
|
|
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('');
|
package/tests/cli_surface.mjs
CHANGED
|
@@ -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 --------
|
package/tests/goal_category.mjs
CHANGED
|
@@ -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
|
+
});
|