@bongos/core 1.19.318 → 1.19.320

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 (51) hide show
  1. package/.bongos-core.json +125 -40
  2. package/.claude/skills/merge-mode/SKILL.md +3 -1
  3. package/.devcontainer/init-firewall.sh +41 -5
  4. package/clients/bongos-client/examples/hello-world.mjs +11 -2
  5. package/docs/copy-inventory.md +20 -20
  6. package/docs/copy-registry.json +22 -22
  7. package/modules/hall-ui/public/builders.js +0 -1
  8. package/package-lock.json +2 -2
  9. package/package.json +1 -1
  10. package/scripts/gds/audit-replay.sh +20 -3
  11. package/scripts/gds/box.js +79 -86
  12. package/scripts/gds/db-backup-nightly.sh +17 -4
  13. package/scripts/gds/db-backup-offsite.sh +23 -6
  14. package/scripts/gds/discipline-modes.json +1 -1
  15. package/scripts/gds/doc-cli-guard.js +34 -2
  16. package/scripts/gds/fitness.js +8 -4
  17. package/scripts/gds/gds-db-backup.service +15 -4
  18. package/scripts/gds/gds-db-backup.timer +12 -1
  19. package/scripts/gds/gds-db-offsite.service +16 -5
  20. package/scripts/gds/gds-db-offsite.timer +12 -1
  21. package/scripts/gds/go-live.js +142 -48
  22. package/scripts/gds/instance-apex.js +28 -0
  23. package/scripts/gds/newcomer-floor.templates.json +2 -2
  24. package/scripts/gds/provision.js +7 -3
  25. package/scripts/gds/ship-deploy-target.js +388 -0
  26. package/scripts/gds/ship-finish.js +377 -0
  27. package/scripts/gds/ship-flow.js +814 -0
  28. package/scripts/gds/ship-git.js +100 -0
  29. package/scripts/gds/ship-grade-steps.js +376 -0
  30. package/scripts/gds/ship-grade.js +319 -0
  31. package/scripts/gds/ship-io.js +100 -0
  32. package/scripts/gds/ship-land.js +539 -0
  33. package/scripts/gds/ship-memory-sync.js +192 -0
  34. package/scripts/gds/ship-merge.js +297 -0
  35. package/scripts/gds/ship-preflight-steps.js +323 -0
  36. package/scripts/gds/ship-regen.js +286 -0
  37. package/scripts/gds/ship-resume.js +151 -0
  38. package/scripts/gds/ship-session-upload.js +216 -0
  39. package/scripts/gds/ship-state.js +73 -0
  40. package/scripts/gds/ship-surprise.js +84 -0
  41. package/scripts/gds/ship.js +105 -4195
  42. package/scripts/gds/update-channel.js +12 -0
  43. package/scripts/status-probe.mjs +30 -2
  44. package/src/module-api.js +5 -1
  45. package/tests/go_live.mjs +65 -32
  46. package/tests/helpers.mjs +13 -0
  47. package/tests/provision.mjs +7 -1
  48. package/tests/regrade_eligibility.mjs +43 -0
  49. package/tests/ship_assurance.mjs +4 -2
  50. package/tests/ship_publish_error.mjs +43 -0
  51. package/tests/update_channel.mjs +33 -0
@@ -56,4207 +56,109 @@
56
56
  // # task. Honored only when the game state is already
57
57
  // # staged-fresh (a re-run); it can't pre-approve.
58
58
 
59
- const crypto = require('node:crypto');
60
- const { execSync, execFileSync, spawnSync, spawn } = require('node:child_process');
61
- const fs = require('node:fs');
62
- const os = require('node:os');
63
- const path = require('node:path');
64
- const readline = require('node:readline');
65
- const { cliClient, requireSession, loadSession, loadSessionSync, printOnboardingNudge, arg, rewardPolicy } = require('./cli-lib');
66
- // task 1003168 (G04): how a ship reports prose + an unfinished run — see that file.
67
- const { resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages } = require('./ship-honesty.js');
68
59
 
