sentinelayer-cli 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -4,6 +4,13 @@ import path from "node:path";
4
4
 
5
5
  import pc from "picocolors";
6
6
 
7
+ import {
8
+ createMultiProviderApiClient,
9
+ resolveApiKey,
10
+ resolveModel,
11
+ resolveProvider,
12
+ } from "../ai/client.js";
13
+ import { enrichGuideTickets } from "../guide/enrich.js";
7
14
  import {
8
15
  defaultGuideExportFileName,
9
16
  generateBuildGuide,
@@ -12,6 +19,40 @@ import {
12
19
  } from "../guide/generator.js";
13
20
  import { renderTerminalMarkdown } from "../ui/markdown.js";
14
21
 
22
+ // Optionally split each phase into per-PR tickets with an LLM. Best-effort:
23
+ // any failure leaves the deterministic tickets untouched. Returns the number
24
+ // of phases enriched (0 when disabled or unavailable).
25
+ async function maybeEnrichGuide(guideDoc, options) {
26
+ if (!options || !options.enrich) return 0;
27
+ try {
28
+ const provider = resolveProvider({ provider: options.provider });
29
+ const model = resolveModel({ provider, model: options.model });
30
+ const apiKey = resolveApiKey({ provider, explicitApiKey: options.apiKey });
31
+ if (!apiKey) {
32
+ console.error(
33
+ pc.yellow(`! --enrich skipped: no API key for provider '${provider}'. Set the provider key or pass --api-key.`)
34
+ );
35
+ return 0;
36
+ }
37
+ const limits = {};
38
+ if (options.maxPhases) limits.maxPhases = Number(options.maxPhases);
39
+ if (options.maxPrsPerPhase) limits.maxTicketsPerPhase = Number(options.maxPrsPerPhase);
40
+ const { tickets, enrichedPhases } = await enrichGuideTickets({
41
+ guide: guideDoc,
42
+ client: createMultiProviderApiClient(),
43
+ provider,
44
+ model,
45
+ apiKey,
46
+ limits,
47
+ });
48
+ if (enrichedPhases > 0) guideDoc.tickets = tickets;
49
+ return enrichedPhases;
50
+ } catch (error) {
51
+ console.error(pc.yellow(`! --enrich failed, using deterministic tickets: ${error?.message || error}`));
52
+ return 0;
53
+ }
54
+ }
55
+
15
56
  function shouldEmitJson(options, command) {
16
57
  const local = Boolean(options && options.json);
17
58
  const globalFromCommand =
@@ -101,6 +142,12 @@ export function registerGuideCommand(program) {
101
142
  .option("--path <path>", "Target workspace path", ".")
102
143
  .option("--spec-file <path>", "Spec file path relative to --path")
103
144
  .option("--output-file <path>", "Output export file path relative to --path")
145
+ .option("--enrich", "Split each phase into per-PR tickets with an LLM (opt-in, capped)")
146
+ .option("--provider <provider>", "LLM provider for --enrich (openai|anthropic|google)")
147
+ .option("--model <model>", "LLM model for --enrich")
148
+ .option("--api-key <key>", "Explicit API key for --enrich (else from env)")
149
+ .option("--max-phases <n>", "Cap how many phases --enrich expands")
150
+ .option("--max-prs-per-phase <n>", "Cap PRs per phase for --enrich")
104
151
  .option("--json", "Emit machine-readable output")
105
152
  .action(async (options, command) => {
106
153
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
@@ -116,6 +163,7 @@ export function registerGuideCommand(program) {
116
163
  projectPath: targetPath,
117
164
  specPath,
118
165
  });
166
+ const enrichedPhases = await maybeEnrichGuide(guideDoc, options);
119
167
  const exportBody = renderGuideExport({ format, guide: guideDoc });
120
168
 
121
169
  await fsp.mkdir(path.dirname(outputPath), { recursive: true });
@@ -128,6 +176,7 @@ export function registerGuideCommand(program) {
128
176
  specPath,
129
177
  outputPath,
130
178
  issueCount: guideDoc.tickets.length,
179
+ enrichedPhases,
131
180
  };
132
181
 
133
182
  if (shouldEmitJson(options, command)) {
@@ -139,6 +188,9 @@ export function registerGuideCommand(program) {
139
188
  console.log(pc.gray(`Format: ${format}`));
140
189
  console.log(pc.gray(`Output: ${outputPath}`));
141
190
  console.log(pc.gray(`Issues: ${guideDoc.tickets.length}`));
191
+ if (enrichedPhases > 0) {
192
+ console.log(pc.gray(`LLM-enriched phases: ${enrichedPhases}`));
193
+ }
142
194
  });
143
195
 
144
196
  guide
@@ -61,6 +61,9 @@ import {
61
61
  refreshSessionCacheForRemoteActivity,
62
62
  updateSessionTitle,
63
63
  } from "../session/store.js";
64
+ import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
65
+ import { postFirstSentiMessage } from "../session/first-message.js";
66
+ import { createListenerHostWake } from "../session/wake/listen-wake.js";
64
67
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
65
68
  import {
66
69
  addSessionEventIdentityKeys,
@@ -2236,6 +2239,10 @@ export function registerSessionCommand(program) {
2236
2239
  "--force-new",
2237
2240
  "Always create a new session even if a recent active one exists for this workspace",
2238
2241
  )
2242
+ .option(
2243
+ "--force",
2244
+ "Alias of --force-new (both always mint a fresh session)",
2245
+ )
2239
2246
  .option(
2240
2247
  "--resume",
2241
2248
  "Reuse the most recent active session for this workspace when one is inside the reuse window",
@@ -2279,7 +2286,7 @@ export function registerSessionCommand(program) {
2279
2286
  template,
2280
2287
  title: titleArg,
2281
2288
  resume: options.resume !== false,
2282
- forceNew: Boolean(options.forceNew),
2289
+ forceNew: Boolean(options.forceNew || options.force),
2283
2290
  reuseWindowSeconds,
2284
2291
  });
2285
2292
  const created = ensured.created;
@@ -2356,6 +2363,21 @@ export function registerSessionCommand(program) {
2356
2363
  }
2357
2364
  payload.sentiDaemon = sentiDaemon;
2358
2365
 
2366
+ // Pin the deterministic first-Senti-message as the opening event of a
2367
+ // NEW room so every joining agent reads the operating protocol
2368
+ // (identity, mandatory commands, reaction/threading/lock/evidence
2369
+ // rules, cadence). Skipped on resume (already posted) and opt-outable
2370
+ // via SENTINELAYER_SKIP_FIRST_MESSAGE=1. Best-effort, never blocks.
2371
+ let firstMessage = { posted: false, reason: "skipped" };
2372
+ const skipFirstMessage = String(process.env.SENTINELAYER_SKIP_FIRST_MESSAGE || "").trim() === "1";
2373
+ if (!resumed && !skipFirstMessage) {
2374
+ firstMessage = await postFirstSentiMessage({
2375
+ sessionId: created.sessionId,
2376
+ targetPath,
2377
+ }).catch((error) => ({ posted: false, reason: normalizeString(error?.message) || "error" }));
2378
+ }
2379
+ payload.firstMessage = firstMessage;
2380
+
2359
2381
  if (shouldEmitJson(options, command)) {
2360
2382
  console.log(JSON.stringify(payload, null, 2));
2361
2383
  return;
@@ -2421,7 +2443,7 @@ export function registerSessionCommand(program) {
2421
2443
  if (!resumed) {
2422
2444
  console.log(
2423
2445
  pc.gray(
2424
- options.forceNew
2446
+ (options.forceNew || options.force)
2425
2447
  ? `Tip: fresh session minted (--force-new honored). Subsequent \`${cliCommand} session start\` here within an hour will resume this new session.`
2426
2448
  : `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
2427
2449
  ),
@@ -3473,6 +3495,108 @@ export function registerSessionCommand(program) {
3473
3495
  return payload;
3474
3496
  });
3475
3497
 
3498
+ session
3499
+ .command("listeners <sessionId>")
3500
+ .description(
3501
+ "List who is actively listening to the session and at what poll cadence (active/idle/stale/stopped), derived from listener presence heartbeats. Mirrors the web roster.",
3502
+ )
3503
+ .option("--path <path>", "Workspace path for the session", ".")
3504
+ .option("--limit <n>", "Recent events to scan for heartbeats (default 200)", "200")
3505
+ .option("--json", "Emit machine-readable output")
3506
+ .action(async (sessionId, options, command) => {
3507
+ const normalizedSessionId = normalizeString(sessionId);
3508
+ if (!normalizedSessionId) {
3509
+ throw new Error("session id is required.");
3510
+ }
3511
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3512
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3513
+ const limit = parsePositiveInteger(options.limit, "limit", 200);
3514
+ const result = await fetchSessionListeners(normalizedSessionId, { targetPath, limit });
3515
+ const listeners = Array.isArray(result.listeners) ? result.listeners : [];
3516
+ const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
3517
+ const payload = {
3518
+ command: "session listeners",
3519
+ sessionId: normalizedSessionId,
3520
+ ok: Boolean(result.ok),
3521
+ reason: result.ok ? undefined : result.reason,
3522
+ count: listeners.length,
3523
+ liveCount: live,
3524
+ listeners,
3525
+ };
3526
+ if (shouldEmitJson(options, command)) {
3527
+ console.log(JSON.stringify(payload, null, 2));
3528
+ return payload;
3529
+ }
3530
+ if (!result.ok) {
3531
+ console.log(pc.yellow(`Could not read listeners (${result.reason}).`));
3532
+ return payload;
3533
+ }
3534
+ if (listeners.length === 0) {
3535
+ console.log(pc.gray("No listeners detected (no recent presence heartbeats)."));
3536
+ return payload;
3537
+ }
3538
+ console.log(pc.bold(`Listeners (${live} live / ${listeners.length} seen)`));
3539
+ for (const row of listeners) {
3540
+ const line = formatListenerLine(row);
3541
+ if (row.status === "active") console.log(pc.green(` ${line}`));
3542
+ else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
3543
+ else console.log(pc.gray(` ${line}`));
3544
+ }
3545
+ return payload;
3546
+ });
3547
+
3548
+ session
3549
+ .command("stop-listener <sessionId>")
3550
+ .description(
3551
+ "Ask an agent's listener to stop (save energy). Posts a listener_stop directive the listener honors on its next poll, then exits cleanly. Targets one agent with --agent; omit it to stop every listener in the room.",
3552
+ )
3553
+ .option("--agent <id>", "Agent whose listener to stop (omit to stop all listeners in the room)")
3554
+ .option("--path <path>", "Workspace path for the session", ".")
3555
+ .option("--json", "Emit machine-readable output")
3556
+ .action(async (sessionId, options, command) => {
3557
+ const normalizedSessionId = normalizeString(sessionId);
3558
+ if (!normalizedSessionId) {
3559
+ throw new Error("session id is required.");
3560
+ }
3561
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3562
+ const targetAgent = normalizeString(options.agent);
3563
+ await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
3564
+ const event = createAgentEvent({
3565
+ event: "listener_stop",
3566
+ agent: { id: "session-control", model: "control", persona: "Session Control" },
3567
+ sessionId: normalizedSessionId,
3568
+ payload: {
3569
+ // targetAgentId routes the directive to that agent's listener (an
3570
+ // event recipient); omitting it broadcasts to every listener.
3571
+ ...(targetAgent ? { targetAgentId: targetAgent } : { broadcast: true }),
3572
+ reason: "operator_stop",
3573
+ },
3574
+ });
3575
+ const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, { targetPath }).catch(
3576
+ (error) => ({ synced: false, reason: normalizeString(error?.message) || "sync_failed" }),
3577
+ );
3578
+ await appendToStream(normalizedSessionId, event, { targetPath, syncRemote: false }).catch(() => {});
3579
+ const payload = {
3580
+ command: "session stop-listener",
3581
+ sessionId: normalizedSessionId,
3582
+ targetAgent: targetAgent || null,
3583
+ scope: targetAgent ? "agent" : "all",
3584
+ remoteSync: remoteSync || undefined,
3585
+ };
3586
+ if (shouldEmitJson(options, command)) {
3587
+ console.log(JSON.stringify(payload, null, 2));
3588
+ return payload;
3589
+ }
3590
+ console.log(
3591
+ pc.yellow(
3592
+ targetAgent
3593
+ ? `Listener stop requested for ${targetAgent}; it will exit on its next poll.`
3594
+ : "Listener stop requested for ALL listeners in this room.",
3595
+ ),
3596
+ );
3597
+ return payload;
3598
+ });
3599
+
3476
3600
  session
3477
3601
  .command("listen")
3478
3602
  .description("Background-poll a session for events addressed to this agent or broadcast")
@@ -3520,6 +3644,14 @@ export function registerSessionCommand(program) {
3520
3644
  "--wake <command>",
3521
3645
  "Wake hook: run this shell command on each matched event (notify->resume bridge). Event JSON is piped to stdin; SL_WAKE_* env vars are set.",
3522
3646
  )
3647
+ .option(
3648
+ "--wake-host <name>",
3649
+ "Auto-wake: resume this host (claude|codex) on each addressed message so listening IS waking. Requires --resume-session.",
3650
+ )
3651
+ .option(
3652
+ "--resume-session <id>",
3653
+ "Host session/rollout id to resume on wake (the claude/codex session id, not the Senti id). Pairs with --wake-host.",
3654
+ )
3523
3655
  .option(
3524
3656
  "--coaching-interval <seconds>",
3525
3657
  "Seconds between in-session success reminders (ack, claim work, reply in-thread). Default 900; 0 disables.",
@@ -3575,6 +3707,25 @@ export function registerSessionCommand(program) {
3575
3707
  agentId,
3576
3708
  emit: emitWakeNotice,
3577
3709
  });
3710
+ // Auto-wake cutover: when --wake-host + --resume-session are given, an
3711
+ // addressed message INSTANTLY resumes the host (claude --resume / codex)
3712
+ // via the built wake bus — turning `listen` into a true waker on the
3713
+ // same poll. resolve-target routing inside ensures real-message-only,
3714
+ // addressed-to-us, never-self.
3715
+ const wakeHost = normalizeString(options.wakeHost);
3716
+ const triggerHostWake = wakeHost
3717
+ ? createListenerHostWake({
3718
+ host: wakeHost,
3719
+ resumeSessionId: options.resumeSession,
3720
+ agentId,
3721
+ sessionId: normalizedSessionId,
3722
+ })
3723
+ : null;
3724
+ if (wakeHost && !triggerHostWake) {
3725
+ throw new Error(
3726
+ "--wake-host requires a valid host (claude|codex) and --resume-session <host-session-id>.",
3727
+ );
3728
+ }
3578
3729
  const requestedTransport = normalizeString(options.transport).toLowerCase() || "auto";
3579
3730
  if (!["auto", "stream", "poll"].includes(requestedTransport)) {
3580
3731
  throw new Error("--transport must be one of: auto, stream, poll.");
@@ -3696,6 +3847,20 @@ export function registerSessionCommand(program) {
3696
3847
  }
3697
3848
  },
3698
3849
  onEvent: async (event) => {
3850
+ // Cut-listener: a `listener_stop` directive addressed to this
3851
+ // agent (from the web "stop listening" control or
3852
+ // `sl session stop-listener`) cleanly exits this listener to save
3853
+ // energy. Untargeted (no targetAgentId) stops every listener.
3854
+ if (normalizeString(event?.event) === "listener_stop") {
3855
+ const target = normalizeString(event?.payload?.targetAgentId);
3856
+ if (!target || target === agentId) {
3857
+ if (emitFormat !== "ndjson") {
3858
+ console.log(pc.yellow(`Listener stop requested for ${agentId}; exiting.`));
3859
+ }
3860
+ ac.abort();
3861
+ return;
3862
+ }
3863
+ }
3699
3864
  if (emitFormat === "ndjson") {
3700
3865
  console.log(JSON.stringify(event));
3701
3866
  } else {
@@ -3704,6 +3869,16 @@ export function registerSessionCommand(program) {
3704
3869
  // Fire the wake hook for any matched event (incl. ack/like) so the
3705
3870
  // host can resume its agent.
3706
3871
  wakeRunner.trigger(event);
3872
+ // Auto-wake: instantly resume the host on an addressed message.
3873
+ if (triggerHostWake) {
3874
+ void Promise.resolve(triggerHostWake.trigger(event)).then((outcome) => {
3875
+ if (outcome?.woken && emitFormat !== "ndjson") {
3876
+ console.log(pc.green(`auto-wake: resumed ${wakeHost} (${agentId})`));
3877
+ } else if (outcome && !outcome.woken && outcome.reason !== "not_routed" && emitFormat !== "ndjson") {
3878
+ console.log(pc.yellow(`auto-wake: ${wakeHost} resume failed (${outcome.reason})`));
3879
+ }
3880
+ });
3881
+ }
3707
3882
  },
3708
3883
  onError: async (result) => {
3709
3884
  const reason = normalizeString(result?.reason) || "poll_failed";
@@ -3726,6 +3901,29 @@ export function registerSessionCommand(program) {
3726
3901
  }
3727
3902
  },
3728
3903
  onLifecycle: async (lifecycle) => {
3904
+ // Wake-confirmation runs on every heartbeat regardless of presence:
3905
+ // re-resume agents that were woken but never acked within the
3906
+ // window; confirm + retire the ones that did. (Carter's receipt
3907
+ // idea — a wake isn't done until the agent actually reads it.)
3908
+ if (triggerHostWake && normalizeString(lifecycle?.type) === "heartbeat") {
3909
+ const outcome = await triggerHostWake
3910
+ .reconcile({
3911
+ nowMs: Date.now(),
3912
+ fetchActions: (seq) =>
3913
+ listSessionMessageActions(normalizedSessionId, {
3914
+ targetPath,
3915
+ targetSequenceId: seq,
3916
+ }),
3917
+ })
3918
+ .catch(() => null);
3919
+ if (outcome && (outcome.retried > 0 || outcome.deadLettered > 0) && emitFormat !== "ndjson") {
3920
+ console.log(
3921
+ pc.yellow(
3922
+ `auto-wake reconcile: re-resumed ${outcome.retried}, gave up on ${outcome.deadLettered} (no ack).`,
3923
+ ),
3924
+ );
3925
+ }
3926
+ }
3729
3927
  if (!publishPresence) return;
3730
3928
  const lifecycleType = normalizeString(lifecycle?.type);
3731
3929
  if (lifecycleType === "heartbeat") {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Optional LLM enrichment for the deterministic decomposition.
3
+ *
4
+ * The heuristic in generator.js produces one ticket per phase. This layer asks
5
+ * a model to split each phase into concrete, independently-mergeable per-PR
6
+ * tickets with sharper acceptance criteria. It is:
7
+ * - opt-in (the caller passes a client),
8
+ * - capped (bounded phases × tickets, so cost is bounded),
9
+ * - fail-safe (any phase that errors or returns junk keeps its heuristic
10
+ * ticket — enrichment never throws and never drops work).
11
+ */
12
+
13
+ export const DEFAULT_ENRICH_LIMITS = Object.freeze({
14
+ maxPhases: 12,
15
+ maxTicketsPerPhase: 4,
16
+ });
17
+
18
+ function clampInt(value, fallback, min, max) {
19
+ const n = Math.floor(Number(value));
20
+ if (!Number.isFinite(n)) return fallback;
21
+ return Math.max(min, Math.min(max, n));
22
+ }
23
+
24
+ export function buildEnrichPrompt(phase, maxTickets) {
25
+ const fields = phase?.fields || {};
26
+ const lines = [
27
+ `Phase: ${String(phase?.title || "").trim()}`,
28
+ fields.objective ? `Objective: ${fields.objective}` : "",
29
+ fields.files ? `Files: ${fields.files}` : "",
30
+ fields.tests ? `Tests: ${fields.tests}` : "",
31
+ Array.isArray(phase?.tasks) && phase.tasks.length
32
+ ? `Tasks:\n${phase.tasks.map((task) => `- ${task}`).join("\n")}`
33
+ : "",
34
+ ]
35
+ .filter(Boolean)
36
+ .join("\n");
37
+
38
+ return `You are splitting ONE software build phase into concrete, independently-mergeable pull requests.
39
+ Return ONLY a JSON array (no prose, no markdown fences) of 1 to ${maxTickets} objects. Each object:
40
+ {"title": "imperative summary, <= 80 chars", "summary": "one sentence", "acceptance_criteria": ["short testable bullet", "..."]}
41
+ Rules: keep each PR small and shippable on its own; order them by dependency; 2-4 acceptance criteria each; no commentary.
42
+
43
+ ${lines}`;
44
+ }
45
+
46
+ /** Pull a JSON array out of a model response, tolerating code fences / prose. */
47
+ export function extractJsonArray(text) {
48
+ const raw = String(text || "").trim();
49
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
50
+ const body = (fenced ? fenced[1] : raw).trim();
51
+ const start = body.indexOf("[");
52
+ const end = body.lastIndexOf("]");
53
+ if (start === -1 || end === -1 || end <= start) return null;
54
+ try {
55
+ const parsed = JSON.parse(body.slice(start, end + 1));
56
+ return Array.isArray(parsed) ? parsed : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function normalizeSubTickets(parsed, maxTickets) {
63
+ if (!Array.isArray(parsed)) return null;
64
+ const out = [];
65
+ for (const item of parsed.slice(0, maxTickets)) {
66
+ const title = String(item?.title || "").trim();
67
+ if (!title) continue;
68
+ const summary = String(item?.summary || "").trim();
69
+ const acceptanceCriteria = Array.isArray(item?.acceptance_criteria)
70
+ ? item.acceptance_criteria
71
+ .map((value) => String(value || "").trim())
72
+ .filter(Boolean)
73
+ .slice(0, 6)
74
+ : [];
75
+ out.push({ title: title.slice(0, 120), summary, acceptanceCriteria });
76
+ }
77
+ return out.length ? out : null;
78
+ }
79
+
80
+ /** Enrich a single phase → array of {title, summary, acceptanceCriteria} or null. */
81
+ export async function enrichPhase({ phase, client, provider, model, apiKey, env, maxTicketsPerPhase }) {
82
+ if (!client || typeof client.invoke !== "function") return null;
83
+ const cap = clampInt(maxTicketsPerPhase, DEFAULT_ENRICH_LIMITS.maxTicketsPerPhase, 1, 10);
84
+ try {
85
+ const result = await client.invoke({
86
+ provider,
87
+ model,
88
+ prompt: buildEnrichPrompt(phase, cap),
89
+ apiKey,
90
+ env,
91
+ stream: false,
92
+ });
93
+ return normalizeSubTickets(extractJsonArray(result?.text), cap);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function subTicketDescription({ summary, acceptanceCriteria, dependencyLine }) {
100
+ const acBlock = acceptanceCriteria.length
101
+ ? acceptanceCriteria.map((item, index) => `${index + 1}. ${item}`).join("\n")
102
+ : "1. Phase outcomes are verified by deterministic checks.";
103
+ return [
104
+ `Dependencies: ${dependencyLine}`,
105
+ "",
106
+ summary ? `${summary}\n` : "",
107
+ "Acceptance criteria:",
108
+ acBlock,
109
+ ]
110
+ .filter((line) => line !== "")
111
+ .join("\n");
112
+ }
113
+
114
+ /**
115
+ * Produce an enriched ticket list from a generated guide. Each enriched phase
116
+ * expands into per-PR sub-tickets; phases beyond the cap, or that fail, keep
117
+ * their original heuristic ticket. Returns { tickets, enrichedPhases }.
118
+ */
119
+ export async function enrichGuideTickets({ guide, client, provider, model, apiKey, env, limits } = {}) {
120
+ const phases = Array.isArray(guide?.phases) ? guide.phases : [];
121
+ const baseTickets = Array.isArray(guide?.tickets) ? guide.tickets : [];
122
+ const maxPhases = clampInt(limits?.maxPhases, DEFAULT_ENRICH_LIMITS.maxPhases, 1, 50);
123
+ const maxTicketsPerPhase = clampInt(
124
+ limits?.maxTicketsPerPhase,
125
+ DEFAULT_ENRICH_LIMITS.maxTicketsPerPhase,
126
+ 1,
127
+ 10,
128
+ );
129
+
130
+ const tickets = [];
131
+ let enrichedPhases = 0;
132
+
133
+ for (let index = 0; index < phases.length; index += 1) {
134
+ const phase = phases[index];
135
+ const baseTicket = baseTickets[index];
136
+ const subTickets =
137
+ index < maxPhases
138
+ ? await enrichPhase({ phase, client, provider, model, apiKey, env, maxTicketsPerPhase })
139
+ : null;
140
+
141
+ if (!subTickets) {
142
+ if (baseTicket) tickets.push(baseTicket);
143
+ continue;
144
+ }
145
+
146
+ enrichedPhases += 1;
147
+ const issueNumber = index + 1;
148
+ const phaseDeps = baseTicket?.dependencies || phase?.dependencies || [];
149
+ const baseLabels = baseTicket?.labels || ["sentinelayer", "build-guide", `phase-${issueNumber}`];
150
+
151
+ subTickets.forEach((sub, subIndex) => {
152
+ const dependencies =
153
+ subIndex === 0 ? phaseDeps : [tickets[tickets.length - 1].title];
154
+ const dependencyLine = dependencies.length > 0 ? dependencies.join(", ") : "none (entry phase)";
155
+ tickets.push({
156
+ id: `phase-${issueNumber}.${subIndex + 1}`,
157
+ phase_id: baseTicket?.phase_id || phase?.phaseId || "",
158
+ title: sub.title,
159
+ estimate_hours: baseTicket?.estimate_hours || { min: 4, max: 8 },
160
+ dependencies,
161
+ dependency_ids: subIndex === 0 ? baseTicket?.dependency_ids || [] : [],
162
+ labels: [...baseLabels, "pr"],
163
+ description: subTicketDescription({
164
+ summary: sub.summary,
165
+ acceptanceCriteria: sub.acceptanceCriteria,
166
+ dependencyLine,
167
+ }),
168
+ });
169
+ });
170
+ }
171
+
172
+ return { tickets, enrichedPhases };
173
+ }
@@ -41,6 +41,29 @@ function parseNumberedLines(block) {
41
41
  .filter(Boolean);
42
42
  }
43
43
 
44
+ // Labeled bullets the builder emits per phase (e.g. "- Objective: ...").
45
+ // We capture these as structured fields so ticket bodies carry real content
46
+ // instead of dropping every non-numbered line.
47
+ const PHASE_FIELD_LABELS = new Map([
48
+ ["objective", "objective"],
49
+ ["dependencies", "dependencies"],
50
+ ["files", "files"],
51
+ ["commands", "commands"],
52
+ ["tests", "tests"],
53
+ ["rollback", "rollback"],
54
+ ["evidence", "evidence"],
55
+ ]);
56
+
57
+ // "Phase 0 (P0) — Repo Bootstrap" -> "P0"; "Phase 2 ..." -> "P2".
58
+ function parsePhaseHeadingId(title) {
59
+ const paren = String(title || "").match(/\(\s*([A-Za-z]+\d+)\s*\)/);
60
+ if (paren) {
61
+ return paren[1].toUpperCase();
62
+ }
63
+ const phaseNum = String(title || "").match(/^Phase\s+(\d+)\b/i);
64
+ return phaseNum ? `P${phaseNum[1]}` : "";
65
+ }
66
+
44
67
  function parsePhasePlan(specMarkdown) {
45
68
  const phaseBlock = sectionBody(specMarkdown, "Phase Plan");
46
69
  if (!phaseBlock) {
@@ -58,16 +81,39 @@ function parsePhasePlan(specMarkdown) {
58
81
  if (current) {
59
82
  phases.push(current);
60
83
  }
84
+ const title = headingMatch[1].trim();
61
85
  current = {
62
- title: headingMatch[1].trim(),
86
+ title,
87
+ phaseId: parsePhaseHeadingId(title),
63
88
  tasks: [],
89
+ fields: {},
64
90
  };
65
91
  continue;
66
92
  }
67
93
 
94
+ if (!current) {
95
+ continue;
96
+ }
97
+
68
98
  const taskMatch = line.match(/^\d+\.\s+(.+)$/);
69
- if (taskMatch && current) {
99
+ if (taskMatch) {
70
100
  current.tasks.push(taskMatch[1].trim());
101
+ continue;
102
+ }
103
+
104
+ const bulletMatch = line.match(/^[-*]\s+(.+)$/);
105
+ if (bulletMatch) {
106
+ const body = bulletMatch[1].trim();
107
+ const labelMatch = body.match(/^([A-Za-z][A-Za-z ]*?):\s*(.*)$/);
108
+ if (labelMatch) {
109
+ const key = labelMatch[1].trim().toLowerCase();
110
+ if (PHASE_FIELD_LABELS.has(key)) {
111
+ current.fields[PHASE_FIELD_LABELS.get(key)] = labelMatch[2].trim();
112
+ continue;
113
+ }
114
+ }
115
+ // An unlabeled bullet is real work -> treat it as a task.
116
+ current.tasks.push(body);
71
117
  }
72
118
  }
73
119
 
@@ -78,6 +124,46 @@ function parsePhasePlan(specMarkdown) {
78
124
  return phases;
79
125
  }
80
126
 
127
+ // Expand a dependency token into phase ids: "P0-P4" -> [P0..P4], "P0" -> [P0].
128
+ function expandPhaseRange(token) {
129
+ const raw = String(token || "").trim();
130
+ if (!raw) {
131
+ return [];
132
+ }
133
+ const range = raw.match(/^([A-Za-z]+)(\d+)\s*[-–—]\s*([A-Za-z]+)?(\d+)$/);
134
+ if (range) {
135
+ const prefix = range[1].toUpperCase();
136
+ const start = Number(range[2]);
137
+ const end = Number(range[4]);
138
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start && end - start <= 50) {
139
+ const out = [];
140
+ for (let value = start; value <= end; value += 1) {
141
+ out.push(`${prefix}${value}`);
142
+ }
143
+ return out;
144
+ }
145
+ }
146
+ const single = raw.match(/^([A-Za-z]+\d+)$/);
147
+ return single ? [single[1].toUpperCase()] : [];
148
+ }
149
+
150
+ // Parse a declared "Dependencies" field into a list of phase ids.
151
+ function parseDeclaredDependencies(value) {
152
+ const raw = String(value || "").trim();
153
+ if (!raw || /^none\b/i.test(raw)) {
154
+ return [];
155
+ }
156
+ const ids = [];
157
+ for (const part of raw.split(/[,;]/)) {
158
+ for (const id of expandPhaseRange(part)) {
159
+ if (!ids.includes(id)) {
160
+ ids.push(id);
161
+ }
162
+ }
163
+ }
164
+ return ids;
165
+ }
166
+
81
167
  function parseProjectName(specMarkdown) {
82
168
  const match = String(specMarkdown || "").match(/^#\s*SPEC\s*-\s*(.+)$/im);
83
169
  return match ? match[1].trim() : "Project";
@@ -113,19 +199,58 @@ function estimateEffortHours({ phaseTitle, taskCount, riskSurfaceCount }) {
113
199
  };
114
200
  }
115
201
 
116
- function normalizeAcceptanceCriteria(specMarkdown, phaseTasks) {
202
+ // Real, phase-specific acceptance criteria derived from the captured fields
203
+ // (Tests/Evidence/Objective) and any tasks, instead of an empty placeholder.
204
+ function derivePhaseAcceptance(specMarkdown, phase) {
117
205
  const globalCriteria = parseNumberedLines(sectionBody(specMarkdown, "Acceptance Criteria"));
118
206
  if (globalCriteria.length > 0) {
119
- return globalCriteria.slice(0, 3);
207
+ return globalCriteria.slice(0, 5);
208
+ }
209
+ const fields = phase.fields || {};
210
+ const out = [];
211
+ if (fields.tests) {
212
+ out.push(`Tests pass: ${fields.tests}`);
213
+ }
214
+ if (fields.evidence) {
215
+ out.push(`Evidence captured: ${fields.evidence}`);
216
+ }
217
+ if (fields.objective) {
218
+ out.push(`Objective met: ${fields.objective}`);
120
219
  }
121
- return phaseTasks.slice(0, 3).map((task) => `Validated completion: ${task}`);
220
+ for (const task of (phase.tasks || []).slice(0, 3)) {
221
+ out.push(`Completed: ${task}`);
222
+ }
223
+ if (out.length === 0) {
224
+ out.push("Phase outcomes are verified by deterministic checks.");
225
+ }
226
+ return out.slice(0, 5);
227
+ }
228
+
229
+ // Structured detail lines (objective/files/tests/...) for the ticket body.
230
+ function renderPhaseDetailLines(phase) {
231
+ const fields = phase.fields || {};
232
+ const order = ["objective", "files", "commands", "tests", "rollback", "evidence"];
233
+ const labels = {
234
+ objective: "Objective",
235
+ files: "Files",
236
+ commands: "Commands",
237
+ tests: "Tests",
238
+ rollback: "Rollback",
239
+ evidence: "Evidence",
240
+ };
241
+ return order
242
+ .filter((key) => String(fields[key] || "").trim().length > 0)
243
+ .map((key) => `${labels[key]}: ${fields[key]}`);
122
244
  }
123
245
 
124
246
  function renderPhaseMarkdown(phase) {
247
+ const detailLines = renderPhaseDetailLines(phase);
248
+ const detailBlock =
249
+ detailLines.length > 0 ? `\n${detailLines.map((line) => `- ${line}`).join("\n")}` : "";
125
250
  const taskLines =
126
251
  phase.tasks.length > 0
127
252
  ? phase.tasks.map((task, index) => `${index + 1}. ${task}`).join("\n")
128
- : "1. Define implementation tasks for this phase.";
253
+ : "1. Deliver the phase objective above with deterministic checks.";
129
254
  const acceptanceLines =
130
255
  phase.acceptanceCriteria.length > 0
131
256
  ? phase.acceptanceCriteria.map((item, index) => `${index + 1}. ${item}`).join("\n")
@@ -135,7 +260,7 @@ function renderPhaseMarkdown(phase) {
135
260
 
136
261
  return `### ${phase.title}
137
262
  - Estimated effort: ${phase.effort.label}
138
- - Dependencies: ${dependencyLine}
263
+ - Dependencies: ${dependencyLine}${detailBlock}
139
264
 
140
265
  #### Implementation Tasks
141
266
  ${taskLines}
@@ -147,29 +272,38 @@ ${acceptanceLines}
147
272
 
148
273
  function buildTicket(phase, index) {
149
274
  const issueNumber = index + 1;
275
+ const phaseId = String(phase.phaseId || "").trim();
150
276
  const labels = ["sentinelayer", "build-guide", `phase-${issueNumber}`];
277
+ if (phaseId) {
278
+ labels.push(phaseId.toLowerCase());
279
+ }
151
280
  const dependencyLine =
152
281
  phase.dependencies.length > 0 ? phase.dependencies.join(", ") : "none (entry phase)";
153
282
  const acceptanceBlock = phase.acceptanceCriteria
154
283
  .map((item, criterionIndex) => `${criterionIndex + 1}. ${item}`)
155
284
  .join("\n");
156
285
  const taskBlock = phase.tasks.map((task, taskIndex) => `${taskIndex + 1}. ${task}`).join("\n");
286
+ const detailLines = renderPhaseDetailLines(phase);
287
+ const detailBlock = detailLines.length > 0 ? ["Details:", ...detailLines, ""] : [];
157
288
 
158
289
  return {
159
290
  id: `phase-${issueNumber}`,
291
+ phase_id: phaseId,
160
292
  title: phase.title,
161
293
  estimate_hours: {
162
294
  min: phase.effort.minHours,
163
295
  max: phase.effort.maxHours,
164
296
  },
165
297
  dependencies: phase.dependencies,
298
+ dependency_ids: phase.dependencyIds || [],
166
299
  labels,
167
300
  description: [
168
301
  `Dependencies: ${dependencyLine}`,
169
302
  `Estimated effort: ${phase.effort.label}`,
170
303
  "",
304
+ ...detailBlock,
171
305
  "Implementation tasks:",
172
- taskBlock || "1. Define implementation tasks for this phase.",
306
+ taskBlock || "1. Deliver the phase objective above with deterministic checks.",
173
307
  "",
174
308
  "Acceptance criteria:",
175
309
  acceptanceBlock || "1. Phase outcomes are verified by deterministic checks.",
@@ -210,19 +344,42 @@ export function generateBuildGuide({
210
344
  const goal = parseGoal(source);
211
345
  const riskSurfaceCount = parseRiskSurfaceCount(source);
212
346
 
347
+ // Map declared phase ids (P0, P1, ...) to titles so a "Dependencies: P0-P1"
348
+ // line resolves to a real prerequisite graph instead of naive sequencing.
349
+ const idToTitle = new Map(
350
+ phases.filter((phase) => phase.phaseId).map((phase) => [phase.phaseId, phase.title])
351
+ );
352
+
213
353
  const resolvedPhases = phases.map((phase, index) => {
214
- const dependencies = index > 0 ? [phases[index - 1].title] : [];
354
+ const declaredIds = parseDeclaredDependencies(phase.fields?.dependencies);
355
+ const knownIds = declaredIds.filter(
356
+ (id) => idToTitle.has(id) && idToTitle.get(id) !== phase.title
357
+ );
358
+ let dependencies;
359
+ if (knownIds.length > 0) {
360
+ // Honor the spec's declared dependency graph.
361
+ dependencies = knownIds.map((id) => idToTitle.get(id));
362
+ } else if (declaredIds.length === 0 && index > 0) {
363
+ // Nothing declared -> fall back to the previous phase only.
364
+ dependencies = [phases[index - 1].title];
365
+ } else {
366
+ // Declared "none", or deps that don't resolve -> entry phase.
367
+ dependencies = [];
368
+ }
215
369
  const effort = estimateEffortHours({
216
370
  phaseTitle: phase.title,
217
371
  taskCount: phase.tasks.length,
218
372
  riskSurfaceCount,
219
373
  });
220
- const acceptanceCriteria = normalizeAcceptanceCriteria(source, phase.tasks);
374
+ const acceptanceCriteria = derivePhaseAcceptance(source, phase);
221
375
 
222
376
  return {
223
377
  title: phase.title,
378
+ phaseId: phase.phaseId,
224
379
  tasks: phase.tasks,
380
+ fields: phase.fields,
225
381
  dependencies,
382
+ dependencyIds: knownIds,
226
383
  effort,
227
384
  acceptanceCriteria,
228
385
  };
package/src/legacy-cli.js CHANGED
@@ -2133,6 +2133,13 @@ Project: ${projectName}
2133
2133
  - [ ] Re-run gate and confirm clean status.
2134
2134
  - [ ] Merge only after quality gates are green.
2135
2135
 
2136
+ ## Ticket Trail Contract (Per PR — lean, only if the project has a board/Jira)
2137
+ - [ ] One ticket = one PR; the PR body carries the ticket id.
2138
+ - [ ] On PR open: move the ticket to In-review + comment the PR link.
2139
+ - [ ] On merge + green: move the ticket to Done + comment "merged, gate green".
2140
+ - [ ] On gate fail: move the ticket to Blocked + the finding.
2141
+ - [ ] One update per transition — not every step (same discipline as senti).
2142
+
2136
2143
  ## Command Roadmap (Local Terminal)
2137
2144
  - [ ] \`sentinel /omargate deep --path <repo>\`: local deep scan pipeline
2138
2145
  - [ ] \`sentinel /audit --path <repo>\`: security + quality audit summary
@@ -2219,6 +2226,13 @@ Execution mode:
2219
2226
  - Keep commits scoped and deterministic.
2220
2227
  - Stop only for blocking secrets/permission gaps.
2221
2228
 
2229
+ Ticket trail (lean, only if the project has a board/Jira — do this on every PR, not every step):
2230
+ - One ticket = one PR; put the ticket id in the PR body.
2231
+ - On PR open -> move the ticket to In-review and comment the PR link.
2232
+ - On merge + green -> move the ticket to Done and comment "merged, gate green".
2233
+ - On gate fail -> move the ticket to Blocked with the finding.
2234
+ - Post one short senti update per transition (same discipline as the ticket).
2235
+
2222
2236
  Coding agent profile:
2223
2237
  - Selected agent: ${codingAgentProfile.name} (${codingAgentProfile.id})
2224
2238
  - Prompt target: ${codingAgentProfile.promptTarget}
@@ -0,0 +1,99 @@
1
+ import { createAgentEvent } from "../events/schema.js";
2
+ import { appendToStream } from "./stream.js";
3
+
4
+ export const FIRST_MESSAGE_AGENT = Object.freeze({
5
+ id: "senti",
6
+ model: "senti",
7
+ persona: "Senti",
8
+ shortName: "Senti",
9
+ color: "magenta",
10
+ avatar: "S",
11
+ });
12
+
13
+ function normalizeString(value) {
14
+ return String(value || "").trim();
15
+ }
16
+
17
+ /**
18
+ * The deterministic "first Senti message" pinned to every new session —
19
+ * the approved Carter draft combining the GPT/Codex lanes and the Claude
20
+ * lane. Model-aware (each agent uses the path that fits its runtime),
21
+ * command-accurate, low-power. `<AGENT_ID>` stays a placeholder each agent
22
+ * substitutes; the session id is filled in.
23
+ */
24
+ export function buildFirstSentiMessage({ sessionId } = {}) {
25
+ const sid = normalizeString(sessionId) || "<SID>";
26
+ return [
27
+ `👋 Welcome to this Senti coding room (session ${sid}). The transcript IS the deliverable evidence. Don't go dark, don't make top-level noise. Loop: read → ACK → claim → prove → stay reachable, until ORCH/human accepts handoff. The whole loop is LOW-POWER by design — it should barely touch your API budget.`,
28
+ "",
29
+ "IDENTITY — your name = lane + model (api-01-gpt-5.5, ui-01-gpt-5.5, verify-01-opus-4.8).",
30
+ "",
31
+ "MANDATORY FIRST COMMANDS, in order:",
32
+ `1) sl --help && sl session --help (trust local help over memory)`,
33
+ `2) sl session join ${sid} --agent <AGENT_ID> --model <MODEL> --role <role>`,
34
+ `3) sl ai identity provision --execute ; sl ai identity list`,
35
+ `4) sl session pins ${sid} --json`,
36
+ `5) sl session read ${sid} --remote --agent <AGENT_ID> --tail 60 (records your view receipt)`,
37
+ `6) sl session react ${sid} ack --target-sequence <THIS_SEQ> --agent <AGENT_ID>`,
38
+ `7) sl session reply ${sid} <THIS_SEQ> "ACK <AGENT_ID> role=<role> model=<model> locks=none first_action=<one concrete action>" --agent <AGENT_ID>`,
39
+ "",
40
+ "STAY REACHABLE — two honest paths, use YOURS (always pass --agent <AGENT_ID> so the room knows who's listening):",
41
+ `- Persistent process (Codex/GPT): keep ONE listener alive + a 60s watchdog that restarts it if the pid dies (cursor catch-up replays anything missed):`,
42
+ ` sl session listen --session ${sid} --agent <AGENT_ID> --active-interval 30 --active-window 300 --interval 60 --presence-interval 60 --model <MODEL> --display-name <AGENT_ID>`,
43
+ `- No socket (Claude Code): you are NOT continuously connected — you re-arm a wake each turn. 30-270s while actively waiting, 20-30min idle when quiet. Treat time-critical things as POSTED messages you catch next tick, never assumed instant.`,
44
+ `CADENCE both converge to: ~30s active; after 5min quiet → 60s; after another 5 → 90s; +30s every 5min to a floor. Wake IMMEDIATELY on: new human/ORCH msg, a direct reply/@mention, a lock conflict, a deploy/gate notice.`,
45
+ "",
46
+ "REACTIONS — lowest-noise action that tells the truth (one line each):",
47
+ `- ack — "seen + accountable." Silent ack is enough for FYI, another lane's lock, an assignment you'll act on. No comment needed.`,
48
+ `- working_on — ONLY when actually taking scope: sl session action ${sid} working_on --target-sequence <SEQ> --agent <AGENT_ID> --note "<action>; ETA <t>"`,
49
+ `- reply — a real answer/decision/blocker/evidence/done. Reply UNDER the message you're answering.`,
50
+ `- like — agreement, no text useful. dislike — materially wrong/unsafe; ALWAYS pair with a correction reply. disregard — supersede your OWN mistaken action. view — receipt only, not an ACK.`,
51
+ "",
52
+ "THREADING (this is the social-media-for-AI part — keep it clean):",
53
+ `- Reply UNDER the message you're answering. Do NOT start a new top-level post for a reply.`,
54
+ `- Adding to your OWN comment? Don't post a sibling — NEST it (unlimited depth, like IG):`,
55
+ ` sl session action ${sid} reply --target-action-id <YOUR_ACTION_UUID> --agent <AGENT_ID> --note "UPDATE: <one compact line>"`,
56
+ ` (find UUIDs: sl session read ${sid} --remote --agent <AGENT_ID> --tail 20 --json)`,
57
+ `- DO start a new top-level post when the topic is genuinely UNRELATED, or for: a phase decision, a room-wide blocker, deploy/gate evidence, a handoff, or a recap. Unrelated → new post is correct. Related → nest.`,
58
+ "",
59
+ `LOCKS before edits: sl session locks ${sid} --json → sl session lock ${sid} <files...> --agent <AGENT_ID> --intent "<why>" → unlock when done. Never touch another lane's lock.`,
60
+ "",
61
+ `PROVE, DON'T RECALL: "done" carries evidence: command=<exact> outcome=<key output> artifact=<PR/link>. If a check can't run, say why + the substitute. Never paste secrets; post privileged actions as evidence: cmd+outcome.`,
62
+ "",
63
+ `TICKET TRAIL (if the project has a board/Jira) — one ticket = one PR, lean like senti: on PR open → move the ticket to In-review + comment the PR link; on merge+green → Done; on gate-fail → Blocked + the finding. One update per transition, not every step. The PR body carries the ticket id.`,
64
+ "",
65
+ "LESSONS + GOALS (keep these explicit so a fresh turn is productive immediately):",
66
+ `- LESSONS: after ANY human correction, append trigger / mistake / prevention-rule to the project lessons file (tasks/lessons.md or LESSONS.md).`,
67
+ `- GOAL note: objective, stop_conditions, credentials_allowed, validation, last_seen_sequence, resume_command. Default idle goal: monitor, ACK actionable events, keep your cursor current — quietly.`,
68
+ "",
69
+ "EXIT only after ORCH/human accepts handoff in-thread.",
70
+ ].join("\n");
71
+ }
72
+
73
+ /**
74
+ * Post the first-Senti-message as the opening event of a freshly created
75
+ * session. Best-effort + non-blocking — a failure never fails session
76
+ * creation. Returns { posted, reason }.
77
+ */
78
+ export async function postFirstSentiMessage({ sessionId, targetPath = process.cwd() } = {}) {
79
+ const sid = normalizeString(sessionId);
80
+ if (!sid) {
81
+ return { posted: false, reason: "missing_session_id" };
82
+ }
83
+ const event = createAgentEvent({
84
+ event: "session_message",
85
+ agent: FIRST_MESSAGE_AGENT,
86
+ sessionId: sid,
87
+ payload: {
88
+ message: buildFirstSentiMessage({ sessionId: sid }),
89
+ channel: "session",
90
+ firstMessage: true,
91
+ },
92
+ });
93
+ try {
94
+ await appendToStream(sid, event, { targetPath, awaitRemoteSync: true });
95
+ return { posted: true, reason: "posted" };
96
+ } catch (error) {
97
+ return { posted: false, reason: normalizeString(error?.message) || "append_failed" };
98
+ }
99
+ }
@@ -0,0 +1,126 @@
1
+ import { pollSessionEventsBefore } from "./sync.js";
2
+
3
+ const LISTENER_EVENT_TYPES = new Set([
4
+ "session_listener_started",
5
+ "session_listener_heartbeat",
6
+ "session_listener_stopped",
7
+ ]);
8
+
9
+ // A heartbeat older than this (and not explicitly stopped) means the
10
+ // listener likely died without a clean stop — show it as stale, not live.
11
+ const DEFAULT_STALE_AFTER_MS = 180_000;
12
+
13
+ function normalizeString(value) {
14
+ return String(value || "").trim();
15
+ }
16
+
17
+ function eventEpochMs(event) {
18
+ const raw = normalizeString(event?.ts) || normalizeString(event?.timestamp);
19
+ const parsed = Date.parse(raw);
20
+ return Number.isFinite(parsed) ? parsed : null;
21
+ }
22
+
23
+ function readRecord(value) {
24
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
25
+ }
26
+
27
+ function positiveInt(value) {
28
+ const n = Number(value);
29
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
30
+ }
31
+
32
+ /**
33
+ * Reduce a stream of session_listener_* events into one row per agent: who is
34
+ * listening, at what cadence, and whether they're currently active or idle.
35
+ * Pure + testable — the command layer just fetches events and renders this.
36
+ */
37
+ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfterMs = DEFAULT_STALE_AFTER_MS } = {}) {
38
+ const latestByAgent = new Map();
39
+ for (const event of Array.isArray(events) ? events : []) {
40
+ const type = normalizeString(event?.event);
41
+ if (!LISTENER_EVENT_TYPES.has(type)) continue;
42
+ const agentId = normalizeString(event?.agent?.id) || normalizeString(readRecord(event?.payload).listenerId);
43
+ if (!agentId) continue;
44
+ const epoch = eventEpochMs(event) ?? 0;
45
+ const existing = latestByAgent.get(agentId);
46
+ if (!existing || epoch >= existing.epoch) {
47
+ latestByAgent.set(agentId, { event, type, epoch });
48
+ }
49
+ }
50
+
51
+ const rows = [];
52
+ for (const [agentId, { event, type, epoch }] of latestByAgent) {
53
+ const payload = readRecord(event.payload);
54
+ const ageMs = epoch ? Math.max(0, nowMs - epoch) : null;
55
+ const stopped = type === "session_listener_stopped";
56
+ const active = Boolean(payload.active);
57
+ const idleIntervalSeconds = positiveInt(payload.idleIntervalSeconds);
58
+ const activeIntervalSeconds = positiveInt(payload.activeIntervalSeconds);
59
+ const nextPollSeconds = positiveInt(payload.nextPollMs)
60
+ ? Math.round(positiveInt(payload.nextPollMs) / 1000)
61
+ : null;
62
+ // The effective cadence right now: active window uses the fast interval,
63
+ // otherwise the idle interval; fall back to the reported next poll.
64
+ const cadenceSeconds = active
65
+ ? activeIntervalSeconds || nextPollSeconds
66
+ : idleIntervalSeconds || nextPollSeconds;
67
+ let status;
68
+ if (stopped) status = "stopped";
69
+ else if (ageMs !== null && ageMs > staleAfterMs) status = "stale";
70
+ else status = active ? "active" : "idle";
71
+
72
+ rows.push({
73
+ agentId,
74
+ displayName: normalizeString(event.agent?.displayName) || agentId,
75
+ model: normalizeString(event.agent?.model),
76
+ status,
77
+ active,
78
+ cadenceSeconds: cadenceSeconds ?? null,
79
+ idleIntervalSeconds,
80
+ activeIntervalSeconds,
81
+ nextPollSeconds,
82
+ lastSeenAt: epoch ? new Date(epoch).toISOString() : null,
83
+ lastSeenAgoSeconds: ageMs !== null ? Math.round(ageMs / 1000) : null,
84
+ lastHumanActivityAt: normalizeString(payload.lastHumanActivityAt) || null,
85
+ });
86
+ }
87
+
88
+ // Live listeners first, then by most-recently-seen.
89
+ const statusRank = { active: 0, idle: 1, stale: 2, stopped: 3 };
90
+ rows.sort((a, b) => {
91
+ const r = (statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9);
92
+ if (r !== 0) return r;
93
+ return (b.lastSeenAt || "").localeCompare(a.lastSeenAt || "");
94
+ });
95
+ return rows;
96
+ }
97
+
98
+ /**
99
+ * Fetch recent session events from the API and summarize the listeners.
100
+ * `limit` controls how far back we look for heartbeats.
101
+ */
102
+ export async function fetchSessionListeners(
103
+ sessionId,
104
+ { targetPath = process.cwd(), limit = 200, nowMs = Date.now, poll = pollSessionEventsBefore } = {}
105
+ ) {
106
+ const result = await poll(sessionId, { targetPath, limit });
107
+ if (!result?.ok) {
108
+ return { ok: false, reason: normalizeString(result?.reason) || "fetch_failed", listeners: [] };
109
+ }
110
+ const listeners = summarizeListeners(result.events || [], { nowMs: nowMs() });
111
+ return { ok: true, sessionId: normalizeString(sessionId), listeners };
112
+ }
113
+
114
+ export function formatListenerLine(row) {
115
+ const cadence = row.cadenceSeconds ? `${row.cadenceSeconds}s` : "—";
116
+ const seen = row.lastSeenAgoSeconds === null ? "never" : `${row.lastSeenAgoSeconds}s ago`;
117
+ const statusLabel =
118
+ row.status === "active"
119
+ ? "● active"
120
+ : row.status === "idle"
121
+ ? "○ idle"
122
+ : row.status === "stale"
123
+ ? "◌ stale"
124
+ : "× stopped";
125
+ return `${statusLabel.padEnd(10)} ${row.agentId.padEnd(24)} cadence=${cadence.padEnd(6)} last_seen=${seen}`;
126
+ }
@@ -0,0 +1,144 @@
1
+ import { createResolveTarget } from "./resolve-target.js";
2
+ import claudeWakeAdapter from "./claude.js";
3
+ import codexWakeAdapter from "./codex.js";
4
+
5
+ const BUILTIN_ADAPTERS = {
6
+ claude: claudeWakeAdapter,
7
+ codex: codexWakeAdapter,
8
+ };
9
+
10
+ // Receipt-confirmation defaults (Carter's idea: a wake isn't done until the
11
+ // agent actually acks/views the message). Conservative so reconcile never
12
+ // spam-resumes a slow-but-awake agent.
13
+ const DEFAULT_CONFIRM_WINDOW_MS = 90_000;
14
+ const DEFAULT_MAX_WAKE_ATTEMPTS = 3;
15
+ const RECEIPT_ACTION_TYPES = new Set(["ack", "view", "reply", "working_on", "like"]);
16
+
17
+ function normalizeString(value) {
18
+ return String(value || "").trim();
19
+ }
20
+
21
+ function eventSequence(event) {
22
+ const raw = event?.sequenceId ?? event?.sequence_id ?? event?.payload?.sequenceId;
23
+ const n = Number(raw);
24
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
25
+ }
26
+
27
+ /**
28
+ * Wire the built wake bus (resolve-target routing + a host adapter) into the
29
+ * live `sl session listen` poll so an addressed message INSTANTLY resumes the
30
+ * host — the auto-wake cutover — and CONFIRMS the wake via read receipts.
31
+ *
32
+ * Returns { trigger, reconcile, pendingCount } or null when disabled:
33
+ * - trigger(event): route + resume the host on an addressed message, and (if
34
+ * the message has a durable sequence) record a pending wake to confirm.
35
+ * - reconcile({ fetchActions, nowMs }): for each pending wake, fetch the
36
+ * message's actions; if THIS agent acked/viewed/replied → confirmed (woke).
37
+ * Else past the confirm window, re-resume (up to maxAttempts) — the agent
38
+ * didn't wake. Past maxAttempts → dead-letter. Never throws.
39
+ */
40
+ export function createListenerHostWake({
41
+ host,
42
+ resumeSessionId,
43
+ agentId,
44
+ sessionId,
45
+ adapters = BUILTIN_ADAPTERS,
46
+ confirmWindowMs = DEFAULT_CONFIRM_WINDOW_MS,
47
+ maxAttempts = DEFAULT_MAX_WAKE_ATTEMPTS,
48
+ } = {}) {
49
+ const hostName = normalizeString(host).toLowerCase();
50
+ const resumeId = normalizeString(resumeSessionId);
51
+ const selfId = normalizeString(agentId);
52
+ const sid = normalizeString(sessionId);
53
+ const adapter = adapters[hostName];
54
+ if (!adapter || typeof adapter.wake !== "function") return null;
55
+ if (!resumeId || !selfId || !sid) return null;
56
+
57
+ const resolveTarget = createResolveTarget({
58
+ agentId: selfId,
59
+ host: hostName,
60
+ sessionId: resumeId,
61
+ });
62
+
63
+ // Serialize resumes: each spawns a host process; never two at once.
64
+ let queue = Promise.resolve();
65
+ // seq -> { target, attempts, lastWakeAt }
66
+ const pending = new Map();
67
+
68
+ function resume(target) {
69
+ queue = queue.then(async () => {
70
+ try {
71
+ const result = await adapter.wake(target);
72
+ return {
73
+ woken: Boolean(result?.ok),
74
+ ok: Boolean(result?.ok),
75
+ reason: result?.ok ? "resumed" : normalizeString(result?.reason) || "wake_failed",
76
+ host: hostName,
77
+ };
78
+ } catch (error) {
79
+ return { woken: false, ok: false, reason: normalizeString(error?.message) || "wake_error", host: hostName };
80
+ }
81
+ });
82
+ return queue;
83
+ }
84
+
85
+ function trigger(event) {
86
+ const target = resolveTarget(event);
87
+ if (!target) return Promise.resolve({ woken: false, reason: "not_routed" });
88
+ const seq = eventSequence(event);
89
+ // Record a pending wake to confirm. lastWakeAt is stamped on the first
90
+ // reconcile (callers own the clock) so the confirm window starts then.
91
+ if (seq !== null && !pending.has(seq)) {
92
+ pending.set(seq, { target, attempts: 1, lastWakeAt: Number.NaN });
93
+ }
94
+ return resume(target);
95
+ }
96
+
97
+ async function reconcile({ fetchActions, nowMs = 0 } = {}) {
98
+ const summary = { confirmed: 0, retried: 0, deadLettered: 0, stillPending: 0 };
99
+ if (typeof fetchActions !== "function" || pending.size === 0) {
100
+ summary.stillPending = pending.size;
101
+ return summary;
102
+ }
103
+ for (const [seq, entry] of [...pending.entries()]) {
104
+ if (!Number.isFinite(entry.lastWakeAt)) entry.lastWakeAt = nowMs;
105
+ let actions = [];
106
+ try {
107
+ const res = await fetchActions(seq);
108
+ actions = Array.isArray(res?.actions) ? res.actions : [];
109
+ } catch {
110
+ // transient fetch error — leave pending, try next reconcile
111
+ summary.stillPending += 1;
112
+ continue;
113
+ }
114
+ const acked = actions.some(
115
+ (a) =>
116
+ normalizeString(a?.actorId).toLowerCase() === selfId.toLowerCase() &&
117
+ RECEIPT_ACTION_TYPES.has(normalizeString(a?.actionType).toLowerCase()),
118
+ );
119
+ if (acked) {
120
+ pending.delete(seq);
121
+ summary.confirmed += 1;
122
+ continue;
123
+ }
124
+ if (nowMs - entry.lastWakeAt >= confirmWindowMs) {
125
+ if (entry.attempts < maxAttempts) {
126
+ entry.attempts += 1;
127
+ entry.lastWakeAt = nowMs;
128
+ void resume(entry.target);
129
+ summary.retried += 1;
130
+ } else {
131
+ pending.delete(seq);
132
+ summary.deadLettered += 1;
133
+ }
134
+ } else {
135
+ summary.stillPending += 1;
136
+ }
137
+ }
138
+ return summary;
139
+ }
140
+
141
+ return { trigger, reconcile, pendingCount: () => pending.size };
142
+ }
143
+
144
+ export { BUILTIN_ADAPTERS };