sentinelayer-cli 0.22.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.
@@ -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
@@ -2182,6 +2189,7 @@ export function buildHandoffPrompt({
2182
2189
  buildFromExistingRepo,
2183
2190
  authMode,
2184
2191
  codingAgent,
2192
+ sessionId,
2185
2193
  }) {
2186
2194
  const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
2187
2195
  const codingAgentConfigPath = codingAgentProfile.configFile || "none";
@@ -2218,6 +2226,13 @@ Execution mode:
2218
2226
  - Keep commits scoped and deterministic.
2219
2227
  - Stop only for blocking secrets/permission gaps.
2220
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
+
2221
2236
  Coding agent profile:
2222
2237
  - Selected agent: ${codingAgentProfile.name} (${codingAgentProfile.id})
2223
2238
  - Prompt target: ${codingAgentProfile.promptTarget}
@@ -2245,7 +2260,16 @@ Repo context:
2245
2260
  - Target repo: ${repoSlug || "not provided"}
2246
2261
  - Workspace mode: ${buildFromExistingRepo ? "existing codebase" : "new scaffold"}
2247
2262
 
2248
- ## Multi-Agent Coordination (if session active)
2263
+ ${
2264
+ String(sessionId || "").trim()
2265
+ ? `## Multi-Agent Coordination
2266
+
2267
+ Project senti session (auto-created at init): \`${String(sessionId).trim()}\`
2268
+ - Join before starting work: \`sl session join ${String(sessionId).trim()} --agent <your-agent-name>\`
2269
+ - Post status updates as you work: \`sl session say ${String(sessionId).trim()} "<update>" --agent <your-agent-name>\`
2270
+ - Audit runs (\`sentinel /audit\`) relay per-persona progress into this session automatically, so swarm agents can watch each other's findings without losing context.`
2271
+ : `## Multi-Agent Coordination (if session active)`
2272
+ }
2249
2273
 
2250
2274
  ${renderCoordinationNumberedList()}
2251
2275
 
@@ -2577,6 +2601,7 @@ async function writeInitConfigLockfile({
2577
2601
  secretName,
2578
2602
  repoSlug,
2579
2603
  workflowPath,
2604
+ sessionId,
2580
2605
  }) {
2581
2606
  const lockDir = path.join(projectDir, ".sentinelayer");
2582
2607
  const configPath = path.join(lockDir, "config.json");
@@ -2588,6 +2613,7 @@ async function writeInitConfigLockfile({
2588
2613
  required_secret_name: String(secretName || "SENTINELAYER_TOKEN").trim() || "SENTINELAYER_TOKEN",
2589
2614
  repo_slug: normalizeRepoSlug(repoSlug || ""),
2590
2615
  workflow_path: path.relative(projectDir, workflowPath).replace(/\\/g, "/"),
2616
+ session_id: String(sessionId || "").trim(),
2591
2617
  };
2592
2618
 
2593
2619
  await fsp.mkdir(lockDir, { recursive: true });
@@ -3145,6 +3171,26 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3145
3171
  expectedSpecId: generatedSpecId || workflowSpecIdFromTemplate,
3146
3172
  });
3147
3173
  }
3174
+ // Project senti session: every new project gets its own coordination room
3175
+ // so agents (audit personas, builders, reviewers) can post progress and see
3176
+ // each other's messages without losing context. Local-first + best-effort:
3177
+ // an offline/unauthenticated init still completes.
3178
+ let projectSession = null;
3179
+ if (!boolFromEnv(process.env.SENTINELAYER_SKIP_PROJECT_SESSION)) {
3180
+ try {
3181
+ const { bootstrapProjectSession } = await import("./session/project-bootstrap.js");
3182
+ projectSession = await bootstrapProjectSession({
3183
+ projectDir,
3184
+ projectName: effectiveProjectName,
3185
+ skipGuides: true,
3186
+ });
3187
+ } catch (error) {
3188
+ console.log(
3189
+ pc.yellow(`! Senti project session bootstrap skipped: ${error?.message || error}`)
3190
+ );
3191
+ }
3192
+ }
3193
+
3148
3194
  const configLockfilePath = await writeInitConfigLockfile({
3149
3195
  projectDir,
3150
3196
  specId: workflowSpecId || generatedSpecId || workflowSpecIdFromTemplate,
@@ -3152,6 +3198,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3152
3198
  secretName,
3153
3199
  repoSlug: interview.repoSlug || detectRepoSlug(projectDir) || "",
3154
3200
  workflowPath,
3201
+ sessionId: projectSession?.sessionId || "",
3155
3202
  });
3156
3203
 
3157
3204
  await writeTextFile(
@@ -3177,6 +3224,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3177
3224
  buildFromExistingRepo: interview.buildFromExistingRepo,
3178
3225
  authMode: effectiveAuthMode,
3179
3226
  codingAgent: interview.codingAgent,
3227
+ sessionId: projectSession?.sessionId || "",
3180
3228
  })
