@bongos/core 1.19.702 → 1.19.703

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.702",
6
- "core_contract": "1.19.702",
7
- "source_commit": "3b1a2e8029758a6ee091812baccb9e24fdc4670a",
5
+ "core_version": "1.19.703",
6
+ "core_contract": "1.19.703",
7
+ "source_commit": "f85beb07ce74ab0f248774c68e6d16453aa964c1",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T03:33:51.903Z",
9
+ "built_at": "2026-09-13T03:53:32.093Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 477,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2130,
14
+ "functional_verbatim": 2132,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2631,
20
- "tree_sha256": "7bf1dff3812c90015cc9d2476643856d5c6e9e950d26c6859e219c0eab9796b5",
19
+ "file_count": 2633,
20
+ "tree_sha256": "4890a8f49b9e7da75dd0e91d122be5cc3c64802bca863b7eae48b237cab5e9b3",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "7458093aecd8dd3fcc7753356a12e995b25663e9251a295c406e4ecc4cc54c27"
2805
+ "sha256": "f777f91ad88b47ff271da5bbc1670507ed77fa105df07e3d15dca6912617baef"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -7782,12 +7782,12 @@
7782
7782
  {
7783
7783
  "path": "package-lock.json",
7784
7784
  "mode": "0000644",
7785
- "sha256": "1aa096840e001b544038432419a0e07577014954f7cb83d8e19c716704e3f36c"
7785
+ "sha256": "d63e44b7d4f919455708ad0fd9041ef90552639733c506b1fdc06fe2e2ccd12b"
7786
7786
  },
7787
7787
  {
7788
7788
  "path": "package.json",
7789
7789
  "mode": "0000644",
7790
- "sha256": "cee79b868fecce6053d7d04108233e795a92c3635854bea1bf3003b909953e20"
7790
+ "sha256": "ff9eefa1e4c9f66ddd29b8a077ef567602ca79e69ab569b13f72e9221289b2ed"
7791
7791
  },
7792
7792
  {
7793
7793
  "path": "public-docs/index.html",
@@ -7859,6 +7859,11 @@
7859
7859
  "mode": "0000644",
7860
7860
  "sha256": "9c7ef61b64e41ee5dd6d913883e16dfed6d44bd6eefa4960b8dde566a63b5621"
7861
7861
  },
