@cohortapp/agent-sdk 2.11.1 → 2.11.3

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.
@@ -38,7 +38,11 @@ skills:
38
38
  workflows:
39
39
  - { id: weekly-operating-review, name: "Weekly operating review", cadence: weekly, purpose: "Run the leadership operating cadence: surface on-track/at-risk OKRs, blockers and decisions needed, then publish the review" }
40
40
  - { id: commitment-sweep, name: "Commitment closure sweep", cadence: daily, purpose: "Sweep open action items and commitments, chase ageing/overdue owners, and keep the register clean" }
41
- - { id: inbox-to-outcome, name: "Inbox-to-outcome triage", cadence: continuous, purpose: "Convert the principal's inbox and inbound channels into routed actions, decisions and tracked commitments" }
41
+ # inbox-to-outcome (cadence: continuous) removed 2026-08-25: it generated a
42
+ # continuous cadence + plist expecting schedules/triggers/inbox-to-outcome.md,
43
+ # which does not exist, so it span skipped_circuit_open forever and padded the
44
+ # queue. Real inbox triage is already covered by the STANDARD_CADENCES
45
+ # inbox-processor + messaging-inbound (both have prompts). No replacement needed.
42
46
  - { id: board-cadence, name: "Board and investor cadence", cadence: quarterly, purpose: "Drive the board meeting cycle end to end — pre-reads, pack, decisions-sought and post-meeting action closure" }
43
47
  - { id: quarterly-planning, name: "Quarterly planning and OKRs", cadence: quarterly, purpose: "Open and run the planning cycle: refresh strategy, set objectives, cascade owners and lock roadmap traceability" }
44
48
  - { id: leadership-meeting-cycle, name: "Leadership meeting cycle", cadence: weekly, purpose: "Architect, pre-read, capture and follow up the recurring leadership-team and exec-staff meetings" }
