@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.
- package/lib/channels/inbox-item.mjs +4 -0
- package/lib/comms/send-gate.mjs +23 -1
- package/lib/comms/send-gate.test.mjs +24 -0
- package/lib/model-router/economics.mjs +53 -1
- package/lib/model-router/economics.test.mjs +76 -0
- package/lib/model-router/resolve.mjs +57 -4
- package/lib/model-router.mjs +95 -8
- package/lib/model-router.test.mjs +305 -5
- package/lib/org/inbound/project.mjs +9 -5
- package/lib/org/messaging.mjs +6 -1
- package/lib/org/param-contract.mjs +24 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +78 -3
- package/lib/org/protocol.test.mjs +14 -2
- package/lib/org/work-ledger.mjs +5 -0
- package/lib/org/work-ledger.test.mjs +36 -0
- package/package.json +1 -1
- package/scripts/ci/conformance-org-api.mjs +18 -0
- package/scripts/ci/conformance-org-api.test.mjs +9 -1
- package/scripts/daemon/agent-daemon.mjs +20 -1
- package/scripts/daemon/assurance.mjs +20 -19
- package/scripts/daemon/assurance.test.mjs +40 -0
- package/scripts/daemon/board-mirror.mjs +92 -93
- package/scripts/daemon/board-mirror.test.mjs +124 -66
- package/scripts/daemon/deliver.mjs +457 -33
- package/scripts/daemon/deliver.test.mjs +564 -0
- package/scripts/daemon/responder-history.test.mjs +18 -2
- package/scripts/daemon/responder.mjs +7 -1
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* drift
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
53
|
-
|
|
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()];
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
/**
|
|
59
|
-
*
|
|
74
|
+
* How an obligation's own verdict reads on the board.
|
|
60
75
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
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
|
|
111
|
-
if (!
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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 ${
|
|
134
|
-
return { mirrored: false,
|
|
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
|
-
|
|
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 };
|