@cohortapp/agent-sdk 2.4.1 → 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 (79) 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/sections/mandate.mjs +43 -1
  57. package/lib/subagents/schema.mjs +14 -2
  58. package/lib/subagents/schema.test.mjs +22 -0
  59. package/package.json +1 -1
  60. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  61. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  62. package/scripts/ci/check.mjs +3 -0
  63. package/scripts/ci/conformance-org-api.mjs +16 -0
  64. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  65. package/scripts/daemon/agent-daemon.mjs +582 -28
  66. package/scripts/daemon/cadence-handlers.mjs +273 -17
  67. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  68. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  69. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  70. package/scripts/daemon/maestro-daemon.mjs +53 -0
  71. package/scripts/daemon/prompt-builder.mjs +47 -0
  72. package/scripts/daemon/responder.mjs +70 -3
  73. package/scripts/poller/imap-client.mjs +20 -1
  74. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  75. package/scripts/poller/utils.mjs +51 -0
  76. package/scripts/setup/generate-capability.mjs +120 -11
  77. package/scripts/setup/generate-capability.test.mjs +134 -0
  78. package/scripts/setup/generate-plan.mjs +6 -1
  79. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -59,6 +59,9 @@ import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock
59
59
  import { markDeferred } from "./inbox-deferral.mjs";
60
60
  import { parseQueueItems, rankBacklog, resolveBacklogWeights } from "../../lib/backlog.mjs";
61
61
  import { readLatestGaps } from "../../lib/goals/gaps.mjs";
62
+ import { RUNGS } from "../../lib/execution/route.mjs";
63
+ /** rung id → the machinery `routeRung` named for it. Built from the one table. */
64
+ const RUNG_MECHANISM = Object.fromEntries(RUNGS.map((r) => [r.id, r.mechanism]));
62
65
  // Org shared-memory write-back (central store via memory.author / knowledge.append
63
66
  // RPC). After a daemon turn completes cleanly we distil a one-line record of the
64
67
  // work and land it in the org's shared, ACL'd store so the fleet's memory
@@ -79,6 +82,20 @@ import { mintTraceId, withTrace } from "../../lib/diagnostics/trace.mjs";
79
82
  import { emitEvent, EVENT_TYPES } from "../../lib/diagnostics/events.mjs";
80
83
  import * as counters from "../../lib/diagnostics/counters.mjs";
81
84
  import { getHookBus } from "../../lib/hooks/bus.mjs";
85
+ // EXECUTION LADDER (lib/execution/**). The reactive front of the chain:
86
+ // intake (what surface is this, and is it mine) → match (which compiled
87
+ // obligation covers it) → route (which rung: protocol call, skill, plugin,
88
+ // Claude session, workflow, sub-agent team) → drive (run the effect) → journal
89
+ // (why, on disk, before the effect runs). Until now nothing in the daemon
90
+ // entered it; processItem went straight from item_received to the Haiku
91
+ // classifier, so every event was implicitly "react now, rung 3" regardless of
92
+ // surface, obligation or budget. It is now the FRONT of processItem: the
93
+ // classifier path below is the mechanism the ladder's `react` effect drives,
94
+ // not a parallel universe.
95
+ import { processOne } from "../../lib/execution/pipeline.mjs";
96
+ import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
97
+ import { loadReactObligations } from "../../lib/execution/match.mjs";
98
+ import { policyFor } from "../../lib/execution/surface-policy.mjs";
82
99
 
83
100
  // ---------------------------------------------------------------------------
84
101
  // Configuration
@@ -250,6 +267,70 @@ export async function processItem(item, service, deps = {}) {
250
267
  }
251
268
 
252
269
  async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
