@bongos/core 1.19.318 → 1.19.320

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.bongos-core.json +125 -40
  2. package/.claude/skills/merge-mode/SKILL.md +3 -1
  3. package/.devcontainer/init-firewall.sh +41 -5
  4. package/clients/bongos-client/examples/hello-world.mjs +11 -2
  5. package/docs/copy-inventory.md +20 -20
  6. package/docs/copy-registry.json +22 -22
  7. package/modules/hall-ui/public/builders.js +0 -1
  8. package/package-lock.json +2 -2
  9. package/package.json +1 -1
  10. package/scripts/gds/audit-replay.sh +20 -3
  11. package/scripts/gds/box.js +79 -86
  12. package/scripts/gds/db-backup-nightly.sh +17 -4
  13. package/scripts/gds/db-backup-offsite.sh +23 -6
  14. package/scripts/gds/discipline-modes.json +1 -1
  15. package/scripts/gds/doc-cli-guard.js +34 -2
  16. package/scripts/gds/fitness.js +8 -4
  17. package/scripts/gds/gds-db-backup.service +15 -4
  18. package/scripts/gds/gds-db-backup.timer +12 -1
  19. package/scripts/gds/gds-db-offsite.service +16 -5
  20. package/scripts/gds/gds-db-offsite.timer +12 -1
  21. package/scripts/gds/go-live.js +142 -48
  22. package/scripts/gds/instance-apex.js +28 -0
  23. package/scripts/gds/newcomer-floor.templates.json +2 -2
  24. package/scripts/gds/provision.js +7 -3
  25. package/scripts/gds/ship-deploy-target.js +388 -0
  26. package/scripts/gds/ship-finish.js +377 -0
  27. package/scripts/gds/ship-flow.js +814 -0
  28. package/scripts/gds/ship-git.js +100 -0
  29. package/scripts/gds/ship-grade-steps.js +376 -0
  30. package/scripts/gds/ship-grade.js +319 -0
  31. package/scripts/gds/ship-io.js +100 -0
  32. package/scripts/gds/ship-land.js +539 -0
  33. package/scripts/gds/ship-memory-sync.js +192 -0
  34. package/scripts/gds/ship-merge.js +297 -0
  35. package/scripts/gds/ship-preflight-steps.js +323 -0
  36. package/scripts/gds/ship-regen.js +286 -0
  37. package/scripts/gds/ship-resume.js +151 -0
  38. package/scripts/gds/ship-session-upload.js +216 -0
  39. package/scripts/gds/ship-state.js +73 -0
  40. package/scripts/gds/ship-surprise.js +84 -0
  41. package/scripts/gds/ship.js +105 -4195
  42. package/scripts/gds/update-channel.js +12 -0
  43. package/scripts/status-probe.mjs +30 -2
  44. package/src/module-api.js +5 -1
  45. package/tests/go_live.mjs +65 -32
  46. package/tests/helpers.mjs +13 -0
  47. package/tests/provision.mjs +7 -1
  48. package/tests/regrade_eligibility.mjs +43 -0
  49. package/tests/ship_assurance.mjs +4 -2
  50. package/tests/ship_publish_error.mjs +43 -0
  51. package/tests/update_channel.mjs +33 -0
