@cohortapp/agent-sdk 2.10.0 → 2.11.1

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.
Files changed (64) hide show
  1. package/.claude/commands/init-maestro.md +16 -9
  2. package/docs/guides/mac-mini.md +11 -1
  3. package/docs/runbooks/cohort-cutover.md +16 -0
  4. package/lib/channels/inbox-item.mjs +12 -0
  5. package/lib/channels/inbox-item.test.mjs +33 -0
  6. package/lib/execution/disposition.mjs +13 -2
  7. package/lib/execution/disposition.test.mjs +19 -2
  8. package/lib/execution/pipeline.test.mjs +4 -1
  9. package/lib/mcp/server.test.mjs +16 -4
  10. package/lib/org/client.mjs +58 -1
  11. package/lib/org/messaging.mjs +5 -0
  12. package/lib/org/messaging.test.mjs +7 -0
  13. package/lib/org/protocol.checksum +1 -1
  14. package/lib/org/protocol.mjs +98 -0
  15. package/lib/org/protocol.test.mjs +19 -2
  16. package/lib/org/resource-tools.mjs +317 -0
  17. package/lib/org/resource-tools.test.mjs +361 -0
  18. package/lib/org/tool-access.mjs +176 -0
  19. package/lib/org/tool-access.test.mjs +144 -0
  20. package/lib/org/tool-surface.mjs +431 -5
  21. package/lib/org/tool-surface.test.mjs +385 -8
  22. package/lib/org/ui-parity.mjs +196 -3
  23. package/lib/org/ui-parity.test.mjs +126 -7
  24. package/lib/tool-definitions.js +23 -2
  25. package/package.json +2 -2
  26. package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
  27. package/plugins/maestro-skills/plugin.json +4 -0
  28. package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
  29. package/policies/information-barriers.yaml +34 -7
  30. package/scripts/ci/check-no-residual-identity.mjs +281 -9
  31. package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
  32. package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
  33. package/scripts/cloud-relay/voice/server.mjs +42 -2
  34. package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
  35. package/scripts/cost/track-claude-usage.mjs +113 -4
  36. package/scripts/daemon/agent-daemon.mjs +212 -5
  37. package/scripts/daemon/agent-daemon.test.mjs +307 -0
  38. package/scripts/daemon/assurance.mjs +38 -15
  39. package/scripts/daemon/assurance.test.mjs +39 -1
  40. package/scripts/daemon/cadence-handlers.mjs +48 -5
  41. package/scripts/daemon/cadence-handlers.test.mjs +57 -2
  42. package/scripts/daemon/classifier-identity.test.mjs +137 -0
  43. package/scripts/daemon/classifier.mjs +98 -17
  44. package/scripts/daemon/inbox-deferral.mjs +49 -24
  45. package/scripts/daemon/inbox-deferral.test.mjs +39 -1
  46. package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
  47. package/scripts/daemon/prompt-builder.mjs +264 -41
  48. package/scripts/daemon/prompt-builder.test.mjs +5 -5
  49. package/scripts/daemon/responder.mjs +9 -0
  50. package/scripts/disclosure_boundaries.py +56 -5
  51. package/scripts/huddle/huddle-prompt.test.mjs +176 -0
  52. package/scripts/huddle/huddle-server.mjs +128 -13
  53. package/scripts/local-triggers/autoupdate.sh +83 -0
  54. package/scripts/local-triggers/generate-plists.sh +9 -0
  55. package/scripts/local-triggers/generate-plists.test.mjs +12 -10
  56. package/scripts/media-generation/brand-clause.test.mjs +135 -0
  57. package/scripts/media-generation/gemini-image-client.mjs +27 -9
  58. package/scripts/media-generation/generate-assets.mjs +102 -7
  59. package/scripts/poller/inbox-scan-poller.mjs +7 -0
  60. package/scripts/poller/utils.mjs +11 -0
  61. package/scripts/pre-draft-context.py +91 -15
  62. package/scripts/spawn-session.sh +36 -6
  63. package/scripts/test-employer-grounding.py +348 -0
  64. package/scripts/validate_outbound.py +190 -26
@@ -10,9 +10,34 @@
10
10
  * the corpus (triggers → agents → lib → templates → docs).
11
11
  *
