@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,151 @@
1
+ // scripts/gds/ship-resume.js — the claim-less resume + re-grade eligibility decisions and resumeMerge.
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 3353-3477 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 { cliClient } = require('./cli-lib');
12
+ const { preflight } = require('./ship-preflight-steps.js');
13
+ const { playShipBell } = require('./ship-io.js');
14
+ const { strandNextStep } = require('./ship-land.js');
15
+ const { mergeAndDeploy, onboardingNudgeBestEffort, postShipSync, printCompletionCard } = require('./ship-finish.js');
16
+
17
+ // task 1003221 — `pushMode` ('server'|'local') so the 'confirmed' answer names a
18
+ // command the caller can ACTUALLY run: it hardcoded `/merge-mode`, which needs gh +
19
+ // a main checkout (SEC #863) no server-mediated builder has. Defers to
20
+ // strandNextStep; defaults to 'local' so an old caller is unchanged.
21
+ function evaluateRegradeEligibility({ task, lastClaim, builder, pushMode = 'local' }) {
22
+ if (!task) {
23
+ return { ok: false, code: 'task_not_found', reason: 'Task not found.' };
24
+ }
25
+ const status = task.status;
26
+ if (status !== 'completed') {
27
+ let reason;
28
+ if (status === 'confirmed') {
29
+ reason = `Task #${task.id} is already 'confirmed' — its grade passed. Nothing to re-grade. `
30
+ + `If it hasn't shipped: ${strandNextStep(task.id, pushMode)}`;
31
+ } else if (status === 'shipped') {
32
+ reason = `Task #${task.id} is already 'shipped'. Done.`;
33
+ } else if (status === 'active' || status === 'ready') {
34
+ 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'.`;
35
+ } else {
36
+ reason = `Task #${task.id} is '${status}'. --regrade only applies to a task stuck at 'completed' after a failed grade.`;
37
+ }
38
+ return { ok: false, code: 'not_completed', reason, status };
39
+ }
40
+ const isArchon = !!builder && builder.rank === 'archon';
41
+ const owns = !!lastClaim && !!builder && Number(lastClaim.builder_id) === Number(builder.id);
42
+ if (!isArchon && !owns) {
43
+ const holder = lastClaim?.github_login ? `@${lastClaim.github_login}` : 'another builder';
44
+ return {
45
+ ok: false,
46
+ code: 'not_owner',
47
+ reason: `Task #${task.id} was last claimed by ${holder}. Only that builder or an Archon can re-grade it.`,
48
+ };
49
+ }
50
+ return {
51
+ ok: true,
52
+ code: 'eligible',
53
+ via: owns ? 'owner' : 'archon',
54
+ reason: owns ? 'you last held this claim' : 'archon re-grade',
55
+ };
56
+ }
57
+
58
+ // task 941 (V4.R03 — idempotent ship re-drive): decide what a CLAIM-LESS
59
+ // `ship.js <id>` re-run should do. The claim is consumed at the resolve step, so
60
+ // a ship that fails AFTER it (grade fail → stuck `completed`; merge/deploy fail →
61
+ // stuck `confirmed`, the task-1027 nightmare) left a re-run dead-ending at "you
62
+ // don't have an active claim" — the builder had to know the `--regrade` flag (and
63
+ // for `confirmed` there was NO path at all). This makes a PLAIN re-run resume
64
+ // from the stalled leg: `completed` → re-grade, `confirmed` → re-merge/deploy.
65
+ // Pure + exported so the truth table is unit-tested with no git/network. Returns
66
+ // { action, via?, reason? } where action ∈
67
+ // resume-grade | resume-merge | done | claim-first | error
68
+ function holderLabel(lastClaim) {
69
+ return lastClaim?.github_login ? `@${lastClaim.github_login}` : 'another builder';
70
+ }
71
+ function evaluateResumeAction({ task, lastClaim, builder }) {
72
+ if (!task) return { action: 'error', reason: 'Task not found.' };
73
+ const status = task.status;
74
+ if (status === 'shipped') {
75
+ return { action: 'done', reason: `Task #${task.id} is already 'shipped'. Nothing to do.` };
76
+ }
77
+ const isArchon = !!builder && builder.rank === 'archon';
78
+ const owns = !!lastClaim && !!builder && Number(lastClaim.builder_id) === Number(builder.id);
79
+ if (status === 'completed' || status === 'confirmed') {
80
+ const leg = status === 'completed' ? 'grade' : 'merge';
81
+ if (!isArchon && !owns) {
82
+ return {
83
+ action: 'error',
84
+ reason: `Task #${task.id} is at '${status}' but was last claimed by ${holderLabel(lastClaim)}. ` +
85
+ `Only that builder or an Archon can resume the ${leg}.`,
86
+ };
87
+ }
88
+ return {
89
+ action: status === 'completed' ? 'resume-grade' : 'resume-merge',
90
+ via: owns ? 'owner' : 'archon',
91
+ };
92
+ }
93
+ if (status === 'active') {
94
+ return {
95
+ action: 'claim-first',
96
+ reason: `Task #${task.id} is 'active' (claimed) but not in THIS session. Ship from the session that ` +
97
+ `claimed it, or \`node scripts/gds/release.js ${task.id} --force\` here, then re-claim.`,
98
+ };
99
+ }
100
+ if (status === 'ready' || status === 'backlog' || status === 'blocked') {
101
+ return {
102
+ action: 'claim-first',
103
+ reason: `Task #${task.id} is '${status}' — claim it first: \`node scripts/gds/claim.js ${task.id}\`.`,
104
+ };
105
+ }
106
+ return { action: 'error', reason: `Task #${task.id} is '${status}'. Nothing to resume.` };
107
+ }
108
+
109
+ // task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
110
+ // (it already passed the grade + earned credits; this is the leg that stalls when
111
+ // a box can't reach the server publish, a poll window expires, etc.). Mirrors the
112
+ // tail of a normal ship: preflight → mergeAndDeploy → post-ship sync.
113
+ async function resumeMerge(taskId, { summary, skipMerge, force, task, dbOnly = false }) {
114
+ console.log(`Resuming merge + deploy for task #${taskId}: ${task?.title || ''}`);
115
+ // Same working-tree-clean / branch-freshness guard a first ship runs before
116
+ // the land. head_sha is unused on this leg; touches is only for preflight.
117
+ const claimLike = { touches: task?.touches || [], head_sha: null, task_id: taskId, title: task?.title };
118
+ console.log('Preflight (working tree + branch freshness)...');
119
+ const pre = preflight(claimLike, { force });
120
+ if (!pre.ok) {
121
+ console.error('');
122
+ console.error(`Resume aborted: ${pre.reason}. Task stays at confirmed.`);
123
+ process.exit(1);
124
+ }
125
+ // The first ship stored value_summary; reuse it so the merge commit + broadcast
126
+ // carry meaningful text even when --summary is omitted on the re-run.
127
+ const effSummary = summary || task?.value_summary || '';
128
+ const land = await mergeAndDeploy({ taskId, summary: effSummary, advanceToMerge: true, skipMerge, dbOnly });
129
+ const usage = await postShipSync(taskId, 'confirmed');
130
+ console.log('');
131
+ console.log('Done — merge/deploy resume complete.');
132
+ playShipBell(); // task 1442: ring the bell when a stalled ship finally lands.
133
+ await onboardingNudgeBestEffort();
134
+ // task 1275: the resume path lands a stalled ship — show the completion card
135
+ // too (the gap surfaced on task 1270's own ship). Re-fetch the status so the
136
+ // card reflects whether it actually reached shipped. Best-effort.
137
+ try {
138
+ const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
139
+ const status = (t.ok && t.data && t.data.task) ? t.data.task.status : 'shipped';
140
+ await printCompletionCard({
141
+ taskId, postGradeStatus: status, usage, land,
142
+ card: { title: task?.title, valueSummary: effSummary, notes: '', creditsAwarded: null },
143
+ });
144
+ } catch (_) { /* the resume already succeeded; the card is a bonus */ }
145
+ }
146
+
147
+ module.exports = {
148
+ evaluateRegradeEligibility,
149
+ evaluateResumeAction,
150
+ resumeMerge,
151
+ };
@@ -0,0 +1,216 @@
1
+ // scripts/gds/ship-session-upload.js — session digest, transcript upload + the ADR 0022 scrub/skip bookkeeping.
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 366-559 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 path = require('node:path');
13
+ const { cliClient, loadSession } = require('./cli-lib');
14
+ const { claudeConfigBase, encodeProjectPath, resolveTranscriptPath } = require('./ship-memory-sync.js');
15
+ const { apiErrorLine } = require('./ship-io.js');
16
+ const { REPO_ROOT } = require('./ship-state.js');
17
+
18
+ // ---------- Session upload-on-ship (6D.1 / ADR 0027) ----------
19
+ //
20
+ // After a successful ship, upload a COMPACT, secret-scrubbed digest of this
21
+ // Claude Code session (POST /api/gds/sessions), so the BFG (a local Claude Code
22
+ // routine — 6D.3) can later search the corpus (6D.2) and deep-dive the wasteful
23
+ // sessions. We never upload raw transcript CONTENT — only per-turn metadata
24
+ // (role, tool name, error flag, token usage, length) + short scrubbed snippets
25
+ // + token aggregates. That keeps the row small and the secret surface minimal
26
+ // (ADR 0027). BEST-EFFORT: any failure is a warning, never a ship failure —
27
+ // exactly like syncMemoryOnShip.
28
+
29
+ // The transcript-grade secret scrubber + its ADR-0022 internals now live in
30
+ // modules/security/secret-scrub.js — extracted (task 1211 / GDS-V4 R72) so the
31
+ // public-mirror export pipeline can reuse the ONE copy of the secret shapes
32
+ // without requiring ship.js (whose grader / Discord / sound / session deps are
33
+ // absent in a bare CI job). Imported + re-exported here so this file's public
34
+ // surface and tests/scrubber_corpus.mjs are unchanged. See that module for the
35
+ // recall-first design (a missed secret is a compromise; over-redaction is cosmetic).
36
+ const {
37
+ REDACTED, scrubSecrets, shannonEntropy, looksHighEntropySecret,
38
+ redactHighEntropy, collectEnvSecrets,
39
+ } = require('../../modules/security/secret-scrub');
40
+
41
+ // snippetOf + buildSessionDigest moved to ./session-digest-build (task 1003217,
42
+ // serving the de-monolith goal of task 1003203) — pure, separately tested, and
43
+ // shared with session-digest.js. Re-exported below so this file's public surface
44
+ // and tests/session_records.mjs are unchanged.
45
+ const {
46
+ snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
47
+ } = require('./session-digest-build');
48
+
49
+
50
+ // classifyUploadSkip — PURE decision for whether the session upload must skip,
51
+ // and the clear, actionable reason why. Separated from I/O so it is unit-tested
52
+ // (tests/ship_upload_skip.mjs). Every reason names the cause AND states the
53
+ // consequence plainly: a skip means this ship is absent from the BFG session
54
+ // corpus — the silent gap #608 surfaced, this (#617) makes it loud + auditable.
55
+ // Takes already-resolved preconditions so it stays free of env + fs reads.
56
+ function classifyUploadSkip({ sessionId, sessionIdSafe, transcriptPath, pathsTried }) {
57
+ const tail = 'This ship will NOT appear in the BFG session corpus.';
58
+ if (!sessionId) {
59
+ return {
60
+ skip: true,
61
+ code: 'no_session_id',
62
+ reason:
63
+ 'CLAUDE_CODE_SESSION_ID is not set — this ship did not run inside an interactive Claude Code ' +
64
+ `session (e.g. an autonomous / merge-mode / cron context), so there is no transcript to digest. ${tail}`,
65
+ };
66
+ }
67
+ if (!sessionIdSafe) {
68
+ return {
69
+ skip: true,
70
+ code: 'unsafe_session_id',
71
+ reason: `CLAUDE_CODE_SESSION_ID is not a plain token, refusing to interpolate it into a filesystem path. ${tail}`,
72
+ };
73
+ }
74
+ if (!transcriptPath) {
75
+ const where = Array.isArray(pathsTried) && pathsTried.length ? ` Looked in: ${pathsTried.join(' ; ')}.` : '';
76
+ return {
77
+ skip: true,
78
+ code: 'transcript_not_found',
79
+ reason: `the session transcript .jsonl was not found for ${sessionId}.${where} ${tail}`,
80
+ };
81
+ }
82
+ return { skip: false };
83
+ }
84
+
85
+ // recordUploadSkip — surface a skipped/failed session upload LOUDLY (not the old
86
+ // quiet console.log that scrolled past + vanished in non-interactive runs) AND
87
+ // leave a durable, append-only trace so a missed capture is auditable after the
88
+ // fact. ~/.config/otb survives worktree churn (same home as the GDS session
89
+ // token). Best-effort: never throws into the ship path. opts.dir overrides the
90
+ // trace dir for tests. Returns the trace file path written, or null.
91
+ function recordUploadSkip(taskId, { code, reason }, opts = {}) {
92
+ console.log(` ⚠️ session upload SKIPPED [${code}]: ${reason}`);
93
+ try {
94
+ const dir = opts.dir || require('../../src/instance-config').configHome();
95
+ fs.mkdirSync(dir, { recursive: true });
96
+ const file = path.join(dir, 'session-upload-skips.jsonl');
97
+ const row = {
98
+ ts: new Date().toISOString(),
99
+ task_id: taskId != null && Number.isFinite(Number(taskId)) ? Number(taskId) : null,
100
+ session_id: process.env.CLAUDE_CODE_SESSION_ID || null,
101
+ code,
102
+ reason,
103
+ };
104
+ fs.appendFileSync(file, JSON.stringify(row) + '\n');
105
+ return file;
106
+ } catch (e) {
107
+ console.log(` (could not persist skip trace: ${e && e.message ? e.message : e})`);
108
+ return null;
109
+ }
110
+ }
111
+
112
+ async function uploadSessionOnShip(taskId) {
113
+ try {
114
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
115
+ // Path-traversal guard: the session id is interpolated into a filesystem
116
+ // path below, so reject anything that isn't a plain UUID-ish token. A
117
+ // hostile env value with `../` could otherwise point the read elsewhere.
118
+ const sessionIdSafe = !!sessionId && /^[A-Za-z0-9_-]+$/.test(sessionId);
119
+ // The transcript is keyed to the session's CWD (the worktree when shipping
120
+ // from one), so try that before REPO_ROOT (#551). Reconstruct the exact
121
+ // paths resolveTranscriptPath checks so a transcript-not-found skip can name
122
+ // precisely where it looked (actionable, not a bare "not found").
123
+ const dirsTried = [process.cwd(), REPO_ROOT].filter(Boolean);
124
+ const pathsTried = sessionIdSafe
125
+ ? dirsTried.map((d) =>
126
+ path.join(claudeConfigBase(), 'projects', encodeProjectPath(path.resolve(d)), `${sessionId}.jsonl`)
127
+ )
128
+ : [];
129
+ const transcriptPath = sessionIdSafe ? resolveTranscriptPath(sessionId, dirsTried) : null;
130
+
131
+ const decision = classifyUploadSkip({ sessionId, sessionIdSafe, transcriptPath, pathsTried });
132
+ if (decision.skip) {
133
+ recordUploadSkip(taskId, decision);
134
+ return null;
135
+ }
136
+ const raw = fs.readFileSync(transcriptPath, 'utf8');
137
+ const entries = [];
138
+ for (const line of raw.split('\n')) {
139
+ const t = line.trim();
140
+ if (!t) continue;
141
+ try {
142
+ entries.push(JSON.parse(t));
143
+ } catch (_) {
144
+ /* skip a malformed line — best-effort */
145
+ }
146
+ }
147
+ // Dynamic secrets to scrub: every secret-shaped env VALUE (Apple/MAC_* CI
148
+ // creds, a baked GDS/DO token, GITHUB_TOKEN, ANTHROPIC_API_KEY, …) plus this
149
+ // run's live GDS session token. Defense in depth on top of the pattern +
150
+ // entropy scrubbers — these literals get redacted even with no known shape.
151
+ const extraSecrets = collectEnvSecrets();
152
+ // loadSession() returns null when there's no session — unlike
153
+ // requireSession(), it never process.exit()s, so the best-effort /
154
+ // non-blocking contract holds even with no valid session.
155
+ const s = await loadSession();
156
+ if (s && s.token) extraSecrets.push(s.token);
157
+ const digest = buildSessionDigest(entries, { extraSecrets });
158
+ // task 1270: surface the session usage so the completion card can show
159
+ // Claude tokens + an estimated cost. Returned even when the POST below fails
160
+ // (we already have the digest) — the card is best-effort either way.
161
+ const usageOut = {
162
+ totalTokens: digest.totalTokens,
163
+ modelUsage: (digest.metrics && digest.metrics.model_usage) || {},
164
+ };
165
+ // drachmae_earned is computed SERVER-SIDE from the credit_log ledger
166
+ // (POST /sessions). ship.js no longer fetches or sends it — that removes an
167
+ // extra apiCall whose 401 path would process.exit(2) and break this
168
+ // best-effort, non-blocking contract.
169
+ const payload = {
170
+ session_id: sessionId,
171
+ task_ids: [Number(taskId)].filter(Number.isFinite),
172
+ total_tokens: digest.totalTokens,
173
+ duration_seconds: digest.durationSeconds,
174
+ started_at: digest.startedAt,
175
+ ended_at: digest.endedAt,
176
+ detail: digest.detail,
177
+ metrics: digest.metrics,
178
+ model_usage: digest.metrics.model_usage || {}, // per-model tokens → cost-plus reward (task 1005)
179
+ };
180
+ const r = await (await cliClient({ allowUnauthenticated: true })).sessions.postSessions({ body: payload });
181
+ if (r.ok) {
182
+ console.log(
183
+ ` 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).`
184
+ );
185
+ } else if (r.status === 401) {
186
+ recordUploadSkip(taskId, {
187
+ code: 'session_invalid',
188
+ reason:
189
+ 'POST /api/gds/sessions returned 401 — the GDS session token is missing or invalid, so the ' +
190
+ 'digest was rejected. Re-auth with /builder-reauth. This ship will NOT appear in the BFG session corpus.',
191
+ });
192
+ } else {
193
+ recordUploadSkip(taskId, {
194
+ code: `http_${r.status}`,
195
+ reason:
196
+ `POST /api/gds/sessions returned ${r.status}${r.data && r.data.error ? ` (${apiErrorLine(r.data)})` : ''} — ` +
197
+ 'the API rejected or could not store the digest. This ship will NOT appear in the BFG session corpus.',
198
+ });
199
+ }
200
+ return usageOut;
201
+ } catch (e) {
202
+ recordUploadSkip(taskId, {
203
+ code: 'exception',
204
+ reason:
205
+ `session upload threw before completing: ${e && e.message ? e.message : e}. ` +
206
+ 'This ship will NOT appear in the BFG session corpus.',
207
+ });
208
+ return null;
209
+ }
210
+ }
211
+
212
+ module.exports = {
213
+ classifyUploadSkip,
214
+ recordUploadSkip,
215
+ uploadSessionOnShip,
216
+ };
@@ -0,0 +1,73 @@
1
+ // scripts/gds/ship-state.js — the handful of process-wide facts the ship phases
2
+ // share: two tuning constants, the repo root auto-merge runs in, the cosmetic
3
+ // sound hook, and the instance's reward policy.
4
+ //
5
+ // Carved out of scripts/gds/ship.js by task 1003204's sibling task 1003205 (goal
6
+ // 1000079, the R03 de-monolith plan). ship.js was 4,613 lines; it is now a
7
+ // facade over ship-*.js phase modules and this shared base.
8
+ //
9
+ // WHY A GETTER FOR serverReward. It is the one piece of MUTABLE module state the
10
+ // carve had to cross a file boundary: `noteServerReward` writes it when a /me
11
+ // lands, and two later phases (the credits print in ship-grade-steps, the
12
+ // completion card in ship-finish) read it. A plain `module.exports.serverReward`
13
+ // would export the value captured at require time — always null — and the ship
14
+ // would silently claim "not paid" on a paying instance. Read it through
15
+ // getServerReward() and the reassignment is visible to every phase, exactly as
16
+ // it was when all of this lived in one file.
17
+
18
+ 'use strict';
19
+
20
+ const path = require('node:path');
21
+ const { findMainWorktree } = require('../../modules/lifecycle/ship-preflight');
22
+
23
+ // The instance's reward policy, as reported by the server on GET /me (ADR 0146 /
24
+ // task 1002469). Cached module-level because the credits print + the completion
25
+ // card are reached through several call chains and the mode is a single
26
+ // instance-wide fact — threading it through each would be noise. Stays null
27
+ // until a /me lands; rewardPolicy(null) then degrades to the paying default, so
28
+ // a missed fetch under-explains rather than wrongly claiming "not paid".
29
+ let serverReward = null;
30
+ function noteServerReward(meRes) {
31
+ const r = meRes && meRes.ok && meRes.data && meRes.data.reward;
32
+ if (r && typeof r.mode === 'string') serverReward = r;
33
+ return meRes;
34
+ }
35
+ function getServerReward() {
36
+ return serverReward;
37
+ }
38
+
39
+ // V3.R18 (#209): cap the diff we feed the Opus subagent grader. Very large
40
+ // diffs blow up prompt size for marginal grading benefit — the subagent has
41
+ // Read access to the post-resolve working tree anyway, so it can pull the
42
+ // full text of any file it wants to inspect closely.
43
+ const GRADER_DIFF_CHAR_CAP = 250_000;
44
+ const GRADER_TIMEOUT_MS = 240_000; // 4 min — Opus on a big diff with Read tool calls
45
+
46
+ // Where auto-merge runs — the worktree that has `main` checked out, not the
47
+ // feature worktree this script lives in. Falls back to two-levels-up if we
48
+ // can't ask git (e.g. fresh clone with no `main` worktree set up yet).
49
+ const REPO_ROOT = findMainWorktree(__dirname) || path.resolve(__dirname, '..', '..');
50
+
51
+ // Sound playback is COSMETIC — the ship pipeline must NEVER depend on it. Guard
52
+ // the require so a missing/broken player (e.g. .claude/sounds/ absent on a fresh
53
+ // clone or after a re-seed) degrades to a silent no-op instead of crashing ship.js
54
+ // at load. A cosmetic feature can't be allowed to break shipping (task 1002199).
55
+ const { playSound } = (() => {
56
+ try {
57
+ return require(path.resolve(__dirname, '../../.claude/sounds/play.js'));
58
+ } catch {
59
+ return { playSound: () => {} };
60
+ }
61
+ })();
62
+
63
+ const BEHIND_MAIN_LIMIT = 5;
64
+
65
+ module.exports = {
66
+ noteServerReward,
67
+ getServerReward,
68
+ GRADER_DIFF_CHAR_CAP,
69
+ GRADER_TIMEOUT_MS,
70
+ REPO_ROOT,
71
+ playSound,
72
+ BEHIND_MAIN_LIMIT,
73
+ };
@@ -0,0 +1,84 @@
1
+ // scripts/gds/ship-surprise.js — surprise / learning-capture prompt on ship (task #32).
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 132-196 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 readline = require('node:readline');
12
+ const { cliClient } = require('./cli-lib');
13
+ const { computeChangedFiles, computeGraderBaseline } = require('./ship-grade.js');
14
+
15
+ // ---------- Surprise / learning-capture helpers (Task #32) ----------
16
+ //
17
+ // Forced "anything surprise you?" gate so we never miss a learning. The flag
18
+ // can be supplied explicitly (--surprise "text" / --surprise none / --no-surprise),
19
+ // prompted interactively when stdin is a TTY, or default to no-surprise with a
20
+ // stderr warning in non-interactive contexts.
21
+
22
+ function promptSurprise() {
23
+ return new Promise((resolve) => {
24
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
+ rl.question('Anything surprise you in this work? (Enter for none, or describe): ', (answer) => {
26
+ rl.close();
27
+ resolve(answer == null ? '' : String(answer).trim());
28
+ });
29
+ });
30
+ }
31
+
32
+ function deriveTagsFromPaths(paths) {
33
+ // Each entry is a path/glob; take its leading folder segment as a tag.
34
+ // touches[] cleanup [3/8] (task 878): renamed from deriveTagsFromTouches — it
35
+ // now runs over the committed changed-files (reality), not the predicted
36
+ // touches[]. Same folder-segment logic, just a more honest input.
37
+ const tags = new Set();
38
+ for (const t of paths || []) {
39
+ if (!t) continue;
40
+ const first = String(t).split('/')[0].trim();
41
+ if (first && first !== '*' && first !== '**') tags.add(first);
42
+ }
43
+ if (tags.size === 0) tags.add('pms');
44
+ return [...tags];
45
+ }
46
+
47
+ async function captureSurpriseLearning(surpriseText, claim, taskId) {
48
+ const text = String(surpriseText).trim();
49
+ const title = text.length > 80 ? text.slice(0, 77) + '...' : text;
50
+ // touches[] cleanup [3/8] (task 878): tag + relate the learning by what was
51
+ // ACTUALLY shipped (the committed diff), not the predicted touches[]. Fall
52
+ // back to touches[] only when the diff is unavailable (no git / empty baseline).
53
+ const baseline = computeGraderBaseline(claim);
54
+ const changedFiles = computeChangedFiles(baseline);
55
+ const relatedFiles = changedFiles.length > 0 ? changedFiles : (claim.touches || []);
56
+ const tags = deriveTagsFromPaths(relatedFiles);
57
+ const r = await (await cliClient()).learnings.postLearnings({
58
+ body: {
59
+ title,
60
+ body_md: text,
61
+ tags,
62
+ related_files: relatedFiles,
63
+ source: 'ship-surprise',
64
+ source_ref: `task-${taskId}-claim-${claim.claim_id}`,
65
+ task_id: taskId,
66
+ },
67
+ });
68
+ if (r.status === 200 && r.data?.skipped) {
69
+ console.log(`📝 Learning already captured for this claim (skipped duplicate).`);
70
+ return true;
71
+ }
72
+ if (r.status !== 201) {
73
+ console.error(` learning capture failed (${r.status}):`, r.data);
74
+ return false;
75
+ }
76
+ console.log(`📝 Captured learning: ${title}`);
77
+ return true;
78
+ }
79
+
80
+ module.exports = {
81
+ captureSurpriseLearning,
82
+ deriveTagsFromPaths,
83
+ promptSurprise,
84
+ };