sentinelayer-cli 0.17.0 → 0.18.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 CHANGED
@@ -111,6 +111,9 @@ Sentinelayer includes a deterministic session coordination surface for multi-age
111
111
 
112
112
  Read the full guide: [docs/sessions.md](docs/sessions.md)
113
113
 
114
+ For new engineers or agents joining an active SentinelLayer loop, start with
115
+ [docs/ENGINEERING_ONBOARDING.md](docs/ENGINEERING_ONBOARDING.md).
116
+
114
117
  For strategy context, see the long-form blog draft: [docs/blog/slack-for-ai-coding-agents.md](docs/blog/slack-for-ai-coding-agents.md)
115
118
 
116
119
  ## Advanced options
@@ -914,13 +917,20 @@ Prerequisites:
914
917
  - repository secret `NPM_TOKEN` with publish access, or
915
918
  - npm trusted publishing for this repository/tag workflow
916
919
 
917
- Release options:
920
+ Release flow:
921
+
922
+ 1. Merge to `main` and let `Release Please` open/update the release PR.
923
+ 2. Merge the release PR after Omar, Quality Gates, and Attestation are green.
924
+ 3. From a clean checkout of the release commit, run the guarded release command:
925
+ ```bash
926
+ git fetch origin main --tags
927
+ git checkout main && git pull --ff-only
928
+ npm run release:publish -- --tag v0.1.1 --notes-file release-notes.md
929
+ ```
930
+ 4. The guarded command creates the tag with `git tag -s`, verifies it with `git tag -v`, pushes it, confirms the remote GitHub tag object is annotated and cryptographically verified by an allowlisted signer, then creates the GitHub release with `gh release create --verify-tag`.
931
+ 5. The tag push triggers `Release`, which re-verifies upstream checks, release artifact attestations, rollback readiness, protected `package-release` approval, and npm trusted publishing OIDC before publishing.
918
932
 
919
- 1. Merge to `main` and let `Release Please` open/update the release PR and tag.
920
- 2. Push a tag like `v0.1.1` to publish automatically (or via release-please tag creation).
921
- 3. Run `Release` manually (`workflow_dispatch`) to validate gates and rollback readiness without publishing.
922
- 4. Tag-triggered publish resolves auth mode at runtime (`NPM_TOKEN` first, otherwise trusted publishing OIDC).
923
- 5. If neither auth mode is available, publish fails closed with an explicit workflow error.
933
+ Do not run `gh release create v0.1.1` before pushing the signed tag. GitHub release creation creates a lightweight tag when the tag does not already exist, and the Release workflow intentionally fails closed for lightweight tags. If a remote tag already exists as a lightweight tag, `npm run release:publish` refuses to create the GitHub release.
924
934
 
925
935
  Release publish now enforces tarball checksum-manifest validation and attestation verification bound to `.github/workflows/release.yml` before `npm publish`.
926
936
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,7 +11,8 @@
11
11
  "test:unit": "node --import ./tests/setup-env.mjs --test tests/unit*.test.mjs",
12
12
  "test:e2e": "node --import ./tests/setup-env.mjs --test tests/e2e.test.mjs",
13
13
  "test:coverage": "c8 node --import ./tests/setup-env.mjs --test tests/unit*.test.mjs",
14
- "verify": "npm run check && npm run docs:build && npm run test:e2e && npm run test:coverage && npm pack --dry-run"
14
+ "verify": "npm run check && npm run docs:build && npm run test:e2e && npm run test:coverage && npm pack --dry-run",
15
+ "release:publish": "node scripts/release-publish.mjs"
15
16
  },
