@deftai/directive-core 0.66.1 → 0.67.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 (71) hide show
  1. package/dist/agents-md-advisory/evaluate.d.ts +44 -0
  2. package/dist/agents-md-advisory/evaluate.js +115 -0
  3. package/dist/agents-md-advisory/index.d.ts +2 -0
  4. package/dist/agents-md-advisory/index.js +2 -0
  5. package/dist/agents-md-budget/evaluate.d.ts +38 -0
  6. package/dist/agents-md-budget/evaluate.js +152 -0
  7. package/dist/agents-md-budget/index.d.ts +2 -0
  8. package/dist/agents-md-budget/index.js +2 -0
  9. package/dist/codebase/map.d.ts +1 -0
  10. package/dist/codebase/map.js +5 -2
  11. package/dist/doctor/constants.d.ts +5 -2
  12. package/dist/doctor/constants.js +15 -1
  13. package/dist/doctor/flags.js +5 -1
  14. package/dist/doctor/main.js +99 -8
  15. package/dist/doctor/paths.d.ts +1 -0
  16. package/dist/doctor/paths.js +55 -0
  17. package/dist/doctor/types.d.ts +7 -0
  18. package/dist/intake/github-auth-modes.d.ts +1 -0
  19. package/dist/intake/github-auth-modes.js +1 -0
  20. package/dist/intake/issue-ingest.d.ts +6 -2
  21. package/dist/intake/issue-ingest.js +65 -9
  22. package/dist/platform/agents-consumer-header.d.ts +13 -0
  23. package/dist/platform/agents-consumer-header.js +57 -0
  24. package/dist/platform/agents-md.js +4 -1
  25. package/dist/platform/index.d.ts +1 -0
  26. package/dist/platform/index.js +1 -0
  27. package/dist/policy/agents-md-advisory.d.ts +52 -0
  28. package/dist/policy/agents-md-advisory.js +63 -0
  29. package/dist/policy/agents-md-budget.d.ts +24 -0
  30. package/dist/policy/agents-md-budget.js +89 -0
  31. package/dist/policy/index.d.ts +1 -0
  32. package/dist/policy/index.js +1 -0
  33. package/dist/pr-merge-readiness/ci-gate.d.ts +28 -0
  34. package/dist/pr-merge-readiness/ci-gate.js +79 -0
  35. package/dist/pr-merge-readiness/compute.d.ts +5 -1
  36. package/dist/pr-merge-readiness/compute.js +90 -6
  37. package/dist/pr-merge-readiness/gh.d.ts +8 -0
  38. package/dist/pr-merge-readiness/gh.js +30 -5
  39. package/dist/pr-merge-readiness/index.d.ts +3 -0
  40. package/dist/pr-merge-readiness/index.js +2 -0
  41. package/dist/pr-merge-readiness/main.d.ts +3 -0
  42. package/dist/pr-merge-readiness/main.js +73 -6
  43. package/dist/pr-merge-readiness/output.js +20 -0
  44. package/dist/pr-merge-readiness/slizard-gate.d.ts +47 -0
  45. package/dist/pr-merge-readiness/slizard-gate.js +119 -0
  46. package/dist/preflight-cache/evaluate.d.ts +1 -0
  47. package/dist/preflight-cache/evaluate.js +17 -10
  48. package/dist/render/project-render.d.ts +16 -2
  49. package/dist/render/project-render.js +55 -22
  50. package/dist/swarm/launch.d.ts +6 -0
  51. package/dist/swarm/launch.js +2 -2
  52. package/dist/swarm/routing-verify.d.ts +1 -1
  53. package/dist/swarm/routing-verify.js +11 -10
  54. package/dist/swarm/routing.d.ts +9 -0
  55. package/dist/swarm/routing.js +46 -0
  56. package/dist/swarm/verify-review-clean-cli.js +25 -1
  57. package/dist/swarm/verify-review-clean.d.ts +9 -1
  58. package/dist/swarm/verify-review-clean.js +76 -3
  59. package/dist/verify-source/biome-config.d.ts +41 -0
  60. package/dist/verify-source/biome-config.js +97 -0
  61. package/dist/verify-source/index.d.ts +1 -0
  62. package/dist/verify-source/index.js +1 -0
  63. package/dist/xbrief-migrate/agents-header.d.ts +69 -0
  64. package/dist/xbrief-migrate/agents-header.js +179 -0
  65. package/dist/xbrief-migrate/drift-gate.js +2 -0
  66. package/dist/xbrief-migrate/fs-helpers.d.ts +3 -0
  67. package/dist/xbrief-migrate/fs-helpers.js +11 -0
  68. package/dist/xbrief-migrate/index.d.ts +1 -0
  69. package/dist/xbrief-migrate/index.js +1 -0
  70. package/dist/xbrief-migrate/migrate-project.js +29 -10
  71. package/package.json +11 -3
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Dedicated gate for the SLizard second-reviewer verdict (#2189).
3
+ *
4
+ * SLizard posts a check-run whose `output.summary` carries a structured verdict:
5
+ *
6
+ * Decision: request_changes
7
+ * Merge impact: blocking
8
+ * Findings: 2 (P0: 0, P1: 1, P2: 0, P3: 0)
9
+ *
10
+ * The generic CI check-run gate (#2169) only fails closed on a check-run
11
+ * `conclusion` in the failed set, so a blocking *decision* carried on a
12
+ * non-`failure` conclusion (e.g. `neutral`) would slip through and the review
13
+ * is surfaced indistinctly among build/test checks. This gate parses the
14
+ * structured verdict and fails merge-readiness on a blocking decision.
15
+ */
16
+ /** Canonical SLizard check-run name; matching is case-insensitive substring for resilience. */
17
+ export const SLIZARD_CHECK_NAME = "SLizard";
18
+ const FAILED_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out"]);
19
+ const PENDING_STATUSES = new Set(["queued", "in_progress"]);
20
+ const BLOCKING_DECISIONS = new Set(["request_changes", "changes_requested", "reject"]);
21
+ export function isSlizardCheck(name) {
22
+ return name.toLowerCase().includes("slizard");
23
+ }
24
+ function firstMatch(text, re) {
25
+ const m = re.exec(text);
26
+ return m?.[1] !== undefined ? m[1].trim() : null;
27
+ }
28
+ function countFor(text, sev) {
29
+ const m = new RegExp(`${sev}\\s*:\\s*(\\d+)`, "i").exec(text);
30
+ return m?.[1] !== undefined ? Number.parseInt(m[1], 10) : null;
31
+ }
32
+ /** Parse a SLizard check-run `output.summary` into a structured verdict. */
33
+ export function parseSlizardVerdict(summary) {
34
+ const text = summary ?? "";
35
+ const decision = firstMatch(text, /Decision\s*:\s*([A-Za-z_]+)/i);
36
+ const mergeImpact = firstMatch(text, /Merge impact\s*:\s*([A-Za-z_-]+)/i);
37
+ return {
38
+ decision: decision ? decision.toLowerCase() : null,
39
+ mergeImpact: mergeImpact ? mergeImpact.toLowerCase() : null,
40
+ p0Count: countFor(text, "P0"),
41
+ p1Count: countFor(text, "P1"),
42
+ p2Count: countFor(text, "P2"),
43
+ };
44
+ }
45
+ function skippedSummary(reason) {
46
+ return {
47
+ ready_state: "skipped",
48
+ present: false,
49
+ check_name: null,
50
+ status: null,
51
+ conclusion: null,
52
+ verdict: null,
53
+ summary_line: `SLizard review: skipped (${reason})`,
54
+ };
55
+ }
56
+ function isPending(status, conclusion) {
57
+ if (PENDING_STATUSES.has(status)) {
58
+ return true;
59
+ }
60
+ return status !== "completed" || conclusion === "none";
61
+ }
62
+ export function evaluateSlizardGate(checkRuns, options = {}) {
63
+ if (options.skipSlizard === true) {
64
+ return { failures: [], summary: skippedSummary("--skip-slizard") };
65
+ }
66
+ const run = checkRuns.find((r) => isSlizardCheck(r.name));
67
+ if (run === undefined) {
68
+ // SLizard is an optional second reviewer; its absence does not block merge.
69
+ return { failures: [], summary: skippedSummary("no SLizard check-run on this commit") };
70
+ }
71
+ const verdict = parseSlizardVerdict(run.summary);
72
+ const blockingDecision = verdict.decision !== null && BLOCKING_DECISIONS.has(verdict.decision);
73
+ const blockingImpact = verdict.mergeImpact === "blocking";
74
+ const failedConclusion = FAILED_CONCLUSIONS.has(run.conclusion);
75
+ const failures = [];
76
+ let readyState;
77
+ if (blockingDecision || blockingImpact || failedConclusion) {
78
+ readyState = "blocked";
79
+ const reasons = [];
80
+ if (verdict.decision !== null) {
81
+ reasons.push(`decision=${verdict.decision}`);
82
+ }
83
+ if (verdict.mergeImpact !== null) {
84
+ reasons.push(`merge impact=${verdict.mergeImpact}`);
85
+ }
86
+ if (failedConclusion) {
87
+ reasons.push(`conclusion=${run.conclusion}`);
88
+ }
89
+ const findings = verdict.p0Count !== null || verdict.p1Count !== null || verdict.p2Count !== null
90
+ ? ` (P0=${verdict.p0Count ?? 0} P1=${verdict.p1Count ?? 0} P2=${verdict.p2Count ?? 0})`
91
+ : "";
92
+ failures.push(`SLizard review is blocking: ${reasons.join(", ")}${findings}. ` +
93
+ "Resolve the SLizard findings or pass --skip-slizard to override (#2189).");
94
+ }
95
+ else if (isPending(run.status, run.conclusion)) {
96
+ readyState = "not_ready_yet";
97
+ failures.push(`SLizard review still in progress (${run.status}); wait for the verdict before merge (#2189).`);
98
+ }
99
+ else {
100
+ readyState = "ready";
101
+ }
102
+ const parts = [
103
+ `decision=${verdict.decision ?? "?"}`,
104
+ `impact=${verdict.mergeImpact ?? "?"}`,
105
+ ];
106
+ return {
107
+ failures,
108
+ summary: {
109
+ ready_state: readyState,
110
+ present: true,
111
+ check_name: run.name,
112
+ status: run.status,
113
+ conclusion: run.conclusion,
114
+ verdict,
115
+ summary_line: `SLizard review: ${readyState} (${parts.join(", ")})`,
116
+ },
117
+ };
118
+ }
119
+ //# sourceMappingURL=slizard-gate.js.map
@@ -34,6 +34,7 @@ export interface EvaluateOptions {
34
34
  /** Injectable drift probe for tests. */
35
35
  probeDriftFn?: (repo: string, cacheRoot: string, source: string) => CacheDriftProbeResult | null;
36
36
  }
37
+ export declare function normaliseRepoUrl(url: string): string | null;
37
38
  /**
38
39
  * Branch-aware recovery hint (#1953 Option 3).
39
40
  * Age-stale (or age+drift mixed) → --force bypasses TTL; drift-only → plain refetch.
@@ -44,19 +44,26 @@ function inferRepoFromGit(projectRoot) {
44
44
  return null;
45
45
  }
46
46
  }
47
- function normaliseRepoUrl(url) {
47
+ export function normaliseRepoUrl(url) {
48
48
  if (!url)
49
49
  return null;
50
- const cleaned = url.replace(/\/$/, "").replace(/\.git$/, "");
51
- if (!cleaned.includes("github.com"))
50
+ const cleaned = url
51
+ .trim()
52
+ .replace(/\/$/, "")
53
+ .replace(/\.git$/, "");
54
+ // Match `github.com` only as the HOST component of the remote URL: right
55
+ // after an optional scheme and optional `user@`, and immediately followed by
56
+ // `:` or `/`. A plain `includes("github.com")` would also accept spoofed
57
+ // hosts like `github.com.evil.com` or `evil.com/github.com/...` (CodeQL
58
+ // js/incomplete-url-substring-sanitization). Supports https/ssh/git schemes
59
+ // and the scp-like `git@github.com:owner/repo` form.
60
+ const match = cleaned.match(/^(?:(?:https?|ssh|git):\/\/)?(?:[^/@]+@)?github\.com[:/]+([^/]+)\/([^/]+)/i);
61
+ if (!match)
52
62
  return null;
53
- const tail = cleaned
54
- .split("github.com")
55
- .pop()
56
- ?.replace(/^[:/]+/, "") ?? "";
57
- const parts = tail.split("/");
58
- if (parts.length >= 2 && parts[0] && parts[1]) {
59
- return `${parts[0]}/${parts[1]}`;
63
+ const owner = match[1];
64
+ const repo = match[2];
65
+ if (owner && repo) {
66
+ return `${owner}/${repo}`;
60
67
  }
61
68
  return null;
62
69
  }
@@ -1,4 +1,11 @@
1
+ import { LEGACY_INFO_ROOT_KEY, MIGRATED_INFO_ROOT_KEY } from "../xbrief-migrate/constants.js";
1
2
  type JsonObject = Record<string, unknown>;
3
+ /** PROJECT-DEFINITION artifact shape (filename + envelope key + emitted version). */
4
+ interface ProjectDefinitionLayout {
5
+ readonly filename: string;
6
+ readonly infoRootKey: typeof MIGRATED_INFO_ROOT_KEY | typeof LEGACY_INFO_ROOT_KEY;
7
+ readonly infoVersion: string;
8
+ }
2
9
  /** Durable review state for PROJECT-DEFINITION narrative staleness (#640). */
3
10
  export interface StalenessReviewMetadata {
4
11
  /** ISO-8601 UTC when narratives were last reviewed/acknowledged. */
@@ -24,12 +31,19 @@ export declare function buildStalenessAcknowledgement(completedItems: readonly L
24
31
  existing?: StalenessReviewMetadata | null;
25
32
  }): StalenessReviewMetadata;
26
33
  export declare function computeStalenessFlags(narratives: Record<string, string>, completedItems: readonly LifecycleItem[], review?: StalenessReviewMetadata | null): string[];
27
- export declare function createSkeleton(items: LifecycleItem[], now: string): JsonObject;
34
+ export declare function createSkeleton(items: LifecycleItem[], now: string, layout: ProjectDefinitionLayout): JsonObject;
28
35
  export interface RenderProjectOptions {
29
36
  readonly now?: Date;
30
37
  }
31
38
  export type RenderProjectResult = readonly [boolean, string];
32
- /** Regenerate PROJECT-DEFINITION.vbrief.json (mirrors ``scripts/project_render.render_project_definition``). */
39
+ /**
40
+ * Regenerate the PROJECT-DEFINITION artifact for `vbriefDir`.
41
+ *
42
+ * Layout-aware (#2149): on a migrated `xbrief/` root it targets
43
+ * `PROJECT-DEFINITION.xbrief.json` with an `xBRIEFInfo` envelope; on a legacy `vbrief/`
44
+ * root it keeps `PROJECT-DEFINITION.vbrief.json` + `vBRIEFInfo`.
45
+ * (Mirrors ``scripts/project_render.render_project_definition``.)
46
+ */
33
47
  export declare function renderProjectDefinition(vbriefDir: string, options?: RenderProjectOptions): RenderProjectResult;
34
48
  /**
35
49
  * Mark current completed scopes as reviewed for PROJECT-DEFINITION narratives.
@@ -1,10 +1,34 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
3
- import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { hasArtifactSuffix, resolveLifecycleRoot, stripArtifactSuffix } from "../layout/resolve.js";
4
4
  import { EMITTED_VBRIEF_VERSION } from "../vbrief-build/constants.js";
5
5
  import { deriveRegistryItemStatus, registryMetadataReferencesFromScope, } from "../vbrief-validate/registry-status.js";
6
+ import { LEGACY_ARTIFACT_SUFFIX, LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_DIR, MIGRATED_ARTIFACT_SUFFIX, MIGRATED_INFO_ROOT_KEY, VBRIEF_VERSION, } from "../xbrief-migrate/constants.js";
6
7
  import { PROJECT_LIFECYCLE_FOLDERS, SKELETON_NARRATIVES } from "./constants.js";
7
8
  import { splitCamel, splitWords } from "./text-utils.js";
9
+ /**
10
+ * Resolve the PROJECT-DEFINITION artifact shape for a lifecycle root directory.
11
+ *
12
+ * The decision is STRUCTURAL and keyed on the lifecycle root directory name -- the
13
+ * same signal `resolveLifecycleLayout` / `resolveLifecycleRoot` produce, so ingest and
14
+ * render never diverge (#2149). A migrated `xbrief/` root gets `PROJECT-DEFINITION.xbrief.json`
15
+ * + `xBRIEFInfo`; a legacy `vbrief/` root keeps `PROJECT-DEFINITION.vbrief.json` + `vBRIEFInfo`.
16
+ * This prevents render from writing a legacy-named/enveloped artifact into a migrated tree.
17
+ */
18
+ function resolveProjectDefinitionLayout(vbriefDir) {
19
+ const migrated = basename(vbriefDir) === MIGRATED_ARTIFACT_DIR;
20
+ return migrated
21
+ ? {
22
+ filename: `PROJECT-DEFINITION${MIGRATED_ARTIFACT_SUFFIX}`,
23
+ infoRootKey: MIGRATED_INFO_ROOT_KEY,
24
+ infoVersion: VBRIEF_VERSION,
25
+ }
26
+ : {
27
+ filename: `PROJECT-DEFINITION${LEGACY_ARTIFACT_SUFFIX}`,
28
+ infoRootKey: LEGACY_INFO_ROOT_KEY,
29
+ infoVersion: EMITTED_VBRIEF_VERSION,
30
+ };
31
+ }
8
32
  export function scanLifecycleFolders(vbriefDir) {
9
33
  const items = [];
10
34
  for (const folderName of PROJECT_LIFECYCLE_FOLDERS) {
@@ -25,11 +49,11 @@ export function scanLifecycleFolders(vbriefDir) {
25
49
  try {
26
50
  const data = JSON.parse(readFileSync(full, "utf8"));
27
51
  const plan = (data.plan ?? {});
28
- const title = String(plan.title ?? vbriefFile.replace(/\.vbrief\.json$/, ""));
52
+ const title = String(plan.title ?? stripArtifactSuffix(vbriefFile));
29
53
  const status = deriveRegistryItemStatus(plan.status, folderName);
30
54
  const references = registryMetadataReferencesFromScope(plan.references);
31
55
  const item = {
32
- id: vbriefFile.replace(/\.vbrief\.json$/, "").replace(/\.vbrief$/, ""),
56
+ id: stripArtifactSuffix(vbriefFile),
33
57
  title,
34
58
  status,
35
59
  metadata: {
@@ -44,7 +68,7 @@ export function scanLifecycleFolders(vbriefDir) {
44
68
  }
45
69
  catch {
46
70
  items.push({
47
- id: vbriefFile.replace(/\.vbrief\.json$/, "").replace(/\.vbrief$/, ""),
71
+ id: stripArtifactSuffix(vbriefFile),
48
72
  title: `[unreadable] ${vbriefFile}`,
49
73
  status: "draft",
50
74
  metadata: {
@@ -136,12 +160,12 @@ export function computeStalenessFlags(narratives, completedItems, review = null)
136
160
  const pending = unacknowledgedCompletedItems(completedItems, review);
137
161
  return flagStaleNarratives(narratives, pending);
138
162
  }
139
- export function createSkeleton(items, now) {
163
+ export function createSkeleton(items, now, layout) {
140
164
  const completedItems = items.filter((i) => i.status === "completed");
141
165
  const stalenessFlags = computeStalenessFlags({ ...SKELETON_NARRATIVES }, completedItems);
142
166
  return {
143
- vBRIEFInfo: {
144
- version: EMITTED_VBRIEF_VERSION,
167
+ [layout.infoRootKey]: {
168
+ version: layout.infoVersion,
145
169
  description: "Project definition -- synthesized gestalt of the project",
146
170
  created: now,
147
171
  updated: now,
@@ -155,11 +179,19 @@ export function createSkeleton(items, now) {
155
179
  },
156
180
  };
157
181
  }
158
- /** Regenerate PROJECT-DEFINITION.vbrief.json (mirrors ``scripts/project_render.render_project_definition``). */
182
+ /**
183
+ * Regenerate the PROJECT-DEFINITION artifact for `vbriefDir`.
184
+ *
185
+ * Layout-aware (#2149): on a migrated `xbrief/` root it targets
186
+ * `PROJECT-DEFINITION.xbrief.json` with an `xBRIEFInfo` envelope; on a legacy `vbrief/`
187
+ * root it keeps `PROJECT-DEFINITION.vbrief.json` + `vBRIEFInfo`.
188
+ * (Mirrors ``scripts/project_render.render_project_definition``.)
189
+ */
159
190
  export function renderProjectDefinition(vbriefDir, options = {}) {
160
191
  const nowDate = options.now ?? new Date();
161
192
  const now = nowDate.toISOString().replace(/\.\d{3}Z$/, "Z");
162
- const projectDefPath = join(vbriefDir, "PROJECT-DEFINITION.vbrief.json");
193
+ const layout = resolveProjectDefinitionLayout(vbriefDir);
194
+ const projectDefPath = join(vbriefDir, layout.filename);
163
195
  const items = scanLifecycleFolders(vbriefDir);
164
196
  const createdNew = !existsSync(projectDefPath);
165
197
  let projectDef;
@@ -172,10 +204,11 @@ export function renderProjectDefinition(vbriefDir, options = {}) {
172
204
  }
173
205
  const plan = (projectDef.plan ?? {});
174
206
  plan.items = items;
175
- if (typeof projectDef.vBRIEFInfo !== "object" || projectDef.vBRIEFInfo === null) {
176
- projectDef.vBRIEFInfo = {};
207
+ if (typeof projectDef[layout.infoRootKey] !== "object" ||
208
+ projectDef[layout.infoRootKey] === null) {
209
+ projectDef[layout.infoRootKey] = {};
177
210
  }
178
- projectDef.vBRIEFInfo.updated = now;
211
+ projectDef[layout.infoRootKey].updated = now;
179
212
  const narratives = typeof plan.narratives === "object" &&
180
213
  plan.narratives !== null &&
181
214
  !Array.isArray(plan.narratives)
@@ -194,7 +227,7 @@ export function renderProjectDefinition(vbriefDir, options = {}) {
194
227
  projectDef.plan = plan;
195
228
  }
196
229
  else {
197
- projectDef = createSkeleton(items, now);
230
+ projectDef = createSkeleton(items, now, layout);
198
231
  }
199
232
  mkdirSync(vbriefDir, { recursive: true });
200
233
  writeFileSync(projectDefPath, `${JSON.stringify(projectDef, null, 2)}\n`, "utf8");
@@ -202,7 +235,7 @@ export function renderProjectDefinition(vbriefDir, options = {}) {
202
235
  const planMeta = (projectDef.plan?.metadata ?? {});
203
236
  const flagCount = Array.isArray(planMeta.staleness_flags) ? planMeta.staleness_flags.length : 0;
204
237
  const action = createdNew ? "created" : "updated";
205
- const parts = [`✓ PROJECT-DEFINITION.vbrief.json ${action} (${itemCount} scope items)`];
238
+ const parts = [`✓ ${layout.filename} ${action} (${itemCount} scope items)`];
206
239
  if (flagCount > 0)
207
240
  parts.push(`⚠ ${flagCount} staleness flag(s) -- agent review recommended`);
208
241
  return [true, parts.join("\n")];
@@ -216,7 +249,8 @@ export function renderProjectDefinition(vbriefDir, options = {}) {
216
249
  export function acknowledgeProjectDefinitionStaleness(vbriefDir, options = {}) {
217
250
  const nowDate = options.now ?? new Date();
218
251
  const now = isoTimestamp(nowDate);
219
- const projectDefPath = join(vbriefDir, "PROJECT-DEFINITION.vbrief.json");
252
+ const layout = resolveProjectDefinitionLayout(vbriefDir);
253
+ const projectDefPath = join(vbriefDir, layout.filename);
220
254
  if (!existsSync(projectDefPath)) {
221
255
  return [false, `✗ ${projectDefPath} not found — run project:render first`];
222
256
  }
@@ -245,10 +279,11 @@ export function acknowledgeProjectDefinitionStaleness(vbriefDir, options = {}) {
245
279
  ? plan.narratives
246
280
  : {};
247
281
  planMetadata.staleness_flags = computeStalenessFlags(narratives, completedItems, parseStalenessReview(planMetadata));
248
- if (typeof projectDef.vBRIEFInfo !== "object" || projectDef.vBRIEFInfo === null) {
249
- projectDef.vBRIEFInfo = {};
282
+ if (typeof projectDef[layout.infoRootKey] !== "object" ||
283
+ projectDef[layout.infoRootKey] === null) {
284
+ projectDef[layout.infoRootKey] = {};
250
285
  }
251
- projectDef.vBRIEFInfo.updated = now;
286
+ projectDef[layout.infoRootKey].updated = now;
252
287
  projectDef.plan = plan;
253
288
  writeFileSync(projectDefPath, `${JSON.stringify(projectDef, null, 2)}\n`, "utf8");
254
289
  const ackCount = completedItems.length;
@@ -280,9 +315,7 @@ export function main(argv) {
280
315
  process.stderr.write("Usage: project-render [--acknowledge-staleness] [--project-root <dir>] [vbrief_dir]\n");
281
316
  return 2;
282
317
  }
283
- const vbriefDir = projectRoot !== undefined
284
- ? resolveLifecycleRoot(resolve(projectRoot))
285
- : (positional[0] ?? "vbrief");
318
+ const vbriefDir = positional[0] ?? resolveLifecycleRoot(resolve(projectRoot !== undefined ? projectRoot : "."));
286
319
  const [ok, message] = acknowledge
287
320
  ? acknowledgeProjectDefinitionStaleness(vbriefDir)
288
321
  : renderProjectDefinition(vbriefDir);
@@ -68,6 +68,12 @@ export interface LaunchArgs {
68
68
  readinessGate?: ReadinessGateFn;
69
69
  worktreeResolver?: WorktreeResolverFn;
70
70
  runtimeAuthProbe?: RuntimeAuthProbeFn;
71
+ /**
72
+ * Injection seam for the routing-provider environment lookup, mirroring
73
+ * `resolveRoutingPath`'s `environ` parameter (#1877 Greptile follow-up).
74
+ * Defaults to `process.env` when unset.
75
+ */
76
+ environ?: NodeJS.ProcessEnv;
71
77
  }
72
78
  export declare function swarmLaunch(args: LaunchArgs): {
73
79
  exitCode: number;
@@ -8,7 +8,7 @@ import { issueNumbersFromPlan, scopeMetadataRank } from "../triage/queue/scope-w
8
8
  import { selectionOrderingKey } from "../triage/queue/selection.js";
9
9
  import { DEFAULT_BASE_BRANCH, EXIT_CONFIG_ERROR, EXIT_GATE_FAILED, EXIT_OK, GATE_ADVISE, GATE_ENFORCE, LEAF_CODING_WORKER_ROLE, } from "./constants.js";
10
10
  import { readinessReport } from "./readiness.js";
11
- import { dispatchProviderFromRuntime, loadRoutingFile, resolveModelRoute, resolveRoutingPath, } from "./routing.js";
11
+ import { loadRoutingFile, resolveDispatchProvider, resolveModelRoute, resolveRoutingPath, } from "./routing.js";
12
12
  import { dispatchProviderFor, enforceSubagentBackendPolicy } from "./subagent-backend.js";
13
13
  import { resolveWorktreeMap } from "./worktrees.js";
14
14
  export const defaultPreflightGate = (vbriefPath) => {
@@ -525,7 +525,7 @@ export function swarmLaunch(args) {
525
525
  let modelSource = null;
526
526
  let routingProvider = null;
527
527
  if (routingFile !== null) {
528
- routingProvider = dispatchProviderFromRuntime(runtimeMode);
528
+ routingProvider = resolveDispatchProvider(args.environ ?? process.env);
529
529
  const route = resolveModelRoute(routingFile, routingProvider, LEAF_CODING_WORKER_ROLE);
530
530
  // A malformed decision object must fail loud here: the legacy backend gate
531
531
  // was already bypassed above (routingFile !== null), so silently continuing
@@ -9,7 +9,7 @@ export interface VerifyRoutingOptions {
9
9
  advise?: boolean;
10
10
  /** Override the resolved provider (else derived from the runtime). */
11
11
  provider?: string | null;
12
- /** Inject the runtime descriptor (else getPlatformCapabilities().runtimeMode). */
12
+ /** Inject the runtime descriptor (legacy test seam; else resolveDispatchProvider). */
13
13
  runtimeProbe?: () => string;
14
14
  }
15
15
  export interface VerifyRoutingResult {
@@ -8,9 +8,8 @@
8
8
  * 1 = at least one in-scope role is undecided / not dispatchable
9
9
  * 2 = config error (unreadable / malformed route file)
10
10
  */
11
- import { getPlatformCapabilities } from "../intake/platform-capabilities.js";
12
11
  import { EXIT_CONFIG_ERROR, EXIT_GATE_FAILED, EXIT_OK } from "./constants.js";
13
- import { dispatchProviderFromRuntime, HARNESS_BOUND_PROVIDERS, loadRoutingFile, ROUTING_MODE_HARNESS_DEFAULT, resolveModelRoute, resolveRoutingPath, } from "./routing.js";
12
+ import { dispatchProviderFromRuntime, HARNESS_BOUND_PROVIDERS, loadRoutingFile, ROUTING_MODE_HARNESS_DEFAULT, resolveDispatchProvider, resolveModelRoute, resolveRoutingPath, } from "./routing.js";
14
13
  export const ROUTING_SET_CMD = "task swarm:routing-set -- --role <role> --model <slug> (or --harness-default)";
15
14
  /** Roles the pre-dispatch gate checks by default: the actual model lever. */
16
15
  export const DEFAULT_GATED_ROLES = ["leaf-implementation"];
@@ -18,15 +17,17 @@ function resolveProvider(options) {
18
17
  if (options.provider !== undefined && options.provider !== null && options.provider.length > 0) {
19
18
  return options.provider;
20
19
  }
21
- const probe = options.runtimeProbe ?? (() => getPlatformCapabilities().runtimeMode);
22
- let runtimeMode = "";
23
- try {
24
- runtimeMode = probe();
25
- }
26
- catch {
27
- runtimeMode = "";
20
+ if (options.runtimeProbe !== undefined) {
21
+ let runtimeMode = "";
22
+ try {
23
+ runtimeMode = options.runtimeProbe();
24
+ }
25
+ catch {
26
+ runtimeMode = "";
27
+ }
28
+ return dispatchProviderFromRuntime(runtimeMode);
28
29
  }
29
- return dispatchProviderFromRuntime(runtimeMode);
30
+ return resolveDispatchProvider(options.environ ?? process.env);
30
31
  }
31
32
  export function verifyRouting(options) {
32
33
  const roles = options.roles && options.roles.length > 0 ? options.roles : DEFAULT_GATED_ROLES;
@@ -9,6 +9,8 @@ export declare const ROUTING_MODE_HARNESS_DEFAULT = "harness-default";
9
9
  export declare const ROUTING_FILENAME = "routing.local.json";
10
10
  /** Providers whose model is harness-bound -- deft cannot pin or verify a slug. */
11
11
  export declare const HARNESS_BOUND_PROVIDERS: Set<string>;
12
+ /** Providers whose per-role model must be decided before sub-agent dispatch (#1739 / #1877). */
13
+ export declare const ROUTING_GATED_DISPATCH_PROVIDERS: Set<string>;
12
14
  export interface RouteDecision {
13
15
  model: string | null;
14
16
  mode?: string;
@@ -48,6 +50,13 @@ export declare function loadRoutingFile(path: string): {
48
50
  export declare function resolveModelRoute(file: RoutingFile | null, provider: string, role: string): RouteResolution;
49
51
  /** Map a runtime descriptor (platform-capabilities.runtimeMode) to a route key. */
50
52
  export declare function dispatchProviderFromRuntime(runtimeMode: string): string;
53
+ /**
54
+ * Resolve the `dispatch_provider` routing key from the active runtime envelope.
55
+ * Separate from `runtime_mode` (#1557): Cursor sessions may carry
56
+ * `runtime_mode=cloud-headless` for gh-auth purposes but route under provider
57
+ * `cursor` for model selection (#1877).
58
+ */
59
+ export declare function resolveDispatchProvider(environ?: NodeJS.ProcessEnv): string;
51
60
  /**
52
61
  * Write a decision back to the route file (create-if-missing). Stamps
53
62
  * `decidedAt` when the caller did not supply one. Used by the interactive
@@ -31,6 +31,12 @@ export const ROUTING_MODE_HARNESS_DEFAULT = "harness-default";
31
31
  export const ROUTING_FILENAME = "routing.local.json";
32
32
  /** Providers whose model is harness-bound -- deft cannot pin or verify a slug. */
33
33
  export const HARNESS_BOUND_PROVIDERS = new Set(["grok"]);
34
+ /** Providers whose per-role model must be decided before sub-agent dispatch (#1739 / #1877). */
35
+ export const ROUTING_GATED_DISPATCH_PROVIDERS = new Set(["cursor", "grok"]);
36
+ const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]);
37
+ function envTruthy(environ, name) {
38
+ return TRUTHY_ENV.has((environ[name] ?? "").trim().toLowerCase());
39
+ }
34
40
  /**
35
41
  * Resolve the route-file path. Honors the `DEFT_ROUTING_PATH` override first
36
42
  * (keeps both maintainer and consumer testable), then reads from the MAIN
@@ -150,12 +156,52 @@ export function dispatchProviderFromRuntime(runtimeMode) {
150
156
  }
151
157
  return normalized;
152
158
  }
159
+ /**
160
+ * Resolve the `dispatch_provider` routing key from the active runtime envelope.
161
+ * Separate from `runtime_mode` (#1557): Cursor sessions may carry
162
+ * `runtime_mode=cloud-headless` for gh-auth purposes but route under provider
163
+ * `cursor` for model selection (#1877).
164
+ */
165
+ export function resolveDispatchProvider(environ = process.env) {
166
+ if (envTruthy(environ, "CURSOR_COMPOSER") || envTruthy(environ, "CURSOR_AGENT")) {
167
+ return "cursor";
168
+ }
169
+ const runtime = (environ.DEFT_AGENT_RUNTIME ?? "").trim().toLowerCase();
170
+ if (envTruthy(environ, "GROK_BUILD") || runtime === "grok-build") {
171
+ return "grok";
172
+ }
173
+ if (runtime === "cloud" || runtime === "headless") {
174
+ return "cloud-headless";
175
+ }
176
+ if (envTruthy(environ, "GITHUB_ACTIONS") ||
177
+ envTruthy(environ, "BUILDKITE") ||
178
+ (envTruthy(environ, "CI") &&
179
+ !envTruthy(environ, "CURSOR_COMPOSER") &&
180
+ !envTruthy(environ, "CURSOR_AGENT"))) {
181
+ return "cloud-headless";
182
+ }
183
+ return "unknown";
184
+ }
185
+ /**
186
+ * Keys that would mutate the prototype chain rather than set an own property
187
+ * if used as a computed object key. Rejected for provider/role names so a
188
+ * malicious routing input cannot pollute `Object.prototype` (CodeQL
189
+ * js/prototype-polluting-assignment).
190
+ */
191
+ const FORBIDDEN_ROUTING_KEYS = new Set(["__proto__", "constructor", "prototype"]);
192
+ function assertSafeRoutingKey(kind, key) {
193
+ if (FORBIDDEN_ROUTING_KEYS.has(key)) {
194
+ throw new Error(`routing ${kind} name "${key}" is not allowed`);
195
+ }
196
+ }
153
197
  /**
154
198
  * Write a decision back to the route file (create-if-missing). Stamps
155
199
  * `decidedAt` when the caller did not supply one. Used by the interactive
156
200
  * resolver path (resolver step 5) and the `swarm:routing-set` task.
157
201
  */
158
202
  export function writeModelDecision(path, provider, role, decision) {
203
+ assertSafeRoutingKey("provider", provider);
204
+ assertSafeRoutingKey("role", role);
159
205
  const { data } = loadRoutingFile(path);
160
206
  const file = data ?? {};
161
207
  const existing = providerBlockOf(file, provider);
@@ -4,8 +4,11 @@ import { verifyReviewClean } from "./verify-review-clean.js";
4
4
  export function verifyReviewCleanMain(argv = process.argv.slice(2)) {
5
5
  const prNumbers = [];
6
6
  const cohortGlobs = [];
7
+ const ciIgnoreChecks = [];
7
8
  let repo = null;
8
9
  let emitJson = false;
10
+ let skipCi = false;
11
+ let skipSlizard = false;
9
12
  for (let i = 0; i < argv.length; i += 1) {
10
13
  const arg = argv[i];
11
14
  if (arg === "--cohort" && argv[i + 1] !== undefined) {
@@ -19,11 +22,32 @@ export function verifyReviewCleanMain(argv = process.argv.slice(2)) {
19
22
  else if (arg === "--json") {
20
23
  emitJson = true;
21
24
  }
25
+ else if (arg === "--skip-ci") {
26
+ skipCi = true;
27
+ }
28
+ else if (arg === "--skip-slizard") {
29
+ skipSlizard = true;
30
+ }
31
+ else if (arg === "--ci-ignore-check" && argv[i + 1] !== undefined) {
32
+ ciIgnoreChecks.push(argv[i + 1] ?? "");
33
+ i += 1;
34
+ }
35
+ else if (arg?.startsWith("--ci-ignore-check=")) {
36
+ ciIgnoreChecks.push(arg.slice("--ci-ignore-check=".length));
37
+ }
22
38
  else if (arg !== undefined && /^\d+$/.test(arg)) {
23
39
  prNumbers.push(Number.parseInt(arg, 10));
24
40
  }
25
41
  }
26
- const result = verifyReviewClean({ prNumbers, cohortGlobs, repo, emitJson });
42
+ const result = verifyReviewClean({
43
+ prNumbers,
44
+ cohortGlobs,
45
+ repo,
46
+ emitJson,
47
+ skipCi,
48
+ skipSlizard,
49
+ ciIgnoreChecks,
50
+ });
27
51
  if (result.stdout.length > 0) {
28
52
  process.stdout.write(result.stdout);
29
53
  }
@@ -1,4 +1,7 @@
1
+ import type { CiGateOptions } from "../pr-merge-readiness/ci-gate.js";
2
+ import type { SlizardGateOptions } from "../pr-merge-readiness/slizard-gate.js";
1
3
  import type { RunGhFn } from "../pr-merge-readiness/types.js";
4
+ type ReviewGateOptions = CiGateOptions & SlizardGateOptions;
2
5
  import { EXIT_EXTERNAL_ERROR, EXIT_OK, EXIT_UNCLEAN } from "./constants.js";
3
6
  export interface CohortResolutionError {
4
7
  vbrief_path: string;
@@ -10,6 +13,8 @@ export interface CohortPRResult {
10
13
  verdict: Record<string, unknown>;
11
14
  failures: string[];
12
15
  clean: boolean;
16
+ ci_summary: Record<string, unknown> | null;
17
+ slizard_summary: Record<string, unknown> | null;
13
18
  }
14
19
  export interface CohortResult {
15
20
  repo: string | null;
@@ -21,7 +26,7 @@ export declare function resolveCohortFromVbriefs(vbriefGlobs: readonly string[])
21
26
  prNumbers: number[];
22
27
  failures: CohortResolutionError[];
23
28
  };
24
- export declare function evaluatePr(prNumber: number, repo: string | null, runGh?: RunGhFn): CohortPRResult | null;
29
+ export declare function evaluatePr(prNumber: number, repo: string | null, runGh?: RunGhFn, options?: ReviewGateOptions): CohortPRResult | null;
25
30
  export declare function cohortResultToDict(cohort: CohortResult): Record<string, unknown>;
26
31
  export declare function renderReviewCleanText(cohort: CohortResult): string;
27
32
  export interface VerifyReviewCleanArgs {
@@ -29,6 +34,9 @@ export interface VerifyReviewCleanArgs {
29
34
  cohortGlobs?: readonly string[];
30
35
  repo?: string | null;
31
36
  emitJson?: boolean;
37
+ skipCi?: boolean;
38
+ skipSlizard?: boolean;
39
+ ciIgnoreChecks?: readonly string[];
32
40
  runGh?: RunGhFn;
33
41
  }
34
42
  export declare function verifyReviewClean(args: VerifyReviewCleanArgs): {