@@ -0,0 +1,192 @@
1
+ // scripts/gds/ship-memory-sync.js — memory sync-on-ship + transcript/model resolution (task #274, V3.R55).
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 197-365 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { cliClient } = require('./cli-lib');
15
+ const { REPO_ROOT } = require('./ship-state.js');
16
+
17
+ // ---------- Memory sync-on-ship (Task #274 / V3.R55) ----------
18
+ //
19
+ // After a successful ship, push the builder's local Claude Code memory dir to
20
+ // the server (POST /api/gds/memory/sync), so a builder's MEMORY.md +
21
+ // feedback_*.md follow them across machines and survive a dead laptop. This is
22
+ // the push half of ADR 0024's cloneable-memory chain; #275 wires the pull side.
23
+ //
24
+ // Convention for locating the memory dir (NO hardcoded identity — CLAUDE.md §10):
25
+ // Claude Code stores per-project memory under
26
+ // <homedir>/.claude/projects/<encoded-project-path>/memory/
27
+ // where <encoded-project-path> is the project's ABSOLUTE path with every '/'
28
+ // and '.' replaced by '-' (e.g. /Users/x/Desktop/Claude-OTB →
29
+ // -Users-x-Desktop-Claude-OTB). We derive the project path from the MAIN
30
+ // worktree root (REPO_ROOT), not this ephemeral feature worktree, because the
31
+ // memory dir is keyed to the canonical project checkout. CLAUDE_CONFIG_DIR
32
+ // overrides the ~/.claude base when set (matches Claude Code's own behavior).
33
+ //
34
+ // Conflict policy: storeMemoryFile is an unconditional UPSERT (last-write-wins,
35
+ // server clock — ADR 0024 §3), so we push every file and let the server
36
+ // overwrite. Smarter mtime-diffing (skip files the server already has newer) is
37
+ // a possible follow-up; for #274 a full push is correct and cheap (KB of md).
38
+ //
39
+ // BEST-EFFORT: any failure here is logged as a warning and swallowed — a memory
40
+ // sync hiccup (dir missing, API down, quota) must NEVER fail or undo a ship.
41
+
42
+ function claudeConfigBase() {
43
+ // Claude Code honors CLAUDE_CONFIG_DIR as the base for its state dir; default
44
+ // is ~/.claude. Resolve via os.homedir() so this works for any user.
45
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
46
+ }
47
+
48
+ function encodeProjectPath(absProjectPath) {
49
+ // Claude Code's encoding: every NON-ALPHANUMERIC char becomes '-'. The old
50
+ // `[/.]` class silently no-op'd on Windows (separator '\', drive ':', spaces),
51
+ // so the transcript was never found, no session_record was written, and the
52
+ // cost-plus reward — the only stream a cost-plus-only instance pays (ADR 0146)
53
+ // — could never book: every Windows ship paid 0 (task 1002471). Same rule as
54
+ // scripts/gds/onboarding-config.js, which already had it right. POSIX output is
55
+ // unchanged for paths whose only specials are '/' and '.'.
56
+ return String(absProjectPath).replace(/[^a-zA-Z0-9]/g, '-');
57
+ }
58
+
59
+ // resolveTranscriptPath — find this session's transcript .jsonl. Claude Code
60
+ // keys the transcript to the project dir of the CWD where the session RUNS, so
61
+ // a session in a git worktree stores it under the WORKTREE-encoded dir, not the
62
+ // main checkout. (6D.1 #540 shipped resolving only REPO_ROOT, which silently
63
+ // missed every worktree session — #551.) We try the session-local dir
64
+ // (process.cwd()) first, then REPO_ROOT as a fallback. Pure (exists + base
65
+ // injectable for tests). Returns the first existing path, or null.
66
+ function resolveTranscriptPath(sessionId, projectDirs, opts = {}) {
67
+ const exists = opts.existsFn || fs.existsSync;
68
+ const base = opts.base || claudeConfigBase();
69
+ for (const dir of projectDirs) {
70
+ if (!dir) continue;
71
+ const p = path.join(base, 'projects', encodeProjectPath(path.resolve(dir)), `${sessionId}.jsonl`);
72
+ if (exists(p)) return p;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // resolveBuilderModel — the REAL model that authored this session's work, read
78
+ // from the session transcript (dominant by output tokens, synthetic turns
79
+ // skipped), so selectGraderModel gets a true cross-model input instead of the
80
+ // ASSUMED_BUILDER_MODEL fallback (task 1002659 — everything graded on the
81
+ // assumed-builder default before this). Best-effort: no session id, no
82
+ // transcript, or any read error → null → the grader assumes, as before.
83
+ function resolveBuilderModel(opts = {}) {
84
+ try {
85
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
86
+ // Same path-traversal guard as uploadSessionOnShip: the id lands in a path.
87
+ if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
88
+ const transcriptPath = resolveTranscriptPath(sessionId, [process.cwd(), REPO_ROOT].filter(Boolean), opts);
89
+ if (!transcriptPath) return null;
90
+ const perModel = new Map();
91
+ for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
92
+ if (!line.trim()) continue;
93
+ let o;
94
+ try { o = JSON.parse(line); } catch (_) { continue; }
95
+ const msg = o && o.message;
96
+ if (!msg || !msg.model || msg.model === '<synthetic>' || !msg.usage) continue;
97
+ perModel.set(msg.model, (perModel.get(msg.model) || 0) + (Number(msg.usage.output_tokens) || 0));
98
+ }
99
+ let best = null;
100
+ let bestTokens = 0;
101
+ for (const [model, tokens] of perModel) {
102
+ if (tokens > bestTokens) { best = model; bestTokens = tokens; }
103
+ }
104
+ return best;
105
+ } catch (_) {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function resolveMemoryDir() {
111
+ // REPO_ROOT is the main worktree (canonical project checkout). The memory dir
112
+ // is keyed to that path, not the per-task feature worktree we're shipping from.
113
+ const encoded = encodeProjectPath(path.resolve(REPO_ROOT));
114
+ return path.join(claudeConfigBase(), 'projects', encoded, 'memory');
115
+ }
116
+
117
+ // Enumerate the memory dir into [{ path, content, mtime }]. `path` is RELATIVE
118
+ // to the memory dir (matches the server's (builder_id, relative-path) key).
119
+ // Recurses so nested memory files (if Claude Code ever nests them) are caught.
120
+ // Returns [] if the dir doesn't exist (not all builders have memory).
121
+ function collectMemoryFiles(memDir) {
122
+ if (!fs.existsSync(memDir)) return [];
123
+ const out = [];
124
+ const walk = (absDir, relPrefix) => {
125
+ let entries;
126
+ try {
127
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
128
+ } catch (_) {
129
+ return;
130
+ }
131
+ for (const ent of entries) {
132
+ const abs = path.join(absDir, ent.name);
133
+ const rel = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
134
+ if (ent.isDirectory()) {
135
+ walk(abs, rel);
136
+ } else if (ent.isFile()) {
137
+ try {
138
+ const content = fs.readFileSync(abs, 'utf8');
139
+ const mtime = fs.statSync(abs).mtime.toISOString();
140
+ // Use POSIX-style relative paths regardless of OS so the server key
141
+ // is stable across machines.
142
+ out.push({ path: rel.split(path.sep).join('/'), content, mtime });
143
+ } catch (_) {
144
+ /* skip unreadable file — best-effort */
145
+ }
146
+ }
147
+ }
148
+ };
149
+ walk(memDir, '');
150
+ return out;
151
+ }
152
+
153
+ async function syncMemoryOnShip() {
154
+ try {
155
+ const memDir = resolveMemoryDir();
156
+ const files = collectMemoryFiles(memDir);
157
+ if (files.length === 0) {
158
+ // Silent-ish: a builder with no memory dir is normal. One quiet line so
159
+ // the absence is visible in the ship log without looking like an error.
160
+ console.log(' memory sync: no local memory dir found — skipping (this is fine).');
161
+ return;
162
+ }
163
+ console.log(` syncing ${files.length} memory file${files.length === 1 ? '' : 's'} to the server...`);
164
+ const r = await (await cliClient({ allowUnauthenticated: true })).memory.postMemorySync({ body: { files } });
165
+ if (r.status === 401) {
166
+ console.log(' memory sync: skipped (session not valid for sync; non-blocking).');
167
+ return;
168
+ }
169
+ const d = r.data || {};
170
+ if (r.ok) {
171
+ const errN = Array.isArray(d.errors) ? d.errors.length : 0;
172
+ console.log(
173
+ ` memory sync: ${d.stored ?? 0} stored, ${d.skipped ?? 0} skipped${errN ? `, ${errN} error(s)` : ''}.`
174
+ );
175
+ } else if (r.status === 413) {
176
+ console.log(` memory sync: quota/size limit hit (${d.error || 'quota'}) — prune memory and re-sync. Non-blocking; ship is complete.`);
177
+ } else {
178
+ console.log(` memory sync: server returned ${r.status} (non-blocking):`, d.error || d);
179
+ }
180
+ } catch (e) {
181
+ // Any error at all — network, fs, anything — must not fail the ship.
182
+ console.log(' memory sync failed (non-blocking):', e && e.message ? e.message : e);
183
+ }
184
+ }
185
+
186
+ module.exports = {
187
+ claudeConfigBase,
188
+ encodeProjectPath,
189
+ resolveBuilderModel,
190
+ resolveTranscriptPath,
191
+ syncMemoryOnShip,
192
+ };
@@ -0,0 +1,297 @@
1
+ // scripts/gds/ship-merge.js — autoMerge — the merge/regen/push/land chain that puts the branch on main.
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 2307-2579 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const path = require('node:path');
12
+ const { cleanupMacFinderDupRefs } = require('../../modules/lifecycle/ship-preflight');
13
+ const mergeLock = require('../../modules/lifecycle/merge-lock');
14
+ const { buildMergeArgv, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
15
+ const { mainIsProtected, pushVia, readDeployConfigRaw, resolveDeployMode, resolveDeployTarget, safeBranding, shouldBailOnFetchFailure } = require('./ship-deploy-target.js');
16
+ const { ciLand, landBailed, landed } = require('./ship-land.js');
17
+ const { runSmoke } = require('./ship-grade.js');
18
+ const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
19
+ const { REPO_ROOT } = require('./ship-state.js');
20
+
21
+ function zeroCommitVerdict(commitsAhead, dbOnly) {
22
+ if (commitsAhead !== '0') return 'has-commits';
23
+ return dbOnly ? 'db-only' : 'refuse';
24
+ }
25
+
26
+ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
27
+ const branch = currentBranch();
28
+ if (!branch || branch === 'main' || branch === 'HEAD') {
29
+ // task 1002723: an --allow-empty / --db-only ship legitimately runs FROM the
30
+ // main checkout (nothing was ever committed), but this bail fired BEFORE the
31
+ // deliberate-empty landing below could — the task parked at confirmed with no
32
+ // branch and no published tip, a shape the server sweep can never prove (the
33
+ // task-1002716 strand). Land it here iff the checkout has zero commits ahead
34
+ // of origin/main; anything else keeps the safe bail.
35
+ if (dbOnly && shellOk('git fetch origin main')
36
+ && zeroCommitVerdict(gitOk('git rev-list origin/main..HEAD --count'), true) === 'db-only') {
37
+ console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) from the main checkout - nothing to merge; advancing to shipped without merge/deploy.');
38
+ return landed({ noArtifact: true, noArtifactReason: 'empty ship from the main checkout — no commits to merge' });
39
+ }
40
+ console.log(` auto-merge: skipped (current branch is ${branch})`);
41
+ // The merge source IS the current branch, so on main there is nothing to
42
+ // merge. Half of the single-checkout catch-22 this task documents: the other
43
+ // half is the "main worktree is on <branch>, not main" bail below.
44
+ return landBailed(`nothing to merge — this checkout is on ${branch}, not a task branch`, '/merge-mode');
45
+ }
46
+
47
+ // Refresh the data-driven diagrams before computing commits-ahead, so a
48
+ // diagram-only refresh still counts as something to deploy.
49
+ regenerateDiagrams();
50
+ // Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
51
+ regenerateRepoMap();
52
+ // Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
53
+ regenerateSessionIndex();
54
+ // Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
55
+ regenerateFileMap();
56
+ // Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
57
+ regenerateApiDocs();
58
+ // ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
59
+ regenerateApiClient();
60
+
61
+ // Resolve the deploy mode ONCE (task 1093 / C9) — it reads config/deploy.json;
62
+ // reused at the fetch-bail check + the ci-land branch below.
63
+ const deployMode = resolveDeployMode();
64
+
65
+ // DB-only / no-op ship detection (task #219). Some legitimate ships have
66
+ // no commits to merge — e.g. a task whose work was a direct SQL write to
67
+ // the production DB. The change is already live; the merge step has
68
+ // nothing to push, merge, or deploy. Short-circuit so the caller's POST
69
+ // /tasks/:id/ship still flips status to shipped instead of stranding the
70
+ // task at confirmed forever.
71
+ // A credential-less dev box can't `git fetch origin main` (private origin, no
72
+ // creds — ADR 0053), but that's only fatal OFF the server-publish path: the
73
+ // box's ADR-0055 path bundles against its LOCAL origin/main and the server tops
74
+ // up main. Without this, the box dead-ends here and strands every ship at
75
+ // confirmed (task 1027). The `!fetchedMain &&` short-circuit keeps the gh-probe
76
+ // (pushVia) off the happy laptop path, so laptop behavior is byte-for-byte.
77
+ const fetchedMain = shellOk('git fetch origin main');
78
+ if (!fetchedMain && shouldBailOnFetchFailure(fetchedMain, deployMode, pushVia())) {
79
+ console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
80
+ return landBailed('could not fetch origin/main', '/merge-mode');
81
+ }
82
+ const commitsAhead = gitOk('git rev-list origin/main..HEAD --count');
83
+ const verdict = zeroCommitVerdict(commitsAhead, dbOnly);
84
+ if (verdict === 'refuse') {
85
+ // task 391 (idea #37): the old code short-circuited ANY zero-commit branch
86
+ // straight to shipped — silently advancing a claim with empty/wrong touches[]
87
+ // or simply uncommitted work, awarding credit for no change. Now a zero-commit
88
+ // ship must be DELIBERATELY declared with --db-only. Fail-safe: the claim is
89
+ // already resolved by this point, so the task stays at confirmed and a plain
90
+ // re-run resumes the merge leg (task 941) once they commit or pass --db-only.
91
+ console.error('');
92
+ console.error(' Ship refused: no commits ahead of main, and --db-only was not passed.');
93
+ console.error(' A zero-commit ship is almost always a mistake (uncommitted work, wrong branch, or empty/incorrect touches[]).');
94
+ console.error(' • If you have local changes, commit them, then re-ship.');
95
+ console.error(' • If this is a DELIBERATE direct-SQL / DB-only ship (the work was a write straight to the prod');
96
+ console.error(' DB — nothing to merge or deploy), re-run with --db-only:');
97
+ console.error(` node scripts/gds/ship.js ${taskId} --db-only`);
98
+ console.error(' The task stays at confirmed; nothing was merged or deployed.');
99
+ return landBailed('no commits ahead of main, and --db-only was not passed', `node scripts/gds/ship.js ${taskId} --db-only`);
100
+ }
101
+ if (verdict === 'db-only') {
102
+ console.log(' auto-merge: deliberate empty ship (--db-only / --allow-empty) - no commits ahead of main; advancing to shipped without merge/deploy.');
103
+ return landed({ noArtifact: true, noArtifactReason: 'empty ship — no commits ahead of main' });
104
+ }
105
+
106
+ // #679 / ADR 0042: in 'ci' deploy mode the operator's machine never merges or
107
+ // SSHes the droplet — ship lands via PR auto-merge and CI deploys. The laptop
108
+ // block below stays the default ('laptop' mode) so this is inert until the
109
+ // deliberate cutover. The DB-only short-circuit above is shared by both modes.
110
+ if (deployMode === 'ci') {
111
+ return await ciLand(taskId, branch, valueSummary);
112
+ }
113
+
114
+ console.log(` pushing ${branch} to origin...`);
115
+ if (!gitArgvOk(['push', 'origin', branch])) {
116
+ console.log(' auto-merge: push failed; task stays at confirmed for /merge-mode');
117
+ return landBailed(`could not push ${branch} to origin`, '/merge-mode');
118
+ }
119
+
120
+ // Serialize the shared-main-worktree merge (#462 C4; #269 C6). Concurrent
121
+ // ships AND manual /merge-mode runs queue on this one lock; if a live holder
122
+ // is still merging past the wait window we bail cleanly to /merge-mode rather
123
+ // than racing its working tree.
124
+ if (!mergeLock.acquire(REPO_ROOT)) {
125
+ console.log(' auto-merge: another active ship or /merge-mode run holds the merge lock; task stays at confirmed for /merge-mode');
126
+ return landBailed('another ship or /merge-mode run holds the merge lock', '/merge-mode');
127
+ }
128
+ let mergedClean = false;
129
+ try {
130
+ // Never touch REPO_ROOT if a parallel session left it mid-merge or dirty —
131
+ // this is the exact collision that previously bailed with the misleading
132
+ // "main is ahead of origin somehow" (#462).
133
+ const busy = mainWorktreeBusy();
134
+ if (busy) {
135
+ console.log(` auto-merge: main worktree busy (${busy}); task stays at confirmed for /merge-mode`);
136
+ return landBailed(`the main worktree is busy (${busy})`, '/merge-mode');
137
+ }
138
+
139
+ console.log(` switching to main worktree (${REPO_ROOT}) for merge...`);
140
+ // Same cleanup as preflight: drop macOS Finder/iCloud `* 2` ref duplicates
141
+ // before fetching, otherwise `git fetch origin` fails with "bad object
142
+ // refs/heads/<name> 2" and the auto-merge bails. Was a recurring 2026-05-10
143
+ // failure mode — every ship of the day hit this and required /merge-mode.
144
+ const dupRefs = cleanupMacFinderDupRefs(REPO_ROOT);
145
+ if (dupRefs.length > 0) {
146
+ console.log(` auto-merge: removed ${dupRefs.length} stray refs (macOS Finder duplicates)`);
147
+ }
148
+ if (!shellOk(`git fetch origin`, { cwd: REPO_ROOT })) {
149
+ console.log(' auto-merge: fetch failed; task stays at confirmed for /merge-mode');
150
+ return landBailed('could not fetch origin in the main worktree', '/merge-mode');
151
+ }
152
+
153
+ // The main worktree must currently have main checked out.
154
+ const onMain = gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT });
155
+ if (onMain !== 'main') {
156
+ console.log(` auto-merge: main worktree is on ${onMain}, not main; task stays at confirmed`);
157
+ // The other half of the single-checkout catch-22: the merge target tree is
158
+ // the one holding the feature branch, so there is nowhere to merge INTO.
159
+ // Name the cure, not just the symptom — this is the bail that used to be
160
+ // invisible behind "landing automatically".
161
+ return landBailed(
162
+ `the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`,
163
+ '/merge-mode',
164
+ );
165
+ }
166
+
167
+ if (!gitArgvOk(['merge', '--ff-only', 'origin/main'], { cwd: REPO_ROOT })) {
168
+ console.log(' auto-merge: main is ahead of origin somehow; task stays at confirmed');
169
+ return landBailed('the local main is ahead of origin/main', '/merge-mode');
170
+ }
171
+
172
+ // #681: build the merge argv and run it WITHOUT a shell. valueSummary is
173
+ // DB-sourced (untrusted) — passing it as a literal argv element means
174
+ // `$(...)`/backticks in it can never be interpreted as a command.
175
+ if (!gitArgvOk(buildMergeArgv(branch, valueSummary), { cwd: REPO_ROOT })) {
176
+ console.log(' auto-merge: merge failed (conflicts?); task stays at confirmed for manual /merge-mode');
177
+ return landBailed(`merging ${branch} into main failed (conflicts?)`, '/merge-mode');
178
+ }
179
+
180
+ console.log(' smoke against post-merge state...');
181
+ if (!runSmoke().clean) {
182
+ console.log(' auto-merge: post-merge smoke failed; task stays at confirmed; investigate');
183
+ return landBailed('post-merge smoke tests failed', '/merge-mode');
184
+ }
185
+
186
+ console.log(' pushing merged main to origin...');
187
+ // SEC #863: route through push-main.js, which carries the
188
+ // OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
189
+ // graded+confirmed pipeline push through while still blocking ad-hoc
190
+ // `git push origin main`. process.execPath (quoted) for the same
191
+ // cross-platform reason as the other node spawns here (#660).
192
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'push-main.js'))}`, { cwd: REPO_ROOT })) {
193
+ console.log(' auto-merge: push failed; task stays at confirmed');
194
+ // task 1002629: a branch-protected main refuses EVERY direct push, so
195
+ // retrying here can never work — resolveDeployMode normally reroutes to
196
+ // the ci PR path before this point; reaching this with protection on
197
+ // means an env-forced 'laptop' or a failed protection probe.
198
+ if (mainIsProtected() === true) {
199
+ 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}`);
200
+ }
201
+ return landBailed('could not push the merged main to origin', '/merge-mode');
202
+ }
203
+
204
+ // Deploy step. The core carries NO deploy target of its own — under the
205
+ // standalone model a core change reaches a live instance via package-core +
206
+ // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
207
+ // instance whose own checkout IS its live server has configured a target
208
+ // (config/deploy.json "sshTarget" or <PREFIX>_DEPLOY_SSH_TARGET), the land is
209
+ // complete at "merged + pushed to main" and the SSH deploy is skipped. (This
210
+ // used to SSH a hardcoded target — the founder instance's old game box, i.e. the
211
+ // WRONG server post-migration. task 1002202.)
212
+ const { sshTarget, healthzUrl } = resolveDeployTarget({
213
+ configRaw: readDeployConfigRaw(),
214
+ branding: safeBranding(),
215
+ });
216
+ if (!sshTarget) {
217
+ console.log(' merged + pushed to main. No deploy target configured, so no SSH deploy —');
218
+ console.log(' expected for the core. To publish to a live instance run the standalone release');
219
+ console.log(' flow: package-core → bongos upgrade (pin-bump), per ADR 0108. (Not an SSH deploy.)');
220
+ } else {
221
+ console.log(` triggering deploy on ${sshTarget}...`);
222
+ // Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it before ssh
223
+ // transmits the command; with the quotes ssh sends the literal `~/deploy.sh`
224
+ // and the REMOTE shell expands it. (Matches /merge-mode SKILL.md:81.)
225
+ if (!shellOk(`ssh -o ConnectTimeout=10 ${sshTarget} "~/deploy.sh"`)) {
226
+ // task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
227
+ // service-restart window (Node not yet bound) and exit non-zero even though
228
+ // the deploy succeeded — which used to false-fail the ship and force a
229
+ // needless /merge-mode. Before concluding failure, re-probe the CONFIGURED
230
+ // /healthz with backoff (curl's own --retry; cross-platform, no sleep) —
231
+ // never a hardcoded host. --retry-all-errors retries connection-refused
232
+ // during the bind window; -f makes a non-200 an error so a 502/503 also
233
+ // retries. If it comes up within the window the deploy really succeeded.
234
+ if (healthzUrl) {
235
+ console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
236
+ if (shellOk(`curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors ${JSON.stringify(healthzUrl)} -o /dev/null`)) {
237
+ console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
238
+ } else {
239
+ console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
240
+ return landBailed('merged to main, but the deploy failed (/healthz never returned 200)', '/merge-mode');
241
+ }
242
+ } else {
243
+ console.log(' auto-merge: ~/deploy.sh returned non-zero and no healthzUrl configured to re-probe; task stays at confirmed');
244
+ return landBailed('merged to main, but ~/deploy.sh returned non-zero (no healthz to re-probe)', '/merge-mode');
245
+ }
246
+ }
247
+ }
248
+
249
+ // Merged + deployed — the feature branch is permanently in main's history
250
+ // (--no-ff merge commit) and no longer needed on origin. Deleting it keeps
251
+ // GitHub's "Create PR" affordance from offering empty PRs against zero-diff
252
+ // branches and prevents builder confusion about whether a branch landed.
253
+ // Local refs/heads ref stays so the worktree on the Mac isn't disturbed
254
+ // (cleanup of stale local refs happens next `git fetch --prune`).
255
+ // Non-blocking: a deploy that succeeded shouldn't be marked failed just
256
+ // because the cleanup push fails (e.g. branch already deleted manually,
257
+ // permission glitch, network blip).
258
+ console.log(` cleaning up: deleting ${branch} on origin...`);
259
+ if (!gitArgvOk(['push', 'origin', '--delete', branch], { cwd: REPO_ROOT })) {
260
+ console.log(` warn: could not delete origin/${branch} — may already be gone (non-blocking)`);
261
+ }
262
+
263
+ console.log(' flipping task to shipped...');
264
+ mergedClean = true;
265
+ return landed();
266
+ } finally {
267
+ // Leave REPO_ROOT clean for the NEXT ship/merge no matter how we exited.
268
+ // A failed merge here wedges everyone else: a conflict leaves the tree
269
+ // mid-merge (MERGE_HEAD + conflicted index → mainWorktreeBusy() rejects all
270
+ // future ships), and a failure AFTER the local merge commit but BEFORE push
271
+ // (post-merge smoke/push/deploy) leaves main ahead-but-unpushed (the next
272
+ // `git merge --ff-only origin/main` then fails with the misleading "main is
273
+ // ahead of origin somehow"). Only normalize on the failure path — on the
274
+ // success path we already pushed, so touching the tree is needless risk.
275
+ // Best-effort: cleanup must never mask the real outcome. This is the
276
+ // clean-failure twin of merge-lock.recoverWorktree() (which handles the
277
+ // crash/stale-reclaim case where we never reach this finally at all).
278
+ if (!mergedClean) {
279
+ try {
280
+ if (gitOk('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT })) {
281
+ shellOk('git merge --abort', { cwd: REPO_ROOT });
282
+ }
283
+ if (gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT }) === 'main') {
284
+ shellOk('git reset --hard origin/main', { cwd: REPO_ROOT });
285
+ }
286
+ } catch (_) {
287
+ /* best-effort cleanup; the lock release below is what matters most */
288
+ }
289
+ }
290
+ mergeLock.release(REPO_ROOT);
291
+ }
292
+ }
293
+
294
+ module.exports = {
295
+ autoMerge,
296
+ zeroCommitVerdict,
297
+ };