@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.
- package/.bongos-core.json +125 -40
- package/.claude/skills/merge-mode/SKILL.md +3 -1
- package/.devcontainer/init-firewall.sh +41 -5
- package/clients/bongos-client/examples/hello-world.mjs +11 -2
- package/docs/copy-inventory.md +20 -20
- package/docs/copy-registry.json +22 -22
- package/modules/hall-ui/public/builders.js +0 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-replay.sh +20 -3
- package/scripts/gds/box.js +79 -86
- package/scripts/gds/db-backup-nightly.sh +17 -4
- package/scripts/gds/db-backup-offsite.sh +23 -6
- package/scripts/gds/discipline-modes.json +1 -1
- package/scripts/gds/doc-cli-guard.js +34 -2
- package/scripts/gds/fitness.js +8 -4
- package/scripts/gds/gds-db-backup.service +15 -4
- package/scripts/gds/gds-db-backup.timer +12 -1
- package/scripts/gds/gds-db-offsite.service +16 -5
- package/scripts/gds/gds-db-offsite.timer +12 -1
- package/scripts/gds/go-live.js +142 -48
- package/scripts/gds/instance-apex.js +28 -0
- package/scripts/gds/newcomer-floor.templates.json +2 -2
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/ship-deploy-target.js +388 -0
- package/scripts/gds/ship-finish.js +377 -0
- package/scripts/gds/ship-flow.js +814 -0
- package/scripts/gds/ship-git.js +100 -0
- package/scripts/gds/ship-grade-steps.js +376 -0
- package/scripts/gds/ship-grade.js +319 -0
- package/scripts/gds/ship-io.js +100 -0
- package/scripts/gds/ship-land.js +539 -0
- package/scripts/gds/ship-memory-sync.js +192 -0
- package/scripts/gds/ship-merge.js +297 -0
- package/scripts/gds/ship-preflight-steps.js +323 -0
- package/scripts/gds/ship-regen.js +286 -0
- package/scripts/gds/ship-resume.js +151 -0
- package/scripts/gds/ship-session-upload.js +216 -0
- package/scripts/gds/ship-state.js +73 -0
- package/scripts/gds/ship-surprise.js +84 -0
- package/scripts/gds/ship.js +105 -4195
- package/scripts/gds/update-channel.js +12 -0
- package/scripts/status-probe.mjs +30 -2
- package/src/module-api.js +5 -1
- package/tests/go_live.mjs +65 -32
- package/tests/helpers.mjs +13 -0
- package/tests/provision.mjs +7 -1
- package/tests/regrade_eligibility.mjs +43 -0
- package/tests/ship_assurance.mjs +4 -2
- package/tests/ship_publish_error.mjs +43 -0
- package/tests/update_channel.mjs +33 -0
package/scripts/gds/ship.js
CHANGED
|
@@ -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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
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
|
-
//
|
|
4062
|
-
//
|
|
4063
|
-
//
|
|
4064
|
-
//
|
|
4065
|
-
//
|
|
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
|
-
//
|
|
4070
|
-
//
|
|
4071
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
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;
|