sentinelayer-cli 0.24.0 → 0.25.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -21,6 +23,7 @@ function summarizeSeverity(findings = []) {
21
23
  function buildHotspots(ingest = {}) {
22
24
  const indexed = Array.isArray(ingest.indexedFiles?.files) ? ingest.indexedFiles.files : [];
23
25
  return indexed
26
+ .filter((file) => isAuditSourceFile(file))
24
27
  .filter((file) => Number(file.loc || 0) >= 300)
25
28
  .sort((left, right) => Number(right.loc || 0) - Number(left.loc || 0))
26
29
  .slice(0, 20)
@@ -50,7 +53,7 @@ function deriveArchitectureFindings({ findings = [], hotspots = [] } = {}) {
50
53
  continue;
51
54
  }
52
55
  derived.push({
53
- severity: hotspot.risk === "high" ? "P1" : "P2",
56
+ severity: "P2",
54
57
  file: hotspot.path,
55
58
  line: 1,
56
59
  message: `Large architectural hotspot detected (${hotspot.loc} LOC).`,
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -26,9 +28,7 @@ function buildDocumentationInventory(ingest = {}) {
26
28
  language: file.language,
27
29
  }));
28
30
  const docFiles = normalized.filter((file) => /(^|\/)(docs?|guides?|adr|readme)/i.test(file.path));
29
- const codeFiles = normalized.filter(
30
- (file) => file.loc > 0 && !/(^|\/)(docs?|guides?|adr|readme)/i.test(file.path)
31
- );
31
+ const codeFiles = normalized.filter((file) => file.loc > 0 && isAuditSourceFile(file));
32
32
  const docDensity = codeFiles.length > 0 ? docFiles.length / codeFiles.length : 0;
33
33
  const undocumentedHotspots = codeFiles
34
34
  .filter((file) => file.loc >= 320)
@@ -42,7 +42,7 @@ function buildDocumentationInventory(ingest = {}) {
42
42
  path: file.path,
43
43
  loc: file.loc,
44
44
  language: file.language,
45
- severity: file.loc >= 800 ? "P1" : "P2",
45
+ severity: "P2",
46
46
  }));
47
47
 