270
+ emitEvent({
271
+ type: EVENT_TYPES.ITEM_RECEIVED,
272
+ trace_id,
273
+ attrs: { item_id: itemId, service, sender: item.sender || null, kind: item.kind || "message" },
274
+ });
275
+
276
+ // Enrich item with channel-type metadata for directed-message detection.
277
+ // HOISTED above the execution ladder: `intake` reads `is_dm` to tell a private
278
+ // room from a space, and a DM that arrived here without it would be classified
279
+ // as an ambient space message and parked. This is the single most
280
+ // regression-prone line in the file.
281
+ enrichItem(item, service);
282
+
283
+ // ── THE EXECUTION LADDER, at the front of the chain ──────────────────────
284
+ // Returns `{ handled }`. `handled:true` means the ladder took responsibility
285
+ // for this item — it ignored it (journalled), queued it, delegated it,
286
+ // escalated it, or drove the classifier path below through its `react` effect.
287
+ // `handled:false` means the ladder could not establish ownership and the
288
+ // legacy classifier path runs exactly as it did before. Never throws.
289
+ const gate = await runExecutionLadder(item, service, itemId, trace_id, deps);
290
+ if (gate.handled) return;
291
+
292
+ return answerItem(item, service, itemId, trace_id, deps, gate.decision);
293
+ }
294
+
295
+ /**
296
+ * Attach the channel-type metadata every downstream directedness check reads.
297
+ * Extracted so the execution ladder and the classifier path cannot disagree
298
+ * about what `is_dm` means for the same item.
299
+ *
300
+ * @param {object} item
301
+ * @param {string} service
302
+ * @returns {object} the same item, mutated
303
+ */
304
+ export function enrichItem(item, service) {
305
+ const channelStr = (item.channel || "").toLowerCase();
306
+ const channelId = item.channel_id || "";
307
+ const isDm = channelStr.startsWith("dm/") || channelId.startsWith("D");
308
+ const myFirstName = loadAgent().firstName || "Agent";
309
+ const agentThreadRegex = new RegExp(`^${myFirstName}:`, "m");
310
+ const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
311
+ item.is_dm = isDm;
312
+ item.agent_in_thread = agentInThread;
313
+ if (!item.service) item.service = service;
314
+ return item;
315
+ }
316
+
317
+ /**
318
+ * The classifier → quick-reply-or-session path. This is UNCHANGED behaviour:
319
+ * it is the body `processItem` used to run inline. It is now a named mechanism
320
+ * so the execution ladder's `react_now` disposition can drive it as an effect,
321
+ * and so an item the ladder cannot place still gets exactly today's handling.
322
+ *
323
+ * @param {object} item
324
+ * @param {string} service
325
+ * @param {string} itemId
326
+ * @param {string} trace_id
327
+ * @param {object} deps
328
+ * @param {object|null} [routed] the ladder's decision, when there was one. Its
329
+ * `rung` is honoured: rung 0/1 (protocol call / bounded skill) may answer via
330
+ * the in-process quick-reply responder; rung ≥2 requires a spawned session.
331
+ * @returns {Promise<{ok:boolean, path:string, reason?:string}>}
332
+ */
333
+ export async function answerItem(item, service, itemId, trace_id, deps = {}, routed = null) {
253
334
  // Resolve collaborators: real imports by default (production), injected fakes
254
335
  // only when a test supplies them. Names match the imported symbols 1:1.
255
336
  const _classify = deps.classify || classifyItem;
@@ -258,22 +339,13 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
258
339
  const _sendHoldingMessage = deps.sendHoldingMessage || sendHoldingMessage;
259
340
  const _isQuickReply = deps.isQuickReply || isQuickReply;
260
341
 
261
- emitEvent({
262
- type: EVENT_TYPES.ITEM_RECEIVED,
263
- trace_id,
264
- attrs: { item_id: itemId, service, sender: item.sender || null, kind: item.kind || "message" },
265
- });
266
-
267
342
  try {
268
- // Enrich item with channel-type metadata for directed-message detection
269
- const channelStr = (item.channel || "").toLowerCase();
270
- const channelId = item.channel_id || "";
271
- const isDm = channelStr.startsWith("dm/") || channelId.startsWith("D");
272
- const myFirstName = loadAgent().firstName || "Agent";
273
- const agentThreadRegex = new RegExp(`^${myFirstName}:`, "m");
274
- const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
275
- item.is_dm = isDm;
276
- item.agent_in_thread = agentInThread;
343
+ const isDm = item.is_dm === true;
344
+ // Carry the routing decision on the item so everything downstream of here —
345
+ // the prompt builder, the spawned session, the responder's log rows — can
346
+ // see which obligation and which rung this turn is serving. Without it the
347
+ // session has no idea it is discharging a planned obligation.
348
+ if (routed) item.execution = routed;
277
349
 
278
350
  // Classify via Haiku API
279
351
  const classResult = await _classify({
@@ -287,7 +359,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
287
359
  subject: item.subject || "",
288
360
  is_dm: isDm,
289
361
  is_group: !isDm && service === "slack",
290
- agent_in_thread: agentInThread,
362
+ agent_in_thread: item.agent_in_thread === true,
291
363
  kind: item.kind || "message",
292
364
  });
293
365
 
@@ -305,7 +377,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
305
377
  // Skip ignored items
306
378
  if (classResult.category === "ignore" || classResult.action === "ignore") {
307
379
  markProcessed(item, service);
308
- return;
380
+ return { ok: true, path: "ignored", reason: "classifier_ignore" };
309
381
  }
310
382
 
311
383
  // DIRECTED-MESSAGE GATE: In channels and group chats, only respond to
@@ -316,8 +388,6 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
316
388
  // 1. If LLM says NOT directed → verify with rules (catch missed @mentions, CEO, DMs)
317
389
  // 2. If LLM says directed BUT it's a non-DM channel → verify with rules (catch over-eager LLM)
318
390
  if (service === "slack") {
319
- const isDm = item.is_dm || (item.channel || "").startsWith("dm/") || (item.channel_id || "").startsWith("D");
320
-
321
391
  if (!classResult.directed_at_agent) {
322
392
  // LLM says not directed — double-check with rule-based heuristics
323
393
  // to catch clear signals the LLM may have missed (DM, CEO, @mention)
@@ -335,7 +405,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
335
405
  summary: classResult.summary,
336
406
  });
337
407
  markProcessed(item, service);
338
- return;
408
+ return { ok: true, path: "filtered", reason: "not_directed_at_agent" };
339
409
  }
340
410
  // Rule-based says directed — override LLM
341
411
  console.log(`[daemon] Directed-message override: LLM said not directed but rule-based detected direction (${item.sender} in ${item.channel})`);
@@ -364,7 +434,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
364
434
  summary: classResult.summary,
365
435
  });