16
17
  "bin": {
17
18
  "sentinelayer-cli": "bin/sentinelayer-cli.js",
@@ -61,6 +61,7 @@ export function buildLegacyArgs(baseArgs, { commandOptions = {}, command } = {})
61
61
  appendPassthroughFlag(args, "--notify-email", commandOptions.notifyEmail);
62
62
  appendPassthroughFlag(args, "--email-on-complete", commandOptions.emailOnComplete);
63
63
  appendPassthroughFlag(args, "--notify-session", commandOptions.notifySession);
64
+ appendBooleanFlag(args, "--require-usage-ledger", commandOptions.requireUsageLedger);
64
65
  appendPassthroughFlag(args, "--devtestbot-base-url", commandOptions.devtestbotBaseUrl);
65
66
  appendPassthroughFlag(args, "--devtestbot-scope", commandOptions.devtestbotScope);
66
67
  appendNegatedBooleanFlag(args, "--no-devtestbot", commandOptions.devtestbot);
@@ -50,6 +50,7 @@ export function registerOmarGateCommand(program, invokeLegacy) {
50
50
  .option("--notify-email <addr>", "Send final report to this email (default: account email)")
51
51
  .option("--email-on-complete <addr>", "Trigger the API-side DD report email after the run completes")
52
52
  .option("--notify-session <session-id>", "Stream progress into this Senti session (default: auto-start)")
53
+ .option("--require-usage-ledger", "Fail if Investor-DD LLM planner calls cannot write session_usage")
53
54
  .option("--no-email", "Skip email dispatch")
54
55
  .option("--no-dashboard", "Skip dashboard card persistence")
55
56
  .option("--devtestbot-base-url <url>", "Approved absolute URL for devTestBot browser lanes")
@@ -43,6 +43,7 @@ import { listSessionTasks } from "../session/tasks.js";
43
43
  import {
44
44
  createSession,
45
45
  DEFAULT_TTL_SECONDS,
46
+ expireSession,
46
47
  getSession,
47
48
  isSessionCacheExpired,
48
49
  listActiveSessions,
@@ -86,7 +87,7 @@ import {
86
87
  generateSessionCheckpoint,
87
88
  listSessionCheckpoints,
88
89
  } from "../session/checkpoints.js";
89
- import { authLoginHint } from "../ui/command-hints.js";
90
+ import { authLoginHint, preferredCliCommand } from "../ui/command-hints.js";
90
91
  import { parseCsvTokens } from "./ai/shared.js";
91
92
 
92
93
  function shouldEmitJson(options, command) {
@@ -425,6 +426,63 @@ function sentiAutostartDisabled() {
425
426
  return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
426
427
  }
427
428
 
429
+ function buildResumeContext(candidate, { reuseWindowSeconds = 3600 } = {}) {
430
+ if (!candidate) return null;
431
+ const source = normalizeString(candidate._source) || "unknown";
432
+ const lastActivityAt =
433
+ normalizeString(candidate.lastInteractionAt) ||
434
+ normalizeString(candidate.lastActivityAt) ||
435
+ normalizeString(candidate.updatedAt) ||
436
+ normalizeString(candidate.createdAt) ||
437
+ null;
438
+ return {
439
+ source,
440
+ reuseWindowSeconds,
441
+ lastActivityAt,
442
+ reason: `recent_${source}_session`,
443
+ };
444
+ }
445
+
446
+ async function resolveSessionRemoteSyncState({ dashboardUrl } = {}) {
447
+ if (remoteSessionLookupDisabled()) {
448
+ return {
449
+ status: "disabled",
450
+ attempted: false,
451
+ reason: "remote_sync_disabled",
452
+ dashboardUrl,
453
+ };
454
+ }
455
+
456
+ let storedSession = null;
457
+ try {
458
+ storedSession = await readStoredSession();
459
+ } catch {
460
+ storedSession = null;
461
+ }
462
+ const apiUrl =
463
+ normalizeString(storedSession?.apiUrl) ||
464
+ normalizeString(process.env.SENTINELAYER_API_URL) ||
465
+ "https://api.sentinelayer.com";
466
+ const hasToken = Boolean(
467
+ normalizeString(storedSession?.token) || normalizeString(process.env.SENTINELAYER_TOKEN),
468
+ );
469
+ if (!hasToken) {
470
+ return {
471
+ status: "auth_required",
472
+ attempted: false,
473
+ reason: "not_authenticated",
474
+ apiUrl,
475
+ dashboardUrl,
476
+ };
477
+ }
478
+ return {
479
+ status: "background_sync_queued",
480
+ attempted: true,
481
+ apiUrl,
482
+ dashboardUrl,
483
+ };
484
+ }
485
+
428
486
  function mergeResumeCandidate(existing, incoming) {
429
487
  if (!existing) return incoming;
430
488
  const existingActivity = Number(existing._activityMs || 0);
@@ -614,6 +672,69 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
614
672
  return { ok: false, reason: lastReason };
615
673
  }
616
674
 
675
+ function normalizeRemoteResumeStatus(session = {}) {
676
+ return normalizeString(session?.archiveStatus || session?.status).toLowerCase();
677
+ }
678
+
679
+ function remoteStatusAllowsResume(status) {
680
+ if (!status) return true;
681
+ return status === "active" || status === "pending";
682
+ }
683
+
684
+ async function reconcileLocalResumeCandidate(candidate, { targetPath } = {}) {
685
+ if (!candidate || candidate._source !== "local" || remoteSessionLookupDisabled()) {
686
+ return { candidate, staleResume: null };
687
+ }
688
+ const verification = await verifyRemoteSession(candidate.sessionId, { targetPath }).catch((error) => ({
689
+ ok: false,
690
+ reason: normalizeString(error?.message) || "probe_failed",
691
+ }));
692
+
693
+ if (verification.ok) {
694
+ const remoteStatus = normalizeRemoteResumeStatus(verification.session);
695
+ if (remoteStatusAllowsResume(remoteStatus)) {
696
+ return { candidate, staleResume: null };
697
+ }
698
+ await expireSession(candidate.sessionId, { targetPath }).catch(() => null);
699
+ return {
700
+ candidate: null,
701
+ staleResume: {
702
+ sessionId: candidate.sessionId,
703
+ source: "remote",
704
+ reason: "remote_not_active",
705
+ remoteStatus,
706
+ action: "expired_local_and_created_new",
707
+ },
708
+ };
709
+ }
710
+
711
+ if (verification.reason === "not_found" || verification.reason === "forbidden") {
712
+ await expireSession(candidate.sessionId, { targetPath }).catch(() => null);
713
+ return {
714
+ candidate: null,
715
+ staleResume: {
716
+ sessionId: candidate.sessionId,
717
+ source: "remote",
718
+ reason: verification.reason,
719
+ remoteStatus: verification.reason,
720
+ status: verification.status || null,
721
+ action: "expired_local_and_created_new",
722
+ },
723
+ };
724
+ }
725
+
726
+ return {
727
+ candidate,
728
+ staleResume: {
729
+ sessionId: candidate.sessionId,
730
+ source: "remote",
731
+ reason: verification.reason || "probe_failed",
732
+ status: verification.status || null,
733
+ action: "kept_local_resume",
734
+ },
735
+ };
736
+ }
737
+
617
738
  // Render an absolute ISO timestamp as a coarse "Nm ago" / "Nh ago" / "Nd ago"
618
739
  // label for human-readable join output. Returns `"never"` for missing input
619
740
  // and `"just now"` for sub-minute deltas.
@@ -737,12 +858,15 @@ async function ensureWorkspaceSession({
737
858
  const titleArg = normalizeString(title);
738
859
  const fallbackTitle = deriveSessionTitle(targetPath);
739
860
  const startedAt = Date.now();
740
- const resumedCandidate = await findReusableSessionCandidate({
861
+ let resumedCandidate = await findReusableSessionCandidate({
741
862
  targetPath,
742
863
  reuseWindowSeconds,
743
864
  resume,
744
865
  forceNew,
745
866
  });
867
+ const reconciliation = await reconcileLocalResumeCandidate(resumedCandidate, { targetPath });
868
+ resumedCandidate = reconciliation.candidate;
869
+ const staleResume = reconciliation.staleResume;
746
870
  let created;
747
871
  const resumeTitle =
748
872
  titleArg || normalizeString(resumedCandidate?.title) || fallbackTitle;
@@ -825,6 +949,7 @@ async function ensureWorkspaceSession({
825
949
  title: effectiveTitle || null,
826
950
  titleAuto,
827
951
  titleSync,
952
+ staleResume,
828
953
  };
829
954
  }
830
955
 
@@ -848,6 +973,42 @@ async function defaultAgentId(value, _targetPath) {
848
973
  return resolveSessionSayAgentId(value);
849
974
  }
850
975
 
976
+ async function resolveSessionAgentEnvelope(
977
+ sessionId,
978
+ agentId,
979
+ {
980
+ targetPath = process.cwd(),
981
+ model = "",
982
+ role = "",
983
+ displayName = "",
984
+ clientKind = "cli",
985
+ } = {}
986
+ ) {
987
+ const normalizedAgentId = normalizeAgentId(agentId, "cli-user");
988
+ let registeredAgent = null;
989
+ try {
990
+ const agents = await listAgents(sessionId, { targetPath, includeInactive: true });
991
+ registeredAgent = agents.find(
992
+ (agent) => normalizeString(agent.agentId).toLowerCase() === normalizedAgentId.toLowerCase(),
993
+ );
994
+ } catch {
995
+ registeredAgent = null;
996
+ }
997
+
998
+ const resolvedModel = normalizeString(model) || normalizeString(registeredAgent?.model);
999
+ const resolvedRole = normalizeString(role) || normalizeString(registeredAgent?.role);
1000
+ const resolvedDisplayName =
1001
+ normalizeString(displayName) || normalizeString(registeredAgent?.displayName);
1002
+ const envelope = {
1003
+ id: normalizedAgentId,
1004
+ model: resolvedModel || undefined,
1005
+ role: resolvedRole || undefined,
1006
+ displayName: resolvedDisplayName || undefined,
1007
+ clientKind: normalizeString(clientKind) || undefined,
1008
+ };
1009
+ return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
1010
+ }
1011
+
851
1012
  async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
852
1013
  const normalizedItems = Array.isArray(items) ? items : [];
853
1014
  const normalizedConcurrency = Math.max(
@@ -930,7 +1091,67 @@ function checkpointGradeLabel(checkpoint = {}) {
930
1091
  .filter(Boolean)
931
1092
  .slice(0, 2);
932
1093
  const reasonLabel = reasons.length ? `: ${reasons.join("; ")}` : "";
933
- return ` grade ${grade}${scoreLabel}${reasonLabel}`;
1094
+ return ` completeness ${grade}${scoreLabel}${reasonLabel}`;
1095
+ }
1096
+
1097
+ function checkpointSummarySections(checkpoint = {}) {
1098
+ const sections = checkpoint.summarySections || checkpoint.summary_sections;
1099
+ return sections && typeof sections === "object" && !Array.isArray(sections) ? sections : null;
1100
+ }
1101
+
1102
+ function normalizeCheckpointTextItems(value, limit = 3) {
1103
+ if (!Array.isArray(value)) {
1104
+ return [];
1105
+ }
1106
+ return value
1107
+ .map((item) => normalizeString(item))
1108
+ .filter(Boolean)
1109
+ .slice(0, limit);
1110
+ }
1111
+
1112
+ function formatCheckpointSectionPreview(checkpoint = {}) {
1113
+ const sections = checkpointSummarySections(checkpoint);
1114
+ if (!sections) {
1115
+ return "";
1116
+ }
1117
+ const parts = [];
1118
+ const work = normalizeCheckpointTextItems(sections.workCompleted || sections.work_completed, 1)[0];
1119
+ if (work) {
1120
+ parts.push(`work: ${work}`);
1121
+ }
1122
+ const agents = Array.isArray(sections.agentContributions)
1123
+ ? sections.agentContributions
1124
+ : Array.isArray(sections.agent_contributions)
1125
+ ? sections.agent_contributions
1126
+ : [];
1127
+ const agentLabels = agents
1128
+ .map((item) => {
1129
+ const agentId = normalizeString(item?.agentId || item?.agent_id);
1130
+ const summary = normalizeString(item?.summary);
1131
+ return agentId && summary ? `${agentId}: ${summary}` : "";
1132
+ })
1133
+ .filter(Boolean)
1134
+ .slice(0, 2);
1135
+ if (agentLabels.length) {
1136
+ parts.push(`agents: ${agentLabels.join("; ")}`);
1137
+ }
1138
+ const evidence = Array.isArray(sections.evidence) ? sections.evidence : [];
1139
+ const evidenceLabels = evidence
1140
+ .map((item) => normalizeString(item?.label || item?.value))
1141
+ .filter(Boolean)
1142
+ .slice(0, 3);
1143
+ if (evidenceLabels.length) {
1144
+ parts.push(`evidence: ${evidenceLabels.join(", ")}`);
1145
+ }
1146
+ const risks = normalizeCheckpointTextItems(sections.risks, 1);
1147
+ if (risks.length) {
1148
+ parts.push(`risks: ${risks.join("; ")}`);
1149
+ }
1150
+ const nextSteps = normalizeCheckpointTextItems(sections.nextSteps || sections.next_steps, 1);
1151
+ if (nextSteps.length) {
1152
+ parts.push(`next: ${nextSteps.join("; ")}`);
1153
+ }
1154
+ return parts.length ? ` | ${parts.join(" | ")}` : "";
934
1155
  }
935
1156
 
936
1157
  export function formatCheckpointLine(checkpoint = {}) {
@@ -939,7 +1160,7 @@ export function formatCheckpointLine(checkpoint = {}) {
939
1160
  const title = normalizeString(checkpoint.title) || "Untitled checkpoint";
940
1161
  const byline = normalizeString(checkpoint.createdByAgentId || checkpoint.createdBy);
941
1162
  const by = byline ? ` by ${byline}` : "";
942
- return `${checkpointSequenceRange(checkpoint)} ${id} [${kind}] ${title}${by}${checkpointGradeLabel(checkpoint)}`;
1163
+ return `${checkpointSequenceRange(checkpoint)} ${id} [${kind}] ${title}${by}${checkpointGradeLabel(checkpoint)}${formatCheckpointSectionPreview(checkpoint)}`;
943
1164
  }
944
1165
 
945
1166
  async function readCheckpointSummaryOption(options = {}, { targetPath } = {}) {
@@ -1244,6 +1465,9 @@ export function registerSessionCommand(program) {
1244
1465
  const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
1245
1466
  const dashboardUrl = buildDashboardUrl(created.sessionId);
1246
1467
  const effectiveTitle = ensured.title;
1468
+ const cliCommand = preferredCliCommand();
1469
+ const resumeContext = buildResumeContext(ensured.resumedCandidate, { reuseWindowSeconds });
1470
+ const remoteSync = await resolveSessionRemoteSyncState({ dashboardUrl });
1247
1471
 
1248
1472
  const payload = {
1249
1473
  command: "session start",
@@ -1265,23 +1489,29 @@ export function registerSessionCommand(program) {
1265
1489
  launchPlan,
1266
1490
  dashboardUrl,
1267
1491
  resumed,
1492
+ resumeSource: resumeContext?.source || null,
1493
+ resumeContext: resumeContext || undefined,
1494
+ staleResume: ensured.staleResume || undefined,
1495
+ remoteSync,
1268
1496
  title: effectiveTitle || null,
1269
1497
  titleAuto: Boolean(ensured.titleAuto),
1270
1498
  titleSync: ensured.titleSync || undefined,
1271
1499
  };
1272
1500
 
1273
1501
  // Best-effort admin visibility sync. Session creation remains local-first.
1274
- void syncSessionMetadataToApi(created.sessionId, {
1275
- targetPath,
1276
- sessionId: created.sessionId,
1277
- status: created.status,
1278
- createdAt: created.createdAt,
1279
- expiresAt: created.expiresAt,
1280
- title: effectiveTitle || null,
1281
- ttlSeconds,
1282
- template: created.template,
1283
- codebaseContext: created.codebaseContext,
1284
- }).catch(() => {});
1502
+ if (remoteSync.attempted) {
1503
+ void syncSessionMetadataToApi(created.sessionId, {
1504
+ targetPath,
1505
+ sessionId: created.sessionId,
1506
+ status: created.status,
1507
+ createdAt: created.createdAt,
1508
+ expiresAt: created.expiresAt,
1509
+ title: effectiveTitle || null,
1510
+ ttlSeconds,
1511
+ template: created.template,
1512
+ codebaseContext: created.codebaseContext,
1513
+ }).catch(() => {});
1514
+ }
1285
1515
 
1286
1516
  // Auto-start the Senti orchestrator daemon. Without this, every
1287
1517
  // session ran with `Senti actions: 1` (just the welcome alert)
@@ -1323,15 +1553,38 @@ export function registerSessionCommand(program) {
1323
1553
  console.log(pc.bold(resumed ? "Session resumed" : "Session created"));
1324
1554
  console.log(pc.gray(`Session: ${created.sessionId}`));
1325
1555
  if (titleArg) console.log(pc.gray(`Title: ${titleArg}`));
1556
+ if (resumeContext) {
1557
+ console.log(
1558
+ pc.gray(
1559
+ `Resume source: ${resumeContext.source} (last activity ${resumeContext.lastActivityAt || "unknown"}; window ${reuseWindowSeconds}s)`,
1560
+ ),
1561
+ );
1562
+ }
1563
+ if (ensured.staleResume?.action === "expired_local_and_created_new") {
1564
+ console.log(
1565
+ pc.yellow(
1566
+ `Skipped stale local session ${ensured.staleResume.sessionId}: remote status is ${ensured.staleResume.remoteStatus || ensured.staleResume.reason}.`,
1567
+ ),
1568
+ );
1569
+ }
1326
1570
  if (created.streamPath) console.log(pc.gray(`Stream: ${created.streamPath}`));
1327
1571
  console.log(pc.gray(`${resumed ? "Resumed" : "Created"} in ${durationMs}ms`));
1328
1572
  console.log(
1329
1573
  `status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
1330
1574
  );
1575
+ if (remoteSync.status === "auth_required") {
1576
+ console.log(
1577
+ pc.yellow(
1578
+ `Dashboard sync pending: run \`${authLoginHint()}\`, then rerun \`${cliCommand} session start\` in this workspace to publish local metadata.`,
1579
+ ),
1580
+ );
1581
+ } else if (remoteSync.status === "disabled") {
1582
+ console.log(pc.gray("Dashboard sync disabled by SENTINELAYER_SKIP_REMOTE_SYNC=1."));
1583
+ }
1331
1584
  if (!resumed) {
1332
1585
  console.log(
1333
1586
  pc.gray(
1334
- "Tip: subsequent `slc session start` in this workspace within an hour will resume this session. Pass --force-new to override.",
1587
+ `Tip: subsequent \`${cliCommand} session start\` in this workspace within an hour will resume this session. Pass --force-new to override.`,
1335
1588
  ),
1336
1589
  );
1337
1590
  }
@@ -1451,13 +1704,20 @@ export function registerSessionCommand(program) {
1451
1704
  forceNew: Boolean(options.forceNew),
1452
1705
  reuseWindowSeconds,
1453
1706
  });
1707
+ const dashboardUrl = buildDashboardUrl(ensured.created.sessionId);
1708
+ const resumeContext = buildResumeContext(ensured.resumedCandidate, { reuseWindowSeconds });
1709
+ const remoteSync = await resolveSessionRemoteSyncState({ dashboardUrl });
1454
1710
  const payload = {
1455
1711
  command: "session ensure",
1456
1712
  targetPath,
1457
1713
  sessionId: ensured.created.sessionId,
1458
1714
  title: ensured.title || null,
1459
1715
  resumed: Boolean(ensured.resumedCandidate),
1460
- dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
1716
+ resumeSource: resumeContext?.source || null,
1717
+ resumeContext: resumeContext || undefined,
1718
+ staleResume: ensured.staleResume || undefined,
1719
+ dashboardUrl,
1720
+ remoteSync,
1461
1721
  titleSync: ensured.titleSync || undefined,
1462
1722
  };
1463
1723
  console.log(JSON.stringify(payload, null, 2));
@@ -1721,6 +1981,21 @@ export function registerSessionCommand(program) {
1721
1981
  .command("say <sessionId> <message>")
1722
1982
  .description("Send a message to the session")
1723
1983
  .option("--agent <id>", "Agent id to emit from", "cli-user")
1984
+ .option(
1985
+ "--model <model>",
1986
+ "Agent model/provider hint; defaults to local joined agent metadata or SENTINELAYER_AGENT_MODEL",
1987
+ process.env.SENTINELAYER_AGENT_MODEL || "",
1988
+ )
1989
+ .option(
1990
+ "--display-name <name>",
1991
+ "Human-readable agent display name",
1992
+ process.env.SENTINELAYER_AGENT_DISPLAY_NAME || process.env.SENTINELAYER_AGENT_NAME || "",
1993
+ )
1994
+ .option(
1995
+ "--role <role>",
1996
+ "Agent role metadata; defaults to local joined agent metadata or SENTINELAYER_AGENT_ROLE",
1997
+ process.env.SENTINELAYER_AGENT_ROLE || "",
1998
+ )
1724
1999
  .option("--to <agent>", "Direct the message to a specific agent id")
1725
2000
  .option("--reply-to <sequence>", "Mark this message as a reply to a target sequence id")
1726
2001
  .option("--reply-cursor <cursor>", "Mark this message as a reply to a target event cursor")
@@ -1757,9 +2032,15 @@ export function registerSessionCommand(program) {
1757
2032
  eventPayload.replyToCursor = replyToCursor;
1758
2033
  }
1759
2034
  const clientMessageId = `cli-${randomUUID()}`;
2035
+ const agent = await resolveSessionAgentEnvelope(normalizedSessionId, agentId, {
2036
+ targetPath,
2037
+ model: options.model,
2038
+ role: options.role,
2039
+ displayName: options.displayName,
2040
+ });
1760
2041
  const event = createAgentEvent({
1761
2042
  event: "session_message",
1762
- agentId,
2043
+ agent,
1763
2044
  sessionId: normalizedSessionId,
1764
2045
  payload: eventPayload,
1765
2046
  });
@@ -1854,13 +2135,9 @@ export function registerSessionCommand(program) {
1854
2135
  event.eventId = clientMessageId;
1855
2136
  event.idempotencyToken = clientMessageId;
1856
2137
 
1857
- let remoteSync = null;
1858
- for (let attempt = 0; attempt < 2; attempt += 1) {
1859
- remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
1860
- targetPath,
1861
- });
1862
- if (remoteSync?.synced) break;
1863
- }
2138
+ const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
2139
+ targetPath,
2140
+ });
1864
2141
  if (!remoteSync?.synced) {
1865
2142
  throw new Error(
1866
2143
  `Agent post failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
@@ -1,6 +1,8 @@
1
1
  import { estimateTokens } from "./tokenizer.js";
2
2
 
3
- const DEFAULT_MODEL_PRICING = Object.freeze({
3
+ export const DEFAULT_PRICE_BOOK_VERSION = "2026-05-24";
4
+
5
+ export const DEFAULT_MODEL_PRICING = Object.freeze({
4
6
  "gpt-4o": Object.freeze({
5
7
  inputPerMillionUsd: 2.5,
6
8
  outputPerMillionUsd: 10.0,
@@ -54,6 +54,14 @@ function normalizeTimestamp(value, fallbackTimestamp) {
54
54
  return new Date(epoch).toISOString();
55
55
  }
56
56
 
57
+ function normalizePositiveInteger(value) {
58
+ const normalized = Number(value);
59
+ if (!Number.isFinite(normalized) || normalized <= 0) {
60
+ return undefined;
61
+ }
62
+ return Math.floor(normalized);
63
+ }
64
+
57
65
  function stripUndefinedEntries(record) {
58
66
  const cleaned = {};
59
67
  for (const [key, value] of Object.entries(record || {})) {
@@ -112,6 +120,11 @@ export function createAgentEvent({
112
120
  agent,
113
121
  ts,
114
122
  timestamp,
123
+ eventId,
124
+ idempotencyToken,
125
+ cursor,
126
+ sequenceId,
127
+ sequence_id,
115
128
  } = {}) {
116
129
  const normalizedEvent = normalizeNonEmptyString(event);
117
130
  const normalizedAgent = normalizeAgentShape({
@@ -135,6 +148,10 @@ export function createAgentEvent({
135
148
  runId: normalizeOptionalString(runId),
136
149
  workItemId: normalizeOptionalString(workItemId),
137
150
  requestId: normalizeOptionalString(requestId),
151
+ eventId: normalizeOptionalString(eventId),
152
+ idempotencyToken: normalizeOptionalString(idempotencyToken),
153
+ cursor: normalizeOptionalString(cursor),
154
+ sequenceId: normalizePositiveInteger(sequenceId ?? sequence_id),
138
155
  ts: canonicalTs,
139
156
  // Keep legacy timestamp key for existing consumers while PR0 migrates envelope usage.
140
157
  timestamp: canonicalTs,
@@ -202,6 +219,10 @@ export function normalizeAgentEvent(evt, { allowLegacy = true } = {}) {
202
219
  runId: evt.runId,
203
220
  workItemId: evt.workItemId,
204
221
  requestId: evt.requestId,
222
+ eventId: evt.eventId,
223
+ idempotencyToken: evt.idempotencyToken,
224
+ cursor: evt.cursor,
225
+ sequenceId: evt.sequenceId ?? evt.sequence_id,
205
226
  ts: normalizedTs,
206
227
  });
207
228
  } catch {
package/src/legacy-cli.js CHANGED
@@ -1135,6 +1135,10 @@ async function runLocalOmarGateCommand(args) {
1135
1135
  const devTestBotBaseUrl = getCommandOptionValue(args, "--devtestbot-base-url") || "";
1136
1136
  const devTestBotScope = getCommandOptionValue(args, "--devtestbot-scope") || "";
1137
1137
  const emailOnComplete = getCommandOptionValue(args, "--email-on-complete") || "";
1138
+ const notifySession = getCommandOptionValue(args, "--notify-session") || "";
1139
+ const requireUsageLedger = hasCommandOption(args, "--require-usage-ledger");
1140
+ const model = getCommandOptionValue(args, "--model") || "gpt-5.3-codex";
1141
+ const provider = getCommandOptionValue(args, "--provider") || "sentinelayer";
1138
1142
 
1139
1143
  const targetPath = path.resolve(process.cwd(), pathArg);
1140
1144
  if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
@@ -1163,6 +1167,18 @@ async function runLocalOmarGateCommand(args) {
1163
1167
  baseUrl: devTestBotBaseUrl,
1164
1168
  scope: devTestBotScope,
1165
1169
  },
1170
+ sessionUsage: notifySession || requireUsageLedger
1171
+ ? {
1172
+ sessionId: notifySession,
1173
+ agentId: "investor-dd",
1174
+ model,
1175
+ provider,
1176
+ targetPath,
1177
+ billingTier: "internal",
1178
+ sourceCommand: "omargate investor-dd",
1179
+ required: requireUsageLedger,
1180
+ }
1181
+ : null,
1166
1182
  reportEmail: emailOnComplete
1167
1183
  ? {
1168
1184
  to: emailOnComplete,
@@ -2322,11 +2338,18 @@ jobs:
2322
2338
  fi
2323
2339
  - name: Run Omar Gate
2324
2340
  id: omar
2325
- uses: mrrCarter/sentinelayer-v1-action@4cb3063e04e3b899981b25f6918b26f70d35a8d4
2341
+ uses: mrrCarter/sentinelayer-v1-action@8595c4ad41e7b710ff6b1de0603da6ad8c0c3c07
2326
2342
  with:
2343
+ github_token: \${{ github.token }}
2327
2344
  sentinelayer_token: \${{ secrets.${normalizedSecret} }}${specIdBindingLine}
2345
+ sentinelayer_managed_llm: "false"
2346
+ openai_api_key: \${{ secrets.OPENAI_API_KEY }}
2328
2347
  scan_mode: \${{ github.event_name == 'workflow_dispatch' && inputs.scan_mode || 'deep' }}
2329
2348
  severity_gate: \${{ github.event_name == 'workflow_dispatch' && inputs.severity_gate || 'P1' }}
2349
+ model: gpt-5.3-codex
2350
+ codex_model: gpt-5.3-codex
2351
+ model_fallback: gpt-5.2-codex
2352
+ llm_failure_policy: block
2330
2353
  - name: Enforce Omar reviewer merge thresholds
2331
2354
  shell: bash
2332
2355
  env: