@cohortapp/agent-sdk 2.4.0 → 2.5.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.
Files changed (81) hide show
  1. package/bin/maestro.mjs +9 -0
  2. package/lib/backlog.mjs +35 -0
  3. package/lib/backlog.test.mjs +36 -0
  4. package/lib/channels/contract.mjs +1 -0
  5. package/lib/channels/contract.test.mjs +2 -1
  6. package/lib/channels/inbox-item.mjs +54 -0
  7. package/lib/comms/send-gate.mjs +56 -1
  8. package/lib/comms/send-gate.test.mjs +56 -0
  9. package/lib/execution/disposition.mjs +62 -2
  10. package/lib/execution/disposition.test.mjs +54 -0
  11. package/lib/execution/drive.mjs +1 -1
  12. package/lib/execution/effects.mjs +282 -24
  13. package/lib/execution/effects.test.mjs +112 -0
  14. package/lib/execution/index.mjs +1 -0
  15. package/lib/execution/intake.mjs +43 -9
  16. package/lib/execution/intake.test.mjs +46 -0
  17. package/lib/execution/pipeline.mjs +5 -0
  18. package/lib/execution/surface-policy.mjs +80 -30
  19. package/lib/goals/classify.mjs +49 -5
  20. package/lib/goals/classify.test.mjs +58 -0
  21. package/lib/goals/collaborate.mjs +131 -17
  22. package/lib/goals/collaborate.test.mjs +16 -4
  23. package/lib/goals/loop.mjs +160 -9
  24. package/lib/goals/loop.test.mjs +129 -3
  25. package/lib/kpi-sensors.mjs +666 -0
  26. package/lib/kpi-sensors.test.mjs +275 -0
  27. package/lib/kpi.mjs +23 -0
  28. package/lib/mandate/audit.mjs +3 -0
  29. package/lib/mandate/contract.mjs +277 -0
  30. package/lib/mandate/contract.test.mjs +185 -0
  31. package/lib/mandate/derive.mjs +49 -5
  32. package/lib/mandate/derive.test.mjs +7 -1
  33. package/lib/mandate/model.mjs +10 -1
  34. package/lib/mandate/model.test.mjs +22 -3
  35. package/lib/mandate/refresh.mjs +53 -5
  36. package/lib/mandate/refresh.test.mjs +83 -1
  37. package/lib/org/doctor.mjs +66 -0
  38. package/lib/org/doctor.test.mjs +73 -1
  39. package/lib/org/inbound/directedness.mjs +119 -1
  40. package/lib/org/inbound/directedness.test.mjs +67 -0
  41. package/lib/org/inbound/facts.mjs +132 -9
  42. package/lib/org/inbound/facts.test.mjs +96 -0
  43. package/lib/org/inbound/hydrate.mjs +40 -0
  44. package/lib/org/inbound/index.test.mjs +83 -0
  45. package/lib/org/inbound/project.mjs +8 -0
  46. package/lib/org/inbound/surfaces.mjs +20 -0
  47. package/lib/org/param-contract.mjs +16 -2
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +214 -2
  50. package/lib/org/protocol.test.mjs +11 -2
  51. package/lib/org/push.mjs +213 -49
  52. package/lib/org/push.test.mjs +112 -10
  53. package/lib/plan/compile.mjs +85 -8
  54. package/lib/plan/compile.test.mjs +82 -0
  55. package/lib/plan/emit.test.mjs +6 -1
  56. package/lib/setup/enroll-from-cohort.mjs +22 -2
  57. package/lib/setup/enroll-from-cohort.test.mjs +25 -0
  58. package/lib/setup/sections/mandate.mjs +43 -1
  59. package/lib/subagents/schema.mjs +14 -2
  60. package/lib/subagents/schema.test.mjs +22 -0
  61. package/package.json +1 -1
  62. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  63. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  64. package/scripts/ci/check.mjs +3 -0
  65. package/scripts/ci/conformance-org-api.mjs +16 -0
  66. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  67. package/scripts/daemon/agent-daemon.mjs +582 -28
  68. package/scripts/daemon/cadence-handlers.mjs +273 -17
  69. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  70. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  71. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  72. package/scripts/daemon/maestro-daemon.mjs +53 -0
  73. package/scripts/daemon/prompt-builder.mjs +47 -0
  74. package/scripts/daemon/responder.mjs +70 -3
  75. package/scripts/poller/imap-client.mjs +20 -1
  76. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  77. package/scripts/poller/utils.mjs +51 -0
  78. package/scripts/setup/generate-capability.mjs +120 -11
  79. package/scripts/setup/generate-capability.test.mjs +134 -0
  80. package/scripts/setup/generate-plan.mjs +6 -1
  81. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -38,6 +38,20 @@
