sentinelayer-cli 0.23.0 → 0.25.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 +285 -9
- package/src/coord/events-log.js +1 -1
- package/src/guide/enrich.js +173 -0
- package/src/guide/generator.js +167 -10
- package/src/legacy-cli.js +17 -0
- package/src/session/first-message.js +99 -0
- package/src/session/invitations.js +241 -0
- package/src/session/listeners.js +126 -0
- package/src/session/stream.js +31 -2
- package/src/session/wake/listen-wake.js +144 -0
|
@@ -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}
|
|
@@ -2280,6 +2294,9 @@ ${renderCoordinationNumberedList()}
|
|
|
2280
2294
|
- Finding: \`sl session say <id> "finding: [P2] missing rate limit on POST /api/auth/login"\`
|
|
2281
2295
|
- Help: \`sl session say <id> "help: unexpected change in package.json - who modified it?"\`
|
|
2282
2296
|
- Done: \`sl session say <id> "done: PR merged, auth hardening complete"\`
|
|
2297
|
+
|
|
2298
|
+
## Reserved Seat Onboarding
|
|
2299
|
+
- If \`sl session join\` returns \`onboardingGuide.markdownPath\`, read that file before posting; it contains your reserved-seat SOUL instructions, role, and first actions.
|
|
2283
2300
|
`;
|
|
2284
2301
|
}
|
|
2285
2302
|
|
|
@@ -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
|
+
}
|