sentinelayer-cli 0.46.4 → 0.48.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.
Files changed (137) hide show
  1. package/README.md +63 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +46 -8
  26. package/src/auth/service.js +34 -15
  27. package/src/auth/session-store.js +105 -47
  28. package/src/board/evidence-resolvers.js +3 -4
  29. package/src/cli.js +23 -3
  30. package/src/commands/ai/identity-lifecycle.js +30 -26
  31. package/src/commands/ai/provision-governance.js +32 -24
  32. package/src/commands/ai/shared.js +6 -4
  33. package/src/commands/audit.js +14 -12
  34. package/src/commands/auth.js +24 -4
  35. package/src/commands/board.js +2 -2
  36. package/src/commands/chat.js +8 -6
  37. package/src/commands/judge.js +125 -0
  38. package/src/commands/legacy-args.js +1 -0
  39. package/src/commands/mcp.js +45 -52
  40. package/src/commands/omargate.js +2 -1
  41. package/src/commands/plugin.js +11 -3
  42. package/src/commands/review.js +9 -4
  43. package/src/commands/scan.js +7 -7
  44. package/src/commands/session.js +607 -57
  45. package/src/commands/skills.js +45 -0
  46. package/src/commands/watch.js +9 -7
  47. package/src/config/output-filesystem.js +156 -0
  48. package/src/config/schema.js +4 -2
  49. package/src/config/service.js +40 -4
  50. package/src/cost/history.js +15 -9
  51. package/src/daemon/artifact-lineage.js +15 -12
  52. package/src/daemon/assignment-ledger.js +31 -25
  53. package/src/daemon/ast-drift.js +7 -3
  54. package/src/daemon/budget-governor.js +26 -20
  55. package/src/daemon/error-worker.js +28 -22
  56. package/src/daemon/fix-cycle.js +5 -4
  57. package/src/daemon/hybrid-mapper.js +19 -16
  58. package/src/daemon/ingest-refresh.js +4 -0
  59. package/src/daemon/jira-lifecycle.js +21 -17
  60. package/src/daemon/operator-control.js +18 -15
  61. package/src/daemon/reliability-lane.js +18 -15
  62. package/src/daemon/scope-engine.js +12 -8
  63. package/src/daemon/watchdog.js +15 -12
  64. package/src/engram/build-state.js +24 -3
  65. package/src/engram/capability-index.js +367 -0
  66. package/src/engram/compile-graph.js +1063 -0
  67. package/src/engram/contracts/action-receipt.js +82 -0
  68. package/src/engram/contracts/capability-index.js +259 -0
  69. package/src/engram/contracts/capability.js +377 -0
  70. package/src/engram/contracts/content-hash.js +8 -0
  71. package/src/engram/contracts/evidence.js +258 -0
  72. package/src/engram/contracts/graph.js +594 -0
  73. package/src/engram/contracts/identity.js +8 -0
  74. package/src/engram/contracts/index.js +149 -0
  75. package/src/engram/contracts/media-asset.js +85 -0
  76. package/src/engram/contracts/money.js +249 -0
  77. package/src/engram/contracts/source-snapshot.js +57 -0
  78. package/src/engram/contracts/task-context-pack.js +114 -0
  79. package/src/engram/contracts/temporal-segment.js +72 -0
  80. package/src/engram/contracts/versions.js +96 -0
  81. package/src/engram/extract-html.js +692 -0
  82. package/src/engram/graph-store.js +877 -0
  83. package/src/engram/index.js +98 -14
  84. package/src/engram/ingest-site.js +132 -0
  85. package/src/engram/live-state.js +1180 -0
  86. package/src/engram/namespace.js +34 -1
  87. package/src/engram/retrieve-site.js +753 -0
  88. package/src/engram/safe-reader.js +597 -0
  89. package/src/engram/source-intake.js +251 -0
  90. package/src/engram/store.js +227 -9
  91. package/src/ingest/engine.js +21 -28
  92. package/src/judge/verdict.js +305 -0
  93. package/src/legacy-cli.js +96 -19
  94. package/src/mcp/bridge-inputs.js +28 -4
  95. package/src/mcp/cli-command-tools.js +70 -0
  96. package/src/mcp/registry.js +17 -13
  97. package/src/mcp/session-stdio-server.js +297 -19
  98. package/src/memory/blackboard.js +5 -4
  99. package/src/net/gh-api-path.js +35 -9
  100. package/src/net/test-egress-guard.js +16 -4
  101. package/src/plugin/manifest.js +12 -8
  102. package/src/review/ai-review.js +110 -10
  103. package/src/review/investor-dd-devtestbot.js +17 -6
  104. package/src/review/investor-dd-orchestrator.js +21 -13
  105. package/src/review/local-review.js +26 -16
  106. package/src/review/omargate-cache.js +5 -5
  107. package/src/review/omargate-orchestrator.js +89 -17
  108. package/src/review/replay.js +9 -4
  109. package/src/review/report.js +22 -13
  110. package/src/review/typesafe-severity.js +201 -0
  111. package/src/session/admission-auth.js +34 -7
  112. package/src/session/admission.js +322 -71
  113. package/src/session/agent-access.js +137 -0
  114. package/src/session/agent-registry.js +2 -1
  115. package/src/session/attachment-summary.js +77 -0
  116. package/src/session/edit-summary.js +19 -0
  117. package/src/session/invitations.js +65 -1
  118. package/src/session/mentions.js +17 -9
  119. package/src/session/message-edits.js +5 -4
  120. package/src/session/preview.js +10 -2
  121. package/src/session/recap.js +3 -2
  122. package/src/session/room-files.js +519 -0
  123. package/src/session/route-classes.js +22 -8
  124. package/src/session/setup-guides.js +4 -0
  125. package/src/session/standup.js +192 -0
  126. package/src/session/sync.js +229 -10
  127. package/src/session/tickets.js +41 -0
  128. package/src/session/transcript.js +2 -1
  129. package/src/session/wake/resolve-target.js +7 -3
  130. package/src/skills/listener-runtime.cjs +92 -0
  131. package/src/skills/service.js +207 -0
  132. package/src/swarm/factory.js +5 -5
  133. package/src/swarm/pentest.js +9 -9
  134. package/src/swarm/report.js +4 -4
  135. package/src/swarm/runtime.js +15 -12
  136. package/src/swarm/scenario-dsl.js +7 -5
  137. package/src/telemetry/ledger.js +16 -7
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
3
4
 
