@bongos/core 1.19.319 → 1.19.321
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/dev-box/box-access.js +20 -0
- 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-ratchets.js +54 -6
- 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/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/status-probe.mjs +30 -2
- package/src/module-api.js +5 -1
- package/tests/box_access.mjs +46 -0
- package/tests/fitness_ratchets.mjs +82 -0
- 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
|
@@ -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
|
+
};
|