12
12
  * These markers are the ORIGINAL client identity (NOT confidential data — that is
13
- * the job of check-no-confidential.mjs), so they are safe to hardcode here. The
14
- * generic placeholders the framework now ships (Northwind / Alex / Jordan etc.)
15
- * are intentional examples and are NOT flagged.
13
+ * the job of check-no-confidential.mjs), so they are safe to hardcode here.
14
+ *
15
+ * ── THE SECOND CLASS: FICTIONAL PLACEHOLDER IDENTITY ──────────────────────────
16
+ *
17
+ * This header used to end: "the generic placeholders the framework now ships are
18
+ * intentional examples and are NOT flagged." That exemption was written for
19
+ * DOCUMENTATION — an example in a guide is genuinely an example — but it was
20
+ * applied to the whole corpus, so an invented employer and invented colleagues
21
+ * sat unguarded inside live prompt-building code: the huddle system prompt
22
+ * spoken aloud on auto-joined calls, the Twilio relay's inbound-call fallback,
23
+ * the classifier's identity line (whose summary is carried into the human ack
24
+ * and the org board row), the outbound validator's internal/external test, and
25
+ * the information-barrier keyword matcher. None of those is an example. Each
26
+ * asserted, to real people, a company that does not exist.
27
+ *
28
+ * So the exemption is now NARROW and structural, not blanket:
29
+ *
30
+ * FLAGGED — scripts/, lib/, policies/, agents/, teams/, bin/, services/,
31
+ * archetypes/, workflows/, schedules/triggers/ (see
32
+ * PLACEHOLDER_SCAN_PATHS): anything that can reach a prompt, a
33
+ * classifier, a policy or a send path.
34
+ * EXEMPT — docs/ (never scanned at all), scaffold/ (the example-config
35
+ * tree, which is what a placeholder is FOR), any *.example file,
36
+ * and test/fixture files by naming convention (see isFixturePath).
37
+ * BASELINED — PLACEHOLDER_PENDING: files that still carry the class and are
38
+ * not yet cleaned. Every run PRINTS them, so they cannot hide; the
39
+ * list is a strict ratchet that can only SHRINK (an entry that has
40
+ * become clean is itself a failure, so it gets deleted).
16
41
  *
17
42
  * Pure, dependency-light: Node builtins only. ESM.
18
43
  *