7862
+ {
7863
+ "path": "scripts/gds/agents-sync.js",
7864
+ "mode": "0000644",
7865
+ "sha256": "2e1a55ae7187a8ee970873a108a7989fed121f1ac1f92361d7b9bc61987056a2"
7866
+ },
7862
7867
  {
7863
7868
  "path": "scripts/gds/api.js",
7864
7869
  "mode": "0000644",
@@ -9177,7 +9182,7 @@
9177
9182
  {
9178
9183
  "path": "scripts/migrate.sh",
9179
9184
  "mode": "0000644",
9180
- "sha256": "0768bf9c9d6368ae673e1e5ac161cfe9bae4d113129f11f8b89afeeef1f9dc1b"
9185
+ "sha256": "669ba0cd4e0c5ad2c7809364e4ac17b24bfa3f8542fae5ba7ec05e9bcf77053f"
9181
9186
  },
9182
9187
  {
9183
9188
  "path": "scripts/migrate_cost_ledger.sh",
@@ -9552,7 +9557,7 @@
9552
9557
  {
9553
9558
  "path": "src/module-api.js",
9554
9559
  "mode": "0000644",
9555
- "sha256": "be1bd4054da76be7eb7aa62d3e9ee2805de116d811e25043a49bbd4731ad1643"
9560
+ "sha256": "6cb88cea4e61987151e1872011b8667189ef59d03f3999be5950ba716ad9b05e"
9556
9561
  },
9557
9562
  {
9558
9563
  "path": "src/module-loader/catalog.js",
@@ -9664,6 +9669,11 @@
9664
9669
  "mode": "0000644",
9665
9670
  "sha256": "8cc261471a36e3c447df57b46082997b6a50da3c8ff4b214787ae55ab27cb9ef"
9666
9671
  },
9672
+ {
9673
+ "path": "tests/agents_sync.mjs",
9674
+ "mode": "0000644",
9675
+ "sha256": "96197b8e25d9a8620cd3e02b7c96137d7118543662555c73d74e22a6b6161953"
9676
+ },
9667
9677
  {
9668
9678
  "path": "tests/agents_validate.mjs",
9669
9679
  "mode": "0000644",
@@ -1863,5 +1863,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1863
1863
  landed since 1.19.700 with no explicit bump. run 34735311256. (task 1002620)
1864
1864
  1.19.702 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1865
1865
  landed since 1.19.701 with no explicit bump. run 34735775228. (task 1002620)
1866
+ 1.19.703 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1867
+ landed since 1.19.702 with no explicit bump. run 34736566183. (task 1002620)
1866
1868
  ---------------------------------------------------------------------------
1867
1869
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.702",
3
+ "version": "1.19.703",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.702",
9
+ "version": "1.19.703",
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.702",
3
+ "version": "1.19.703",
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",
@@ -0,0 +1,448 @@
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(['scope_protected_rank', 'scope_out_of_bounds']);
68
+
69
+ // Does this definition reach a PROTECTED module? If so, the git-derived author
70
+ // identity is not consulted at all (see below) — which is what makes rule 2 hold
71
+ // against a value the author controls.
72
+ function declaresProtectedScope(candidate, protectedModules = []) {
73
+ const declared = Array.isArray(candidate?.scope_modules) ? candidate.scope_modules : [];
74
+ return declared.some((m) => protectedModules.includes(m));
75
+ }
76
+
77
+ // THE RANK THAT ACTUALLY GETS STAMPED, and the answer to the spoofable-identity
78
+ // problem in lastCommitterLogin above.
79
+ //
80
+ // The naive design — resolve the committer, read their live rank, stamp it — is
81
+ // an escalation path: the committer email is attacker-controlled, so anyone who
82
+ // can land a commit can claim to be an Archon and arm an agent scoped onto the
83
+ // authority surface. Verifying the identity properly would mean a trustworthy
84
+ // server-side record of who authored each file, which this script does not have.
85
+ //
86
+ // So instead of trying to make the input trustworthy, the input is denied any
87
+ // power: WHERE THE RANK COULD GRANT SOMETHING, IT IS NOT TAKEN FROM GIT.
88
+ //
89
+ // - definition reaches a protected module → rank is NULL, full stop. The wall
90
+ // then flags it (an unresolved rank is insufficient, never exempt) and it
91
+ // imports disabled. Arming it is an operator act with a real session behind
92
+ // it, which is where the authority decision belongs.
93
+ // - definition reaches nothing protected → the rank gates nothing, so the
94
+ // hint is recorded as the provenance it is.
95
+ //
96
+ // The result is that spoofing the git identity buys exactly nothing: on the only
97
+ // path where rank matters, no git-derived value is read.
98
+ function stampableAuthorRank(candidate, { gitRank = null, protectedModules = [] } = {}) {
99
+ if (declaresProtectedScope(candidate, protectedModules)) return null;
100
+ return gitRank;
101
+ }
102
+
103
+ // ---- pure core -------------------------------------------------------------
104
+
105
+ // Split `---\n<yaml>\n---\n<body>` into frontmatter text and body.
106
+ // Deliberately NOT a YAML parser: the definition grammar is a flat map of
107
+ // scalars and simple lists, and pulling a YAML dependency into a deploy script
108
+ // to read four keys would be a supply-chain decision, not a convenience. The
109
+ // validator is the thing that decides whether the result is a definition — this
110
+ // only has to hand it a plain object faithfully.
111
+ function splitFrontmatter(text) {
112
+ const s = String(text || '');
113
+ // Tolerates a BOM and CRLF, both of which a Windows-authored file will carry.
114
+ //
115
+ // The BOM is stripped BY CODE POINT rather than matched inside the regex. Two
116
+ // earlier spellings both failed review for the same reason: a literal U+FEFF
117
+ // in the pattern is invisible, so `/^<BOM>?---/` reads as `/^?---/` — an
118
+ // apparent no-op quantifier on a zero-width assertion — and an escape can be
119
+ // re-interpreted back into that literal by tooling on the way to disk. This
120
+ // form is unambiguous ASCII, and the file now contains no invisible
121
+ // characters at all. Behaviour that is correct but unreadable is still a
122
+ // defect; it cost two review rounds here.
123
+ const withoutBom = s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
124
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(withoutBom);
125
+ if (!m) return { frontmatter: null, body: s };
126
+ return { frontmatter: m[1], body: m[2] };
127
+ }
128
+
129
+ // `key: value` / `key: [a, b]` / `key:\n - a\n - b`. Unknown keys are kept —
130
+ // the validator refuses what it does not recognise, and silently dropping a key
131
+ // here would turn a typo into a default instead of an error.
132
+ function parseFrontmatter(fmText) {
133
+ const out = {};
134
+ if (typeof fmText !== 'string') return out;
135
+ const lines = fmText.split(/\r?\n/);
136
+ let listKey = null;
137
+ for (const raw of lines) {
138
+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
139
+ const item = /^\s*-\s+(.*)$/.exec(raw);
140
+ if (item && listKey) { out[listKey].push(scalar(item[1])); continue; }
141
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(raw);
142
+ if (!kv) { listKey = null; continue; }
143
+ const [, key, rest] = kv;
144
+ if (rest === '') { listKey = key; out[key] = []; continue; }
145
+ listKey = null;
146
+ const inline = /^\[(.*)\]$/.exec(rest.trim());
147
+ out[key] = inline
148
+ ? inline[1].split(',').map((p) => scalar(p)).filter((p) => p !== '')
149
+ : scalar(rest);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function scalar(raw) {
155
+ let v = String(raw).trim();
156
+ // Strip a trailing comment only when the value is not quoted — a persona-ish
157
+ // string legitimately contains '#'.
158
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(v);
159
+ if (quoted) return quoted[2];
160
+ v = v.replace(/\s+#.*$/, '').trim();
161
+ if (v === 'true') return true;
162
+ if (v === 'false') return false;
163
+ return v;
164
+ }
165
+
166
+ // One file → the candidate object the validator takes. The BODY is the persona:
167
+ // a persona is prose, and prose does not belong in frontmatter.
168
+ function parseAgentFile(text, { name } = {}) {
169
+ const { frontmatter, body } = splitFrontmatter(text);
170
+ const fm = parseFrontmatter(frontmatter);
171
+ const candidate = { ...fm };
172
+ // The filename is the identity when frontmatter does not override it, so
173
+ // historian.md is the `historian` agent without having to say so twice.
174
+ if (!candidate.name && name) candidate.name = name;
175
+ const persona = String(body || '').trim();
176
+ if (persona) candidate.persona = persona;
177
+ // `trigger: event` + `event: task.shipped` is the readable file spelling of
178
+ // the column pair the schema stores. Mapped here rather than in the validator
179
+ // so the validator keeps taking exactly the row shape the DB holds.
180
+ if (candidate.trigger && !candidate.trigger_type) {
181
+ candidate.trigger_type = candidate.trigger;
182
+ delete candidate.trigger;
183
+ }
184
+ if (candidate.event && !candidate.trigger_spec) {
185
+ candidate.trigger_spec = { event: candidate.event };
186
+ delete candidate.event;
187
+ }
188
+ return candidate;
189
+ }
190
+
191
+ /**
192
+ * Decide what to do with ONE parsed candidate. Pure: no disk, no database, no
193
+ * git. Returns a plan the caller applies (or prints, under --dry-run).
194
+ *
195
+ * Outcomes:
196
+ * skip — not a definition. No row; the reason is reported.
197
+ * flagged — a real definition whose SCOPE is refused. Imported disabled, with
198
+ * scope_violation set, so it is visible and fixable.
199
+ * upsert — clean. Imported; `enabled` is left to the operator (rule 4).
200
+ * protected — a `source=db` row owns this name. Never touched (rule 1).
201
+ */
202
+ function planOne(candidate, {
203
+ authorRank: gitRank = null,
204
+ allowedModules = [],
205
+ protectedModules = [],
206
+ existing = null,
207
+ } = {}) {
208
+ const name = typeof candidate?.name === 'string' ? candidate.name : null;
209
+ // The caller passes the GIT-DERIVED rank; what gets stamped is whatever
210
+ // survives stampableAuthorRank, which withholds it entirely on any definition
211
+ // that reaches a protected module. Everything below uses `authorRank`, so the
212
+ // spoofable value cannot reach the wall by a path that forgets to launder it.
213
+ const authorRank = stampableAuthorRank(candidate, { gitRank, protectedModules });
214
+
215
+ // Rule 1, checked before anything else: an instance-authored row owns its
216
+ // name. A file that happens to share it does not get to overwrite it, and
217
+ // this is reported rather than silent so the collision is visible.
218
+ if (existing && existing.source === 'db') {
219
+ return { action: 'protected', name, reason: 'an instance-authored (source=db) row owns this name — the file is ignored' };
220
+ }
221
+
222
+ const verdict = validate.validateAgentDefinition(candidate, {
223
+ authorRank, allowedModules, protectedModules,
224
+ });
225
+
226
+ if (verdict.ok) {
227
+ return {
228
+ action: 'upsert',
229
+ name,
230
+ value: verdict.value,
231
+ authorRank,
232
+ scopeViolation: null,
233
+ // Rule 4: never arm. Absent an existing row this is a fresh insert, which
234
+ // the schema defaults to disabled; on update we carry the operator's own
235
+ // setting forward untouched.
236
+ enabled: existing ? existing.enabled === true : false,
237
+ };
238
+ }
239
+
240
+ const codes = verdict.errors.map((e) => e.code);
241
+ const scopeOnly = codes.length > 0 && codes.every((c) => SCOPE_CODES.includes(c));
242
+ if (!scopeOnly) {
243
+ return { action: 'skip', name, reason: verdict.errors.map((e) => e.message).join(' · ') };
244
+ }
245
+
246
+ // Rule 3. The definition is well-formed; only its scope is refused. To import
247
+ // it we need the NORMALIZED row, which the validator only returns on success —
248
+ // so it is re-run with the scope checks satisfied purely to obtain that shape.
249
+ //
250
+ // This cannot widen anything: the re-run's verdict is discarded except for
251
+ // `value`, the row is forced enabled:false with scope_violation set, and the
252
+ // schema's agents_definitions_flagged_not_armed_chk refuses to store a flagged
253
+ // row as armed even if this code were wrong.
254
+ const declaredModules = Array.isArray(candidate.scope_modules) ? candidate.scope_modules : [];
255
+ const shapeOnly = validate.validateAgentDefinition(candidate, {
256
+ authorRank: validate.RANK_ORDER[validate.RANK_ORDER.length - 1],
257
+ allowedModules: [...new Set([...allowedModules, ...declaredModules])],
258
+ protectedModules: [],
259
+ });
260
+ if (!shapeOnly.ok) {
261
+ // Defensive: the two runs disagreeing means an error that is scope-coded but
262
+ // not scope-caused. Skipping is the fail-closed answer.
263
+ return { action: 'skip', name, reason: verdict.errors.map((e) => e.message).join(' · ') };
264
+ }
265
+ return {
266
+ action: 'flagged',
267
+ name,
268
+ value: shapeOnly.value,
269
+ authorRank,
270
+ scopeViolation: verdict.errors.map((e) => e.message).join(' · '),
271
+ enabled: false,
272
+ };
273
+ }
274
+
275
+ // The whole plan, for a list of { name, candidate } and a lookup of existing
276
+ // rows by name. Pure, so the reconcile's DECISIONS are unit-testable with no
277
+ // database standing up.
278
+ function planSync(files, { existingByName = new Map(), authorRankFor = () => null, allowedModules = [], protectedModules = [] } = {}) {
279
+ return files.map((f) => ({
280
+ file: f.file || null,
281
+ ...planOne(f.candidate, {
282
+ authorRank: authorRankFor(f.file),
283
+ allowedModules,
284
+ protectedModules,
285
+ existing: existingByName.get(f.candidate?.name) || null,
286
+ }),
287
+ }));
288
+ }
289
+
290
+ // ---- disk + git ------------------------------------------------------------
291
+
292
+ // An ABSENT directory is a legitimate state, not an error: `.claude/agents/`
293
+ // does not exist in the core tree at all today, and an instance that ships no
294
+ // agents must deploy cleanly. Zero files, exit 0, nothing written.
295
+ function readAgentFiles(dir = DEFAULT_DIR) {
296
+ let names;
297
+ try {
298
+ names = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
299
+ } catch (err) {
300
+ if (err.code === 'ENOENT') return [];
301
+ throw err;
302
+ }
303
+ return names.sort().map((f) => {
304
+ const full = path.join(dir, f);
305
+ return {
306
+ file: path.relative(REPO_ROOT, full).split(path.sep).join('/'),
307
+ candidate: parseAgentFile(fs.readFileSync(full, 'utf8'), { name: f.replace(/\.md$/, '') }),
308
+ };
309
+ });
310
+ }
311
+
312
+ // The login that last committed a file — a PROVENANCE HINT, and explicitly NOT
313
+ // an authority input. Read `declaresProtectedScope` below before using it.
314
+ //
315
+ // An earlier version of this comment claimed the GitHub noreply address "cannot
316
+ // be spoofed by setting a local git name". THAT WAS WRONG, and it was the more
317
+ // dangerous half of the mistake: `git log --format=%ae` reports the commit
318
+ // AUTHOR, which any committer sets freely with `git config user.email` or
319
+ // `--author`, noreply-shaped values included. Nothing here verifies the address
320
+ // against GitHub. So this value is attacker-controlled, and a comment asserting
321
+ // otherwise invites the next reviewer to skip the one check that matters.
322
+ //
323
+ // It is kept because provenance is genuinely useful where nothing is riding on
324
+ // it. The wall is that it is never consulted where it could grant anything.
325
+ function lastCommitterLogin(file, { runGit = defaultRunGit } = {}) {
326
+ let out;
327
+ try { out = runGit(['log', '-1', '--format=%ae%n%an', '--', file]); }
328
+ catch { return null; }
329
+ const [email = '', authorName = ''] = String(out || '').split(/\r?\n/);
330
+ const noreply = /^(?:\d+\+)?([A-Za-z0-9-]+)@users\.noreply\.github\.com$/.exec(email.trim());
331
+ if (noreply) return noreply[1];
332
+ return authorName.trim() || null;
333
+ }
334
+
335
+ function defaultRunGit(args) {
336
+ return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' });
337
+ }
338
+
339
+ module.exports = {
340
+ splitFrontmatter,
341
+ parseFrontmatter,
342
+ parseAgentFile,
343
+ declaresProtectedScope,
344
+ stampableAuthorRank,
345
+ planOne,
346
+ planSync,
347
+ readAgentFiles,
348
+ lastCommitterLogin,
349
+ SCOPE_CODES,
350
+ DEFAULT_DIR,
351
+ };
352
+
353
+ // ---- facade ----------------------------------------------------------------
354
+
355
+ async function main() {
356
+ const argv = process.argv.slice(2);
357
+ const dryRun = argv.includes('--dry-run');
358
+ const asJson = argv.includes('--json');
359
+ const dirIdx = argv.indexOf('--dir');
360
+ const dir = dirIdx >= 0 ? argv[dirIdx + 1] : DEFAULT_DIR;
361
+
362
+ const files = readAgentFiles(dir);
363
+ if (files.length === 0) {
364
+ if (asJson) console.log(JSON.stringify({ ok: true, scanned: 0, plan: [] }));
365
+ else console.log(`agents-sync: no definitions under ${path.relative(REPO_ROOT, dir) || dir} — nothing to reconcile.`);
366
+ return 0;
367
+ }
368
+
369
+ const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
370
+ const scopeMap = require(path.join(REPO_ROOT, 'src/bongos/module-scope-map.js'));
371
+ const allowedModules = scopeMap.moduleKeys();
372
+ const protectedModules = scopeMap.protectedModules();
373
+
374
+ const { rows: existingRows } = await pool.query(
375
+ 'SELECT name, source, enabled FROM agents_definitions'
376
+ );
377
+ const existingByName = new Map(existingRows.map((r) => [r.name, r]));
378
+
379
+ // ONE git subprocess per file, memoised — `authorRankFor` is called again from
380
+ // planSync, and re-shelling out there doubled the blocking spawns for an answer
381
+ // that cannot have changed mid-run.
382
+ const loginByFile = new Map(files.map((f) => [f.file, lastCommitterLogin(f.file)]));
383
+ // One query for every distinct committer, not one per file.
384
+ const logins = [...new Set([...loginByFile.values()].filter(Boolean))];
385
+ const rankByLogin = new Map();
386
+ if (logins.length) {
387
+ const { rows } = await pool.query(
388
+ 'SELECT github_login, rank FROM builders WHERE lower(github_login) = ANY($1)',
389
+ [logins.map((l) => l.toLowerCase())]
390
+ );
391
+ for (const r of rows) rankByLogin.set(String(r.github_login).toLowerCase(), r.rank);
392
+ }
393
+ // The git-derived rank. planOne withholds it on any protected-scope definition
394
+ // (stampableAuthorRank) — this function never decides whether it may be used.
395
+ const authorRankFor = (file) => {
396
+ const login = loginByFile.get(file);
397
+ return login ? (rankByLogin.get(login.toLowerCase()) || null) : null;
398
+ };
399
+
400
+ const plan = planSync(files, { existingByName, authorRankFor, allowedModules, protectedModules });
401
+
402
+ if (dryRun) {
403
+ if (asJson) console.log(JSON.stringify({ ok: true, scanned: files.length, plan }, null, 2));
404
+ else for (const p of plan) console.log(` ${p.action.padEnd(9)} ${p.name || p.file}${p.reason ? ` — ${p.reason}` : ''}`);
405
+ return 0;
406
+ }
407
+
408
+ let upserted = 0;
409
+ let flagged = 0;
410
+ for (const p of plan) {
411
+ if (p.action === 'skip' || p.action === 'protected') {
412
+ console.error(`agents-sync: ${p.action} ${p.name || p.file} — ${p.reason}`);
413
+ continue;
414
+ }
415
+ const v = p.value;
416
+ // ON CONFLICT targets the name unique constraint, and the WHERE clause is
417
+ // rule 1 made structural: a row that turned source=db between the read above
418
+ // and this write is not overwritten by the race either.
419
+ await pool.query(
420
+ `INSERT INTO agents_definitions
421
+ (name, title, persona, trigger_type, trigger_spec, model_tier,
422
+ scope_modules, scope_paths, scope_violation, source, provenance,
423
+ author_rank, source_path, last_synced_at, enabled)
424
+ VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9,'file','built-in',$10,$11,now(),$12)
425
+ ON CONFLICT (name) DO UPDATE SET
426
+ title = EXCLUDED.title, persona = EXCLUDED.persona,
427
+ trigger_type = EXCLUDED.trigger_type, trigger_spec = EXCLUDED.trigger_spec,
428
+ model_tier = EXCLUDED.model_tier, scope_modules = EXCLUDED.scope_modules,
429
+ scope_paths = EXCLUDED.scope_paths, scope_violation = EXCLUDED.scope_violation,
430
+ author_rank = EXCLUDED.author_rank, source_path = EXCLUDED.source_path,
431
+ last_synced_at = now(), enabled = EXCLUDED.enabled, updated_at = now()
432
+ WHERE agents_definitions.source = 'file'`,
433
+ [v.name, v.title, v.persona, v.trigger_type, JSON.stringify(v.trigger_spec), v.model_tier,
434
+ v.scope_modules, v.scope_paths, p.scopeViolation, p.authorRank, p.file || null, p.enabled]
435
+ );
436
+ if (p.action === 'flagged') flagged += 1; else upserted += 1;
437
+ }
438
+ console.log(`agents-sync: ${files.length} file(s) — ${upserted} reconciled, ${flagged} flagged (disabled), `
439
+ + `${plan.filter((p) => p.action === 'skip').length} skipped, ${plan.filter((p) => p.action === 'protected').length} db-owned.`);
440
+ return 0;
441
+ }
442
+
443
+ if (require.main === module) {
444
+ main().then((code) => { process.exitCode = code; }).catch((err) => {
445
+ console.error(`agents-sync: ${err && err.message ? err.message : err}`);
446
+ process.exitCode = 1;
447
+ });
448
+ }
@@ -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.703'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,338 @@
1
+ // tests/agents_sync.mjs — the deploy-time file↔DB reconcile (task 1002490,
2
+ // goal 1000038 Phase 1, T6).
3
+ //
4
+ // The four rules this script exists to keep, each tested as the FAILURE it
5
+ // prevents rather than as the happy path:
6
+ //
7
+ // 1. a source=db row is never touched → an instance-authored agent
8
+ // survives a deploy
9
+ // 2. author_rank is never taken from git → the committer email is
10
+ // where it could grant attacker-controlled, so it is
11
+ // withheld on any protected scope
12
+ // (ADR 0016)
13
+ // 3. a refused SCOPE imports disabled+flagged → visible and fixable, never armed
14
+ // 4. the sync never ARMS anything → `git push` cannot switch an
15
+ // agent on
16
+ //
17
+ // Pure: no database, no git, no disk except one tmp dir for the absent/present
18
+ // directory cases. The reconcile's DECISIONS are the thing under test.
19
+ //
20
+ // In tests/ rather than modules/agents/tests/ for the same reason as
21
+ // tests/agents_routes.mjs: the module is default:false and module-local tests
22
+ // are discovered only when the module is ENABLED, so they would never run here.
23
+ //
24
+ // Run: node tests/agents_sync.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { createRequire } from 'node:module';
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const sync = require('../scripts/gds/agents-sync.js');
34
+ const validate = require('../modules/agents/lib/validate.js');
35
+
36
+ let passed = 0;
37
+ let failed = 0;
38
+ function t(name, fn) {
39
+ try { fn(); console.log(` PASS ${name}`); passed += 1; }
40
+ catch (err) { console.log(` FAIL ${name}\n ${err.message}`); failed += 1; }
41
+ }
42
+
43
+ const FILE = [
44
+ '---',
45
+ 'name: historian',
46
+ 'title: The Historian',
47
+ 'trigger: on-demand',
48
+ 'model_tier: routine',
49
+ 'scope_modules: [agents]',
50
+ '---',
51
+ '',
52
+ 'You are the historian. Answer with citations.',
53
+ ].join('\n');
54
+
55
+ const def = () => sync.parseAgentFile(FILE, { name: 'historian' });
56
+
57
+ console.log('\nparsing — the file is a definition, the body is the persona:');
58
+
59
+ t('frontmatter + body split, with the body as persona', () => {
60
+ const d = def();
61
+ assert.equal(d.name, 'historian');
62
+ assert.equal(d.title, 'The Historian');
63
+ assert.equal(d.trigger_type, 'on-demand', 'the readable `trigger:` spelling maps to the column');
64
+ assert.equal(d.model_tier, 'routine');
65
+ assert.deepEqual(d.scope_modules, ['agents']);
66
+ assert.match(d.persona, /^You are the historian/);
67
+ assert.ok(!('trigger' in d), 'the file spelling is consumed, not left to confuse the validator');
68
+ });
69
+
70
+ t('the FILENAME is the identity when frontmatter omits a name', () => {
71
+ const d = sync.parseAgentFile('---\ntrigger: on-demand\n---\nbody', { name: 'planner' });
72
+ assert.equal(d.name, 'planner', 'planner.md is the planner agent without saying so twice');
73
+ });
74
+
75
+ t('CRLF and a REAL BOM parse — a Windows-authored file is still a definition', () => {
76
+ // The first draft of this test was named for the BOM and contained none —
77
+ // only CRLF — so it asserted nothing about the behaviour its name promised.
78
+ // The second carried a LITERAL U+FEFF, which is invisible and read in review
79
+ // as no BOM at all. Built from the CODE POINT: unambiguous ASCII in source,
80
+ // and the charCodeAt assertion below means the fixture cannot quietly lose it
81
+ // a third time.
82
+ const withBom = String.fromCharCode(0xFEFF) + '---\r\nname: x\r\ntrigger: on-demand\r\n---\r\nbody';
83
+ assert.equal(withBom.charCodeAt(0), 0xFEFF, 'the fixture really starts with a BOM');
84
+ const d = sync.parseAgentFile(withBom, {});
85
+ assert.equal(d.name, 'x');
86
+ assert.equal(d.trigger_type, 'on-demand');
87
+ assert.equal(d.persona, 'body');
88
+ // And the same file WITHOUT the BOM must still parse — the BOM is optional,
89
+ // not required, and a regex that demanded it would break every POSIX file.
90
+ const noBom = sync.parseAgentFile(withBom.slice(1), {});
91
+ assert.equal(noBom.name, 'x');
92
+ });
93
+
94
+ t('`event:` becomes the trigger_spec the schema stores', () => {
95
+ const d = sync.parseAgentFile('---\nname: lens\ntrigger: event\nevent: task.shipped\n---\nb', {});
96
+ assert.deepEqual(d.trigger_spec, { event: 'task.shipped' });
97
+ assert.ok(!('event' in d));
98
+ });
99
+
100
+ t('a file with NO frontmatter yields no definition, and does not throw', () => {
101
+ const d = sync.parseAgentFile('just prose, no fences', { name: 'loose' });
102
+ assert.equal(d.name, 'loose');
103
+ assert.equal(d.trigger_type, undefined, 'nothing is invented from a bare file');
104
+ });
105
+
106
+ t('an unknown key is KEPT, so a typo is refused rather than defaulted', () => {
107
+ const d = sync.parseAgentFile('---\nname: x\ntriger: event\n---\nb', {});
108
+ assert.equal(d.triger, 'event', 'silently dropping it would turn a typo into a default');
109
+ });
110
+
111
+ console.log('\nrule 1 — a source=db row is never touched:');
112
+
113
+ t('an instance-authored row owns its name; the file is ignored', () => {
114
+ const p = sync.planOne(def(), {
115
+ authorRank: 'archon', allowedModules: ['agents'],
116
+ existing: { source: 'db', enabled: true },
117
+ });
118
+ assert.equal(p.action, 'protected');
119
+ assert.match(p.reason, /instance-authored/);
120
+ assert.equal(p.value, undefined, 'no row is produced, so nothing can be written');
121
+ });
122
+
123
+ t('a source=file row with the same name IS reconciled', () => {
124
+ const p = sync.planOne(def(), {
125
+ authorRank: 'archon', allowedModules: ['agents'],
126
+ existing: { source: 'file', enabled: false },
127
+ });
128
+ assert.equal(p.action, 'upsert');
129
+ });
130
+
131
+ console.log('\nrule 2 — authority comes from the live DB rank, never the file:');
132
+
133
+ t('a file claiming author_rank cannot promote itself', () => {
134
+ // The frontmatter says archon; the caller passes the live rank, which is what
135
+ // the wall consults. If the file could win, writing one line would be a
136
+ // privilege escalation whose only gate is code review.
137
+ const claiming = sync.parseAgentFile(
138
+ '---\nname: x\ntrigger: on-demand\nauthor_rank: archon\nscope_modules: [government]\n---\nbody', {});
139
+ const p = sync.planOne(claiming, {
140
+ authorRank: 'xenos', allowedModules: ['government'], protectedModules: ['government'],
141
+ });
142
+ assert.equal(p.action, 'flagged', 'the declared rank did not open the protected scope');
143
+ // NULL, not 'xenos': this definition reaches a protected module, so no
144
+ // git-derived rank is stamped at all (stampableAuthorRank). The file's claim
145
+ // loses, and so does the spoofable git value — both by the same rule.
146
+ assert.equal(p.authorRank, null, 'nothing git-derived is stamped on a protected-scope row');
147
+ assert.match(p.scopeViolation, /metic\+ author/i);
148
+ });
149
+
150
+ t('THE ESCALATION PATH: a spoofed git identity cannot arm a protected scope', () => {
151
+ // git log --format=%ae reports the commit AUTHOR, which any committer sets
152
+ // with `git config user.email` or `--author` — noreply-shaped values included,
153
+ // and nothing verifies them against GitHub. So the resolved rank is
154
+ // attacker-controlled. The wall is that it is never consulted where it could
155
+ // grant: a definition reaching a protected module is stamped NULL whatever
156
+ // git said, which the validator treats as insufficient.
157
+ const d = def(); // scope_modules: [agents]
158
+ assert.equal(sync.declaresProtectedScope(d, ['agents']), true);
159
+ assert.equal(sync.stampableAuthorRank(d, { gitRank: 'archon', protectedModules: ['agents'] }), null,
160
+ 'a claimed archon rank must not survive onto a protected-scope definition');
161
+ const p = sync.planOne(d, {
162
+ authorRank: 'archon', allowedModules: ['agents'], protectedModules: ['agents'],
163
+ });
164
+ assert.equal(p.action, 'flagged', 'so it flags, exactly as an unresolved author would');
165
+ assert.equal(p.authorRank, null, 'and nothing spoofable is stamped on the row');
166
+ assert.equal(p.enabled, false);
167
+ });
168
+
169
+ t('where rank grants nothing, the hint is kept as provenance', () => {
170
+ // The other side: withholding it everywhere would throw away useful
171
+ // provenance for no gain, since on an unprotected scope the rank gates nothing.
172
+ const d = def();
173
+ assert.equal(sync.stampableAuthorRank(d, { gitRank: 'metic', protectedModules: [] }), 'metic');
174
+ const p = sync.planOne(d, { authorRank: 'metic', allowedModules: ['agents'], protectedModules: [] });
175
+ assert.equal(p.action, 'upsert');
176
+ assert.equal(p.authorRank, 'metic');
177
+ });
178
+
179
+ t('an UNRESOLVED author is insufficient, not exempt', () => {
180
+ // A committer who maps to no builder row yields null. Fail-closed: null must
181
+ // behave like "too low", never like "skip the check".
182
+ const p = sync.planOne(def(), {
183
+ authorRank: null, allowedModules: ['agents'], protectedModules: ['agents'],
184
+ });
185
+ assert.equal(p.action, 'flagged');
186
+ assert.equal(p.authorRank, null);
187
+ assert.equal(p.enabled, false);
188
+ });
189
+
190
+ console.log('\nrule 3 — a refused scope is IMPORTED, disabled and flagged:');
191
+
192
+ t('a protected scope below the floor imports flagged, never armed', () => {
193
+ const p = sync.planOne(def(), {
194
+ authorRank: 'thetes', allowedModules: ['agents'], protectedModules: ['agents'],
195
+ });
196
+ assert.equal(p.action, 'flagged');
197
+ assert.equal(p.enabled, false, 'the whole point — present, not armed');
198
+ assert.ok(p.scopeViolation && p.scopeViolation.length > 0, 'the reason is stored so it can be fixed');
199
+ assert.ok(p.value, 'a flagged row still carries the normalized definition');
200
+ assert.equal(p.value.name, 'historian');
201
+ });
202
+
203
+ t('a flagged row keeps its real scope, not a sanitised one', () => {
204
+ // The re-run that recovers the normalized shape must not be allowed to launder
205
+ // the scope — what is stored has to be what the file actually asked for, or
206
+ // the flag names a problem the row no longer shows.
207
+ const p = sync.planOne(def(), {
208
+ authorRank: 'thetes', allowedModules: ['agents'], protectedModules: ['agents'],
209
+ });
210
+ assert.deepEqual(p.value.scope_modules, ['agents']);
211
+ });
212
+
213
+ t('a SHAPE error is skipped — there is no definition to import', () => {
214
+ const p = sync.planOne({ name: 'x', trigger_type: 'telepathy' }, {
215
+ authorRank: 'archon', allowedModules: ['agents'],
216
+ });
217
+ assert.equal(p.action, 'skip');
218
+ assert.equal(p.value, undefined, 'a partial row must never reach the registry');
219
+ assert.match(p.reason, /trigger/i);
220
+ });
221
+
222
+ t('the two are not confused: every SCOPE_CODE is a real validator code', () => {
223
+ // If a code were renamed in the validator, scope refusals would start being
224
+ // reported as shape errors and silently stop being imported.
225
+ const p = validate.validateAgentDefinition(
226
+ { name: 'x', trigger_type: 'on-demand', persona: 'p', scope_modules: ['nope'] },
227
+ { authorRank: 'archon', allowedModules: [] }
228
+ );
229
+ assert.equal(p.ok, false);
230
+ assert.ok(p.errors.some((e) => sync.SCOPE_CODES.includes(e.code)),
231
+ 'scope_out_of_bounds must still be spelled the way SCOPE_CODES expects');
232
+ });
233
+
234
+ console.log('\nrule 4 — the sync never arms anything:');
235
+
236
+ t('a NEW clean definition inserts DISABLED', () => {
237
+ const p = sync.planOne(def(), { authorRank: 'archon', allowedModules: ['agents'] });
238
+ assert.equal(p.action, 'upsert');
239
+ assert.equal(p.enabled, false, 'arming is an operator act, by the same logic as rule 2');
240
+ });
241
+
242
+ t("an EXISTING row keeps the operator's own enabled setting", () => {
243
+ const on = sync.planOne(def(), {
244
+ authorRank: 'archon', allowedModules: ['agents'], existing: { source: 'file', enabled: true },
245
+ });
246
+ assert.equal(on.enabled, true, 'a deploy must not undo an operator switching an agent on');
247
+ const off = sync.planOne(def(), {
248
+ authorRank: 'archon', allowedModules: ['agents'], existing: { source: 'file', enabled: false },
249
+ });
250
+ assert.equal(off.enabled, false);
251
+ });
252
+
253
+ t('a flagged row is forced OFF even if it was armed before', () => {
254
+ // The dangerous ordering: an agent is armed, then its scope is edited to reach
255
+ // a protected surface. Carrying `enabled` forward would arm the new scope.
256
+ const p = sync.planOne(def(), {
257
+ authorRank: 'thetes', allowedModules: ['agents'], protectedModules: ['agents'],
258
+ existing: { source: 'file', enabled: true },
259
+ });
260
+ assert.equal(p.action, 'flagged');
261
+ assert.equal(p.enabled, false, 'a newly-flagged agent must be disarmed, not left running');
262
+ });
263
+
264
+ console.log('\nthe absent directory is a legitimate state:');
265
+
266
+ t('no .claude/agents/ → zero files, no throw', () => {
267
+ // It does not exist in the core tree at all today, and an instance shipping no
268
+ // agents must still deploy cleanly.
269
+ assert.deepEqual(sync.readAgentFiles(path.join(os.tmpdir(), 'agents-sync-absent-xyz')), []);
270
+ });
271
+
272
+ t('a real directory is read, .md only, sorted', () => {
273
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-sync-'));
274
+ fs.writeFileSync(path.join(dir, 'zeta.md'), FILE);
275
+ fs.writeFileSync(path.join(dir, 'alpha.md'), FILE.replace('name: historian', 'name: alpha'));
276
+ fs.writeFileSync(path.join(dir, 'README.txt'), 'not a definition');
277
+ const files = sync.readAgentFiles(dir);
278
+ assert.equal(files.length, 2, 'only .md files are definitions');
279
+ assert.deepEqual(files.map((f) => f.candidate.name), ['alpha', 'historian'], 'sorted, so a run is reproducible');
280
+ });
281
+
282
+ console.log('\nplanSync — the whole reconcile, still pure:');
283
+
284
+ t('one plan entry per file, each carrying its own author rank', () => {
285
+ const files = [
286
+ { file: '.claude/agents/a.md', candidate: sync.parseAgentFile(FILE.replace('historian', 'a'), {}) },
287
+ { file: '.claude/agents/b.md', candidate: sync.parseAgentFile(FILE.replace('historian', 'b'), {}) },
288
+ ];
289
+ const plan = sync.planSync(files, {
290
+ existingByName: new Map([['b', { source: 'db', enabled: true }]]),
291
+ authorRankFor: (f) => (f.endsWith('a.md') ? 'archon' : 'xenos'),
292
+ allowedModules: ['agents'],
293
+ protectedModules: [],
294
+ });
295
+ assert.equal(plan.length, 2);
296
+ assert.equal(plan[0].action, 'upsert');
297
+ assert.equal(plan[0].authorRank, 'archon', 'rank is resolved PER FILE, not once for the run');
298
+ assert.equal(plan[1].action, 'protected', 'the db-owned name is left alone');
299
+ });
300
+
301
+ console.log('\nthe committer mapping — provenance only, and known spoofable:');
302
+
303
+ t('a GitHub noreply address yields the login; a local name is only a fallback', () => {
304
+ // Neither form is trustworthy — both come from the commit author, which the
305
+ // committer sets. The preference is about giving the BEST GUESS at provenance,
306
+ // not about security; the security is that this value never reaches a decision
307
+ // (see the escalation-path test above).
308
+ const login = sync.lastCommitterLogin('x', { runGit: () => '12345+octocat@users.noreply.github.com\nSomeone Else\n' });
309
+ assert.equal(login, 'octocat', 'the noreply address carries the login and cannot be set locally');
310
+ // A generic fixture on purpose: a real builder's login baked into a test is
311
+ // the hardcoded-founder-identity defect audit-authorship.sh exists to catch,
312
+ // and it caught this line in its first draft.
313
+ const fallback = sync.lastCommitterLogin('x', { runGit: () => 'someone@example.com\na-builder\n' });
314
+ assert.equal(fallback, 'a-builder');
315
+ assert.equal(sync.lastCommitterLogin('x', { runGit: () => { throw new Error('not a repo'); } }), null,
316
+ 'an unreadable history resolves to null, which rule 2 treats as insufficient');
317
+ });
318
+
319
+ console.log('\nthe reconcile actually RUNS at deploy:');
320
+
321
+ t('migrate.sh invokes agents-sync, module-gated and fail-open', () => {
322
+ // A reconcile nothing calls is a script, not a deploy step — review caught
323
+ // that the first cut shipped the machinery with no caller. migrate.sh is the
324
+ // one deploy-time hook the portable core owns, and it applies the schema this
325
+ // writes into a few lines above, so the table exists by the time it runs.
326
+ const sh = fs.readFileSync(new URL('../scripts/migrate.sh', import.meta.url), 'utf8');
327
+ assert.match(sh, /scripts\/gds\/agents-sync\.js/, 'the deploy path must call it');
328
+ assert.match(sh, /isModuleEnabled\('agents'\)/, "gated on the module — it is default:false");
329
+ // Fail-OPEN: a stale registry is a nuisance, an aborted deploy is an outage,
330
+ // and the script is already fail-closed about what it imports.
331
+ assert.match(sh, /agents-sync failed — the registry is stale by one deploy/,
332
+ 'a failed reconcile must not abort the deploy');
333
+ const call = sh.slice(sh.indexOf('agents-sync'));
334
+ assert.doesNotMatch(call.slice(0, 200), /exit 1/, 'no exit 1 on the agents-sync path');
335
+ });
336
+
337
+ console.log(`\nagents_sync: ${passed} passed, ${failed} failed`);
338
+ process.exit(failed ? 1 : 0);