366
436
  markProcessed(item, service);
367
- return;
437
+ return { ok: true, path: "filtered", reason: "llm_over_eager_directed" };
368
438
  }
369
439
  }
370
440
  }
@@ -401,7 +471,7 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
401
471
  deferred: deferred > 0,
402
472
  reason: `thread_dedup: ${threadCheck.reason}`,
403
473
  });
404
- return;
474
+ return { ok: true, path: "deferred", reason: `thread_dedup: ${threadCheck.reason}` };
405
475
  }
406
476
  }
407
477
  }
@@ -428,18 +498,27 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
428
498
  summary: classResult.summary,
429
499
  });
430
500
  markProcessed(item, service);
431
- return;
501
+ return { ok: true, path: "claim_denied", reason: claim.reason };
432
502
  }
433
503
  }
434
504
 
435
505
  // QUICK REPLY PATH: Direct responses via API + Slack/Gmail posting.
436
506
  // No claude --print session needed. ~4-8 seconds total.
437
- if (_isQuickReply(classResult)) {
507
+ //
508
+ // The ladder's RUNG is a veto here, never a promotion. `isQuickReply` is a
509
+ // judgement about the classifier's output; the rung is a judgement about the
510
+ // work the matched obligation actually needs. Rung 0/1 (a single protocol
511
+ // call, or a bounded skill) is what the in-process responder IS. Rung ≥2
512
+ // (plugin / session / workflow / team) means the answer needs machinery this
513
+ // path does not have, so a quick reply would be a wrong answer delivered
514
+ // fast. With no routing decision (an item the ladder could not place) this
515
+ // is exactly today's behaviour.
516
+ if (_isQuickReply(classResult, routed)) {
438
517
  console.log(`[daemon] Quick reply path for ${item.sender} (${classResult.model})`);
439
- const result = await _sendQuickResponse(item, classResult);
518
+ const result = await _sendQuickResponse(item, classResult, routed);
440
519
  if (result.sent) {
441
520
  markProcessed(item, service);
442
- return;
521
+ return { ok: true, path: "quick_reply", reason: classResult.model };
443
522
  }
444
523
  // If quick reply failed to send or was blocked by validation, fall through to dispatch a full session
445
524
  const reason = result.blocked ? `validation blocked: ${result.issues?.map(i => i.rule).join(", ")}` : "send failed";
@@ -509,11 +588,450 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
509
588
  },
510
589
  });
511
590
  recordSession(false); // spawned
591
+ return { ok: true, path: "session", reason: classResult.action };
512
592
 
