@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.
- package/.claude/commands/init-maestro.md +16 -9
- package/docs/guides/mac-mini.md +11 -1
- package/docs/runbooks/cohort-cutover.md +16 -0
- package/lib/channels/inbox-item.mjs +12 -0
- package/lib/channels/inbox-item.test.mjs +33 -0
- package/lib/execution/disposition.mjs +13 -2
- package/lib/execution/disposition.test.mjs +19 -2
- package/lib/execution/pipeline.test.mjs +4 -1
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- package/lib/org/messaging.mjs +5 -0
- package/lib/org/messaging.test.mjs +7 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +98 -0
- package/lib/org/protocol.test.mjs +19 -2
- package/lib/org/resource-tools.mjs +317 -0
- package/lib/org/resource-tools.test.mjs +361 -0
- package/lib/org/tool-access.mjs +176 -0
- package/lib/org/tool-access.test.mjs +144 -0
- package/lib/org/tool-surface.mjs +431 -5
- package/lib/org/tool-surface.test.mjs +385 -8
- package/lib/org/ui-parity.mjs +196 -3
- package/lib/org/ui-parity.test.mjs +126 -7
- package/lib/tool-definitions.js +23 -2
- package/package.json +2 -2
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
- package/policies/information-barriers.yaml +34 -7
- package/scripts/ci/check-no-residual-identity.mjs +281 -9
- package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
- package/scripts/cloud-relay/voice/server.mjs +42 -2
- package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
- package/scripts/cost/track-claude-usage.mjs +113 -4
- package/scripts/daemon/agent-daemon.mjs +212 -5
- package/scripts/daemon/agent-daemon.test.mjs +307 -0
- package/scripts/daemon/assurance.mjs +38 -15
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/cadence-handlers.mjs +48 -5
- package/scripts/daemon/cadence-handlers.test.mjs +57 -2
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- package/scripts/daemon/inbox-deferral.mjs +49 -24
- package/scripts/daemon/inbox-deferral.test.mjs +39 -1
- package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
- package/scripts/daemon/prompt-builder.mjs +264 -41
- package/scripts/daemon/prompt-builder.test.mjs +5 -5
- package/scripts/daemon/responder.mjs +9 -0
- package/scripts/disclosure_boundaries.py +56 -5
- package/scripts/huddle/huddle-prompt.test.mjs +176 -0
- package/scripts/huddle/huddle-server.mjs +128 -13
- package/scripts/local-triggers/autoupdate.sh +83 -0
- package/scripts/local-triggers/generate-plists.sh +9 -0
- package/scripts/local-triggers/generate-plists.test.mjs +12 -10
- package/scripts/media-generation/brand-clause.test.mjs +135 -0
- package/scripts/media-generation/gemini-image-client.mjs +27 -9
- package/scripts/media-generation/generate-assets.mjs +102 -7
- package/scripts/poller/inbox-scan-poller.mjs +7 -0
- package/scripts/poller/utils.mjs +11 -0
- package/scripts/pre-draft-context.py +91 -15
- package/scripts/spawn-session.sh +36 -6
- package/scripts/test-employer-grounding.py +348 -0
- 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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|