38
38
  * this surface — approvals and decisions land as `escalate`
39
39
  * even when the payload names the agent as the approver. This
40
40
  * is the same anti-self-grading law as `mandate.adopt`.
41
+ * `escalationOptions`
42
+ * The concrete ways a `selfApprove:false` escalation can go,
43
+ * for surfaces where they are STRUCTURALLY KNOWN rather than
44
+ * situational. Required, in practice, by the thing on the far
45
+ * end: hq's `escalation.ask` refuses fewer than two options
46
+ * ("a question with fewer is a message, not a decision"), and
47
+ * nothing supplies `facts.ambiguousOptions` for an approval —
48
+ * the daemon has no way to invent them and no reason to,
49
+ * because an approval can only ever be approved, rejected or
50
+ * held. Without this column the ladder decided `escalate` and
51
+ * the effect then had nothing to ask, so the escalation lane
52
+ * produced nothing at all. Omitted where the options really do
53
+ * depend on the situation; `facts.ambiguousOptions` still wins
54
+ * over it when a caller knows better.
41
55
  * `maxChainDepth` How many consecutive agent turns are allowed in one thread
42
56
  * before the ladder calls it a ping-pong loop and stops. Lower
43
57
  * for broadcast surfaces (a space) than for a private DM.
@@ -148,6 +162,13 @@ export const ORG_SURFACE_POLICY = Object.freeze({
148
162
  call: Object.freeze({
149
163
  respondable: false,
150
164
  replyMethod: null,
165
+ // The two verbs the row above describes in prose, as data. Read ONLY as a
166
+ // fallback when neither the router nor a compiled obligation named a method
167
+ // (see `effects.usesFor`), and used ONLY to furnish the queue row — it does
168
+ // not feed `need.methods`, so it moves no rung. Without it a live huddle
169
+ // (`calling/start` seq 8194) queued a row whose `uses` was empty: the drain
170
+ // was handed a call id and left to guess what one does with it.
171
+ queueUses: Object.freeze(["calling.getDetails", "calling.join"]),
151
172
  latency: "now",
152
173
  actionClasses: Object.freeze([]),
153
174
  offlineSafe: false,
@@ -222,6 +243,11 @@ export const ORG_SURFACE_POLICY = Object.freeze({
222
243
  actionClasses: Object.freeze(["irreversible"]),
223
244
  offlineSafe: false,
224
245
  selfApprove: false,
246
+ escalationOptions: Object.freeze([
247
+ "approve it",
248
+ "reject it",
249
+ "hold it — I need more context first",
250
+ ]),
225
251
  maxChainDepth: 1,
226
252
  ambientDrop: true,
227
253
  }),
@@ -233,6 +259,11 @@ export const ORG_SURFACE_POLICY = Object.freeze({
233
259
  actionClasses: Object.freeze(["irreversible"]),
234
260
  offlineSafe: false,
235
261
  selfApprove: false,
262
+ escalationOptions: Object.freeze([
263
+ "adopt it as proposed",
264
+ "request an adjustment",
265
+ "reject it",
266
+ ]),
236
267
  maxChainDepth: 1,
237
268
  ambientDrop: true,
238
269
  }),
@@ -263,6 +294,47 @@ export const ORG_SURFACE_POLICY = Object.freeze({
263
294
  ambientDrop: true,
264
295
  }),
265
296
 
297
+ // ── calendar ────────────────────────────────────────────────────────────
298
+ /**
299
+ * ADOPTED 2026-08 (JOINT 3). This row lived in EXTENDED_SURFACE_POLICY while
300
+ * `directedness.classifyEvent` had no `calendar` branch. It now has one, and
301
+ * `SURFACES.calendar` ships, so the row moves here — which is exactly what
302
+ * the `adoptedExtendedSurfaces` drift guard exists to force.
303
+ *
304
+ * A meeting invite / reschedule / cancellation naming me as an attendee. Not
305
+ * respondable: the answer is an RSVP (`calendar.rsvp`), not a sentence. It is
306
+ * `now` latency because a 9am invite answered on tomorrow's batch tick is a
307
+ * missed meeting, and NOT offline-safe because accepting commits the
308
+ * principal's time — and `calendar.write` queues real invite/update/cancel
309
+ * mail, which leaves the org.
310
+ */
311
+ calendar: Object.freeze({
312
+ // `replyMethod` is null because it is null for every non-respondable row:
313
+ // it names the method a CONVERSATIONAL reply would use, and an RSVP is an
314
+ // action the ladder routes, not a turn in a conversation. The routing hint
315
+ // lives on the obligation's `uses[]`, where it belongs.
316
+ respondable: false,
317
+ replyMethod: null,
318
+ // …EXCEPT that an un-adopted agent has no compiled plan, so there is no
319
+ // obligation and therefore no `uses[]`. That is the common case, not the
320
+ // exotic one: a live seat with no `config/plan.yaml` queued a real invite
321
+ // with `action.method: null`, and the row it wrote said only "Handle the
322
+ // calendar event calendar.calendar#8261" — no meeting id, no method. The
323
+ // session that drains it cannot RSVP something it cannot name.
324
+ //
325
+ // `queueUses` is the FALLBACK, never the override: it is read only when
326
+ // nothing else named a method, and only to furnish the queue row. It does
327
+ // NOT feed `need.methods`, so it cannot move a rung — the blast-radius and
328
+ // approval gates decide exactly as before.
329
+ queueUses: Object.freeze(["calendar.rsvp"]),
330
+ latency: "now",
331
+ actionClasses: Object.freeze(["external"]),
332
+ offlineSafe: false,
333
+ selfApprove: true,
334
+ maxChainDepth: 1,
335
+ ambientDrop: true,
336
+ }),
337
+
266
338
  // ── outside the org ─────────────────────────────────────────────────────
267
339
  /**
268
340
  * Inbound email. The only default surface whose reply LEAVES the org, so it
@@ -284,13 +356,12 @@ export const ORG_SURFACE_POLICY = Object.freeze({
284
356
  /**
285
357
  * Surfaces this layer decides for that the org inbound table does NOT name yet.
286
358
  *
287
- * Three of them exist, and each is a real event class that reaches an agent
288
- * today with no policy at all:
359
+ * Two of them exist, and each is a real event class that reaches an agent
360
+ * today with no policy at all. (`calendar` used to be the third; JOINT 3 gave
361
+ * `directedness.classifyEvent` a `calendar` branch and `SURFACES` a `calendar`
362
+ * entry, the drift guard fired, and its row MOVED into ORG_SURFACE_POLICY — the
363
+ * mechanism working as designed rather than two definitions accumulating.)
289
364
  *
290
- * `calendar` the protocol ships nine `calendar.*` methods and hq's classifier
291
- * gains a `calendar` topic (SPEC §3), but `directedness.classifyEvent`
292
- * has no `calendar` branch — an invite where I am an attendee is
293
- * currently policy-less.
294
365
  * `alert` a LOCAL fact, not an org event: `lib/diagnostics/alerts.mjs` and
295
366
  * `lib/telemetry/alerts.mjs` derive `{id, severity, detail}` records
296
367
  * that have never had a route to a decision — they were emitted to a
@@ -300,33 +371,12 @@ export const ORG_SURFACE_POLICY = Object.freeze({
300
371
  *
301
372
  * Kept in a SEPARATE table rather than merged into the literal above so the
302
373
  * "every org surface has a row" coverage assertion stays exact, and so the day
303
- * the parallel inbound workflow adds `calendar` to `SURFACES` the drift guard
374
+ * the inbound layer adopts one of these the drift guard
304
375
  * {@link adoptedExtendedSurfaces} fires and someone MOVES the row instead of
305
- * quietly ending up with two definitions of the same surface.
376
+ * quietly ending up with two definitions of the same surface. That is precisely
377
+ * how `calendar` left this table.
306
378
  */
307
379
  export const EXTENDED_SURFACE_POLICY = Object.freeze({
308
- /**
309
- * A meeting invite / reschedule / cancellation naming me as an attendee. Not
310
- * respondable: the answer is an RSVP (`calendar.rsvp`), not a sentence. It is
311
- * `now` latency because a 9am invite answered on tomorrow's batch tick is a
312
- * missed meeting, and NOT offline-safe because accepting commits the
313
- * principal's time — and `calendar.write` queues real invite/update/cancel
314
- * mail, which leaves the org.
315
- */
316
- calendar: Object.freeze({
317
- // `replyMethod` is null because it is null for every non-respondable row:
318
- // it names the method a CONVERSATIONAL reply would use, and an RSVP is an
319
- // action the ladder routes, not a turn in a conversation. The routing hint
320
- // lives on the obligation's `uses[]`, where it belongs.
321
- respondable: false,
322
- replyMethod: null,
323
- latency: "now",
324
- actionClasses: Object.freeze(["external"]),
325
- offlineSafe: false,
326
- selfApprove: true,
327
- maxChainDepth: 1,
328
- ambientDrop: true,
329
- }),
330
380
  /**
331
381
  * A local health / budget / freshness alert about this agent. `offlineSafe` is
332
382
  * TRUE and deliberately so: a partition is exactly when alerts matter, and an
@@ -54,6 +54,41 @@ function otherRoles(list, me, roleSet) {
54
54
  return list.filter((c) => roleSet.has(c.role) && c.memberId && c.memberId !== me);
55
55
  }
56
56
 
57
+ /**
58
+ * Collaborator edges hq DERIVES from org structure rather than from anyone
59
+ * naming a reviewer for this work.
60
+ *
61
+ * ── WHY THIS SET EXISTS (a live, universal misroute) ──
62
+ * `mandate.adopt` enforces the anti-Goodhart law: the adopter can never be the
63
+ * objective's owner. `projectObjective` then emits that adopter as
64
+ * `{role:"APPROVER", source:"objective.adoptedById"}` on EVERY adopted
65
+ * objective. Rule 4 below ("a named APPROVER means the output needs a signature
66
+ * before it executes") therefore fired on **every candidate the goal steward
67
+ * has ever produced from a real hq mandate** — 100% of self-directed work
68
+ * classified NEEDS_REVIEW and went out for signature. Confirmed end-to-end
69
+ * against the adopted-snapshot wire shape.
70
+ *
71
+ * Adopting an objective is standing sign-off on the NUMBER, not per-task
72
+ * sign-off on every action taken to move it. The thing that genuinely needs a
73
+ * signature before execution is a gated action class (external / irreversible /
74
+ * financial), and that check is untouched. A human who wants per-task review
75
+ * says so by naming a reviewer — an edge with no derived `source`, or a
76
+ * `reviewBy` — and that still routes to NEEDS_REVIEW.
77
+ *
78
+ * These seats stay in `approvers[]`/`reviewers[]` regardless, so when a gate DOES
79
+ * fire, `goals/collaborate.routeNeedsReview` still has a real person to send it
80
+ * to. They just no longer MANUFACTURE the gate.
81
+ */
82
+ const DERIVED_GOVERNANCE_SOURCES = new Set([
83
+ "objective.adoptedById",
84
+ "workforceMember.supervisorId",
85
+ ]);
86
+
87
+ /** True when this edge was named for the work, not inferred from org structure. */
88
+ function isNamedForTheWork(c) {
89
+ return !(c && c.source && DERIVED_GOVERNANCE_SOURCES.has(String(c.source)));
90
+ }
91
+
57
92
  /**
58
93
  * Which of the candidate's required capabilities this seat cannot reach, and
59
94
  * which peer (if any) declares each. Pure.
@@ -163,24 +198,33 @@ export function classifyWorkItem(candidate = {}, ctx = {}) {
163
198
  // 4 — blast radius or a named APPROVER: the output needs a signature BEFORE it
164
199
  // executes. Approval outranks collaboration; a gated action that is also
165
200
  // collaborative is still gated.
166
- if (gated.length > 0 || approvers.length > 0) {
201
+ // Only an edge someone NAMED for this work manufactures a review gate; a
202
+ // derived governance edge (the objective's adopter, this seat's supervisor)
203
+ // does not. See DERIVED_GOVERNANCE_SOURCES.
204
+ const namedApprovers = approvers.filter(isNamedForTheWork);
205
+ const namedReviewers = reviewers.filter(isNamedForTheWork);
206
+
207
+ if (gated.length > 0 || namedApprovers.length > 0) {
167
208
  why.push(
168
209
  gated.length
169
210
  ? `action classes ${gated.join("+")} are gated`
170
- : `collaborator ${approvers[0].memberId} carries role APPROVER`
211
+ : `collaborator ${namedApprovers[0].memberId} carries role APPROVER`
171
212
  );
172
213
  return {
173
214
  ...base,
174
215
  disposition: "NEEDS_REVIEW",
175
216
  reason: gated.length ? "gated-action-class" : "collaborator-is-approver",
176
- target: approvers.length ? approvers[0].memberId : null,
217
+ // A gated class still routes to the objective's adopter when that is the
218
+ // only approver on file — the derived edge is a fallback TARGET, never a
219
+ // trigger.
220
+ target: (namedApprovers[0] || approvers[0] || {}).memberId || null,
177
221
  };
178
222
  }
179
223
 
180
224
  // 5 — a named reviewer (candidate- or objective-level).
181
225
  const reviewBy = candidate.reviewBy || (ctx.objective && ctx.objective.reviewBy) || null;
182
- if (reviewers.length > 0 || (reviewBy && reviewBy !== me)) {
183
- const target = reviewers.length ? reviewers[0].memberId : reviewBy;
226
+ if (namedReviewers.length > 0 || (reviewBy && reviewBy !== me)) {
227
+ const target = namedReviewers.length ? namedReviewers[0].memberId : reviewBy;
184
228
  why.push(`review required by ${target}`);
185
229
  return { ...base, disposition: "NEEDS_REVIEW", reason: "review-required", target };
186
230
  }
@@ -107,3 +107,61 @@ test("capabilityGaps splits missing into peer-covered and uncovered, determinist
107
107
  assert.deepEqual(g.coveredByPeer, [{ capability: "b", memberId: "p1" }, { capability: "c", memberId: "p2" }]);
108
108
  assert.deepEqual(g.uncovered, []);
109
109
  });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // The derived-governance-edge regression (found end-to-end, 2026-08-11)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ test("REGRESSION — an objective's ADOPTER does not by itself force NEEDS_REVIEW", () => {
116
+ // hq's mandate.adopt refuses self-adoption, and projectObjective then emits the
117
+ // adopter as {role:"APPROVER", source:"objective.adoptedById"} on EVERY adopted
118
+ // objective. Rule 4 fired on it, so 100% of goal-steward candidates went out
119
+ // for signature and none was ever done SELF. Adopting the NUMBER is not
120
+ // per-task sign-off on the actions taken to move it.
121
+ const objective = {
122
+ key: "board-on-time-delivery",
123
+ collaborators: [{ memberId: "boss", role: "APPROVER", source: "objective.adoptedById" }],
124
+ };
125
+ const c = classifyWorkItem({ title: "Close the gap" }, { memberId: "me", objective, reachable: [] });
126
+ assert.equal(c.disposition, "SELF");
127
+ assert.equal(c.reason, "self");
128
+ // The edge is not discarded — it is still on file as an approver.
129
+ assert.deepEqual(c.approvers, ["boss"]);
130
+ });
131
+
132
+ test("REGRESSION — the seat's SUPERVISOR does not force NEEDS_REVIEW either", () => {
133
+ const c = classifyWorkItem(
134
+ { title: "Close the gap" },
135
+ {
136
+ memberId: "me",
137
+ objective: { collaborators: [{ memberId: "boss", role: "REVIEWER", source: "workforceMember.supervisorId" }] },
138
+ reachable: [],
139
+ }
140
+ );
141
+ assert.equal(c.disposition, "SELF");
142
+ assert.deepEqual(c.reviewers, ["boss"]);
143
+ });
144
+
145
+ test("a DELIBERATELY named reviewer (no derived source) still routes to NEEDS_REVIEW", () => {
146
+ const c = classifyWorkItem(
147
+ { title: "Close the gap", collaborators: [{ memberId: "peer", role: "REVIEWER" }] },
148
+ { memberId: "me", reachable: [] }
149
+ );
150
+ assert.equal(c.disposition, "NEEDS_REVIEW");
151
+ assert.equal(c.reason, "review-required");
152
+ assert.equal(c.target, "peer");
153
+ });
154
+
155
+ test("a GATED action class still gates — and falls back to the adopter as the target", () => {
156
+ const c = classifyWorkItem(
157
+ { title: "Wire the payment", actionClasses: ["financial"] },
158
+ {
159
+ memberId: "me",
160
+ objective: { collaborators: [{ memberId: "boss", role: "APPROVER", source: "objective.adoptedById" }] },
161
+ reachable: [],
162
+ }
163
+ );
164
+ assert.equal(c.disposition, "NEEDS_REVIEW");
165
+ assert.equal(c.reason, "gated-action-class");
166
+ assert.equal(c.target, "boss", "the derived edge is a fallback TARGET, never a trigger");
167
+ });
@@ -54,9 +54,73 @@ export function workResourceId(objectiveKey, title) {
54
54
  // Lazily-resolved defaults (so a test can inject everything and touch no I/O)
55
55
  // ---------------------------------------------------------------------------
56
56
 
57
+ /**
58
+ * The org connection for this seat.
59
+ *
60
+ * THE BUG THIS EXISTS TO KILL: every default primitive here took `base`/`token`
61
+ * off `deps`, and NOTHING ever put them there — `runGoalSteward`'s deps carry the
62
+ * agent root, the clock and the sensors, not a credential. So `client.call`
63
+ * returned `{ok:false, BAD_REQUEST, "missing base"}` and the callers logged only
64
+ * the CODE, never the message. Every board item, handoff, approval and escalation
65
+ * the goal loop has ever produced died on this machine, and the log line
66
+ * ("board.create failed: BAD_REQUEST — the local backlog item still stands")
67
+ * read exactly like a server-side rejection. Resolve it the same way every other
68
+ * plane does: env first, `config/org.yaml` second, explicit deps win.
69
+ *
70
+ * @param {object} deps @returns {{base:string, token:string, orgId:string}}
71
+ */
72
+ async function orgConn(deps = {}) {
73
+ if (deps.base && deps.token) return { base: deps.base, token: deps.token, orgId: deps.orgId };
74
+ let resolved = { base: "", token: "", orgId: "" };
75
+ try {
76
+ const { resolveOrgToolConfig } = await import("../org/tool-surface.mjs");
77
+ resolved = resolveOrgToolConfig({ agentRoot: deps.agentRoot, orgConfig: deps.cfg });
78
+ } catch (err) {
79
+ logOf(deps)("warn", `[goals] could not resolve the org connection (${err && err.message ? err.message : err}) — org writes will fail locally`);
80
+ }
81
+ const conn = {
82
+ base: deps.base || resolved.base || "",
83
+ token: deps.token || resolved.token || "",
84
+ orgId: deps.orgId || resolved.orgId || "",
85
+ };
86
+ if (!conn.base || !conn.token) {
87
+ logOf(deps)(
88
+ "warn",
89
+ `[goals] no org credential for this seat (base=${conn.base ? "set" : "MISSING"} token=${conn.token ? "set" : "MISSING"}) — the work stays in the local queue and is invisible on the board`,
90
+ );
91
+ }
92
+ return conn;
93
+ }
94
+
95
+ /**
96
+ * Post the board payload to the RIGHT method.
97
+ *
98
+ * `lib/org/board.createItem` calls **`board.create`**, whose hq schema is
99
+ * `.strict()` and accepts only {title, detail, status, priority, workstreamId,
100
+ * itemId}. It rejects `col`, `assigneeId` and — fatally for machine-created work
101
+ * — `why`. `board.createTask` is the method that carries the provenance block and
102
+ * an owner, so prefer it whenever the vendored protocol has it, and degrade to
103
+ * `board.create` LOUDLY (an unowned, untraceable task is still better than no
104
+ * task, but the operator has to be told which one they got).
105
+ */
57
106
  async function defaultCreateItem(item, deps) {
107
+ const conn = await orgConn(deps);
108
+ if (methodDef("board.createTask")) {
109
+ const { call } = await import("../org/client.mjs");
110
+ // `itemId` is meaningless to board.createTask (non-strict → stripped) and is
111
+ // the idempotency key of the legacy path; carrying it costs nothing.
112
+ const { itemId, ...params } = item || {};
113
+ return call("board.createTask", params, { ...conn, fetchImpl: deps.fetchImpl });
114
+ }
115
+ logOf(deps)(
116
+ "warn",
117
+ "[goals] board.createTask is not in the vendored protocol — falling back to board.create, which drops the assignee and the `why` provenance block",
118
+ );
58
119
  const { createItem } = await import("../org/board.mjs");
59
- return createItem(item, { cfg: deps.cfg, base: deps.base, token: deps.token, fetchImpl: deps.fetchImpl });
120
+ const legacy = { title: item.title, priority: item.priority };
121
+ if (item.detail) legacy.detail = String(item.detail).slice(0, 5000);
122
+ if (item.itemId) legacy.itemId = item.itemId;
123
+ return createItem(legacy, { cfg: deps.cfg, ...conn, fetchImpl: deps.fetchImpl });
60
124
  }
61
125
 
62
126
  async function defaultSendHandoff(o, deps) {
@@ -76,7 +140,8 @@ async function defaultClaimLease(scope, resourceId, opts) {
76
140
 
77
141
  async function defaultCall(method, params, deps) {
78
142
  const { call } = await import("../org/client.mjs");
79
- return call(method, params, { base: deps.base, token: deps.token, orgId: deps.orgId, fetchImpl: deps.fetchImpl });
143
+ const conn = await orgConn(deps);
144
+ return call(method, params, { ...conn, fetchImpl: deps.fetchImpl });
80
145
  }
81
146
 
82
147
  function pick(deps, name, fallback) {
@@ -87,6 +152,62 @@ function pick(deps, name, fallback) {
87
152
  // The board item — every disposition creates one, so the work is visible
88
153
  // ---------------------------------------------------------------------------
89
154
 
155
+ /** Local priority word → hq's `taskPriorityEnum`. P0 stays reserved for humans. */
156
+ export const BOARD_PRIORITY = Object.freeze({ high: "P1", normal: "P2", low: "P3" });
157
+
158
+ /**
159
+ * Shape a candidate into `board.createTask`'s ACTUAL parameters.
160
+ *
161
+ * This used to send `{id, status:"open", priority:"normal", assignee,
162
+ * objectiveKey, obligationKey}`. hq's `createTaskSchema` (src/server/validation/
163
+ * task.ts) accepts none of those spellings: `priority` is the P0..P4 enum, the
164
+ * owner field is `assigneeId`, and `why` is a CLOSED block of
165
+ * {reason, options, clause, wouldChange}. Every self-directed item this loop
166
+ * ever produced was therefore rejected BAD_REQUEST — the work existed in the
167
+ * local queue and was invisible to every human on the board.
168
+ *
169
+ * Provenance hq will not carry in `why` (objectiveKey, obligationKey, the
170
+ * expected delta) is written into `detail` instead, in prose, so the trace
171
+ * survives the round trip even though the columns do not.
172
+ *
173
+ * @param {object} item - { id, title, objectiveKey, obligationKey, why, priority, assignee?, expectedDelta?, successCriteria? }
174
+ * @param {object} [deps] - { memberId, boardCol? }
175
+ * @returns {object} board.createTask params
176
+ */
177
+ export function boardPayloadFor(item = {}, deps = {}) {
178
+ const why = {};
179
+ const src = item.why || {};
180
+ if (src.reason) why.reason = String(src.reason).slice(0, 2000);
181
+ if (src.clause) why.clause = String(src.clause).slice(0, 500);
182
+ if (src.wouldChange) why.wouldChange = String(src.wouldChange).slice(0, 2000);
183
+ if (Array.isArray(src.options) && src.options.length) why.options = src.options.slice(0, 20);
184
+
185
+ const lines = [];
186
+ if (item.objectiveKey) lines.push(`Advances objective: ${item.objectiveKey}`);
187
+ if (item.obligationKey) lines.push(`Obligation: ${item.obligationKey}`);
188
+ if (item.expectedDelta != null) lines.push(`Expected delta: ${item.expectedDelta}`);
189
+ if (src.metric) lines.push(`Metric: ${src.metric} (gap ${src.gap}, trend ${src.trend})`);
190
+ for (const c of [].concat(item.successCriteria || [])) lines.push(`Success: ${c}`);
191
+ if (item.id) lines.push(`Local queue id: ${item.id}`);
192
+
193
+ const payload = {
194
+ // Stripped by board.createTask (non-strict); the idempotency key of the
195
+ // legacy board.create fallback.
196
+ itemId: item.id || undefined,
197
+ title: String(item.title || "").slice(0, 500),
198
+ // `todo`, not the schema's `triage` default: this work was ADMITTED through
199
+ // eight gates against a measured gap. Dropping it into triage would hand a
200
+ // human the decision the loop just made.
201
+ col: deps.boardCol || "todo",
202
+ priority: BOARD_PRIORITY[String(item.priority || "normal")] || "P2",
203
+ };
204
+ if (lines.length) payload.detail = lines.join("\n").slice(0, 20000);
205
+ const assigneeId = item.assignee || deps.memberId;
206
+ if (assigneeId) payload.assigneeId = assigneeId;
207
+ if (Object.keys(why).length) payload.why = why;
208
+ return payload;
209
+ }
210
+
90
211
  /**
91
212
  * Create the org board item that carries the work, stamped with the full
92
213
  * provenance chain (`why`). Fail-open: an unreachable org returns
@@ -98,19 +219,7 @@ function pick(deps, name, fallback) {
98
219
  */
99
220
  export async function createWorkItem(item, deps = {}) {
100
221
  const create = pick(deps, "createItem", (i) => defaultCreateItem(i, deps));
101
- const payload = {
102
- id: item.id,
103
- title: item.title,
104
- status: "open",
105
- priority: item.priority || "normal",
106
- assignee: item.assignee || deps.memberId || undefined,
107
- // `why` is mandatory for machine-created work: {reason, objectiveKey,
108
- // obligationKey, clause, expectedDelta}. A task a human cannot trace is a
109
- // task a human cannot trust.
110
- why: item.why || null,
111
- objectiveKey: item.objectiveKey || null,
112
- obligationKey: item.obligationKey || null,
113
- };
222
+ const payload = boardPayloadFor(item, deps);
114
223
  let frame;
115
224
  try {
116
225
  frame = await create(payload);
@@ -122,7 +231,10 @@ export async function createWorkItem(item, deps = {}) {
122
231
  if (!ok) {
123
232
  logOf(deps)(
124
233
  "warn",
125
- `[goals] board.create failed for "${item.title}": ${(frame && frame.error && frame.error.code) || "unknown"} — the local backlog item still stands`
234
+ // The MESSAGE, not just the code. "BAD_REQUEST" on its own is what let
235
+ // "missing base" (a local credential fault) masquerade as a server-side
236
+ // rejection for the whole life of this loop.
237
+ `[goals] ${methodDef("board.createTask") ? "board.createTask" : "board.create"} failed for "${item.title}": ${(frame && frame.error && frame.error.code) || "unknown"}${frame && frame.error && frame.error.message ? ` — ${frame.error.message}` : ""} — the local backlog item still stands`
126
238
  );
127
239
  }
128
240
  return {
@@ -381,7 +493,7 @@ export async function raiseEscalation(o = {}, deps = {}) {
381
493
  if (!ok) {
382
494
  logOf(deps)(
383
495
  "warn",
384
- `[goals] ${method} failed (${(frame && frame.error && frame.error.code) || "unknown"}) — the question is still recorded in the audit ledger`
496
+ `[goals] ${method} failed (${(frame && frame.error && frame.error.code) || "unknown"}${frame && frame.error && frame.error.message ? `: ${frame.error.message}` : ""}) — the question is still recorded in the audit ledger`
385
497
  );
386
498
  }
387
499
  return { ok, reason: ok ? "raised" : (frame && frame.error && frame.error.code) || "failed", id: ok && frame.result ? frame.result.id || null : null, method };
@@ -404,7 +516,9 @@ export async function routeWorkItem(item, classification, deps = {}) {
404
516
 
405
517
  export default {
406
518
  WORK_LEASE_SCOPE,
519
+ BOARD_PRIORITY,
407
520
  workResourceId,
521
+ boardPayloadFor,
408
522
  createWorkItem,
409
523
  routeWorkItem,
410
524
  routeSelf,
@@ -76,10 +76,22 @@ test("every work item carries the full provenance chain onto the board", async (
76
76
  assert.equal(r.ok, true);
77
77
  assert.equal(r.itemId, "bi_1");
78
78
  const payload = calls.board[0];
79
- assert.equal(payload.objectiveKey, "pipeline");
80
- assert.equal(payload.obligationKey, "outcome.pipeline");
79
+ // The payload is shaped for hq's REAL schema (board.createTask): the owner is
80
+ // `assigneeId`, the priority is the P0..P4 enum, and `why` is the CLOSED
81
+ // {reason, options, clause, wouldChange} block. `objectiveKey`/`obligationKey`
82
+ // have no column there, so they ride in `detail` — the trace survives even
83
+ // though the fields do not. (The old spellings were rejected BAD_REQUEST by
84
+ // every hq that ever received them.)
81
85
  assert.equal(payload.why.clause, "cs_12");
82
- assert.equal(payload.assignee, SELF);
86
+ assert.equal(payload.why.reason, "kpi-gap");
87
+ assert.equal(payload.assigneeId, SELF);
88
+ assert.equal(payload.priority, "P1", "the item is high priority → P1, not the word \"high\"");
89
+ assert.equal(payload.col, "todo");
90
+ assert.match(payload.detail, /Advances objective: pipeline/);
91
+ assert.match(payload.detail, /Obligation: outcome\.pipeline/);
92
+ assert.equal(payload.objectiveKey, undefined, "a spelling hq rejects must not be sent");
93
+ assert.equal(payload.assignee, undefined);
94
+ assert.equal(payload.status, undefined);
83
95
  });
84
96
 
85
97
  test("SERVER UNREACHABLE: a thrown board.create is reported, logged, and never throws out", async () => {
@@ -182,7 +194,7 @@ test("HANDOFF assigns the item to the target and sends the envelope with full co
182
194
  const r = await routeHandoff(ITEM, c, deps);
183
195
  assert.equal(r.ok, true);
184
196
  assert.equal(r.primitive, "handoff.offer");
185
- assert.equal(calls.board[0].assignee, PEER, "the board item must be assigned to whoever is doing it");
197
+ assert.equal(calls.board[0].assigneeId, PEER, "the board item must be assigned to whoever is doing it");
186
198
  const env = calls.handoff[0];
187
199
  assert.equal(env.to, PEER);
188
200
  assert.equal(env.from, SELF);