@bongos/core 1.19.834 → 1.19.836

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.
@@ -68,6 +68,7 @@ const { checkTree: _checkTree } = require('./tree-preflight.js');
68
68
  }
69
69
 
70
70
  const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError, cardFlagsFromMe } = require('./cli-lib');
71
+ const { plainCardLines } = require('./plain-cards');
71
72
  const { contextPack, formatContextPackCompact } = require('./context-pack');
72
73
  const { depsFinding } = require('./preflight');
73
74
 
@@ -719,11 +720,13 @@ async function main() {
719
720
  if (r.status === 403 && r.data && r.data.error === 'stage_required') {
720
721
  console.log('');
721
722
  console.log(r.data.what_to_do || `Clear "${r.data.required_stage_label || r.data.required_stage}" before the work queue opens.`);
723
+ for (const line of plainCardLines('stage_required')) console.log(line);
722
724
  console.log('');
723
725
  process.exit(0);
724
726
  }
725
727
  if (!r.ok) {
726
728
  console.error(`failed (${r.status}):`, r.data);
729
+ for (const line of plainCardLines('board_unavailable')) console.error(line);
727
730
  process.exit(1);
728
731
  }
729
732
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.834'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.836'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -28,13 +28,15 @@ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../m
28
28
  const db = require('../modules/lifecycle/db.js');
29
29
 
30
30
  const VER = 'ZZZ-CG', WORKER = 90000201, STALLED = 90000201, FRESH = 90000202;
31
+ // task 1003294 — the unprovable-strand cases below use their own rows.
32
+ const AGED = 90000203, FRESH2 = 90000204, AGED_OTHER = 90000205;
31
33
  const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
32
34
 
33
35
  async function cleanup() {
34
36
  await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
35
- await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, [STALLED, FRESH]]).catch(() => {});
36
- await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [[STALLED, FRESH]]).catch(() => {});
37
- await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [[STALLED, FRESH]]).catch(() => {});
37
+ await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, [STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
38
+ await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
39
+ await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
38
40
  await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
39
41
  await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
40
42
  }
@@ -88,6 +90,55 @@ try {
88
90
  const activeClaim = (await q(`SELECT builder_id FROM claims WHERE task_id=$1 AND released_at IS NULL`, [FRESH]))[0];
89
91
  ok(activeClaim && Number(activeClaim.builder_id) === WORKER, 'the worker now holds an active claim on the task');
90
92
 
93
+ // ---------------------------------------------------------------------------
94
+ // task 1003294 — a strand the builder CANNOT clear stops BLOCKING, without
95
+ // stopping being REPORTED.
96
+ //
97
+ // 'strand:no_branch_no_tip' means no branch in the core repo and no published
98
+ // tip. That is either "never pushed" (their chore, the gate is working) or
99
+ // "delivered into the INSTANCE repo" (task 1003253 — real, complete, deployed,
100
+ // and structurally invisible to this reconciler). Indistinguishable here, so the
101
+ // gate keeps its teeth for a bounded window and then lets go. These cases prove
102
+ // the SQL actually does that, which the DB-free suite cannot.
103
+ // ---------------------------------------------------------------------------
104
+ await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH]);
105
+
106
+ const seedStrand = async (id, reason, ageHours) => {
107
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,shipped_by) VALUES ($1,$2,'aged','confirmed',10,'feature',$3)`, [id, VER, WORKER]);
108
+ await q(`INSERT INTO claims (task_id,builder_id,claimed_at) VALUES ($1,$2, now())`, [id, WORKER]);
109
+ await db.flagTaskNeedsRebase(String(id), reason);
110
+ await q(`UPDATE tasks SET needs_rebase_at = now() - ($2::bigint * interval '1 hour') WHERE id = $1`, [id, ageHours]);
111
+ };
112
+
113
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind) VALUES ($1,$2,'fresh2','ready',10,'feature')`, [FRESH2, VER]);
114
+
115
+ // 4) An unprovable strand AGED past the grace no longer refuses a claim.
116
+ await seedStrand(AGED, 'strand:no_branch_no_tip', 48);
117
+ const claim2 = await db.claimTask({ taskId: String(FRESH2), builderId: String(WORKER), worktreeName: 'cg-fresh2' });
118
+ ok(claim2 && Number(claim2.task_id) === FRESH2,
119
+ 'an unprovable strand past the grace no longer freezes the queue');
120
+
121
+ // 5) …and it is STILL owed and STILL surfaced. This is the half that must not
122
+ // be lost: releasing the gate is not resolving the strand.
123
+ const stillOwed = await db.needsRebaseTasksForBuilder(String(WORKER));
124
+ ok(stillOwed.some((t) => Number(t.id) === AGED),
125
+ 'the ungated strand is still reported to its builder (me.js / hall / CLI)');
126
+ const agedFlag = (await q(`SELECT needs_rebase FROM tasks WHERE id=$1`, [AGED]))[0].needs_rebase;
127
+ ok(agedFlag === true, 'the flag is untouched — the row is still owed, just not blocking');
128
+
129
+ // 6) A strand with a DIFFERENT reason, equally aged, still gates: this release is
130
+ // scoped to the one ambiguous reason, not to age in general.
131
+ await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH2]);
132
+ await q(`UPDATE tasks SET status='shipped', needs_rebase=false WHERE id=$1`, [AGED]);
133
+ await seedStrand(AGED_OTHER, 'strand:pr_not_landed', 48);
134
+ await q(`UPDATE tasks SET status='ready' WHERE id=$1`, [FRESH2]);
135
+ let threw2 = null;
136
+ try {
137
+ await db.claimTask({ taskId: String(FRESH2), builderId: String(WORKER), worktreeName: 'cg-fresh2b' });
138
+ } catch (e) { threw2 = e; }
139
+ ok(threw2 && threw2.code === 'REBASE_REQUIRED',
140
+ 'an aged pr_not_landed strand still gates — the builder CAN fix that one');
141
+
91
142
  console.log(`\nclaim_gate_rebase (ADR 0120 part 3): ${pass} passed, 0 failed`);
