sentinelayer-cli 0.1.1 → 0.3.0

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