sentinelayer-cli 0.1.2 → 0.4.4

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 (129) hide show
  1. package/README.md +998 -996
  2. package/bin/create-sentinelayer.js +5 -5
  3. package/bin/sentinelayer-cli.js +4 -4
  4. package/bin/sl.js +5 -5
  5. package/package.json +63 -54
  6. package/src/agents/jules/config/definition.js +209 -209
  7. package/src/agents/jules/config/system-prompt.js +175 -175
  8. package/src/agents/jules/error-intake.js +51 -51
  9. package/src/agents/jules/fix-cycle.js +377 -377
  10. package/src/agents/jules/loop.js +367 -367
  11. package/src/agents/jules/pulse.js +327 -319
  12. package/src/agents/jules/stream.js +186 -186
  13. package/src/agents/jules/swarm/file-scanner.js +74 -74
  14. package/src/agents/jules/swarm/index.js +11 -11
  15. package/src/agents/jules/swarm/orchestrator.js +362 -362
  16. package/src/agents/jules/swarm/pattern-hunter.js +123 -123
  17. package/src/agents/jules/swarm/sub-agent.js +308 -308
  18. package/src/agents/jules/tools/auth-audit.js +557 -222
  19. package/src/agents/jules/tools/dispatch.js +327 -327
  20. package/src/agents/jules/tools/file-edit.js +180 -180
  21. package/src/agents/jules/tools/file-read.js +100 -100
  22. package/src/agents/jules/tools/frontend-analyze.js +570 -570
  23. package/src/agents/jules/tools/glob.js +168 -168
  24. package/src/agents/jules/tools/grep.js +228 -228
  25. package/src/agents/jules/tools/index.js +29 -29
  26. package/src/agents/jules/tools/path-guards.js +161 -161
  27. package/src/agents/jules/tools/runtime-audit.js +503 -493
  28. package/src/agents/jules/tools/shell.js +383 -383
  29. package/src/agents/jules/tools/url-policy.js +100 -0
  30. package/src/ai/aidenid.js +972 -945
  31. package/src/ai/client.js +508 -508
  32. package/src/ai/domain-target-store.js +268 -268
  33. package/src/ai/identity-store.js +270 -270
  34. package/src/ai/site-store.js +145 -145
  35. package/src/audit/agents/architecture.js +180 -180
  36. package/src/audit/agents/compliance.js +179 -179
  37. package/src/audit/agents/documentation.js +165 -165
  38. package/src/audit/agents/performance.js +145 -145
  39. package/src/audit/agents/security.js +215 -215
  40. package/src/audit/agents/testing.js +172 -172
  41. package/src/audit/orchestrator.js +557 -557
  42. package/src/audit/package.js +204 -204
  43. package/src/audit/registry.js +284 -284
  44. package/src/audit/replay.js +103 -103
  45. package/src/auth/gate.js +45 -11
  46. package/src/auth/http.js +270 -113
  47. package/src/auth/service.js +891 -848
  48. package/src/auth/session-store.js +359 -345
  49. package/src/cli.js +252 -252
  50. package/src/commands/ai/identity-lifecycle.js +1338 -1337
  51. package/src/commands/ai/provision-governance.js +1272 -1246
  52. package/src/commands/ai/shared.js +147 -147
  53. package/src/commands/ai.js +11 -11
  54. package/src/commands/apply.js +12 -12
  55. package/src/commands/audit.js +1166 -1166
  56. package/src/commands/auth.js +375 -366
  57. package/src/commands/chat.js +191 -191
  58. package/src/commands/config.js +184 -184
  59. package/src/commands/cost.js +311 -311
  60. package/src/commands/daemon/core.js +850 -850
  61. package/src/commands/daemon/extended.js +1048 -1048
  62. package/src/commands/daemon/shared.js +213 -213
  63. package/src/commands/daemon.js +11 -11
  64. package/src/commands/guide.js +174 -174
  65. package/src/commands/ingest.js +58 -58
  66. package/src/commands/init.js +55 -55
  67. package/src/commands/legacy-args.js +10 -10
  68. package/src/commands/mcp.js +461 -404
  69. package/src/commands/omargate.js +15 -15
  70. package/src/commands/persona.js +20 -20
  71. package/src/commands/plugin.js +260 -260
  72. package/src/commands/policy.js +132 -132
  73. package/src/commands/prompt.js +238 -238
  74. package/src/commands/review.js +704 -704
  75. package/src/commands/scan.js +866 -788
  76. package/src/commands/spec.js +716 -716
  77. package/src/commands/swarm.js +651 -651
  78. package/src/commands/telemetry.js +202 -202
  79. package/src/commands/watch.js +510 -510
  80. package/src/config/agent-dictionary.js +182 -182
  81. package/src/config/io.js +56 -56
  82. package/src/config/paths.js +18 -18
  83. package/src/config/schema.js +55 -55
  84. package/src/config/service.js +184 -184
  85. package/src/cost/budget.js +235 -235
  86. package/src/cost/history.js +188 -188
  87. package/src/cost/tracker.js +171 -171
  88. package/src/daemon/artifact-lineage.js +534 -534
  89. package/src/daemon/assignment-ledger.js +770 -770
  90. package/src/daemon/ast-parser-layer.js +258 -258
  91. package/src/daemon/budget-governor.js +633 -633
  92. package/src/daemon/callgraph-overlay.js +646 -646
  93. package/src/daemon/error-worker.js +626 -626
  94. package/src/daemon/hybrid-mapper.js +929 -929
  95. package/src/daemon/jira-lifecycle.js +632 -632
  96. package/src/daemon/operator-control.js +657 -657
  97. package/src/daemon/reliability-lane.js +471 -471
  98. package/src/daemon/watchdog.js +971 -971
  99. package/src/guide/generator.js +316 -316
  100. package/src/ingest/engine.js +918 -918
  101. package/src/legacy-cli.js +2592 -2435
  102. package/src/mcp/registry.js +695 -695
  103. package/src/memory/blackboard.js +301 -301
  104. package/src/memory/retrieval.js +581 -581
  105. package/src/plugin/manifest.js +553 -553
  106. package/src/policy/packs.js +144 -144
  107. package/src/prompt/generator.js +118 -106
  108. package/src/review/ai-review.js +669 -669
  109. package/src/review/local-review.js +1295 -1284
  110. package/src/review/replay.js +235 -235
  111. package/src/review/report.js +664 -664
  112. package/src/review/spec-binding.js +487 -487
  113. package/src/scaffold/generator.js +67 -0
  114. package/src/scaffold/templates.js +150 -0
  115. package/src/scan/generator.js +418 -351
  116. package/src/scan/gh-secrets.js +107 -0
  117. package/src/spec/generator.js +519 -519
  118. package/src/spec/regenerate.js +237 -237
  119. package/src/spec/templates.js +91 -91
  120. package/src/swarm/dashboard.js +247 -247
  121. package/src/swarm/factory.js +363 -363
  122. package/src/swarm/pentest.js +934 -934
  123. package/src/swarm/registry.js +419 -419
  124. package/src/swarm/report.js +158 -158
  125. package/src/swarm/runtime.js +576 -576
  126. package/src/swarm/scenario-dsl.js +272 -272
  127. package/src/telemetry/ledger.js +302 -302
  128. package/src/telemetry/sync.js +107 -61
  129. package/src/ui/markdown.js +220 -220
