@cohortapp/agent-sdk 2.18.12 → 2.18.13
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/lib/comms/send-gate.mjs +6 -4
- package/lib/identity/claude-md.mjs +107 -0
- package/lib/identity/disclosure-instructions.mjs +148 -0
- package/lib/identity/disclosure-scrub.mjs +207 -0
- package/lib/identity/persona.mjs +141 -6
- package/lib/org/inbound/conversation-frame.mjs +289 -0
- package/lib/org/inbound/directedness.mjs +27 -7
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
- package/scripts/ci/run-tests.mjs +16 -2
- package/scripts/daemon/context-compiler.mjs +9 -1
- package/scripts/daemon/prompt-builder.mjs +219 -137
- package/scripts/daemon/responder.mjs +226 -26
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-frame.mjs — the block that tells a reply prompt it is joining a
|
|
3
|
+
* conversation ALREADY IN PROGRESS, with people who already know who the agent is.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* Seats were posting a self-introduction into the middle of live threads — a
|
|
7
|
+
* rendering of `channels.<c>.identity_line` from policies/ai-disclosure.yaml,
|
|
8
|
+
* the opener naming the agent's own nature and pairing it with its principal
|
|
9
|
+
* — on a release-status post and on a correction to the agent's
|
|
10
|
+
* OWN earlier figures, in the org's own #risk-compliance channel. Removing the
|
|
11
|
+
* send-gate that DEMANDED a disclosure line (2.18.9/2.18.12) stopped the gate
|
|
12
|
+
* training the behaviour, but it did not give the model any reason to stop:
|
|
13
|
+
* the quick-reply prompt never said this was an ongoing conversation.
|
|
14
|
+
*
|
|
15
|
+
* Rendering the real prompt (scripts/daemon/responder.mjs #realGenerateResponse)
|
|
16
|
+
* for a mid-thread channel reply showed why a competent model would introduce
|
|
17
|
+
* itself there:
|
|
18
|
+
* - the item's transcript arrived as a bare "Recent conversation history"
|
|
19
|
+
* dump with no statement that the agent is a PARTICIPANT in it;
|
|
20
|
+
* - the agent's own earlier turns were unmarked, so lines authored by the
|
|
21
|
+
* seat read as a third party's;
|
|
22
|
+
* - nothing named the surface as a room inside the agent's own organisation,
|
|
23
|
+
* and nothing said the other speakers are colleagues;
|
|
24
|
+
* - the standing instruction was "You are generating a direct response to
|
|
25
|
+
* this message" — the frame of a one-shot, not of a turn in a thread.
|
|
26
|
+
* A model handed a transcript it is not told it is part of behaves correctly
|
|
27
|
+
* when it introduces itself. The defect is the frame, not the model.
|
|
28
|
+
*
|
|
29
|
+
* WHAT THIS IS NOT
|
|
30
|
+
* It is NOT a disclosure policy and it must never render one. The truthfulness
|
|
31
|
+
* invariant lives in policies/ai-disclosure.yaml and lib/comms/send-gate.mjs
|
|
32
|
+
* and is untouched: a sincere question about whether someone is an AI is still
|
|
33
|
+
* answered plainly, on any message, internal or external. What this block
|
|
34
|
+
* suppresses is the UNPROMPTED self-introduction, which nobody asked for and
|
|
35
|
+
* which no policy ever required inside the agent's own workspace.
|
|
36
|
+
*
|
|
37
|
+
* PURE. No I/O, no clock, no env — every input is a parameter, so the exact text
|
|
38
|
+
* a given item produces is a unit test (conversation-frame.test.mjs).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A transcript line as both history renderers emit it: `[<iso>] <Name>: <text>`.
|
|
43
|
+
* The timestamp is optional because `thread_context` hydrators and the local
|
|
44
|
+
* interaction mirror both sometimes omit it.
|
|
45
|
+
*/
|
|
46
|
+
const SPEAKER_LINE = /^(\s*(?:\[[^\]]*\]\s*)?)([^:\n]{1,80}?):(\s|$)/;
|
|
47
|
+
|
|
48
|
+
/** Trim to a string, "" for anything nullish/non-scalar. */
|
|
49
|
+
function s(v) {
|
|
50
|
+
return typeof v === "string" ? v.trim() : "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Drop a trailing "(you)" marker from a speaker label. @param {string} n @returns {string} */
|
|
54
|
+
function stripOwnMarker(n) {
|
|
55
|
+
return String(n || "").replace(/\s*\(you\)\s*$/i, "").trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The speaker names in a transcript, in first-appearance order.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately conservative: a line that does not match `[ts] Name:` contributes
|
|
62
|
+
* nothing rather than guessing, so a wrapped continuation line cannot invent a
|
|
63
|
+
* participant. Names are capped at 80 chars by the pattern itself.
|
|
64
|
+
*
|
|
65
|
+
* @param {string|null|undefined} transcript
|
|
66
|
+
* @returns {string[]}
|
|
67
|
+
*/
|
|
68
|
+
export function parseSpeakers(transcript) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
for (const line of s(transcript).split("\n")) {
|
|
72
|
+
const m = SPEAKER_LINE.exec(line);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
const name = m[2].trim();
|
|
75
|
+
if (!name) continue;
|
|
76
|
+
const key = name.toLowerCase();
|
|
77
|
+
if (seen.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
out.push(name);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mark the agent's own turns in a transcript so the model can see that it is
|
|
86
|
+
* already IN this conversation.
|
|
87
|
+
*
|
|
88
|
+
* Only the speaker label is touched — the body is never rewritten, because the
|
|
89
|
+
* body is attacker-influenced inbound text and this function is not a sanitiser.
|
|
90
|
+
* A transcript with no line by `agentName` comes back byte-identical.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|null|undefined} transcript
|
|
93
|
+
* @param {string|null|undefined} agentName
|
|
94
|
+
* @returns {string} "" when the transcript is empty
|
|
95
|
+
*/
|
|
96
|
+
export function markOwnTurns(transcript, agentName) {
|
|
97
|
+
const text = s(transcript);
|
|
98
|
+
const me = s(agentName);
|
|
99
|
+
if (!text || !me) return text;
|
|
100
|
+
const meKey = me.toLowerCase();
|
|
101
|
+
return text
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((line) => {
|
|
104
|
+
const m = SPEAKER_LINE.exec(line);
|
|
105
|
+
if (!m) return line;
|
|
106
|
+
const name = m[2].trim();
|
|
107
|
+
if (name.toLowerCase() !== meKey) return line;
|
|
108
|
+
// Already marked (re-entrant call, or an upstream renderer did it).
|
|
109
|
+
if (/\(you\)\s*$/.test(name)) return line;
|
|
110
|
+
return `${m[1]}${name} (you):${m[3]}${line.slice(m[0].length)}`;
|
|
111
|
+
})
|
|
112
|
+
.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drop from `threadContext` every line the channel history already carries.
|
|
117
|
+
*
|
|
118
|
+
* The quick-reply user content used to render BOTH blocks in full, and for a
|
|
119
|
+
* mid-thread channel reply they overlap almost completely: the same three or
|
|
120
|
+
* four turns arrived twice, worth up to COHORT_HISTORY_LIMIT ×
|
|
121
|
+
* COHORT_HISTORY_CHARS of budget, and read as two different records of the same
|
|
122
|
+
* exchange. Comparison is on the whole trimmed line, so a turn truncated to a
|
|
123
|
+
* different length in the two renderers is kept rather than silently dropped —
|
|
124
|
+
* a duplicate is cheap, a lost turn is not.
|
|
125
|
+
*
|
|
126
|
+
* @param {string|null|undefined} history
|
|
127
|
+
* @param {string|null|undefined} threadContext
|
|
128
|
+
* @returns {string} "" when nothing survives
|
|
129
|
+
*/
|
|
130
|
+
export function dedupeThreadAgainstHistory(history, threadContext) {
|
|
131
|
+
const thread = s(threadContext);
|
|
132
|
+
if (!thread) return "";
|
|
133
|
+
const seen = new Set(
|
|
134
|
+
s(history).split("\n").map((l) => l.trim()).filter(Boolean),
|
|
135
|
+
);
|
|
136
|
+
if (seen.size === 0) return thread;
|
|
137
|
+
const kept = thread.split("\n").filter((l) => {
|
|
138
|
+
const t = l.trim();
|
|
139
|
+
return t ? !seen.has(t) : false;
|
|
140
|
+
});
|
|
141
|
+
return kept.join("\n").trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Services whose INBOUND items are internal by construction.
|
|
146
|
+
*
|
|
147
|
+
* An item on the org plane reached this agent because hq's ACL put it in reach:
|
|
148
|
+
* it is a row inside the workspace the agent is a member of. There is no such
|
|
149
|
+
* thing as an inbound Cohort channel post from outside the org.
|
|
150
|
+
*
|
|
151
|
+
* Both spellings are live in the tree — `lib/org/messaging` says "cohort",
|
|
152
|
+
* `lib/org/tool-surface` says "cohort-org" — so both are listed rather than one
|
|
153
|
+
* of them being quietly wrong. (The same pair is enumerated in
|
|
154
|
+
* lib/comms/send-gate.mjs#classifyRecipient for the same reason.)
|
|
155
|
+
*/
|
|
156
|
+
const ORG_PLANE_SERVICES = new Set(["cohort", "cohort-org"]);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Which branch of the frame this item gets: are the other speakers colleagues
|
|
160
|
+
* inside the agent's own organisation, or correspondents outside it?
|
|
161
|
+
*
|
|
162
|
+
* THIS IS NOT A SECURITY DECISION AND MUST NEVER BECOME ONE. It selects one
|
|
163
|
+
* sentence of prose. The gate that decides whether a message may be SENT, and
|
|
164
|
+
* which information-barrier walls apply, is lib/comms/send-gate.mjs — it runs on
|
|
165
|
+
* the outbound path, on the real recipient, and nothing here can relax it.
|
|
166
|
+
*
|
|
167
|
+
* WHY IT IS NOT SIMPLY `classifyRecipient(service, channel_id || channel)`.
|
|
168
|
+
* That was the first shape, and it is wrong in a way that says nothing when it
|
|
169
|
+
* misses. `classifyRecipient` is default-DENY: its Cohort case returns
|
|
170
|
+
* "internal" only for a cuid/uuid-shaped id, so an item projected WITHOUT a
|
|
171
|
+
* `channel_id` — or one carrying a human channel slug like "#risk-compliance" —
|
|
172
|
+
* fell to "external" and the frame told the agent that colleagues in its own
|
|
173
|
+
* #risk-compliance channel were outside correspondents. Default-deny is exactly
|
|
174
|
+
* right for a send gate and exactly wrong for a description of a room, because
|
|
175
|
+
* the failure is silent: the external branch still renders no disclosure and
|
|
176
|
+
* still says "no self-introduction", so nothing downstream goes red.
|
|
177
|
+
*
|
|
178
|
+
* So origin decides first, shape second. Off the org plane the gate's own
|
|
179
|
+
* predicate is used unchanged, and its default-deny is inherited — an email or
|
|
180
|
+
* an unrecognised handle is external, which is both conservative and true.
|
|
181
|
+
*
|
|
182
|
+
* PURE: the classifier is a PARAMETER, so this module still has no imports with
|
|
183
|
+
* state and the branch a given item takes is a unit test.
|
|
184
|
+
*
|
|
185
|
+
* @param {{service?:string, channel_id?:string, recipient?:string, channel?:string}} item
|
|
186
|
+
* @param {((channel:string, recipient:string)=>string)|null} [classify]
|
|
187
|
+
* lib/comms/send-gate.mjs#classifyRecipient, injected
|
|
188
|
+
* @returns {"internal"|"external"}
|
|
189
|
+
*/
|
|
190
|
+
export function frameRecipientClass(item = {}, classify = null) {
|
|
191
|
+
const service = s(item.service).toLowerCase();
|
|
192
|
+
if (ORG_PLANE_SERVICES.has(service)) return "internal";
|
|
193
|
+
const handle = s(item.channel_id) || s(item.recipient) || s(item.channel);
|
|
194
|
+
if (!handle || typeof classify !== "function") return "external";
|
|
195
|
+
try {
|
|
196
|
+
return classify(service, handle) === "external" ? "external" : "internal";
|
|
197
|
+
} catch {
|
|
198
|
+
return "external";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* How the room should be described to the model.
|
|
204
|
+
* @param {{service?:string, channel?:string, is_dm?:boolean}} item
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
function surfaceLabel(item) {
|
|
208
|
+
const channel = s(item.channel);
|
|
209
|
+
const service = s(item.service) || "this workspace";
|
|
210
|
+
if (item.is_dm) {
|
|
211
|
+
return `a direct message on ${service}`;
|
|
212
|
+
}
|
|
213
|
+
return channel ? `the ${channel} channel on ${service}` : `a channel on ${service}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The frame block.
|
|
218
|
+
*
|
|
219
|
+
* BUDGET. Six lines of standing frame plus at most two derived lines — under
|
|
220
|
+
* ~120 tokens, and it is spent only when there is actually a conversation to
|
|
221
|
+
* frame (`turns > 0`). It is paid for several times over by
|
|
222
|
+
* `dedupeThreadAgainstHistory`, which removes a duplicate transcript worth up to
|
|
223
|
+
* ~1,200 tokens from the same prompt.
|
|
224
|
+
*
|
|
225
|
+
* WHAT IS INCLUDED — the four things the rendered prompt was missing and a model
|
|
226
|
+
* would otherwise have to infer: that the exchange is ongoing, that the agent is
|
|
227
|
+
* already a participant in it, who the other participants are, and whether they
|
|
228
|
+
* are colleagues inside the agent's own organisation.
|
|
229
|
+
*
|
|
230
|
+
* WHAT IS EXCLUDED, deliberately: the room's full member list (unbounded, and
|
|
231
|
+
* the speakers are what matters for a reply), member titles and profiles (the
|
|
232
|
+
* context compiler's job, and only for the sender), the channel topic/purpose
|
|
233
|
+
* (an extra round trip on a 60-second path), and anything about the agent's
|
|
234
|
+
* nature — see the module header.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} o
|
|
237
|
+
* @param {object} o.item the inbox item
|
|
238
|
+
* @param {string} [o.agentName] the seat's own full name
|
|
239
|
+
* @param {"internal"|"external"} [o.recipientClass="internal"]
|
|
240
|
+
* @param {string} [o.history] channel transcript
|
|
241
|
+
* @param {string} [o.threadContext] thread transcript (already deduped)
|
|
242
|
+
* @returns {string} "" when there is no prior conversation to frame
|
|
243
|
+
*/
|
|
244
|
+
export function conversationFrame({
|
|
245
|
+
item = {},
|
|
246
|
+
agentName = "",
|
|
247
|
+
recipientClass = "internal",
|
|
248
|
+
history = "",
|
|
249
|
+
threadContext = "",
|
|
250
|
+
} = {}) {
|
|
251
|
+
const transcript = [s(history), s(threadContext)].filter(Boolean).join("\n");
|
|
252
|
+
if (!transcript) return "";
|
|
253
|
+
|
|
254
|
+
const me = s(agentName);
|
|
255
|
+
const meKey = me.toLowerCase();
|
|
256
|
+
// The transcript handed in has usually already been through markOwnTurns, so
|
|
257
|
+
// the agent's own label reads "<Name> (you)". Compare on the bare name or the
|
|
258
|
+
// seat lands in its own list of "others who have spoken here".
|
|
259
|
+
const all = parseSpeakers(transcript).map((n) => ({ name: n, key: stripOwnMarker(n).toLowerCase() }));
|
|
260
|
+
const speakers = all.filter((x) => x.key !== meKey).map((x) => stripOwnMarker(x.name));
|
|
261
|
+
const iHaveSpoken = meKey !== "" && all.some((x) => x.key === meKey);
|
|
262
|
+
|
|
263
|
+
const internal = recipientClass !== "external";
|
|
264
|
+
const who = internal
|
|
265
|
+
? "The people in it are colleagues in your own organisation. They know who you are"
|
|
266
|
+
: "The people in it are already in correspondence with you";
|
|
267
|
+
|
|
268
|
+
const lines = [];
|
|
269
|
+
lines.push("## This conversation");
|
|
270
|
+
lines.push("");
|
|
271
|
+
lines.push(
|
|
272
|
+
`You are already part of this exchange — it is ${surfaceLabel(item)}, in progress. ${who}; they do not need to be told, and nobody has asked.`,
|
|
273
|
+
);
|
|
274
|
+
if (speakers.length > 0) {
|
|
275
|
+
lines.push(
|
|
276
|
+
`Others who have spoken here: ${speakers.slice(0, 8).join(", ")}${speakers.length > 8 ? ", and others" : ""}.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (iHaveSpoken) {
|
|
280
|
+
lines.push(`Lines marked "(you)" below are your OWN earlier turns — you are continuing them, including when you are correcting one.`);
|
|
281
|
+
}
|
|
282
|
+
lines.push(
|
|
283
|
+
"Write the next turn and nothing else: no greeting, no preamble, no self-introduction, no statement of your role, your reporting line or what you are. Pick up where the thread left off.",
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const _test = { SPEAKER_LINE, surfaceLabel, ORG_PLANE_SERVICES };
|
|
@@ -480,7 +480,11 @@ function yes(surface, reason, extra = {}) {
|
|
|
480
480
|
* @param {import("./facts.mjs").Facts} facts
|
|
481
481
|
* @param {{enabled?: Record<string,boolean>, meAliases?: string[]}} [opts]
|
|
482
482
|
* `meAliases` — additional identity strings for this seat (slug, member id,
|
|
483
|
-
* agent email).
|
|
483
|
+
* agent email). Widens own-echo suppression AND, as of TD-001, is also
|
|
484
|
+
* load-bearing for reach: branch (5) matches a structured mentions[] row
|
|
485
|
+
* against this same alias set, not against bare `me`, so a seat called with
|
|
486
|
+
* its slug still resolves a mentions[] row keyed by CUID. A caller that
|
|
487
|
+
* omits meAliases here loses that match, not just echo suppression.
|
|
484
488
|
* @returns {{directed:boolean, surface:string|null, reason:string, why:string}}
|
|
485
489
|
*/
|
|
486
490
|
export function resolveDirected(cand, me, facts = {}, opts = {}) {
|
|
@@ -535,7 +539,7 @@ export function resolveDirected(cand, me, facts = {}, opts = {}) {
|
|
|
535
539
|
|
|
536
540
|
switch (cand.family) {
|
|
537
541
|
case "messaging":
|
|
538
|
-
return resolveMessaging(cand, meId, facts, on);
|
|
542
|
+
return resolveMessaging(cand, meId, facts, on, meAliases);
|
|
539
543
|
case "calling":
|
|
540
544
|
return resolveCalling(cand, meId, facts, on);
|
|
541
545
|
case "board":
|
|
@@ -603,7 +607,7 @@ function resolveCalendar(cand, me, facts, on) {
|
|
|
603
607
|
* 6. thread root I have spoken in / been named in → `thread_reply`
|
|
604
608
|
* 7. otherwise → ambient chatter, not directed
|
|
605
609
|
*/
|
|
606
|
-
function resolveMessaging(cand, me, facts, on) {
|
|
610
|
+
function resolveMessaging(cand, me, facts, on, meAliases) {
|
|
607
611
|
const channelId = cand.ids.channelId;
|
|
608
612
|
const visible = asSet(facts.visibleChannelIds);
|
|
609
613
|
const members = asSet(facts.memberChannelIds);
|
|
@@ -656,8 +660,20 @@ function resolveMessaging(cand, me, facts, on) {
|
|
|
656
660
|
|
|
657
661
|
// (5) An explicit @mention of my member id. The mention list is NOT in the
|
|
658
662
|
// chain payload; it comes from `messaging.history`, which hq ACLs to rooms
|
|
659
|
-
// I may read.
|
|
660
|
-
|
|
663
|
+
// I may read. mentions[] holds member CUIDs, but the caller cannot always
|
|
664
|
+
// know which namespace it handed us as `me` (slug vs cuid) — so match the
|
|
665
|
+
// row against the SAME alias set the own-echo drop uses, not bare `me`.
|
|
666
|
+
// Otherwise a seat called with its slug never matches a CUID mention row,
|
|
667
|
+
// and the prose fallback below (now gated on an empty mentions[]) is the
|
|
668
|
+
// only remaining catch — which the gate removes. Fall back to bare `me`
|
|
669
|
+
// if no alias set was threaded through.
|
|
670
|
+
const mentionRow = msg && Array.isArray(msg.mentions) ? msg.mentions : null;
|
|
671
|
+
const mentionsMe =
|
|
672
|
+
mentionRow &&
|
|
673
|
+
(meAliases instanceof Set
|
|
674
|
+
? mentionRow.some((m) => meAliases.has(String(m).trim()))
|
|
675
|
+
: me && mentionRow.map(String).includes(me));
|
|
676
|
+
if (mentionsMe) {
|
|
661
677
|
return on("mention") ? yes("mention", "mention") : no("surface_disabled");
|
|
662
678
|
}
|
|
663
679
|
|
|
@@ -670,8 +686,12 @@ function resolveMessaging(cand, me, facts, on) {
|
|
|
670
686
|
|
|
671
687
|
// A mention by DISPLAY NAME, when the org has no structured mention row (an
|
|
672
688
|
// agent addressed as "@Isla" in prose). Only ever consulted for a space I can
|
|
673
|
-
// already read,
|
|
674
|
-
|
|
689
|
+
// already read, only when the caller supplied its own names, and — per
|
|
690
|
+
// matchesMyName's own contract — only as a LAST RESORT: a populated
|
|
691
|
+
// mentions[] row means hq already told us who this was for, and prose
|
|
692
|
+
// naming someone else in the same breath must not override that.
|
|
693
|
+
const hasStructuredMentions = msg && Array.isArray(msg.mentions) && msg.mentions.length > 0;
|
|
694
|
+
if (msg && !hasStructuredMentions && matchesMyName(facts, msg.body ?? msg.text)) {
|
|
675
695
|
return on("mention") ? yes("mention", "named") : no("surface_disabled");
|
|
676
696
|
}
|
|
677
697
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.13",
|
|
4
4
|
"description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,11 +37,33 @@ actor is needed, it is a person-shaped one: team, colleague, analyst.
|
|
|
37
37
|
|
|
38
38
|
## Being straight about what you are
|
|
39
39
|
|
|
40
|
+
**Never open a message by announcing what you are.** No standing identity
|
|
41
|
+
line in any shape: nothing that pairs your name with your own nature, nothing
|
|
42
|
+
that pairs it with the person you work for, no throat-clearing preamble whose
|
|
43
|
+
only job is to flag what you are before you say anything. Not on your first
|
|
44
|
+
post in a channel, not mid-thread, not on a correction to your own figures.
|
|
45
|
+
Cohort marks every AI member on every surface it has: the member row, the
|
|
46
|
+
directory, the mailbox list, the message header. A colleague reading you has
|
|
47
|
+
already seen it. The sentence tells them nothing new and reads as you
|
|
48
|
+
disclaiming your own work before you have said anything.
|
|
49
|
+
|
|
50
|
+
The renderings of that line live in `policies/ai-disclosure.yaml` as
|
|
51
|
+
`channels.<c>.identity_line`, scoped to an external first contact. They are
|
|
52
|
+
deliberately not reproduced here, or anywhere else in this tree: a banned
|
|
53
|
+
string quoted as an example is how the next author reads it in good faith and
|
|
54
|
+
writes it back into the product. This file is loaded into a live session's
|
|
55
|
+
context, so it is one of the places that would happen from.
|
|
56
|
+
|
|
40
57
|
If someone sincerely asks whether they are dealing with an AI, say yes,
|
|
41
58
|
plainly, once, and carry on. That is an honest answer to a direct question,
|
|
42
59
|
not a disclaimer to attach to your work, and it does not license naming the
|
|
43
|
-
machinery.
|
|
44
|
-
|
|
60
|
+
machinery. The two rules are one rule: **honest when asked, silent when not.**
|
|
61
|
+
|
|
62
|
+
Proactive disclosure is owed in exactly one place — an EXTERNAL first contact
|
|
63
|
+
— and it is not yours to decide. `policies/ai-disclosure.yaml` scopes it
|
|
64
|
+
(`posture.internal_recipients_exempt`, `channels.<c>.identity_scope`) and the
|
|
65
|
+
send-gate applies it. Inside your own workspace the duty does not arise, so
|
|
66
|
+
volunteering the line there is not caution; it is noise.
|
|
45
67
|
|
|
46
68
|
## When the send gate blocks you
|
|
47
69
|
|
package/scripts/ci/run-tests.mjs
CHANGED
|
@@ -75,7 +75,17 @@ function main(argv) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
if (argv.includes("--list")) {
|
|
78
|
-
|
|
78
|
+
// ONE write, not one per file. `console.log` to a PIPE is asynchronous, and
|
|
79
|
+
// the caller below used to `process.exit()` the moment main() returned —
|
|
80
|
+
// which discards whatever has not drained yet. It only shows up under load:
|
|
81
|
+
// run-tests.test.mjs#"--list prints the discovered set" spawns this with a
|
|
82
|
+
// pipe from INSIDE the full suite, where a machine running `node --test`
|
|
83
|
+
// across every core does not drain it in time, and the child exited 0
|
|
84
|
+
// having printed a list truncated mid-alphabet. A green run and a silently
|
|
85
|
+
// short inventory is exactly the failure this module exists to prevent —
|
|
86
|
+
// see the header — so it is fixed on both halves: one write here, and
|
|
87
|
+
// `process.exitCode` rather than `process.exit()` at the bottom.
|
|
88
|
+
process.stdout.write(files.map((f) => relative(REPO_ROOT, f)).join("\n") + "\n");
|
|
79
89
|
console.error(`run-tests: ${files.length} test file(s) discovered (nothing was run).`);
|
|
80
90
|
return 0;
|
|
81
91
|
}
|
|
@@ -95,5 +105,9 @@ function main(argv) {
|
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
98
|
-
process.
|
|
108
|
+
// `process.exitCode`, NOT `process.exit()`: the latter tears the process down
|
|
109
|
+
// immediately and drops any stdout still queued on a pipe. Node exits with
|
|
110
|
+
// this code on its own once the event loop drains, which is the only way a
|
|
111
|
+
// piped `--list` is guaranteed to arrive whole.
|
|
112
|
+
process.exitCode = main(process.argv.slice(2));
|
|
99
113
|
}
|
|
@@ -17,6 +17,7 @@ import { join } from "path";
|
|
|
17
17
|
import { isEnabled as orgEnabled, loadOrgConfig } from "../../lib/org/client.mjs";
|
|
18
18
|
import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
|
|
19
19
|
import { isPrivateConversation, historyDirNames } from "../../lib/context/history-scope.mjs";
|
|
20
|
+
import { stripDisclosureInstructionsAndWarn } from "../../lib/identity/disclosure-instructions.mjs";
|
|
20
21
|
import { fitSections } from "../../lib/context/budget.mjs";
|
|
21
22
|
import { currentWorkBlock, audienceForItem } from "../../lib/session/current-work.mjs";
|
|
22
23
|
|
|
@@ -590,9 +591,16 @@ export async function compileContext(item, classResult, options = {}) {
|
|
|
590
591
|
sections.push("--- COMPILED CONTEXT (do not repeat verbatim) ---");
|
|
591
592
|
sections.push("");
|
|
592
593
|
|
|
594
|
+
// The sender profile is SEAT-AUTHORED prose (memory/profiles/users/*.yaml),
|
|
595
|
+
// not inbound data, and it lands in a context block the model reads as
|
|
596
|
+
// guidance. A profile line ordering a self-introduction would defeat the
|
|
597
|
+
// rest of the fix for exactly one correspondent — the hardest version of
|
|
598
|
+
// the bug to reproduce — and the repo cannot see the file. Filtered like
|
|
599
|
+
// the CLAUDE.md scrape; the transcript below is deliberately NOT filtered,
|
|
600
|
+
// because it is a record of what people said, not an instruction.
|
|
593
601
|
if (profileText) {
|
|
594
602
|
sections.push("## Sender Profile");
|
|
595
|
-
sections.push(profileText);
|
|
603
|
+
sections.push(stripDisclosureInstructionsAndWarn(profileText, "memory/profiles/users/*.yaml"));
|
|
596
604
|
sections.push("");
|
|
597
605
|
}
|
|
598
606
|
|