@bongos/core 1.19.702 → 1.19.704

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,482 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/agents-sync.js — reconcile .claude/agents/*.md into
3
+ // agents_definitions at deploy (task 1002490, goal 1000038 Phase 1, T6).
4
+ //
5
+ // WHY. The registry is what the spawn path fires from, so it — not the tree — is
6
+ // the operative list. Files-are-source means a committed definition must reach
7
+ // that table on deploy without anyone running anything by hand, and without a
8
+ // deploy ever silently undoing an operator's decision.
9
+ //
10
+ // FOUR RULES, each of which is a way this could go wrong:
11
+ //
12
+ // 1. A `source=db` row is NEVER touched. An instance-authored agent is not in
13
+ // any file, so a file-driven reconcile that "cleaned up" rows with no
14
+ // matching file would delete exactly the agents nobody can restore from git.
15
+ // This script only ever writes rows it owns.
16
+ //
17
+ // 2. `author_rank` is NEVER read from the file, and never taken from git where
18
+ // it could grant anything. A committed file cannot carry authority (ADR
19
+ // 0016) — writing `author_rank: archon` into frontmatter must buy nothing.
20
+ // The obvious implementation, "resolve the committer and stamp their live
21
+ // rank", has the same hole one level down: `git log --format=%ae` reports
22
+ // the commit AUTHOR, which the committer sets freely with `git config` or
23
+ // `--author`, so it is attacker-controlled too. Rather than pretend that
24
+ // value can be trusted, it is denied any power — see stampableAuthorRank:
25
+ // a definition reaching a PROTECTED module is stamped NULL regardless of
26
+ // git, which the wall treats as insufficient, so it imports disabled and
27
+ // arming it becomes an operator act with a real session behind it.
28
+ //
29
+ // 3. The scope wall is RE-RUN here, server-side. The same check runs when a
30
+ // definition is authored, but that check ran against the author's rank at
31
+ // authoring time on a machine outside the trust boundary. A definition
32
+ // scoped onto protected surfaces imports DISABLED and FLAGGED — present in
33
+ // the registry so it can be seen and fixed, never armed.
34
+ //
35
+ // 4. The sync NEVER ARMS ANYTHING. `enabled` is an operator decision, not a
36
+ // file one — by exactly the reasoning in rule 2. A new row inserts disabled;
37
+ // an existing row keeps whatever `enabled` an operator set, unless it is
38
+ // flagged, in which case it is forced off. So `git push` can add an agent to
39
+ // the registry and can never switch one on.
40
+ //
41
+ // FAIL-CLOSED IN THE DIRECTION THAT MATTERS. An unresolvable author yields a
42
+ // NULL rank, which the validator treats as insufficient for any protected scope,
43
+ // and which the schema's agents_definitions_armed_needs_author_chk independently
44
+ // refuses to arm. Three layers have to agree before an agent fires; this script
45
+ // is only one of them.
46
+ //
47
+ // Run: node scripts/gds/agents-sync.js [--dry-run] [--json] [--dir <path>]
48
+
49
+ 'use strict';
50
+
51
+ const fs = require('node:fs');
52
+ const path = require('node:path');
53
+ const { execFileSync } = require('node:child_process');
54
+
55
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
56
+ const DEFAULT_DIR = path.join(REPO_ROOT, '.claude', 'agents');
57
+
58
+ // The module owns the definition grammar; this script is its deploy-time
59
+ // carrier. Requiring the module's own pure validator from the module's own
60
+ // script is the same shape as context-pack.js reading task-classifier.
61
+ const validate = require(path.join(REPO_ROOT, 'modules/agents/lib/validate.js'));
62
+
63
+ // Validator error codes that mean "the SCOPE is refused", as opposed to "this is
64
+ // not a definition". The distinction is the whole of rule 3: a scope refusal is
65
+ // imported as a flagged row so somebody can SEE it, while a shape error has no
66
+ // row to import — there is no definition there to put in the registry.
67
+ const SCOPE_CODES = Object.freeze([
68
+ 'scope_protected_rank',
69
+ 'scope_out_of_bounds',
70
+ // task 1002492. The definition is well-formed; what failed is that its paths
71
+ // could not be checked. That is importable-but-flagged (visible, disabled),
72
+ // never a silent skip — "nobody checked" must surface as loudly as "refused".
73
+ 'scope_paths_uncheckable',
74
+ ]);
75
+
76
+ // Does this definition reach a PROTECTED surface — by module OR by path? If so,
77
+ // the git-derived author identity is not consulted at all (see below), which is
78
+ // what makes rule 2 hold against a value the author controls.
79
+ //
80
+ // The path half arrived with task 1002492. Before it, this asked about modules
81
+ // only, which left the withholding rule with the same asymmetry the scope wall
82
+ // itself had: naming `modules/government/` as a PATH reached neither check, so a
83
+ // spoofed identity could still have stamped a rank onto a definition pointed at
84
+ // the authority surface. Both halves, or neither is a wall.
85
+ function declaresProtectedScope(candidate, protectedModules = [], isProtectedPath = null) {
86
+ const modules = Array.isArray(candidate?.scope_modules) ? candidate.scope_modules : [];
87
+ if (modules.some((m) => protectedModules.includes(m))) return true;
88
+ const paths = Array.isArray(candidate?.scope_paths) ? candidate.scope_paths : [];
89
+ if (paths.length === 0) return false;
90
+ // No matcher and declared paths: unanswerable, so treated as protected — the
91
+ // same fail-closed reading the validator applies (scope_paths_uncheckable).
92
+ if (typeof isProtectedPath !== 'function') return true;
93
+ return paths.some((p) => {
94
+ try { return isProtectedPath(p) !== false; } catch { return true; }
95
+ });
96
+ }
97
+
98
+ // THE RANK THAT ACTUALLY GETS STAMPED, and the answer to the spoofable-identity
99
+ // problem in lastCommitterLogin above.
100
+ //
101
+ // The naive design — resolve the committer, read their live rank, stamp it — is
102
+ // an escalation path: the committer email is attacker-controlled, so anyone who
103
+ // can land a commit can claim to be an Archon and arm an agent scoped onto the
104
+ // authority surface. Verifying the identity properly would mean a trustworthy
105
+ // server-side record of who authored each file, which this script does not have.
106
+ //
107
+ // So instead of trying to make the input trustworthy, the input is denied any
108
+ // power: WHERE THE RANK COULD GRANT SOMETHING, IT IS NOT TAKEN FROM GIT.
109
+ //
110
+ // - definition reaches a protected module → rank is NULL, full stop. The wall
111
+ // then flags it (an unresolved rank is insufficient, never exempt) and it
112
+ // imports disabled. Arming it is an operator act with a real session behind
113
+ // it, which is where the authority decision belongs.
114
+ // - definition reaches nothing protected → the rank gates nothing, so the
115
+ // hint is recorded as the provenance it is.
116
+ //
117
+ // The result is that spoofing the git identity buys exactly nothing: on the only
118
+ // path where rank matters, no git-derived value is read.
119
+ function stampableAuthorRank(candidate, { gitRank = null, protectedModules = [], isProtectedPath = null } = {}) {
120
+ if (declaresProtectedScope(candidate, protectedModules, isProtectedPath)) return null;
121
+ return gitRank;
122
+ }
123
+
124
+ // ---- pure core -------------------------------------------------------------
125
+
126
+ // Split `---\n<yaml>\n---\n<body>` into frontmatter text and body.
127
+ // Deliberately NOT a YAML parser: the definition grammar is a flat map of
128
+ // scalars and simple lists, and pulling a YAML dependency into a deploy script
129
+ // to read four keys would be a supply-chain decision, not a convenience. The
130
+ // validator is the thing that decides whether the result is a definition — this
131
+ // only has to hand it a plain object faithfully.
132
+ function splitFrontmatter(text) {
133
+ const s = String(text || '');
134
+ // Tolerates a BOM and CRLF, both of which a Windows-authored file will carry.
135
+ //
136
+ // The BOM is stripped BY CODE POINT rather than matched inside the regex. Two
137
+ // earlier spellings both failed review for the same reason: a literal U+FEFF
138
+ // in the pattern is invisible, so `/^<BOM>?---/` reads as `/^?---/` — an
139
+ // apparent no-op quantifier on a zero-width assertion — and an escape can be
140
+ // re-interpreted back into that literal by tooling on the way to disk. This
141
+ // form is unambiguous ASCII, and the file now contains no invisible
142
+ // characters at all. Behaviour that is correct but unreadable is still a
143
+ // defect; it cost two review rounds here.
144
+ const withoutBom = s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
145
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(withoutBom);
146
+ if (!m) return { frontmatter: null, body: s };
147
+ return { frontmatter: m[1], body: m[2] };
148
+ }
149
+
150
+ // `key: value` / `key: [a, b]` / `key:\n - a\n - b`. Unknown keys are kept —
151
+ // the validator refuses what it does not recognise, and silently dropping a key
152
+ // here would turn a typo into a default instead of an error.
153
+ function parseFrontmatter(fmText) {
154
+ const out = {};
155
+ if (typeof fmText !== 'string') return out;
156
+ const lines = fmText.split(/\r?\n/);
157
+ let listKey = null;
158
+ for (const raw of lines) {
159
+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
160
+ const item = /^\s*-\s+(.*)$/.exec(raw);
161
+ if (item && listKey) { out[listKey].push(scalar(item[1])); continue; }
162
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(raw);
163
+ if (!kv) { listKey = null; continue; }
164
+ const [, key, rest] = kv;
165
+ if (rest === '') { listKey = key; out[key] = []; continue; }
166
+ listKey = null;
167
+ const inline = /^\[(.*)\]$/.exec(rest.trim());
168
+ out[key] = inline
169
+ ? inline[1].split(',').map((p) => scalar(p)).filter((p) => p !== '')
170
+ : scalar(rest);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ function scalar(raw) {
176
+ let v = String(raw).trim();
177
+ // Strip a trailing comment only when the value is not quoted — a persona-ish
178
+ // string legitimately contains '#'.
179
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(v);
180
+ if (quoted) return quoted[2];
181
+ v = v.replace(/\s+#.*$/, '').trim();
182
+ if (v === 'true') return true;
183
+ if (v === 'false') return false;
184
+ return v;
185
+ }
186
+
187
+ // One file → the candidate object the validator takes. The BODY is the persona:
188
+ // a persona is prose, and prose does not belong in frontmatter.
189
+ function parseAgentFile(text, { name } = {}) {
190
+ const { frontmatter, body } = splitFrontmatter(text);
191
+ const fm = parseFrontmatter(frontmatter);
192
+ const candidate = { ...fm };
193
+ // The filename is the identity when frontmatter does not override it, so
194
+ // historian.md is the `historian` agent without having to say so twice.
195
+ if (!candidate.name && name) candidate.name = name;
196
+ const persona = String(body || '').trim();
197
+ if (persona) candidate.persona = persona;
198
+ // `trigger: event` + `event: task.shipped` is the readable file spelling of
199
+ // the column pair the schema stores. Mapped here rather than in the validator
200
+ // so the validator keeps taking exactly the row shape the DB holds.
201
+ if (candidate.trigger && !candidate.trigger_type) {
202
+ candidate.trigger_type = candidate.trigger;
203
+ delete candidate.trigger;
204
+ }
205
+ if (candidate.event && !candidate.trigger_spec) {
206
+ candidate.trigger_spec = { event: candidate.event };
207
+ delete candidate.event;
208
+ }
209
+ return candidate;
210
+ }
211
+
212
+ /**
213
+ * Decide what to do with ONE parsed candidate. Pure: no disk, no database, no
214
+ * git. Returns a plan the caller applies (or prints, under --dry-run).
215
+ *
216
+ * Outcomes:
217
+ * skip — not a definition. No row; the reason is reported.
218
+ * flagged — a real definition whose SCOPE is refused. Imported disabled, with
219
+ * scope_violation set, so it is visible and fixable.
220
+ * upsert — clean. Imported; `enabled` is left to the operator (rule 4).
221
+ * protected — a `source=db` row owns this name. Never touched (rule 1).
222
+ */
223
+ function planOne(candidate, {
224
+ authorRank: gitRank = null,
225
+ allowedModules = [],
226
+ protectedModules = [],
227
+ isProtectedPath = null,
228
+ existing = null,
229
+ } = {}) {
230
+ const name = typeof candidate?.name === 'string' ? candidate.name : null;
231
+ // The caller passes the GIT-DERIVED rank; what gets stamped is whatever
232
+ // survives stampableAuthorRank, which withholds it entirely on any definition
233
+ // that reaches a protected module. Everything below uses `authorRank`, so the
234
+ // spoofable value cannot reach the wall by a path that forgets to launder it.
235
+ const authorRank = stampableAuthorRank(candidate, { gitRank, protectedModules, isProtectedPath });
236
+
237
+ // Rule 1, checked before anything else: an instance-authored row owns its
238
+ // name. A file that happens to share it does not get to overwrite it, and
239
+ // this is reported rather than silent so the collision is visible.
240
+ if (existing && existing.source === 'db') {
241
+ return { action: 'protected', name, reason: 'an instance-authored (source=db) row owns this name — the file is ignored' };
242
+ }
243
+
244
+ const verdict = validate.validateAgentDefinition(candidate, {
245
+ authorRank, allowedModules, protectedModules, isProtectedPath,
246
+ });
247
+
248
+ if (verdict.ok) {
249
+ return {
250
+ action: 'upsert',
251
+ name,
252
+ value: verdict.value,
253
+ authorRank,
254
+ scopeViolation: null,
255
+ // Rule 4: never arm. Absent an existing row this is a fresh insert, which
256
+ // the schema defaults to disabled; on update we carry the operator's own
257
+ // setting forward untouched.
258
+ enabled: existing ? existing.enabled === true : false,
259
+ };
260
+ }
261
+
262
+ const codes = verdict.errors.map((e) => e.code);
263
+ const scopeOnly = codes.length > 0 && codes.every((c) => SCOPE_CODES.includes(c));
264
+ if (!scopeOnly) {
265
+ return { action: 'skip', name, reason: verdict.errors.map((e) => e.message).join(' · ') };
266
+ }
267
+
268
+ // Rule 3. The definition is well-formed; only its scope is refused. To import
269
+ // it we need the NORMALIZED row, which the validator only returns on success —
270
+ // so it is re-run with the scope checks satisfied purely to obtain that shape.
271
+ //
272
+ // This cannot widen anything: the re-run's verdict is discarded except for
273
+ // `value`, the row is forced enabled:false with scope_violation set, and the
274
+ // schema's agents_definitions_flagged_not_armed_chk refuses to store a flagged
275
+ // row as armed even if this code were wrong.
276
+ const declaredModules = Array.isArray(candidate.scope_modules) ? candidate.scope_modules : [];
277
+ const shapeOnly = validate.validateAgentDefinition(candidate, {
278
+ authorRank: validate.RANK_ORDER[validate.RANK_ORDER.length - 1],
279
+ allowedModules: [...new Set([...allowedModules, ...declaredModules])],
280
+ protectedModules: [],
281
+ // The re-run clears the MODULE wall to recover the normalized shape. The
282
+ // path matcher is deliberately left in place: a matcher that says a path is
283
+ // protected is a fact about the path, not about the author, so laundering
284
+ // it here would store a row whose scope no longer matches its flag.
285
+ isProtectedPath: () => false,
286
+ });
287
+ if (!shapeOnly.ok) {
288
+ // Defensive: the two runs disagreeing means an error that is scope-coded but
289
+ // not scope-caused. Skipping is the fail-closed answer.
290
+ return { action: 'skip', name, reason: verdict.errors.map((e) => e.message).join(' · ') };
291
+ }
292
+ return {
293
+ action: 'flagged',
294
+ name,
295
+ value: shapeOnly.value,
296
+ authorRank,
297
+ scopeViolation: verdict.errors.map((e) => e.message).join(' · '),
298
+ enabled: false,
299
+ };
300
+ }
301
+
302
+ // The whole plan, for a list of { name, candidate } and a lookup of existing
303
+ // rows by name. Pure, so the reconcile's DECISIONS are unit-testable with no
304
+ // database standing up.
305
+ function planSync(files, { existingByName = new Map(), authorRankFor = () => null, allowedModules = [], protectedModules = [], isProtectedPath = null } = {}) {
306
+ return files.map((f) => ({
307
+ file: f.file || null,
308
+ ...planOne(f.candidate, {
309
+ authorRank: authorRankFor(f.file),
310
+ allowedModules,
311
+ protectedModules,
312
+ isProtectedPath,
313
+ existing: existingByName.get(f.candidate?.name) || null,
314
+ }),
315
+ }));
316
+ }
317
+
318
+ // ---- disk + git ------------------------------------------------------------
319
+
320
+ // An ABSENT directory is a legitimate state, not an error: `.claude/agents/`
321
+ // does not exist in the core tree at all today, and an instance that ships no
322
+ // agents must deploy cleanly. Zero files, exit 0, nothing written.
323
+ function readAgentFiles(dir = DEFAULT_DIR) {
324
+ let names;
325
+ try {
326
+ names = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
327
+ } catch (err) {
328
+ if (err.code === 'ENOENT') return [];
329
+ throw err;
330
+ }
331
+ return names.sort().map((f) => {
332
+ const full = path.join(dir, f);
333
+ return {
334
+ file: path.relative(REPO_ROOT, full).split(path.sep).join('/'),
335
+ candidate: parseAgentFile(fs.readFileSync(full, 'utf8'), { name: f.replace(/\.md$/, '') }),
336
+ };
337
+ });
338
+ }
339
+
340
+ // The login that last committed a file — a PROVENANCE HINT, and explicitly NOT
341
+ // an authority input. Read `declaresProtectedScope` below before using it.
342
+ //
343
+ // An earlier version of this comment claimed the GitHub noreply address "cannot
344
+ // be spoofed by setting a local git name". THAT WAS WRONG, and it was the more
345
+ // dangerous half of the mistake: `git log --format=%ae` reports the commit
346
+ // AUTHOR, which any committer sets freely with `git config user.email` or
347
+ // `--author`, noreply-shaped values included. Nothing here verifies the address
348
+ // against GitHub. So this value is attacker-controlled, and a comment asserting
349
+ // otherwise invites the next reviewer to skip the one check that matters.
350
+ //
351
+ // It is kept because provenance is genuinely useful where nothing is riding on
352
+ // it. The wall is that it is never consulted where it could grant anything.
353
+ function lastCommitterLogin(file, { runGit = defaultRunGit } = {}) {
354
+ let out;
355
+ try { out = runGit(['log', '-1', '--format=%ae%n%an', '--', file]); }
356
+ catch { return null; }
357
+ const [email = '', authorName = ''] = String(out || '').split(/\r?\n/);
358
+ const noreply = /^(?:\d+\+)?([A-Za-z0-9-]+)@users\.noreply\.github\.com$/.exec(email.trim());
359
+ if (noreply) return noreply[1];
360
+ return authorName.trim() || null;
361
+ }
362
+
363
+ function defaultRunGit(args) {
364
+ return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' });
365
+ }
366
+
367
+ module.exports = {
368
+ splitFrontmatter,
369
+ parseFrontmatter,
370
+ parseAgentFile,
371
+ declaresProtectedScope,
372
+ stampableAuthorRank,
373
+ planOne,
374
+ planSync,
375
+ readAgentFiles,
376
+ lastCommitterLogin,
377
+ SCOPE_CODES,
378
+ DEFAULT_DIR,
379
+ };
380
+
381
+ // ---- facade ----------------------------------------------------------------
382
+
383
+ async function main() {
384
+ const argv = process.argv.slice(2);
385
+ const dryRun = argv.includes('--dry-run');
386
+ const asJson = argv.includes('--json');
387
+ const dirIdx = argv.indexOf('--dir');
388
+ const dir = dirIdx >= 0 ? argv[dirIdx + 1] : DEFAULT_DIR;
389
+
390
+ const files = readAgentFiles(dir);
391
+ if (files.length === 0) {
392
+ if (asJson) console.log(JSON.stringify({ ok: true, scanned: 0, plan: [] }));
393
+ else console.log(`agents-sync: no definitions under ${path.relative(REPO_ROOT, dir) || dir} — nothing to reconcile.`);
394
+ return 0;
395
+ }
396
+
397
+ const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
398
+ const scopeMap = require(path.join(REPO_ROOT, 'src/bongos/module-scope-map.js'));
399
+ const allowedModules = scopeMap.moduleKeys();
400
+ const protectedModules = scopeMap.protectedModules();
401
+ // The protected-surface registry, read the way every other enforcer reads it:
402
+ // synchronously from the checkout, no DB and no server (ADR 0043). surfaceFor
403
+ // returns the governing registry entry or null, so this is the boolean the
404
+ // validator's scope_paths wall asks for.
405
+ const permissionPaths = require(path.join(REPO_ROOT, 'src/bongos/permission-path-check.js'));
406
+ const isProtectedPath = (p) => permissionPaths.surfaceFor(p) !== null;
407
+
408
+ const { rows: existingRows } = await pool.query(
409
+ 'SELECT name, source, enabled FROM agents_definitions'
410
+ );
411
+ const existingByName = new Map(existingRows.map((r) => [r.name, r]));
412
+
413
+ // ONE git subprocess per file, memoised — `authorRankFor` is called again from
414
+ // planSync, and re-shelling out there doubled the blocking spawns for an answer
415
+ // that cannot have changed mid-run.
416
+ const loginByFile = new Map(files.map((f) => [f.file, lastCommitterLogin(f.file)]));
417
+ // One query for every distinct committer, not one per file.
418
+ const logins = [...new Set([...loginByFile.values()].filter(Boolean))];
419
+ const rankByLogin = new Map();
420
+ if (logins.length) {
421
+ const { rows } = await pool.query(
422
+ 'SELECT github_login, rank FROM builders WHERE lower(github_login) = ANY($1)',
423
+ [logins.map((l) => l.toLowerCase())]
424
+ );
425
+ for (const r of rows) rankByLogin.set(String(r.github_login).toLowerCase(), r.rank);
426
+ }
427
+ // The git-derived rank. planOne withholds it on any protected-scope definition
428
+ // (stampableAuthorRank) — this function never decides whether it may be used.
429
+ const authorRankFor = (file) => {
430
+ const login = loginByFile.get(file);
431
+ return login ? (rankByLogin.get(login.toLowerCase()) || null) : null;
432
+ };
433
+
434
+ const plan = planSync(files, { existingByName, authorRankFor, allowedModules, protectedModules, isProtectedPath });
435
+
436
+ if (dryRun) {
437
+ if (asJson) console.log(JSON.stringify({ ok: true, scanned: files.length, plan }, null, 2));
438
+ else for (const p of plan) console.log(` ${p.action.padEnd(9)} ${p.name || p.file}${p.reason ? ` — ${p.reason}` : ''}`);
439
+ return 0;
440
+ }
441
+
442
+ let upserted = 0;
443
+ let flagged = 0;
444
+ for (const p of plan) {
445
+ if (p.action === 'skip' || p.action === 'protected') {
446
+ console.error(`agents-sync: ${p.action} ${p.name || p.file} — ${p.reason}`);
447
+ continue;
448
+ }
449
+ const v = p.value;
450
+ // ON CONFLICT targets the name unique constraint, and the WHERE clause is
451
+ // rule 1 made structural: a row that turned source=db between the read above
452
+ // and this write is not overwritten by the race either.
453
+ await pool.query(
454
+ `INSERT INTO agents_definitions
455
+ (name, title, persona, trigger_type, trigger_spec, model_tier,
456
+ scope_modules, scope_paths, scope_violation, source, provenance,
457
+ author_rank, source_path, last_synced_at, enabled)
458
+ VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9,'file','built-in',$10,$11,now(),$12)
459
+ ON CONFLICT (name) DO UPDATE SET
460
+ title = EXCLUDED.title, persona = EXCLUDED.persona,
461
+ trigger_type = EXCLUDED.trigger_type, trigger_spec = EXCLUDED.trigger_spec,
462
+ model_tier = EXCLUDED.model_tier, scope_modules = EXCLUDED.scope_modules,
463
+ scope_paths = EXCLUDED.scope_paths, scope_violation = EXCLUDED.scope_violation,
464
+ author_rank = EXCLUDED.author_rank, source_path = EXCLUDED.source_path,
465
+ last_synced_at = now(), enabled = EXCLUDED.enabled, updated_at = now()
466
+ WHERE agents_definitions.source = 'file'`,
467
+ [v.name, v.title, v.persona, v.trigger_type, JSON.stringify(v.trigger_spec), v.model_tier,
468
+ v.scope_modules, v.scope_paths, p.scopeViolation, p.authorRank, p.file || null, p.enabled]
469
+ );
470
+ if (p.action === 'flagged') flagged += 1; else upserted += 1;
471
+ }
472
+ console.log(`agents-sync: ${files.length} file(s) — ${upserted} reconciled, ${flagged} flagged (disabled), `
473
+ + `${plan.filter((p) => p.action === 'skip').length} skipped, ${plan.filter((p) => p.action === 'protected').length} db-owned.`);
474
+ return 0;
475
+ }
476
+
477
+ if (require.main === module) {
478
+ main().then((code) => { process.exitCode = code; }).catch((err) => {
479
+ console.error(`agents-sync: ${err && err.message ? err.message : err}`);
480
+ process.exitCode = 1;
481
+ });
482
+ }
@@ -358,3 +358,26 @@ if [ "${OTB_POST_DEPLOY_CHECK:-0}" = "1" ]; then
358
358
  echo "post-deploy: python3 not found — skipping whole-world check" >&2