package/src/legacy-cli.js CHANGED
@@ -1,2435 +1,2592 @@
1
- #!/usr/bin/env node
2
-
3
- import crypto from "node:crypto";
4
- import fs from "node:fs";
5
- import fsp from "node:fs/promises";
6
- import path from "node:path";
7
- import process from "node:process";
8
- import { spawnSync } from "node:child_process";
9
- import { createInterface } from "node:readline/promises";
10
- import { stdin as input, stdout as output } from "node:process";
11
- import { pathToFileURL } from "node:url";
12
-
13
- import open from "open";
14
- import pc from "picocolors";
15
- import prompts from "prompts";
16
- import {
17
- DEFAULT_CODING_AGENT_ID,
18
- detectCodingAgentFromEnv,
19
- detectIdeFromEnv,
20
- listSupportedCodingAgents,
21
- resolveCodingAgent,
22
- } from "./config/agent-dictionary.js";
23
- import { resolveOutputRoot } from "./config/service.js";
24
- import { collectCodebaseIngest, formatIngestSummary } from "./ingest/engine.js";
25
-
26
- let DEFAULT_API_URL = process.env.SENTINELAYER_API_URL || "https://api.sentinelayer.com";
27
- let DEFAULT_WEB_URL = process.env.SENTINELAYER_WEB_URL || "https://sentinelayer.com";
28
- let DEFAULT_GITHUB_CLONE_BASE_URL =
29
- process.env.SENTINELAYER_GITHUB_CLONE_BASE_URL || "https://github.com";
30
- const DEFAULT_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
31
- const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
32
- const PACKAGE_JSON_PATH = new URL("../package.json", import.meta.url);
33
-
34
- function refreshRuntimeDefaults() {
35
- DEFAULT_API_URL = process.env.SENTINELAYER_API_URL || "https://api.sentinelayer.com";
36
- DEFAULT_WEB_URL = process.env.SENTINELAYER_WEB_URL || "https://sentinelayer.com";
37
- DEFAULT_GITHUB_CLONE_BASE_URL =
38
- process.env.SENTINELAYER_GITHUB_CLONE_BASE_URL || "https://github.com";
39
- }
40
-
41
- function resolveCliVersion() {
42
- try {
43
- const raw = fs.readFileSync(PACKAGE_JSON_PATH, "utf-8");
44
- const pkg = JSON.parse(raw);
45
- const version = String(pkg && pkg.version ? pkg.version : "").trim();
46
- if (version) {
47
- return version;
48
- }
49
- } catch {
50
- // Ignore and fall through to static fallback.
51
- }
52
- return "0.1.0";
53
- }
54
-
55
- export const CLI_VERSION = resolveCliVersion();
56
-
57
- const DEFAULT_MODEL_BY_PROVIDER = {
58
- openai: "gpt-5.3-codex",
59
- anthropic: "claude-sonnet-4-6",
60
- google: "gemini-2.5-flash",
61
- };
62
-
63
- const VALID_AI_PROVIDERS = new Set(["openai", "anthropic", "google"]);
64
- const VALID_GENERATION_MODES = new Set(["detailed", "quick", "enterprise"]);
65
- const VALID_AUDIENCE_LEVELS = new Set(["developer", "intermediate", "beginner"]);
66
- const VALID_PROJECT_TYPES = new Set(["greenfield", "add_feature", "bugfix"]);
67
- const VALID_AUTH_MODES = new Set(["sentinelayer", "byok"]);
68
-
69
- class SentinelayerApiError extends Error {
70
- constructor(message, { code = "API_ERROR", status = 500, requestId = null } = {}) {
71
- super(message);
72
- this.name = "SentinelayerApiError";
73
- this.code = code;
74
- this.status = status;
75
- this.requestId = requestId;
76
- }
77
- }
78
-
79
- function nowIso() {
80
- return new Date().toISOString();
81
- }
82
-
83
- function parseCommaList(value) {
84
- return String(value || "")
85
- .split(",")
86
- .map((item) => item.trim())
87
- .filter(Boolean);
88
- }
89
-
90
- function sanitizeProjectName(value) {
91
- const raw = String(value || "").trim();
92
- if (!raw) return "";
93
- return raw
94
- .toLowerCase()
95
- .replace(/[^a-z0-9-_ ]+/g, "")
96
- .trim()
97
- .replace(/\s+/g, "-")
98
- .replace(/-+/g, "-")
99
- .replace(/^-|-$/g, "");
100
- }
101
-
102
- function normalizeRepoSlug(value) {
103
- return String(value || "").trim().replace(/\.git$/i, "");
104
- }
105
-
106
- function isValidRepoSlug(value) {
107
- return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizeRepoSlug(value));
108
- }
109
-
110
- function getRepoNameFromSlug(value) {
111
- const normalized = normalizeRepoSlug(value);
112
- const parts = normalized.split("/");
113
- if (parts.length !== 2) return "";
114
- return sanitizeProjectName(parts[1]);
115
- }
116
-
117
- function isValidSecretName(value) {
118
- return /^[A-Z][A-Z0-9_]{1,127}$/.test(String(value || "").trim());
119
- }
120
-
121
- function boolFromEnv(value) {
122
- const normalized = String(value || "").trim().toLowerCase();
123
- return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
124
- }
125
-
126
- function normalizeListInput(value) {
127
- if (Array.isArray(value)) {
128
- return value.map((item) => String(item || "").trim()).filter(Boolean);
129
- }
130
- return parseCommaList(value);
131
- }
132
-
133
- function parseCliArgs(argv) {
134
- let projectName = "";
135
- let interviewFile = "";
136
- let nonInteractive = boolFromEnv(process.env.SENTINELAYER_CLI_NON_INTERACTIVE);
137
- let skipBrowserOpen = boolFromEnv(process.env.SENTINELAYER_CLI_SKIP_BROWSER_OPEN);
138
- let showHelp = false;
139
- let showVersion = false;
140
-
141
- for (let i = 0; i < argv.length; i += 1) {
142
- const arg = String(argv[i] || "").trim();
143
- if (!arg) continue;
144
- if (arg === "--help" || arg === "-h") {
145
- showHelp = true;
146
- continue;
147
- }
148
- if (arg === "--version" || arg === "-v") {
149
- showVersion = true;
150
- continue;
151
- }
152
- if (arg === "--non-interactive") {
153
- nonInteractive = true;
154
- continue;
155
- }
156
- if (arg === "--skip-browser-open") {
157
- skipBrowserOpen = true;
158
- continue;
159
- }
160
- if (arg === "--interview-file") {
161
- const next = String(argv[i + 1] || "").trim();
162
- if (!next) {
163
- throw new Error("Missing value for --interview-file");
164
- }
165
- interviewFile = next;
166
- i += 1;
167
- continue;
168
- }
169
- if (arg.startsWith("-")) {
170
- throw new Error(`Unknown argument: ${arg}`);
171
- }
172
- if (!projectName) {
173
- projectName = arg;
174
- } else {
175
- throw new Error(`Unexpected extra argument: ${arg}`);
176
- }
177
- }
178
-
179
- return {
180
- projectName,
181
- interviewFile,
182
- nonInteractive,
183
- skipBrowserOpen,
184
- showHelp,
185
- showVersion,
186
- };
187
- }
188
-
189
- function printUsage() {
190
- console.log(`sentinelayer-cli v${CLI_VERSION}`);
191
- console.log("");
192
- console.log("Usage:");
193
- console.log(" sentinelayer-cli [project-name] [options]");
194
- console.log(" sentinelayer-cli /omargate deep [--path PATH]");
195
- console.log(" sentinelayer-cli /audit [--path PATH]");
196
- console.log(" sentinelayer-cli /persona orchestrator [--mode MODE] [--path PATH]");
197
- console.log(" sentinelayer-cli /apply --plan <todo.md> [--path PATH]");
198
- console.log(" sentinelayer-cli config <list|get|set|edit> [options]");
199
- console.log(" sentinelayer-cli ingest map [--path PATH] [--output-file PATH]");
200
- console.log(" sentinelayer-cli spec <list-templates|show-template|generate> [options]");
201
- console.log(" sentinelayer-cli prompt <generate|preview> [options]");
202
- console.log("");
203
- console.log("Options:");
204
- console.log(" -h, --help Show help");
205
- console.log(" -v, --version Show CLI version");
206
- console.log(" --non-interactive Disable prompts and require interview payload");
207
- console.log(" --interview-file PATH Load interview JSON from file");
208
- console.log(" --skip-browser-open Do not auto-open browser during auth");
209
- console.log(" --path PATH Target path for local command mode");
210
- console.log(" --output-dir PATH Artifact root for local command reports (default .sentinelayer)");
211
- console.log(" --json Emit machine-readable JSON for local command mode");
212
- console.log("");
213
- console.log("Environment:");
214
- console.log(" SENTINELAYER_CLI_NON_INTERACTIVE=1");
215
- console.log(" SENTINELAYER_CLI_SKIP_BROWSER_OPEN=1");
216
- console.log(" SENTINELAYER_CLI_INTERVIEW_JSON='{\"projectName\":\"my-app\",...}'");
217
- console.log(" SENTINELAYER_GITHUB_CLONE_BASE_URL=https://github.com");
218
- }
219
-
220
- function normalizeInterviewInput(
221
- raw,
222
- { argProjectName = "", detectedRepo = "", detectedCodingAgent = DEFAULT_CODING_AGENT_ID } = {}
223
- ) {
224
- const obj = raw && typeof raw === "object" ? raw : {};
225
- const aiProvider = String(obj.aiProvider || "openai").trim().toLowerCase();
226
- const generationMode = String(obj.generationMode || "detailed").trim().toLowerCase();
227
- const audienceLevel = String(obj.audienceLevel || "developer").trim().toLowerCase();
228
- const codingAgentCandidate = String(obj.codingAgent || detectedCodingAgent || DEFAULT_CODING_AGENT_ID)
229
- .trim()
230
- .toLowerCase();
231
- const authMode = String(obj.authMode || "sentinelayer").trim().toLowerCase();
232
- const explicitRepoSlug = normalizeRepoSlug(obj.repoSlug || "");
233
- const connectRepo = Boolean(obj.connectRepo) || isValidRepoSlug(explicitRepoSlug);
234
- const repoSlug = normalizeRepoSlug(obj.repoSlug || detectedRepo || "");
235
- const buildFromExistingRepo = connectRepo ? Boolean(obj.buildFromExistingRepo) : false;
236
- const rawProjectType = String(obj.projectType || "")
237
- .trim()
238
- .toLowerCase();
239
- const fallbackProjectType =
240
- buildFromExistingRepo || (connectRepo && isValidRepoSlug(repoSlug)) || isValidRepoSlug(detectedRepo)
241
- ? "add_feature"
242
- : "greenfield";
243
- const projectType = VALID_PROJECT_TYPES.has(rawProjectType) ? rawProjectType : fallbackProjectType;
244
- const normalizedAuthMode = VALID_AUTH_MODES.has(authMode) ? authMode : "sentinelayer";
245
- const derivedProjectName = sanitizeProjectName(obj.projectName || argProjectName) || getRepoNameFromSlug(repoSlug);
246
- let resolvedCodingAgent;
247
- try {
248
- resolvedCodingAgent = resolveCodingAgent(codingAgentCandidate).id;
249
- } catch {
250
- resolvedCodingAgent = DEFAULT_CODING_AGENT_ID;
251
- }
252
-
253
- const normalized = {
254
- projectName: derivedProjectName,
255
- projectDescription: String(obj.projectDescription || "").trim(),
256
- aiProvider: VALID_AI_PROVIDERS.has(aiProvider) ? aiProvider : "openai",
257
- generationMode: VALID_GENERATION_MODES.has(generationMode) ? generationMode : "detailed",
258
- audienceLevel: VALID_AUDIENCE_LEVELS.has(audienceLevel) ? audienceLevel : "developer",
259
- projectType,
260
- codingAgent: resolvedCodingAgent,
261
- techStack: normalizeListInput(obj.techStack),
262
- features: normalizeListInput(obj.features),
263
- authMode: normalizedAuthMode,
264
- connectRepo,
265
- repoSlug: connectRepo ? repoSlug : "",
266
- buildFromExistingRepo,
267
- injectSecret: connectRepo && normalizedAuthMode === "sentinelayer" ? Boolean(obj.injectSecret) : false,
268
- };
269
-
270
- return normalized;
271
- }
272
-
273
- function validateInterviewInput(interview) {
274
- if (!interview.projectName) {
275
- throw new Error("Project name is required.");
276
- }
277
- if (String(interview.projectDescription || "").trim().length < 15) {
278
- throw new Error("Project description must be at least 15 characters.");
279
- }
280
- if (!VALID_AI_PROVIDERS.has(interview.aiProvider)) {
281
- throw new Error("Invalid aiProvider. Use openai, anthropic, or google.");
282
- }
283
- if (!VALID_GENERATION_MODES.has(interview.generationMode)) {
284
- throw new Error("Invalid generationMode. Use detailed, quick, or enterprise.");
285
- }
286
- if (!VALID_AUDIENCE_LEVELS.has(interview.audienceLevel)) {
287
- throw new Error("Invalid audienceLevel. Use developer, intermediate, or beginner.");
288
- }
289
- if (!VALID_PROJECT_TYPES.has(interview.projectType)) {
290
- throw new Error("Invalid projectType. Use greenfield, add_feature, or bugfix.");
291
- }
292
- try {
293
- resolveCodingAgent(interview.codingAgent || DEFAULT_CODING_AGENT_ID);
294
- } catch {
295
- throw new Error(
296
- `Invalid codingAgent. Use one of: ${listSupportedCodingAgents()
297
- .map((agent) => agent.id)
298
- .join(", ")}.`
299
- );
300
- }
301
- if (!VALID_AUTH_MODES.has(interview.authMode)) {
302
- throw new Error("Invalid authMode. Use sentinelayer or byok.");
303
- }
304
- if (interview.connectRepo && !isValidRepoSlug(interview.repoSlug)) {
305
- throw new Error("Invalid repo slug. Expected owner/repo.");
306
- }
307
- if (interview.buildFromExistingRepo && !interview.connectRepo) {
308
- throw new Error("buildFromExistingRepo requires connectRepo=true.");
309
- }
310
- if (interview.injectSecret && interview.authMode !== "sentinelayer") {
311
- throw new Error("injectSecret requires authMode=sentinelayer.");
312
- }
313
- }
314
-
315
- async function loadAutomatedInterview({
316
- argProjectName,
317
- detectedRepo,
318
- detectedCodingAgent,
319
- interviewFile,
320
- }) {
321
- const envPayload = String(process.env.SENTINELAYER_CLI_INTERVIEW_JSON || "").trim();
322
- let payload = null;
323
- let source = "";
324
-
325
- if (interviewFile) {
326
- const filePath = path.resolve(process.cwd(), interviewFile);
327
- const fileContents = await fsp.readFile(filePath, "utf-8");
328
- payload = JSON.parse(fileContents);
329
- source = `--interview-file (${filePath})`;
330
- } else if (envPayload) {
331
- payload = JSON.parse(envPayload);
332
- source = "SENTINELAYER_CLI_INTERVIEW_JSON";
333
- }
334
-
335
- if (payload === null) {
336
- return null;
337
- }
338
-
339
- const normalized = normalizeInterviewInput(payload, {
340
- argProjectName,
341
- detectedRepo,
342
- detectedCodingAgent,
343
- });
344
- try {
345
- validateInterviewInput(normalized);
346
- } catch (error) {
347
- const message = error instanceof Error ? error.message : String(error);
348
- throw new Error(`Invalid interview payload from ${source}: ${message}`);
349
- }
350
- return normalized;
351
- }
352
-
353
- async function waitForEnter(message) {
354
- const rl = createInterface({ input, output });
355
- try {
356
- await rl.question(`${message}\n`);
357
- } finally {
358
- rl.close();
359
- }
360
- }
361
-
362
- function sleep(ms) {
363
- return new Promise((resolve) => setTimeout(resolve, ms));
364
- }
365
-
366
- async function requestJson(url, { method = "GET", headers = {}, body, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
367
- const controller = new AbortController();
368
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
369
- try {
370
- const response = await fetch(url, {
371
- method,
372
- headers: {
373
- "Content-Type": "application/json",
374
- ...headers,
375
- },
376
- body: body === undefined ? undefined : JSON.stringify(body),
377
- signal: controller.signal,
378
- });
379
-
380
- const text = await response.text();
381
- let payload = null;
382
- if (text.trim().length > 0) {
383
- try {
384
- payload = JSON.parse(text);
385
- } catch {
386
- payload = null;
387
- }
388
- }
389
-
390
- if (!response.ok) {
391
- const errorEnvelope = payload && typeof payload === "object" ? payload.error || null : null;
392
- const code = errorEnvelope?.code || `HTTP_${response.status}`;
393
- const message = errorEnvelope?.message || `Request failed (${response.status})`;
394
- const requestId = errorEnvelope?.request_id || null;
395
- throw new SentinelayerApiError(message, {
396
- code,
397
- status: response.status,
398
- requestId,
399
- });
400
- }
401
-
402
- if (payload === null) {
403
- return {};
404
- }
405
- return payload;
406
- } catch (error) {
407
- if (error instanceof SentinelayerApiError) {
408
- throw error;
409
- }
410
- if (error instanceof Error && error.name === "AbortError") {
411
- throw new SentinelayerApiError("Sentinelayer request timed out", {
412
- code: "NETWORK_TIMEOUT",
413
- status: 504,
414
- });
415
- }
416
- throw new SentinelayerApiError("Unable to reach Sentinelayer API", {
417
- code: "NETWORK_ERROR",
418
- status: 503,
419
- });
420
- } finally {
421
- clearTimeout(timeout);
422
- }
423
- }
424
-
425
- function detectIde() {
426
- return detectIdeFromEnv(process.env).id;
427
- }
428
-
429
- async function startCliSession({ apiUrl, challenge, cliVersion }) {
430
- return requestJson(`${apiUrl}/api/v1/auth/cli/sessions/start`, {
431
- method: "POST",
432
- body: {
433
- challenge,
434
- ide: detectIde(),
435
- cli_version: cliVersion,
436
- },
437
- });
438
- }
439
-
440
- async function pollCliSession({
441
- apiUrl,
442
- sessionId,
443
- challenge,
444
- pollIntervalSeconds,
445
- timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
446
- }) {
447
- const start = Date.now();
448
- while (Date.now() - start < timeoutMs) {
449
- const response = await requestJson(`${apiUrl}/api/v1/auth/cli/sessions/poll`, {
450
- method: "POST",
451
- body: {
452
- session_id: sessionId,
453
- challenge,
454
- },
455
- });
456
- if (response.status === "approved" && response.auth_token) {
457
- return response;
458
- }
459
- await sleep(Math.max(1, Number(pollIntervalSeconds) || 2) * 1000);
460
- }
461
- throw new SentinelayerApiError("CLI authentication timed out. Restart and try again.", {
462
- code: "CLI_AUTH_TIMEOUT",
463
- status: 408,
464
- });
465
- }
466
-
467
- async function generateArtifacts({ apiUrl, authToken, payload }) {
468
- return requestJson(`${apiUrl}/api/v1/builder/generate`, {
469
- method: "POST",
470
- headers: {
471
- Authorization: `Bearer ${authToken}`,
472
- },
473
- body: payload,
474
- timeoutMs: 180_000,
475
- });
476
- }
477
-
478
- async function issueBootstrapToken({ apiUrl, authToken }) {
479
- return requestJson(`${apiUrl}/api/v1/builder/bootstrap-token`, {
480
- method: "POST",
481
- headers: {
482
- Authorization: `Bearer ${authToken}`,
483
- },
484
- });
485
- }
486
-
487
- function detectRepoSlug(cwd) {
488
- const gitRemote = spawnSync("git", ["config", "--get", "remote.origin.url"], {
489
- cwd,
490
- encoding: "utf-8",
491
- });
492
- if (gitRemote.status !== 0) return null;
493
- const remote = String(gitRemote.stdout || "").trim();
494
- if (!remote) return null;
495
-
496
- const sshMatch = remote.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i);
497
- if (sshMatch) {
498
- return `${sshMatch[1]}/${sshMatch[2]}`;
499
- }
500
-
501
- const httpsMatch = remote.match(/^https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i);
502
- if (httpsMatch) {
503
- return `${httpsMatch[1]}/${httpsMatch[2]}`;
504
- }
505
-
506
- const sshUrlMatch = remote.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i);
507
- if (sshUrlMatch) {
508
- return `${sshUrlMatch[1]}/${sshUrlMatch[2]}`;
509
- }
510
-
511
- return null;
512
- }
513
-
514
- function getGhCommand() {
515
- return String(process.env.SENTINELAYER_GH_BIN || "").trim() || "gh";
516
- }
517
-
518
- function getGitCommand() {
519
- return String(process.env.SENTINELAYER_GIT_BIN || "").trim() || "git";
520
- }
521
-
522
- function isGitRepo(cwd) {
523
- const gitCommand = getGitCommand();
524
- const probe = spawnSync(gitCommand, ["rev-parse", "--is-inside-work-tree"], {
525
- cwd,
526
- encoding: "utf-8",
527
- });
528
- return probe.status === 0;
529
- }
530
-
531
- function buildGithubCloneUrl(repoSlug) {
532
- const base = String(DEFAULT_GITHUB_CLONE_BASE_URL || "https://github.com").trim().replace(/\/+$/g, "");
533
- return `${base}/${normalizeRepoSlug(repoSlug)}.git`;
534
- }
535
-
536
- function ensureGhCliAvailable(ghCommand) {
537
- const ghVersion = spawnSync(ghCommand, ["--version"], { encoding: "utf-8" });
538
- if (ghVersion.status !== 0) {
539
- throw new Error("GitHub CLI (gh) is not installed or not in PATH.");
540
- }
541
- }
542
-
543
- function ensureGhAuthSession(ghCommand) {
544
- ensureGhCliAvailable(ghCommand);
545
- const status = spawnSync(ghCommand, ["auth", "status", "-h", "github.com"], {
546
- encoding: "utf-8",
547
- });
548
- if (status.status === 0) {
549
- return;
550
- }
551
-
552
- console.log("GitHub authorization required. Opening browser for gh auth login...");
553
- const login = spawnSync(ghCommand, ["auth", "login", "-h", "github.com", "-s", "repo", "-w"], {
554
- encoding: "utf-8",
555
- stdio: "inherit",
556
- });
557
- if (login.status !== 0) {
558
- throw new Error("GitHub authorization failed. Complete gh auth login and retry.");
559
- }
560
- }
561
-
562
- function listReposViaGh(ghCommand) {
563
- const endpoint = "/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member";
564
- const apiResult = spawnSync(
565
- ghCommand,
566
- ["api", "--paginate", "--slurp", endpoint],
567
- { encoding: "utf-8" }
568
- );
569
- if (apiResult.status !== 0) {
570
- const fallback = spawnSync(ghCommand, ["api", endpoint], { encoding: "utf-8" });
571
- if (fallback.status !== 0) {
572
- throw new Error(
573
- String(
574
- fallback.stderr ||
575
- fallback.stdout ||
576
- apiResult.stderr ||
577
- apiResult.stdout ||
578
- "Unable to fetch repositories with gh api."
579
- ).trim()
580
- );
581
- }
582
- return parseGhRepoListPayload(String(fallback.stdout || "[]"));
583
- }
584
- return parseGhRepoListPayload(String(apiResult.stdout || "[]"));
585
- }
586
-
587
- function parseGhRepoListPayload(rawJson) {
588
- let payload = [];
589
- try {
590
- payload = JSON.parse(rawJson);
591
- } catch {
592
- throw new Error("GitHub repo list response was not valid JSON.");
593
- }
594
- if (!Array.isArray(payload)) {
595
- throw new Error("GitHub repo list response was not an array.");
596
- }
597
-
598
- const flattened = [];
599
- for (const entry of payload) {
600
- if (Array.isArray(entry)) {
601
- flattened.push(...entry);
602
- } else {
603
- flattened.push(entry);
604
- }
605
- }
606
-
607
- const seen = new Set();
608
- const repos = [];
609
- for (const item of flattened) {
610
- const slug = normalizeRepoSlug(item && typeof item.full_name === "string" ? item.full_name : "");
611
- if (!isValidRepoSlug(slug)) continue;
612
- const key = slug.toLowerCase();
613
- if (seen.has(key)) continue;
614
- seen.add(key);
615
- repos.push({
616
- slug,
617
- privateRepo: Boolean(item.private),
618
- defaultBranch: String(item.default_branch || "").trim() || "main",
619
- });
620
- }
621
- return repos;
622
- }
623
-
624
- async function selectRepoSlugFromGithub() {
625
- const ghCommand = getGhCommand();
626
- ensureGhAuthSession(ghCommand);
627
- const repos = listReposViaGh(ghCommand);
628
- if (repos.length === 0) {
629
- throw new Error("No accessible GitHub repos found for this account.");
630
- }
631
-
632
- const result = await prompts(
633
- [
634
- {
635
- type: "select",
636
- name: "repoSlug",
637
- message: "Choose a GitHub repo",
638
- choices: repos.map((repo) => ({
639
- title: `${repo.slug}${repo.privateRepo ? " (private)" : ""} [${repo.defaultBranch}]`,
640
- value: repo.slug,
641
- })),
642
- initial: 0,
643
- },
644
- ],
645
- {
646
- onCancel: () => {
647
- throw new Error("GitHub repo selection cancelled.");
648
- },
649
- }
650
- );
651
-
652
- const selected = normalizeRepoSlug(result.repoSlug);
653
- if (!isValidRepoSlug(selected)) {
654
- throw new Error("GitHub repo selection returned an invalid repository slug.");
655
- }
656
- return selected;
657
- }
658
-
659
- async function cloneGithubRepo({ repoSlug, cwd }) {
660
- const normalizedRepo = normalizeRepoSlug(repoSlug);
661
- const repoName = getRepoNameFromSlug(normalizedRepo) || "repo";
662
- const targetDir = path.resolve(cwd, repoName);
663
- const gitCommand = getGitCommand();
664
- const cloneUrl = buildGithubCloneUrl(normalizedRepo);
665
-
666
- if (path.resolve(cwd) === path.resolve(targetDir)) {
667
- throw new Error("Target clone directory cannot match the current working directory.");
668
- }
669
- if (fs.existsSync(targetDir) && !isGitRepo(targetDir)) {
670
- const entries = await fsp.readdir(targetDir);
671
- if (entries.length > 0) {
672
- throw new Error(
673
- `Cannot clone ${normalizedRepo}: target directory '${repoName}' already exists and is not an empty git repo.`
674
- );
675
- }
676
- }
677
- if (isGitRepo(targetDir)) {
678
- const localSlug = normalizeRepoSlug(detectRepoSlug(targetDir) || "");
679
- if (!localSlug) {
680
- throw new Error(
681
- `Directory '${repoName}' already contains a git repo without a detectable GitHub origin. Refusing to overwrite it.`
682
- );
683
- }
684
- if (localSlug && localSlug.toLowerCase() !== normalizedRepo.toLowerCase()) {
685
- throw new Error(
686
- `Directory '${repoName}' already contains a different repo (${localSlug}). Choose another project name or folder.`
687
- );
688
- }
689
- return {
690
- projectDir: targetDir,
691
- cloneUrl,
692
- cloned: false,
693
- };
694
- }
695
-
696
- const cloneResult = spawnSync(gitCommand, ["clone", "--depth", "1", cloneUrl, targetDir], {
697
- cwd,
698
- encoding: "utf-8",
699
- });
700
- if (cloneResult.status !== 0) {
701
- throw new Error(String(cloneResult.stderr || cloneResult.stdout || "git clone failed").trim());
702
- }
703
- return {
704
- projectDir: targetDir,
705
- cloneUrl,
706
- cloned: true,
707
- };
708
- }
709
-
710
- async function ensureGitRepositorySetup({ projectDir, repoSlug }) {
711
- const gitCommand = getGitCommand();
712
- if (!isGitRepo(projectDir)) {
713
- const initResult = spawnSync(gitCommand, ["init"], {
714
- cwd: projectDir,
715
- encoding: "utf-8",
716
- });
717
- if (initResult.status !== 0) {
718
- throw new Error(String(initResult.stderr || initResult.stdout || "git init failed").trim());
719
- }
720
- }
721
-
722
- const normalizedRepo = normalizeRepoSlug(repoSlug);
723
- if (!isValidRepoSlug(normalizedRepo)) return;
724
-
725
- const remoteGet = spawnSync(gitCommand, ["config", "--get", "remote.origin.url"], {
726
- cwd: projectDir,
727
- encoding: "utf-8",
728
- });
729
- const remote = String(remoteGet.stdout || "").trim();
730
- if (remote) return;
731
-
732
- const remoteUrl = buildGithubCloneUrl(normalizedRepo);
733
- const remoteAdd = spawnSync(gitCommand, ["remote", "add", "origin", remoteUrl], {
734
- cwd: projectDir,
735
- encoding: "utf-8",
736
- });
737
- if (remoteAdd.status !== 0) {
738
- throw new Error(String(remoteAdd.stderr || remoteAdd.stdout || "git remote add failed").trim());
739
- }
740
- }
741
-
742
- async function buildRepoIngestSummary(projectDir) {
743
- try {
744
- const ingest = await collectCodebaseIngest({ rootPath: projectDir });
745
- return formatIngestSummary(ingest);
746
- } catch {
747
- return "";
748
- }
749
- }
750
-
751
- function formatTimestampForFile() {
752
- const now = new Date();
753
- const pad = (value) => String(value).padStart(2, "0");
754
- return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(
755
- now.getUTCHours()
756
- )}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`;
757
- }
758
-
759
- function getCommandOptionValue(args, optionName) {
760
- const index = args.findIndex((arg) => String(arg || "").trim() === optionName);
761
- if (index < 0) return "";
762
- const next = String(args[index + 1] || "").trim();
763
- if (!next || next.startsWith("-")) {
764
- throw new Error(`Missing value for ${optionName}`);
765
- }
766
- return next;
767
- }
768
-
769
- function hasCommandOption(args, optionName) {
770
- return args.some((arg) => String(arg || "").trim() === optionName);
771
- }
772
-
773
- async function collectScanFiles(rootPath) {
774
- const files = [];
775
- const stack = [rootPath];
776
- const ignoredDirs = new Set([".git", "node_modules", ".venv", ".next", "dist", "build", ".sentinelayer"]);
777
- const maxFileSizeBytes = 512 * 1024;
778
-
779
- while (stack.length > 0) {
780
- const current = stack.pop();
781
- if (!current) continue;
782
- let entries = [];
783
- try {
784
- entries = await fsp.readdir(current, { withFileTypes: true });
785
- } catch {
786
- continue;
787
- }
788
- for (const entry of entries) {
789
- const fullPath = path.join(current, entry.name);
790
- if (entry.isDirectory()) {
791
- if (ignoredDirs.has(entry.name)) continue;
792
- stack.push(fullPath);
793
- continue;
794
- }
795
- if (!entry.isFile()) continue;
796
- try {
797
- const stat = await fsp.stat(fullPath);
798
- if (stat.size > maxFileSizeBytes) continue;
799
- } catch {
800
- continue;
801
- }
802
- files.push(fullPath);
803
- }
804
- }
805
- return files;
806
- }
807
-
808
- async function runCredentialScan(targetPath) {
809
- const rules = [
810
- {
811
- severity: "P1",
812
- message: "Possible AWS access key detected.",
813
- regex: /AKIA[0-9A-Z]{16}/,
814
- },
815
- {
816
- severity: "P1",
817
- message: "Possible private key material detected.",
818
- regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
819
- },
820
- {
821
- severity: "P1",
822
- message: "Possible provider API key detected.",
823
- regex: /\b(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,})\b/,
824
- },
825
- {
826
- severity: "P2",
827
- message: "Possible hardcoded credential literal.",
828
- regex: /(api[_-]?key|secret|token)\s*[:=]\s*['"][^'"]{20,}['"]/i,
829
- },
830
- {
831
- severity: "P2",
832
- message: "Work-item marker found.",
833
- regex: /\b(?:\x54\x4f\x44\x4f|\x46\x49\x58\x4d\x45|\x48\x41\x43\x4b)\b/,
834
- },
835
- ];
836
-
837
- const files = await collectScanFiles(targetPath);
838
- const findings = [];
839
- const maxFindings = 200;
840
-
841
- for (const filePath of files) {
842
- let text = "";
843
- try {
844
- text = await fsp.readFile(filePath, "utf-8");
845
- } catch {
846
- continue;
847
- }
848
- const lines = text.split(/\r?\n/);
849
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
850
- const line = lines[lineIndex];
851
- if (!line) continue;
852
- if (line.includes("<your-token>") || line.includes("example")) continue;
853
- for (const rule of rules) {
854
- if (!rule.regex.test(line)) continue;
855
- findings.push({
856
- severity: rule.severity,
857
- file: path.relative(targetPath, filePath).replace(/\\/g, "/"),
858
- line: lineIndex + 1,
859
- message: rule.message,
860
- excerpt: line.trim().slice(0, 180),
861
- });
862
- if (findings.length >= maxFindings) break;
863
- }
864
- if (findings.length >= maxFindings) break;
865
- }
866
- if (findings.length >= maxFindings) break;
867
- }
868
-
869
- const p1 = findings.filter((item) => item.severity === "P1").length;
870
- const p2 = findings.filter((item) => item.severity === "P2").length;
871
-
872
- return {
873
- scannedFiles: files.length,
874
- findings,
875
- p1,
876
- p2,
877
- };
878
- }
879
-
880
- async function writeLocalCommandReport(targetPath, prefix, body, { outputDir = "" } = {}) {
881
- const outputRoot = await resolveOutputRoot({
882
- cwd: targetPath,
883
- outputDirOverride: outputDir,
884
- });
885
- const reportDir = path.join(outputRoot, "reports");
886
- await ensureDirectory(reportDir);
887
- const reportPath = path.join(reportDir, `${prefix}-${formatTimestampForFile()}.md`);
888
- await writeTextFile(reportPath, `${body}\n`);
889
- return reportPath;
890
- }
891
-
892
- function formatFindingsMarkdown(findings) {
893
- if (!findings.length) return "- none";
894
- return findings
895
- .map((item, index) => `${index + 1}. [${item.severity}] ${item.file}:${item.line} - ${item.message}`)
896
- .join("\n");
897
- }
898
-
899
- async function runLocalOmarGateCommand(args) {
900
- const mode = String(args[0] || "").trim().toLowerCase();
901
- if (mode && mode !== "deep") {
902
- throw new Error(`Unsupported /omargate mode '${mode}'. Use: /omargate deep`);
903
- }
904
- const asJson = hasCommandOption(args, "--json");
905
- const pathArg = getCommandOptionValue(args, "--path") || ".";
906
- const outputDirArg = getCommandOptionValue(args, "--output-dir") || "";
907
- const targetPath = path.resolve(process.cwd(), pathArg);
908
- if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
909
- throw new Error(`Invalid --path target: ${targetPath}`);
910
- }
911
-
912
- if (!asJson) {
913
- printSection("Local Omar Gate Deep");
914
- printInfo(`Target: ${targetPath}`);
915
- }
916
-
917
- const scan = await runCredentialScan(targetPath);
918
- const report = `# Local Omar Gate Deep Scan
919
-
920
- Generated: ${nowIso()}
921
- Target: ${targetPath}
922
-
923
- Summary:
924
- - Files scanned: ${scan.scannedFiles}
925
- - P1 findings: ${scan.p1}
926
- - P2 findings: ${scan.p2}
927
-
928
- Findings:
929
- ${formatFindingsMarkdown(scan.findings)}
930
- `;
931
-
932
- const reportPath = await writeLocalCommandReport(targetPath, "omargate-deep", report, {
933
- outputDir: outputDirArg,
934
- });
935
- if (asJson) {
936
- console.log(
937
- JSON.stringify(
938
- {
939
- command: "/omargate deep",
940
- targetPath,
941
- reportPath,
942
- scannedFiles: scan.scannedFiles,
943
- p1: scan.p1,
944
- p2: scan.p2,
945
- blocking: scan.p1 > 0,
946
- },
947
- null,
948
- 2
949
- )
950
- );
951
- } else {
952
- console.log(pc.cyan(`Report: ${reportPath}`));
953
- console.log(`P1 findings: ${scan.p1}`);
954
- console.log(`P2 findings: ${scan.p2}`);
955
- }
956
-
957
- if (scan.p1 > 0) {
958
- if (!asJson) {
959
- console.log(pc.red("Blocking findings detected (P1 > 0)."));
960
- }
961
- return 2;
962
- }
963
- return 0;
964
- }
965
-
966
- async function runLocalAuditCommand(args) {
967
- const asJson = hasCommandOption(args, "--json");
968
- const pathArg = getCommandOptionValue(args, "--path") || ".";
969
- const outputDirArg = getCommandOptionValue(args, "--output-dir") || "";
970
- const targetPath = path.resolve(process.cwd(), pathArg);
971
- if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
972
- throw new Error(`Invalid --path target: ${targetPath}`);
973
- }
974
-
975
- if (!asJson) {
976
- printSection("Local Audit");
977
- printInfo(`Target: ${targetPath}`);
978
- }
979
-
980
- const requiredChecks = [
981
- {
982
- key: ".github/workflows/omar-gate.yml",
983
- severity: "P1",
984
- ok: fs.existsSync(path.join(targetPath, ".github", "workflows", "omar-gate.yml")),
985
- message: "Omar workflow is present.",
986
- },
987
- {
988
- key: "docs/spec.md",
989
- severity: "P2",
990
- ok: fs.existsSync(path.join(targetPath, "docs", "spec.md")),
991
- message: "Spec doc is present.",
992
- },
993
- {
994
- key: "tasks/todo.md",
995
- severity: "P2",
996
- ok: fs.existsSync(path.join(targetPath, "tasks", "todo.md")),
997
- message: "Todo plan is present.",
998
- },
999
- ];
1000
-
1001
- const scan = await runCredentialScan(targetPath);
1002
- const failedP1Checks = requiredChecks.filter((item) => !item.ok && item.severity === "P1").length;
1003
- const failedP2Checks = requiredChecks.filter((item) => !item.ok && item.severity === "P2").length;
1004
- const totalP1 = scan.p1 + failedP1Checks;
1005
- const totalP2 = scan.p2 + failedP2Checks;
1006
- const overallStatus = totalP1 > 0 ? "FAIL" : "PASS";
1007
-
1008
- const checkText = requiredChecks
1009
- .map(
1010
- (item) =>
1011
- `- [${item.ok ? "x" : " "}] (${item.severity}) ${item.key} :: ${item.message}${item.ok ? "" : " [missing]"}`
1012
- )
1013
- .join("\n");
1014
- const report = `# Local Sentinelayer Audit
1015
-
1016
- Generated: ${nowIso()}
1017
- Target: ${targetPath}
1018
- Overall status: ${overallStatus}
1019
-
1020
- Readiness checks:
1021
- ${checkText}
1022
-
1023
- Scan summary:
1024
- - Files scanned: ${scan.scannedFiles}
1025
- - P1 findings: ${scan.p1}
1026
- - P2 findings: ${scan.p2}
1027
-
1028
- Findings:
1029
- ${formatFindingsMarkdown(scan.findings)}
1030
- `;
1031
-
1032
- const reportPath = await writeLocalCommandReport(targetPath, "audit", report, {
1033
- outputDir: outputDirArg,
1034
- });
1035
- if (asJson) {
1036
- console.log(
1037
- JSON.stringify(
1038
- {
1039
- command: "/audit",
1040
- targetPath,
1041
- reportPath,
1042
- overallStatus,
1043
- scannedFiles: scan.scannedFiles,
1044
- p1: scan.p1,
1045
- p2: scan.p2,
1046
- p1Total: totalP1,
1047
- p2Total: totalP2,
1048
- blocking: totalP1 > 0,
1049
- },
1050
- null,
1051
- 2
1052
- )
1053
- );
1054
- } else {
1055
- console.log(pc.cyan(`Report: ${reportPath}`));
1056
- console.log(`Overall status: ${overallStatus}`);
1057
- console.log(`P1 total: ${totalP1}`);
1058
- console.log(`P2 total: ${totalP2}`);
1059
- }
1060
-
1061
- if (totalP1 > 0) {
1062
- if (!asJson) {
1063
- console.log(pc.red("Audit failed due to blocking findings (P1 > 0)."));
1064
- }
1065
- return 2;
1066
- }
1067
- return 0;
1068
- }
1069
-
1070
- async function runLocalPersonaCommand(args) {
1071
- const subcommand = String(args[0] || "").trim().toLowerCase();
1072
- const optionArgs = subcommand === "orchestrator" ? args.slice(1) : args;
1073
- if (subcommand && subcommand !== "orchestrator") {
1074
- throw new Error(`Unsupported /persona subcommand '${subcommand}'. Use: /persona orchestrator --mode <mode>`);
1075
- }
1076
- const asJson = hasCommandOption(optionArgs, "--json");
1077
-
1078
- const mode = String(getCommandOptionValue(optionArgs, "--mode") || "builder").trim().toLowerCase();
1079
- const validModes = new Set(["builder", "reviewer", "hardener"]);
1080
- if (!validModes.has(mode)) {
1081
- throw new Error("Invalid --mode for /persona. Use builder, reviewer, or hardener.");
1082
- }
1083
-
1084
- const pathArg = getCommandOptionValue(optionArgs, "--path") || ".";
1085
- const outputDirArg = getCommandOptionValue(optionArgs, "--output-dir") || "";
1086
- const targetPath = path.resolve(process.cwd(), pathArg);
1087
- if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
1088
- throw new Error(`Invalid --path target: ${targetPath}`);
1089
- }
1090
-
1091
- if (!asJson) {
1092
- printSection("Persona Orchestrator");
1093
- printInfo(`Mode: ${mode}`);
1094
- printInfo(`Target: ${targetPath}`);
1095
- }
1096
-
1097
- const modeInstructions = {
1098
- builder: [
1099
- "Prioritize implementation throughput and deterministic delivery.",
1100
- "Keep PR scope tight and finish one batch before opening the next.",
1101
- "Use Omar loop after each PR and fix all P0/P1 before merge.",
1102
- ],
1103
- reviewer: [
1104
- "Prioritize risk discovery, regressions, and missing tests.",
1105
- "Focus findings-first output ordered by severity.",
1106
- "Escalate architecture/security concerns before code changes.",
1107
- ],
1108
- hardener: [
1109
- "Prioritize security posture, policy controls, and failure modes.",
1110
- "Add guardrails for auth, secrets handling, and CI enforceability.",
1111
- "Treat P2 debt as merge-blocking unless explicitly waived.",
1112
- ],
1113
- };
1114
-
1115
- const ingest = await buildRepoIngestSummary(targetPath);
1116
- const report = `# Persona Orchestrator Plan
1117
-
1118
- Generated: ${nowIso()}
1119
- Target: ${targetPath}
1120
- Mode: ${mode}
1121
-
1122
- Instructions:
1123
- ${modeInstructions[mode].map((line, index) => `${index + 1}. ${line}`).join("\n")}
1124
-
1125
- Repo summary:
1126
- ${ingest || "No repository summary available."}
1127
- `;
1128
-
1129
- const reportPath = await writeLocalCommandReport(targetPath, `persona-orchestrator-${mode}`, report, {
1130
- outputDir: outputDirArg,
1131
- });
1132
- if (asJson) {
1133
- console.log(
1134
- JSON.stringify(
1135
- {
1136
- command: "/persona orchestrator",
1137
- mode,
1138
- targetPath,
1139
- reportPath,
1140
- },
1141
- null,
1142
- 2
1143
- )
1144
- );
1145
- } else {
1146
- console.log(pc.cyan(`Report: ${reportPath}`));
1147
- }
1148
- return 0;
1149
- }
1150
-
1151
- function parseTodoPlanTasks(content) {
1152
- const tasks = [];
1153
- const lines = String(content || "").split(/\r?\n/);
1154
- for (const line of lines) {
1155
- const unchecked = line.match(/^\s*-\s*\[\s\]\s+(.+)\s*$/);
1156
- if (unchecked) {
1157
- tasks.push(unchecked[1].trim());
1158
- continue;
1159
- }
1160
- const ordered = line.match(/^\s*\d+\.\s+(.+)\s*$/);
1161
- if (ordered) {
1162
- tasks.push(ordered[1].trim());
1163
- }
1164
- }
1165
- return tasks.filter(Boolean);
1166
- }
1167
-
1168
- async function runLocalApplyCommand(args) {
1169
- const asJson = hasCommandOption(args, "--json");
1170
- const pathArg = getCommandOptionValue(args, "--path") || ".";
1171
- const outputDirArg = getCommandOptionValue(args, "--output-dir") || "";
1172
- const targetPath = path.resolve(process.cwd(), pathArg);
1173
- if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
1174
- throw new Error(`Invalid --path target: ${targetPath}`);
1175
- }
1176
-
1177
- const planArg = getCommandOptionValue(args, "--plan") || "tasks/todo.md";
1178
- const planPath = path.resolve(targetPath, planArg);
1179
- if (!fs.existsSync(planPath)) {
1180
- throw new Error(`Plan file not found: ${planPath}`);
1181
- }
1182
-
1183
- if (!asJson) {
1184
- printSection("Apply Plan");
1185
- printInfo(`Target: ${targetPath}`);
1186
- printInfo(`Plan: ${planPath}`);
1187
- }
1188
-
1189
- const planText = await fsp.readFile(planPath, "utf-8");
1190
- const tasks = parseTodoPlanTasks(planText);
1191
- if (!tasks.length) {
1192
- throw new Error("No executable checklist items were found in the plan file.");
1193
- }
1194
-
1195
- const report = `# Apply Plan Preview
1196
-
1197
- Generated: ${nowIso()}
1198
- Target: ${targetPath}
1199
- Plan: ${planPath}
1200
-
1201
- Execution order:
1202
- ${tasks.map((task, index) => `${index + 1}. ${task}`).join("\n")}
1203
-
1204
- Next action:
1205
- - Execute each item PR-by-PR and run Omar loop before every merge.
1206
- `;
1207
-
1208
- const reportPath = await writeLocalCommandReport(targetPath, "apply-plan", report, {
1209
- outputDir: outputDirArg,
1210
- });
1211
- if (asJson) {
1212
- console.log(
1213
- JSON.stringify(
1214
- {
1215
- command: "/apply",
1216
- targetPath,
1217
- planPath,
1218
- reportPath,
1219
- taskCount: tasks.length,
1220
- },
1221
- null,
1222
- 2
1223
- )
1224
- );
1225
- } else {
1226
- console.log(pc.cyan(`Report: ${reportPath}`));
1227
- console.log(`Parsed tasks: ${tasks.length}`);
1228
- }
1229
- return 0;
1230
- }
1231
-
1232
- async function tryRunLocalCommandMode(argv) {
1233
- const command = String(argv[0] || "").trim().toLowerCase();
1234
- if (command !== "/omargate" && command !== "/audit" && command !== "/persona" && command !== "/apply") {
1235
- return null;
1236
- }
1237
- const args = argv.slice(1);
1238
- if (command === "/omargate") {
1239
- return runLocalOmarGateCommand(args);
1240
- }
1241
- if (command === "/audit") {
1242
- return runLocalAuditCommand(args);
1243
- }
1244
- if (command === "/persona") {
1245
- return runLocalPersonaCommand(args);
1246
- }
1247
- return runLocalApplyCommand(args);
1248
- }
1249
-
1250
- async function resolveProjectDirectory({ cwd, interview, detectedRepo }) {
1251
- const normalizedTargetRepo = normalizeRepoSlug(interview.repoSlug);
1252
- const normalizedDetected = normalizeRepoSlug(detectedRepo || "");
1253
-
1254
- if (interview.connectRepo && interview.buildFromExistingRepo && isValidRepoSlug(normalizedTargetRepo)) {
1255
- if (normalizedDetected && normalizedDetected.toLowerCase() === normalizedTargetRepo.toLowerCase()) {
1256
- return {
1257
- projectDir: cwd,
1258
- clonedRepo: false,
1259
- reusedCurrentRepo: true,
1260
- };
1261
- }
1262
- const cloned = await cloneGithubRepo({
1263
- repoSlug: normalizedTargetRepo,
1264
- cwd,
1265
- });
1266
- return {
1267
- projectDir: cloned.projectDir,
1268
- clonedRepo: cloned.cloned,
1269
- reusedCurrentRepo: false,
1270
- cloneUrl: cloned.cloneUrl,
1271
- };
1272
- }
1273
-
1274
- return {
1275
- projectDir: path.resolve(cwd, interview.projectName),
1276
- clonedRepo: false,
1277
- reusedCurrentRepo: false,
1278
- };
1279
- }
1280
-
1281
- async function ensureDirectory(targetPath) {
1282
- await fsp.mkdir(targetPath, { recursive: true });
1283
- }
1284
-
1285
- async function writeTextFile(filePath, content) {
1286
- await ensureDirectory(path.dirname(filePath));
1287
- await fsp.writeFile(filePath, content, "utf-8");
1288
- }
1289
-
1290
- async function upsertEnvVariable(filePath, key, value) {
1291
- let existing = "";
1292
- if (fs.existsSync(filePath)) {
1293
- existing = await fsp.readFile(filePath, "utf-8");
1294
- }
1295
- const line = `${key}=${value}`;
1296
- const regex = new RegExp(`^${key}=.*$`, "m");
1297
- let next;
1298
- if (regex.test(existing)) {
1299
- next = existing.replace(regex, line);
1300
- } else if (existing.trim().length === 0) {
1301
- next = `${line}\n`;
1302
- } else if (existing.endsWith("\n")) {
1303
- next = `${existing}${line}\n`;
1304
- } else {
1305
- next = `${existing}\n${line}\n`;
1306
- }
1307
- await writeTextFile(filePath, next);
1308
- }
1309
-
1310
- async function ensureEnvFileIgnored(projectDir) {
1311
- const gitignorePath = path.join(projectDir, ".gitignore");
1312
- let existing = "";
1313
- if (fs.existsSync(gitignorePath)) {
1314
- existing = await fsp.readFile(gitignorePath, "utf-8");
1315
- }
1316
-
1317
- const lines = existing.split(/\r?\n/);
1318
- const hasEntry = lines.some((line) => {
1319
- const normalized = String(line || "").trim();
1320
- return normalized === ".env" || normalized === "/.env";
1321
- });
1322
- if (hasEntry) {
1323
- return;
1324
- }
1325
-
1326
- const envEntry = ".env";
1327
- let next = "";
1328
- if (existing.trim().length === 0) {
1329
- next = `${envEntry}\n`;
1330
- } else if (existing.endsWith("\n")) {
1331
- next = `${existing}${envEntry}\n`;
1332
- } else {
1333
- next = `${existing}\n${envEntry}\n`;
1334
- }
1335
- await writeTextFile(gitignorePath, next);
1336
- }
1337
-
1338
- async function ensureSentinelStartScript(projectDir, projectName) {
1339
- const packagePath = path.join(projectDir, "package.json");
1340
- const fallback = {
1341
- name: sanitizeProjectName(projectName) || "sentinelayer-project",
1342
- version: "0.1.0",
1343
- private: true,
1344
- scripts: {},
1345
- };
1346
- let payload = fallback;
1347
- if (fs.existsSync(packagePath)) {
1348
- try {
1349
- const parsed = JSON.parse(await fsp.readFile(packagePath, "utf-8"));
1350
- if (parsed && typeof parsed === "object") {
1351
- payload = parsed;
1352
- }
1353
- } catch {
1354
- payload = fallback;
1355
- }
1356
- }
1357
- payload.scripts = payload.scripts && typeof payload.scripts === "object" ? payload.scripts : {};
1358
- payload.scripts["sentinel:start"] =
1359
- payload.scripts["sentinel:start"] ||
1360
- "echo \"Sentinelayer artifacts are ready. Open AGENT_HANDOFF_PROMPT.md and start your coding agent.\"";
1361
- const scriptDefaults = {
1362
- "sentinel:omargate": "npx sentinelayer-cli@latest /omargate deep --path .",
1363
- "sentinel:omargate:json": "npx sentinelayer-cli@latest /omargate deep --path . --json",
1364
- "sentinel:audit": "npx sentinelayer-cli@latest /audit --path .",
1365
- "sentinel:audit:json": "npx sentinelayer-cli@latest /audit --path . --json",
1366
- "sentinel:persona:builder":
1367
- "npx sentinelayer-cli@latest /persona orchestrator --mode builder --path .",
1368
- "sentinel:persona:reviewer":
1369
- "npx sentinelayer-cli@latest /persona orchestrator --mode reviewer --path .",
1370
- "sentinel:persona:hardener":
1371
- "npx sentinelayer-cli@latest /persona orchestrator --mode hardener --path .",
1372
- "sentinel:apply": "npx sentinelayer-cli@latest /apply --plan tasks/todo.md --path .",
1373
- };
1374
- for (const [name, command] of Object.entries(scriptDefaults)) {
1375
- if (!payload.scripts[name]) {
1376
- payload.scripts[name] = command;
1377
- }
1378
- }
1379
- await writeTextFile(packagePath, `${JSON.stringify(payload, null, 2)}\n`);
1380
- }
1381
-
1382
- function buildCodingAgentConfigTemplate({ agentProfile, projectName }) {
1383
- const projectLabel = String(projectName || "sentinelayer-project").trim() || "sentinelayer-project";
1384
- const commonChecklist = [
1385
- "Read docs/spec.md, docs/build-guide.md, tasks/todo.md, and AGENT_HANDOFF_PROMPT.md in order.",
1386
- "Work one PR scope at a time and keep changes deterministic.",
1387
- "Run local checks before push: /omargate deep and /audit.",
1388
- ];
1389
-
1390
- if (agentProfile.id === "aider") {
1391
- return `model: gpt-5.3-codex
1392
- read:
1393
- - docs/spec.md
1394
- - docs/build-guide.md
1395
- - tasks/todo.md
1396
- - AGENT_HANDOFF_PROMPT.md
1397
- notes:
1398
- - ${commonChecklist.join("\n - ")}
1399
- `;
1400
- }
1401
-
1402
- if (agentProfile.id === "continue" || agentProfile.id === "cody") {
1403
- return `${JSON.stringify(
1404
- {
1405
- profile: "sentinelayer",
1406
- project: projectLabel,
1407
- promptTarget: agentProfile.promptTarget,
1408
- instructions: commonChecklist,
1409
- },
1410
- null,
1411
- 2
1412
- )}\n`;
1413
- }
1414
-
1415
- const markdownBody = [
1416
- `# Sentinelayer ${agentProfile.name} Profile`,
1417
- "",
1418
- `Project: ${projectLabel}`,
1419
- `Prompt target: ${agentProfile.promptTarget}`,
1420
- "",
1421
- "Rules:",
1422
- ...commonChecklist.map((item) => `- ${item}`),
1423
- "",
1424
- ].join("\n");
1425
-
1426
- return `${markdownBody}\n`;
1427
- }
1428
-
1429
- async function ensureCodingAgentConfigFile({ projectDir, projectName, codingAgent }) {
1430
- const agentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
1431
- if (!agentProfile.configFile) {
1432
- return {
1433
- created: false,
1434
- path: "",
1435
- agent: agentProfile,
1436
- };
1437
- }
1438
-
1439
- const configPath = path.join(projectDir, agentProfile.configFile);
1440
- if (fs.existsSync(configPath)) {
1441
- return {
1442
- created: false,
1443
- path: configPath,
1444
- agent: agentProfile,
1445
- };
1446
- }
1447
-
1448
- const configContent = buildCodingAgentConfigTemplate({
1449
- agentProfile,
1450
- projectName,
1451
- });
1452
- await writeTextFile(configPath, configContent);
1453
- return {
1454
- created: true,
1455
- path: configPath,
1456
- agent: agentProfile,
1457
- };
1458
- }
1459
-
1460
- function buildTodoContent({
1461
- projectName,
1462
- aiProvider,
1463
- codingAgent,
1464
- authMode,
1465
- repoSlug,
1466
- buildFromExistingRepo,
1467
- generationMode,
1468
- audienceLevel,
1469
- projectType,
1470
- }) {
1471
- const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
1472
- return `# Sentinelayer Autonomous Build Plan
1473
-
1474
- Generated: ${nowIso()}
1475
- Project: ${projectName}
1476
-
1477
- ## Inputs
1478
- - AI provider: \`${aiProvider}\`
1479
- - Coding agent: \`${codingAgentProfile.name} (${codingAgentProfile.id})\`
1480
- - Auth mode: \`${authMode}\`
1481
- - Generation mode: \`${generationMode}\`
1482
- - Audience level: \`${audienceLevel}\`
1483
- - Project type: \`${projectType}\`
1484
- - Repo: \`${repoSlug || "not connected"}\`
1485
- - Workspace mode: \`${buildFromExistingRepo ? "existing repo clone" : "new scaffold"}\`
1486
-
1487
- ## Execution Checklist
1488
- - [ ] PR 1: repository bootstrap, CI checks, and deterministic scaffolding baseline
1489
- - [ ] PR 2: domain model + migrations + persistence abstraction
1490
- - [ ] PR 3: API contracts + auth/session lifecycle hardening
1491
- - [ ] PR 4: existing-codebase ingest path and repo context extraction
1492
- - [ ] PR 5: build planner generation quality and prompt artifact validation
1493
- - [ ] PR 6: workflow orchestration integration with Omar Gate policy defaults
1494
- - [ ] PR 7: local scan command runner (\`sentinel /omargate deep\`) MVP
1495
- - [ ] PR 8: local audit command runner (\`sentinel /audit\`) MVP
1496
- - [ ] PR 9: persona orchestrator command router + policy templates
1497
- - [ ] PR 10: scale/performance tuning and caching strategy
1498
- - [ ] PR 11: observability, retries, timeout policies, and structured logs
1499
- - [ ] PR 12: docs, release, rollout safety checks, and production readiness
1500
-
1501
- ## Omar Loop Contract (Per PR)
1502
- - [ ] Run Omar Gate for the PR.
1503
- - [ ] Fix all P0 and P1 findings.
1504
- - [ ] Fix P2 findings before merge when feasible.
1505
- - [ ] Re-run gate and confirm clean status.
1506
- - [ ] Merge only after quality gates are green.
1507
-
1508
- ## Command Roadmap (Local Terminal)
1509
- - [ ] \`sentinel /omargate deep --path <repo>\`: local deep scan pipeline
1510
- - [ ] \`sentinel /audit --path <repo>\`: security + quality audit summary
1511
- - [ ] \`sentinel /persona orchestrator --mode <builder|reviewer|hardener>\`: agent persona routing
1512
- - [ ] \`sentinel /apply --plan tasks/todo.md\`: execute roadmap batches autonomously
1513
-
1514
- ## Required Read Order
1515
- 1. \`docs/spec.md\`
1516
- 2. \`docs/build-guide.md\`
1517
- 3. \`prompts/execution-prompt.md\`
1518
- 4. \`.github/workflows/omar-gate.yml\`
1519
- 5. \`AGENT_HANDOFF_PROMPT.md\`
1520
- `;
1521
- }
1522
-
1523
- function buildAgentPromptGuidance(promptTarget) {
1524
- const normalized = String(promptTarget || "generic").trim().toLowerCase();
1525
- if (normalized === "claude") {
1526
- return `- Use explicit plan -> implement -> verify loops.
1527
- - Keep deterministic checks first, then optional AI steps.
1528
- - Capture concrete evidence per PR before handoff.`;
1529
- }
1530
- if (normalized === "cursor") {
1531
- return `- Keep edits small and keep scope to one PR id.
1532
- - Run local verification before each push.
1533
- - Keep repository conventions and test style unchanged.`;
1534
- }
1535
- if (normalized === "copilot") {
1536
- return `- Keep error handling explicit on all new paths.
1537
- - Avoid implicit behavior changes in existing modules.
1538
- - Add targeted tests for each new branch introduced.`;
1539
- }
1540
- if (normalized === "codex") {
1541
- return `- Execute autonomously, one bounded PR at a time.
1542
- - Use deterministic ingest/spec context as primary source.
1543
- - Fail closed when scope or safety requirements are ambiguous.`;
1544
- }
1545
- return `- Follow the provided spec and todo list exactly.
1546
- - Implement incrementally with deterministic checkpoints.
1547
- - Document assumptions and unresolved risks clearly.`;
1548
- }
1549
-
1550
- function buildHandoffPrompt({
1551
- projectName,
1552
- repoSlug,
1553
- secretName,
1554
- buildFromExistingRepo,
1555
- authMode,
1556
- codingAgent,
1557
- }) {
1558
- const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
1559
- const codingAgentConfigPath = codingAgentProfile.configFile || "none";
1560
- const codingAgentGuidance = buildAgentPromptGuidance(codingAgentProfile.promptTarget);
1561
- const tokenContract =
1562
- authMode === "sentinelayer"
1563
- ? `- Required secret name: ${secretName}
1564
- - Workflow input binding: sentinelayer_token: \${{ secrets.${secretName} }}
1565
- - Optional: OPENAI_API_KEY for runtime policy/BYOK scenarios.`
1566
- : `- Sentinelayer token: not configured (BYOK mode).
1567
- - Keep provider credentials in your own environment (OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_API_KEY).
1568
- - If you later adopt Omar Gate GitHub Action, set secrets.${secretName} and wire sentinelayer_token accordingly.`;
1569
- const workflowTuning =
1570
- authMode === "sentinelayer"
1571
- ? `- scan_mode: deep (default) or quick
1572
- - severity_gate: P1 (default) or P2`
1573
- : `- BYOK workflow is guidance-only and does not call the Sentinelayer action.
1574
- - To enable Omar Gate later, set ${secretName} and configure scan_mode/severity_gate in workflow inputs.`;
1575
-
1576
- return `# Sentinelayer Agent Handoff Prompt
1577
-
1578
- You are executing "${projectName}" autonomously.
1579
-
1580
- Read files in this exact order:
1581
- 1. docs/spec.md
1582
- 2. docs/build-guide.md
1583
- 3. prompts/execution-prompt.md
1584
- 4. tasks/todo.md
1585
- 5. .github/workflows/omar-gate.yml
1586
-
1587
- Execution mode:
1588
- - Work PR-by-PR from tasks/todo.md.
1589
- - For each PR run Omar loop until P0/P1 are zero and quality checks pass.
1590
- - Keep commits scoped and deterministic.
1591
- - Stop only for blocking secrets/permission gaps.
1592
-
1593
- Coding agent profile:
1594
- - Selected agent: ${codingAgentProfile.name} (${codingAgentProfile.id})
1595
- - Prompt target: ${codingAgentProfile.promptTarget}
1596
- - Suggested config path: ${codingAgentConfigPath}
1597
-
1598
- Agent-specific guidance:
1599
- ${codingAgentGuidance}
1600
-
1601
- GitHub Action contract:
1602
- ${tokenContract}
1603
-
1604
- Terminal command options:
1605
- - sentinel /omargate deep --path .
1606
- - sentinel /audit --path .
1607
- - sentinel /persona orchestrator --mode builder --path .
1608
- - sentinel /persona orchestrator --mode reviewer --path .
1609
- - sentinel /persona orchestrator --mode hardener --path .
1610
- - sentinel /apply --plan tasks/todo.md --path .
1611
- - Add --json to /omargate, /audit, /persona, or /apply for machine-readable CI output.
1612
-
1613
- Workflow tuning options:
1614
- ${workflowTuning}
1615
-
1616
- Repo context:
1617
- - Target repo: ${repoSlug || "not provided"}
1618
- - Workspace mode: ${buildFromExistingRepo ? "existing codebase" : "new scaffold"}
1619
-
1620
- Start now and continue autonomously.
1621
- `;
1622
- }
1623
-
1624
- function fallbackWorkflow({ secretName = "SENTINELAYER_TOKEN", authMode = "sentinelayer" } = {}) {
1625
- if (authMode === "byok") {
1626
- return `name: Omar Gate (BYOK Mode)
1627
-
1628
- on:
1629
- pull_request:
1630
- types: [opened, synchronize, reopened]
1631
-
1632
- permissions:
1633
- contents: read
1634
-
1635
- jobs:
1636
- byok-note:
1637
- runs-on: ubuntu-latest
1638
- steps:
1639
- - name: BYOK mode reminder
1640
- run: |
1641
- echo "Sentinelayer token is not configured in BYOK mode."
1642
- echo "Use local commands: npx sentinelayer-cli@latest /audit --path ."
1643
- echo "Set SENTINELAYER_TOKEN and wire sentinelayer_token to enable Omar Gate action later."
1644
- `;
1645
- }
1646
- const normalizedSecret = isValidSecretName(secretName) ? secretName : "SENTINELAYER_TOKEN";
1647
- return `name: Omar Gate
1648
-
1649
- on:
1650
- pull_request:
1651
- types: [opened, synchronize, reopened]
1652
-
1653
- permissions:
1654
- contents: read
1655
- pull-requests: write
1656
- checks: write
1657
-
1658
- jobs:
1659
- quality-gates:
1660
- runs-on: ubuntu-latest
1661
- steps:
1662
- - uses: actions/checkout@v4
1663
- - name: Omar Gate
1664
- uses: mrrCarter/sentinelayer-v1-action@v1
1665
- with:
1666
- sentinelayer_token: \${{ secrets.${normalizedSecret} }}
1667
- scan_mode: deep
1668
- severity_gate: P1
1669
- `;
1670
- }
1671
-
1672
- function buildByokArtifacts({ interview, description }) {
1673
- const featureList =
1674
- interview.features.length > 0
1675
- ? interview.features.map((item, index) => `${index + 1}. ${item}`).join("\n")
1676
- : "1. Implement the core workflow end-to-end.\n2. Add observability and hardening.\n3. Add tests and docs.";
1677
- const techStack =
1678
- interview.techStack.length > 0 ? interview.techStack.join(", ") : "Node.js, TypeScript, PostgreSQL";
1679
-
1680
- return {
1681
- project_name: interview.projectName,
1682
- spec_sheet: `# Spec
1683
-
1684
- ## Project
1685
- ${interview.projectName}
1686
-
1687
- ## Goal
1688
- ${description}
1689
-
1690
- ## Target audience
1691
- ${interview.audienceLevel}
1692
-
1693
- ## Preferred provider
1694
- ${interview.aiProvider}
1695
-
1696
- ## Project type
1697
- ${interview.projectType}
1698
-
1699
- ## Suggested stack
1700
- ${techStack}
1701
-
1702
- ## Key features
1703
- ${featureList}
1704
- `,
1705
- playbook: `# Build Guide
1706
-
1707
- ## Scope
1708
- - Keep each PR bounded and shippable.
1709
- - Run tests and local scans before each handoff.
1710
- - Keep secrets out of source control.
1711
-
1712
- ## Implementation order
1713
- 1. Establish repo baseline and CI checks.
1714
- 2. Implement domain model and persistence boundaries.
1715
- 3. Implement API/worker surface and auth/session policies.
1716
- 4. Add observability, retries, and production hardening.
1717
- 5. Finalize docs and operational runbooks.
1718
-
1719
- ## Review loop
1720
- - Run \`sentinel /omargate deep --path .\` and \`sentinel /audit --path .\`.
1721
- - Fix P0/P1 issues before merge.
1722
- - Fix P2 findings before merge when feasible.
1723
- `,
1724
- builder_prompt: `You are operating in Sentinelayer BYOK mode.
1725
-
1726
- Read files in order:
1727
- 1. docs/spec.md
1728
- 2. docs/build-guide.md
1729
- 3. tasks/todo.md
1730
- 4. AGENT_HANDOFF_PROMPT.md
1731
-
1732
- Execute PR-by-PR from tasks/todo.md.
1733
- Run local scans after each PR:
1734
- - sentinel /omargate deep --path .
1735
- - sentinel /audit --path .
1736
-
1737
- Continue autonomously unless blocked by missing credentials or permissions.`,
1738
- omar_gate_yaml: fallbackWorkflow({ authMode: "byok" }),
1739
- };
1740
- }
1741
-
1742
- function runGhSecretSet({ repoSlug, secretName, secretValue }) {
1743
- const normalizedRepo = normalizeRepoSlug(repoSlug);
1744
- const ghCommand = getGhCommand();
1745
- const secretSinkFile = String(process.env.SENTINELAYER_SECRET_SINK_FILE || "").trim();
1746
- if (!isValidRepoSlug(normalizedRepo)) {
1747
- return {
1748
- ok: false,
1749
- reason: "Invalid repo format. Use owner/repo.",
1750
- };
1751
- }
1752
- if (!isValidSecretName(secretName)) {
1753
- return {
1754
- ok: false,
1755
- reason: "Invalid secret name from bootstrap response.",
1756
- };
1757
- }
1758
- if (secretSinkFile) {
1759
- try {
1760
- fs.appendFileSync(secretSinkFile, `${normalizedRepo}|${secretName}|${secretValue}\n`, "utf-8");
1761
- return { ok: true };
1762
- } catch (error) {
1763
- return {
1764
- ok: false,
1765
- reason: `Failed to write SENTINELAYER_SECRET_SINK_FILE: ${error instanceof Error ? error.message : String(error)}`,
1766
- };
1767
- }
1768
- }
1769
- try {
1770
- ensureGhCliAvailable(ghCommand);
1771
- } catch (error) {
1772
- return {
1773
- ok: false,
1774
- reason: error instanceof Error ? error.message : String(error),
1775
- };
1776
- }
1777
-
1778
- const result = spawnSync(ghCommand, ["secret", "set", secretName, "--repo", normalizedRepo], {
1779
- encoding: "utf-8",
1780
- input: `${secretValue}\n`,
1781
- });
1782
- if (result.status !== 0) {
1783
- return {
1784
- ok: false,
1785
- reason: String(result.stderr || result.stdout || "gh secret set failed").trim(),
1786
- };
1787
- }
1788
-
1789
- const verifyResult = spawnSync(ghCommand, ["secret", "list", "--repo", normalizedRepo], {
1790
- encoding: "utf-8",
1791
- });
1792
- if (verifyResult.status !== 0) {
1793
- return {
1794
- ok: false,
1795
- reason: String(verifyResult.stderr || verifyResult.stdout || "gh secret list failed").trim(),
1796
- };
1797
- }
1798
-
1799
- const listedSecrets = String(verifyResult.stdout || "");
1800
- const escapedSecretName = String(secretName || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1801
- const secretRegex = new RegExp(`(^|\\r?\\n)\\s*${escapedSecretName}(\\s|$)`, "m");
1802
- if (!secretRegex.test(listedSecrets)) {
1803
- return {
1804
- ok: false,
1805
- reason: `Secret '${secretName}' was not visible in gh secret list output after injection.`,
1806
- };
1807
- }
1808
-
1809
- return { ok: true };
1810
- }
1811
-
1812
- async function collectInterview({ initialProjectName, detectedRepo, detectedCodingAgent }) {
1813
- const onCancel = () => {
1814
- throw new Error("Prompt flow cancelled by user.");
1815
- };
1816
- const detectedAgentRecord = resolveCodingAgent(detectedCodingAgent || DEFAULT_CODING_AGENT_ID);
1817
- const codingAgentChoices = listSupportedCodingAgents().map((agent) => ({
1818
- title:
1819
- agent.id === detectedAgentRecord.id
1820
- ? `${agent.name} (${agent.id}, detected)`
1821
- : `${agent.name} (${agent.id})`,
1822
- value: agent.id,
1823
- }));
1824
- const defaultCodingAgentIndex = Math.max(
1825
- 0,
1826
- codingAgentChoices.findIndex((choice) => choice.value === detectedAgentRecord.id)
1827
- );
1828
- const projectTypeChoices = [
1829
- { title: "Greenfield", value: "greenfield" },
1830
- { title: "Add feature", value: "add_feature" },
1831
- { title: "Bugfix / hardening", value: "bugfix" },
1832
- ];
1833
- const inferredProjectType = isValidRepoSlug(detectedRepo || "") ? "add_feature" : "greenfield";
1834
- const defaultProjectTypeIndex = Math.max(
1835
- 0,
1836
- projectTypeChoices.findIndex((choice) => choice.value === inferredProjectType)
1837
- );
1838
-
1839
- const base = await prompts(
1840
- [
1841
- {
1842
- type: initialProjectName ? null : "text",
1843
- name: "projectName",
1844
- message: "Project folder name",
1845
- initial: "my-agent-app",
1846
- validate: (value) =>
1847
- sanitizeProjectName(value).length > 0 ? true : "Enter a valid project folder name.",
1848
- },
1849
- {
1850
- type: "text",
1851
- name: "projectDescription",
1852
- message: "What are you building?",
1853
- validate: (value) =>
1854
- String(value || "").trim().length >= 15
1855
- ? true
1856
- : "Describe your project in at least 15 characters.",
1857
- },
1858
- {
1859
- type: "select",
1860
- name: "aiProvider",
1861
- message: "Select your AI provider",
1862
- choices: [
1863
- { title: "OpenAI (Codex)", value: "openai" },
1864
- { title: "Anthropic (Claude)", value: "anthropic" },
1865
- { title: "Google (Gemini)", value: "google" },
1866
- ],
1867
- initial: 0,
1868
- },
1869
- {
1870
- type: "select",
1871
- name: "codingAgent",
1872
- message: "Which coding agent will you use?",
1873
- choices: codingAgentChoices,
1874
- initial: defaultCodingAgentIndex,
1875
- },
1876
- {
1877
- type: "select",
1878
- name: "generationMode",
1879
- message: "Artifact depth",
1880
- choices: [
1881
- { title: "Detailed (recommended)", value: "detailed" },
1882
- { title: "Quick", value: "quick" },
1883
- { title: "Enterprise", value: "enterprise" },
1884
- ],
1885
- initial: 0,
1886
- },
1887
- {
1888
- type: "select",
1889
- name: "audienceLevel",
1890
- message: "Primary audience",
1891
- choices: [
1892
- { title: "Developer", value: "developer" },
1893
- { title: "Intermediate", value: "intermediate" },
1894
- { title: "Beginner", value: "beginner" },
1895
- ],
1896
- initial: 0,
1897
- },
1898
- {
1899
- type: "select",
1900
- name: "projectType",
1901
- message: "Project type",
1902
- choices: projectTypeChoices,
1903
- initial: defaultProjectTypeIndex,
1904
- },
1905
- {
1906
- type: "text",
1907
- name: "techStack",
1908
- message: "Tech stack (comma-separated, optional)",
1909
- initial: "TypeScript, Node.js, PostgreSQL",
1910
- },
1911
- {
1912
- type: "text",
1913
- name: "features",
1914
- message: "Key features (comma-separated, optional)",
1915
- },
1916
- {
1917
- type: "select",
1918
- name: "authMode",
1919
- message: "Auth mode",
1920
- choices: [
1921
- { title: "Sentinelayer managed token (recommended)", value: "sentinelayer" },
1922
- { title: "BYOK only (skip Sentinelayer token)", value: "byok" },
1923
- ],
1924
- initial: 0,
1925
- },
1926
- {
1927
- type: "toggle",
1928
- name: "advanced",
1929
- message: "Advanced options?",
1930
- initial: true,
1931
- active: "yes",
1932
- inactive: "no",
1933
- },
1934
- ],
1935
- { onCancel }
1936
- );
1937
-
1938
- let advanced = {
1939
- connectRepo: false,
1940
- repoSlug: detectedRepo || "",
1941
- buildFromExistingRepo: false,
1942
- injectSecret: false,
1943
- };
1944
- if (base.advanced) {
1945
- const repoChoices = [];
1946
- if (detectedRepo) {
1947
- repoChoices.push({
1948
- title: `Use current repo (${detectedRepo})`,
1949
- value: "current",
1950
- });
1951
- }
1952
- repoChoices.push({
1953
- title: "Choose from GitHub account (browser auth)",
1954
- value: "picker",
1955
- });
1956
- repoChoices.push({
1957
- title: "Enter owner/repo manually",
1958
- value: "manual",
1959
- });
1960
-
1961
- const repoSetup = await prompts(
1962
- [
1963
- {
1964
- type: "toggle",
1965
- name: "connectRepo",
1966
- message: "Connect a GitHub repo and inject Actions secret?",
1967
- initial: Boolean(detectedRepo),
1968
- active: "yes",
1969
- inactive: "no",
1970
- },
1971
- {
1972
- type: (prev) => (prev ? "select" : null),
1973
- name: "repoSource",
1974
- message: "How should we choose the repo?",
1975
- choices: repoChoices,
1976
- initial: detectedRepo ? 0 : 1,
1977
- },
1978
- ],
1979
- { onCancel }
1980
- );
1981
-
1982
- advanced.connectRepo = Boolean(repoSetup.connectRepo);
1983
- if (advanced.connectRepo) {
1984
- let repoSlug;
1985
- const repoSource = String(repoSetup.repoSource || "").trim().toLowerCase();
1986
-
1987
- if (repoSource === "manual") {
1988
- const manual = await prompts(
1989
- [
1990
- {
1991
- type: "text",
1992
- name: "repoSlug",
1993
- message: "GitHub repo (owner/repo)",
1994
- initial: detectedRepo || "",
1995
- validate: (value) => (isValidRepoSlug(value) ? true : "Use owner/repo format."),
1996
- },
1997
- ],
1998
- { onCancel }
1999
- );
2000
- repoSlug = normalizeRepoSlug(manual.repoSlug);
2001
- } else if (repoSource === "picker") {
2002
- repoSlug = await selectRepoSlugFromGithub();
2003
- } else {
2004
- repoSlug = normalizeRepoSlug(detectedRepo);
2005
- }
2006
-
2007
- if (!isValidRepoSlug(repoSlug)) {
2008
- throw new Error("GitHub repo selection did not produce a valid owner/repo value.");
2009
- }
2010
-
2011
- const repoMode = await prompts(
2012
- [
2013
- {
2014
- type: "toggle",
2015
- name: "buildFromExistingRepo",
2016
- message: "Clone this repo locally and build directly into it now?",
2017
- initial: base.projectType === "add_feature" || base.projectType === "bugfix",
2018
- active: "yes",
2019
- inactive: "no",
2020
- },
2021
- {
2022
- type: base.authMode === "sentinelayer" ? "toggle" : null,
2023
- name: "injectSecret",
2024
- message: "Inject SENTINELAYER_TOKEN into GitHub Actions secrets now?",
2025
- initial: true,
2026
- active: "yes",
2027
- inactive: "no",
2028
- },
2029
- ],
2030
- { onCancel }
2031
- );
2032
-
2033
- advanced.repoSlug = repoSlug;
2034
- advanced.buildFromExistingRepo = Boolean(repoMode.buildFromExistingRepo);
2035
- advanced.injectSecret = base.authMode === "sentinelayer" ? Boolean(repoMode.injectSecret) : false;
2036
- }
2037
- }
2038
-
2039
- const projectName =
2040
- sanitizeProjectName(initialProjectName || base.projectName) || getRepoNameFromSlug(advanced.repoSlug);
2041
-
2042
- const interviewResult = {
2043
- projectName,
2044
- projectDescription: String(base.projectDescription || "").trim(),
2045
- aiProvider: base.aiProvider,
2046
- generationMode: base.generationMode,
2047
- audienceLevel: base.audienceLevel,
2048
- projectType: base.projectType,
2049
- codingAgent: resolveCodingAgent(base.codingAgent || detectedAgentRecord.id).id,
2050
- techStack: parseCommaList(base.techStack),
2051
- features: parseCommaList(base.features),
2052
- authMode: base.authMode,
2053
- connectRepo: Boolean(advanced.connectRepo),
2054
- repoSlug: normalizeRepoSlug(advanced.repoSlug),
2055
- buildFromExistingRepo: Boolean(advanced.buildFromExistingRepo),
2056
- injectSecret: Boolean(advanced.injectSecret),
2057
- };
2058
-
2059
- printSection("Interview Review");
2060
- printInfo(`Project: ${interviewResult.projectName}`);
2061
- printInfo(`Type: ${interviewResult.projectType}`);
2062
- printInfo(`Provider: ${interviewResult.aiProvider}`);
2063
- printInfo(`Coding agent: ${interviewResult.codingAgent}`);
2064
- printInfo(`Auth mode: ${interviewResult.authMode}`);
2065
- printInfo(`Repo: ${interviewResult.repoSlug || "not connected"}`);
2066
- printInfo(
2067
- `Existing repo mode: ${interviewResult.buildFromExistingRepo ? "enabled (clone/reuse)" : "disabled"}`
2068
- );
2069
-
2070
- const review = await prompts(
2071
- [
2072
- {
2073
- type: "toggle",
2074
- name: "proceed",
2075
- message: "Proceed with these selections?",
2076
- initial: true,
2077
- active: "yes",
2078
- inactive: "no",
2079
- },
2080
- ],
2081
- { onCancel }
2082
- );
2083
-
2084
- if (!review.proceed) {
2085
- const next = await prompts(
2086
- [
2087
- {
2088
- type: "select",
2089
- name: "action",
2090
- message: "What do you want to do?",
2091
- choices: [
2092
- { title: "Restart interview", value: "restart" },
2093
- { title: "Cancel", value: "cancel" },
2094
- ],
2095
- initial: 0,
2096
- },
2097
- ],
2098
- { onCancel }
2099
- );
2100
- if (next.action === "restart") {
2101
- return collectInterview({ initialProjectName, detectedRepo, detectedCodingAgent });
2102
- }
2103
- throw new Error("Prompt flow cancelled by user.");
2104
- }
2105
-
2106
- return interviewResult;
2107
- }
2108
-
2109
- function printSection(title) {
2110
- console.log(`\n${pc.bold(pc.cyan(title))}`);
2111
- }
2112
-
2113
- function printInfo(message) {
2114
- console.log(pc.gray(`- ${message}`));
2115
- }
2116
-
2117
- export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
2118
- refreshRuntimeDefaults();
2119
- const commandExitCode = await tryRunLocalCommandMode(rawArgs);
2120
- if (commandExitCode !== null) {
2121
- if (commandExitCode !== 0) {
2122
- process.exitCode = commandExitCode;
2123
- }
2124
- return;
2125
- }
2126
-
2127
- const args = parseCliArgs(rawArgs);
2128
- if (args.showHelp) {
2129
- printUsage();
2130
- return;
2131
- }
2132
- if (args.showVersion) {
2133
- console.log(CLI_VERSION);
2134
- return;
2135
- }
2136
- const argProjectName = args.projectName;
2137
- const detectedRepo = detectRepoSlug(process.cwd());
2138
- const detectedCodingAgent = detectCodingAgentFromEnv(process.env).id;
2139
-
2140
- printSection("Sentinelayer Scaffold");
2141
- printInfo(`API: ${DEFAULT_API_URL}`);
2142
- printInfo(`Web: ${DEFAULT_WEB_URL}`);
2143
- if (detectedRepo) {
2144
- printInfo(`Detected repo: ${detectedRepo}`);
2145
- }
2146
-
2147
- const automatedInterview = await loadAutomatedInterview({
2148
- argProjectName,
2149
- detectedRepo,
2150
- detectedCodingAgent,
2151
- interviewFile: args.interviewFile,
2152
- });
2153
-
2154
- const interview =
2155
- automatedInterview ||
2156
- (args.nonInteractive
2157
- ? null
2158
- : await collectInterview({
2159
- initialProjectName: argProjectName,
2160
- detectedRepo,
2161
- detectedCodingAgent,
2162
- }));
2163
-
2164
- if (!interview) {
2165
- throw new Error(
2166
- "Non-interactive mode requires SENTINELAYER_CLI_INTERVIEW_JSON or --interview-file."
2167
- );
2168
- }
2169
- validateInterviewInput(interview);
2170
-
2171
- const workspace = await resolveProjectDirectory({
2172
- cwd: process.cwd(),
2173
- interview,
2174
- detectedRepo,
2175
- });
2176
- const projectDir = workspace.projectDir;
2177
-
2178
- printSection("Workspace");
2179
- if (workspace.reusedCurrentRepo) {
2180
- printInfo(`Using current repo workspace: ${projectDir}`);
2181
- } else if (workspace.clonedRepo) {
2182
- printInfo(`Cloned repo workspace: ${projectDir}`);
2183
- if (workspace.cloneUrl) {
2184
- printInfo(`Clone URL: ${workspace.cloneUrl}`);
2185
- }
2186
- } else {
2187
- printInfo(`Target scaffold workspace: ${projectDir}`);
2188
- }
2189
-
2190
- const requestedAuthMode = interview.authMode === "byok" ? "byok" : "sentinelayer";
2191
- let authToken = "";
2192
-
2193
- printSection("Authentication");
2194
- if (requestedAuthMode === "byok") {
2195
- printInfo("BYOK mode selected. Skipping Sentinelayer browser auth and token bootstrap.");
2196
- } else {
2197
- if (args.nonInteractive) {
2198
- console.log("Non-interactive mode: skipping Enter confirmation.");
2199
- } else {
2200
- await waitForEnter("Press Enter to authenticate with Sentinelayer in your browser...");
2201
- }
2202
-
2203
- const challenge = crypto.randomBytes(32).toString("hex");
2204
- const session = await startCliSession({
2205
- apiUrl: DEFAULT_API_URL,
2206
- challenge,
2207
- cliVersion: CLI_VERSION,
2208
- });
2209
-
2210
- if (args.skipBrowserOpen || args.nonInteractive) {
2211
- console.log(`Browser open skipped. Authorize manually: ${session.authorize_url}`);
2212
- } else {
2213
- console.log(`Opening browser: ${session.authorize_url}`);
2214
- try {
2215
- await open(session.authorize_url);
2216
- } catch {
2217
- console.log(pc.yellow("Could not auto-open browser. Open this URL manually:"));
2218
- console.log(pc.yellow(session.authorize_url));
2219
- }
2220
- }
2221
-
2222
- console.log("Waiting for browser approval...");
2223
- const approval = await pollCliSession({
2224
- apiUrl: DEFAULT_API_URL,
2225
- sessionId: session.session_id,
2226
- challenge,
2227
- pollIntervalSeconds: session.poll_interval_seconds || 2,
2228
- timeoutMs: DEFAULT_AUTH_TIMEOUT_MS,
2229
- });
2230
-
2231
- authToken = String(approval.auth_token || "").trim();
2232
- if (!authToken) {
2233
- throw new Error("Authentication completed but no auth token was returned.");
2234
- }
2235
- }
2236
-
2237
- printSection("Artifact Generation");
2238
- let description = interview.projectDescription;
2239
- if (interview.buildFromExistingRepo) {
2240
- const repoSummary = await buildRepoIngestSummary(projectDir);
2241
- if (repoSummary) {
2242
- description = `${description}\n\nExisting repo context:\n${repoSummary}`;
2243
- printInfo("Included existing repo ingest summary in generation payload.");
2244
- } else {
2245
- printInfo("No repo ingest summary was available. Continuing with base description.");
2246
- }
2247
- }
2248
- const generatePayload = {
2249
- description,
2250
- tech_stack: interview.techStack,
2251
- features: interview.features,
2252
- generation_mode: interview.generationMode,
2253
- audience_level: interview.audienceLevel,
2254
- project_type: interview.projectType,
2255
- model_provider: interview.aiProvider,
2256
- model_id: DEFAULT_MODEL_BY_PROVIDER[interview.aiProvider] || undefined,
2257
- };
2258
- let generated = null;
2259
- let sentinelayerToken = "";
2260
- let secretName = "SENTINELAYER_TOKEN";
2261
-
2262
- if (requestedAuthMode === "byok") {
2263
- generated = buildByokArtifacts({
2264
- interview,
2265
- description,
2266
- });
2267
- } else {
2268
- generated = await generateArtifacts({
2269
- apiUrl: DEFAULT_API_URL,
2270
- authToken,
2271
- payload: generatePayload,
2272
- });
2273
-
2274
- let bootstrapToken = generated?.bootstrap_token || null;
2275
- if (!bootstrapToken || !String(bootstrapToken.token || "").trim()) {
2276
- try {
2277
- bootstrapToken = await issueBootstrapToken({
2278
- apiUrl: DEFAULT_API_URL,
2279
- authToken,
2280
- });
2281
- } catch (error) {
2282
- const message = error instanceof Error ? error.message : String(error);
2283
- console.log(
2284
- pc.yellow(`Token bootstrap unavailable. Continuing in BYOK mode for this scaffold. (${message})`)
2285
- );
2286
- }
2287
- }
2288
-
2289
- sentinelayerToken = String(bootstrapToken?.token || "").trim();
2290
- if (sentinelayerToken) {
2291
- const requestedSecretName = String(bootstrapToken.required_secret_name || "").trim();
2292
- secretName = isValidSecretName(requestedSecretName) ? requestedSecretName : "SENTINELAYER_TOKEN";
2293
- if (requestedSecretName && requestedSecretName !== secretName) {
2294
- console.log(
2295
- pc.yellow(
2296
- `Received invalid secret name '${requestedSecretName}' from API. Falling back to ${secretName}.`
2297
- )
2298
- );
2299
- }
2300
- } else {
2301
- console.log(pc.yellow("Sentinelayer token unavailable. Continuing in BYOK mode for this scaffold."));
2302
- }
2303
- }
2304
- const effectiveAuthMode = sentinelayerToken ? "sentinelayer" : "byok";
2305
-
2306
- const effectiveProjectName =
2307
- sanitizeProjectName(generated.project_name || interview.projectName || path.basename(projectDir)) ||
2308
- path.basename(projectDir);
2309
- const docsDir = path.join(projectDir, "docs");
2310
- const promptsDir = path.join(projectDir, "prompts");
2311
- const tasksDir = path.join(projectDir, "tasks");
2312
-
2313
- await writeTextFile(path.join(docsDir, "spec.md"), String(generated.spec_sheet || "").trim() + "\n");
2314
- await writeTextFile(
2315
- path.join(docsDir, "build-guide.md"),
2316
- String(generated.playbook || "").trim() + "\n"
2317
- );
2318
- await writeTextFile(
2319
- path.join(promptsDir, "execution-prompt.md"),
2320
- String(generated.builder_prompt || "").trim() + "\n"
2321
- );
2322
- await writeTextFile(
2323
- path.join(projectDir, ".github", "workflows", "omar-gate.yml"),
2324
- (
2325
- (effectiveAuthMode === "sentinelayer" ? String(generated.omar_gate_yaml || "").trim() : "") ||
2326
- fallbackWorkflow({ secretName, authMode: effectiveAuthMode })
2327
- ) + "\n"
2328
- );
2329
- await writeTextFile(
2330
- path.join(tasksDir, "todo.md"),
2331
- buildTodoContent({
2332
- projectName: effectiveProjectName,
2333
- aiProvider: interview.aiProvider,
2334
- codingAgent: interview.codingAgent,
2335
- authMode: effectiveAuthMode,
2336
- repoSlug: interview.repoSlug,
2337
- buildFromExistingRepo: interview.buildFromExistingRepo,
2338
- generationMode: interview.generationMode,
2339
- audienceLevel: interview.audienceLevel,
2340
- projectType: interview.projectType,
2341
- })
2342
- );
2343
- await writeTextFile(
2344
- path.join(projectDir, "AGENT_HANDOFF_PROMPT.md"),
2345
- buildHandoffPrompt({
2346
- projectName: effectiveProjectName,
2347
- repoSlug: interview.repoSlug,
2348
- secretName,
2349
- buildFromExistingRepo: interview.buildFromExistingRepo,
2350
- authMode: effectiveAuthMode,
2351
- codingAgent: interview.codingAgent,
2352
- })
2353
- );
2354
- const codingAgentConfig = await ensureCodingAgentConfigFile({
2355
- projectDir,
2356
- projectName: effectiveProjectName,
2357
- codingAgent: interview.codingAgent,
2358
- });
2359
-
2360
- await ensureSentinelStartScript(projectDir, effectiveProjectName);
2361
- if (sentinelayerToken) {
2362
- await ensureEnvFileIgnored(projectDir);
2363
- await upsertEnvVariable(path.join(projectDir, ".env"), secretName, sentinelayerToken);
2364
- }
2365
- await ensureGitRepositorySetup({
2366
- projectDir,
2367
- repoSlug: interview.connectRepo ? interview.repoSlug : "",
2368
- });
2369
-
2370
- let secretInjection = { ok: false, reason: "Skipped." };
2371
- if (interview.connectRepo && interview.injectSecret && interview.repoSlug && sentinelayerToken) {
2372
- secretInjection = runGhSecretSet({
2373
- repoSlug: interview.repoSlug,
2374
- secretName,
2375
- secretValue: sentinelayerToken,
2376
- });
2377
- }
2378
-
2379
- printSection("Complete");
2380
- console.log(pc.green(`✔ Sentinelayer orchestration initialized in ${projectDir}`));
2381
- if (sentinelayerToken) {
2382
- console.log(pc.green(`✔ ${secretName} injected into ${path.join(projectDir, ".env")}`));
2383
- } else {
2384
- console.log(pc.yellow("! BYOK mode active: Sentinelayer token was not injected."));
2385
- }
2386
- if (codingAgentConfig.created) {
2387
- console.log(
2388
- pc.green(`✔ ${codingAgentConfig.agent.name} config scaffolded at ${codingAgentConfig.path}`)
2389
- );
2390
- }
2391
- if (interview.connectRepo && interview.injectSecret && sentinelayerToken) {
2392
- if (secretInjection.ok) {
2393
- console.log(pc.green(`✔ ${secretName} injected into GitHub repo secret (${interview.repoSlug})`));
2394
- } else {
2395
- console.log(pc.yellow(`! GitHub secret injection skipped/failed: ${secretInjection.reason}`));
2396
- console.log(
2397
- pc.yellow(
2398
- ` Run manually: gh secret set ${secretName} --repo ${interview.repoSlug || "<owner/repo>"}`
2399
- )
2400
- );
2401
- }
2402
- }
2403
-
2404
- console.log("\nNext:");
2405
- const nextCd = path.relative(process.cwd(), projectDir) || ".";
2406
- console.log(`1. cd ${nextCd}`);
2407
- console.log("2. npm run sentinel:start");
2408
- console.log("3. Copy/paste AGENT_HANDOFF_PROMPT.md into your coding agent and let it run autonomously.");
2409
- }
2410
-
2411
- export function renderCliFailure(error) {
2412
- const message = error instanceof Error ? error.message : String(error);
2413
- const code = error instanceof SentinelayerApiError ? ` [${error.code}]` : "";
2414
- const requestId =
2415
- error instanceof SentinelayerApiError && error.requestId ? ` request_id=${error.requestId}` : "";
2416
- console.error(pc.red(`\nSentinelayer scaffold failed${code}:${requestId}`));
2417
- console.error(pc.red(message));
2418
- }
2419
-
2420
- export async function runLegacyCliWithErrorHandling(rawArgs = process.argv.slice(2)) {
2421
- try {
2422
- await runLegacyCli(rawArgs);
2423
- } catch (error) {
2424
- renderCliFailure(error);
2425
- process.exitCode = 1;
2426
- }
2427
- }
2428
-
2429
- const invokedAsEntrypoint =
2430
- process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
2431
-
2432
- if (invokedAsEntrypoint) {
2433
- runLegacyCliWithErrorHandling();
2434
- }
2435
-
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import fsp from "node:fs/promises";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { stdin as input, stdout as output } from "node:process";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ import open from "open";
14
+ import pc from "picocolors";
15
+ import prompts from "prompts";
16
+ import {
17
+ DEFAULT_CODING_AGENT_ID,
18
+ detectCodingAgentFromEnv,
19
+ detectIdeFromEnv,
20
+ listSupportedCodingAgents,
21
+ resolveCodingAgent,
22
+ } from "./config/agent-dictionary.js";
23
+ import { resolveOutputRoot } from "./config/service.js";
24
+ import { collectCodebaseIngest, formatIngestSummary } from "./ingest/engine.js";
25
+ import { getExpressTemplate, getPackageJsonTemplate, buildReadmeContent } from "./scaffold/templates.js";
26
+ import { generateScaffold } from "./scaffold/generator.js";
27
+
28
+ let DEFAULT_API_URL = process.env.SENTINELAYER_API_URL || "https://api.sentinelayer.com";
29
+ let DEFAULT_WEB_URL = process.env.SENTINELAYER_WEB_URL || "https://sentinelayer.com";
30
+ let DEFAULT_GITHUB_CLONE_BASE_URL =
31
+ process.env.SENTINELAYER_GITHUB_CLONE_BASE_URL || "https://github.com";
32
+ const DEFAULT_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
33
+ const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
34
+ const PACKAGE_JSON_PATH = new URL("../package.json", import.meta.url);
35
+
36
+ function refreshRuntimeDefaults() {
37
+ DEFAULT_API_URL = process.env.SENTINELAYER_API_URL || "https://api.sentinelayer.com";
38
+ DEFAULT_WEB_URL = process.env.SENTINELAYER_WEB_URL || "https://sentinelayer.com";
39
+ DEFAULT_GITHUB_CLONE_BASE_URL =
40
+ process.env.SENTINELAYER_GITHUB_CLONE_BASE_URL || "https://github.com";
41
+ }
42
+
43
+ function resolveCliVersion() {
44
+ try {
45
+ const raw = fs.readFileSync(PACKAGE_JSON_PATH, "utf-8");
46
+ const pkg = JSON.parse(raw);
47
+ const version = String(pkg && pkg.version ? pkg.version : "").trim();
48
+ if (version) {
49
+ return version;
50
+ }
51
+ } catch {
52
+ // Ignore and fall through to static fallback.
53
+ }
54
+ return "0.1.0";
55
+ }
56
+
57
+ export const CLI_VERSION = resolveCliVersion();
58
+
59
+ const DEFAULT_MODEL_BY_PROVIDER = {
60
+ openai: "gpt-5.3-codex",
61
+ anthropic: "claude-sonnet-4-6",
62
+ google: "gemini-2.5-flash",
63
+ };
64
+
65
+ const VALID_AI_PROVIDERS = new Set(["openai", "anthropic", "google"]);
66
+ const VALID_GENERATION_MODES = new Set(["detailed", "quick", "enterprise"]);
67
+ const VALID_AUDIENCE_LEVELS = new Set(["developer", "intermediate", "beginner"]);
68
+ const VALID_PROJECT_TYPES = new Set(["greenfield", "add_feature", "bugfix"]);
69
+ const VALID_AUTH_MODES = new Set(["sentinelayer", "byok"]);
70
+
71
+ class SentinelayerApiError extends Error {
72
+ constructor(message, { code = "API_ERROR", status = 500, requestId = null } = {}) {
73
+ super(message);
74
+ this.name = "SentinelayerApiError";
75
+ this.code = code;
76
+ this.status = status;
77
+ this.requestId = requestId;
78
+ }
79
+ }
80
+
81
+ function nowIso() {
82
+ return new Date().toISOString();
83
+ }
84
+
85
+ function parseCommaList(value) {
86
+ return String(value || "")
87
+ .split(",")
88
+ .map((item) => item.trim())
89
+ .filter(Boolean);
90
+ }
91
+
92
+ function sanitizeProjectName(value) {
93
+ const raw = String(value || "").trim();
94
+ if (!raw) return "";
95
+ return raw
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9-_ ]+/g, "")
98
+ .trim()
99
+ .replace(/\s+/g, "-")
100
+ .replace(/-+/g, "-")
101
+ .replace(/^-|-$/g, "");
102
+ }
103
+
104
+ function normalizeRepoSlug(value) {
105
+ return String(value || "").trim().replace(/\.git$/i, "");
106
+ }
107
+
108
+ function isValidRepoSlug(value) {
109
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizeRepoSlug(value));
110
+ }
111
+
112
+ function getRepoNameFromSlug(value) {
113
+ const normalized = normalizeRepoSlug(value);
114
+ const parts = normalized.split("/");
115
+ if (parts.length !== 2) return "";
116
+ return sanitizeProjectName(parts[1]);
117
+ }
118
+
119
+ function isValidSecretName(value) {
120
+ return /^[A-Z][A-Z0-9_]{1,127}$/.test(String(value || "").trim());
121
+ }
122
+
123
+ function boolFromEnv(value) {
124
+ const normalized = String(value || "").trim().toLowerCase();
125
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
126
+ }
127
+
128
+ function normalizeListInput(value) {
129
+ if (Array.isArray(value)) {
130
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
131
+ }
132
+ return parseCommaList(value);
133
+ }
134
+
135
+ function parseCliArgs(argv) {
136
+ let projectName = "";
137
+ let interviewFile = "";
138
+ let nonInteractive = boolFromEnv(process.env.SENTINELAYER_CLI_NON_INTERACTIVE);
139
+ let skipBrowserOpen = boolFromEnv(process.env.SENTINELAYER_CLI_SKIP_BROWSER_OPEN);
140
+ let showHelp = false;
141
+ let showVersion = false;
142
+
143
+ for (let i = 0; i < argv.length; i += 1) {
144
+ const arg = String(argv[i] || "").trim();
145
+ if (!arg) continue;
146
+ if (arg === "--help" || arg === "-h" || arg === "help") {
147
+ showHelp = true;
148
+ continue;
149
+ }
150
+ if (arg === "--version" || arg === "-v") {
151
+ showVersion = true;
152
+ continue;
153
+ }
154
+ if (arg === "--non-interactive") {
155
+ nonInteractive = true;
156
+ continue;
157
+ }
158
+ if (arg === "--skip-browser-open") {
159
+ skipBrowserOpen = true;
160
+ continue;
161
+ }
162
+ if (arg === "--interview-file") {
163
+ const next = String(argv[i + 1] || "").trim();
164
+ if (!next) {
165
+ throw new Error("Missing value for --interview-file");
166
+ }
167
+ interviewFile = next;
168
+ i += 1;
169
+ continue;
170
+ }
171
+ if (arg.startsWith("-")) {
172
+ throw new Error(`Unknown argument: ${arg}`);
173
+ }
174
+ if (!projectName) {
175
+ projectName = arg;
176
+ } else {
177
+ throw new Error(`Unexpected extra argument: ${arg}`);
178
+ }
179
+ }
180
+
181
+ return {
182
+ projectName,
183
+ interviewFile,
184
+ nonInteractive,
185
+ skipBrowserOpen,
186
+ showHelp,
187
+ showVersion,
188
+ };
189
+ }
190
+
191
+ function printUsage() {
192
+ console.log(`sentinelayer-cli v${CLI_VERSION}`);
193
+ console.log("");
194
+ console.log("Usage: sl <command> [options]");
195
+ console.log("");
196
+ console.log("Scaffold:");
197
+ console.log(" sl [project-name] Create a new project with SentinelLayer scaffolding");
198
+ console.log(" sl init [project-name] Same as above (interactive or --non-interactive)");
199
+ console.log("");
200
+ console.log("Authentication:");
201
+ console.log(" sl auth login Log in via browser (provisions SentinelLayer + AIdenID)");
202
+ console.log(" sl auth status Show authentication and AIdenID provisioning status");
203
+ console.log(" sl auth sessions List stored session metadata");
204
+ console.log(" sl auth logout Clear local session");
205
+ console.log("");
206
+ console.log("Security & Review:");
207
+ console.log(" sl review scan --path . --json Deterministic code review (full or --mode diff)");
208
+ console.log(" sl /omargate deep --path . --json Local Omar Gate security scan (P0/P1/P2 findings)");
209
+ console.log(" sl scan init Generate .github/workflows/omar-gate.yml from spec");
210
+ console.log(" sl scan setup-secrets --repo <slug> Inject SENTINELAYER_TOKEN into GitHub repo secrets");
211
+ console.log("");
212
+ console.log("Specification & Planning:");
213
+ console.log(" sl spec list-templates List available project templates");
214
+ console.log(" sl spec generate Generate SPEC.md from template or AI");
215
+ console.log(" sl prompt generate Generate agent execution prompt from spec");
216
+ console.log(" sl guide generate Generate BUILD_GUIDE.md from spec");
217
+ console.log(" sl ingest map --json Codebase AST ingest with framework detection");
218
+ console.log("");
219
+ console.log("Audit & Quality:");
220
+ console.log(" sl audit --path . --json Full 15-agent audit swarm");
221
+ console.log(" sl audit frontend --path . --json Jules frontend audit (--stream for NDJSON, --url for runtime)");
222
+ console.log(" sl audit security --path . --json Security-focused audit");
223
+ console.log("");
224
+ console.log("AIdenID (Identity Testing):");
225
+ console.log(" sl ai identity provision --execute Provision ephemeral test email (auto-credentials after login)");
226
+ console.log(" sl ai identity wait-for-otp <id> Poll for OTP extraction from provisioned email");
227
+ console.log(" sl ai identity list List tracked identities");
228
+ console.log(" sl ai identity lineage <id> Show identity parent/child tree");
229
+ console.log(" sl ai identity revoke <id> Revoke a provisioned identity");
230
+ console.log("");
231
+ console.log("Cost & Policy:");
232
+ console.log(" sl cost show --json Show accumulated cost tracking");
233
+ console.log(" sl policy list List available policy packs");
234
+ console.log(" sl policy use <pack> Switch active policy pack");
235
+ console.log("");
236
+ console.log("Advanced:");
237
+ console.log(" sl swarm plan --path . --json Multi-agent swarm planning");
238
+ console.log(" sl mcp list --json List MCP registries and adapters");
239
+ console.log(" sl telemetry show --json Show run event ledger");
240
+ console.log(" sl config list Show current configuration");
241
+ console.log("");
242
+ console.log("Options:");
243
+ console.log(" -h, --help Show this help");
244
+ console.log(" -v, --version Show CLI version");
245
+ console.log(" --json Machine-readable JSON output");
246
+ console.log(" --path PATH Target workspace path");
247
+ console.log(" --non-interactive Disable prompts (require --interview-file)");
248
+ console.log("");
249
+ console.log("Quickstart:");
250
+ console.log(" sl auth login && npx create-sentinelayer my-app && cd my-app");
251
+ console.log(" # Then hand docs/spec.md to your coding agent");
252
+ console.log("");
253
+ console.log("Docs: https://sentinelayer.com/docs");
254
+ }
255
+
256
+ function normalizeInterviewInput(
257
+ raw,
258
+ { argProjectName = "", detectedRepo = "", detectedCodingAgent = DEFAULT_CODING_AGENT_ID } = {}
259
+ ) {
260
+ const obj = raw && typeof raw === "object" ? raw : {};
261
+ const aiProvider = String(obj.aiProvider || "openai").trim().toLowerCase();
262
+ const generationMode = String(obj.generationMode || "detailed").trim().toLowerCase();
263
+ const audienceLevel = String(obj.audienceLevel || "developer").trim().toLowerCase();
264
+ const codingAgentCandidate = String(obj.codingAgent || detectedCodingAgent || DEFAULT_CODING_AGENT_ID)
265
+ .trim()
266
+ .toLowerCase();
267
+ const authMode = String(obj.authMode || "sentinelayer").trim().toLowerCase();
268
+ const explicitRepoSlug = normalizeRepoSlug(obj.repoSlug || "");
269
+ const connectRepo = Boolean(obj.connectRepo) || isValidRepoSlug(explicitRepoSlug);
270
+ const repoSlug = normalizeRepoSlug(obj.repoSlug || detectedRepo || "");
271
+ const buildFromExistingRepo = connectRepo ? Boolean(obj.buildFromExistingRepo) : false;
272
+ const rawProjectType = String(obj.projectType || "")
273
+ .trim()
274
+ .toLowerCase();
275
+ const fallbackProjectType =
276
+ buildFromExistingRepo || (connectRepo && isValidRepoSlug(repoSlug)) || isValidRepoSlug(detectedRepo)
277
+ ? "add_feature"
278
+ : "greenfield";
279
+ const projectType = VALID_PROJECT_TYPES.has(rawProjectType) ? rawProjectType : fallbackProjectType;
280
+ const normalizedAuthMode = VALID_AUTH_MODES.has(authMode) ? authMode : "sentinelayer";
281
+ const derivedProjectName = sanitizeProjectName(obj.projectName || argProjectName) || getRepoNameFromSlug(repoSlug);
282
+ let resolvedCodingAgent;
283
+ try {
284
+ resolvedCodingAgent = resolveCodingAgent(codingAgentCandidate).id;
285
+ } catch {
286
+ resolvedCodingAgent = DEFAULT_CODING_AGENT_ID;
287
+ }
288
+
289
+ const normalized = {
290
+ projectName: derivedProjectName,
291
+ projectDescription: String(obj.projectDescription || "").trim(),
292
+ aiProvider: VALID_AI_PROVIDERS.has(aiProvider) ? aiProvider : "openai",
293
+ generationMode: VALID_GENERATION_MODES.has(generationMode) ? generationMode : "detailed",
294
+ audienceLevel: VALID_AUDIENCE_LEVELS.has(audienceLevel) ? audienceLevel : "developer",
295
+ projectType,
296
+ codingAgent: resolvedCodingAgent,
297
+ techStack: normalizeListInput(obj.techStack),
298
+ features: normalizeListInput(obj.features),
299
+ authMode: normalizedAuthMode,
300
+ connectRepo,
301
+ repoSlug: connectRepo ? repoSlug : "",
302
+ buildFromExistingRepo,
303
+ injectSecret: connectRepo && normalizedAuthMode === "sentinelayer" ? Boolean(obj.injectSecret) : false,
304
+ };
305
+
306
+ return normalized;
307
+ }
308
+
309
+ function validateInterviewInput(interview) {
310
+ if (!interview.projectName) {
311
+ throw new Error("Project name is required.");
312
+ }
313
+ if (String(interview.projectDescription || "").trim().length < 15) {
314
+ throw new Error("Project description must be at least 15 characters.");
315
+ }
316
+ if (!VALID_AI_PROVIDERS.has(interview.aiProvider)) {
317
+ throw new Error("Invalid aiProvider. Use openai, anthropic, or google.");
318
+ }
319
+ if (!VALID_GENERATION_MODES.has(interview.generationMode)) {
320
+ throw new Error("Invalid generationMode. Use detailed, quick, or enterprise.");
321
+ }
322
+ if (!VALID_AUDIENCE_LEVELS.has(interview.audienceLevel)) {
323
+ throw new Error("Invalid audienceLevel. Use developer, intermediate, or beginner.");
324
+ }
325
+ if (!VALID_PROJECT_TYPES.has(interview.projectType)) {
326
+ throw new Error("Invalid projectType. Use greenfield, add_feature, or bugfix.");
327
+ }
328
+ try {
329
+ resolveCodingAgent(interview.codingAgent || DEFAULT_CODING_AGENT_ID);
330
+ } catch {
331
+ throw new Error(
332
+ `Invalid codingAgent. Use one of: ${listSupportedCodingAgents()
333
+ .map((agent) => agent.id)
334
+ .join(", ")}.`
335
+ );
336
+ }
337
+ if (!VALID_AUTH_MODES.has(interview.authMode)) {
338
+ throw new Error("Invalid authMode. Use sentinelayer or byok.");
339
+ }
340
+ if (interview.connectRepo && !isValidRepoSlug(interview.repoSlug)) {
341
+ throw new Error("Invalid repo slug. Expected owner/repo.");
342
+ }
343
+ if (interview.buildFromExistingRepo && !interview.connectRepo) {
344
+ throw new Error("buildFromExistingRepo requires connectRepo=true.");
345
+ }
346
+ if (interview.injectSecret && interview.authMode !== "sentinelayer") {
347
+ throw new Error("injectSecret requires authMode=sentinelayer.");
348
+ }
349
+ }
350
+
351
+ async function loadAutomatedInterview({
352
+ argProjectName,
353
+ detectedRepo,
354
+ detectedCodingAgent,
355
+ interviewFile,
356
+ }) {
357
+ const envPayload = String(process.env.SENTINELAYER_CLI_INTERVIEW_JSON || "").trim();
358
+ let payload = null;
359
+ let source = "";
360
+
361
+ if (interviewFile) {
362
+ const filePath = path.resolve(process.cwd(), interviewFile);
363
+ const fileContents = await fsp.readFile(filePath, "utf-8");
364
+ payload = JSON.parse(fileContents);
365
+ source = `--interview-file (${filePath})`;
366
+ } else if (envPayload) {
367
+ payload = JSON.parse(envPayload);
368
+ source = "SENTINELAYER_CLI_INTERVIEW_JSON";
369
+ }
370
+
371
+ if (payload === null) {
372
+ return null;
373
+ }
374
+
375
+ const normalized = normalizeInterviewInput(payload, {
376
+ argProjectName,
377
+ detectedRepo,
378
+ detectedCodingAgent,
379
+ });
380
+ try {
381
+ validateInterviewInput(normalized);
382
+ } catch (error) {
383
+ const message = error instanceof Error ? error.message : String(error);
384
+ throw new Error(`Invalid interview payload from ${source}: ${message}`);
385
+ }
386
+ return normalized;
387
+ }
388
+
389
+ async function waitForEnter(message) {
390
+ const rl = createInterface({ input, output });
391
+ try {
392
+ await rl.question(`${message}\n`);
393
+ } finally {
394
+ rl.close();
395
+ }
396
+ }
397
+
398
+ function sleep(ms) {
399
+ return new Promise((resolve) => setTimeout(resolve, ms));
400
+ }
401
+
402
+ async function requestJson(url, { method = "GET", headers = {}, body, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
403
+ const controller = new AbortController();
404
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
405
+ try {
406
+ const response = await fetch(url, {
407
+ method,
408
+ headers: {
409
+ "Content-Type": "application/json",
410
+ ...headers,
411
+ },
412
+ body: body === undefined ? undefined : JSON.stringify(body),
413
+ signal: controller.signal,
414
+ });
415
+
416
+ const text = await response.text();
417
+ let payload = null;
418
+ if (text.trim().length > 0) {
419
+ try {
420
+ payload = JSON.parse(text);
421
+ } catch {
422
+ payload = null;
423
+ }
424
+ }
425
+
426
+ if (!response.ok) {
427
+ const errorEnvelope = payload && typeof payload === "object" ? payload.error || null : null;
428
+ const code = errorEnvelope?.code || `HTTP_${response.status}`;
429
+ const message = errorEnvelope?.message || `Request failed (${response.status})`;
430
+ const requestId = errorEnvelope?.request_id || null;
431
+ throw new SentinelayerApiError(message, {
432
+ code,
433
+ status: response.status,
434
+ requestId,
435
+ });
436
+ }
437
+
438
+ if (payload === null) {
439
+ return {};
440
+ }
441
+ return payload;
442
+ } catch (error) {
443
+ if (error instanceof SentinelayerApiError) {
444
+ throw error;
445
+ }
446
+ if (error instanceof Error && error.name === "AbortError") {
447
+ throw new SentinelayerApiError("Sentinelayer request timed out", {
448
+ code: "NETWORK_TIMEOUT",
449
+ status: 504,
450
+ });
451
+ }
452
+ throw new SentinelayerApiError("Unable to reach Sentinelayer API", {
453
+ code: "NETWORK_ERROR",
454
+ status: 503,
455
+ });
456
+ } finally {
457
+ clearTimeout(timeout);
458
+ }
459
+ }
460
+
461
+ function detectIde() {
462
+ return detectIdeFromEnv(process.env).id;
463
+ }
464
+
465
+ async function startCliSession({ apiUrl, challenge, cliVersion }) {
466
+ return requestJson(`${apiUrl}/api/v1/auth/cli/sessions/start`, {
467
+ method: "POST",
468
+ body: {
469
+ challenge,
470
+ ide: detectIde(),
471
+ cli_version: cliVersion,
472
+ },
473
+ });
474
+ }
475
+
476
+ async function pollCliSession({
477
+ apiUrl,
478
+ sessionId,
479
+ challenge,
480
+ pollIntervalSeconds,
481
+ timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
482
+ }) {
483
+ const start = Date.now();
484
+ while (Date.now() - start < timeoutMs) {
485
+ const response = await requestJson(`${apiUrl}/api/v1/auth/cli/sessions/poll`, {
486
+ method: "POST",
487
+ body: {
488
+ session_id: sessionId,
489
+ challenge,
490
+ },
491
+ });
492
+ if (response.status === "approved" && response.auth_token) {
493
+ return response;
494
+ }
495
+ await sleep(Math.max(1, Number(pollIntervalSeconds) || 2) * 1000);
496
+ }
497
+ throw new SentinelayerApiError("CLI authentication timed out. Restart and try again.", {
498
+ code: "CLI_AUTH_TIMEOUT",
499
+ status: 408,
500
+ });
501
+ }
502
+
503
+ async function generateArtifacts({ apiUrl, authToken, payload }) {
504
+ return requestJson(`${apiUrl}/api/v1/builder/generate`, {
505
+ method: "POST",
506
+ headers: {
507
+ Authorization: `Bearer ${authToken}`,
508
+ },
509
+ body: payload,
510
+ timeoutMs: 180_000,
511
+ });
512
+ }
513
+
514
+ async function issueBootstrapToken({ apiUrl, authToken }) {
515
+ return requestJson(`${apiUrl}/api/v1/builder/bootstrap-token`, {
516
+ method: "POST",
517
+ headers: {
518
+ Authorization: `Bearer ${authToken}`,
519
+ },
520
+ });
521
+ }
522
+
523
+ function detectRepoSlug(cwd) {
524
+ const gitRemote = spawnSync("git", ["config", "--get", "remote.origin.url"], {
525
+ cwd,
526
+ encoding: "utf-8",
527
+ });
528
+ if (gitRemote.status !== 0) return null;
529
+ const remote = String(gitRemote.stdout || "").trim();
530
+ if (!remote) return null;
531
+
532
+ const sshMatch = remote.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i);
533
+ if (sshMatch) {
534
+ return `${sshMatch[1]}/${sshMatch[2]}`;
535
+ }
536
+
537
+ const httpsMatch = remote.match(/^https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i);
538
+ if (httpsMatch) {
539
+ return `${httpsMatch[1]}/${httpsMatch[2]}`;
540
+ }
541
+
542
+ const sshUrlMatch = remote.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i);
543
+ if (sshUrlMatch) {
544
+ return `${sshUrlMatch[1]}/${sshUrlMatch[2]}`;
545
+ }
546
+
547
+ return null;
548
+ }
549
+
550
+ function getGhCommand() {
551
+ return String(process.env.SENTINELAYER_GH_BIN || "").trim() || "gh";
552
+ }
553
+
554
+ function getGitCommand() {
555
+ return String(process.env.SENTINELAYER_GIT_BIN || "").trim() || "git";
556
+ }
557
+
558
+ function isGitRepo(cwd) {
559
+ const gitCommand = getGitCommand();
560
+ const probe = spawnSync(gitCommand, ["rev-parse", "--is-inside-work-tree"], {
561
+ cwd,
562
+ encoding: "utf-8",
563
+ });
564
+ return probe.status === 0;
565
+ }
566
+
567
+ function buildGithubCloneUrl(repoSlug) {
568
+ const base = String(DEFAULT_GITHUB_CLONE_BASE_URL || "https://github.com").trim().replace(/\/+$/g, "");
569
+ return `${base}/${normalizeRepoSlug(repoSlug)}.git`;
570
+ }
571
+
572
+ function ensureGhCliAvailable(ghCommand) {
573
+ const ghVersion = spawnSync(ghCommand, ["--version"], { encoding: "utf-8" });
574
+ if (ghVersion.status !== 0) {
575
+ throw new Error("GitHub CLI (gh) is not installed or not in PATH.");
576
+ }
577
+ }
578
+
579
+ function ensureGhAuthSession(ghCommand) {
580
+ ensureGhCliAvailable(ghCommand);
581
+ const status = spawnSync(ghCommand, ["auth", "status", "-h", "github.com"], {
582
+ encoding: "utf-8",
583
+ });
584
+ if (status.status === 0) {
585
+ return;
586
+ }
587
+
588
+ console.log("GitHub authorization required. Opening browser for gh auth login...");
589
+ const login = spawnSync(ghCommand, ["auth", "login", "-h", "github.com", "-s", "repo", "-w"], {
590
+ encoding: "utf-8",
591
+ stdio: "inherit",
592
+ });
593
+ if (login.status !== 0) {
594
+ throw new Error("GitHub authorization failed. Complete gh auth login and retry.");
595
+ }
596
+ }
597
+
598
+ function listReposViaGh(ghCommand) {
599
+ const endpoint = "/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member";
600
+ const apiResult = spawnSync(
601
+ ghCommand,
602
+ ["api", "--paginate", "--slurp", endpoint],
603
+ { encoding: "utf-8" }
604
+ );
605
+ if (apiResult.status !== 0) {
606
+ const fallback = spawnSync(ghCommand, ["api", endpoint], { encoding: "utf-8" });
607
+ if (fallback.status !== 0) {
608
+ throw new Error(
609
+ String(
610
+ fallback.stderr ||
611
+ fallback.stdout ||
612
+ apiResult.stderr ||
613
+ apiResult.stdout ||
614
+ "Unable to fetch repositories with gh api."
615
+ ).trim()
616
+ );
617
+ }
618
+ return parseGhRepoListPayload(String(fallback.stdout || "[]"));
619
+ }
620
+ return parseGhRepoListPayload(String(apiResult.stdout || "[]"));
621
+ }
622
+
623
+ function parseGhRepoListPayload(rawJson) {
624
+ let payload = [];
625
+ try {
626
+ payload = JSON.parse(rawJson);
627
+ } catch {
628
+ throw new Error("GitHub repo list response was not valid JSON.");
629
+ }
630
+ if (!Array.isArray(payload)) {
631
+ throw new Error("GitHub repo list response was not an array.");
632
+ }
633
+
634
+ const flattened = [];
635
+ for (const entry of payload) {
636
+ if (Array.isArray(entry)) {
637
+ flattened.push(...entry);
638
+ } else {
639
+ flattened.push(entry);
640
+ }
641
+ }
642
+
643
+ const seen = new Set();
644
+ const repos = [];
645
+ for (const item of flattened) {
646
+ const slug = normalizeRepoSlug(item && typeof item.full_name === "string" ? item.full_name : "");
647
+ if (!isValidRepoSlug(slug)) continue;
648
+ const key = slug.toLowerCase();
649
+ if (seen.has(key)) continue;
650
+ seen.add(key);
651
+ repos.push({
652
+ slug,
653
+ privateRepo: Boolean(item.private),
654
+ defaultBranch: String(item.default_branch || "").trim() || "main",
655
+ });
656
+ }
657
+ return repos;
658
+ }
659
+
660
+ async function selectRepoSlugFromGithub() {
661
+ const ghCommand = getGhCommand();
662
+ ensureGhAuthSession(ghCommand);
663
+ const repos = listReposViaGh(ghCommand);
664
+ if (repos.length === 0) {
665
+ throw new Error("No accessible GitHub repos found for this account.");
666
+ }
667
+
668
+ const result = await prompts(
669
+ [
670
+ {
671
+ type: "select",
672
+ name: "repoSlug",
673
+ message: "Choose a GitHub repo",
674
+ choices: repos.map((repo) => ({
675
+ title: `${repo.slug}${repo.privateRepo ? " (private)" : ""} [${repo.defaultBranch}]`,
676
+ value: repo.slug,
677
+ })),
678
+ initial: 0,
679
+ },
680
+ ],
681
+ {
682
+ onCancel: () => {
683
+ throw new Error("GitHub repo selection cancelled.");
684
+ },
685
+ }
686
+ );
687
+
688
+ const selected = normalizeRepoSlug(result.repoSlug);
689
+ if (!isValidRepoSlug(selected)) {
690
+ throw new Error("GitHub repo selection returned an invalid repository slug.");
691
+ }
692
+ return selected;
693
+ }
694
+
695
+ async function cloneGithubRepo({ repoSlug, cwd }) {
696
+ const normalizedRepo = normalizeRepoSlug(repoSlug);
697
+ const repoName = getRepoNameFromSlug(normalizedRepo) || "repo";
698
+ const targetDir = path.resolve(cwd, repoName);
699
+ const gitCommand = getGitCommand();
700
+ const cloneUrl = buildGithubCloneUrl(normalizedRepo);
701
+
702
+ if (path.resolve(cwd) === path.resolve(targetDir)) {
703
+ throw new Error("Target clone directory cannot match the current working directory.");
704
+ }
705
+ if (fs.existsSync(targetDir) && !isGitRepo(targetDir)) {
706
+ const entries = await fsp.readdir(targetDir);
707
+ if (entries.length > 0) {
708
+ throw new Error(
709
+ `Cannot clone ${normalizedRepo}: target directory '${repoName}' already exists and is not an empty git repo.`
710
+ );
711
+ }
712
+ }
713
+ if (isGitRepo(targetDir)) {
714
+ const localSlug = normalizeRepoSlug(detectRepoSlug(targetDir) || "");
715
+ if (!localSlug) {
716
+ throw new Error(
717
+ `Directory '${repoName}' already contains a git repo without a detectable GitHub origin. Refusing to overwrite it.`
718
+ );
719
+ }
720
+ if (localSlug && localSlug.toLowerCase() !== normalizedRepo.toLowerCase()) {
721
+ throw new Error(
722
+ `Directory '${repoName}' already contains a different repo (${localSlug}). Choose another project name or folder.`
723
+ );
724
+ }
725
+ return {
726
+ projectDir: targetDir,
727
+ cloneUrl,
728
+ cloned: false,
729
+ };
730
+ }
731
+
732
+ const cloneResult = spawnSync(gitCommand, ["clone", "--depth", "1", cloneUrl, targetDir], {
733
+ cwd,
734
+ encoding: "utf-8",
735
+ });
736
+ if (cloneResult.status !== 0) {
737
+ throw new Error(String(cloneResult.stderr || cloneResult.stdout || "git clone failed").trim());
738
+ }
739
+ return {
740
+ projectDir: targetDir,
741
+ cloneUrl,
742
+ cloned: true,
743
+ };
744
+ }
745
+
746
+ async function ensureGitRepositorySetup({ projectDir, repoSlug }) {
747
+ const gitCommand = getGitCommand();
748
+ if (!isGitRepo(projectDir)) {
749
+ const initResult = spawnSync(gitCommand, ["init"], {
750
+ cwd: projectDir,
751
+ encoding: "utf-8",
752
+ });
753
+ if (initResult.status !== 0) {
754
+ throw new Error(String(initResult.stderr || initResult.stdout || "git init failed").trim());
755
+ }
756
+ }
757
+
758
+ const normalizedRepo = normalizeRepoSlug(repoSlug);
759
+ if (!isValidRepoSlug(normalizedRepo)) return;
760
+
761
+ const remoteGet = spawnSync(gitCommand, ["config", "--get", "remote.origin.url"], {
762
+ cwd: projectDir,
763
+ encoding: "utf-8",
764
+ });
765
+ const remote = String(remoteGet.stdout || "").trim();
766
+ if (remote) return;
767
+
768
+ const remoteUrl = buildGithubCloneUrl(normalizedRepo);
769
+ const remoteAdd = spawnSync(gitCommand, ["remote", "add", "origin", remoteUrl], {
770
+ cwd: projectDir,
771
+ encoding: "utf-8",
772
+ });
773
+ if (remoteAdd.status !== 0) {
774
+ throw new Error(String(remoteAdd.stderr || remoteAdd.stdout || "git remote add failed").trim());
775
+ }
776
+ }
777
+
778
+ async function buildRepoIngestSummary(projectDir) {
779
+ try {
780
+ const ingest = await collectCodebaseIngest({ rootPath: projectDir });
781
+ return formatIngestSummary(ingest);
782
+ } catch {
783
+ return "";
784
+ }
785
+ }
786
+
787
+ function formatTimestampForFile() {
788
+ const now = new Date();
789
+ const pad = (value) => String(value).padStart(2, "0");
790
+ return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(
791
+ now.getUTCHours()
792
+ )}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`;
793
+ }
794
+
795
+ function getCommandOptionValue(args, optionName) {
796
+ const index = args.findIndex((arg) => String(arg || "").trim() === optionName);
797
+ if (index < 0) return "";
798
+ const next = String(args[index + 1] || "").trim();
799
+ if (!next || next.startsWith("-")) {
800
+ throw new Error(`Missing value for ${optionName}`);
801
+ }
802
+ return next;
803
+ }
804
+
805
+ function hasCommandOption(args, optionName) {
806
+ return args.some((arg) => String(arg || "").trim() === optionName);
807
+ }
808
+
809
+ async function collectScanFiles(rootPath) {
810
+ const files = [];
811
+ const stack = [rootPath];
812
+ const ignoredDirs = new Set([".git", "node_modules", ".venv", ".next", "dist", "build", ".sentinelayer"]);
813
+ const maxFileSizeBytes = 512 * 1024;
814
+
815
+ while (stack.length > 0) {
816
+ const current = stack.pop();
817
+ if (!current) continue;
818
+ let entries = [];
819
+ try {
820
+ entries = await fsp.readdir(current, { withFileTypes: true });
821
+ } catch {
822
+ continue;
823
+ }
824
+ for (const entry of entries) {
825
+ const fullPath = path.join(current, entry.name);
826
+ if (entry.isDirectory()) {
827
+ if (ignoredDirs.has(entry.name)) continue;
828
+ stack.push(fullPath);
829
+ continue;
830
+ }
831
+ if (!entry.isFile()) continue;
832
+ try {
833
+ const stat = await fsp.stat(fullPath);
834
+ if (stat.size > maxFileSizeBytes) continue;
835
+ } catch {
836
+ continue;
837
+ }
838
+ files.push(fullPath);
839
+ }
840
+ }
841
+ return files;
842
+ }
843
+
844
+ async function runCredentialScan(targetPath) {
845
+ const testOrFixturePathPattern = /(?:^|[\\/])(?:test|tests|__tests__|fixtures?)(?:[\\/]|$)/i;
846
+ const localReviewSourcePathPattern = /(?:^|[\\/])src[\\/]review[\\/]local-review\.js$/i;
847
+ const workItemExcludePathPattern = new RegExp(
848
+ `${testOrFixturePathPattern.source}|${localReviewSourcePathPattern.source}`,
849
+ "i"
850
+ );
851
+ const rules = [
852
+ {
853
+ severity: "P1",
854
+ message: "Possible AWS access key detected.",
855
+ regex: /AKIA[0-9A-Z]{16}/,
856
+ },
857
+ {
858
+ severity: "P1",
859
+ message: "Possible private key material detected.",
860
+ regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
861
+ },
862
+ {
863
+ severity: "P1",
864
+ message: "Possible provider API key detected.",
865
+ regex: /\b(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,})\b/,
866
+ },
867
+ {
868
+ severity: "P2",
869
+ message: "Possible hardcoded credential literal.",
870
+ regex: /(api[_-]?key|secret|token)\s*[:=]\s*['"][^'"]{20,}['"]/i,
871
+ excludePathPattern: testOrFixturePathPattern,
872
+ },
873
+ {
874
+ severity: "P2",
875
+ message: "Work-item marker found.",
876
+ regex: /\b(?:\x54\x4f\x44\x4f|\x46\x49\x58\x4d\x45|\x48\x41\x43\x4b)\b/,
877
+ excludePathPattern: workItemExcludePathPattern,
878
+ },
879
+ ];
880
+
881
+ const files = await collectScanFiles(targetPath);
882
+ const findings = [];
883
+ const maxFindings = 200;
884
+
885
+ for (const filePath of files) {
886
+ const relativePath = path.relative(targetPath, filePath).replace(/\\/g, "/");
887
+ let text = "";
888
+ try {
889
+ text = await fsp.readFile(filePath, "utf-8");
890
+ } catch {
891
+ continue;
892
+ }
893
+ const lines = text.split(/\r?\n/);
894
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
895
+ const line = lines[lineIndex];
896
+ if (!line) continue;
897
+ if (line.includes("<your-token>") || line.includes("example")) continue;
898
+ for (const rule of rules) {
899
+ if (rule.excludePathPattern && rule.excludePathPattern.test(relativePath)) continue;
900
+ if (!rule.regex.test(line)) continue;
901
+ findings.push({
902
+ severity: rule.severity,
903
+ file: relativePath,
904
+ line: lineIndex + 1,
905
+ message: rule.message,
906
+ excerpt: line.trim().slice(0, 180),
907
+ });
908
+ if (findings.length >= maxFindings) break;
909
+ }
910
+ if (findings.length >= maxFindings) break;
911
+ }
912
+ if (findings.length >= maxFindings) break;
913
+ }
914
+
915
+ const p1 = findings.filter((item) => item.severity === "P1").length;
916
+ const p2 = findings.filter((item) => item.severity === "P2").length;
917
+
918
+ return {
919
+ scannedFiles: files.length,
920
+ findings,
921
+ p1,
922
+ p2,
923
+ };
924
+ }
925
+
926
+ async function writeLocalCommandReport(targetPath, prefix, body, { outputDir = "" } = {}) {
927
+ const outputRoot = await resolveOutputRoot({
928
+ cwd: targetPath,
929
+ outputDirOverride: outputDir,
930
+ });
931
+ const reportDir = path.join(outputRoot, "reports");
932
+ await ensureDirectory(reportDir);
933
+ const reportPath = path.join(reportDir, `${prefix}-${formatTimestampForFile()}.md`);
934
+ await writeTextFile(reportPath, `${body}\n`);
935
+ return reportPath;
936
+ }
937
+
938
+ function formatFindingsMarkdown(findings) {
939
+ if (!findings.length) return "- none";
940
+ return findings
941
+ .map((item, index) => `${index + 1}. [${item.severity}] ${item.file}:${item.line} - ${item.message}`)
942
+ .join("\n");
943
+ }
944
+
945
+ async function runLocalOmarGateCommand(args) {
946
+ const mode = String(args[0] || "").trim().toLowerCase();
947
+ if (mode && mode !== "deep") {
948
+ throw new Error(`Unsupported /omargate mode '${mode}'. Use: /omargate deep`);
949
+ }
950
+ const asJson = hasCommandOption(args, "--json");
951
+ const pathArg = getCommandOptionValue(args, "--path") || ".";
952
+ const outputDirArg = getCommandOptionValue(args, "--output-dir") || "";
953
+ const targetPath = path.resolve(process.cwd(), pathArg);
954
+ if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
955
+ throw new Error(`Invalid --path target: ${targetPath}`);
956
+ }
957
+
958
+ if (!asJson) {
959
+ printSection("Local Omar Gate Deep");
960
+ printInfo(`Target: ${targetPath}`);
961
+ }
962
+
963
+ const scan = await runCredentialScan(targetPath);
964
+ const report = `# Local Omar Gate Deep Scan
965
+
966
+ Generated: ${nowIso()}
967
+ Target: ${targetPath}
968
+
969
+ Summary:
970
+ - Files scanned: ${scan.scannedFiles}
971
+ - P1 findings: ${scan.p1}
972
+ - P2 findings: ${scan.p2}
973
+
974
+ Findings:
975
+ ${formatFindingsMarkdown(scan.findings)}
976
+ `;
977
+
978
+ const reportPath = await writeLocalCommandReport(targetPath, "omargate-deep", report, {
979
+ outputDir: outputDirArg,
980
+ });
981
+ if (asJson) {
982
+ console.log(
983
+ JSON.stringify(
984
+ {
985
+ command: "/omargate deep",
986
+ targetPath,
987
+ reportPath,
988
+ scannedFiles: scan.scannedFiles,
989
+ p1: scan.p1,
990
+ p2: scan.p2,
991
+ blocking: scan.p1 > 0,
992
+ },
993
+ null,
994
+ 2
995
+ )
996
+ );
997
+ } else {
998
+ console.log(pc.cyan(`Report: ${reportPath}`));
999
+ console.log(`P1 findings: ${scan.p1}`);
1000
+ console.log(`P2 findings: ${scan.p2}`);
1001
+ }
1002
+
1003
+ if (scan.p1 > 0) {
1004
+ if (!asJson) {
1005
+ console.log(pc.red("Blocking findings detected (P1 > 0)."));
1006
+ }
1007
+ return 2;
1008
+ }
1009
+ return 0;
1010
+ }
1011
+
1012
+ async function runLocalAuditCommand(args) {
1013
+ const asJson = hasCommandOption(args, "--json");
1014
+ const pathArg = getCommandOptionValue(args, "--path") || ".";
1015
+ const outputDirArg = getCommandOptionValue(args, "--output-dir") || "";
1016
+ const targetPath = path.resolve(process.cwd(), pathArg);
1017
+ if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
1018
+ throw new Error(`Invalid --path target: ${targetPath}`);
1019
+ }
1020
+
1021
+ if (!asJson) {
1022
+ printSection("Local Audit");
1023
+ printInfo(`Target: ${targetPath}`);
1024
+ }
1025
+
1026
+ const requiredChecks = [
1027
+ {
1028
+ key: ".github/workflows/omar-gate.yml",
1029
+ severity: "P1",
1030
+ ok: fs.existsSync(path.join(targetPath, ".github", "workflows", "omar-gate.yml")),
1031
+ message: "Omar workflow is present.",
1032
+ },
1033
+ {
1034
+ key: "docs/spec.md",
1035
+ severity: "P2",
1036
+ ok: fs.existsSync(path.join(targetPath, "docs", "spec.md")),
1037
+ message: "Spec doc is present.",
1038
+ },
1039
+ {
1040
+ key: "tasks/todo.md",
1041
+ severity: "P2",
1042
+ ok: fs.existsSync(path.join(targetPath, "tasks", "todo.md")),
1043
+ message: "Todo plan is present.",
1044
+ },
1045
+ ];
1046
+
1047
+ const scan = await runCredentialScan(targetPath);
1048
+ const failedP1Checks = requiredChecks.filter((item) => !item.ok && item.severity === "P1").length;
1049
+ const failedP2Checks = requiredChecks.filter((item) => !item.ok && item.severity === "P2").length;
1050
+ const totalP1 = scan.p1 + failedP1Checks;
1051
+ const totalP2 = scan.p2 + failedP2Checks;
1052
+ const overallStatus = totalP1 > 0 ? "FAIL" : "PASS";
1053
+
1054
+ const checkText = requiredChecks
1055
+ .map(
1056
+ (item) =>
1057
+ `- [${item.ok ? "x" : " "}] (${item.severity}) ${item.key} :: ${item.message}${item.ok ? "" : " [missing]"}`
1058
+ )
1059
+ .join("\n");
1060
+ const report = `# Local Sentinelayer Audit
1061
+
1062
+ Generated: ${nowIso()}
1063
+ Target: ${targetPath}
1064
+ Overall status: ${overallStatus}
1065
+
1066
+ Readiness checks:
1067
+ ${checkText}
1068
+
1069
+ Scan summary:
1070
+ - Files scanned: ${scan.scannedFiles}
1071
+ - P1 findings: ${scan.p1}
1072
+ - P2 findings: ${scan.p2}
1073
+
1074
+ Findings:
1075
+ ${formatFindingsMarkdown(scan.findings)}
1076
+ `;
1077
+
1078
+ const reportPath = await writeLocalCommandReport(targetPath, "audit", report, {
1079
+ outputDir: outputDirArg,
1080
+ });
1081
+ if (asJson) {
1082
+ console.log(
1083
+ JSON.stringify(
1084
+ {
1085
+ command: "/audit",
1086
+ targetPath,
1087
+ reportPath,
1088
+ overallStatus,
1089
+ scannedFiles: scan.scannedFiles,
1090
+ p1: scan.p1,
1091
+ p2: scan.p2,
1092
+ p1Total: totalP1,
1093
+ p2Total: totalP2,
1094
+ blocking: totalP1 > 0,
1095
+ },
1096
+ null,
1097
+ 2
1098
+ )
1099
+ );
1100
+ } else {
1101
+ console.log(pc.cyan(`Report: ${reportPath}`));
1102
+ console.log(`Overall status: ${overallStatus}`);
1103
+ console.log(`P1 total: ${totalP1}`);
1104
+ console.log(`P2 total: ${totalP2}`);
1105
+ }
1106
+
1107
+ if (totalP1 > 0) {
1108
+ if (!asJson) {
1109
+ console.log(pc.red("Audit failed due to blocking findings (P1 > 0)."));
1110
+ }
1111
+ return 2;
1112
+ }
1113
+ return 0;
1114
+ }
1115
+
1116
+ async function runLocalPersonaCommand(args) {
1117
+ const subcommand = String(args[0] || "").trim().toLowerCase();
1118
+ const optionArgs = subcommand === "orchestrator" ? args.slice(1) : args;
1119
+ if (subcommand && subcommand !== "orchestrator") {
1120
+ throw new Error(`Unsupported /persona subcommand '${subcommand}'. Use: /persona orchestrator --mode <mode>`);
1121
+ }
1122
+ const asJson = hasCommandOption(optionArgs, "--json");
1123
+
1124
+ const mode = String(getCommandOptionValue(optionArgs, "--mode") || "builder").trim().toLowerCase();
1125
+ const validModes = new Set(["builder", "reviewer", "hardener"]);
1126
+ if (!validModes.has(mode)) {
1127
+ throw new Error("Invalid --mode for /persona. Use builder, reviewer, or hardener.");
1128
+ }
1129
+
1130
+ const pathArg = getCommandOptionValue(optionArgs, "--path") || ".";
1131
+ const outputDirArg = getCommandOptionValue(optionArgs, "--output-dir") || "";
1132
+ const targetPath = path.resolve(process.cwd(), pathArg);
1133
+ if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
1134
+ throw new Error(`Invalid --path target: ${targetPath}`);
1135
+ }
1136
+
1137
+ if (!asJson) {
1138
+ printSection("Persona Orchestrator");
1139
+ printInfo(`Mode: ${mode}`);
1140
+ printInfo(`Target: ${targetPath}`);
1141
+ }
1142
+
1143
+ const modeInstructions = {
1144
+ builder: [
1145
+ "Prioritize implementation throughput and deterministic delivery.",
1146
+ "Keep PR scope tight and finish one batch before opening the next.",
1147
+ "Use Omar loop after each PR and fix all P0/P1 before merge.",
1148
+ ],
1149
+ reviewer: [
1150
+ "Prioritize risk discovery, regressions, and missing tests.",
1151
+ "Focus findings-first output ordered by severity.",
1152
+ "Escalate architecture/security concerns before code changes.",
1153
+ ],
1154
+ hardener: [
1155
+ "Prioritize security posture, policy controls, and failure modes.",
1156
+ "Add guardrails for auth, secrets handling, and CI enforceability.",
1157
+ "Treat P2 debt as merge-blocking unless explicitly waived.",
1158
+ ],
1159
+ };
1160
+
1161
+ const ingest = await buildRepoIngestSummary(targetPath);
1162
+ const report = `# Persona Orchestrator Plan
1163
+
1164
+ Generated: ${nowIso()}
1165
+ Target: ${targetPath}
1166
+ Mode: ${mode}
1167
+
1168
+ Instructions:
1169
+ ${modeInstructions[mode].map((line, index) => `${index + 1}. ${line}`).join("\n")}
1170
+
1171
+ Repo summary:
1172
+ ${ingest || "No repository summary available."}
1173
+ `;
1174
+
1175
+ const reportPath = await writeLocalCommandReport(targetPath, `persona-orchestrator-${mode}`, report, {
1176
+ outputDir: outputDirArg,
1177
+ });
1178
+ if (asJson) {
1179
+ console.log(
1180
+ JSON.stringify(
1181
+ {
1182
+ command: "/persona orchestrator",
1183
+ mode,
1184
+ targetPath,
1185
+ reportPath,
1186
+ },
1187
+ null,
1188
+ 2
1189
+ )
1190
+ );
1191
+ } else {
1192
+ console.log(pc.cyan(`Report: ${reportPath}`));
1193
+ }
1194
+ return 0;
1195
+ }
1196
+
1197
+ function parseTodoPlanTasks(content) {
1198
+ const tasks = [];
1199
+ const lines = String(content || "").split(/\r?\n/);
1200
+ for (const line of lines) {
1201
+ const unchecked = line.match(/^\s*-\s*\[\s\]\s+(.+)\s*$/);
1202
+ if (unchecked) {
1203
+ tasks.push(unchecked[1].trim());
1204
+ continue;
1205
+ }
1206
+ const ordered = line.match(/^\s*\d+\.\s+(.+)\s*$/);
1207
+ if (ordered) {
1208
+ tasks.push(ordered[1].trim());
1209
+ }
1210
+ }
1211
+ return tasks.filter(Boolean);
1212
+ }
1213
+
1214
+ async function runLocalApplyCommand(args) {
1215
+ const asJson = hasCommandOption(args, "--json");
1216
+ const pathArg = getCommandOptionValue(args, "--path") || ".";
1217
+ const outputDirArg = getCommandOptionValue(args, "--output-dir") || "";
1218
+ const targetPath = path.resolve(process.cwd(), pathArg);
1219
+ if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
1220
+ throw new Error(`Invalid --path target: ${targetPath}`);
1221
+ }
1222
+
1223
+ const planArg = getCommandOptionValue(args, "--plan") || "tasks/todo.md";
1224
+ const planPath = path.resolve(targetPath, planArg);
1225
+ if (!fs.existsSync(planPath)) {
1226
+ throw new Error(`Plan file not found: ${planPath}`);
1227
+ }
1228
+
1229
+ if (!asJson) {
1230
+ printSection("Apply Plan");
1231
+ printInfo(`Target: ${targetPath}`);
1232
+ printInfo(`Plan: ${planPath}`);
1233
+ }
1234
+
1235
+ const planText = await fsp.readFile(planPath, "utf-8");
1236
+ const tasks = parseTodoPlanTasks(planText);
1237
+ if (!tasks.length) {
1238
+ throw new Error("No executable checklist items were found in the plan file.");
1239
+ }
1240
+
1241
+ const report = `# Apply Plan Preview
1242
+
1243
+ Generated: ${nowIso()}
1244
+ Target: ${targetPath}
1245
+ Plan: ${planPath}
1246
+
1247
+ Execution order:
1248
+ ${tasks.map((task, index) => `${index + 1}. ${task}`).join("\n")}
1249
+
1250
+ Next action:
1251
+ - Execute each item PR-by-PR and run Omar loop before every merge.
1252
+ `;
1253
+
1254
+ const reportPath = await writeLocalCommandReport(targetPath, "apply-plan", report, {
1255
+ outputDir: outputDirArg,
1256
+ });
1257
+ if (asJson) {
1258
+ console.log(
1259
+ JSON.stringify(
1260
+ {
1261
+ command: "/apply",
1262
+ targetPath,
1263
+ planPath,
1264
+ reportPath,
1265
+ taskCount: tasks.length,
1266
+ },
1267
+ null,
1268
+ 2
1269
+ )
1270
+ );
1271
+ } else {
1272
+ console.log(pc.cyan(`Report: ${reportPath}`));
1273
+ console.log(`Parsed tasks: ${tasks.length}`);
1274
+ }
1275
+ return 0;
1276
+ }
1277
+
1278
+ async function tryRunLocalCommandMode(argv) {
1279
+ const command = String(argv[0] || "").trim().toLowerCase();
1280
+ if (command !== "/omargate" && command !== "/audit" && command !== "/persona" && command !== "/apply") {
1281
+ return null;
1282
+ }
1283
+ const args = argv.slice(1);
1284
+ if (command === "/omargate") {
1285
+ return runLocalOmarGateCommand(args);
1286
+ }
1287
+ if (command === "/audit") {
1288
+ return runLocalAuditCommand(args);
1289
+ }
1290
+ if (command === "/persona") {
1291
+ return runLocalPersonaCommand(args);
1292
+ }
1293
+ return runLocalApplyCommand(args);
1294
+ }
1295
+
1296
+ async function resolveProjectDirectory({ cwd, interview, detectedRepo }) {
1297
+ const normalizedTargetRepo = normalizeRepoSlug(interview.repoSlug);
1298
+ const normalizedDetected = normalizeRepoSlug(detectedRepo || "");
1299
+
1300
+ if (interview.connectRepo && interview.buildFromExistingRepo && isValidRepoSlug(normalizedTargetRepo)) {
1301
+ if (normalizedDetected && normalizedDetected.toLowerCase() === normalizedTargetRepo.toLowerCase()) {
1302
+ return {
1303
+ projectDir: cwd,
1304
+ clonedRepo: false,
1305
+ reusedCurrentRepo: true,
1306
+ };
1307
+ }
1308
+ const cloned = await cloneGithubRepo({
1309
+ repoSlug: normalizedTargetRepo,
1310
+ cwd,
1311
+ });
1312
+ return {
1313
+ projectDir: cloned.projectDir,
1314
+ clonedRepo: cloned.cloned,
1315
+ reusedCurrentRepo: false,
1316
+ cloneUrl: cloned.cloneUrl,
1317
+ };
1318
+ }
1319
+
1320
+ return {
1321
+ projectDir: path.resolve(cwd, interview.projectName),
1322
+ clonedRepo: false,
1323
+ reusedCurrentRepo: false,
1324
+ };
1325
+ }
1326
+
1327
+ async function ensureDirectory(targetPath) {
1328
+ await fsp.mkdir(targetPath, { recursive: true });
1329
+ }
1330
+
1331
+ async function writeTextFile(filePath, content) {
1332
+ await ensureDirectory(path.dirname(filePath));
1333
+ await fsp.writeFile(filePath, content, "utf-8");
1334
+ }
1335
+
1336
+ async function upsertEnvVariable(filePath, key, value) {
1337
+ let existing = "";
1338
+ if (fs.existsSync(filePath)) {
1339
+ existing = await fsp.readFile(filePath, "utf-8");
1340
+ }
1341
+ const line = `${key}=${value}`;
1342
+ const regex = new RegExp(`^${key}=.*$`, "m");
1343
+ let next;
1344
+ if (regex.test(existing)) {
1345
+ next = existing.replace(regex, line);
1346
+ } else if (existing.trim().length === 0) {
1347
+ next = `${line}\n`;
1348
+ } else if (existing.endsWith("\n")) {
1349
+ next = `${existing}${line}\n`;
1350
+ } else {
1351
+ next = `${existing}\n${line}\n`;
1352
+ }
1353
+ await writeTextFile(filePath, next);
1354
+ }
1355
+
1356
+ async function ensureEnvFileIgnored(projectDir) {
1357
+ const gitignorePath = path.join(projectDir, ".gitignore");
1358
+ let existing = "";
1359
+ if (fs.existsSync(gitignorePath)) {
1360
+ existing = await fsp.readFile(gitignorePath, "utf-8");
1361
+ }
1362
+
1363
+ const lines = existing.split(/\r?\n/);
1364
+ const hasEntry = lines.some((line) => {
1365
+ const normalized = String(line || "").trim();
1366
+ return normalized === ".env" || normalized === "/.env";
1367
+ });
1368
+ if (hasEntry) {
1369
+ return;
1370
+ }
1371
+
1372
+ const envEntry = ".env";
1373
+ let next = "";
1374
+ if (existing.trim().length === 0) {
1375
+ next = `${envEntry}\n`;
1376
+ } else if (existing.endsWith("\n")) {
1377
+ next = `${existing}${envEntry}\n`;
1378
+ } else {
1379
+ next = `${existing}\n${envEntry}\n`;
1380
+ }
1381
+ await writeTextFile(gitignorePath, next);
1382
+ }
1383
+
1384
+ async function ensureSentinelStartScript(projectDir, projectName) {
1385
+ const packagePath = path.join(projectDir, "package.json");
1386
+ const fallback = {
1387
+ name: sanitizeProjectName(projectName) || "sentinelayer-project",
1388
+ version: "0.1.0",
1389
+ private: true,
1390
+ scripts: {},
1391
+ };
1392
+ let payload = fallback;
1393
+ if (fs.existsSync(packagePath)) {
1394
+ try {
1395
+ const parsed = JSON.parse(await fsp.readFile(packagePath, "utf-8"));
1396
+ if (parsed && typeof parsed === "object") {
1397
+ payload = parsed;
1398
+ }
1399
+ } catch {
1400
+ payload = fallback;
1401
+ }
1402
+ }
1403
+ payload.scripts = payload.scripts && typeof payload.scripts === "object" ? payload.scripts : {};
1404
+ payload.scripts["sentinel:start"] =
1405
+ payload.scripts["sentinel:start"] ||
1406
+ "echo \"Sentinelayer artifacts are ready. Open AGENT_HANDOFF_PROMPT.md and start your coding agent.\"";
1407
+ const scriptDefaults = {
1408
+ "sentinel:omargate": "npx sentinelayer-cli@latest /omargate deep --path .",
1409
+ "sentinel:omargate:json": "npx sentinelayer-cli@latest /omargate deep --path . --json",
1410
+ "sentinel:audit": "npx sentinelayer-cli@latest /audit --path .",
1411
+ "sentinel:audit:json": "npx sentinelayer-cli@latest /audit --path . --json",
1412
+ "sentinel:persona:builder":
1413
+ "npx sentinelayer-cli@latest /persona orchestrator --mode builder --path .",
1414
+ "sentinel:persona:reviewer":
1415
+ "npx sentinelayer-cli@latest /persona orchestrator --mode reviewer --path .",
1416
+ "sentinel:persona:hardener":
1417
+ "npx sentinelayer-cli@latest /persona orchestrator --mode hardener --path .",
1418
+ "sentinel:apply": "npx sentinelayer-cli@latest /apply --plan tasks/todo.md --path .",
1419
+ };
1420
+ for (const [name, command] of Object.entries(scriptDefaults)) {
1421
+ if (!payload.scripts[name]) {
1422
+ payload.scripts[name] = command;
1423
+ }
1424
+ }
1425
+ await writeTextFile(packagePath, `${JSON.stringify(payload, null, 2)}\n`);
1426
+ }
1427
+
1428
+ function buildCodingAgentConfigTemplate({ agentProfile, projectName }) {
1429
+ const projectLabel = String(projectName || "sentinelayer-project").trim() || "sentinelayer-project";
1430
+ const commonChecklist = [
1431
+ "Read docs/spec.md, docs/build-guide.md, tasks/todo.md, and AGENT_HANDOFF_PROMPT.md in order.",
1432
+ "Work one PR scope at a time and keep changes deterministic.",
1433
+ "Run local checks before push: /omargate deep and /audit.",
1434
+ ];
1435
+
1436
+ if (agentProfile.id === "aider") {
1437
+ return `model: gpt-5.3-codex
1438
+ read:
1439
+ - docs/spec.md
1440
+ - docs/build-guide.md
1441
+ - tasks/todo.md
1442
+ - AGENT_HANDOFF_PROMPT.md
1443
+ notes:
1444
+ - ${commonChecklist.join("\n - ")}
1445
+ `;
1446
+ }
1447
+
1448
+ if (agentProfile.id === "continue" || agentProfile.id === "cody") {
1449
+ return `${JSON.stringify(
1450
+ {
1451
+ profile: "sentinelayer",
1452
+ project: projectLabel,
1453
+ promptTarget: agentProfile.promptTarget,
1454
+ instructions: commonChecklist,
1455
+ },
1456
+ null,
1457
+ 2
1458
+ )}\n`;
1459
+ }
1460
+
1461
+ const markdownBody = [
1462
+ `# Sentinelayer ${agentProfile.name} Profile`,
1463
+ "",
1464
+ `Project: ${projectLabel}`,
1465
+ `Prompt target: ${agentProfile.promptTarget}`,
1466
+ "",
1467
+ "Rules:",
1468
+ ...commonChecklist.map((item) => `- ${item}`),
1469
+ "",
1470
+ ].join("\n");
1471
+
1472
+ return `${markdownBody}\n`;
1473
+ }
1474
+
1475
+ async function ensureCodingAgentConfigFile({ projectDir, projectName, codingAgent }) {
1476
+ const agentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
1477
+ if (!agentProfile.configFile) {
1478
+ return {
1479
+ created: false,
1480
+ path: "",
1481
+ agent: agentProfile,
1482
+ };
1483
+ }
1484
+
1485
+ const configPath = path.join(projectDir, agentProfile.configFile);
1486
+ if (fs.existsSync(configPath)) {
1487
+ return {
1488
+ created: false,
1489
+ path: configPath,
1490
+ agent: agentProfile,
1491
+ };
1492
+ }
1493
+
1494
+ const configContent = buildCodingAgentConfigTemplate({
1495
+ agentProfile,
1496
+ projectName,
1497
+ });
1498
+ await writeTextFile(configPath, configContent);
1499
+ return {
1500
+ created: true,
1501
+ path: configPath,
1502
+ agent: agentProfile,
1503
+ };
1504
+ }
1505
+
1506
+ function buildTodoContent({
1507
+ projectName,
1508
+ aiProvider,
1509
+ codingAgent,
1510
+ authMode,
1511
+ repoSlug,
1512
+ buildFromExistingRepo,
1513
+ generationMode,
1514
+ audienceLevel,
1515
+ projectType,
1516
+ }) {
1517
+ const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
1518
+ return `# Sentinelayer Autonomous Build Plan
1519
+
1520
+ Generated: ${nowIso()}
1521
+ Project: ${projectName}
1522
+
1523
+ ## Inputs
1524
+ - AI provider: \`${aiProvider}\`
1525
+ - Coding agent: \`${codingAgentProfile.name} (${codingAgentProfile.id})\`
1526
+ - Auth mode: \`${authMode}\`
1527
+ - Generation mode: \`${generationMode}\`
1528
+ - Audience level: \`${audienceLevel}\`
1529
+ - Project type: \`${projectType}\`
1530
+ - Repo: \`${repoSlug || "not connected"}\`
1531
+ - Workspace mode: \`${buildFromExistingRepo ? "existing repo clone" : "new scaffold"}\`
1532
+
1533
+ ## Execution Checklist
1534
+ - [ ] PR 1: repository bootstrap, CI checks, and deterministic scaffolding baseline
1535
+ - [ ] PR 2: domain model + migrations + persistence abstraction
1536
+ - [ ] PR 3: API contracts + auth/session lifecycle hardening
1537
+ - [ ] PR 4: existing-codebase ingest path and repo context extraction
1538
+ - [ ] PR 5: build planner generation quality and prompt artifact validation
1539
+ - [ ] PR 6: workflow orchestration integration with Omar Gate policy defaults
1540
+ - [ ] PR 7: local scan command runner (\`sentinel /omargate deep\`) MVP
1541
+ - [ ] PR 8: local audit command runner (\`sentinel /audit\`) MVP
1542
+ - [ ] PR 9: persona orchestrator command router + policy templates
1543
+ - [ ] PR 10: scale/performance tuning and caching strategy
1544
+ - [ ] PR 11: observability, retries, timeout policies, and structured logs
1545
+ - [ ] PR 12: docs, release, rollout safety checks, and production readiness
1546
+
1547
+ ## Omar Loop Contract (Per PR)
1548
+ - [ ] Run Omar Gate for the PR.
1549
+ - [ ] Fix all P0 and P1 findings.
1550
+ - [ ] Fix P2 findings before merge when feasible.
1551
+ - [ ] Re-run gate and confirm clean status.
1552
+ - [ ] Merge only after quality gates are green.
1553
+
1554
+ ## Command Roadmap (Local Terminal)
1555
+ - [ ] \`sentinel /omargate deep --path <repo>\`: local deep scan pipeline
1556
+ - [ ] \`sentinel /audit --path <repo>\`: security + quality audit summary
1557
+ - [ ] \`sentinel /persona orchestrator --mode <builder|reviewer|hardener>\`: agent persona routing
1558
+ - [ ] \`sentinel /apply --plan tasks/todo.md\`: execute roadmap batches autonomously
1559
+
1560
+ ## Required Read Order
1561
+ 1. \`docs/spec.md\`
1562
+ 2. \`docs/build-guide.md\`
1563
+ 3. \`prompts/execution-prompt.md\`
1564
+ 4. \`.github/workflows/omar-gate.yml\`
1565
+ 5. \`AGENT_HANDOFF_PROMPT.md\`
1566
+ `;
1567
+ }
1568
+
1569
+ function buildAgentPromptGuidance(promptTarget) {
1570
+ const normalized = String(promptTarget || "generic").trim().toLowerCase();
1571
+ if (normalized === "claude") {
1572
+ return `- Use explicit plan -> implement -> verify loops.
1573
+ - Keep deterministic checks first, then optional AI steps.
1574
+ - Capture concrete evidence per PR before handoff.`;
1575
+ }
1576
+ if (normalized === "cursor") {
1577
+ return `- Keep edits small and keep scope to one PR id.
1578
+ - Run local verification before each push.
1579
+ - Keep repository conventions and test style unchanged.`;
1580
+ }
1581
+ if (normalized === "copilot") {
1582
+ return `- Keep error handling explicit on all new paths.
1583
+ - Avoid implicit behavior changes in existing modules.
1584
+ - Add targeted tests for each new branch introduced.`;
1585
+ }
1586
+ if (normalized === "codex") {
1587
+ return `- Execute autonomously, one bounded PR at a time.
1588
+ - Use deterministic ingest/spec context as primary source.
1589
+ - Fail closed when scope or safety requirements are ambiguous.`;
1590
+ }
1591
+ return `- Follow the provided spec and todo list exactly.
1592
+ - Implement incrementally with deterministic checkpoints.
1593
+ - Document assumptions and unresolved risks clearly.`;
1594
+ }
1595
+
1596
+ function buildHandoffPrompt({
1597
+ projectName,
1598
+ repoSlug,
1599
+ secretName,
1600
+ buildFromExistingRepo,
1601
+ authMode,
1602
+ codingAgent,
1603
+ }) {
1604
+ const codingAgentProfile = resolveCodingAgent(codingAgent || DEFAULT_CODING_AGENT_ID);
1605
+ const codingAgentConfigPath = codingAgentProfile.configFile || "none";
1606
+ const codingAgentGuidance = buildAgentPromptGuidance(codingAgentProfile.promptTarget);
1607
+ const tokenContract =
1608
+ authMode === "sentinelayer"
1609
+ ? `- Required secret name: ${secretName}
1610
+ - Workflow input binding: sentinelayer_token: \${{ secrets.${secretName} }}
1611
+ - Optional: OPENAI_API_KEY for runtime policy/BYOK scenarios.`
1612
+ : `- Sentinelayer token: not configured (BYOK mode).
1613
+ - Keep provider credentials in your own environment (OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_API_KEY).
1614
+ - If you later adopt Omar Gate GitHub Action, set secrets.${secretName} and wire sentinelayer_token accordingly.`;
1615
+ const workflowTuning =
1616
+ authMode === "sentinelayer"
1617
+ ? `- scan_mode: deep (default) or quick
1618
+ - severity_gate: P1 (default) or P2`
1619
+ : `- BYOK workflow is guidance-only and does not call the Sentinelayer action.
1620
+ - To enable Omar Gate later, set ${secretName} and configure scan_mode/severity_gate in workflow inputs.`;
1621
+
1622
+ return `# Sentinelayer Agent Handoff Prompt
1623
+
1624
+ You are executing "${projectName}" autonomously.
1625
+
1626
+ Read files in this exact order:
1627
+ 1. docs/spec.md
1628
+ 2. docs/build-guide.md
1629
+ 3. prompts/execution-prompt.md
1630
+ 4. tasks/todo.md
1631
+ 5. .github/workflows/omar-gate.yml
1632
+
1633
+ Execution mode:
1634
+ - Work PR-by-PR from tasks/todo.md.
1635
+ - For each PR run Omar loop until P0/P1 are zero and quality checks pass.
1636
+ - Keep commits scoped and deterministic.
1637
+ - Stop only for blocking secrets/permission gaps.
1638
+
1639
+ Coding agent profile:
1640
+ - Selected agent: ${codingAgentProfile.name} (${codingAgentProfile.id})
1641
+ - Prompt target: ${codingAgentProfile.promptTarget}
1642
+ - Suggested config path: ${codingAgentConfigPath}
1643
+
1644
+ Agent-specific guidance:
1645
+ ${codingAgentGuidance}
1646
+
1647
+ GitHub Action contract:
1648
+ ${tokenContract}
1649
+
1650
+ Terminal command options:
1651
+ - sentinel /omargate deep --path .
1652
+ - sentinel /audit --path .
1653
+ - sentinel /persona orchestrator --mode builder --path .
1654
+ - sentinel /persona orchestrator --mode reviewer --path .
1655
+ - sentinel /persona orchestrator --mode hardener --path .
1656
+ - sentinel /apply --plan tasks/todo.md --path .
1657
+ - Add --json to /omargate, /audit, /persona, or /apply for machine-readable CI output.
1658
+
1659
+ Workflow tuning options:
1660
+ ${workflowTuning}
1661
+
1662
+ Repo context:
1663
+ - Target repo: ${repoSlug || "not provided"}
1664
+ - Workspace mode: ${buildFromExistingRepo ? "existing codebase" : "new scaffold"}
1665
+
1666
+ Start now and continue autonomously.
1667
+ `;
1668
+ }
1669
+
1670
+ function fallbackWorkflow({ secretName = "SENTINELAYER_TOKEN", authMode = "sentinelayer" } = {}) {
1671
+ const normalizedSecret = isValidSecretName(secretName) ? secretName : "SENTINELAYER_TOKEN";
1672
+ const workflowName = authMode === "byok" ? "Omar Gate (BYOK Mode)" : "Omar Gate";
1673
+ return `name: ${workflowName}
1674
+
1675
+ on:
1676
+ pull_request:
1677
+ types:
1678
+ - opened
1679
+ - synchronize
1680
+ - reopened
1681
+ workflow_dispatch:
1682
+ inputs:
1683
+ scan_mode:
1684
+ description: Sentinelayer scan profile
1685
+ required: false
1686
+ default: deep
1687
+ type: choice
1688
+ options:
1689
+ - deep
1690
+ - nightly
1691
+ severity_gate:
1692
+ description: Severity threshold that blocks merge
1693
+ required: false
1694
+ default: P1
1695
+ type: choice
1696
+ options:
1697
+ - P0
1698
+ - P1
1699
+ - P2
1700
+ - none
1701
+ p2_max_allowed:
1702
+ description: Maximum allowed P2 findings before Omar Gate blocks merge
1703
+ required: false
1704
+ default: "5"
1705
+ type: string
1706
+
1707
+ permissions:
1708
+ contents: read
1709
+ checks: write
1710
+ pull-requests: write
1711
+ id-token: write
1712
+
1713
+ env:
1714
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
1715
+
1716
+ jobs:
1717
+ omar_gate:
1718
+ name: Omar Gate
1719
+ runs-on: ubuntu-latest
1720
+ permissions:
1721
+ contents: read
1722
+ checks: write
1723
+ pull-requests: write
1724
+ id-token: write
1725
+ steps:
1726
+ - uses: actions/checkout@v4
1727
+ - name: Validate Sentinelayer token secret
1728
+ shell: bash
1729
+ env:
1730
+ SENTINELAYER_TOKEN: \${{ secrets.${normalizedSecret} }}
1731
+ run: |
1732
+ set -euo pipefail
1733
+ if [ -z "\${SENTINELAYER_TOKEN}" ]; then
1734
+ echo "::warning::SENTINELAYER_TOKEN not set. Set it with: gh secret set ${normalizedSecret} --body <your-token>"
1735
+ echo "Skipping Omar Gate scan — run locally with: npx sentinelayer-cli@latest /omargate deep --path ."
1736
+ exit 0
1737
+ fi
1738
+ - name: Run Omar Gate
1739
+ id: omar
1740
+ uses: mrrCarter/sentinelayer-v1-action@v1
1741
+ with:
1742
+ sentinelayer_token: \${{ secrets.${normalizedSecret} }}
1743
+ scan_mode: \${{ github.event_name == 'workflow_dispatch' && inputs.scan_mode || 'deep' }}
1744
+ severity_gate: \${{ github.event_name == 'workflow_dispatch' && inputs.severity_gate || 'P1' }}
1745
+ - name: Enforce Omar reviewer merge thresholds
1746
+ shell: bash
1747
+ env:
1748
+ P0_COUNT: \${{ steps.omar.outputs.p0_count || '0' }}
1749
+ P1_COUNT: \${{ steps.omar.outputs.p1_count || '0' }}
1750
+ P2_COUNT: \${{ steps.omar.outputs.p2_count || '0' }}
1751
+ P2_MAX_ALLOWED: \${{ github.event_name == 'workflow_dispatch' && inputs.p2_max_allowed || '5' }}
1752
+ run: |
1753
+ set -euo pipefail
1754
+ p0="\$(echo "\${P0_COUNT}" | tr -d '\\r' | xargs || true)"
1755
+ p1="\$(echo "\${P1_COUNT}" | tr -d '\\r' | xargs || true)"
1756
+ p2="\$(echo "\${P2_COUNT}" | tr -d '\\r' | xargs || true)"
1757
+ p2_max="\$(echo "\${P2_MAX_ALLOWED}" | tr -d '\\r' | xargs || true)"
1758
+ case "\${p0}" in ''|*[!0-9]*) echo "::error::Invalid P0 count" ; exit 1 ;; esac
1759
+ case "\${p1}" in ''|*[!0-9]*) echo "::error::Invalid P1 count" ; exit 1 ;; esac
1760
+ case "\${p2}" in ''|*[!0-9]*) echo "::error::Invalid P2 count" ; exit 1 ;; esac
1761
+ case "\${p2_max}" in ''|*[!0-9]*) echo "::error::Invalid p2_max" ; exit 1 ;; esac
1762
+ if [ "\${p0}" -gt 0 ] || [ "\${p1}" -gt 0 ]; then
1763
+ echo "::error::Omar Gate blocked: P0=\${p0}, P1=\${p1}. Requires P0=0 and P1=0."
1764
+ exit 1
1765
+ fi
1766
+ if [ "\${p2}" -gt "\${p2_max}" ]; then
1767
+ echo "::error::Omar Gate blocked: P2=\${p2} exceeds max \${p2_max}."
1768
+ exit 1
1769
+ fi
1770
+ - name: Emit Omar run summary
1771
+ shell: bash
1772
+ run: |
1773
+ set -euo pipefail
1774
+ echo "## Omar Gate" >> "\$GITHUB_STEP_SUMMARY"
1775
+ echo "- run_id: \\\`\${{ steps.omar.outputs.run_id }}\\\`" >> "\$GITHUB_STEP_SUMMARY"
1776
+ echo "- gate_status: \\\`\${{ steps.omar.outputs.gate_status }}\\\`" >> "\$GITHUB_STEP_SUMMARY"
1777
+ echo "- findings: P0=\${{ steps.omar.outputs.p0_count }} P1=\${{ steps.omar.outputs.p1_count }} P2=\${{ steps.omar.outputs.p2_count }} P3=\${{ steps.omar.outputs.p3_count }}" >> "\$GITHUB_STEP_SUMMARY"
1778
+ `;
1779
+ }
1780
+
1781
+ function hasAuthKeywords(text) {
1782
+ const lower = String(text || "").toLowerCase();
1783
+ return ["login", "signup", "sign up", "register", "authentication", "auth flow", "otp", "verification", "password reset"].some((kw) => lower.includes(kw));
1784
+ }
1785
+
1786
+ function buildByokArtifacts({ interview, description }) {
1787
+ const featureList =
1788
+ interview.features.length > 0
1789
+ ? interview.features.map((item, index) => `${index + 1}. ${item}`).join("\n")
1790
+ : "1. Implement the core workflow end-to-end.\n2. Add observability and hardening.\n3. Add tests and docs.";
1791
+ const techStack =
1792
+ interview.techStack.length > 0 ? interview.techStack.join(", ") : "Node.js, TypeScript, PostgreSQL";
1793
+
1794
+ return {
1795
+ project_name: interview.projectName,
1796
+ spec_sheet: `# Spec
1797
+
1798
+ ## Project
1799
+ ${interview.projectName}
1800
+
1801
+ ## Goal
1802
+ ${description}
1803
+
1804
+ ## Target audience
1805
+ ${interview.audienceLevel}
1806
+
1807
+ ## Preferred provider
1808
+ ${interview.aiProvider}
1809
+
1810
+ ## Project type
1811
+ ${interview.projectType}
1812
+
1813
+ ## Suggested stack
1814
+ ${techStack}
1815
+
1816
+ ## Key features
1817
+ ${featureList}
1818
+ ${hasAuthKeywords(description) ? `
1819
+ ## AIdenID E2E Verification
1820
+ When authentication flows are implemented, use AIdenID to test them at scale:
1821
+ 1. Confirm AIdenID credentials via \`sl auth status\` (auto-provisioned at login).
1822
+ 2. Provision ephemeral test identity: \`sl ai provision-email --execute --json\`.
1823
+ 3. Run automated signup flow with provisioned email.
1824
+ 4. Extract OTP from inbound email: \`sl ai identity wait-for-otp <identityId> --timeout 30\`.
1825
+ 5. Complete login flow with extracted OTP and verify authenticated session.
1826
+ 6. Revoke test identity after verification: \`sl ai identity revoke <identityId>\`.
1827
+ ` : ""}`,
1828
+ playbook: `# Build Guide
1829
+
1830
+ ## Scope
1831
+ - Keep each PR bounded and shippable.
1832
+ - Run tests and local scans before each handoff.
1833
+ - Keep secrets out of source control.
1834
+
1835
+ ## Implementation order
1836
+ 1. Establish repo baseline and CI checks.
1837
+ 2. Implement domain model and persistence boundaries.
1838
+ 3. Implement API/worker surface and auth/session policies.
1839
+ 4. Add observability, retries, and production hardening.
1840
+ 5. Finalize docs and operational runbooks.
1841
+
1842
+ ## Review loop
1843
+ - Run \`sentinel /omargate deep --path .\` and \`sentinel /audit --path .\`.
1844
+ - Fix P0/P1 issues before merge.
1845
+ - Fix P2 findings before merge when feasible.
1846
+ `,
1847
+ builder_prompt: `You are operating in Sentinelayer BYOK mode.
1848
+
1849
+ Read files in order:
1850
+ 1. docs/spec.md
1851
+ 2. docs/build-guide.md
1852
+ 3. tasks/todo.md
1853
+ 4. AGENT_HANDOFF_PROMPT.md
1854
+
1855
+ Execute PR-by-PR from tasks/todo.md.
1856
+ Run local scans after each PR:
1857
+ - sentinel /omargate deep --path .
1858
+ - sentinel /audit --path .
1859
+
1860
+ Continue autonomously unless blocked by missing credentials or permissions.`,
1861
+ omar_gate_yaml: fallbackWorkflow({ authMode: "byok" }),
1862
+ };
1863
+ }
1864
+
1865
+ function runGhSecretSet({ repoSlug, secretName, secretValue }) {
1866
+ const normalizedRepo = normalizeRepoSlug(repoSlug);
1867
+ const ghCommand = getGhCommand();
1868
+ const secretSinkFile = String(process.env.SENTINELAYER_SECRET_SINK_FILE || "").trim();
1869
+ if (!isValidRepoSlug(normalizedRepo)) {
1870
+ return {
1871
+ ok: false,
1872
+ reason: "Invalid repo format. Use owner/repo.",
1873
+ };
1874
+ }
1875
+ if (!isValidSecretName(secretName)) {
1876
+ return {
1877
+ ok: false,
1878
+ reason: "Invalid secret name from bootstrap response.",
1879
+ };
1880
+ }
1881
+ if (secretSinkFile) {
1882
+ try {
1883
+ fs.appendFileSync(secretSinkFile, `${normalizedRepo}|${secretName}|${secretValue}\n`, "utf-8");
1884
+ return { ok: true };
1885
+ } catch (error) {
1886
+ return {
1887
+ ok: false,
1888
+ reason: `Failed to write SENTINELAYER_SECRET_SINK_FILE: ${error instanceof Error ? error.message : String(error)}`,
1889
+ };
1890
+ }
1891
+ }
1892
+ try {
1893
+ ensureGhCliAvailable(ghCommand);
1894
+ } catch (error) {
1895
+ return {
1896
+ ok: false,
1897
+ reason: error instanceof Error ? error.message : String(error),
1898
+ };
1899
+ }
1900
+
1901
+ const result = spawnSync(ghCommand, ["secret", "set", secretName, "--repo", normalizedRepo], {
1902
+ encoding: "utf-8",
1903
+ input: `${secretValue}\n`,
1904
+ });
1905
+ if (result.status !== 0) {
1906
+ return {
1907
+ ok: false,
1908
+ reason: String(result.stderr || result.stdout || "gh secret set failed").trim(),
1909
+ };
1910
+ }
1911
+
1912
+ const verifyResult = spawnSync(ghCommand, ["secret", "list", "--repo", normalizedRepo], {
1913
+ encoding: "utf-8",
1914
+ });
1915
+ if (verifyResult.status !== 0) {
1916
+ return {
1917
+ ok: false,
1918
+ reason: String(verifyResult.stderr || verifyResult.stdout || "gh secret list failed").trim(),
1919
+ };
1920
+ }
1921
+
1922
+ const listedSecrets = String(verifyResult.stdout || "");
1923
+ const escapedSecretName = String(secretName || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1924
+ const secretRegex = new RegExp(`(^|\\r?\\n)\\s*${escapedSecretName}(\\s|$)`, "m");
1925
+ if (!secretRegex.test(listedSecrets)) {
1926
+ return {
1927
+ ok: false,
1928
+ reason: `Secret '${secretName}' was not visible in gh secret list output after injection.`,
1929
+ };
1930
+ }
1931
+
1932
+ return { ok: true };
1933
+ }
1934
+
1935
+ async function collectInterview({ initialProjectName, detectedRepo, detectedCodingAgent }) {
1936
+ const onCancel = () => {
1937
+ throw new Error("Prompt flow cancelled by user.");
1938
+ };
1939
+ const detectedAgentRecord = resolveCodingAgent(detectedCodingAgent || DEFAULT_CODING_AGENT_ID);
1940
+ const codingAgentChoices = listSupportedCodingAgents().map((agent) => ({
1941
+ title:
1942
+ agent.id === detectedAgentRecord.id
1943
+ ? `${agent.name} (${agent.id}, detected)`
1944
+ : `${agent.name} (${agent.id})`,
1945
+ value: agent.id,
1946
+ }));
1947
+ const defaultCodingAgentIndex = Math.max(
1948
+ 0,
1949
+ codingAgentChoices.findIndex((choice) => choice.value === detectedAgentRecord.id)
1950
+ );
1951
+ const projectTypeChoices = [
1952
+ { title: "Greenfield", value: "greenfield" },
1953
+ { title: "Add feature", value: "add_feature" },
1954
+ { title: "Bugfix / hardening", value: "bugfix" },
1955
+ ];
1956
+ const inferredProjectType = isValidRepoSlug(detectedRepo || "") ? "add_feature" : "greenfield";
1957
+ const defaultProjectTypeIndex = Math.max(
1958
+ 0,
1959
+ projectTypeChoices.findIndex((choice) => choice.value === inferredProjectType)
1960
+ );
1961
+
1962
+ const base = await prompts(
1963
+ [
1964
+ {
1965
+ type: initialProjectName ? null : "text",
1966
+ name: "projectName",
1967
+ message: "Project folder name",
1968
+ initial: "my-agent-app",
1969
+ validate: (value) =>
1970
+ sanitizeProjectName(value).length > 0 ? true : "Enter a valid project folder name.",
1971
+ },
1972
+ {
1973
+ type: "text",
1974
+ name: "projectDescription",
1975
+ message: "What are you building?",
1976
+ validate: (value) =>
1977
+ String(value || "").trim().length >= 15
1978
+ ? true
1979
+ : "Describe your project in at least 15 characters.",
1980
+ },
1981
+ {
1982
+ type: "select",
1983
+ name: "aiProvider",
1984
+ message: "Select your AI provider",
1985
+ choices: [
1986
+ { title: "OpenAI (Codex)", value: "openai" },
1987
+ { title: "Anthropic (Claude)", value: "anthropic" },
1988
+ { title: "Google (Gemini)", value: "google" },
1989
+ ],
1990
+ initial: 0,
1991
+ },
1992
+ {
1993
+ type: "select",
1994
+ name: "codingAgent",
1995
+ message: "Which coding agent will you use?",
1996
+ choices: codingAgentChoices,
1997
+ initial: defaultCodingAgentIndex,
1998
+ },
1999
+ {
2000
+ type: "select",
2001
+ name: "generationMode",
2002
+ message: "Artifact depth",
2003
+ choices: [
2004
+ { title: "Detailed (recommended)", value: "detailed" },
2005
+ { title: "Quick", value: "quick" },
2006
+ { title: "Enterprise", value: "enterprise" },
2007
+ ],
2008
+ initial: 0,
2009
+ },
2010
+ {
2011
+ type: "select",
2012
+ name: "audienceLevel",
2013
+ message: "Primary audience",
2014
+ choices: [
2015
+ { title: "Developer", value: "developer" },
2016
+ { title: "Intermediate", value: "intermediate" },
2017
+ { title: "Beginner", value: "beginner" },
2018
+ ],
2019
+ initial: 0,
2020
+ },
2021
+ {
2022
+ type: "select",
2023
+ name: "projectType",
2024
+ message: "Project type",
2025
+ choices: projectTypeChoices,
2026
+ initial: defaultProjectTypeIndex,
2027
+ },
2028
+ {
2029
+ type: "text",
2030
+ name: "techStack",
2031
+ message: "Tech stack (comma-separated, optional)",
2032
+ initial: "TypeScript, Node.js, PostgreSQL",
2033
+ },
2034
+ {
2035
+ type: "text",
2036
+ name: "features",
2037
+ message: "Key features (comma-separated, optional)",
2038
+ },
2039
+ {
2040
+ type: "select",
2041
+ name: "authMode",
2042
+ message: "Auth mode",
2043
+ choices: [
2044
+ { title: "Sentinelayer managed token (recommended)", value: "sentinelayer" },
2045
+ { title: "BYOK only (skip Sentinelayer token)", value: "byok" },
2046
+ ],
2047
+ initial: 0,
2048
+ },
2049
+ {
2050
+ type: "toggle",
2051
+ name: "advanced",
2052
+ message: "Advanced options?",
2053
+ initial: true,
2054
+ active: "yes",
2055
+ inactive: "no",
2056
+ },
2057
+ ],
2058
+ { onCancel }
2059
+ );
2060
+
2061
+ let advanced = {
2062
+ connectRepo: false,
2063
+ repoSlug: detectedRepo || "",
2064
+ buildFromExistingRepo: false,
2065
+ injectSecret: false,
2066
+ };
2067
+ if (base.advanced) {
2068
+ const repoChoices = [];
2069
+ if (detectedRepo) {
2070
+ repoChoices.push({
2071
+ title: `Use current repo (${detectedRepo})`,
2072
+ value: "current",
2073
+ });
2074
+ }
2075
+ repoChoices.push({
2076
+ title: "Choose from GitHub account (browser auth)",
2077
+ value: "picker",
2078
+ });
2079
+ repoChoices.push({
2080
+ title: "Enter owner/repo manually",
2081
+ value: "manual",
2082
+ });
2083
+
2084
+ const repoSetup = await prompts(
2085
+ [
2086
+ {
2087
+ type: "toggle",
2088
+ name: "connectRepo",
2089
+ message: "Connect a GitHub repo and inject Actions secret?",
2090
+ initial: Boolean(detectedRepo),
2091
+ active: "yes",
2092
+ inactive: "no",
2093
+ },
2094
+ {
2095
+ type: (prev) => (prev ? "select" : null),
2096
+ name: "repoSource",
2097
+ message: "How should we choose the repo?",
2098
+ choices: repoChoices,
2099
+ initial: detectedRepo ? 0 : 1,
2100
+ },
2101
+ ],
2102
+ { onCancel }
2103
+ );
2104
+
2105
+ advanced.connectRepo = Boolean(repoSetup.connectRepo);
2106
+ if (advanced.connectRepo) {
2107
+ let repoSlug;
2108
+ const repoSource = String(repoSetup.repoSource || "").trim().toLowerCase();
2109
+
2110
+ if (repoSource === "manual") {
2111
+ const manual = await prompts(
2112
+ [
2113
+ {
2114
+ type: "text",
2115
+ name: "repoSlug",
2116
+ message: "GitHub repo (owner/repo)",
2117
+ initial: detectedRepo || "",
2118
+ validate: (value) => (isValidRepoSlug(value) ? true : "Use owner/repo format."),
2119
+ },
2120
+ ],
2121
+ { onCancel }
2122
+ );
2123
+ repoSlug = normalizeRepoSlug(manual.repoSlug);
2124
+ } else if (repoSource === "picker") {
2125
+ repoSlug = await selectRepoSlugFromGithub();
2126
+ } else {
2127
+ repoSlug = normalizeRepoSlug(detectedRepo);
2128
+ }
2129
+
2130
+ if (!isValidRepoSlug(repoSlug)) {
2131
+ throw new Error("GitHub repo selection did not produce a valid owner/repo value.");
2132
+ }
2133
+
2134
+ const repoMode = await prompts(
2135
+ [
2136
+ {
2137
+ type: "toggle",
2138
+ name: "buildFromExistingRepo",
2139
+ message: "Clone this repo locally and build directly into it now?",
2140
+ initial: base.projectType === "add_feature" || base.projectType === "bugfix",
2141
+ active: "yes",
2142
+ inactive: "no",
2143
+ },
2144
+ {
2145
+ type: base.authMode === "sentinelayer" ? "toggle" : null,
2146
+ name: "injectSecret",
2147
+ message: "Inject SENTINELAYER_TOKEN into GitHub Actions secrets now?",
2148
+ initial: true,
2149
+ active: "yes",
2150
+ inactive: "no",
2151
+ },
2152
+ ],
2153
+ { onCancel }
2154
+ );
2155
+
2156
+ advanced.repoSlug = repoSlug;
2157
+ advanced.buildFromExistingRepo = Boolean(repoMode.buildFromExistingRepo);
2158
+ advanced.injectSecret = base.authMode === "sentinelayer" ? Boolean(repoMode.injectSecret) : false;
2159
+ }
2160
+ }
2161
+
2162
+ const projectName =
2163
+ sanitizeProjectName(initialProjectName || base.projectName) || getRepoNameFromSlug(advanced.repoSlug);
2164
+
2165
+ const interviewResult = {
2166
+ projectName,
2167
+ projectDescription: String(base.projectDescription || "").trim(),
2168
+ aiProvider: base.aiProvider,
2169
+ generationMode: base.generationMode,
2170
+ audienceLevel: base.audienceLevel,
2171
+ projectType: base.projectType,
2172
+ codingAgent: resolveCodingAgent(base.codingAgent || detectedAgentRecord.id).id,
2173
+ techStack: parseCommaList(base.techStack),
2174
+ features: parseCommaList(base.features),
2175
+ authMode: base.authMode,
2176
+ connectRepo: Boolean(advanced.connectRepo),
2177
+ repoSlug: normalizeRepoSlug(advanced.repoSlug),
2178
+ buildFromExistingRepo: Boolean(advanced.buildFromExistingRepo),
2179
+ injectSecret: Boolean(advanced.injectSecret),
2180
+ };
2181
+
2182
+ printSection("Interview Review");
2183
+ printInfo(`Project: ${interviewResult.projectName}`);
2184
+ printInfo(`Type: ${interviewResult.projectType}`);
2185
+ printInfo(`Provider: ${interviewResult.aiProvider}`);
2186
+ printInfo(`Coding agent: ${interviewResult.codingAgent}`);
2187
+ printInfo(`Auth mode: ${interviewResult.authMode}`);
2188
+ printInfo(`Repo: ${interviewResult.repoSlug || "not connected"}`);
2189
+ printInfo(
2190
+ `Existing repo mode: ${interviewResult.buildFromExistingRepo ? "enabled (clone/reuse)" : "disabled"}`
2191
+ );
2192
+
2193
+ const review = await prompts(
2194
+ [
2195
+ {
2196
+ type: "toggle",
2197
+ name: "proceed",
2198
+ message: "Proceed with these selections?",
2199
+ initial: true,
2200
+ active: "yes",
2201
+ inactive: "no",
2202
+ },
2203
+ ],
2204
+ { onCancel }
2205
+ );
2206
+
2207
+ if (!review.proceed) {
2208
+ const next = await prompts(
2209
+ [
2210
+ {
2211
+ type: "select",
2212
+ name: "action",
2213
+ message: "What do you want to do?",
2214
+ choices: [
2215
+ { title: "Restart interview", value: "restart" },
2216
+ { title: "Cancel", value: "cancel" },
2217
+ ],
2218
+ initial: 0,
2219
+ },
2220
+ ],
2221
+ { onCancel }
2222
+ );
2223
+ if (next.action === "restart") {
2224
+ return collectInterview({ initialProjectName, detectedRepo, detectedCodingAgent });
2225
+ }
2226
+ throw new Error("Prompt flow cancelled by user.");
2227
+ }
2228
+
2229
+ return interviewResult;
2230
+ }
2231
+
2232
+ function printSection(title) {
2233
+ console.log(`\n${pc.bold(pc.cyan(title))}`);
2234
+ }
2235
+
2236
+ function printInfo(message) {
2237
+ console.log(pc.gray(`- ${message}`));
2238
+ }
2239
+
2240
+ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
2241
+ refreshRuntimeDefaults();
2242
+ const commandExitCode = await tryRunLocalCommandMode(rawArgs);
2243
+ if (commandExitCode !== null) {
2244
+ if (commandExitCode !== 0) {
2245
+ process.exitCode = commandExitCode;
2246
+ }
2247
+ return;
2248
+ }
2249
+
2250
+ const args = parseCliArgs(rawArgs);
2251
+ if (args.showHelp) {
2252
+ printUsage();
2253
+ return;
2254
+ }
2255
+ if (args.showVersion) {
2256
+ console.log(CLI_VERSION);
2257
+ return;
2258
+ }
2259
+ const argProjectName = args.projectName;
2260
+ const detectedRepo = detectRepoSlug(process.cwd());
2261
+ const detectedCodingAgent = detectCodingAgentFromEnv(process.env).id;
2262
+
2263
+ printSection("Sentinelayer Scaffold");
2264
+ printInfo(`API: ${DEFAULT_API_URL}`);
2265
+ printInfo(`Web: ${DEFAULT_WEB_URL}`);
2266
+ if (detectedRepo) {
2267
+ printInfo(`Detected repo: ${detectedRepo}`);
2268
+ }
2269
+
2270
+ const automatedInterview = await loadAutomatedInterview({
2271
+ argProjectName,
2272
+ detectedRepo,
2273
+ detectedCodingAgent,
2274
+ interviewFile: args.interviewFile,
2275
+ });
2276
+
2277
+ const interview =
2278
+ automatedInterview ||
2279
+ (args.nonInteractive
2280
+ ? null
2281
+ : await collectInterview({
2282
+ initialProjectName: argProjectName,
2283
+ detectedRepo,
2284
+ detectedCodingAgent,
2285
+ }));
2286
+
2287
+ if (!interview) {
2288
+ throw new Error(
2289
+ "Non-interactive mode requires SENTINELAYER_CLI_INTERVIEW_JSON or --interview-file."
2290
+ );
2291
+ }
2292
+ validateInterviewInput(interview);
2293
+
2294
+ const workspace = await resolveProjectDirectory({
2295
+ cwd: process.cwd(),
2296
+ interview,
2297
+ detectedRepo,
2298
+ });
2299
+ const projectDir = workspace.projectDir;
2300
+
2301
+ printSection("Workspace");
2302
+ if (workspace.reusedCurrentRepo) {
2303
+ printInfo(`Using current repo workspace: ${projectDir}`);
2304
+ } else if (workspace.clonedRepo) {
2305
+ printInfo(`Cloned repo workspace: ${projectDir}`);
2306
+ if (workspace.cloneUrl) {
2307
+ printInfo(`Clone URL: ${workspace.cloneUrl}`);
2308
+ }
2309
+ } else {
2310
+ printInfo(`Target scaffold workspace: ${projectDir}`);
2311
+ }
2312
+
2313
+ const requestedAuthMode = interview.authMode === "byok" ? "byok" : "sentinelayer";
2314
+ let authToken = "";
2315
+
2316
+ printSection("Authentication");
2317
+ if (requestedAuthMode === "byok") {
2318
+ printInfo("BYOK mode selected. Skipping Sentinelayer browser auth and token bootstrap.");
2319
+ } else {
2320
+ if (args.nonInteractive) {
2321
+ console.log("Non-interactive mode: skipping Enter confirmation.");
2322
+ } else {
2323
+ await waitForEnter("Press Enter to authenticate with Sentinelayer in your browser...");
2324
+ }
2325
+
2326
+ const challenge = crypto.randomBytes(32).toString("hex");
2327
+ const session = await startCliSession({
2328
+ apiUrl: DEFAULT_API_URL,
2329
+ challenge,
2330
+ cliVersion: CLI_VERSION,
2331
+ });
2332
+
2333
+ if (args.skipBrowserOpen || args.nonInteractive) {
2334
+ console.log(`Browser open skipped. Authorize manually: ${session.authorize_url}`);
2335
+ } else {
2336
+ console.log(`Opening browser: ${session.authorize_url}`);
2337
+ try {
2338
+ await open(session.authorize_url);
2339
+ } catch {
2340
+ console.log(pc.yellow("Could not auto-open browser. Open this URL manually:"));
2341
+ console.log(pc.yellow(session.authorize_url));
2342
+ }
2343
+ }
2344
+
2345
+ console.log("Waiting for browser approval...");
2346
+ const approval = await pollCliSession({
2347
+ apiUrl: DEFAULT_API_URL,
2348
+ sessionId: session.session_id,
2349
+ challenge,
2350
+ pollIntervalSeconds: session.poll_interval_seconds || 2,
2351
+ timeoutMs: DEFAULT_AUTH_TIMEOUT_MS,
2352
+ });
2353
+
2354
+ authToken = String(approval.auth_token || "").trim();
2355
+ if (!authToken) {
2356
+ throw new Error("Authentication completed but no auth token was returned.");
2357
+ }
2358
+ }
2359
+
2360
+ printSection("Artifact Generation");
2361
+ let description = interview.projectDescription;
2362
+ if (interview.buildFromExistingRepo) {
2363
+ const repoSummary = await buildRepoIngestSummary(projectDir);
2364
+ if (repoSummary) {
2365
+ description = `${description}\n\nExisting repo context:\n${repoSummary}`;
2366
+ printInfo("Included existing repo ingest summary in generation payload.");
2367
+ } else {
2368
+ printInfo("No repo ingest summary was available. Continuing with base description.");
2369
+ }
2370
+ }
2371
+ const generatePayload = {
2372
+ description,
2373
+ tech_stack: interview.techStack,
2374
+ features: interview.features,
2375
+ generation_mode: interview.generationMode,
2376
+ audience_level: interview.audienceLevel,
2377
+ project_type: interview.projectType,
2378
+ model_provider: interview.aiProvider,
2379
+ model_id: DEFAULT_MODEL_BY_PROVIDER[interview.aiProvider] || undefined,
2380
+ };
2381
+ let generated = null;
2382
+ let sentinelayerToken = "";
2383
+ let secretName = "SENTINELAYER_TOKEN";
2384
+
2385
+ if (requestedAuthMode === "byok") {
2386
+ generated = buildByokArtifacts({
2387
+ interview,
2388
+ description,
2389
+ });
2390
+ } else {
2391
+ generated = await generateArtifacts({
2392
+ apiUrl: DEFAULT_API_URL,
2393
+ authToken,
2394
+ payload: generatePayload,
2395
+ });
2396
+
2397
+ let bootstrapToken = generated?.bootstrap_token || null;
2398
+ if (!bootstrapToken || !String(bootstrapToken.token || "").trim()) {
2399
+ try {
2400
+ bootstrapToken = await issueBootstrapToken({
2401
+ apiUrl: DEFAULT_API_URL,
2402
+ authToken,
2403
+ });
2404
+ } catch (error) {
2405
+ const message = error instanceof Error ? error.message : String(error);
2406
+ console.log(
2407
+ pc.yellow(`Token bootstrap unavailable. Continuing in BYOK mode for this scaffold. (${message})`)
2408
+ );
2409
+ }
2410
+ }
2411
+
2412
+ sentinelayerToken = String(bootstrapToken?.token || "").trim();
2413
+ if (sentinelayerToken) {
2414
+ const requestedSecretName = String(bootstrapToken.required_secret_name || "").trim();
2415
+ secretName = isValidSecretName(requestedSecretName) ? requestedSecretName : "SENTINELAYER_TOKEN";
2416
+ if (requestedSecretName && requestedSecretName !== secretName) {
2417
+ console.log(
2418
+ pc.yellow(
2419
+ `Received invalid secret name '${requestedSecretName}' from API. Falling back to ${secretName}.`
2420
+ )
2421
+ );
2422
+ }
2423
+ } else {
2424
+ console.log(pc.yellow("Sentinelayer token unavailable. Continuing in BYOK mode for this scaffold."));
2425
+ }
2426
+ }
2427
+ const effectiveAuthMode = sentinelayerToken ? "sentinelayer" : "byok";
2428
+
2429
+ const effectiveProjectName =
2430
+ sanitizeProjectName(generated.project_name || interview.projectName || path.basename(projectDir)) ||
2431
+ path.basename(projectDir);
2432
+ const docsDir = path.join(projectDir, "docs");
2433
+ const promptsDir = path.join(projectDir, "prompts");
2434
+ const tasksDir = path.join(projectDir, "tasks");
2435
+
2436
+ await writeTextFile(path.join(docsDir, "spec.md"), String(generated.spec_sheet || "").trim() + "\n");
2437
+ await writeTextFile(
2438
+ path.join(docsDir, "build-guide.md"),
2439
+ String(generated.playbook || "").trim() + "\n"
2440
+ );
2441
+ await writeTextFile(
2442
+ path.join(promptsDir, "execution-prompt.md"),
2443
+ String(generated.builder_prompt || "").trim() + "\n"
2444
+ );
2445
+ await writeTextFile(
2446
+ path.join(projectDir, ".github", "workflows", "omar-gate.yml"),
2447
+ (
2448
+ (effectiveAuthMode === "sentinelayer" ? String(generated.omar_gate_yaml || "").trim() : "") ||
2449
+ fallbackWorkflow({ secretName, authMode: effectiveAuthMode })
2450
+ ) + "\n"
2451
+ );
2452
+ await writeTextFile(
2453
+ path.join(tasksDir, "todo.md"),
2454
+ buildTodoContent({
2455
+ projectName: effectiveProjectName,
2456
+ aiProvider: interview.aiProvider,
2457
+ codingAgent: interview.codingAgent,
2458
+ authMode: effectiveAuthMode,
2459
+ repoSlug: interview.repoSlug,
2460
+ buildFromExistingRepo: interview.buildFromExistingRepo,
2461
+ generationMode: interview.generationMode,
2462
+ audienceLevel: interview.audienceLevel,
2463
+ projectType: interview.projectType,
2464
+ })
2465
+ );
2466
+ await writeTextFile(
2467
+ path.join(projectDir, "AGENT_HANDOFF_PROMPT.md"),
2468
+ buildHandoffPrompt({
2469
+ projectName: effectiveProjectName,
2470
+ repoSlug: interview.repoSlug,
2471
+ secretName,
2472
+ buildFromExistingRepo: interview.buildFromExistingRepo,
2473
+ authMode: effectiveAuthMode,
2474
+ codingAgent: interview.codingAgent,
2475
+ })
2476
+ );
2477
+ const codingAgentConfig = await ensureCodingAgentConfigFile({
2478
+ projectDir,
2479
+ projectName: effectiveProjectName,
2480
+ codingAgent: interview.codingAgent,
2481
+ });
2482
+
2483
+ await ensureSentinelStartScript(projectDir, effectiveProjectName);
2484
+
2485
+ // Code scaffold: write starter source files, skip existing
2486
+ const templateFiles = getExpressTemplate({
2487
+ projectName: effectiveProjectName,
2488
+ description: interview.description,
2489
+ });
2490
+ const packageJsonTemplate = getPackageJsonTemplate({
2491
+ projectName: effectiveProjectName,
2492
+ description: interview.description,
2493
+ });
2494
+ const readmeContent = buildReadmeContent({
2495
+ projectName: effectiveProjectName,
2496
+ description: interview.description,
2497
+ techStack: interview.projectType || "Node.js + Express",
2498
+ });
2499
+ const scaffoldResult = await generateScaffold({
2500
+ projectDir,
2501
+ templateFiles,
2502
+ packageJsonTemplate,
2503
+ readmeContent,
2504
+ force: false,
2505
+ });
2506
+ if (scaffoldResult.written.length > 0) {
2507
+ console.log(pc.green(`Scaffold: wrote ${scaffoldResult.written.length} starter files`));
2508
+ for (const f of scaffoldResult.written) {
2509
+ console.log(pc.gray(` + ${f}`));
2510
+ }
2511
+ }
2512
+ if (scaffoldResult.skipped.length > 0) {
2513
+ for (const s of scaffoldResult.skipped) {
2514
+ console.log(pc.gray(` ~ ${s.path} (${s.reason})`));
2515
+ }
2516
+ }
2517
+
2518
+ if (sentinelayerToken) {
2519
+ await ensureEnvFileIgnored(projectDir);
2520
+ await upsertEnvVariable(path.join(projectDir, ".env"), secretName, sentinelayerToken);
2521
+ }
2522
+ await ensureGitRepositorySetup({
2523
+ projectDir,
2524
+ repoSlug: interview.connectRepo ? interview.repoSlug : "",
2525
+ });
2526
+
2527
+ let secretInjection = { ok: false, reason: "Skipped." };
2528
+ if (interview.connectRepo && interview.injectSecret && interview.repoSlug && sentinelayerToken) {
2529
+ secretInjection = runGhSecretSet({
2530
+ repoSlug: interview.repoSlug,
2531
+ secretName,
2532
+ secretValue: sentinelayerToken,
2533
+ });
2534
+ }
2535
+
2536
+ printSection("Complete");
2537
+ console.log(pc.green(`✔ Sentinelayer orchestration initialized in ${projectDir}`));
2538
+ if (sentinelayerToken) {
2539
+ console.log(pc.green(`✔ ${secretName} injected into ${path.join(projectDir, ".env")}`));
2540
+ } else {
2541
+ console.log(pc.yellow("! BYOK mode active: Sentinelayer token was not injected."));
2542
+ }
2543
+ if (codingAgentConfig.created) {
2544
+ console.log(
2545
+ pc.green(`✔ ${codingAgentConfig.agent.name} config scaffolded at ${codingAgentConfig.path}`)
2546
+ );
2547
+ }
2548
+ if (interview.connectRepo && interview.injectSecret && sentinelayerToken) {
2549
+ if (secretInjection.ok) {
2550
+ console.log(pc.green(`✔ ${secretName} injected into GitHub repo secret (${interview.repoSlug})`));
2551
+ } else {
2552
+ console.log(pc.yellow(`! GitHub secret injection skipped/failed: ${secretInjection.reason}`));
2553
+ console.log(
2554
+ pc.yellow(
2555
+ ` Run manually: gh secret set ${secretName} --repo ${interview.repoSlug || "<owner/repo>"}`
2556
+ )
2557
+ );
2558
+ }
2559
+ }
2560
+
2561
+ console.log("\nNext:");
2562
+ const nextCd = path.relative(process.cwd(), projectDir) || ".";
2563
+ console.log(`1. cd ${nextCd}`);
2564
+ console.log("2. npm run sentinel:start");
2565
+ console.log("3. Copy/paste AGENT_HANDOFF_PROMPT.md into your coding agent and let it run autonomously.");
2566
+ }
2567
+
2568
+ export function renderCliFailure(error) {
2569
+ const message = error instanceof Error ? error.message : String(error);
2570
+ const code = error instanceof SentinelayerApiError ? ` [${error.code}]` : "";
2571
+ const requestId =
2572
+ error instanceof SentinelayerApiError && error.requestId ? ` request_id=${error.requestId}` : "";
2573
+ console.error(pc.red(`\nSentinelayer scaffold failed${code}:${requestId}`));
2574
+ console.error(pc.red(message));
2575
+ }
2576
+
2577
+ export async function runLegacyCliWithErrorHandling(rawArgs = process.argv.slice(2)) {
2578
+ try {
2579
+ await runLegacyCli(rawArgs);
2580
+ } catch (error) {
2581
+ renderCliFailure(error);
2582
+ process.exitCode = 1;
2583
+ }
2584
+ }
2585
+
2586
+ const invokedAsEntrypoint =
2587
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
2588
+
2589
+ if (invokedAsEntrypoint) {
2590
+ runLegacyCliWithErrorHandling();
2591
+ }
2592
+