@@ -84,7 +109,14 @@ assertExemptPolicy(EXEMPT_FILES);
84
109
  export const IDENTITY_PATTERNS = [
85
110
  { label: "agent name (Sophie)", re: /\bSophie\b/i },
86
111
  { label: "principal name (Mehran)", re: /\bMehran\b/i },
87
- { label: "company (Adaptic)", re: /\bAdaptic\b/ },
112
+ // Deliberately NOT /i: the all-lowercase form is a legitimate identifier
113
+ // token across the tree (launchd labels `ai.adaptic.*`, docker tags, hostnames
114
+ // — deliberate old-brand pins that must not be "cleaned"). But the guard only
115
+ // matched the Title-case form, so a SHOUT-case header slipped past it — an
116
+ // `ABOUT ADAPTIC:` block sat in the huddle system prompt, spoken aloud, and
117
+ // this guard reported the tree clean. Match both prose cases, neither
118
+ // identifier case.
119
+ { label: "company (Adaptic)", re: /\b(?:Adaptic|ADAPTIC)\b/ },
88
120
  { label: "surname (Granfar)", re: /\bGranfar\b/i },
89
121
  { label: "surname (Nguyen)", re: /\bNguyen\b/i },
90
122
  { label: "principal first name (Hootan)", re: /\bHootan\b/i },
@@ -107,6 +139,158 @@ export function scanIdentity(text) {
107
139
  return hits;
108
140
  }
109
141
 
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ // The FICTIONAL PLACEHOLDER class (see the module header).
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * The framework's invented employer, counterparties and colleagues. A match in a
148
+ * prompt-building, policy or send path means the code is asserting something
149
+ * that is true of no deployment.
150
+ *
151
+ * Deliberately NOT here: bare first names used as few-shot illustration
152
+ * ("Input: CEO DM: can you send this to Jordan?"). A generic first name in a
153
+ * labelled example teaches format, not identity, and pattern-matching it would
154
+ * flag half the corpus and get the guard switched off. The full names below are
155
+ * the ones that appear as roster entries, clearance grants and entity ids.
156
+ *
157
+ * @type {Array<{label:string, re:RegExp}>}
158
+ */
159
+ export const PLACEHOLDER_PATTERNS = [
160
+ { label: "placeholder employer (Northwind)", re: /\bNorthwind\b/i },
161
+ { label: "placeholder counterparty (PartnerCo)", re: /\bPartnerCo\b/i },
162
+ { label: "placeholder colleague (Alex Chen)", re: /\bAlex[\s-]Chen\b/i },
163
+ { label: "placeholder colleague (Jordan Blake)", re: /\bJordan[\s-]Blake\b/i },
164
+ { label: "placeholder colleague (Casey Morgan)", re: /\bCasey[\s-]Morgan\b/i },
165
+ { label: "placeholder agent (Robin Hayes)", re: /\bRobin[\s-]Hayes\b/i },
166
+ ];
167
+
168
+ /**
169
+ * Where the placeholder class is FLAGGED. Everything here can reach a prompt, a
170
+ * classifier, a policy or a send path.
171
+ *
172
+ * `docs` and `scaffold` are absent on purpose and must stay absent: an example
173
+ * in a guide is an example, and scaffold/ IS the example-config tree.
174
+ * @type {string[]}
175
+ */
176
+ export const PLACEHOLDER_SCAN_PATHS = [
177
+ "agents",
178
+ "archetypes",
179
+ "bin",
180
+ "lib",
181
+ "policies",
182
+ "schedules/triggers",
183
+ "scripts",
184
+ "services",
185
+ "teams",
186
+ "workflows",
187
+ ];
188
+
189
+ /**
190
+ * Structural exemption: test, fixture and example files, recognised by naming
191
+ * convention rather than by a hand-maintained list — a per-file list would grow
192
+ * into the blanket exemption this change exists to remove.
193
+ *
194
+ * A placeholder persona is exactly what a fixture is for (see
195
+ * scripts/test-information-barriers.py, lib/model-router.test.mjs), so these are
196
+ * legitimate and must not be "cleaned".
197
+ *
198
+ * @param {string} rel repo-relative path (POSIX-ish separators tolerated)
199
+ * @returns {boolean}
200
+ */
201
+ export function isFixturePath(rel) {
202
+ const p = String(rel).split(path.sep).join("/");
203
+ const base = p.slice(p.lastIndexOf("/") + 1);
204
+ if (p.startsWith("docs/") || p.includes("/docs/")) return true;
205
+ if (p.startsWith("scaffold/")) return true;
206
+ if (p.includes("/fixtures/") || p.startsWith("fixtures/")) return true;
207
+ if (p.includes("/__tests__/")) return true;
208
+ if (base.includes(".example")) return true;
209
+ if (/(^|\.)test\./.test(base)) return true; // foo.test.mjs, foo.test.py
210
+ if (/^test[-_]/.test(base)) return true; // test-voice-parser.mjs
211
+ if (/[-_]test\.[^.]+$/.test(base)) return true; // foo_test.py
212
+ return false;
213
+ }
214
+
215
+ /**
216
+ * Files that STILL carry the placeholder class and have not been cleaned yet.
217
+ *
218
+ * This is a strict, shrinking ratchet, not an exemption list:
219
+ * - a file NOT listed here that carries the class → FAIL (the durable fix);
220
+ * - a file listed here that has become clean → FAIL, delete the entry;
221
+ * - every entry is PRINTED on every run, so the debt is never invisible.
222
+ *
223
+ * Each entry states WHY it is still dirty. Entries are removed, never added:
224
+ * adding one requires a deliberate edit to this file and is visible in review.
225
+ *
226
+ * @type {Array<{file:string, reason:string}>}
227
+ */
228
+ export const PLACEHOLDER_PENDING = [
229
+ {
230
+ file: "scripts/validate_outbound.py",
231
+ reason: "embedded `--self-test` harness looks up example people in the entity index; the production paths are config-driven",
232
+ },
233
+ {
234
+ file: "scripts/disclosure_assessment.py",
235
+ reason: "Layer 2 assessor — not yet grounded in config/company.json",
236
+ },
237
+ {
238
+ file: "scripts/daemon/voice-webhook-server.mjs",
239
+ reason: "inbound voice webhook prompt — not yet grounded in config/agent.json",
240
+ },
241
+ {
242
+ file: "scripts/media-generation/veo-video-client.mjs",
243
+ reason: "module docblock title still names the example employer",
244
+ },
245
+ { file: "scripts/pdf-generation/build-document.mjs", reason: "default PDF author/branding" },
246
+ { file: "scripts/pdf-generation/templates/board-pack.latex", reason: "document template branding" },
247
+ { file: "scripts/pdf-generation/templates/corporate-letter.latex", reason: "document template branding" },
248
+ { file: "scripts/pdf-generation/templates/memo.latex", reason: "document template branding" },
249
+ { file: "scripts/email-signature.html", reason: "signature template; /init-maestro rewrites it per agent" },
250
+ { file: "scripts/email-signature-principal.html", reason: "signature template; /init-maestro rewrites it per agent" },
251
+ { file: "scripts/send-email.sh", reason: "From header + inline signature fallback" },
252
+ { file: "scripts/send-email-as-principal.py", reason: "From header + inline signature fallback" },
253
+ { file: "scripts/post-interaction-indexer.py", reason: "author docstring" },
254
+ { file: "scripts/rag-indexer.py", reason: "author docstring" },
255
+ { file: "scripts/user-context-search.py", reason: "author docstring" },
256
+ { file: "scripts/setup/init-agent.sh", reason: "setup wizard copy" },
257
+ { file: "scripts/setup/init-known-agents.mjs", reason: "docblock claims a shipped roster; scaffold ships `agents: []` — fix the COMMENT, never the code" },
258
+ { file: "scripts/setup/configure-voice-tunnel.mjs", reason: "setup wizard copy" },
259
+ { file: "policies/communication-style.md", reason: "style guide worked examples" },
260
+ ];
261
+
262
+ /**
263
+ * Guard-of-the-guard for the baseline: every entry must name a real, currently
264
+ * dirty file under a scanned path, and carry a reason. Exported so the test can
265
+ * drive the throwing branch.
266
+ * @param {Array<{file:string, reason:string}>} pending
267
+ */
268
+ export function assertPendingPolicy(pending) {
269
+ for (const e of pending) {
270
+ if (!e || typeof e.file !== "string" || !e.file) {
271
+ throw new Error("check-no-residual-identity: PLACEHOLDER_PENDING entry has no `file`");
272
+ }
273
+ if (typeof e.reason !== "string" || e.reason.trim().length < 10) {
274
+ throw new Error(
275
+ `check-no-residual-identity: PLACEHOLDER_PENDING entry "${e.file}" needs a real \`reason\` — ` +
276
+ "an undocumented entry is an escape hatch",
277
+ );
278
+ }
279
+ }
280
+ }
281
+ assertPendingPolicy(PLACEHOLDER_PENDING);
282
+
283
+ /** Scan a text blob for placeholder markers. Pure. @returns {Array<{label:string, match:string}>} */
284
+ export function scanPlaceholders(text) {
285
+ if (typeof text !== "string" || text.length === 0) return [];
286
+ const hits = [];
287
+ for (const p of PLACEHOLDER_PATTERNS) {
288
+ const m = text.match(p.re);
289
+ if (m) hits.push({ label: p.label, match: m[0] });
290
+ }
291
+ return hits;
292
+ }
293
+
110
294
  function walkFiles(dir) {
111
295
  const out = [];
112
296
  for (const name of readdirSync(dir)) {
@@ -144,18 +328,106 @@ export function findResidualIdentity(opts = {}) {
144
328
  return offenders;
145
329
  }
146
330
 
331
+ /**
332
+ * Scan the executable corpus for the FICTIONAL PLACEHOLDER class.
333
+ *
334
+ * @param {object} [opts]
335
+ * @param {string} [opts.cwd=REPO_ROOT]
336
+ * @param {string[]} [opts.dirs=PLACEHOLDER_SCAN_PATHS]
337
+ * @param {Array<{file:string,reason:string}>} [opts.pending=PLACEHOLDER_PENDING]
338
+ * @returns {{ regressions: Array<{file:string, hits:Array<{label:string,match:string}>}>,
339
+ * pendingDirty: Array<{file:string, reason:string}>,
340
+ * pendingClean: string[] }}
341
+ * regressions — dirty files NOT on the baseline (this is what fails the build)
342
+ * pendingDirty — baselined files still dirty (printed, does not fail)
343
+ * pendingClean — baselined files now clean (the entry must be deleted; fails)
344
+ */
345
+ export function findPlaceholderIdentity(opts = {}) {
346
+ const cwd = opts.cwd || REPO_ROOT;
347
+ const dirs = opts.dirs || PLACEHOLDER_SCAN_PATHS;
348
+ const pending = opts.pending || PLACEHOLDER_PENDING;
349
+ const exempt = new Set(opts.exemptFiles || EXEMPT_FILES);
350
+ const pendingByFile = new Map(pending.map((e) => [e.file, e]));
351
+
352
+ const regressions = [];
353
+ const stillDirty = new Set();
354
+
355
+ for (const d of dirs) {
356
+ const abs = path.join(cwd, d);
357
+ if (!existsSync(abs)) continue;
358
+ for (const file of walkFiles(abs)) {
359
+ const rel = path.relative(cwd, file).split(path.sep).join("/");
360
+ if (exempt.has(rel)) continue; // the guards' own literals
361
+ if (isFixturePath(rel)) continue; // tests, fixtures, docs, scaffold, *.example
362
+ let body;
363
+ try { body = readFileSync(file, "utf8"); } catch { continue; }
364
+ const hits = scanPlaceholders(body);
365
+ if (!hits.length) continue;
366
+ if (pendingByFile.has(rel)) { stillDirty.add(rel); continue; }
367
+ regressions.push({ file: rel, hits });
368
+ }
369
+ }
370
+
371
+ const pendingDirty = pending.filter((e) => stillDirty.has(e.file));
372
+ const pendingClean = pending.filter((e) => !stillDirty.has(e.file)).map((e) => e.file);
373
+ return { regressions, pendingDirty, pendingClean };
374
+ }
375
+
147
376
  /** @param {string} [cwd=REPO_ROOT] @returns {Promise<number>} */
148
377
  export async function run(cwd = REPO_ROOT) {
378
+ let failed = false;
379
+
149
380
  const offenders = findResidualIdentity({ cwd });
150
381
  if (offenders.length === 0) {
151
382
  console.log(`check-no-residual-identity: OK (${DE_IDENTIFIED_PATHS.length} de-identified path(s) clean)`);
152
- return 0;
383
+ } else {
384
+ failed = true;
385
+ console.error("check-no-residual-identity: FAIL — original instance identity reintroduced into de-identified paths:");
386
+ for (const o of offenders) {
387
+ for (const h of o.hits) console.error(` ${o.file} :: ${h.label} ("${h.match}")`);
388
+ }
153
389
  }
154
- console.error("check-no-residual-identity: FAIL — original instance identity reintroduced into de-identified paths:");
155
- for (const o of offenders) {
156
- for (const h of o.hits) console.error(` ${o.file} :: ${h.label} ("${h.match}")`);
390
+
391
+ const ph = findPlaceholderIdentity({ cwd });
392
+
393
+ // State what the guard CANNOT see, every run — a silent baseline is a blind spot.
394
+ if (ph.pendingDirty.length) {
395
+ console.log(
396
+ `check-no-residual-identity: ${ph.pendingDirty.length} file(s) still carry fictional placeholder identity ` +
397
+ "(baselined in PLACEHOLDER_PENDING — this list may only shrink):",
398
+ );
399
+ for (const e of ph.pendingDirty) console.log(` - ${e.file} — ${e.reason}`);
400
+ }
401
+
402
+ if (ph.pendingClean.length) {
403
+ failed = true;
404
+ console.error(
405
+ "check-no-residual-identity: FAIL — PLACEHOLDER_PENDING entries are now CLEAN and must be deleted " +
406
+ "(the baseline is a ratchet, not a permanent exemption):",
407
+ );
408
+ for (const f of ph.pendingClean) console.error(` ${f}`);
409
+ }
410
+
411
+ if (ph.regressions.length) {
412
+ failed = true;
413
+ console.error(
414
+ "check-no-residual-identity: FAIL — fictional placeholder identity in a prompt/policy/send path. " +
415
+ "Ground it in config/agent.json + config/company.json and OMIT the clause when unset " +
416
+ "(lib/identity/persona.mjs#renderPersona is the pattern) — do not add it to PLACEHOLDER_PENDING:",
417
+ );
418
+ for (const o of ph.regressions) {
419
+ for (const h of o.hits) console.error(` ${o.file} :: ${h.label} ("${h.match}")`);
420
+ }
157
421
  }
158
- return 1;
422
+
423
+ if (!failed && ph.regressions.length === 0) {
424
+ console.log(
425
+ `check-no-residual-identity: OK (${PLACEHOLDER_SCAN_PATHS.length} executable path(s) free of fictional placeholder identity, ` +
426
+ `${ph.pendingDirty.length} baselined)`,
427
+ );
428
+ }
429
+
430
+ return failed ? 1 : 0;
159
431
  }
160
432
 
161
433
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { test } from "node:test";
15
15
  import assert from "node:assert/strict";
16
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
16
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join } from "node:path";
19
19
 
@@ -21,9 +21,14 @@ import {
21
21
  DE_IDENTIFIED_PATHS,
22
22
  EXEMPT_FILES,
23
23
  IDENTITY_PATTERNS,
24
+ PLACEHOLDER_PENDING,
24
25
  assertExemptPolicy,
26
+ assertPendingPolicy,
27
+ findPlaceholderIdentity,
25
28
  findResidualIdentity,
29
+ isFixturePath,
26
30
  scanIdentity,
31
+ scanPlaceholders,
27
32
  run,
28
33
  } from "./check-no-residual-identity.mjs";
29
34
 
@@ -79,7 +84,10 @@ test("(iii) a seeded marker in a NON-exempt file still fails the ratchet", async
79
84
 
80
85
  test("scanIdentity flags every 2.0 pattern incl. the package scope; placeholders stay clean", () => {
81
86
  assert.ok(scanIdentity("npm i @adaptic/maestro").some((h) => /@adaptic\//.test(h.match)));
82
- assert.equal(scanIdentity("Northwind's Alex and Jordan shipped it").length, 0, "generic placeholders are intentional");
87
+ // scanIdentity is the ORIGINAL-INSTANCE scan only. The fictional placeholder
88
+ // class is a SEPARATE pass (scanPlaceholders) over its own path set — see the
89
+ // placeholder tests at the bottom of this file.
90
+ assert.equal(scanIdentity("Northwind's Alex and Jordan shipped it").length, 0, "different scan");
83
91
  assert.equal(scanIdentity("").length, 0);
84
92
  });
85
93
 
@@ -87,3 +95,108 @@ test("the LIVE repo is clean under the expanded ratchet (the commit that lands i
87
95
  const code = await run();
88
96
  assert.equal(code, 0, "expanded ratchet must be green in-tree");
89
97
  });
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // The FICTIONAL PLACEHOLDER class — the exemption that used to say
101
+ // "Northwind / Alex / Jordan are intentional examples and are NOT flagged".
102
+ // That blanket exemption is what let an invented employer sit inside the huddle
103
+ // system prompt, the PSTN relay fallback, the classifier identity line and the
104
+ // outbound validator. These tests pin the narrowed shape.
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+
107
+ test("placeholder scan FLAGS a fictional employer in a live prompt path", () => {
108
+ const cwd = mkdtempSync(join(tmpdir(), "ratchet-ph-"));
109
+ mkdirSync(join(cwd, "scripts", "huddle"), { recursive: true });
110
+ writeFileSync(
111
+ join(cwd, "scripts", "huddle", "huddle-server.mjs"),
112
+ 'const p = `ABOUT: Northwind is a global asset manager`;\n',
113
+ );
114
+ const { regressions } = findPlaceholderIdentity({ cwd, pending: [] });
115
+ assert.equal(regressions.length, 1, "a prompt-path literal is a regression");
116
+ assert.match(regressions[0].hits[0].label, /Northwind/);
117
+ rmSync(cwd, { recursive: true, force: true });
118
+ });
119
+
120
+ test("placeholder scan EXEMPTS tests, fixtures, docs, scaffold and *.example", () => {
121
+ const cwd = mkdtempSync(join(tmpdir(), "ratchet-ph2-"));
122
+ mkdirSync(join(cwd, "scripts", "daemon"), { recursive: true });
123
+ mkdirSync(join(cwd, "lib", "fixtures"), { recursive: true });
124
+ mkdirSync(join(cwd, "scaffold", "config"), { recursive: true });
125
+ const body = 'const who = "Alex Chen at Northwind";\n';
126
+ writeFileSync(join(cwd, "scripts", "daemon", "thing.test.mjs"), body);
127
+ writeFileSync(join(cwd, "scripts", "test-information-barriers.py"), body);
128
+ writeFileSync(join(cwd, "lib", "fixtures", "roster.json"), body);
129
+ writeFileSync(join(cwd, "scaffold", "config", "known-agents.json"), body);
130
+ writeFileSync(join(cwd, "lib", "voice.yaml.example"), body);
131
+ const { regressions } = findPlaceholderIdentity({ cwd, pending: [] });
132
+ assert.deepEqual(regressions, [], "fixtures/tests/scaffold/examples are legitimate placeholders");
133
+ rmSync(cwd, { recursive: true, force: true });
134
+ });
135
+
136
+ test("isFixturePath classifies by convention, not by a hand-maintained list", () => {
137
+ for (const p of [
138
+ "lib/model-router.test.mjs",
139
+ "scripts/test-voice-parser.mjs",
140
+ "scripts/foo_test.py",
141
+ "docs/guides/voice.md",
142
+ "scaffold/config/known-agents.json",
143
+ "lib/channels/__tests__/x.mjs",
144
+ "config/voice.yaml.example",
145
+ ]) assert.equal(isFixturePath(p), true, p);
146
+
147
+ for (const p of [
148
+ "scripts/huddle/huddle-server.mjs",
149
+ "scripts/daemon/classifier.mjs",
150
+ "policies/information-barriers.yaml",
151
+ "lib/identity/persona.mjs",
152
+ ]) assert.equal(isFixturePath(p), false, p);
153
+ });
154
+
155
+ test("PLACEHOLDER_PENDING is a shrinking ratchet: a baselined file that went clean FAILS", () => {
156
+ const cwd = mkdtempSync(join(tmpdir(), "ratchet-ph3-"));
157
+ mkdirSync(join(cwd, "scripts"), { recursive: true });
158
+ writeFileSync(join(cwd, "scripts", "clean.mjs"), "export const x = 1;\n");
159
+ const pending = [{ file: "scripts/clean.mjs", reason: "stale baseline entry, ten chars plus" }];
160
+ const res = findPlaceholderIdentity({ cwd, pending });
161
+ assert.deepEqual(res.regressions, []);
162
+ assert.deepEqual(res.pendingClean, ["scripts/clean.mjs"], "clean baseline entry must be deleted");
163
+ rmSync(cwd, { recursive: true, force: true });
164
+ });
165
+
166
+ test("PLACEHOLDER_PENDING suppresses only its own listed files, and reports them", () => {
167
+ const cwd = mkdtempSync(join(tmpdir(), "ratchet-ph4-"));
168
+ mkdirSync(join(cwd, "scripts"), { recursive: true });
169
+ writeFileSync(join(cwd, "scripts", "known.mjs"), 'const a = "Northwind";\n');
170
+ writeFileSync(join(cwd, "scripts", "new.mjs"), 'const b = "PartnerCo";\n');
171
+ const pending = [{ file: "scripts/known.mjs", reason: "not yet grounded in config/company.json" }];
172
+ const res = findPlaceholderIdentity({ cwd, pending });
173
+ assert.deepEqual(res.regressions.map((r) => r.file), ["scripts/new.mjs"], "only the NEW file fails");
174
+ assert.deepEqual(res.pendingDirty.map((e) => e.file), ["scripts/known.mjs"], "the baseline is reported, not hidden");
175
+ rmSync(cwd, { recursive: true, force: true });
176
+ });
177
+
178
+ test("assertPendingPolicy rejects an undocumented baseline entry", () => {
179
+ assert.throws(() => assertPendingPolicy([{ file: "scripts/x.mjs", reason: "meh" }]), /needs a real/);
180
+ assert.throws(() => assertPendingPolicy([{ reason: "a perfectly long reason" }]), /no `file`/);
181
+ assertPendingPolicy(PLACEHOLDER_PENDING); // the shipped baseline is in policy
182
+ });
183
+
184
+ test("the files this lane grounded are NOT on the baseline and are clean in-tree", () => {
185
+ const grounded = [
186
+ "scripts/huddle/huddle-server.mjs",
187
+ "scripts/daemon/classifier.mjs",
188
+ "scripts/daemon/prompt-builder.mjs",
189
+ "scripts/cloud-relay/voice/server.mjs",
190
+ "scripts/media-generation/generate-assets.mjs",
191
+ "scripts/media-generation/gemini-image-client.mjs",
192
+ "scripts/spawn-session.sh",
193
+ "scripts/pre-draft-context.py",
194
+ "scripts/disclosure_boundaries.py",
195
+ "policies/information-barriers.yaml",
196
+ ];
197
+ const baselined = new Set(PLACEHOLDER_PENDING.map((e) => e.file));
198
+ for (const f of grounded) {
199
+ assert.equal(baselined.has(f), false, `${f} must not be baselined — it is grounded`);
200
+ assert.equal(scanPlaceholders(readFileSync(new URL(`../../${f}`, import.meta.url), "utf8")).length, 0, f);
201
+ }
202
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * relay-identity.test.mjs — the PSTN relay must never invent an employer.
3
+ *
4
+ * WHAT BROKE
5
+ * `buildSessionUpdate()` built its last-resort instructions as:
6
+ * `You are ${identity.name || "the agent"}, ${identity.role || "an executive
7
+ * operator"} at ${identity.company || "Northwind"}.`
8
+ * This service is the DEPLOYED Railway relay that answers inbound PSTN calls.
9
+ * The fallback fires whenever the local agent queues a call with a bare
10
+ * `brief` and no `instructions`, and whatever it says is spoken to a person on
11
+ * a phone line — it leaves the org irrecoverably.
12
+ *
13
+ * WHY THE FIX IS SERVER-SIDE REFUSAL
14
+ * The relay runs on Railway with no access to config/agent.json or
15
+ * config/company.json. Everything it knows about the agent arrived in the
16
+ * queue entry. A field that did not arrive is a field this process CANNOT
17
+ * know, so the only honest behaviour is to drop the clause.
18
+ *
19
+ * HOW IT RUNS
20
+ * The relay ships its own package.json and depends on `ws`, which is not
21
+ * resolvable from this repo root, so server.mjs cannot be imported in-process.
22
+ * The identity helpers are pure, so we lift their REAL source out of the
23
+ * shipped file and evaluate that — the text under test is the text that ships.
24
+ *
25
+ * Run: node --test scripts/cloud-relay/voice/relay-identity.test.mjs
26
+ */
27
+
28
+ "use strict";
29
+
30
+ import { test } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { readFileSync } from "node:fs";
33
+
34
+ const SRC = readFileSync(new URL("./server.mjs", import.meta.url), "utf8");
35
+
36
+ function extractFunction(src, name) {
37
+ const re = new RegExp(`(?:export\\s+)?function\\s+${name}\\s*\\(`);
38
+ const m = re.exec(src);
39
+ assert.ok(m, `server.mjs no longer defines ${name}() — update this test deliberately`);
40
+ const open = src.indexOf("{", m.index + m[0].length - 1);
41
+ let depth = 0;
42
+ for (let i = open; i < src.length; i++) {
43
+ if (src[i] === "{") depth++;
44
+ else if (src[i] === "}" && --depth === 0) return src.slice(m.index, i + 1).replace(/^export\s+/, "");
45
+ }
46
+ throw new Error(`unbalanced braces extracting ${name}`);
47
+ }
48
+
49
+ const names = ["configured", "fallbackInstructions"];
50
+ const mod = new Function(
51
+ `${names.map((n) => extractFunction(SRC, n)).join("\n\n")}\nreturn { ${names.join(", ")} };`,
52
+ )();
53
+ const { fallbackInstructions } = mod;
54
+
55
+ // ── GROUNDED — the local agent shipped a real identity ──────────────────────
56
+
57
+ test("grounded: the full identity from the queue entry is spoken", () => {
58
+ const out = fallbackInstructions({ name: "Robin Okafor", role: "VP Operations", company: "Meridian Freight" });
59
+ assert.equal(out, "You are Robin Okafor, VP Operations at Meridian Freight. Reply briefly in your voice.");
60
+ });
61
+
62
+ // ── EMPTY — omit the clause, never substitute a default ─────────────────────
63
+
64
+ test("empty: no company ⇒ the 'at <company>' clause is DROPPED, not defaulted", () => {
65
+ const out = fallbackInstructions({ name: "Robin Okafor", role: "VP Operations" });
66
+ assert.equal(out, "You are Robin Okafor, VP Operations. Reply briefly in your voice.");
67
+ assert.doesNotMatch(out, / at /);
68
+ });
69
+
70
+ test("empty: the UNCONFIGURED sentinel is treated as absent", () => {
71
+ const out = fallbackInstructions({ name: "Robin Okafor", role: "VP Operations", company: "UNCONFIGURED" });
72
+ assert.doesNotMatch(out, /UNCONFIGURED/);
73
+ assert.doesNotMatch(out, / at /);
74
+ });
75
+
76
+ test("empty: no role ⇒ no invented role", () => {
77
+ const out = fallbackInstructions({ name: "Robin Okafor" });
78
+ assert.equal(out, "You are Robin Okafor. Reply briefly in your voice.");
79
+ assert.doesNotMatch(out, /executive operator/i);
80
+ });
81
+
82
+ test("empty: nothing at all ⇒ NO persona is asserted to the caller", () => {
83
+ for (const id of [{}, { name: "", role: "", company: "" }, { name: " " }]) {
84
+ const out = fallbackInstructions(id);
85
+ assert.equal(out, "Reply briefly in your voice.", JSON.stringify(id));
86
+ assert.doesNotMatch(out, /You are/);
87
+ }
88
+ });
89
+
90
+ // ── The literal is gone from the deployed source ────────────────────────────
91
+
92
+ test("the deployed relay source carries no default employer", () => {
93
+ assert.doesNotMatch(SRC, /\bNorthwind\b/i);
94
+ assert.doesNotMatch(SRC, /identity\.company \|\|/, "no `|| <default>` on the employer");
95
+ assert.doesNotMatch(SRC, /an executive operator/, "no `|| <default>` on the role");
96
+ });
@@ -490,6 +490,47 @@ function handleTwilioStream(twilioWs, req) {
490
490
  }
491
491
  }
492
492
 
493
+ /**
494
+ * A config value the operator has not filled in yet. The scaffold ships
495
+ * `"name": "UNCONFIGURED"` in config/company.json and empty strings elsewhere,
496
+ * so BOTH shapes mean "absent" and neither may be rendered into speech.
497
+ * @param {*} v
498
+ * @returns {string} the trimmed value, or "" when it is absent/unconfigured
499
+ */
500
+ function configured(v) {
501
+ const s = typeof v === "string" ? v.trim() : "";
502
+ if (!s) return "";
503
+ if (s.toUpperCase() === "UNCONFIGURED") return "";
504
+ return s;
505
+ }
506
+
507
+ /**
508
+ * Last-resort identity line for a call whose queue entry carried neither
509
+ * `instructions` nor a `brief`.
510
+ *
511
+ * FAIL CLOSED ON IDENTITY. This relay runs on Railway with no access to the
512
+ * agent's config/agent.json or config/company.json — everything it knows about
513
+ * who the agent is arrived in the queue entry from lib/voice/outbound.mjs. A
514
+ * field that did not arrive is a field this process CANNOT know, and this text
515
+ * is spoken to a person on a PSTN call: a wrong employer leaves the org
516
+ * irrecoverably. So an absent field drops its clause rather than taking a
517
+ * default. A generic directive is honest; an invented company is not.
518
+ *
519
+ * @param {{name?:string, role?:string, company?:string}} identity
520
+ * @returns {string}
521
+ */
522
+ function fallbackInstructions(identity) {
523
+ const name = configured(identity.name);
524
+ const role = configured(identity.role);
525
+ const company = configured(identity.company);
526
+ const who = [name, role].filter(Boolean).join(", ");
527
+ const at = company ? ` at ${company}` : "";
528
+ // No name and no role: say nothing about identity at all rather than assert
529
+ // a persona the caller never supplied.
530
+ const lead = who ? `You are ${who}${at}. ` : "";
531
+ return `${lead}Reply briefly in your voice.`;
532
+ }
533
+
493
534
  function buildSessionUpdate(queued) {
494
535
  const voice = queued.voice || "cedar";
495
536
  const identity = queued.identity || {};
@@ -497,8 +538,7 @@ function buildSessionUpdate(queued) {
497
538
  // local agent shipped — includes identity, memory, RAG, peer info,
498
539
  // voice-mode rules) over the bare `brief`. The `brief` describes
499
540
  // the task; `instructions` describes the whole agent.
500
- const instructions = queued.instructions || queued.brief ||
501
- `You are ${identity.name || "the agent"}, ${identity.role || "an executive operator"} at ${identity.company || "Northwind"}. Reply briefly in your voice.`;
541
+ const instructions = queued.instructions || queued.brief || fallbackInstructions(identity);
502
542
  return {
503
543
  type: "session.update",
504
544
  session: {