sentinelayer-cli 0.6.2 → 0.8.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 (159) 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 +64 -63
  6. package/src/agents/jules/config/definition.js +160 -160
  7. package/src/agents/jules/config/system-prompt.js +182 -182
  8. package/src/agents/jules/error-intake.js +51 -51
  9. package/src/agents/jules/fix-cycle.js +17 -17
  10. package/src/agents/jules/loop.js +457 -450
  11. package/src/agents/jules/pulse.js +10 -10
  12. package/src/agents/jules/stream.js +187 -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 +311 -309
  18. package/src/agents/jules/tools/aidenid-email.js +189 -189
  19. package/src/agents/jules/tools/auth-audit.js +1699 -1691
  20. package/src/agents/jules/tools/dispatch.js +340 -335
  21. package/src/agents/jules/tools/file-edit.js +2 -2
  22. package/src/agents/jules/tools/file-read.js +2 -2
  23. package/src/agents/jules/tools/frontend-analyze.js +570 -570
  24. package/src/agents/jules/tools/glob.js +2 -2
  25. package/src/agents/jules/tools/grep.js +2 -2
  26. package/src/agents/jules/tools/index.js +29 -29
  27. package/src/agents/jules/tools/path-guards.js +2 -2
  28. package/src/agents/jules/tools/runtime-audit.js +507 -507
  29. package/src/agents/jules/tools/shell.js +2 -2
  30. package/src/agents/jules/tools/url-policy.js +100 -100
  31. package/src/agents/persona-visuals.js +64 -61
  32. package/src/agents/shared-tools/dispatch-core.js +320 -315
  33. package/src/agents/shared-tools/file-edit.js +180 -180
  34. package/src/agents/shared-tools/file-read.js +100 -100
  35. package/src/agents/shared-tools/glob.js +168 -168
  36. package/src/agents/shared-tools/grep.js +228 -228
  37. package/src/agents/shared-tools/index.js +46 -46
  38. package/src/agents/shared-tools/path-guards.js +161 -161
  39. package/src/agents/shared-tools/shell.js +383 -383
  40. package/src/ai/aidenid.js +1021 -1009
  41. package/src/ai/client.js +553 -553
  42. package/src/ai/domain-target-store.js +268 -268
  43. package/src/ai/identity-store.js +270 -270
  44. package/src/ai/proxy.js +137 -137
  45. package/src/ai/site-store.js +145 -145
  46. package/src/audit/agents/architecture.js +180 -180
  47. package/src/audit/agents/compliance.js +179 -179
  48. package/src/audit/agents/documentation.js +165 -165
  49. package/src/audit/agents/performance.js +145 -145
  50. package/src/audit/agents/security.js +215 -215
  51. package/src/audit/agents/testing.js +172 -172
  52. package/src/audit/orchestrator.js +557 -557
  53. package/src/audit/package.js +204 -204
  54. package/src/audit/registry.js +284 -284
  55. package/src/audit/replay.js +103 -103
  56. package/src/auth/gate.js +400 -371
  57. package/src/auth/http.js +681 -611
  58. package/src/auth/service.js +1106 -1106
  59. package/src/auth/session-store.js +813 -813
  60. package/src/cli.js +257 -252
  61. package/src/commands/ai/identity-lifecycle.js +1338 -1338
  62. package/src/commands/ai/provision-governance.js +1272 -1272
  63. package/src/commands/ai/shared.js +147 -147
  64. package/src/commands/ai.js +11 -11
  65. package/src/commands/apply.js +12 -12
  66. package/src/commands/audit.js +1171 -1166
  67. package/src/commands/auth.js +419 -419
  68. package/src/commands/chat.js +191 -191
  69. package/src/commands/config.js +184 -184
  70. package/src/commands/cost.js +311 -311
  71. package/src/commands/daemon/core.js +850 -850
  72. package/src/commands/daemon/extended.js +1048 -1048
  73. package/src/commands/daemon/shared.js +213 -213
  74. package/src/commands/daemon.js +11 -11
  75. package/src/commands/guide.js +174 -174
  76. package/src/commands/ingest.js +58 -58
  77. package/src/commands/init.js +55 -55
  78. package/src/commands/legacy-args.js +10 -10
  79. package/src/commands/mcp.js +461 -461
  80. package/src/commands/omargate.js +29 -29
  81. package/src/commands/persona.js +20 -20
  82. package/src/commands/plugin.js +260 -260
  83. package/src/commands/policy.js +132 -132
  84. package/src/commands/prompt.js +238 -238
  85. package/src/commands/review.js +704 -704
  86. package/src/commands/scan.js +872 -872
  87. package/src/commands/session.js +590 -0
  88. package/src/commands/spec.js +778 -716
  89. package/src/commands/swarm.js +651 -651
  90. package/src/commands/telemetry.js +202 -202
  91. package/src/commands/watch.js +511 -511
  92. package/src/config/agent-dictionary.js +182 -182
  93. package/src/config/io.js +56 -56
  94. package/src/config/paths.js +18 -18
  95. package/src/config/schema.js +55 -55
  96. package/src/config/service.js +184 -184
  97. package/src/cost/budget.js +235 -235
  98. package/src/cost/history.js +188 -188
  99. package/src/cost/tracker.js +171 -171
  100. package/src/daemon/artifact-lineage.js +534 -534
  101. package/src/daemon/assignment-ledger.js +966 -770
  102. package/src/daemon/ast-parser-layer.js +258 -258
  103. package/src/daemon/budget-governor.js +633 -633
  104. package/src/daemon/callgraph-overlay.js +646 -646
  105. package/src/daemon/error-worker.js +1209 -626
  106. package/src/daemon/fix-cycle.js +384 -377
  107. package/src/daemon/hybrid-mapper.js +929 -929
  108. package/src/daemon/ingest-refresh.js +10 -9
  109. package/src/daemon/jira-lifecycle.js +767 -632
  110. package/src/daemon/operator-control.js +657 -657
  111. package/src/daemon/pulse.js +327 -327
  112. package/src/daemon/reliability-lane.js +471 -471
  113. package/src/daemon/scope-engine.js +1068 -0
  114. package/src/daemon/watchdog.js +971 -971
  115. package/src/events/schema.js +190 -0
  116. package/src/guide/generator.js +316 -316
  117. package/src/ingest/engine.js +918 -918
  118. package/src/interactive/index.js +97 -97
  119. package/src/legacy-cli.js +3161 -2994
  120. package/src/mcp/registry.js +695 -695
  121. package/src/memory/blackboard.js +301 -301
  122. package/src/memory/retrieval.js +581 -581
  123. package/src/plugin/manifest.js +553 -553
  124. package/src/policy/packs.js +144 -144
  125. package/src/prompt/generator.js +136 -118
  126. package/src/review/ai-review.js +679 -679
  127. package/src/review/local-review.js +1351 -1305
  128. package/src/review/omargate-interactive.js +68 -68
  129. package/src/review/omargate-orchestrator.js +404 -300
  130. package/src/review/persona-prompts.js +296 -296
  131. package/src/review/replay.js +235 -235
  132. package/src/review/report.js +664 -664
  133. package/src/review/scan-modes.js +48 -42
  134. package/src/review/spec-binding.js +487 -487
  135. package/src/scaffold/generator.js +67 -67
  136. package/src/scaffold/templates.js +150 -150
  137. package/src/scan/generator.js +418 -418
  138. package/src/scan/gh-secrets.js +107 -107
  139. package/src/session/agent-registry.js +352 -0
  140. package/src/session/daemon.js +801 -0
  141. package/src/session/paths.js +33 -0
  142. package/src/session/runtime-bridge.js +739 -0
  143. package/src/session/store.js +388 -0
  144. package/src/session/stream.js +325 -0
  145. package/src/spec/generator.js +619 -519
  146. package/src/spec/regenerate.js +237 -237
  147. package/src/spec/templates.js +91 -91
  148. package/src/swarm/dashboard.js +247 -247
  149. package/src/swarm/factory.js +363 -363
  150. package/src/swarm/pentest.js +934 -934
  151. package/src/swarm/registry.js +419 -419
  152. package/src/swarm/report.js +158 -158
  153. package/src/swarm/runtime.js +576 -576
  154. package/src/swarm/scenario-dsl.js +272 -272
  155. package/src/telemetry/ledger.js +302 -302
  156. package/src/telemetry/session-tracker.js +234 -234
  157. package/src/telemetry/sync.js +203 -203
  158. package/src/ui/command-hints.js +13 -13
  159. package/src/ui/markdown.js +220 -220
