@bongos/core 1.19.968 → 1.19.970
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 +55 -30
- package/.claude/skills/builder-cost/SKILL.md +5 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/parallel-goal-waves.md +7 -0
- package/modules/economy/cost-report.js +66 -0
- package/modules/economy/cost-summary-shape.js +13 -1
- package/modules/economy/cost.js +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/abandon.js +8 -4
- package/scripts/gds/cli-lib.js +51 -0
- package/scripts/gds/cost-report.js +63 -0
- package/scripts/gds/demote.js +9 -4
- package/scripts/gds/doctor.js +16 -1
- package/scripts/gds/install-git-hooks.js +72 -5
- package/scripts/gds/login.js +6 -2
- package/scripts/gds/provision-repo.js +36 -3
- package/scripts/gds/release.js +11 -3
- package/scripts/gds/search-ingest-md.js +1 -1
- package/scripts/gds/setup.js +2 -2
- package/scripts/gds/start.js +7 -3
- package/src/module-api.js +1 -1
- package/tests/cli_err_code.mjs +148 -0
- package/tests/cost_report.mjs +83 -0
- package/tests/cost_summary_shape.mjs +17 -1
- package/tests/merge_driver_register.mjs +150 -0
- package/tests/plain_cards.mjs +5 -1
- package/tests/provision.mjs +105 -0
- package/tests/search_ingest.mjs +40 -4
package/scripts/gds/doctor.js
CHANGED
|
@@ -28,6 +28,7 @@ const path = require('node:path');
|
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const { execSync, spawnSync } = require('node:child_process');
|
|
30
30
|
const { cliClient, SESSION_PATH, loadSession } = require('./cli-lib');
|
|
31
|
+
const { classifyMergeDriverState, MERGE_DRIVER_NAME } = require('./install-git-hooks.js');
|
|
31
32
|
const { preflightChecks } = require('./preflight');
|
|
32
33
|
|
|
33
34
|
// ANSI helpers (only if stdout is a TTY; otherwise emit plain text)
|
|
@@ -253,6 +254,20 @@ function classifyPreCommitState({ preCommitPath, present, executable }) {
|
|
|
253
254
|
|
|
254
255
|
function checkGitPlumbing() {
|
|
255
256
|
section('Git plumbing');
|
|
257
|
+
|
|
258
|
+
// The merge-driver section is reported FIRST and unconditionally — it is checked
|
|
259
|
+
// even when core.hooksPath is unset, because the two are independent and the
|
|
260
|
+
// half-written-section wedge (task 1003846) is silent until a merge dies with
|
|
261
|
+
// `fatal: custom merge driver ... lacks command line`. A builder who hits that has
|
|
262
|
+
// no reason to connect it to a setup step that ran days ago, so doctor naming it
|
|
263
|
+
// is the whole point. Early-returning before this line would hide it on exactly
|
|
264
|
+
// the clones most likely to be mis-set-up.
|
|
265
|
+
const md = classifyMergeDriverState({
|
|
266
|
+
name: gitOut(`git config --get merge.${MERGE_DRIVER_NAME}.name`),
|
|
267
|
+
driver: gitOut(`git config --get merge.${MERGE_DRIVER_NAME}.driver`),
|
|
268
|
+
});
|
|
269
|
+
({ ok, warn, fail }[md.status])('git', md.message);
|
|
270
|
+
|
|
256
271
|
const verdict = classifyHooksState({
|
|
257
272
|
hooksPath: gitOut('git config --get core.hooksPath'),
|
|
258
273
|
worktreeRoot: gitOut('git rev-parse --show-toplevel'),
|
|
@@ -438,7 +453,7 @@ function checkRunnerDrift() {
|
|
|
438
453
|
// --------------------------------------------------------------------------
|
|
439
454
|
// Guarded so requiring this file for its pure helpers (tests/doctor_hooks.mjs)
|
|
440
455
|
// does not run the checks, which shell out to git and hit the network.
|
|
441
|
-
module.exports = { classifyHooksState, classifyPreCommitState, runnerDriftRows };
|
|
456
|
+
module.exports = { classifyHooksState, classifyMergeDriverState, classifyPreCommitState, runnerDriftRows };
|
|
442
457
|
|
|
443
458
|
if (require.main === module) (async () => {
|
|
444
459
|
console.log(`${C.bold}GDS Doctor${C.reset} — local-machine health check`);
|
|
@@ -30,21 +30,88 @@ function git(args) {
|
|
|
30
30
|
// the generated files (docs/repo-map.md + the nested CLAUDE.md blocks).
|
|
31
31
|
const MERGE_DRIVER_NAME = 'otb-regen';
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// ORDER MATTERS, and the wrong order is worse than not registering at all (task 1003846).
|
|
34
|
+
//
|
|
35
|
+
// These are two git-config writes, not one atomic operation. If the first succeeds and
|
|
36
|
+
// the second throws, the half-written section SURVIVES — and git does not treat a named
|
|
37
|
+
// driver with no command as "fall back to a text merge". It treats it as fatal:
|
|
38
|
+
//
|
|
39
|
+
// fatal: custom merge driver otb-regen lacks command line.
|
|
40
|
+
//
|
|
41
|
+
// exit 128, no unmerged paths, nothing that looks like a merge conflict. Every merge
|
|
42
|
+
// touching any `merge=otb-regen` path then dies — which is now the root CLAUDE.md, the
|
|
43
|
+
// file/session-log maps, two nested CLAUDE.md files, the copy-registry pair and the nine
|
|
44
|
+
// generated API artifacts. So the failure mode of the old order was "no merge in this
|
|
45
|
+
// clone can complete", where not registering at all merely yields an ordinary conflict.
|
|
46
|
+
//
|
|
47
|
+
// Both halves of the fix are verified, not assumed (see tests/merge_driver_register.mjs):
|
|
48
|
+
// - `.driver` alone IS a valid section — git runs it and the merge succeeds.
|
|
49
|
+
// - `.name` alone is the fatal state above.
|
|
50
|
+
// So writing `.driver` FIRST makes the intermediate state safe, and removing the whole
|
|
51
|
+
// section on failure makes even a mid-write abort leave no trace.
|
|
52
|
+
function registerMergeDriver(root, { run = execFileSync } = {}) {
|
|
34
53
|
// execFileSync (arg array, no shell) so the literal %O/%A/%B/%P placeholders and
|
|
35
54
|
// the spaces in the command survive verbatim on every platform (a shell would
|
|
36
55
|
// mangle %VARS on Windows cmd).
|
|
37
56
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
execFileSync('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
|
|
57
|
+
// .driver FIRST — a section carrying only .driver is valid and works.
|
|
58
|
+
run('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
|
|
41
59
|
'node scripts/gds/git-merge-regen.js %O %A %B %P'], { cwd: root, stdio: 'ignore' });
|
|
60
|
+
// .name SECOND — cosmetic (it is what `git config --list` shows); if this throws,
|
|
61
|
+
// the section left behind is the safe driver-only one.
|
|
62
|
+
run('git', ['config', `merge.${MERGE_DRIVER_NAME}.name`,
|
|
63
|
+
`${require('./cli-lib').productName()} generated-file regenerator (task 1243)`], { cwd: root, stdio: 'ignore' });
|
|
42
64
|
return { ok: true };
|
|
43
65
|
} catch (e) {
|
|
66
|
+
// Belt and braces: leave NO trace rather than a possibly-partial section. A missing
|
|
67
|
+
// section is a normal textual conflict; a partial one can be the exit-128 wedge.
|
|
68
|
+
// This removal is itself best-effort — if it throws (e.g. no section to remove, which
|
|
69
|
+
// git reports as an error) the original failure is still what we report.
|
|
70
|
+
try {
|
|
71
|
+
run('git', ['config', '--remove-section', `merge.${MERGE_DRIVER_NAME}`], { cwd: root, stdio: 'ignore' });
|
|
72
|
+
} catch (_) { /* nothing to clean up, or we cannot — report the original failure */ }
|
|
44
73
|
return { ok: false, error: e.message };
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
|
|
77
|
+
// The doctor-facing verdict for an already-registered driver, pure so it is testable
|
|
78
|
+
// without a git repo (the classifyHooksState precedent in doctor.js).
|
|
79
|
+
//
|
|
80
|
+
// The wedge state is INVISIBLE until the next merge dies, which is exactly why doctor
|
|
81
|
+
// should say so: a builder who hits `fatal: custom merge driver ... lacks command line`
|
|
82
|
+
// has no reason to connect it to a setup step that ran days earlier.
|
|
83
|
+
function classifyMergeDriverState({ name, driver }) {
|
|
84
|
+
const hasName = Boolean(name && String(name).trim());
|
|
85
|
+
const hasDriver = Boolean(driver && String(driver).trim());
|
|
86
|
+
const installer = 'node scripts/gds/install-git-hooks.js';
|
|
87
|
+
|
|
88
|
+
if (hasDriver) {
|
|
89
|
+
return {
|
|
90
|
+
status: 'ok',
|
|
91
|
+
code: 'registered',
|
|
92
|
+
message: `merge driver '${MERGE_DRIVER_NAME}' registered — generated-file conflicts auto-regenerate on merge.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (hasName) {
|
|
96
|
+
return {
|
|
97
|
+
status: 'fail',
|
|
98
|
+
code: 'name-without-driver',
|
|
99
|
+
message:
|
|
100
|
+
`merge.${MERGE_DRIVER_NAME}.name is set but .driver is MISSING. Git treats this as fatal, not as a `
|
|
101
|
+
+ `fallback: every merge touching a \`merge=${MERGE_DRIVER_NAME}\` path will die with `
|
|
102
|
+
+ `"fatal: custom merge driver ${MERGE_DRIVER_NAME} lacks command line" (exit 128, no conflict shown). `
|
|
103
|
+
+ `Repair with: ${installer} — or clear it with: git config --remove-section merge.${MERGE_DRIVER_NAME}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
status: 'warn',
|
|
108
|
+
code: 'unregistered',
|
|
109
|
+
message:
|
|
110
|
+
`merge driver '${MERGE_DRIVER_NAME}' is not registered, so generated-file merges will raise ordinary `
|
|
111
|
+
+ `text conflicts instead of auto-regenerating (ship.js still self-heals). Register with: ${installer}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
48
115
|
// Resolve the configured hooks dir (absolute) for reporting / verification.
|
|
49
116
|
function configuredHooksDir() {
|
|
50
117
|
try {
|
|
@@ -114,4 +181,4 @@ if (require.main === module) {
|
|
|
114
181
|
}
|
|
115
182
|
}
|
|
116
183
|
|
|
117
|
-
module.exports = { activate, configuredHooksDir, registerMergeDriver, MERGE_DRIVER_NAME };
|
|
184
|
+
module.exports = { activate, classifyMergeDriverState, configuredHooksDir, registerMergeDriver, MERGE_DRIVER_NAME };
|
package/scripts/gds/login.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
const readline = require('node:readline');
|
|
24
24
|
const { spawnSync } = require('node:child_process');
|
|
25
|
-
const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
|
|
25
|
+
const { errCode, saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
|
|
26
26
|
// The device-flow refusal detector + its plain-language cure (task 1003881). A leaf
|
|
27
27
|
// module, NOT setup.js: setup requires this file for runDeviceFlow, so requiring it
|
|
28
28
|
// back would close a require cycle. Both callers depend on the leaf instead.
|
|
@@ -104,7 +104,11 @@ async function runDeviceFlow(base, { log, idp = null }) {
|
|
|
104
104
|
// assertion scoped to the instance the user actually typed (ADR 0141 §1).
|
|
105
105
|
...(idp ? { body: JSON.stringify({ origin: base }) } : {}),
|
|
106
106
|
});
|
|
107
|
-
|
|
107
|
+
// ADR 0116 nests the slug at error.code (task 1003658). This site was half-converted:
|
|
108
|
+
// it read the nested shape for idp_signing_unconfigured but kept a FLAT comparison for
|
|
109
|
+
// auth_not_configured, so that arm was dead against a live server. errCode reads both.
|
|
110
|
+
const startCode = errCode(start.data);
|
|
111
|
+
if (start.status === 503 || startCode === 'auth_not_configured' || startCode === 'idp_signing_unconfigured') {
|
|
108
112
|
throw new Error('that instance has no sign-in configured yet — ask its Archon to set it up.');
|
|
109
113
|
}
|
|
110
114
|
// Device Flow is OFF on every GitHub App born from a manifest, and GitHub exposes no
|
|
@@ -871,6 +871,15 @@ function deployKeyPath(inst) { return path.posix.join(standaloneRoot(inst), '.gi
|
|
|
871
871
|
// github.com and core.sshCommand pinned to the key with -F /dev/null so no
|
|
872
872
|
// ~/.ssh/config identity can shadow it. Public repos return untouched; no token →
|
|
873
873
|
// skip (the request-time refusal is task 1002909's preflight). Dry-run plans only.
|
|
874
|
+
//
|
|
875
|
+
// `deps.intendedPrivate` (task 1004167) declares that the repo is ABOUT to be made
|
|
876
|
+
// private, so the wiring runs before the flip rather than after it. Opt-in, and it
|
|
877
|
+
// widens only WHEN we wire: the key stays read-only and repository ADMIN is still
|
|
878
|
+
// required. It also HARDENS the two soft skips — no credential, GitHub unreachable —
|
|
879
|
+
// into refusals, because a caller that has committed to flipping cannot be handed an
|
|
880
|
+
// unwired checkout. NO PRODUCTION CALLER SETS IT YET: the flip route and its
|
|
881
|
+
// provisioning intent are splits (b) + (c) of task 1003078, which need a migration
|
|
882
|
+
// this deliberately does not. The seam ships first so the ordering is settled.
|
|
874
883
|
async function ensurePrivateRepoAccess(inst, deps) {
|
|
875
884
|
const { log, apply } = deps;
|
|
876
885
|
const exec = deps.controlExec || deps.exec;
|
|
@@ -881,11 +890,19 @@ async function ensurePrivateRepoAccess(inst, deps) {
|
|
|
881
890
|
// preflight is apply-gated for the same reason): no token read, no GitHub call —
|
|
882
891
|
// a plan run must stay a safe inspection tool for a snagged instance.
|
|
883
892
|
if (!apply) {
|
|
884
|
-
log(
|
|
893
|
+
log(deps.intendedPrivate === true
|
|
894
|
+
? ` [repo-auth] would treat ${repo.owner}/${repo.name} as ABOUT TO BE MADE PRIVATE (pre-flip) and mint + register a read-only deploy key, wiring origin + core.sshCommand to it before the flip (ADR 0176)`
|
|
895
|
+
: ` [repo-auth] would check ${repo.owner}/${repo.name} visibility with the owner's credential and, if PRIVATE, mint + register a read-only deploy key and wire origin + core.sshCommand to it (ADR 0176)`);
|
|
885
896
|
return { planned: true };
|
|
886
897
|
}
|
|
887
898
|
const ownerToken = await resolveOwnerGithubToken(inst, deps);
|
|
888
899
|
if (!ownerToken) {
|
|
900
|
+
// With an intent declared the caller is ABOUT to flip, so a soft skip hands back
|
|
901
|
+
// an unwired checkout and the flip then breaks the recurring pull for good. The
|
|
902
|
+
// same missing credential is only a warning when nobody has committed to flipping.
|
|
903
|
+
if (deps.intendedPrivate === true) {
|
|
904
|
+
throw new Error(`${repo.owner}/${repo.name} cannot be made private: there is no usable GitHub credential stored for this project, and without one its deploy key cannot be registered — which would break the next deploy. Reconnect the project's GitHub access, then try again.`);
|
|
905
|
+
}
|
|
889
906
|
log(' [repo-auth] no usable owner GitHub credential (none stored, or it could not be read) — a PRIVATE repo would fail at the deploy pull (public repos are unaffected)');
|
|
890
907
|
return { skipped: 'no_token' };
|
|
891
908
|
}
|
|
@@ -898,6 +915,11 @@ async function ensurePrivateRepoAccess(inst, deps) {
|
|
|
898
915
|
// Plain sentence: this rides error_note onto the owner's project card.
|
|
899
916
|
throw new Error(`repository not found: GitHub answered 404 for ${repo.owner}/${repo.name} even with your granted credential — the repository may have been renamed or deleted, or the credential lost access. Check it on GitHub, then try again.`);
|
|
900
917
|
}
|
|
918
|
+
// Same asymmetry as the missing credential above: soft when nothing is committed,
|
|
919
|
+
// hard once the caller has declared the flip.
|
|
920
|
+
if (deps.intendedPrivate === true) {
|
|
921
|
+
throw new Error(`${repo.owner}/${repo.name} cannot be made private right now: GitHub could not be reached to set up its deploy key (${e && e.message}). Nothing has been changed — try again.`);
|
|
922
|
+
}
|
|
901
923
|
log(` [repo-auth] could not read ${repo.owner}/${repo.name} (${e && e.message}) — continuing; a PRIVATE repo would fail at the deploy pull`);
|
|
902
924
|
return { skipped: 'github_unreachable' };
|
|
903
925
|
}
|
|
@@ -907,7 +929,16 @@ async function ensurePrivateRepoAccess(inst, deps) {
|
|
|
907
929
|
const title = deployKeyTitle(inst);
|
|
908
930
|
const fsImpl = deps.fsImpl || fs;
|
|
909
931
|
|
|
910
|
-
|
|
932
|
+
// A flip-to-private must wire BEFORE it flips — a deploy key authenticates fine
|
|
933
|
+
// against a still-public repo, so wiring first leaves no broken window, while
|
|
934
|
+
// flipping first breaks the very next deploy pull. At that moment GitHub still
|
|
935
|
+
// answers `private: false`, so the observed value is the wrong thing to branch on:
|
|
936
|
+
// untouched, this takes the public path and (worse, if a key file exists) actively
|
|
937
|
+
// UN-wires. `intendedPrivate` is the caller declaring where the repo is going.
|
|
938
|
+
// Opt-in: absent, every path below is exactly what it was.
|
|
939
|
+
const treatPrivate = info.private || deps.intendedPrivate === true;
|
|
940
|
+
|
|
941
|
+
if (!treatPrivate) {
|
|
911
942
|
// Flipped private → public since we wired it: un-wire, or the checkout stays
|
|
912
943
|
// pinned (IdentitiesOnly + -F /dev/null admit no other identity) to a key the
|
|
913
944
|
// owner is now free to delete from their repo settings. Keyed on our key file
|
|
@@ -933,7 +964,9 @@ async function ensurePrivateRepoAccess(inst, deps) {
|
|
|
933
964
|
throw new Error(`repository access is not admin: your granted credential can read ${repo.owner}/${repo.name}, but registering its deploy key needs repository ADMIN. Ask a repository admin to raise your access (or make the repository public), then try again.`);
|
|
934
965
|
}
|
|
935
966
|
|
|
936
|
-
log(
|
|
967
|
+
log(info.private
|
|
968
|
+
? ` [repo-auth] ${repo.owner}/${repo.name} is PRIVATE — wiring its own read-only deploy key (ADR 0176)`
|
|
969
|
+
: ` [repo-auth] ${repo.owner}/${repo.name} is ABOUT TO BE MADE PRIVATE — wiring its own read-only deploy key first (ADR 0176)`);
|
|
937
970
|
if (!fsImpl.existsSync(keyPath) || !fsImpl.existsSync(`${keyPath}.pub`)) {
|
|
938
971
|
exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
|
|
939
972
|
exec(`ssh-keygen -q -t ed25519 -N '' -C ${title} -f ${keyPath}`);
|
package/scripts/gds/release.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const { execFileSync } = require('node:child_process');
|
|
10
10
|
const path = require('node:path');
|
|
11
|
-
const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
11
|
+
const { errCode, errDetails, cliClient, requireSession, arg } = require('./cli-lib');
|
|
12
12
|
const { parkedMainCheckoutWarning } = require('./worktree-claim-guard');
|
|
13
13
|
const { plainCardLines } = require('./plain-cards');
|
|
14
14
|
|
|
@@ -96,8 +96,16 @@ function mainCheckoutRoot() {
|
|
|
96
96
|
},
|
|
97
97
|
});
|
|
98
98
|
if (!r.ok) {
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// ADR 0116 nests the slug at error.code AND its payload at error.details — see the
|
|
100
|
+
// note in abandon.js (task 1003658). claimWorktree reaches the wire through
|
|
101
|
+
// `res.fail(err.code, status, { ...err })` in modules/lifecycle/routes/claims.js:612,
|
|
102
|
+
// so it lands in details, not at the top level. Reading it off r.data directly made
|
|
103
|
+
// `wt` undefined and silently dropped the worktree name from the message — the exact
|
|
104
|
+
// half-fix this task exists to avoid, caught by the grader on the first round.
|
|
105
|
+
const code = errCode(r.data);
|
|
106
|
+
const details = errDetails(r.data);
|
|
107
|
+
if (code === 'CLAIM_SESSION_MISMATCH') {
|
|
108
|
+
const wt = details.claimWorktree;
|
|
101
109
|
console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
|
|
102
110
|
console.error('Only the original session can release it.');
|
|
103
111
|
console.error('If that session is gone, re-run with --force to override.');
|
|
@@ -48,7 +48,7 @@ const SENSITIVE_DOC_GLOBS = [
|
|
|
48
48
|
'docs/canonical-permissions.md', // the rank-ladder / authority reference
|
|
49
49
|
'docs/security/', // threat models + red-team reports
|
|
50
50
|
'docs/audits/', // security + architecture audit findings
|
|
51
|
-
'docs/governance/', // the gate ENUMERATION + choke-point map
|
|
51
|
+
'docs/governance/', // the gate ENUMERATION + choke-point map
|
|
52
52
|
'docs/adr/0016-trust-boundary-server-enforced-permissions.md',
|
|
53
53
|
'docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md',
|
|
54
54
|
];
|
package/scripts/gds/setup.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// home directory) and prints the builder profile.
|
|
20
20
|
|
|
21
21
|
const readline = require('readline');
|
|
22
|
-
const { cliClient, saveSession, SESSION_PATH, loadSession, printOnboardingNudge, API_BASE, hallBase, surfaceAction, isTerminalSurface } = require('./cli-lib');
|
|
22
|
+
const { cliClient, errCode, saveSession, SESSION_PATH, loadSession, printOnboardingNudge, API_BASE, hallBase, surfaceAction, isTerminalSurface } = require('./cli-lib');
|
|
23
23
|
|
|
24
24
|
// The instance's status dashboard, from branding — never a hardcoded host, so a
|
|
25
25
|
// non-OTB instance points its builders at ITS OWN dashboard (task 1002428).
|
|
@@ -566,7 +566,7 @@ async function main() {
|
|
|
566
566
|
process.exit(0);
|
|
567
567
|
}
|
|
568
568
|
console.error(`device/start failed (${start.status}):`, start.data);
|
|
569
|
-
if (start.data
|
|
569
|
+
if (errCode(start.data) === 'auth_not_configured') { // ADR 0116 nests the slug (task 1003658)
|
|
570
570
|
console.error('');
|
|
571
571
|
console.error('The server does not yet have GitHub OAuth credentials.');
|
|
572
572
|
console.error('An Archon needs to register the GitHub OAuth App and provision credentials on the server.');
|
package/scripts/gds/start.js
CHANGED
|
@@ -67,7 +67,7 @@ const { checkTree: _checkTree } = require('./tree-preflight.js');
|
|
|
67
67
|
if (_fault) { console.error(_fault); process.exit(1); }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError, cardFlagsFromMe } = require('./cli-lib');
|
|
70
|
+
const { cliClient, errCode, errDetails, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError, cardFlagsFromMe } = require('./cli-lib');
|
|
71
71
|
const { plainCardLines } = require('./plain-cards');
|
|
72
72
|
const { contextPack, formatContextPackCompact } = require('./context-pack');
|
|
73
73
|
const { depsFinding } = require('./preflight');
|
|
@@ -723,9 +723,13 @@ async function main() {
|
|
|
723
723
|
// #766: the primer-read gate on the claimable endpoint was removed, so a
|
|
724
724
|
// newcomer no longer gets a 403 stage_required here. A generic stage_required
|
|
725
725
|
// (any future onboarding gate) still renders cleanly rather than crashing.
|
|
726
|
-
|
|
726
|
+
// ADR 0116 nests the slug and its payload — see the note in cli-lib errCode (task
|
|
727
|
+
// 1003658). The onboarding copy lives in error.details, so reading it off the top
|
|
728
|
+
// level rendered an empty prompt even when the branch fired.
|
|
729
|
+
if (r.status === 403 && errCode(r.data) === 'stage_required') {
|
|
730
|
+
const d = errDetails(r.data);
|
|
727
731
|
console.log('');
|
|
728
|
-
console.log(
|
|
732
|
+
console.log(d.what_to_do || `Clear "${d.required_stage_label || d.required_stage}" before the work queue opens.`);
|
|
729
733
|
for (const line of plainCardLines('stage_required')) console.log(line);
|
|
730
734
|
console.log('');
|
|
731
735
|
process.exit(0);
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.970'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// tests/cli_err_code.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003658 — ADR 0116 standardised every failure on a NESTED envelope:
|
|
4
|
+
//
|
|
5
|
+
// { "error": { "code": "task_not_found", "message": "…", "details": {…} } }
|
|
6
|
+
//
|
|
7
|
+
// so `r.data.error === 'task_not_found'` compares an OBJECT to a STRING and is never
|
|
8
|
+
// true. Each such comparison guarded a specific, helpful message, so against a live
|
|
9
|
+
// server every one of those branches was UNREACHABLE and the user fell through to a raw
|
|
10
|
+
// `(409): [object Object]` dump — a clear server refusal rendered as a tooling crash.
|
|
11
|
+
//
|
|
12
|
+
// NINE sites across SIX scripts now route through errCode/errDetails: abandon x3,
|
|
13
|
+
// release, demote x2, setup, start, login. (login.js was HALF converted — nested for
|
|
14
|
+
// idp_signing_unconfigured, flat for auth_not_configured — so one arm was dead. It was
|
|
15
|
+
// found by the source scan at the bottom of this file, not by reading.)
|
|
16
|
+
//
|
|
17
|
+
// THE HALF-FIX THIS ALSO GUARDS AGAINST. The sibling fields moved WITH the code.
|
|
18
|
+
// demote.js printed `r.data.current_status` beside its message; 0116 put that at
|
|
19
|
+
// `error.details.current_status`. Changing only the comparison restores the branch and
|
|
20
|
+
// then prints "it is 'undefined', not 'ready'" — which looks like a whole fix. Both
|
|
21
|
+
// readers are therefore tested, and both are tested against the REAL shapes observed
|
|
22
|
+
// live on 2026-09-22 rather than shapes we assumed.
|
|
23
|
+
//
|
|
24
|
+
// Run: node --test tests/cli_err_code.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { test } from 'node:test';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const { errCode, errDetails } = require(path.join(REPO_ROOT, 'scripts/gds/cli-lib.js'));
|
|
35
|
+
|
|
36
|
+
// Captured verbatim from cloudbongos.com on 2026-09-22 — not invented.
|
|
37
|
+
const LIVE_ABANDON_SHIPPED = { error: { code: 'cannot_abandon_shipped', message: 'shipped tasks are immutable history' } };
|
|
38
|
+
const LIVE_DEMOTE_REFUSED = { error: { code: 'cannot_demote', message: 'cannot_demote', details: { current_status: 'shipped' } } };
|
|
39
|
+
const LIVE_NOT_FOUND = { error: { code: 'task_not_found', message: 'task_not_found' } };
|
|
40
|
+
|
|
41
|
+
test('THE BUG: the old flat comparison never matches a real envelope', () => {
|
|
42
|
+
// This is the whole finding. If this assertion ever fails, the envelope changed and
|
|
43
|
+
// the fix below is no longer needed — which is worth knowing loudly.
|
|
44
|
+
assert.notEqual(LIVE_ABANDON_SHIPPED.error, 'cannot_abandon_shipped');
|
|
45
|
+
assert.equal(typeof LIVE_ABANDON_SHIPPED.error, 'object',
|
|
46
|
+
'error is an OBJECT — comparing it to a string is why every branch was dead');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('errCode reads the slug out of real observed envelopes', () => {
|
|
50
|
+
assert.equal(errCode(LIVE_ABANDON_SHIPPED), 'cannot_abandon_shipped');
|
|
51
|
+
assert.equal(errCode(LIVE_DEMOTE_REFUSED), 'cannot_demote');
|
|
52
|
+
assert.equal(errCode(LIVE_NOT_FOUND), 'task_not_found');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('errCode still reads a legacy flat string', () => {
|
|
56
|
+
// Older routes, and any non-envelope transport error (a proxy returning a plain body,
|
|
57
|
+
// or a pre-0116 instance an upgraded CLI is talking to) still surface a bare string.
|
|
58
|
+
assert.equal(errCode({ error: 'auth_not_configured' }), 'auth_not_configured');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('errCode returns null rather than throwing on anything unexpected', () => {
|
|
62
|
+
for (const input of [null, undefined, {}, { error: null }, { error: {} }, { error: 42 }, { error: { code: 7 } }, 'a string']) {
|
|
63
|
+
assert.equal(errCode(input), null, `errCode(${JSON.stringify(input)}) must be null, never a throw`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('THE HALF-FIX GUARD: errDetails finds the sibling field 0116 moved', () => {
|
|
68
|
+
assert.equal(errDetails(LIVE_DEMOTE_REFUSED).current_status, 'shipped',
|
|
69
|
+
'reading current_status off the top level yields undefined — the half-fix this prevents');
|
|
70
|
+
assert.equal(LIVE_DEMOTE_REFUSED.current_status, undefined,
|
|
71
|
+
'proving the naive read really is empty, so the guard is not vacuous');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('errDetails falls back to the top-level body for a pre-0116 shape', () => {
|
|
75
|
+
assert.equal(errDetails({ error: 'cannot_demote', current_status: 'shipped' }).current_status, 'shipped');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('errDetails always returns an object, so callers can destructure safely', () => {
|
|
79
|
+
for (const input of [null, undefined, {}, { error: {} }, { error: { details: 'not-an-object' } }]) {
|
|
80
|
+
assert.equal(typeof errDetails(input), 'object');
|
|
81
|
+
assert.notEqual(errDetails(input), null);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('no flat `.error === <string>` comparison survives in scripts/gds', () => {
|
|
86
|
+
// The durable half: this is what stops a sixth script reintroducing the bug. It scans
|
|
87
|
+
// source rather than trusting that the sweep was complete — the filed row said 11
|
|
88
|
+
// sites across 6 scripts, a later correction said 4 across 2, and the truth was 8
|
|
89
|
+
// across 5. A count in prose rots; a scan does not.
|
|
90
|
+
const fs = require('node:fs');
|
|
91
|
+
const dir = path.join(REPO_ROOT, 'scripts/gds');
|
|
92
|
+
const offenders = [];
|
|
93
|
+
for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.js'))) {
|
|
94
|
+
const src = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
95
|
+
src.split('\n').forEach((line, i) => {
|
|
96
|
+
if (/^\s*(\/\/|\*)/.test(line)) return; // commentary may quote the bug
|
|
97
|
+
if (/\.data\??\.error\s*===\s*['"]/.test(line)) offenders.push(`${f}:${i + 1}: ${line.trim()}`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
assert.deepEqual(offenders, [],
|
|
101
|
+
'use errCode(r.data) — a flat comparison against the ADR 0116 envelope is always false');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// End-to-end branch selection (grader finding, round 1).
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// The tests above prove the PRIMITIVE is right. They do not prove each script WIRED it
|
|
108
|
+
// right — a script could call errCode() and still order its if/else wrongly, or read a
|
|
109
|
+
// sibling field off the top level. That second mistake is not hypothetical: the first
|
|
110
|
+
// round of this task shipped exactly it in release.js (`r.data.claimWorktree`, which
|
|
111
|
+
// ADR 0116 moved to error.details), and only the grader caught it.
|
|
112
|
+
//
|
|
113
|
+
// So this block asserts the WIRING, by the one property that distinguishes a real fix
|
|
114
|
+
// from a half fix: for every branch, no sibling field may be read off the top level of
|
|
115
|
+
// the response body. It scans source rather than spawning the CLIs, because spawning
|
|
116
|
+
// them needs a live server and a session — which would make this an integration test
|
|
117
|
+
// that silently self-skips, the failure mode tests/copy_inventory-class suites already
|
|
118
|
+
// demonstrate.
|
|
119
|
+
test('no converted branch reads a sibling payload field off the top level', () => {
|
|
120
|
+
const fs = require('node:fs');
|
|
121
|
+
// The fields ADR 0116 moved into error.details, and the scripts that render them.
|
|
122
|
+
const MOVED = ['claimWorktree', 'current_status', 'what_to_do', 'required_stage_label', 'required_stage'];
|
|
123
|
+
const offenders = [];
|
|
124
|
+
for (const f of ['abandon.js', 'release.js', 'demote.js', 'setup.js', 'start.js', 'login.js']) {
|
|
125
|
+
const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts/gds', f), 'utf8');
|
|
126
|
+
src.split('\n').forEach((line, i) => {
|
|
127
|
+
if (/^\s*(\/\/|\*)/.test(line)) return;
|
|
128
|
+
for (const field of MOVED) {
|
|
129
|
+
if (new RegExp(`\\.data\\??\\.${field}\\b`).test(line)) offenders.push(`${f}:${i + 1}: ${line.trim()}`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
assert.deepEqual(offenders, [],
|
|
134
|
+
'ADR 0116 moved these beside the code into error.details — read them via errDetails(r.data), '
|
|
135
|
+
+ 'or the branch fires and then renders undefined (the half-fix)');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('every script that compares an error code imports errCode', () => {
|
|
139
|
+
const fs = require('node:fs');
|
|
140
|
+
const missing = [];
|
|
141
|
+
for (const f of fs.readdirSync(path.join(REPO_ROOT, 'scripts/gds')).filter((n) => n.endsWith('.js'))) {
|
|
142
|
+
const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts/gds', f), 'utf8');
|
|
143
|
+
const uses = /\berrCode\s*\(/.test(src);
|
|
144
|
+
const imports = /require\('\.\/cli-lib'\)/.test(src) && /\berrCode\b/.test(src.split('\n').find((l) => l.includes("require('./cli-lib')")) || '');
|
|
145
|
+
if (uses && !imports && f !== 'cli-lib.js') missing.push(f);
|
|
146
|
+
}
|
|
147
|
+
assert.deepEqual(missing, [], 'a script calling errCode() without importing it throws at the branch it was meant to fix');
|
|
148
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// tests/cost_report.mjs — the spend read-out's text rendering (task 1001621).
|
|
2
|
+
// DB-free. Run: node tests/cost_report.mjs
|
|
3
|
+
|
|
4
|
+
import { strict as assert } from 'node:assert';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { formatCostReport } = require('../modules/economy/cost-report.js');
|
|
8
|
+
|
|
9
|
+
let passed = 0, failed = 0;
|
|
10
|
+
function test(name, fn) {
|
|
11
|
+
try { fn(); console.log(` ok ${name}`); passed++; }
|
|
12
|
+
catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FULL = {
|
|
16
|
+
total_usd: 123.45,
|
|
17
|
+
filtered_total_usd: 123.45,
|
|
18
|
+
applied_filters: { source: null, category: null, days: null },
|
|
19
|
+
by_month: [
|
|
20
|
+
{ month: '2026-06', total_usd: 100 },
|
|
21
|
+
{ month: '2026-07', total_usd: 23.45 },
|
|
22
|
+
],
|
|
23
|
+
by_category: [{ category: 'api', total_usd: 120 }, { category: 'infra', total_usd: 3.45 }],
|
|
24
|
+
by_source: [{ source: 'session:interactive', total_usd: 123.45 }],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test('renders the three dimensions the task asks for: month, category, source', () => {
|
|
28
|
+
const out = formatCostReport(FULL);
|
|
29
|
+
assert.match(out, /total spend:\s+\$123\.45/);
|
|
30
|
+
assert.match(out, /2026-06: \$100\.00/);
|
|
31
|
+
assert.match(out, /2026-07: \$23\.45/);
|
|
32
|
+
assert.match(out, /api: \$120\.00/);
|
|
33
|
+
assert.match(out, /session:interactive: \$123\.45/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('no filter applied → no filter line and no filtered subtotal', () => {
|
|
37
|
+
const out = formatCostReport(FULL);
|
|
38
|
+
assert.doesNotMatch(out, /filter:/);
|
|
39
|
+
assert.doesNotMatch(out, /under filter:/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a filter applied → the filter is named and the subtotal is separate from the total', () => {
|
|
43
|
+
const out = formatCostReport({
|
|
44
|
+
...FULL,
|
|
45
|
+
filtered_total_usd: 23.45,
|
|
46
|
+
applied_filters: { source: 'grader', category: 'api', days: 30 },
|
|
47
|
+
});
|
|
48
|
+
assert.match(out, /filter:.*source=grader/);
|
|
49
|
+
assert.match(out, /filter:.*category=api/);
|
|
50
|
+
assert.match(out, /filter:.*last 30 days/);
|
|
51
|
+
assert.match(out, /under filter:\s+\$23\.45/);
|
|
52
|
+
// the header total stays project-wide — costSummary never rescopes it
|
|
53
|
+
assert.match(out, /total spend:\s+\$123\.45/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a server predating by_month says so rather than reading as zero spend', () => {
|
|
57
|
+
const { by_month, ...older } = FULL;
|
|
58
|
+
const out = formatCostReport(older);
|
|
59
|
+
assert.match(out, /by month:\s+unavailable/);
|
|
60
|
+
assert.doesNotMatch(out, /2026-06/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the current month is labelled partial — it is spend to date, not a month', () => {
|
|
64
|
+
const out = formatCostReport(FULL, { now: new Date('2026-07-15T00:00:00Z') });
|
|
65
|
+
assert.match(out, /2026-07: \$23\.45\s+\(partial — month to date\)/);
|
|
66
|
+
// a month that has closed carries no such caveat
|
|
67
|
+
assert.match(out, /2026-06: \$100\.00\n/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('no row is labelled partial once the newest month has closed', () => {
|
|
71
|
+
const out = formatCostReport(FULL, { now: new Date('2026-09-22T00:00:00Z') });
|
|
72
|
+
assert.doesNotMatch(out, /partial/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an empty by_month is reported as no spend, not as unavailable', () => {
|
|
76
|
+
const out = formatCostReport({ ...FULL, by_month: [] });
|
|
77
|
+
assert.match(out, /by month:/);
|
|
78
|
+
assert.doesNotMatch(out, /unavailable/);
|
|
79
|
+
assert.match(out, /no spend recorded/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log(`\ncost_report: ${passed} passed, ${failed} failed`);
|
|
83
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { strict as assert } from 'node:assert';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
|
-
const { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration } = require('../modules/economy/cost-summary-shape.js');
|
|
7
|
+
const { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration, shapeByMonth } = require('../modules/economy/cost-summary-shape.js');
|
|
8
8
|
|
|
9
9
|
let passed = 0, failed = 0;
|
|
10
10
|
function test(name, fn) {
|
|
@@ -90,5 +90,21 @@ test('shapeCostCalibration empty/undefined → empty by_kind, null overall', ()
|
|
|
90
90
|
assert.deepEqual(shapeCostCalibration(undefined), { by_kind: [], overall: null });
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test('shapeByMonth coerces numbers and preserves the SQL month ordering (task 1001621)', () => {
|
|
94
|
+
const out = shapeByMonth([
|
|
95
|
+
{ month: '2026-06', total_usd: '100.005' },
|
|
96
|
+
{ month: '2026-07', total_usd: 23.45 },
|
|
97
|
+
]);
|
|
98
|
+
assert.deepEqual(out, [
|
|
99
|
+
{ month: '2026-06', total_usd: 100.01 }, // round2
|
|
100
|
+
{ month: '2026-07', total_usd: 23.45 },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('shapeByMonth handles empty/undefined input', () => {
|
|
105
|
+
assert.deepEqual(shapeByMonth([]), []);
|
|
106
|
+
assert.deepEqual(shapeByMonth(undefined), []);
|
|
107
|
+
});
|
|
108
|
+
|
|
93
109
|
console.log(`\ncost_summary_shape: ${passed} passed, ${failed} failed`);
|
|
94
110
|
process.exit(failed ? 1 : 0);
|