@bongos/core 1.19.699 → 1.19.701

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.
@@ -0,0 +1,177 @@
1
+ // modules/agents/routes/agents.js — the agent registry's READ surface (task
2
+ // 1002488, BONGOS-V1 goal 1000038 Phase 1).
3
+ //
4
+ // WHY. agents-sync reconciles .claude/agents/*.md into agents_definitions at
5
+ // deploy, and the spawn path fires from that table — so the table is the only
6
+ // honest answer to "what agents does this instance actually have, and which of
7
+ // them are armed?". Until now nothing could ask. A builder wondering why an
8
+ // agent did or did not fire had to read the deploy log or the DB directly, and
9
+ // an instance author had no way to see that the definition they committed was
10
+ // flagged for a scope violation and therefore silently disarmed.
11
+ //
12
+ // GET /agents — the registry, newest-synced first.
13
+ // GET /agents/:name — one definition, by its stable name.
14
+ //
15
+ // SELF-GATED, and that is the whole gate: `requireBuilder` and nothing else.
16
+ // These are reads of the instance's own configuration by the people who build
17
+ // it, the same audience and posture as GET /tasks. There is deliberately NO rank
18
+ // floor — a xenos who can see WHICH agents are armed is better placed to
19
+ // understand the review they just received, and none of the authority-bearing
20
+ // fields is writable here (authoring is a separate task, with its own wall).
21
+ //
22
+ // WHAT IS NOT SERVED, ON PURPOSE:
23
+ //
24
+ // `persona` is omitted from the LIST and served only on the detail route. It
25
+ // is capped at 32KB per row by the schema, so a 40-agent registry would be a
26
+ // 1.2MB response to render a table that shows none of it. This is a size
27
+ // decision, not a secrecy one — the detail route hands it over to anyone who
28
+ // asks by name.
29
+ //
30
+ // `author_builder_id` is never served. The FACT that matters downstream is
31
+ // `author_rank` — the rank the definition was stamped with at sync, which is
32
+ // what the spawn gate consults — and serving the id would turn a registry read
33
+ // into a people-directory read, which has its own gate (GET /scouting).
34
+ //
35
+ // Same idioms as modules/copy-desk/routes/copy-desk.js: doorway-only requires,
36
+ // factory export mounted from module.json `contributes.routes`, res.fail codes.
37
+
38
+ 'use strict';
39
+
40
+ const express = require('express');
41
+ const api = require('../../../src/module-api');
42
+ const validate = require('../lib/validate');
43
+
44
+ const log = api.logger('agents');
45
+
46
+ // The columns the serializer needs. Spelled out rather than `SELECT *` so that a
47
+ // column added later (an author's email, a raw credential) cannot reach a
48
+ // response by default — the read surface widens only when someone edits this
49
+ // list and reads the comment above about what is deliberately not served.
50
+ const COLUMNS = `
51
+ id, name, title, trigger_type, trigger_spec, model_tier,
52
+ scope_modules, scope_paths, scope_violation,
53
+ source, provenance, author_rank,
54
+ source_path, last_synced_at, enabled, created_at, updated_at
55
+ `;
56
+
57
+ // PURE. Row → wire shape, so the projection is unit-testable without a database.
58
+ //
59
+ // `model_tier` is stored as the TIER ('default' | 'routine'), never a model id:
60
+ // the id a tier resolves to is a deployment fact that changes under the row, and
61
+ // a registry that answered with a stale model name would be worse than one that
62
+ // answers with the tier and lets the caller resolve it. The task asks for the
63
+ // "resolved model tier", and the tier IS the resolved value at this layer.
64
+ //
65
+ // `armed` is derived rather than aliased from `enabled`, because two schema
66
+ // CHECKs make "enabled" narrower than it reads: a row carrying a
67
+ // `scope_violation` is forced to enabled=false, and an enabled row must carry an
68
+ // author_rank. Surfacing the derived boolean beside the reason means a caller
69
+ // asking "why did this not fire?" gets the answer in the same object.
70
+ function serializeDefinition(row, { includePersona = false } = {}) {
71
+ if (!row) return null;
72
+ const out = {
73
+ name: row.name,
74
+ title: row.title || null,
75
+ trigger: {
76
+ type: row.trigger_type,
77
+ // trigger_spec is jsonb and NOT NULL DEFAULT '{}', but a hand-written row
78
+ // could still be any object; the event name is lifted out because it is
79
+ // the only part of the spec every event agent must carry (schema CHECK).
80
+ spec: row.trigger_spec && typeof row.trigger_spec === 'object' ? row.trigger_spec : {},
81
+ event: row.trigger_type === 'event' && row.trigger_spec ? (row.trigger_spec.event || null) : null,
82
+ },
83
+ model_tier: row.model_tier,
84
+ provenance: row.provenance, // 'built-in' | 'instance'
85
+ source: row.source, // 'file' | 'db'
86
+ // A file-sourced row's path is its identity in the repo; a db-authored row
87
+ // has none, and null says so rather than an empty string that sorts oddly.
88
+ source_path: row.source_path || null,
89
+ author_rank: row.author_rank || null,
90
+ scope: {
91
+ modules: Array.isArray(row.scope_modules) ? row.scope_modules : [],
92
+ paths: Array.isArray(row.scope_paths) ? row.scope_paths : [],
93
+ violation: row.scope_violation || null,
94
+ },
95
+ armed: row.enabled === true,
96
+ // Why an agent is not armed, when the answer is knowable. A scope violation
97
+ // is the one disarm the registry can explain; anything else is simply off.
98
+ disarmed_reason: row.enabled === true ? null : (row.scope_violation ? 'scope_violation' : null),
99
+ last_synced_at: row.last_synced_at || null,
100
+ created_at: row.created_at,
101
+ updated_at: row.updated_at,
102
+ };
103
+ if (includePersona) out.persona = row.persona;
104
+ return out;
105
+ }
106
+
107
+ module.exports = function agentsRoutes() {
108
+ const router = express.Router();
109
+
110
+ // GET /agents — the whole registry. Unpaginated on purpose: agents are
111
+ // instance CONFIGURATION reconciled from committed files, so the row count is
112
+ // bounded by what a human wrote, not by traffic. If that ever stops being
113
+ // true, this is the line that needs a cursor — not a silent LIMIT, which
114
+ // would make the registry quietly incomplete.
115
+ router.get('/agents', api.requireBuilder, async (req, res) => {
116
+ try {
117
+ const { rows } = await api.pool.query(
118
+ `SELECT ${COLUMNS} FROM agents_definitions
119
+ ORDER BY enabled DESC, name ASC`
120
+ );
121
+ res.json({
122
+ ok: true,
123
+ agents: rows.map((r) => serializeDefinition(r)),
124
+ // The counts a reader actually wants first, computed here so three
125
+ // callers do not each reduce the array their own way.
126
+ counts: {
127
+ total: rows.length,
128
+ armed: rows.filter((r) => r.enabled === true).length,
129
+ flagged: rows.filter((r) => r.scope_violation).length,
130
+ },
131
+ // The vocabulary, from the validator that enforces it — so a UI building
132
+ // a filter cannot spell a trigger type the registry will never contain.
133
+ // Same rule as the task-kind filter (task 1003453): serve the enum, do
134
+ // not let the client keep a copy.
135
+ vocabulary: {
136
+ trigger_types: [...validate.TRIGGER_TYPES],
137
+ model_tiers: [...validate.MODEL_TIERS],
138
+ events: [...validate.EVENT_CATALOG],
139
+ },
140
+ });
141
+ } catch (err) {
142
+ log.error({ err }, 'GET /agents failed');
143
+ if (!res.headersSent) res.fail('agents_list_failed', 500);
144
+ }
145
+ });
146
+
147
+ // GET /agents/:name — one definition, persona included.
148
+ //
149
+ // The name is validated against the SAME regex the schema CHECKs, before it
150
+ // reaches the query. The query is parameterised regardless, so this is not the
151
+ // injection wall — it is the difference between a 404 and a 400 for a caller
152
+ // who sent something that could never have been a name.
153
+ router.get('/agents/:name', api.requireBuilder, async (req, res) => {
154
+ const name = String(req.params.name || '');
155
+ if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) {
156
+ return res.fail('invalid_agent_name', 400);
157
+ }
158
+ try {
159
+ const { rows } = await api.pool.query(
160
+ `SELECT ${COLUMNS}, persona FROM agents_definitions WHERE name = $1`,
161
+ [name]
162
+ );
163
+ if (!rows.length) return res.fail('agent_not_found', 404);
164
+ return res.json({ ok: true, agent: serializeDefinition(rows[0], { includePersona: true }) });
165
+ } catch (err) {
166
+ log.error({ err, name }, 'GET /agents/:name failed');
167
+ if (!res.headersSent) return res.fail('agent_read_failed', 500);
168
+ return undefined;
169
+ }
170
+ });
171
+
172
+ return router;
173
+ };
174
+
175
+ // Exported for the unit tests, which drive the projection with fabricated rows
176
+ // rather than standing up Postgres.
177
+ module.exports.serializeDefinition = serializeDefinition;
@@ -99,6 +99,12 @@ function createClient(opts = {}) {
99
99
  // GET /achievements — rank: any-builder — GET /achievements
100
100
  getAchievements: (args) => request("GET", "/achievements", { hasBody: false }, args),
101
101
  },