92
143
  } catch (e) {
93
144
  console.error('claim_gate_rebase.mjs FAILED:', e.stack || e.message);
@@ -0,0 +1,221 @@
1
+ // tests/plain_cards.mjs — every failure a builder can be parked behind has a
2
+ // plain-language card (task 1003989).
3
+ //
4
+ // WHY. The CLIs explain a refusal in engineer prose and the lifecycle skills said
5
+ // "relay verbatim", so a non-technical builder met REBASE_REQUIRED or
6
+ // strand:pr_not_landed raw, at exactly the moment they were stuck. The registry in
7
+ // scripts/gds/plain-cards.js says the same thing in plain words. This file holds
8
+ // three lines: a new failure code cannot ship without a card, a card cannot drift
9
+ // back into jargon, and the deliberate restrictions stay marked as deliberate.
10
+ //
11
+ // Run: node tests/plain_cards.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import fs from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { CARDS, cardFor, plainCardLines, PLAIN_MARKER } from '../scripts/gds/plain-cards.js';
17
+ import * as shipLand from '../scripts/gds/ship-land.js';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { JARGON_TERMS } = require('../modules/sessions/accessibility.js');
21
+ const { formatClaimFailure } = require('../scripts/gds/claim.js');
22
+ const { formatGradeOutcome } = require('../scripts/gds/ship-grade-steps.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ function test(name, fn) {
27
+ try { fn(); passed++; console.log(` ok ${name}`); }
28
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
29
+ }
30
+
31
+ const read = (rel) => fs.readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
32
+ const missing = (codes) => [...new Set(codes)].filter((c) => !cardFor(c)).sort();
33
+
34
+ // ── completeness: the codes are read out of the code that raises them ──────────
35
+
36
+ test('every refusal claim.js renders has a card', () => {
37
+ const src = read('scripts/gds/claim.js');
38
+ const body = src.slice(src.indexOf('function claimFailureGuidance'), src.indexOf('function goalMembershipOfferPlan'));
39
+ const codes = [...body.matchAll(/code === '([A-Za-z_]+)'/g)].map((m) => m[1]);
40
+ assert.ok(codes.length >= 12, `expected the claim ladder, found ${codes.length}`);
41
+ assert.deepEqual(missing(codes), []);
42
+ });
43
+
44
+ test('every refusal the claim ladder can throw server-side has a card', () => {
45
+ const src = read('modules/lifecycle/db-claims.js');
46
+ const codes = [...src.matchAll(/\bcode: '([A-Za-z_]+)'/g), ...src.matchAll(/\.code = '([A-Za-z_]+)'/g)].map((m) => m[1]);
47
+ assert.ok(codes.includes('REBASE_REQUIRED'), 'the ladder was not found — has db-claims.js moved?');
48
+ assert.deepEqual(missing(codes), []);
49
+ });
50
+
51
+ test('every reason a confirmed task can be flagged with has a card', () => {
52
+ // The reconciler composes `strand:${reason}` at runtime, so read both halves: the
53
+ // literal tokens, and the four values the `reason` ternary can take.
54
+ const src = read('modules/lifecycle/publish-reconciler.js');
55
+ const ternary = src.slice(src.indexOf('const reason = prClosedUnmerged'));
56
+ const reasons = [...ternary.slice(0, ternary.indexOf(';')).matchAll(/'([a-z_]+)'/g)].map((m) => `strand:${m[1]}`);
57
+ const literals = [...src.matchAll(/strand:([a-z_]{3,})/g)].map((m) => `strand:${m[1]}`);
58
+ assert.equal(reasons.length, 4, `the reason ternary moved: ${reasons.join(',')}`);
59
+ assert.ok(literals.length >= 2, 'the conflict strand literals were not found');
60
+ assert.deepEqual(missing([...reasons, ...literals]), []);
61
+ assert.ok(cardFor('strand:unresolvable_conflict:docs/a.md,docs/b.md'), 'a reason carrying file names still resolves');
62
+ });
63
+
64
+ test('every reason an open pull request has not merged has a card', () => {
65
+ const src = read('modules/lifecycle/github-push.js');
66
+ const body = src.slice(src.indexOf('function mergeBlockReason'), src.indexOf('async function mergeIfGreen'));
67
+ const tokens = [...body.matchAll(/return [`']([a-z_]+)/g)].map((m) => m[1]);
68
+ assert.ok(tokens.includes('merge_conflict') && tokens.includes('failing_check_unreadable'), tokens.join(','));
69
+ assert.deepEqual(missing(tokens), []);
70
+ assert.ok(cardFor('failing_check:unit') && cardFor('non_required_check_failing:e2e'), 'named-check reasons resolve by prefix');
71
+ });
72
+
73
+ test('every push diagnosis and land bail has a card', () => {
74
+ const ids = [...shipLand.PUSH_DIAGNOSES.map((d) => d.id), 'unknown',
75
+ shipLand.LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH, shipLand.LAND_BAIL_NO_TASK_BRANCH];
76
+ assert.deepEqual(missing(ids.map((id) => `ship:${id}`)), []);
77
+ });
78
+
79
+ // ── the words ─────────────────────────────────────────────────────────────────
80
+
81
+ // Beyond the scorer's list: the git/GitHub nouns these particular failures are
82
+ // made of, which is exactly what the card exists to keep out of the first line.
83
+ const GIT_NOUNS = ['branch', 'pull request', 'commit', 'merge conflict', 'CI', 'SHA', 'diff', 'PAT', 'origin'];
84
+
85
+ test('no card speaks in the vocabulary it exists to translate', () => {
86
+ const banned = [...JARGON_TERMS, ...GIT_NOUNS];
87
+ const hits = [];
88
+ for (const [code, card] of Object.entries(CARDS)) {
89
+ for (const field of ['happened', 'next', 'person']) {
90
+ for (const term of banned) {
91
+ const re = new RegExp(`(^|[^A-Za-z])${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z]|$)`, /^[A-Z+-]+$/.test(term) ? '' : 'i');
92
+ if (re.test(card[field] || '')) hits.push(`${code}.${field}: "${term}"`);
93
+ }
94
+ }
95
+ }
96
+ assert.deepEqual(hits, []);
97
+ });
98
+
99
+ test('every card is complete, and short enough to be read', () => {
100
+ for (const [code, card] of Object.entries(CARDS)) {
101
+ assert.ok(card.happened && card.next, `${code} needs happened + next`);
102
+ assert.equal(typeof card.by_design, 'boolean', `${code}.by_design`);
103
+ assert.ok(card.person === null || typeof card.person === 'string', `${code}.person is null or who`);
104
+ for (const f of ['happened', 'next']) assert.ok(card[f].length <= 230, `${code}.${f} is ${card[f].length} chars`);
105
+ }
106
+ });
107
+
108
+ test('the restrictions that must never be worked around are marked by_design', () => {
109
+ for (const code of ['REBASE_REQUIRED', 'INSUFFICIENT_RANK', 'rank_too_low_for_task', 'session_already_has_active_claim',
110
+ 'main_checkout_claim', 'ship:protected_branch', 'ship:auth', 'box_scope', 'no_push_credential', 'grade_refused_authorization']) {
111
+ assert.equal(cardFor(code)?.by_design, true, code);
112
+ }
113
+ assert.match(cardFor('no_push_credential').next, /never ask/i);
114
+ });
115
+
116
+ // ── the rendering, and the seams that print it ────────────────────────────────
117
+
118
+ test('plainCardLines renders four labelled lines under the marker, and nothing for an unknown code', () => {
119
+ const lines = plainCardLines('ALREADY_CLAIMED');
120
+ assert.ok(lines[0].includes(PLAIN_MARKER));
121
+ assert.equal(lines.length, 5);
122
+ assert.deepEqual(plainCardLines('never_heard_of_it'), []);
123
+ assert.deepEqual(plainCardLines(undefined), []);
124
+ });
125
+
126
+ test('a card interpolates nothing — it is static text, so it adds no injection surface', () => {
127
+ for (const card of Object.values(CARDS)) assert.ok(!/\$\{|%s|<[a-z_]+>/.test(JSON.stringify(card)));
128
+ });
129
+
130
+ test('a refused claim prints the engineer text first and the card after it', () => {
131
+ const r = { status: 409, data: { error: { code: 'REBASE_REQUIRED', message: 'x', details: { rebaseTasks: [{ id: 7, reason: 'strand:pr_not_landed' }] } } } };
132
+ const { lines, exit } = formatClaimFailure(r, 42);
133
+ assert.equal(exit, 1);
134
+ assert.match(lines[0], /queue is gated/);
135
+ const at = lines.findIndex((l) => l.includes(PLAIN_MARKER));
136
+ assert.ok(at > 0, 'card follows the existing guidance');
137
+ assert.ok(lines.slice(at).join('\n').includes(CARDS.REBASE_REQUIRED.happened));
138
+ assert.ok(lines.join('\n').includes(CARDS['strand:pr_not_landed'].happened), 'the flagged reason is translated too');
139
+ });
140
+
141
+ test('an unmapped claim code still renders, just without a card', () => {
142
+ const { lines } = formatClaimFailure({ status: 500, data: { error: { code: 'SOMETHING_NEW', message: 'm' } } }, 1);
143
+ assert.ok(lines.length >= 1 && !lines.join('\n').includes(PLAIN_MARKER));
144
+ });
145
+
146
+ test('a grader outage and a failed grade each carry their own card', () => {
147
+ const outage = formatGradeOutcome({ passed: false, signals: { panel_outcome: 'unavailable' } }, 9);
148
+ assert.ok(outage.includes(CARDS.grade_unavailable.happened));
149
+ const fail = formatGradeOutcome({ passed: false, score: 4, threshold: 7, issues: [] }, 9);
150
+ assert.ok(fail.includes(CARDS.grade_failed.happened));
151
+ assert.ok(!formatGradeOutcome({ passed: true, score: 9, threshold: 7 }, 9).includes(PLAIN_MARKER), 'a pass needs no card');
152
+ });
153
+
154
+ test('a red pull request line is followed by its card', () => {
155
+ const lines = shipLand.redPrLines({ reason: 'failing_check_unreadable', checks_unreadable: true });
156
+ assert.ok(lines[0].includes('RED') && lines.join('\n').includes(CARDS.failing_check_unreadable.happened));
157
+ assert.deepEqual(shipLand.redPrLines({ reason: 'review_required' }), []);
158
+ });
159
+
160
+ test('a person at a terminal gets no card — it is written in the agent\'s voice', () => {
161
+ assert.deepEqual(plainCardLines('REBASE_REQUIRED', { BONGOS_SURFACE: 'terminal' }), []);
162
+ assert.equal(plainCardLines('REBASE_REQUIRED', {}).length, 5);
163
+ });
164
+
165
+ test('the sign-in printers, the release refusals and the claim guard each print their card', () => {
166
+ const wired = {
167
+ 'scripts/gds/cli-lib.js': ['session_invalid', 'box_scope', 'no_session'],
168
+ 'scripts/gds/release.js': ['no_active_claim', 'CLAIM_SESSION_MISMATCH'],
169
+ 'scripts/gds/claim.js': ['main_checkout_claim'],
170
+ 'scripts/gds/ship-land.js': ['land_pending', 'reconcile_blocked'],
171
+ 'scripts/gds/push-path-brief.js': ['no_push_credential'],
172
+ };
173
+ for (const [file, codes] of Object.entries(wired)) {
174
+ const src = read(file);
175
+ for (const code of codes) {
176
+ assert.ok(src.includes(`plainCardLines('${code}')`), `${file} should print the ${code} card`);
177
+ assert.ok(cardFor(code), `${code} has no card`);
178
+ }
179
+ }
180
+ });
181
+
182
+ test('every way start.js can refuse or fail prints a card', () => {
183
+ const src = read('scripts/gds/start.js');
184
+ const codes = [...src.matchAll(/\.error === '([A-Za-z_]+)'/g)].map((m) => m[1]);
185
+ assert.ok(codes.includes('stage_required'), 'start.js no longer names its refusal — has the branch moved?');
186
+ assert.deepEqual(missing(codes), []);
187
+ for (const code of [...new Set(codes)]) assert.ok(src.includes(`plainCardLines('${code}')`), `start.js never prints the ${code} card`);
188
+ // The generic non-ok exit is the last way out; it must not be the one mute path.
189
+ const generic = src.slice(src.indexOf('if (!r.ok) {'));
190
+ assert.ok(generic.slice(0, generic.indexOf('process.exit(1)')).includes("plainCardLines('board_unavailable')"));
191
+ });
192
+
193
+ test('no card is an orphan — each is printed by name, or belongs to a family a seam prints by code', () => {
194
+ const scripts = fs.readdirSync(new URL('../scripts/gds/', import.meta.url)).filter((f) => f.endsWith('.js') && f !== 'plain-cards.js');
195
+ const printed = new Set();
196
+ for (const f of scripts) for (const m of read(`scripts/gds/${f}`).matchAll(/plainCardLines\('([^']+)'\)/g)) printed.add(m[1]);
197
+ // Families reach plainCardLines through a variable: the claim refusal code, the
198
+ // flagged strand reason, the merge reason on a red pull request, `ship:${d.id}`.
199
+ const claimSrc = read('scripts/gds/claim.js') + read('modules/lifecycle/db-claims.js') + read('modules/lifecycle/routes/claims.js') + read('modules/lifecycle/goal-authz.js');
200
+ const mergeSrc = read('modules/lifecycle/github-push.js');
201
+ const byFamily = (code) => code.startsWith('strand:') || code.startsWith('ship:')
202
+ || claimSrc.includes(`'${code}'`) || mergeSrc.includes(`'${code}'`) || mergeSrc.includes(`\`${code}:`);
203
+ const orphans = Object.keys(CARDS).filter((code) => !printed.has(code) && !byFamily(code));
204
+ assert.deepEqual(orphans, []);
205
+ });
206
+
207
+ test('the four lifecycle skills tell the agent how to use the card', () => {
208
+ for (const skill of ['builder-claim', 'builder-ship', 'builder-start', 'builder-release']) {
209
+ const md = read(`.claude/skills/${skill}/SKILL.md`);
210
+ assert.ok(md.includes(PLAIN_MARKER), `${skill} never mentions the ${PLAIN_MARKER} card`);
211
+ assert.match(md, /interaction profile/, `${skill} does not tie the card to the builder's profile`);
212
+ }
213
+ assert.match(read('.claude/skills/builder-claim/SKILL.md'), /never look for a way round it/);
214
+ });
215
+
216
+ test('the public CLI package ships the card file its scripts require', () => {
217
+ assert.ok(read('scripts/gds/build-cli-package.js').includes("'scripts/gds/plain-cards.js'"));
218
+ });
219
+
220
+ console.log(`\nplain cards (task 1003989) tests: ${passed} passed, ${failed} failed`);
221
+ process.exitCode = failed > 0 ? 1 : 0;
@@ -566,15 +566,79 @@ await ta('scaffoldStandaloneRepo: idempotent — a repo that already carries pac
566
566
  mkdirSync(join(tmp, 'demo'), { recursive: true });
567
567
  writeFileSync(join(tmp, 'demo', 'package.json'), '{}\n'); // sign of a completed scaffold
568
568
  const { deps, cmds } = recorder({ apply: true });
569
+ deps.exec = (cmd) => { cmds.push(cmd); return { ok: true, stdout: /ls-remote/.test(cmd) ? 'abc123\trefs/heads/main\n' : '' }; };
569
570
  const res = await P.scaffoldStandaloneRepo({ slug: 'demo', hosting_shape: 'standalone', target_ref: 'example-owner/demo' }, deps);
570
571
  assert.equal(res.already, true);
571
- assert.equal(cmds.length, 0, 'no init/build/push when already scaffolded');
572
+ assert.deepEqual(cmds.filter((c) => !/ls-remote/.test(c)), [], 'no init/build/push when already scaffolded AND the remote carries main');
572
573
  } finally {
573
574
  P.CONFIG.standaloneBase = origBase;
574
575
  rmSync(tmp, { recursive: true, force: true });
575
576
  }
576
577
  });