@@ -344,6 +344,15 @@ export function compilePlan(input = {}) {
344
344
  for (const c of archetypeCadences) {
345
345
  if (!c || !c.id) continue;
346
346
  if (c.scope === "standard") continue; // already emitted verbatim above
347
+ // A `cadence: continuous` capability (inbox triage, SLA/obligation watch) is
348
+ // a standing REAL-TIME responsibility, NOT a launchd tick. It ships no
349
+ // schedules/triggers/<id>.md, so compiling it to a SCHEDULE fires every
350
+ // interval, finds no prompt, and spins `skipped_circuit_open` forever,
351
+ // padding the cadence-bus queue on every seat. Do NOT schedule it; the
352
+ // daemon's real-time loop owns the capability. The legacy producer
353
+ // (scripts/setup/generate-cadences.mjs) applies the SAME skip, so the two
354
+ // stay byte-identical (mandate.test.mjs COMPATIBILITY).
355
+ if (c.cadence === "continuous") continue;
347
356
  const key = `schedule.${c.id}`;
348
357
  if (obligations.some((o) => o.key === key)) continue;
349
358
  const uses = resolveUses(usesForArchetypeCadence(c, reachable), key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.1",
3
+ "version": "2.11.3",
4
4
  "description": "Cohort Agent SDK — 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": {
@@ -190,7 +190,8 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
190
190
  const ack = transcript.find((m) => m.kind === "ack");
191
191
  assert.ok(ack, "the acknowledgement must go out");
192
192
  assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms — the old path budgeted 60,000ms and missed 2 times in 3`);
193
- assert.match(ack.text, /either way/i);
193
+ assert.match(ack.text, /on it|looking now|digging in/i);
194
+ assert.ok(ack.text.length <= 40, "the ack is one short human line, not a templated paragraph");
194
195
 
195
196
  const key = assurance.openObligations()[0].key;
196
197
  note("obligation opened and acknowledged; session running");
@@ -248,7 +249,7 @@ test("SCENARIO 3 — a timing-out session tells the human what happened and that
248
249
  const failure = transcript.filter((m) => m.kind === "failure").at(-1);
249
250
  assert.ok(failure, "the human MUST be told; the old code emitted telemetry and nothing else");
250
251
  assert.match(failure.text, /ran past its time limit/i);
251
- assert.match(failure.text, /retrying it now/i);
252
+ assert.match(failure.text, /retrying now/i);
252
253
  assert.equal(assurance.readObligation(key).state, "open", "still owed until the retry resolves");
253
254
  note("item left un-processed for redelivery; the debt stays open so the retry is covered too", virtual.v);
254
255
  });
@@ -278,7 +279,7 @@ test("SCENARIO 4 — a crashing session tells the human it failed, stops, and es
278
279
 
279
280
  assert.equal(r.willRetry, false, "a spawn failure is permanent — retrying burns another 45 minutes for the same outcome");
280
281
  const failure = transcript.filter((m) => m.kind === "failure").at(-1);
281
- assert.match(failure.text, /couldn't get this done/i);
282
+ assert.match(failure.text, /couldn't finish this/i);
282
283
  assert.match(failure.text, /stopped retrying/i);
283
284
  assert.match(failure.text, /flagged it so it isn't lost/i);
284
285
  assert.match(failure.text, /\?\s*$/, "a dead end ends in a question, so the human has a move");
@@ -311,16 +311,13 @@ function variant(seed, n) {
311
311
  /**
312
312
  * Trim the classifier's summary into a short topic phrase — casing preserved.
313
313
  *
314
- * This used to lowercase the first letter so the phrase could sit mid-sentence.
315
- * Applied to a summary that starts with an acronym or a name, that manufactures
316
- * a typo the requester reads as the agent's own: "Picking up: cEO checking in
317
- * on work progress." went to a real human, first letter of "CEO" folded. No
318
- * local rule can tell "Fix" (safe to downcase) from "CEO", "Q3" or a person's
319
- * name (not safe), so the phrase is taken exactly as the classifier wrote it, and
320
- * every slot that renders it — the parentheses here and in topicAside — is one
321
- * where original casing reads correctly. The stored `rec.summary` flows into
322
- * progress/failure/interrupted messages and the board-row title too, so the
323
- * no-recasing rule protects all of them at once.
314
+ * This is NO LONGER interpolated into any message a human reads (the "(CEO
315
+ * asking what machine…)" bleed is gone — see composeAck). It survives only to
316
+ * populate `rec.summary`, which feeds the board-row title and the
317
+ * needs-attention escalation record. Casing is preserved because a summary that
318
+ * starts with an acronym or a name ("CEO", "Q3") must not be mangled in those
319
+ * records either; no local rule can tell "Fix" (safe to downcase) from "CEO"
320
+ * (not), so the phrase is stored exactly as the classifier wrote it.
324
321
  */
325
322
  function topicClause(classResult, item) {
326
323
  const raw = (classResult && classResult.summary) || (item && item.subject) || "";
@@ -329,84 +326,49 @@ function topicClause(classResult, item) {
329
326
  return t;
330
327
  }
331
328
 
332
- /** Rough, honest expectation. Derived from the measured distribution of real
333
- * sessions, not invented: sonnet work clusters near a minute, opus work near
334
- * fifteen. Phrased as a range because it IS a range. */
335
- export function expectedWindow(classResult) {
336
- const model = classResult && classResult.model;
337
- const priority = classResult && classResult.priority;
338
- if (model === "opus" || priority === "critical") return "usually within 10-20 minutes";
339
- return "usually within a few minutes";
340
- }
341
-
342
329
  /**
343
- * The acknowledgement. Two sentences, written the way a colleague would type
344
- * them, and — critically — it commits to coming back "either way". That clause
345
- * is a promise the rest of this module now actually keeps: failure notices,
346
- * interim updates and the stale sweep all exist so it is never a lie. The time
347
- * window is expectedWindow's — measured, not invented.
348
- *
349
- * The topic rides in parentheses at the end of the opener, never spliced into
350
- * the sentence: the summary is an arbitrary phrase in arbitrary case, and the
351
- * previous template — `Picking up: <lowercased summary>. It needs a bit of
352
- * work, …` — showed the daemon's internal framing to the requester and mangled
353
- * acronym-initial summaries on top (see topicClause). "Picking up:" is triage
354
- * vocabulary; a human answering a human does not narrate their queue.
330
+ * The acknowledgement — sent ONLY when genuinely long, multi-step work is about
331
+ * to run (a directly-answerable question is answered in the turn on the quick
332
+ * path, never acked). So it is one plain line, the way a colleague types it
333
+ * before they get to work: no aside, no filler, no promise-of-followup
334
+ * boilerplate. The failure notices, interim updates and stale sweep are what
335
+ * actually keep the human informed — the ack does not need to narrate them.
336
+ *
337
+ * The classifier's summary is DELIBERATELY not shown here. It used to ride in a
338
+ * parenthetical aside, which leaked the daemon's internal framing to the
339
+ * requester verbatim — "(CEO asking what machine/infrastructure is being used)"
340
+ * reached a real human. The summary is still stored on the obligation for the
341
+ * board row and escalations; it just never appears in a message a person reads.
355
342
  */
356
- export function composeAck(item, classResult = {}) {
357
- const topic = topicClause(classResult, item);
358
- const aside = topic ? ` (${topic})` : "";
343
+ export function composeAck(item) {
359
344
  const v = variant(obligationKey(item) || (item && item.sender), 3);
360
- const openers = [
361
- `No problem — I'm looking into this now${aside}.`,
362
- `Got it — I'm on it now${aside}.`,
363
- `Understood — I'm digging into this now${aside}.`,
364
- ];
365
- return `${openers[v]} I want to get this right, so I'll come back to you here with a full answer — ${expectedWindow(classResult)} — and you'll hear from me either way.`;
366
- }
367
-
368
- /**
369
- * The topic, set off in parentheses.
370
- *
371
- * It is NOT interpolated as the object of a preposition, because the classifier's
372
- * summary is as often a verb phrase ("fix the issues and push them") as a noun
373
- * phrase ("July spend reconciliation") — and "Still working on fix the issues"
374
- * is the kind of sentence that tells a reader they are talking to a machine.
375
- * Parentheses read correctly for both.
376
- */
377
- function topicAside(rec) {
378
- return rec && rec.summary ? ` (${rec.summary})` : "";
345
+ return ["On it.", "Looking now.", "On it — digging in now."][v];
379
346
  }
380
347
 
381
- /** An interim update. Says how long it has been and that the work is live.
382
- * The first update no longer claims "it's taking longer than I expected": it
383
- * fires at PROGRESS_AFTER_MS (5 min), squarely inside the 10-20 minute window
384
- * the acknowledgement promised for opus work minutes earlier — a claim the
385
- * same human could see was false. */
348
+ /** An interim update. Says how long it has been and that the work is live —
349
+ * short, no internal topic aside, no filler. */
386
350
  export function composeProgress(rec, o = {}) {
387
351
  const now = Number.isFinite(o.now) ? o.now : Date.now();
388
352
  const mins = Math.max(1, Math.round((now - (rec.openedAt || now)) / 60_000));
389
- const topic = topicAside(rec);
390
353
  if ((rec.progressSent || 0) === 0) {
391
- return `Still working on this${topic} — ${mins} minutes in. Nothing's stuck; I'll come back as soon as I have something worth sending.`;
354
+ return `Still on this — ${mins} minutes in. I'll come back as soon as I've got something.`;
392
355
  }
393
- return `Quick check-in: still on this${topic}, ${mins} minutes in. I haven't forgotten it — I'll follow up the moment it's done, or tell you if I can't finish it.`;
356
+ return `Still going — ${mins} minutes in. I'll follow up the moment it's done.`;
394
357
  }
395
358
 
396
359
  /**
397
360
  * The failure notice. Says WHAT HAPPENED and WHAT HAPPENS NEXT, in that order,
398
361
  * because those are the two things the human is missing when a session dies.
399
- * Never blames the human, never hides behind "an error occurred", and never
400
- * ends without a next step.
362
+ * Terse and human: never blames the human, never hides behind "an error
363
+ * occurred", never ends without a next step, and never shows internal framing.
401
364
  */
402
365
  export function composeFailure(rec, o = {}) {
403
366
  const f = o.failure || {};
404
- const topic = topicAside(rec);
405
367
  const cause = f.human || "the working session ended unexpectedly";
406
368
  if (o.willRetry) {
407
- return `I hit a problem with this${topic} — ${cause}. I'm retrying it now (attempt ${(rec.attempts || 1) + 1} of ${RETRY_MAX + 1}); if it fails again I'll come straight back to you rather than leave you waiting.`;
369
+ return `Hit a problem — ${cause}. Retrying now; if it fails again I'll come straight back rather than leave you waiting.`;
408
370
  }
409
- return `I couldn't get this done${topic} — ${cause}. I've stopped retrying so I'm not silently burning time on it, and I've flagged it so it isn't lost. Do you want me to try a narrower version of this, hand it to someone else, or leave it with you?`;
371
+ return `Couldn't finish this — ${cause}. I've stopped retrying and flagged it so it isn't lost. Want me to try a narrower version, hand it off, or leave it with you?`;
410
372
  }
411
373
 
412
374
  /**
@@ -416,20 +378,18 @@ export function composeFailure(rec, o = {}) {
416
378
  * `sent`. This message is what the human gets instead of that silence.
417
379
  */
418
380
  export function composeSilentSuccess(rec, o = {}) {
419
- const topic = topicAside(rec);
420
381
  const tail = (o.finalText || "").trim();
421
- const head = `I finished working on this${topic} but didn't get a reply out to you — that's my fault, not yours.`;
382
+ const head = `Finished this but didn't get a reply out to you — my fault.`;
422
383
  if (tail) {
423
384
  const excerpt = tail.length > 900 ? `${tail.slice(0, 900).trimEnd()}…` : tail;
424
- return `${head} Here's where I got to:\n\n${excerpt}\n\nIf that doesn't answer it, say so and I'll take another run at it.`;
385
+ return `${head} Here's where I got to:\n\n${excerpt}`;
425
386
  }
426
- return `${head} I don't have a clean result to show you, so I'd rather say that than pretend otherwise. Want me to run it again?`;
387
+ return `${head} I don't have a clean result to show you, though. Want me to run it again?`;
427
388
  }
428
389
 
429
390
  /** A session interrupted by the daemon itself dying/restarting. */
430
- export function composeInterrupted(rec) {
431
- const topic = topicAside(rec);
432
- return `Heads up — my working session on this${topic} was interrupted before it finished (my end restarted). I've put it back in the queue and I'm picking it up again now; I'll come back to you with the answer.`;
391
+ export function composeInterrupted() {
392
+ return `Heads up — my session on this was interrupted before it finished (my end restarted). I've picked it back up; I'll come back with the answer.`;
433
393
  }
434
394
 
435
395
  // ---------------------------------------------------------------------------
@@ -135,28 +135,28 @@ describe("composeAck", () => {
135
135
  assert.equal(a, composeAck(ITEM, CLASS_COMPLEX), "same input must give the same words");
136
136
  });
137
137
 
138
- test("names the actual ask and promises to come back either way", () => {
138
+ test("is one short human line — no filler, no promise-of-followup boilerplate", () => {
139
139
  const a = composeAck(ITEM, CLASS_COMPLEX);
140
- assert.match(a, /fix the noted issues and push the fixes to git/i);
141
- assert.match(a, /either way/i, "the promise the rest of the system exists to keep");
142
- assert.match(a, /10-20 minutes/, "an opus/critical item is honestly framed as slow");
140
+ assert.ok(a.length <= 40, `an ack is one short line, got ${a.length} chars: ${a}`);
141
+ // The templated filler communication-style.md bans.
142
+ assert.doesNotMatch(a, /either way|full answer|within a few minutes|within 10-20|I want to get this right|hear from me|come back to you/i);
143
143
  });
144
144
 
145
145
  test("degrades gracefully when the classifier gave nothing to go on", () => {
146
146
  const a = composeAck(ITEM, {});
147
- assert.ok(a.length > 40);
148
- assert.match(a, /either way/i);
147
+ assert.ok(a.length > 0);
148
+ assert.doesNotMatch(a, /\(.*\)/, "no parenthetical aside");
149
149
  });
150
150
 
151
- test("REGRESSION: an acronym-initial summary keeps its casing — no 'cEO'", () => {
152
- // Owner screenshot, verbatim: "Picking up: cEO checking in on work
153
- // progress." — topicClause downcased the first letter of whatever the
154
- // classifier wrote so it could sit mid-sentence, and "CEO…" became a typo
155
- // the requester reads as the agent's own. The summary is now carried
156
- // exactly as written.
157
- const a = composeAck(ITEM, { ...CLASS_COMPLEX, summary: "CEO checking in on work progress" });
158
- assert.match(a, /CEO checking in on work progress/);
159
- assert.doesNotMatch(a, /cEO/);
151
+ test("REGRESSION: the classifier summary NEVER reaches the human — no '(CEO …)' bleed", () => {
152
+ // Owner screenshot, verbatim: the ack carried the classifier's internal
153
+ // summary in a parenthetical aside — "(CEO asking what machine/infrastructure
154
+ // is being used)" reached a real human. The summary is now stored on the
155
+ // obligation for the board row but never shown in a message anyone reads.
156
+ const a = composeAck(ITEM, { ...CLASS_COMPLEX, summary: "CEO asking what machine/infrastructure is being used" });
157
+ assert.doesNotMatch(a, /CEO asking/i);
158
+ assert.doesNotMatch(a, /machine\/infrastructure/i);
159
+ assert.doesNotMatch(a, /\(.*\)/, "no parenthetical aside at all");
160
160
  });
161
161
 
162
162
  test("reads like a colleague, not a template — no internal framing shown to the human", () => {
@@ -223,10 +223,10 @@ describe("openAndAcknowledge", () => {
223
223
  assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
224
224
  });
225
225
 
226
- test("the stored summary keeps the classifier's casing — every later message reads from it", async () => {
227
- // `rec.summary` feeds topicAside (progress, failure, silent-success,
228
- // interrupted), the sweep's compensating ack, and board-mirror's row title.
229
- // A summary mangled at open time would resurface in all of them.
226
+ test("the stored summary keeps the classifier's casing — the board row and escalations read from it", async () => {
227
+ // `rec.summary` feeds board-mirror's row title and the needs-attention
228
+ // escalation record. A summary mangled at open time would resurface there.
229
+ // It must NOT, however, appear in any message the human reads.
230
230
  const t = fakeTransport();
231
231
  const r = await openAndAcknowledge({
232
232
  item: ITEM,
@@ -235,7 +235,7 @@ describe("openAndAcknowledge", () => {
235
235
  });
236
236
  const rec = readObligation(r.key);
237
237
  assert.equal(rec.summary, "CEO checking in on work progress");
238
- assert.match(composeProgress(rec, { now: Date.now() + 6 * 60_000 }), /\(CEO checking in on work progress\)/);
238
+ assert.doesNotMatch(composeProgress(rec, { now: Date.now() + 6 * 60_000 }), /CEO checking in on work progress/, "the summary is never leaked into a progress update");
239
239
  });
240
240
 
241
241
  test("acknowledges ONCE — a re-delivered item does not re-announce itself", async () => {
@@ -339,7 +339,7 @@ describe("settleSession", () => {
339
339
  assert.match(r.verdict, /^retrying:timeout$/);
340
340
  const msg = t.sent.at(-1).text;
341
341
  assert.match(msg, /ran past its time limit/i);
342
- assert.match(msg, /retrying it now/i);
342
+ assert.match(msg, /retrying now/i);
343
343
  assert.equal(readObligation(key).state, "open", "still owed — the retry has to be covered too");
344
344
  });
345
345
 
@@ -350,7 +350,7 @@ describe("settleSession", () => {
350
350
  assert.equal(r.willRetry, false);
351
351
  assert.match(r.verdict, /^failed:spawn_failed$/);
352
352
  const msg = t.sent.at(-1).text;
353
- assert.match(msg, /couldn't get this done/i);
353
+ assert.match(msg, /couldn't finish this/i);
354
354
  assert.match(msg, /stopped retrying/i);
355
355
  assert.match(msg, /\?$/, "a dead end must end in a question, not a shrug");
356
356
  assert.equal(readObligation(key).state, "failed");
@@ -384,7 +384,7 @@ describe("sweepObligations", () => {
384
384
  const now = Date.now() + ACK_GRACE_MS + 1000;
385
385
  const stats = await sweepObligations({ now, deps: { deliverImpl: working.impl, spokeSinceImpl: () => false } });
386
386
  assert.equal(stats.acked, 1);
387
- assert.match(working.sent[0].text, /come back to you/i);
387
+ assert.match(working.sent[0].text, /on it|looking now|digging in/i);
388
388
  assert.equal(readObligation(r.key).acknowledged, true);
389
389
  });
390
390
 
@@ -395,7 +395,7 @@ describe("sweepObligations", () => {
395
395
  const stats = await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
396
396
  assert.equal(stats.progressed, 1);
397
397
  const msg = t.sent.at(-1).text;
398
- assert.match(msg, /still working/i);
398
+ assert.match(msg, /still on this/i);
399
399
  assert.match(msg, /minutes in/i, "an interim update that omits how long it has been is not much of an update");
400
400
  // Six minutes in is INSIDE the 10-20 minute window the ack just promised
401
401
  // for opus work; claiming it here contradicts a message the same human read.
@@ -642,7 +642,7 @@ describe("attribution: which debt did that message actually discharge", () => {
642
642
  const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl } });
643
643
  assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
644
644
  assert.equal(stats.acked, 1, "instead the missing acknowledgement is finally sent");
645
- assert.match(t.sent.at(-1).text, /looking into this|on it now|digging into this/i);
645
+ assert.match(t.sent.at(-1).text, /on it|looking now|digging in/i);
646
646
  });
647
647
  });
648
648
 
@@ -719,7 +719,7 @@ describe("a running retry is not declared dead", () => {
719
719
  now: t0 + 45 * 60_000, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false },
720
720
  });
721
721
  assert.equal(settled.willRetry, true);
722
- assert.match(t.sent.at(-1).text, /retrying it now/i);
722
+ assert.match(t.sent.at(-1).text, /retrying now/i);
723
723
 
724
724
  const stats = await sweepObligations({ now: t0 + 51 * 60_000, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
725
725
  assert.equal(stats.staled, 0, "the retry is minutes old, not fifty");
@@ -243,7 +243,8 @@ Your job: classify each incoming message and return a JSON object with exactly t
243
243
  "model": "opus" | "sonnet",
244
244
  "summary": "<one-line summary of the message>",
245
245
  "category": "action_required" | "fyi" | "ignore",
246
- "directed_at_agent": true | false
246
+ "directed_at_agent": true | false,
247
+ "answerable": true | false
247
248
  }
248
249
  ${peerContext}
249
250
  Classification rules:
@@ -271,6 +272,10 @@ CATEGORY:
271
272
  - "fyi": Informational, no action needed
272
273
  - "ignore": No value
273
274
 
275
+ ANSWERABLE (can ${agentName} answer this DIRECTLY, in one reply, right now?):
276
+ - true: A question that can be answered from what ${agentName} already knows or can see, needing NO tools, NO external lookups, NO drafting, and NO multi-step work — e.g. "what machine are you running on?", "which model are you?", "are you online?", "what's your role?", a simple factual or conversational question. These get answered immediately instead of a "I'll look into it" holding message, even from the ${principalTitle}.
277
+ - false: Anything that needs an action performed (send, schedule, file, push, draft, research, look something up), anything multi-step, or anything that is not a direct question. When unsure, use false — a wrong direct answer is worse than a brief wait.
278
+
274
279
  DIRECTED_AT_AGENT (CRITICAL — determines whether ${agentName} should respond. Default to false in channels/group chats.):
275
280
  - true: The message is a DM to ${agentName} (1:1)
276
281
  - true: The message explicitly mentions ${agentName} by name or @mention
@@ -300,43 +305,46 @@ Context:
300
305
  Examples:
301
306
 
302
307
  Input: CEO DM on Slack: "Can you send the board resolution to Jordan?"
303
- Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO requesting board resolution sent to GC Jordan","category":"action_required","directed_at_agent":true}
308
+ Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO requesting board resolution sent to GC Jordan","category":"action_required","directed_at_agent":true,"answerable":false}
309
+
310
+ Input: CEO DM on Slack: "what kind of machine are you running on?"
311
+ Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO asking what machine/infrastructure the agent runs on","category":"action_required","directed_at_agent":true,"answerable":true}
304
312
 
305
313
  Input: GitHub notification email about a merged PR
306
- Output: {"priority":"ignore","action":"ignore","model":"sonnet","summary":"GitHub PR merge notification","category":"ignore","directed_at_agent":false}
314
+ Output: {"priority":"ignore","action":"ignore","model":"sonnet","summary":"GitHub PR merge notification","category":"ignore","directed_at_agent":false,"answerable":false}
307
315
 
308
316
  Input: Leadership Slack: "We need the the regulator gap analysis updated by Thursday"
309
- Output: {"priority":"high","action":"draft","model":"opus","summary":"Leadership requesting the regulator gap analysis update by Thursday deadline","category":"action_required","directed_at_agent":true}
317
+ Output: {"priority":"high","action":"draft","model":"opus","summary":"Leadership requesting the regulator gap analysis update by Thursday deadline","category":"action_required","directed_at_agent":true,"answerable":false}
310
318
 
311
319
  Input: Team member: "Standup notes from today's sync"
312
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team standup notes shared","category":"fyi","directed_at_agent":false}
320
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team standup notes shared","category":"fyi","directed_at_agent":false,"answerable":false}
313
321
 
314
322
  Input: External email: "Following up on our conversation about the Singapore entity"
315
- Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"External follow-up on Singapore entity discussion","category":"action_required","directed_at_agent":true}
323
+ Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"External follow-up on Singapore entity discussion","category":"action_required","directed_at_agent":true,"answerable":false}
316
324
 
317
325
  Input: Channel message from Jacob: "@${agentName} can you check the engine deployment?"
318
- Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking ${agentName} to check engine deployment","category":"action_required","directed_at_agent":true}
326
+ Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking ${agentName} to check engine deployment","category":"action_required","directed_at_agent":true,"answerable":false}
319
327
 
320
328
  Input: Channel message from CEO: "@AnotherAgent can you review the agentic libraries in this channel?"
321
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"CEO asking another agent to review agentic libraries — not directed at ${agentName}","category":"fyi","directed_at_agent":false}
329
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"CEO asking another agent to review agentic libraries — not directed at ${agentName}","category":"fyi","directed_at_agent":false,"answerable":false}
322
330
 
323
331
  Input: Channel message from Sam to Jordan: "Jordan, did you file the compliance response yet?"
324
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Sam asking Jordan about compliance response filing","category":"fyi","directed_at_agent":false}
332
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Sam asking Jordan about compliance response filing","category":"fyi","directed_at_agent":false,"answerable":false}
325
333
 
326
334
  Input: Channel thread — Jacob: "pushed the fix" / Sam: "nice, looks good"
327
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team exchange about code fix","category":"fyi","directed_at_agent":false}
335
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team exchange about code fix","category":"fyi","directed_at_agent":false,"answerable":false}
328
336
 
329
337
  Input: Thread where ${agentName} previously replied — Jacob: "yeah that makes sense, I'll handle it" (responding to Sam, not ${agentName})
330
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jacob acknowledging Sam in thread","category":"fyi","directed_at_agent":false}
338
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jacob acknowledging Sam in thread","category":"fyi","directed_at_agent":false,"answerable":false}
331
339
 
332
340
  Input: Thread where ${agentName} previously replied — Sam: "${agentName}, can you send the updated doc?"
333
- Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Sam asking ${agentName} for updated document","category":"action_required","directed_at_agent":true}
341
+ Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Sam asking ${agentName} for updated document","category":"action_required","directed_at_agent":true,"answerable":false}
334
342
 
335
343
  Input: Channel message: "FYI — the Cayman entity docs are signed and filed"
336
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"FYI: Cayman entity docs signed and filed","category":"fyi","directed_at_agent":false}
344
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"FYI: Cayman entity docs signed and filed","category":"fyi","directed_at_agent":false,"answerable":false}
337
345
 
338
346
  Input: Group chat — Jordan: "Sam, can we sync on the the regulator gaps tomorrow?"
339
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jordan asking Sam to sync on the regulator gaps","category":"fyi","directed_at_agent":false}
347
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jordan asking Sam to sync on the regulator gaps","category":"fyi","directed_at_agent":false,"answerable":false}
340
348
 
341
349
  Return ONLY the JSON object. No explanation, no markdown fencing, no extra text.`;
342
350
  }
@@ -407,6 +415,10 @@ function parseClassification(text) {
407
415
  summary: typeof parsed.summary === "string" ? parsed.summary : "Unclassified message",
408
416
  category: validCategories.includes(parsed.category) ? parsed.category : "fyi",
409
417
  directed_at_agent: typeof parsed.directed_at_agent === "boolean" ? parsed.directed_at_agent : false, // default false — don't respond unless we're confident the message is for this agent
418
+ // A directly-answerable question gets a quick reply instead of a session +
419
+ // holding ack (see responder.isQuickReply). Default false — when the field
420
+ // is absent or non-boolean, take the session path, never guess "answerable".
421
+ answerable: parsed.answerable === true,
410
422
  };
411
423
  }
412
424
 
@@ -781,5 +793,6 @@ function classifiedAttrs(r) {
781
793
  model: r.model,
782
794
  category: r.category,
783
795
  directed_at_agent: r.directed_at_agent,
796
+ answerable: r.answerable,
784
797
  };
785
798
  }
@@ -384,6 +384,29 @@ test("RUNG: the quick-reply responder accepts rungs 0-1 and refuses 2+", async (
384
384
  );
385
385
  });
386
386
 
387
+ test("ANSWERABLE: a directly-answerable question is quick-replied, not sessioned + acked", () => {
388
+ // "what machine are you running on?" from the CEO classifies critical/opus,
389
+ // which would otherwise force a session and a robotic holding ack. Flagged
390
+ // answerable, it is answered in the turn instead — the defect being fixed.
391
+ assert.equal(
392
+ responder.isQuickReply({ action: "respond", priority: "critical", model: "opus", answerable: true }),
393
+ true,
394
+ "an answerable question skips the session even at critical/opus",
395
+ );
396
+ // The flag only fast-paths a `respond` — a draft/research item stays on the
397
+ // session path even if the classifier also (contradictorily) flags it.
398
+ assert.equal(responder.isQuickReply({ action: "draft", priority: "high", model: "opus", answerable: true }), false);
399
+ assert.equal(responder.isQuickReply({ action: "research", priority: "normal", model: "sonnet", answerable: true }), false);
400
+ // And the rung veto still wins over answerable.
401
+ assert.equal(
402
+ responder.isQuickReply({ action: "respond", priority: "critical", model: "opus", answerable: true }, { rung: 3 }),
403
+ false,
404
+ "a rung-3 answer needs a session even when the classifier called it answerable",
405
+ );
406
+ // Without the flag, a critical/opus item still takes the session path.
407
+ assert.equal(responder.isQuickReply({ action: "respond", priority: "critical", model: "opus" }), false);
408
+ });
409
+
387
410
  // ---------------------------------------------------------------------------
388
411
  // 8) REGRESSION (live run): with no need estimate every DM routed to rung 3,
389
412
  // which vetoed the quick reply and spawned a session for a two-line message.
@@ -1075,6 +1075,17 @@ export function isQuickReply(classResult, routed = null) {
1075
1075
  // place, or a caller that has no ladder) leaves the original rules untouched.
1076
1076
  if (!rungPermitsQuickReply(routed)) return false;
1077
1077
 
1078
+ // ── directly-answerable question: answer it, don't ack it ────────────────
1079
+ // A factual question the classifier judged answerable in one reply — no
1080
+ // tools, no drafting, no multi-step work ("what machine are you running on?")
1081
+ // — belongs on the quick path even from the CEO and even when the model or
1082
+ // priority would otherwise route it to a session. Spawning a 15-minute
1083
+ // session and posting a robotic "I'm looking into this now (CEO asking…)" to
1084
+ // a question that could be answered in one line IS the defect being fixed.
1085
+ // Gated to `respond` so a `draft`/`research` item that also gets flagged
1086
+ // answerable (a classifier contradiction) still takes the session path.
1087
+ if (classResult.answerable === true && classResult.action === "respond") return true;
1088
+
1078
1089
  // These always need full sessions — never quick reply
1079
1090
  if (classResult.action === "research") return false;
1080
1091
  if (classResult.action === "draft") return false;
@@ -59,7 +59,15 @@ async function main() {
59
59
  // resolveArchetype validates the pair; throws if invalid.
60
60
  await resolveArchetype(sel);
61
61
 
62
- const cadences = await resolveCadences(sel);
62
+ // A `cadence: continuous` capability (inbox triage, SLA/obligation watch) is a
63
+ // standing REAL-TIME responsibility, NOT a launchd tick: it ships no
64
+ // schedules/triggers/<id>.md, so scheduling it fires every interval, finds no
65
+ // prompt, and spins `skipped_circuit_open` forever, padding the cadence-bus
66
+ // queue. Drop it here so it never becomes a cadence/registry/plist entry; the
67
+ // daemon's real-time loop owns the capability. The plan compiler
68
+ // (lib/plan/compile.mjs §2) applies the SAME skip, so the two producers stay
69
+ // byte-identical (mandate.test.mjs COMPATIBILITY).
70
+ const cadences = (await resolveCadences(sel)).filter((c) => c.cadence !== "continuous");
63
71
 
64
72
  mkdirSync(join(AGENT_ROOT, "config"), { recursive: true });
65
73
  writeFileSync(join(AGENT_ROOT, "config", "cadences.yaml"),