48
48
  return {
@@ -0,0 +1,120 @@
1
+ const SOURCE_EXTENSIONS = new Set([
2
+ ".c",
3
+ ".cc",
4
+ ".cpp",
5
+ ".cs",
6
+ ".css",
7
+ ".go",
8
+ ".graphql",
9
+ ".h",
10
+ ".hpp",
11
+ ".java",
12
+ ".js",
13
+ ".jsx",
14
+ ".kt",
15
+ ".mjs",
16
+ ".php",
17
+ ".py",
18
+ ".rb",
19
+ ".rs",
20
+ ".scss",
21
+ ".sql",
22
+ ".svelte",
23
+ ".swift",
24
+ ".tf",
25
+ ".ts",
26
+ ".tsx",
27
+ ".vue",
28
+ ]);
29
+
30
+ const SOURCE_LANGUAGES = new Set([
31
+ "c",
32
+ "c++",
33
+ "c#",
34
+ "css",
35
+ "dockerfile",
36
+ "go",
37
+ "graphql",
38
+ "java",
39
+ "javascript",
40
+ "jsx",
41
+ "kotlin",
42
+ "php",
43
+ "python",
44
+ "ruby",
45
+ "rust",
46
+ "scss",
47
+ "sql",
48
+ "svelte",
49
+ "swift",
50
+ "terraform",
51
+ "typescript",
52
+ "tsx",
53
+ "vue",
54
+ ]);
55
+
56
+ const LOCKFILE_NAMES = new Set([
57
+ "cargo.lock",
58
+ "composer.lock",
59
+ "go.sum",
60
+ "package-lock.json",
61
+ "pipfile.lock",
62
+ "pnpm-lock.yaml",
63
+ "poetry.lock",
64
+ "yarn.lock",
65
+ ]);
66
+
67
+ function normalizePath(value) {
68
+ return String(value || "").replace(/\\/g, "/").replace(/^\.\/+/, "").toLowerCase();
69
+ }
70
+
71
+ function basename(filePath) {
72
+ const normalized = normalizePath(filePath);
73
+ return normalized.slice(normalized.lastIndexOf("/") + 1);
74
+ }
75
+
76
+ function extension(filePath) {
77
+ const name = basename(filePath);
78
+ const dot = name.lastIndexOf(".");
79
+ return dot > 0 ? name.slice(dot) : "";
80
+ }
81
+
82
+ export function isAuditTestPath(filePath) {
83
+ const normalized = normalizePath(filePath);
84
+ return /(^|\/)(__tests__|tests?|specs?)(\/|$)|\.(test|spec)\.[^.\/]+$/i.test(normalized);
85
+ }
86
+
87
+ export function isAuditSupportPath(filePath) {
88
+ const normalized = normalizePath(filePath);
89
+ if (!normalized) {
90
+ return true;
91
+ }
92
+ if (LOCKFILE_NAMES.has(basename(normalized))) {
93
+ return true;
94
+ }
95
+ if (/(^|\/)(node_modules|vendor|fixtures?|__fixtures__|mocks?|__mocks__|snapshots?|__snapshots__)(\/|$)/i.test(normalized)) {
96
+ return true;
97
+ }
98
+ if (/(^|\/)(generated|__generated__|auto-generated|coverage|dist|build|out|\.next|\.nuxt|\.turbo|\.cache)(\/|$)/i.test(normalized)) {
99
+ return true;
100
+ }
101
+ if (/^(docs?|guides?|adr|tasks?|\.github|\.vscode|\.husky|public)(\/|$)/i.test(normalized)) {
102
+ return true;
103
+ }
104
+ if (/(\.min|\.bundle|generated)\.[^.\/]+$/i.test(normalized)) {
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ export function isAuditSourceFile(file = {}) {
111
+ const filePath = typeof file === "string" ? file : file.path;
112
+ const normalized = normalizePath(filePath);
113
+ if (!normalized || isAuditTestPath(normalized) || isAuditSupportPath(normalized)) {
114
+ return false;
115
+ }
116
+ if (SOURCE_EXTENSIONS.has(extension(normalized))) {
117
+ return true;
118
+ }
119
+ return SOURCE_LANGUAGES.has(String(file.language || "").trim().toLowerCase());
120
+ }
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -21,6 +23,7 @@ function summarizeSeverity(findings = []) {
21
23
  function buildRuntimeHotspots(ingest = {}) {
22
24
  const indexed = Array.isArray(ingest.indexedFiles?.files) ? ingest.indexedFiles.files : [];
23
25
  return indexed
26
+ .filter((file) => isAuditSourceFile(file))
24
27
  .filter((file) => Number(file.loc || 0) >= 280)
25
28
  .sort((left, right) => Number(right.loc || 0) - Number(left.loc || 0))
26
29
  .slice(0, 20)
@@ -28,7 +31,7 @@ function buildRuntimeHotspots(ingest = {}) {
28
31
  path: toPosixPath(file.path),
29
32
  loc: Number(file.loc || 0),
30
33
  language: file.language,
31
- severity: Number(file.loc || 0) >= 900 ? "P1" : "P2",
34
+ severity: "P2",
32
35
  }));
33
36
  }
34
37
 
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile, isAuditTestPath } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -18,10 +20,6 @@ function summarizeSeverity(findings = []) {
18
20
  return summary;
19
21
  }
20
22
 
21
- function isTestPath(filePath) {
22
- return /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(filePath);
23
- }
24
-
25
23
  function buildCoverageInventory(ingest = {}) {
26
24
  const indexedFiles = Array.isArray(ingest.indexedFiles?.files) ? ingest.indexedFiles.files : [];
27
25
  const normalized = indexedFiles.map((file) => ({
@@ -30,8 +28,8 @@ function buildCoverageInventory(ingest = {}) {
30
28
  loc: Number(file.loc || 0),
31
29
  }));
32
30
 
33
- const testFiles = normalized.filter((file) => isTestPath(file.path));
34
- const nonTestCodeFiles = normalized.filter((file) => !isTestPath(file.path) && file.loc > 0);
31
+ const testFiles = normalized.filter((file) => isAuditTestPath(file.path));
32
+ const nonTestCodeFiles = normalized.filter((file) => isAuditSourceFile(file) && file.loc > 0);
35
33
  const ratio = nonTestCodeFiles.length > 0 ? testFiles.length / nonTestCodeFiles.length : 0;
36
34
  const likelyGaps = nonTestCodeFiles
37
35
  .filter((file) => file.loc >= 260)
@@ -45,7 +43,7 @@ function buildCoverageInventory(ingest = {}) {
45
43
  path: file.path,
46
44
  loc: file.loc,
47
45
  language: file.language,
48
- severity: file.loc >= 550 ? "P1" : "P2",
46
+ severity: "P2",
49
47
  }));
50
48
 
51
49
  return {
@@ -0,0 +1,27 @@
1
+ export function resolveActionOptions(options = {}, command = null) {
2
+ const localOptions = options && typeof options.opts === "function" ? options.opts() : { ...options };
3
+ const parent = command?.parent;
4
+ if (!parent || typeof parent.opts !== "function") {
5
+ return localOptions;
6
+ }
7
+
8
+ const parentOptions = parent.opts();
9
+ const merged = {
10
+ ...parentOptions,
11
+ ...localOptions,
12
+ };
13
+
14
+ for (const [key, value] of Object.entries(parentOptions)) {
15
+ const parentSource =
16
+ typeof parent.getOptionValueSource === "function" ? parent.getOptionValueSource(key) : null;
17
+ const localSource =
18
+ typeof command?.getOptionValueSource === "function" ? command.getOptionValueSource(key) : null;
19
+ const parentWasProvided = parentSource && parentSource !== "default";
20
+ const localIsOnlyDefault = localSource === "default" || localOptions[key] === undefined;
21
+ if (parentWasProvided && localIsOnlyDefault) {
22
+ merged[key] = value;
23
+ }
24
+ }
25
+
26
+ return merged;
27
+ }
@@ -10,6 +10,7 @@ import { loadAuditRegistry, selectAuditAgents } from "../audit/registry.js";
10
10
  import { resolveOutputRoot } from "../config/service.js";
11
11
  import { createAgentEvent } from "../events/schema.js";
12
12
  import { createAuditSessionReporter, resolveAuditSessionId } from "../session/audit-reporter.js";
13
+ import { resolveActionOptions } from "./action-options.js";
13
14
  import { buildLegacyArgs } from "./legacy-args.js";
14
15
 
15
16
  function shouldEmitJson(options, command) {
@@ -213,16 +214,17 @@ export function registerAuditCommand(program, invokeLegacy) {
213
214
  .option("--output-dir <path>", "Optional artifact output root override")
214
215
  .option("--json", "Emit machine-readable output")
215
216
  .action(async (options, command) => {
216
- const emitJson = shouldEmitJson(options, command);
217
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
217
+ const actionOptions = resolveActionOptions(options, command);
218
+ const emitJson = shouldEmitJson(actionOptions, command);
219
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
218
220
  const outputRoot = await resolveOutputRoot({
219
221
  cwd: targetPath,
220
- outputDirOverride: options.outputDir,
222
+ outputDirOverride: actionOptions.outputDir,
221
223
  env: process.env,
222
224
  });
223
225
  const runDirectory = await resolveAuditRunDirectory({
224
226
  outputRoot,
225
- runId: options.runId,
227
+ runId: actionOptions.runId,
226
228
  });
227
229
  const { report } = await loadAuditRunReport(runDirectory);
228
230
  const ddPackage = await writeDdPackage({
@@ -264,11 +266,12 @@ export function registerAuditCommand(program, invokeLegacy) {
264
266
  .option("--refresh", "Refresh CODEBASE_INGEST before replay")
265
267
  .option("--json", "Emit machine-readable output")
266
268
  .action(async (runId, options, command) => {
267
- const emitJson = shouldEmitJson(options, command);
268
- const initialTargetPath = path.resolve(process.cwd(), String(options.path || "."));
269
+ const actionOptions = resolveActionOptions(options, command);
270
+ const emitJson = shouldEmitJson(actionOptions, command);
271
+ const initialTargetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
269
272
  const outputRoot = await resolveOutputRoot({
270
273
  cwd: initialTargetPath,
271
- outputDirOverride: options.outputDir,
274
+ outputDirOverride: actionOptions.outputDir,
272
275
  env: process.env,
273
276
  });
274
277
  const baseRunDirectory = await resolveAuditRunDirectory({
@@ -276,12 +279,12 @@ export function registerAuditCommand(program, invokeLegacy) {
276
279
  runId,
277
280
  });
278
281
  const { report: baseReport } = await loadAuditRunReport(baseRunDirectory);
279
- const targetPath = options.path
280
- ? path.resolve(process.cwd(), String(options.path || "."))
282
+ const targetPath = actionOptions.path
283
+ ? path.resolve(process.cwd(), String(actionOptions.path || "."))
281
284
  : path.resolve(String(baseReport.targetPath || "."));
282
285
 
283
286
  const registry = await loadAuditRegistry({
284
- registryFile: options.registryFile,
287
+ registryFile: actionOptions.registryFile,
285
288
  });
286
289
  const selected = selectAuditAgents(registry.agents, (baseReport.selectedAgents || []).join(","));
287
290
  if (selected.selected.length === 0) {
@@ -292,9 +295,9 @@ export function registerAuditCommand(program, invokeLegacy) {
292
295
  targetPath,
293
296
  agents: selected.selected,
294
297
  maxParallel: Number(baseReport.maxParallel || 1),
295
- outputDir: options.outputDir,
298
+ outputDir: actionOptions.outputDir,
296
299
  dryRun: Boolean(baseReport.dryRun),
297
- refreshIngest: Boolean(options.refresh),
300
+ refreshIngest: Boolean(actionOptions.refresh),
298
301
  isolation: baseReport.isolation || "strict",
299
302
  seedFromDeterministic: baseReport.seedFromDeterministic !== false,
300
303
  });
@@ -341,11 +344,12 @@ export function registerAuditCommand(program, invokeLegacy) {
341
344
  .option("--output-dir <path>", "Optional artifact output root override")
342
345
  .option("--json", "Emit machine-readable output")
343
346
  .action(async (baseRunId, candidateRunId, options, command) => {
344
- const emitJson = shouldEmitJson(options, command);
345
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
347
+ const actionOptions = resolveActionOptions(options, command);
348
+ const emitJson = shouldEmitJson(actionOptions, command);
349
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
346
350
  const outputRoot = await resolveOutputRoot({
347
351
  cwd: targetPath,
348
- outputDirOverride: options.outputDir,
352
+ outputDirOverride: actionOptions.outputDir,
349
353
  env: process.env,
350
354
  });
351
355
  const baseRunDirectory = await resolveAuditRunDirectory({
@@ -388,9 +392,10 @@ export function registerAuditCommand(program, invokeLegacy) {
388
392
  .option("--registry-file <path>", "Optional custom audit registry file")
389
393
  .option("--json", "Emit machine-readable output")
390
394
  .action(async (options, command) => {
391
- const emitJson = shouldEmitJson(options, command);
395
+ const actionOptions = resolveActionOptions(options, command);
396
+ const emitJson = shouldEmitJson(actionOptions, command);
392
397
  const registry = await loadAuditRegistry({
393
- registryFile: options.registryFile,
398
+ registryFile: actionOptions.registryFile,
394
399
  });
395
400
  const payload = {
396
401
  command: "audit registry",
@@ -422,10 +427,11 @@ export function registerAuditCommand(program, invokeLegacy) {
422
427
  .option("--dry-run", "Skip deterministic baseline and run security planning only")
423
428
  .option("--json", "Emit machine-readable output")
424
429
  .action(async (options, command) => {
425
- const emitJson = shouldEmitJson(options, command);
426
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
430
+ const actionOptions = resolveActionOptions(options, command);
431
+ const emitJson = shouldEmitJson(actionOptions, command);
432
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
427
433
  const registry = await loadAuditRegistry({
428
- registryFile: options.registryFile,
434
+ registryFile: actionOptions.registryFile,
429
435
  });
430
436
  const selected = selectAuditAgents(registry.agents, "security");
431
437
  if (selected.selected.length !== 1) {
@@ -436,9 +442,9 @@ export function registerAuditCommand(program, invokeLegacy) {
436
442
  targetPath,
437
443
  agents: selected.selected,
438
444
  maxParallel: 1,
439
- outputDir: options.outputDir,
440
- dryRun: Boolean(options.dryRun),
441
- refreshIngest: Boolean(options.refresh),
445
+ outputDir: actionOptions.outputDir,
446
+ dryRun: Boolean(actionOptions.dryRun),
447
+ refreshIngest: Boolean(actionOptions.refresh),
442
448
  });
443
449
  const securityAgent = result.agentResults.find((agent) => agent.agentId === "security") || null;
444
450
 
@@ -486,10 +492,11 @@ export function registerAuditCommand(program, invokeLegacy) {
486
492
  .option("--dry-run", "Skip deterministic baseline and run architecture planning only")
487
493
  .option("--json", "Emit machine-readable output")
488
494
  .action(async (options, command) => {
489
- const emitJson = shouldEmitJson(options, command);
490
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
495
+ const actionOptions = resolveActionOptions(options, command);
496
+ const emitJson = shouldEmitJson(actionOptions, command);
497
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
491
498
  const registry = await loadAuditRegistry({
492
- registryFile: options.registryFile,
499
+ registryFile: actionOptions.registryFile,
493
500
  });
494
501
  const selected = selectAuditAgents(registry.agents, "architecture");
495
502
  if (selected.selected.length !== 1) {
@@ -500,9 +507,9 @@ export function registerAuditCommand(program, invokeLegacy) {
500
507
  targetPath,
501
508
  agents: selected.selected,
502
509
  maxParallel: 1,
503
- outputDir: options.outputDir,
504
- dryRun: Boolean(options.dryRun),
505
- refreshIngest: Boolean(options.refresh),
510
+ outputDir: actionOptions.outputDir,
511
+ dryRun: Boolean(actionOptions.dryRun),
512
+ refreshIngest: Boolean(actionOptions.refresh),
506
513
  });
507
514
  const architectureAgent =
508
515
  result.agentResults.find((agent) => agent.agentId === "architecture") || null;
@@ -551,10 +558,11 @@ export function registerAuditCommand(program, invokeLegacy) {
551
558
  .option("--dry-run", "Skip deterministic baseline and run testing planning only")
552
559
  .option("--json", "Emit machine-readable output")
553
560
  .action(async (options, command) => {
554
- const emitJson = shouldEmitJson(options, command);
555
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
561
+ const actionOptions = resolveActionOptions(options, command);
562
+ const emitJson = shouldEmitJson(actionOptions, command);
563
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
556
564
  const registry = await loadAuditRegistry({
557
- registryFile: options.registryFile,
565
+ registryFile: actionOptions.registryFile,
558
566
  });
559
567
  const selected = selectAuditAgents(registry.agents, "testing");
560
568
  if (selected.selected.length !== 1) {
@@ -565,9 +573,9 @@ export function registerAuditCommand(program, invokeLegacy) {
565
573
  targetPath,
566
574
  agents: selected.selected,
567
575
  maxParallel: 1,
568
- outputDir: options.outputDir,
569
- dryRun: Boolean(options.dryRun),
570
- refreshIngest: Boolean(options.refresh),
576
+ outputDir: actionOptions.outputDir,
577
+ dryRun: Boolean(actionOptions.dryRun),
578
+ refreshIngest: Boolean(actionOptions.refresh),
571
579
  });
572
580
  const testingAgent = result.agentResults.find((agent) => agent.agentId === "testing") || null;
573
581
 
@@ -615,10 +623,11 @@ export function registerAuditCommand(program, invokeLegacy) {
615
623
  .option("--dry-run", "Skip deterministic baseline and run performance planning only")
616
624
  .option("--json", "Emit machine-readable output")
617
625
  .action(async (options, command) => {
618
- const emitJson = shouldEmitJson(options, command);
619
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
626
+ const actionOptions = resolveActionOptions(options, command);
627
+ const emitJson = shouldEmitJson(actionOptions, command);
628
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
620
629
  const registry = await loadAuditRegistry({
621
- registryFile: options.registryFile,
630
+ registryFile: actionOptions.registryFile,
622
631
  });
623
632
  const selected = selectAuditAgents(registry.agents, "performance");
624
633
  if (selected.selected.length !== 1) {
@@ -629,9 +638,9 @@ export function registerAuditCommand(program, invokeLegacy) {
629
638
  targetPath,
630
639
  agents: selected.selected,
631
640
  maxParallel: 1,
632
- outputDir: options.outputDir,
633
- dryRun: Boolean(options.dryRun),
634
- refreshIngest: Boolean(options.refresh),
641
+ outputDir: actionOptions.outputDir,
642
+ dryRun: Boolean(actionOptions.dryRun),
643
+ refreshIngest: Boolean(actionOptions.refresh),
635
644
  });
636
645
  const performanceAgent =
637
646
  result.agentResults.find((agent) => agent.agentId === "performance") || null;
@@ -680,10 +689,11 @@ export function registerAuditCommand(program, invokeLegacy) {
680
689
  .option("--dry-run", "Skip deterministic baseline and run compliance planning only")
681
690
  .option("--json", "Emit machine-readable output")
682
691
  .action(async (options, command) => {
683
- const emitJson = shouldEmitJson(options, command);
684
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
692
+ const actionOptions = resolveActionOptions(options, command);
693
+ const emitJson = shouldEmitJson(actionOptions, command);
694
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
685
695
  const registry = await loadAuditRegistry({
686
- registryFile: options.registryFile,
696
+ registryFile: actionOptions.registryFile,
687
697
  });
688
698
  const selected = selectAuditAgents(registry.agents, "compliance");
689
699
  if (selected.selected.length !== 1) {
@@ -694,9 +704,9 @@ export function registerAuditCommand(program, invokeLegacy) {
694
704
  targetPath,
695
705
  agents: selected.selected,
696
706
  maxParallel: 1,
697
- outputDir: options.outputDir,
698
- dryRun: Boolean(options.dryRun),
699
- refreshIngest: Boolean(options.refresh),
707
+ outputDir: actionOptions.outputDir,
708
+ dryRun: Boolean(actionOptions.dryRun),
709
+ refreshIngest: Boolean(actionOptions.refresh),
700
710
  });
701
711
  const complianceAgent =
702
712
  result.agentResults.find((agent) => agent.agentId === "compliance") || null;
@@ -745,10 +755,11 @@ export function registerAuditCommand(program, invokeLegacy) {
745
755
  .option("--dry-run", "Skip deterministic baseline and run documentation planning only")
746
756
  .option("--json", "Emit machine-readable output")
747
757
  .action(async (options, command) => {
748
- const emitJson = shouldEmitJson(options, command);
749
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
758
+ const actionOptions = resolveActionOptions(options, command);
759
+ const emitJson = shouldEmitJson(actionOptions, command);
760
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
750
761
  const registry = await loadAuditRegistry({
751
- registryFile: options.registryFile,
762
+ registryFile: actionOptions.registryFile,
752
763
  });
753
764
  const selected = selectAuditAgents(registry.agents, "documentation");
754
765
  if (selected.selected.length !== 1) {
@@ -759,9 +770,9 @@ export function registerAuditCommand(program, invokeLegacy) {
759
770
  targetPath,
760
771
  agents: selected.selected,
761
772
  maxParallel: 1,
762
- outputDir: options.outputDir,
763
- dryRun: Boolean(options.dryRun),
764
- refreshIngest: Boolean(options.refresh),
773
+ outputDir: actionOptions.outputDir,
774
+ dryRun: Boolean(actionOptions.dryRun),
775
+ refreshIngest: Boolean(actionOptions.refresh),
765
776
  });
766
777
  const documentationAgent =
767
778
  result.agentResults.find((agent) => agent.agentId === "documentation") || null;
@@ -808,8 +819,9 @@ export function registerAuditCommand(program, invokeLegacy) {
808
819
  .option("--reuse-omargate <runId>", "Reuse deterministic findings from an OmarGate run id or latest")
809
820
  .option("--json", "Emit machine-readable output")
810
821
  .action(async (options, command) => {
822
+ const actionOptions = resolveActionOptions(options, command);
811
823
  const legacyArgs = buildLegacyArgs(["/audit"], {
812
- commandOptions: options,
824
+ commandOptions: actionOptions,
813
825
  command,
814
826
  });
815
827
  await invokeLegacy(legacyArgs);
@@ -848,9 +860,10 @@ export function registerAuditCommand(program, invokeLegacy) {
848
860
  .option("--output-dir <path>", "Artifact output root override")
849
861
  .option("--json", "Emit machine-readable final output")
850
862
  .action(async (options, command) => {
851
- const emitJson = shouldEmitJson(options, command);
852
- const emitStream = Boolean(options.stream);
853
- const targetPath = path.resolve(process.cwd(), String(options.path || "."));
863
+ const actionOptions = resolveActionOptions(options, command);
864
+ const emitJson = shouldEmitJson(actionOptions, command);
865
+ const emitStream = Boolean(actionOptions.stream);
866
+ const targetPath = path.resolve(process.cwd(), String(actionOptions.path || "."));
854
867
 
855
868
  // Lazy-load modules
856
869
  const { julesAuditLoop } = await import("../agents/jules/loop.js");
@@ -861,7 +874,7 @@ export function registerAuditCommand(program, invokeLegacy) {
861
874
  const { runDeterministicReviewPipeline } = await import("../review/local-review.js");
862
875
  const fsp = await import("node:fs/promises");
863
876
 
864
- const outputRoot = await resolveOut({ targetPath, outputDir: options.outputDir });
877
+ const outputRoot = await resolveOut({ targetPath, outputDir: actionOptions.outputDir });
865
878
  const artifactDir = path.join(outputRoot, "reports", "jules-" + Date.now());
866
879
  await fsp.mkdir(artifactDir, { recursive: true });
867
880
 
@@ -876,7 +889,7 @@ export function registerAuditCommand(program, invokeLegacy) {
876
889
  try {
877
890
  ingest = await generateCodebaseIngest({
878
891
  rootPath: targetPath, outputDir: outputRoot,
879
- refresh: Boolean(options.refresh),
892
+ refresh: Boolean(actionOptions.refresh),
880
893
  });
881
894
  } catch {
882
895
  ingest = await collectCodebaseIngest({ rootPath: targetPath });
@@ -887,7 +900,7 @@ export function registerAuditCommand(program, invokeLegacy) {
887
900
 
888
901
  // ── [2] OMAR BASELINE (blind — Jules won't see until reconciliation) ──
889
902
  let omarBaseline = null;
890
- if (!options.skipBaseline) {
903
+ if (!actionOptions.skipBaseline) {
891
904
  emitProgress(onEvent, JULES_DEFINITION, "Running Omar 7-layer deterministic baseline...");
892
905
  try {
893
906
  omarBaseline = await runDeterministicReviewPipeline({
@@ -925,14 +938,14 @@ export function registerAuditCommand(program, invokeLegacy) {
925
938
 
926
939
  // ── [5] PROVIDER ──────────────────────────────────────────────
927
940
  const providerConfig = {};
928
- if (options.provider) providerConfig.provider = options.provider;
929
- if (options.model) providerConfig.model = options.model;
930
- if (options.apiKey) providerConfig.apiKey = options.apiKey;
941
+ if (actionOptions.provider) providerConfig.provider = actionOptions.provider;
942
+ if (actionOptions.model) providerConfig.model = actionOptions.model;
943
+ if (actionOptions.apiKey) providerConfig.apiKey = actionOptions.apiKey;
931
944
 
932
945
  // ── [6] SYSTEM PROMPT (full production prompt) ─────────────────
933
946
  const { buildJulesProductionPrompt } = await import("../agents/jules/config/system-prompt.js");
934
947
  const systemPrompt = buildJulesProductionPrompt({
935
- mode: options.mode,
948
+ mode: actionOptions.mode,
936
949
  framework: ingest?.frameworks?.[0] || "unknown",
937
950
  componentCount: ingest?.indexedFiles?.files?.filter(
938
951
  f => /\.(tsx|jsx|vue|svelte)$/.test(f.path || ""),
@@ -952,14 +965,14 @@ export function registerAuditCommand(program, invokeLegacy) {
952
965
  blackboard,
953
966
  memory: memoryIndex,
954
967
  budget: {
955
- maxCostUsd: parseFloat(options.maxCost) || 5.0,
968
+ maxCostUsd: parseFloat(actionOptions.maxCost) || 5.0,
956
969
  maxOutputTokens: JULES_DEFINITION.budget.maxOutputTokens,
957
970
  maxRuntimeMs: JULES_DEFINITION.budget.maxRuntimeMs,
958
971
  maxToolCalls: JULES_DEFINITION.budget.maxToolCalls,
959
972
  },
960
973
  provider: providerConfig,
961
- mode: options.mode,
962
- maxTurns: parseInt(options.maxTurns) || 25,
974
+ mode: actionOptions.mode,
975
+ maxTurns: parseInt(actionOptions.maxTurns) || 25,
963
976
  onEvent,
964
977
  });
965
978
 
@@ -973,8 +986,8 @@ export function registerAuditCommand(program, invokeLegacy) {
973
986
 
974
987
  // ── [7.5] RUNTIME AUDIT (if --url provided or URL detected) ────
975
988
  let runtimeResult = null;
976
- const runtimeUrl = options.url || null;
977
- if (runtimeUrl || !options.skipRuntime) {
989
+ const runtimeUrl = actionOptions.url || null;
990
+ if (runtimeUrl || !actionOptions.skipRuntime) {
978
991
  emitProgress(onEvent, JULES_DEFINITION, "Checking for deployed URL...");
979
992
  try {
980
993
  const { runtimeAudit: runRT } = await import("../agents/jules/tools/runtime-audit.js");
@@ -1057,7 +1070,7 @@ export function registerAuditCommand(program, invokeLegacy) {
1057
1070
  command: "audit frontend",
1058
1071
  persona: JULES_DEFINITION.persona,
1059
1072
  targetPath,
1060
- mode: options.mode,
1073
+ mode: actionOptions.mode,
1061
1074
  framework: ingest?.frameworks?.[0] || "unknown",
1062
1075
  findings: allFindings,
1063
1076
  summary: { total: allFindings.length, ...severityCounts, blocking: severityCounts.P0 > 0 || severityCounts.P1 > 0 },
@@ -1109,7 +1122,7 @@ export function registerAuditCommand(program, invokeLegacy) {
1109
1122
  const syncResult = await syncRunToDashboard({
1110
1123
  command: "audit frontend",
1111
1124
  persona: JULES_DEFINITION.persona,
1112
- mode: options.mode,
1125
+ mode: actionOptions.mode,
1113
1126
  framework: ingest?.frameworks?.[0] || "unknown",
1114
1127
  summary: { total: allFindings.length, ...severityCounts, blocking: severityCounts.P0 > 0 || severityCounts.P1 > 0 },
1115
1128
  usage: report?.usage || {},
@@ -25,6 +25,7 @@ import {
25
25
  writeReviewComparisonArtifact,
26
26
  writeReviewRunContext,
27
27
  } from "../review/replay.js";
28
+ import { resolveActionOptions } from "./action-options.js";
28
29
 
29
30
  function shouldEmitJson(options, command) {
30
31
  const local = Boolean(options && options.json);
@@ -236,16 +237,17 @@ function registerVerdictCommand(review, verdict) {
236
237
  .option("--actor <id>", "Operator identifier for audit trail", "")
237
238
  .option("--json", "Emit machine-readable output")
238
239
  .action(async (findingId, options, command) => {
239
- const emitJson = shouldEmitJson(options, command);
240
- const targetPath = resolveTargetPath(".", options);
240
+ const actionOptions = resolveActionOptions(options, command);
241
+ const emitJson = shouldEmitJson(actionOptions, command);
242
+ const targetPath = resolveTargetPath(".", actionOptions);
241
243
  const result = await recordReviewDecision({
242
244
  targetPath,
243
- runId: options.runId,
244
- outputDir: options.outputDir,
245
+ runId: actionOptions.runId,
246
+ outputDir: actionOptions.outputDir,
245
247
  findingId,
246
248
  verdict,
247
- note: options.note,
248
- actor: options.actor,
249
+ note: actionOptions.note,
250
+ actor: actionOptions.actor,
249
251
  env: process.env,
250
252
  });
251
253
 
@@ -374,12 +376,13 @@ export function registerReviewCommand(program) {
374
376
  .option("--output-dir <path>", "Optional artifact output root override")
375
377
  .option("--json", "Emit machine-readable output")
376
378
  .action(async (options, command) => {
377
- const emitJson = shouldEmitJson(options, command);
378
- const targetPath = resolveTargetPath(".", options);
379
+ const actionOptions = resolveActionOptions(options, command);
380
+ const emitJson = shouldEmitJson(actionOptions, command);
381
+ const targetPath = resolveTargetPath(".", actionOptions);
379
382
  const loaded = await loadUnifiedReviewReport({
380
383
  targetPath,
381
- runId: options.runId,
382
- outputDir: options.outputDir,
384
+ runId: actionOptions.runId,
385
+ outputDir: actionOptions.outputDir,
383
386
  env: process.env,
384
387
  });
385
388
 
@@ -412,14 +415,15 @@ export function registerReviewCommand(program) {
412
415
  .option("--output-file <path>", "Optional custom export output path")
413
416
  .option("--json", "Emit machine-readable output")
414
417
  .action(async (options, command) => {
415
- const emitJson = shouldEmitJson(options, command);
416
- const targetPath = resolveTargetPath(".", options);
418
+ const actionOptions = resolveActionOptions(options, command);
419
+ const emitJson = shouldEmitJson(actionOptions, command);
420
+ const targetPath = resolveTargetPath(".", actionOptions);
417
421
  const exported = await exportUnifiedReviewReport({
418
422
  targetPath,
419
- runId: options.runId,
420
- outputDir: options.outputDir,
421
- format: options.format,
422
- outputFile: options.outputFile,
423
+ runId: actionOptions.runId,
424
+ outputDir: actionOptions.outputDir,
425
+ format: actionOptions.format,
426
+ outputFile: actionOptions.outputFile,
423
427
  env: process.env,
424
428
  });
425
429
 
@@ -453,12 +457,13 @@ export function registerReviewCommand(program) {
453
457
  .option("--ai-dry-run", "Force AI dry-run during replay")
454
458
  .option("--json", "Emit machine-readable output")
455
459
  .action(async (runId, options, command) => {
456
- const emitJson = shouldEmitJson(options, command);
457
- const lookupPath = resolveTargetPath(".", options);
460
+ const actionOptions = resolveActionOptions(options, command);
461
+ const emitJson = shouldEmitJson(actionOptions, command);
462
+ const lookupPath = resolveTargetPath(".", actionOptions);
458
463
  const source = await loadUnifiedReviewReport({
459
464
  targetPath: lookupPath,
460
465
  runId,
461
- outputDir: options.outputDir,
466
+ outputDir: actionOptions.outputDir,
462
467
  env: process.env,
463
468
  });
464
469
 
@@ -470,20 +475,20 @@ export function registerReviewCommand(program) {
470
475
  }
471
476
 
472
477
  const invocation = sourceContext?.context?.invocation || {};
473
- const replayTargetPath = options.path
474
- ? resolveTargetPath(".", options)
478
+ const replayTargetPath = actionOptions.path
479
+ ? resolveTargetPath(".", actionOptions)
475
480
  : path.resolve(sourceContext?.context?.targetPath || source.report.targetPath || lookupPath);
476
- const aiEnabled = options.noAi ? false : Boolean(invocation.aiEnabled);
481
+ const aiEnabled = actionOptions.noAi ? false : Boolean(invocation.aiEnabled);
477
482
 
478
483
  const replayOutcome = await executeReviewRun({
479
484
  targetPath: replayTargetPath,
480
485
  mode: sourceContext?.context?.mode || source.report.mode || "full",
481
- outputDir: options.outputDir || invocation.outputDir || "",
482
- specFile: options.spec || invocation.specFile || "",
483
- refreshIngest: Boolean(options.refresh || invocation.refreshIngest),
486
+ outputDir: actionOptions.outputDir || invocation.outputDir || "",
487
+ specFile: actionOptions.spec || invocation.specFile || "",
488
+ refreshIngest: Boolean(actionOptions.refresh || invocation.refreshIngest),
484
489
  aiConfig: {
485
490
  enable: aiEnabled,
486
- aiDryRun: Boolean(options.aiDryRun || invocation.aiDryRun),
491
+ aiDryRun: Boolean(actionOptions.aiDryRun || invocation.aiDryRun),
487
492
  provider: invocation.provider,
488
493
  model: invocation.model,
489
494
  sessionId: invocation.sessionId,
@@ -542,18 +547,19 @@ export function registerReviewCommand(program) {
542
547
  .option("--output-file <path>", "Optional custom output path for comparison JSON")
543
548
  .option("--json", "Emit machine-readable output")
544
549
  .action(async (baseRunId, candidateRunId, options, command) => {
545
- const emitJson = shouldEmitJson(options, command);
546
- const targetPath = resolveTargetPath(".", options);
550
+ const actionOptions = resolveActionOptions(options, command);
551
+ const emitJson = shouldEmitJson(actionOptions, command);
552
+ const targetPath = resolveTargetPath(".", actionOptions);
547
553
  const base = await loadUnifiedReviewReport({
548
554
  targetPath,
549
555
  runId: baseRunId,
550
- outputDir: options.outputDir,
556
+ outputDir: actionOptions.outputDir,
551
557
  env: process.env,
552
558
  });
553
559
  const candidate = await loadUnifiedReviewReport({
554
560
  targetPath,
555
561
  runId: candidateRunId,
556
- outputDir: options.outputDir,
562
+ outputDir: actionOptions.outputDir,
557
563
  env: process.env,
558
564
  });
559
565
 
@@ -566,8 +572,8 @@ export function registerReviewCommand(program) {
566
572
  });
567
573
 
568
574
  let outputPath = artifact.artifactPath;
569
- if (options.outputFile) {
570
- outputPath = path.resolve(targetPath, String(options.outputFile));
575
+ if (actionOptions.outputFile) {
576
+ outputPath = path.resolve(targetPath, String(actionOptions.outputFile));
571
577
  await fsp.mkdir(path.dirname(outputPath), { recursive: true });
572
578
  await fsp.writeFile(outputPath, `${JSON.stringify(artifact.payload, null, 2)}\n`, "utf-8");
573
579
  }
@@ -611,13 +617,14 @@ export function registerReviewCommand(program) {
611
617
  .option("--refresh", "Refresh CODEBASE_INGEST prior to compatibility scan")
612
618
  .option("--json", "Emit machine-readable output")
613
619
  .action(async (options, command) => {
614
- const emitJson = shouldEmitJson(options, command);
615
- const targetPath = resolveTargetPath(".", options);
620
+ const actionOptions = resolveActionOptions(options, command);
621
+ const emitJson = shouldEmitJson(actionOptions, command);
622
+ const targetPath = resolveTargetPath(".", actionOptions);
616
623
  let ingestRefresh = null;
617
- if (options.refresh) {
624
+ if (actionOptions.refresh) {
618
625
  const ingestResolution = await resolveCodebaseIngest({
619
626
  rootPath: targetPath,
620
- outputDir: options.outputDir,
627
+ outputDir: actionOptions.outputDir,
621
628
  refresh: true,
622
629
  });
623
630
  ingestRefresh = {
@@ -630,13 +637,13 @@ export function registerReviewCommand(program) {
630
637
  contentHash: ingestResolution.fingerprint?.contentHash || "",
631
638
  };
632
639
  }
633
- const mode = resolveModeFromOptions(options, {
634
- defaultMode: String(options.mode || "full").trim().toLowerCase() || "full",
640
+ const mode = resolveModeFromOptions(actionOptions, {
641
+ defaultMode: String(actionOptions.mode || "full").trim().toLowerCase() || "full",
635
642
  });
636
643
  const scan = await runLocalReviewScan({
637
644
  targetPath,
638
645
  mode,
639
- specFile: options.spec,
646
+ specFile: actionOptions.spec,
640
647
  });
641
648
 
642
649
  const report = `# Local Review Scan
@@ -660,7 +667,7 @@ ${formatFindingsMarkdown(scan.findings)}
660
667
  const reportPath = await writeReviewReport({
661
668
  targetPath: scan.targetPath,
662
669
  mode: scan.mode,
663
- outputDir: options.outputDir,
670
+ outputDir: actionOptions.outputDir,
664
671
  reportMarkdown: report,
665
672
  });
666
673
 
@@ -62,6 +62,11 @@ import {
62
62
  updateSessionTitle,
63
63
  } from "../session/store.js";
64
64
  import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
65
+ import {
66
+ acceptSessionInvitation,
67
+ normalizeSessionOnboarding,
68
+ writeSessionOnboardingBrief,
69
+ } from "../session/invitations.js";
65
70
  import { postFirstSentiMessage } from "../session/first-message.js";
66
71
  import { createListenerHostWake } from "../session/wake/listen-wake.js";
67
72
  import { appendToStream, readStream, tailStream } from "../session/stream.js";
@@ -124,6 +129,13 @@ function normalizeString(value) {
124
129
  return String(value || "").trim();
125
130
  }
126
131
 
132
+ function optionWasSetByCli(command, optionName) {
133
+ if (!command || typeof command.getOptionValueSource !== "function") {
134
+ return false;
135
+ }
136
+ return command.getOptionValueSource(optionName) === "cli";
137
+ }
138
+
127
139
  const SESSION_SAY_CONFIRM_ATTEMPTS = 3;
128
140
  const SESSION_SAY_CONFIRM_DELAY_MS = 250;
129
141
  const SESSION_SAY_CONFIRM_PAGE_LIMIT = 200;
@@ -2719,6 +2731,9 @@ export function registerSessionCommand(program) {
2719
2731
  "--agent <id>",
2720
2732
  "Granted agent id to emit an agent_join event as. Behaves like post-agent for human/placeholder ids — those are recorded in the local registry only.",
2721
2733
  )
2734
+ .option("--invite-token <token>", "Invitation token to accept before joining the session")
2735
+ .option("--seat-key <key>", "Reserved session seat key to claim while accepting an invite")
2736
+ .option("--idempotency-key <key>", "Explicit idempotency key for invite acceptance")
2722
2737
  .option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
2723
2738
  .option("--model <model>", "Agent model hint", "cli")
2724
2739
  .option("--path <path>", "Workspace path for the session", ".")
@@ -2729,12 +2744,35 @@ export function registerSessionCommand(program) {
2729
2744
  throw new Error("session id is required.");
2730
2745
  }
2731
2746
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2747
+ const explicitAgent = normalizeString(options.agent);
2748
+ const legacyName = normalizeString(options.name);
2749
+ const inviteToken = normalizeString(options.inviteToken);
2750
+ let invitationAccept = null;
2751
+ let invitationAcceptResult = null;
2752
+ if (inviteToken) {
2753
+ invitationAccept = await acceptSessionInvitation(normalizedSessionId, {
2754
+ targetPath,
2755
+ invitationToken: inviteToken,
2756
+ seatKey: options.seatKey,
2757
+ agentId: explicitAgent || legacyName,
2758
+ idempotencyKey: options.idempotencyKey,
2759
+ });
2760
+ invitationAcceptResult =
2761
+ invitationAccept?.result && typeof invitationAccept.result === "object"
2762
+ ? invitationAccept.result
2763
+ : null;
2764
+ }
2732
2765
 
2733
2766
  // PR #483 contract: verify the session exists and the caller has access
2734
2767
  // BEFORE materializing local cache state. Without this we'd silently
2735
2768
  // create a phantom local NDJSON for a session that's archived or owned
2736
2769
  // by another tenant — which is the bug Carter reported when asking for
2737
2770
  // a clean "share an id from web → join in CLI" flow.
2771
+ //
2772
+ // Invitation acceptance is the only exception to the old ordering:
2773
+ // before the invite is accepted the user may legitimately receive 403,
2774
+ // so --invite-token performs the guarded accept mutation first and this
2775
+ // verification proves membership immediately afterward.
2738
2776
  const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
2739
2777
  if (!verification.ok) {
2740
2778
  if (verification.status === 404 || verification.reason === "not_found") {
@@ -2766,17 +2804,26 @@ export function registerSessionCommand(program) {
2766
2804
  targetPath,
2767
2805
  });
2768
2806
 
2769
- const explicitAgent = normalizeString(options.agent);
2770
- const agentSeed = explicitAgent || normalizeString(options.name);
2807
+ const acceptedOnboarding = normalizeSessionOnboarding(
2808
+ invitationAcceptResult?.onboarding,
2809
+ invitationAcceptResult?.claimedSeat,
2810
+ );
2811
+ const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
2812
+ const agentSeed = explicitAgent || acceptedAgentId || legacyName;
2771
2813
  const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
2772
- const role = normalizeString(options.role) || "coder";
2814
+ const roleWasExplicit = optionWasSetByCli(command, "role");
2815
+ const role =
2816
+ normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
2817
+ "coder";
2773
2818
  const model = normalizeString(options.model) || "cli";
2819
+ const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
2774
2820
 
2775
2821
  // `registerAgent` already writes the canonical `agent_join` event to the
2776
2822
  // local NDJSON and best-effort relays it to /events via appendToStream
2777
2823
  // → syncSessionEventToApi. That gives us the exact `post-agent` parity
2778
- // the spec calls for when `--agent <granted>` is provided. We don't
2779
- // double-emit; we just record whether the explicit agent path was used
2824
+ // the spec calls for when `--agent <granted>` or an accepted reserved
2825
+ // seat provides a concrete agent id. We don't double-emit; we record
2826
+ // whether a durable agent identity path was used
2780
2827
  // so the JSON output can advertise it to callers (and tests).
2781
2828
  const joined = await registerAgent(normalizedSessionId, {
2782
2829
  targetPath,
@@ -2784,15 +2831,21 @@ export function registerSessionCommand(program) {
2784
2831
  model,
2785
2832
  role,
2786
2833
  trackProcessExit: false,
2787
- awaitRemoteSync: Boolean(explicitAgent),
2834
+ awaitRemoteSync: hasConcreteAgentIdentity,
2788
2835
  });
2789
2836
  const agentJoinRelayed =
2790
2837
  joined.emittedJoinEvent !== false &&
2791
- Boolean(explicitAgent) &&
2838
+ hasConcreteAgentIdentity &&
2792
2839
  Boolean(resolvedAgentId) &&
2793
2840
  resolvedAgentId !== "cli-user" &&
2794
2841
  resolvedAgentId !== "unknown" &&
2795
2842
  !resolvedAgentId.startsWith("human-");
2843
+ const onboardingBrief = await writeSessionOnboardingBrief(normalizedSessionId, {
2844
+ targetPath,
2845
+ onboarding: acceptedOnboarding,
2846
+ claimedSeat: invitationAcceptResult?.claimedSeat || null,
2847
+ agentId: joined.agentId,
2848
+ });
2796
2849
 
2797
2850
  const eventCount = Number(remoteSession.eventCount ?? remoteSession.events ?? 0);
2798
2851
  const agents = Array.isArray(remoteSession.agents) ? remoteSession.agents : [];
@@ -2824,6 +2877,21 @@ export function registerSessionCommand(program) {
2824
2877
  agentCount: Number.isFinite(agentCount) ? agentCount : 0,
2825
2878
  lastActivityAt: lastActivityIso || null,
2826
2879
  agentJoinRelayed,
2880
+ invitationAccepted: Boolean(invitationAcceptResult?.ok || invitationAccept),
2881
+ invitationAccept: invitationAccept
2882
+ ? {
2883
+ idempotencyKey: invitationAccept.idempotencyKey,
2884
+ claimedSeat: invitationAcceptResult?.claimedSeat || null,
2885
+ onboarding: acceptedOnboarding,
2886
+ capacity: invitationAcceptResult?.capacity || null,
2887
+ }
2888
+ : null,
2889
+ onboardingGuide: onboardingBrief
2890
+ ? {
2891
+ markdownPath: onboardingBrief.markdownPath,
2892
+ jsonPath: onboardingBrief.jsonPath,
2893
+ }
2894
+ : null,
2827
2895
  };
2828
2896
  if (shouldEmitJson(options, command)) {
2829
2897
  console.log(JSON.stringify(payload, null, 2));
@@ -2836,7 +2904,17 @@ export function registerSessionCommand(program) {
2836
2904
  `Joined session ${titleLabel} (${normalizedSessionId}) — ${payload.eventCount} events, ${payload.agentCount} agents, last activity ${ageLabel}`,
2837
2905
  ),
2838
2906
  );
2907
+ if (payload.invitationAccepted) {
2908
+ const seat = payload.invitationAccept?.claimedSeat || {};
2909
+ const seatLabel = normalizeString(seat.seatKey)
2910
+ ? `; claimed ${normalizeString(seat.seatType) || "reserved"} seat ${seat.seatKey}`
2911
+ : "";
2912
+ console.log(pc.green(`Invitation accepted${seatLabel}`));
2913
+ }
2839
2914
  console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
2915
+ if (payload.onboardingGuide?.markdownPath) {
2916
+ console.log(pc.gray(`onboarding=${payload.onboardingGuide.markdownPath}`));
2917
+ }
2840
2918
  });
2841
2919
 
2842
2920
  session
@@ -19,7 +19,7 @@ import {
19
19
  resolveSentinelDir,
20
20
  } from "./paths.js";
21
21
 
22
- const EVENTS_LOCK_TIMEOUT_MS = 5000;
22
+ const EVENTS_LOCK_TIMEOUT_MS = 15_000;
23
23
  const EVENTS_LOCK_STALE_MS = 30_000;
24
24
  const EVENTS_LOCK_POLL_MS = 25;
25
25
 
package/src/legacy-cli.js CHANGED
@@ -2294,6 +2294,9 @@ ${renderCoordinationNumberedList()}
2294
2294
  - Finding: \`sl session say <id> "finding: [P2] missing rate limit on POST /api/auth/login"\`
2295
2295
  - Help: \`sl session say <id> "help: unexpected change in package.json - who modified it?"\`
2296
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.
2297
2300
  `;
2298
2301
  }
2299
2302
 
@@ -0,0 +1,241 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ import { requestJsonMutation } from "../auth/http.js";
7
+ import { resolveActiveAuthSession } from "../auth/service.js";
8
+ import { renderCoordinationBulletList } from "./coordination-guidance.js";
9
+ import { resolveSessionPaths } from "./paths.js";
10
+
11
+ export const SESSION_INVITATION_ACCEPT_ROUTE_ID =
12
+ "POST /api/v1/sessions/{session_id}/invitations/accept";
13
+ export const SESSION_MUTATION_ORIGIN = "https://sentinelayer.com";
14
+
15
+ function normalizeString(value) {
16
+ return String(value || "").trim();
17
+ }
18
+
19
+ function safeFileToken(value, fallback = "onboarding") {
20
+ const normalized = normalizeString(value)
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9._-]+/g, "-")
23
+ .replace(/^-+|-+$/g, "");
24
+ return (normalized || fallback).slice(0, 80);
25
+ }
26
+
27
+ export function canonicalSessionRef(value) {
28
+ const raw = normalizeString(value);
29
+ if (!raw) return "unavailable";
30
+ return raw.toLowerCase();
31
+ }
32
+
33
+ export function createSessionMutationCsrfToken({
34
+ bearerToken,
35
+ sessionId,
36
+ routeId,
37
+ idempotencyKey,
38
+ } = {}) {
39
+ const secret = normalizeString(bearerToken);
40
+ if (!secret) return "";
41
+ const message = [
42
+ "session-mutation-csrf:v1",
43
+ canonicalSessionRef(sessionId),
44
+ normalizeString(routeId),
45
+ normalizeString(idempotencyKey),
46
+ ].join("\0");
47
+ return createHmac("sha256", secret).update(message, "utf-8").digest("hex");
48
+ }
49
+
50
+ export function createSessionMutationHeaders({
51
+ bearerToken,
52
+ sessionId,
53
+ routeId,
54
+ idempotencyKey,
55
+ origin = SESSION_MUTATION_ORIGIN,
56
+ } = {}) {
57
+ const token = normalizeString(bearerToken);
58
+ const normalizedOrigin = normalizeString(origin) || SESSION_MUTATION_ORIGIN;
59
+ return {
60
+ Authorization: `Bearer ${token}`,
61
+ Origin: normalizedOrigin,
62
+ "Sec-Fetch-Site": "same-site",
63
+ "X-Sentinelayer-Session-Mutation": "session-mutation",
64
+ "X-CSRF-Token": createSessionMutationCsrfToken({
65
+ bearerToken: token,
66
+ sessionId,
67
+ routeId,
68
+ idempotencyKey,
69
+ }),
70
+ };
71
+ }
72
+
73
+ export function createSessionMutationIdempotencyKey(operationName = "invitation-accept") {
74
+ const operation = normalizeString(operationName)
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, "-")
77
+ .replace(/^-+|-+$/g, "")
78
+ .slice(0, 32) || "session-mutation";
79
+ return `sl-cli-${operation}-${randomUUID()}`;
80
+ }
81
+
82
+ export async function acceptSessionInvitation(
83
+ sessionId,
84
+ {
85
+ invitationToken,
86
+ seatKey = "",
87
+ agentId = "",
88
+ targetPath = process.cwd(),
89
+ idempotencyKey = "",
90
+ origin = SESSION_MUTATION_ORIGIN,
91
+ resolveAuthSession = resolveActiveAuthSession,
92
+ requestMutation = requestJsonMutation,
93
+ } = {},
94
+ ) {
95
+ const normalizedSessionId = normalizeString(sessionId);
96
+ const normalizedInvitationToken = normalizeString(invitationToken);
97
+ if (!normalizedSessionId) {
98
+ throw new Error("session id is required.");
99
+ }
100
+ if (!normalizedInvitationToken) {
101
+ throw new Error("invite token is required.");
102
+ }
103
+
104
+ const auth = await resolveAuthSession({
105
+ cwd: targetPath,
106
+ env: process.env,
107
+ autoRotate: false,
108
+ });
109
+ if (!auth?.token || !auth?.apiUrl) {
110
+ throw new Error("Not authenticated. Run `sl auth login` first.");
111
+ }
112
+
113
+ const resolvedIdempotencyKey =
114
+ normalizeString(idempotencyKey) || createSessionMutationIdempotencyKey("session-invite-accept");
115
+ const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
116
+ const body = {
117
+ token: normalizedInvitationToken,
118
+ };
119
+ const normalizedSeatKey = normalizeString(seatKey);
120
+ const normalizedAgentId = normalizeString(agentId);
121
+ if (normalizedSeatKey) body.seatKey = normalizedSeatKey;
122
+ if (normalizedAgentId) body.agentId = normalizedAgentId;
123
+
124
+ const result = await requestMutation(
125
+ `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/invitations/accept`,
126
+ {
127
+ method: "POST",
128
+ operationName: "session.invitation_accept",
129
+ idempotencyKey: resolvedIdempotencyKey,
130
+ headers: createSessionMutationHeaders({
131
+ bearerToken: auth.token,
132
+ sessionId: normalizedSessionId,
133
+ routeId: SESSION_INVITATION_ACCEPT_ROUTE_ID,
134
+ idempotencyKey: resolvedIdempotencyKey,
135
+ origin,
136
+ }),
137
+ body,
138
+ },
139
+ );
140
+
141
+ return {
142
+ idempotencyKey: resolvedIdempotencyKey,
143
+ result,
144
+ };
145
+ }
146
+
147
+ export function normalizeSessionOnboarding(onboarding = {}, claimedSeat = null) {
148
+ const onboardingSource =
149
+ onboarding && typeof onboarding === "object" && !Array.isArray(onboarding) ? onboarding : {};
150
+ const seatSource =
151
+ claimedSeat && typeof claimedSeat === "object" && !Array.isArray(claimedSeat) ? claimedSeat : {};
152
+ const normalized = {
153
+ seatKey: normalizeString(onboardingSource.seatKey) || normalizeString(seatSource.seatKey) || null,
154
+ agentId:
155
+ normalizeString(onboardingSource.agentId) ||
156
+ normalizeString(seatSource.claimedAgentId) ||
157
+ normalizeString(seatSource.agentId) ||
158
+ null,
159
+ displayName:
160
+ normalizeString(onboardingSource.displayName) || normalizeString(seatSource.displayName) || null,
161
+ role: normalizeString(onboardingSource.role) || normalizeString(seatSource.role) || null,
162
+ instructions:
163
+ normalizeString(onboardingSource.instructions) || normalizeString(seatSource.instructions) || null,
164
+ };
165
+ return Object.values(normalized).some(Boolean) ? normalized : null;
166
+ }
167
+
168
+ function renderOnboardingMarkdown({ sessionId, onboarding, claimedSeat, agentId }) {
169
+ const lines = [
170
+ "# Senti Session Onboarding",
171
+ "",
172
+ `Session: ${sessionId}`,
173
+ `Agent: ${normalizeString(onboarding.agentId) || normalizeString(agentId) || "unknown"}`,
174
+ ];
175
+ if (onboarding.displayName) lines.push(`Display name: ${onboarding.displayName}`);
176
+ if (onboarding.role) lines.push(`Role: ${onboarding.role}`);
177
+ if (onboarding.seatKey) lines.push(`Seat key: ${onboarding.seatKey}`);
178
+ if (claimedSeat?.seatType) lines.push(`Seat type: ${normalizeString(claimedSeat.seatType)}`);
179
+ lines.push("", "## SOUL Instructions", "");
180
+ lines.push(onboarding.instructions || "No seat-specific instructions were provided.");
181
+ lines.push("", "## Session Etiquette", "", renderCoordinationBulletList());
182
+ lines.push(
183
+ "",
184
+ "## First Actions",
185
+ "",
186
+ `- Read recent room context: \`sl session read ${sessionId} --remote --tail 50 --json\``,
187
+ `- Start a quiet listener: \`sl session listen --session ${sessionId} --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence\``,
188
+ `- Post a short plan before editing: \`sl session say ${sessionId} \"plan: <scope>; files: <paths>\"\``,
189
+ "- Review `AGENTS.md` / `CLAUDE.md`, then keep `tasks/todo.md` current for work status.",
190
+ "- Add any Carter correction pattern to `tasks/lessons.md` before continuing the next slice.",
191
+ );
192
+ return `${lines.join("\n")}\n`;
193
+ }
194
+
195
+ export async function writeSessionOnboardingBrief(
196
+ sessionId,
197
+ {
198
+ onboarding,
199
+ claimedSeat = null,
200
+ agentId = "",
201
+ targetPath = process.cwd(),
202
+ } = {},
203
+ ) {
204
+ const normalizedSessionId = normalizeString(sessionId);
205
+ const normalizedOnboarding = normalizeSessionOnboarding(onboarding, claimedSeat);
206
+ if (!normalizedSessionId || !normalizedOnboarding) {
207
+ return null;
208
+ }
209
+ const paths = resolveSessionPaths(normalizedSessionId, { targetPath });
210
+ const owner = safeFileToken(normalizedOnboarding.agentId || agentId || normalizedOnboarding.seatKey);
211
+ const dir = path.join(paths.sentiDir, "onboarding");
212
+ const jsonPath = path.join(dir, `${owner}.json`);
213
+ const markdownPath = path.join(dir, `${owner}.md`);
214
+ const payload = {
215
+ schemaVersion: "1.0.0",
216
+ sessionId: normalizedSessionId,
217
+ agentId: normalizeString(agentId) || normalizedOnboarding.agentId || null,
218
+ onboarding: normalizedOnboarding,
219
+ claimedSeat: claimedSeat && typeof claimedSeat === "object" ? claimedSeat : null,
220
+ writtenAt: new Date().toISOString(),
221
+ };
222
+ await fsp.mkdir(dir, { recursive: true });
223
+ await Promise.all([
224
+ fsp.writeFile(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8"),
225
+ fsp.writeFile(
226
+ markdownPath,
227
+ renderOnboardingMarkdown({
228
+ sessionId: normalizedSessionId,
229
+ onboarding: normalizedOnboarding,
230
+ claimedSeat,
231
+ agentId,
232
+ }),
233
+ "utf-8",
234
+ ),
235
+ ]);
236
+ return {
237
+ jsonPath,
238
+ markdownPath,
239
+ onboarding: normalizedOnboarding,
240
+ };
241
+ }
@@ -15,6 +15,10 @@ const DEFAULT_LOCK_POLL_MS = 25;
15
15
  const DEFAULT_MAX_STREAM_EVENTS = 10_000;
16
16
  const DEFAULT_REMOTE_REFRESH_TTL_SECONDS = 24 * 60 * 60;
17
17
  const REMOTE_REFRESH_TIMEOUT_MS = 2_500;
18
+ const METADATA_WRITE_RETRY_ATTEMPTS = 5;
19
+ const METADATA_WRITE_RETRY_DELAY_MS = 50;
20
+
21
+ let metadataWriteCounter = 0;
18
22
 
19
23
  function normalizeString(value) {
20
24
  return String(value || "").trim();
@@ -68,11 +72,36 @@ async function readSessionMetadata(paths) {
68
72
  }
69
73
  }
70
74
 
75
+ function isRetryableMetadataWriteError(error) {
76
+ const code = error && typeof error === "object" ? error.code : "";
77
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY";
78
+ }
79
+
80
+ async function replaceMetadataFile(tmpPath, metadataPath) {
81
+ for (let attempt = 1; attempt <= METADATA_WRITE_RETRY_ATTEMPTS; attempt += 1) {
82
+ try {
83
+ await fsp.rename(tmpPath, metadataPath);
84
+ return;
85
+ } catch (error) {
86
+ if (!isRetryableMetadataWriteError(error) || attempt >= METADATA_WRITE_RETRY_ATTEMPTS) {
87
+ throw error;
88
+ }
89
+ await sleep(METADATA_WRITE_RETRY_DELAY_MS * attempt);
90
+ }
91
+ }
92
+ }
93
+
71
94
  async function writeSessionMetadata(paths, metadata = {}) {
72
95
  await fsp.mkdir(paths.sessionDir, { recursive: true });
73
- const tmpPath = `${paths.metadataPath}.${process.pid}.${Date.now()}.tmp`;
96
+ metadataWriteCounter = (metadataWriteCounter + 1) % Number.MAX_SAFE_INTEGER;
97
+ const tmpPath = `${paths.metadataPath}.${process.pid}.${Date.now()}.${metadataWriteCounter}.tmp`;
74
98
  await fsp.writeFile(tmpPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
75
- await fsp.rename(tmpPath, paths.metadataPath);
99
+ try {
100
+ await replaceMetadataFile(tmpPath, paths.metadataPath);
101
+ } catch (error) {
102
+ await fsp.rm(tmpPath, { force: true }).catch(() => {});
103
+ throw error;
104
+ }
76
105
  }
77
106
 
78
107
  function remoteStatusAllowsLocalRefresh(remoteSession = {}) {