@cohortapp/agent-sdk 2.9.0 → 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.9.0",
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 /
@@ -635,13 +636,31 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
635
636
  // `deferred: true` is the whole signal the server's gate needs; everything
636
637
  // else about whether this deserves a row is decided there, by the same code
637
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.
638
649
  void trackWorkStep({
639
650
  item,
640
651
  stage: "accepted",
641
652
  deferred: true,
653
+ priority: boardPriority(classResult && classResult.priority),
642
654
  note: classResult && classResult.summary ? `Picked this up. ${classResult.summary}` : "Picked this up — starting work now.",
643
655
  source: { service, trace_id, classified: String(classResult && classResult.action) },
644
- });
656
+ }).then(() =>
657
+ trackWorkStep({
658
+ item,
659
+ stage: "working",
660
+ deferred: true,
661
+ source: { service, trace_id },
662
+ }),
663
+ );
645
664
 
646
665
  // Build prompt with holding message context and dispatch
647
666
  const prompt = await buildPrompt(item, classResult, {
@@ -64,7 +64,7 @@ async function boardMirror() {
64
64
  try { _boardMirror = await import("./board-mirror.mjs"); }
65
65
  catch (err) {
66
66
  console.warn(`[assurance] board mirror unavailable: ${err.message}`);
67
- _boardMirror = { openBoardItem: async () => ({ mirrored: false }), closeBoardItem: async () => ({ mirrored: false }) };
67
+ _boardMirror = { closeBoardItem: async () => ({ mirrored: false }) };
68
68
  }
69
69
  return _boardMirror;
70
70
  }
@@ -72,17 +72,6 @@ async function boardMirror() {
72
72
  /** Test seam: replace the board mirror wholesale. */
73
73
  export function _setBoardMirror(impl) { _boardMirror = impl; }
74
74
 
75
- function mirrorOpen(rec, a = {}) {
76
- const inject = a.deps && a.deps.boardMirror;
77
- const run = async () => {
78
- const m = inject || (await boardMirror());
79
- return m.openBoardItem({ rec, agentRoot: AGENT_REPO_DIR, deps: a.deps });
80
- };
81
- return run().catch((err) => {
82
- console.warn(`[assurance] board mirror open failed: ${err.message}`);
83
- });
84
- }
85
-
86
75
  function mirrorClose(rec, o = {}) {
87
76
  const inject = o.deps && o.deps.boardMirror;
88
77
  const run = async () => {
@@ -533,12 +522,13 @@ export async function openAndAcknowledge(a = {}) {
533
522
  if (rec.deliverable === undefined) rec.deliverable = canDeliverTo(item);
534
523
  writeRecord(rec);
535
524
 
536
- // Mirror the accepted work onto the board, so "what is this colleague on?"
537
- // has an answer a human can read. Deliberately NOT awaited: the board is
538
- // bookkeeping and the acknowledgement is the product, so a slow or
539
- // unreachable board must not add a millisecond to the reply. board-mirror
540
- // never rejects, so the floating promise is safe.
541
- mirrorOpen(rec, a);
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.
542
532
 
543
533
  if (!wantAck || !rec.deliverable) {
544
534
  // The debt is on the books and the sweep will not try to speak into a
@@ -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
  // ---------------------------------------------------------------------------
@@ -1,37 +1,41 @@
1
1
  /**
2
- * ── THE BOARD MIRROR ─────────────────────────────────────────────────────────
2
+ * ── RETIRING THE BOARD ROW WHEN THE OBLIGATION SETTLES ───────────────────────
3
3
  *
4
4
  * Every piece of work this agent takes on becomes a row on its board, and moves
5
- * as the work 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.
6
7
  *
7
- * `lib/org/board.mjs` has been complete for a long time and had exactly ONE
8
- * caller — `lib/goals/collaborate.mjs`, the self-directed goal path. Nothing on
9
- * the responder path touched it. So an agent could spend forty minutes on an
10
- * ask that arrived by DM and leave no trace on the board at all: the human
11
- * asking "what are you on?" saw whatever seed rows the workspace shipped with.
12
- * On one live seat those were three tasks from 22 June, and the product's "On
13
- * now" banner had been faithfully rendering one of them ever since.
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.
14
15
  *
15
- * The hook point is the OBLIGATION, not the session. An obligation is already
16
- * the daemon's record of "a human is owed something and I have started" — it
17
- * opens when work is accepted and closes when the answer lands, it survives a
18
- * crash, and it is keyed stably. Mirroring it needs no new lifecycle and cannot
19
- * drift from the one that already governs replies.
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.
20
30
  *
21
31
  * EVERYTHING HERE FAILS OPEN. A board that is unreachable, unauthorised or slow
22
- * must never delay or break a reply — the work and the answer matter, the row
23
- * is bookkeeping. Every function resolves; none throws; failures warn once.
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.
24
34
  */
25
35
 
26
- import { createItem, completeItem } from "../../lib/org/board.mjs";
36
+ import { recordWorkStep } from "../../lib/org/work-ledger.mjs";
27
37
  import { loadOrgConfig } from "../../lib/org/client.mjs";
28
38
 
29
- /** Board item ids are derived from the obligation key, so re-entry is idempotent. */
30
- export function boardItemId(obligationKey) {
31
- if (!obligationKey) return null;
32
- return `ob-${String(obligationKey).replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 96)}`;
33
- }
34
-
35
39
  /**
36
40
  * A board title from the obligation's own summary.
37
41
  *
@@ -49,94 +53,89 @@ export function boardTitle(rec) {
49
53
  return capped.charAt(0).toUpperCase() + capped.slice(1);
50
54
  }
51
55
 
52
- function connOpts(agentRoot, deps) {
53
- const load = (deps && deps.loadOrgConfig) || loadOrgConfig;
54
- const cfg = load(agentRoot) || {};
55
- return { base: cfg.base, token: cfg.token, cfg, agentRoot };
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()];
56
71
  }
57
72
 
58
73
  /**
59
- * Open a `running` board row for an obligation the agent has just accepted.
74
+ * How an obligation's own verdict reads on the board.
60
75
  *
61
- * Idempotent: the id is derived from the obligation key, so a re-delivery or a
62
- * daemon restart re-creates the same row rather than a duplicate.
63
- *
64
- * @returns {Promise<{mirrored:boolean, itemId:string|null, reason?:string}>}
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.
65
81
  */
66
- export async function openBoardItem(a = {}) {
67
- const rec = a.rec || {};
68
- const itemId = boardItemId(rec.key);
69
- if (!itemId) return { mirrored: false, itemId: null, reason: "no-key" };
82
+ export function closeStageFor(outcome) {
83
+ return String(outcome || "answered") === "answered" ? "done" : "failed";
84
+ }
70
85
 
71
- const create = (a.deps && a.deps.createItem) || createItem;
72
- try {
73
- const opts = connOpts(a.agentRoot, a.deps);
74
- if (!opts.base || !opts.token) {
75
- return { mirrored: false, itemId, reason: "no-org-credential" };
76
- }
77
- const res = await create(
78
- {
79
- id: itemId,
80
- title: boardTitle(rec),
81
- // `running` is what the product's "On now" reads. A row parked in
82
- // `todo` would be invisible there, which defeats the whole exercise.
83
- status: "running",
84
- col: "running",
85
- priority: rec.priority || undefined,
86
- detail: rec.sender ? `Requested by ${rec.sender}.` : undefined,
87
- obligationKey: rec.key,
88
- },
89
- { ...opts, idempotencyKey: itemId }
90
- );
91
- if (res && res.ok === false) {
92
- console.warn(`[board-mirror] board.create refused ${itemId}: ${describe(res)}`);
93
- return { mirrored: false, itemId, reason: "refused" };
94
- }
95
- return { mirrored: true, itemId };
96
- } catch (err) {
97
- console.warn(`[board-mirror] could not open ${itemId}: ${err.message}`);
98
- return { mirrored: false, itemId, reason: "error" };
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.`;
99
93
  }
94
+ return `This did not finish${detail}. Back on the board, blocked, rather than quietly dropped.`;
100
95
  }
101
96
 
102
97
  /**
103
- * Close the board row when the obligation settles.
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.
104
106
  *
105
- * `outcome` mirrors the obligation's own verdict, so a row that ended in a
106
- * failure notice does not read as delivered work.
107
+ * @param {object} a - { rec, outcome?, agentRoot?, deps? }
108
+ * @returns {Promise<{mirrored:boolean, reason?:string, taskId?:string|null, col?:string|null}>}
107
109
  */
108
110
  export async function closeBoardItem(a = {}) {
109
111
  const rec = a.rec || {};
110
- const itemId = boardItemId(rec.key);
111
- if (!itemId) return { mirrored: false, itemId: null, reason: "no-key" };
112
+ const item = rec.item;
113
+ if (!item) return { mirrored: false, reason: "no-item" };
112
114
 
113
- const complete = (a.deps && a.deps.completeItem) || completeItem;
114
115
  try {
115
- const opts = connOpts(a.agentRoot, a.deps);
116
- if (!opts.base || !opts.token) {
117
- return { mirrored: false, itemId, reason: "no-org-credential" };
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" };
118
133
  }
119
- const res = await complete(
120
- {
121
- itemId,
122
- proof: a.outcome ? `outcome:${a.outcome}` : undefined,
123
- obligationKey: rec.key,
124
- },
125
- { ...opts, idempotencyKey: `${itemId}-done` }
126
- );
127
- if (res && res.ok === false) {
128
- console.warn(`[board-mirror] board.complete refused ${itemId}: ${describe(res)}`);
129
- return { mirrored: false, itemId, reason: "refused" };
130
- }
131
- return { mirrored: true, itemId };
134
+ return { mirrored: true, taskId: res.taskId || null, col: res.col || null };
132
135
  } catch (err) {
133
- console.warn(`[board-mirror] could not close ${itemId}: ${err.message}`);
134
- return { mirrored: false, itemId, reason: "error" };
136
+ console.warn(`[board-mirror] could not close ${rec.key}: ${err && err.message}`);
137
+ return { mirrored: false, reason: "error" };
135
138
  }
136
139
  }
137
140
 
138
- function describe(res) {
139
- const e = res && res.error;
140
- if (!e) return "unknown error";
141
- return `${e.code || "ERROR"} — ${e.message || "no message"}`;
142
- }
141
+ export default { boardTitle, boardPriority, closeStageFor, closeBoardItem };
@@ -1,34 +1,56 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import {
5
- boardItemId,
6
- boardTitle,
7
- openBoardItem,
8
- closeBoardItem,
9
- } from "./board-mirror.mjs";
4
+ import { boardPriority, boardTitle, closeStageFor, closeBoardItem } from "./board-mirror.mjs";
10
5
 
11
6
  // ---------------------------------------------------------------------------
12
- // The board mirror exists because lib/org/board.mjs had exactly ONE caller —
13
- // the self-directed goal path — and nothing on the responder path touched it.
14
- // An agent could spend forty minutes on a DM ask and leave the board showing
15
- // whatever seed rows the workspace shipped with.
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
16
  //
17
- // Its cardinal rule is that it FAILS OPEN: the work and the answer matter, the
18
- // row is bookkeeping. Most of these tests are about it staying out of the way.
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.
19
21
  // ---------------------------------------------------------------------------
20
22
 
21
- const CFG = { base: "https://hq.example", token: "t" };
23
+ const CFG = { org: { cohort: { enabled: true, base: "https://hq.example", token: "t" } } };
22
24
  const okCfg = { loadOrgConfig: () => CFG };
23
25
 
24
- test("board item ids are derived from the obligation key, so re-entry is idempotent", () => {
25
- const a = boardItemId("cohort:dm:abc123:7");
26
- const b = boardItemId("cohort:dm:abc123:7");
27
- assert.equal(a, b, "the same obligation must map to the same row");
28
- assert.match(a, /^ob-/);
29
- assert.ok(!/[^a-zA-Z0-9_-]/.test(a.slice(3)), "id is transport-safe");
30
- assert.equal(boardItemId(null), null);
31
- });
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
+ };
32
54
 
33
55
  test("the title is the topic the acknowledgement already speaks aloud", () => {
34
56
  assert.equal(
@@ -41,67 +63,103 @@ test("the title is the topic the acknowledgement already speaks aloud", () => {
41
63
  assert.ok(boardTitle({ summary: "x".repeat(400) }).length <= 120);
42
64
  });
43
65
 
44
- test("opens the row in `running`, which is what the product's On now reads", async () => {
45
- let sent = null;
46
- const r = await openBoardItem({
47
- rec: { key: "k1", summary: "drafting a reply to Karen", sender: "Karen" },
48
- agentRoot: "/x",
49
- deps: { ...okCfg, createItem: async (item) => { sent = item; return { ok: true }; } },
50
- });
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
+
51
91
  assert.equal(r.mirrored, true);
52
- assert.equal(sent.status, "running");
53
- assert.equal(sent.col, "running", "a row parked in todo is invisible to the banner");
54
- assert.equal(sent.title, "Drafting a reply to Karen");
55
- assert.equal(sent.obligationKey, "k1");
56
- assert.match(sent.detail, /Karen/);
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/);
57
104
  });
58
105
 
59
- test("a missing org credential is a no-op, not a throw", async () => {
60
- const r = await openBoardItem({
61
- rec: { key: "k1" },
62
- deps: { loadOrgConfig: () => ({}), createItem: async () => { throw new Error("must not be called"); } },
63
- });
64
- assert.equal(r.mirrored, false);
65
- assert.equal(r.reason, "no-org-credential");
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/);
66
112
  });
67
113
 
68
- test("a refusing board does not throw — the reply must still go out", async () => {
69
- const r = await openBoardItem({
70
- rec: { key: "k1", summary: "x" },
71
- deps: { ...okCfg, createItem: async () => ({ ok: false, error: { code: "FORBIDDEN", message: "no" } }) },
72
- });
73
- assert.equal(r.mirrored, false);
74
- assert.equal(r.reason, "refused");
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/);
75
119
  });
76
120
 
77
- test("a throwing transport does not throw — same reason", async () => {
78
- const r = await openBoardItem({
79
- rec: { key: "k1", summary: "x" },
80
- deps: { ...okCfg, createItem: async () => { throw new Error("socket hang up"); } },
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,
81
137
  });
82
138
  assert.equal(r.mirrored, false);
83
- assert.equal(r.reason, "error");
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");
84
141
  });
85
142
 
86
- test("closing carries the obligation's own verdict, so a failure does not read as delivered", async () => {
87
- let sent = null;
143
+ test("an obligation with no item snapshot is a no-op, not a throw", async () => {
88
144
  const r = await closeBoardItem({
89
145
  rec: { key: "k1" },
90
- outcome: "failed",
91
- deps: { ...okCfg, completeItem: async (p) => { sent = p; return { ok: true }; } },
146
+ deps: { ...okCfg, recordWorkStep: async () => { throw new Error("must not be called"); } },
92
147
  });
93
- assert.equal(r.mirrored, true);
94
- assert.equal(sent.proof, "outcome:failed");
95
- assert.equal(sent.itemId, boardItemId("k1"));
148
+ assert.equal(r.mirrored, false);
149
+ assert.equal(r.reason, "no-item");
96
150
  });
97
151
 
98
- test("close is as fail-open as open", async () => {
99
- for (const deps of [
100
- { ...okCfg, completeItem: async () => ({ ok: false, error: { code: "GONE" } }) },
101
- { ...okCfg, completeItem: async () => { throw new Error("boom"); } },
102
- { loadOrgConfig: () => ({}), completeItem: async () => { throw new Error("nope"); } },
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" })],
103
157
  ]) {
104
- const r = await closeBoardItem({ rec: { key: "k1" }, outcome: "answered", deps });
105
- assert.equal(r.mirrored, false);
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`);
106
164
  }
107
165
  });