577
578
 
579
+ // F12 (task 1002718): package.json is a LOCAL marker, so a scaffold whose push failed left it
580
+ // behind — the re-run skipped the never-done push and wedged at step 2's `git pull --ff-only`
581
+ // on the empty remote. The runner now asks the remote before skipping.
582
+ async function rerunScaffold({ apply = true, lsRemote, token = null, credWriteThrows = false }) {
583
+ const tmp = mkdtempSync(join(tmpdir(), 'scaffold-resume-'));
584
+ const origBase = P.CONFIG.standaloneBase;
585
+ P.CONFIG.standaloneBase = tmp;
586
+ try {
587
+ mkdirSync(join(tmp, 'demo'), { recursive: true });
588
+ writeFileSync(join(tmp, 'demo', 'package.json'), '{}\n');
589
+ const { deps, cmds } = recorder({ apply });
590
+ const writes = [];
591
+ deps.exec = (cmd) => { cmds.push(cmd); return /ls-remote/.test(cmd) ? lsRemote : { ok: true, stdout: '' }; };
592
+ deps.writeFile = (p, body, opts) => {
593
+ if (credWriteThrows && /bongos-gitcred/.test(p)) throw new Error('EACCES: tmp not writable');
594
+ writes.push({ p, body, opts }); return { ok: true };
595
+ };
596
+ if (token) deps.getGithubToken = async () => ({ token, scope: 'repo' });
597
+ const res = await P.scaffoldStandaloneRepo({ slug: 'demo', hosting_shape: 'standalone', target_ref: 'example-owner/demo', owner_builder_id: 7 }, deps);
598
+ return { res, cmds, writes };
599
+ } finally {
600
+ P.CONFIG.standaloneBase = origBase;
601
+ rmSync(tmp, { recursive: true, force: true });
602
+ }
603
+ }
604
+
605
+ await ta('scaffoldStandaloneRepo: a scaffold whose push never landed RE-PUSHES on re-run (remote has no main) — F12, task 1002718', async () => {
606
+ const { res, cmds } = await rerunScaffold({ lsRemote: { ok: true, stdout: '' } });
607
+ const joined = cmds.join('\n');
608
+ assert.equal(res.resumed, true);
609
+ assert.match(joined, /ls-remote --heads [^\n]*origin main/, 'asks the remote whether main exists');
610
+ assert.match(joined, /git -C [^\n]* push -u origin main/, 're-pushes the scaffold commit');
611
+ assert.ok(!/init\.js|package-core|commit -m|--force/.test(joined), 'does NOT re-scaffold, re-commit or force — only the owed push');
612
+ });
613
+
614
+ await ta('scaffoldStandaloneRepo: the resume push rides the owner token and removes its credential file (F12)', async () => {
615
+ const { cmds, writes } = await rerunScaffold({ lsRemote: { ok: true, stdout: '' }, token: 'gho_RESUME_SECRET' });
616
+ const joined = cmds.join('\n');
617
+ assert.ok(!joined.includes('gho_RESUME_SECRET'), 'token never appears in a command string');
618
+ assert.match(joined, /credential\.helper='store --file=[^']*bongos-gitcred-demo[^']*' ls-remote --heads https:\/\/x-access-token@github\.com\/example-owner\/demo\.git main/);
619
+ assert.match(joined, /credential\.helper='store --file=[^']*bongos-gitcred-demo[^']*' push https:\/\/x-access-token@github\.com\/example-owner\/demo\.git HEAD:main/);
620
+ assert.equal(writes.find((w) => /bongos-gitcred-demo/.test(w.p)).opts.mode, 0o600);
621
+ assert.match(cmds[cmds.length - 1], /rm -f [^\n]*bongos-gitcred-demo/, 'credential file removed last');
622
+ });
623
+
624
+ await ta('scaffoldStandaloneRepo: an UNREACHABLE remote keeps the old skip — only a confirmed-empty remote re-pushes (F12)', async () => {
625
+ const { res, cmds } = await rerunScaffold({ lsRemote: { ok: true, stdout: '', softFailed: true } });
626
+ assert.equal(res.already, true);
627
+ assert.ok(!cmds.some((c) => /push/.test(c)), cmds.join(' | '));
628
+ });
629
+
630
+ await ta('scaffoldStandaloneRepo: a probe that cannot start (credential write throws) keeps the skip — a live instance is never failed by it (F12)', async () => {
631
+ const { res, cmds } = await rerunScaffold({ lsRemote: { ok: true, stdout: '' }, token: 'gho_X', credWriteThrows: true });
632
+ assert.equal(res.already, true);
633
+ assert.ok(!cmds.some((c) => /push|ls-remote/.test(c)), cmds.join(' | '));
634
+ });
635
+
636
+ await ta('scaffoldStandaloneRepo: dry-run of an already-scaffolded repo probes nothing (F12)', async () => {
637
+ const { res, cmds } = await rerunScaffold({ apply: false, lsRemote: { ok: true, stdout: '' } });
638
+ assert.equal(res.already, true);
639
+ assert.deepEqual(cmds, []);
640
+ });
641
+
578
642
  // ── ADR 0134 (task 2090): registry core-install path + fail-soft to vendored ──
