@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.
- package/lib/org/param-contract.mjs +24 -0
- 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 +8 -18
- 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
|
@@ -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,
|
package/lib/org/work-ledger.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 () => {
|
|
@@ -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
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
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
|
|
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 };
|
|
@@ -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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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("
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
53
|
-
assert.equal(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert.
|
|
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("
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
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("
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
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, "
|
|
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("
|
|
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
|
-
|
|
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,
|
|
94
|
-
assert.equal(
|
|
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
|
|
99
|
-
for (const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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({
|
|
105
|
-
|
|
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
|
});
|