@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
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Visual Asset Generator
|
|
4
|
+
* Visual Asset Generator
|
|
5
5
|
*
|
|
6
6
|
* Generates branded illustrations, diagrams, and videos for
|
|
7
7
|
* board materials, investor presentations, and corporate communications.
|
|
8
8
|
*
|
|
9
|
+
* BRAND CONTEXT IS READ, NOT WRITTEN. The brand sentence appended to every
|
|
10
|
+
* image prompt comes from config/brand-assets.yaml (generated per agent by
|
|
11
|
+
* bin/maestro.mjs) and config/company.json (the SOT synced from Cohort). When
|
|
12
|
+
* neither is populated NO brand clause is emitted at all — an unbranded
|
|
13
|
+
* illustration is a correct outcome; one branded for a company that does not
|
|
14
|
+
* exist is not.
|
|
15
|
+
*
|
|
9
16
|
* Ported from ~/company-web/app/scripts/media-generation/generate-visual-assets.mjs
|
|
10
17
|
*
|
|
11
18
|
* Usage:
|
|
@@ -37,6 +44,76 @@ const LOG_FILE = path.join(__dirname, "generation-log.json");
|
|
|
37
44
|
|
|
38
45
|
const RATE_LIMIT_DELAY = 15000; // 15s between requests
|
|
39
46
|
|
|
47
|
+
// ─── Brand context (config/company.json + config/brand-assets.yaml) ───
|
|
48
|
+
|
|
49
|
+
/** Trimmed config string, or "" when absent/UNCONFIGURED (both mean unset). */
|
|
50
|
+
export function configuredStr(v) {
|
|
51
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
52
|
+
if (!s || s.toUpperCase() === "UNCONFIGURED") return "";
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the brand sentence appended to an image prompt.
|
|
58
|
+
*
|
|
59
|
+
* Pure, so the empty case is testable. Every clause is conditional: with an
|
|
60
|
+
* unpopulated config/company.json this returns "" and the caller appends
|
|
61
|
+
* nothing. It previously hardcoded one company's positioning into every
|
|
62
|
+
* generated asset on every deployment.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} company parsed config/company.json
|
|
65
|
+
* @param {object} [brand] parsed config/brand-assets.yaml
|
|
66
|
+
* @returns {string} the sentence, or "" when nothing is configured
|
|
67
|
+
*/
|
|
68
|
+
export function buildBrandClause(company, brand) {
|
|
69
|
+
const co = company && typeof company === "object" ? company : {};
|
|
70
|
+
const br = brand && typeof brand === "object" ? brand : {};
|
|
71
|
+
|
|
72
|
+
const name = configuredStr(co.name);
|
|
73
|
+
const tagline = configuredStr(co.tagline);
|
|
74
|
+
const industry = configuredStr(co.industry);
|
|
75
|
+
const description = configuredStr(co.description);
|
|
76
|
+
|
|
77
|
+
const parts = [];
|
|
78
|
+
if (name) {
|
|
79
|
+
const gloss = tagline || description || industry;
|
|
80
|
+
parts.push(gloss ? `This is for ${name} — ${gloss}.` : `This is for ${name}.`);
|
|
81
|
+
} else if (industry) {
|
|
82
|
+
parts.push(`This is for a company in ${industry}.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// usage_rules from config/brand-assets.yaml are the operator's own stated
|
|
86
|
+
// rules — pass them through verbatim rather than paraphrasing them.
|
|
87
|
+
const rules = br.usage_rules && typeof br.usage_rules === "object" ? br.usage_rules : {};
|
|
88
|
+
const never = configuredStr(rules.never);
|
|
89
|
+
if (never) parts.push(`Never ${never}.`);
|
|
90
|
+
|
|
91
|
+
if (parts.length === 0) return "";
|
|
92
|
+
return parts.join(" ");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read config/company.json. Never throws. */
|
|
96
|
+
async function loadCompanyConfig() {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(await fs.readFile(path.join(ROOT, "config/company.json"), "utf-8"));
|
|
99
|
+
} catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read config/brand-assets.yaml. Never throws; {} when js-yaml or file is absent. */
|
|
105
|
+
async function loadBrandAssets() {
|
|
106
|
+
try {
|
|
107
|
+
const raw = await fs.readFile(path.join(ROOT, "config/brand-assets.yaml"), "utf-8");
|
|
108
|
+
const m = await import("js-yaml");
|
|
109
|
+
const yaml = m && m.default ? m.default : m;
|
|
110
|
+
const doc = yaml.load(raw);
|
|
111
|
+
return doc && typeof doc === "object" ? doc : {};
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
40
117
|
// ─── Helpers ───
|
|
41
118
|
|
|
42
119
|
async function loadPromptSpec(type, slug) {
|
|
@@ -95,7 +172,7 @@ async function generateIllustration(slug) {
|
|
|
95
172
|
|
|
96
173
|
await fs.mkdir(outputDir, { recursive: true });
|
|
97
174
|
|
|
98
|
-
const prompt = spec.prompt || buildDefaultPrompt(spec);
|
|
175
|
+
const prompt = spec.prompt || buildDefaultPrompt(spec, await loadBrandContext());
|
|
99
176
|
const aspectRatio = spec.aspectRatio || "16:9";
|
|
100
177
|
|
|
101
178
|
const result = await generateImageGemini(prompt, { aspectRatio });
|
|
@@ -121,7 +198,21 @@ async function generateIllustration(slug) {
|
|
|
121
198
|
return outputPath;
|
|
122
199
|
}
|
|
123
200
|
|
|
124
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Resolve the brand clause once from the two real sources. Returns "" when
|
|
203
|
+
* neither is populated — the caller then emits no brand clause at all.
|
|
204
|
+
* @returns {Promise<string>}
|
|
205
|
+
*/
|
|
206
|
+
async function loadBrandContext() {
|
|
207
|
+
const [company, brand] = await Promise.all([loadCompanyConfig(), loadBrandAssets()]);
|
|
208
|
+
return buildBrandClause(company, brand);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {object} spec the prompt spec
|
|
213
|
+
* @param {string} [brandClause] rendered brand sentence; "" omits the clause
|
|
214
|
+
*/
|
|
215
|
+
export function buildDefaultPrompt(spec, brandClause = "") {
|
|
125
216
|
const parts = [
|
|
126
217
|
`Create an illustration for: ${spec.title}`,
|
|
127
218
|
`Purpose: ${spec.purpose}`,
|
|
@@ -136,9 +227,13 @@ function buildDefaultPrompt(spec) {
|
|
|
136
227
|
);
|
|
137
228
|
}
|
|
138
229
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
230
|
+
// Craft direction is framework doctrine and names no company, so it is always
|
|
231
|
+
// safe to state. The BRAND clause is only added when the config actually
|
|
232
|
+
// carries one — no config, no brand sentence.
|
|
233
|
+
const craft =
|
|
234
|
+
"IMPORTANT: The illustration must feel premium, restrained, and sophisticated. " +
|
|
235
|
+
"Avoid cartoon, isometric, or generic SaaS illustration styles.";
|
|
236
|
+
parts.push(brandClause ? `IMPORTANT: ${brandClause} ${craft.slice("IMPORTANT: ".length)}` : craft);
|
|
142
237
|
|
|
143
238
|
return parts.join("\n\n");
|
|
144
239
|
}
|
|
@@ -228,7 +323,7 @@ async function main() {
|
|
|
228
323
|
|
|
229
324
|
if (args.length === 0 || args.includes("--help")) {
|
|
230
325
|
console.log(`
|
|
231
|
-
|
|
326
|
+
Visual Asset Generator
|
|
232
327
|
|
|
233
328
|
Usage:
|
|
234
329
|
--illustration <slug> Generate a single illustration
|
|
@@ -367,22 +367,98 @@ def lookup_entity(name_or_email):
|
|
|
367
367
|
# ─────────────────────────────────────────────
|
|
368
368
|
# Relationship classification
|
|
369
369
|
# ─────────────────────────────────────────────
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
370
|
+
# Employer identity — resolved ONCE at load from config/company.json (the SOT
|
|
371
|
+
# lib/setup/enroll-from-cohort.mjs syncs from Cohort).
|
|
372
|
+
#
|
|
373
|
+
# This used to be sixteen hardcoded entity ids for the framework's example
|
|
374
|
+
# employer plus a matching `startswith` prefix test, so on any real deployment
|
|
375
|
+
# NOTHING matched: every colleague classified as external, and the pre-draft
|
|
376
|
+
# brief handed the model a wrong relationship label for its own teammates.
|
|
377
|
+
#
|
|
378
|
+
# FAIL CLOSED: with no employer configured the id set is EMPTY and
|
|
379
|
+
# `is_own_entity()` is always False — people resolve as external/unknown, which
|
|
380
|
+
# is the conservative direction for a pre-send context brief.
|
|
381
|
+
def _load_company() -> dict:
|
|
382
|
+
"""Read config/company.json. Never raises."""
|
|
383
|
+
try:
|
|
384
|
+
with open(REPO_ROOT / "config" / "company.json") as f:
|
|
385
|
+
data = json.load(f)
|
|
386
|
+
return data if isinstance(data, dict) else {}
|
|
387
|
+
except Exception:
|
|
388
|
+
return {}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _configured(value) -> str:
|
|
392
|
+
"""Trimmed config string, or '' when absent/UNCONFIGURED (both mean unset)."""
|
|
393
|
+
if not isinstance(value, str):
|
|
394
|
+
return ''
|
|
395
|
+
v = value.strip()
|
|
396
|
+
if not v or v.upper() == 'UNCONFIGURED':
|
|
397
|
+
return ''
|
|
398
|
+
return v
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
_COMPANY = _load_company()
|
|
402
|
+
COMPANY_NAME = _configured(_COMPANY.get('name'))
|
|
403
|
+
COMPANY_LEGAL_NAME = _configured(_COMPANY.get('legalName'))
|
|
404
|
+
COMPANY_DOMAIN = _configured(_COMPANY.get('domain'))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _slugify(value: str) -> str:
|
|
408
|
+
"""'Acme Holdings, Ltd.' -> 'acme-holdings-ltd'. '' for junk."""
|
|
409
|
+
if not value:
|
|
410
|
+
return ''
|
|
411
|
+
return re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-')
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _resolve_own_entity_ids():
|
|
415
|
+
"""(ids, prefixes) for the agent's own employer. See the note above.
|
|
416
|
+
|
|
417
|
+
An explicit `own_entity_ids` list in config/company.json wins; otherwise ids
|
|
418
|
+
are derived from name / legalName / domain, with prefix reach from the
|
|
419
|
+
shortest stem so a group's subsidiaries resolve without enumeration.
|
|
420
|
+
"""
|
|
421
|
+
explicit = _COMPANY.get('own_entity_ids')
|
|
422
|
+
if isinstance(explicit, list):
|
|
423
|
+
ids = {str(x).strip() for x in explicit if isinstance(x, str) and str(x).strip()}
|
|
424
|
+
if ids:
|
|
425
|
+
return ids, set()
|
|
426
|
+
|
|
427
|
+
stems = set()
|
|
428
|
+
for raw in (COMPANY_NAME, COMPANY_LEGAL_NAME):
|
|
429
|
+
slug = _slugify(raw)
|
|
430
|
+
if slug:
|
|
431
|
+
stems.add(slug)
|
|
432
|
+
if COMPANY_DOMAIN:
|
|
433
|
+
host = COMPANY_DOMAIN.lower().strip().split('/')[0]
|
|
434
|
+
host = host[4:] if host.startswith('www.') else host
|
|
435
|
+
slug = _slugify(host.split('.')[0])
|
|
436
|
+
if slug:
|
|
437
|
+
stems.add(slug)
|
|
438
|
+
|
|
439
|
+
ids = {'company:' + s for s in stems}
|
|
440
|
+
# Every stem is also a prefix so a group's subsidiaries resolve without
|
|
441
|
+
# enumeration; `own_entity_ids` in config/company.json overrides both.
|
|
442
|
+
prefixes = {'company:' + s for s in stems}
|
|
443
|
+
return ids, prefixes
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
OWN_ENTITY_IDS, OWN_ENTITY_PREFIXES = _resolve_own_entity_ids()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def is_own_entity(obj) -> bool:
|
|
450
|
+
"""True when an entity-index object id denotes the agent's own employer."""
|
|
451
|
+
if not obj or not isinstance(obj, str):
|
|
452
|
+
return False
|
|
453
|
+
if obj in OWN_ENTITY_IDS:
|
|
454
|
+
return True
|
|
455
|
+
return any(obj.startswith(p) for p in OWN_ENTITY_PREFIXES)
|
|
380
456
|
|
|
381
457
|
|
|
382
458
|
def classify_relationship(entity_id):
|
|
383
|
-
"""Classify a person or company's relationship to
|
|
459
|
+
"""Classify a person or company's relationship to the agent's employer.
|
|
384
460
|
|
|
385
|
-
Returns dict: relationship_type, is_internal,
|
|
461
|
+
Returns dict: relationship_type, is_internal, relationships_to_company.
|
|
386
462
|
"""
|
|
387
463
|
idx = load_entity_index()
|
|
388
464
|
rels = idx['rels_by_subject'].get(entity_id, [])
|
|
@@ -394,7 +470,7 @@ def classify_relationship(entity_id):
|
|
|
394
470
|
for rel in rels:
|
|
395
471
|
obj = rel.get('object', '')
|
|
396
472
|
pred = rel.get('predicate', '')
|
|
397
|
-
if obj
|
|
473
|
+
if is_own_entity(obj):
|
|
398
474
|
company_rels.append(rel)
|
|
399
475
|
if pred in ('employed_by', 'directs'):
|
|
400
476
|
is_internal = True
|
|
@@ -419,7 +495,7 @@ def classify_relationship(entity_id):
|
|
|
419
495
|
return {
|
|
420
496
|
'relationship_type': rel_type,
|
|
421
497
|
'is_internal': is_internal,
|
|
422
|
-
'
|
|
498
|
+
'relationships_to_company': company_rels,
|
|
423
499
|
}
|
|
424
500
|
|
|
425
501
|
|
|
@@ -813,7 +889,7 @@ def retrieve_context(recipient, topic=None):
|
|
|
813
889
|
'scope': r.get('scope', ''),
|
|
814
890
|
'notes': r.get('notes', ''),
|
|
815
891
|
}
|
|
816
|
-
for r in rel_info['
|
|
892
|
+
for r in rel_info['relationships_to_company']
|
|
817
893
|
],
|
|
818
894
|
},
|
|
819
895
|
'communication': {
|
package/scripts/spawn-session.sh
CHANGED
|
@@ -8,17 +8,31 @@
|
|
|
8
8
|
set -e
|
|
9
9
|
|
|
10
10
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
-
|
|
11
|
+
# The agent repo root. Renamed from the old instance-specific variable name;
|
|
12
|
+
# bin/maestro.mjs already exports AGENT_DIR (and treats the old name as an
|
|
13
|
+
# alias of it), so honouring the environment first is the correct precedence.
|
|
14
|
+
AGENT_DIR="${AGENT_DIR:-$(dirname "$SCRIPT_DIR")}"
|
|
15
|
+
|
|
16
|
+
# Real identity for the sub-session prompt. config/agent.env is generated from
|
|
17
|
+
# config/agent.json by scripts/setup/generate-agent-env.mjs on every
|
|
18
|
+
# `maestro upgrade` — it exists precisely so shell scripts can read the SOT.
|
|
19
|
+
# Absent or unset fields stay EMPTY and their clause is dropped below; the
|
|
20
|
+
# prompt never names an employer the config does not have.
|
|
21
|
+
if [ -f "$AGENT_DIR/config/agent.env" ]; then
|
|
22
|
+
# shellcheck disable=SC1091
|
|
23
|
+
. "$AGENT_DIR/config/agent.env"
|
|
24
|
+
fi
|
|
25
|
+
|
|
12
26
|
SESSION_ID="${1:?Session ID required}"
|
|
13
27
|
TASK_FILE="${2:?Task file path required}"
|
|
14
28
|
TIME_BUDGET="${3:-600}" # Default 10 minutes
|
|
15
29
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
16
30
|
|
|
17
|
-
SESSION_DIR="$
|
|
18
|
-
LOG_FILE="$
|
|
31
|
+
SESSION_DIR="$AGENT_DIR/outputs/sessions/$SESSION_ID"
|
|
32
|
+
LOG_FILE="$AGENT_DIR/logs/sessions/$SESSION_ID.log"
|
|
19
33
|
|
|
20
34
|
# Validate
|
|
21
|
-
if [ -f "$
|
|
35
|
+
if [ -f "$AGENT_DIR/.emergency-stop" ]; then
|
|
22
36
|
echo "[$TIMESTAMP] BLOCKED: Emergency stop is active" >&2
|
|
23
37
|
exit 1
|
|
24
38
|
fi
|
|
@@ -72,12 +86,28 @@ echo "[$TIMESTAMP] Spawning session $SESSION_ID (budget: ${TIME_BUDGET}s)" | tee
|
|
|
72
86
|
# MAESTRO_SESSION_BACKEND=claudraband — persistent sessions via claudraband daemon
|
|
73
87
|
SESSION_BACKEND="${MAESTRO_SESSION_BACKEND:-claude}"
|
|
74
88
|
|
|
89
|
+
# Identity clause — assembled from config/agent.env, omitting whatever is unset.
|
|
90
|
+
# "UNCONFIGURED" is what the scaffold ships, so it counts as unset too.
|
|
91
|
+
_agent_name="${AGENT_FULL_NAME:-}"
|
|
92
|
+
_company="${COMPANY_NAME:-}"
|
|
93
|
+
# `set -e` is on: use `if`, not `[ … ] && …` (a false test would exit the script).
|
|
94
|
+
if [ "$_agent_name" = "UNCONFIGURED" ]; then _agent_name=""; fi
|
|
95
|
+
if [ "$_company" = "UNCONFIGURED" ]; then _company=""; fi
|
|
96
|
+
|
|
97
|
+
if [ -n "$_agent_name" ] && [ -n "$_company" ]; then
|
|
98
|
+
SESSION_IDENTITY="IMPORTANT: You are a sub-session spawned for $_agent_name at $_company."
|
|
99
|
+
elif [ -n "$_agent_name" ]; then
|
|
100
|
+
SESSION_IDENTITY="IMPORTANT: You are a sub-session spawned for $_agent_name."
|
|
101
|
+
else
|
|
102
|
+
SESSION_IDENTITY="IMPORTANT: You are a sub-session spawned by an agent."
|
|
103
|
+
fi
|
|
104
|
+
|
|
75
105
|
SESSION_PROMPT="$TASK_CONTENT
|
|
76
106
|
|
|
77
|
-
|
|
107
|
+
$SESSION_IDENTITY
|
|
78
108
|
Write your output to: $SESSION_DIR/output.md
|
|
79
109
|
When complete, write 'DONE' to: $SESSION_DIR/complete
|
|
80
|
-
Working directory: $
|
|
110
|
+
Working directory: $AGENT_DIR
|
|
81
111
|
Do NOT send any communications. Only produce the requested output."
|
|
82
112
|
|
|
83
113
|
(
|