579
643
  // Runs the scaffold against a FRESH standaloneBase (no package.json → it proceeds) with
580
644
  // an injected env, and asserts which core-resolution shape it chose from the commands.
@@ -1499,6 +1563,30 @@ await ta('teardownInstance(dedicated): no systemd steps at all, so no reset-fail
1499
1563
  assert.ok(!cmds.some((c) => /reset-failed|daemon-reload|disable --now/.test(c)), cmds.join(' | '));
1500
1564
  });
1501
1565
 
1566
+ // F6 (task 1002718): main() builds deps.doApi on first READ, so a caller must only read it
1567
+ // on a path that really calls DigitalOcean — a throwing getter proves the others never do.
1568
+ function doApiTripwire(deps) {
1569
+ Object.defineProperty(deps, 'doApi', { get() { throw new Error('read deps.doApi on a path that makes no DO call'); } });
1570
+ return deps;
1571
+ }
1572
+
1573
+ await ta('teardownInstance(dedicated, no droplet_id) under --apply never reads deps.doApi (F6)', async () => {
1574
+ const { deps } = recorder({ apply: true });
1575
+ await P.teardownInstance({ ...coTenant, hosting_shape: 'dedicated', droplet_id: null }, doApiTripwire(deps));
1576
+ });
1577
+
1578
+ await ta('provisionInstance(co-tenant) under --apply never reads deps.doApi (F6)', async () => {
1579
+ const { deps } = recorder({ apply: true });
1580
+ const r = await P.provisionInstance(coTenant, doApiTripwire(deps));
1581
+ assert.ok(!/deps\.doApi/.test(String(r && r.error)), String(r && r.error));
1582
+ });
1583
+
1584
+ t('main() constructs the DO client lazily — no DO_API_TOKEN needed until a DO call (F6)', () => {
1585
+ const src = readFileSync(new URL('../scripts/gds/provision.js', import.meta.url), 'utf8');
1586
+ assert.match(src, /get doApi\(\)/, 'deps.doApi is a getter');
1587
+ assert.ok(!/doApi:\s*DoApi \? new DoApi/.test(src), 'no eager construction at deps build');
1588
+ });
1589
+
1502
1590
  await ta('teardownInstance(default): NO sudo (bare, unchanged)', async () => {
1503
1591
  const { deps, cmds } = recorder();
1504
1592
  await P.teardownInstance(coTenant, deps);
@@ -0,0 +1,134 @@
1
+ // tests/rebase_gate_unprovable.mjs — a strand the builder CANNOT clear must stop
2
+ // blocking their queue without stopping being reported (task 1003294).
3
+ //
4
+ // THE BUG. publish-reconciler proves a land by looking for a branch / published tip
5
+ // in the CORE repo. A task that legitimately delivers into the INSTANCE repo (task
6
+ // 1003253 — a real, complete, deployed commit on the instance host) can never be
7
+ // proven here, so the sweep writes needs_rebase_reason='strand:no_branch_no_tip'.
8
+ // ADR 0120 part 3 then refuses that builder EVERY new claim until they land or
9
+ // release it — and they can do neither, because there is nothing in this repository
10
+ // to land. The recorded remedy was a hand-run SQL UPDATE on the droplet.
11
+ //
12
+ // THE SHAPE OF THE FIX, and what these tests are really protecting: the gate and the
13
+ // SURFACING were one function. "Stop blocking" must not become "stop telling them",
14
+ // so the fix SPLITS them — gatingRebaseTasksForBuilder may release a strand past the
15
+ // grace, needsRebaseTasksForBuilder keeps answering in full for me.js / the hall /
16
+ // the CLI. Re-collapsing the two is the regression this file exists to catch.
17
+ //
18
+ // SQL SEMANTICS ARE NOT TESTED HERE — they need a real Postgres and live in
19
+ // tests/claim_gate_rebase.mjs (INTEGRATION set). What is testable DB-free is the
20
+ // wiring, the parameters, and the NULL-handling SHAPE of the predicate, and a
21
+ // fail-open in that shape is the dangerous mistake, so it is asserted directly.
22
+ //
23
+ // Run: node tests/rebase_gate_unprovable.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { readFileSync } from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { createRequire } from 'node:module';
30
+ import { makeRunner } from './helpers.mjs';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const { test, summary } = makeRunner();
34
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
+ const read = (rel) => readFileSync(path.join(REPO_ROOT, rel), 'utf8');
36
+
37
+ const shipMod = require('../modules/lifecycle/db-ship.js');
38
+ const CLAIMS = read('modules/lifecycle/db-claims.js');
39
+ const SHIP = read('modules/lifecycle/db-ship.js');
40
+ const RECON = read('modules/lifecycle/publish-reconciler.js');
41
+
42
+ // A client that records what it was asked, so the query can be inspected without a DB.
43
+ const spyClient = () => {
44
+ const seen = [];
45
+ return { seen, query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } };
46
+ };
47
+
48
+ await test('the CLAIM GATE asks the gating query, not the surfacing one', async () => {
49
+ assert.match(CLAIMS, /gatingRebaseTasksForBuilder\(builderId, client\)/,
50
+ 'claimTask must consult the gating subset');
51
+ assert.doesNotMatch(CLAIMS, /needsRebaseTasksForBuilder\(/,
52
+ 'the gate must NOT use the unfiltered surfacing query — that is the bug, restored');
53
+ });
54
+
55
+ await test('the SURFACING query still returns everything — no exclusion at all', () => {
56
+ const body = SHIP.slice(
57
+ SHIP.indexOf('async function needsRebaseTasksForBuilder'),
58
+ SHIP.indexOf('// The ship gate (task 1002483')
59
+ );
60
+ assert.ok(body.length > 0, 'needsRebaseTasksForBuilder must still exist');
61
+ assert.doesNotMatch(body, /interval '1 millisecond'/,
62
+ 'the surfacing query must not inherit the gate grace — the builder still sees the strand');
63
+ assert.doesNotMatch(body, /no_branch_no_tip/,
64
+ 'the surfacing query must not filter on the strand reason');
65
+ });
66
+
67
+ await test('the gating query is parameterised with the grace and the reason', async () => {
68
+ const c = spyClient();
69
+ await shipMod.gatingRebaseTasksForBuilder(4242, c);
70
+ assert.equal(c.seen.length, 1);
71
+ const { sql, params } = c.seen[0];
72
+ assert.equal(params[0], 4242, 'builder id');
73
+ assert.equal(params[1], shipMod.UNPROVABLE_GATE_GRACE_MS, 'the grace is passed, not inlined');
74
+ assert.equal(params[2], 'strand:no_branch_no_tip',
75
+ 'the reason is passed in the SPELLING announceStrand stores (strand:<reason>)');
76
+ assert.match(sql, /needs_rebase = true AND status = 'confirmed' AND shipped_by = \$1/,
77
+ 'it must still be the same set of owed rebases, only narrowed');
78
+ });
79
+
80
+ await test('an explicit grace overrides the default — the window is tunable', async () => {
81
+ const c = spyClient();
82
+ await shipMod.gatingRebaseTasksForBuilder(1, c, 5_000);
83
+ assert.equal(c.seen[0].params[1], 5_000);
84
+ });
85
+
86
+ await test('a negative or junk grace degrades to 0, never to a negative interval', async () => {
87
+ // A negative interval would make `needs_rebase_at < now() + X` true for FRESH
88
+ // strands — releasing the gate immediately, the exact inversion of the intent.
89
+ for (const bad of [-1, NaN, undefined, 'nonsense']) {
90
+ const c = spyClient();
91
+ await shipMod.gatingRebaseTasksForBuilder(1, c, bad);
92
+ assert.ok(c.seen[0].params[1] >= 0, `grace ${String(bad)} must not go negative`);
93
+ }
94
+ });
95
+
96
+ await test('the predicate is FAIL-CLOSED on NULLs — an unknown keeps the gate', () => {
97
+ const body = SHIP.slice(SHIP.indexOf('async function gatingRebaseTasksForBuilder'));
98
+ const q = body.slice(0, body.indexOf('return rows;'));
99
+ // A bare `needs_rebase_reason = $3` yields NULL for a NULL reason, and
100
+ // NOT(NULL AND true AND true) is NULL, which drops the row from the WHERE —
101
+ // releasing the gate for a row we know nothing about. COALESCE removes the NULL.
102
+ assert.match(q, /COALESCE\(needs_rebase_reason, ''\) = \$3/,
103
+ 'the reason comparison must not be able to produce NULL');
104
+ assert.match(q, /needs_rebase_at IS NOT NULL/,
105
+ 'a NULL timestamp must not participate in the age comparison');
106
+ assert.match(q, /AND NOT \(/, 'the exclusion is expressed as NOT over non-NULL operands');
107
+ });
108
+
109
+ await test('the reconciler ESCALATES past the grace instead of going quiet', () => {
110
+ assert.match(RECON, /UNPROVABLE_GATE_GRACE_MS/,
111
+ 'the sweep must use the SAME constant as the gate, not a second number that can drift');
112
+ assert.match(RECON, /require\('\.\/db-ship\.js'\)/, 'imported rather than restated');
113
+ assert.match(RECON, /UNPROVABLE task \$\{r\.task_id\}/,
114
+ 'an ungated strand must still be named on every sweep');
115
+ assert.match(RECON, /still confirmed and still unlanded/,
116
+ 'the log must say this is not a resolution');
117
+ });
118
+
119
+ await test('releasing the gate does NOT clear the flag — the row stays owed', () => {
120
+ // The fix must not touch needs_rebase/needs_rebase_reason. If a future change
121
+ // "tidied up" by clearing the flag past the grace, the strand would vanish from
122
+ // every surfacing and the task would silently sit confirmed forever.
123
+ const body = SHIP.slice(SHIP.indexOf('async function gatingRebaseTasksForBuilder'));
124
+ const q = body.slice(0, body.indexOf('return rows;'));
125
+ assert.doesNotMatch(q, /UPDATE|SET /, 'the gating query must be a pure read');
126
+ });
127
+
128
+ await test('the grace is bounded and sane by default', () => {
129
+ assert.ok(shipMod.UNPROVABLE_GATE_GRACE_MS > 0, 'a zero default would never gate at all');
130
+ assert.ok(shipMod.UNPROVABLE_GATE_GRACE_MS >= 60 * 60_000,
131
+ 'too short and case (a) — the builder who simply never pushed — escapes the gate that exists for them');
132
+ });
133
+
134
+ summary();