359
359
  fi
360
360
  fi
361
+
362
+ # ---------- post-deploy: reconcile the agent registry (task 1002490) ----------
363
+ #
364
+ # .claude/agents/*.md -> agents_definitions. This runs HERE because migrate.sh is
365
+ # the one deploy-time hook the portable core owns: the droplet's own deploy.sh
366
+ # calls it, and the schema the reconcile writes into is applied a few lines
367
+ # above, so the table is guaranteed to exist by the time this runs.
368
+ #
369
+ # FAIL-OPEN, deliberately. A reconcile that cannot run is a registry that is
370
+ # stale by one deploy; a reconcile that aborts the deploy is an outage. The
371
+ # script itself is already fail-closed in the direction that matters (an
372
+ # unresolvable author, or any protected scope, imports disabled+flagged), so the
373
+ # worst case here is an agent that does not appear until the next deploy.
374
+ #
375
+ # Silent when the module is off: `agents` is default:false, so on most instances
376
+ # there is no table to reconcile into and nothing to say about it.
377
+ if node -e "process.exit(require('./src/modules').isModuleEnabled('agents') ? 0 : 1)" 2>/dev/null; then
378
+ echo
379
+ echo "post-deploy: reconciling .claude/agents/ into the agent registry"
380
+ if ! node scripts/gds/agents-sync.js; then
381
+ echo "post-deploy: agents-sync failed — the registry is stale by one deploy, not broken" >&2
382
+ fi
383
+ fi
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.702'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.704'; // 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');