3181
3229
  );
3182
3230
  await writeTextFile(
@@ -3189,6 +3237,19 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3189
3237
  codingAgent: interview.codingAgent,
3190
3238
  });
3191
3239
 
3240
+ // Guides go in after the coding-agent config so the config scaffold above
3241
+ // doesn't see a guide-created AGENTS.md/CLAUDE.md and skip itself.
3242
+ if (projectSession?.sessionId) {
3243
+ try {
3244
+ const { setupSessionGuides } = await import("./session/setup-guides.js");
3245
+ projectSession.guides = await setupSessionGuides(projectSession.sessionId, {
3246
+ targetPath: projectDir,
3247
+ });
3248
+ } catch (error) {
3249
+ console.log(pc.yellow(`! Session coordination guides skipped: ${error?.message || error}`));
3250
+ }
3251
+ }
3252
+
3192
3253
  await ensureSentinelStartScript(projectDir, effectiveProjectName);
3193
3254
 
3194
3255
  // Code scaffold: write starter source files, skip existing
@@ -3281,6 +3342,32 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
3281
3342
  printSection("Complete");
3282
3343
  console.log(pc.green(`✔ Sentinelayer orchestration initialized in ${projectDir}`));
3283
3344
  console.log(pc.green(`✔ Config lockfile written: ${configLockfilePath}`));
