@cohortapp/agent-sdk 2.8.1 → 2.9.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.
@@ -346,6 +346,30 @@ export const PARAM_CONTRACT = {
346
346
  "calling.invite": { alias: { invitees: "memberIds", participantIds: "memberIds" }, required: ["callId", "memberIds"] },
347
347
 
348
348
  // ── board ────────────────────────────────────────────────────────────────
349
+ // `board.create` had NO entry, so `normalizeParams` passed its params through
350
+ // byte-for-byte and the board mirror's `{id, col, obligationKey}` reached a
351
+ // `.strict()` schema that accepts none of the three — every mirrored row was
352
+ // refused before a row was ever written, and the only trace was one fail-open
353
+ // warn line. (That mirror has since been retired in favour of the shared work
354
+ // ledger — `board.create`'s remaining caller is the self-directed goal path,
355
+ // lib/goals/collaborate.mjs. The contract stands either way: it is hq's
356
+ // schema, not one caller's habits.) The allow-list below IS hq's createSchema
357
+ // (src/server/methods/board/create.ts); hq's own board-lifecycle test pins the
358
+ // same six-plus-one names from the other side.
359
+ "board.create": {
360
+ alias: { id: "itemId", col: "status" },
361
+ dropAlias: true,
362
+ strict: ["title", "detail", "status", "priority", "workstreamId", "itemId", "channelId"],
363
+ // `status` is refined against the protocol BOARD_STATUS, which is the same
364
+ // ten words as `boardColEnum` — BOARD_COLS above is that list.
365
+ enums: { status: BOARD_COLS, priority: TASK_PRIORITIES },
366
+ required: ["title"],
367
+ note:
368
+ "createSchema is .strict(): `col`/`obligationKey`/a bare `id` 400 the whole create. " +
369
+ "`priority` is the P-scale (P0..P4) — the classifier's critical|high|normal|ignore is " +
370
+ "NOT accepted and must be mapped before the call (boardPriority, scripts/daemon/board-mirror.mjs). " +
371
+ "There is no `assigneeId`: board.claim is what puts an owner on a created row.",
372
+ },
349
373
  "board.complete": {
350
374
  strict: ["itemId", "proof"],
351
375
  transform: completeProof,
@@ -138,6 +138,7 @@ function clip(s, n) {
138
138
  * (true for the session path — the same signal that fires the holding
139
139
  * message; false for the quick-reply path). The server's gate reads it.
140
140
  * @param {string} [a.title] row title (defaults server-side from the ask)
141
+ * @param {string} [a.priority] P0..P4, applied when the row is created
141
142
  * @param {string} [a.detail] row detail, written once at open
142
143
  * @param {string} [a.note] a progress comment for this step
143
144
  * @param {string[]} [a.notify] extra member ids to @-tag on this step
@@ -184,6 +185,10 @@ export async function recordWorkStep(a = {}) {
184
185
  const requester = requesterMemberId(a.item);
185
186
  if (requester) params.requesterId = requester;
186
187
  if (a.title) params.title = clip(a.title, MAX_TITLE);
188
+ // The classifier's urgency, already mapped onto hq's P-scale by the caller
189
+ // (`board-mirror.mjs#boardPriority` — hq 400s the whole call on anything
190
+ // that is not P0..P4). Only ever read when the row is CREATED.
191
+ if (a.priority) params.priority = String(a.priority);
187
192
  if (a.detail) params.detail = clip(a.detail, MAX_BODY);
188
193
  if (a.note) params.note = clip(a.note, MAX_NOTE);
189
194
  if (Array.isArray(a.notify) && a.notify.length) params.notify = a.notify.slice(0, 8);
@@ -133,6 +133,42 @@ test("an accepted step sends the ask's identity and the deferred fact", async ()
133
133
  assert.equal(res.taskId, "t_1");
134
134
  });
135
135
 
136
+ test("a `working` step is what puts the row where the live-work surface looks", async () => {
137
+ // `accepted` lands the row in `triage`, and NO live-work surface reads
138
+ // `triage` — hq's "On now" banner reads `running` and nothing else. Sending
139
+ // only the first step is why every daemon row existed and was still
140
+ // invisible, and why a second, ungated board writer came to exist beside it.
141
+ const { calls, impl } = capture();
142
+ await recordWorkStep({
143
+ item: inboxItem(),
144
+ stage: "working",
145
+ deferred: true,
146
+ cfg: CFG,
147
+ trackImpl: impl,
148
+ });
149
+ assert.equal(calls[0].params.stage, "working");
150
+ assert.equal(calls[0].opts.idempotencyKey, "board.track:ch_dm_1:msg_abc123:working");
151
+ });
152
+
153
+ test("the classifier's urgency rides along, already on hq's P-scale", async () => {
154
+ // hq validates `priority` against z.enum(["P0".."P4"]) and 400s the whole
155
+ // call on anything else, so the mapping happens before the wire
156
+ // (scripts/daemon/board-mirror.mjs#boardPriority) and this only forwards it.
157
+ const { calls, impl } = capture();
158
+ await recordWorkStep({
159
+ item: inboxItem(),
160
+ stage: "accepted",
161
+ priority: "P0",
162
+ cfg: CFG,
163
+ trackImpl: impl,
164
+ });
165
+ assert.equal(calls[0].params.priority, "P0");
166
+
167
+ const plain = capture();
168
+ await recordWorkStep({ item: inboxItem(), stage: "accepted", cfg: CFG, trackImpl: plain.impl });
169
+ assert.ok(!("priority" in plain.calls[0].params), "no priority ⇒ hq's own default answers");
170
+ });
171
+
136
172
  test("directedness comes from the ingest layer's real verdict, never a guess", async () => {
137
173
  const { calls, impl } = capture();
138
174
  await recordWorkStep({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.8.1",
3
+ "version": "2.9.1",
4
4
  "description": "Cohort Agent SDK \u2014 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": {
@@ -495,6 +495,24 @@ export function buildProbes() {
495
495
  guard: "write",
496
496
  why: "creates a real Task",
497
497
  },
498
+ {
499
+ // NOT `ghost`. A ghost id is only a guard when the handler REQUIRES the
500
+ // entity to exist, and this one is a create — an unknown `itemId` is the
501
+ // CREATE branch, which is a real Task row. The only thing standing between
502
+ // a default read-only run and a junk row in the live org is that
503
+ // `assertChannelVisible` happens to run before `task.create`; reorder those
504
+ // two lines in hq and CI starts writing. Its sibling board.createTask makes
505
+ // the identical row and has always been `write` — so does this.
506
+ method: "board.create",
507
+ params: {
508
+ itemId: `probe-${Date.now()}`,
509
+ title: "[conformance probe] delete me",
510
+ priority: "P4",
511
+ },
512
+ writes: true,
513
+ guard: "write",
514
+ why: "creates a real Task — the same row board.createTask makes",
515
+ },
498
516
  {
499
517
  method: "decision.propose",
500
518
  params: { title: "[conformance probe] delete me", why: { reason: "wire conformance" }, tag: "probe" },
@@ -221,7 +221,15 @@ test("SAFETY: a create-or-upsert method is never guarded `ghost` — an unknown
221
221
  // `writes:false` and a read-only run created a junk fact in the live org.
222
222
  //
223
223
  // These methods are upserts on hq's side and must stay `--write`-gated.
224
- const UPSERTS = ["knowledge.replace", "contacts.upsert", "meetings.record"];
224
+ //
225
+ // `board.create` is here because it shipped as `ghost` and was one line away
226
+ // from the same incident: its `itemId` is a caller-chosen id, so an unknown
227
+ // one is the CREATE branch and makes a real Task. The only thing standing
228
+ // between a default read-only run and a junk row in the live org was that
229
+ // `assertChannelVisible` happened to run before `task.create` in hq — a
230
+ // guarantee no probe declaration should ever rest on. Its sibling
231
+ // `board.createTask` makes the identical row and has always been `write`.
232
+ const UPSERTS = ["knowledge.replace", "contacts.upsert", "meetings.record", "board.create"];
225
233
  const byMethod = new Map(buildProbes().map((p) => [p.method, p]));
226
234
  for (const method of UPSERTS) {
227
235
  const p = byMethod.get(method);
@@ -87,6 +87,7 @@ import { isEnabled as orgEnabled, loadOrgConfig } from "../../lib/org/client.mjs
87
87
  import { remember as orgRemember } from "../../lib/org/knowledge.mjs";
88
88
  import { sweepSessionOutcomes, resultTextFromStdout } from "./session-outcomes.mjs";
89
89
  import { recordWorkStep as orgRecordWorkStep } from "../../lib/org/work-ledger.mjs";
90
+ import { boardPriority } from "./board-mirror.mjs";
90
91
  // Observability spine (WS — diagnostics). mintTraceId + withTrace give each
91
92
  // inbound item ONE trace_id that deep callees inherit via AsyncLocalStorage;
92
93
  // emitEvent appends the canonical interaction hops (item_received → … → sent /
@@ -582,6 +583,11 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
582
583
  // assurance sweep retries it — the old code caught that failure, logged it,
583
584
  // and left the human staring at a typing indicator.
584
585
  let holdingText = null;
586
+ // Whether the acknowledgement actually LANDED, which is a different fact
587
+ // from whether one was composed. openAndAcknowledge returns ackText on both
588
+ // paths; passing only the text to buildPrompt made every failed ack read to
589
+ // the session as a delivered one.
590
+ let holdingDelivered = false;
585
591
  let obligationKeyForItem = null;
586
592
  const ackVerdict = shouldAcknowledge({ willSpawnSession: true, item, source: "inbox" });
587
593
  try {
@@ -603,6 +609,7 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
603
609
  });
604
610
  obligationKeyForItem = opened.key;
605
611
  holdingText = opened.ackText;
612
+ holdingDelivered = Boolean(opened.acked);
606
613
  if (opened.acked) {
607
614
  updateLock(itemId, { holdingSent: true });
608
615
  } else if (ackVerdict.ack) {
@@ -629,18 +636,37 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
629
636
  // `deferred: true` is the whole signal the server's gate needs; everything
630
637
  // else about whether this deserves a row is decided there, by the same code
631
638
  // that decides it for hq's responder.
639
+ //
640
+ // TWO steps, CHAINED. `accepted` is what tags the requester ("picked this up
641
+ // and put it on the board as X") and it lands the row in `triage`; `working`
642
+ // is what moves it to `running`, and `running` is the ONLY column the "On
643
+ // now" banner reads (hq components/dm/now-work.ts). Sending only the first
644
+ // left every daemon row parked where no live-work surface looks — the row
645
+ // existed and was still invisible, which is how a second, ungated writer
646
+ // (the old board mirror) came to exist. Chained rather than fired in
647
+ // parallel because `triage` is not terminal: an `accepted` that commits
648
+ // AFTER a `working` drags the row back out of `running` for good.
632
649
  void trackWorkStep({
633
650
  item,
634
651
  stage: "accepted",
635
652
  deferred: true,
653
+ priority: boardPriority(classResult && classResult.priority),
636
654
  note: classResult && classResult.summary ? `Picked this up. ${classResult.summary}` : "Picked this up — starting work now.",
637
655
  source: { service, trace_id, classified: String(classResult && classResult.action) },
638
- });
656
+ }).then(() =>
657
+ trackWorkStep({
658
+ item,
659
+ stage: "working",
660
+ deferred: true,
661
+ source: { service, trace_id },
662
+ }),
663
+ );
639
664
 
640
665
  // Build prompt with holding message context and dispatch
641
666
  const prompt = await buildPrompt(item, classResult, {
642
667
  type: "inbox",
643
668
  holdingMessage: holdingText,
669
+ holdingSent: holdingDelivered,
644
670
  });
645
671
  // F1/H2: record a DURABLE in-flight admission and DEFER markProcessed() to
646
672
  // the dispatch onClose SUCCESS path. The previous code marked the item
@@ -55,6 +55,35 @@ import { deliver, deliverWithRetry, replyTargetOf, canDeliverTo } from "./delive
55
55
  import { spokeFor } from "../../lib/comms/receipts.mjs";
56
56
  import { resultTextFromStdout } from "./session-outcomes.mjs";
57
57
 
58
+ // ── Board mirror seams ───────────────────────────────────────────────────────
59
+ // Imported lazily so a board module problem can never stop the daemon booting,
60
+ // and so tests can swap the implementation without a live org credential.
61
+ let _boardMirror = null;
62
+ async function boardMirror() {
63
+ if (_boardMirror) return _boardMirror;
64
+ try { _boardMirror = await import("./board-mirror.mjs"); }
65
+ catch (err) {
66
+ console.warn(`[assurance] board mirror unavailable: ${err.message}`);
67
+ _boardMirror = { closeBoardItem: async () => ({ mirrored: false }) };
68
+ }
69
+ return _boardMirror;
70
+ }
71
+
72
+ /** Test seam: replace the board mirror wholesale. */
73
+ export function _setBoardMirror(impl) { _boardMirror = impl; }
74
+
75
+ function mirrorClose(rec, o = {}) {
76
+ const inject = o.deps && o.deps.boardMirror;
77
+ const run = async () => {
78
+ const m = inject || (await boardMirror());
79
+ return m.closeBoardItem({ rec, outcome: rec.state, agentRoot: AGENT_REPO_DIR, deps: o.deps });
80
+ };
81
+ return run().catch((err) => {
82
+ console.warn(`[assurance] board mirror close failed: ${err.message}`);
83
+ });
84
+ }
85
+
86
+
58
87
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
59
88
 
60
89
  // ---------------------------------------------------------------------------
@@ -493,6 +522,14 @@ export async function openAndAcknowledge(a = {}) {
493
522
  if (rec.deliverable === undefined) rec.deliverable = canDeliverTo(item);
494
523
  writeRecord(rec);
495
524
 
525
+ // NOTE: opening the board row is NOT done here. It belongs to the caller
526
+ // (agent-daemon.mjs, `accepted` → `working` through the shared work ledger),
527
+ // which holds the FULL inbox item — its directedness verdict, its author kind
528
+ // — rather than the thin snapshot above. This file used to open a second row
529
+ // of its own through `board.create`, which produced two cards per ask on the
530
+ // same board and gave every non-Cohort obligation an org-wide row named after
531
+ // a private message.
532
+
496
533
  if (!wantAck || !rec.deliverable) {
497
534
  // The debt is on the books and the sweep will not try to speak into a
498
535
  // channel that does not exist (see sweepObligations branch (b)). What it
@@ -601,7 +638,13 @@ export function closeObligation(key, o = {}) {
601
638
  rec.state = o.outcome || "answered";
602
639
  rec.closedAt = Number.isFinite(o.now) ? o.now : Date.now();
603
640
  if (o.note) rec.closeNote = o.note;
604
- return writeRecord(rec);
641
+ const wrote = writeRecord(rec);
642
+ // Every close path in this module funnels through here — answered, silent
643
+ // success, failed, undeliverable, stale — so this is the one place the
644
+ // board row can be retired without the seven call sites drifting apart.
645
+ // Fire-and-forget for the same reason as the open hook.
646
+ mirrorClose(rec, o);
647
+ return wrote;
605
648
  }
606
649
 
607
650
  /**
@@ -209,6 +209,46 @@ describe("openAndAcknowledge", () => {
209
209
  await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
210
210
  assert.equal(t.sent.length, 1, "do not spam");
211
211
  });
212
+
213
+ test("opens NO board row of its own — there is exactly one writer per ask", async () => {
214
+ // It used to mirror the obligation onto the board here, through
215
+ // `board.create`, while agent-daemon tracked the SAME item forty lines
216
+ // later through the shared ledger. One DM, two cards on one board, and the
217
+ // one the "On now" banner rendered was the copy with no requester, no
218
+ // comments and no provenance. The open belongs to the caller, which holds
219
+ // the full inbox item; this function only owes the acknowledgement.
220
+ const mirror = { calls: [], closeBoardItem: async (a) => { mirror.calls.push(a); return { mirrored: true }; } };
221
+ assurance._setBoardMirror(mirror);
222
+ try {
223
+ const t = fakeTransport();
224
+ await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
225
+ await new Promise((r) => setTimeout(r, 5));
226
+ assert.equal(mirror.calls.length, 0);
227
+ assert.equal(typeof mirror.openBoardItem, "undefined", "there is no open hook to call");
228
+ } finally {
229
+ assurance._setBoardMirror(null);
230
+ }
231
+ });
232
+
233
+ test("closing the debt retires the board row, carrying the obligation's own verdict", async () => {
234
+ // `closeObligation` is the funnel EVERY terminal path goes through, and only
235
+ // two of them reach the daemon's onClose. A row nothing retires sits in
236
+ // `running`, which is the one column the "On now" banner reads.
237
+ const mirror = { calls: [], closeBoardItem: async (a) => { mirror.calls.push(a); return { mirrored: true }; } };
238
+ assurance._setBoardMirror(mirror);
239
+ try {
240
+ const t = fakeTransport();
241
+ const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
242
+ assurance.closeObligation(r.key, { outcome: "failed", note: "stale-no-outcome" });
243
+ await new Promise((res) => setTimeout(res, 5));
244
+ assert.equal(mirror.calls.length, 1);
245
+ assert.equal(mirror.calls[0].outcome, "failed");
246
+ assert.equal(mirror.calls[0].rec.key, r.key);
247
+ assert.ok(mirror.calls[0].rec.item, "the close needs the item to identify the ask");
248
+ } finally {
249
+ assurance._setBoardMirror(null);
250
+ }
251
+ });
212
252
  });
213
253
 
214
254
  // ---------------------------------------------------------------------------
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ── RETIRING THE BOARD ROW WHEN THE OBLIGATION SETTLES ───────────────────────
3
+ *
4
+ * Every piece of work this agent takes on becomes a row on its board, and moves
5
+ * as the work moves. The OPEN half of that lives in `scripts/daemon/agent-
6
+ * daemon.mjs` (accepted → working, through `trackWorkStep`); this is the CLOSE.
7
+ *
8
+ * WHY IT IS A SEPARATE HOOK. `closeObligation` is the funnel every terminal path
9
+ * in assurance.mjs goes through — answered, silent success, failed, undelivered,
10
+ * swept-stale — and only two of those reach the daemon's own onClose handler. A
11
+ * row that nothing retires sits in `running`, and `running` is the ONE column
12
+ * the product's "On now" banner reads, so a sweep-closed obligation would leave
13
+ * a colleague permanently "on" something it stopped doing hours ago. That stale
14
+ * banner is the exact complaint this whole workstream exists for.
15
+ *
16
+ * WHY IT NO LONGER CALLS `board.create`. It used to open and close its own row
17
+ * through `lib/org/board.mjs` — a SECOND implementation of a lifecycle hq and
18
+ * this repo already share exactly one of (`lib/org/work-ledger.mjs` → hq's
19
+ * `board.track` → `server/work/ledger.ts`). Two writers on the same ask is not a
20
+ * theoretical drift risk, it was the observed behaviour: `openAndAcknowledge`
21
+ * mirrored the obligation and the daemon tracked the same item forty lines
22
+ * later, so one DM produced TWO cards on the same board — one carrying the
23
+ * requester, the comments and the provenance, one carrying none of them, and
24
+ * the second was the one "On now" rendered. Worse, `board.create` gives a
25
+ * channel-less row ORG-WIDE visibility, and the mirror sent no channel for
26
+ * Slack/email/voice/telegram obligations — so a private ask's topic was
27
+ * published to everybody. The ledger's gate refuses those outright
28
+ * (`not-a-cohort-channel`), which is the rule this file now inherits instead of
29
+ * re-deciding.
30
+ *
31
+ * EVERYTHING HERE FAILS OPEN. A board that is unreachable, unauthorised or slow
32
+ * must never delay or break a reply — the work and the answer matter, the row is
33
+ * bookkeeping. Every function resolves; none throws; failures warn once.
34
+ */
35
+
36
+ import { recordWorkStep } from "../../lib/org/work-ledger.mjs";
37
+ import { loadOrgConfig } from "../../lib/org/client.mjs";
38
+
39
+ /**
40
+ * A board title from the obligation's own summary.
41
+ *
42
+ * The summary is the topic clause the acknowledgement already speaks aloud
43
+ * ("compiling the regulatory filing calendar"), so the row and the reply say
44
+ * the same thing — which is the point of a board a human reads next to a
45
+ * conversation. Sentence case, no trailing stop, bounded length.
46
+ */
47
+ export function boardTitle(rec) {
48
+ const raw =
49
+ (rec && (rec.summary || (rec.item && rec.item.subject) || (rec.item && rec.item.content))) || "";
50
+ const one = String(raw).replace(/\s+/g, " ").trim().replace(/[.\s]+$/, "");
51
+ if (!one) return "Responding to an inbound request";
52
+ const capped = one.length > 120 ? `${one.slice(0, 117)}…` : one;
53
+ return capped.charAt(0).toUpperCase() + capped.slice(1);
54
+ }
55
+
56
+ /**
57
+ * The obligation priority vocabulary, mapped onto the board's P-scale.
58
+ *
59
+ * The classifier speaks `critical|high|normal|ignore` (classifier.mjs); hq
60
+ * validates `priority` against `z.enum(["P0"…"P4"])` and 400s the whole call on
61
+ * anything else. An unrecognised value yields undefined rather than a guess:
62
+ * hq's own default (P2) is a better answer than one we invented.
63
+ */
64
+ const BOARD_PRIORITY = { critical: "P0", high: "P1", normal: "P2", ignore: "P3" };
65
+
66
+ /** Obligation priority → board priority, or undefined when there is no mapping. */
67
+ export function boardPriority(priority) {
68
+ const raw = String(priority || "").trim();
69
+ if (/^P[0-4]$/i.test(raw)) return raw.toUpperCase();
70
+ return BOARD_PRIORITY[raw.toLowerCase()];
71
+ }
72
+
73
+ /**
74
+ * How an obligation's own verdict reads on the board.
75
+ *
76
+ * `answered` is the only outcome that means the human got what he was owed.
77
+ * Everything else — a failed session, an undeliverable channel, a sweep that
78
+ * gave up — lands the row BLOCKED with the requester tagged, because the work is
79
+ * still owed to somebody and a row that quietly says `done` is worse than no row
80
+ * at all.
81
+ */
82
+ export function closeStageFor(outcome) {
83
+ return String(outcome || "answered") === "answered" ? "done" : "failed";
84
+ }
85
+
86
+ /** The comment that goes on the row when it is retired. */
87
+ function closeNote(rec) {
88
+ const state = String((rec && rec.state) || "answered");
89
+ const detail = rec && rec.closeNote ? ` (${String(rec.closeNote).slice(0, 200)})` : "";
90
+ if (state === "answered") return `Answered in the conversation${detail}.`;
91
+ if (state === "undeliverable") {
92
+ return `There was no way to deliver the answer${detail}. Back on the board, blocked.`;
93
+ }
94
+ return `This did not finish${detail}. Back on the board, blocked, rather than quietly dropped.`;
95
+ }
96
+
97
+ /**
98
+ * Retire the board row when the obligation settles.
99
+ *
100
+ * The obligation's SNAPSHOT (`assurance.mjs#itemSnapshot`) is deliberately thin
101
+ * — it carries the service, the channel and the ask, but not the ingest layer's
102
+ * directedness verdict — so `directed` is sent false. That is not a downgrade:
103
+ * hq's gate decides whether to START tracking, never whether to abandon it, so a
104
+ * row the open already created is closed normally, and an ask that never earned
105
+ * a row is refused here too. Fail-CLOSED on identity, fail-OPEN on transport.
106
+ *
107
+ * @param {object} a - { rec, outcome?, agentRoot?, deps? }
108
+ * @returns {Promise<{mirrored:boolean, reason?:string, taskId?:string|null, col?:string|null}>}
109
+ */
110
+ export async function closeBoardItem(a = {}) {
111
+ const rec = a.rec || {};
112
+ const item = rec.item;
113
+ if (!item) return { mirrored: false, reason: "no-item" };
114
+
115
+ try {
116
+ const load = (a.deps && a.deps.loadOrgConfig) || loadOrgConfig;
117
+ const cfg = load(a.agentRoot) || {};
118
+ const track = (a.deps && a.deps.recordWorkStep) || recordWorkStep;
119
+ const res = await track({
120
+ item,
121
+ stage: closeStageFor(a.outcome || rec.state),
122
+ deferred: true,
123
+ title: boardTitle(rec),
124
+ note: closeNote(rec),
125
+ source: { service: String(item.service || ""), obligation: String(rec.key || "") },
126
+ cfg,
127
+ });
128
+ if (!res || !res.tracked) {
129
+ // A refusal is INFORMATION, not noise: "not-a-cohort-channel" is the rule
130
+ // working (a Slack ask has no board here), "no-substance" is the gate.
131
+ console.log(`[board-mirror] close not tracked: ${(res && res.reason) || "unknown"}`);
132
+ return { mirrored: false, reason: (res && res.reason) || "unknown" };
133
+ }
134
+ return { mirrored: true, taskId: res.taskId || null, col: res.col || null };
135
+ } catch (err) {
136
+ console.warn(`[board-mirror] could not close ${rec.key}: ${err && err.message}`);
137
+ return { mirrored: false, reason: "error" };
138
+ }
139
+ }
140
+
141
+ export default { boardTitle, boardPriority, closeStageFor, closeBoardItem };
@@ -0,0 +1,165 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { boardPriority, boardTitle, closeStageFor, closeBoardItem } from "./board-mirror.mjs";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // This file used to open AND close a board row of its own through
8
+ // `board.create` + `board.claim` + `board.complete`. That was a second
9
+ // implementation of a lifecycle hq and this repo already share exactly one of
10
+ // (`lib/org/work-ledger.mjs` → `board.track` → hq `server/work/ledger.ts`), and
11
+ // the daemon called BOTH on the same item — two cards per ask on one board, one
12
+ // of them carrying no requester, no comments and no provenance. Worse,
13
+ // `board.create` gives a channel-less row ORG-WIDE visibility and the mirror
14
+ // sent no channel for Slack/email/voice, so a private ask's topic was published
15
+ // to the whole workspace.
16
+ //
17
+ // What survives is the CLOSE hook, because `closeObligation` is the funnel every
18
+ // terminal path goes through and only two of them reach the daemon's onClose.
19
+ // These tests pin that it goes through the SHARED ledger, that it never
20
+ // overstates, and that it still fails open.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const CFG = { org: { cohort: { enabled: true, base: "https://hq.example", token: "t" } } };
24
+ const okCfg = { loadOrgConfig: () => CFG };
25
+
26
+ /** A recording ledger seam. Answers the way `work-ledger.mjs` does. */
27
+ function ledger(over = {}) {
28
+ const seen = { calls: [] };
29
+ return {
30
+ seen,
31
+ deps: {
32
+ ...okCfg,
33
+ recordWorkStep: async (a) => {
34
+ seen.calls.push(a);
35
+ if (over.impl) return over.impl(a);
36
+ return { tracked: true, reason: "ok", taskId: "task_1", col: a.stage === "done" ? "done" : "blocked" };
37
+ },
38
+ },
39
+ };
40
+ }
41
+
42
+ const REC = {
43
+ key: "cohort:dm:C1:9",
44
+ state: "answered",
45
+ summary: "compiling the regulatory filing calendar",
46
+ item: {
47
+ id: "cohort-msg_1",
48
+ raw_ref: "cohort:messaging:msg_1:1",
49
+ service: "cohort",
50
+ channel_id: "chan_1",
51
+ content: "can you compile the regulatory filing calendar",
52
+ },
53
+ };
54
+
55
+ test("the title is the topic the acknowledgement already speaks aloud", () => {
56
+ assert.equal(
57
+ boardTitle({ summary: "compiling the regulatory filing calendar." }),
58
+ "Compiling the regulatory filing calendar"
59
+ );
60
+ // Falls back through the item, then to something honest.
61
+ assert.equal(boardTitle({ item: { subject: "Q3 numbers" } }), "Q3 numbers");
62
+ assert.equal(boardTitle({}), "Responding to an inbound request");
63
+ assert.ok(boardTitle({ summary: "x".repeat(400) }).length <= 120);
64
+ });
65
+
66
+ test("the classifier's priority words are mapped onto hq's P-scale, which is all it accepts", () => {
67
+ // hq validates priority against z.enum(["P0".."P4"]); "critical" 400s the
68
+ // whole call, so the obligation vocabulary has to be translated here.
69
+ assert.equal(boardPriority("critical"), "P0");
70
+ assert.equal(boardPriority("high"), "P1");
71
+ assert.equal(boardPriority("normal"), "P2");
72
+ assert.equal(boardPriority("ignore"), "P3");
73
+ assert.equal(boardPriority("P1"), "P1");
74
+ // No mapping → omit, and let hq's own default (P2) answer.
75
+ assert.equal(boardPriority("whatever"), undefined);
76
+ assert.equal(boardPriority(undefined), undefined);
77
+ });
78
+
79
+ test("only `answered` closes the row as done — every other verdict blocks it", () => {
80
+ assert.equal(closeStageFor("answered"), "done");
81
+ assert.equal(closeStageFor(undefined), "done");
82
+ for (const bad of ["failed", "undeliverable", "stale", "anything-else"]) {
83
+ assert.equal(closeStageFor(bad), "failed", `${bad} must not read as delivered`);
84
+ }
85
+ });
86
+
87
+ test("the close goes through the SHARED ledger, not a second board writer", async () => {
88
+ const { seen, deps } = ledger();
89
+ const r = await closeBoardItem({ rec: REC, agentRoot: "/x", deps });
90
+
91
+ assert.equal(r.mirrored, true);
92
+ assert.equal(r.taskId, "task_1");
93
+ assert.equal(seen.calls.length, 1);
94
+ const call = seen.calls[0];
95
+ // The ledger identifies the ask from the ITEM (service + channel + message),
96
+ // and derives the dedupe key server-side — which is what makes this land on
97
+ // the SAME row the daemon's `accepted`/`working` steps opened, rather than a
98
+ // second card beside it.
99
+ assert.equal(call.item, REC.item);
100
+ assert.equal(call.stage, "done");
101
+ assert.equal(call.deferred, true);
102
+ assert.equal(call.cfg, CFG);
103
+ assert.match(call.note, /Answered in the conversation/);
104
+ });
105
+
106
+ test("a failed obligation lands the row blocked, and says why", async () => {
107
+ const { seen, deps } = ledger();
108
+ await closeBoardItem({ rec: { ...REC, state: "failed", closeNote: "exit 1" }, deps });
109
+ assert.equal(seen.calls[0].stage, "failed");
110
+ assert.match(seen.calls[0].note, /exit 1/);
111
+ assert.match(seen.calls[0].note, /blocked/);
112
+ });
113
+
114
+ test("an undeliverable obligation says so, and still does not read as delivered", async () => {
115
+ const { seen, deps } = ledger();
116
+ await closeBoardItem({ rec: { ...REC, state: "undeliverable" }, deps });
117
+ assert.equal(seen.calls[0].stage, "failed");
118
+ assert.match(seen.calls[0].note, /no way to deliver/);
119
+ });
120
+
121
+ test("an explicit outcome overrides the record's own state", async () => {
122
+ const { seen, deps } = ledger();
123
+ await closeBoardItem({ rec: REC, outcome: "failed", deps });
124
+ assert.equal(seen.calls[0].stage, "failed");
125
+ });
126
+
127
+ test("a non-Cohort obligation writes NO row — a private ask is not published org-wide", async () => {
128
+ // THE LEAK THIS REPLACED: `board.create` with no channelId produces a row
129
+ // every member of the org can read, titled after the ask. The daemon polls
130
+ // Slack, two Gmail accounts, telegram, whatsapp and voice. The shared gate
131
+ // refuses all of them (`not-a-cohort-channel`) — this test pins that the
132
+ // refusal reaches us as a benign no-op rather than being worked around.
133
+ const { seen, deps } = ledger({ impl: async () => ({ tracked: false, reason: "not-a-cohort-channel" }) });
134
+ const r = await closeBoardItem({
135
+ rec: { ...REC, item: { ...REC.item, service: "slack", channel_id: "D0123" } },
136
+ deps,
137
+ });
138
+ assert.equal(r.mirrored, false);
139
+ assert.equal(r.reason, "not-a-cohort-channel");
140
+ assert.equal(seen.calls.length, 1, "the decision is the ledger's, not a rule re-implemented here");
141
+ });
142
+
143
+ test("an obligation with no item snapshot is a no-op, not a throw", async () => {
144
+ const r = await closeBoardItem({
145
+ rec: { key: "k1" },
146
+ deps: { ...okCfg, recordWorkStep: async () => { throw new Error("must not be called"); } },
147
+ });
148
+ assert.equal(r.mirrored, false);
149
+ assert.equal(r.reason, "no-item");
150
+ });
151
+
152
+ test("the close is fail-open: a refusal, a throw and a missing credential all resolve", async () => {
153
+ for (const [label, impl] of [
154
+ ["refused", async () => ({ tracked: false, reason: "FORBIDDEN_SCOPE: no" })],
155
+ ["threw", async () => { throw new Error("socket hang up"); }],
156
+ ["disabled", async () => ({ tracked: false, reason: "org-disabled" })],
157
+ ]) {
158
+ const r = await closeBoardItem({
159
+ rec: REC,
160
+ deps: { ...okCfg, recordWorkStep: impl },
161
+ });
162
+ assert.equal(r.mirrored, false, label);
163
+ assert.ok(r.reason, `${label} must report why`);
164
+ }
165
+ });
@@ -616,7 +616,11 @@ function buildBacklogContext(queueItem) {
616
616
  * @returns {string} Prompt string ready for claude --print
617
617
  */
618
618
  export async function buildPrompt(item, classResult, options = {}) {
619
- const { type = "inbox", queueItem, holdingMessage } = options;
619
+ const { type = "inbox", queueItem, holdingMessage, holdingSent } = options;
620
+ // Only `false` — an explicit "the send failed" from the caller — flips the
621
+ // framing. Callers that pass no flag keep the historical assertion, so this
622
+ // cannot silently downgrade a genuinely delivered acknowledgement.
623
+ const holdingDelivered = holdingSent !== false;
620
624
  const preamble = loadPreamble();
621
625
  const action = classResult.action || "respond";
622
626
  const actionBlock = ACTION_INSTRUCTIONS[action] || ACTION_INSTRUCTIONS.respond;
@@ -652,7 +656,7 @@ export async function buildPrompt(item, classResult, options = {}) {
652
656
  // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
653
657
  // This is the most critical instruction in the prompt: prevents double-replies.
654
658
  // We repeat it at section 7a as well, immediately before the action block.
655
- if (holdingMessage) {
659
+ if (holdingMessage && holdingDelivered) {
656
660
  parts.push("===== STOP — READ THIS FIRST =====");
657
661
  parts.push(`A HOLDING MESSAGE has ALREADY been sent to the sender by the daemon. The exact text was:`);
658
662
  parts.push(` "${holdingMessage}"`);
@@ -664,6 +668,25 @@ export async function buildPrompt(item, classResult, options = {}) {
664
668
  parts.push("- If after investigation you still cannot deliver a substantive response and need more time, send a SECOND-LEVEL UPDATE (specific blocker, ETA, what you need from the user) — never a generic 'still looking into it'.");
665
669
  parts.push("===== END WARNING =====");
666
670
  parts.push("");
671
+ } else if (holdingMessage) {
672
+ // The acknowledgement was COMPOSED but never landed (openAndAcknowledge
673
+ // returns ackText on the failure path too, so the session can see what the
674
+ // sender would have read). Asserting "already sent" here is the bug this
675
+ // branch exists to prevent: a session told a human had been acknowledged
676
+ // opens mid-conversation at a human who has heard nothing at all, and — if
677
+ // the item is one this session decides needs no reply — the ask lands
678
+ // nowhere with no trace the sender can see.
679
+ parts.push("===== STOP — READ THIS FIRST =====");
680
+ parts.push(`An acknowledgement was composed for this item but DELIVERY FAILED. The sender has received NOTHING — they do not know this item was seen. The text that did not go out was:`);
681
+ parts.push(` "${holdingMessage}"`);
682
+ parts.push("");
683
+ parts.push("Plan around that, do not paper over it.");
684
+ parts.push("- Do NOT write as though contact has already been made — no 'as I mentioned', no 'following up on my earlier note'.");
685
+ parts.push("- The reply channel is unresolved or unreachable. Before composing anything, establish whether you actually have a working channel to this sender.");
686
+ parts.push("- If you DO have one, send a single self-contained message: the acknowledgement and the substantive answer together.");
687
+ parts.push("- If you do NOT, do not let the item evaporate. Record the undeliverable ask and escalate it to the operator — silence plus a closed inbox item is how a request disappears.");
688
+ parts.push("===== END WARNING =====");
689
+ parts.push("");
667
690
  }
668
691
 
669
692
  // 2. Session context
@@ -724,9 +747,12 @@ export async function buildPrompt(item, classResult, options = {}) {
724
747
  // 5. Action instructions
725
748
  // If a holding message was already sent, prepend a second reminder so the
726
749
  // action block is unambiguous about not re-acknowledging.
727
- if (holdingMessage) {
750
+ if (holdingMessage && holdingDelivered) {
728
751
  parts.push("REMINDER: A holding message was already sent (see top of prompt). The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
729
752
  parts.push("");
753
+ } else if (holdingMessage) {
754
+ parts.push("REMINDER: The acknowledgement for this item FAILED to send (see top of prompt) — the sender has heard nothing. The action below describes WHAT to do; carry it out knowing this is still first contact, and escalate rather than close the item silently if you cannot reach them.");
755
+ parts.push("");
730
756
  }
731
757
  parts.push(actionBlock);
732
758
  parts.push("");
@@ -211,3 +211,44 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
211
211
  assert.ok(bullets > 0 && bullets <= 6, `expected <=6 injected facts, got ${bullets}`);
212
212
  });
213
213
  });
214
+
215
+ test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
216
+ const prompt = await buildPrompt(ITEM, CLASS, {
217
+ type: "inbox",
218
+ holdingMessage: "Understood — let me dig into this.",
219
+ holdingSent: true,
220
+ });
221
+ assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
222
+ assert.ok(prompt.includes("Understood — let me dig into this."));
223
+ assert.ok(!/DELIVERY FAILED/.test(prompt), "no failure framing on a delivered ack");
224
+ });
225
+
226
+ test("buildPrompt does NOT claim delivery when the acknowledgement failed to send", async () => {
227
+ const prompt = await buildPrompt(ITEM, CLASS, {
228
+ type: "inbox",
229
+ holdingMessage: "Understood — let me dig into this.",
230
+ holdingSent: false,
231
+ });
232
+ // The lie: telling the session a human already heard from us when they did not.
233
+ assert.ok(
234
+ !/has ALREADY been sent/.test(prompt),
235
+ "must not assert delivery of an acknowledgement that never left the process"
236
+ );
237
+ assert.ok(
238
+ !/they already received the holding note/.test(prompt),
239
+ "must not assert receipt in the second-reminder block either"
240
+ );
241
+ // And it must say so positively, so the session plans around it.
242
+ assert.match(prompt, /DELIVERY FAILED/);
243
+ assert.match(prompt, /received NOTHING/);
244
+ // The composed text is still shown — it is what the sender would have seen.
245
+ assert.ok(prompt.includes("Understood — let me dig into this."));
246
+ });
247
+
248
+ test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
249
+ const prompt = await buildPrompt(ITEM, CLASS, {
250
+ type: "inbox",
251
+ holdingMessage: "Understood — let me dig into this.",
252
+ });
253
+ assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
254
+ });