@cohortapp/agent-sdk 2.9.1 → 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/channels/inbox-item.mjs +4 -0
- package/lib/comms/send-gate.mjs +23 -1
- package/lib/comms/send-gate.test.mjs +24 -0
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/model-router/economics.mjs +53 -1
- package/lib/model-router/economics.test.mjs +76 -0
- package/lib/model-router/resolve.mjs +57 -4
- package/lib/model-router.mjs +95 -8
- package/lib/model-router.test.mjs +305 -5
- package/lib/org/client.mjs +58 -1
- package/lib/org/inbound/project.mjs +9 -5
- package/lib/org/messaging.mjs +6 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +176 -3
- package/lib/org/protocol.test.mjs +31 -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 +50 -16
- 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/deliver.mjs +457 -33
- package/scripts/daemon/deliver.test.mjs +564 -0
- 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-history.test.mjs +18 -2
- package/scripts/daemon/responder.mjs +7 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -1139,11 +1139,126 @@ class HuddleServer extends EventEmitter {
|
|
|
1139
1139
|
// Huddle-specific system prompt
|
|
1140
1140
|
// ---------------------------------------------------------------------------
|
|
1141
1141
|
|
|
1142
|
-
|
|
1142
|
+
/**
|
|
1143
|
+
* A config value the operator has not filled in yet.
|
|
1144
|
+
*
|
|
1145
|
+
* The scaffold ships `"name": "UNCONFIGURED"` in config/company.json and empty
|
|
1146
|
+
* strings/arrays everywhere else, so BOTH shapes mean "absent". Treating only
|
|
1147
|
+
* `""` as absent is what let "UNCONFIGURED" reach a prompt.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {*} v
|
|
1150
|
+
* @returns {string} trimmed value, or "" when absent/unconfigured
|
|
1151
|
+
*/
|
|
1152
|
+
export function configuredStr(v) {
|
|
1153
|
+
const s = typeof v === "string" ? v.trim() : (typeof v === "number" ? String(v) : "");
|
|
1154
|
+
if (!s) return "";
|
|
1155
|
+
if (s.toUpperCase() === "UNCONFIGURED") return "";
|
|
1156
|
+
return s;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Normalise a config array to clean strings; drops blanks and UNCONFIGURED. */
|
|
1160
|
+
function configuredList(v) {
|
|
1161
|
+
if (!Array.isArray(v)) return [];
|
|
1162
|
+
return v
|
|
1163
|
+
.map((x) => (x && typeof x === "object" ? configuredStr(x.name) || configuredStr(x.title) : configuredStr(x)))
|
|
1164
|
+
.filter(Boolean);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Render the "about the company" section of the huddle prompt from
|
|
1169
|
+
* config/company.json — the SoT that lib/setup/enroll-from-cohort.mjs syncs
|
|
1170
|
+
* from Cohort.
|
|
1171
|
+
*
|
|
1172
|
+
* WHY THIS IS A FUNCTION AND NOT A STRING LITERAL
|
|
1173
|
+
* This block used to be five hardcoded bullets under an `ABOUT <X>:`
|
|
1174
|
+
* header naming one fictional company whose body then described a
|
|
1175
|
+
* DIFFERENT, equally fictional one (a head office, a jurisdiction count and
|
|
1176
|
+
* a licensing phase, none of them the header's). Two non-current identities
|
|
1177
|
+
* in one block is the tell that no code path ever grounded it. This text is SPOKEN ALOUD, on a huddle the server auto-joins
|
|
1178
|
+
* (`this.autoJoin = true`), to participants who can include people outside
|
|
1179
|
+
* the org, and the turn is persisted to logs/huddle/*.jsonl. A fabricated
|
|
1180
|
+
* employer said out loud to an outside participant cannot be retracted.
|
|
1181
|
+
*
|
|
1182
|
+
* FAIL CLOSED. Every line is conditional on a real value. When config/company.json
|
|
1183
|
+
* is untouched (the scaffold ships it as UNCONFIGURED + empty) this returns ""
|
|
1184
|
+
* and the prompt carries NO company section at all. Saying nothing about the
|
|
1185
|
+
* employer is correct behaviour; saying something invented is not.
|
|
1186
|
+
*
|
|
1187
|
+
* @param {object} co parsed config/company.json
|
|
1188
|
+
* @returns {string} the section (no trailing newline), or "" when nothing is known
|
|
1189
|
+
*/
|
|
1190
|
+
export function renderHuddleCompanyBlock(co) {
|
|
1191
|
+
const c = co && typeof co === "object" ? co : {};
|
|
1192
|
+
const name = configuredStr(c.name);
|
|
1193
|
+
const legalName = configuredStr(c.legalName);
|
|
1194
|
+
const description = configuredStr(c.description);
|
|
1195
|
+
const industry = configuredStr(c.industry);
|
|
1196
|
+
const stage = configuredStr(c.stage);
|
|
1197
|
+
const jurisdictions = configuredList(c.jurisdictions);
|
|
1198
|
+
const products = configuredList(c.products);
|
|
1199
|
+
const priorities = configuredList(c.strategicPriorities);
|
|
1200
|
+
|
|
1201
|
+
const bullets = [];
|
|
1202
|
+
if (description) bullets.push(`- ${description}`);
|
|
1203
|
+
else if (industry) bullets.push(`- ${name || "The company"} operates in ${industry}`);
|
|
1204
|
+
if (industry && description) bullets.push(`- Industry: ${industry}`);
|
|
1205
|
+
if (legalName && legalName !== name) bullets.push(`- Legal entity: ${legalName}`);
|
|
1206
|
+
if (jurisdictions.length) bullets.push(`- Jurisdictions: ${jurisdictions.join(", ")}`);
|
|
1207
|
+
if (products.length) bullets.push(`- Products: ${products.join(", ")}`);
|
|
1208
|
+
if (stage) bullets.push(`- Stage: ${stage}`);
|
|
1209
|
+
if (priorities.length) bullets.push(`- Current priorities: ${priorities.join("; ")}`);
|
|
1210
|
+
|
|
1211
|
+
if (bullets.length === 0) return "";
|
|
1212
|
+
const header = name ? `ABOUT ${name.toUpperCase()}:` : "ABOUT THE COMPANY:";
|
|
1213
|
+
return [header, ...bullets].join("\n");
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* The identity line. Same omission rule as lib/identity/persona.mjs#renderPersona:
|
|
1218
|
+
* an unset field drops its clause rather than taking a plausible default. The
|
|
1219
|
+
* previous line interpolated `${a.company}` unconditionally, so an unconfigured
|
|
1220
|
+
* seat introduced itself as "… of ." on a live call.
|
|
1221
|
+
*
|
|
1222
|
+
* @param {object} a config/agent.json
|
|
1223
|
+
* @param {object} co config/company.json
|
|
1224
|
+
* @returns {string}
|
|
1225
|
+
*/
|
|
1226
|
+
export function renderHuddleIdentityLine(a, co) {
|
|
1227
|
+
const name = configuredStr(a.fullName) ||
|
|
1228
|
+
[configuredStr(a.firstName), configuredStr(a.lastName)].filter(Boolean).join(" ").trim();
|
|
1229
|
+
const title = configuredStr(a.title);
|
|
1230
|
+
const company = configuredStr(a.company) || configuredStr(co && co.name);
|
|
1231
|
+
const principal = (a.principal && typeof a.principal === "object") ? a.principal : {};
|
|
1232
|
+
const pName = configuredStr(principal.fullName) || configuredStr(principal.firstName);
|
|
1233
|
+
const pTitle = configuredStr(principal.title);
|
|
1234
|
+
|
|
1235
|
+
const who = [name, title].filter(Boolean).join(", ");
|
|
1236
|
+
if (!who) {
|
|
1237
|
+
// Nothing on record. Do not invent a persona — state the role of the seat
|
|
1238
|
+
// in the conversation and nothing more.
|
|
1239
|
+
return "You are participating in a Slack huddle — a real-time voice conversation.";
|
|
1240
|
+
}
|
|
1241
|
+
const at = company ? ` at ${company}` : "";
|
|
1242
|
+
const lines = [`You are ${who}${at}.`];
|
|
1243
|
+
if (pName) lines.push(pTitle ? `You report to ${pName}, ${pTitle}.` : `You report to ${pName}.`);
|
|
1244
|
+
lines.push("You are participating in a Slack huddle — a real-time voice conversation.");
|
|
1245
|
+
return lines.join("\n");
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** Read config/company.json from the agent repo. Never throws. */
|
|
1249
|
+
function loadCompany() {
|
|
1250
|
+
try {
|
|
1251
|
+
return JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/company.json"), "utf-8"));
|
|
1252
|
+
} catch {
|
|
1253
|
+
return {};
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
export function buildHuddleSystemPrompt() {
|
|
1143
1258
|
const a = loadAgent();
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1146
|
-
|
|
1259
|
+
const co = loadCompany();
|
|
1260
|
+
const companyBlock = renderHuddleCompanyBlock(co);
|
|
1261
|
+
return `${renderHuddleIdentityLine(a, co)}
|
|
1147
1262
|
|
|
1148
1263
|
PERSONALITY:
|
|
1149
1264
|
- Sound like a thoughtful colleague, not an assistant. Full guide: policies/communication-style.md
|
|
@@ -1159,15 +1274,8 @@ BANNED OPENERS AND PHRASES (do not use):
|
|
|
1159
1274
|
- "As an AI" / "As a language model" / "I should clarify that…"
|
|
1160
1275
|
- "I hope this helps" / "Feel free to ask if you have any questions"
|
|
1161
1276
|
- "Here's a comprehensive overview of…"
|
|
1162
|
-
- Don't re-introduce yourself by listing your role at length. "Hi, this is ${a.firstName || "the agent"}" is enough.
|
|
1163
|
-
|
|
1164
|
-
ABOUT ADAPTIC:
|
|
1165
|
-
- Northwind is a global AI-native institutional asset management group
|
|
1166
|
-
- Headquartered in DIFC, Dubai with entities across seven plus jurisdictions
|
|
1167
|
-
- CEO and founder: the principal (resolved from config/agent.json)
|
|
1168
|
-
- Building Northwind OS, an algorithmic trading platform
|
|
1169
|
-
- Currently in regulatory licensing phase
|
|
1170
|
-
|
|
1277
|
+
- Don't re-introduce yourself by listing your role at length. "Hi, this is ${configuredStr(a.firstName) || "the agent"}" is enough.
|
|
1278
|
+
${companyBlock ? `\n${companyBlock}\n` : ""}
|
|
1171
1279
|
HUDDLE RULES:
|
|
1172
1280
|
- Huddles are informal — keep responses short and natural
|
|
1173
1281
|
- Listen more than you talk — do not dominate the conversation
|
|
@@ -1199,6 +1307,13 @@ const HUDDLE_SYSTEM_PROMPT = buildHuddleSystemPrompt();
|
|
|
1199
1307
|
|
|
1200
1308
|
// ---------------------------------------------------------------------------
|
|
1201
1309
|
// Main
|
|
1310
|
+
//
|
|
1311
|
+
// NOTE: this bootstrap deliberately runs on IMPORT, not behind an
|
|
1312
|
+
// `import.meta.url === process.argv[1]` entrypoint guard. Such a guard compares
|
|
1313
|
+
// a symlink-resolved path against an unresolved argv[1], so a repo reached
|
|
1314
|
+
// through a symlink would start NOTHING, silently, on a voice server that is
|
|
1315
|
+
// supposed to be listening. The prompt renderers are tested without importing
|
|
1316
|
+
// this module (see huddle-prompt.test.mjs) precisely so this stays as it is.
|
|
1202
1317
|
// ---------------------------------------------------------------------------
|
|
1203
1318
|
|
|
1204
1319
|
const server = new HuddleServer();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# autoupdate.sh — keep @cohortapp/agent-sdk at @latest, health-gated + rollback
|
|
4
|
+
# =============================================================================
|
|
5
|
+
# Runs hourly via the ai.maestro.<first>-autoupdate launchd job. Policy is
|
|
6
|
+
# "auto to @latest": whenever npm's latest is a strictly-greater semver than the
|
|
7
|
+
# installed SDK, this installs it, smart-merges the copied framework files
|
|
8
|
+
# (`maestro upgrade`), restarts the daemon, and VERIFIES the daemon comes back
|
|
9
|
+
# healthy — rolling back to the prior version if it does not.
|
|
10
|
+
#
|
|
11
|
+
# Safety rails:
|
|
12
|
+
# - kill-switch: `touch ~/.maestro-no-autoupdate` (or $AGENT_DIR/.no-autoupdate)
|
|
13
|
+
# halts all auto-updates fleet-wide without unloading the job.
|
|
14
|
+
# - jitter 0-600s so 15 machines don't hit npm / restart in lockstep.
|
|
15
|
+
# - re-exec from a /tmp copy so `maestro upgrade` overwriting THIS file
|
|
16
|
+
# mid-run cannot corrupt the running shell.
|
|
17
|
+
# - health-gate + automatic rollback to the previous version on a bad release.
|
|
18
|
+
# =============================================================================
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
|
|
21
|
+
|
|
22
|
+
# Re-exec from a stable copy: `maestro upgrade` may overwrite this very file.
|
|
23
|
+
if [ "${MAESTRO_AUTOUPDATE_REEXEC:-}" != "1" ]; then
|
|
24
|
+
_tmp="$(mktemp "${TMPDIR:-/tmp}/maestro-autoupdate.XXXXXX")" || exit 0
|
|
25
|
+
cp "${BASH_SOURCE[0]}" "$_tmp" 2>/dev/null || exit 0
|
|
26
|
+
MAESTRO_AUTOUPDATE_REEXEC=1 exec /bin/bash "$_tmp" "$@"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
AGENT_DIR="${AGENT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
30
|
+
cd "$AGENT_DIR" 2>/dev/null || exit 0
|
|
31
|
+
PKG="@cohortapp/agent-sdk"
|
|
32
|
+
LOG_DIR="$AGENT_DIR/logs/autoupdate"; mkdir -p "$LOG_DIR" 2>/dev/null
|
|
33
|
+
LOG="$LOG_DIR/$(date +%Y-%m-%d).log"
|
|
34
|
+
log(){ echo "[$(date -u +%FT%TZ)] $*" >> "$LOG" 2>/dev/null; }
|
|
35
|
+
|
|
36
|
+
# ── kill-switch ──────────────────────────────────────────────────────────────
|
|
37
|
+
if [ -e "$HOME/.maestro-no-autoupdate" ] || [ -e "$AGENT_DIR/.no-autoupdate" ]; then
|
|
38
|
+
log "SKIP: kill-switch present"; exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# ── jitter ───────────────────────────────────────────────────────────────────
|
|
42
|
+
J=$(( RANDOM % 601 )); log "wake; jitter ${J}s"; sleep "$J"
|
|
43
|
+
|
|
44
|
+
# ── version check ────────────────────────────────────────────────────────────
|
|
45
|
+
CUR="$(node -p "require('$AGENT_DIR/node_modules/$PKG/package.json').version" 2>/dev/null || echo 0.0.0)"
|
|
46
|
+
LATEST="$(npm view "$PKG" version 2>/dev/null || echo '')"
|
|
47
|
+
[ -z "$LATEST" ] && { log "npm view failed; skip (still $CUR)"; exit 0; }
|
|
48
|
+
if [ "$CUR" = "$LATEST" ] || [ "$(printf '%s\n%s\n' "$CUR" "$LATEST" | sort -V | tail -1)" != "$LATEST" ]; then
|
|
49
|
+
log "up to date ($CUR)"; exit 0
|
|
50
|
+
fi
|
|
51
|
+
log "UPDATE $CUR -> $LATEST"
|
|
52
|
+
|
|
53
|
+
DAEMON_PLIST="$(ls "$HOME"/Library/LaunchAgents/ai.maestro.*-daemon.plist 2>/dev/null | head -1)"
|
|
54
|
+
DAEMON_LABEL="$(basename "$DAEMON_PLIST" .plist 2>/dev/null)"
|
|
55
|
+
UID_="$(id -u)"
|
|
56
|
+
DLOG="$AGENT_DIR/logs/daemon/daemon-$(date +%Y-%m-%d).log"
|
|
57
|
+
|
|
58
|
+
restart_daemon(){ [ -n "$DAEMON_LABEL" ] && launchctl kickstart -k "gui/$UID_/$DAEMON_LABEL" >> "$LOG" 2>&1; }
|
|
59
|
+
apply_version(){ # $1 = version spec
|
|
60
|
+
npm install "$PKG@$1" --save >> "$LOG" 2>&1 || return 1
|
|
61
|
+
node "$AGENT_DIR/node_modules/$PKG/bin/maestro.mjs" upgrade --force-overwrite >> "$LOG" 2>&1 || log "WARN: upgrade nonzero for $1"
|
|
62
|
+
return 0
|
|
63
|
+
}
|
|
64
|
+
is_healthy(){ # daemon process alive AND a fresh boot/connect line
|
|
65
|
+
pgrep -f "$AGENT_DIR/scripts/daemon/maestro-daemon.mjs" >/dev/null 2>&1 || return 1
|
|
66
|
+
tail -80 "$DLOG" 2>/dev/null | grep -qE "org-mesh connected|daemon\] Running" || return 1
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if ! apply_version "$LATEST"; then
|
|
71
|
+
log "npm install FAILED; aborting, staying on $CUR"; exit 1
|
|
72
|
+
fi
|
|
73
|
+
restart_daemon
|
|
74
|
+
sleep 30
|
|
75
|
+
if is_healthy; then
|
|
76
|
+
log "OK: healthy on $LATEST"
|
|
77
|
+
else
|
|
78
|
+
log "UNHEALTHY on $LATEST -> ROLLBACK to $CUR"
|
|
79
|
+
apply_version "$CUR" || log "WARN: rollback install nonzero"
|
|
80
|
+
restart_daemon
|
|
81
|
+
sleep 20
|
|
82
|
+
is_healthy && log "rolled back to $CUR (healthy)" || log "ROLLBACK health still failing on $CUR — needs operator"
|
|
83
|
+
fi
|
|
@@ -293,6 +293,15 @@ generate_plist "ai.maestro.${AGENT_FIRST}-slack-socket" \
|
|
|
293
293
|
"${AGENT_DIR}/scripts/cadence/launchd-socket-mode-wrapper.sh" \
|
|
294
294
|
"" "" "true"
|
|
295
295
|
|
|
296
|
+
# 2c. SDK auto-updater (hourly + in-script jitter). Keeps @cohortapp/agent-sdk at
|
|
297
|
+
# @latest: installs a strictly-newer published version, smart-merges the
|
|
298
|
+
# copied framework files (`maestro upgrade`), restarts the daemon, and rolls
|
|
299
|
+
# back if the daemon fails to come up healthy. Kill-switch:
|
|
300
|
+
# `touch ~/.maestro-no-autoupdate`. See scripts/local-triggers/autoupdate.sh.
|
|
301
|
+
generate_plist "ai.maestro.${AGENT_FIRST}-autoupdate" \
|
|
302
|
+
"${AGENT_DIR}/scripts/daemon/launchd-wrapper-generic.sh|${AGENT_DIR}/scripts/local-triggers/autoupdate.sh" \
|
|
303
|
+
"" "3600" ""
|
|
304
|
+
|
|
296
305
|
# 3. Standard cadence triggers — derived from the canonical STANDARD_CADENCES
|
|
297
306
|
# in lib/cadences.mjs (the single source of truth), NOT a second hardcoded
|
|
298
307
|
# list. Previously these were hand-maintained here and drifted from the SoT
|
|
@@ -107,10 +107,10 @@ function listPlists(agentRoot) {
|
|
|
107
107
|
// Tests
|
|
108
108
|
// ---------------------------------------------------------------------------
|
|
109
109
|
|
|
110
|
-
test("generator emits
|
|
110
|
+
test("generator emits 24 standard plists with the agent's first name", async () => {
|
|
111
111
|
// Standard inventory (cadence-bus v1 + slack-socket-mode v1), with NO
|
|
112
112
|
// config/.cadence-plists.tsv present (no archetype cadences):
|
|
113
|
-
// daemon, poll-relay, slack-socket (
|
|
113
|
+
// daemon, poll-relay, slack-socket, autoupdate (4 infra), PLUS one trigger plist per
|
|
114
114
|
// STANDARD_CADENCES entry in lib/cadences.mjs (the SoT):
|
|
115
115
|
// inbox-processor, backlog-executor, meeting-prep, meeting-action-capture,
|
|
116
116
|
// daily-morning-brief, daily-midday-sweep, daily-evening-wrap,
|
|
@@ -120,7 +120,7 @@ test("generator emits 23 standard plists with the agent's first name", async ()
|
|
|
120
120
|
// messaging-inbound cadence (1), PLUS the 2026-07 Directory & Design
|
|
121
121
|
// stewards directory-hygiene + brand-steward (2), PLUS the D3 self-directed
|
|
122
122
|
// loop goal-steward (1), PLUS the 2026-08 DR cadence nightly-backup (1).
|
|
123
|
-
// =
|
|
123
|
+
// = 4 infra + 20 trigger = 24 total.
|
|
124
124
|
// org-pulse used to be declared standard but was OMITTED by the old hardcoded
|
|
125
125
|
// list (it never got a plist); deriving from the SoT fixes that drift.
|
|
126
126
|
// The former weekly-* cadences are still ARCHETYPE-DRIVEN (function × altitude),
|
|
@@ -130,7 +130,9 @@ test("generator emits 23 standard plists with the agent's first name", async ()
|
|
|
130
130
|
const r = runGenerator(root);
|
|
131
131
|
assert.equal(r.status, 0, r.stderr);
|
|
132
132
|
const plists = listPlists(root);
|
|
133
|
-
assert.equal(plists.length,
|
|
133
|
+
assert.equal(plists.length, 24, `expected 24 standard plists; got ${plists.join(",")}`);
|
|
134
|
+
assert.ok(plists.includes("ai.maestro.alice-autoupdate.plist"),
|
|
135
|
+
`autoupdate plist missing; got ${plists.join(",")}`);
|
|
134
136
|
assert.ok(plists.includes("ai.maestro.alice-messaging-inbound.plist"),
|
|
135
137
|
"messaging-inbound (SP10 standard cadence) must get a SoT-derived plist");
|
|
136
138
|
assert.ok(plists.includes("ai.maestro.alice-nightly-backup.plist"),
|
|
@@ -203,7 +205,7 @@ test("archetype cadences from config/.cadence-plists.tsv emit extra trigger plis
|
|
|
203
205
|
const r = runGenerator(root);
|
|
204
206
|
assert.equal(r.status, 0, r.stderr);
|
|
205
207
|
const plists = listPlists(root);
|
|
206
|
-
assert.equal(plists.length,
|
|
208
|
+
assert.equal(plists.length, 26, `expected 24 standard + 2 archetype; got ${plists.join(",")}`);
|
|
207
209
|
const dir = join(root, "scripts/local-triggers/plists");
|
|
208
210
|
const eng = readFileSync(join(dir, "ai.maestro.erin-engineering-health.plist"), "utf-8");
|
|
209
211
|
assert.match(eng, /<key>Weekday<\/key>\s*<integer>3<\/integer>/); // base64 schedule decoded
|
|
@@ -232,12 +234,12 @@ test("NO trigger plist invokes run-trigger.sh", async () => {
|
|
|
232
234
|
const dir = join(root, "scripts/local-triggers/plists");
|
|
233
235
|
for (const name of listPlists(root)) {
|
|
234
236
|
const body = readFileSync(join(dir, name), "utf-8");
|
|
235
|
-
// Exception: the daemon/poll-relay/slack-socket plists don't run
|
|
236
|
-
// triggers at all (
|
|
237
|
-
// a
|
|
238
|
-
// enqueue-cadence-tick.mjs.
|
|
237
|
+
// Exception: the daemon/poll-relay/slack-socket/autoupdate plists don't run
|
|
238
|
+
// cadence triggers at all (daemon + slack-socket are KeepAlive workers;
|
|
239
|
+
// poll-relay is a 5s poller; autoupdate is an hourly SDK self-updater). The
|
|
240
|
+
// trigger plists must invoke enqueue-cadence-tick.mjs.
|
|
239
241
|
if (name.endsWith("-daemon.plist") || name.endsWith("-poll-relay.plist") ||
|
|
240
|
-
name.endsWith("-slack-socket.plist")) {
|
|
242
|
+
name.endsWith("-slack-socket.plist") || name.endsWith("-autoupdate.plist")) {
|
|
241
243
|
continue;
|
|
242
244
|
}
|
|
243
245
|
assert.ok(!body.includes("run-trigger.sh"),
|