@cohortapp/agent-sdk 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 () => {
@@ -236,14 +225,25 @@ export function openObligations() {
236
225
  * object is gone. Content is truncated: this file is a debt record, not a
237
226
  * message archive.
238
227
  */
239
- function itemSnapshot(item) {
228
+ export function itemSnapshot(item) {
240
229
  return {
241
230
  id: item.id || null,
242
231
  raw_ref: item.raw_ref || null,
243
232
  message_id: item.message_id || null,
244
233
  service: item.service || null,
234
+ // `kind` IS AN ADDRESS TOO — `deliver.cohortSurfaceOf` falls back to it when
235
+ // `raw_ref` carries no surface name, and without it every such item
236
+ // re-routed from the sweep as a plain `dm` with no channel: undeliverable.
237
+ kind: item.kind || null,
245
238
  channel: item.channel || null,
246
239
  channel_id: item.channel_id || null,
240
+ // `scope_id` IS AN ADDRESS, not decoration. `deliver.cohortReplyRoute` reads
241
+ // it FIRST for every roomless Cohort surface (board, doc, decision). The
242
+ // sweep re-delivers from `rec.item`, so leaving it out meant the sweep
243
+ // routed a DIFFERENT item than the live path did — masked only by every
244
+ // hydrator happening to set `thread_id` too. Anything the routing table
245
+ // reads belongs in this snapshot.
246
+ scope_id: item.scope_id || null,
247
247
  thread_id: item.thread_id || null,
248
248
  sender: item.sender || null,
249
249
  sender_email: item.sender_email || null,
@@ -533,12 +533,13 @@ export async function openAndAcknowledge(a = {}) {
533
533
  if (rec.deliverable === undefined) rec.deliverable = canDeliverTo(item);
534
534
  writeRecord(rec);
535
535
 
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);
536
+ // NOTE: opening the board row is NOT done here. It belongs to the caller
537
+ // (agent-daemon.mjs, `accepted` → `working` through the shared work ledger),
538
+ // which holds the FULL inbox item — its directedness verdict, its author kind
539
+ // — rather than the thin snapshot above. This file used to open a second row
540
+ // of its own through `board.create`, which produced two cards per ask on the
541
+ // same board and gave every non-Cohort obligation an org-wide row named after
542
+ // a private message.
542
543
 
543
544
  if (!wantAck || !rec.deliverable) {
544
545
  // 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 };