3345
+ if (projectSession?.sessionId) {
3346
+ console.log(pc.green(`✔ Senti project session created: ${projectSession.sessionId}`));
3347
+ console.log(pc.green(` Dashboard: ${projectSession.dashboardUrl}`));
3348
+ if (projectSession.daemon?.spawned) {
3349
+ console.log(
3350
+ pc.green(` Senti: managing this session (daemon pid ${projectSession.daemon.pid}, detached).`)
3351
+ );
3352
+ } else if (projectSession.daemon?.reason && projectSession.daemon.reason !== "disabled") {
3353
+ console.log(
3354
+ pc.yellow(
3355
+ ` Senti daemon not started (${projectSession.daemon.reason}); run: sl session daemon ${projectSession.sessionId}`
3356
+ )
3357
+ );
3358
+ }
3359
+ console.log(
3360
+ pc.gray(
3361
+ ` Agents coordinate here: sl session join ${projectSession.sessionId} --agent <name>; audit runs post progress automatically.`
3362
+ )
3363
+ );
3364
+ } else {
3365
+ console.log(
3366
+ pc.yellow(
3367
+ "! Senti project session not created. Run `sl session start` inside the project to create the coordination room."
3368
+ )
3369
+ );
3370
+ }
3284
3371
  if (workflowSpecId) {
3285
3372
  console.log(pc.green(`✔ Omar workflow spec binding validated: ${workflowSpecId}`));
3286
3373
  } else {
@@ -0,0 +1,164 @@
1
+ import { createAgentEvent } from "../events/schema.js";
2
+ import { registerAgent } from "./agent-registry.js";
3
+ import { listActiveSessions } from "./store.js";
4
+ import { appendToStream } from "./stream.js";
5
+
6
+ const ORCHESTRATOR_AGENT_ID = "audit-orchestrator";
7
+
8
+ function normalizeString(value) {
9
+ return String(value || "").trim();
10
+ }
11
+
12
+ function formatSeveritySummary(summary = {}) {
13
+ return `P0=${Number(summary.P0 || 0)} P1=${Number(summary.P1 || 0)} P2=${Number(summary.P2 || 0)} P3=${Number(summary.P3 || 0)}`;
14
+ }
15
+
16
+ function formatDurationSeconds(durationMs) {
17
+ return `${Math.max(0, Math.round(Number(durationMs || 0) / 1000))}s`;
18
+ }
19
+
20
+ /**
21
+ * Resolve which senti session an audit run should report into.
22
+ * Explicit id wins; otherwise the workspace's most recently active local
23
+ * session (the one `create-sentinelayer` bootstraps for new projects).
24
+ * Returns "" when relay is disabled or no session exists — audit runs
25
+ * never require a session.
26
+ */
27
+ export async function resolveAuditSessionId({
28
+ targetPath = process.cwd(),
29
+ explicitSessionId = "",
30
+ disabled = false,
31
+ } = {}) {
32
+ if (disabled) {
33
+ return "";
34
+ }
35
+ const explicit = normalizeString(explicitSessionId);
36
+ if (explicit) {
37
+ return explicit;
38
+ }
39
+ const sessions = await listActiveSessions({ targetPath }).catch(() => []);
40
+ if (!Array.isArray(sessions) || sessions.length === 0) {
41
+ return "";
42
+ }
43
+ const sorted = [...sessions].sort((left, right) =>
44
+ normalizeString(right.lastInteractionAt || right.updatedAt || right.createdAt).localeCompare(
45
+ normalizeString(left.lastInteractionAt || left.updatedAt || left.createdAt)
46
+ )
47
+ );
48
+ return normalizeString(sorted[0]?.sessionId);
49
+ }
50
+
51
+ /**
52
+ * Relay audit-orchestrator lifecycle events into a senti session so swarm
53
+ * personas can see each other's progress (start, per-agent completion,
54
+ * final summary) in the project's shared room.
55
+ *
56
+ * Posts are queued sequentially so transcript order matches audit order,
57
+ * and every post is best-effort: a session outage never fails an audit.
58
+ */
59
+ export function createAuditSessionReporter({ sessionId, targetPath = process.cwd() } = {}) {
60
+ const normalizedSessionId = normalizeString(sessionId);
61
+ if (!normalizedSessionId) {
62
+ return null;
63
+ }
64
+
65
+ const registeredAgents = new Set();
66
+ let postedCount = 0;
67
+ let failedCount = 0;
68
+ let queue = Promise.resolve();
69
+
70
+ const post = (agentId, message) => {
71
+ const id = normalizeString(agentId) || ORCHESTRATOR_AGENT_ID;
72
+ queue = queue.then(async () => {
73
+ try {
74
+ if (!registeredAgents.has(id)) {
75
+ registeredAgents.add(id);
76
+ await registerAgent(normalizedSessionId, {
77
+ agentId: id,
78
+ model: "audit-persona",
79
+ role: "auditor",
80
+ targetPath,
81
+ trackProcessExit: false,
82
+ }).catch(() => {});
83
+ }
84
+ const event = createAgentEvent({
85
+ event: "session_message",
86
+ agent: { id, persona: id },
87
+ sessionId: normalizedSessionId,
88
+ payload: { message, channel: "session" },
89
+ });
90
+ await appendToStream(normalizedSessionId, event, { targetPath, awaitRemoteSync: true });
91
+ postedCount += 1;
92
+ } catch {
93
+ failedCount += 1;
94
+ }
95
+ });
96
+ return queue;
97
+ };
98
+
99
+ const handleEvent = (evt) => {
100
+ if (!evt || typeof evt !== "object") {
101
+ return;
102
+ }
103
+ const payload = evt.payload && typeof evt.payload === "object" ? evt.payload : {};
104
+ switch (evt.event) {
105
+ case "phase_start":
106
+ if (payload.phase === "dispatch") {
107
+ void post(
108
+ ORCHESTRATOR_AGENT_ID,
109
+ `🔍 Audit dispatch started: ${Number(payload.agentCount || 0)} persona(s), max ${Number(payload.maxParallel || 1)} in parallel.`
110
+ );
111
+ }
112
+ break;
113
+ case "dispatch":
114
+ void post(
115
+ payload.agentId,
116
+ `▶ Starting ${normalizeString(payload.persona) || normalizeString(payload.agentId)} audit (${normalizeString(payload.domain) || "general"}).`
117
+ );
118
+ break;
119
+ case "agent_complete":
120
+ void post(
121
+ payload.agentId,
122
+ `✅ ${normalizeString(payload.agentId)} audit complete: ${Number(payload.findingCount || 0)} finding(s) (${formatSeveritySummary(payload.summary)}), status=${normalizeString(payload.status) || "ok"}, ${formatDurationSeconds(payload.durationMs)}.`
123
+ );
124
+ break;
125
+ case "phase_complete":
126
+ if (payload.phase === "dispatch") {
127
+ void post(
128
+ ORCHESTRATOR_AGENT_ID,
129
+ `Dispatch complete: ${Number(payload.agentCount || 0)} persona result(s) in ${formatDurationSeconds(payload.durationMs)}.`
130
+ );
131
+ }
132
+ break;
133
+ default:
134
+ break;
135
+ }
136
+ };
137
+
138
+ const stats = () => ({ posted: postedCount, failed: failedCount });
139
+
140
+ const completed = async (result = {}) => {
141
+ await post(
142
+ ORCHESTRATOR_AGENT_ID,
143
+ `🏁 Audit run ${normalizeString(result.runId)} complete — ${formatSeveritySummary(result.summary)} across ${Array.isArray(result.agentResults) ? result.agentResults.length : 0} persona(s). Report: ${normalizeString(result.reportMarkdownPath)}`
144
+ );
145
+ await queue;
146
+ return stats();
147
+ };
148
+
149
+ const failed = async (error) => {
150
+ await post(
151
+ ORCHESTRATOR_AGENT_ID,
152
+ `❌ Audit run failed: ${normalizeString(error?.message) || "unknown error"}`
153
+ );
154
+ await queue;
155
+ return stats();
156
+ };
157
+
158
+ return {
159
+ sessionId: normalizedSessionId,
160
+ handleEvent,
161
+ completed,
162
+ failed,
163
+ };
164
+ }