@cohortapp/agent-sdk 2.10.0 → 2.11.0
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/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- 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 +150 -3
- package/scripts/daemon/agent-daemon.test.mjs +190 -0
- package/scripts/daemon/assurance.mjs +38 -15
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- 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/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/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
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync, readdirSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
8
9
|
import { compileContext } from "./context-compiler.mjs";
|
|
9
|
-
import {
|
|
10
|
+
import { renderPersona } from "../../lib/identity/persona.mjs";
|
|
10
11
|
import { wrapExternalContent } from "../../lib/security/external-content.mjs";
|
|
11
12
|
import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
|
|
12
13
|
import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
|
|
@@ -14,12 +15,22 @@ import { outcomeSourceShareable } from "./session-outcomes.mjs";
|
|
|
14
15
|
|
|
15
16
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
+
// js-yaml, synchronously. The autonomy policy is read on the fallback-preamble
|
|
19
|
+
// path, which is sync by contract; the org-config reader below can afford the
|
|
20
|
+
// dynamic import, this cannot. js-yaml is a declared dependency of this package.
|
|
21
|
+
const _require = createRequire(import.meta.url);
|
|
22
|
+
const yaml = _require("js-yaml");
|
|
23
|
+
|
|
24
|
+
// Load agent identity from canonical SOT. Used for the backlog-item owner label.
|
|
25
|
+
// The fallback carries ONLY a generic first name — it used to also carry
|
|
26
|
+
// `company: "the company"` and a fabricated principal, which is a claim about
|
|
27
|
+
// the seat that nothing read. Identity for prompts goes through
|
|
28
|
+
// renderSeatPersona() below, which omits an unset field rather than filling it.
|
|
18
29
|
function loadAgent() {
|
|
19
30
|
try {
|
|
20
31
|
return JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
|
|
21
32
|
} catch {
|
|
22
|
-
return { firstName: "
|
|
33
|
+
return { firstName: "agent" };
|
|
23
34
|
}
|
|
24
35
|
}
|
|
25
36
|
|
|
@@ -128,10 +139,94 @@ let cachedPreamble = null;
|
|
|
128
139
|
// back to a generic-assistant register.
|
|
129
140
|
let cachedPersona = null;
|
|
130
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A config value the operator has not filled in yet.
|
|
144
|
+
*
|
|
145
|
+
* The scaffold does not ship empty strings for everything — it ships SENTINELS:
|
|
146
|
+
* config/company.json has `"name": "UNCONFIGURED"`, config/agent.json has
|
|
147
|
+
* `"firstName": "UNCONFIGURED"`, `"fullName": "Unconfigured Agent"`,
|
|
148
|
+
* `"title": "Unconfigured Role"`. lib/identity/persona.mjs#renderPersona omits
|
|
149
|
+
* EMPTY fields, which is right, but a sentinel is not empty — so an untouched
|
|
150
|
+
* scaffold rendered "You are Unconfigured Agent, Unconfigured Role at
|
|
151
|
+
* UNCONFIGURED." into a live prompt. Treat the sentinel as absence.
|
|
152
|
+
*
|
|
153
|
+
* @param {*} v
|
|
154
|
+
* @returns {string} the trimmed value, or "" when absent/sentinel
|
|
155
|
+
*/
|
|
156
|
+
function configuredStr(v) {
|
|
157
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
158
|
+
if (!s) return "";
|
|
159
|
+
if (/^unconfigured\b/i.test(s)) return "";
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Strip scaffold sentinels out of config/agent.json before it is rendered, so
|
|
165
|
+
* `renderPersona`'s omit-when-unset rule actually fires on a fresh repo.
|
|
166
|
+
* @param {object} a
|
|
167
|
+
* @returns {object}
|
|
168
|
+
*/
|
|
169
|
+
function scrubAgentConfig(a) {
|
|
170
|
+
const src = a && typeof a === "object" ? a : {};
|
|
171
|
+
const out = { ...src };
|
|
172
|
+
for (const k of ["firstName", "lastName", "fullName", "title", "company", "companyDescription", "persona", "background", "bio"]) {
|
|
173
|
+
if (k in out) out[k] = configuredStr(out[k]);
|
|
174
|
+
}
|
|
175
|
+
// A surname with no first name and no full name is not an identity — better
|
|
176
|
+
// to render no name at all than "You are AGENT." (the scaffold ships
|
|
177
|
+
// firstName "UNCONFIGURED" / lastName "AGENT").
|
|
178
|
+
if (!out.firstName && !out.fullName) out.lastName = "";
|
|
179
|
+
if (src.principal && typeof src.principal === "object") {
|
|
180
|
+
const p = { ...src.principal };
|
|
181
|
+
for (const k of ["firstName", "lastName", "fullName", "title"]) p[k] = configuredStr(p[k]);
|
|
182
|
+
out.principal = p;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Same, for config/company.json. */
|
|
188
|
+
function scrubCompanyConfig(c) {
|
|
189
|
+
const src = c && typeof c === "object" ? c : {};
|
|
190
|
+
const out = { ...src };
|
|
191
|
+
for (const k of ["name", "legalName", "description", "tagline", "industry", "stage"]) {
|
|
192
|
+
if (k in out) out[k] = configuredStr(out[k]);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Read + scrub both config files. Never throws. @returns {{agent:object, company:object}} */
|
|
198
|
+
function readSeatConfig(root) {
|
|
199
|
+
const read = (rel) => {
|
|
200
|
+
try { return JSON.parse(readFileSync(join(root, rel), "utf-8")); } catch { return {}; }
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
agent: scrubAgentConfig(read("config/agent.json")),
|
|
204
|
+
company: scrubCompanyConfig(read("config/company.json")),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render the persona block for a seat — config/agent.json + config/company.json
|
|
210
|
+
* through lib/identity/persona.mjs#renderPersona, with scaffold sentinels
|
|
211
|
+
* scrubbed first so an unconfigured field is OMITTED rather than asserted.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} root agent repo root
|
|
214
|
+
* @param {object} [opts] forwarded to renderPersona
|
|
215
|
+
* @returns {string} "" when nothing is configured
|
|
216
|
+
*/
|
|
217
|
+
export function renderSeatPersona(root, opts = {}) {
|
|
218
|
+
try {
|
|
219
|
+
const { agent, company } = readSeatConfig(root);
|
|
220
|
+
return renderPersona(agent, company, opts) || "";
|
|
221
|
+
} catch {
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
131
226
|
/** Render (once per process) the persona block from config/agent.json. */
|
|
132
227
|
function loadPersona() {
|
|
133
228
|
if (cachedPersona !== null) return cachedPersona;
|
|
134
|
-
cachedPersona =
|
|
229
|
+
cachedPersona = renderSeatPersona(AGENT_REPO_DIR);
|
|
135
230
|
return cachedPersona;
|
|
136
231
|
}
|
|
137
232
|
|
|
@@ -148,7 +243,7 @@ function loadPreamble() {
|
|
|
148
243
|
cachedPreamble = extractPreamble(raw);
|
|
149
244
|
} catch (err) {
|
|
150
245
|
console.error(`[prompt-builder] Failed to read CLAUDE.md: ${err.message}`);
|
|
151
|
-
cachedPreamble =
|
|
246
|
+
cachedPreamble = fallbackPreamble();
|
|
152
247
|
}
|
|
153
248
|
return cachedPreamble;
|
|
154
249
|
}
|
|
@@ -196,44 +291,172 @@ function extractPreamble(raw) {
|
|
|
196
291
|
|
|
197
292
|
// If extraction got something reasonable, use it; otherwise fall back
|
|
198
293
|
if (extracted.length > 200) return extracted;
|
|
199
|
-
return
|
|
294
|
+
return fallbackPreamble();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Read the seat's GENERATED autonomy policy, or null.
|
|
299
|
+
*
|
|
300
|
+
* Provenance check, not just a file check. `policies/action-classification.yaml`
|
|
301
|
+
* exists in two very different shapes:
|
|
302
|
+
*
|
|
303
|
+
* - the hand-written TEMPLATE that ships with the framework, whose header says
|
|
304
|
+
* "the agent is a fully autonomous executive operator" for every seat; and
|
|
305
|
+
* - the per-seat artefact `scripts/setup/generate-autonomy.mjs` renders from
|
|
306
|
+
* `lib/autonomy.mjs#buildActionClassification` for THIS agent's archetype
|
|
307
|
+
* (function × altitude), which is the only one that describes a real grant.
|
|
308
|
+
*
|
|
309
|
+
* Only the generated artefact carries `archetype` + `delegation_breadth`, so we
|
|
310
|
+
* require both. A template copy is treated as ABSENT — inheriting another
|
|
311
|
+
* seat's maximal grant is precisely the fabrication this function exists to stop.
|
|
312
|
+
*
|
|
313
|
+
* Never throws; returns null on any read/parse failure.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} [root=AGENT_REPO_DIR]
|
|
316
|
+
* @returns {object|null}
|
|
317
|
+
*/
|
|
318
|
+
export function loadSeatAutonomyPolicy(root = AGENT_REPO_DIR) {
|
|
319
|
+
try {
|
|
320
|
+
const raw = readFileSync(join(root, "policies/action-classification.yaml"), "utf-8");
|
|
321
|
+
const doc = yaml.load(raw);
|
|
322
|
+
if (!doc || typeof doc !== "object") return null;
|
|
323
|
+
if (!doc.archetype || !doc.delegation_breadth) return null;
|
|
324
|
+
return doc;
|
|
325
|
+
} catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Clean string[] from a config list; drops blanks. */
|
|
331
|
+
function strList(v, max = 12) {
|
|
332
|
+
if (!Array.isArray(v)) return [];
|
|
333
|
+
return v.map((x) => (typeof x === "string" ? x.trim() : "")).filter(Boolean).slice(0, max);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Render what this seat may actually do, from the generated policy — or, when
|
|
338
|
+
* no policy has been generated, a fail-closed restraint.
|
|
339
|
+
*
|
|
340
|
+
* WHY THIS REPLACED A LITERAL
|
|
341
|
+
* The previous text granted the seat authority to send, unreviewed, "all
|
|
342
|
+
* internal messages, external operational messages, candidate comms,
|
|
343
|
+
* follow-ups, calendar coordination" and to escalate only four narrow
|
|
344
|
+
* categories. NONE of that was read from anywhere. It is a GOVERNANCE claim,
|
|
345
|
+
* not a naming one, and it fired on exactly the population least entitled to
|
|
346
|
+
* it: this preamble is used when CLAUDE.md is unreadable OR its extracted
|
|
347
|
+
* sections total <= 200 chars, i.e. on a freshly scaffolded repo that has
|
|
348
|
+
* never had autonomy bands set — while driving a real `claude --print`
|
|
349
|
+
* session that can send real messages.
|
|
350
|
+
*
|
|
351
|
+
* So: bands come from the seat's generated policy or they are not asserted.
|
|
352
|
+
* With no policy the correct posture is LESS authority, not more — the
|
|
353
|
+
* preamble says draft-and-confirm and says why. That is a statement about the
|
|
354
|
+
* ABSENCE of a configured grant, not an invented grant.
|
|
355
|
+
*
|
|
356
|
+
* @param {object|null} policy result of loadSeatAutonomyPolicy()
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
export function renderAutonomySection(policy) {
|
|
360
|
+
if (!policy) {
|
|
361
|
+
return [
|
|
362
|
+
"Authority (NO AUTONOMY POLICY IS CONFIGURED FOR THIS SEAT):",
|
|
363
|
+
"- `policies/action-classification.yaml` has not been generated for you (run `maestro upgrade`, which runs scripts/setup/generate-autonomy.mjs).",
|
|
364
|
+
"- Until it has been, assume NO standing authority to act outward. Draft, do not send. Prepare the message, document or calendar change and put it in front of your principal for a decision.",
|
|
365
|
+
"- Do not make commitments, send outbound communications, or take irreversible actions on your own authority on the strength of this prompt. Nothing here grants that.",
|
|
366
|
+
].join("\n");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const levels = (policy.action_levels && typeof policy.action_levels === "object") ? policy.action_levels : {};
|
|
370
|
+
const green = strList(levels.execute_autonomous && levels.execute_autonomous.examples);
|
|
371
|
+
const amber = strList(levels.approval_preferred && levels.approval_preferred.examples);
|
|
372
|
+
const red = strList(levels.escalate && levels.escalate.examples);
|
|
373
|
+
const escalateTo = strList(levels.escalate && levels.escalate.escalate_to, 4);
|
|
374
|
+
const rights = Array.isArray(policy.decision_rights) ? policy.decision_rights.slice(0, 8) : [];
|
|
375
|
+
|
|
376
|
+
const out = [
|
|
377
|
+
`Authority (from policies/action-classification.yaml, generated for archetype ${policy.archetype}):`,
|
|
378
|
+
];
|
|
379
|
+
if (policy.autonomy_default) out.push(`- Default posture: ${policy.autonomy_default}`);
|
|
380
|
+
if (green.length) out.push(`- You act on your own authority: ${green.join("; ")}`);
|
|
381
|
+
if (amber.length) out.push(`- You get agreement first: ${amber.join("; ")}`);
|
|
382
|
+
if (red.length) out.push(`- You escalate and do NOT act alone: ${red.join("; ")}`);
|
|
383
|
+
if (escalateTo.length) out.push(`- Escalate to: ${escalateTo.join(", ")}`);
|
|
384
|
+
for (const d of rights) {
|
|
385
|
+
if (!d || typeof d !== "object") continue;
|
|
386
|
+
const scope = typeof d.scope === "string" ? d.scope.trim() : "";
|
|
387
|
+
const authority = typeof d.authority === "string" ? d.authority.trim() : "";
|
|
388
|
+
if (!scope) continue;
|
|
389
|
+
out.push(`- Decision right — ${scope}${authority ? ` (${authority})` : ""}`);
|
|
390
|
+
}
|
|
391
|
+
// A generated policy with every band empty asserts nothing. Say so rather
|
|
392
|
+
// than implying the empty bands are a grant.
|
|
393
|
+
if (out.length === 1) {
|
|
394
|
+
out.push("- The generated policy declares no bands. Treat that as no standing authority: draft, do not send.");
|
|
395
|
+
}
|
|
396
|
+
return out.join("\n");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Preamble used ONLY when CLAUDE.md is unreadable or effectively empty.
|
|
401
|
+
*
|
|
402
|
+
* Everything seat-specific is rendered from the source of truth:
|
|
403
|
+
* identity/company/responsibilities/operating principles → config/agent.json +
|
|
404
|
+
* config/company.json via lib/identity/persona.mjs#renderPersona (which omits
|
|
405
|
+
* an unset field rather than faking it);
|
|
406
|
+
* authority → the seat's generated action-classification policy.
|
|
407
|
+
*
|
|
408
|
+
* What remains hardcoded is framework craft doctrine that makes NO claim about
|
|
409
|
+
* this seat's employer or its authority, so it is honest on any deployment.
|
|
410
|
+
*
|
|
411
|
+
* @returns {string}
|
|
412
|
+
*/
|
|
413
|
+
export function buildFallbackPreamble(root = AGENT_REPO_DIR) {
|
|
414
|
+
const parts = [];
|
|
415
|
+
|
|
416
|
+
// Identity, company, responsibilities and operating principles — all from
|
|
417
|
+
// config. Voice rules are omitted here because buildPrompt() already leads
|
|
418
|
+
// with the full persona block; this is the degraded path, not a second copy.
|
|
419
|
+
const persona = renderSeatPersona(root, { includeVoiceRules: false });
|
|
420
|
+
if (persona) parts.push(persona);
|
|
421
|
+
|
|
422
|
+
parts.push(renderAutonomySection(loadSeatAutonomyPolicy(root)));
|
|
423
|
+
|
|
424
|
+
parts.push(
|
|
425
|
+
[
|
|
426
|
+
"How you work:",
|
|
427
|
+
"- Follow-through over brilliance — track every commitment until it closes",
|
|
428
|
+
"- Concise over comprehensive — sharp recommendations, not exhaustive reports",
|
|
429
|
+
"- Evidence over opinion — cite sources, and say when you do not know",
|
|
430
|
+
"- Audit everything — every action is logged",
|
|
431
|
+
].join("\n"),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
parts.push(
|
|
435
|
+
[
|
|
436
|
+
"Document Sharing (CRITICAL):",
|
|
437
|
+
"- NEVER reference local file paths in outbound communications",
|
|
438
|
+
"- Upload to shared storage or generate a branded PDF for sharing",
|
|
439
|
+
"- Inline short content directly in messages",
|
|
440
|
+
"- Never attach raw .md or .yaml files",
|
|
441
|
+
].join("\n"),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
return parts.join("\n\n");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Computed lazily (and cached) rather than at module load: the autonomy policy
|
|
448
|
+
// and config/agent.json are read from disk, and a test that points AGENT_DIR at
|
|
449
|
+
// a fixture must not be beaten to it by import-time evaluation.
|
|
450
|
+
let _fallbackPreamble = null;
|
|
451
|
+
function fallbackPreamble() {
|
|
452
|
+
if (_fallbackPreamble === null) _fallbackPreamble = buildFallbackPreamble();
|
|
453
|
+
return _fallbackPreamble;
|
|
200
454
|
}
|
|
201
455
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
function buildFallbackPreamble() {
|
|
206
|
-
const a = loadAgent();
|
|
207
|
-
const principal = a.principal || {};
|
|
208
|
-
const principalName = principal.fullName || "the principal";
|
|
209
|
-
const principalTitle = principal.title || "principal";
|
|
210
|
-
// "<title> to <principal>" only reads correctly for assistant-shaped titles;
|
|
211
|
-
// for a functional seat ("SVP AI Systems & Agent Platform") it turns the role
|
|
212
|
-
// into a service relationship. State the seat, then the reporting line.
|
|
213
|
-
return `You are ${a.fullName}, ${a.title} at ${a.company}. You report to ${principalName}, ${principalTitle}.
|
|
214
|
-
You operate as the autonomous executive command layer for the company.
|
|
215
|
-
|
|
216
|
-
${a.companyDescription || a.company}.
|
|
217
|
-
|
|
218
|
-
Operating Principles:
|
|
219
|
-
1. Follow-through over brilliance — track every commitment until it closes
|
|
220
|
-
2. Concise over comprehensive — sharp recommendations, not exhaustive reports
|
|
221
|
-
3. Evidence over opinion — cite sources
|
|
222
|
-
4. Bias to action — act decisively on operational matters; escalate only strategic commitments
|
|
223
|
-
5. Audit everything — every action is logged
|
|
224
|
-
6. Full autonomy, full accountability
|
|
225
|
-
|
|
226
|
-
Autonomy Model:
|
|
227
|
-
- ${a.firstName} sends autonomously: all internal messages, external operational messages, candidate comms, follow-ups, calendar coordination
|
|
228
|
-
- ${a.firstName} escalates only: binding legal/financial obligations, regulatory submissions, acquisition deal terms, public statements
|
|
229
|
-
|
|
230
|
-
Document Sharing (CRITICAL):
|
|
231
|
-
- NEVER reference local file paths in outbound communications
|
|
232
|
-
- Upload to Google Drive or generate branded PDF for sharing
|
|
233
|
-
- Inline short content directly in messages
|
|
234
|
-
- Never attach raw .md or .yaml files`;
|
|
456
|
+
/** For tests: drop the cached fallback preamble. */
|
|
457
|
+
export function _resetFallbackPreamble() {
|
|
458
|
+
_fallbackPreamble = null;
|
|
235
459
|
}
|
|
236
|
-
const FALLBACK_PREAMBLE = buildFallbackPreamble();
|
|
237
460
|
|
|
238
461
|
/**
|
|
239
462
|
* Proactive self-learning habit (WS3). Injected near the top of every reactive /
|
|
@@ -531,7 +754,7 @@ function buildBacklogContext(queueItem) {
|
|
|
531
754
|
lines.push(`Title: ${queueItem.title || "untitled"}`);
|
|
532
755
|
lines.push(`Status: ${queueItem.status || "open"}`);
|
|
533
756
|
lines.push(`Priority: ${queueItem.priority || "normal"}`);
|
|
534
|
-
lines.push(`Owner: ${queueItem.owner || loadAgent().firstName.toLowerCase()}`);
|
|
757
|
+
lines.push(`Owner: ${queueItem.owner || configuredStr(loadAgent().firstName).toLowerCase() || "agent"}`);
|
|
535
758
|
if (queueItem.source) lines.push(`Source: ${queueItem.source}`);
|
|
536
759
|
if (queueItem.source_ref) lines.push(`Source ref: ${queueItem.source_ref}`);
|
|
537
760
|
// ── what to act ON, and with WHAT ────────────────────────────────────────
|
|
@@ -215,18 +215,18 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
|
|
|
215
215
|
test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
|
|
216
216
|
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
217
217
|
type: "inbox",
|
|
218
|
-
holdingMessage: "Understood —
|
|
218
|
+
holdingMessage: "Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way.",
|
|
219
219
|
holdingSent: true,
|
|
220
220
|
});
|
|
221
221
|
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
222
|
-
assert.ok(prompt.includes("Understood —
|
|
222
|
+
assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
|
|
223
223
|
assert.ok(!/DELIVERY FAILED/.test(prompt), "no failure framing on a delivered ack");
|
|
224
224
|
});
|
|
225
225
|
|
|
226
226
|
test("buildPrompt does NOT claim delivery when the acknowledgement failed to send", async () => {
|
|
227
227
|
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
228
228
|
type: "inbox",
|
|
229
|
-
holdingMessage: "Understood —
|
|
229
|
+
holdingMessage: "Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way.",
|
|
230
230
|
holdingSent: false,
|
|
231
231
|
});
|
|
232
232
|
// The lie: telling the session a human already heard from us when they did not.
|
|
@@ -242,13 +242,13 @@ test("buildPrompt does NOT claim delivery when the acknowledgement failed to sen
|
|
|
242
242
|
assert.match(prompt, /DELIVERY FAILED/);
|
|
243
243
|
assert.match(prompt, /received NOTHING/);
|
|
244
244
|
// The composed text is still shown — it is what the sender would have seen.
|
|
245
|
-
assert.ok(prompt.includes("Understood —
|
|
245
|
+
assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
|
|
246
246
|
});
|
|
247
247
|
|
|
248
248
|
test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
|
|
249
249
|
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
250
250
|
type: "inbox",
|
|
251
|
-
holdingMessage: "Understood —
|
|
251
|
+
holdingMessage: "Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way.",
|
|
252
252
|
});
|
|
253
253
|
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
254
254
|
});
|
|
@@ -9,8 +9,8 @@ Generates dynamic disclosure boundary guidance for a given recipient by:
|
|
|
9
9
|
recent sensitive context, and excluded domains active
|
|
10
10
|
|
|
11
11
|
Usage (CLI):
|
|
12
|
-
python3 scripts/disclosure_boundaries.py --recipient
|
|
13
|
-
python3 scripts/disclosure_boundaries.py --recipient
|
|
12
|
+
python3 scripts/disclosure_boundaries.py --recipient <profile-slug>
|
|
13
|
+
python3 scripts/disclosure_boundaries.py --recipient <profile-slug> --hours 24
|
|
14
14
|
|
|
15
15
|
Exit codes:
|
|
16
16
|
0 = always (profile not found prints to stderr and exits 0)
|
|
@@ -35,6 +35,57 @@ USER_PROFILES_DIR = REPO_ROOT / "memory" / "profiles" / "users"
|
|
|
35
35
|
PROVENANCE_INDEX = REPO_ROOT / "memory" / "provenance" / "index.jsonl"
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
# ─── Employer identity (config/company.json — the SOT) ───────────────────────
|
|
39
|
+
# The clearance summary states a recipient's relationship to the agent's
|
|
40
|
+
# EMPLOYER. That employer used to be a hardcoded name in the sentence, so every
|
|
41
|
+
# deployment's clearance summaries named a company that does not exist. It now
|
|
42
|
+
# comes from config/company.json, and when nothing is configured the sentence
|
|
43
|
+
# says "the company" rather than inventing one.
|
|
44
|
+
def _load_company() -> dict:
|
|
45
|
+
"""Read config/company.json. Never raises."""
|
|
46
|
+
try:
|
|
47
|
+
with open(REPO_ROOT / "config" / "company.json") as f:
|
|
48
|
+
data = json.load(f)
|
|
49
|
+
return data if isinstance(data, dict) else {}
|
|
50
|
+
except Exception:
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _configured(value) -> str:
|
|
55
|
+
"""Trimmed config string, or '' when absent/UNCONFIGURED (both mean unset)."""
|
|
56
|
+
if not isinstance(value, str):
|
|
57
|
+
return ''
|
|
58
|
+
v = value.strip()
|
|
59
|
+
if not v or v.upper() == 'UNCONFIGURED':
|
|
60
|
+
return ''
|
|
61
|
+
return v
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
COMPANY_NAME = _configured(_load_company().get('name'))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def legacy_relationship_value(profile: dict) -> str:
|
|
68
|
+
"""Read a profile's flat "relationship to the employer" field.
|
|
69
|
+
|
|
70
|
+
Canonical key: `relationship_to_company`. Profiles written before this was
|
|
71
|
+
de-hardcoded used `relationship_to_<employer-name>`, so ANY
|
|
72
|
+
`relationship_to_*` key is accepted rather than pinning one company name
|
|
73
|
+
into the reader.
|
|
74
|
+
"""
|
|
75
|
+
if not isinstance(profile, dict):
|
|
76
|
+
return ''
|
|
77
|
+
primary = profile.get('relationship_to_company')
|
|
78
|
+
if isinstance(primary, str) and primary.strip():
|
|
79
|
+
return primary
|
|
80
|
+
for key in sorted(profile.keys()):
|
|
81
|
+
if not isinstance(key, str) or not key.startswith('relationship_to_'):
|
|
82
|
+
continue
|
|
83
|
+
value = profile.get(key)
|
|
84
|
+
if isinstance(value, str) and value.strip():
|
|
85
|
+
return value
|
|
86
|
+
return ''
|
|
87
|
+
|
|
88
|
+
|
|
38
89
|
# ─── YAML Parser ─────────────────────────────────────────────────────────────
|
|
39
90
|
# Custom minimal YAML parser — no PyYAML dependency (consistent with repo pattern).
|
|
40
91
|
# Handles: top-level scalars, nested mappings, inline lists [a, b, c],
|
|
@@ -427,7 +478,7 @@ def _build_clearance_summary(profile: dict, boundaries: dict) -> str:
|
|
|
427
478
|
name = profile.get('name') or profile.get('preferred_name') or 'Unknown'
|
|
428
479
|
role = profile.get('role') or ''
|
|
429
480
|
org = profile.get('organisation') or ''
|
|
430
|
-
relationship = profile
|
|
481
|
+
relationship = legacy_relationship_value(profile)
|
|
431
482
|
domains_of_interest = boundaries.get('domains_of_interest') or []
|
|
432
483
|
excluded_domains = boundaries.get('excluded_domains') or []
|
|
433
484
|
conflicts = boundaries.get('conflicts') or []
|
|
@@ -446,7 +497,7 @@ def _build_clearance_summary(profile: dict, boundaries: dict) -> str:
|
|
|
446
497
|
if org:
|
|
447
498
|
parts.append(f"Organisation: {org}.")
|
|
448
499
|
if relationship:
|
|
449
|
-
parts.append(f"Relationship to
|
|
500
|
+
parts.append(f"Relationship to {COMPANY_NAME or 'the company'}: {relationship}.")
|
|
450
501
|
|
|
451
502
|
if domains_of_interest:
|
|
452
503
|
parts.append(f"Permitted domains: {', '.join(str(d) for d in domains_of_interest)}.")
|
|
@@ -536,7 +587,7 @@ def main():
|
|
|
536
587
|
parser.add_argument(
|
|
537
588
|
'--recipient',
|
|
538
589
|
required=True,
|
|
539
|
-
help="Recipient slug (
|
|
590
|
+
help="Recipient profile slug (memory/profiles/users/<slug>.yaml)",
|
|
540
591
|
)
|
|
541
592
|
parser.add_argument(
|
|
542
593
|
'--hours',
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* huddle-prompt.test.mjs — the huddle system prompt is SPOKEN ALOUD. It must
|
|
3
|
+
* never say anything the config does not know.
|
|
4
|
+
*
|
|
5
|
+
* WHAT BROKE
|
|
6
|
+
* `buildHuddleSystemPrompt()` carried a five-bullet block headed
|
|
7
|
+
* a shout-case `ABOUT <ONE-COMPANY>:` header whose body described a
|
|
8
|
+
* completely different, equally
|
|
9
|
+
* fictional company (a head office, a jurisdiction count, a product, a
|
|
10
|
+
* licensing phase). Two non-current identities in one block is the tell that
|
|
11
|
+
* no code path ever grounded it. The server `autoJoin`s incoming huddle
|
|
12
|
+
* invitations, participants can include people outside the org, and every turn
|
|
13
|
+
* is persisted to logs/huddle/*.jsonl — so this was a fabricated employer said
|
|
14
|
+
* out loud to outsiders and then written to disk. The identity line above it
|
|
15
|
+
* also interpolated `${a.company}` unconditionally, so an unconfigured seat
|
|
16
|
+
* introduced itself as "… of .".
|
|
17
|
+
*
|
|
18
|
+
* WHAT THESE TESTS PIN
|
|
19
|
+
* 1. GROUNDED — every bullet in the company block traces to a field of
|
|
20
|
+
* config/company.json (name, legalName, description, industry, stage,
|
|
21
|
+
* jurisdictions, products, strategicPriorities), one for one.
|
|
22
|
+
* 2. EMPTY — an unpopulated (or scaffold-UNCONFIGURED) config produces NO
|
|
23
|
+
* company block AT ALL and no dangling "at <blank>" clause. Saying nothing
|
|
24
|
+
* about the employer is the correct outcome.
|
|
25
|
+
*
|
|
26
|
+
* HOW IT RUNS
|
|
27
|
+
* huddle-server.mjs imports `dotenv` and `@anthropic-ai/sdk`, neither of which
|
|
28
|
+
* is a dependency of this package (only better-sqlite3 + js-yaml are), so the
|
|
29
|
+
* module cannot be imported in-process here. The renderers are pure and
|
|
30
|
+
* dependency-free, so we lift their REAL source out of the shipped file and
|
|
31
|
+
* evaluate that — the text under test is exactly the text that ships, and
|
|
32
|
+
* deleting/renaming a renderer fails this file rather than silently skipping.
|
|
33
|
+
*
|
|
34
|
+
* Run: node --test scripts/huddle/huddle-prompt.test.mjs
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
"use strict";
|
|
38
|
+
|
|
39
|
+
import { test } from "node:test";
|
|
40
|
+
import assert from "node:assert/strict";
|
|
41
|
+
import { readFileSync } from "node:fs";
|
|
42
|
+
|
|
43
|
+
const SRC = readFileSync(new URL("./huddle-server.mjs", import.meta.url), "utf8");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Slice one top-level `function NAME(...) { ... }` out of a source file by
|
|
47
|
+
* brace matching. Throws when the function is absent — a rename or deletion is
|
|
48
|
+
* a test failure, not a skip.
|
|
49
|
+
*/
|
|
50
|
+
function extractFunction(src, name) {
|
|
51
|
+
const re = new RegExp(`(?:export\\s+)?function\\s+${name}\\s*\\(`);
|
|
52
|
+
const m = re.exec(src);
|
|
53
|
+
assert.ok(m, `huddle-server.mjs no longer defines ${name}() — update this test deliberately`);
|
|
54
|
+
const open = src.indexOf("{", m.index + m[0].length - 1);
|
|
55
|
+
let depth = 0;
|
|
56
|
+
for (let i = open; i < src.length; i++) {
|
|
57
|
+
const c = src[i];
|
|
58
|
+
if (c === "{") depth++;
|
|
59
|
+
else if (c === "}") {
|
|
60
|
+
depth--;
|
|
61
|
+
if (depth === 0) return src.slice(m.index, i + 1).replace(/^export\s+/, "");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unbalanced braces extracting ${name}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const names = ["configuredStr", "configuredList", "renderHuddleCompanyBlock", "renderHuddleIdentityLine"];
|
|
68
|
+
const body = names.map((n) => extractFunction(SRC, n)).join("\n\n");
|
|
69
|
+
// eslint-disable-next-line no-new-func
|
|
70
|
+
const mod = new Function(`${body}\nreturn { ${names.join(", ")} };`)();
|
|
71
|
+
const { renderHuddleCompanyBlock, renderHuddleIdentityLine } = mod;
|
|
72
|
+
|
|
73
|
+
// ── 1. GROUNDED ─────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const REAL_COMPANY = {
|
|
76
|
+
name: "Meridian Freight",
|
|
77
|
+
legalName: "Meridian Freight Holdings Ltd",
|
|
78
|
+
description: "A regional logistics operator moving temperature-controlled freight.",
|
|
79
|
+
industry: "Logistics",
|
|
80
|
+
stage: "Series B",
|
|
81
|
+
jurisdictions: ["Ireland", "United Kingdom"],
|
|
82
|
+
products: ["Meridian Cold Chain", "Meridian Track"],
|
|
83
|
+
strategicPriorities: ["Open the Cork depot", "Cut empty-mile rate below 12%"],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
test("grounded: every bullet traces to a config/company.json field", () => {
|
|
87
|
+
const block = renderHuddleCompanyBlock(REAL_COMPANY);
|
|
88
|
+
assert.match(block, /^ABOUT MERIDIAN FREIGHT:/);
|
|
89
|
+
assert.match(block, /A regional logistics operator moving temperature-controlled freight\./);
|
|
90
|
+
assert.match(block, /Industry: Logistics/);
|
|
91
|
+
assert.match(block, /Legal entity: Meridian Freight Holdings Ltd/);
|
|
92
|
+
assert.match(block, /Jurisdictions: Ireland, United Kingdom/);
|
|
93
|
+
assert.match(block, /Products: Meridian Cold Chain, Meridian Track/);
|
|
94
|
+
assert.match(block, /Stage: Series B/);
|
|
95
|
+
assert.match(block, /Current priorities: Open the Cork depot; Cut empty-mile rate below 12%/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("grounded: a partially-filled config renders only the fields it has", () => {
|
|
99
|
+
const block = renderHuddleCompanyBlock({ name: "Meridian Freight", industry: "Logistics" });
|
|
100
|
+
assert.match(block, /^ABOUT MERIDIAN FREIGHT:/);
|
|
101
|
+
assert.match(block, /Meridian Freight operates in Logistics/);
|
|
102
|
+
assert.doesNotMatch(block, /Jurisdictions:/);
|
|
103
|
+
assert.doesNotMatch(block, /Products:/);
|
|
104
|
+
assert.doesNotMatch(block, /Stage:/);
|
|
105
|
+
assert.doesNotMatch(block, /Current priorities:/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("grounded: the identity line names the real employer and principal", () => {
|
|
109
|
+
const line = renderHuddleIdentityLine(
|
|
110
|
+
{
|
|
111
|
+
fullName: "Robin Okafor",
|
|
112
|
+
title: "VP Operations",
|
|
113
|
+
company: "",
|
|
114
|
+
principal: { fullName: "Dana Lee", title: "CEO" },
|
|
115
|
+
},
|
|
116
|
+
REAL_COMPANY,
|
|
117
|
+
);
|
|
118
|
+
assert.match(line, /^You are Robin Okafor, VP Operations at Meridian Freight\./m);
|
|
119
|
+
assert.match(line, /^You report to Dana Lee, CEO\./m);
|
|
120
|
+
assert.match(line, /Slack huddle/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── 2. EMPTY — no config, no claim ──────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
test("empty: the scaffold's UNCONFIGURED company yields NO company block at all", () => {
|
|
126
|
+
const scaffold = {
|
|
127
|
+
name: "UNCONFIGURED",
|
|
128
|
+
legalName: "",
|
|
129
|
+
description: "",
|
|
130
|
+
industry: "",
|
|
131
|
+
stage: "",
|
|
132
|
+
jurisdictions: [],
|
|
133
|
+
products: [],
|
|
134
|
+
strategicPriorities: [],
|
|
135
|
+
};
|
|
136
|
+
assert.equal(renderHuddleCompanyBlock(scaffold), "", "silence is the correct outcome");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("empty: a missing/garbage config yields NO company block", () => {
|
|
140
|
+
assert.equal(renderHuddleCompanyBlock({}), "");
|
|
141
|
+
assert.equal(renderHuddleCompanyBlock(null), "");
|
|
142
|
+
assert.equal(renderHuddleCompanyBlock(undefined), "");
|
|
143
|
+
assert.equal(renderHuddleCompanyBlock("nonsense"), "");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("empty: no employer ⇒ the identity line drops the 'at <company>' clause", () => {
|
|
147
|
+
const line = renderHuddleIdentityLine(
|
|
148
|
+
{ fullName: "Robin Okafor", title: "VP Operations", company: "", principal: {} },
|
|
149
|
+
{ name: "UNCONFIGURED" },
|
|
150
|
+
);
|
|
151
|
+
assert.match(line, /^You are Robin Okafor, VP Operations\.$/m);
|
|
152
|
+
assert.doesNotMatch(line, / at /);
|
|
153
|
+
assert.doesNotMatch(line, /UNCONFIGURED/);
|
|
154
|
+
assert.doesNotMatch(line, /You report to/, "no principal on record ⇒ no reporting line");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("empty: no identity at all ⇒ no persona is asserted, only the setting", () => {
|
|
158
|
+
const line = renderHuddleIdentityLine({}, {});
|
|
159
|
+
assert.equal(line, "You are participating in a Slack huddle — a real-time voice conversation.");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── 3. The fabricated block is gone from the shipped source ─────────────────
|
|
163
|
+
|
|
164
|
+
test("the shipped prompt carries no invented employer, jurisdiction or product", () => {
|
|
165
|
+
assert.doesNotMatch(SRC, /\bNorthwind\b/i);
|
|
166
|
+
// No shout-case literal company header of ANY name survives — the header is
|
|
167
|
+
// built as `ABOUT ${name.toUpperCase()}:` from config, so a literal one is a
|
|
168
|
+
// regression by construction.
|
|
169
|
+
assert.doesNotMatch(SRC, /ABOUT [A-Z]{4,}:/);
|
|
170
|
+
assert.doesNotMatch(SRC, /\bDIFC\b/);
|
|
171
|
+
assert.doesNotMatch(SRC, /Currently in regulatory licensing phase/i);
|
|
172
|
+
assert.doesNotMatch(SRC, /global AI-native institutional asset management/i);
|
|
173
|
+
// The company block must be a render call, not a literal.
|
|
174
|
+
assert.match(SRC, /renderHuddleCompanyBlock\(co\)/);
|
|
175
|
+
assert.match(SRC, /\$\{companyBlock \? /, "the block is conditional on there being one");
|
|
176
|
+
});
|