@cohortapp/agent-sdk 2.10.0 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/init-maestro.md +16 -9
- package/docs/guides/mac-mini.md +11 -1
- package/docs/runbooks/cohort-cutover.md +16 -0
- package/lib/channels/inbox-item.mjs +12 -0
- package/lib/channels/inbox-item.test.mjs +33 -0
- package/lib/execution/disposition.mjs +13 -2
- package/lib/execution/disposition.test.mjs +19 -2
- package/lib/execution/pipeline.test.mjs +4 -1
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- package/lib/org/messaging.mjs +5 -0
- package/lib/org/messaging.test.mjs +7 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +98 -0
- package/lib/org/protocol.test.mjs +19 -2
- package/lib/org/resource-tools.mjs +317 -0
- package/lib/org/resource-tools.test.mjs +361 -0
- package/lib/org/tool-access.mjs +176 -0
- package/lib/org/tool-access.test.mjs +144 -0
- package/lib/org/tool-surface.mjs +431 -5
- package/lib/org/tool-surface.test.mjs +385 -8
- package/lib/org/ui-parity.mjs +196 -3
- package/lib/org/ui-parity.test.mjs +126 -7
- package/lib/tool-definitions.js +23 -2
- package/package.json +2 -2
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
- package/policies/information-barriers.yaml +34 -7
- package/scripts/ci/check-no-residual-identity.mjs +281 -9
- package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
- package/scripts/cloud-relay/voice/server.mjs +42 -2
- package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
- package/scripts/cost/track-claude-usage.mjs +113 -4
- package/scripts/daemon/agent-daemon.mjs +212 -5
- package/scripts/daemon/agent-daemon.test.mjs +307 -0
- package/scripts/daemon/assurance.mjs +38 -15
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/cadence-handlers.mjs +48 -5
- package/scripts/daemon/cadence-handlers.test.mjs +57 -2
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- package/scripts/daemon/inbox-deferral.mjs +49 -24
- package/scripts/daemon/inbox-deferral.test.mjs +39 -1
- package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
- package/scripts/daemon/prompt-builder.mjs +264 -41
- package/scripts/daemon/prompt-builder.test.mjs +5 -5
- package/scripts/daemon/responder.mjs +9 -0
- package/scripts/disclosure_boundaries.py +56 -5
- package/scripts/huddle/huddle-prompt.test.mjs +176 -0
- package/scripts/huddle/huddle-server.mjs +128 -13
- package/scripts/local-triggers/autoupdate.sh +83 -0
- package/scripts/local-triggers/generate-plists.sh +9 -0
- package/scripts/local-triggers/generate-plists.test.mjs +12 -10
- package/scripts/media-generation/brand-clause.test.mjs +135 -0
- package/scripts/media-generation/gemini-image-client.mjs +27 -9
- package/scripts/media-generation/generate-assets.mjs +102 -7
- package/scripts/poller/inbox-scan-poller.mjs +7 -0
- package/scripts/poller/utils.mjs +11 -0
- package/scripts/pre-draft-context.py +91 -15
- package/scripts/spawn-session.sh +36 -6
- package/scripts/test-employer-grounding.py +348 -0
- package/scripts/validate_outbound.py +190 -26
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brand-clause.test.mjs — generated imagery must not be branded for a company
|
|
3
|
+
* that does not exist.
|
|
4
|
+
*
|
|
5
|
+
* WHAT BROKE
|
|
6
|
+
* `buildDefaultPrompt()` appended, to EVERY illustration prompt on EVERY
|
|
7
|
+
* deployment: "IMPORTANT: This is for <one fictional company>, an
|
|
8
|
+
* institutional-grade AI asset management platform." — while `spec.style`,
|
|
9
|
+
* `spec.mood` and `spec.colorPalette` three lines above were already
|
|
10
|
+
* parameterised correctly. `gemini-image-client.mjs` then closed every
|
|
11
|
+
* refinement pass with "... and <that company>'s brand aesthetic
|
|
12
|
+
* requirements", re-asserting a brand the module cannot know.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THESE TESTS PIN
|
|
15
|
+
* 1. GROUNDED — the brand sentence is built from config/company.json (name,
|
|
16
|
+
* tagline, description, industry) and config/brand-assets.yaml
|
|
17
|
+
* (usage_rules), which bin/maestro.mjs generates per agent.
|
|
18
|
+
* 2. EMPTY — nothing configured ⇒ NO brand clause is emitted at all. The
|
|
19
|
+
* craft direction (which names no company) survives, because it is true on
|
|
20
|
+
* any deployment. An unbranded illustration is a correct outcome.
|
|
21
|
+
*
|
|
22
|
+
* HOW IT RUNS
|
|
23
|
+
* generate-assets.mjs imports "dotenv/config" and gemini-image-client.mjs
|
|
24
|
+
* imports "@google/genai" — neither is a dependency of this package, so
|
|
25
|
+
* neither module can be imported in-process. The renderers are pure, so we
|
|
26
|
+
* lift their REAL source and evaluate it.
|
|
27
|
+
*
|
|
28
|
+
* Run: node --test scripts/media-generation/brand-clause.test.mjs
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
"use strict";
|
|
32
|
+
|
|
33
|
+
import { test } from "node:test";
|
|
34
|
+
import assert from "node:assert/strict";
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
|
|
37
|
+
const ASSETS_SRC = readFileSync(new URL("./generate-assets.mjs", import.meta.url), "utf8");
|
|
38
|
+
const GEMINI_SRC = readFileSync(new URL("./gemini-image-client.mjs", import.meta.url), "utf8");
|
|
39
|
+
|
|
40
|
+
function extractFunction(src, name, file) {
|
|
41
|
+
const re = new RegExp(`(?:export\\s+)?function\\s+${name}\\s*\\(`);
|
|
42
|
+
const m = re.exec(src);
|
|
43
|
+
assert.ok(m, `${file} no longer defines ${name}() — update this test deliberately`);
|
|
44
|
+
const open = src.indexOf("{", m.index + m[0].length - 1);
|
|
45
|
+
let depth = 0;
|
|
46
|
+
for (let i = open; i < src.length; i++) {
|
|
47
|
+
if (src[i] === "{") depth++;
|
|
48
|
+
else if (src[i] === "}" && --depth === 0) return src.slice(m.index, i + 1).replace(/^export\s+/, "");
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`unbalanced braces extracting ${name}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const assetNames = ["configuredStr", "buildBrandClause", "buildDefaultPrompt"];
|
|
54
|
+
const assets = new Function(
|
|
55
|
+
`${assetNames.map((n) => extractFunction(ASSETS_SRC, n, "generate-assets.mjs")).join("\n\n")}\n` +
|
|
56
|
+
`return { ${assetNames.join(", ")} };`,
|
|
57
|
+
)();
|
|
58
|
+
const gemini = new Function(
|
|
59
|
+
`${extractFunction(GEMINI_SRC, "buildRefinementPrompt", "gemini-image-client.mjs")}\nreturn { buildRefinementPrompt };`,
|
|
60
|
+
)();
|
|
61
|
+
|
|
62
|
+
const { buildBrandClause, buildDefaultPrompt } = assets;
|
|
63
|
+
const { buildRefinementPrompt } = gemini;
|
|
64
|
+
|
|
65
|
+
const SPEC = { title: "Depot network", purpose: "Board deck cover", elements: ["a map"] };
|
|
66
|
+
|
|
67
|
+
// ── GROUNDED ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
test("grounded: the brand sentence is built from config/company.json", () => {
|
|
70
|
+
const clause = buildBrandClause(
|
|
71
|
+
{ name: "Meridian Freight", tagline: "Cold chain, unbroken", industry: "Logistics" },
|
|
72
|
+
{},
|
|
73
|
+
);
|
|
74
|
+
assert.equal(clause, "This is for Meridian Freight — Cold chain, unbroken.");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("grounded: description or industry stands in when there is no tagline", () => {
|
|
78
|
+
assert.match(buildBrandClause({ name: "Meridian Freight", description: "Regional logistics." }, {}), /— Regional logistics\./);
|
|
79
|
+
assert.match(buildBrandClause({ name: "Meridian Freight", industry: "Logistics" }, {}), /— Logistics\./);
|
|
80
|
+
assert.equal(buildBrandClause({ name: "Meridian Freight" }, {}), "This is for Meridian Freight.");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("grounded: config/brand-assets.yaml usage_rules pass through verbatim", () => {
|
|
84
|
+
const clause = buildBrandClause(
|
|
85
|
+
{ name: "Meridian Freight" },
|
|
86
|
+
{ usage_rules: { never: "stretch, rotate, recolour, or modify the assets" } },
|
|
87
|
+
);
|
|
88
|
+
assert.match(clause, /Never stretch, rotate, recolour, or modify the assets\./);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("grounded: the brand clause reaches the prompt", () => {
|
|
92
|
+
const prompt = buildDefaultPrompt(SPEC, "This is for Meridian Freight — Cold chain, unbroken.");
|
|
93
|
+
assert.match(prompt, /IMPORTANT: This is for Meridian Freight — Cold chain, unbroken\./);
|
|
94
|
+
assert.match(prompt, /premium, restrained, and sophisticated/, "craft direction survives");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── EMPTY — no config, no brand claim ───────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
test("empty: the scaffold's UNCONFIGURED company yields NO brand clause", () => {
|
|
100
|
+
assert.equal(buildBrandClause({ name: "UNCONFIGURED", tagline: "", description: "", industry: "" }, {}), "");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("empty: a missing/garbage config yields NO brand clause", () => {
|
|
104
|
+
assert.equal(buildBrandClause({}, {}), "");
|
|
105
|
+
assert.equal(buildBrandClause(null, null), "");
|
|
106
|
+
assert.equal(buildBrandClause(undefined, undefined), "");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("empty: with no brand clause the prompt names NO company at all", () => {
|
|
110
|
+
const prompt = buildDefaultPrompt(SPEC, "");
|
|
111
|
+
assert.doesNotMatch(prompt, /This is for/);
|
|
112
|
+
assert.doesNotMatch(prompt, /UNCONFIGURED/);
|
|
113
|
+
// The company-agnostic craft direction is still stated — it is true anywhere.
|
|
114
|
+
assert.match(prompt, /IMPORTANT: The illustration must feel premium, restrained, and sophisticated\./);
|
|
115
|
+
assert.match(prompt, /Create an illustration for: Depot network/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("empty: buildDefaultPrompt defaults to no brand clause when called with one argument", () => {
|
|
119
|
+
assert.doesNotMatch(buildDefaultPrompt(SPEC), /This is for/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── The refinement pass re-asserts nothing ──────────────────────────────────
|
|
123
|
+
|
|
124
|
+
test("the refinement prompt preserves the brand STATED ABOVE, it does not name one", () => {
|
|
125
|
+
const out = buildRefinementPrompt("ORIGINAL PROMPT BODY", "fix the horizon line");
|
|
126
|
+
assert.match(out, /ORIGINAL PROMPT BODY/);
|
|
127
|
+
assert.match(out, /fix the horizon line/);
|
|
128
|
+
assert.match(out, /every brand and style requirement stated above/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("neither media-generation source carries a hardcoded brand", () => {
|
|
132
|
+
assert.doesNotMatch(ASSETS_SRC, /\bNorthwind\b/i);
|
|
133
|
+
assert.doesNotMatch(GEMINI_SRC, /\bNorthwind\b/i);
|
|
134
|
+
assert.doesNotMatch(ASSETS_SRC, /institutional-grade AI asset management/i);
|
|
135
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Gemini Image Generation Client
|
|
2
|
+
* Gemini Image Generation Client
|
|
3
3
|
*
|
|
4
4
|
* Generates branded illustrations, diagrams, and visual assets for
|
|
5
5
|
* board materials, investor presentations, and corporate communications.
|
|
@@ -140,14 +140,20 @@ async function generateViaImagen(ai, model, prompt, aspectRatio) {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
*
|
|
143
|
+
* Build the refinement prompt. Pure, so the wording is testable.
|
|
144
|
+
*
|
|
145
|
+
* The closing sentence used to name one specific company's brand aesthetic on
|
|
146
|
+
* every refinement, for every deployment. The originalPrompt ALREADY carries whatever brand direction the
|
|
147
|
+
* caller resolved from config/company.json + config/brand-assets.yaml
|
|
148
|
+
* (generate-assets.mjs#buildBrandClause), so the correct instruction here is to
|
|
149
|
+
* preserve THAT — not to re-assert a brand this module cannot know.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} originalPrompt
|
|
152
|
+
* @param {string} refinementInstructions
|
|
153
|
+
* @returns {string}
|
|
144
154
|
*/
|
|
145
|
-
export
|
|
146
|
-
originalPrompt
|
|
147
|
-
refinementInstructions,
|
|
148
|
-
options = {},
|
|
149
|
-
) {
|
|
150
|
-
const enhancedPrompt = `${originalPrompt}
|
|
155
|
+
export function buildRefinementPrompt(originalPrompt, refinementInstructions) {
|
|
156
|
+
return `${originalPrompt}
|
|
151
157
|
|
|
152
158
|
---
|
|
153
159
|
|
|
@@ -158,7 +164,18 @@ ${refinementInstructions}
|
|
|
158
164
|
---
|
|
159
165
|
|
|
160
166
|
Generate a corrected version addressing ALL refinement instructions above while maintaining
|
|
161
|
-
the original composition, concept, and
|
|
167
|
+
the original composition, concept, and every brand and style requirement stated above.`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Generate a refined image by re-generating with enhanced prompt
|
|
172
|
+
*/
|
|
173
|
+
export async function generateImageGeminiWithRefinement(
|
|
174
|
+
originalPrompt,
|
|
175
|
+
refinementInstructions,
|
|
176
|
+
options = {},
|
|
177
|
+
) {
|
|
178
|
+
const enhancedPrompt = buildRefinementPrompt(originalPrompt, refinementInstructions);
|
|
162
179
|
|
|
163
180
|
console.log(
|
|
164
181
|
` [ImageGen] Regenerating with refinement instructions (${refinementInstructions.length} chars)`,
|
|
@@ -170,4 +187,5 @@ the original composition, concept, and Northwind brand aesthetic requirements.`;
|
|
|
170
187
|
export default {
|
|
171
188
|
generateImageGemini,
|
|
172
189
|
generateImageGeminiWithRefinement,
|
|
190
|
+
buildRefinementPrompt,
|
|
173
191
|
};
|
|
@@ -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
|
|
@@ -111,6 +111,13 @@ export function parseInboxItemYaml(body) {
|
|
|
111
111
|
if (channelType) item.channel_type = channelType;
|
|
112
112
|
if (isPrivate !== undefined) item.is_private = isPrivate;
|
|
113
113
|
if (isDm !== undefined) item.is_dm = isDm;
|
|
114
|
+
// THE CHANNEL KIND. Cohort's only reliable DM signal, written by
|
|
115
|
+
// scripts/poller/utils.inboxItemToYaml when present. Attached only when the
|
|
116
|
+
// writer emitted it so a non-Cohort item keeps its old shape; the daemon's
|
|
117
|
+
// enrichItem reads it to tell a 1:1 DM (skips the election) from a GROUP_DM
|
|
118
|
+
// (goes through the election as a multi-participant channel).
|
|
119
|
+
const channelKind = scalar("channel_kind");
|
|
120
|
+
if (channelKind) item.channel_kind = channelKind;
|
|
114
121
|
// The hq chain kind, when the org projection carried one through the writer.
|
|
115
122
|
// Attached only when present so a non-org item keeps exactly its old shape;
|
|
116
123
|
// `lib/execution/intake.fromInboxItem` prefers it over the surface so an
|
package/scripts/poller/utils.mjs
CHANGED
|
@@ -239,6 +239,17 @@ raw_ref: "${item.raw_ref || ""}"
|
|
|
239
239
|
yaml += `channel_id: "${item.channel_id}"\n`;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// THE CHANNEL KIND (DM / GROUP_DM / PUBLIC / …). Cohort's only reliable DM
|
|
243
|
+
// signal (its channel ids are cuids, so the "D…"/"dm/" prefix heuristics never
|
|
244
|
+
// fire). Written ONLY when present so a non-Cohort item is byte-identical to
|
|
245
|
+
// before; the daemon's `enrichItem` reads it to route a 1:1 DM past the group
|
|
246
|
+
// election gate while a GROUP_DM still goes through it. Enumerated writers like
|
|
247
|
+
// this one drop anything not named here, which is exactly how the signal was
|
|
248
|
+
// lost between eventToInboxItem and parseInboxItemYaml.
|
|
249
|
+
if (item.channel_kind) {
|
|
250
|
+
yaml += `channel_kind: "${item.channel_kind}"\n`;
|
|
251
|
+
}
|
|
252
|
+
|
|
242
253
|
// CONFIDENTIALITY SIGNALS. Slack's conversation-ID prefix is not a reliable
|
|
243
254
|
// type indicator (a private channel can carry a `C…` id), so downstream
|
|
244
255
|
// consumers — notably the daemon's fail-closed outcome gate,
|
|
@@ -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
|
(
|