@@ -1,553 +1,553 @@
1
- import fsp from "node:fs/promises";
2
- import path from "node:path";
3
-
4
- import { ZodError, z } from "zod";
5
-
6
- import { resolveOutputRoot } from "../config/service.js";
7
-
8
- export const PLUGIN_MANIFEST_SCHEMA_VERSION = "1.0.0";
9
- export const PLUGIN_MANIFEST_KIND = "sentinelayer.cli.plugin";
10
- export const PLUGIN_PACK_TYPES = ["plugin", "template_pack", "policy_pack", "hybrid"];
11
- export const PLUGIN_LOAD_STAGES = ["pre_scan", "scan", "post_scan", "reporting"];
12
-
13
- const pluginIdRegex = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
14
- const semverRegex = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
15
-
16
- const pluginManifestSchema = z
17
- .object({
18
- schema_version: z.literal(PLUGIN_MANIFEST_SCHEMA_VERSION).default(PLUGIN_MANIFEST_SCHEMA_VERSION),
19
- kind: z.literal(PLUGIN_MANIFEST_KIND).default(PLUGIN_MANIFEST_KIND),
20
- id: z.string().regex(pluginIdRegex, "plugin id must match [a-z0-9._-] and be 1-64 chars"),
21
- name: z.string().min(1),
22
- version: z.string().regex(semverRegex, "version must use semver (for example 0.1.0)"),
23
- description: z.string().min(1),
24
- pack_type: z.enum(PLUGIN_PACK_TYPES).default("plugin"),
25
- entrypoint: z
26
- .object({
27
- command: z.string().min(1),
28
- args: z.array(z.string()).default([]),
29
- })
30
- .strict(),
31
- load_order: z
32
- .object({
33
- stage: z.enum(PLUGIN_LOAD_STAGES).default("scan"),
34
- after: z.array(z.string()).default([]),
35
- before: z.array(z.string()).default([]),
36
- })
37
- .strict()
38
- .default({
39
- stage: "scan",
40
- after: [],
41
- before: [],
42
- }),
43
- capabilities: z
44
- .object({
45
- commands: z.array(z.string()).default([]),
46
- templates: z.array(z.string()).default([]),
47
- policies: z.array(z.string()).default([]),
48
- mcp_tools: z.array(z.string()).default([]),
49
- })
50
- .strict()
51
- .default({
52
- commands: [],
53
- templates: [],
54
- policies: [],
55
- mcp_tools: [],
56
- }),
57
- budgets: z
58
- .object({
59
- max_runtime_ms: z.number().int().positive().default(20000),
60
- max_tool_calls: z.number().int().positive().default(20),
61
- })
62
- .strict()
63
- .default({
64
- max_runtime_ms: 20000,
65
- max_tool_calls: 20,
66
- }),
67
- security: z
68
- .object({
69
- requires_human_approval: z.boolean().default(false),
70
- allow_network: z.boolean().default(false),
71
- allowed_paths: z.array(z.string()).default([]),
72
- kill_switch: z.enum(["enabled", "disabled"]).default("enabled"),
73
- })
74
- .strict()
75
- .default({
76
- requires_human_approval: false,
77
- allow_network: false,
78
- allowed_paths: [],
79
- kill_switch: "enabled",
80
- }),
81
- metadata: z.record(z.string(), z.any()).optional(),
82
- })
83
- .strict();
84
-
85
- export function normalizePluginId(rawValue) {
86
- const base = String(rawValue || "")
87
- .trim()
88
- .toLowerCase();
89
- if (!base) {
90
- throw new Error("Plugin id is required.");
91
- }
92
- const normalized = base
93
- .replace(/\s+/g, "-")
94
- .replace(/[^a-z0-9._-]+/g, "-")
95
- .replace(/^-+|-+$/g, "");
96
- if (!pluginIdRegex.test(normalized)) {
97
- throw new Error(
98
- "Plugin id is invalid. Use lowercase letters, numbers, '.', '_' or '-' (1-64 chars)."
99
- );
100
- }
101
- return normalized;
102
- }
103
-
104
- export function normalizePluginPackType(rawValue) {
105
- const normalized = String(rawValue || "plugin")
106
- .trim()
107
- .toLowerCase();
108
- if (!PLUGIN_PACK_TYPES.includes(normalized)) {
109
- throw new Error(`pack type must be one of: ${PLUGIN_PACK_TYPES.join(", ")}`);
110
- }
111
- return normalized;
112
- }
113
-
114
- export function normalizePluginLoadStage(rawValue) {
115
- const normalized = String(rawValue || "scan")
116
- .trim()
117
- .toLowerCase();
118
- if (!PLUGIN_LOAD_STAGES.includes(normalized)) {
119
- throw new Error(`load stage must be one of: ${PLUGIN_LOAD_STAGES.join(", ")}`);
120
- }
121
- return normalized;
122
- }
123
-
124
- function titleFromPluginId(pluginId) {
125
- return pluginId
126
- .split(/[._-]+/g)
127
- .filter(Boolean)
128
- .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
129
- .join(" ");
130
- }
131
-
132
- function buildDefaultCapabilities(packType) {
133
- if (packType === "template_pack") {
134
- return {
135
- commands: [],
136
- templates: ["templates/example-template.json"],
137
- policies: [],
138
- mcp_tools: [],
139
- };
140
- }
141
- if (packType === "policy_pack") {
142
- return {
143
- commands: [],
144
- templates: [],
145
- policies: ["policies/example-policy.json"],
146
- mcp_tools: [],
147
- };
148
- }
149
- if (packType === "hybrid") {
150
- return {
151
- commands: [],
152
- templates: ["templates/example-template.json"],
153
- policies: ["policies/example-policy.json"],
154
- mcp_tools: [],
155
- };
156
- }
157
- return {
158
- commands: [],
159
- templates: [],
160
- policies: [],
161
- mcp_tools: [],
162
- };
163
- }
164
-
165
- export function buildPluginManifestTemplate({
166
- pluginId,
167
- packType = "plugin",
168
- stage = "scan",
169
- generatedAt = new Date().toISOString(),
170
- } = {}) {
171
- const normalizedId = normalizePluginId(pluginId);
172
- const normalizedPackType = normalizePluginPackType(packType);
173
- const normalizedStage = normalizePluginLoadStage(stage);
174
- return {
175
- schema_version: PLUGIN_MANIFEST_SCHEMA_VERSION,
176
- kind: PLUGIN_MANIFEST_KIND,
177
- id: normalizedId,
178
- name: titleFromPluginId(normalizedId),
179
- version: "0.1.0",
180
- description: `Sentinelayer plugin package for ${normalizedId}.`,
181
- pack_type: normalizedPackType,
182
- entrypoint: {
183
- command: "node",
184
- args: ["index.js"],
185
- },
186
- load_order: {
187
- stage: normalizedStage,
188
- after: [],
189
- before: [],
190
- },
191
- capabilities: buildDefaultCapabilities(normalizedPackType),
192
- budgets: {
193
- max_runtime_ms: 20000,
194
- max_tool_calls: 20,
195
- },
196
- security: {
197
- requires_human_approval: false,
198
- allow_network: false,
199
- allowed_paths: [],
200
- kill_switch: "enabled",
201
- },
202
- metadata: {
203
- generated_at: generatedAt,
204
- generated_by: "create-sentinelayer",
205
- },
206
- };
207
- }
208
-
209
- function normalizeDependencySet(values = []) {
210
- const seen = new Set();
211
- const normalized = [];
212
- for (const rawValue of values) {
213
- const candidate = String(rawValue || "").trim().toLowerCase();
214
- if (!candidate) {
215
- continue;
216
- }
217
- if (!pluginIdRegex.test(candidate)) {
218
- throw new Error(`load_order reference '${rawValue}' is not a valid plugin id`);
219
- }
220
- if (!seen.has(candidate)) {
221
- seen.add(candidate);
222
- normalized.push(candidate);
223
- }
224
- }
225
- return normalized;
226
- }
227
-
228
- function validateManifestGovernance(manifest) {
229
- const normalizedAfter = normalizeDependencySet(manifest.load_order?.after || []);
230
- const normalizedBefore = normalizeDependencySet(manifest.load_order?.before || []);
231
-
232
- if (normalizedAfter.includes(manifest.id) || normalizedBefore.includes(manifest.id)) {
233
- throw new Error("load_order cannot reference the plugin itself");
234
- }
235
-
236
- const overlap = normalizedAfter.filter((value) => normalizedBefore.includes(value));
237
- if (overlap.length > 0) {
238
- throw new Error(`load_order.after and load_order.before overlap: ${overlap.join(", ")}`);
239
- }
240
-
241
- if (normalizedAfter.length > 25 || normalizedBefore.length > 25) {
242
- throw new Error("load_order dependency list is too large (max 25 entries for after/before)");
243
- }
244
-
245
- const templateCount = manifest.capabilities?.templates?.length || 0;
246
- const policyCount = manifest.capabilities?.policies?.length || 0;
247
-
248
- if (manifest.pack_type === "template_pack" && templateCount === 0) {
249
- throw new Error("template_pack must declare at least one template capability");
250
- }
251
- if (manifest.pack_type === "policy_pack" && policyCount === 0) {
252
- throw new Error("policy_pack must declare at least one policy capability");
253
- }
254
- if (manifest.pack_type === "plugin" && (templateCount > 0 || policyCount > 0)) {
255
- throw new Error("plugin pack_type cannot declare templates/policies (use template_pack/policy_pack/hybrid)");
256
- }
257
-
258
- return {
259
- ...manifest,
260
- load_order: {
261
- ...manifest.load_order,
262
- after: normalizedAfter,
263
- before: normalizedBefore,
264
- },
265
- };
266
- }
267
-
268
- export function validatePluginManifest(payload) {
269
- const parsed = pluginManifestSchema.parse(payload);
270
- return validateManifestGovernance(parsed);
271
- }
272
-
273
- export function summarizePluginValidationError(error) {
274
- if (!(error instanceof ZodError)) {
275
- return String(error instanceof Error ? error.message : error);
276
- }
277
- return error.issues
278
- .slice(0, 10)
279
- .map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`)
280
- .join("; ");
281
- }
282
-
283
- export function stringifyJson(value) {
284
- return `${JSON.stringify(value, null, 2)}\n`;
285
- }
286
-
287
- export async function writeJsonFile(filePath, value, { force = false } = {}) {
288
- const resolvedPath = path.resolve(filePath);
289
- if (!force) {
290
- try {
291
- await fsp.access(resolvedPath);
292
- throw new Error(`File already exists: ${resolvedPath}. Use --force to overwrite.`);
293
- } catch (error) {
294
- if (error && typeof error === "object" && error.code === "ENOENT") {
295
- // missing file is expected
296
- } else if (error instanceof Error && error.message.startsWith("File already exists:")) {
297
- throw error;
298
- } else if (error) {
299
- throw error;
300
- }
301
- }
302
- }
303
-
304
- await fsp.mkdir(path.dirname(resolvedPath), { recursive: true });
305
- await fsp.writeFile(resolvedPath, stringifyJson(value), "utf-8");
306
- return resolvedPath;
307
- }
308
-
309
- export async function readJsonFile(filePath) {
310
- const resolvedPath = path.resolve(filePath);
311
- const rawText = await fsp.readFile(resolvedPath, "utf-8");
312
- return {
313
- path: resolvedPath,
314
- data: JSON.parse(rawText),
315
- };
316
- }
317
-
318
- export async function resolvePluginsDirectoryPath({ cwd, outputDir, env } = {}) {
319
- const outputRoot = await resolveOutputRoot({
320
- cwd,
321
- outputDirOverride: outputDir,
322
- env,
323
- });
324
- return path.join(outputRoot, "plugins");
325
- }
326
-
327
- export async function resolveDefaultPluginManifestPath({ cwd, outputDir, env, pluginId } = {}) {
328
- const pluginsRoot = await resolvePluginsDirectoryPath({
329
- cwd,
330
- outputDir,
331
- env,
332
- });
333
- const normalizedId = normalizePluginId(pluginId);
334
- return path.join(pluginsRoot, normalizedId, "plugin.json");
335
- }
336
-
337
- async function collectPluginManifestPaths(rootDir) {
338
- const queue = [rootDir];
339
- const matches = [];
340
-
341
- while (queue.length > 0) {
342
- const currentDir = queue.pop();
343
- let entries = [];
344
- try {
345
- entries = await fsp.readdir(currentDir, { withFileTypes: true });
346
- } catch (error) {
347
- if (error && typeof error === "object" && error.code === "ENOENT") {
348
- continue;
349
- }
350
- throw error;
351
- }
352
-
353
- for (const entry of entries) {
354
- const entryPath = path.join(currentDir, entry.name);
355
- if (entry.isDirectory()) {
356
- queue.push(entryPath);
357
- continue;
358
- }
359
- if (entry.isFile() && entry.name === "plugin.json") {
360
- matches.push(entryPath);
361
- }
362
- }
363
- }
364
-
365
- matches.sort((left, right) => left.localeCompare(right));
366
- return matches;
367
- }
368
-
369
- export async function listPluginManifests({ cwd, outputDir, env } = {}) {
370
- const pluginsRoot = await resolvePluginsDirectoryPath({ cwd, outputDir, env });
371
- const manifestPaths = await collectPluginManifestPaths(pluginsRoot);
372
- const plugins = [];
373
- const invalid = [];
374
-
375
- for (const manifestPath of manifestPaths) {
376
- try {
377
- const loaded = await readJsonFile(manifestPath);
378
- const manifest = validatePluginManifest(loaded.data);
379
- plugins.push({
380
- id: manifest.id,
381
- name: manifest.name,
382
- version: manifest.version,
383
- packType: manifest.pack_type,
384
- stage: manifest.load_order.stage,
385
- commandCount: manifest.capabilities.commands.length,
386
- policyCount: manifest.capabilities.policies.length,
387
- templateCount: manifest.capabilities.templates.length,
388
- path: loaded.path,
389
- });
390
- } catch (error) {
391
- invalid.push({
392
- path: manifestPath,
393
- error: summarizePluginValidationError(error),
394
- });
395
- }
396
- }
397
-
398
- plugins.sort((left, right) => {
399
- if (left.id !== right.id) {
400
- return left.id.localeCompare(right.id);
401
- }
402
- return left.path.localeCompare(right.path);
403
- });
404
-
405
- return {
406
- pluginsRoot,
407
- plugins,
408
- invalid,
409
- };
410
- }
411
-
412
- function computeTopologicalOrderForStage(stagePlugins = []) {
413
- const pluginById = new Map(stagePlugins.map((plugin) => [plugin.id, plugin]));
414
- const edges = new Map();
415
- const indegree = new Map();
416
- const unresolvedReferences = [];
417
-
418
- for (const plugin of stagePlugins) {
419
- edges.set(plugin.id, new Set());
420
- indegree.set(plugin.id, 0);
421
- }
422
-
423
- for (const plugin of stagePlugins) {
424
- const after = plugin.load_order?.after || [];
425
- const before = plugin.load_order?.before || [];
426
-
427
- for (const dependencyId of after) {
428
- if (!pluginById.has(dependencyId)) {
429
- unresolvedReferences.push({
430
- pluginId: plugin.id,
431
- relation: "after",
432
- dependencyId,
433
- });
434
- continue;
435
- }
436
- if (!edges.get(dependencyId).has(plugin.id)) {
437
- edges.get(dependencyId).add(plugin.id);
438
- indegree.set(plugin.id, (indegree.get(plugin.id) || 0) + 1);
439
- }
440
- }
441
-
442
- for (const dependencyId of before) {
443
- if (!pluginById.has(dependencyId)) {
444
- unresolvedReferences.push({
445
- pluginId: plugin.id,
446
- relation: "before",
447
- dependencyId,
448
- });
449
- continue;
450
- }
451
- if (!edges.get(plugin.id).has(dependencyId)) {
452
- edges.get(plugin.id).add(dependencyId);
453
- indegree.set(dependencyId, (indegree.get(dependencyId) || 0) + 1);
454
- }
455
- }
456
- }
457
-
458
- const queue = [...stagePlugins.map((plugin) => plugin.id).filter((id) => (indegree.get(id) || 0) === 0)].sort(
459
- (left, right) => left.localeCompare(right)
460
- );
461
- const order = [];
462
-
463
- while (queue.length > 0) {
464
- const currentId = queue.shift();
465
- order.push(currentId);
466
-
467
- for (const neighborId of [...(edges.get(currentId) || [])].sort((left, right) => left.localeCompare(right))) {
468
- const nextInDegree = (indegree.get(neighborId) || 0) - 1;
469
- indegree.set(neighborId, nextInDegree);
470
- if (nextInDegree === 0) {
471
- queue.push(neighborId);
472
- queue.sort((left, right) => left.localeCompare(right));
473
- }
474
- }
475
- }
476
-
477
- const cycleNodes = stagePlugins
478
- .map((plugin) => plugin.id)
479
- .filter((pluginId) => (indegree.get(pluginId) || 0) > 0)
480
- .sort((left, right) => left.localeCompare(right));
481
-
482
- return {
483
- order,
484
- cycleDetected: cycleNodes.length > 0,
485
- cycleNodes,
486
- unresolvedReferences: unresolvedReferences.sort((left, right) => {
487
- if (left.pluginId !== right.pluginId) {
488
- return left.pluginId.localeCompare(right.pluginId);
489
- }
490
- if (left.relation !== right.relation) {
491
- return left.relation.localeCompare(right.relation);
492
- }
493
- return left.dependencyId.localeCompare(right.dependencyId);
494
- }),
495
- };
496
- }
497
-
498
- export async function computePluginLoadOrder({
499
- cwd,
500
- outputDir,
501
- env,
502
- stage = "",
503
- } = {}) {
504
- const normalizedStage = String(stage || "")
505
- .trim()
506
- .toLowerCase();
507
- if (normalizedStage && !PLUGIN_LOAD_STAGES.includes(normalizedStage)) {
508
- throw new Error(`stage must be one of: ${PLUGIN_LOAD_STAGES.join(", ")}`);
509
- }
510
-
511
- const listing = await listPluginManifests({
512
- cwd,
513
- outputDir,
514
- env,
515
- });
516
-
517
- const stageNames = normalizedStage ? [normalizedStage] : PLUGIN_LOAD_STAGES;
518
- const stages = [];
519
-
520
- for (const stageName of stageNames) {
521
- const stagePlugins = listing.plugins
522
- .filter((plugin) => plugin.stage === stageName)
523
- .sort((left, right) => left.id.localeCompare(right.id));
524
-
525
- const manifestById = new Map();
526
- for (const plugin of stagePlugins) {
527
- const loaded = await readJsonFile(plugin.path);
528
- const manifest = validatePluginManifest(loaded.data);
529
- manifestById.set(plugin.id, manifest);
530
- }
531
-
532
- const orderResult = computeTopologicalOrderForStage([...manifestById.values()]);
533
- stages.push({
534
- stage: stageName,
535
- pluginCount: stagePlugins.length,
536
- order: orderResult.order,
537
- cycleDetected: orderResult.cycleDetected,
538
- cycleNodes: orderResult.cycleNodes,
539
- unresolvedReferences: orderResult.unresolvedReferences,
540
- });
541
- }
542
-
543
- const invalidCount = listing.invalid.length;
544
- const cycleStageCount = stages.filter((stageEntry) => stageEntry.cycleDetected).length;
545
-
546
- return {
547
- pluginsRoot: listing.pluginsRoot,
548
- invalidCount,
549
- invalid: listing.invalid,
550
- stages,
551
- hasBlockingIssues: invalidCount > 0 || cycleStageCount > 0,
552
- };
553
- }
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { ZodError, z } from "zod";
5
+
6
+ import { resolveOutputRoot } from "../config/service.js";
7
+
8
+ export const PLUGIN_MANIFEST_SCHEMA_VERSION = "1.0.0";
9
+ export const PLUGIN_MANIFEST_KIND = "sentinelayer.cli.plugin";
10
+ export const PLUGIN_PACK_TYPES = ["plugin", "template_pack", "policy_pack", "hybrid"];
11
+ export const PLUGIN_LOAD_STAGES = ["pre_scan", "scan", "post_scan", "reporting"];
12
+
13
+ const pluginIdRegex = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
14
+ const semverRegex = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
15
+
16
+ const pluginManifestSchema = z
17
+ .object({
18
+ schema_version: z.literal(PLUGIN_MANIFEST_SCHEMA_VERSION).default(PLUGIN_MANIFEST_SCHEMA_VERSION),
19
+ kind: z.literal(PLUGIN_MANIFEST_KIND).default(PLUGIN_MANIFEST_KIND),
20
+ id: z.string().regex(pluginIdRegex, "plugin id must match [a-z0-9._-] and be 1-64 chars"),
21
+ name: z.string().min(1),
22
+ version: z.string().regex(semverRegex, "version must use semver (for example 0.1.0)"),
23
+ description: z.string().min(1),
24
+ pack_type: z.enum(PLUGIN_PACK_TYPES).default("plugin"),
25
+ entrypoint: z
26
+ .object({
27
+ command: z.string().min(1),
28
+ args: z.array(z.string()).default([]),
29
+ })
30
+ .strict(),
31
+ load_order: z
32
+ .object({
33
+ stage: z.enum(PLUGIN_LOAD_STAGES).default("scan"),
34
+ after: z.array(z.string()).default([]),
35
+ before: z.array(z.string()).default([]),
36
+ })
37
+ .strict()
38
+ .default({
39
+ stage: "scan",
40
+ after: [],
41
+ before: [],
42
+ }),
43
+ capabilities: z
44
+ .object({
45
+ commands: z.array(z.string()).default([]),
46
+ templates: z.array(z.string()).default([]),
47
+ policies: z.array(z.string()).default([]),
48
+ mcp_tools: z.array(z.string()).default([]),
49
+ })
50
+ .strict()
51
+ .default({
52
+ commands: [],
53
+ templates: [],
54
+ policies: [],
55
+ mcp_tools: [],
56
+ }),
57
+ budgets: z
58
+ .object({
59
+ max_runtime_ms: z.number().int().positive().default(20000),
60
+ max_tool_calls: z.number().int().positive().default(20),
61
+ })
62
+ .strict()
63
+ .default({
64
+ max_runtime_ms: 20000,
65
+ max_tool_calls: 20,
66
+ }),
67
+ security: z
68
+ .object({
69
+ requires_human_approval: z.boolean().default(false),
70
+ allow_network: z.boolean().default(false),
71
+ allowed_paths: z.array(z.string()).default([]),
72
+ kill_switch: z.enum(["enabled", "disabled"]).default("enabled"),
73
+ })
74
+ .strict()
75
+ .default({
76
+ requires_human_approval: false,
77
+ allow_network: false,
78
+ allowed_paths: [],
79
+ kill_switch: "enabled",
80
+ }),
81
+ metadata: z.record(z.string(), z.any()).optional(),
82
+ })
83
+ .strict();
84
+
85
+ export function normalizePluginId(rawValue) {
86
+ const base = String(rawValue || "")
87
+ .trim()
88
+ .toLowerCase();
89
+ if (!base) {
90
+ throw new Error("Plugin id is required.");
91
+ }
92
+ const normalized = base
93
+ .replace(/\s+/g, "-")
94
+ .replace(/[^a-z0-9._-]+/g, "-")
95
+ .replace(/^-+|-+$/g, "");
96
+ if (!pluginIdRegex.test(normalized)) {
97
+ throw new Error(
98
+ "Plugin id is invalid. Use lowercase letters, numbers, '.', '_' or '-' (1-64 chars)."
99
+ );
100
+ }
101
+ return normalized;
102
+ }
103
+
104
+ export function normalizePluginPackType(rawValue) {
105
+ const normalized = String(rawValue || "plugin")
106
+ .trim()
107
+ .toLowerCase();
108
+ if (!PLUGIN_PACK_TYPES.includes(normalized)) {
109
+ throw new Error(`pack type must be one of: ${PLUGIN_PACK_TYPES.join(", ")}`);
110
+ }
111
+ return normalized;
112
+ }
113
+
114
+ export function normalizePluginLoadStage(rawValue) {
115
+ const normalized = String(rawValue || "scan")
116
+ .trim()
117
+ .toLowerCase();
118
+ if (!PLUGIN_LOAD_STAGES.includes(normalized)) {
119
+ throw new Error(`load stage must be one of: ${PLUGIN_LOAD_STAGES.join(", ")}`);
120
+ }
121
+ return normalized;
122
+ }
123
+
124
+ function titleFromPluginId(pluginId) {
125
+ return pluginId
126
+ .split(/[._-]+/g)
127
+ .filter(Boolean)
128
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
129
+ .join(" ");
130
+ }
131
+
132
+ function buildDefaultCapabilities(packType) {
133
+ if (packType === "template_pack") {
134
+ return {
135
+ commands: [],
136
+ templates: ["templates/example-template.json"],
137
+ policies: [],
138
+ mcp_tools: [],
139
+ };
140
+ }
141
+ if (packType === "policy_pack") {
142
+ return {
143
+ commands: [],
144
+ templates: [],
145
+ policies: ["policies/example-policy.json"],
146
+ mcp_tools: [],
147
+ };
148
+ }
149
+ if (packType === "hybrid") {
150
+ return {
151
+ commands: [],
152
+ templates: ["templates/example-template.json"],
153
+ policies: ["policies/example-policy.json"],
154
+ mcp_tools: [],
155
+ };
156
+ }
157
+ return {
158
+ commands: [],
159
+ templates: [],
160
+ policies: [],
161
+ mcp_tools: [],
162
+ };
163
+ }
164
+
165
+ export function buildPluginManifestTemplate({
166
+ pluginId,
167
+ packType = "plugin",
168
+ stage = "scan",
169
+ generatedAt = new Date().toISOString(),
170
+ } = {}) {
171
+ const normalizedId = normalizePluginId(pluginId);
172
+ const normalizedPackType = normalizePluginPackType(packType);
173
+ const normalizedStage = normalizePluginLoadStage(stage);
174
+ return {
175
+ schema_version: PLUGIN_MANIFEST_SCHEMA_VERSION,
176
+ kind: PLUGIN_MANIFEST_KIND,
177
+ id: normalizedId,
178
+ name: titleFromPluginId(normalizedId),
179
+ version: "0.1.0",
180
+ description: `Sentinelayer plugin package for ${normalizedId}.`,
181
+ pack_type: normalizedPackType,
182
+ entrypoint: {
183
+ command: "node",
184
+ args: ["index.js"],
185
+ },
186
+ load_order: {
187
+ stage: normalizedStage,
188
+ after: [],
189
+ before: [],
190
+ },
191
+ capabilities: buildDefaultCapabilities(normalizedPackType),
192
+ budgets: {
193
+ max_runtime_ms: 20000,
194
+ max_tool_calls: 20,
195
+ },
196
+ security: {
197
+ requires_human_approval: false,
198
+ allow_network: false,
199
+ allowed_paths: [],
200
+ kill_switch: "enabled",
201
+ },
202
+ metadata: {
203
+ generated_at: generatedAt,
204
+ generated_by: "create-sentinelayer",
205
+ },
206
+ };
207
+ }
208
+
209
+ function normalizeDependencySet(values = []) {
210
+ const seen = new Set();
211
+ const normalized = [];
212
+ for (const rawValue of values) {
213
+ const candidate = String(rawValue || "").trim().toLowerCase();
214
+ if (!candidate) {
215
+ continue;
216
+ }
217
+ if (!pluginIdRegex.test(candidate)) {
218
+ throw new Error(`load_order reference '${rawValue}' is not a valid plugin id`);
219
+ }
220
+ if (!seen.has(candidate)) {
221
+ seen.add(candidate);
222
+ normalized.push(candidate);
223
+ }
224
+ }
225
+ return normalized;
226
+ }
227
+
228
+ function validateManifestGovernance(manifest) {
229
+ const normalizedAfter = normalizeDependencySet(manifest.load_order?.after || []);
230
+ const normalizedBefore = normalizeDependencySet(manifest.load_order?.before || []);
231
+
232
+ if (normalizedAfter.includes(manifest.id) || normalizedBefore.includes(manifest.id)) {
233
+ throw new Error("load_order cannot reference the plugin itself");
234
+ }
235
+
236
+ const overlap = normalizedAfter.filter((value) => normalizedBefore.includes(value));
237
+ if (overlap.length > 0) {
238
+ throw new Error(`load_order.after and load_order.before overlap: ${overlap.join(", ")}`);
239
+ }
240
+
241
+ if (normalizedAfter.length > 25 || normalizedBefore.length > 25) {
242
+ throw new Error("load_order dependency list is too large (max 25 entries for after/before)");
243
+ }
244
+
245
+ const templateCount = manifest.capabilities?.templates?.length || 0;
246
+ const policyCount = manifest.capabilities?.policies?.length || 0;
247
+
248
+ if (manifest.pack_type === "template_pack" && templateCount === 0) {
249
+ throw new Error("template_pack must declare at least one template capability");
250
+ }
251
+ if (manifest.pack_type === "policy_pack" && policyCount === 0) {
252
+ throw new Error("policy_pack must declare at least one policy capability");
253
+ }
254
+ if (manifest.pack_type === "plugin" && (templateCount > 0 || policyCount > 0)) {
255
+ throw new Error("plugin pack_type cannot declare templates/policies (use template_pack/policy_pack/hybrid)");
256
+ }
257
+
258
+ return {
259
+ ...manifest,
260
+ load_order: {
261
+ ...manifest.load_order,
262
+ after: normalizedAfter,
263
+ before: normalizedBefore,
264
+ },
265
+ };
266
+ }
267
+
268
+ export function validatePluginManifest(payload) {
269
+ const parsed = pluginManifestSchema.parse(payload);
270
+ return validateManifestGovernance(parsed);
271
+ }
272
+
273
+ export function summarizePluginValidationError(error) {
274
+ if (!(error instanceof ZodError)) {
275
+ return String(error instanceof Error ? error.message : error);
276
+ }
277
+ return error.issues
278
+ .slice(0, 10)
279
+ .map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`)
280
+ .join("; ");
281
+ }
282
+
283
+ export function stringifyJson(value) {
284
+ return `${JSON.stringify(value, null, 2)}\n`;
285
+ }
286
+
287
+ export async function writeJsonFile(filePath, value, { force = false } = {}) {
288
+ const resolvedPath = path.resolve(filePath);
289
+ if (!force) {
290
+ try {
291
+ await fsp.access(resolvedPath);
292
+ throw new Error(`File already exists: ${resolvedPath}. Use --force to overwrite.`);
293
+ } catch (error) {
294
+ if (error && typeof error === "object" && error.code === "ENOENT") {
295
+ // missing file is expected
296
+ } else if (error instanceof Error && error.message.startsWith("File already exists:")) {
297
+ throw error;
298
+ } else if (error) {
299
+ throw error;
300
+ }
301
+ }
302
+ }
303
+
304
+ await fsp.mkdir(path.dirname(resolvedPath), { recursive: true });
305
+ await fsp.writeFile(resolvedPath, stringifyJson(value), "utf-8");
306
+ return resolvedPath;
307
+ }
308
+
309
+ export async function readJsonFile(filePath) {
310
+ const resolvedPath = path.resolve(filePath);
311
+ const rawText = await fsp.readFile(resolvedPath, "utf-8");
312
+ return {
313
+ path: resolvedPath,
314
+ data: JSON.parse(rawText),
315
+ };
316
+ }
317
+
318
+ export async function resolvePluginsDirectoryPath({ cwd, outputDir, env } = {}) {
319
+ const outputRoot = await resolveOutputRoot({
320
+ cwd,
321
+ outputDirOverride: outputDir,
322
+ env,
323
+ });
324
+ return path.join(outputRoot, "plugins");
325
+ }
326
+
327
+ export async function resolveDefaultPluginManifestPath({ cwd, outputDir, env, pluginId } = {}) {
328
+ const pluginsRoot = await resolvePluginsDirectoryPath({
329
+ cwd,
330
+ outputDir,
331
+ env,
332
+ });
333
+ const normalizedId = normalizePluginId(pluginId);
334
+ return path.join(pluginsRoot, normalizedId, "plugin.json");
335
+ }
336
+
337
+ async function collectPluginManifestPaths(rootDir) {
338
+ const queue = [rootDir];
339
+ const matches = [];
340
+
341
+ while (queue.length > 0) {
342
+ const currentDir = queue.pop();
343
+ let entries = [];
344
+ try {
345
+ entries = await fsp.readdir(currentDir, { withFileTypes: true });
346
+ } catch (error) {
347
+ if (error && typeof error === "object" && error.code === "ENOENT") {
348
+ continue;
349
+ }
350
+ throw error;
351
+ }
352
+
353
+ for (const entry of entries) {
354
+ const entryPath = path.join(currentDir, entry.name);
355
+ if (entry.isDirectory()) {
356
+ queue.push(entryPath);
357
+ continue;
358
+ }
359
+ if (entry.isFile() && entry.name === "plugin.json") {
360
+ matches.push(entryPath);
361
+ }
362
+ }
363
+ }
364
+
365
+ matches.sort((left, right) => left.localeCompare(right));
366
+ return matches;
367
+ }
368
+
369
+ export async function listPluginManifests({ cwd, outputDir, env } = {}) {
370
+ const pluginsRoot = await resolvePluginsDirectoryPath({ cwd, outputDir, env });
371
+ const manifestPaths = await collectPluginManifestPaths(pluginsRoot);
372
+ const plugins = [];
373
+ const invalid = [];
374
+
375
+ for (const manifestPath of manifestPaths) {
376
+ try {
377
+ const loaded = await readJsonFile(manifestPath);
378
+ const manifest = validatePluginManifest(loaded.data);
379
+ plugins.push({
380
+ id: manifest.id,
381
+ name: manifest.name,
382
+ version: manifest.version,
383
+ packType: manifest.pack_type,
384
+ stage: manifest.load_order.stage,
385
+ commandCount: manifest.capabilities.commands.length,
386
+ policyCount: manifest.capabilities.policies.length,
387
+ templateCount: manifest.capabilities.templates.length,
388
+ path: loaded.path,
389
+ });
390
+ } catch (error) {
391
+ invalid.push({
392
+ path: manifestPath,
393
+ error: summarizePluginValidationError(error),
394
+ });
395
+ }
396
+ }
397
+
398
+ plugins.sort((left, right) => {
399
+ if (left.id !== right.id) {
400
+ return left.id.localeCompare(right.id);
401
+ }
402
+ return left.path.localeCompare(right.path);
403
+ });
404
+
405
+ return {
406
+ pluginsRoot,
407
+ plugins,
408
+ invalid,
409
+ };
410
+ }
411
+
412
+ function computeTopologicalOrderForStage(stagePlugins = []) {
413
+ const pluginById = new Map(stagePlugins.map((plugin) => [plugin.id, plugin]));
414
+ const edges = new Map();
415
+ const indegree = new Map();
416
+ const unresolvedReferences = [];
417
+
418
+ for (const plugin of stagePlugins) {
419
+ edges.set(plugin.id, new Set());
420
+ indegree.set(plugin.id, 0);
421
+ }
422
+
423
+ for (const plugin of stagePlugins) {
424
+ const after = plugin.load_order?.after || [];
425
+ const before = plugin.load_order?.before || [];
426
+
427
+ for (const dependencyId of after) {
428
+ if (!pluginById.has(dependencyId)) {
429
+ unresolvedReferences.push({
430
+ pluginId: plugin.id,
431
+ relation: "after",
432
+ dependencyId,
433
+ });
434
+ continue;
435
+ }
436
+ if (!edges.get(dependencyId).has(plugin.id)) {
437
+ edges.get(dependencyId).add(plugin.id);
438
+ indegree.set(plugin.id, (indegree.get(plugin.id) || 0) + 1);
439
+ }
440
+ }
441
+
442
+ for (const dependencyId of before) {
443
+ if (!pluginById.has(dependencyId)) {
444
+ unresolvedReferences.push({
445
+ pluginId: plugin.id,
446
+ relation: "before",
447
+ dependencyId,
448
+ });
449
+ continue;
450
+ }
451
+ if (!edges.get(plugin.id).has(dependencyId)) {
452
+ edges.get(plugin.id).add(dependencyId);
453
+ indegree.set(dependencyId, (indegree.get(dependencyId) || 0) + 1);
454
+ }
455
+ }
456
+ }
457
+
458
+ const queue = [...stagePlugins.map((plugin) => plugin.id).filter((id) => (indegree.get(id) || 0) === 0)].sort(
459
+ (left, right) => left.localeCompare(right)
460
+ );
461
+ const order = [];
462
+
463
+ while (queue.length > 0) {
464
+ const currentId = queue.shift();
465
+ order.push(currentId);
466
+
467
+ for (const neighborId of [...(edges.get(currentId) || [])].sort((left, right) => left.localeCompare(right))) {
468
+ const nextInDegree = (indegree.get(neighborId) || 0) - 1;
469
+ indegree.set(neighborId, nextInDegree);
470
+ if (nextInDegree === 0) {
471
+ queue.push(neighborId);
472
+ queue.sort((left, right) => left.localeCompare(right));
473
+ }
474
+ }
475
+ }
476
+
477
+ const cycleNodes = stagePlugins
478
+ .map((plugin) => plugin.id)
479
+ .filter((pluginId) => (indegree.get(pluginId) || 0) > 0)
480
+ .sort((left, right) => left.localeCompare(right));
481
+
482
+ return {
483
+ order,
484
+ cycleDetected: cycleNodes.length > 0,
485
+ cycleNodes,
486
+ unresolvedReferences: unresolvedReferences.sort((left, right) => {
487
+ if (left.pluginId !== right.pluginId) {
488
+ return left.pluginId.localeCompare(right.pluginId);
489
+ }
490
+ if (left.relation !== right.relation) {
491
+ return left.relation.localeCompare(right.relation);
492
+ }
493
+ return left.dependencyId.localeCompare(right.dependencyId);
494
+ }),
495
+ };
496
+ }
497
+
498
+ export async function computePluginLoadOrder({
499
+ cwd,
500
+ outputDir,
501
+ env,
502
+ stage = "",
503
+ } = {}) {
504
+ const normalizedStage = String(stage || "")
505
+ .trim()
506
+ .toLowerCase();
507
+ if (normalizedStage && !PLUGIN_LOAD_STAGES.includes(normalizedStage)) {
508
+ throw new Error(`stage must be one of: ${PLUGIN_LOAD_STAGES.join(", ")}`);
509
+ }
510
+
511
+ const listing = await listPluginManifests({
512
+ cwd,
513
+ outputDir,
514
+ env,
515
+ });
516
+
517
+ const stageNames = normalizedStage ? [normalizedStage] : PLUGIN_LOAD_STAGES;
518
+ const stages = [];
519
+
520
+ for (const stageName of stageNames) {
521
+ const stagePlugins = listing.plugins
522
+ .filter((plugin) => plugin.stage === stageName)
523
+ .sort((left, right) => left.id.localeCompare(right.id));
524
+
525
+ const manifestById = new Map();
526
+ for (const plugin of stagePlugins) {
527
+ const loaded = await readJsonFile(plugin.path);
528
+ const manifest = validatePluginManifest(loaded.data);
529
+ manifestById.set(plugin.id, manifest);
530
+ }
531
+
532
+ const orderResult = computeTopologicalOrderForStage([...manifestById.values()]);
533
+ stages.push({
534
+ stage: stageName,
535
+ pluginCount: stagePlugins.length,
536
+ order: orderResult.order,
537
+ cycleDetected: orderResult.cycleDetected,
538
+ cycleNodes: orderResult.cycleNodes,
539
+ unresolvedReferences: orderResult.unresolvedReferences,
540
+ });
541
+ }
542
+
543
+ const invalidCount = listing.invalid.length;
544
+ const cycleStageCount = stages.filter((stageEntry) => stageEntry.cycleDetected).length;
545
+
546
+ return {
547
+ pluginsRoot: listing.pluginsRoot,
548
+ invalidCount,
549
+ invalid: listing.invalid,
550
+ stages,
551
+ hasBlockingIssues: invalidCount > 0 || cycleStageCount > 0,
552
+ };
553
+ }