69
- // The instance's reward policy, as reported by the server on GET /me (ADR 0146 /
70
- // task 1002469). Cached module-level because the credits print + the completion
71
- // card are reached through several call chains and the mode is a single
72
- // instance-wide fact — threading it through each would be noise. Stays null
73
- // until a /me lands; rewardPolicy(null) then degrades to the paying default, so
74
- // a missed fetch under-explains rather than wrongly claiming "not paid".
75
- let serverReward = null;
76
- function noteServerReward(meRes) {
77
- const r = meRes && meRes.ok && meRes.data && meRes.data.reward;
78
- if (r && typeof r.mode === 'string') serverReward = r;
79
- return meRes;
80
- }
81
- // ship-preflight + merge-lock carved to modules/lifecycle/ (BV1.R86). A script may
82
- // require a module directly (it's outside the kernel boundary — the grading/economy
83
- // CLI precedent).
84
- const { trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, closeoutPlan } = require('../../modules/lifecycle/ship-preflight');
85
- const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
86
- const { matchOne } = require('../../src/bongos/path-match');
87
- // touches[] cleanup [4/8] (task 879): the predictive drift scanner
88
- // (src/bongos/touches-scan.js) is gone — git merge + /merge-mode are the
89
- // ground-truth collision detector. ship's gate is now smoke-only.
90
- const mergeLock = require('../../modules/lifecycle/merge-lock');
91
- const mergeRegen = require('./git-merge-regen');
92
- const grader = require('../../modules/grading/grader');
93
- const { runShipGate } = require('./sandbox-stage');
94
- // task 1045: the ship-broadcast channel router lives in a shared module so the
95
- // laptop-local path here and the server-mediated publish path (ADR 0055) route by
96
- // the exact same rule. Re-exported below for tests/ship_broadcast_routing.mjs.
97
- const { chooseBroadcastTargets } = require('../../modules/discord/ship-broadcast');
98
-
99
- // V3.R18 (#209): cap the diff we feed the Opus subagent grader. Very large
100
- // diffs blow up prompt size for marginal grading benefit — the subagent has
101
- // Read access to the post-resolve working tree anyway, so it can pull the
102
- // full text of any file it wants to inspect closely.
103
- const GRADER_DIFF_CHAR_CAP = 250_000;
104
- const GRADER_TIMEOUT_MS = 240_000; // 4 min — Opus on a big diff with Read tool calls
105
-
106
- // Where auto-merge runs — the worktree that has `main` checked out, not the
107
- // feature worktree this script lives in. Falls back to two-levels-up if we
108
- // can't ask git (e.g. fresh clone with no `main` worktree set up yet).
109
- const REPO_ROOT = findMainWorktree(__dirname) || path.resolve(__dirname, '..', '..');
110
- // Sound playback is COSMETIC — the ship pipeline must NEVER depend on it. Guard
111
- // the require so a missing/broken player (e.g. .claude/sounds/ absent on a fresh
112
- // clone or after a re-seed) degrades to a silent no-op instead of crashing ship.js
113
- // at load. A cosmetic feature can't be allowed to break shipping (task 1002199).
114
- const { playSound } = (() => {
115
- try {
116
- return require(path.resolve(__dirname, '../../.claude/sounds/play.js'));
117
- } catch {
118
- return { playSound: () => {} };
119
- }
120
- })();
121
- // The laptop-mode SSH deploy target is resolved at land time from config/deploy.json
122
- // ("sshTarget") or <PREFIX>_DEPLOY_SSH_TARGET — see resolveDeployTarget(). There is NO
123
- // hardcoded default: the core carries no deploy target of its own. The founder
124
- // instance's old game-box SSH target used to be baked in here, which post-migration
125
- // meant a laptop-mode ship deployed to the WRONG server (task 1002202).
126
- const BEHIND_MAIN_LIMIT = 5;
127
-
128
- // arg() — the =-aware flag-value parser now lives in cli-lib (the canonical
129
- // single source, C11); imported above. ship.js's call sites pass `args`
130
- // explicitly, which cli-lib.arg() accepts (its default is process.argv.slice(2)).
131
-
132
- // ---------- Surprise / learning-capture helpers (Task #32) ----------
133
- //
134
- // Forced "anything surprise you?" gate so we never miss a learning. The flag
135
- // can be supplied explicitly (--surprise "text" / --surprise none / --no-surprise),
136
- // prompted interactively when stdin is a TTY, or default to no-surprise with a
137
- // stderr warning in non-interactive contexts.
138
-
139
- function promptSurprise() {
140
- return new Promise((resolve) => {
141
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
142
- rl.question('Anything surprise you in this work? (Enter for none, or describe): ', (answer) => {
143
- rl.close();
144
- resolve(answer == null ? '' : String(answer).trim());
145
- });
146
- });
147
- }
148
-
149
- function deriveTagsFromPaths(paths) {
150
- // Each entry is a path/glob; take its leading folder segment as a tag.
151
- // touches[] cleanup [3/8] (task 878): renamed from deriveTagsFromTouches — it
152
- // now runs over the committed changed-files (reality), not the predicted
153
- // touches[]. Same folder-segment logic, just a more honest input.
154
- const tags = new Set();
155
- for (const t of paths || []) {
156
- if (!t) continue;
157
- const first = String(t).split('/')[0].trim();
158
- if (first && first !== '*' && first !== '**') tags.add(first);
159
- }
160
- if (tags.size === 0) tags.add('pms');
161
- return [...tags];
162
- }
163
-
164
- async function captureSurpriseLearning(surpriseText, claim, taskId) {
165
- const text = String(surpriseText).trim();
166
- const title = text.length > 80 ? text.slice(0, 77) + '...' : text;
167
- // touches[] cleanup [3/8] (task 878): tag + relate the learning by what was
168
- // ACTUALLY shipped (the committed diff), not the predicted touches[]. Fall
169
- // back to touches[] only when the diff is unavailable (no git / empty baseline).
170
- const baseline = computeGraderBaseline(claim);
171
- const changedFiles = computeChangedFiles(baseline);
172
- const relatedFiles = changedFiles.length > 0 ? changedFiles : (claim.touches || []);
173
- const tags = deriveTagsFromPaths(relatedFiles);
174
- const r = await (await cliClient()).learnings.postLearnings({
175
- body: {
176
- title,
177
- body_md: text,
178
- tags,
179
- related_files: relatedFiles,
180
- source: 'ship-surprise',
181
- source_ref: `task-${taskId}-claim-${claim.claim_id}`,
182
- task_id: taskId,
183
- },
184
- });
185
- if (r.status === 200 && r.data?.skipped) {
186
- console.log(`📝 Learning already captured for this claim (skipped duplicate).`);
187
- return true;
188
- }
189
- if (r.status !== 201) {
190
- console.error(` learning capture failed (${r.status}):`, r.data);
191
- return false;
192
- }
193
- console.log(`📝 Captured learning: ${title}`);
194
- return true;
195
- }
196
-
197
- // ---------- Memory sync-on-ship (Task #274 / V3.R55) ----------
198
- //
199
- // After a successful ship, push the builder's local Claude Code memory dir to
200
- // the server (POST /api/gds/memory/sync), so a builder's MEMORY.md +
201
- // feedback_*.md follow them across machines and survive a dead laptop. This is
202
- // the push half of ADR 0024's cloneable-memory chain; #275 wires the pull side.
203
- //
204
- // Convention for locating the memory dir (NO hardcoded identity — CLAUDE.md §10):
205
- // Claude Code stores per-project memory under
206
- // <homedir>/.claude/projects/<encoded-project-path>/memory/
207
- // where <encoded-project-path> is the project's ABSOLUTE path with every '/'
208
- // and '.' replaced by '-' (e.g. /Users/x/Desktop/Claude-OTB →
209
- // -Users-x-Desktop-Claude-OTB). We derive the project path from the MAIN
210
- // worktree root (REPO_ROOT), not this ephemeral feature worktree, because the
211
- // memory dir is keyed to the canonical project checkout. CLAUDE_CONFIG_DIR
212
- // overrides the ~/.claude base when set (matches Claude Code's own behavior).
213
- //
214
- // Conflict policy: storeMemoryFile is an unconditional UPSERT (last-write-wins,
215
- // server clock — ADR 0024 §3), so we push every file and let the server
216
- // overwrite. Smarter mtime-diffing (skip files the server already has newer) is
217
- // a possible follow-up; for #274 a full push is correct and cheap (KB of md).
218
- //
219
- // BEST-EFFORT: any failure here is logged as a warning and swallowed — a memory
220
- // sync hiccup (dir missing, API down, quota) must NEVER fail or undo a ship.
221
-
222
- function claudeConfigBase() {
223
- // Claude Code honors CLAUDE_CONFIG_DIR as the base for its state dir; default
224
- // is ~/.claude. Resolve via os.homedir() so this works for any user.
225
- return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
226
- }
227
-
228
- function encodeProjectPath(absProjectPath) {
229
- // Claude Code's encoding: every NON-ALPHANUMERIC char becomes '-'. The old
230
- // `[/.]` class silently no-op'd on Windows (separator '\', drive ':', spaces),
231
- // so the transcript was never found, no session_record was written, and the
232
- // cost-plus reward — the only stream a cost-plus-only instance pays (ADR 0146)
233
- // — could never book: every Windows ship paid 0 (task 1002471). Same rule as
234
- // scripts/gds/onboarding-config.js, which already had it right. POSIX output is
235
- // unchanged for paths whose only specials are '/' and '.'.
236
- return String(absProjectPath).replace(/[^a-zA-Z0-9]/g, '-');
237
- }
238
-
239
- // resolveTranscriptPath — find this session's transcript .jsonl. Claude Code
240
- // keys the transcript to the project dir of the CWD where the session RUNS, so
241
- // a session in a git worktree stores it under the WORKTREE-encoded dir, not the
242
- // main checkout. (6D.1 #540 shipped resolving only REPO_ROOT, which silently
243
- // missed every worktree session — #551.) We try the session-local dir
244
- // (process.cwd()) first, then REPO_ROOT as a fallback. Pure (exists + base
245
- // injectable for tests). Returns the first existing path, or null.
246
- function resolveTranscriptPath(sessionId, projectDirs, opts = {}) {
247
- const exists = opts.existsFn || fs.existsSync;
248
- const base = opts.base || claudeConfigBase();
249
- for (const dir of projectDirs) {
250
- if (!dir) continue;
251
- const p = path.join(base, 'projects', encodeProjectPath(path.resolve(dir)), `${sessionId}.jsonl`);
252
- if (exists(p)) return p;
253
- }
254
- return null;
255
- }
256
-
257
- // resolveBuilderModel — the REAL model that authored this session's work, read
258
- // from the session transcript (dominant by output tokens, synthetic turns
259
- // skipped), so selectGraderModel gets a true cross-model input instead of the
260
- // ASSUMED_BUILDER_MODEL fallback (task 1002659 — everything graded on the
261
- // assumed-builder default before this). Best-effort: no session id, no
262
- // transcript, or any read error → null → the grader assumes, as before.
263
- function resolveBuilderModel(opts = {}) {
264
- try {
265
- const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
266
- // Same path-traversal guard as uploadSessionOnShip: the id lands in a path.
267
- if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
268
- const transcriptPath = resolveTranscriptPath(sessionId, [process.cwd(), REPO_ROOT].filter(Boolean), opts);
269
- if (!transcriptPath) return null;
270
- const perModel = new Map();
271
- for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
272
- if (!line.trim()) continue;
273
- let o;
274
- try { o = JSON.parse(line); } catch (_) { continue; }
275
- const msg = o && o.message;
276
- if (!msg || !msg.model || msg.model === '<synthetic>' || !msg.usage) continue;
277
- perModel.set(msg.model, (perModel.get(msg.model) || 0) + (Number(msg.usage.output_tokens) || 0));
278
- }
279
- let best = null;
280
- let bestTokens = 0;
281
- for (const [model, tokens] of perModel) {
282
- if (tokens > bestTokens) { best = model; bestTokens = tokens; }
283
- }
284
- return best;
285
- } catch (_) {
286
- return null;
287
- }
288
- }
289
-
290
- function resolveMemoryDir() {
291
- // REPO_ROOT is the main worktree (canonical project checkout). The memory dir
292
- // is keyed to that path, not the per-task feature worktree we're shipping from.
293
- const encoded = encodeProjectPath(path.resolve(REPO_ROOT));
294
- return path.join(claudeConfigBase(), 'projects', encoded, 'memory');
295
- }
296
-
297
- // Enumerate the memory dir into [{ path, content, mtime }]. `path` is RELATIVE
298
- // to the memory dir (matches the server's (builder_id, relative-path) key).
299
- // Recurses so nested memory files (if Claude Code ever nests them) are caught.
300
- // Returns [] if the dir doesn't exist (not all builders have memory).
301
- function collectMemoryFiles(memDir) {
302
- if (!fs.existsSync(memDir)) return [];
303
- const out = [];
304
- const walk = (absDir, relPrefix) => {
305
- let entries;
306
- try {
307
- entries = fs.readdirSync(absDir, { withFileTypes: true });
308
- } catch (_) {
309
- return;
310
- }
311
- for (const ent of entries) {
312
- const abs = path.join(absDir, ent.name);
313
- const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
314
- if (ent.isDirectory()) {
315
- walk(abs, rel);
316
- } else if (ent.isFile()) {
317
- try {
318
- const content = fs.readFileSync(abs, 'utf8');
319
- const mtime = fs.statSync(abs).mtime.toISOString();
320
- // Use POSIX-style relative paths regardless of OS so the server key
321
- // is stable across machines.
322
- out.push({ path: rel.split(path.sep).join('/'), content, mtime });
323
- } catch (_) {
324
- /* skip unreadable file — best-effort */
325
- }
326
- }
327
- }
328
- };
329
- walk(memDir, '');
330
- return out;
331
- }
332
-
333
- async function syncMemoryOnShip() {
334
- try {
335
- const memDir = resolveMemoryDir();
336
- const files = collectMemoryFiles(memDir);
337
- if (files.length === 0) {
338
- // Silent-ish: a builder with no memory dir is normal. One quiet line so
339
- // the absence is visible in the ship log without looking like an error.
340
- console.log(' memory sync: no local memory dir found — skipping (this is fine).');
341
- return;
342
- }
343
- console.log(` syncing ${files.length} memory file${files.length === 1 ? '' : 's'} to the server...`);
344
- const r = await (await cliClient({ allowUnauthenticated: true })).memory.postMemorySync({ body: { files } });
345
- if (r.status === 401) {
346
- console.log(' memory sync: skipped (session not valid for sync; non-blocking).');
347
- return;
348
- }
349
- const d = r.data || {};
350
- if (r.ok) {
351
- const errN = Array.isArray(d.errors) ? d.errors.length : 0;
352
- console.log(
353
- ` memory sync: ${d.stored ?? 0} stored, ${d.skipped ?? 0} skipped${errN ? `, ${errN} error(s)` : ''}.`
354
- );
355
- } else if (r.status === 413) {
356
- console.log(` memory sync: quota/size limit hit (${d.error || 'quota'}) — prune memory and re-sync. Non-blocking; ship is complete.`);
357
- } else {
358
- console.log(` memory sync: server returned ${r.status} (non-blocking):`, d.error || d);
359
- }
360
- } catch (e) {
361
- // Any error at all — network, fs, anything — must not fail the ship.
362
- console.log(' memory sync failed (non-blocking):', e && e.message ? e.message : e);
363
- }
364
- }
365
-
366
- // ---------- Session upload-on-ship (6D.1 / ADR 0027) ----------
367
- //
368
- // After a successful ship, upload a COMPACT, secret-scrubbed digest of this
369
- // Claude Code session (POST /api/gds/sessions), so the BFG (a local Claude Code
370
- // routine — 6D.3) can later search the corpus (6D.2) and deep-dive the wasteful
371
- // sessions. We never upload raw transcript CONTENT — only per-turn metadata
372
- // (role, tool name, error flag, token usage, length) + short scrubbed snippets
373
- // + token aggregates. That keeps the row small and the secret surface minimal
374
- // (ADR 0027). BEST-EFFORT: any failure is a warning, never a ship failure —
375
- // exactly like syncMemoryOnShip.
376
-
377
- // The transcript-grade secret scrubber + its ADR-0022 internals now live in
378
- // modules/security/secret-scrub.js — extracted (task 1211 / GDS-V4 R72) so the
379
- // public-mirror export pipeline can reuse the ONE copy of the secret shapes
380
- // without requiring ship.js (whose grader / Discord / sound / session deps are
381
- // absent in a bare CI job). Imported + re-exported here so this file's public
382
- // surface and tests/scrubber_corpus.mjs are unchanged. See that module for the
383
- // recall-first design (a missed secret is a compromise; over-redaction is cosmetic).
384
- const {
385
- REDACTED, scrubSecrets, shannonEntropy, looksHighEntropySecret,
386
- redactHighEntropy, collectEnvSecrets,
387
- } = require('../../modules/security/secret-scrub');
388
-
389
- // snippetOf + buildSessionDigest moved to ./session-digest-build (task 1003217,
390
- // serving the de-monolith goal of task 1003203) — pure, separately tested, and
391
- // shared with session-digest.js. Re-exported below so this file's public surface
392
- // and tests/session_records.mjs are unchanged.
393
- const {
394
- snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
395
- } = require('./session-digest-build');
396
-
397
-
398
- // classifyUploadSkip — PURE decision for whether the session upload must skip,
399
- // and the clear, actionable reason why. Separated from I/O so it is unit-tested
400
- // (tests/ship_upload_skip.mjs). Every reason names the cause AND states the
401
- // consequence plainly: a skip means this ship is absent from the BFG session
402
- // corpus — the silent gap #608 surfaced, this (#617) makes it loud + auditable.
403
- // Takes already-resolved preconditions so it stays free of env + fs reads.
404
- function classifyUploadSkip({ sessionId, sessionIdSafe, transcriptPath, pathsTried }) {
405
- const tail = 'This ship will NOT appear in the BFG session corpus.';
406
- if (!sessionId) {
407
- return {
408
- skip: true,
409
- code: 'no_session_id',
410
- reason:
411
- 'CLAUDE_CODE_SESSION_ID is not set — this ship did not run inside an interactive Claude Code ' +
412
- `session (e.g. an autonomous / merge-mode / cron context), so there is no transcript to digest. ${tail}`,
413
- };
414
- }
415
- if (!sessionIdSafe) {
416
- return {
417
- skip: true,
418
- code: 'unsafe_session_id',
419
- reason: `CLAUDE_CODE_SESSION_ID is not a plain token, refusing to interpolate it into a filesystem path. ${tail}`,
420
- };
421
- }
422
- if (!transcriptPath) {
423
- const where = Array.isArray(pathsTried) && pathsTried.length ? ` Looked in: ${pathsTried.join(' ; ')}.` : '';
424
- return {
425
- skip: true,
426
- code: 'transcript_not_found',
427
- reason: `the session transcript .jsonl was not found for ${sessionId}.${where} ${tail}`,
428
- };
429
- }
430
- return { skip: false };
431
- }
432
-
433
- // recordUploadSkip — surface a skipped/failed session upload LOUDLY (not the old
434
- // quiet console.log that scrolled past + vanished in non-interactive runs) AND
435
- // leave a durable, append-only trace so a missed capture is auditable after the
436
- // fact. ~/.config/otb survives worktree churn (same home as the GDS session
437
- // token). Best-effort: never throws into the ship path. opts.dir overrides the
438
- // trace dir for tests. Returns the trace file path written, or null.
439
- function recordUploadSkip(taskId, { code, reason }, opts = {}) {
440
- console.log(` ⚠️ session upload SKIPPED [${code}]: ${reason}`);
441
- try {
442
- const dir = opts.dir || require('../../src/instance-config').configHome();
443
- fs.mkdirSync(dir, { recursive: true });
444
- const file = path.join(dir, 'session-upload-skips.jsonl');
445
- const row = {
446
- ts: new Date().toISOString(),
447
- task_id: taskId != null && Number.isFinite(Number(taskId)) ? Number(taskId) : null,
448
- session_id: process.env.CLAUDE_CODE_SESSION_ID || null,
449
- code,
450
- reason,
451
- };
452
- fs.appendFileSync(file, JSON.stringify(row) + '\n');
453
- return file;
454
- } catch (e) {
455
- console.log(` (could not persist skip trace: ${e && e.message ? e.message : e})`);
456
- return null;
457
- }
458
- }
459
-
460
- async function uploadSessionOnShip(taskId) {
461
- try {
462
- const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
463
- // Path-traversal guard: the session id is interpolated into a filesystem
464
- // path below, so reject anything that isn't a plain UUID-ish token. A
465
- // hostile env value with `../` could otherwise point the read elsewhere.
466
- const sessionIdSafe = !!sessionId && /^[A-Za-z0-9_-]+$/.test(sessionId);
467
- // The transcript is keyed to the session's CWD (the worktree when shipping
468
- // from one), so try that before REPO_ROOT (#551). Reconstruct the exact
469
- // paths resolveTranscriptPath checks so a transcript-not-found skip can name
470
- // precisely where it looked (actionable, not a bare "not found").
471
- const dirsTried = [process.cwd(), REPO_ROOT].filter(Boolean);
472
- const pathsTried = sessionIdSafe
473
- ? dirsTried.map((d) =>
474
- path.join(claudeConfigBase(), 'projects', encodeProjectPath(path.resolve(d)), `${sessionId}.jsonl`)
475
- )
476
- : [];
477
- const transcriptPath = sessionIdSafe ? resolveTranscriptPath(sessionId, dirsTried) : null;
478
-
479
- const decision = classifyUploadSkip({ sessionId, sessionIdSafe, transcriptPath, pathsTried });
480
- if (decision.skip) {
481
- recordUploadSkip(taskId, decision);
482
- return null;
483
- }
484
- const raw = fs.readFileSync(transcriptPath, 'utf8');
485
- const entries = [];
486
- for (const line of raw.split('\n')) {
487
- const t = line.trim();
488
- if (!t) continue;
489
- try {
490
- entries.push(JSON.parse(t));
491
- } catch (_) {
492
- /* skip a malformed line — best-effort */
493
- }
494
- }
495
- // Dynamic secrets to scrub: every secret-shaped env VALUE (Apple/MAC_* CI
496
- // creds, a baked GDS/DO token, GITHUB_TOKEN, ANTHROPIC_API_KEY, …) plus this
497
- // run's live GDS session token. Defense in depth on top of the pattern +
498
- // entropy scrubbers — these literals get redacted even with no known shape.
499
- const extraSecrets = collectEnvSecrets();
500
- // loadSession() returns null when there's no session — unlike
501
- // requireSession(), it never process.exit()s, so the best-effort /
502
- // non-blocking contract holds even with no valid session.
503
- const s = await loadSession();
504
- if (s && s.token) extraSecrets.push(s.token);
505
- const digest = buildSessionDigest(entries, { extraSecrets });
506
- // task 1270: surface the session usage so the completion card can show
507
- // Claude tokens + an estimated cost. Returned even when the POST below fails
508
- // (we already have the digest) — the card is best-effort either way.
509
- const usageOut = {
510
- totalTokens: digest.totalTokens,
511
- modelUsage: (digest.metrics && digest.metrics.model_usage) || {},
512
- };
513
- // drachmae_earned is computed SERVER-SIDE from the credit_log ledger
514
- // (POST /sessions). ship.js no longer fetches or sends it — that removes an
515
- // extra apiCall whose 401 path would process.exit(2) and break this
516
- // best-effort, non-blocking contract.
517
- const payload = {
518
- session_id: sessionId,
519
- task_ids: [Number(taskId)].filter(Number.isFinite),
520
- total_tokens: digest.totalTokens,
521
- duration_seconds: digest.durationSeconds,
522
- started_at: digest.startedAt,
523
- ended_at: digest.endedAt,
524
- detail: digest.detail,
525
- metrics: digest.metrics,
526
- model_usage: digest.metrics.model_usage || {}, // per-model tokens → cost-plus reward (task 1005)
527
- };
528
- const r = await (await cliClient({ allowUnauthenticated: true })).sessions.postSessions({ body: payload });
529
- if (r.ok) {
530
- console.log(
531
- ` session upload: stored digest (${digest.metrics.assistant_turns} assistant turns, ${digest.totalTokens} tokens, ${digest.metrics.tool_error_count} tool errors, ${digest.metrics.harness_retry_count} harness retries).`
532
- );
533
- } else if (r.status === 401) {
534
- recordUploadSkip(taskId, {
535
- code: 'session_invalid',
536
- reason:
537
- 'POST /api/gds/sessions returned 401 — the GDS session token is missing or invalid, so the ' +
538
- 'digest was rejected. Re-auth with /builder-reauth. This ship will NOT appear in the BFG session corpus.',
539
- });
540
- } else {
541
- recordUploadSkip(taskId, {
542
- code: `http_${r.status}`,
543
- reason:
544
- `POST /api/gds/sessions returned ${r.status}${r.data && r.data.error ? ` (${apiErrorLine(r.data)})` : ''} — ` +
545
- 'the API rejected or could not store the digest. This ship will NOT appear in the BFG session corpus.',
546
- });
547
- }
548
- return usageOut;
549
- } catch (e) {
550
- recordUploadSkip(taskId, {
551
- code: 'exception',
552
- reason:
553
- `session upload threw before completing: ${e && e.message ? e.message : e}. ` +
554
- 'This ship will NOT appear in the BFG session corpus.',
555
- });
556
- return null;
557
- }
558
- }
559
-
560
- function gitOk(cmd, opts = {}) {
561
- try {
562
- return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
563
- } catch (_) {
564
- return null;
565
- }
566
- }
567
-
568
- function shellOk(cmd, opts = {}) {
569
- try {
570
- execSync(cmd, { stdio: 'inherit', ...opts });
571
- return true;
572
- } catch (_) {
573
- return false;
574
- }
575
- }
576
-
577
- // argvOk / argvOut — the two no-shell exec primitives behind the git/gh wrappers.
578
- // Run `<cmd> <args...>` so each arg reaches the program as a literal argv element
579
- // (never interpolated into a shell string) — the discipline that closes the
580
- // autoMerge injection (#681), since branch names + DB-sourced value_summary flow
581
- // through here. `shell: false` is forced AFTER the opts spread so a caller can
582
- // never pass `{ shell: true }` and silently re-enable shell interpretation — the
583
- // no-shell guarantee is the whole point and must not be overridable.
584
- // argvOk — exit status only → boolean (gitArgvOk / ghArgvOk). stdio 'inherit'.
585
- // argvOut — capture trimmed stdout → string|null (ghJson / fetchDeployedVersion).
586
- // Default stdio captures BOTH stdout and stderr; a caller that must
587
- // DISCARD stderr passes its own `stdio: ['ignore','pipe','ignore']`.
588
- function argvOk(cmd, args, opts = {}) {
589
- try {
590
- execFileSync(cmd, args, { stdio: 'inherit', ...opts, shell: false });
591
- return true;
592
- } catch (_) {
593
- return false;
594
- }
595
- }
596
-
597
- function argvOut(cmd, args, opts = {}) {
598
- try {
599
- return execFileSync(cmd, args, {
600
- encoding: 'utf8',
601
- stdio: ['ignore', 'pipe', 'pipe'],
602
- ...opts,
603
- shell: false,
604
- }).trim();
605
- } catch (_) {
606
- return null;
607
- }
608
- }
609
-
610
- // gitArgvOk — the git boolean runner. The boolean/`stdio:'inherit'` contract
611
- // matches shellOk so callers can swap one for the other; see buildMergeArgv + the
612
- // #681 regression test.
613
- function gitArgvOk(args, opts = {}) {
614
- return argvOk('git', args, opts);
615
- }
616
-
617
- // sanitizeGitHubRefs — rewrite "#NNN" → "task NNN" in any text that lands on a
618
- // GitHub-rendered surface that can't carry a markdown link (commit messages,
619
- // PR titles). GitHub autolinks every bare "#NNN" there to its OWN PR/issue
620
- // number space, which our GDS task ids long since diverged from — so a value
621
- // summary like "#914 player straw hat" rendered a dead link to issues/914 on
622
- // every merge commit (task 916). Pure + exported for the regression test.
623
- function sanitizeGitHubRefs(text) {
624
- return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
625
- }
626
-
627
- // buildMergeArgv — construct the argv for the auto-merge commit. Pure +
628
- // exported so the regression test can assert the untrusted value_summary lands
629
- // as a single literal argv element (never spliced into a shell string). The
630
- // merge message is `Merge <branch>: <summary>` with task refs de-#-ed (commit
631
- // messages can't carry markdown links, so a bare "#NNN" would autolink to a
632
- // dead GitHub issue — task 916); transport stays argv, never a shell string.
633
- function buildMergeArgv(branch, valueSummary) {
634
- const summary = sanitizeGitHubRefs(valueSummary) || `(no value summary)`;
635
- const mergeMsg = `Merge ${branch}: ${summary}`;
636
- return ['merge', '--no-ff', branch, '-m', mergeMsg];
637
- }
638
-
639
- // ---------- CI deploy mode (#679, ADR 0042) ----------
640
- //
641
- // Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
642
- // switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
643
- //
644
- // 'laptop' (default) — the operator's machine merges to main locally and, IFF a
645
- // deploy target is configured (config/deploy.json "sshTarget" or
646
- // <PREFIX>_DEPLOY_SSH_TARGET, resolveDeployTarget()), SSHes `~/deploy.sh` on it.
647
- // The core carries NO target of its own, so by default this is a merge-to-main
648
- // with no SSH deploy — a change reaches a live instance via package-core +
649
- // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). When a target IS
650
- // set it requires that host's SSH key, so only the operator can land.
651
- //
652
- // 'ci' — NO builder box holds the prod key. ship pushes the branch, opens a
653
- // PR, and enables GitHub auto-merge; once required checks pass, GitHub
654
- // merges to main and .github/workflows/deploy-prod.yml deploys from
655
- // Actions. ship then CONFIRMS the deploy landed (PR merged + the
656
- // deploy-prod workflow run succeeded for the merge commit + public
657
- // healthz 200) so the synchronous "deploy-confirmed" UX is preserved
658
- // without the key. This is what lets a Windows/Chromebook builder with no
659
- // main checkout and no droplet key land their own work.
660
- //
661
- // parseDeployMode is pure (env string + raw config file contents → mode) and
662
- // exported so the test can assert the precedence + the safe default without
663
- // touching fs/env. The default is 'laptop' precisely so this code is INERT
664
- // until the deliberate, atomic cutover (config flip + GitHub arming together).
665
- function parseDeployMode(envVal, configRaw) {
666
- const fromEnv = String(envVal || '').trim().toLowerCase();
667
- if (fromEnv === 'ci' || fromEnv === 'laptop') return fromEnv;
668
- if (configRaw) {
669
- try {
670
- const m = String(JSON.parse(configRaw).mode || '').trim().toLowerCase();
671
- if (m === 'ci' || m === 'laptop') return m;
672
- } catch (_) {
673
- /* malformed config → fall through to the safe default */
674
- }
675
- }
676
- return 'laptop';
677
- }
678
-
679
- // effectiveDeployMode — credential- and protection-aware adjustment of the parsed
680
- // mode (pure; exported for the regression test). The 'laptop' land path pushes
681
- // main + SSHes the droplet FROM THIS MACHINE, so it requires a local GitHub push
682
- // credential. A machine without one — a dev box, or a fresh/stale local clone that
683
- // can't even `git pull` (token-less origin, ADR 0053) — physically cannot run it;
684
- // it must ship via the 'ci' server-mediated publish path instead
685
- // (pushVia()==='server', ADR 0055), which lets the GDS server push with its own
686
- // credential. So when the parsed mode is 'laptop' but the machine can't push
687
- // locally AND the mode was NOT pinned by an explicit OTB_DEPLOY_MODE, fall back to
688
- // 'ci' rather than strand every ship at merge. This is the scootermack 2026-06-22
689
- // case: a checkout left at the pre-cutover 'laptop' default that can't pull
690
- // forward to 'ci', so it kept attempting a push it had no credential for. An
691
- // explicit env override always wins (tests/staging may force 'laptop'). Mirrors
692
- // pushVia()'s existing credential-awareness — the asymmetry (pushVia smart,
693
- // resolveDeployMode blind) WAS the bug. (task 1449)
694
- //
695
- // Second input, same shape (task 1002629): a machine WITH push credentials but a
696
- // branch-PROTECTED main also cannot run the laptop land — GitHub refuses the
697
- // direct `git push origin main` no matter whose credential it is. When task
698
- // 1002619 armed protection (required `unit` check), every ship resolved 'laptop'
699
- // and pushed into the wall, stranding graded-PASS tasks at confirmed (tasks
700
- // 1002608/1002609; learning 1000177). mainProtected must be STRICTLY true to
701
- // trigger — null/undefined means the probe couldn't answer (no gh, offline, no
702
- // permission) and keeps today's behavior rather than rerouting on a guess.
703
- function effectiveDeployMode(mode, { canPushLocally, envForced, mainProtected } = {}) {
704
- if (mode === 'laptop' && !envForced && !canPushLocally) return 'ci';
705
- if (mode === 'laptop' && !envForced && mainProtected === true) return 'ci';
706
- return mode;
707
- }
708
-
709
- // mainIsProtected — true/false when GitHub answers, null when it can't. The
710
- // `gh api` resolves {owner}/{repo} from the checkout's origin; any failure
711
- // (gh missing, offline, non-200) is null = unknown, which effectiveDeployMode
712
- // treats as "no change". Same spawn discipline as ghAvailableAndAuthed().
713
- function mainIsProtected() {
714
- try {
715
- const r = spawnSync('gh', ['api', 'repos/{owner}/{repo}/branches/main', '--jq', '.protected'], {
716
- encoding: 'utf8',
717
- shell: process.platform === 'win32',
718
- windowsHide: true,
719
- timeout: 15000,
720
- });
721
- if (!r || r.error || r.status !== 0) return null;
722
- const out = String(r.stdout || '').trim();
723
- if (out === 'true') return true;
724
- if (out === 'false') return false;
725
- return null;
726
- } catch (_) {
727
- return null;
728
- }
729
- }
730
-
731
- function resolveDeployMode() {
732
- const configRaw = readDeployConfigRaw();
733
- const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
734
- const envForced = ['ci', 'laptop'].includes(
735
- String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
736
- );
737
- // Probe `gh` ONLY when it can change the answer (resolved 'laptop', no env
738
- // override) — the happy 'ci' path and an env-pinned mode spawn no extra gh,
739
- // keeping the documented spawn discipline (the line-1019 note). The protection
740
- // probe runs only when the credential check kept us on 'laptop' (a
741
- // credential-less machine already falls back without it).
742
- if (mode === 'laptop' && !envForced) {
743
- const canPushLocally = ghAvailableAndAuthed();
744
- const mainProtected = canPushLocally ? mainIsProtected() : null;
745
- const effective = effectiveDeployMode(mode, { canPushLocally, envForced, mainProtected });
746
- if (effective === 'ci' && mainProtected === true) {
747
- console.log(' deploy mode: main is branch-protected (direct pushes refused) — landing via the ci PR path');
748
- }
749
- return effective;
750
- }
751
- return mode;
752
- }
753
-
754
- // readDeployConfigRaw — raw contents of config/deploy.json (or null if absent /
755
- // unreadable). One reader shared by resolveDeployMode (the mode switch) and
756
- // resolveDeployTarget (the SSH target) so they never diverge on where the file is.
757
- function readDeployConfigRaw() {
758
- try {
759
- return fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
760
- } catch (_) {
761
- return null; // no config file → callers apply their safe default
762
- }
763
- }
764
-
765
- // safeBranding — the instance brand pack, or null if it can't be read. Guarded the
766
- // same way ship.js guards the sound player (line ~91): a deploy must never crash
767
- // because the brand file is missing on a fresh / re-seeded checkout.
768
- function safeBranding() {
769
- try {
770
- return require('../../src/branding').branding();
771
- } catch (_) {
772
- return null;
773
- }
774
- }
775
-
776
- // readSessionApiBase — the signed-in session's persisted api_base (the instance the
777
- // CLI is actually pointed at), or null when not signed in / unreadable. This is the
778
- // session's OWN value only — deliberately NOT readSessionToken().apiBase, which
779
- // already folds in the env override + branding default; resolveInstanceBase orders
780
- // that precedence itself so it stays pure over its injected inputs.
781
- function readSessionApiBase() {
782
- try {
783
- const s = loadSessionSync();
784
- return (s && typeof s.api_base === 'string' && s.api_base.trim()) ? s.api_base.trim() : null;
785
- } catch (_) {
786
- return null;
787
- }
788
- }
789
-
790
- // resolveInstanceBase — the ORIGIN of the instance this ship is landing on, used by
791
- // the ci-land deployed-version probe (/version), the /healthz re-probe, the ci
792
- // PR-body deploy note, and the manual-confirm curl guidance. Precedence mirrors the
793
- // CLI's own base resolution: <PREFIX>_API_BASE env → the signed-in session's
794
- // api_base → branding publicOrigin → null (unknowable). There is NO hardcoded
795
- // founder host — 5 sites here baked in `https://amazonprimea.com`, so a ci-land /
796
- // manual-confirm on a migrated instance pointed the builder at the WRONG box; the
797
- // ci-land/guidance sibling of resolveDeployTarget()'s laptop-mode fix (task 1002232
798
- // ⊃ task 1002202). Pure over its injected inputs (env / sessionApiBase / branding)
799
- // so the precedence is unit-tested without fs/env/network; the defaults read the
800
- // real session file + brand pack.
801
- function resolveInstanceBase({
802
- env = process.env,
803
- sessionApiBase = readSessionApiBase(),
804
- branding = safeBranding(),
805
- } = {}) {
806
- const ic = require('../../src/instance-config');
807
- const trim = (s) => (typeof s === 'string' && s.trim() ? s.trim().replace(/\/+$/, '') : null);
808
- const envBase = trim(ic.resolveEnv('API_BASE', { env }));
809
- if (envBase) return envBase;
810
- const sess = trim(sessionApiBase);
811
- if (sess) return sess;
812
- const origin = branding && branding.domains && branding.domains.publicOrigin;
813
- return trim(origin);
814
- }
815
-
816
- // deployedVersionUrl — the instance's /version endpoint, or null when no instance
817
- // origin is resolvable (then fetchDeployedVersion is a no-op). Derived from
818
- // resolveInstanceBase — no hardcoded host (task 1002232).
819
- function deployedVersionUrl() {
820
- const base = resolveInstanceBase();
821
- return base ? `${base}/version` : null;
822
- }
823
-
824
- // resolveDeployTarget — WHERE a laptop-mode ship SSHes `~/deploy.sh`, and which
825
- // /healthz it re-probes when deploy.sh's own health check races the restart window.
826
- // Pure (env + raw config + branding → target) and exported so the precedence and the
827
- // safe "no target" default are unit-tested without touching fs/env/network.
828
- //
829
- // There is deliberately NO hardcoded default. The founder instance's old game-box
830
- // SSH target was baked in here, so a laptop-mode ship from a migrated
831
- // checkout SSHed the WRONG server (task 1002202). The core carries no deploy target
832
- // of its own — under the standalone model a core change reaches a live instance via
833
- // package-core + `bongos upgrade` (pin-bump), not an SSH deploy (ADR 0108). An
834
- // instance whose OWN checkout is its live server opts back into laptop-SSH deploy by
835
- // setting the target:
836
- // config/deploy.json → { "sshTarget": "user@host", "healthzUrl": "https://…/healthz" }
837
- // env → <PREFIX>_DEPLOY_SSH_TARGET, <PREFIX>_DEPLOY_HEALTHZ_URL
838
- // healthzUrl, when unset, falls back to the instance's own publicOrigin + /healthz
839
- // (never a hardcoded host); null when even that is unknown (re-probe then skipped).
840
- function resolveDeployTarget({ env = process.env, configRaw = null, branding = null } = {}) {
841
- const ic = require('../../src/instance-config');
842
- let cfg = {};
843
- if (configRaw) {
844
- try { cfg = JSON.parse(configRaw) || {}; } catch (_) { cfg = {}; }
845
- }
846
- const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
847
-
848
- const sshTarget = ic.resolveEnv('DEPLOY_SSH_TARGET', { env }) || str(cfg.sshTarget) || null;
849
-
850
- let healthzUrl = ic.resolveEnv('DEPLOY_HEALTHZ_URL', { env }) || str(cfg.healthzUrl) || null;
851
- const publicOrigin = branding && branding.domains && branding.domains.publicOrigin;
852
- if (!healthzUrl && str(publicOrigin)) {
853
- try { healthzUrl = new URL('/healthz', publicOrigin).toString(); }
854
- catch (_) { /* malformed origin → leave healthzUrl null */ }
855
- }
856
- return { sshTarget, healthzUrl };
857
- }
858
-
859
- // gh runners — mirror gitArgvOk/gitOk but for the GitHub CLI. No shell, ever:
860
- // branch names + DB-sourced value_summary reach `gh` as literal argv elements
861
- // (same injection discipline as #681). ghArgvOk = boolean side-effect; ghJson =
862
- // parse `gh ... --json` stdout to an object/array (null on any failure).
863
- function ghArgvOk(args, opts = {}) {
864
- return argvOk('gh', args, opts);
865
- }
866
-
867
- function ghJson(args, opts = {}) {
868
- const out = argvOut('gh', args, opts);
869
- if (out == null) return null;
870
- try { return JSON.parse(out); } catch (_) { return null; }
871
- }
872
-
873
- // Pure argv builders for the PR land path — exported + tested so the untrusted
874
- // value_summary is proven to ride as ONE literal argv element (never a shell
875
- // string), exactly like buildMergeArgv.
876
- function buildPrCreateArgv(branch, title, body) {
877
- return ['pr', 'create', '--head', branch, '--base', 'main', '--title', title, '--body', body];
878
- }
879
-
880
- // --merge keeps the project's --no-ff merge-commit convention (architecture.md
881
- // branch-cleanup note); --auto arms GitHub to merge the instant required checks
882
- // pass + branch protection is satisfied; --delete-branch mirrors the laptop
883
- // path's origin-branch cleanup.
884
- function buildPrMergeArgv(branch) {
885
- return ['pr', 'merge', '--auto', '--merge', '--delete-branch', branch];
886
- }
887
-
888
- // Pure: did the prod-deploy workflow finish successfully for the merge commit?
889
- // `runs` is the parsed `gh run list --json headSha,status,conclusion` array.
890
- function deployRunSucceeded(runs, mergeSha) {
891
- if (!mergeSha) return false;
892
- return (runs || []).some(
893
- (r) => r && r.headSha === mergeSha && r.status === 'completed' && r.conclusion === 'success'
894
- );
895
- }
896
-
897
- // Pure: does the prod /version endpoint (#858) report it's running the commit we
898
- // just merged? 'match' = commit-pinned confirmation; 'mismatch' = prod is on a
899
- // different commit (e.g. a later deploy already superseded ours — still fine, our
900
- // run succeeded); 'unknown' = /version absent or unparseable (older prod / blip)
901
- // → caller falls back to the deploy-run + healthz signal. Compares on the shorter
902
- // length so a short vs full sha still matches. Exported + tested.
903
- function deployedCommitStatus(versionBody, mergeSha) {
904
- if (!versionBody || !mergeSha) return 'unknown';
905
- const c = String(versionBody.commit || '');
906
- if (!/^[0-9a-f]{7,40}$/i.test(c)) return 'unknown';
907
- const n = Math.min(c.length, String(mergeSha).length);
908
- return c.slice(0, n).toLowerCase() === String(mergeSha).slice(0, n).toLowerCase()
909
- ? 'match'
910
- : 'mismatch';
911
- }
912
-
913
- // Fetch + parse the prod /version JSON (#858). null on any failure — never throws.
914
- // Default target is the resolved instance origin (no hardcoded host — task 1002232);
915
- // null when none is resolvable, in which case the probe is skipped.
916
- function fetchDeployedVersion(url = deployedVersionUrl()) {
917
- if (!url) return null; // no resolvable instance origin → nothing to probe
918
- // stdio override DISCARDS stderr (a curl progress/error line must not pollute
919
- // the JSON parse) — the documented per-caller difference from ghJson.
920
- const out = argvOut('curl', ['-fsS', '--max-time', '10', url], { stdio: ['ignore', 'pipe', 'ignore'] });
921
- if (out == null) return null;
922
- try { return JSON.parse(out); } catch (_) { return null; }
923
- }
924
-
925
- // ciLand — the 'ci' deploy-mode analogue of the laptop merge+SSH block. Pushes
926
- // the branch, ensures a PR exists, enables auto-merge, then confirms the deploy
927
- // actually landed. Returns true ONLY when prod is confirmed live on the merge
928
- // commit — so the caller flips the task to shipped exactly as the laptop path
929
- // does. Any earlier exit leaves the task at confirmed with a clear next step.
930
- // pushVia — which publish path ship uses in 'ci' mode (ADR 0055 / task 1025):
931
- // 'local' — this machine pushes the branch + opens the PR with git + gh
932
- // (laptop: gh present + authenticated). Today's behaviour.
933
- // 'server' — this machine has NO GitHub push credential (a dev box: no gh, a
934
- // credential-less origin). ship uploads a thin branch bundle to the
935
- // GDS, which pushes + opens the PR + auto-merges with the SERVER's
936
- // credential. The box holds nothing (#919 model preserved).
937
- // Override with OTB_PUSH_VIA_SERVER=1 (force server) / =0 (force local) for tests.
938
- function ghAvailableAndAuthed() {
939
- try {
940
- const a = spawnSync('gh', ['auth', 'status'], {
941
- stdio: 'ignore',
942
- shell: process.platform === 'win32',
943
- windowsHide: true,
944
- timeout: 15000,
945
- });
946
- return !!a && !a.error && a.status === 0;
947
- } catch (_) {
948
- return false;
949
- }
950
- }
951
-
952
- function pushVia() {
953
- const o = String(process.env.OTB_PUSH_VIA_SERVER || '').trim();
954
- if (o === '1') return 'server';
955
- if (o === '0') return 'local';
956
- // No explicit override: a machine that can run an authenticated gh (laptop)
957
- // takes the local path; everything else (a dev box) goes server-mediated.
958
- return ghAvailableAndAuthed() ? 'local' : 'server';
959
- }
960
-
961
- // broadcastHandledByServer — task 1045: on the server-mediated publish path the
962
- // SERVER posts the Discord ship-broadcast after it confirms the deploy (it holds
963
- // the webhook secrets; the box does NOT — ADR 0053). So ship.js must NOT also post
964
- // locally there, or the box would either double-post (impossible — no secret) or,
965
- // as before, fail silently. True only when the publish actually goes server-side:
966
- // ci deploy mode AND this machine has no GitHub push credential (pushVia 'server').
967
- // The laptop path (pushVia 'local') keeps posting locally, exactly as today. Pure
968
- // w.r.t. its inputs (env + committed deploy.json) + exported so the gate is tested.
969
- function broadcastHandledByServer() {
970
- return resolveDeployMode() === 'ci' && pushVia() === 'server';
971
- }
972
-
973
- // shouldBailOnFetchFailure — autoMerge's decision when `git fetch origin main`
974
- // fails. A credential-less dev box (private origin, scrubbed clone token —
975
- // ADR 0053) CANNOT fetch origin, and that is FINE for the server-mediated
976
- // publish path (ADR 0055): ciLandServer bundles against the box's LOCAL
977
- // origin/main and the SERVER tops up main with its own credential. So a fetch
978
- // failure is fatal ONLY when we are NOT taking that path — otherwise the box can
979
- // never reach ciLand and strands every ship at confirmed (the bug task 1027
980
- // surfaced: ADR 0055 shipped the server path but left this fetch guard in front
981
- // of it). Pure + exported for the regression test (no git, no network).
982
- // NOTE: callers must still guard the pushVia() gh-probe behind `!fetchedMain` so
983
- // the happy path (fetch ok) spawns no extra gh and laptop behavior is unchanged.
984
- function shouldBailOnFetchFailure(fetchedMain, deployMode, via) {
985
- if (fetchedMain) return false;
986
- const serverPublish = deployMode === 'ci' && via === 'server';
987
- return !serverPublish;
988
- }
989
-
990
- // ---------- the land outcome (task 1002541, part 1) ----------
991
- //
992
- // The land chain used to answer with a bare boolean. Every bail site already
993
- // computes a precise reason and logs it, but `false` threw that reason away — so
994
- // the completion card had only the task's STATUS to go on ('confirmed') and
995
- // printed "Deploy landing automatically" even when nothing was landing and the
996
- // builder was, under ADR 0120, unpaid. The failure announced itself as success.
997
- //
998
- // "Not shipped" is not one thing, so this is a three-way, not a boolean:
999
- // landed() — merged + deployed; the task flips to shipped.
1000
- // landPending() — published and auto-merge armed: it DOES land on its own
1001
- // (CI / the server reconciler), so "landing automatically"
1002
- // is honest here. A poll timeout is this, not a failure
1003
- // (task 1049) — keep it out of the loud path.
1004
- // landBailed(r, n) — nothing is landing. `r` is the reason already logged,
1005
- // `n` the command that recovers it.
1006
- // Only landBailed makes the card say NOT landed. Purely additive: no bail site
1007
- // changes what it DOES, only what it now reports back.
1008
- // ADR 0184: an EMPTY ship has no commit to prove, so it must DECLARE itself.
1009
- function landed(o = {}) { return { ok: true, pending: false, reason: null, nextStep: null, noArtifact: o.noArtifact === true, noArtifactReason: o.noArtifactReason || null }; }
1010
- function landPending() { return { ok: false, pending: true, reason: null, nextStep: null }; }
1011
- function landBailed(reason, nextStep = null) { return { ok: false, pending: false, reason, nextStep }; }
1012
-
1013
- // The short COMMAND form of strandNextStep (which is prose, sized for the log).
1014
- // The card has room for one command, not a sentence.
1015
- function strandCommand(taskId, via) {
1016
- return via === 'server' ? `node scripts/gds/ship.js ${taskId}` : '/merge-mode';
1017
- }
1018
-
1019
- // ciLand — dispatch the 'ci' confirmed->shipped land to the local or the
1020
- // server-mediated path. Async because the server path awaits GDS API calls.
1021
- async function ciLand(taskId, branch, valueSummary) {
1022
- console.log(' deploy mode: ci — landing via PR auto-merge (no droplet key needed)');
1023
- if (pushVia() === 'server') {
1024
- return ciLandServer(taskId, branch, valueSummary);
1025
- }
1026
- return await ciLandLocal(taskId, branch, valueSummary);
1027
- }
1028
-
1029
- // task 1001916: render a FAILED POST /tasks/:id/publish-branch for the builder.
1030
- // The server has carried { step, reason } — which git step failed + the
1031
- // credential-scrubbed stderr — since task 1547, but the bail site printed only
1032
- // `data.message || data.error`: "[object Object]" on the ADR 0116 envelope, a
1033
- // bare `publish_failed` on the legacy flat shape. That hid a non-fast-forward
1034
- // rejection (the server had already freshened the branch itself) across several
1035
- // blind retry laps, so that case also gets its specific remedy. Pure + exported
1036
- // for tests/ship_publish_error.mjs (no git, no network).
1037
- const NON_FAST_FORWARD_RE = /fetch first|non-fast-forward/i;
1038
-
1039
- function formatPublishFailure(data, status, branch) {
1040
- const { code, message } = normalizeApiError(data);
1041
- const envelopeDetails = (data && data.error && typeof data.error === 'object' && data.error.details) || null;
1042
- const details = envelopeDetails || data || {};
1043
- const label = message || code || (data && data.message) || `HTTP ${status}`;
1044
- // git stderr is multi-line; this is embedded mid-sentence in one log line.
1045
- const reason = details.reason ? String(details.reason).replace(/\s+/g, ' ').trim() : '';
1046
- const parts = [];
1047
- if (details.step) parts.push(`step: ${details.step}`);
1048
- if (reason) parts.push(`reason: ${reason}`);
1049
- let out = parts.length ? `${label} (${parts.join('; ')})` : label;
1050
- if (NON_FAST_FORWARD_RE.test(reason)) {
1051
- out += ` — the server already moved ${branch}: fetch origin, reset onto origin/${branch}, `
1052
- + 're-run the generators, and re-ship so the push fast-forwards.';
1053
- }
1054
- return out;
1055
- }
1056
-
1057
- // ciLandServer — the dev-box path: no GitHub push credential on this machine, so
1058
- // hand the branch to the GDS, which pushes it + opens the PR + auto-merges with
1059
- // the server-side credential. Requires a RE-AUTHED (non-box) cli session — a
1060
- // box-scoped session is 403'd by the endpoint (it must `/builder-reauth` first).
1061
- async function ciLandServer(taskId, branch, valueSummary) {
1062
- console.log(' ci-land: server-mediated publish (this machine has no GitHub push credential)');
1063
- const headSha = gitOk('git rev-parse HEAD');
1064
- // Make sure origin/main is current so the THIN bundle's `origin/main..HEAD`
1065
- // base is resolvable (a read-only fetch — works on a credential-less origin).
1066
- gitArgvOk(['fetch', 'origin', 'main'], { stdio: 'ignore' });
1067
-
1068
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-bundle-'));
1069
- const bundlePath = path.join(tmpDir, 'branch.bundle');
1070
- try {
1071
- if (!gitArgvOk(['bundle', 'create', bundlePath, `origin/main..${branch}`], { stdio: 'ignore' })) {
1072
- console.log(` ci-land: could not build the branch bundle (nothing new to push, or a git error); task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
1073
- return landBailed('could not build the branch bundle', strandCommand(taskId, 'server'));
1074
- }
1075
- const bundleB64 = fs.readFileSync(bundlePath).toString('base64');
1076
- // allowBoxScope: handle the box_scope 403 locally (graceful "stays at
1077
- // confirmed, re-auth + re-ship") instead of cli-lib's central exit — task
1078
- // 1050. (In practice unreachable: a box session can't have claimed/confirmed
1079
- // to get here. Kept as a defensive, graceful path.)
1080
- const r = await (await cliClient({ allowBoxScope: true })).tasks.postTasksIdPublishBranch({
1081
- id: taskId,
1082
- body: { branch, head_sha: headSha, value_summary: valueSummary, bundle_b64: bundleB64 },
1083
- });
1084
- if (r.status === 403 && normalizeApiError(r.data).code === 'box_scope') {
1085
- console.log(' ci-land: this session is BOX-SCOPED — run /builder-reauth (browser, no terminal) to mint a real session, then re-ship. Task stays at confirmed.');
1086
- return landBailed('this CLI session is box-scoped', '/builder-reauth');
1087
- }
1088
- if (r.status === 503) {
1089
- console.log(' ci-land: server-mediated push is not configured on the server yet (no GitHub App nor GITHUB_PUSH_TOKEN — operator action, ADR 0055). Task stays at confirmed.');
1090
- return landBailed('server-mediated push is not configured (operator action)', strandCommand(taskId, 'server'));
1091
- }
1092
- if (!r.ok) {
1093
- const e = formatPublishFailure(r.data, r.status, branch);
1094
- console.log(` ci-land: server publish failed — ${e}. Task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
1095
- return landBailed(`server publish failed — ${e}`, strandCommand(taskId, 'server'));
1096
- }
1097
- console.log(` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
1098
- return pollServerPublish(taskId, branch);
1099
- } finally {
1100
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
1101
- }
1102
- }
1103
-
1104
- // task 1049 (B — async land semantics): classify a CI-land poll outcome so a
1105
- // SUCCESSFUL-but-still-running publish reads as success, not failure. The #1027
1106
- // retrospective: the ship's poll window is shorter than real CI, so a PR that was
1107
- // opened + auto-merge-armed (a SUCCESS) printed "did not merge — task stays at
1108
- // confirmed" and looked like a failure. Outcomes:
1109
- // 'shipped' — merged AND deploy-prod succeeded → flip the task to shipped
1110
- // 'failed' — PR closed without merging (a check failed / closed manually)
1111
- // 'pending' — PR still open with auto-merge armed (or merged, deploy not yet):
1112
- // NOT a failure — it lands on its own (the server reconciles the
1113
- // task to shipped, and a re-run resumes — task 941).
1114
- // Pure + exported for unit tests (no git/network). Accepts the publish-status
1115
- // response shape verbatim — getPublishStatus returns snake_case (pr_state,
1116
- // deploy_status); camelCase aliases are tolerated for test convenience.
1117
- // task 1002472: 'unconfigured' means the repo has no deploy-prod.yml to wait for
1118
- // (ADR 0108 pin-bump publishing), so the merge IS the land. Mirrors
1119
- // github-push.landConfirmed — kept in step by tests/ship_ci_deploy.mjs, since this
1120
- // CLI cannot import the server module.
1121
- function classifyLandOutcome(s) {
1122
- const merged = !!(s && s.merged);
1123
- const deployStatus = s && (s.deploy_status ?? s.deployStatus);
1124
- const prState = s && (s.pr_state ?? s.prState);
1125
- if (merged && (deployStatus === 'success' || deployStatus === 'unconfigured')) return 'shipped';
1126
- if (prState === 'closed' && !merged) return 'failed';
1127
- return 'pending';
1128
- }
1129
-
1130
- // task 1718: PURE — after ciLandLocal tries to arm GitHub auto-merge, decide which
1131
- // poll path lands + watches the PR. Arming auto-merge commonly FAILS when shipping
1132
- // from a worktree (`gh pr merge --auto` runs a git op that trips on `'main' is
1133
- // already used by worktree`) or with branch protection off — but the PR is open and
1134
- // this is NOT a dead-end: the server's merge driver + reconciler still land green
1135
- // PRs. So an enable-failure must NOT strand the builder at 'confirmed' (they'd never
1136
- // see the SHIPPED banner in-session, and the machine-global land-watch breadcrumb
1137
- // would surface it in some OTHER session). Instead:
1138
- // 'ci' → auto-merge armed: watch GitHub directly (pollCiDeploy).
1139
- // 'server' → auto-merge NOT armed: drive + watch the server land via publish-status
1140
- // (pollServerPublish) — reading it is reconcile-on-read, so it DRIVES the
1141
- // merge and observes the flip to 'shipped'.
1142
- // Both paths end at 'shipped' on a real land, so the banner renders in the shipping
1143
- // session and no 'confirmed' breadcrumb is written. Exported for unit tests.
1144
- function landPollAfterAutoMerge(autoMergeArmed) {
1145
- return autoMergeArmed ? 'ci' : 'server';
1146
- }
1147
-
1148
- // task 1719: PURE — after mergeAndDeploy runs, the completion card + the pending-land
1149
- // breadcrumb must reflect the AUTHORITATIVE post-land status, not the pre-merge one
1150
- // finishShip was handed. If the task actually reached 'shipped' in-session (a laptop
1151
- // sync land, the enable-success poll, or the task-1718 enable-failure server poll),
1152
- // the card renders the SHIPPED banner and printCompletionCard writes NO breadcrumb;
1153
- // otherwise keep the pre-merge status (the async land is still pending → 'confirmed' →
1154
- // the next-turn land-watch breadcrumb is the intended fallback). Exported for tests.
1155
- function reconciledCardStatus(liveStatus, preMergeStatus) {
1156
- return liveStatus === 'shipped' ? 'shipped' : preMergeStatus;
1157
- }
1158
-
1159
- // task 1052 (E): the box-appropriate next step when a ship lands the task at
1160
- // 'confirmed' without finishing. A dev box (pushVia 'server' — no authed gh)
1161
- // CANNOT run /merge-mode (it needs gh + a main checkout), so the old "for
1162
- // /merge-mode" advice was a dead end ON A BOX (the #1027 retrospective: the agent
1163
- // had to reverse-engineer recovery from source). Point a box at the resume
1164
- // instead (task 941); a laptop keeps /merge-mode (with resume as the alternative).
1165
- // Pure + exported for unit tests.
1166
- function strandNextStep(taskId, via) {
1167
- if (via === 'server') {
1168
- return `Re-run \`node scripts/gds/ship.js ${taskId}\` to resume — it picks up exactly where it stopped (task 941). ` +
1169
- `(/merge-mode is NOT available here — it needs gh + a main checkout this machine doesn't have.)`;
1170
- }
1171
- return `Run /merge-mode to land it — or re-run \`node scripts/gds/ship.js ${taskId}\`, which resumes from here (task 941).`;
1172
- }
1173
-
1174
- // The standard "published — it'll land on its own" success-pending banner
1175
- // (task 1049). Shared by both poll paths so the message is identical and never
1176
- // reads as a failure. The task legitimately stays at 'confirmed' until CI lands
1177
- // it; the server reconciles it to 'shipped' (publish-status reconcile-on-read)
1178
- // and a re-run resumes (task 941).
1179
- //
1180
- // task 1002606 (finding P-3): the banner used to end at "NOT a failure", so when
1181
- // a land DID fail (hand-written conflict, PR closed unmerged, persistent red
1182
- // check) the builder's last signal said everything was fine and the strand sat
1183
- // invisible until they happened to look. The banner now tells the failure story
1184
- // too: the server flags a strand needs_rebase (definitive shapes within a sweep,
1185
- // ambiguous ones at ~30 min), /builder-start warns, and new claims gate on it.
1186
- // Pure + exported so the wording is pinned by tests/ship_ci_deploy.mjs.
1187
- function landPendingLines(taskId, { merged } = {}) {
1188
- return [
1189
- ` ci-land: ✓ Published — auto-merge is armed${merged ? ' and the PR has merged' : ''}. This is NOT a failure.`,
1190
- ` CI is still finishing; GitHub will ${merged ? 'deploy' : 'merge + deploy'} automatically once the required checks pass,`,
1191
- ` and the task flips to 'shipped' on its own. Re-run \`node scripts/gds/ship.js ${taskId}\` anytime to confirm`,
1192
- ` it landed — it resumes from here (task 941). The task is at 'confirmed' until the deploy completes.`,
1193
- ` If it CANNOT land on its own (a hand-written merge conflict, the PR closed without merging, a check`,
1194
- ` that keeps failing), the server flags the task needs_rebase — /builder-start will warn you and new`,
1195
- ` claims are gated until you fix + re-land it (re-run the ship after fixing), or release it.`,
1196
- ];
1197
- }
1198
- function printLandPending(taskId, opts = {}) {
1199
- console.log('');
1200
- for (const line of landPendingLines(taskId, opts)) console.log(line);
1201
- }
1202
-
1203
- // task 1002645 — the honest close-out when the PR MERGED but the server refused the
1204
- // confirmed→shipped flip (reconcile_blocked: the task-1547 land proof missed — a
1205
- // wrong/missing published tip, or the matched PR doesn't reference this task). The
1206
- // optimistic "NOT a failure" banner was a lie here: without proof the auto-land can
1207
- // NEVER finish, and the wave-5 ships sat at 'confirmed' with their last signal
1208
- // saying everything was fine. Name the refusal and the remedy instead. Pure +
1209
- // exported so the wording is pinned by tests/ship_ci_deploy.mjs.
1210
- function reconcileBlockedLines(taskId, reason) {
1211
- return [
1212
- ` ci-land: the PR MERGED, but the task did NOT flip to 'shipped' — the server could not prove the land is`,
1213
- ` THIS task's: ${reason}.`,
1214
- ` The server reconciler retries the proof every ~5 min, so a late-recorded tip or PR reference still lands`,
1215
- ` on its own. Re-run \`node scripts/gds/ship.js ${taskId}\` to re-drive it; if it stays blocked the server`,
1216
- ` flags the task needs_rebase and /builder-start will show the remedy.`,
1217
- ];
1218
- }
1219
-
1220
- // One module-scope sleep command shared by both poll loops (task 1093 / C9). A
1221
- // child `node -e setTimeout` is used (not `await new Promise`) so it blocks
1222
- // identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
1223
- // the loops keep their own structure (sync vs async, 1-stage vs 2-stage); only
1224
- // the ~10s cadence is shared.
1225
- const POLL_SLEEP_CMD = 'node -e "setTimeout(()=>{}, 10000)"';
1226
-
1227
- // pollServerPublish — the server-mediated analogue of pollCiDeploy. Polls the
1228
- // GDS publish-status endpoint (which the server answers from GitHub, and which
1229
- // reconciles the task to shipped when it observes merge+deploy — task 1049) until
1230
- // the PR merges + deploy-prod succeeds. Returns true when prod is confirmed live;
1231
- // a timeout with an armed PR is success-PENDING (the publish worked), not failure.
1232
- async function pollServerPublish(taskId, branch) {
1233
- const TRIES = 54; // ~9 min at 10s — checks + auto-merge + Actions deploy
1234
- console.log(' ci-land: waiting for auto-merge + prod deploy (polling the server)...');
1235
- // task 1002645: GitHub can report merged+deployed while the SERVER refused the
1236
- // confirmed→shipped flip (reconcile_blocked — the task-1547 land proof missed).
1237
- // "Prod is live" + exit here was a lie that left the task stranded with no
1238
- // signal. Say the real reason and keep polling — attest-gate now records the
1239
- // pushed tip, so the proof usually heals within the window.
1240
- let blockedReason = null;
1241
- for (let i = 0; i < TRIES; i++) {
1242
- const r = await (await cliClient()).tasks.getTasksIdPublishStatus({ id: taskId, query: { branch } });
1243
- if (r.ok && r.data) {
1244
- const outcome = classifyLandOutcome(r.data);
1245
- if (outcome === 'shipped') {
1246
- if (r.data.reconcile_blocked) {
1247
- if (r.data.reconcile_blocked !== blockedReason) {
1248
- blockedReason = r.data.reconcile_blocked;
1249
- console.log(` ci-land: merged, but the server did NOT flip the task to shipped: ${blockedReason}. Still polling (the reconciler retries every sweep)...`);
1250
- }
1251
- } else {
1252
- console.log(` ci-land: merged (${String(r.data.merge_sha || '').slice(0, 8)}) + deploy-prod succeeded. Prod is live.`);
1253
- return landed();
1254
- }
1255
- }
1256
- if (outcome === 'failed') {
1257
- console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
1258
- return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
1259
- }
1260
- }
1261
- shellOk(POLL_SLEEP_CMD);
1262
- }
1263
- if (blockedReason) {
1264
- console.log('');
1265
- for (const line of reconcileBlockedLines(taskId, blockedReason)) console.log(line);
1266
- return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
1267
- }
1268
- printLandPending(taskId);
1269
- return landPending();
1270
- }
1271
-
1272
- // attestGateTrust — task 1169 (ADR 0058). Ask the SERVER to post the trustless
1273
- // gate-author-trust commit status for THIS builder's server-resolved rank on the
1274
- // pushed head SHA. ship.js's local path pushes + opens the PR with the laptop's
1275
- // OWN gh credential, but only the server can forge the rank attestation the
1276
- // ENFORCING gate-review check reads — without it a Metic+ author's gate-surface PR
1277
- // from a laptop is BLOCKED and they can't self-approve it (task 1162 / PR #131).
1278
- // Best-effort + never throws: a non-Metic author, an unconfigured server, or a
1279
- // transient failure just leaves the PR un-attested → it escalates (the safe
1280
- // direction). A non-gate PR is unaffected by the attestation either way.
1281
- async function attestGateTrust(taskId, headSha) {
1282
- if (!headSha) return;
1283
- try {
1284
- const r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
1285
- if (r.ok && r.data) {
1286
- if (r.data.attested) {
1287
- console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
1288
- } else {
1289
- console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
1290
- }
1291
- } else if (r.status === 503) {
1292
- console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
1293
- } else {
1294
- console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
1295
- }
1296
- } catch (_) {
1297
- /* best-effort — never block the ship on the attestation */
1298
- }
1299
- }
1300
-
1301
- // pickStaleGateReviewRun — PURE. Among `gh run list --json
1302
- // databaseId,headSha,status,conclusion` results for the gate-review workflow,
1303
- // find a run on THIS head SHA that has already COMPLETED without success — the
1304
- // one to re-run after the attestation is posted, because a new commit status does
1305
- // NOT re-trigger a pull_request-triggered check (the task 1162 / PR #131 gotcha).
1306
- // Returns the run's databaseId, or null when there's nothing stale to re-run (no
1307
- // run yet, still in flight, or already green). Exported for unit tests.
1308
- function pickStaleGateReviewRun(runs, headSha) {
1309
- if (!headSha || !Array.isArray(runs)) return null;
1310
- const hit = runs.find(
1311
- (r) => r && r.headSha === headSha && r.status === 'completed' && r.conclusion && r.conclusion !== 'success'
1312
- );
1313
- return hit ? (hit.databaseId ?? null) : null;
1314
- }
1315
-
1316
- // rerunGateReviewIfStale — best-effort gh re-run of a stale (completed-non-success)
1317
- // gate-review run on the current head, so it re-reads the freshly-posted
1318
- // gate-author-trust attestation. Only acts when such a run exists: a freshly-opened
1319
- // PR needs NO nudge (gate-review runs once, AFTER the attestation, and passes); the
1320
- // SHA-reuse-after-red resume case (and the rare push-then-attest race) needs one.
1321
- function rerunGateReviewIfStale(branch, headSha) {
1322
- const runs = ghJson([
1323
- 'run', 'list', '--workflow', 'gate-review.yml', '--branch', branch,
1324
- '--json', 'databaseId,headSha,status,conclusion', '--limit', '15',
1325
- ]);
1326
- const id = pickStaleGateReviewRun(runs, headSha);
1327
- if (id == null) return false;
1328
- console.log(` ci-land: re-running the stale gate-review check (run ${id}) so it re-reads the gate-author-trust attestation...`);
1329
- return ghArgvOk(['run', 'rerun', String(id)]);
1330
- }
1331
-
1332
- // ciLandLocal — today's local path: this machine pushes the branch + opens the PR
1333
- // + enables auto-merge via git + gh. async since task 1169 — it asks the server to
1334
- // post the gate-author-trust attestation between the push and the PR open.
1335
- async function ciLandLocal(taskId, branch, valueSummary) {
1336
- const headSha = gitOk('git rev-parse HEAD');
1337
-
1338
- console.log(` pushing ${branch} to origin...`);
1339
- if (!gitArgvOk(['push', 'origin', branch])) {
1340
- console.log(' ci-land: push failed; task stays at confirmed. Fix the push and re-ship.');
1341
- return landBailed(`could not push ${branch} to origin`, `node scripts/gds/ship.js ${taskId}`);
1342
- }
1343
-
1344
- // task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
1345
- // opening the PR, so the gate-review check reads it on its first (and only) run
1346
- // and a Metic+ gate-surface PR auto-passes — race-free for a fresh PR, since the
1347
- // `gh pr create` below is what fires gate-review and it runs only after this
1348
- // returns. (A reused open PR is handled by the re-run nudge after auto-merge.)
1349
- await attestGateTrust(taskId, headSha);
1350
-
1351
- // Ensure exactly one PR for this branch. gh pr create is idempotent-ish: it
1352
- // errors if a PR already exists, so check first and reuse it.
1353
- const existing = ghJson(['pr', 'view', branch, '--json', 'number,url,state']);
1354
- // Reusing an already-open PR means gate-review may have ALREADY run (red, before
1355
- // the attestation existed) on this same head SHA — nudge a re-run below.
1356
- const reusingOpenPr = !!(existing && existing.state === 'OPEN');
1357
- // PR titles + bodies are GitHub-rendered: a bare "#NNN" autolinks into
1358
- // GitHub's PR/issue number space (dead links for GDS task ids — task 916),
1359
- // so the task ref is written as "task NNN" and the summary is de-#-ed.
1360
- const title = `task ${taskId}: ${sanitizeGitHubRefs(valueSummary) || branch}`.slice(0, 240);
1361
- const deployTargetNote = resolveInstanceBase() || 'the configured instance';
1362
- const body =
1363
- `Auto-opened by ship.js (deploy mode: ci) for task ${taskId}.\n\n` +
1364
- `${sanitizeGitHubRefs(valueSummary) || '(no value summary)'}\n\n` +
1365
- `Merging this PR triggers .github/workflows/deploy-prod.yml to deploy to ` +
1366
- `${deployTargetNote}. Auto-merge is enabled — GitHub lands it once the ` +
1367
- `required checks pass.`;
1368
- if (!existing || existing.state === 'CLOSED' || existing.state === 'MERGED') {
1369
- console.log(' ci-land: opening PR -> main...');
1370
- if (!ghArgvOk(buildPrCreateArgv(branch, title, body))) {
1371
- console.log(' ci-land: `gh pr create` failed. Is the GitHub CLI installed + authenticated (`gh auth status`)? Open the PR manually; task stays at confirmed.');
1372
- return landBailed('`gh pr create` failed (is gh installed + authenticated?)', `node scripts/gds/ship.js ${taskId}`);
1373
- }
1374
- } else {
1375
- console.log(` ci-land: reusing open PR ${existing.url}`);
1376
- }
1377
-
1378
- console.log(' ci-land: enabling auto-merge (lands when required checks pass)...');
1379
- // task 1718: an enable-failure is NOT a dead-end — the PR is open and the server
1380
- // still lands green PRs. Don't return false (which stranded the builder at
1381
- // 'confirmed' with no in-session SHIPPED banner); fall through to the server poll,
1382
- // which drives + observes the land. See landPollAfterAutoMerge.
1383
- const autoMergeArmed = ghArgvOk(buildPrMergeArgv(branch));
1384
- if (!autoMergeArmed) {
1385
- console.log(' ci-land: GitHub auto-merge could not be armed from here (normal when shipping from a worktree, or with branch protection off). The PR is open; the server lands it — waiting for the land instead of stranding at confirmed...');
1386
- }
1387
-
1388
- // task 1169: if we REUSED an open PR, a stale red gate-review run may sit on this
1389
- // same head SHA from before the attestation existed; a new commit status does not
1390
- // re-trigger a pull_request check, so re-run it (best-effort). A freshly-opened PR
1391
- // is left alone — its single gate-review run reads the attestation and passes.
1392
- if (reusingOpenPr) rerunGateReviewIfStale(branch, headSha);
1393
-
1394
- // enable-success watches GitHub directly; enable-failure drives + watches the
1395
- // server land (publish-status reconcile-on-read). Both flip to 'shipped' on a real
1396
- // land, so the banner renders in-session and no 'confirmed' breadcrumb is written.
1397
- return landPollAfterAutoMerge(autoMergeArmed) === 'ci'
1398
- ? pollCiDeploy(taskId, branch, headSha)
1399
- : pollServerPublish(taskId, branch);
1400
- }
1401
-
1402
- // Confirm the CI land actually reached production. Polls in stages:
1403
- // 1. PR state -> MERGED (checks passing + branch protection satisfied); grabs
1404
- // the merge commit oid.
1405
- // 2. the deploy-prod workflow run for that commit -> success.
1406
- // 3. public edge healthz -> 200 (belt-and-braces, a different vantage).
1407
- // Times out conservatively; on any timeout the task stays at confirmed with a
1408
- // precise next step rather than a false "shipped".
1409
- function pollCiDeploy(taskId, branch, headSha) {
1410
- const MERGE_TRIES = 30; // ~5 min at 10s — checks + auto-merge
1411
- const DEPLOY_TRIES = 24; // ~4 min at 10s — Actions deploy
1412
-
1413
- console.log(' ci-land: waiting for auto-merge (checks + branch protection)...');
1414
- let mergeSha = null;
1415
- for (let i = 0; i < MERGE_TRIES; i++) {
1416
- const pr = ghJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
1417
- if (pr && pr.state === 'MERGED') {
1418
- mergeSha = pr.mergeCommit && pr.mergeCommit.oid;
1419
- break;
1420
- }
1421
- if (pr && pr.state === 'CLOSED') {
1422
- console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
1423
- return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
1424
- }
1425
- shellOk(POLL_SLEEP_CMD);
1426
- }
1427
- if (!mergeSha) {
1428
- // task 1049: PR open + auto-merge armed but not merged in-window = the publish
1429
- // SUCCEEDED and is still in CI — success-pending, not a failure.
1430
- printLandPending(taskId);
1431
- return landPending();
1432
- }
1433
- console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
1434
-
1435
- for (let i = 0; i < DEPLOY_TRIES; i++) {
1436
- const runs = ghJson([
1437
- 'run', 'list', '--workflow', 'deploy-prod.yml', '--branch', 'main',
1438
- '--json', 'headSha,status,conclusion', '--limit', '15',
1439
- ]);
1440
- if (deployRunSucceeded(runs, mergeSha)) {
1441
- console.log(' ci-land: deploy-prod workflow succeeded for the merge commit.');
1442
- // Commit-pin the confirmation against /version (#858) — stronger than a
1443
- // liveness probe. The deploy run succeeding for OUR mergeSha is already
1444
- // authoritative, so /version only refines the message (and never turns a
1445
- // real deploy into a false negative on a transient miss or a concurrent
1446
- // later deploy).
1447
- const status = deployedCommitStatus(fetchDeployedVersion(), mergeSha);
1448
- const instanceBase = resolveInstanceBase();
1449
- const healthzUrl = instanceBase ? `${instanceBase}/healthz` : null;
1450
- if (status === 'match') {
1451
- console.log(' ci-land: /version confirms prod is live on the merge commit (commit-pinned).');
1452
- } else if (status === 'mismatch') {
1453
- console.log(' ci-land: deploy run succeeded; /version shows a different commit (a later deploy has since superseded it — fine, our change is in main).');
1454
- } else if (healthzUrl && shellOk(`curl -fsS --max-time 10 ${healthzUrl} -o /dev/null`)) {
1455
- console.log(` ci-land: /version unavailable; ${healthzUrl} 200 — treating the successful deploy run as authoritative.`);
1456
- } else {
1457
- console.log(' ci-land: deploy run succeeded but /version + healthz probes both missed (likely transient). Treating the successful deploy run as authoritative.');
1458
- }
1459
- return landed();
1460
- }
1461
- shellOk(POLL_SLEEP_CMD);
1462
- }
1463
- // task 1049: merged but the deploy run hasn't confirmed in-window — still
1464
- // success-pending (it deploys on its own), not a failure.
1465
- printLandPending(taskId, { merged: true });
1466
- return landPending();
1467
- }
1468
-
1469
- // ---------- Step 0: preflight (working tree + branch freshness) ----------
1470
- //
1471
- // Catches the 2026-05-10 "phantom ship" bug: builder forgot to stage/commit
1472
- // their actual work. The committed-diff scanner reported clean (because nothing
1473
- // was committed), smoke passed, and the task auto-shipped with zero code
1474
- // change. The fix is to inspect the working tree BEFORE the claim resolves and
1475
- // refuse to proceed when any tracked file has uncommitted/staged changes.
1476
- //
1477
- // Also refuses when the branch is far behind origin/main — merging a stale
1478
- // branch is a known regression vector.
1479
-
1480
- // parseDeletedUpstreamPaths — pure: turn `git log --diff-filter=D --name-only
1481
- // --pretty=format:` output (one deleted path per line, blank lines between
1482
- // commits) into a deduped, order-preserving list. Exported so preflight's
1483
- // upstream-deletion warning (step (d)) is unit-tested without a live git repo.
1484
- // Null/empty input (no matches, or the git call failed closed) → [].
1485
- function parseDeletedUpstreamPaths(rawLogOutput) {
1486
- if (!rawLogOutput) return [];
1487
- return [...new Set(rawLogOutput.split('\n').map((s) => s.trim()).filter(Boolean))];
1488
- }
1489
-
1490
- function preflight(claim, opts = {}) {
1491
- // (a) Working-tree check — refuse if any TRACKED file carries an uncommitted
1492
- // modification or staged change. This fires post-commit, so a clean tree
1493
- // proves the builder's work is actually committed. touches[] cleanup [2/8]
1494
- // (task 877): this replaced the old touches[]-scoped match, which was skipped
1495
- // entirely for empty-touches tasks (leaving them unprotected). The plain
1496
- // clean-tree check is strictly stronger and needs no touches[]. Untracked /
1497
- // .gitignored files are ignored so autonomous scratch files don't false-refuse.
1498
- const porcelain = gitOk('git status --porcelain');
1499
- const dirty = trackedDirty(porcelain);
1500
- if (dirty.length > 0) {
1501
- console.log(' preflight: working tree has uncommitted tracked changes:');
1502
- for (const file of dirty) console.log(` - ${file}`);
1503
- console.log('');
1504
- console.log(' Stage + commit these (or revert them) before shipping.');
1505
- console.log(' This guard exists because uncommitted work has shipped as "complete" before.');
1506
- return { ok: false, reason: 'working-tree-dirty' };
1507
- }
1508
-
1509
- // (b) Branch-freshness check — refuse if behind origin/main by too much.
1510
- // Fetch quietly so the comparison is against current remote state. Clean
1511
- // macOS Finder/iCloud `* 2` duplicate refs first or fetch will fail with
1512
- // "bad object refs/heads/<name> 2".
1513
- const cleaned = cleanupMacFinderDupRefs(REPO_ROOT);
1514
- if (cleaned.length > 0) {
1515
- console.log(` preflight: removed ${cleaned.length} stray refs (macOS Finder duplicates)`);
1516
- }
1517
- gitOk('git fetch origin main');
1518
- const behindStr = gitOk('git rev-list --count HEAD..origin/main');
1519
- const behind = behindStr ? Number(behindStr) : 0;
1520
- if (Number.isFinite(behind) && behind > 0) {
1521
- // (b) Branch-freshness is REPORT-ONLY now (#1068). Being behind origin/main
1522
- // is no longer a refusal: shipMain() merges origin/main INTO the branch up
1523
- // front (mergeMainIntoBranch), BEFORE the sandbox review gate, so the tree
1524
- // the human reviews == the tree we ship — killing the post-approval re-stage
1525
- // loop the #1057 retro hit. We still fetch (above) so that merge + the
1526
- // stale-tooling guard below see current remote state.
1527
- const note = behind > BEHIND_MAIN_LIMIT ? ` (well past the ${BEHIND_MAIN_LIMIT}-commit mark)` : '';
1528
- console.log(` preflight: branch is ${behind} commit(s) behind origin/main${note} — shipMain merges it in before staging.`);
1529
- }
1530
-
1531
- // (c) Stale ship-tooling guard. The behind-limit above is deliberately lenient
1532
- // so an unrelated commit to main doesn't block every parallel builder — but
1533
- // that means a branch a FEW commits behind sails through, and if those commits
1534
- // touched the ship/grade tooling, the builder runs a STALE ship.js / grader.js
1535
- // / smoke runner and the ship silently fails the same way every time. That was
1536
- // the #662 root cause (a grader.js predating the Windows spawn fix ran twice).
1537
- // So: regardless of commit count, refuse if origin/main carries changes to the
1538
- // tooling that runs the ship locally and this branch lacks them. Three-dot diff
1539
- // (HEAD...origin/main) scopes to changes on the origin/main side since the
1540
- // merge-base, so the builder's OWN edits to these files (e.g. shipping a
1541
- // ship.js fix) do not trip it.
1542
- const TOOLING_PATHS = [
1543
- 'scripts/gds/ship.js',
1544
- 'scripts/gds/smoke-gds.js',
1545
- 'scripts/gds/cli-lib.js',
1546
- 'modules/grading/grader.js', // BV1.R73: grading carved to modules/grading/
1547
- ];
1548
- const staleToolingStr = gitOk(`git diff --name-only HEAD...origin/main -- ${TOOLING_PATHS.join(' ')}`);
1549
- const staleTooling = staleToolingStr ? staleToolingStr.trim().split('\n').filter(Boolean) : [];
1550
- if (staleTooling.length > 0) {
1551
- if (opts.force) {
1552
- console.log(` preflight: ship tooling is behind origin/main (${staleTooling.join(', ')}) — proceeding due to --force`);
1553
- } else {
1554
- console.log(' preflight: your ship/grade tooling is behind origin/main:');
1555
- for (const f of staleTooling) console.log(` - ${f}`);
1556
- console.log(' These files RUN the ship + grader locally — shipping with a stale copy silently fails (root cause of #662).');
1557
- console.log(' Run `git merge origin/main` (or `git pull --rebase origin main`) on this branch, then re-ship. Or --force to override.');
1558
- return { ok: false, reason: 'stale-tooling' };
1559
- }
1560
- }
1561
-
1562
- // (d) Upstream-deletion warning (WARN-ONLY — never blocks, never changes the
1563
- // exit code). main can delete or relocate a surface between when a task was
1564
- // claimed and when it ships (a cutover, a carve, a rename — the R89 hall-ui
1565
- // cutover class); the branch's own diff still touches the OLD path, and an
1566
- // oblivious ship can silently revert the upstream deletion or leave this
1567
- // branch's work orphaned. Scoped to the paths THIS branch's diff actually
1568
- // touches — the same baseline/diff computeGraderBaseline/computeChangedFiles
1569
- // compute for the grader — so an unrelated upstream deletion elsewhere in the
1570
- // repo never fires it. Silent no-op if origin/main isn't resolvable (offline /
1571
- // never fetched) or this branch's diff is empty — the check itself must never
1572
- // be why a ship breaks. parseDeletedUpstreamPaths is the pure parsing seam,
1573
- // exported + unit-tested (tests/ship_upstream_deletion.mjs) without a live git
1574
- // repo; this block is just the git I/O + presentation around it.
1575
- const upstreamDelBaseline = computeGraderBaseline(claim);
1576
- const upstreamDelTouched = upstreamDelBaseline ? computeChangedFiles(upstreamDelBaseline) : [];
1577
- if (upstreamDelTouched.length > 0) {
1578
- const deletedUpstreamStr = argvOut('git', [
1579
- 'log', 'HEAD..origin/main', '--diff-filter=D', '--name-only', '--pretty=format:',
1580
- '--', ...upstreamDelTouched,
1581
- ]);
1582
- const deletedUpstream = parseDeletedUpstreamPaths(deletedUpstreamStr);
1583
- if (deletedUpstream.length > 0) {
1584
- console.error('');
1585
- console.error(`⚠ Upstream deletion warning: main has removed ${deletedUpstream.length} path(s) this branch still touches, since this task was claimed:`);
1586
- for (const f of deletedUpstream) console.error(` - ${f}`);
1587
- console.error(' This looks like the R89 hall-ui cutover class: a surface removed or relocated upstream while the claim was in flight.');
1588
- console.error(' Verify these paths still exist on main (or that the removal/rename is intentional) before shipping —');
1589
- console.error(" shipping anyway may silently revert the upstream deletion or leave this branch's work orphaned.");
1590
- console.error(' Warning only — not blocking; the ship proceeds.');
1591
- console.error('');
1592
- }
1593
- }
1594
-
1595
- console.log(` preflight: working tree clean, branch ${behind} commits behind main, ship tooling current — OK`);
1596
- return { ok: true };
1597
- }
1598
-
1599
- // mergeMainIntoBranch — pull origin/main INTO the current feature branch BEFORE
1600
- // the sandbox review gate (#1068, gap 2), so the tree the human reviews == the
1601
- // tree we ship and an --approved lands in ONE shot. This subsumes the old
1602
- // behind-main hard refusal: instead of telling the builder to merge by hand
1603
- // (which then invalidated their just-given sandbox approval — the #1057 retro),
1604
- // we do it up front. Aborts ONLY on a genuine merge conflict (the human must
1605
- // resolve those); a no-op when already current or not on a feature branch. Runs
1606
- // in the session's worktree (currentBranch()/gitOk operate on process.cwd()).
1607
- // Returns { ok, merged, conflict?, reason? }.
1608
- // decidePreMerge — the PURE policy for the pre-stage merge: skip on main/detached
1609
- // HEAD, on the server-publish path, or when already current; otherwise merge.
1610
- // Exported so the truth table is unit-tested with no git. `via` is pushVia()'s
1611
- // verdict ('server' | 'local' | undefined). Returns { action:'skip'|'merge', reason }.
1612
- function decidePreMerge({ branch, behind, via }) {
1613
- if (!branch || branch === 'main' || branch === 'HEAD') {
1614
- return { action: 'skip', reason: `not on a feature branch (${branch})` };
1615
- }
1616
- // Server-mediated publish (ADR 0055): a credential-less partial-clone dev box
1617
- // (blob:none, HTTPS origin, no gh/credential-helper — ADR 0053) CANNOT run a
1618
- // local `git merge origin/main` — the ort merge lazily fetches main's blobs
1619
- // from the promisor remote and there are no credentials to authenticate that
1620
- // fetch, so the box strands here and can never ship while behind main, i.e.
1621
- // ≈always (idea #364). It also does not NEED to: ciLandServer bundles
1622
- // `origin/main..HEAD` and the SERVER reconciles the branch with the real main
1623
- // when it pushes + opens + auto-merges the PR; a genuine conflict surfaces
1624
- // server-side as merge_conflict (publish-reconciler / conflict-resolve,
1625
- // ADR 0082). The local pre-merge (#1068) is a LAPTOP-path optimization (so the
1626
- // sandbox-reviewed tree == the shipped tree); it stays exactly as-is there
1627
- // (via 'local'/undefined) and is simply skipped on the box.
1628
- if (via === 'server') {
1629
- return {
1630
- action: 'skip',
1631
- reason: 'server-mediated publish reconciles with origin/main server-side (credential-less partial-clone box cannot fetch main blobs locally — idea #364)',
1632
- };
1633
- }
1634
- if (!Number.isFinite(behind) || behind === 0) {
1635
- return { action: 'skip', reason: 'already up to date with origin/main' };
1636
- }
1637
- return { action: 'merge', reason: `${behind} commit(s) behind origin/main` };
1638
- }
1639
-
1640
- function mergeMainIntoBranch() {
1641
- const branch = currentBranch();
1642
- // preflight already fetched origin/main + cleaned stray refs; reuse that state.
1643
- const behindStr = gitOk('git rev-list --count HEAD..origin/main');
1644
- const behind = behindStr ? Number(behindStr) : 0;
1645
- const decision = decidePreMerge({ branch, behind, via: pushVia() });
1646
- if (decision.action === 'skip') {
1647
- return { ok: true, merged: false, reason: decision.reason };
1648
- }
1649
- console.log(` pre-stage: merging origin/main into ${branch} (${behind} commit(s) behind) so the reviewed tree == the shipped tree...`);
1650
- const msg = `Merge origin/main into ${branch} (pre-stage freshness, task 1068)`;
1651
- if (gitArgvOk(['merge', '--no-edit', '-m', msg, 'origin/main'])) {
1652
- console.log(' pre-stage: merged origin/main cleanly.');
1653
- return { ok: true, merged: true };
1654
- }
1655
- // Conflict (or other merge failure). Before giving up, AUTO-HEAL conflicts that
1656
- // are confined to GENERATED files (task 1243): docs/repo-map.md + the nested
1657
- // CLAUDE.md blocks reliably collide between parallel branches (they're rebuilt
1658
- // from code, so two branches render them differently), and re-resolving them by
1659
- // hand — three times — was the PR #167 churn. Regenerate them from the merged
1660
- // tree, stage, and complete the merge. This is the guaranteed-to-run safety net
1661
- // that backstops the `otb-regen` git merge driver (install-git-hooks.js), which
1662
- // only fires where it's been registered per-clone.
1663
- const conflicted = (gitOk('git diff --name-only --diff-filter=U') || '').trim();
1664
- const files = conflicted ? conflicted.split('\n').map((s) => s.trim()).filter(Boolean) : [];
1665
- if (files.length && files.every((f) => mergeRegen.REGENERABLE.has(f))) {
1666
- const healed = files.every((f) => mergeRegen.resolveInWorktree(f));
1667
- if (healed && gitArgvOk(['add', ...files]) && gitArgvOk(['commit', '--no-edit'])) {
1668
- console.log(` pre-stage: auto-healed generated-file conflict(s) by regenerating ${files.join(', ')} from the merged tree.`);
1669
- return { ok: true, merged: true, healed: files };
1670
- }
1671
- // A hand-written conflict remained (resolveInWorktree returned false) or the
1672
- // commit failed — fall through to the clean abort below; no half-merged tree.
1673
- }
1674
- // Abort so the tree is left clean, and surface it — we must NOT ship a
1675
- // half-merged tree.
1676
- gitArgvOk(['merge', '--abort']);
1677
- return {
1678
- ok: false,
1679
- merged: false,
1680
- conflict: true,
1681
- reason: conflicted
1682
- ? `merge conflict with origin/main in: ${files.join(', ')}. Resolve on this branch (git merge origin/main), commit, then re-ship`
1683
- : 'could not merge origin/main into this branch (see git output). Resolve manually, then re-ship',
1684
- };
1685
- }
1686
-
1687
- // ---------- Pre-verify: auto-linkify the ship's own changed docs ----------
1688
- //
1689
- // The doc-ref-linkify CI gate (tests/linkify_check.mjs, in the required `unit`
1690
- // check — task 1002639; a standalone doc-ref-linkify.yml workflow never
1691
- // actually existed) red-Xes a PR carrying a bare "#NNN" task ref in a doc. A
1692
- // red-X after the builder walked away still strands the land (#747).
1693
- //
1694
- // The structural cure is to fix the ship's OWN changed docs here, before the
1695
- // merge, and commit the rewrite. The gate then stays as the backstop for refs
1696
- // that arrive by other paths (generated docs, direct pushes) but stops firing
1697
- // in the normal /builder-ship path.
1698
- //
1699
- // Scope is deliberately the docs already in THIS commit's diff: rewriting only
1700
- // files the builder already changed means it can NEVER rewrite a doc they
1701
- // didn't touch, and a pre-existing bare ref elsewhere is left for
1702
- // the periodic backlog sweep (`linkify-refs.js --apply`) + the CI gate.
1703
-
1704
- // Pure: which changed paths are in the linkifier's domain (the hand-authored
1705
- // markdown a reader sees on github.com — root CLAUDE.md/README.md + docs/**).
1706
- // Mirrors linkify-refs.js discoverDocs() roots. Exported for unit tests.
1707
- function isLinkifiableDocPath(rel) {
1708
- return (
1709
- typeof rel === 'string' &&
1710
- rel.endsWith('.md') &&
1711
- (rel === 'CLAUDE.md' || rel === 'README.md' || rel.startsWith('docs/'))
1712
- );
1713
- }
1714
-
1715
- function autoLinkifyChangedDocs(ac) {
1716
- const baseline = computeGraderBaseline(ac);
1717
- let docs = computeChangedFiles(baseline).filter(isLinkifiableDocPath);
1718
- // Mirror discoverDocs()'s GENERATED-FILE skip — an explicit file set bypasses
1719
- // it, and rewriting a generated doc just churns until its next regen.
1720
- docs = docs.filter((rel) => {
1721
- try {
1722
- const head = fs.readFileSync(path.resolve(process.cwd(), rel), 'utf8').slice(0, 200);
1723
- return !/GENERATED FILE/i.test(head);
1724
- } catch (_) {
1725
- return false;
1726
- }
1727
- });
1728
- if (docs.length === 0) return;
1729
-
1730
- console.log(`Auto-linkify: scanning ${docs.length} changed doc(s) for bare "#NNN" task refs...`);
1731
- const linkifyScript = path.resolve(__dirname, 'linkify-refs.js');
1732
- try {
1733
- // --apply (no --check) throws on an unreachable id allow-list; treat any
1734
- // failure as best-effort and continue — the CI gate is the backstop, a
1735
- // network blip must never block a ship.
1736
- execFileSync(process.execPath, [linkifyScript, '--apply', ...docs], {
1737
- stdio: ['ignore', 'pipe', 'pipe'],
1738
- cwd: process.cwd(),
1739
- });
1740
- } catch (err) {
1741
- const line = (err.message || 'unknown error').split('\n')[0];
1742
- console.log(` auto-linkify: skipped (linkifier unavailable: ${line}). CI gate remains the backstop.`);
1743
- return;
1744
- }
1745
-
1746
- // Commit only if the rewrite actually changed something. status --porcelain
1747
- // over the explicit doc set, via argv (paths can't be spliced into a shell).
1748
- const dirty = gitOk(`git status --porcelain -- ${docs.map((d) => JSON.stringify(d)).join(' ')}`);
1749
- if (!dirty) {
1750
- console.log(' auto-linkify: changed docs already clean — nothing to commit.');
1751
- return;
1752
- }
1753
- gitArgvOk(['add', '--', ...docs]);
1754
- const committed = gitArgvOk(['commit', '-m', 'chore(docs): auto-linkify task refs before ship (task 747)']);
1755
- if (committed) {
1756
- const n = dirty.split('\n').filter(Boolean).length;
1757
- console.log(` auto-linkify: rewrote bare task refs in ${n} doc(s) and committed before merge.`);
1758
- } else {
1759
- console.log(' auto-linkify: commit failed; continuing (CI gate is the backstop).');
1760
- }
1761
- }
1762
-
1763
- // ---------- Step 1+2: local verification ----------
1764
-
1765
- // V3.R18 (#209) → MAS Phase 1 #395: inputs for the subagent grader. The grader
1766
- // needs three things from the local working tree: the changed files list, the
1767
- // full unified diff against the baseline, and (for the audit-trail signals)
1768
- // the shortstat summary.
1769
- //
1770
- // task 1002422 (blocker 1000082): a builder-settable pre-task baseline. When a
1771
- // task's artifact reached main BEFORE the ship (a release task: package-core
1772
- // archives committed HEAD, so the version bump must merge first), merge-base(
1773
- // origin/main, HEAD) == HEAD → an EMPTY committed diff → the --allow-empty /
1774
- // skip-grade / Archon-only /confirm dead-end that NO sub-Archon builder can
1775
- // finish. --base <ref> pins the pre-task commit so the grader reads the REAL diff
1776
- // <base>...HEAD and the ship confirms via the rank-open graded path instead. Set
1777
- // once in main() after resolveBaseRef() validates it; null on a normal ship.
1778
- let baseOverrideSha = null;
1779
-
1780
- // resolveBaseRef — validate + resolve a --base <ref> to a commit sha. Injection-
1781
- // safe: the ref reaches git as a literal argv element (execFile, no shell), never
1782
- // a string interpolation. Pure over injected git runners so the unit test drives
1783
- // it with fakes (no real repo). Returns { ok:true, sha } or { ok:false, reason }.
1784
- function resolveBaseRef(rawBase, { revParse, isAncestor } = {}) {
1785
- const ref = String(rawBase == null ? '' : rawBase).trim();
1786
- if (!ref) return { ok: false, reason: 'empty ref' };
1787
- // Conservative allowlist (defence-in-depth even though transport is argv): no
1788
- // whitespace, no leading dash (git would read it as a flag), no shell metachar.
1789
- if (!/^[A-Za-z0-9][A-Za-z0-9._/~^-]*$/.test(ref)) {
1790
- return { ok: false, reason: `invalid ref syntax "${ref}"` };
1791
- }
1792
- const rp = revParse || ((r) => argvOut('git', ['rev-parse', '--verify', '--quiet', `${r}^{commit}`]));
1793
- const sha = rp(ref);
1794
- if (!sha) return { ok: false, reason: `"${ref}" is not a commit this repo knows` };
1795
- const anc = isAncestor || ((s) => gitArgvOk(['merge-base', '--is-ancestor', s, 'HEAD']));
1796
- if (!anc(sha)) {
1797
- return { ok: false, reason: `"${ref}" (${sha.slice(0, 9)}) is not an ancestor of HEAD — --base must be a commit your work built on top of` };
1798
- }
1799
- return { ok: true, sha };
1800
- }
1801
-
1802
- // baseRefShipGuard — the SECURITY invariant behind --base. --base decouples the
1803
- // GRADED diff (<base>...HEAD) from what the merge leg lands (the whole branch:
1804
- // autoMerge runs `git merge <branch>`, bringing every commit ahead of main, not
1805
- // just <base>...HEAD). So if HEAD has commits AHEAD of origin/main, honoring
1806
- // --base would let the grader review only a SUBSET while the full branch merges —
1807
- // an unreviewed-code path onto main. --base is therefore ONLY valid for work
1808
- // ALREADY fully on main (its documented premise: "nothing left to merge"). Pure
1809
- // over the ahead-count string (git rev-list --count origin/main..HEAD) so it is
1810
- // unit-tested without a repo. '0' → allowed; anything else (or null) → refuse.
1811
- function baseRefShipGuard(aheadCount) {
1812
- if (aheadCount === '0') return { ok: true };
1813
- const n = aheadCount == null ? 'an unknown number of' : aheadCount;
1814
- return { ok: false, reason: `HEAD is ${n} commit(s) ahead of origin/main — --base is only for work already merged to main` };
1815
- }
1816
-
1817
- // applyBaseOverride — the SINGLE source of truth for --base validation, shared by
1818
- // shipMain + regradeMain so the security guard can never diverge between entry
1819
- // points. Resolves the ref, enforces the 0-ahead invariant, and sets the module-
1820
- // level baseOverrideSha on success. Returns { ok:true, sha } | { ok:false, reason }
1821
- // (the caller prints + exits). runGit is injectable for tests.
1822
- function applyBaseOverride(baseRefRaw, { runGit = gitOk } = {}) {
1823
- const r = resolveBaseRef(baseRefRaw);
1824
- if (!r.ok) return { ok: false, reason: `--base ${baseRefRaw} is unusable (${r.reason})` };
1825
- // Freshen origin/main (best-effort; a stale ref only OVER-counts → errs to refuse).
1826
- runGit('git fetch origin main --quiet');
1827
- const guard = baseRefShipGuard(runGit('git rev-list --count origin/main..HEAD'));
1828
- if (!guard.ok) return { ok: false, reason: guard.reason };
1829
- baseOverrideSha = r.sha;
1830
- return { ok: true, sha: r.sha };
1831
- }
1832
-
1833
- // #536: the grader's baseline is computed INDEPENDENTLY of touches[] — always
1834
- // from merge-base, never gated on the declared scope. (Historically the
1835
- // touches-gated drift scanner returned a null baseline for empty touches[],
1836
- // which once blinded the grader to a fully-committed diff and hard-FAILed
1837
- // empty-touches newcomer ships; the scanner is gone as of task 879, but the
1838
- // grader keeping its own merge-base baseline remains the correct invariant.)
1839
- // baseSha (task 1002422): a validated --base override wins over merge-base, so a
1840
- // task whose commits already landed on main still grades its real diff instead of
1841
- // seeing an empty one.
1842
- function computeGraderBaseline(claim, baseSha = baseOverrideSha) {
1843
- if (baseSha) return baseSha;
1844
- return (
1845
- gitOk('git merge-base origin/main HEAD') ||
1846
- gitOk('git merge-base main HEAD') ||
1847
- (claim && claim.head_sha) ||
1848
- null
1849
- );
1850
- }
1851
-
1852
- function computeChangedFiles(baseline) {
1853
- if (!baseline) return [];
1854
- const out = gitOk(`git diff --name-only ${baseline}...HEAD`);
1855
- return out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : [];
1856
- }
1857
-
1858
- function computeDiffText(baseline) {
1859
- if (!baseline) return '';
1860
- // -M to track renames; --no-color so the subagent gets clean diff lines.
1861
- const diff = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
1862
- if (!diff) return '';
1863
- if (diff.length > GRADER_DIFF_CHAR_CAP) {
1864
- const head = diff.slice(0, GRADER_DIFF_CHAR_CAP);
1865
- return `${head}\n\n[diff truncated — original was ${diff.length} chars, capped at ${GRADER_DIFF_CHAR_CAP}. The subagent can Read any file in its post-resolve state for the full picture.]`;
1866
- }
1867
- return diff;
1868
- }
1869
-
1870
- function computeDiffShortstat(baseline) {
1871
- if (!baseline) return null;
1872
- const summary = gitOk(`git diff --shortstat ${baseline}...HEAD`);
1873
- if (!summary) return { files_changed: 0, lines_added: 0, lines_removed: 0 };
1874
- const files = /(\d+)\s+files?\s+changed/.exec(summary);
1875
- const ins = /(\d+)\s+insertions?\(\+\)/.exec(summary);
1876
- const del = /(\d+)\s+deletions?\(-\)/.exec(summary);
1877
- return {
1878
- files_changed: files ? Number(files[1]) : 0,
1879
- lines_added: ins ? Number(ins[1]) : 0,
1880
- lines_removed: del ? Number(del[1]) : 0,
1881
- };
1882
- }
1883
-
1884
- // ---------- V4.R08 — diff fingerprint + grade reuse ----------
1885
-
1886
- // SHA-256 of the raw diff string. Stable identifier for "has the committed
1887
- // diff changed?" — used as the key to skip re-running the adversarial panel
1888
- // when the diff is byte-for-byte identical to the last PASS.
1889
- function diffHash(rawDiff) {
1890
- return crypto.createHash('sha256').update(rawDiff || '').digest('hex');
1891
- }
1892
-
1893
- // Compute the hash of the full (untruncated) diff against baseline. Returns
1894
- // null when baseline is unavailable so callers can treat null as "unknown".
1895
- function computeRawDiffHash(baseline) {
1896
- if (!baseline) return null;
1897
- const raw = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
1898
- return diffHash(raw || '');
1899
- }
1900
-
1901
- // Pure predicate: should we reuse the stored grade instead of running the
1902
- // panel again? True when a PASS grade exists for the exact same diff hash.
1903
- // A null storedGrade, a non-PASS grade, or a hash mismatch all return false.
1904
- function shouldReuseGrade(storedGrade, currentDiffHash) {
1905
- if (!storedGrade || !storedGrade.passed) return false;
1906
- if (!storedGrade.diff_hash || !currentDiffHash) return false;
1907
- return storedGrade.diff_hash === currentDiffHash;
1908
- }
1909
-
1910
- // task 1002660: task_grades has no issues column — the panel's deduped
1911
- // issues[] survive only inside signals.workers[].issues. Rebuild them (same
1912
- // (file, summary) dedupe as aggregatePanel) so the reuse re-POST carries the
1913
- // substance the original PASS carried. Before this, the reused body posted
1914
- // issues: undefined, so every reused PASS with findings read as a zero-issue
1915
- // pass server-side and tripped the SR-9 plausibility guard into
1916
- // manual-confirm — 0/106 successful uses, the feature defeated itself.
1917
- function reconstructPanelIssues(signals) {
1918
- const out = [];
1919
- const seen = new Set();
1920
- for (const w of (Array.isArray(signals?.workers) ? signals.workers : [])) {
1921
- for (const it of (Array.isArray(w?.issues) ? w.issues : [])) {
1922
- if (!it || typeof it !== 'object') continue;
1923
- const key = `${it.file || ''}|${it.summary || ''}`;
1924
- if (seen.has(key)) continue;
1925
- seen.add(key);
1926
- out.push(it);
1927
- }
1928
- }
1929
- return out;
1930
- }
1931
-
1932
- // --- Subagent grader runner ---
1933
- // Wraps grader.gradeSubagent so ship.js callers see a single function with
1934
- // progress logging. MAS Phase 2.0 #410: gradeSubagent now runs the WORKER
1935
- // PANEL (Narc + Quality; Hacker / Efficiency Monger added in 2.1/2.2) in
1936
- // parallel. Each worker is a specialized prompt with its own verdict; the
1937
- // panel gate is unanimous PASS — any 'fail' blocks, any 'concerns' falls
1938
- // through to manual-confirm.
1939
- async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: precomputedChanged, priorRound = null }) {
1940
- // Reuse the caller's changed-files list when provided (the empty-diff guard
1941
- // computes it just before calling us) so we don't run `git diff --name-only`
1942
- // twice per ship (#513).
1943
- const changedFiles = precomputedChanged || computeChangedFiles(baseline);
1944
- const diff = computeDiffText(baseline);
1945
- const shortstat = computeDiffShortstat(baseline);
1946
- // SEC #863: feed the grader the builder's LIVE rank so the deterministic
1947
- // permission-path pre-pass can hard-fail a sub-Metic diff that touches the
1948
- // rank/permission/ship-grade/deploy machinery. Best-effort read; if it fails
1949
- // the pre-pass fails OPEN (the pre-push hook + post-push main audit are the
1950
- // fail-closed layers).
1951
- let builderRank;
1952
- try {
1953
- const meRes = noteServerReward(await (await cliClient()).me.getMe());
1954
- if (meRes.ok) builderRank = meRes.data?.builder?.rank;
1955
- } catch (_) { /* unknown rank → pre-pass fails open; audit + hook still gate */ }
1956
- const runOpts = {
1957
- timeoutMs: GRADER_TIMEOUT_MS,
1958
- cwd: process.cwd(), // grade the FEATURE worktree, not REPO_ROOT
1959
- };
1960
- // task 1002659: the real session model (transcript-derived) drives the
1961
- // cross-model grader pick; null → the grader assumes, as before.
1962
- const builderModel = resolveBuilderModel();
1963
- const t0 = Date.now();
1964
- let result;
1965
- // #513: a small copy/docs-only diff gets a SINGLE generalist grader shot
1966
- // rather than the full adversarial panel — "lighter, not skipped". The 1-shot
1967
- // still returns ff+cq+economy+confidence so the server gate applies; the
1968
- // deterministic scanner + smoke + route-rank checks ran regardless. Fixes the
1969
- // inefficiency of running a 4-worker panel on a +1/-1 copy tweak.
1970
- if (grader.isTrivialDiff({ changedFiles, diffStats: shortstat })) {
1971
- const totalLines = (shortstat.lines_added || 0) + (shortstat.lines_removed || 0);
1972
- console.log(` subagent grader ${grader.VERSION}: TRIVIAL diff (${shortstat.files_changed} file(s), ${totalLines} line(s), copy/docs only) → single generalist shot, model=${grader.selectGraderModel({ builderModel })} — instead of the ${grader.WORKERS.DEFAULT_PANEL.length}-worker panel (#513)`);
1973
- console.log(` gate: the shot must return functional_fidelity>=${grader.THRESHOLD} AND code_quality>=${grader.THRESHOLD} AND confidence>=0.4`);
1974
- result = await grader.gradeSubagentV3MultiShot({
1975
- task,
1976
- valueSummary,
1977
- diff,
1978
- changedFiles,
1979
- diffStats: shortstat,
1980
- shots: 1,
1981
- builderModel, // task 1002659: real session model → cross-model grader pick
1982
- runOpts,
1983
- });
1984
- // task 1002660: the single generalist shot has no Hacker or Efficiency
1985
- // lens — acceptable for genuinely inert copy/docs files, but never
1986
- // silently. The marker makes the dropped lenses queryable by /grade-audit
1987
- // (same pattern as implausible_pass_waived, ADR 0164).
1988
- result.signals = {
1989
- ...(result.signals || {}),
1990
- trivial_path: true,
1991
- lenses_dropped: ['hacker', 'efficiency'],
1992
- };
1993
- } else {
1994
- const panel = grader.WORKERS.DEFAULT_PANEL;
1995
- console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.selectGraderModel({ builderModel })} (builder ${builderModel || `${grader.ASSUMED_BUILDER_MODEL} assumed`}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
1996
- console.log(` gate: every worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
1997
- if (priorRound) {
1998
- console.log(` delta re-grade (task 1002662): prior round's findings + builder response ride along, fenced; answered findings can only re-veto with NEW evidence`);
1999
- }
2000
- result = await grader.gradeSubagent({
2001
- task,
2002
- valueSummary,
2003
- diff,
2004
- changedFiles,
2005
- diffStats: shortstat, // V3.R92 #353: feed diff size into the economy axis
2006
- builderRank, // SEC #863: drives the permission-path pre-pass
2007
- builderModel, // task 1002659: real session model → cross-model grader pick
2008
- priorRound, // task 1002662: delta-aware re-grade context (panel path only)
2009
- runOpts,
2010
- });
2011
- }
2012
- const wallMs = Date.now() - t0;
2013
- // Decorate signals with the local shortstat — useful in audit-trail.
2014
- // graded_head_sha (task 1002662): record WHAT commit this grade judged, so a
2015
- // later --regrade can compute the interim fix diff (prior graded HEAD → now)
2016
- // for the delta-aware prior-round payload.
2017
- result.signals = {
2018
- ...(result.signals || {}),
2019
- diff_stats: shortstat,
2020
- wall_ms: wallMs,
2021
- changed_files_count: changedFiles.length,
2022
- graded_head_sha: gitOk('git rev-parse HEAD') || null,
2023
- };
2024
- return result;
2025
- }
2026
-
2027
- function runSmoke() {
2028
- // `cwd: REPO_ROOT` + a relative script path is robust to spaces or other
2029
- // shell metacharacters that may appear in the absolute repo path on some
2030
- // machines.
2031
- const okPms = shellOk(`"${process.execPath}" scripts/gds/smoke-gds.js`, { cwd: REPO_ROOT });
2032
- return { ran: true, clean: okPms };
2033
- }
2034
-
2035
- // task 1002660: the async twin of runSmoke for the PRE-RESOLVE gate only. The
2036
- // sync runSmoke blocks the event loop (execSync), which would freeze the
2037
- // overlapped grade-panel promise mid-flight; awaiting this one keeps the loop
2038
- // free so the panel's subprocess callbacks progress while smoke runs. The
2039
- // post-merge smoke call keeps the sync form (nothing overlaps there).
2040
- function runSmokeAsync() {
2041
- return new Promise((resolve) => {
2042
- const child = spawn(process.execPath, ['scripts/gds/smoke-gds.js'], {
2043
- cwd: REPO_ROOT, stdio: 'inherit', windowsHide: true,
2044
- });
2045
- child.on('error', () => resolve({ ran: true, clean: false }));
2046
- child.on('close', (code) => resolve({ ran: true, clean: code === 0 }));
2047
- });
2048
- }
2049
-
2050
- // ---------- Step 3: merge + deploy chain ----------
2051
-
2052
- function currentBranch() {
2053
- return gitOk('git rev-parse --abbrev-ref HEAD');
2054
- }
2055
-
2056
- // Task #357: keep the 3 mechanical onboarding diagrams current on every deploy.
2057
- // Regenerate them from live facts; if any changed, commit the refresh on this
2058
- // branch so it ships with the deploy. Non-fatal — a render/network hiccup must
2059
- // never block a ship; the diagram-drift routine will flag any miss.
2060
- //
2061
- // Returns a status string ('skipped-server' | 'regen-failed' | 'current' |
2062
- // 'committed' | 'committed-nofatal') so the regression test (task 1046) can
2063
- // prove the dev-box skip without git or a headless browser.
2064
- function regenerateDiagrams() {
2065
- // task 1046: on the server-mediated-publish path (a dev box — pushVia 'server'),
2066
- // there is no headless browser, so render-diagrams.sh fails and gen-diagrams.js
2067
- // exits non-zero AFTER writing the regenerated .mmd + assertions.json — which
2068
- // shellOk() then treats as a failed regen and returns BEFORE the commit below,
2069
- // leaving those files UNCOMMITTED in the builder's tree (had to be reverted by
2070
- // hand). The box must not attempt the regen at all: skip here and let a
2071
- // render-capable ship (the laptop path, which CAN render the PNGs) refresh them
2072
- // as a matched .mmd+.png+assertions set; the diagram-drift routine backstops any
2073
- // staleness in the meantime. Skipping entirely (vs --no-render) avoids committing
2074
- // a .mmd/.png mismatch to main. Mirrors gen-diagrams.js's own non-fatal skip when
2075
- // bash itself is absent (ENOENT).
2076
- if (pushVia() === 'server') {
2077
- console.log(' diagram regen skipped on the dev-box ship path (no GDS session for the conceptual diagrams) — a full-clone ship refreshes the .mmd; drift checker backstops staleness');
2078
- return 'skipped-server';
2079
- }
2080
- // ADR 0110 pt.3 (task 2027): the .svg/.png RENDERS are now gitignored and
2081
- // regenerated at deploy (deploy-prod.sh) — no longer committed here — so ship
2082
- // only refreshes the TRACKED sources. Run gen-diagrams with --no-render: it
2083
- // rewrites the mechanical .mmd + assertions.json from live facts WITHOUT
2084
- // invoking the headless-Chromium render, which is why this no longer needs a
2085
- // render-capable machine. The gitignored .svg/.png never appear in the git add
2086
- // below (a directory add silently skips ignored paths).
2087
- console.log(' regenerating onboarding diagram SOURCES from live facts (--no-render; renders are a deploy artifact, ADR 0110 pt.3)...');
2088
- // process.execPath (quoted) instead of bare `node`: on Windows cmd.exe can't
2089
- // resolve a bare `node` without a full PATH, the same failure #660 fixed for
2090
- // the smoke-gds spawn. The quotes survive spaces in a Windows install path
2091
- // (e.g. C:\Program Files\nodejs\node.exe).
2092
- if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-diagrams.js'))} --no-render`)) {
2093
- console.log(' diagram regen failed (non-fatal) — drift checker will flag any staleness');
2094
- return 'regen-failed';
2095
- }
2096
- const dirty = gitOk('git status --porcelain docs/onboarding/diagrams');
2097
- if (!dirty) {
2098
- console.log(' diagram sources already current — nothing to commit');
2099
- return 'current';
2100
- }
2101
- shellOk('git add docs/onboarding/diagrams');
2102
- if (shellOk('git commit -m "chore(diagrams): auto-regenerate diagram sources from live facts (task 357)"')) {
2103
- console.log(' committed auto-regenerated diagrams');
2104
- return 'committed';
2105
- }
2106
- return 'committed-nofatal';
2107
- }
2108
-
2109
- // task 1224 (Path B / ADR 0063): refresh the generated symbol skeleton from the shipped tree so the
2110
- // altitude map can't drift. Pure JS (no headless browser, no render) — unlike the diagrams it is safe
2111
- // on EVERY ship path, including the dev-box server-publish path. Non-fatal: a hiccup must never block
2112
- // a ship. ADR 0110 (task 1926): docs/repo-map.md is now GITIGNORED + regenerated at deploy — the
2113
- // generator still WRITES it (untracked, harmless), but ship commits ONLY the tracked nested-doc
2114
- // blocks. It is deliberately absent from the git add/status lists below: `git add <ignored path>`
2115
- // ABORTS the whole command (exit 1), which would drop the nested-block commit. The block bucket is
2116
- // backstopped on main by fitness.js (`gen-repo-map --check-block`).
2117
- function regenerateRepoMap() {
2118
- console.log(' regenerating code symbol skeleton (repo map + nested-doc blocks) from the shipped tree...');
2119
- if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-repo-map.js'))}`)) {
2120
- console.log(' repo-map regen failed (non-fatal) — CI --check-block backstops the tracked blocks');
2121
- return 'regen-failed';
2122
- }
2123
- const dirty = gitOk('git status --porcelain src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
2124
- if (!dirty) {
2125
- console.log(' nested-doc symbol blocks already current — nothing to commit (docs/repo-map.md is gitignored, ADR 0110)');
2126
- return 'current';
2127
- }
2128
- shellOk('git add src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
2129
- if (shellOk('git commit -m "chore(repo-map): auto-regenerate nested-doc symbol blocks (task 1224)"')) {
2130
- console.log(' committed auto-regenerated nested-doc symbol blocks');
2131
- return 'committed';
2132
- }
2133
- return 'committed-nofatal';
2134
- }
2135
-
2136
- // Refresh the generated session-log index (docs/session-log-index.md) + the bounded CLAUDE.md §13
2137
- // "recent sessions" snippet from the shipped tree's docs/session-logs/*.md (ADR 0062 §8, task 1226).
2138
- // Pure JS (reads files, no DB, no headless browser) — safe on EVERY ship path including the dev-box
2139
- // server-publish path. Non-fatal: a hiccup must never block a ship; the Path-C CI gate
2140
- // (`gen-session-index --check`) backstops staleness on main.
2141
- // Refresh the generated API docs (docs/api/openapi.json + docs/api-reference.md +
2142
- // docs/routes-permissions.md) from the shipped route tree (task 1918). Pure JS (reads
2143
- // route files, no DB, no headless browser) — safe on EVERY ship path incl. the dev-box
2144
- // server-publish path. Non-fatal: a hiccup must never block a ship; the fitness
2145
- // `gen-api-docs --check` gate backstops staleness on main.
2146
- function regenerateApiDocs() {
2147
- console.log(' regenerating OpenAPI spec + API reference from the shipped route tree...');
2148
- if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-api-docs.js'))}`)) {
2149
- console.log(' api-docs regen failed (non-fatal) — CI --check backstops staleness');
2150
- return 'regen-failed';
2151
- }
2152
- const dirty = gitOk('git status --porcelain docs/api/openapi.json docs/api-reference.md docs/routes-permissions.md');
2153
- if (!dirty) {
2154
- console.log(' API docs already current — nothing to commit');
2155
- return 'current';
2156
- }
2157
- shellOk('git add docs/api/openapi.json docs/api-reference.md docs/routes-permissions.md');
2158
- if (shellOk('git commit -m "chore(api-docs): auto-regenerate OpenAPI spec + reference (task 1918)"')) {
2159
- console.log(' committed auto-regenerated API docs');
2160
- return 'committed';
2161
- }
2162
- return 'committed-nofatal';
2163
- }
2164
-
2165
- // Refresh the generated typed client (clients/bongos-client) FROM the just-regenerated
2166
- // spec, then keep the devbox vendored CJS copy in lockstep (task 2051). Runs right
2167
- // after regenerateApiDocs so the client reflects the same route tree. Automatic here
2168
- // (not fail-if-forgotten): before this, ship regenerated the spec but not the client,
2169
- // so a route change left the client stale until the api_client test caught it in CI.
2170
- // Non-fatal + safe on every ship path; the api_client freshness test backstops main.
2171
-
2172
- // Where the devbox Electron app vendors the CJS client (ADR 0045). It lives inside the
2173
- // dev-box MODULE (ADR 0145) — an instance that ships without that module has no copy.
2174
- const VENDORED_CLIENT_REL = 'modules/dev-box/app/src/vendor/bongos-client.cjs';
2175
-
2176
- // The git pathspec for the regenerated client. Pure + exported so the rule is unit-tested
2177
- // with no git/fs (task 1002417). `git status` tolerates an unmatched pathspec (exit 0) but
2178
- // `git add` aborts the ENTIRE staging step (exit 128), which leaves the regenerated client
2179
- // unstaged and the worktree dirty — and a dirty worktree makes the ship's auto-merge bail,
2180
- // stranding the task at `confirmed` with no PR. So the vendored copy is named ONLY when it
2181
- // is actually on disk.
2182
- function apiClientPathspec({ worktreeRoot, existsSync = fs.existsSync } = {}) {
2183
- const paths = ['clients/bongos-client'];
2184
- if (existsSync(path.join(worktreeRoot, VENDORED_CLIENT_REL))) paths.push(VENDORED_CLIENT_REL);
2185
- return paths;
2186
- }
2187
-
2188
- function regenerateApiClient() {
2189
- console.log(' regenerating typed API client (clients/bongos-client) from the OpenAPI spec...');
2190
- if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-api-client.js'))}`)) {
2191
- console.log(' api-client regen failed (non-fatal) — CI api_client freshness test backstops staleness');
2192
- return 'regen-failed';
2193
- }
2194
- // The devbox Electron app can't require() clients/ at runtime, so it vendors the CJS
2195
- // build (ADR 0045); keep the copy byte-identical (the api_client test asserts it).
2196
- // Present only where the dev-box module ships — an instance without it has no vendored
2197
- // copy, so both the vendor step and the git pathspec below drop out (task 1002417: the
2198
- // app moved from top-level devbox-app/ to modules/dev-box/app/ per ADR 0145, and these
2199
- // paths were not updated, so the guard below silently never fired).
2200
- // Resolve it WORKTREE-LOCALLY (like the generator above, __dirname=scripts/gds), NOT
2201
- // via REPO_ROOT: on a ship from a linked worktree REPO_ROOT is the MAIN checkout, so a
2202
- // REPO_ROOT path would vendor the main tree while gen-api-client just wrote THIS tree,
2203
- // leaving this worktree's vendored copy stale → the api_client byte-identity test reds
2204
- // the PR (task 2060 fix). __dirname keeps the generator, the vendored file, and the
2205
- // git add all on the same tree.
2206
- const worktreeRoot = path.join(__dirname, '..', '..');
2207
- const vendorScript = path.join(worktreeRoot, 'modules', 'dev-box', 'app', 'scripts', 'vendor-client.js');
2208
- if (fs.existsSync(vendorScript)) {
2209
- shellOk(`"${process.execPath}" ${JSON.stringify(vendorScript)}`);
2210
- }
2211
- const clientPathspec = apiClientPathspec({ worktreeRoot }).join(' ');
2212
- const dirty = gitOk(`git status --porcelain ${clientPathspec}`);
2213
- if (!dirty) {
2214
- console.log(' API client already current — nothing to commit');
2215
- return 'current';
2216
- }
2217
- shellOk(`git add ${clientPathspec}`);
2218
- if (shellOk('git commit -m "chore(api-client): auto-regenerate typed client + devbox vendor (task 2051)"')) {
2219
- console.log(' committed auto-regenerated API client');
2220
- return 'committed';
2221
- }
2222
- return 'committed-nofatal';
2223
- }
2224
-
2225
- // ADR 0110 (task 1926): docs/session-log-index.md is GITIGNORED + deploy-regenerated — the generator
2226
- // still writes it (untracked), but ship commits ONLY the tracked CLAUDE.md §13 snippet. The index is
2227
- // omitted from the git add/status lists (an explicit `git add` of an ignored path aborts the commit);
2228
- // the tracked snippet is backstopped on main by fitness.js (`gen-session-index --check-block`).
2229
- function regenerateSessionIndex() {
2230
- console.log(' regenerating session-log index + CLAUDE.md snippet from the shipped tree...');
2231
- if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-session-index.js'))}`)) {
2232
- console.log(' session-index regen failed (non-fatal) — CI --check-block backstops the tracked snippet');
2233
- return 'regen-failed';
2234
- }
2235
- const dirty = gitOk('git status --porcelain CLAUDE.md');
2236
- if (!dirty) {
2237
- console.log(' CLAUDE.md §13 snippet already current — nothing to commit (docs/session-log-index.md is gitignored, ADR 0110)');
2238
- return 'current';
2239
- }
2240
- shellOk('git add CLAUDE.md');
2241
- if (shellOk('git commit -m "chore(session-log): auto-regenerate CLAUDE.md §13 snippet (task 1226)"')) {
2242
- console.log(' committed auto-regenerated CLAUDE.md §13 snippet');
2243
- return 'committed';
2244
- }
2245
- return 'committed-nofatal';
2246
- }
2247
-
2248
- // Refresh the generated file-map sections (.claude/skills/ + .claude/scheduled-tasks/ trees in
2249
- // docs/file-map.md) from what is on disk (ADR 0066, task 1276). Pure JS (reads dirs + the
2250
- // docs/file-map.notes.json sidecar, no DB, no headless browser) — safe on EVERY ship path including
2251
- // the dev-box server-publish path. Non-fatal: a hiccup must never block a ship; the Path-C CI gate
2252
- // (`gen-file-map --check`, in fitness.js) backstops staleness on main. NB gen-file-map exits 2 when
2253
- // a skill/routine has no note, so shellOk may report "failed" on a legit note-gap ship — that is
2254
- // fine, we still commit whatever it wrote and CI surfaces the missing note.
2255
- function regenerateFileMap() {
2256
- console.log(' regenerating file-map sections (skills + scheduled-tasks) from the shipped tree...');
2257
- shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-file-map.js'))}`);
2258
- const dirty = gitOk('git status --porcelain docs/file-map.md');
2259
- if (!dirty) {
2260
- console.log(' file-map already current — nothing to commit');
2261
- return 'current';
2262
- }
2263
- shellOk('git add docs/file-map.md');
2264
- if (shellOk('git commit -m "chore(file-map): auto-regenerate skills + scheduled-tasks sections (task 1276)"')) {
2265
- console.log(' committed auto-regenerated file-map sections');
2266
- return 'committed';
2267
- }
2268
- return 'committed-nofatal';
2269
- }
2270
-
2271
- // ---------- merge serialization lock (#462 C4; extended in #269 C6) ----------
2272
- //
2273
- // autoMerge() mutates the SHARED main worktree (REPO_ROOT). Two concurrent
2274
- // ships — or a ship racing a /merge-mode in another session — collide on that
2275
- // single working tree. Observed 2026-05-30: a parallel session was mid-merge
2276
- // in REPO_ROOT, so this ship's `git merge --ff-only` failed and it bailed with
2277
- // the misleading "main is ahead of origin somehow".
2278
- //
2279
- // The lock primitives now live in src/bongos/merge-lock.js (V3.R50 / #269) so BOTH
2280
- // ship.js AND the /merge-mode CLI take the SAME mutex — closing the
2281
- // ship-vs-merge-mode and merge-mode-vs-merge-mode races that the in-ship.js
2282
- // lock could not cover. See docs/recipes/multi-builder-merge.md.
2283
-
2284
- // REPO_ROOT must be on main, not mid-merge, and clean before we mutate it.
2285
- // Returns a human reason string if busy, or null if safe to proceed.
2286
- function mainWorktreeBusy() {
2287
- if (gitOk('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT })) {
2288
- return 'a merge is already in progress';
2289
- }
2290
- const dirty = gitOk('git status --porcelain', { cwd: REPO_ROOT });
2291
- if (dirty && dirty.trim().length > 0) {
2292
- return 'the working tree is dirty or has unmerged files';
2293
- }
2294
- return null;
2295
- }
2296
-
2297
- // task 391 (idea #37): the verdict for a ship whose branch has no commits ahead
2298
- // of main. Pure, so the gate is unit-tested.
2299
- // 'has-commits' → normal path: there is a real diff to merge + deploy. (Also
2300
- // the value when `git rev-list` failed/returned null — same as
2301
- // the original `=== '0'` check, which only short-circuited an
2302
- // exact '0'.)
2303
- // 'db-only' → zero commits + explicit --db-only: a deliberate direct-SQL
2304
- // ship (work already live in the prod DB); short-circuit to shipped.
2305
- // 'refuse' → zero commits + NO --db-only: almost always a mistake
2306
- // (uncommitted work / wrong branch / empty touches[]); refuse.
2307
- function zeroCommitVerdict(commitsAhead, dbOnly) {
2308
- if (commitsAhead !== '0') return 'has-commits';
2309
- return dbOnly ? 'db-only' : 'refuse';
2310
- }
2311
-
2312
- async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
2313
- const branch = currentBranch();
2314
- if (!branch || branch === 'main' || branch === 'HEAD') {
2315
- // task 1002723: an --allow-empty / --db-only ship legitimately runs FROM the
2316
- // main checkout (nothing was ever committed), but this bail fired BEFORE the
2317
- // deliberate-empty landing below could — the task parked at confirmed with no
2318
- // branch and no published tip, a shape the server sweep can never prove (the
2319
- // task-1002716 strand). Land it here iff the checkout has zero commits ahead
2320
- // of origin/main; anything else keeps the safe bail.
2321
- if (dbOnly && shellOk('git fetch origin main')
2322
- && zeroCommitVerdict(gitOk('git rev-list origin/main..HEAD --count'), true) === 'db-only') {
2323
- console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) from the main checkout - nothing to merge; advancing to shipped without merge/deploy.');
2324
- return landed({ noArtifact: true, noArtifactReason: 'empty ship from the main checkout — no commits to merge' });
2325
- }
2326
- console.log(` auto-merge: skipped (current branch is ${branch})`);
2327
- // The merge source IS the current branch, so on main there is nothing to
2328
- // merge. Half of the single-checkout catch-22 this task documents: the other
2329
- // half is the "main worktree is on <branch>, not main" bail below.
2330
- return landBailed(`nothing to merge — this checkout is on ${branch}, not a task branch`, '/merge-mode');
2331
- }
2332
-
2333
- // Refresh the data-driven diagrams before computing commits-ahead, so a
2334
- // diagram-only refresh still counts as something to deploy.
2335
- regenerateDiagrams();
2336
- // Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
2337
- regenerateRepoMap();
2338
- // Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
2339
- regenerateSessionIndex();
2340
- // Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
2341
- regenerateFileMap();
2342
- // Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
2343
- regenerateApiDocs();
2344
- // ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
2345
- regenerateApiClient();
2346
-
2347
- // Resolve the deploy mode ONCE (task 1093 / C9) — it reads config/deploy.json;
2348
- // reused at the fetch-bail check + the ci-land branch below.
2349
- const deployMode = resolveDeployMode();
2350
-
2351
- // DB-only / no-op ship detection (task #219). Some legitimate ships have
2352
- // no commits to merge — e.g. a task whose work was a direct SQL write to
2353
- // the production DB. The change is already live; the merge step has
2354
- // nothing to push, merge, or deploy. Short-circuit so the caller's POST
2355
- // /tasks/:id/ship still flips status to shipped instead of stranding the
2356
- // task at confirmed forever.
2357
- // A credential-less dev box can't `git fetch origin main` (private origin, no
2358
- // creds — ADR 0053), but that's only fatal OFF the server-publish path: the
2359
- // box's ADR-0055 path bundles against its LOCAL origin/main and the server tops
2360
- // up main. Without this, the box dead-ends here and strands every ship at
2361
- // confirmed (task 1027). The `!fetchedMain &&` short-circuit keeps the gh-probe
2362
- // (pushVia) off the happy laptop path, so laptop behavior is byte-for-byte.
2363
- const fetchedMain = shellOk('git fetch origin main');
2364
- if (!fetchedMain && shouldBailOnFetchFailure(fetchedMain, deployMode, pushVia())) {
2365
- console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
2366
- return landBailed('could not fetch origin/main', '/merge-mode');
2367
- }
2368
- const commitsAhead = gitOk('git rev-list origin/main..HEAD --count');
2369
- const verdict = zeroCommitVerdict(commitsAhead, dbOnly);
2370
- if (verdict === 'refuse') {
2371
- // task 391 (idea #37): the old code short-circuited ANY zero-commit branch
2372
- // straight to shipped — silently advancing a claim with empty/wrong touches[]
2373
- // or simply uncommitted work, awarding credit for no change. Now a zero-commit
2374
- // ship must be DELIBERATELY declared with --db-only. Fail-safe: the claim is
2375
- // already resolved by this point, so the task stays at confirmed and a plain
2376
- // re-run resumes the merge leg (task 941) once they commit or pass --db-only.
2377
- console.error('');
2378
- console.error(' Ship refused: no commits ahead of main, and --db-only was not passed.');
2379
- console.error(' A zero-commit ship is almost always a mistake (uncommitted work, wrong branch, or empty/incorrect touches[]).');
2380
- console.error(' • If you have local changes, commit them, then re-ship.');
2381
- console.error(' • If this is a DELIBERATE direct-SQL / DB-only ship (the work was a write straight to the prod');
2382
- console.error(' DB — nothing to merge or deploy), re-run with --db-only:');
2383
- console.error(` node scripts/gds/ship.js ${taskId} --db-only`);
2384
- console.error(' The task stays at confirmed; nothing was merged or deployed.');
2385
- return landBailed('no commits ahead of main, and --db-only was not passed', `node scripts/gds/ship.js ${taskId} --db-only`);
2386
- }
2387
- if (verdict === 'db-only') {
2388
- console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) - no commits ahead of main; advancing to shipped without merge/deploy.');
2389
- return landed({ noArtifact: true, noArtifactReason: 'empty ship — no commits ahead of main' });
2390
- }
2391
-
2392
- // #679 / ADR 0042: in 'ci' deploy mode the operator's machine never merges or
2393
- // SSHes the droplet — ship lands via PR auto-merge and CI deploys. The laptop
2394
- // block below stays the default ('laptop' mode) so this is inert until the
2395
- // deliberate cutover. The DB-only short-circuit above is shared by both modes.
2396
- if (deployMode === 'ci') {
2397
- return await ciLand(taskId, branch, valueSummary);
2398
- }
2399
-
2400
- console.log(` pushing ${branch} to origin...`);
2401
- if (!gitArgvOk(['push', 'origin', branch])) {
2402
- console.log(' auto-merge: push failed; task stays at confirmed for /merge-mode');
2403
- return landBailed(`could not push ${branch} to origin`, '/merge-mode');
2404
- }
2405
-
2406
- // Serialize the shared-main-worktree merge (#462 C4; #269 C6). Concurrent
2407
- // ships AND manual /merge-mode runs queue on this one lock; if a live holder
2408
- // is still merging past the wait window we bail cleanly to /merge-mode rather
2409
- // than racing its working tree.
2410
- if (!mergeLock.acquire(REPO_ROOT)) {
2411
- console.log(' auto-merge: another active ship or /merge-mode run holds the merge lock; task stays at confirmed for /merge-mode');
2412
- return landBailed('another ship or /merge-mode run holds the merge lock', '/merge-mode');
2413
- }
2414
- let mergedClean = false;
2415
- try {
2416
- // Never touch REPO_ROOT if a parallel session left it mid-merge or dirty —
2417
- // this is the exact collision that previously bailed with the misleading
2418
- // "main is ahead of origin somehow" (#462).
2419
- const busy = mainWorktreeBusy();
2420
- if (busy) {
2421
- console.log(` auto-merge: main worktree busy (${busy}); task stays at confirmed for /merge-mode`);
2422
- return landBailed(`the main worktree is busy (${busy})`, '/merge-mode');
2423
- }
2424
-
2425
- console.log(` switching to main worktree (${REPO_ROOT}) for merge...`);
2426
- // Same cleanup as preflight: drop macOS Finder/iCloud `* 2` ref duplicates
2427
- // before fetching, otherwise `git fetch origin` fails with "bad object
2428
- // refs/heads/<name> 2" and the auto-merge bails. Was a recurring 2026-05-10
2429
- // failure mode — every ship of the day hit this and required /merge-mode.
2430
- const dupRefs = cleanupMacFinderDupRefs(REPO_ROOT);
2431
- if (dupRefs.length > 0) {
2432
- console.log(` auto-merge: removed ${dupRefs.length} stray refs (macOS Finder duplicates)`);
2433
- }
2434
- if (!shellOk(`git fetch origin`, { cwd: REPO_ROOT })) {
2435
- console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
2436
- return landBailed('could not fetch origin in the main worktree', '/merge-mode');
2437
- }
2438
-
2439
- // The main worktree must currently have main checked out.
2440
- const onMain = gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT });
2441
- if (onMain !== 'main') {
2442
- console.log(` auto-merge: main worktree is on ${onMain}, not main; task stays at confirmed`);
2443
- // The other half of the single-checkout catch-22: the merge target tree is
2444
- // the one holding the feature branch, so there is nowhere to merge INTO.
2445
- // Name the cure, not just the symptom — this is the bail that used to be
2446
- // invisible behind "landing automatically".
2447
- return landBailed(
2448
- `the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`,
2449
- '/merge-mode',
2450
- );
2451
- }
2452
-
2453
- if (!gitArgvOk(['merge', '--ff-only', 'origin/main'], { cwd: REPO_ROOT })) {
2454
- console.log(' auto-merge: main is ahead of origin somehow; task stays at confirmed');
2455
- return landBailed('the local main is ahead of origin/main', '/merge-mode');
2456
- }
2457
-
2458
- // #681: build the merge argv and run it WITHOUT a shell. valueSummary is
2459
- // DB-sourced (untrusted) — passing it as a literal argv element means
2460
- // `$(...)`/backticks in it can never be interpreted as a command.
2461
- if (!gitArgvOk(buildMergeArgv(branch, valueSummary), { cwd: REPO_ROOT })) {
2462
- console.log(' auto-merge: merge failed (conflicts?); task stays at confirmed for manual /merge-mode');
2463
- return landBailed(`merging ${branch} into main failed (conflicts?)`, '/merge-mode');
2464
- }
2465
-
2466
- console.log(' smoke against post-merge state...');
2467
- if (!runSmoke().clean) {
2468
- console.log(' auto-merge: post-merge smoke failed; task stays at confirmed; investigate');
2469
- return landBailed('post-merge smoke tests failed', '/merge-mode');
2470
- }
2471
-
2472
- console.log(' pushing merged main to origin...');
2473
- // SEC #863: route through push-main.js, which carries the
2474
- // OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
2475
- // graded+confirmed pipeline push through while still blocking ad-hoc
2476
- // `git push origin main`. process.execPath (quoted) for the same
2477
- // cross-platform reason as the other node spawns here (#660).
2478
- if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'push-main.js'))}`, { cwd: REPO_ROOT })) {
2479
- console.log(' auto-merge: push failed; task stays at confirmed');
2480
- // task 1002629: a branch-protected main refuses EVERY direct push, so
2481
- // retrying here can never work — resolveDeployMode normally reroutes to
2482
- // the ci PR path before this point; reaching this with protection on
2483
- // means an env-forced 'laptop' or a failed protection probe.
2484
- if (mainIsProtected() === true) {
2485
- return landBailed('main is branch-protected — the direct push can never land; unset OTB_DEPLOY_MODE (the ci PR path lands it)', `node scripts/gds/ship.js ${taskId}`);
2486
- }
2487
- return landBailed('could not push the merged main to origin', '/merge-mode');
2488
- }
2489
-
2490
- // Deploy step. The core carries NO deploy target of its own — under the
2491
- // standalone model a core change reaches a live instance via package-core +
2492
- // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
2493
- // instance whose own checkout IS its live server has configured a target
2494
- // (config/deploy.json "sshTarget" or <PREFIX>_DEPLOY_SSH_TARGET), the land is
2495
- // complete at "merged + pushed to main" and the SSH deploy is skipped. (This
2496
- // used to SSH a hardcoded target — the founder instance's old game box, i.e. the
2497
- // WRONG server post-migration. task 1002202.)
2498
- const { sshTarget, healthzUrl } = resolveDeployTarget({
2499
- configRaw: readDeployConfigRaw(),
2500
- branding: safeBranding(),
2501
- });
2502
- if (!sshTarget) {
2503
- console.log(' merged + pushed to main. No deploy target configured, so no SSH deploy —');
2504
- console.log(' expected for the core. To publish to a live instance run the standalone release');
2505
- console.log(' flow: package-core → bongos upgrade (pin-bump), per ADR 0108. (Not an SSH deploy.)');
2506
- } else {
2507
- console.log(` triggering deploy on ${sshTarget}...`);
2508
- // Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it before ssh
2509
- // transmits the command; with the quotes ssh sends the literal `~/deploy.sh`
2510
- // and the REMOTE shell expands it. (Matches /merge-mode SKILL.md:81.)
2511
- if (!shellOk(`ssh -o ConnectTimeout=10 ${sshTarget} "~/deploy.sh"`)) {
2512
- // task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
2513
- // service-restart window (Node not yet bound) and exit non-zero even though
2514
- // the deploy succeeded — which used to false-fail the ship and force a
2515
- // needless /merge-mode. Before concluding failure, re-probe the CONFIGURED
2516
- // /healthz with backoff (curl's own --retry; cross-platform, no sleep) —
2517
- // never a hardcoded host. --retry-all-errors retries connection-refused
2518
- // during the bind window; -f makes a non-200 an error so a 502/503 also
2519
- // retries. If it comes up within the window the deploy really succeeded.
2520
- if (healthzUrl) {
2521
- console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
2522
- if (shellOk(`curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors ${JSON.stringify(healthzUrl)} -o /dev/null`)) {
2523
- console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
2524
- } else {
2525
- console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
2526
- return landBailed('merged to main, but the deploy failed (/healthz never returned 200)', '/merge-mode');
2527
- }
2528
- } else {
2529
- console.log(' auto-merge: ~/deploy.sh returned non-zero and no healthzUrl configured to re-probe; task stays at confirmed');
2530
- return landBailed('merged to main, but ~/deploy.sh returned non-zero (no healthz to re-probe)', '/merge-mode');
2531
- }
2532
- }
2533
- }
2534
-
2535
- // Merged + deployed — the feature branch is permanently in main's history
2536
- // (--no-ff merge commit) and no longer needed on origin. Deleting it keeps
2537
- // GitHub's "Create PR" affordance from offering empty PRs against zero-diff
2538
- // branches and prevents builder confusion about whether a branch landed.
2539
- // Local refs/heads ref stays so the worktree on the Mac isn't disturbed
2540
- // (cleanup of stale local refs happens next `git fetch --prune`).
2541
- // Non-blocking: a deploy that succeeded shouldn't be marked failed just
2542
- // because the cleanup push fails (e.g. branch already deleted manually,
2543
- // permission glitch, network blip).
2544
- console.log(` cleaning up: deleting ${branch} on origin...`);
2545
- if (!gitArgvOk(['push', 'origin', '--delete', branch], { cwd: REPO_ROOT })) {
2546
- console.log(` warn: could not delete origin/${branch} — may already be gone (non-blocking)`);
2547
- }
2548
-
2549
- console.log(' flipping task to shipped...');
2550
- mergedClean = true;
2551
- return landed();
2552
- } finally {
2553
- // Leave REPO_ROOT clean for the NEXT ship/merge no matter how we exited.
2554
- // A failed merge here wedges everyone else: a conflict leaves the tree
2555
- // mid-merge (MERGE_HEAD + conflicted index → mainWorktreeBusy() rejects all
2556
- // future ships), and a failure AFTER the local merge commit but BEFORE push
2557
- // (post-merge smoke/push/deploy) leaves main ahead-but-unpushed (the next
2558
- // `git merge --ff-only origin/main` then fails with the misleading "main is
2559
- // ahead of origin somehow"). Only normalize on the failure path — on the
2560
- // success path we already pushed, so touching the tree is needless risk.
2561
- // Best-effort: cleanup must never mask the real outcome. This is the
2562
- // clean-failure twin of merge-lock.recoverWorktree() (which handles the
2563
- // crash/stale-reclaim case where we never reach this finally at all).
2564
- if (!mergedClean) {
2565
- try {
2566
- if (gitOk('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT })) {
2567
- shellOk('git merge --abort', { cwd: REPO_ROOT });
2568
- }
2569
- if (gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT }) === 'main') {
2570
- shellOk('git reset --hard origin/main', { cwd: REPO_ROOT });
2571
- }
2572
- } catch (_) {
2573
- /* best-effort cleanup; the lock release below is what matters most */
2574
- }
2575
- }
2576
- mergeLock.release(REPO_ROOT);
2577
- }
2578
- }
2579
-
2580
- // ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
2581
- //
2582
- // #678: the grade step is factored out of shipMain so the `--regrade` recovery
2583
- // path can reuse it verbatim instead of replicating ~50 lines (the trap that
2584
- // learning #43 warned about — "runSubagentGrader in ship.js is NOT exported, so
2585
- // replicate its ~10 lines"). These helpers are behavior-preserving lifts of the
2586
- // inline blocks shipMain used before; both paths now share one implementation.
2587
-
2588
- // Map a grader result into the POST /tasks/:id/grade request body. Forwards the
2589
- // advisory sub-scores (economy/security/efficiency) only when present so the
2590
- // server's validateAndScore re-records them (back-compat: omitted when null).
2591
- function buildGradePayload(gradeResult, committedFiles = null) {
2592
- return {
2593
- // touches[] cleanup [3/8] (task 878): pass the real committed changed-files
2594
- // so the server keys the shipper badges off the diff, not touches[].
2595
- ...(Array.isArray(committedFiles) && committedFiles.length > 0
2596
- ? { committed_files: committedFiles }
2597
- : {}),
2598
- subagent_output: {
2599
- functional_fidelity: gradeResult.rubric_scores?.functional_fidelity,
2600
- code_quality: gradeResult.rubric_scores?.code_quality,
2601
- ...(gradeResult.rubric_scores?.economy != null
2602
- ? { economy: gradeResult.rubric_scores.economy }
2603
- : {}),
2604
- ...(gradeResult.rubric_scores?.security_score != null
2605
- ? { security_score: gradeResult.rubric_scores.security_score }
2606
- : {}),
2607
- ...(gradeResult.rubric_scores?.efficiency_score != null
2608
- ? { efficiency_score: gradeResult.rubric_scores.efficiency_score }
2609
- : {}),
2610
- notes_md: gradeResult.notes_md,
2611
- confidence: gradeResult.signals?.confidence ?? null,
2612
- issues: gradeResult.issues,
2613
- },
2614
- signals: gradeResult.signals,
2615
- };
2616
- }
2617
-
2618
- // Log the grader panel's own API spend (#486 / ADR 0028 path 1). Best-effort +
2619
- // idempotent on (source, source_ref): a cost-log failure must never block a
2620
- // ship/re-grade, and re-grading at the same HEAD is a no-op.
2621
- async function logGraderPanelCost(taskId, gradeResult) {
2622
- const panelCostUsd = Number(gradeResult.panel_cost_usd) || 0;
2623
- if (panelCostUsd <= 0) return;
2624
- const headSha = gitOk('git rev-parse --short HEAD') || 'nohead';
2625
- const costPost = await (await cliClient()).cost.postCost({
2626
- body: {
2627
- task_id: Number(taskId),
2628
- amount_usd: panelCostUsd,
2629
- category: 'api',
2630
- source: 'grader',
2631
- source_ref: `grade-${taskId}-${headSha}`,
2632
- description: `grader panel (${gradeResult.grader_kind || 'subagent'}) on #${taskId}`,
2633
- },
2634
- });
2635
- // An idempotent conflict returns HTTP 200 {skipped:true} (cost.js), so
2636
- // costPost.ok is TRUE on a skip — check `skipped` first.
2637
- if (costPost.data && costPost.data.skipped) {
2638
- console.log(` grader cost already logged for ${headSha} (idempotent skip)`);
2639
- } else if (costPost.ok) {
2640
- console.log(` grader cost logged: $${panelCostUsd.toFixed(4)} → cost_log (category=api, source=grader)`);
2641
- } else {
2642
- console.log(` grader cost log skipped (non-fatal): HTTP ${costPost.status}`);
2643
- }
2644
- }
2645
-
2646
- // task 1002662: assemble the delta-aware prior-round context for a --regrade.
2647
- // Pure (no git/network) so the payload shape is unit-tested. Returns null when
2648
- // there is no prior grade worth being delta-aware of; otherwise:
2649
- // forPanel — { round, findings_md, response_md, interim_md } for the worker
2650
- // prompts (fenced as untrusted by the shared scaffold);
2651
- // audit — the compact regrade block recorded into the new grade's signals
2652
- // so grade_attempts rounds read as remediation history (the
2653
- // task-1002661 reader consumes this).
2654
- const PRIOR_ROUND_TEXT_CAP = 4000;
2655
- function buildPriorRoundContext({ storedGrade, responseText = '', interimCommits = '', interimDiffStat = '' }) {
2656
- if (!storedGrade || typeof storedGrade !== 'object') return null;
2657
- const issues = Array.isArray(storedGrade.issues) ? storedGrade.issues : [];
2658
- const notes = typeof storedGrade.notes_md === 'string' ? storedGrade.notes_md.trim() : '';
2659
- if (issues.length === 0 && !notes) return null;
2660
- const verdictLine = `Prior verdict: ${storedGrade.passed ? 'PASS' : 'FAIL'} · score ${storedGrade.score ?? '?'}/10 · ${issues.length} finding(s)`;
2661
- const findingLines = issues.map((it) => `- [${it.severity || '?'}] ${it.worker || 'panel'} · ${it.file || '?'} — ${it.summary || ''}`);
2662
- const findings_md = [
2663
- verdictLine,
2664
- '',
2665
- ...(findingLines.length ? findingLines : ['(no per-finding rows — see the prior notes below)']),
2666
- ...(notes ? ['', 'Prior panel notes:', notes.slice(0, PRIOR_ROUND_TEXT_CAP)] : []),
2667
- ].join('\n');
2668
- const interimParts = [];
2669
- if (interimCommits) interimParts.push('Interim commits since the prior grade:', String(interimCommits).slice(0, PRIOR_ROUND_TEXT_CAP));
2670
- if (interimDiffStat) interimParts.push('', 'Interim diffstat:', String(interimDiffStat).slice(0, PRIOR_ROUND_TEXT_CAP));
2671
- const response = String(responseText || '').trim();
2672
- // Round numbering is self-consistent across regrades: the first grade is
2673
- // round 1; each regrade reads the prior signals.regrade.round when present.
2674
- const round = (Number(storedGrade.signals?.regrade?.round) || 1) + 1;
2675
- return {
2676
- forPanel: {
2677
- round,
2678
- findings_md,
2679
- response_md: response ? response.slice(0, PRIOR_ROUND_TEXT_CAP) : null,
2680
- interim_md: interimParts.length ? interimParts.join('\n') : null,
2681
- },
2682
- audit: {
2683
- round,
2684
- prior_passed: storedGrade.passed === true,
2685
- prior_score: storedGrade.score ?? null,
2686
- prior_findings_count: issues.length,
2687
- response_present: Boolean(response),
2688
- interim_present: Boolean(interimCommits || interimDiffStat),
2689
- },
2690
- };
2691
- }
2692
-
2693
- // Run the subagent grader against the committed diff, POST the result to the
2694
- // rank-open /tasks/:id/grade endpoint, and log the panel cost on success.
2695
- // Returns the raw apiCall response so the caller can branch on .ok / read the
2696
- // grade. Shared by the ship path and the --regrade path.
2697
- //
2698
- // V4.R08: before spawning the adversarial panel we compute the raw diff hash
2699
- // and check whether a stored PASS grade exists for the same hash. On a match
2700
- // we re-POST the stored grade (marking it reused in signals) and skip the
2701
- // panel entirely — cheap re-ships with an unchanged diff stay fast.
2702
- // task 1002660: the COMPUTE half — the reuse lookup + the panel run. No server
2703
- // writes, so shipMain can start it before the smoke gate and overlap the
2704
- // multi-minute panel with smoke + the claim resolve. Returns
2705
- // { kind: 'reuse', currentHash, reusedResult } | { kind: 'fresh', currentHash, gradeResult }.
2706
- async function computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound = null }) {
2707
- // Compute the fingerprint of the raw (untruncated) diff so we can compare
2708
- // against any stored grade, independent of the display-truncation cap.
2709
- const currentHash = computeRawDiffHash(baseline);
2710
-
2711
- if (currentHash) {
2712
- const storedGradeRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'grade' } });
2713
- const storedGrade = storedGradeRes.ok ? (storedGradeRes.data.grade ?? null) : null;
2714
-
2715
- if (shouldReuseGrade(storedGrade, currentHash)) {
2716
- console.log(' Grade reuse: stored PASS grade matches current diff hash — skipping panel.');
2717
- // Reuse the stored result with a reused marker so the audit trail is
2718
- // complete. We keep the original grader_kind and inject reuse metadata
2719
- // into signals so a bare table scan can spot reused grades.
2720
- const reusedSignals = {
2721
- ...(storedGrade.signals || {}),
2722
- reused_from_diff_hash: storedGrade.diff_hash,
2723
- reused_at: new Date().toISOString(),
2724
- };
2725
- const reusedResult = {
2726
- passed: storedGrade.passed,
2727
- score: storedGrade.score,
2728
- threshold: storedGrade.threshold,
2729
- grader_kind: storedGrade.grader_kind,
2730
- grader_version: storedGrade.grader_version,
2731
- rubric_scores: storedGrade.rubric_scores,
2732
- notes_md: storedGrade.notes_md,
2733
- signals: reusedSignals,
2734
- // task 1002660: carry the original panel's issues (rebuilt from
2735
- // signals.workers[].issues — the row stores them nowhere else) so the
2736
- // reused PASS stands on the same substance/evidence it passed with.
2737
- issues: reconstructPanelIssues(storedGrade.signals),
2738
- };
2739
- return { kind: 'reuse', currentHash, reusedResult };
2740
- }
2741
- }
2742
-
2743
- const gradeResult = await runSubagentGrader({
2744
- task,
2745
- valueSummary: summary,
2746
- baseline,
2747
- changedFiles: committedFiles,
2748
- priorRound: priorRound?.forPanel ?? null,
2749
- });
2750
- // task 1002662: record the regrade audit block on the new grade's signals so
2751
- // this round is readable as remediation history (grade_attempts keeps one row
2752
- // per round; the latest grade's signals.regrade names what it responded to).
2753
- if (priorRound?.audit) {
2754
- gradeResult.signals = { ...(gradeResult.signals || {}), regrade: priorRound.audit };
2755
- }
2756
- return { kind: 'fresh', currentHash, gradeResult };
2757
- }
2758
-
2759
- // The RECORD half: POST the computed grade + log the panel cost. `precomputed`
2760
- // (a promise from computeGradeResult, task 1002660) lets the ship path hand in
2761
- // the overlapped computation; the --regrade path passes nothing and computes
2762
- // inline — behavior identical to the pre-split gradeAndRecord either way.
2763
- async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles, priorRound = null, precomputed = null }) {
2764
- const computed = await (precomputed
2765
- || computeGradeResult({ taskId, task, summary, baseline, committedFiles, priorRound }));
2766
-
2767
- if (computed.kind === 'reuse') {
2768
- const payload = { ...buildGradePayload(computed.reusedResult, committedFiles), diff_hash: computed.currentHash };
2769
- return (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
2770
- // No logGraderPanelCost — the panel didn't run, there's no cost to log.
2771
- }
2772
-
2773
- const { gradeResult } = computed;
2774
- const payload = { ...buildGradePayload(gradeResult, committedFiles), diff_hash: computed.currentHash ?? undefined };
2775
- const gradePost = await (await cliClient()).tasks.postTasksIdGrade({ id: taskId, body: payload });
2776
- // Log the panel's cost once the grade is recorded — independent of the
2777
- // pass/fail VERDICT (the panel ran, and cost money, either way), but only
2778
- // when the POST itself succeeded (a failed POST means nothing was recorded).
2779
- if (gradePost.ok) await logGraderPanelCost(taskId, gradeResult);
2780
- return gradePost;
2781
- }
2782
-
2783
- // Build the grade verdict display block as a string (per-axis scores + notes +
2784
- // issues + next-step hints). Pure + exported so the #943 grader-unavailable vs
2785
- // FAIL branch is unit-tested without capturing stdout. printGradeOutcome just
2786
- // prints what this returns.
2787
- function formatGradeOutcome(g, taskId) {
2788
- const out = [];
2789
- // #943: a TOTAL grader outage (every gating worker failed to spawn / the
2790
- // grader was bypassed pre-spawn) is NOT a quality rejection — it's "the grader
2791
- // could not run". Present it distinctly so a worker-infra failure never reaches
2792
- // the builder as a misleading "FAIL · 0/10 — fix the issues" (the session-1659
2793
- // false-FAIL). There are no issues to fix; the path forward is re-grade when the
2794
- // grader is reachable, or manual-confirm.
2795
- if (g.signals?.panel_outcome === 'unavailable') {
2796
- const reason = g.signals?.grader_unavailable_reason;
2797
- out.push('');
2798
- out.push(`Subagent grade: GRADER UNAVAILABLE${reason ? ` (${reason})` : ''} · no quality signal produced — NOT a quality rejection`);
2799
- if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
2800
- out.push('');
2801
- out.push('The grader could not run (infra/outage/bypass) — there are no findings to fix. Task stays at completed.');
2802
- out.push('When the grader is reachable again, re-grade:');
2803
- out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
2804
- out.push('Or land it now via manual-confirm:');
2805
- out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/gds/tasks/${taskId}/confirm`);
2806
- return out.join('\n');
2807
- }
2808
-
2809
- // task 1002614 (map P-2): a protected-path refusal used to render as an
2810
- // ordinary quality FAIL — "fix the issues and --regrade" — although no rank of
2811
- // the builder's can land that diff. Present it as the authorization wall it is
2812
- // (the #943 grader-unavailable branch is the precedent for a distinct shape).
2813
- // Keyed off the guard name so it also reads grades from servers predating the
2814
- // authorization_refusal marker.
2815
- const guardTripped = g.signals?.server_guard?.tripped || [];
2816
- const permRefusal = g.signals?.server_guard?.authorization_refusal === true
2817
- || guardTripped.some((t) => t.guard === 'permission_path');
2818
- if (!g.passed && permRefusal) {
2819
- out.push('');
2820
- out.push('Subagent grade: REFUSED (authorization) · not a quality verdict — your work was not judged and rejected');
2821
- out.push('');
2822
- out.push('The committed diff touches PROTECTED surfaces above your rank, so the server');
2823
- out.push('refused to accept it — no re-attempt at your rank can land this diff as-is.');
2824
- if ((g.issues || []).length > 0) {
2825
- out.push('');
2826
- out.push('What was refused (file · rule):');
2827
- for (const it of g.issues.filter((i) => String(i.worker || '').includes('permission-path'))) {
2828
- out.push(` ${it.file || '?'} — ${it.summary || ''}`);
2829
- }
2830
- }
2831
- out.push('');
2832
- out.push('Ways forward:');
2833
- out.push(' - Split the protected files out of your diff and re-ship the rest, or');
2834
- out.push(' - hand this task to a builder at/above the floor the refusal names.');
2835
- out.push(`(--regrade re-runs the same server check and will refuse again unless the diff changes.)`);
2836
- return out.join('\n');
2837
- }
2838
-
2839
- const verdict = g.passed ? 'PASS' : 'FAIL';
2840
- out.push('');
2841
- out.push(`Subagent grade: ${verdict} · blended ${g.score}/10 · threshold ${g.threshold} (both axes)`);
2842
- if (g.rubric_scores) {
2843
- const ff = g.rubric_scores.functional_fidelity;
2844
- const cq = g.rubric_scores.code_quality;
2845
- if (ff != null) out.push(` functional_fidelity: ${ff}/10`);
2846
- if (cq != null) out.push(` code_quality: ${cq}/10`);
2847
- }
2848
- if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
2849
- if ((g.issues || []).length > 0) {
2850
- out.push('');
2851
- out.push(`Issues (${g.issues.length}):`);
2852
- for (const it of g.issues) {
2853
- const sev = String(it.severity || '?').padEnd(7);
2854
- out.push(` [${sev}] ${it.file || '?'} — ${it.summary || ''}`);
2855
- }
2856
- }
2857
- if (!g.passed) {
2858
- out.push('');
2859
- out.push('Grade failed — task stays at completed. Fix the issues above, commit, then re-grade:');
2860
- out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
2861
- out.push('Last resort (only if the grade was wrong):');
2862
- out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/gds/tasks/${taskId}/confirm`);
2863
- }
2864
- return out.join('\n');
2865
- }
2866
-
2867
- // Print the grade verdict + per-axis scores + notes + issues. On a FAIL, print
2868
- // both the re-grade hint (the cheap default) and the Archon override curl (the
2869
- // last resort — see feedback_prefer_regrade_over_override). #943: a total grader
2870
- // outage prints as "GRADER UNAVAILABLE", not a 0/10 FAIL — see formatGradeOutcome.
2871
- function printGradeOutcome(g, taskId) {
2872
- console.log(formatGradeOutcome(g, taskId));
2873
- }
2874
-
2875
- // Print the credits line + multiplier breakdown + idea bonus + unlocked
2876
- // achievements from a grade/confirm response.
2877
- function printCreditsAndAchievements({ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo, justUnlocked }) {
2878
- console.log('');
2879
- if (postGradeStatus === 'completed') {
2880
- console.log(`Credits awarded: ${credits} (none — task at completed, grade not passed)`);
2881
- } else if (postGradeStatus === 'confirmed') {
2882
- console.log(`Credits awarded: ${credits} (none yet — credits land when the task merges to main)`);
2883
- } else if (!rewardPolicy(serverReward).perTaskCredit) {
2884
- // cost-plus-only (ADR 0146): the per-task estimate is never paid, so this is
2885
- // 0 BY DESIGN. Left unexplained it reads as a broken payout — it sent a real
2886
- // session diagnosing the reward gate on 2026-07-26 (task 1002469).
2887
- console.log(`Credits awarded: ${credits} — expected: this instance pays cost-plus only.`);
2888
- console.log(" The task's estimate is a guide, never a payout. You're paid for this session's");
2889
- console.log(' real cost + 20%, booked separately from your token spend.');
2890
- } else {
2891
- console.log(`Credits awarded: ${credits}`);
2892
- }
2893
- // Write the credits flag so the NEXT Claude turn plays the credits-awarded
2894
- // sound via the Stop hook (task 582). Only when credits were actually awarded.
2895
- if (typeof credits === 'number' && credits > 0) {
2896
- const tmpDir = process.platform === 'win32'
2897
- ? (process.env.TEMP || process.env.TMP || os.tmpdir()) : '/tmp';
2898
- try { fs.writeFileSync(require('path').join(tmpDir, 'otb-credits'), ''); } catch (_) {}
2899
- }
2900
- if (creditsRaw != null && creditsMultiplier != null && Number(creditsMultiplier) !== 1.0) {
2901
- const mult = Number(creditsMultiplier).toFixed(2).replace(/0$/, '').replace(/\.$/, '.0');
2902
- console.log(` Kind multiplier: ${creditsRaw}c × ${mult}× → ${credits}c`);
2903
- }
2904
- if (ideaBonusInfo && ideaBonusInfo.ideaBonusAwarded > 0) {
2905
- console.log(
2906
- ` Idea-promotion bonus: +${ideaBonusInfo.ideaBonusAwarded}c paid to builder #${ideaBonusInfo.bonusBuilderId} (idea #${ideaBonusInfo.ideaId})`
2907
- );
2908
- }
2909
- for (const ach of justUnlocked || []) {
2910
- const bonus = ach.credit_bonus > 0 ? ` (+${ach.credit_bonus}c bonus)` : '';
2911
- console.log(`✨ Achievement unlocked: ${ach.name}${bonus}`);
2912
- }
2913
- }
2914
-
2915
- // chooseBroadcastTargets — the ship-broadcast channel router (task 990) now lives
2916
- // in src/bongos/ship-broadcast.js (task 1045) so the server-mediated publish path
2917
- // shares the exact same security-sensitive routing. Imported at the top of this
2918
- // file; still re-exported below for tests/ship_broadcast_routing.mjs.
2919
-
2920
- // task 1442: play the task-shipped "ship bell". ship.js runs as its own process
2921
- // (not inside a Claude turn), so it plays the sound directly rather than via the
2922
- // Stop hook. Called at the confirmed/shipped success milestone so it rings on
2923
- // the ci/async landing path too — there ship.js exits at 'confirmed' and the
2924
- // deploy finishes later server-side, which is exactly why the old post-deploy
2925
- // call site never fired (bug #1442). Resolve the builder's chosen variant from
2926
- // the local prefs file; fall back to 'ship'. Best-effort: playSound swallows all
2927
- // errors and is a silent no-op with no audio player, so this can never fail a ship.
2928
- function playShipBell() {
2929
- let shipWav = 'ship';
2930
- try {
2931
- const prefsPath = require('path').join(os.homedir(), '.config', 'otb', 'sound-prefs.json');
2932
- const raw = require('fs').readFileSync(prefsPath, 'utf8');
2933
- const parsed = JSON.parse(raw);
2934
- const ev = parsed && parsed.event_sounds && parsed.event_sounds['task-shipped'];
2935
- if (typeof ev === 'string') shipWav = ev;
2936
- } catch (_) { /* fall through to default */ }
2937
- playSound(shipWav);
2938
- }
2939
-
2940
- // task 1002809: normalize an API error body across BOTH wire shapes — the live
2941
- // ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
2942
- // `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
2943
- // cli-lib:isBoxScope; pure + exported for tests/ship_post_land_verify.mjs.
2944
- function normalizeApiError(data) {
2945
- const errRaw = data ? data.error : undefined;
2946
- const isEnvelope = errRaw !== null && typeof errRaw === 'object';
2947
- return {
2948
- code: isEnvelope ? errRaw.code : errRaw,
2949
- message: isEnvelope ? errRaw.message : undefined,
2950
- // task 1002789: `details` is where the envelope carries the structured
2951
- // payload the flat shape used to put at the TOP level (claimWorktree,
2952
- // step/reason, floors). A site that reads `data.<field>` directly is
2953
- // reading a field that moved. Fall back to the body so a legacy flat
2954
- // response still resolves.
2955
- details: (isEnvelope && errRaw.details && typeof errRaw.details === 'object')
2956
- ? errRaw.details
2957
- : (data && typeof data === 'object' ? data : {}),
2958
- };
2959
- }
2960
-
2961
- // task 1002789: the ONE renderer for a failed API call on the ship path.
2962
- // Every generic bail site used to hand the raw body to console.error (or
2963
- // interpolate `data.error` into a template, which stringifies the ADR 0116
2964
- // envelope object to the literal "[object Object]"). Both hide a fully
2965
- // diagnosable server error: console.error's util.inspect stops at depth 2, so
2966
- // the `details` that names WHICH git step failed prints as "[Object]".
2967
- // Collapse the body to one readable line instead, whichever shape it is.
2968
- // Pure + exported for tests/ship_error_shape.mjs.
2969
- function apiErrorLine(data, status) {
2970
- const { code, message, details } = normalizeApiError(data);
2971
- const head = code
2972
- ? (message && message !== code ? `${code}: ${message}` : code)
2973
- : (message || (status ? `HTTP ${status}` : 'unknown error'));
2974
- // Only the small scalar fields — a details blob can carry a whole bundle.
2975
- const extras = Object.entries(details || {})
2976
- .filter(([k, v]) => k !== 'code' && k !== 'message' && k !== 'error'
2977
- && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
2978
- .map(([k, v]) => `${k}: ${String(v).replace(/\s+/g, ' ').trim()}`);
2979
- const line = extras.length ? `${head} (${extras.join('; ')})` : head;
2980
- return status ? `${line} [HTTP ${status}]` : line;
2981
- }
2982
-
2983
- // task 1002809: classify a FAILED post-land POST /tasks/<id>/ship. The merge
2984
- // already succeeded, so the server may simply have landed the task first
2985
- // (reconcile-on-read — task 1049 — or the ~5-min reconciler racing this call).
2986
- // The old check compared `data.error === 'task_not_confirmed'` — dead against
2987
- // the envelope shape, so the already-landed SUCCESS rendered as a failure with
2988
- // an "[object Object]" reason (the HANDOFF-apex-ui "400 after a successful
2989
- // land" gotcha). liveStatus is the task's status from a verify GET (null when
2990
- // that GET itself failed): 'shipped' is authoritative success whatever the
2991
- // error said; task_not_confirmed with no live status keeps the task-1049
2992
- // benefit of the doubt; any KNOWN non-shipped status stays a loud failure.
2993
- // Pure + exported for tests/ship_post_land_verify.mjs.
2994
- function postLandShipOutcome({ code, liveStatus }) {
2995
- if (liveStatus === 'shipped') {
2996
- return { alreadyShipped: true, why: 'the server landed it first — the failed ship call lost that race, nothing to fix' };
2997
- }
2998
- if (liveStatus == null && code === 'task_not_confirmed') {
2999
- return { alreadyShipped: true, why: 'the server reconciled it from the merged PR' };
3000
- }
3001
- return { alreadyShipped: false };
3002
- }
3003
-
3004
- // Step 3 of the lifecycle: confirmed → shipped. Merge the branch into main,
3005
- // deploy, flip the task to shipped, and fire the Discord broadcast (only when
3006
- // the change actually deploys — #94 / ADR 0032; best-effort). Side-effecting;
3007
- // the early returns are control flow only.
3008
- async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOnly = false }) {
3009
- if (advanceToMerge && skipMerge) {
3010
- console.log('');
3011
- console.log('--no-merge: stopping at confirmed. Run /merge-mode later to land it.');
3012
- return landBailed('--no-merge was passed, so the land was skipped', '/merge-mode');
3013
- }
3014
- // Grade failed: the card renders the 'failed' stage ('not deployed'), so there
3015
- // is no land outcome to report.
3016
- if (!advanceToMerge) return null;
3017
-
3018
- console.log('');
3019
- console.log('Auto-merge chain (confirmed → shipped):');
3020
- const merged = await autoMerge(taskId, summary, { dbOnly });
3021
- // NOTE: `merged` is an OBJECT now — every value is truthy, so this MUST test
3022
- // .ok. A bare `if (!merged)` would fall through on a bail and POST /ship for a
3023
- // task that never landed.
3024
- if (!merged.ok) return merged;
3025
-
3026
- const shipBody = merged.noArtifact ? { no_artifact: true, no_artifact_reason: merged.noArtifactReason || 'deliberate empty ship (--db-only)' } : {}; // ADR 0184
3027
- const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: shipBody });
3028
- if (!ship.ok) {
3029
- // task 1049 / 1002809: the server may have already flipped this task to
3030
- // shipped (reconcile-on-read, or the reconciler racing this call). Verify
3031
- // the LIVE status before treating the refusal as a failure — the old
3032
- // flat-shape string check never matched the R01 envelope, so this success
3033
- // path was dead and every reconciled land reported as a failed ship.
3034
- const { code, message } = normalizeApiError(ship.data);
3035
- let liveStatus = null;
3036
- try {
3037
- const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
3038
- if (t.ok && t.data && t.data.task) liveStatus = t.data.task.status;
3039
- } catch (_) { /* verify is best-effort; fall back to the code-based call */ }
3040
- const outcome = postLandShipOutcome({ code, liveStatus });
3041
- if (outcome.alreadyShipped) {
3042
- console.log(`Task #${taskId} is now: shipped (${outcome.why}).`);
3043
- } else {
3044
- const e = message || code || `HTTP ${ship.status}`;
3045
- console.error(` /tasks/${taskId}/ship failed: ${e}${liveStatus ? ` (task is at '${liveStatus}')` : ''}`);
3046
- // The merge itself succeeded — only the status flip failed, so a re-run
3047
- // resumes cleanly (task 941). Report it rather than letting the card claim
3048
- // the land is in progress.
3049
- return landBailed(`merged, but the ship call failed — ${e}`, `node scripts/gds/ship.js ${taskId}`);
3050
- }
3051
- } else {
3052
- console.log(`Task #${taskId} is now: shipped`);
3053
- }
3054
-
3055
- // task 1442: the task-shipped bell used to play HERE, but this point is only
3056
- // reached on a SYNCHRONOUS merge+deploy. In ci/async landing mode (now the
3057
- // norm) ship.js exits at 'confirmed' and never gets here, so the bell never
3058
- // rang. It now plays at the confirmed/shipped success milestone via
3059
- // playShipBell() (finishShip + resumeMerge), which covers both paths.
3060
-
3061
- // If the builder was promoted during this ship, write the promotion flag so
3062
- // the NEXT Claude turn plays the fanfare via the Stop hook.
3063
- const TMP_FLAGS = process.platform === 'win32'
3064
- ? (process.env.TEMP || process.env.TMP || os.tmpdir())
3065
- : '/tmp';
3066
- if (ship.data?.task?.graduatedTo) {
3067
- try { fs.writeFileSync(require('path').join(TMP_FLAGS, 'otb-promotion'), ''); } catch (_) {}
3068
- console.log(` Rank promotion: ${ship.data.task.graduatedTo} — fanfare queued.`);
3069
- }
3070
-
3071
-
3072
- if (summary) {
3073
- // task 1749: the SERVER is now the SINGLE ship broadcaster. It emits
3074
- // task.shipped on the confirmed→shipped transition from every entry point
3075
- // (POST /ship, the publish-status reconcile-on-read, and the 5-min
3076
- // reconciler sweep — all idempotent via db.shipTask), and the discord
3077
- // module posts the #ship-feed line off that one event. This local post used
3078
- // to fire IN ADDITION to the server's, so a single ship announced FOUR
3079
- // messages (local #ship-news + #ship-feed, then the server's #ship-news +
3080
- // #ship-feed). Defer unconditionally so exactly one broadcast fires — from
3081
- // the server, after the deploy lands. (#ship-news itself is paused in
3082
- // chooseBroadcastTargets, so that one broadcast is the #ship-feed line —
3083
- // task 1749 / idea 458.) The security-sensitive fail-closed routing lives
3084
- // server-side in ship-broadcast.postShipBroadcast, so it is preserved.
3085
- console.log(' Discord broadcast: deferred to the server — it posts #ship-feed after the deploy lands.');
3086
- }
3087
- return merged;
3088
- }
3089
-
3090
- // Memory + session-digest push, gated on a successful land (confirmed/shipped).
3091
- // Both are best-effort — a failure here is a warning, never a ship failure.
3092
- async function postShipSync(taskId, postGradeStatus) {
3093
- if (postGradeStatus !== 'confirmed' && postGradeStatus !== 'shipped') return null;
3094
- console.log('');
3095
- console.log('Memory sync (best-effort):');
3096
- await syncMemoryOnShip();
3097
- console.log('');
3098
- console.log('Session upload (best-effort):');
3099
- // task 1270: the upload returns { totalTokens, modelUsage } (or null) so the
3100
- // completion card can show Claude usage + an estimated cost.
3101
- return await uploadSessionOnShip(taskId);
3102
- }
3103
-
3104
- // Post-ship onboarding nudge (V3.R13 #237). Best-effort — never let a stale /me
3105
- // read make the script exit non-zero after a successful ship.
3106
- async function onboardingNudgeBestEffort() {
3107
- try {
3108
- const meRes = noteServerReward(await (await cliClient()).me.getMe());
3109
- if (meRes.ok) {
3110
- console.log('');
3111
- printOnboardingNudge(meRes.data);
3112
- }
3113
- } catch (_) {
3114
- /* never block on a UX nudge */
3115
- }
3116
- }
3117
-
3118
- // finishShip — the confirmed→shipped tail shared by shipMain + regradeMain (task
3119
- // 1093 / C9): auto-merge + deploy, then the best-effort memory/session sync, the
3120
- // Done line, and the onboarding nudge. Only this UNIFORM tail is shared;
3121
- // printGradeOutcome + printCreditsAndAchievements stay caller-owned (their inputs
3122
- // differ between the two paths). `doneLine` is a param so each caller keeps its
3123
- // own wording ('Done.' vs the re-grade pass/fail message).
3124
- // task 932 (Lars 2026-06-12): deterministic, no-LLM post-ship working-tree
3125
- // close-out, folded into the delivery process (NOT a separate skill). After a
3126
- // ship lands, scratch often lingers uncommitted — untracked files the preflight
3127
- // lets through, generated artifacts, post-commit edits — making the session
3128
- // hard to archive cleanly. This reports what's left and stashes it under a
3129
- // labeled, RECOVERABLE stash so the tree is left clean + archive-ready and
3130
- // nothing is lost. STRICTLY fail-open: the ship has already succeeded by the
3131
- // time this runs, so nothing here may throw, prompt, or change the exit status —
3132
- // a messy tree (or a stash failure) must never surface as a ship failure. The
3133
- // caller gates this on a SUCCESSFUL land so a grade-failed 'completed' tree the
3134
- // builder still needs to fix is never stashed out from under them.
3135
- function postShipCloseout(taskId) {
3136
- try {
3137
- const out = gitOk('git status --porcelain');
3138
- if (out == null) return; // git unavailable / not a repo — nothing to reconcile
3139
- const plan = closeoutPlan(out, taskId);
3140
- if (plan.clean) {
3141
- console.log('Close-out: working tree clean — session is archive-ready.');
3142
- return;
3143
- }
3144
- console.log('');
3145
- console.log(`Close-out: ${plan.leftover.length} uncommitted/untracked path(s) remain after the ship:`);
3146
- for (const f of plan.leftover.slice(0, 15)) console.log(` ${f}`);
3147
- if (plan.leftover.length > 15) console.log(` … and ${plan.leftover.length - 15} more`);
3148
- // --include-untracked so scratch the preflight waved through is captured too.
3149
- // Label is `otb-postship-<numericId>` — no shell metacharacters.
3150
- const stashed = shellOk(`git stash push --include-untracked -m "${plan.stashLabel}"`);
3151
- if (stashed) {
3152
- console.log(`Close-out: stashed the leftover as "${plan.stashLabel}" — tree is now clean + archive-ready.`);
3153
- console.log(' Recover anytime: git stash list → git stash pop');
3154
- } else {
3155
- console.log('Close-out: could not stash the leftover; left it in place (the ship already succeeded — tidy up manually when convenient).');
3156
- }
3157
- } catch (err) {
3158
- // Fail-open — a post-ship close-out problem must never look like a ship failure.
3159
- console.log(`Close-out: skipped (${err && err.message ? err.message : 'error'}).`);
3160
- }
3161
- }
3162
-
3163
- async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine, card, dbOnly = false }) {
3164
- // task 1002541: the land outcome carries WHY a land didn't happen. Without it
3165
- // the card had only the status to go on and printed 'landing automatically'
3166
- // over a merge that had already bailed.
3167
- const land = await mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOnly });
3168
- // task 1719: mergeAndDeploy may have landed the task to 'shipped' IN-SESSION — the
3169
- // enable-success poll (pollCiDeploy) or, since task 1718, the enable-failure server
3170
- // poll (pollServerPublish). finishShip was handed postGradeStatus from the CONFIRM
3171
- // step ('confirmed'), so without re-reading it the completion card renders the stale
3172
- // 'confirmed — landing' banner AND printCompletionCard writes a pending-land
3173
- // breadcrumb — the SHIPPED banner then only appears on a LATER prompt, in whatever
3174
- // session is next (the owner-reported confusion). Re-fetch the authoritative status
3175
- // (exactly as resumeMerge does, task 1275) so a real in-session land shows the
3176
- // SHIPPED banner here and writes no breadcrumb. Best-effort: any read failure keeps
3177
- // today's behavior; the grade-fail path (advanceToMerge=false) never re-fetches.
3178
- let landedStatus = postGradeStatus;
3179
- if (advanceToMerge && (postGradeStatus === 'confirmed' || postGradeStatus === 'shipped')) {
3180
- try {
3181
- const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
3182
- const live = (t.ok && t.data && t.data.task) ? t.data.task.status : null;
3183
- landedStatus = reconciledCardStatus(live, postGradeStatus);
3184
- } catch (_) { /* keep postGradeStatus — the land above already awaited its outcome */ }
3185
- }
3186
- // Memory + session sync (#274 / 6D.1 / ADR 0027) — postShipSync itself gates on
3187
- // a successful land (confirmed/shipped); both legs are best-effort. It returns
3188
- // the session usage ({ totalTokens, modelUsage } | null) so the completion card
3189
- // can show Claude usage + an estimated cost (task 1270).
3190
- const usage = await postShipSync(taskId, landedStatus);
3191
- console.log('');
3192
- console.log(doneLine);
3193
- await onboardingNudgeBestEffort();
3194
- // task 1270: the ONE deterministic completion card — the same shape every ship.
3195
- // Replaces the agent's free-form prose recap (the builder-ship skill renders the
3196
- // rich in-feed card from the HTML this writes; the monospace card below is the
3197
- // always-printed fallback + the headless summary). Strictly best-effort.
3198
- // task 1719: pass the reconciled status so a just-landed ship shows SHIPPED (and
3199
- // writes no breadcrumb), not the stale pre-merge 'confirmed'.
3200
- await printCompletionCard({ taskId, postGradeStatus: landedStatus, card, usage, land });
3201
- // task 932: reconcile the working tree — ONLY on a successful land. A
3202
- // grade-failed ship stays at 'completed'; leave that tree untouched so the
3203
- // builder can fix findings + re-ship.
3204
- if (landedStatus === 'confirmed' || landedStatus === 'shipped') {
3205
- // task 1442: ring the ship bell at the success milestone. 'confirmed' counts —
3206
- // in ci/async mode the work is done + credited here and the deploy lands later
3207
- // server-side, so this is the moment the builder experiences as "shipped".
3208
- playShipBell();
3209
- postShipCloseout(taskId);
3210
- }
3211
- }
3212
-
3213
- // task 1275: sum the committed diffstat (added/removed lines) over baseline...HEAD.
3214
- // Shared by the completion card and the in-review card. Best-effort: returns
3215
- // nulls when there's no baseline or git is unavailable.
3216
- function diffstatFor(baseline) {
3217
- if (!baseline) return { added: null, removed: null };
3218
- const numstat = gitOk(`git diff --numstat ${baseline}...HEAD`) || gitOk(`git diff --numstat ${baseline} HEAD`);
3219
- if (!numstat) return { added: null, removed: null };
3220
- let added = 0;
3221
- let removed = 0;
3222
- for (const line of numstat.split('\n')) {
3223
- const [a, r] = line.split('\t');
3224
- if (a !== '-' && a != null && a !== '') added += Number(a) || 0;
3225
- if (r !== '-' && r != null && r !== '') removed += Number(r) || 0;
3226
- }
3227
- return { added, removed };
3228
- }
3229
-
3230
- // task 1270: build + print the deterministic completion card. Prints the
3231
- // alignment-proof monospace card to stdout (always), then writes the branded
3232
- // in-feed HTML to a temp file and prints an [otb-card-html] marker the
3233
- // builder-ship skill picks up to render it in the chat feed. Every step is
3234
- // best-effort — a summary card must NEVER fail or undo a completed ship.
3235
- async function printCompletionCard({ taskId, postGradeStatus, card, usage, land = null }) {
3236
- if (!card) return;
3237
- try {
3238
- const shipCard = require('../../modules/lifecycle/ship-card');
3239
-
3240
- // Diffstat from the committed range the grader used (baseline...HEAD).
3241
- const fileCount = Array.isArray(card.files) && card.files.length ? card.files.length : null;
3242
- const { added, removed } = diffstatFor(card.baseline);
3243
-
3244
- // Deployed commit (shipped only): prefer prod /version, fall back to short HEAD.
3245
- let commit = null;
3246
- if (postGradeStatus === 'shipped') {
3247
- const v = fetchDeployedVersion();
3248
- commit = (v && v.commit) ? String(v.commit).slice(0, 7) : (gitOk('git rev-parse --short HEAD') || null);
3249
- }
3250
-
3251
- // Running drachmae total + the builder's render-widgets knob (task 1279),
3252
- // both from one /me fetch (best-effort).
3253
- let total = null;
3254
- let widgetsEnabled = true;
3255
- try {
3256
- const me = noteServerReward(await (await cliClient()).me.getMe());
3257
- if (me.ok && me.data) {
3258
- if (me.data.builder) total = me.data.builder.total_credits;
3259
- if (me.data.render) widgetsEnabled = me.data.render.widgets_enabled !== false;
3260
- }
3261
- } catch (_) { /* total + knob are optional; default widgets on */ }
3262
-
3263
- // Token count + estimated cost from the session usage. Split the tokens into
3264
- // 'new' (fresh input + output + first-time cache writes) vs 'cached' re-reads
3265
- // (cache_read — the same large context re-read every turn) so the card can
3266
- // lead with cost and not with a single all-in count that cache re-reads make
3267
- // look alarmingly huge (task 1765). The per-model breakdown is already on hand
3268
- // via usage.modelUsage — no new server data needed.
3269
- let tokens = null;
3270
- let costUsd = null;
3271
- let newTokens = null;
3272
- let cachedTokens = null;
3273
- if (usage) {
3274
- tokens = usage.totalTokens;
3275
- const mu = usage.modelUsage || {};
3276
- let cache = 0;
3277
- let fresh = 0;
3278
- let sawUsage = false;
3279
- for (const u of Object.values(mu)) {
3280
- if (!u) continue;
3281
- sawUsage = true;
3282
- cache += Number(u.cache_read) || 0;
3283
- fresh += (Number(u.input) || 0) + (Number(u.output) || 0) + (Number(u.cache_creation) || 0);
3284
- }
3285
- if (sawUsage) { newTokens = fresh; cachedTokens = cache; }
3286
- try {
3287
- const { priceModelUsage } = require('../../src/bongos/llm-pricing');
3288
- costUsd = priceModelUsage(mu).true_cost_usd;
3289
- } catch (_) { /* cost is optional */ }
3290
- }
3291
-
3292
- const cardInputs = {
3293
- postGradeStatus,
3294
- taskId,
3295
- title: card.title,
3296
- valueSummary: card.valueSummary,
3297
- notes: card.notes,
3298
- fileCount,
3299
- added,
3300
- removed,
3301
- grade: card.grade || null,
3302
- credits: { awarded: card.creditsAwarded, total, perTaskCredit: rewardPolicy(serverReward).perTaskCredit },
3303
- usage: { tokens, costUsd, newTokens, cachedTokens },
3304
- deploy: { commit },
3305
- // task 1002541: absent (the default) ⇒ the card behaves exactly as before,
3306
- // which keeps every other caller — resumeMerge, the claim card, and
3307
- // land-watch replaying breadcrumbs written by older versions — unchanged.
3308
- land,
3309
- };
3310
- const model = shipCard.buildCardModel(cardInputs);
3311
-
3312
- // Print the monospace card + write the branded HTML for the in-feed renderer
3313
- // (shared emitCard, task 1275). The skill reads the printed path and passes
3314
- // the contents to the visualization tool — the agent authors nothing, so the
3315
- // card is identical every ship. task 1279: when the builder has widgets off,
3316
- // emitCard prints only the monospace card (no HTML written or relayed).
3317
- shipCard.emitCard(model, taskId, { widgetsEnabled });
3318
-
3319
- // task 1687: in CI deploy mode this card is 'confirmed — landing' and the real
3320
- // confirmed->shipped land happens minutes later, server-side — so the SHIPPED
3321
- // banner would never render in-session. Drop a pending-land breadcrumb; the
3322
- // land-watch hook renders the shipped card on a later prompt once it lands.
3323
- // Best-effort: a breadcrumb must never fail a ship (mirrors the card itself).
3324
- if (postGradeStatus === 'confirmed') {
3325
- try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardInputs }); }
3326
- catch (_) { /* breadcrumb is best-effort */ }
3327
- }
3328
- } catch (_) {
3329
- /* a summary card must never fail a ship */
3330
- }
3331
-
3332
- // task 1002521: the card above says SHIPPED, which means "merged" — it does
3333
- // NOT mean users can see it. Publishing is manual and owner-gated, so a ship
3334
- // can land on main and change nothing live, and until now nothing said so.
3335
- // Blocker 1000097: 171 commits (a whole governance system) sat unreleased for
3336
- // ten days while every layer reported success. Warn, never block — a shipped
3337
- // task is still shipped, and an offline builder must not be punished for it.
3338
- try {
3339
- const { check, report } = require('./deploy-divergence');
3340
- const r = await check();
3341
- if (!r.undetermined && !r.ok) report(r);
3342
- } catch (_) {
3343
- /* a divergence warning must never fail a ship */
3344
- }
3345
- }
3346
-
3347
- // Decide whether a builder may re-grade a task via `--regrade` (#678). Pure so
3348
- // it's unit-testable. A re-grade is allowed iff the task is stuck at `completed`
3349
- // (a ship whose grade failed) AND the caller either last held the claim or is an
3350
- // Archon. Every other status gets a tailored, actionable message. Note this is a
3351
- // client-side UX guard, not a trust boundary: /tasks/:id/grade is rank-open by
3352
- // design (the subagent grade is the authority, not builder identity).
3353
- function evaluateRegradeEligibility({ task, lastClaim, builder }) {
3354
- if (!task) {
3355
- return { ok: false, code: 'task_not_found', reason: 'Task not found.' };
3356
- }
3357
- const status = task.status;
3358
- if (status !== 'completed') {
3359
- let reason;
3360
- if (status === 'confirmed') {
3361
- reason = `Task #${task.id} is already 'confirmed' — its grade passed. Nothing to re-grade; run /merge-mode if it hasn't shipped.`;
3362
- } else if (status === 'shipped') {
3363
- reason = `Task #${task.id} is already 'shipped'. Done.`;
3364
- } else if (status === 'active' || status === 'ready') {
3365
- reason = `Task #${task.id} is '${status}', not 'completed'. Claim it and run a normal /builder-ship — --regrade only re-grades a ship that landed at 'completed'.`;
3366
- } else {
3367
- reason = `Task #${task.id} is '${status}'. --regrade only applies to a task stuck at 'completed' after a failed grade.`;
3368
- }
3369
- return { ok: false, code: 'not_completed', reason, status };
3370
- }
3371
- const isArchon = !!builder && builder.rank === 'archon';
3372
- const owns = !!lastClaim && !!builder && Number(lastClaim.builder_id) === Number(builder.id);
3373
- if (!isArchon && !owns) {
3374
- const holder = lastClaim?.github_login ? `@${lastClaim.github_login}` : 'another builder';
3375
- return {
3376
- ok: false,
3377
- code: 'not_owner',
3378
- reason: `Task #${task.id} was last claimed by ${holder}. Only that builder or an Archon can re-grade it.`,
3379
- };
3380
- }
3381
- return {
3382
- ok: true,
3383
- code: 'eligible',
3384
- via: owns ? 'owner' : 'archon',
3385
- reason: owns ? 'you last held this claim' : 'archon re-grade',
3386
- };
3387
- }
3388
-
3389
- // task 941 (V4.R03 — idempotent ship re-drive): decide what a CLAIM-LESS
3390
- // `ship.js <id>` re-run should do. The claim is consumed at the resolve step, so
3391
- // a ship that fails AFTER it (grade fail → stuck `completed`; merge/deploy fail →
3392
- // stuck `confirmed`, the task-1027 nightmare) left a re-run dead-ending at "you
3393
- // don't have an active claim" — the builder had to know the `--regrade` flag (and
3394
- // for `confirmed` there was NO path at all). This makes a PLAIN re-run resume
3395
- // from the stalled leg: `completed` → re-grade, `confirmed` → re-merge/deploy.
3396
- // Pure + exported so the truth table is unit-tested with no git/network. Returns
3397
- // { action, via?, reason? } where action ∈
3398
- // resume-grade | resume-merge | done | claim-first | error
3399
- function holderLabel(lastClaim) {
3400
- return lastClaim?.github_login ? `@${lastClaim.github_login}` : 'another builder';
3401
- }
3402
- function evaluateResumeAction({ task, lastClaim, builder }) {
3403
- if (!task) return { action: 'error', reason: 'Task not found.' };
3404
- const status = task.status;
3405
- if (status === 'shipped') {
3406
- return { action: 'done', reason: `Task #${task.id} is already 'shipped'. Nothing to do.` };
3407
- }
3408
- const isArchon = !!builder && builder.rank === 'archon';
3409
- const owns = !!lastClaim && !!builder && Number(lastClaim.builder_id) === Number(builder.id);
3410
- if (status === 'completed' || status === 'confirmed') {
3411
- const leg = status === 'completed' ? 'grade' : 'merge';
3412
- if (!isArchon && !owns) {
3413
- return {
3414
- action: 'error',
3415
- reason: `Task #${task.id} is at '${status}' but was last claimed by ${holderLabel(lastClaim)}. ` +
3416
- `Only that builder or an Archon can resume the ${leg}.`,
3417
- };
3418
- }
3419
- return {
3420
- action: status === 'completed' ? 'resume-grade' : 'resume-merge',
3421
- via: owns ? 'owner' : 'archon',
3422
- };
3423
- }
3424
- if (status === 'active') {
3425
- return {
3426
- action: 'claim-first',
3427
- reason: `Task #${task.id} is 'active' (claimed) but not in THIS session. Ship from the session that ` +
3428
- `claimed it, or \`node scripts/gds/release.js ${task.id} --force\` here, then re-claim.`,
3429
- };
3430
- }
3431
- if (status === 'ready' || status === 'backlog' || status === 'blocked') {
3432
- return {
3433
- action: 'claim-first',
3434
- reason: `Task #${task.id} is '${status}' — claim it first: \`node scripts/gds/claim.js ${task.id}\`.`,
3435
- };
3436
- }
3437
- return { action: 'error', reason: `Task #${task.id} is '${status}'. Nothing to resume.` };
3438
- }
3439
-
3440
- // task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
3441
- // (it already passed the grade + earned credits; this is the leg that stalls when
3442
- // a box can't reach the server publish, a poll window expires, etc.). Mirrors the
3443
- // tail of a normal ship: preflight → mergeAndDeploy → post-ship sync.
3444
- async function resumeMerge(taskId, { summary, skipMerge, force, task, dbOnly = false }) {
3445
- console.log(`Resuming merge + deploy for task #${taskId}: ${task?.title || ''}`);
3446
- // Same working-tree-clean / branch-freshness guard a first ship runs before
3447
- // the land. head_sha is unused on this leg; touches is only for preflight.
3448
- const claimLike = { touches: task?.touches || [], head_sha: null, task_id: taskId, title: task?.title };
3449
- console.log('Preflight (working tree + branch freshness)...');
3450
- const pre = preflight(claimLike, { force });
3451
- if (!pre.ok) {
3452
- console.error('');
3453
- console.error(`Resume aborted: ${pre.reason}. Task stays at confirmed.`);
3454
- process.exit(1);
3455
- }
3456
- // The first ship stored value_summary; reuse it so the merge commit + broadcast
3457
- // carry meaningful text even when --summary is omitted on the re-run.
3458
- const effSummary = summary || task?.value_summary || '';
3459
- const land = await mergeAndDeploy({ taskId, summary: effSummary, advanceToMerge: true, skipMerge, dbOnly });
3460
- const usage = await postShipSync(taskId, 'confirmed');
3461
- console.log('');
3462
- console.log('Done — merge/deploy resume complete.');
3463
- playShipBell(); // task 1442: ring the bell when a stalled ship finally lands.
3464
- await onboardingNudgeBestEffort();
3465
- // task 1275: the resume path lands a stalled ship — show the completion card
3466
- // too (the gap surfaced on task 1270's own ship). Re-fetch the status so the
3467
- // card reflects whether it actually reached shipped. Best-effort.
3468
- try {
3469
- const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
3470
- const status = (t.ok && t.data && t.data.task) ? t.data.task.status : 'shipped';
3471
- await printCompletionCard({
3472
- taskId, postGradeStatus: status, usage, land,
3473
- card: { title: task?.title, valueSummary: effSummary, notes: '', creditsAwarded: null },
3474
- });
3475
- } catch (_) { /* the resume already succeeded; the card is a bonus */ }
3476
- }
3477
-
3478
- // ---------- Main flow ----------
3479
-
3480
- async function shipMain() {
3481
- await requireSession();
3482
- const args = process.argv.slice(2);
3483
- const taskId = Number(args[0]);
3484
- if (!Number.isFinite(taskId)) {
3485
- console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
3486
- console.error(' prefer the -file forms for prose: a shell command-substitutes backticks in an inline value before node sees it (learning 1000197).');
3487
- process.exit(2);
3488
- }
3489
- // G04: prose from a FILE or inline, never shell-mangled in silence.
3490
- const prose = resolveProse(args, ['notes', 'summary']);
3491
- const notes = prose.notes;
3492
- const summary = prose.summary;
3493
- const skipMerge = args.includes('--no-merge');
3494
- const force = args.includes('--force');
3495
- // task 391 (idea #37): a deliberate zero-commit / direct-SQL ship. Without it,
3496
- // a branch with no commits ahead of main is REFUSED (not silently shipped).
3497
- // task 1002422: --base <ref> also implies db-only — the work is already on main
3498
- // (that's the whole point of --base), so the merge leg has nothing to merge and
3499
- // advances straight to shipped. Unlike --allow-empty it does NOT skip the grade:
3500
- // the pinned baseline gives the grader a real diff (see resolveBaseRef / main).
3501
- const baseRefRaw = arg('--base', args);
3502
- const dbOnly = args.includes('--db-only') || args.includes('--allow-empty') || Boolean(baseRefRaw);
3503
- // task 1053 (F): explicit attestation that a verification task's verify
3504
- // actually passed. Required to confirm a kind='verify' task (the server
3505
- // enforces it too); a no-op for every other task.
3506
- const verified = args.includes('--verified');
3507
- // #853 (ADR 0041): `let` not `const` — when the server has the temporary
3508
- // global grader bypass ON (surfaced as me.grader_bypass below), we force the
3509
- // skip-grade/confirm path for everyone so no builder runs the subagent grader
3510
- // during a grader outage window.
3511
- let skipGrade = args.includes('--skip-grade');
3512
- // task 1365: explicit acknowledgement that this task legitimately has NO
3513
- // committed artifact (a pure DB-only / no-op task). Required for the
3514
- // empty-diff guard below to ALLOW an empty committed diff; without it, an
3515
- // empty diff is refused (it used to slip through under --skip-grade — the
3516
- // task-1363 phantom ship). Never overrides a dirty working tree.
3517
- const allowEmpty = args.includes('--allow-empty');
3518
- // task 1002605 (G-18): explicit opt-in to continue past a FAILED production
3519
- // smoke run. Without it a smoke FAIL now ABORTS before the claim resolves
3520
- // (claim intact); with it, the resolve proceeds and the task deliberately
3521
- // parks at 'completed' ungraded — and the ship says so instead of "Done."
3522
- const allowSmokeFail = args.includes('--allow-smoke-fail');
3523
- // Surprise flag handling — see captureSurpriseLearning(). Resolved below
3524
- // once we know which claim is being shipped (so touches[] can become tags).
3525
- const surpriseRaw = arg('--surprise', args);
3526
- const surpriseProvided = surpriseRaw !== null;
3527
- const noSurprise = args.includes('--no-surprise');
3528
-
3529
- // task 1002422 (blocker 1000082): resolve + validate --base BEFORE any heavy
3530
- // work so a bad ref aborts with the claim STILL ACTIVE. On success it sets the
3531
- // module-level baseOverrideSha that computeGraderBaseline() reads, so the grader
3532
- // reads <base>...HEAD (a real diff) and the ship takes the rank-open graded path
3533
- // instead of the --allow-empty → skip-grade → Archon-only /confirm dead-end.
3534
- if (baseRefRaw) {
3535
- const res = applyBaseOverride(baseRefRaw);
3536
- if (!res.ok) {
3537
- console.error(`Ship aborted: ${res.reason}. No claim resolved, no credits awarded.`);
3538
- console.error('Omit --base and ship normally — the grader reviews ALL your commits and the merge lands them. Your claim is still active.');
3539
- process.exit(2);
3540
- }
3541
- console.log(`--base: grading ${res.sha.slice(0, 9)}...HEAD — the task's real committed diff (work already on main). Graded via the rank-open path; no --allow-empty needed.`);
3542
- }
3543
-
3544
- const me = noteServerReward(await (await cliClient()).me.getMe());
3545
- if (!me.ok) {
3546
- console.error('failed to load profile:', me.data);
3547
- process.exit(1);
3548
- }
3549
- // #853 (ADR 0041): honor the server-side global grader bypass. When ON, every
3550
- // builder lands work without the subagent grader (the /tasks/:id/confirm gate
3551
- // is relaxed to any-builder server-side to match). Default OFF.
3552
- if (me.data.grader_bypass && !skipGrade) {
3553
- skipGrade = true;
3554
- console.log('Note: GRADER_BYPASS_ENABLED is on server-side — skipping the subagent grader and confirming directly (temporary outage window).');
3555
- }
3556
- const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
3557
- const ac = claims.find((c) => Number(c.task_id) === taskId);
3558
- if (!ac) {
3559
- // task 941 (V4.R03): no active claim in THIS session is NOT automatically a
3560
- // dead-end. The claim is consumed at resolve, so a ship that stalled AFTER it
3561
- // (grade fail → `completed`; merge/deploy fail → `confirmed`, the #1027 case)
3562
- // leaves no claim — but a plain re-run should RESUME the stalled leg, not
3563
- // strand the task. Route on the task's current status + ownership.
3564
- const tRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'last_claim' } });
3565
- const task = tRes.ok ? tRes.data.task : null;
3566
- const lastClaim = tRes.ok ? (tRes.data.last_claim || null) : null;
3567
- const action = evaluateResumeAction({ task, lastClaim, builder: me.data.builder });
3568
- if (action.action === 'resume-grade') {
3569
- console.log(`Task #${taskId} is at 'completed' (grade stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the grade (no re-claim needed).`);
3570
- return regradeMain();
3571
- }
3572
- if (action.action === 'resume-merge') {
3573
- console.log(`Task #${taskId} is at 'confirmed' (merge/deploy stalled) and ${action.via === 'owner' ? 'you last held the claim' : 'you are an Archon'} — resuming the merge + deploy (no re-claim needed).`);
3574
- return resumeMerge(taskId, { summary, skipMerge, force, task, dbOnly });
3575
- }
3576
- if (action.action === 'done') {
3577
- console.log(action.reason);
3578
- return;
3579
- }
3580
- console.error(action.reason || `You don't have an active claim on task #${taskId}.`);
3581
- if (claims.length > 0) {
3582
- console.error(`Your active claims:`);
3583
- for (const c of claims) console.error(` #${c.task_id}: ${c.title}`);
3584
- }
3585
- process.exit(1);
3586
- }
3587
-
3588
- // ---- Hard cap: refuse to ship over the monthly budget (V3.R84 #302, C3) ----
3589
- // Shipping runs the subagent grader, which spends API $. If this builder is at
3590
- // or over their monthly budget, refuse here — BEFORE the grader runs and BEFORE
3591
- // the claim resolves, so the claim stays active and no cost is incurred. Any
3592
- // Metic+ builder lifts the block by raising the builder's budget
3593
- // (PATCH /builders/:id/budget — builder.budget.set, metic-floored since ADR 0157).
3594
- // Fail-open: a missing/unreadable cost status (e.g. an older server) never blocks a ship.
3595
- const costStatus = me.data.cost;
3596
- if (costStatus && costStatus.level === 'over') {
3597
- console.error('');
3598
- console.error(`Ship refused: monthly budget exceeded — $${costStatus.spend_mtd_usd} of $${costStatus.budget_mtd_usd} spent this month (${costStatus.pct_used}%).`);
3599
- console.error('Shipping runs the quality grader, which spends API budget. No claim resolved, no credits awarded.');
3600
- console.error('Ask any Metic or Archon to raise your monthly budget (PATCH /builders/:id/budget), then re-ship.');
3601
- process.exit(1);
3602
- }
3603
-
3604
- // ---- Surprise gate: forced "anything surprise you?" prompt ----
3605
- //
3606
- // Resolve the user's intent into a single value: either a non-empty surprise
3607
- // string (capture a learning) or empty (no surprise, ship normally).
3608
- // --surprise "text" → capture
3609
- // --surprise none → no surprise
3610
- // --surprise "" → no surprise
3611
- // --no-surprise → no surprise
3612
- // neither + TTY → prompt
3613
- // neither + non-TTY → no surprise, with stderr warning
3614
- let surpriseText = '';
3615
- if (surpriseProvided) {
3616
- const v = (surpriseRaw || '').trim();
3617
- surpriseText = (v === '' || v.toLowerCase() === 'none') ? '' : v;
3618
- } else if (noSurprise) {
3619
- surpriseText = '';
3620
- } else if (process.stdout.isTTY && process.stdin.isTTY) {
3621
- surpriseText = await promptSurprise();
3622
- if (surpriseText.toLowerCase() === 'none') surpriseText = '';
3623
- } else {
3624
- console.error('warn: no --surprise or --no-surprise provided in non-interactive context; defaulting to no surprise.');
3625
- surpriseText = '';
3626
- }
3627
-
3628
- // ---- Step 0: preflight (working tree + branch freshness) ----
3629
- console.log(`Verifying claim #${ac.claim_id} on task #${ac.task_id}: ${ac.title}`);
3630
- console.log('Preflight (working tree + branch freshness)...');
3631
- const pre = preflight(ac, { force });
3632
- if (!pre.ok) {
3633
- console.error('');
3634
- console.error(`Ship aborted: ${pre.reason}. No claim resolved, no credits awarded.`);
3635
- process.exit(1);
3636
- }
3637
-
3638
- // ---- Step 0b: pull origin/main into the branch up front (#1068, gap 2) ----
3639
- // So the tree reviewed on the sandbox == the tree we ship — no post-approval
3640
- // re-stage loop. Skipped under --no-merge (a deliberate "don't touch main"
3641
- // opt-out). Aborts only on a genuine conflict, claim left intact.
3642
- if (!skipMerge) {
3643
- const m = mergeMainIntoBranch();
3644
- if (!m.ok) {
3645
- console.error('');
3646
- console.error(`Ship aborted: ${m.reason}.`);
3647
- console.error('No claim resolved, no credits awarded — your claim is still active.');
3648
- process.exit(1);
3649
- }
3650
- }
3651
-
3652
- // ---- Capture surprise learning (if any) ----
3653
- if (surpriseText) {
3654
- console.log('Capturing surprise as a learning...');
3655
- await captureSurpriseLearning(surpriseText, ac, taskId);
3656
- }
3657
-
3658
- // ---- Pre-verify: auto-linkify this ship's own changed docs (#747) ----
3659
- // Fix bare "#NNN" task refs in the docs this ship changed BEFORE the merge,
3660
- // so they never red-X the doc-ref-linkify gate (tests/linkify_check.mjs in
3661
- // the `unit` check). Best-effort.
3662
- autoLinkifyChangedDocs(ac);
3663
-
3664
- // ---- Empty-diff / phantom-ship guard (must run BEFORE the review gate + resolve) ----
3665
- // #513 added this check, but at step 2 — AFTER the resolve below already
3666
- // landed the task at `completed` and consumed the claim. A builder who ran
3667
- // /builder-ship before committing then burned the claim and couldn't re-ship
3668
- // ("You don't have an active claim" — the task-595-regrade-gap). Observed
3669
- // live on #662 (Windows). Moving it here means an empty committed diff aborts
3670
- // cleanly with the claim STILL ACTIVE, so the builder can commit and re-run.
3671
- // The grader reads committed history (baseline...HEAD), independent of
3672
- // touches[] (see computeGraderBaseline). The computed list is reused by the
3673
- // grader at step 2 so we don't diff twice.
3674
- //
3675
- // task 1365: this guard now runs REGARDLESS of --skip-grade. The old version
3676
- // was gated on `!skipGrade`, so --skip-grade (and the server-side
3677
- // grader_bypass that forces skipGrade) sailed an empty committed diff straight
3678
- // through to resolve → manual-confirm → the publish reconciler then shipped a
3679
- // task with NO artifact on main (task 1363: a new doc left UNTRACKED, never
3680
- // committed, HEAD never moved — the ledger lied, CLAUDE.md §8). classifyEmptyShip
3681
- // splits the three cases: a dirty/untracked working tree means the deliverable
3682
- // was never committed (refuse, even under --allow-empty); a clean tree with an
3683
- // explicit --allow-empty is a genuine no-artifact/DB-only ship (allow); a clean
3684
- // tree without the flag is nothing-to-ship (refuse). Refusing here leaves the
3685
- // claim ACTIVE so the builder can commit + re-run.
3686
- const graderBaseline = computeGraderBaseline(ac);
3687
- const committedFiles = computeChangedFiles(graderBaseline);
3688
- if (committedFiles.length === 0) {
3689
- const verdict = classifyEmptyShip(gitOk('git status --porcelain'), { allowEmpty });
3690
- if (verdict.reason === 'uncommitted-work') {
3691
- console.error('Ship refused: nothing is committed on this branch, but your working tree has uncommitted changes:');
3692
- for (const f of verdict.uncommitted) console.error(` - ${f}`);
3693
- console.error('');
3694
- console.error('Your work was never committed — shipping now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
3695
- console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run /builder-ship. Your claim is still active.');
3696
- process.exit(1);
3697
- }
3698
- if (verdict.reason === 'nothing-to-ship') {
3699
- console.error(`Nothing to ship: the committed diff vs ${graderBaseline || 'baseline'} is empty and the working tree is clean.`);
3700
- console.error('The grader reads committed history, not the working tree — commit your work on this branch, then re-run /builder-ship. Your claim is still active.');
3701
- console.error('If this task legitimately has no code/doc artifact (a pure DB-only or no-op task), re-run with --allow-empty.');
3702
- process.exit(1);
3703
- }
3704
- // verdict.reason === 'allow-empty': a deliberate no-artifact / DB-only ship.
3705
- // There is no committed diff for the subagent grader to read, so confirm
3706
- // directly (this is the DB-only path the old --skip-grade route served).
3707
- if (!skipGrade) skipGrade = true;
3708
- console.log('--allow-empty: no committed diff (no-artifact / DB-only ship) — confirming directly without the subagent grader.');
3709
- }
3710
-
3711
- // ---- Module drift check (BV1.R53, task 1422) ----
3712
- // If the task carries a module_key, verify the committed diff doesn't stray
3713
- // into a DIFFERENT module's territory. "You said game, you touched dev-box" is
3714
- // the semantic violation this catches. Files outside all module scopes (docs,
3715
- // session logs, general scripts) are allowed — they're cross-cutting. Only
3716
- // contact with a DIFFERENT module's glob set is refused.
3717
- //
3718
- // The check is ADVISORY (warns but does not refuse) on the first cut because
3719
- // the module_key column is new and existing tasks may carry a broader diff scope
3720
- // until all tasks are labelled. Enforced tasks can opt in with --enforce-module.
3721
- // ac.module_key comes from getActiveClaimsForBuilder (t.module_key in the join).
3722
- const enforceModule = args.includes('--enforce-module');
3723
- if (ac.module_key && isModuleKey(ac.module_key) && committedFiles.length > 0) {
3724
- const declaredScope = scopeForModule(ac.module_key);
3725
- const { MODULE_SCOPE_MAP } = require('../../src/bongos/module-scope-map');
3726
- const moduleDriftFiles = committedFiles.filter((f) => {
3727
- if (declaredScope && matchOne(f, declaredScope.globs)) return false;
3728
- for (const [key, entry] of Object.entries(MODULE_SCOPE_MAP)) {
3729
- if (key === ac.module_key) continue;
3730
- if (matchOne(f, entry.globs)) return true;
3731
- }
3732
- return false;
3733
- });
3734
- if (moduleDriftFiles.length > 0) {
3735
- const msg = [
3736
- `\n⚠ Module drift detected: task is scoped to '${ac.module_key}' but ${moduleDriftFiles.length} file(s) touch a different module:`,
3737
- ...moduleDriftFiles.map((f) => ` - ${f}`),
3738
- '',
3739
- enforceModule
3740
- ? 'Refused (--enforce-module). Confine your changes to modules/' + ac.module_key + '/ or clear the module_key label.'
3741
- : 'Advisory — ship proceeds, but consider confining your changes to modules/' + ac.module_key + '/.',
3742
- ].join('\n');
3743
- console.log(msg);
3744
- if (enforceModule) process.exit(1);
3745
- }
3746
- }
3747
-
3748
- // ---- Sandbox-first review gate (#927, ADR 0046) ----
3749
- // On a dev box, a diff that touches game surfaces must be STAGED on the
3750
- // builder's own live preview (sandbox-<login>.amazonprimea.com, ADR 0044)
3751
- // and reviewed in a browser before the claim resolves — see it running,
3752
- // get the chance to iterate, THEN decide to ship to the main game. Laptop
3753
- // sessions, non-game diffs, quick-tunnel boxes, and --skip-stage all pass
3754
- // through untouched. Interactive: stage + ask; declining aborts with the
3755
- // claim intact. Non-TTY (agent-driven sessions): stage, then abort with the
3756
- // review URL — the re-run after the human reviews passes on the fresh
3757
- // staged marker. Never hangs on stdin.
3758
- const sandboxGate = await runShipGate({
3759
- taskId,
3760
- files: committedFiles,
3761
- skipStage: args.includes('--skip-stage'),
3762
- // #1048: --approved records the builder's sign-off after they reviewed the
3763
- // staged change on their sandbox. Only honored on a re-run whose game state
3764
- // is already staged-fresh — it can't pre-approve an unstaged change.
3765
- approved: args.includes('--approved'),
3766
- tty: Boolean(process.stdout.isTTY && process.stdin.isTTY),
3767
- });
3768
- if (!sandboxGate.ok) {
3769
- if (sandboxGate.review) {
3770
- // task 1275: the in-review card — the SAME lifecycle card, amber, whose
3771
- // action is "Open in sandbox ↗" (the sandbox URL the owner asked to surface
3772
- // here). Best-effort, never blocks the pause.
3773
- try {
3774
- const shipCard = require('../../modules/lifecycle/ship-card');
3775
- const { added, removed } = diffstatFor(graderBaseline);
3776
- const model = shipCard.buildCardModel({
3777
- stage: 'review',
3778
- taskId,
3779
- title: ac.title,
3780
- valueSummary: summary,
3781
- fileCount: committedFiles.length || null,
3782
- added,
3783
- removed,
3784
- sandboxUrl: sandboxGate.url,
3785
- });
3786
- // task 1279: honour the builder's render-widgets knob (from the /me
3787
- // fetched at the top of shipMain; default ON if absent/older server).
3788
- const widgetsEnabled = me?.data?.render?.widgets_enabled !== false;
3789
- shipCard.emitCard(model, taskId, { widgetsEnabled });
3790
- } catch (_) { /* the review pause stands with or without the card */ }
3791
- // #1061 polish: a sandbox review pause is a DELIBERATE, expected
3792
- // checkpoint — not a failed ship. printReviewBlock already showed the URL
3793
- // + the --approved next step, so don't follow it with red "Ship aborted /
3794
- // no credits awarded" text that makes the review moment read as broken.
3795
- // Calm note on stdout (not stderr); the claim is left fully intact.
3796
- console.log('');
3797
- console.log('⏸ Paused for your sandbox review — this is expected, not an error.');
3798
- console.log(' Your claim is still active; nothing was lost.');
3799
- console.log(' Walk the change at the link above, then approve with: /builder-ship --approved');
3800
- process.exit(1);
3801
- }
3802
- console.error('');
3803
- console.error(`Ship aborted: ${sandboxGate.reason}.`);
3804
- console.error('No claim resolved, no credits awarded — your claim is still active.');
3805
- process.exit(1);
3806
- }
3807
- // #1048: the recorded sandbox self-approval (when the builder approved a
3808
- // staged game change), threaded into the resolve call below so the server
3809
- // persists it on the task. null on laptop ships, non-game diffs, quick-tunnel
3810
- // boxes, --skip-stage, and bare fresh-marker re-runs without --approved.
3811
- const sandboxReview = sandboxGate.sandboxReview || null;
3812
-
3813
- // ---- Step 1+2: verify locally (smoke) — runs AFTER the review gate (#1061) ----
3814
- // touches[] cleanup [4/8] (task 879): the predictive drift scanner is gone;
3815
- // local verification is smoke-only. Smoke runs HERE (post-gate) so a review
3816
- // PAUSE doesn't bury the sandbox link under the smoke output — smoke only runs
3817
- // on the actual ship: the --approved re-run, or a laptop / non-game ship the
3818
- // gate waved straight through. (#1061 — the gate is a no-op for those, so this
3819
- // is the same single smoke run as before, just sequenced after it.)
3820
-
3821
- // task 1002660: start the grade COMPUTATION now, so the multi-minute panel
3822
- // overlaps the smoke run + claim resolve instead of serializing behind them.
3823
- // computeGradeResult performs no server writes — the grade POST stays in the
3824
- // grade step below, after the resolve lands the task at 'completed'. On a
3825
- // smoke abort the in-flight panel is discarded (its partial spend is lost —
3826
- // rare, and cheaper than serializing every ship behind smoke).
3827
- let gradeCompute = null;
3828
- let fullTaskForGrade = null;
3829
- if (!skipGrade) {
3830
- const tFetch = await (await cliClient()).tasks.getTasksId({ id: taskId });
3831
- fullTaskForGrade = tFetch.ok ? tFetch.data.task : { id: taskId, title: ac.title };
3832
- console.log('Starting the quality grader panel (overlaps the smoke run — task 1002660)...');
3833
- gradeCompute = computeGradeResult({
3834
- taskId, task: fullTaskForGrade, summary,
3835
- baseline: graderBaseline, committedFiles,
3836
- });
3837
- gradeCompute.catch(() => {}); // an abort path must not die on an unhandled rejection
3838
- }
3839
-
3840
- console.log('Running smoke tests against production...');
3841
- // task 1002660: the async twin — awaiting keeps the event loop free so the
3842
- // overlapped panel's subprocess callbacks progress while smoke runs.
3843
- const smoke = await runSmokeAsync();
3844
- const passed = smoke.clean;
3845
- console.log('');
3846
- console.log(`Verification: smoke=${smoke.clean ? 'clean' : 'FAIL'}, overall=${passed ? 'PASS' : 'FAIL'}`);
3847
- console.log('');
3848
-
3849
- // task 1002605 (G-18): a smoke FAIL used to fall straight through into the
3850
- // claim resolve — claim consumed, grading silently skipped, task parked at
3851
- // 'completed', exit 0, and the builder read "Done." believing they shipped.
3852
- // Abort BEFORE the resolve instead — the same claim-preserving posture as the
3853
- // budget cap and the sandbox-review gate above. --allow-smoke-fail is the
3854
- // deliberate continuation; the parked message where the grader would have run
3855
- // says exactly where that leaves the task.
3856
- if (!passed && !allowSmokeFail) {
3857
- console.error('');
3858
- for (const line of smokeGateMessages(taskId).abort) console.error(line);
3859
- process.exit(1);
3860
- }
3861
-
3862
- // ---- Step 1: resolve the claim (lands at completed) ----
3863
- // V3.R18 (#209): resolveClaim no longer runs the v1 deterministic grader.
3864
- // It now lands the task at `completed` when verification (smoke) passes, and
3865
- // stops. The v2 subagent grader (step 2 below) is what gates the credits +
3866
- // the completed→confirmed transition.
3867
- const r = await (await cliClient()).claims.postClaimsIdResolve({
3868
- id: ac.claim_id,
3869
- body: {
3870
- outcome: 'shipped',
3871
- notes_md: notes,
3872
- value_summary: summary,
3873
- verification: {
3874
- passed,
3875
- // touches[] cleanup [4/8] (task 879): the drift scanner is gone; with no
3876
- // scanner there is nothing to drift, so this audit-trail signal
3877
- // (grader.scoreScannerSmoke) is vacuously clean.
3878
- scanner_clean: true,
3879
- smoke_clean: smoke.clean,
3880
- },
3881
- // touches[] cleanup [7/8] (task 882): the real committed changed-files, so
3882
- // the server backfills touches[] from reality (the planning prediction is
3883
- // now optional). Same list the grader already used.
3884
- committed_files: committedFiles,
3885
- // #1048: the sandbox self-approval { stamp, url, method } when the builder
3886
- // reviewed + approved a staged game change. The server stamps by/at and
3887
- // persists it to tasks.sandbox_review. null = nothing to record.
3888
- sandbox_review: sandboxReview,
3889
- },
3890
- });
3891
- if (!r.ok) {
3892
- const resolveErr = normalizeApiError(r.data);
3893
- if (resolveErr.code === 'CLAIM_SESSION_MISMATCH') {
3894
- const wt = resolveErr.details.claimWorktree;
3895
- console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
3896
- console.error('You can only ship from the session that originally claimed this task.');
3897
- console.error(`To force-release from this session: node scripts/gds/release.js ${taskId} --force`);
3898
- } else {
3899
- console.error(`resolve failed: ${apiErrorLine(r.data, r.status)}`);
3900
- }
3901
- process.exit(1);
3902
- }
3903
-
3904
- const taskStatus = r.data.taskStatus || 'shipped (legacy response)';
3905
- console.log(`Task #${r.data.taskId} is now: ${taskStatus}`);
3906
- // Claim CONSUMED, task at 'completed' — a crash from here needs fatalRecoveryLines.
3907
- markShipProgress('resolved', taskId);
3908
-
3909
- // ---- Step 2: subagent quality grader ----
3910
- // Runs when local verification passed AND resolveClaim landed at completed.
3911
- // If verification did not pass we don't bother grading — the task stays at
3912
- // completed (or whatever resolveClaim returned) with no credits.
3913
- let credits = r.data.creditsAwarded ?? 0;
3914
- let postGradeStatus = taskStatus;
3915
- let postGradeJustUnlocked = r.data.just_unlocked || [];
3916
- let gradePassed = false;
3917
- let advanceToMerge = false;
3918
- let gradeObj = null; // task 1270: the grade result, hoisted for the completion card
3919
- // V3.R27 (#247): credit breakdown surfaced from the grade or confirm
3920
- // response. Kept separate from `credits` so we only print the multiplier
3921
- // line when the multiplier actually moved the payout.
3922
- let creditsRaw = null;
3923
- let creditsMultiplier = null;
3924
- let ideaBonusInfo = null;
3925
-
3926
- if (passed && taskStatus === 'completed' && skipGrade) {
3927
- // Escape hatch: skip the subagent grader and manual-confirm. The
3928
- // task_grades row gets grader_kind='lars' so the audit trail captures
3929
- // that this ship bypassed the v2 grader.
3930
- console.log('');
3931
- console.log('--skip-grade: skipping subagent grader; manual-confirming via POST /tasks/:id/confirm.');
3932
- // task 1053 (F): pass the verify attestation. The server refuses to confirm a
3933
- // kind='verify' task without it (so a hardware-verify can't land before the
3934
- // verify ran). --verified is a no-op for every other kind.
3935
- const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
3936
- if (!confirm.ok) {
3937
- if (confirm.status === 409 && normalizeApiError(confirm.data).code === 'verify_attestation_required') {
3938
- console.error('');
3939
- console.error(`This is a VERIFICATION task — it won't confirm until you attest the verify actually passed.`);
3940
- console.error(`Run the verification, then re-ship with --verified:`);
3941
- console.error(` node scripts/gds/ship.js ${taskId} --verified`);
3942
- console.error(`(Your work is committed and the task is at 'completed' — re-running resumes, task 941.)`);
3943
- process.exit(1);
3944
- }
3945
- // task 1002613 (map P-1): the one genuine permission wall on the ship path
3946
- // used to surface as a raw envelope dump — an atom name is not a rank, and
3947
- // no remedy printed at the one station where self-service exists. Interpret
3948
- // it: name the wall, the floor (from the server's floors map — never
3949
- // hardcoded), and both ways forward.
3950
- const errObj = confirm.data && confirm.data.error;
3951
- const errCode = typeof errObj === 'string' ? errObj : (errObj && errObj.code);
3952
- if (confirm.status === 403 && errCode === 'permission_forbidden') {
3953
- const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
3954
- const floor = details.floors && details.floors['task.confirm.grade_bypass'];
3955
- const floorNote = floor ? ` (floor: ${floor})` : '';
3956
- console.error('');
3957
- console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floorNote}.`);
3958
- console.error(' This is an authorization wall, not a verdict on your work.');
3959
- console.error(' Self-service: re-run WITHOUT --skip-grade to use the subagent grader, and if a');
3960
- console.error(' grade fails, fix its findings and re-run (unlimited, no attempt cap):');
3961
- console.error(` node scripts/gds/ship.js ${taskId} --regrade`);
3962
- console.error(` If the grade genuinely cannot pass, ask a builder whose role holds`);
3963
- console.error(` task.confirm.grade_bypass${floorNote} to POST /api/gds/tasks/${taskId}/confirm.`);
3964
- process.exit(1);
3965
- }
3966
- console.error(`manual-confirm failed: ${apiErrorLine(confirm.data, confirm.status)}`);
3967
- process.exit(1);
3968
- }
3969
- postGradeStatus = confirm.data.taskStatus;
3970
- credits = confirm.data.creditsAwarded ?? 0;
3971
- ideaBonusInfo = confirm.data.ideaBonus ?? null;
3972
- advanceToMerge = postGradeStatus === 'confirmed';
3973
- gradePassed = true; // by builder fiat, not by grader
3974
- } else if (passed && taskStatus === 'completed') {
3975
- console.log('');
3976
- console.log('Running subagent quality grader...');
3977
-
3978
- // #513 + #536: the grader reads COMMITTED history (baseline...HEAD), not
3979
- // the working tree. The empty-committed-diff guard now runs earlier — BEFORE
3980
- // the claim is resolved (see above) — so by the time we reach here the diff
3981
- // is guaranteed non-empty on the grader path. Reuse the baseline + file list
3982
- // computed there so we don't diff twice. #678: gradeAndRecord POSTs the
3983
- // result and logs the panel cost — the same unit the --regrade path reuses.
3984
- // task 1002660: the panel computation was started BEFORE the smoke gate
3985
- // (gradeCompute above) — hand it in so this step only waits out whatever
3986
- // panel time smoke + resolve didn't already absorb.
3987
- const gradePost = await gradeAndRecord({
3988
- taskId,
3989
- task: fullTaskForGrade || { id: taskId, title: ac.title },
3990
- summary,
3991
- baseline: graderBaseline,
3992
- committedFiles,
3993
- precomputed: gradeCompute,
3994
- });
3995
-
3996
- if (!gradePost.ok) {
3997
- console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
3998
- console.error('Task stays at completed. Manual options:');
3999
- console.error(` - Re-run /builder-ship ${taskId} to try grading again, or`);
4000
- console.error(` - node scripts/gds/ship.js ${taskId} --regrade after fixing findings, or`);
4001
- console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm (last resort —`);
4002
- console.error(' permission-gated on task.confirm.grade_bypass).');
4003
- process.exit(1);
4004
- }
4005
-
4006
- const g = gradePost.data.grade;
4007
- gradeObj = g;
4008
- gradePassed = g.passed;
4009
- advanceToMerge = gradePost.data.advanceToMerge;
4010
- postGradeStatus = gradePost.data.taskStatus;
4011
- credits = gradePost.data.creditsAwarded;
4012
- postGradeJustUnlocked = gradePost.data.just_unlocked || [];
4013
- // V3.R27 (#247): pull the multiplier breakdown + idea-bonus info forward.
4014
- creditsRaw = gradePost.data.creditsRaw ?? null;
4015
- creditsMultiplier = gradePost.data.creditsMultiplier ?? null;
4016
- ideaBonusInfo = gradePost.data.ideaBonus ?? null;
4017
-
4018
- printGradeOutcome(g, taskId);
4019
- } else if (!passed && taskStatus === 'completed') {
4020
- // task 1002605 (G-18): the --allow-smoke-fail continuation. The resolve above
4021
- // consumed the claim with verification.passed=false, so the grader deliberately
4022
- // does not run. This used to be a silent fall-through; say where the task is.
4023
- console.log('');
4024
- for (const line of smokeGateMessages(taskId).parked) console.log(line);
4025
- }
4026
-
4027
- printCreditsAndAchievements({
4028
- postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo,
4029
- justUnlocked: postGradeJustUnlocked,
4030
- });
4031
-
4032
- // ---- Step 3: confirmed→shipped tail (merge + deploy → memory/session sync →
4033
- // Done → onboarding nudge → completion card), shared with regradeMain via
4034
- // finishShip (#1093/C9). ----
4035
- // Graded. If it passed, only the merge remains — a different crash story.
4036
- if (postGradeStatus === 'confirmed' || postGradeStatus === 'shipped') {
4037
- markShipProgress(postGradeStatus === 'shipped' ? 'landed' : 'graded', taskId);
4038
- }
4039
- await finishShip({
4040
- taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly,
4041
- // task 1270: data for the deterministic completion card.
4042
- card: {
4043
- title: ac.title,
4044
- valueSummary: summary,
4045
- notes,
4046
- files: committedFiles,
4047
- baseline: graderBaseline,
4048
- creditsAwarded: credits,
4049
- // task 1275: a real grade ran → pass/fail + issues; otherwise (--skip-grade
4050
- // or server-side GRADER_BYPASS) it was confirmed without a grade → skipped,
4051
- // so the card reads "grade skipped" rather than implying a real pass.
4052
- grade: gradeObj
4053
- ? { passed: gradeObj.passed, issues: gradeObj.issues || [], panel: Array.isArray(gradeObj.workers) ? gradeObj.workers.length : null, skipped: false }
4054
- : { passed: gradePassed, skipped: true },
4055
- },
4056
- });
4057
- }
4058
-
4059
- // ---------- Re-grade flow (#678) ----------
60
+ // ---------------------------------------------------------------------------
61
+ // FACADE + CLI ENTRY (task 1003205, goal 1000079). This file was 4,613 lines;
62
+ // the R03 de-monolith plan carved it into the ship-*.js phase modules required
63
+ // below, each well under the 1,500-line ratchet. The carve was STRUCTURAL: no
64
+ // function body changed, the module.exports block at the bottom is the original
65
+ // one verbatim, and the flag surface documented above is untouched — so both the
66
+ // ship contract and the five other files that import from here are unaffected.
4060
67
  //
