@bongos/core 1.21.177 → 1.21.178
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 +140 -150
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +0 -6
- package/clients/bongos-client/index.cjs +0 -6
- package/clients/bongos-client/index.d.ts +0 -6
- package/clients/bongos-client/index.mjs +0 -6
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +3 -2
- package/docs/api/openapi.json +1 -87
- package/docs/api-reference.md +1 -8
- package/docs/canonical-permissions.md +3 -3
- package/docs/copy-inventory.md +25 -39
- package/docs/copy-registry.json +73 -200
- package/docs/file-map.md +3 -3
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +2 -2
- package/docs/page-inventory.json +4 -7
- package/docs/page-readings.json +1520 -1569
- package/modules/government/catalog.js +10 -7
- package/modules/government/decision-board.js +0 -1
- package/modules/government/decision-rules.js +0 -1
- package/modules/government/docket.js +0 -1
- package/modules/government/protected-surfaces.js +1 -1
- package/modules/government/protected-surfaces.json +2 -3
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.css +4 -26
- package/modules/hall-ui/public/gate.html +12 -27
- package/modules/hall-ui/public/gate.js +26 -176
- package/modules/hall-ui/public/gate.states.json +4 -22
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +1 -1
- package/modules/hall-ui/public/street-card.js +5 -9
- package/modules/hall-ui/public/street-desk.js +2 -2
- package/modules/hall-ui/public/street-inside.js +1 -1
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/lifecycle/matter-acts.js +0 -6
- package/modules/lifecycle/module.json +0 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/scripts/bongos/adopt-preflight.js +3 -3
- package/scripts/bongos/audit-authorship.sh +1 -3
- package/scripts/bongos/bump-version.js +46 -9
- package/scripts/bongos/doc-cli-guard.js +2 -0
- package/scripts/bongos/doc-control-claims.js +2 -2
- package/scripts/bongos/government-walk.js +3 -9
- package/scripts/bongos/hard-floor.js +52 -0
- package/scripts/bongos/publish-manifest.js +3 -3
- package/scripts/bongos/run-unit-tests.js +17 -5
- package/scripts/gds/hard-floor.js +6 -0
- package/src/bongos/module-scope-map.js +2 -2
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +1 -8
- package/src/bongos/routes.js +1 -2
- package/src/module-api.js +1 -1
- package/src/modules.js +1 -1
- package/tests/acclaim_feed_evidence_db.mjs +42 -23
- package/tests/adr_0089_scope.mjs +7 -5
- package/tests/bump_version.mjs +52 -18
- package/tests/copy_desk_flags_db.mjs +22 -14
- package/tests/gating_review_retired.mjs +1 -1
- package/tests/government_act_alone_buildings.mjs +1 -3
- package/tests/government_board_membership_db.mjs +35 -22
- package/tests/government_protected_surfaces.mjs +29 -13
- package/tests/governor_docket.mjs +0 -2
- package/tests/governor_matter_acts.mjs +6 -8
- package/tests/hall_city.mjs +1 -1
- package/tests/hall_matter_card.mjs +2 -2
- package/tests/hall_oversight_world.mjs +16 -12
- package/tests/hall_street_desk.mjs +4 -3
- package/tests/hall_street_inside.mjs +1 -1
- package/tests/helpers.mjs +34 -0
- package/tests/howto_state.mjs +33 -56
- package/tests/howto_state_db.mjs +68 -0
- package/tests/idea_credit_streams_db.mjs +11 -3
- package/tests/integration_parked_ratchet.mjs +7 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/page_notes_db.mjs +12 -8
- package/tests/page_tweak_no_rearm.mjs +50 -0
- package/tests/publish_manifest.mjs +64 -0
- package/tests/search_isolation.mjs +5 -103
- package/tests/search_isolation_db.mjs +140 -0
- package/tests/session_earnings_mirror_db.mjs +2 -0
- package/tests/workflow_exec_paths.mjs +9 -11
- package/modules/lifecycle/routes/gate-approvals.js +0 -299
- package/scripts/bongos/ci-grade.js +0 -82
- package/scripts/bongos/gate-review.js +0 -330
- package/scripts/gds/ci-grade.js +0 -7
- package/scripts/gds/gate-review.js +0 -7
- package/tests/gate_approvals.mjs +0 -611
- package/tests/gate_review.mjs +0 -152
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// tests/search_isolation_db.mjs
|
|
2
|
+
//
|
|
3
|
+
// Layer C of the GDS search isolation suite (task 950, V4.R12 / ADR 0060 §6 / ADR 0016),
|
|
4
|
+
// against a REAL Postgres. Layers A (pure + SQL structure) and B (the HTTP route derives
|
|
5
|
+
// scope from the session only) are DB-free and stay in tests/search_isolation.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Seeds rank-gated + owner-scoped chunks and runs the REAL searchChunks and
|
|
8
|
+
// searchPublicDocs in a ROLLED-BACK transaction for the done-when cases
|
|
9
|
+
// (xenos-vs-protected, cross-builder memory, owner-sees-own, the public port's fixed
|
|
10
|
+
// scope). The search index must never be a read-side bypass of the trust boundary.
|
|
11
|
+
//
|
|
12
|
+
// SPLIT OUT by task 1004863. As the third layer of search_isolation.mjs it said it ran in
|
|
13
|
+
// the integration gate, but that file was never in the INTEGRATION set — so in CI it
|
|
14
|
+
// always skipped and no lane executed it. This file is in the set; it needs a MIGRATED
|
|
15
|
+
// database (builders 1 and 2 are the seeded system rows the owned chunk points at).
|
|
16
|
+
//
|
|
17
|
+
// Run: DATABASE_URL=postgres://... node tests/search_isolation_db.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const search = require('../modules/memory/search.js');
|
|
24
|
+
|
|
25
|
+
let passed = 0;
|
|
26
|
+
let failed = 0;
|
|
27
|
+
async function t(name, fn) {
|
|
28
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
29
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Layer C — DB-backed real-query scoping (auto-skips without Postgres)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
console.log('Layer C — real searchChunks against a live DB (skips if unreachable):');
|
|
36
|
+
|
|
37
|
+
let pool = null;
|
|
38
|
+
try {
|
|
39
|
+
// task 1003207: this said `../src/db`, which has not existed since the kernel
|
|
40
|
+
// carve — so the require ALWAYS threw, pool stayed null, and Layer C skipped
|
|
41
|
+
// even against a live Postgres. It was a dead layer reporting as a skip.
|
|
42
|
+
({ pool } = require('../src/bongos/pool'));
|
|
43
|
+
} catch { pool = null; }
|
|
44
|
+
|
|
45
|
+
async function dbReachable() {
|
|
46
|
+
if (!pool) return null;
|
|
47
|
+
try {
|
|
48
|
+
const c = await pool.connect();
|
|
49
|
+
try { await c.query('SELECT 1'); return c; } catch { c.release(); return null; }
|
|
50
|
+
} catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const client = await dbReachable();
|
|
54
|
+
if (!client) {
|
|
55
|
+
console.log(' SKIP no reachable Postgres — this runs in the integration lane (run-unit-tests.js INTEGRATION) / wherever a Postgres is reachable.');
|
|
56
|
+
console.log(' (The same scoping cases were proven on the prod DB in a rolled-back transaction at ship time.)');
|
|
57
|
+
// END THE POOL ON THE SKIP PATH TOO (task 1003848). Only the else-branch below
|
|
58
|
+
// ended it, so when Postgres was unreachable the Pool survived with libuv handles
|
|
59
|
+
// still closing from its failed connect — and the process.exit() at the bottom of
|
|
60
|
+
// the old combined file then tore down the loop underneath them. On Windows that surfaces as
|
|
61
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`, exit
|
|
62
|
+
// 0xC0000409: a process-teardown crash AFTER '5 passed, 0 failed', which reads as
|
|
63
|
+
// a failing suite in the unit gate while every assertion actually passed.
|
|
64
|
+
if (pool) await pool.end().catch(() => {});
|
|
65
|
+
} else {
|
|
66
|
+
try {
|
|
67
|
+
await client.query('BEGIN');
|
|
68
|
+
await client.query(`SET LOCAL pg_trgm.word_similarity_threshold = ${search.TRGM_THRESHOLD}`);
|
|
69
|
+
// builder 1 exists (example-owner); seed a protected (metic) chunk, a public one,
|
|
70
|
+
// and a chunk owned by builder 1 — all matching q=ISO_TERM.
|
|
71
|
+
//
|
|
72
|
+
// ISO_TERM is a word no real document contains (task 1004889). It used to be
|
|
73
|
+
// 'enforcement', and in the integration lane the booted server's search-index
|
|
74
|
+
// poller fills search_chunks with the repo's docs first: 149 real chunks matched,
|
|
75
|
+
// the three seeded rows fell out of the top 50, and every "sees" case failed.
|
|
76
|
+
// Worse, the "does NOT see" cases were then passing VACUOUSLY — a hidden row and a
|
|
77
|
+
// crowded-out row look the same. With a unique term the seeded rows are the only
|
|
78
|
+
// candidates, so a row missing from the results can only mean the scope removed it.
|
|
79
|
+
const ISO_TERM = 'zqxisoprobe';
|
|
80
|
+
await client.query(`INSERT INTO search_chunks (source_kind, source_ref, chunk_index, heading_path, title, body, min_rank, owner_builder_id, content_hash) VALUES
|
|
81
|
+
('doc','__iso__/protected.md',0,'p','PROT','trust boundary permission core ${ISO_TERM}','metic',NULL,'i1'),
|
|
82
|
+
('doc','__iso__/public.md',0,'pub','PUB','trust boundary public ${ISO_TERM} overview','xenos',NULL,'i2'),
|
|
83
|
+
('db','__iso__/memory:1',0,'m','MEM1','builder one private ${ISO_TERM} memo','xenos',1,'i3')`);
|
|
84
|
+
|
|
85
|
+
const titles = (rows) => rows.map((r) => r.title).sort().join(',');
|
|
86
|
+
|
|
87
|
+
await t('xenos caller sees the public chunk but NOT the metic-protected one (xenos-vs-protected)', async () => {
|
|
88
|
+
const rows = await search.searchChunks({ q: ISO_TERM, builderId: 2, builderRank: 'xenos', limit: 50 }, { client });
|
|
89
|
+
const ts = titles(rows);
|
|
90
|
+
assert.ok(ts.includes('PUB'), `expected PUB, got [${ts}]`);
|
|
91
|
+
assert.ok(!ts.includes('PROT'), `xenos must NOT see the metic chunk, got [${ts}]`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t('metic caller sees the protected chunk too', async () => {
|
|
95
|
+
const rows = await search.searchChunks({ q: ISO_TERM, builderId: 2, builderRank: 'metic', limit: 50 }, { client });
|
|
96
|
+
const ts = titles(rows);
|
|
97
|
+
assert.ok(ts.includes('PROT') && ts.includes('PUB'), `metic should see PROT+PUB, got [${ts}]`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await t('builder 2 does NOT see builder 1\'s owned chunk (cross-builder memory)', async () => {
|
|
101
|
+
const rows = await search.searchChunks({ q: ISO_TERM, builderId: 2, builderRank: 'archon', limit: 50 }, { client });
|
|
102
|
+
assert.ok(!rows.some((r) => r.title === 'MEM1'), 'an Archon STILL must not see another builder\'s private chunk');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('builder 1 DOES see their own owned chunk (owner-sees-own)', async () => {
|
|
106
|
+
const rows = await search.searchChunks({ q: ISO_TERM, builderId: 1, builderRank: 'xenos', limit: 50 }, { client });
|
|
107
|
+
assert.ok(rows.some((r) => r.title === 'MEM1'), 'a builder must see their own private chunk');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The public port (task 1002534, ADR 0367): the Discord #support answerer
|
|
111
|
+
// has no session, so its scope is fixed — shared xenos docs on the
|
|
112
|
+
// allowlist, nothing else, whatever matches the query.
|
|
113
|
+
await client.query(`INSERT INTO search_chunks (source_kind, source_ref, chunk_index, heading_path, title, body, min_rank, owner_builder_id, content_hash) VALUES
|
|
114
|
+
('doc','__pub__/allowed/guide.md',0,'g','PUBOK','support enforcement guide','xenos',NULL,'p1'),
|
|
115
|
+
('doc','__pub__/allowed/locked.md',0,'l','PUBMETIC','support enforcement locked','metic',NULL,'p2'),
|
|
116
|
+
('doc','__pub__/allowed/mine.md',0,'m','PUBOWNED','support enforcement owned','xenos',1,'p3'),
|
|
117
|
+
('db','__pub__/allowed/task:1',0,'d','PUBDB','support enforcement task prose','xenos',NULL,'p4'),
|
|
118
|
+
('doc','__pub__/elsewhere.md',0,'e','PUBOFFLIST','support enforcement off the list','xenos',NULL,'p5')`);
|
|
119
|
+
|
|
120
|
+
await t('searchPublicDocs returns ONLY a shared xenos doc on the allowlist (no metic, owned, db, or off-list row)', async () => {
|
|
121
|
+
const rows = await search.searchPublicDocs({ q: 'support enforcement', refs: ['__pub__/allowed/'], limit: 10 }, { client });
|
|
122
|
+
const ts = titles(rows);
|
|
123
|
+
assert.equal(ts, 'PUBOK', `expected only PUBOK, got [${ts}]`);
|
|
124
|
+
assert.ok(rows[0].body.includes('support enforcement guide'), 'the body comes back with the hit');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await t('searchPublicDocs with no allowlist returns nothing at all', async () => {
|
|
128
|
+
assert.deepEqual(await search.searchPublicDocs({ q: 'support enforcement', refs: [] }, { client }), []);
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
132
|
+
client.release();
|
|
133
|
+
await pool.end().catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
138
|
+
// exitCode, not process.exit() (task 1003848): a hard exit can tear the loop down under a
|
|
139
|
+
// pg handle still closing after a failed connect — the skip path.
|
|
140
|
+
process.exitCode = failed === 0 ? 0 : 1;
|
|
@@ -125,6 +125,8 @@ async function fixture(client) {
|
|
|
125
125
|
title: `${tag} task`,
|
|
126
126
|
status: 'shipped',
|
|
127
127
|
version_id: version.id,
|
|
128
|
+
// Named, not placeholder-filled: tasks_discipline_check admits only the live crafts.
|
|
129
|
+
discipline: 'engineer',
|
|
128
130
|
});
|
|
129
131
|
const sessionId = `${tag}-session`;
|
|
130
132
|
await insertRow(client, 'session_records', {
|
|
@@ -23,11 +23,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
23
23
|
const REPO = path.resolve(__dirname, '..');
|
|
24
24
|
const CORE_PATH_SH = path.join(REPO, 'infra', 'core-path.sh');
|
|
25
25
|
|
|
26
|
-
// infra/
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
26
|
+
// infra/ is HOST topology (publish-manifest.js PROTECTED_TOPOLOGY_EXCLUDE) — absent
|
|
27
|
+
// from a neutral core checkout (task 1002470). The real-tree checks only mean
|
|
28
|
+
// something where that tree exists; the synthetic counterfactual below always runs.
|
|
29
|
+
// Task 1004859: this used to key on .github/workflows/gate-review.yml, a workflow that
|
|
30
|
+
// was only ever planned; it now keys on the resolver every routed invocation needs.
|
|
31
|
+
const HAS_HOST_TOPOLOGY = fs.existsSync(path.join(REPO, 'infra', 'core-node.sh'));
|
|
31
32
|
const HAS_CORE_PATH = fs.existsSync(CORE_PATH_SH);
|
|
32
33
|
|
|
33
34
|
let passed = 0, failed = 0;
|
|
@@ -42,14 +43,11 @@ function hostTest(name, present, fn) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
// ---- A. the workflow lint is green on the real tree ---------------------------------------------
|
|
45
|
-
hostTest('lint-workflow-exec: every core invocation is routed + publishable (no bare repo-relative form)',
|
|
46
|
+
hostTest('lint-workflow-exec: every core invocation is routed + publishable (no bare repo-relative form)', HAS_HOST_TOPOLOGY, () => {
|
|
46
47
|
const r = lintWorkflowExec({ instanceRoot: REPO, coreRoot: REPO });
|
|
47
48
|
assert.deepEqual(r.violations, [], `expected no violations, got: ${JSON.stringify(r.violations, null, 2)}`);
|
|
48
49
|
assert.ok(r.ok, 'lint should pass');
|
|
49
|
-
// The security-critical merge gates + the functional gates must all be covered.
|
|
50
50
|
const rels = r.checked.map((c) => `${c.file}:${c.rel}`);
|
|
51
|
-
assert.ok(rels.some((s) => s.startsWith('gate-review.yml:') && s.endsWith('scripts/bongos/gate-review.js')), 'gate-review routed');
|
|
52
|
-
assert.ok(rels.some((s) => s.startsWith('grade-gate.yml:') && s.endsWith('scripts/bongos/ci-grade.js')), 'grade-gate routed');
|
|
53
51
|
assert.ok(rels.some((s) => s.endsWith('scripts/migrate.sh')), 'selfhost migrate routed via core-path.sh');
|
|
54
52
|
});
|
|
55
53
|
|
|
@@ -58,9 +56,9 @@ test('lint-workflow-exec: a bare core invocation in a workflow is flagged (count
|
|
|
58
56
|
try {
|
|
59
57
|
fs.mkdirSync(path.join(tmp, '.github', 'workflows'), { recursive: true });
|
|
60
58
|
fs.mkdirSync(path.join(tmp, 'scripts', 'bongos'), { recursive: true });
|
|
61
|
-
fs.writeFileSync(path.join(tmp, 'scripts', 'bongos', '
|
|
59
|
+
fs.writeFileSync(path.join(tmp, 'scripts', 'bongos', 'main-audit.js'), '// probe\n');
|
|
62
60
|
fs.writeFileSync(path.join(tmp, '.github', 'workflows', 'legacy.yml'),
|
|
63
|
-
'jobs:\n x:\n steps:\n - run: node scripts/bongos/
|
|
61
|
+
'jobs:\n x:\n steps:\n - run: node scripts/bongos/main-audit.js\n');
|
|
64
62
|
const r = lintWorkflowExec({ instanceRoot: tmp, coreRoot: tmp });
|
|
65
63
|
assert.ok(!r.ok, 'a bare core invocation must fail the lint');
|
|
66
64
|
assert.equal(r.violations.length, 1);
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
// src/bongos/routes/gate-approvals.js — Archon gate-surface PR approval from the hall
|
|
2
|
-
// (task 1179, ADR 0058 amendment).
|
|
3
|
-
//
|
|
4
|
-
// WHY. The gate-review CI check (ADR 0058) clears an ESCALATE (hard floor) ONLY on
|
|
5
|
-
// an owner approval, and a NEEDS_TRUST without a Metic+ author likewise blocks until
|
|
6
|
-
// the owner approves. Historically that owner approval was a GitHub APPROVED review
|
|
7
|
-
// by the repo owner on the head sha — i.e. the owner had to leave a GitHub/terminal review.
|
|
8
|
-
// This router gives Archons a browser-only equivalent (feedback: auth flows must be
|
|
9
|
-
// UI-first), trust-consistent with the existing server-set `gate-author-trust`
|
|
10
|
-
// status: the server re-checks the caller's Archon rank server-side (ADR 0016) and
|
|
11
|
-
// sets a `gate-owner-approved` commit status that the gate-review workflow honors as
|
|
12
|
-
// owner approval. A PR author cannot forge it (only the server holds the credential).
|
|
13
|
-
//
|
|
14
|
-
// GET /gate-approvals — open PRs whose gate-review check is red,
|
|
15
|
-
// with the verdict + per-file reasons (Archon)
|
|
16
|
-
// POST /gate-approvals/:pr/approve — record owner approval + re-run the check (Archon)
|
|
17
|
-
//
|
|
18
|
-
// The list's "is this awaiting approval" signal is the LIVE gate-review check
|
|
19
|
-
// conclusion (authoritative), not a re-derivation; classifyChanges() is reused only
|
|
20
|
-
// to render WHY each PR escalated, so the hall shows exactly the CI reasoning.
|
|
21
|
-
|
|
22
|
-
const express = require('express');
|
|
23
|
-
// Intra-module + the published doorway — a module never requires a core internal.
|
|
24
|
-
const api = require('../../../src/module-api');
|
|
25
|
-
const auth = api;
|
|
26
|
-
const githubPush = require('../github-push');
|
|
27
|
-
// The page-tweak hold (task 1004323 / BV2.TW12, ADR 0341 D7): the approve below
|
|
28
|
-
// re-arms auto-merge on the reopened PR, and a page tweak held for its artist must
|
|
29
|
-
// never be armed from here. Called through the module object so tests can stub it.
|
|
30
|
-
const pageTweakReads = require('../page-tweak-reads');
|
|
31
|
-
|
|
32
|
-
// Would POST /gate-approvals/:pr/approve let this viewer through? The same two
|
|
33
|
-
// checks it mounts, read for the list (task 1004829): the hall draws a held PR as
|
|
34
|
-
// an ask card only on true, so a Metic who may read the gate never sees an Approve
|
|
35
|
-
// the route would refuse. Fail-closed like requirePermission: no government port,
|
|
36
|
-
// or one that throws, is false; an absent act-alone check passes, as the
|
|
37
|
-
// requireActAlone gate does.
|
|
38
|
-
async function viewerMayApprove(builderId) {
|
|
39
|
-
try {
|
|
40
|
-
const government = api.resolveOptional('government');
|
|
41
|
-
if (!government || typeof government.builderHasPermissions !== 'function') return false;
|
|
42
|
-
if (!(await government.builderHasPermissions(Number(builderId), ['gate_approval.decide']))) return false;
|
|
43
|
-
if (typeof government.checkActAlone !== 'function') return true;
|
|
44
|
-
const out = await government.checkActAlone('release', builderId);
|
|
45
|
-
return !!(out && out.ok === true);
|
|
46
|
-
} catch {
|
|
47
|
-
// authority that cannot be established is a denial (ADR 0016); the list still answers
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
const { HELD_NOT_ARMED } = require('../page-tweak-hold');
|
|
52
|
-
const { parseId } = api;
|
|
53
|
-
// The SAME pure classifier the CI gate runs — reused (never duplicated) so the hall
|
|
54
|
-
// shows identical verdict + reasons. Safe to require: gate-review.js guards its CLI
|
|
55
|
-
// entrypoint behind `require.main === module`, so importing it runs no side effects.
|
|
56
|
-
const { classifyChanges, resolveAction } = require('../../../scripts/bongos/gate-review');
|
|
57
|
-
|
|
58
|
-
// Pull a GDS task id out of a server-published PR's title ("task 1163: …") or body
|
|
59
|
-
// ("… for task 1163."). Returns a number or null. Display-only.
|
|
60
|
-
function taskIdFromPr(pr) {
|
|
61
|
-
const hay = `${pr.title || ''}\n${pr.body || ''}`;
|
|
62
|
-
const m = /\btask\s+(\d{1,7})\b/i.exec(hay);
|
|
63
|
-
return m ? Number(m[1]) : null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Find a named check/status in the head-commit rollup (CheckRun.name or
|
|
67
|
-
// StatusContext.context — listOpenPullRequests flattens both to `name`).
|
|
68
|
-
function findCheck(pr, name) {
|
|
69
|
-
return (pr.checks || []).find((c) => c.name === name) || null;
|
|
70
|
-
}
|
|
71
|
-
const isSuccess = (c) => c && String(c.conclusion || '').toUpperCase() === 'SUCCESS';
|
|
72
|
-
const isFailure = (c) => c && String(c.conclusion || '').toUpperCase() === 'FAILURE';
|
|
73
|
-
|
|
74
|
-
// Run the pure classifier over a PR's changed files to produce the display verdict +
|
|
75
|
-
// reasons. blobAt returns null (no file content fetched): the path-level hard-floor /
|
|
76
|
-
// sensitive-surface rules — which produce the human reasons — do not need content;
|
|
77
|
-
// the content-only AUTO-narrowing certifications fail-closed to a stricter verdict,
|
|
78
|
-
// which is the safe direction for a read-only "why is this stuck" view.
|
|
79
|
-
function summarizeGate(files) {
|
|
80
|
-
const r = classifyChanges({ files, blobAt: () => null, diffBytes: 0 });
|
|
81
|
-
const reasons = [
|
|
82
|
-
...(r.escalations || []).map((e) => ({ path: e.path, reason: e.reason, hard_floor: true })),
|
|
83
|
-
...(r.needsTrust || []).map((e) => ({ path: e.path, reason: e.reason, hard_floor: false })),
|
|
84
|
-
];
|
|
85
|
-
return { verdict: r.verdict, reasons, surfaces: r.surfaces || [] };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// The awaiting list itself: open, non-draft PRs whose gate-review check is
|
|
89
|
-
// COMPLETED and red, each with its verdict and reasons. Shared by GET
|
|
90
|
-
// /gate-approvals and the governor's docket (task 1004523), so the two can never
|
|
91
|
-
// disagree about what is waiting. `withFiles: false` skips the per-PR file read
|
|
92
|
-
// (the docket needs only the count and the titles, not the per-file reasons).
|
|
93
|
-
async function listAwaitingGateApprovals({ withFiles = true } = {}) {
|
|
94
|
-
const prs = await githubPush.listOpenPullRequests({});
|
|
95
|
-
const awaiting = [];
|
|
96
|
-
let checking = 0;
|
|
97
|
-
for (const pr of prs) {
|
|
98
|
-
if (pr.is_draft) continue;
|
|
99
|
-
const gate = findCheck(pr, 'gate-review');
|
|
100
|
-
if (!gate) continue; // not a gate-reviewed PR (or it hasn't run yet)
|
|
101
|
-
if (String(gate.status || '').toUpperCase() !== 'COMPLETED') { checking += 1; continue; }
|
|
102
|
-
if (!isFailure(gate)) continue; // green/neutral → not awaiting
|
|
103
|
-
// gate-review is red → blocked awaiting trust/owner approval. Render WHY.
|
|
104
|
-
let summary = { verdict: 'unknown', reasons: [], surfaces: [], files: [] };
|
|
105
|
-
if (withFiles) {
|
|
106
|
-
try {
|
|
107
|
-
const files = await githubPush.getPullRequestFiles({ number: pr.number });
|
|
108
|
-
summary = summarizeGate(files);
|
|
109
|
-
summary.files = files.map((f) => ({ path: f.path, status: f.status }));
|
|
110
|
-
} catch (_) {
|
|
111
|
-
summary.files = [];
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
awaiting.push({
|
|
115
|
-
number: pr.number,
|
|
116
|
-
title: pr.title,
|
|
117
|
-
url: pr.url,
|
|
118
|
-
task: taskIdFromPr(pr),
|
|
119
|
-
author: pr.author,
|
|
120
|
-
head_sha: pr.head_sha,
|
|
121
|
-
verdict: summary.verdict,
|
|
122
|
-
reasons: summary.reasons,
|
|
123
|
-
files: summary.files,
|
|
124
|
-
author_trusted: isSuccess(findCheck(pr, 'gate-author-trust')),
|
|
125
|
-
already_approved: isSuccess(findCheck(pr, githubPush.GATE_OWNER_APPROVED_CONTEXT)),
|
|
126
|
-
gate_review_url: gate.url || null,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
return { prs, awaiting, checking };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// The governor's docket (task 1004523): a PR held at the gate stands at Release,
|
|
133
|
-
// shown only to a viewer holding `gate_approval.read` (this list route's own atom)
|
|
134
|
-
// and needing only a viewer who may clear it (`gate_approval.decide`). An
|
|
135
|
-
// unconfigured server credential is a read error, so the docket reports the
|
|
136
|
-
// source `unavailable` rather than an empty, falsely calm Release.
|
|
137
|
-
function contributeGateApprovalsToDocket() {
|
|
138
|
-
api.contribute('governor.docket', {
|
|
139
|
-
source: 'gate_approvals',
|
|
140
|
-
building: 'release',
|
|
141
|
-
gate: { permission: 'gate_approval.read' },
|
|
142
|
-
read: async (ctx) => {
|
|
143
|
-
if (!githubPush.isConfigured()) throw new Error('publish_unconfigured');
|
|
144
|
-
const { awaiting } = await listAwaitingGateApprovals({ withFiles: false });
|
|
145
|
-
const mayDecide = ctx.has('gate_approval.decide');
|
|
146
|
-
return awaiting.filter((pr) => !pr.already_approved).map((pr) => ({
|
|
147
|
-
id: pr.number,
|
|
148
|
-
rule: 'decide',
|
|
149
|
-
state: mayDecide ? 'needs' : 'moving',
|
|
150
|
-
title: pr.title || `Pull request ${pr.number}`,
|
|
151
|
-
short: 'held at the gate',
|
|
152
|
-
where: 'release · held at the gate',
|
|
153
|
-
opened_at: null,
|
|
154
|
-
deadline: null,
|
|
155
|
-
proposer: pr.author ? { login: pr.author } : null,
|
|
156
|
-
tally: null,
|
|
157
|
-
href: '/gate',
|
|
158
|
-
ref: { type: 'pull_request', id: pr.number },
|
|
159
|
-
}));
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// The approval itself, shared by the route above and a passed Release matter
|
|
165
|
-
// (task 1004561, ../matter-acts.js) so the two can never differ. Returns
|
|
166
|
-
// { ok: true, body } or { ok: false, code, status, message }; throws on an
|
|
167
|
-
// unexpected failure (the caller says it). `login` is who approved, as the
|
|
168
|
-
// status description names it.
|
|
169
|
-
async function approveGatePr({ number, login }) {
|
|
170
|
-
if (!githubPush.isConfigured()) {
|
|
171
|
-
return { ok: false, code: 'publish_unconfigured', status: 503, message: 'No server GitHub credential configured — cannot approve.' };
|
|
172
|
-
}
|
|
173
|
-
// Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
|
|
174
|
-
// so a later commit's head carries no approval — red-team #2b protection).
|
|
175
|
-
const prs = await githubPush.listOpenPullRequests({});
|
|
176
|
-
const pr = prs.find((p) => p.number === number);
|
|
177
|
-
if (!pr) return { ok: false, code: 'pr_not_open', status: 404, message: `No open PR #${number}.` };
|
|
178
|
-
if (!pr.head_sha) return { ok: false, code: 'no_head_sha', status: 409, message: 'Could not resolve the PR head commit.' };
|
|
179
|
-
|
|
180
|
-
const gate = findCheck(pr, 'gate-review');
|
|
181
|
-
if (gate && isSuccess(gate)) {
|
|
182
|
-
return { ok: true, body: { ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' } };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// 1. Set the trustless owner-approval status on the head sha.
|
|
186
|
-
const set = await githubPush.setGateOwnerApproved({ sha: pr.head_sha, login, targetUrl: pr.url });
|
|
187
|
-
if (!set) {
|
|
188
|
-
// 500, not 502 — Cloudflare masks an origin 502 with its branded page (see the route's catch).
|
|
189
|
-
return { ok: false, code: 'status_set_failed', status: 500, message: 'Could not set the gate-owner-approved status.' };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
|
|
193
|
-
// required check re-evaluates the new approval; re-arm auto-merge.
|
|
194
|
-
// A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
|
|
195
|
-
// 1004323): clearing the gate check is the owner's act on the PR, and
|
|
196
|
-
// approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
|
|
197
|
-
// read that throws answers held, and an ordinary confirmed task left
|
|
198
|
-
// un-armed still lands through the publish reconciler's sweep.
|
|
199
|
-
const prTask = taskIdFromPr(pr);
|
|
200
|
-
const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
|
|
201
|
-
let recheck = { reopened: false, auto_merge: false };
|
|
202
|
-
try {
|
|
203
|
-
recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
|
|
204
|
-
} catch (e) {
|
|
205
|
-
// The status IS set; if the re-trigger failed, the next push or a manual
|
|
206
|
-
// re-run still clears it, and the publish reconciler sweep merges once green.
|
|
207
|
-
console.error('[gds] gate-approval recheck failed', e);
|
|
208
|
-
return {
|
|
209
|
-
ok: true,
|
|
210
|
-
body: {
|
|
211
|
-
ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
|
|
212
|
-
message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
ok: true,
|
|
219
|
-
body: {
|
|
220
|
-
ok: true,
|
|
221
|
-
pr: number,
|
|
222
|
-
head_sha: pr.head_sha,
|
|
223
|
-
status_set: true,
|
|
224
|
-
recheck,
|
|
225
|
-
held_for_artist: held,
|
|
226
|
-
message: held
|
|
227
|
-
? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
|
|
228
|
-
: 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
|
|
229
|
-
},
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
module.exports = function buildGateApprovalsRouter() {
|
|
234
|
-
contributeGateApprovalsToDocket();
|
|
235
|
-
const router = express.Router();
|
|
236
|
-
|
|
237
|
-
// GET /gate-approvals — open PRs awaiting owner approval (gate-review red), with the
|
|
238
|
-
// verdict + per-file reasons + changed files + diff link. rank: metic+archon.
|
|
239
|
-
// BV1.R105 left this on requireRank because `gate_approval.decide` was declared over
|
|
240
|
-
// the /approve WRITE only and no atom named the read. ADR 0157 extends the catalog
|
|
241
|
-
// with exactly that atom — `gate_approval.read`, floor metic — so the mapping is now
|
|
242
|
-
// one the catalog makes. The split is load-bearing, not cosmetic: `page.view.government`
|
|
243
|
-
// opens /gate to Metic, and without a Metic-readable data endpoint that page would
|
|
244
|
-
// render nothing but a 403. Approving a held PR stays archon (`.decide`, system).
|
|
245
|
-
router.get('/gate-approvals', auth.requireBuilder, auth.requirePermission('gate_approval.read'), async (req, res) => {
|
|
246
|
-
if (!githubPush.isConfigured()) {
|
|
247
|
-
return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot read pull requests.' });
|
|
248
|
-
}
|
|
249
|
-
try {
|
|
250
|
-
const { prs, awaiting, checking } = await listAwaitingGateApprovals();
|
|
251
|
-
// The PR check rollup is unreadable when the server credential lacks `Checks: read`
|
|
252
|
-
// (operator blocker): listOpenPullRequests then returns partialErrors and every PR's
|
|
253
|
-
// `checks` is empty, so the awaiting list is silently empty. Surface that so the hall
|
|
254
|
-
// can say "checks unreadable — grant the App Checks:read" rather than "nothing stuck".
|
|
255
|
-
const checksReadable = !(prs.partialErrors && prs.partialErrors.length);
|
|
256
|
-
res.json({
|
|
257
|
-
configured: true,
|
|
258
|
-
awaiting,
|
|
259
|
-
count: awaiting.length,
|
|
260
|
-
checking,
|
|
261
|
-
viewer_may_approve: await viewerMayApprove(req.builder.id),
|
|
262
|
-
checks_readable: checksReadable,
|
|
263
|
-
...(checksReadable ? {} : {
|
|
264
|
-
note: 'PR check rollup is unreadable — the server GitHub App is missing the Checks:read permission, so gate-review-red PRs cannot be listed. Grant it and reinstall.',
|
|
265
|
-
}),
|
|
266
|
-
});
|
|
267
|
-
} catch (err) {
|
|
268
|
-
console.error('[gds] GET /gate-approvals', err);
|
|
269
|
-
// 500, not 502: Cloudflare replaces an origin 502/504 with its own branded
|
|
270
|
-
// gateway-error page, which swallows this JSON body. A 500 is passed through, so
|
|
271
|
-
// the structured error actually reaches the caller (the "never a bare 502" rule).
|
|
272
|
-
res.fail('gate_list_failed', { status: 500, message: err.message, details: { detail: err.detail || null } });
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// POST /gate-approvals/:pr/approve — record owner approval for a stuck gate PR and
|
|
277
|
-
// re-run the gate-review check so it clears. rank: archon (clears the hard floor —
|
|
278
|
-
// pinned in route-rank-check.EXPECTED_RANKS for downgrade protection).
|
|
279
|
-
router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), auth.requireActAlone('release'), async (req, res) => {
|
|
280
|
-
const number = parseId(req, res, { param: 'pr', code: 'bad_pr', positiveInt: true });
|
|
281
|
-
if (number == null) return;
|
|
282
|
-
try {
|
|
283
|
-
const out = await approveGatePr({ number, login: req.builder.github_login });
|
|
284
|
-
if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
|
|
285
|
-
res.json(out.body);
|
|
286
|
-
} catch (err) {
|
|
287
|
-
console.error('[gds] POST /gate-approvals/:pr/approve', err);
|
|
288
|
-
// 500, not 502: Cloudflare replaces an origin 502/504 with its own branded
|
|
289
|
-
// gateway-error page, swallowing this JSON. The reported symptom — a bare
|
|
290
|
-
// "error code: 502" interstitial instead of a structured error — was exactly this.
|
|
291
|
-
// A 500 is passed through, so the caller sees { error, message, detail }.
|
|
292
|
-
res.fail('gate_approve_failed', { status: 500, message: err.message, details: { detail: err.detail || null } });
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
return router;
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
module.exports.approveGatePr = approveGatePr;
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// scripts/bongos/ci-grade.js — run the quality grader IN CI as a fail-closed PR
|
|
3
|
-
// gate (#855, ADR 0042). This is the security KEYSTONE for the ci deploy mode.
|
|
4
|
-
//
|
|
5
|
-
// WHY it must run in CI (not "read the recorded grade"):
|
|
6
|
-
// POST /api/bongos/tasks/:id/grade records the grader scores FROM THE REQUEST
|
|
7
|
-
// BODY — the server does not independently recompute them. In laptop mode the
|
|
8
|
-
// grader runs on the OPERATOR's trusted machine, so that's fine. In ci mode the
|
|
9
|
-
// merge trigger moves to an UNTRUSTED builder machine; a tampered local ship.js
|
|
10
|
-
// could POST a fake passing grade, so a gate that merely READS the recorded
|
|
11
|
-
// grade is theater. The only tamper-proof gate runs the grader where the
|
|
12
|
-
// builder can't touch it: here, in GitHub Actions, against the PR diff.
|
|
13
|
-
//
|
|
14
|
-
// It reuses the EXACT production grading path (ship.js runSubagentGrader → the
|
|
15
|
-
// adversarial worker panel / trivial-diff shot), so a CI pass means the same
|
|
16
|
-
// thing a ship-time pass means. Exit 0 = pass, non-zero = fail (FAIL-CLOSED: any
|
|
17
|
-
// grader error also exits non-zero).
|
|
18
|
-
//
|
|
19
|
-
// SKIP-SAFE until armed: if no grader auth is wired (CLAUDE_CODE_OAUTH_TOKEN /
|
|
20
|
-
// ANTHROPIC_API_KEY), it prints SKIP and exits 0 — so merging this workflow
|
|
21
|
-
// changes nothing until the operator (a) adds the auth secret and (b) marks this
|
|
22
|
-
// a REQUIRED check in branch protection. Mirrors the inert-until-cutover pattern
|
|
23
|
-
// of .github/workflows/deploy-prod.yml (#597). See ADR 0042 cutover precondition #1.
|
|
24
|
-
//
|
|
25
|
-
// Inputs (from the workflow env):
|
|
26
|
-
// GRADE_BASE_REF base ref to diff against (default origin/main)
|
|
27
|
-
// GRADE_TASK_ID the PR's task id (context only; grading is diff-based)
|
|
28
|
-
// GRADE_TASK_TITLE the PR title (context for the grader prompt)
|
|
29
|
-
// GRADE_VALUE_SUMMARY the PR value summary (context for the grader prompt)
|
|
30
|
-
|
|
31
|
-
const { execFileSync } = require('node:child_process');
|
|
32
|
-
const ship = require('./ship.js');
|
|
33
|
-
|
|
34
|
-
function git(args) {
|
|
35
|
-
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
(async () => {
|
|
39
|
-
// Auth presence — the grader spawns the `claude` CLI; without auth it cannot
|
|
40
|
-
// run. Until the operator wires it, stay green-but-not-gating (see header).
|
|
41
|
-
const hasAuth = !!(process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY);
|
|
42
|
-
if (!hasAuth) {
|
|
43
|
-
console.log(
|
|
44
|
-
'grade-gate: SKIP — no grader auth (CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY) configured. ' +
|
|
45
|
-
'This check is green-but-not-gating until the operator wires the secret AND marks it a required check ' +
|
|
46
|
-
'(ADR 0042 cutover precondition #1). It is NOT a real gate yet.'
|
|
47
|
-
);
|
|
48
|
-
process.exit(0);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const base = process.env.GRADE_BASE_REF || 'origin/main';
|
|
52
|
-
let baseline;
|
|
53
|
-
try {
|
|
54
|
-
baseline = git(['merge-base', base, 'HEAD']);
|
|
55
|
-
} catch (_) {
|
|
56
|
-
baseline = base; // shallow checkout / no common ancestor — diff against the ref directly
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const changedFiles = ship.computeChangedFiles(baseline);
|
|
60
|
-
if (!changedFiles.length) {
|
|
61
|
-
console.log('grade-gate: no changes vs base — pass (nothing to grade).');
|
|
62
|
-
process.exit(0);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const task = {
|
|
66
|
-
id: process.env.GRADE_TASK_ID || null,
|
|
67
|
-
title: process.env.GRADE_TASK_TITLE || '(pull request)',
|
|
68
|
-
};
|
|
69
|
-
const valueSummary = process.env.GRADE_VALUE_SUMMARY || '';
|
|
70
|
-
|
|
71
|
-
console.log(`grade-gate: grading ${changedFiles.length} changed file(s) vs ${baseline.slice(0, 12)} ...`);
|
|
72
|
-
const result = await ship.runSubagentGrader({ task, valueSummary, baseline, changedFiles });
|
|
73
|
-
|
|
74
|
-
console.log(`grade-gate: verdict = ${result.passed ? 'PASS' : 'FAIL'}`);
|
|
75
|
-
if (result.notes_md) console.log(String(result.notes_md).slice(0, 6000));
|
|
76
|
-
// Fail-closed: only an explicit pass exits 0.
|
|
77
|
-
process.exit(result.passed ? 0 : 1);
|
|
78
|
-
})().catch((err) => {
|
|
79
|
-
// FAIL-CLOSED: a grader error must block the merge, never wave it through.
|
|
80
|
-
console.error('grade-gate: FAIL (grader error — failing closed):', err && err.message ? err.message : err);
|
|
81
|
-
process.exit(1);
|
|
82
|
-
});
|