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.
- package/README.md +22 -0
- package/package.json +1 -1
- package/src/audit/orchestrator.js +17 -0
- package/src/commands/audit.js +58 -10
- package/src/commands/guide.js +52 -0
- package/src/commands/session.js +310 -17
- package/src/guide/enrich.js +173 -0
- package/src/guide/generator.js +167 -10
- package/src/legacy-cli.js +88 -1
- package/src/session/audit-reporter.js +164 -0
- package/src/session/daemon-spawn.js +192 -0
- package/src/session/first-message.js +99 -0
- package/src/session/listeners.js +126 -0
- package/src/session/project-bootstrap.js +115 -0
- package/src/session/wake/listen-wake.js +144 -0
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
|
|
@@ -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
|
-
|
|
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
|
+
}
|