513
593
  } catch (err) {
514
594
  console.error(`[daemon] Failed to process item ${itemId}:`, err.message);
515
595
  recordClassification(false);
516
596
  emitEvent({ type: EVENT_TYPES.ERROR, trace_id, attrs: { item_id: itemId, service, stage: "process_item", error: err.message } });
597
+ return { ok: false, path: "error", reason: err.message };
598
+ }
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // THE EXECUTION LADDER — intake → match → route → drive → journal
603
+ // ---------------------------------------------------------------------------
604
+ //
605
+ // Every inbound item now enters here BEFORE the classifier. What that buys, in
606
+ // order of how much it was costing us to not have it:
607
+ //
608
+ // * An `ignore` is a decision with a written reason, not an absence. Ambient
609
+ // channel chatter is dropped without a Haiku call and WITH a journal row
610
+ // saying why. An agent that answers everything is as broken as one that
611
+ // answers nothing, and until now nothing recorded the difference.
612
+ // * A board task assigned to this seat is WORK, not conversation. The surface
613
+ // policy says `latency: queue`, so it lands in state/queues/inbound.yaml —
614
+ // which the backlog sweep below already reads — instead of provoking an
615
+ // immediate reply into a comment thread nobody is watching.
616
+ // * A reply that is genuinely owed is routed to a RUNG before it is answered,
617
+ // so a single protocol call is not billed as a Claude session.
618
+ // * Ping-pong, dedupe and per-actor flood guards run off the journal, so two
619
+ // agents cannot spiral and one loud actor cannot monopolise the seat.
620
+ //
621
+ // FAIL-OPEN, NEVER SILENT. Any failure inside this function returns
622
+ // `{handled:false}` and the item takes exactly the path it took before the
623
+ // ladder existed. Every such fall-through logs the reason.
624
+
625
+ /**
626
+ * Escape hatch. Read per call, not at module load, so an operator can turn the
627
+ * ladder off on a wedged machine without a restart. Taking it logs, loudly,
628
+ * exactly what is being turned off.
629
+ */
630
+ function ladderEnabled() {
631
+ return process.env.DAEMON_EXECUTION_LADDER !== "0";
632
+ }
633
+ let _ladderDisabledWarned = false;
634
+
635
+ /** REACT obligations from config/plan.yaml, re-read at most every 60s. */
636
+ let _obligationsCache = null;
637
+ const OBLIGATIONS_TTL_MS = 60_000;
638
+
639
+ async function ladderObligations(log) {
640
+ const now = Date.now();
641
+ if (_obligationsCache && now - _obligationsCache.at < OBLIGATIONS_TTL_MS) {
642
+ return _obligationsCache.obligations;
643
+ }
644
+ const loaded = await loadReactObligations(AGENT_REPO_DIR);
645
+ if (loaded.degraded) {
646
+ // Not fatal — an un-adopted agent legitimately has no plan — but it pins the
647
+ // whole reactive lane at rung ≤1, so it must be visible.
648
+ log("warn", `[execution] no compiled REACT obligations (${loaded.reason}) — every event routes at rung ≤1`);
649
+ }
650
+ _obligationsCache = { at: now, obligations: loaded.obligations || [] };
651
+ return _obligationsCache.obligations;
652
+ }
653
+
654
+ /** For tests: drop the cached plan so a fresh config/plan.yaml is re-read. */
655
+ export function _resetLadderCaches() {
656
+ _obligationsCache = null;
657
+ _ladderDisabledWarned = false;
658
+ }
659
+
660
+ /** The seat's org member id — the `me` every directedness join is relative to. */
661
+ function ladderMemberId() {
662
+ const cfg = daemonOrgCfg();
663
+ return (
664
+ process.env.COHORT_AGENT_ID ||
665
+ (cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
666
+ ""
667
+ );
668
+ }
669
+
670
+ /**
671
+ * The routing NEED estimate for an inbound item, from what is knowable BEFORE
672
+ * the classifier runs.
673
+ *
674
+ * This matters more than it looks. `baseRung` demands `paramsKnown === true` for
675
+ * rung 0, and with no need at all every event lands on "no lower predicate held
676
+ * → bounded session" — rung 3, for a two-line DM. The first live run of this
677
+ * wiring did exactly that: it routed every DM to rung 3, which vetoed the quick
678
+ * reply, which turned a 4-second answer into a spawned Claude session. That is a
679
+ * regression dressed as a routing decision.
680
+ *
681
+ * What the daemon honestly knows pre-classification is the ADDRESSING: for a
682
+ * conversational surface it already has the channel and the thread, so the reply
683
+ * really is one protocol method whose parameters are in hand — the only unknown
684
+ * is the text, and generating the text is what the mechanism at rung 0/1 does.
685
+ * When the target CANNOT be resolved, `paramsKnown` is false and the ladder
686
+ * correctly walks up to a session that can go and find it.
687
+ *
688
+ * Everything else — is this open-ended research, how many steps — is the
689
+ * classifier's judgement, which runs after this and is still consulted by
690
+ * `isQuickReply`. So this estimate is a floor, never a ceiling: the obligation's
691
+ * own declared need and the failure walk-up both raise it from here.
692
+ *
693
+ * @param {object} item
694
+ * @returns {object} a `lib/execution/route.mjs` Need
695
+ */
696
+ export function ladderNeed(item) {
697
+ const target = item.channel_id || item.thread_id || item.raw_ref || item.channel || null;
698
+ return {
699
+ paramsKnown: !!target,
700
+ steps: 1,
701
+ filesystem: false,
702
+ openEnded: false,
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Ignore reasons where the ladder is saying "this is not addressed to me".
708
+ *
709
+ * ONLY these consult the rule-based `isDirectedAtAgent` override. The other
710
+ * ignore reasons — `duplicate`, `own_echo`, `reply_chain_depth`, `actor_flood`,
711
+ * `emergency_stop`, `halt`, `mandate_stale`, `paused` — are GUARDS, deliberate
712
+ * decisions taken about an event the ladder understood perfectly well. Letting
713
+ * the rule check override those would defeat every one of them: a DM is always
714
+ * "directed", so `duplicate` would never hold and the agent would answer the
715
+ * same message on both the push and the poll delivery. The first live run of
716
+ * this wiring did precisely that.
717
+ */
718
+ const DIRECTEDNESS_IGNORE_REASONS = new Set(["ambient_channel", "not_directed"]);
719
+
720
+ /**
721
+ * Ignore reasons meaning the ladder could not UNDERSTAND the item at all. These
722
+ * always fall through to the classifier path — the ladder has no opinion to
723
+ * enforce, and dropping the item on the strength of its own confusion is how a
724
+ * message gets lost.
725
+ */
726
+ const UNINTERPRETABLE_IGNORE_REASONS = new Set([
727
+ "not_classified",
728
+ "no_verdict",
729
+ "unknown_surface",
730
+ "unrecognised_shape",
731
+ "intake_error",
732
+ "not_an_object",
733
+ ]);
734
+
735
+ /** Cooperative kill switches the disposition layer reads. */
736
+ function ladderRuntime() {
737
+ const runtime = {};
738
+ try {
739
+ if (_existsSync(join(AGENT_REPO_DIR, ".emergency-stop")) || _existsSync(join(AGENT_REPO_DIR, "state", "EMERGENCY_STOP"))) {
740
+ runtime.emergencyStop = true;
741
+ }
742
+ } catch (err) {
743
+ console.warn(`[execution] could not read the emergency-stop marker (${err.message}) — assuming not stopped`);
744
+ }
745
+ return runtime;
746
+ }
747
+
748
+ /**
749
+ * Thread arbitration for the ladder.
750
+ *
751
+ * The org lease (`preSendArbitration`) is the right answer for a Cohort space,
752
+ * where several agent seats really can see the same thread. It is the WRONG
753
+ * answer for Slack/Gmail, where this daemon is the only agent on the account and
754
+ * the real duplicate-answer risk is two of its own sessions — which the existing
755
+ * `acquireThreadLock` already handles inside `answerItem`. Taking the org lease
756
+ * here for a Slack thread would also double-lock: the ladder would win it and
757
+ * `answerItem` would then defer its own item.
758
+ *
759
+ * So: org surfaces arbitrate for real, channel surfaces say so out loud and
760
+ * leave it to the lock that already works.
761
+ */
762
+ async function ladderArbitrate({ decision, candidate, resource }) {
763
+ const family = String((candidate && candidate.family) || "");
764
+ const orgFamilies = ["messaging", "board", "calling", "files", "calendar", "approval", "decision", "escalation", "handoff", "email"];
765
+ if (!orgFamilies.includes(family)) {
766
+ console.log(
767
+ `[execution] ${family || "channel"} thread ${decision.thread} is arbitrated by the daemon's own thread lock, not an org lease`,
768
+ );
769
+ return { ok: true, ref: { mayReply: true, by: "daemon_thread_lock" }, error: null };
770
+ }
771
+ const { preSendArbitration } = await import("../../lib/org/leases.mjs");
772
+ const ids = (candidate && candidate.ids) || {};
773
+ const r = await preSendArbitration({
774
+ platform: "cohort",
775
+ channel: ids.channelId || resource || decision.thread || "",
776
+ thread: ids.threadRootId || ids.threadId || ids.messageId || "",
777
+ agentRoot: AGENT_REPO_DIR,
778
+ });
779
+ return { ok: true, ref: r, error: null };
780
+ }
781
+
782
+ /**
783
+ * The blast-radius gate, with the seat's own self-approval authority applied.
784
+ *
785
+ * `email` and `calendar` carry the `external` action class, so §8 wants an
786
+ * approval card before either runs. Both surfaces ALSO carry `selfApprove:true`
787
+ * — the surface policy's own statement that this seat may be the terminal
788
+ * authority on them. Routing every outbound email through a human card by
789
+ * default would take an agent that has been answering mail unsupervised for
790
+ * months and silently park its replies behind an approval nobody is watching for
791
+ * — the exact "responds to nothing" failure, arrived at through a safety
792
+ * feature.
793
+ *
794
+ * So: a self-approvable surface is self-approved, LOUDLY, naming the classes.
795
+ * A surface the policy says the seat may NOT self-approve (`selfApprove:false`)
796
+ * never reaches here — `decide` escalates it at step 6 instead. Set
797
+ * `DAEMON_LADDER_APPROVALS=1` to route self-approvable surfaces through the real
798
+ * org approval flow as well.
799
+ */
800
+ async function ladderRequestApproval(args) {
801
+ const { decision, classes } = args;
802
+ const policy = policyFor(decision && decision.surface);
803
+ const human = process.env.DAEMON_LADDER_APPROVALS === "1";
804
+ if (!human && policy.selfApprove === true) {
805
+ console.warn(
806
+ `[execution] ${decision.surface} action is classed ${(classes || []).join("+")} (approval-gated by §8), but the ` +
807
+ `${decision.surface} surface policy grants this seat self-approval → proceeding without a human card. ` +
808
+ "Set DAEMON_LADDER_APPROVALS=1 to require one.",
809
+ );
810
+ counters.bump("execution.self_approved", { surface: decision.surface, classes: (classes || []).join("+") });
811
+ return { ok: true, ref: { granted: true, selfApproved: true, classes }, error: null };
812
+ }
813
+ const real = defaultEffects({ agentRoot: AGENT_REPO_DIR, me: ladderMemberId() }).requestApproval;
814
+ return real(args);
815
+ }
816
+
817
+ /**
818
+ * The `schedule` effect, with ONE carve-out this daemon has to make honestly.
819
+ *
820
+ * A queue row in `state/queues/inbound.yaml` is drained by `sweepBacklog`, which
821
+ * dispatches a session from the row's `next_action` text. That is the right
822
+ * shape for WORK — a task assigned, a call invite, an RSVP, a budget-exhausted
823
+ * deferral: the row says what to do and nothing was lost. It is the WRONG shape
824
+ * for a conversation somebody is waiting on, because the row carries no thread
825
+ * context, no sender and no message body. Batching an email or a board comment
826
+ * through it would answer later AND worse.
827
+ *
828
+ * So `batch_surface` — the only schedule reason that fires on a surface the
829
+ * daemon can already answer live — does not queue. It hands the item back to the
830
+ * classifier path, and says so in the journal (`queued:false`) rather than
831
+ * writing a row that would look drained and never be.
832
+ *
833
+ * THIS IS A SEVERED HOP, NAMED: when the backlog drain learns to re-hydrate the
834
+ * originating inbox item, this carve-out should be deleted and `email` /
835
+ * `task_comment` should batch as their surface policy says they should.
836
+ */
837
+ async function ladderSchedule({ decision, candidate, item }) {
838
+ if (decision && decision.reason === "batch_surface") {
839
+ console.warn(
840
+ `[execution] ${decision.surface} is a batch surface, but the backlog drain cannot re-hydrate the ` +
841
+ "originating item (no thread context in a queue row) — answering it live instead of queueing. " +
842
+ "Decision journalled; the batching is advisory until the drain carries the item.",
843
+ );
844
+ return { ok: true, ref: { queued: false, handedTo: "classifier_path", surface: decision.surface }, error: null };
845
+ }
846
+ // The candidate carries the entity id and the item carries the subject/body.
847
+ // Without both the row the sweep drains names no thing to act on — which is
848
+ // exactly how a real calendar invite reached a queue row that no session
849
+ // could RSVP. See `scheduleToQueue`.
850
+ return scheduleToQueue(decision, { agentRoot: AGENT_REPO_DIR, candidate, item });
851
+ }
852
+
853
+ /**
854
+ * Run one inbound item through the execution ladder.
855
+ *
856
+ * @param {object} item the poller inbox item, already enriched
857
+ * @param {string} service
858
+ * @param {string} itemId
859
+ * @param {string} trace_id
860
+ * @param {object} [deps] test seams; `deps.processOne` / `deps.answerItem`
861
+ * replace the real pipeline / the real classifier path.
862
+ * @returns {Promise<{handled:boolean, decision:object|null, reason:string, answered:object|null}>}
863
+ */
864
+ export async function runExecutionLadder(item, service, itemId, trace_id, deps = {}) {
865
+ const _answer = deps.answerItem || answerItem;
866
+ const _processOne = deps.processOne || processOne;
867
+ const _ruleDirected = deps.isDirectedAtAgent || isDirectedAtAgent;
868
+
869
+ if (!ladderEnabled()) {
870
+ if (!_ladderDisabledWarned) {
871
+ _ladderDisabledWarned = true;
872
+ console.warn(
873
+ "[execution] DAEMON_EXECUTION_LADDER=0 — intake/match/route/journal are OFF. " +
874
+ "Every item goes straight to the classifier: no directedness journal, no obligation binding, " +
875
+ "no rung, no queue for board work, and no record of what was ignored or why.",
876
+ );
877
+ }
878
+ return { handled: false, decision: null, reason: "ladder_disabled", answered: null };
879
+ }
880
+
881
+ const log = (level, msg) => {
882
+ if (level === "error") console.error(`[execution] ${msg}`);
883
+ else if (level === "warn") console.warn(`[execution] ${msg}`);
884
+ else if (level === "debug") { /* per-rule match trace — logEvent only */ }
885
+ else console.log(`[execution] ${msg}`);
886
+ };
887
+
888
+ let answered = null;
889
+ try {
890
+ const me = ladderMemberId();
891
+ const obligations = await ladderObligations(log);
892
+
893
+ // `react_now` means: answer it, now, with the machinery the rung names. The
894
+ // daemon's classifier → quick-reply/session path IS that machinery, so the
895
+ // effect drives it rather than duplicating it. This is the hop that was
896
+ // missing: `defaultEffects` documents `react` as caller-supplied and every
897
+ // previous caller supplied nothing, so react_now was decided and dropped.
898
+ const react = async ({ decision }) => {
899
+ answered = await _answer(item, service, itemId, trace_id, deps, decision);
900
+ return {
901
+ ok: answered && answered.ok !== false,
902
+ ref: {
903
+ rung: decision.rung,
904
+ mechanism: decision.mechanism,
905
+ path: answered ? answered.path : null,
906
+ reason: answered ? answered.reason : null,
907
+ },
908
+ error: answered && answered.ok === false ? answered.reason || "answer failed" : null,
909
+ };
910
+ };
911
+
912
+ const effects = {
913
+ ...defaultEffects({ agentRoot: AGENT_REPO_DIR, me, react }),
914
+ arbitrate: ladderArbitrate,
915
+ schedule: ladderSchedule,
916
+ requestApproval: ladderRequestApproval,
917
+ };
918
+
919
+ const res = await _processOne({
920
+ item,
921
+ me,
922
+ agentRoot: AGENT_REPO_DIR,
923
+ runtime: ladderRuntime(),
924
+ need: ladderNeed(item),
925
+ obligations,
926
+ effects,
927
+ traceId: trace_id,
928
+ log,
929
+ });
930
+
931
+ const d = res.decision || null;
932
+ if (!d) {
933
+ console.warn(`[execution] ${itemId} produced no decision — falling through to the classifier path`);
934
+ return { handled: false, decision: null, reason: "no_decision", answered: null };
935
+ }
936
+
937
+ logEvent("execution", {
938
+ item_id: itemId,
939
+ service,
940
+ trace_id,
941
+ origin: res.origin,
942
+ surface: d.surface,
943
+ tier: d.tier,
944
+ disposition: d.disposition,
945
+ reason: d.reason,
946
+ rung: d.rung,
947
+ mechanism: d.mechanism,
948
+ obligation_key: res.obligationKey,
949
+ proposed: res.proposed,
950
+ effect: res.effect,
951
+ ok: res.ok,
952
+ degraded: d.degraded,
953
+ why: d.why,
954
+ });
955
+ console.log(
956
+ `[execution] ${itemId}: ${res.origin} → ${d.surface || "?"} → ${d.disposition}` +
957
+ (Number.isFinite(d.rung) ? ` @rung ${d.rung} (${d.mechanism})` : "") +
958
+ ` — ${d.reason}` +
959
+ (res.obligationKey ? ` [${res.obligationKey}]` : " [uncovered]"),
960
+ );
961
+
962
+ // ── ignore: a first-class, journalled outcome ─────────────────────────
963
+ if (d.disposition === "ignore") {
964
+ // The ladder did not understand the item. It has no opinion worth
965
+ // enforcing, so the classifier path decides — exactly as before.
966
+ if (UNINTERPRETABLE_IGNORE_REASONS.has(d.reason)) {
967
+ console.warn(
968
+ `[execution] ${itemId} could not be placed by intake (${d.reason}) — no ladder opinion, ` +
969
+ "falling through to the classifier path",
970
+ );
971
+ counters.bump("execution.uninterpretable", { item_id: itemId, reason: d.reason });
972
+ return { handled: false, decision: d, reason: `uninterpretable:${d.reason}`, answered: null };
973
+ }
974
+ // The safety interlock, scoped to the ONE question it is competent to
975
+ // answer: "is this addressed to me?". `isDirectedAtAgent` is the
976
+ // rule-based check the classifier path has always applied, and it knows
977
+ // things intake does not — the CEO privilege flag, the agent's own display
978
+ // name in free text, "this arrived in my mailbox". Where the two disagree
979
+ // about ADDRESSING we do not gag the agent; we fall through, loudly.
980
+ //
981
+ // It is deliberately NOT consulted for the guard reasons. See
982
+ // DIRECTEDNESS_IGNORE_REASONS.
983
+ if (DIRECTEDNESS_IGNORE_REASONS.has(d.reason) && _ruleDirected(item)) {
984
+ console.warn(
985
+ `[execution] directedness disagreement on ${itemId}: ladder says ignore (${d.reason}), ` +
986
+ "rule-based check says this IS directed at me → falling through to the classifier path",
987
+ );
988
+ counters.bump("execution.directedness_disagreement", { item_id: itemId, reason: d.reason });
989
+ return { handled: false, decision: d, reason: "rule_override", answered: null };
990
+ }
991
+ markProcessed(item, service);
992
+ return { handled: true, decision: d, reason: `ignore:${d.reason}`, answered: null };
993
+ }
994
+
995
+ // ── react_now: the classifier path already ran inside the effect ──────
996
+ if (answered) {
997
+ return { handled: true, decision: d, reason: `answered:${answered.path}`, answered };
998
+ }
999
+
1000
+ // ── a batch surface the drain cannot re-hydrate: answer it live ──────
1001
+ if (d.disposition === "schedule" && res.ref && res.ref.handedTo === "classifier_path") {
1002
+ return { handled: false, decision: d, reason: "batch_surface_handed_back", answered: null };
1003
+ }
1004
+
1005
+ // ── the effect ran and did NOT reach the classifier path ─────────────
1006
+ // schedule (queued to state/queues/inbound.yaml, which sweepBacklog reads),
1007
+ // delegate (handoff), escalate — or a react_now that a gate stood down
1008
+ // (approval pending, another agent holds the thread). All are complete
1009
+ // decisions with a journal row. The item is done for this pass.
1010
+ if (res.ok !== false) {
1011
+ markProcessed(item, service);
1012
+ return { handled: true, decision: d, reason: `${d.disposition}:${res.effect || "none"}`, answered: null };
1013
+ }
1014
+
1015
+ // The effect FAILED. Do not silently drop a directed event because a queue
1016
+ // write or a handoff RPC broke — fall through so the classifier path still
1017
+ // gives the sender an answer.
1018
+ console.error(
1019
+ `[execution] ${d.disposition} effect failed for ${itemId} (${res.error || "unknown"}) — ` +
1020
+ "falling through to the classifier path so the item is not lost",
1021
+ );
1022
+ return { handled: false, decision: d, reason: `effect_failed:${res.error || "unknown"}`, answered: null };
1023
+ } catch (err) {
1024
+ // A bug in the ladder must never cost the user their message.
1025
+ console.error(
1026
+ `[execution] ladder threw on ${itemId} (${err && err.message ? err.message : err}) — ` +
1027
+ "falling through to the classifier path",
1028
+ );
1029
+ emitEvent({
1030
+ type: EVENT_TYPES.ERROR,
1031
+ trace_id,
1032
+ attrs: { item_id: itemId, service, stage: "execution_ladder", error: err && err.message ? err.message : String(err) },
1033
+ });
1034
+ return { handled: false, decision: null, reason: "ladder_threw", answered: null };
517
1035
  }
518
1036
  }
519
1037
 
@@ -897,14 +1415,50 @@ async function sweepBacklog() {
897
1415
  let dispatched = 0;
898
1416
 
899
1417
  for (const queueItem of toDispatch) {
1418
+ // ── THE RUNG REACHES THE EXECUTOR ────────────────────────────────────
1419
+ // The goal steward routes every self-directed item through
1420
+ // `lib/execution/route.routeRung`, audits the decision, and writes
1421
+ // `rung:` into `state/queues/goals.yaml`. Nothing downstream read it: the
1422
+ // sweep sized every session off `priority` alone, so a rung-5
1423
+ // subagent-fanout and a rung-0 single tool call got the identical plain
1424
+ // Sonnet turn. The router's cost/blast-radius decision died on disk.
1425
+ //
1426
+ // What the drain can honour today is the SIZE of the turn and the framing
1427
+ // the session sees. What it cannot honour is a mechanism that is not "spawn
1428
+ // a session" — that is named below rather than silently ignored.
1429
+ const rung = Number.isFinite(queueItem.rung) ? queueItem.rung : null;
1430
+ const mechanism = rung != null ? RUNG_MECHANISM[rung] || null : null;
1431
+ if (mechanism) queueItem.mechanism = mechanism; // read by buildBacklogContext
900
1432
  const classResult = {
901
1433
  priority: queueItem.priority,
902
- action: "queue",
903
- model: queueItem.priority === "critical" ? "opus" : "sonnet",
1434
+ // `execute`, NOT `queue`. This row IS the queue; the sweep is what
1435
+ // drains it. `action:"queue"` sent every drained session the inbox-turn
1436
+ // instruction "this item needs tracking but not immediate action" —
1437
+ // so the drain politely re-filed its own backlog forever. See
1438
+ // ACTION_INSTRUCTIONS.execute in prompt-builder.mjs.
1439
+ action: "execute",
1440
+ // Rung 4/5 (workflow-runner, subagent-fanout) are the open-ended, many-step
1441
+ // rungs — those get the stronger model regardless of the declared priority.
1442
+ model: queueItem.priority === "critical" || (rung != null && rung >= 4) ? "opus" : "sonnet",
904
1443
  summary: queueItem.title,
905
1444
  category: "action_required",
1445
+ rung,
1446
+ mechanism,
906
1447
  };
907
1448
 
1449
+ if (rung != null && rung <= 1) {
1450
+ // SEVERED HOP, NAMED: rungs 0-1 mean "one method call / one skill, params
1451
+ // known" — seconds of work. The backlog drain has exactly one mechanism
1452
+ // (spawn a session), so it overpays for these. Nothing is lost or wrong;
1453
+ // it is slower and dearer than the router asked for, and an operator has
1454
+ // to be able to see that rather than infer it from a bill.
1455
+ console.warn(
1456
+ `[daemon] Backlog "${queueItem.title}" routed to rung ${rung} (${mechanism}), but the backlog drain ` +
1457
+ "can only spawn a session — running it as a session anyway. The rung is honoured for framing and " +
1458
+ "model choice only.",
1459
+ );
1460
+ }
1461
+
908
1462
  const prompt = await buildPrompt(null, classResult, {
909
1463
  type: "backlog",
910
1464
  queueItem,