4061
- // `node scripts/gds/ship.js <id> --regrade` closes the task-595-regrade-gap
4062
- // (re-hit on #600): when a ship lands at `completed` but the subagent grader
4063
- // fails, /builder-ship has already resolved+consumed the claim, leaving no CLI
4064
- // path back in (claim refuses "not ready", PATCH refuses, promote refuses, a
4065
- // fresh ship.js says "no active claim"). The only escapes were the Archon
4066
- // /confirm override (bypasses the grader — see feedback_prefer_regrade_over_override)
4067
- // or hand-building a grader harness (learning #43).
68
+ // The phases form a DAG (no require cycles). Roughly in dependency order:
69
+ // ship-state · ship-git · ship-io → ship-memory-sync · ship-deploy-target
70
+ // → ship-surprise · ship-session-upload · ship-land · ship-regen · ship-grade
71
+ // → ship-preflight-steps · ship-merge · ship-grade-steps
72
+ // → ship-finish → ship-resume → ship-flow (shipMain / regradeMain).
4068
73
  //
4069
- // This re-runs ONLY the grade step against the committed diff, with no claim:
4070
- // - eligibility: task at `completed` AND (you last held the claim OR Archon)
4071
- // - preflight: working tree committed + ship/grade tooling current
4072
- // - empty-diff guard: the grader reads committed history, so refuse if empty
4073
- // - grade: gradeAndRecord → POST /tasks/:id/grade (rank-open; applyGrade
4074
- // promotes completed→confirmed + awards credits on pass, idempotent)
4075
- // - on pass: the normal confirmed→shipped merge + sync tail runs.
4076
- async function regradeMain() {
4077
- await requireSession();
4078
- const args = process.argv.slice(2);
4079
- const taskId = Number(args[0]);
4080
- if (!Number.isFinite(taskId)) {
4081
- console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
4082
- process.exit(2);
4083
- }
4084
- const regradeProse = resolveProse(args, ['summary', 'response']);
4085
- const summaryArg = regradeProse.summary;
4086
- // task 1002662: the builder's response to the prior round's findings — how
4087
- // each was fixed, tracked (a task ref), or why it's out of scope. Rides to
4088
- // the panel fenced, under the delta re-grade policy.
4089
- const responseArg = regradeProse.response;
4090
- const skipMerge = args.includes('--no-merge');
4091
- const force = args.includes('--force');
4092
- // task 1002422: honor --base on regrade/resume too, so `--regrade --base <ref>`
4093
- // and shipMain's resume-grade dispatch grade the real <base>...HEAD diff and
4094
- // advance already-on-main work, instead of aborting "nothing to grade". Same
4095
- // 0-ahead security guard as shipMain (applyBaseOverride is the shared gate).
4096
- const baseRefRaw = arg('--base', args);
4097
- const dbOnly = args.includes('--db-only') || args.includes('--allow-empty') || Boolean(baseRefRaw); // task 391 — honor --db-only on a regrade too
4098
- const verified = args.includes('--verified'); // task 1053 (F) — see gate below
4099
-
4100
- if (baseRefRaw) {
4101
- const res = applyBaseOverride(baseRefRaw);
4102
- if (!res.ok) {
4103
- console.error(`Regrade aborted: ${res.reason}. Omit --base and re-grade normally.`);
4104
- process.exit(2);
4105
- }
4106
- console.log(`--base: re-grading ${res.sha.slice(0, 9)}...HEAD (work already on main).`);
4107
- }
4108
-
4109
- // ---- Eligibility: completed + (owner | archon) ----
4110
- const me = noteServerReward(await (await cliClient()).me.getMe());
4111
- if (!me.ok) {
4112
- console.error('failed to load profile:', me.data);
4113
- process.exit(1);
4114
- }
4115
- const builder = me.data.builder || {};
4116
- // ?include=grade,last_claim — the claim-holder lookup for eligibility (#678)
4117
- // + the stored grade row for the delta-aware prior-round payload (task 1002662).
4118
- const tRes = await (await cliClient()).tasks.getTasksId({ id: taskId, query: { include: 'grade,last_claim' } });
4119
- if (!tRes.ok) {
4120
- if (tRes.status === 404) {
4121
- console.error(`Task #${taskId} not found.`);
4122
- } else {
4123
- console.error(`failed to load task #${taskId} (${tRes.status}):`, tRes.data);
4124
- }
4125
- process.exit(1);
4126
- }
4127
- const task = tRes.data.task;
4128
- const lastClaim = tRes.data.last_claim || null;
4129
- const elig = evaluateRegradeEligibility({ task, lastClaim, builder });
4130
- if (!elig.ok) {
4131
- console.error(elig.reason);
4132
- process.exit(1);
4133
- }
4134
- // task 1053 (F): the resume/regrade path promotes completed→confirmed via the
4135
- // grade (applyGrade), which the server-side /confirm verify guard does NOT
4136
- // cover. Gate it here so a verification task can't be confirmed by simply
4137
- // re-running without attesting the verify passed (closing the bypass).
4138
- if (task.kind === 'verify' && !verified) {
4139
- console.error('');
4140
- console.error(`Task #${taskId} is a VERIFICATION task — it won't confirm until you attest the verify actually passed.`);
4141
- console.error(`Run the verification, then re-run with --verified:`);
4142
- console.error(` node scripts/gds/ship.js ${taskId} --verified`);
4143
- process.exit(1);
4144
- }
4145
- console.log(`Re-grading task #${taskId}: ${task.title}`);
4146
- console.log(` eligibility: ${elig.reason} (${elig.via})`);
4147
-
4148
- // A grade-failed ship never reached the merge/broadcast step, so a passing
4149
- // re-grade is the FIRST time this task actually ships. Fall back to the
4150
- // task's stored value_summary so the merge commit + Discord #ship-news post
4151
- // carry meaningful text even when --summary is omitted.
4152
- const summary = summaryArg || task.value_summary || '';
4153
-
4154
- // The grader helpers want a claim-like object for touches[] (preflight's
4155
- // working-tree-drift check) and head_sha (baseline fallback). The claim is
4156
- // gone, so synthesize one from the task row + the last claim's head_sha.
4157
- const claimLike = { touches: task.touches || [], head_sha: lastClaim?.head_sha ?? null, task_id: taskId, title: task.title };
4158
-
4159
- // ---- Preflight: work committed + ship/grade tooling current ----
4160
- // No scanner/smoke re-run — --regrade re-runs ONLY the grade step. But the
4161
- // grader judges COMMITTED history, so uncommitted fixes on touches[] files
4162
- // wouldn't be graded — refuse that (it would grade a stale diff). Also refuse
4163
- // if the local ship/grade tooling is behind main (a stale grader silently
4164
- // fails the same way — the #662 lesson). --force overrides the freshness leg.
4165
- console.log('Preflight (working tree + branch freshness)...');
4166
- const pre = preflight(claimLike, { force });
4167
- if (!pre.ok) {
4168
- console.error('');
4169
- console.error(`Re-grade aborted: ${pre.reason}. Task stays at completed.`);
4170
- process.exit(1);
4171
- }
4172
-
4173
- // ---- Empty-diff guard (the grader reads committed history) ----
4174
- const graderBaseline = computeGraderBaseline(claimLike);
4175
- const committedFiles = computeChangedFiles(graderBaseline);
4176
- if (committedFiles.length === 0) {
4177
- console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
4178
- console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
4179
- process.exit(1);
4180
- }
4181
-
4182
- // ---- Delta-aware prior-round context (task 1002662) ----
4183
- // The prior grade's findings + the builder's response + the interim fix diff
4184
- // ride to the panel so an already-answered finding can't re-veto on
4185
- // re-wording alone. The interim range comes from the prior grade's
4186
- // signals.graded_head_sha (recorded by runSubagentGrader since this task);
4187
- // older grade rows lack it — the findings still ride, just without the diff.
4188
- const storedGradeRow = tRes.data.grade ?? null;
4189
- let interimCommits = '';
4190
- let interimDiffStat = '';
4191
- const priorSha = storedGradeRow?.signals?.graded_head_sha;
4192
- // The sha comes from a DB JSONB blob — validate the shape before shelling out.
4193
- if (typeof priorSha === 'string' && /^[0-9a-f]{7,40}$/i.test(priorSha) && gitOk(`git rev-parse --verify --quiet ${priorSha}`)) {
4194
- interimCommits = gitOk(`git log --oneline ${priorSha}..HEAD`) || '';
4195
- interimDiffStat = gitOk(`git diff --stat ${priorSha}..HEAD`) || '';
4196
- }
4197
- const priorRound = buildPriorRoundContext({
4198
- storedGrade: storedGradeRow,
4199
- responseText: responseArg,
4200
- interimCommits,
4201
- interimDiffStat,
4202
- });
4203
- if (priorRound) {
4204
- console.log(` prior round context: ${priorRound.audit.prior_findings_count} finding(s) from the prior grade${priorRound.audit.response_present ? ' + builder response' : ''}${priorRound.audit.interim_present ? ' + interim fix diff' : ''} → round ${priorRound.audit.round}`);
4205
- }
4206
-
4207
- // ---- Grade step (the only step) ----
4208
- console.log('');
4209
- console.log('Running subagent quality grader...');
4210
- const gradePost = await gradeAndRecord({
4211
- taskId,
4212
- task,
4213
- summary,
4214
- baseline: graderBaseline,
4215
- committedFiles,
4216
- priorRound,
4217
- });
4218
- if (!gradePost.ok) {
4219
- console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
4220
- console.error('Task stays at completed. Fix the issue and re-run --regrade (unlimited, no attempt cap).');
4221
- console.error('(POST /confirm is a last resort — it is permission-gated on task.confirm.grade_bypass.)');
4222
- process.exit(1);
4223
- }
74
+ // Its own module doc always told agents to search by PHASE keyword rather than
75
+ // read end-to-end; the phases are now files, so the search is a file open.
76
+ // ---------------------------------------------------------------------------
4224
77
 
4225
- const g = gradePost.data.grade;
4226
- const postGradeStatus = gradePost.data.taskStatus;
4227
- printGradeOutcome(g, taskId);
4228
- printCreditsAndAchievements({
4229
- postGradeStatus,
4230
- credits: gradePost.data.creditsAwarded,
4231
- creditsRaw: gradePost.data.creditsRaw ?? null,
4232
- creditsMultiplier: gradePost.data.creditsMultiplier ?? null,
4233
- ideaBonusInfo: gradePost.data.ideaBonus ?? null,
4234
- justUnlocked: gradePost.data.just_unlocked || [],
4235
- });
78
+ 'use strict';
4236
79
 
4237
- // On a pass the task is now confirmed — run the SAME confirmed→shipped tail as a
4238
- // normal ship (finishShip, #1093/C9) so a re-grade lands identically.
4239
- await finishShip({
4240
- taskId, summary, advanceToMerge: gradePost.data.advanceToMerge, skipMerge, dbOnly,
4241
- postGradeStatus,
4242
- doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
4243
- // task 1270: completion card for the re-grade path too.
4244
- card: {
4245
- title: task.title,
4246
- valueSummary: summary,
4247
- notes: '',
4248
- files: committedFiles,
4249
- baseline: graderBaseline,
4250
- creditsAwarded: gradePost.data.creditsAwarded,
4251
- grade: { passed: g.passed, issues: g.issues || [], panel: Array.isArray(g.workers) ? g.workers.length : null },
4252
- },
4253
- });
4254
- }
80
+ const { buildMergeArgv, gitArgvOk, sanitizeGitHubRefs } = require('./ship-git.js');
81
+ const { apiErrorLine, normalizeApiError, postLandShipOutcome } = require('./ship-io.js');
82
+ const { deriveTagsFromPaths } = require('./ship-surprise.js');
83
+ const {
84
+ encodeProjectPath,
85
+ resolveBuilderModel,
86
+ resolveTranscriptPath,
87
+ } = require('./ship-memory-sync.js');
88
+ const {
89
+ broadcastHandledByServer,
90
+ buildPrCreateArgv,
91
+ buildPrMergeArgv,
92
+ deployRunSucceeded,
93
+ deployedCommitStatus,
94
+ effectiveDeployMode,
95
+ parseDeployMode,
96
+ pushVia,
97
+ resolveDeployMode,
98
+ resolveDeployTarget,
99
+ resolveInstanceBase,
100
+ shouldBailOnFetchFailure,
101
+ } = require('./ship-deploy-target.js');
102
+ const { classifyUploadSkip, recordUploadSkip } = require('./ship-session-upload.js');
103
+ const {
104
+ classifyLandOutcome,
105
+ formatPublishFailure,
106
+ landBailed,
107
+ landPending,
108
+ landPendingLines,
109
+ landPollAfterAutoMerge,
110
+ landed,
111
+ pickStaleGateReviewRun,
112
+ reconcileBlockedLines,
113
+ reconciledCardStatus,
114
+ strandCommand,
115
+ strandNextStep,
116
+ } = require('./ship-land.js');
117
+ const {
118
+ VENDORED_CLIENT_REL,
119
+ apiClientPathspec,
120
+ regenerateDiagrams,
121
+ regenerateFileMap,
122
+ regenerateRepoMap,
123
+ regenerateSessionIndex,
124
+ } = require('./ship-regen.js');
125
+ const {
126
+ baseRefShipGuard,
127
+ computeChangedFiles,
128
+ computeGraderBaseline,
129
+ computeRawDiffHash,
130
+ diffHash,
131
+ reconstructPanelIssues,
132
+ resolveBaseRef,
133
+ runSubagentGrader,
134
+ shouldReuseGrade,
135
+ } = require('./ship-grade.js');
136
+ const {
137
+ decidePreMerge,
138
+ isLinkifiableDocPath,
139
+ parseDeletedUpstreamPaths,
140
+ } = require('./ship-preflight-steps.js');
141
+ const { zeroCommitVerdict } = require('./ship-merge.js');
142
+ const {
143
+ buildGradePayload,
144
+ buildPriorRoundContext,
145
+ computeGradeResult,
146
+ formatGradeOutcome,
147
+ gradeAndRecord,
148
+ } = require('./ship-grade-steps.js');
149
+ const { evaluateRegradeEligibility, evaluateResumeAction } = require('./ship-resume.js');
150
+ const { regradeMain, shipMain } = require('./ship-flow.js');
151
+ const { fatalRecoveryLines, smokeGateMessages } = require('./ship-honesty.js');
152
+ const { chooseBroadcastTargets } = require('../../modules/discord/ship-broadcast');
153
+ const {
154
+ collectEnvSecrets,
155
+ looksHighEntropySecret,
156
+ redactHighEntropy,
157
+ scrubSecrets,
158
+ shannonEntropy,
159
+ } = require('../../modules/security/secret-scrub');
160
+ const { buildSessionDigest, snippetOf } = require('./session-digest-build');
4255
161
 