102
+ "agents": {
103
+ // GET /agents — rank: any-builder — GET /agents
104
+ getAgents: (args) => request("GET", "/agents", { hasBody: false }, args),
105
+ // GET /agents/{name} — rank: any-builder — GET /agents/:name
106
+ getAgentsName: (args) => request("GET", "/agents/{name}", { hasBody: false }, args),
107
+ },
102
108
  "analytics": {
103
109
  // GET /analytics/builder/{id} — rank: any-builder — GET /analytics/builder/:id
104
110
  getAnalyticsBuilderId: (args) => request("GET", "/analytics/builder/{id}", { hasBody: false }, args),
@@ -51,6 +51,16 @@ const githubPush = require('./github-push');
51
51
  const { repoInfo, branding } = require('../../src/module-api');
52
52
  const { loadRepoInfo } = repoInfo;
53
53
  const { registerMergeDriver } = require('../../scripts/gds/install-git-hooks');
54
+ const { computeExecutedClosure, branchTouchesExecutedCode } = require('./executed-closure');
55
+
56
+ // The closure is a property of the SERVER's trusted tree, not of the branch being
57
+ // resolved, so it is computed once per root and cached. Asking the clone what our code
58
+ // executes would let the branch answer the question the check exists to ask.
59
+ const closureCache = new Map();
60
+ function executedClosure(root) {
61
+ if (!closureCache.has(root)) closureCache.set(root, computeExecutedClosure({ root }));
62
+ return closureCache.get(root);
63
+ }
54
64
 
55
65
  const execFileP = promisify(execFile);
56
66
 
@@ -141,7 +151,8 @@ async function hasConflictMarkers(gitIn) {
141
151
  // { resolved: true, head_sha, branch, regenerated } — merged + pushed
142
152
  // { resolved: false, skipped: '<why>' [, conflicted: [...] ] } — left as-is
143
153
  // skipped reasons: disabled | unconfigured | already_up_to_date | clone_failed |
144
- // fetch_failed | checkout_failed | markers_remain | push_failed |
154
+ // fetch_failed | checkout_failed | diff_failed | branch_modifies_executed_code
155
+ // (with executes[]) | markers_remain | push_failed |
145
156
  // unresolvable_conflict (with conflicted[]) | generator_failed | commit_failed.
146
157
  // `regenerated` says whether step 7b had to commit post-merge generator drift on top
147
158
  // of the merge (task 1003546) — head_sha is that commit when it did.
@@ -211,6 +222,28 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
211
222
  if (r.code !== 0) return { resolved: false, skipped: 'checkout_failed' };
212
223
  const before = (await git(['rev-parse', 'HEAD'])).stdout.trim();
213
224
 
225
+ // 5b. REFUSE A BRANCH THAT EDITS THE CODE THIS FUNCTION IS ABOUT TO RUN
226
+ // (task 1003367, audit B1). Everything below executes out of the clone with the
227
+ // live server's environment — the merge driver at step 6 and the generators at
228
+ // 7b — so a branch that rewrote any of it would be running its own code as the
229
+ // server. This check must sit BEFORE the merge, not after it: `git merge` invokes
230
+ // `node scripts/gds/git-merge-regen.js` by RELATIVE path with cwd at the clone
231
+ // root, so the driver is the clone's copy and it runs at step 6.
232
+ //
233
+ // Fail CLOSED and hand the merge to a human. The alternative — scrubbing the
234
+ // clone and running anyway — has to be right about every file it restores, and
235
+ // the closure is 80-odd files deep through src/instance-config and src/bongos/*,
236
+ // not the scripts/ directory alone.
237
+ const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
238
+ if (touched.code !== 0) return { resolved: false, skipped: 'diff_failed' };
239
+ const executes = branchTouchesExecutedCode(
240
+ touched.stdout.split('\n'),
241
+ deps.executedClosure || executedClosure(repoRoot),
242
+ );
243
+ if (executes.length) {
244
+ return { resolved: false, skipped: 'branch_modifies_executed_code', executes };
245
+ }
246
+
214
247
  // 6. Merge origin/main into the branch. The otb-regen driver resolves the
215
248
  // generated files; union resolves docs/adr/README.md; any genuine conflict
216
249
  // leaves the merge non-zero.
@@ -392,7 +425,8 @@ async function defaultRunGenerators({ work, execFile = execFileP } = {}) {
392
425
  // is a no-op for the strand.
393
426
  // Returns { refreshed: true, head_sha, branch } | { refreshed: false, skipped }.
394
427
  // skipped reasons: disabled | unconfigured | clone_failed | fetch_failed |
395
- // checkout_failed | merge_conflict | generator_failed | no_regen_diff |
428
+ // checkout_failed | diff_failed | branch_modifies_executed_code (with executes[]) |
429
+ // merge_conflict | generator_failed | no_regen_diff |
396
430
  // markers_remain | commit_failed | push_failed.
397
431
  async function refreshStaleGenerated({ branch } = {}, deps = {}) {
398
432
  const disabled = deps.disabled !== undefined ? deps.disabled : process.env.CONFLICT_RESOLVE_DISABLED === '1';
@@ -441,6 +475,22 @@ async function refreshStaleGenerated({ branch } = {}, deps = {}) {
441
475
  r = await git(['checkout', '-B', branch, `refs/remotes/origin/${branch}`]);
442
476
  if (r.code !== 0) return { refreshed: false, skipped: 'checkout_failed' };
443
477
 
478
+ // 4b. Same refusal as resolveBranchConflicts step 5b (task 1003367, audit B1). This
479
+ // path registers no merge driver, so its only execution vector is runGenerators
480
+ // at step 6 — but the check still belongs BEFORE the merge, because after it the
481
+ // tree is a mix of both sides and `origin/main...HEAD` no longer answers "what
482
+ // did this BRANCH change". Same closure, same fail-closed posture, so the two
483
+ // heals cannot drift into different security postures.
484
+ const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
485
+ if (touched.code !== 0) return { refreshed: false, skipped: 'diff_failed' };
486
+ const executes = branchTouchesExecutedCode(
487
+ touched.stdout.split('\n'),
488
+ deps.executedClosure || executedClosure(repoRoot),
489
+ );
490
+ if (executes.length) {
491
+ return { refreshed: false, skipped: 'branch_modifies_executed_code', executes };
492
+ }
493
+
444
494
  // 5. Merge origin/main. Expected CLEAN for this strand (the PR was mergeable and
445
495
  // the failing signal was a stale-generated CHECK, not a conflict). A genuine
446
496
  // conflict → abort + report merge_conflict so the resolveBranchConflicts path
@@ -0,0 +1,183 @@
1
+ // modules/lifecycle/executed-closure.js — which files does the merge resolver EXECUTE
2
+ // out of a builder's branch? (task 1003367, audit ref B1, 2026-08-29 security audit.)
3
+ //
4
+ // WHY. resolveBranchConflicts and refreshStaleGenerated clone a builder's branch into a
5
+ // temp tree and run code out of it with the LIVE SERVER's environment — DATABASE_URL,
6
+ // BUILDER_SECRET_KEY, GITHUB_PUSH_TOKEN, DISCORD_BOT_TOKEN all inherited. That happens
7
+ // on two separate paths, and the second one is easy to miss:
8
+ //
9
+ // 1. the `otb-regen` MERGE DRIVER. registerMergeDriver writes the driver command
10
+ // `node scripts/gds/git-merge-regen.js %O %A %B %P` with cwd at the clone root, so
11
+ // git runs the CLONE's copy during `git merge` — before any post-merge step.
12
+ // 2. the GENERATORS, run after the merge as execFile(node, [<clone>/scripts/gds/…]).
13
+ //
14
+ // Both resolve their repo root from __dirname, which is why the trusted-code problem
15
+ // cannot be solved by pointing them at the server's own copy: a server-rooted
16
+ // git-merge-regen.js would regenerate the SERVER's checkout instead of the clone.
17
+ //
18
+ // WHAT THIS MODULE IS FOR. The audit prescribed restoring scripts/gds/ from origin/main.
19
+ // That is not sufficient on its own: every generator requires ../../src/instance-config,
20
+ // and gen-api-docs additionally requires src/bongos/route-rank-check, api-prefix and
21
+ // routes/_helpers. A restore scoped to scripts/gds/ leaves those builder-controlled and
22
+ // the hole open. So rather than curate a list by hand, we COMPUTE the transitive
23
+ // require-closure of the real entrypoints and let the callers refuse to auto-resolve a
24
+ // branch that touches any of it.
25
+ //
26
+ // The closure is computed against the SERVER's trusted tree on purpose. The question is
27
+ // "what does OUR code execute", and asking the clone would let the branch answer it.
28
+ //
29
+ // THE RESIDUAL ASSUMPTION, stated so it can be re-checked rather than rediscovered.
30
+ // This is a STATIC walk of string-literal require()s, so it is sound exactly as far as
31
+ // that style holds. A computed require of real code — string concatenation, a template
32
+ // literal, a variable — inside a closure member would be invisible here and the closure
33
+ // would under-report what a branch can make the server run. That is not hypothetical:
34
+ // src/module-loader/loader.js is in the closure and computed-requires every module's
35
+ // route file, but only from loadModules(), which nothing on the generator path calls.
36
+ // tests/executed_closure.mjs pins the known computed requires per file and fails the
37
+ // build on a new one — which catches a change to OUR tree, and is therefore a tripwire,
38
+ // not a widening. If a generator ever starts loading modules, or a closure member
39
+ // switches to dynamic require, this walk must grow to match before it is trusted again.
40
+ //
41
+ // COST. computeExecutedClosure does synchronous readFileSync/statSync over ~80 files.
42
+ // It runs in the live server process, so that is worth knowing — but it is called from a
43
+ // ~5-minute background sweep, never a request path, and conflict-resolve.js caches it per
44
+ // repo root for the process lifetime. Revisit if the entrypoint set grows a lot, or if
45
+ // the cache key ever stops being a constant.
46
+ //
47
+ // Route files are deliberately NOT in here: src/bongos/route-rank-check.js reads them as
48
+ // DATA, never require()s them (ADR 0083's one-way rule, and its own comment says so), and
49
+ // gen-api-docs scans route sources textually. That distinction is what makes a narrow
50
+ // closure correct — the resolver still REGENERATES from the builder's merged tree, it
51
+ // just never EXECUTES it.
52
+
53
+ 'use strict';
54
+
55
+ const fs = require('node:fs');
56
+ const path = require('node:path');
57
+
58
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
59
+
60
+ // The two execution roots, as repo-relative POSIX paths.
61
+ //
62
+ // BOTH script-tree spellings are listed while the compat shim exists. Task 1003705 moves
63
+ // these to scripts/bongos/ and leaves scripts/gds/ holding WORKING forwarders, which git
64
+ // still executes by the driver's relative path — so a guard that knew only the new name
65
+ // would stop seeing edits to code that still runs. Missing entries are skipped, so this
66
+ // is correct before the rename, during the shim window, and after C3d retires it.
67
+ const ENTRYPOINTS = Object.freeze([
68
+ // 1. the merge driver (runs DURING `git merge`)
69
+ 'scripts/gds/git-merge-regen.js',
70
+ 'scripts/bongos/git-merge-regen.js',
71
+ // 2. the generators (run after the merge) — mirrors GENERATOR_SCRIPTS in
72
+ // conflict-resolve.js; asserted identical by tests/executed_closure.mjs so the two
73
+ // lists cannot drift apart.
74
+ 'scripts/gds/gen-repo-map.js',
75
+ 'scripts/bongos/gen-repo-map.js',
76
+ 'scripts/gds/gen-file-map.js',
77
+ 'scripts/bongos/gen-file-map.js',
78
+ 'scripts/gds/gen-session-index.js',
79
+ 'scripts/bongos/gen-session-index.js',
80
+ 'scripts/gds/copy-inventory.js',
81
+ 'scripts/bongos/copy-inventory.js',
82
+ 'scripts/gds/gen-api-docs.js',
83
+ 'scripts/bongos/gen-api-docs.js',
84
+ 'scripts/gds/gen-api-client.js',
85
+ 'scripts/bongos/gen-api-client.js',
86
+ ]);
87
+
88
+ // Files that are not require()d but still decide what runs. .gitattributes names which
89
+ // paths the otb-regen driver is applied to, so editing it changes what git hands the
90
+ // driver and how much of the tree that driver rewrites during a merge.
91
+ const TRUSTED_NON_CODE = Object.freeze(['.gitattributes']);
92
+
93
+ const REQUIRE_RE = /\brequire\(\s*(['"])([^'"]+)\1\s*\)/g;
94
+
95
+ function toPosix(p) {
96
+ return p.split(path.sep).join('/');
97
+ }
98
+
99
+ // Resolve a relative require specifier to a repo-relative file, trying the extensions
100
+ // node would. Returns null for bare/`node:` specifiers and for anything that escapes the
101
+ // repo — a require that leaves the tree is not a file a branch can edit.
102
+ function resolveRelative(fromFileAbs, spec, { root }) {
103
+ if (!spec.startsWith('.')) return null;
104
+ const base = path.resolve(path.dirname(fromFileAbs), spec);
105
+ const candidates = [base, base + '.js', base + '.json', path.join(base, 'index.js')];
106
+ for (const c of candidates) {
107
+ let st;
108
+ try {
109
+ st = fs.statSync(c);
110
+ } catch {
111
+ continue;
112
+ }
113
+ if (!st.isFile()) continue;
114
+ const rel = toPosix(path.relative(root, c));
115
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
116
+ return rel;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ // computeExecutedClosure — every file the resolver can execute out of a cloned branch.
122
+ //
123
+ // A STATIC walk of require() literals. It deliberately does not try to follow a computed
124
+ // require: the one that exists (gen-api-docs' require(path.join(CORE_ROOT,
125
+ // 'package.json'))) resolves to package.json, which is parsed as JSON rather than run.
126
+ // If a computed require of real CODE is ever added, the closure would under-report — so
127
+ // tests/executed_closure.mjs fails the build on any computed require inside the closure,
128
+ // which is the honest way to hold a static analysis to its assumption.
129
+ function computeExecutedClosure({ root = REPO_ROOT, entrypoints = ENTRYPOINTS } = {}) {
130
+ const seen = new Set();
131
+ const queue = [];
132
+
133
+ for (const rel of entrypoints) {
134
+ const abs = path.join(root, rel);
135
+ if (fs.existsSync(abs)) queue.push(rel);
136
+ }
137
+
138
+ while (queue.length) {
139
+ const rel = queue.shift();
140
+ if (seen.has(rel)) continue;
141
+ seen.add(rel);
142
+
143
+ const abs = path.join(root, rel);
144
+ let src;
145
+ try {
146
+ src = fs.readFileSync(abs, 'utf8');
147
+ } catch {
148
+ continue;
149
+ }
150
+ REQUIRE_RE.lastIndex = 0;
151
+ let m;
152
+ while ((m = REQUIRE_RE.exec(src))) {
153
+ const next = resolveRelative(abs, m[2], { root });
154
+ if (next && !seen.has(next)) queue.push(next);
155
+ }
156
+ }
157
+
158
+ for (const rel of TRUSTED_NON_CODE) seen.add(rel);
159
+ return Object.freeze([...seen].sort());
160
+ }
161
+
162
+ // branchTouchesExecutedCode — pure predicate over `git diff --name-only` output.
163
+ //
164
+ // Returns the SORTED INTERSECTION rather than a boolean so the caller can name the
165
+ // offending files in its skip reason. A resolver that refuses without saying which file
166
+ // tripped it is one nobody can act on.
167
+ function branchTouchesExecutedCode(touchedPaths, closure) {
168
+ const inClosure = new Set(closure);
169
+ const hits = [];
170
+ for (const raw of touchedPaths || []) {
171
+ const rel = toPosix(String(raw).trim());
172
+ if (rel && inClosure.has(rel)) hits.push(rel);
173
+ }
174
+ return [...new Set(hits)].sort();
175
+ }
176
+
177
+ module.exports = {
178
+ ENTRYPOINTS,
179
+ TRUSTED_NON_CODE,
180
+ REPO_ROOT,
181
+ computeExecutedClosure,
182
+ branchTouchesExecutedCode,
183
+ };
@@ -386,6 +386,21 @@ async function runSweep(deps = {}) {
386
386
  `strand:unresolvable_conflict${files ? `:${files}` : ''}`,
387
387
  `PR #${m.number} (${branch}) has a hand-written conflict (${files || 'unknown files'}) only its builder can resolve`
388
388
  );
389
+ } else if (rr && rr.skipped === 'branch_modifies_executed_code') {
390
+ // task 1003367 (audit B1): DEFINITIVE in exactly the sense P-3 means it —
391
+ // the resolver refused because the branch edits code the resolver would
392
+ // have executed, and no number of later sweeps changes that. Without this
393
+ // branch the skip fell through both chains with no log and no flag, so the
394
+ // PR retried silently every ~5 min forever. Announcing it is not polish:
395
+ // a refusal nobody is told about is indistinguishable from a resolver that
396
+ // quietly stopped working, and this file's own header says a strand must
397
+ // reach its builder when it is DETECTED.
398
+ const files = (rr.executes || []).slice(0, 5).join(',');
399
+ await announceStrand(
400
+ r,
401
+ `strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
402
+ `PR #${m.number} (${branch}) changes code the merge resolver executes (${files || 'unknown files'}) — it will not be auto-resolved; a human must land it`
403
+ );
389
404
  }
390
405
  } catch (e) {
391
406
  errFn(`[publish-reconciler] conflict-resolve for task ${r.task_id} (non-blocking): ${e && e.message}`);
@@ -470,6 +485,19 @@ async function runSweep(deps = {}) {
470
485
  logFn(
471
486
  `[publish-reconciler] task ${r.task_id} (${branch}) PR #${m.number} red (${m.reason}) but regeneration produced no diff — a real check failure, left for a human`
472
487
  );
488
+ } else if (rr && rr.skipped === 'branch_modifies_executed_code') {
489
+ // task 1003367 (audit B1). A tree-level determination, so it burns a cap
490
+ // slot like the two above rather than retrying as if it were flaky infra:
491
+ // the answer is the same on every future sweep. Announced for the same
492
+ // reason as the conflict path — silence here is a PR that never lands and
493
+ // never says why.
494
+ attempts.set(branch, n + 1);
495
+ const files = (rr.executes || []).slice(0, 5).join(',');
496
+ await announceStrand(
497
+ r,
498
+ `strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
499
+ `PR #${m.number} (${branch}) changes code the regen heal executes (${files || 'unknown files'}) — it will not be auto-healed; a human must land it`
500
+ );
473
501
  }
474
502
  } catch (e) {
475
503
  errFn(`[publish-reconciler] regen-heal for task ${r.task_id} (non-blocking): ${e && e.message}`);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.699",
3
+ "version": "1.19.701",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.699",
9
+ "version": "1.19.701",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.699",
3
+ "version": "1.19.701",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
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.699'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.701'; // 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');