4
5
  import { createMultiProviderApiClient } from "../ai/client.js";
5
6
  import { serializeProxyError } from "../ai/proxy.js";
@@ -376,6 +377,7 @@ export function createIsolatedPersonaContext({
376
377
  runId = "",
377
378
  rootPath = ".",
378
379
  artifactDir = "",
380
+ outputFs,
379
381
  provider = null,
380
382
  budget = {},
381
383
  onEvent = null,
@@ -384,6 +386,7 @@ export function createIsolatedPersonaContext({
384
386
  isolation = DEFAULT_ISOLATION_MODE,
385
387
  eventContext = null,
386
388
  } = {}) {
389
+ if (artifactDir) requireOutputFileSystem(outputFs);
387
390
  const normalizedAgent = normalizeAuditPersona(agent || {});
388
391
  const resolvedRootPath = path.resolve(String(rootPath || "."));
389
392
  const resolvedRunId =
@@ -399,6 +402,7 @@ export function createIsolatedPersonaContext({
399
402
  budget: loopBudget,
400
403
  runId: resolvedRunId,
401
404
  artifactDir,
405
+ outputFs,
402
406
  onEvent,
403
407
  });
404
408
  const emitter = (event, payload = {}) => {
@@ -701,6 +705,7 @@ async function runSinglePersonaAgenticLoop({
701
705
  sharedContext = null,
702
706
  hybridContext = null,
703
707
  artifactDir = "",
708
+ outputFs,
704
709
  provider = null,
705
710
  budget = {},
706
711
  maxTurns = null,
@@ -722,6 +727,7 @@ async function runSinglePersonaAgenticLoop({
722
727
  runId: requestedRunId,
723
728
  rootPath: resolvedRootPath,
724
729
  artifactDir,
730
+ outputFs,
725
731
  provider,
726
732
  budget,
727
733
  onEvent,
@@ -1053,6 +1059,7 @@ function summarizeSwarmStatus(results = []) {
1053
1059
  }
1054
1060
 
1055
1061
  export async function runPersonaAgenticLoop(options = {}) {
1062
+ if (options.artifactDir) requireOutputFileSystem(options.outputFs);
1056
1063
  const {
1057
1064
  agent,
1058
1065
  rootPath,
@@ -1,4 +1,4 @@
1
- import fsp from "node:fs/promises";
1
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
2
2
  import path from "node:path";
3
3
 
4
4
  function normalizeString(value) {
@@ -87,15 +87,17 @@ export function compareAuditReports(baseReport = {}, candidateReport = {}) {
87
87
  }
88
88
 
89
89
  export async function writeAuditComparisonArtifact({
90
+ outputFs,
90
91
  baseReport = {},
91
92
  candidateReport = {},
92
93
  outputDirectory = "",
93
94
  } = {}) {
95
+ requireOutputFileSystem(outputFs);
94
96
  const resolvedOutputDirectory = path.resolve(String(outputDirectory || "."));
95
97
  const comparison = compareAuditReports(baseReport, candidateReport);
96
98
  const fileName = `AUDIT_COMPARISON_${comparison.baseRunId}_vs_${comparison.candidateRunId}.json`;
97
99
  const outputPath = path.join(resolvedOutputDirectory, fileName);
98
- await fsp.writeFile(outputPath, `${JSON.stringify(comparison, null, 2)}\n`, "utf-8");
100
+ await outputFs.writeFile(outputPath, `${JSON.stringify(comparison, null, 2)}\n`, "utf-8");
99
101
  return {
100
102
  comparison,
101
103
  outputPath,
package/src/auth/gate.js CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import pc from "picocolors";
7
7
  import { resolveActiveAuthSession } from "./service.js";
8
+ import { readStoredSessionMetadata } from "./session-store.js";
8
9
  import { resolveAgentAdmissionTarget } from "../session/admission-auth.js";
9
10
  import { authLoginHint } from "../ui/command-hints.js";
10
11
  import { isAuthenticated } from "./credential-destinations.js";
@@ -16,6 +17,8 @@ import { isAuthenticated } from "./credential-destinations.js";
16
17
  * - auth login, auth status, auth --help
17
18
  * - --help, --version, -h, -v
18
19
  * - config (read-only config inspection)
20
+ * - skills install/list (local packaged files only)
21
+ * - judge check (validates a local verdict file; sends nothing)
19
22
  *
20
23
  * All other commands require a valid session.
21
24
  * If not authenticated, prints instructions and exits.
@@ -30,7 +33,7 @@ const AUTH_BYPASS_COMMANDS = new Set([
30
33
  "-v",
31
34
  ]);
32
35
 
33
- // Commands that work without auth (read-only, no API calls)
36
+ // Commands that work without auth (local only, no API calls)
34
37
  const NO_AUTH_REQUIRED = new Set([
35
38
  "config", // local config inspection
36
39
  ]);
@@ -330,6 +333,25 @@ function isAuthenticatedSessionValid(session) {
330
333
  return true;
331
334
  }
332
335
 
336
+ /**
337
+ * A help request a newcomer can make before signing in (`sl session --help`, `sl session say --help`): the help flag
338
+ * is the LAST argument and every argument before it is a plain word (command, subcommand, positional). Then no option
339
+ * can take "--help" as its value (`--message-file --help` stays gated), so Commander prints help and runs nothing.
340
+ * The caller applies it only to Commander-dispatched commands. Tokens are judged exactly as Commander will see them,
341
+ * never trimmed: `" --help"` is message text to Commander, so it must not skip the gate either.
342
+ *
343
+ * @param {string[]} args - CLI arguments (after normalization)
344
+ * @returns {boolean}
345
+ */
346
+ export function isHelpOnlyInvocation(args = []) {
347
+ const list = Array.isArray(args) ? args : [];
348
+ const last = list[list.length - 1];
349
+ if (last !== "--help" && last !== "-h") {
350
+ return false;
351
+ }
352
+ return list.slice(0, -1).every((arg) => typeof arg === "string" && arg !== "" && !arg.startsWith("-"));
353
+ }
354
+
333
355
  /**
334
356
  * Check if the current command requires authentication.
335
357
  * Returns true if auth is required but user is not logged in.
@@ -348,6 +370,16 @@ export async function checkAuthGate(args) {
348
370
  return { authenticated: true, session: null, bypassReason: "no_auth_required", failureReason: null };
349
371
  }
350
372
 
373
+ // These two commands only inspect/copy packaged files. Future skills subcommands stay gated.
374
+ if (args[0] === "skills" && (args[1] === "install" || args[1] === "list")) {
375
+ return { authenticated: true, session: null, bypassReason: "local_skills", failureReason: null };
376
+ }
377
+
378
+ // Validating a verdict file is local; `judge verdict` sends, so it stays gated.
379
+ if (args[0] === "judge" && args[1] === "check") {
380
+ return { authenticated: true, session: null, bypassReason: "local_judge_check", failureReason: null };
381
+ }
382
+
351
383
  if (isSessionNoAuthCommand(args)) {
352
384
  return { authenticated: true, session: null, bypassReason: "session_no_auth_required", failureReason: null };
353
385
  }
@@ -378,7 +410,9 @@ export async function checkAuthGate(args) {
378
410
  resolveError = "session_invalid";
379
411
  }
380
412
  } else {
381
- resolveError = "no_session";
413
+ // A keyring/decryption failure leaves the metadata intact. That is not
414
+ // absence, and recommending logout would destroy recoverable sign-in.
415
+ resolveError = (await readStoredSessionMetadata()) ? "session_token_missing" : "no_session";
382
416
  }
383
417
  } catch (error) {
384
418
  resolveError = error instanceof Error ? `session_read_error: ${error.message}` : "session_read_error";
@@ -405,15 +439,19 @@ export function printAuthRequired(failureReason = null) {
405
439
  if (reason === "session_expired") {
406
440
  console.error(" Your stored session has expired. Log in again:");
407
441
  } else if (reason === "session_token_missing") {
408
- console.error(" Your session metadata is present but the token is missing");
409
- console.error(" (likely a keyring read failure or mismatched storage).");
442
+ console.error(" Your session metadata is present but its credential cannot be read.");
443
+ console.error(" Check the current user/home, keyring, encryption key and file permissions.");
444
+ console.error(" Your stored credentials were not cleared; repair storage before signing in again.");
410
445
  console.error("");
411
- console.error(" " + pc.yellow("Fix:") + " log out to clear the stale metadata, then log in:");
412
- console.error(" " + pc.cyan("sentinelayer-cli auth logout"));
446
+ process.exitCode = 1;
447
+ return;
413
448
  } else if (reason && reason.startsWith("session_read_error")) {
414
449
  console.error(" Session read failed: " + pc.yellow(reason.replace(/^session_read_error:\s*/, "")));
415
- console.error(" Log out and back in to reset local state:");
416
- console.error(" " + pc.cyan("sentinelayer-cli auth logout"));
450
+ console.error(" Check the current user/home and credential-file permissions.");
451
+ console.error(" Your stored credentials were not cleared; do not log out to repair a read error.");
452
+ console.error("");
453
+ process.exitCode = 1;
454
+ return;
417
455
  } else {
418
456
  console.error(" Log in to SentinelLayer to use CLI commands:");
419
457
  }
@@ -7,6 +7,7 @@ import open from "open";
7
7
  import { loadConfig } from "../config/service.js";
8
8
  import { scopedAdmissionAuth } from "./admission-scope.js";
9
9
  import {
10
+ CredentialDestinationRefused,
10
11
  DEFAULT_API_URL,
11
12
  assertTrustedApiUrl,
12
13
  credentialFor,
@@ -453,6 +454,7 @@ async function rotateStoredApiTokenIfNeeded({
453
454
  tokenPrefix: issued.token_prefix || null,
454
455
  tokenExpiresAt: issued.expires_at || null,
455
456
  user: session.user,
457
+ aidenid: session.aidenid,
456
458
  },
457
459
  { homeDir }
458
460
  );
@@ -490,7 +492,8 @@ async function rotateStoredApiTokenIfNeeded({
490
492
  * tokenTtlDays?: number,
491
493
  * ide?: string,
492
494
  * cliVersion?: string,
493
- * homeDir?: string
495
+ * homeDir?: string,
496
+ * onAuthorize?: (details: { authorizeUrl: string, browserOpened: boolean }) => void | Promise<void>
494
497
  * }} [options]
495
498
  * @returns {Promise<{
496
499
  * apiUrl: string,
@@ -521,6 +524,7 @@ export async function loginAndPersistSession({
521
524
  ide = DEFAULT_IDE_NAME,
522
525
  cliVersion = "",
523
526
  homeDir,
527
+ onAuthorize = () => {},
524
528
  } = {}) {
525
529
  const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
526
530
  // The token this flow issues is the user's, so the API that issues it must be the configured
@@ -548,6 +552,10 @@ export async function loginAndPersistSession({
548
552
  }
549
553
  }
550
554
 
555
+ // Headless hosts must receive the URL before approval polling can complete.
556
+ // The command renders this on stderr in JSON mode, keeping stdout parseable.
557
+ await onAuthorize({ authorizeUrl, browserOpened });
558
+
551
559
  const approval = await pollCliAuthSession({
552
560
  apiUrl,
553
561
  sessionId: String(session.session_id || "").trim(),
@@ -768,7 +776,8 @@ export async function resolveActiveAuthSession({
768
776
  * homeDir?: string
769
777
  * }} [options]
770
778
  * @returns {Promise<{
771
- * authenticated: boolean,
779
+ * authenticated: boolean | null,
780
+ * validation: "none" | "not_checked" | "verified" | "rejected" | "unavailable" | "storage_unavailable",
772
781
  * apiUrl: string,
773
782
  * source: string | null,
774
783
  * storage: string | null,
@@ -805,20 +814,25 @@ export async function getAuthStatus({
805
814
  });
806
815
 
807
816
  if (!session) {
817
+ // Encrypted metadata can survive a missing/unreadable key or keyring.
818
+ // Distinguish recoverable storage from actual absence without creating a
819
+ // replacement key, validating remotely, or claiming authentication.
820
+ const stored = await readStoredSessionMetadata({ homeDir });
808
821
  return {
809
- authenticated: false,
822
+ authenticated: stored ? null : false,
823
+ validation: stored ? "storage_unavailable" : "none",
810
824
  apiUrl: await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir }),
811
- source: null,
812
- storage: null,
813
- user: null,
814
- aidenid: null,
825
+ source: stored ? "storage_unknown" : null,
826
+ storage: stored?.storage || null,
827
+ user: stored ? normalizeUser(stored.user || {}) : null,
828
+ aidenid: stored?.aidenid || null,
815
829
  remoteUser: null,
816
830
  remoteError: null,
817
831
  rotated: false,
818
- tokenExpiresAt: null,
819
- tokenPrefix: null,
820
- tokenId: null,
821
- filePath: null,
832
+ tokenExpiresAt: stored?.tokenExpiresAt || null,
833
+ tokenPrefix: stored?.tokenPrefix || null,
834
+ tokenId: stored?.tokenId || null,
835
+ filePath: stored?.filePath || null,
822
836
  };
823
837
  }
824
838
 
@@ -829,12 +843,12 @@ export async function getAuthStatus({
829
843
  remoteUser = normalizeUser(await fetchCurrentUser({ apiUrl: session.apiUrl, credential: session.credential }));
830
844
  } catch (error) {
831
845
  remoteError =
832
- error instanceof SentinelayerApiError
846
+ error instanceof SentinelayerApiError || error instanceof CredentialDestinationRefused
833
847
  ? {
834
848
  code: error.code,
835
849
  message: error.message,
836
- status: error.status,
837
- requestId: error.requestId,
850
+ status: error instanceof CredentialDestinationRefused ? 403 : error.status,
851
+ requestId: error.requestId || null,
838
852
  }
839
853
  : {
840
854
  code: "UNKNOWN",
@@ -845,8 +859,13 @@ export async function getAuthStatus({
845
859
  }
846
860
  }
847
861
 
862
+ const validationUnavailable = remoteError && (
863
+ [408, 425, 429, 499].includes(remoteError.status) || remoteError.status >= 500
864
+ );
848
865
  return {
849
- authenticated: !remoteError,
866
+ // Unknown remote health is neither a successful validation nor a logout.
867
+ authenticated: validationUnavailable ? null : !remoteError,
868
+ validation: validationUnavailable ? "unavailable" : remoteError ? "rejected" : checkRemote ? "verified" : "not_checked",
850
869
  apiUrl: session.apiUrl,
851
870
  source: session.source,
852
871
  storage: session.storage,
@@ -19,6 +19,19 @@ const SESSION_WARNING_ALLOWED_FIELDS = new Set([
19
19
  ]);
20
20
  const emittedSessionWarningKeys = new Set();
21
21
  let keytarClientOverrideForTests;
22
+ let sessionOperationTail = Promise.resolve();
23
+
24
+ // A store operation spans metadata, keyring and file publication/cleanup. Reads
25
+ // can migrate plaintext too. Keep that transaction in order within this module;
26
+ // serializing only rename would still permit stale migrations and logout races.
27
+ // One queue also covers path aliases and keyring accounts shared across homes.
28
+ // Other processes still rely on the existing key publication and bounded retries.
29
+ function runSessionOperation(operation) {
30
+ const result = sessionOperationTail.then(operation);
31
+ // A failed operation must not poison the queue or retain returned credentials.
32
+ sessionOperationTail = result.then(() => undefined, () => undefined);
33
+ return result;
34
+ }
22
35
 
23
36
  function nowIso() {
24
37
  return new Date().toISOString();
@@ -135,60 +148,68 @@ function resolveLegacyCredentialsKeyPath({ homeDir } = {}) {
135
148
  return path.join(resolvedHome, ".sentinelayer", "credentials.key");
136
149
  }
137
150
 
138
- async function loadOrCreateFileKey({ homeDir } = {}) {
139
- const keyPath = resolveCredentialsKeyPath({ homeDir });
140
- const legacyKeyPath = resolveLegacyCredentialsKeyPath({ homeDir });
141
- try {
142
- const raw = await fsp.readFile(keyPath, "utf-8");
143
- const key = Buffer.from(String(raw || "").trim(), "base64");
144
- if (key.length === 32) {
145
- return key;
146
- }
147
- } catch (error) {
148
- if (!(error && typeof error === "object" && error.code === "ENOENT")) {
151
+ async function readFileKeyAt(keyPath) {
152
+ // Exclusive-create fallback filesystems may expose a partially written key.
153
+ // Retry that bounded race, but never replace an invalid or unreadable key.
154
+ for (let attempt = 0; ; attempt += 1) {
155
+ let raw;
156
+ try {
157
+ raw = await fsp.readFile(keyPath, "utf-8");
158
+ } catch (error) {
159
+ if (error?.code === "ENOENT") return null;
149
160
  throw error;
150
161
  }
151
- }
152
-
153
- try {
154
- const legacyRaw = await fsp.readFile(legacyKeyPath, "utf-8");
155
- const legacyKey = Buffer.from(String(legacyRaw || "").trim(), "base64");
156
- if (legacyKey.length === 32) {
157
- await fsp.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
158
- await fsp.writeFile(keyPath, legacyKey.toString("base64"), { encoding: "utf-8", mode: 0o600 });
159
- try {
160
- await fsp.chmod(keyPath, 0o600);
161
- } catch {
162
- // Windows does not reliably support POSIX chmod semantics.
163
- }
164
- let verified = false;
165
- try {
166
- const verifyRaw = await fsp.readFile(keyPath, "utf-8");
167
- const verifyKey = Buffer.from(String(verifyRaw || "").trim(), "base64");
168
- verified = verifyKey.length === 32 && verifyKey.equals(legacyKey);
169
- } catch {
170
- verified = false;
171
- }
172
- if (verified) {
173
- await fsp.rm(legacyKeyPath, { force: true });
174
- }
175
- return legacyKey;
176
- }
177
- } catch (error) {
178
- if (!(error && typeof error === "object" && error.code === "ENOENT")) {
162
+ const encoded = String(raw || "").trim();
163
+ const key = Buffer.from(encoded, "base64");
164
+ if (key.length === 32 && key.toString("base64") === encoded) return key;
165
+ if (attempt >= 5) {
166
+ const error = new Error("Stored credential encryption key is invalid; it was not replaced.");
167
+ error.code = "CREDENTIALS_KEY_INVALID";
179
168
  throw error;
180
169
  }
170
+ await delayMilliseconds(20);
181
171
  }
172
+ }
182
173
 
183
- const key = crypto.randomBytes(32);
174
+ async function readFileKey({ homeDir } = {}) {
175
+ return (await readFileKeyAt(resolveCredentialsKeyPath({ homeDir }))) ||
176
+ (await readFileKeyAt(resolveLegacyCredentialsKeyPath({ homeDir })));
177
+ }
178
+
179
+ async function loadOrCreateFileKey({ homeDir } = {}) {
180
+ const keyPath = resolveCredentialsKeyPath({ homeDir });
181
+ const existing = await readFileKeyAt(keyPath);
182
+ if (existing) return existing;
183
+ const legacyKey = await readFileKeyAt(resolveLegacyCredentialsKeyPath({ homeDir }));
184
+ const key = legacyKey || crypto.randomBytes(32);
185
+ const encoded = key.toString("base64");
184
186
  await fsp.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
185
- await fsp.writeFile(keyPath, key.toString("base64"), { encoding: "utf-8", mode: 0o600 });
187
+ const tmpPath = `${keyPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
186
188
  try {
187
- await fsp.chmod(keyPath, 0o600);
188
- } catch {
189
- // Windows does not reliably support POSIX chmod semantics.
189
+ await fsp.writeFile(tmpPath, encoded, { encoding: "utf-8", mode: 0o600, flag: "wx" });
190
+ await syncFile(tmpPath);
191
+ try {
192
+ // Publish complete bytes once. Every concurrent writer then uses the winner.
193
+ await fsp.link(tmpPath, keyPath);
194
+ } catch (error) {
195
+ if (error?.code !== "EEXIST") {
196
+ if (!["ENOTSUP", "ENOSYS", "EXDEV", "EPERM"].includes(error?.code)) throw error;
197
+ try {
198
+ await fsp.writeFile(keyPath, encoded, { encoding: "utf-8", mode: 0o600, flag: "wx" });
199
+ } catch (fallbackError) {
200
+ if (fallbackError?.code !== "EEXIST") throw fallbackError;
201
+ }
202
+ }
203
+ }
204
+ const persisted = await readFileKeyAt(keyPath);
205
+ if (!persisted) throw new Error("Credential encryption key could not be persisted.");
206
+ await syncFile(keyPath);
207
+ await syncDirectory(path.dirname(keyPath));
208
+ // Keep the legacy key: deleting it can race a read-only legacy reader.
209
+ return persisted;
210
+ } finally {
211
+ await fsp.rm(tmpPath, { force: true }).catch(() => {});
190
212
  }
191
- return key;
192
213
  }
193
214
 
194
215
  function encryptToken(token, key) {
@@ -540,7 +561,11 @@ async function tryDecryptFileToken({ metadata, homeDir }) {
540
561
  return null;
541
562
  }
542
563
  try {
543
- const key = await loadOrCreateFileKey({ homeDir });
564
+ const key = await readFileKey({ homeDir });
565
+ if (!key) {
566
+ emitSessionWarning("FILE_CREDENTIAL_UNAVAILABLE", { reason: "key_missing", source: "file-encrypted" });
567
+ return null;
568
+ }
544
569
  const token = decryptToken(
545
570
  {
546
571
  tokenEncrypted: metadata.tokenEncrypted,
@@ -550,7 +575,13 @@ async function tryDecryptFileToken({ metadata, homeDir }) {
550
575
  key
551
576
  );
552
577
  return token || null;
553
- } catch {
578
+ } catch (error) {
579
+ emitSessionWarning("FILE_CREDENTIAL_UNAVAILABLE", {
580
+ reason: error?.code === "EACCES" || error?.code === "EPERM"
581
+ ? "key_unreadable"
582
+ : error?.code === "CREDENTIALS_KEY_INVALID" ? "key_invalid" : "decryption_failed",
583
+ source: "file-encrypted",
584
+ });
554
585
  return null;
555
586
  }
556
587
  }
@@ -582,6 +613,11 @@ async function tryDecryptFileToken({ metadata, homeDir }) {
582
613
  * }>}
583
614
  */
584
615
  export async function readStoredSession({ homeDir } = {}) {
616
+ const options = { homeDir: resolveHomeDir(homeDir) };
617
+ return runSessionOperation(() => readStoredSessionUnlocked(options));
618
+ }
619
+
620
+ async function readStoredSessionUnlocked({ homeDir } = {}) {
585
621
  const { filePath, metadata } = await readMetadata({ homeDir });
586
622
  if (!metadata) {
587
623
  return null;
@@ -693,6 +729,11 @@ export async function readStoredSession({ homeDir } = {}) {
693
729
  * }>}
694
730
  */
695
731
  export async function readStoredSessionMetadata({ homeDir } = {}) {
732
+ const options = { homeDir: resolveHomeDir(homeDir) };
733
+ return runSessionOperation(() => readStoredSessionMetadataUnlocked(options));
734
+ }
735
+
736
+ async function readStoredSessionMetadataUnlocked({ homeDir } = {}) {
696
737
  const { filePath, metadata } = await readMetadata({ homeDir });
697
738
  if (!metadata) {
698
739
  return null;
@@ -722,6 +763,7 @@ export async function readStoredSessionMetadata({ homeDir } = {}) {
722
763
  * tokenPrefix?: string | null,
723
764
  * tokenExpiresAt?: string | null,
724
765
  * user?: Record<string, unknown>
766
+ * aidenid?: Record<string, unknown> | null
725
767
  * }} [session]
726
768
  * @param {{ homeDir?: string }} [options]
727
769
  * @returns {Promise<{
@@ -754,8 +796,18 @@ export async function writeStoredSession(
754
796
  tokenPrefix = null,
755
797
  tokenExpiresAt = null,
756
798
  user = {},
799
+ aidenid = null,
757
800
  } = {},
758
801
  { homeDir } = {}
802
+ ) {
803
+ const session = { apiUrl, token, tokenId, tokenPrefix, tokenExpiresAt, user, aidenid };
804
+ const options = { homeDir: resolveHomeDir(homeDir) };
805
+ return runSessionOperation(() => writeStoredSessionUnlocked(session, options));
806
+ }
807
+
808
+ async function writeStoredSessionUnlocked(
809
+ { apiUrl, token, tokenId, tokenPrefix, tokenExpiresAt, user, aidenid },
810
+ { homeDir }
759
811
  ) {
760
812
  const normalizedApiUrl = String(apiUrl || "").trim();
761
813
  const normalizedToken = String(token || "").trim();
@@ -778,6 +830,7 @@ export async function writeStoredSession(
778
830
  tokenPrefix,
779
831
  tokenExpiresAt,
780
832
  user: normalizeUser(user),
833
+ aidenid,
781
834
  createdAt: existingMetadata?.createdAt || updatedAt,
782
835
  updatedAt,
783
836
  });
@@ -817,6 +870,11 @@ export async function writeStoredSession(
817
870
  * @returns {Promise<{ filePath: string, hadSession: boolean }>}
818
871
  */
819
872
  export async function clearStoredSession({ homeDir } = {}) {
873
+ const options = { homeDir: resolveHomeDir(homeDir) };
874
+ return runSessionOperation(() => clearStoredSessionUnlocked(options));
875
+ }
876
+
877
+ async function clearStoredSessionUnlocked({ homeDir } = {}) {
820
878
  const { filePath, metadata } = await readMetadata({ homeDir });
821
879
  if (metadata && metadata.storage === "keyring") {
822
880
  const keytar = await loadKeytarClient({ allowImplicit: true });
@@ -30,7 +30,7 @@
30
30
  * refused with an Error, which the gate reports as cannot-verify.
31
31
  */
32
32
 
33
- import { ghCommitSha, ghRefSegment, ghRepoSlug } from "../net/gh-api-path.js";
33
+ import { ghCheckRunsApiPath, ghCompareApiPath, ghRepoSlug } from "../net/gh-api-path.js";
34
34
 
35
35
  /** Shape a resolver must return. Anything else is treated as indeterminate. */
36
36
  const YES = true;
@@ -74,8 +74,7 @@ export function createEvidenceResolver({ run, probeDeployedSha } = {}) {
74
74
  }
75
75
 
76
76
  async function shaOnBranch({ repo, sha, branch }) {
77
- const basehead = `${ghRefSegment(branch, { label: "branch" })}...${ghCommitSha(sha)}`;
78
- const res = await run(["api", `repos/${ghRepoSlug(repo)}/compare/${basehead}`, "--jq", ".status"]);
77
+ const res = await run(["api", ghCompareApiPath({ repo, branch, sha }), "--jq", ".status"]);
79
78
  if (!res || !res.ok) return UNKNOWN;
80
79
  const status = String(res.stdout ?? "").trim();
81
80
  // GitHub's compare status, base=branch head=sha:
@@ -87,7 +86,7 @@ export function createEvidenceResolver({ run, probeDeployedSha } = {}) {
87
86
  }
88
87
 
89
88
  async function checkConclusive({ repo, ref, check }) {
90
- const res = await run(["api", `repos/${ghRepoSlug(repo)}/commits/${ghRefSegment(ref)}/check-runs`, "--jq", ".check_runs"]);
89
+ const res = await run(["api", ghCheckRunsApiPath({ repo, ref }), "--jq", ".check_runs"]);
91
90
  if (!res || !res.ok) return UNKNOWN;
92
91
  const runs = parseJson(res.stdout);
93
92
  if (!Array.isArray(runs)) return UNKNOWN;
package/src/cli.js CHANGED
@@ -5,6 +5,16 @@ import { CLI_VERSION, runLegacyCliWithErrorHandling } from "./legacy-cli.js";
5
5
  import { restrictProgramLookupToPath } from "./program-lookup.js";
6
6
 
7
7
  const COMMAND_REGISTRARS = {
8
+ skills: {
9
+ loader: () => import("./commands/skills.js"),
10
+ exportName: "registerSkillsCommand",
11
+ needsLegacy: false,
12
+ },
13
+ judge: {
14
+ loader: () => import("./commands/judge.js"),
15
+ exportName: "registerJudgeCommand",
16
+ needsLegacy: false,
17
+ },
8
18
  init: {
9
19
  loader: () => import("./commands/init.js"),
10
20
  exportName: "registerInitCommand",
@@ -262,9 +272,11 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
262
272
  // Normalize slash commands (/omargate → omargate, /audit → audit, etc.)
263
273
  const normalizedArgs = normalizeSlashArgs(rawArgs);
264
274
 
265
- // Auth gate — require login for all commands except auth/help/version/config
266
- const { checkAuthGate, printAuthRequired } = await import("./auth/gate.js");
267
- const authResult = await checkAuthGate(normalizedArgs);
275
+ // Auth gate — require login for all commands except auth/help/version/config. Help for a Commander command prints
276
+ // without login too: Commander prints it and runs nothing (a newcomer reads `sl session --help` first).
277
+ const { checkAuthGate, isHelpOnlyInvocation, printAuthRequired } = await import("./auth/gate.js");
278
+ const helpOnly = !shouldBypassCommander(normalizedArgs) && isHelpOnlyInvocation(normalizedArgs);
279
+ const authResult = helpOnly ? { authenticated: true } : await checkAuthGate(normalizedArgs);
268
280
  if (!authResult.authenticated) {
269
281
  printAuthRequired(authResult.failureReason);
270
282
  return;
@@ -272,6 +284,14 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
272
284
 
273
285
  if (shouldBypassCommander(normalizedArgs)) {
274
286
  await runLegacyCliWithErrorHandling(normalizedArgs);
287
+ if (["--help", "-h", "help"].includes(normalizedArgs[0])) {
288
+ console.log("\nAgent Skills:");
289
+ console.log(" sl skills install [names...] [--dir <path>] [--force] [--json] Install bundled Senti skills (all, or the named ones)");
290
+ console.log(" sl skills list [--dir <path>] [--json] Show bundled and installed versions/hashes");
291
+ console.log("\nJudge Loop:");
292
+ console.log(" sl judge check --file <verdict.json> [--json] Validate a verdict offline and preview the message");
293
+ console.log(" sl judge verdict <sessionId> --agent <id> --file <verdict.json> Send the verdict to the coder");
294
+ }
275
295
  return;
276
296
  }
277
297