4256
- // Pure helpers are exported for unit tests (tests/session_records.mjs). The
4257
- // orchestration only runs when ship.js is invoked directly — so `require()`ing
4258
- // this module (a test) does NOT kick off a ship. (uploadSessionOnShip is not
4259
- // exported: it does I/O + network and is covered by the live smoke at ship.)
4260
162
  module.exports = {
4261
163
  scrubSecrets, snippetOf, buildSessionDigest, resolveTranscriptPath,
4262
164
  // task 1002659: transcript-derived real builder model (dominant by output
@@ -4409,6 +311,14 @@ module.exports = {
4409
311
  };
4410
312
 
4411
313
  if (require.main === module) {
314
+ // --print-deploy-mode (task 1003221): the EFFECTIVE mode, one word, no session or
315
+ // network. The /merge-mode skill used to probe parseDeployMode directly, which
316
+ // defaults to 'laptop' when config/deploy.json is absent — as it is on every dev
317
+ // box (ADR 0148) — sending the builder down a push path they had no credential for.
318
+ if (process.argv.slice(2).includes('--print-deploy-mode')) {
319
+ console.log(require('./ship-deploy-target.js').resolveDeployMode());
320
+ process.exit(0);
321
+ }
4412
322
  // #678: --regrade re-grades a grade-failed `completed` task (no claim needed).
4413
323
  // Dispatched here so it owns its own arg-parsing + session bootstrap.
4414
324
  const main = process.argv.slice(2).includes('--regrade') ? regradeMain : shipMain;