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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Optional LLM enrichment for the deterministic decomposition.
3
+ *
4
+ * The heuristic in generator.js produces one ticket per phase. This layer asks
5
+ * a model to split each phase into concrete, independently-mergeable per-PR
6
+ * tickets with sharper acceptance criteria. It is:
7
+ * - opt-in (the caller passes a client),
8
+ * - capped (bounded phases × tickets, so cost is bounded),
9
+ * - fail-safe (any phase that errors or returns junk keeps its heuristic
10
+ * ticket — enrichment never throws and never drops work).
11
+ */
12
+
13
+ export const DEFAULT_ENRICH_LIMITS = Object.freeze({
14
+ maxPhases: 12,
15
+ maxTicketsPerPhase: 4,
16
+ });
17
+
18
+ function clampInt(value, fallback, min, max) {
19
+ const n = Math.floor(Number(value));
20
+ if (!Number.isFinite(n)) return fallback;
21
+ return Math.max(min, Math.min(max, n));
22
+ }
23
+
24
+ export function buildEnrichPrompt(phase, maxTickets) {
25
+ const fields = phase?.fields || {};
26
+ const lines = [
27
+ `Phase: ${String(phase?.title || "").trim()}`,
28
+ fields.objective ? `Objective: ${fields.objective}` : "",
29
+ fields.files ? `Files: ${fields.files}` : "",
30
+ fields.tests ? `Tests: ${fields.tests}` : "",
31
+ Array.isArray(phase?.tasks) && phase.tasks.length
32
+ ? `Tasks:\n${phase.tasks.map((task) => `- ${task}`).join("\n")}`
33
+ : "",
34
+ ]
35
+ .filter(Boolean)
36
+ .join("\n");
37
+
38
+ return `You are splitting ONE software build phase into concrete, independently-mergeable pull requests.
39
+ Return ONLY a JSON array (no prose, no markdown fences) of 1 to ${maxTickets} objects. Each object:
40
+ {"title": "imperative summary, <= 80 chars", "summary": "one sentence", "acceptance_criteria": ["short testable bullet", "..."]}
41
+ Rules: keep each PR small and shippable on its own; order them by dependency; 2-4 acceptance criteria each; no commentary.
42
+
43
+ ${lines}`;
44
+ }
45
+
46
+ /** Pull a JSON array out of a model response, tolerating code fences / prose. */
47
+ export function extractJsonArray(text) {
48
+ const raw = String(text || "").trim();
49
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
50
+ const body = (fenced ? fenced[1] : raw).trim();
51
+ const start = body.indexOf("[");
52
+ const end = body.lastIndexOf("]");
53
+ if (start === -1 || end === -1 || end <= start) return null;
54
+ try {
55
+ const parsed = JSON.parse(body.slice(start, end + 1));
56
+ return Array.isArray(parsed) ? parsed : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function normalizeSubTickets(parsed, maxTickets) {
63
+ if (!Array.isArray(parsed)) return null;
64
+ const out = [];
65
+ for (const item of parsed.slice(0, maxTickets)) {
66
+ const title = String(item?.title || "").trim();
67
+ if (!title) continue;
68
+ const summary = String(item?.summary || "").trim();
69
+ const acceptanceCriteria = Array.isArray(item?.acceptance_criteria)
70
+ ? item.acceptance_criteria
71
+ .map((value) => String(value || "").trim())
72
+ .filter(Boolean)
73
+ .slice(0, 6)
74
+ : [];
75
+ out.push({ title: title.slice(0, 120), summary, acceptanceCriteria });
76
+ }
77
+ return out.length ? out : null;
78
+ }
79
+
80
+ /** Enrich a single phase → array of {title, summary, acceptanceCriteria} or null. */
81
+ export async function enrichPhase({ phase, client, provider, model, apiKey, env, maxTicketsPerPhase }) {
82
+ if (!client || typeof client.invoke !== "function") return null;
83
+ const cap = clampInt(maxTicketsPerPhase, DEFAULT_ENRICH_LIMITS.maxTicketsPerPhase, 1, 10);
84
+ try {
85
+ const result = await client.invoke({
86
+ provider,
87
+ model,
88
+ prompt: buildEnrichPrompt(phase, cap),
89
+ apiKey,
90
+ env,
91
+ stream: false,
92
+ });
93
+ return normalizeSubTickets(extractJsonArray(result?.text), cap);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function subTicketDescription({ summary, acceptanceCriteria, dependencyLine }) {
100
+ const acBlock = acceptanceCriteria.length
101
+ ? acceptanceCriteria.map((item, index) => `${index + 1}. ${item}`).join("\n")
102
+ : "1. Phase outcomes are verified by deterministic checks.";
103
+ return [
104
+ `Dependencies: ${dependencyLine}`,
105
+ "",
106
+ summary ? `${summary}\n` : "",
107
+ "Acceptance criteria:",
108
+ acBlock,
109
+ ]
110
+ .filter((line) => line !== "")
111
+ .join("\n");
112
+ }
113
+
114
+ /**
115
+ * Produce an enriched ticket list from a generated guide. Each enriched phase
116
+ * expands into per-PR sub-tickets; phases beyond the cap, or that fail, keep
117
+ * their original heuristic ticket. Returns { tickets, enrichedPhases }.
118
+ */
119
+ export async function enrichGuideTickets({ guide, client, provider, model, apiKey, env, limits } = {}) {
120
+ const phases = Array.isArray(guide?.phases) ? guide.phases : [];
121
+ const baseTickets = Array.isArray(guide?.tickets) ? guide.tickets : [];
122
+ const maxPhases = clampInt(limits?.maxPhases, DEFAULT_ENRICH_LIMITS.maxPhases, 1, 50);
123
+ const maxTicketsPerPhase = clampInt(
124
+ limits?.maxTicketsPerPhase,
125
+ DEFAULT_ENRICH_LIMITS.maxTicketsPerPhase,
126
+ 1,
127
+ 10,
128
+ );
129
+
130
+ const tickets = [];
131
+ let enrichedPhases = 0;
132
+
133
+ for (let index = 0; index < phases.length; index += 1) {
134
+ const phase = phases[index];
135
+ const baseTicket = baseTickets[index];
136
+ const subTickets =
137
+ index < maxPhases
138
+ ? await enrichPhase({ phase, client, provider, model, apiKey, env, maxTicketsPerPhase })
139
+ : null;
140
+
141
+ if (!subTickets) {
142
+ if (baseTicket) tickets.push(baseTicket);
143
+ continue;
144
+ }
145
+
146
+ enrichedPhases += 1;
147
+ const issueNumber = index + 1;
148
+ const phaseDeps = baseTicket?.dependencies || phase?.dependencies || [];
149
+ const baseLabels = baseTicket?.labels || ["sentinelayer", "build-guide", `phase-${issueNumber}`];
150
+
151
+ subTickets.forEach((sub, subIndex) => {
152
+ const dependencies =
153
+ subIndex === 0 ? phaseDeps : [tickets[tickets.length - 1].title];
154
+ const dependencyLine = dependencies.length > 0 ? dependencies.join(", ") : "none (entry phase)";
155
+ tickets.push({
156
+ id: `phase-${issueNumber}.${subIndex + 1}`,
157
+ phase_id: baseTicket?.phase_id || phase?.phaseId || "",
158
+ title: sub.title,
159
+ estimate_hours: baseTicket?.estimate_hours || { min: 4, max: 8 },
160
+ dependencies,
161
+ dependency_ids: subIndex === 0 ? baseTicket?.dependency_ids || [] : [],
162
+ labels: [...baseLabels, "pr"],
163
+ description: subTicketDescription({
164
+ summary: sub.summary,
165
+ acceptanceCriteria: sub.acceptanceCriteria,
166
+ dependencyLine,
167
+ }),
168
+ });
169
+ });
170
+ }
171
+
172
+ return { tickets, enrichedPhases };
173
+ }
@@ -41,6 +41,29 @@ function parseNumberedLines(block) {
41
41
  .filter(Boolean);
42
42
  }
43
43
 
44
+ // Labeled bullets the builder emits per phase (e.g. "- Objective: ...").
45
+ // We capture these as structured fields so ticket bodies carry real content
46
+ // instead of dropping every non-numbered line.
47
+ const PHASE_FIELD_LABELS = new Map([
48
+ ["objective", "objective"],
49
+ ["dependencies", "dependencies"],
50
+ ["files", "files"],
51
+ ["commands", "commands"],
52
+ ["tests", "tests"],
53
+ ["rollback", "rollback"],
54
+ ["evidence", "evidence"],
55
+ ]);
56
+
57
+ // "Phase 0 (P0) — Repo Bootstrap" -> "P0"; "Phase 2 ..." -> "P2".
58
+ function parsePhaseHeadingId(title) {
59
+ const paren = String(title || "").match(/\(\s*([A-Za-z]+\d+)\s*\)/);
60
+ if (paren) {
61
+ return paren[1].toUpperCase();
62
+ }
63
+ const phaseNum = String(title || "").match(/^Phase\s+(\d+)\b/i);
64
+ return phaseNum ? `P${phaseNum[1]}` : "";
65
+ }
66
+
44
67
  function parsePhasePlan(specMarkdown) {
45
68
  const phaseBlock = sectionBody(specMarkdown, "Phase Plan");
46
69
  if (!phaseBlock) {
@@ -58,16 +81,39 @@ function parsePhasePlan(specMarkdown) {
58
81
  if (current) {
59
82
  phases.push(current);
60
83
  }
84
+ const title = headingMatch[1].trim();
61
85
  current = {
62
- title: headingMatch[1].trim(),
86
+ title,
87
+ phaseId: parsePhaseHeadingId(title),
63
88
  tasks: [],
89
+ fields: {},
64
90
  };
65
91
  continue;
66
92
  }
67
93
 
94
+ if (!current) {
95
+ continue;
96
+ }
97
+
68
98
  const taskMatch = line.match(/^\d+\.\s+(.+)$/);
69
- if (taskMatch && current) {
99
+ if (taskMatch) {
70
100
  current.tasks.push(taskMatch[1].trim());
101
+ continue;
102
+ }
103
+
104
+ const bulletMatch = line.match(/^[-*]\s+(.+)$/);
105
+ if (bulletMatch) {
106
+ const body = bulletMatch[1].trim();
107
+ const labelMatch = body.match(/^([A-Za-z][A-Za-z ]*?):\s*(.*)$/);
108
+ if (labelMatch) {
109
+ const key = labelMatch[1].trim().toLowerCase();
110
+ if (PHASE_FIELD_LABELS.has(key)) {
111
+ current.fields[PHASE_FIELD_LABELS.get(key)] = labelMatch[2].trim();
112
+ continue;
113
+ }
114
+ }
115
+ // An unlabeled bullet is real work -> treat it as a task.
116
+ current.tasks.push(body);
71
117
  }
72
118
  }
73
119
 
@@ -78,6 +124,46 @@ function parsePhasePlan(specMarkdown) {
78
124
  return phases;
79
125
  }
80
126
 
127
+ // Expand a dependency token into phase ids: "P0-P4" -> [P0..P4], "P0" -> [P0].
128
+ function expandPhaseRange(token) {
129
+ const raw = String(token || "").trim();
130
+ if (!raw) {
131
+ return [];
132
+ }
133
+ const range = raw.match(/^([A-Za-z]+)(\d+)\s*[-–—]\s*([A-Za-z]+)?(\d+)$/);
134
+ if (range) {
135
+ const prefix = range[1].toUpperCase();
136
+ const start = Number(range[2]);
137
+ const end = Number(range[4]);
138
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start && end - start <= 50) {
139
+ const out = [];
140
+ for (let value = start; value <= end; value += 1) {
141
+ out.push(`${prefix}${value}`);
142
+ }
143
+ return out;
144
+ }
145
+ }
146
+ const single = raw.match(/^([A-Za-z]+\d+)$/);
147
+ return single ? [single[1].toUpperCase()] : [];
148
+ }
149
+
150
+ // Parse a declared "Dependencies" field into a list of phase ids.
151
+ function parseDeclaredDependencies(value) {
152
+ const raw = String(value || "").trim();
153
+ if (!raw || /^none\b/i.test(raw)) {
154
+ return [];
155
+ }
156
+ const ids = [];
157
+ for (const part of raw.split(/[,;]/)) {
158
+ for (const id of expandPhaseRange(part)) {
159
+ if (!ids.includes(id)) {
160
+ ids.push(id);
161
+ }
162
+ }
163
+ }
164
+ return ids;
165
+ }
166
+
81
167
  function parseProjectName(specMarkdown) {
82
168
  const match = String(specMarkdown || "").match(/^#\s*SPEC\s*-\s*(.+)$/im);
83
169
  return match ? match[1].trim() : "Project";
@@ -113,19 +199,58 @@ function estimateEffortHours({ phaseTitle, taskCount, riskSurfaceCount }) {
113
199
  };
114
200
  }
115
201
 
116
- function normalizeAcceptanceCriteria(specMarkdown, phaseTasks) {
202
+ // Real, phase-specific acceptance criteria derived from the captured fields
203
+ // (Tests/Evidence/Objective) and any tasks, instead of an empty placeholder.
204
+ function derivePhaseAcceptance(specMarkdown, phase) {
117
205
  const globalCriteria = parseNumberedLines(sectionBody(specMarkdown, "Acceptance Criteria"));
118
206
  if (globalCriteria.length > 0) {
119
- return globalCriteria.slice(0, 3);
207
+ return globalCriteria.slice(0, 5);
208
+ }
209
+ const fields = phase.fields || {};
210
+ const out = [];
211
+ if (fields.tests) {
212
+ out.push(`Tests pass: ${fields.tests}`);
213
+ }
214
+ if (fields.evidence) {
215
+ out.push(`Evidence captured: ${fields.evidence}`);
216
+ }
217
+ if (fields.objective) {
218
+ out.push(`Objective met: ${fields.objective}`);
120
219
  }
121
- return phaseTasks.slice(0, 3).map((task) => `Validated completion: ${task}`);
220
+ for (const task of (phase.tasks || []).slice(0, 3)) {
221
+ out.push(`Completed: ${task}`);
222
+ }
223
+ if (out.length === 0) {
224
+ out.push("Phase outcomes are verified by deterministic checks.");
225
+ }
226
+ return out.slice(0, 5);
227
+ }
228
+
229
+ // Structured detail lines (objective/files/tests/...) for the ticket body.
230
+ function renderPhaseDetailLines(phase) {
231
+ const fields = phase.fields || {};
232
+ const order = ["objective", "files", "commands", "tests", "rollback", "evidence"];
233
+ const labels = {
234
+ objective: "Objective",
235
+ files: "Files",
236
+ commands: "Commands",
237
+ tests: "Tests",
238
+ rollback: "Rollback",
239
+ evidence: "Evidence",
240
+ };
241
+ return order
242
+ .filter((key) => String(fields[key] || "").trim().length > 0)
243
+ .map((key) => `${labels[key]}: ${fields[key]}`);
122
244
  }
123
245
 
124
246
  function renderPhaseMarkdown(phase) {
247
+ const detailLines = renderPhaseDetailLines(phase);
248
+ const detailBlock =
249
+ detailLines.length > 0 ? `\n${detailLines.map((line) => `- ${line}`).join("\n")}` : "";
125
250
  const taskLines =
126
251
  phase.tasks.length > 0
127
252
  ? phase.tasks.map((task, index) => `${index + 1}. ${task}`).join("\n")
128
- : "1. Define implementation tasks for this phase.";
253
+ : "1. Deliver the phase objective above with deterministic checks.";
129
254
  const acceptanceLines =
130
255
  phase.acceptanceCriteria.length > 0
131
256
  ? phase.acceptanceCriteria.map((item, index) => `${index + 1}. ${item}`).join("\n")
@@ -135,7 +260,7 @@ function renderPhaseMarkdown(phase) {
135
260
 
136
261
  return `### ${phase.title}
137
262
  - Estimated effort: ${phase.effort.label}
138
- - Dependencies: ${dependencyLine}
263
+ - Dependencies: ${dependencyLine}${detailBlock}
139
264
 
140
265
  #### Implementation Tasks
141
266
  ${taskLines}
@@ -147,29 +272,38 @@ ${acceptanceLines}
147
272
 
148
273
  function buildTicket(phase, index) {
149
274
  const issueNumber = index + 1;
275
+ const phaseId = String(phase.phaseId || "").trim();
150
276
  const labels = ["sentinelayer", "build-guide", `phase-${issueNumber}`];
277
+ if (phaseId) {
278
+ labels.push(phaseId.toLowerCase());
279
+ }
151
280
  const dependencyLine =
152
281
  phase.dependencies.length > 0 ? phase.dependencies.join(", ") : "none (entry phase)";
153
282
  const acceptanceBlock = phase.acceptanceCriteria
154
283
  .map((item, criterionIndex) => `${criterionIndex + 1}. ${item}`)
155
284
  .join("\n");
156
285
  const taskBlock = phase.tasks.map((task, taskIndex) => `${taskIndex + 1}. ${task}`).join("\n");
286
+ const detailLines = renderPhaseDetailLines(phase);
287
+ const detailBlock = detailLines.length > 0 ? ["Details:", ...detailLines, ""] : [];
157
288
 
158
289
  return {
159
290
  id: `phase-${issueNumber}`,
291
+ phase_id: phaseId,
160
292
  title: phase.title,
161
293
  estimate_hours: {
162
294
  min: phase.effort.minHours,
163
295
  max: phase.effort.maxHours,
164
296
  },
165
297
  dependencies: phase.dependencies,
298
+ dependency_ids: phase.dependencyIds || [],
166
299
  labels,
167
300
  description: [
168
301
  `Dependencies: ${dependencyLine}`,
169
302
  `Estimated effort: ${phase.effort.label}`,
170
303
  "",
304
+ ...detailBlock,
171
305
  "Implementation tasks:",
172
- taskBlock || "1. Define implementation tasks for this phase.",
306
+ taskBlock || "1. Deliver the phase objective above with deterministic checks.",
173
307
  "",
174
308
  "Acceptance criteria:",
175
309
  acceptanceBlock || "1. Phase outcomes are verified by deterministic checks.",
@@ -210,19 +344,42 @@ export function generateBuildGuide({
210
344
  const goal = parseGoal(source);
211
345
  const riskSurfaceCount = parseRiskSurfaceCount(source);
212
346
 
347
+ // Map declared phase ids (P0, P1, ...) to titles so a "Dependencies: P0-P1"
348
+ // line resolves to a real prerequisite graph instead of naive sequencing.
349
+ const idToTitle = new Map(
350
+ phases.filter((phase) => phase.phaseId).map((phase) => [phase.phaseId, phase.title])
351
+ );
352
+
213
353
  const resolvedPhases = phases.map((phase, index) => {
214
- const dependencies = index > 0 ? [phases[index - 1].title] : [];
354
+ const declaredIds = parseDeclaredDependencies(phase.fields?.dependencies);
355
+ const knownIds = declaredIds.filter(
356
+ (id) => idToTitle.has(id) && idToTitle.get(id) !== phase.title
357
+ );
358
+ let dependencies;
359
+ if (knownIds.length > 0) {
360
+ // Honor the spec's declared dependency graph.
361
+ dependencies = knownIds.map((id) => idToTitle.get(id));
362
+ } else if (declaredIds.length === 0 && index > 0) {
363
+ // Nothing declared -> fall back to the previous phase only.
364
+ dependencies = [phases[index - 1].title];
365
+ } else {
366
+ // Declared "none", or deps that don't resolve -> entry phase.
367
+ dependencies = [];
368
+ }
215
369
  const effort = estimateEffortHours({
216
370
  phaseTitle: phase.title,
217
371
  taskCount: phase.tasks.length,
218
372
  riskSurfaceCount,
219
373
  });
220
- const acceptanceCriteria = normalizeAcceptanceCriteria(source, phase.tasks);
374
+ const acceptanceCriteria = derivePhaseAcceptance(source, phase);
221
375
 
222
376
  return {
223
377
  title: phase.title,
378
+ phaseId: phase.phaseId,
224
379
  tasks: phase.tasks,
380
+ fields: phase.fields,
225
381
  dependencies,
382
+ dependencyIds: knownIds,
226
383
  effort,
227
384
  acceptanceCriteria,
228
385
  };
package/src/legacy-cli.js CHANGED
@@ -2133,6 +2133,13 @@ Project: ${projectName}
2133
2133
  - [ ] Re-run gate and confirm clean status.
2134
2134
  - [ ] Merge only after quality gates are green.
2135
2135
 
2136
+ ## Ticket Trail Contract (Per PR — lean, only if the project has a board/Jira)
2137
+ - [ ] One ticket = one PR; the PR body carries the ticket id.
2138
+ - [ ] On PR open: move the ticket to In-review + comment the PR link.
2139
+ - [ ] On merge + green: move the ticket to Done + comment "merged, gate green".
2140
+ - [ ] On gate fail: move the ticket to Blocked + the finding.
2141
+ - [ ] One update per transition — not every step (same discipline as senti).
2142
+
2136
2143
  ## Command Roadmap (Local Terminal)
2137
2144
  - [ ] \`sentinel /omargate deep --path <repo>\`: local deep scan pipeline
2138
2145
  - [ ] \`sentinel /audit --path <repo>\`: security + quality audit summary
@@ -2219,6 +2226,13 @@ Execution mode:
2219
2226
  - Keep commits scoped and deterministic.
2220
2227
  - Stop only for blocking secrets/permission gaps.
2221
2228
 
2229
+ Ticket trail (lean, only if the project has a board/Jira — do this on every PR, not every step):
2230
+ - One ticket = one PR; put the ticket id in the PR body.
2231
+ - On PR open -> move the ticket to In-review and comment the PR link.
2232
+ - On merge + green -> move the ticket to Done and comment "merged, gate green".
2233
+ - On gate fail -> move the ticket to Blocked with the finding.
2234
+ - Post one short senti update per transition (same discipline as the ticket).
2235
+
2222
2236
  Coding agent profile:
2223
2237
  - Selected agent: ${codingAgentProfile.name} (${codingAgentProfile.id})
2224
2238
  - Prompt target: ${codingAgentProfile.promptTarget}
@@ -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
+ }