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 +1 -1
- package/src/commands/guide.js +52 -0
- package/src/commands/session.js +200 -2
- package/src/guide/enrich.js +173 -0
- package/src/guide/generator.js +167 -10
- package/src/legacy-cli.js +14 -0
- package/src/session/first-message.js +99 -0
- package/src/session/listeners.js +126 -0
- package/src/session/wake/listen-wake.js +144 -0
package/package.json
CHANGED
package/src/commands/guide.js
CHANGED
|
@@ -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
|
package/src/commands/session.js
CHANGED
|
@@ -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
|
+
}
|
package/src/guide/generator.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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 };
|