@harness-engineering/orchestrator 0.5.0 → 0.7.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.
package/dist/index.mjs CHANGED
@@ -27,7 +27,7 @@ function sortCandidates(issues) {
27
27
  return comparePriority(a, b) ?? compareCreatedAt(a, b) ?? a.identifier.localeCompare(b.identifier);
28
28
  });
29
29
  }
30
- function isEligible(issue, state, activeStates, terminalStates) {
30
+ function isEligible(issue, state, activeStates, terminalStates, selfAssignee) {
31
31
  if (!issue.id || !issue.identifier || !issue.title || !issue.state) {
32
32
  return false;
33
33
  }
@@ -49,6 +49,9 @@ function isEligible(issue, state, activeStates, terminalStates) {
49
49
  if (state.completed.has(issue.id)) {
50
50
  return false;
51
51
  }
52
+ if (selfAssignee !== void 0 && issue.assignee != null && issue.assignee !== selfAssignee) {
53
+ return false;
54
+ }
52
55
  if (normalizedState === "todo" && issue.blockedBy.length > 0) {
53
56
  const hasNonTerminalBlocker = issue.blockedBy.some((blocker) => {
54
57
  if (blocker.state === null) return true;
@@ -60,9 +63,11 @@ function isEligible(issue, state, activeStates, terminalStates) {
60
63
  }
61
64
  return true;
62
65
  }
63
- function selectCandidates(issues, state, activeStates, terminalStates) {
66
+ function selectCandidates(issues, state, activeStates, terminalStates, selfAssignee) {
64
67
  const sorted = sortCandidates(issues);
65
- return sorted.filter((issue) => isEligible(issue, state, activeStates, terminalStates));
68
+ return sorted.filter(
69
+ (issue) => isEligible(issue, state, activeStates, terminalStates, selfAssignee)
70
+ );
66
71
  }
67
72
 
68
73
  // src/core/concurrency.ts
@@ -658,7 +663,8 @@ function handleTick(state, event, config) {
658
663
  candidates,
659
664
  next,
660
665
  config.tracker.activeStates,
661
- config.tracker.terminalStates
666
+ config.tracker.terminalStates,
667
+ event.selfAssignee
662
668
  );
663
669
  const escalationConfig = resolveEscalationConfig(config);
664
670
  for (const issue of eligible) {
@@ -1144,7 +1150,7 @@ var ClaimManager = class {
1144
1150
  const claimResult = await this.tracker.claimIssue(issueId, this.orchestratorId);
1145
1151
  if (!claimResult.ok) return claimResult;
1146
1152
  if (this.verifyDelayMs > 0) {
1147
- await new Promise((resolve6) => setTimeout(resolve6, this.verifyDelayMs));
1153
+ await new Promise((resolve8) => setTimeout(resolve8, this.verifyDelayMs));
1148
1154
  }
1149
1155
  const statesResult = await this.tracker.fetchIssueStatesByIds([issueId]);
1150
1156
  if (!statesResult.ok) return statesResult;
@@ -1796,7 +1802,8 @@ function formatFilesList(files) {
1796
1802
  }
1797
1803
 
1798
1804
  // src/workflow/loader.ts
1799
- import * as fs6 from "fs/promises";
1805
+ import * as fs7 from "fs/promises";
1806
+ import * as path7 from "path";
1800
1807
  import { parse } from "yaml";
1801
1808
  import { Ok as Ok3, Err as Err2 } from "@harness-engineering/types";
1802
1809
 
@@ -1804,7 +1811,8 @@ import { Ok as Ok3, Err as Err2 } from "@harness-engineering/types";
1804
1811
  import { z as z2 } from "zod";
1805
1812
  import {
1806
1813
  Ok as Ok2,
1807
- Err
1814
+ Err,
1815
+ STANDARD_COGNITIVE_MODES
1808
1816
  } from "@harness-engineering/types";
1809
1817
 
1810
1818
  // src/workflow/schema.ts
@@ -1852,16 +1860,29 @@ var BackendDefSchema = z.discriminatedUnion("type", [
1852
1860
  probeIntervalMs: z.number().int().min(1e3).optional()
1853
1861
  }).strict()
1854
1862
  ]);
1863
+ var RoutingValueSchema = z.union([
1864
+ z.string().min(1),
1865
+ z.array(z.string().min(1)).nonempty("fallback chain must contain at least one backend name").readonly()
1866
+ ]);
1855
1867
  var RoutingConfigSchema = z.object({
1856
- default: z.string().min(1),
1857
- "quick-fix": z.string().optional(),
1858
- "guided-change": z.string().optional(),
1859
- "full-exploration": z.string().optional(),
1860
- diagnostic: z.string().optional(),
1868
+ default: RoutingValueSchema,
1869
+ "quick-fix": RoutingValueSchema.optional(),
1870
+ "guided-change": RoutingValueSchema.optional(),
1871
+ "full-exploration": RoutingValueSchema.optional(),
1872
+ diagnostic: RoutingValueSchema.optional(),
1861
1873
  intelligence: z.object({
1862
- sel: z.string().optional(),
1863
- pesl: z.string().optional()
1864
- }).strict().optional()
1874
+ sel: RoutingValueSchema.optional(),
1875
+ pesl: RoutingValueSchema.optional()
1876
+ }).strict().optional(),
1877
+ // --- Spec B Phase 2: isolation block widened to RoutingValueSchema ---
1878
+ isolation: z.object({
1879
+ none: RoutingValueSchema.optional(),
1880
+ container: RoutingValueSchema.optional(),
1881
+ "remote-sandbox": RoutingValueSchema.optional()
1882
+ }).strict().optional(),
1883
+ // --- Spec B Phase 0: new optional maps (resolver wired in Phase 1) ---
1884
+ skills: z.record(z.string().min(1), RoutingValueSchema).optional(),
1885
+ modes: z.record(z.string().min(1), RoutingValueSchema).optional()
1865
1886
  }).strict();
1866
1887
 
1867
1888
  // src/workflow/config.ts
@@ -1870,13 +1891,17 @@ var BackendsMapSchema = z2.record(z2.string(), BackendDefSchema);
1870
1891
  function crossFieldRoutingIssues(backends, routing) {
1871
1892
  const issues = [];
1872
1893
  const names = new Set(Object.keys(backends));
1873
- const checkRef = (path19, name) => {
1874
- if (name !== void 0 && !names.has(name)) {
1894
+ const checkRef = (path24, value) => {
1895
+ if (value === void 0) return;
1896
+ const entries = Array.isArray(value) ? value : [value];
1897
+ entries.forEach((name, idx) => {
1898
+ if (names.has(name)) return;
1899
+ const pathWithIdx = Array.isArray(value) ? [...path24, String(idx)] : path24;
1875
1900
  issues.push({
1876
- path: path19,
1877
- message: `routing.${path19.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1901
+ path: pathWithIdx,
1902
+ message: `routing.${pathWithIdx.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1878
1903
  });
1879
- }
1904
+ });
1880
1905
  };
1881
1906
  checkRef(["default"], routing.default);
1882
1907
  checkRef(["quick-fix"], routing["quick-fix"]);
@@ -1885,9 +1910,44 @@ function crossFieldRoutingIssues(backends, routing) {
1885
1910
  checkRef(["diagnostic"], routing.diagnostic);
1886
1911
  checkRef(["intelligence", "sel"], routing.intelligence?.sel);
1887
1912
  checkRef(["intelligence", "pesl"], routing.intelligence?.pesl);
1913
+ checkRef(["isolation", "none"], routing.isolation?.none);
1914
+ checkRef(["isolation", "container"], routing.isolation?.container);
1915
+ checkRef(["isolation", "remote-sandbox"], routing.isolation?.["remote-sandbox"]);
1916
+ if (routing.skills) {
1917
+ for (const [skill, value] of Object.entries(routing.skills)) {
1918
+ checkRef(["skills", skill], value);
1919
+ }
1920
+ }
1921
+ if (routing.modes) {
1922
+ for (const [mode, value] of Object.entries(routing.modes)) {
1923
+ checkRef(["modes", mode], value);
1924
+ }
1925
+ }
1888
1926
  return issues;
1889
1927
  }
1890
- function validateWorkflowConfig(config) {
1928
+ function routingWarnings(routing, knownSkillNames) {
1929
+ const warnings = [];
1930
+ if (knownSkillNames.length > 0 && routing.skills) {
1931
+ const known = new Set(knownSkillNames);
1932
+ for (const name of Object.keys(routing.skills)) {
1933
+ if (known.has(name)) continue;
1934
+ warnings.push(
1935
+ `routing.skills.${name} references a skill that is not present in the local skill catalog. If this is intentional (e.g., a skill installed by a downstream consumer), this warning can be ignored.`
1936
+ );
1937
+ }
1938
+ }
1939
+ if (routing.modes) {
1940
+ const standardModes = new Set(STANDARD_COGNITIVE_MODES);
1941
+ for (const mode of Object.keys(routing.modes)) {
1942
+ if (standardModes.has(mode)) continue;
1943
+ warnings.push(
1944
+ `routing.modes.${mode} is not in STANDARD_COGNITIVE_MODES (${[...STANDARD_COGNITIVE_MODES].join(", ")}). Custom cognitive modes are allowed but uncommon; verify this is not a typo.`
1945
+ );
1946
+ }
1947
+ }
1948
+ return warnings;
1949
+ }
1950
+ function validateWorkflowConfig(config, options = {}) {
1891
1951
  if (!config || typeof config !== "object")
1892
1952
  return Err(new Error("Config is missing or not an object"));
1893
1953
  const c = config;
@@ -1903,6 +1963,7 @@ function validateWorkflowConfig(config) {
1903
1963
  if (!hasLegacyBackend && !hasModernBackends) {
1904
1964
  return Err(new Error("Config must define agent.backend or agent.backends."));
1905
1965
  }
1966
+ const warnings = [];
1906
1967
  if (hasModernBackends) {
1907
1968
  const backendsParsed = BackendsMapSchema.safeParse(agent.backends);
1908
1969
  if (!backendsParsed.success) {
@@ -1913,9 +1974,10 @@ function validateWorkflowConfig(config) {
1913
1974
  return Err(new Error(`agent.routing: ${routingParsed.error.message}`));
1914
1975
  }
1915
1976
  if (routingParsed.data) {
1977
+ const routingData = routingParsed.data;
1916
1978
  const cross = crossFieldRoutingIssues(
1917
1979
  backendsParsed.data,
1918
- routingParsed.data
1980
+ routingData
1919
1981
  );
1920
1982
  if (cross.length > 0) {
1921
1983
  return Err(
@@ -1924,9 +1986,10 @@ function validateWorkflowConfig(config) {
1924
1986
  )
1925
1987
  );
1926
1988
  }
1989
+ warnings.push(...routingWarnings(routingData, options.knownSkillNames ?? []));
1927
1990
  }
1928
1991
  }
1929
- return Ok2(config);
1992
+ return Ok2({ config, warnings });
1930
1993
  }
1931
1994
  function getDefaultConfig() {
1932
1995
  return {
@@ -1979,11 +2042,55 @@ function getDefaultConfig() {
1979
2042
  };
1980
2043
  }
1981
2044
 
2045
+ // src/workflow/skill-catalog.ts
2046
+ import * as fs6 from "fs";
2047
+ import * as path6 from "path";
2048
+ import { parse as parseYaml } from "yaml";
2049
+ function discoverSkillCatalog(projectRoot) {
2050
+ const skillsRoot = path6.join(projectRoot, "agents", "skills");
2051
+ if (!fs6.existsSync(skillsRoot)) return [];
2052
+ const byName = /* @__PURE__ */ new Map();
2053
+ let hosts;
2054
+ try {
2055
+ hosts = fs6.readdirSync(skillsRoot, { withFileTypes: true });
2056
+ } catch {
2057
+ return [];
2058
+ }
2059
+ for (const host of hosts) {
2060
+ if (!host.isDirectory()) continue;
2061
+ const hostDir = path6.join(skillsRoot, host.name);
2062
+ let skills;
2063
+ try {
2064
+ skills = fs6.readdirSync(hostDir, { withFileTypes: true });
2065
+ } catch {
2066
+ continue;
2067
+ }
2068
+ for (const skill of skills) {
2069
+ if (!skill.isDirectory()) continue;
2070
+ const skillYamlPath = path6.join(hostDir, skill.name, "skill.yaml");
2071
+ if (!fs6.existsSync(skillYamlPath)) continue;
2072
+ try {
2073
+ const content = fs6.readFileSync(skillYamlPath, "utf-8");
2074
+ const parsed = parseYaml(content);
2075
+ if (parsed && typeof parsed.name === "string" && parsed.name.length > 0 && !byName.has(parsed.name)) {
2076
+ const entry = typeof parsed.cognitive_mode === "string" && parsed.cognitive_mode.length > 0 ? { name: parsed.name, cognitiveMode: parsed.cognitive_mode } : { name: parsed.name };
2077
+ byName.set(parsed.name, entry);
2078
+ }
2079
+ } catch {
2080
+ }
2081
+ }
2082
+ }
2083
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
2084
+ }
2085
+ function discoverSkillCatalogNames(projectRoot) {
2086
+ return discoverSkillCatalog(projectRoot).map((e) => e.name);
2087
+ }
2088
+
1982
2089
  // src/workflow/loader.ts
1983
2090
  var WorkflowLoader = class {
1984
2091
  async loadWorkflow(filePath) {
1985
2092
  try {
1986
- const content = await fs6.readFile(filePath, "utf-8");
2093
+ const content = await fs7.readFile(filePath, "utf-8");
1987
2094
  const parts = content.split("---");
1988
2095
  if (parts.length < 3) {
1989
2096
  return Err2(
@@ -1995,13 +2102,16 @@ var WorkflowLoader = class {
1995
2102
  const yamlContent = parts[1].trim();
1996
2103
  const promptTemplate = parts.slice(2).join("---").trim();
1997
2104
  const configData = parse(yamlContent);
1998
- const configResult = validateWorkflowConfig(configData);
2105
+ const projectRoot = path7.dirname(path7.resolve(filePath));
2106
+ const knownSkillNames = discoverSkillCatalogNames(projectRoot);
2107
+ const configResult = validateWorkflowConfig(configData, { knownSkillNames });
1999
2108
  if (!configResult.ok) {
2000
2109
  return Err2(configResult.error);
2001
2110
  }
2002
2111
  return Ok3({
2003
- config: configResult.value,
2004
- promptTemplate
2112
+ config: configResult.value.config,
2113
+ promptTemplate,
2114
+ warnings: configResult.value.warnings
2005
2115
  });
2006
2116
  } catch (error) {
2007
2117
  return Err2(error instanceof Error ? error : new Error(String(error)));
@@ -2010,7 +2120,7 @@ var WorkflowLoader = class {
2010
2120
  };
2011
2121
 
2012
2122
  // src/tracker/adapters/roadmap.ts
2013
- import * as fs7 from "fs/promises";
2123
+ import * as fs8 from "fs/promises";
2014
2124
  import { createHash as createHash2 } from "crypto";
2015
2125
  import {
2016
2126
  parseRoadmap,
@@ -2047,7 +2157,7 @@ var RoadmapTrackerAdapter = class {
2047
2157
  async fetchIssuesByStates(stateNames) {
2048
2158
  try {
2049
2159
  if (!this.config.filePath) return Err3(new Error("Missing filePath"));
2050
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2160
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2051
2161
  const roadmapResult = parseRoadmap(content);
2052
2162
  if (!roadmapResult.ok) return roadmapResult;
2053
2163
  const issues = [];
@@ -2079,7 +2189,7 @@ var RoadmapTrackerAdapter = class {
2079
2189
  if (!terminal) {
2080
2190
  return Err3(new Error("Tracker config has no terminalStates; cannot mark complete"));
2081
2191
  }
2082
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2192
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2083
2193
  const roadmapResult = parseRoadmap(content);
2084
2194
  if (!roadmapResult.ok) return roadmapResult;
2085
2195
  const roadmap = roadmapResult.value;
@@ -2088,7 +2198,7 @@ var RoadmapTrackerAdapter = class {
2088
2198
  const normalizedTerminal = this.config.terminalStates.map((s) => s.toLowerCase());
2089
2199
  if (normalizedTerminal.includes(target.status.toLowerCase())) return Ok4(void 0);
2090
2200
  target.status = terminal;
2091
- await fs7.writeFile(this.config.filePath, serializeRoadmap(roadmap), "utf-8");
2201
+ await fs8.writeFile(this.config.filePath, serializeRoadmap(roadmap), "utf-8");
2092
2202
  return Ok4(void 0);
2093
2203
  } catch (error) {
2094
2204
  return Err3(error instanceof Error ? error : new Error(String(error)));
@@ -2102,19 +2212,22 @@ var RoadmapTrackerAdapter = class {
2102
2212
  async claimIssue(issueId, orchestratorId) {
2103
2213
  try {
2104
2214
  if (!this.config.filePath) return Err3(new Error("Missing filePath"));
2105
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2215
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2106
2216
  const roadmapResult = parseRoadmap(content);
2107
2217
  if (!roadmapResult.ok) return roadmapResult;
2108
2218
  const roadmap = roadmapResult.value;
2109
2219
  const target = this.findFeatureById(roadmap.milestones, issueId);
2110
2220
  if (!target) return Ok4(void 0);
2221
+ if (target.assignee != null && target.assignee !== orchestratorId) {
2222
+ return Ok4(void 0);
2223
+ }
2111
2224
  if (target.status === "in-progress" && target.assignee === orchestratorId) {
2112
2225
  return Ok4(void 0);
2113
2226
  }
2114
2227
  target.status = "in-progress";
2115
2228
  target.assignee = orchestratorId;
2116
2229
  target.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2117
- await fs7.writeFile(this.config.filePath, serializeRoadmap(roadmap), "utf-8");
2230
+ await fs8.writeFile(this.config.filePath, serializeRoadmap(roadmap), "utf-8");
2118
2231
  return Ok4(void 0);
2119
2232
  } catch (error) {
2120
2233
  return Err3(error instanceof Error ? error : new Error(String(error)));
@@ -2131,7 +2244,7 @@ var RoadmapTrackerAdapter = class {
2131
2244
  if (!activeState) {
2132
2245
  return Err3(new Error("Tracker config has no activeStates; cannot release"));
2133
2246
  }
2134
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2247
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2135
2248
  const roadmapResult = parseRoadmap(content);
2136
2249
  if (!roadmapResult.ok) return roadmapResult;
2137
2250
  const roadmap = roadmapResult.value;
@@ -2143,7 +2256,7 @@ var RoadmapTrackerAdapter = class {
2143
2256
  target.status = activeState;
2144
2257
  target.assignee = null;
2145
2258
  target.updatedAt = null;
2146
- await fs7.writeFile(this.config.filePath, serializeRoadmap(roadmap), "utf-8");
2259
+ await fs8.writeFile(this.config.filePath, serializeRoadmap(roadmap), "utf-8");
2147
2260
  return Ok4(void 0);
2148
2261
  } catch (error) {
2149
2262
  return Err3(error instanceof Error ? error : new Error(String(error)));
@@ -2165,7 +2278,7 @@ var RoadmapTrackerAdapter = class {
2165
2278
  async fetchIssueStatesByIds(issueIds) {
2166
2279
  try {
2167
2280
  if (!this.config.filePath) return Err3(new Error("Missing filePath"));
2168
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2281
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2169
2282
  const roadmapResult = parseRoadmap(content);
2170
2283
  if (!roadmapResult.ok) return roadmapResult;
2171
2284
  const issueMap = /* @__PURE__ */ new Map();
@@ -2230,8 +2343,8 @@ var LinearGraphQLStub = class {
2230
2343
  };
2231
2344
 
2232
2345
  // src/workspace/manager.ts
2233
- import * as fs8 from "fs/promises";
2234
- import * as path6 from "path";
2346
+ import * as fs9 from "fs/promises";
2347
+ import * as path8 from "path";
2235
2348
  import { execFile as execFile2 } from "child_process";
2236
2349
  import { promisify as promisify2 } from "util";
2237
2350
  import { Ok as Ok6, Err as Err4 } from "@harness-engineering/types";
@@ -2262,15 +2375,15 @@ var WorkspaceManager = class {
2262
2375
  */
2263
2376
  resolvePath(identifier) {
2264
2377
  const sanitized = this.sanitizeIdentifier(identifier);
2265
- return path6.join(this.config.root, sanitized);
2378
+ return path8.join(this.config.root, sanitized);
2266
2379
  }
2267
2380
  /**
2268
2381
  * Discovers the git repository root from the workspace root directory.
2269
2382
  */
2270
2383
  async getRepoRoot() {
2271
2384
  if (this.repoRoot) return this.repoRoot;
2272
- const root = path6.resolve(this.config.root);
2273
- await fs8.mkdir(root, { recursive: true });
2385
+ const root = path8.resolve(this.config.root);
2386
+ await fs9.mkdir(root, { recursive: true });
2274
2387
  const stdout = await this.git(["rev-parse", "--show-toplevel"], root);
2275
2388
  this.repoRoot = stdout.trim();
2276
2389
  return this.repoRoot;
@@ -2281,23 +2394,23 @@ var WorkspaceManager = class {
2281
2394
  */
2282
2395
  async ensureWorkspace(identifier) {
2283
2396
  try {
2284
- const workspacePath = path6.resolve(this.resolvePath(identifier));
2397
+ const workspacePath = path8.resolve(this.resolvePath(identifier));
2285
2398
  try {
2286
- await fs8.access(path6.join(workspacePath, ".git"));
2399
+ await fs9.access(path8.join(workspacePath, ".git"));
2287
2400
  const repoRoot2 = await this.getRepoRoot();
2288
2401
  try {
2289
2402
  await this.git(["worktree", "remove", "--force", workspacePath], repoRoot2);
2290
2403
  } catch {
2291
- await fs8.rm(workspacePath, { recursive: true, force: true });
2404
+ await fs9.rm(workspacePath, { recursive: true, force: true });
2292
2405
  }
2293
2406
  } catch {
2294
2407
  try {
2295
- await fs8.access(workspacePath);
2408
+ await fs9.access(workspacePath);
2296
2409
  const repoRoot2 = await this.getRepoRoot();
2297
2410
  try {
2298
2411
  await this.git(["worktree", "remove", "--force", workspacePath], repoRoot2);
2299
2412
  } catch {
2300
- await fs8.rm(workspacePath, { recursive: true, force: true });
2413
+ await fs9.rm(workspacePath, { recursive: true, force: true });
2301
2414
  }
2302
2415
  } catch {
2303
2416
  }
@@ -2393,7 +2506,7 @@ var WorkspaceManager = class {
2393
2506
  async exists(identifier) {
2394
2507
  try {
2395
2508
  const workspacePath = this.resolvePath(identifier);
2396
- await fs8.access(workspacePath);
2509
+ await fs9.access(workspacePath);
2397
2510
  return true;
2398
2511
  } catch {
2399
2512
  return false;
@@ -2406,9 +2519,9 @@ var WorkspaceManager = class {
2406
2519
  */
2407
2520
  async findPushedBranch(identifier) {
2408
2521
  try {
2409
- const workspacePath = path6.resolve(this.resolvePath(identifier));
2522
+ const workspacePath = path8.resolve(this.resolvePath(identifier));
2410
2523
  try {
2411
- await fs8.access(path6.join(workspacePath, ".git"));
2524
+ await fs9.access(path8.join(workspacePath, ".git"));
2412
2525
  } catch {
2413
2526
  return null;
2414
2527
  }
@@ -2514,12 +2627,12 @@ var WorkspaceManager = class {
2514
2627
  */
2515
2628
  async removeWorkspace(identifier) {
2516
2629
  try {
2517
- const workspacePath = path6.resolve(this.resolvePath(identifier));
2630
+ const workspacePath = path8.resolve(this.resolvePath(identifier));
2518
2631
  try {
2519
2632
  const repoRoot = await this.getRepoRoot();
2520
2633
  await this.git(["worktree", "remove", "--force", workspacePath], repoRoot);
2521
2634
  } catch {
2522
- await fs8.rm(workspacePath, { recursive: true, force: true });
2635
+ await fs9.rm(workspacePath, { recursive: true, force: true });
2523
2636
  }
2524
2637
  return Ok6(void 0);
2525
2638
  } catch (error) {
@@ -2544,7 +2657,7 @@ var WorkspaceHooks = class {
2544
2657
  if (!command) {
2545
2658
  return Ok7(void 0);
2546
2659
  }
2547
- return new Promise((resolve6) => {
2660
+ return new Promise((resolve8) => {
2548
2661
  const filteredEnv = {};
2549
2662
  for (const [k, v] of Object.entries(process.env)) {
2550
2663
  if (v != null && !k.includes("SECRET") && !k.includes("TOKEN") && !k.includes("PASSWORD")) {
@@ -2557,19 +2670,19 @@ var WorkspaceHooks = class {
2557
2670
  });
2558
2671
  const timeout = setTimeout(() => {
2559
2672
  child.kill();
2560
- resolve6(Err5(new Error(`Hook ${hookName} timed out after ${this.config.timeoutMs}ms`)));
2673
+ resolve8(Err5(new Error(`Hook ${hookName} timed out after ${this.config.timeoutMs}ms`)));
2561
2674
  }, this.config.timeoutMs);
2562
2675
  child.on("exit", (code) => {
2563
2676
  clearTimeout(timeout);
2564
2677
  if (code === 0) {
2565
- resolve6(Ok7(void 0));
2678
+ resolve8(Ok7(void 0));
2566
2679
  } else {
2567
- resolve6(Err5(new Error(`Hook ${hookName} failed with exit code ${code}`)));
2680
+ resolve8(Err5(new Error(`Hook ${hookName} failed with exit code ${code}`)));
2568
2681
  }
2569
2682
  });
2570
2683
  child.on("error", (error) => {
2571
2684
  clearTimeout(timeout);
2572
- resolve6(Err5(error));
2685
+ resolve8(Err5(error));
2573
2686
  });
2574
2687
  });
2575
2688
  }
@@ -2609,7 +2722,7 @@ var MockBackend = class {
2609
2722
  content: "Thinking...",
2610
2723
  sessionId: session.sessionId
2611
2724
  };
2612
- await new Promise((resolve6) => setTimeout(resolve6, 100));
2725
+ await new Promise((resolve8) => setTimeout(resolve8, 100));
2613
2726
  yield {
2614
2727
  type: "thought",
2615
2728
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2661,12 +2774,12 @@ var PromptRenderer = class {
2661
2774
 
2662
2775
  // src/orchestrator.ts
2663
2776
  import { EventEmitter } from "events";
2664
- import * as path16 from "path";
2777
+ import * as path21 from "path";
2665
2778
  import { randomUUID as randomUUID5 } from "crypto";
2666
2779
  import { writeTaint } from "@harness-engineering/core";
2667
2780
 
2668
2781
  // src/intelligence/pipeline-runner.ts
2669
- import * as path7 from "path";
2782
+ import * as path9 from "path";
2670
2783
  import { weightedRecommendPersona, refreshProfiles } from "@harness-engineering/intelligence";
2671
2784
  import {
2672
2785
  GitHubIssuesSyncAdapter,
@@ -2788,7 +2901,7 @@ var IntelligencePipelineRunner = class {
2788
2901
  }
2789
2902
  async loadGraphStore() {
2790
2903
  try {
2791
- const graphDir = path7.join(this.ctx.config.workspace.root, "..", "graph");
2904
+ const graphDir = path9.join(this.ctx.config.workspace.root, "..", "graph");
2792
2905
  const loaded = await this.ctx.graphStore.load(graphDir);
2793
2906
  if (loaded) {
2794
2907
  this.ctx.logger.info("Graph store loaded from disk");
@@ -3066,7 +3179,7 @@ var IntelligencePipelineRunner = class {
3066
3179
  };
3067
3180
 
3068
3181
  // src/completion/handler.ts
3069
- import * as path8 from "path";
3182
+ import * as path10 from "path";
3070
3183
  import { GitHubIssuesSyncAdapter as GitHubIssuesSyncAdapter2, loadTrackerSyncConfig as loadTrackerSyncConfig2 } from "@harness-engineering/core";
3071
3184
  var CompletionHandler = class {
3072
3185
  ctx;
@@ -3149,7 +3262,7 @@ var CompletionHandler = class {
3149
3262
  result: outcome.result
3150
3263
  });
3151
3264
  if (this.ctx.graphStore) {
3152
- const graphDir = path8.join(this.ctx.config.workspace.root, "..", "graph");
3265
+ const graphDir = path10.join(this.ctx.config.workspace.root, "..", "graph");
3153
3266
  await this.ctx.graphStore.save(graphDir);
3154
3267
  }
3155
3268
  } catch (err) {
@@ -3635,11 +3748,11 @@ function detectLegacyFields(agent) {
3635
3748
  }
3636
3749
  function buildCase1Warnings(presentLegacy, suppressLocalGroup) {
3637
3750
  const warnings = [];
3638
- for (const path19 of presentLegacy) {
3639
- if (CASE1_ALWAYS_SUPPRESS.has(path19)) continue;
3640
- if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path19)) continue;
3751
+ for (const path24 of presentLegacy) {
3752
+ if (CASE1_ALWAYS_SUPPRESS.has(path24)) continue;
3753
+ if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path24)) continue;
3641
3754
  warnings.push(
3642
- `Ignoring legacy field '${path19}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3755
+ `Ignoring legacy field '${path24}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3643
3756
  );
3644
3757
  }
3645
3758
  return warnings;
@@ -3667,7 +3780,7 @@ function migrateAgentConfig(agent) {
3667
3780
  }
3668
3781
  const { backends, routing } = synthesizeBackendsAndRouting(agent);
3669
3782
  const warnings = presentLegacy.map(
3670
- (path19) => `Deprecated config field '${path19}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3783
+ (path24) => `Deprecated config field '${path24}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3671
3784
  );
3672
3785
  return {
3673
3786
  config: { ...agent, backends, routing },
@@ -3730,61 +3843,160 @@ function synthesizeLocal(agent) {
3730
3843
  }
3731
3844
 
3732
3845
  // src/agent/backend-router.ts
3846
+ function toArray(value) {
3847
+ return Array.isArray(value) ? value : [value];
3848
+ }
3733
3849
  var BackendRouter = class {
3734
3850
  backends;
3735
3851
  routing;
3852
+ decisionBus;
3736
3853
  constructor(opts) {
3737
3854
  this.backends = opts.backends;
3738
3855
  this.routing = opts.routing;
3856
+ this.decisionBus = opts.decisionBus;
3739
3857
  this.validateReferences();
3740
3858
  }
3741
3859
  /**
3742
- * Returns the backend name for a given use case.
3860
+ * Resolve a {@link RoutingUseCase} to a {@link RoutingDecision}.
3861
+ *
3862
+ * @param useCase the routing query
3863
+ * @param opts.invocationOverride if set and the named backend exists,
3864
+ * beats all other sources (D7 — the `--backend <name>` escape hatch)
3865
+ */
3866
+ resolve(useCase, opts) {
3867
+ const startedAt = performance.now();
3868
+ const path24 = [];
3869
+ const tryChain = (source, value) => {
3870
+ if (value === void 0) return void 0;
3871
+ for (const name of toArray(value)) {
3872
+ const step = { source, candidate: name, outcome: "considered" };
3873
+ path24.push(step);
3874
+ if (this.backends[name]) {
3875
+ step.outcome = "chosen";
3876
+ return name;
3877
+ }
3878
+ step.outcome = "unknown-backend";
3879
+ }
3880
+ return void 0;
3881
+ };
3882
+ const decide = (backendName) => {
3883
+ const def = this.backends[backendName];
3884
+ if (!def) {
3885
+ throw new Error(
3886
+ `BackendRouter.resolve: internal invariant violated \u2014 backend '${backendName}' missing.`
3887
+ );
3888
+ }
3889
+ return {
3890
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3891
+ useCase,
3892
+ resolutionPath: path24,
3893
+ backendName,
3894
+ backendType: def.type,
3895
+ durationMs: performance.now() - startedAt
3896
+ };
3897
+ };
3898
+ const emitAndReturn = (decision) => {
3899
+ this.decisionBus?.emit(decision);
3900
+ return decision;
3901
+ };
3902
+ const fromInvocation = tryChain(
3903
+ "invocation",
3904
+ opts?.invocationOverride !== void 0 ? opts.invocationOverride : void 0
3905
+ );
3906
+ if (fromInvocation) return emitAndReturn(decide(fromInvocation));
3907
+ if (useCase.kind === "skill") {
3908
+ const fromSkill = tryChain("skill", this.routing.skills?.[useCase.skillName]);
3909
+ if (fromSkill) return emitAndReturn(decide(fromSkill));
3910
+ }
3911
+ const mode = useCase.kind === "skill" ? useCase.cognitiveMode : useCase.kind === "mode" ? useCase.cognitiveMode : void 0;
3912
+ if (mode !== void 0) {
3913
+ const fromMode = tryChain("mode", this.routing.modes?.[mode]);
3914
+ if (fromMode) return emitAndReturn(decide(fromMode));
3915
+ }
3916
+ const fromExisting = this.resolveExistingUseCase(useCase);
3917
+ if (fromExisting !== void 0) {
3918
+ const chained = tryChain("tier", fromExisting);
3919
+ if (chained) return emitAndReturn(decide(chained));
3920
+ }
3921
+ const fromDefault = tryChain("default", this.routing.default);
3922
+ if (fromDefault) return emitAndReturn(decide(fromDefault));
3923
+ const knownList = Object.keys(this.backends).join(", ") || "(none)";
3924
+ throw new Error(
3925
+ `BackendRouter.resolve: routing.default produced no available backend for useCase=${JSON.stringify(useCase)}. Resolution path: ${JSON.stringify(path24)}. Known backends: [${knownList}].`
3926
+ );
3927
+ }
3928
+ /**
3929
+ * Returns the {@link BackendDef} reference for the resolved name.
3930
+ * Identity-equal to the entry in `backends` (no copy) so callers
3931
+ * relying on reference equality (SC21) continue to work.
3932
+ */
3933
+ resolveDefinition(useCase, opts) {
3934
+ const decision = this.resolve(useCase, opts);
3935
+ const def = this.backends[decision.backendName];
3936
+ if (!def) {
3937
+ throw new Error(
3938
+ `BackendRouter.resolveDefinition: routing target '${decision.backendName}' is not in backends (useCase=${JSON.stringify(useCase)}).`
3939
+ );
3940
+ }
3941
+ return def;
3942
+ }
3943
+ /**
3944
+ * Spec B Phase 4 (closes P1-IMP-2): a single resolve() + def lookup
3945
+ * for callers that need both. Replaces the previous pattern of
3946
+ * `resolveDefinition(useCase) + resolve(useCase)` which produced two
3947
+ * RoutingDecision emissions per dispatch — doubling routing-decision
3948
+ * log volume now that Phase 4 emits.
3743
3949
  *
3744
- * - `tier`: per-tier override, falling back to `routing.default`.
3745
- * - `intelligence`: per-layer override under `routing.intelligence`,
3746
- * falling back to `routing.default`.
3747
- * - `maintenance` / `chat`: always `routing.default`.
3950
+ * Identity-equal `BackendDef` (no copy) so callers relying on
3951
+ * reference equality (SC21) continue to work.
3952
+ */
3953
+ resolveDecisionAndDef(useCase, opts) {
3954
+ const decision = this.resolve(useCase, opts);
3955
+ const def = this.backends[decision.backendName];
3956
+ if (!def) {
3957
+ throw new Error(
3958
+ `BackendRouter.resolveDecisionAndDef: routing target '${decision.backendName}' is not in backends (useCase=${JSON.stringify(useCase)}).`
3959
+ );
3960
+ }
3961
+ return { decision, def };
3962
+ }
3963
+ /**
3964
+ * The pre-Spec-B resolution helper: returns the configured
3965
+ * {@link RoutingValue} for tier/intelligence/isolation/maintenance/chat
3966
+ * use cases (or `undefined` for skill/mode use cases, which are owned
3967
+ * by the per-skill / per-mode steps in {@link resolve}). Returning
3968
+ * `undefined` lets the caller fall through to `routing.default`.
3748
3969
  */
3749
- resolve(useCase) {
3970
+ resolveExistingUseCase(useCase) {
3750
3971
  switch (useCase.kind) {
3751
3972
  case "tier": {
3752
- const named = this.routing[useCase.tier];
3753
- return named ?? this.routing.default;
3973
+ const tierMap = this.routing;
3974
+ return tierMap[useCase.tier];
3754
3975
  }
3755
3976
  case "intelligence": {
3756
3977
  const intel = this.routing.intelligence;
3757
- return intel?.[useCase.layer] ?? this.routing.default;
3978
+ return intel?.[useCase.layer];
3758
3979
  }
3759
3980
  case "isolation": {
3760
3981
  const iso = this.routing.isolation;
3761
- return iso?.[useCase.tier] ?? this.routing.default;
3982
+ return iso?.[useCase.tier];
3762
3983
  }
3763
3984
  case "maintenance":
3764
3985
  case "chat":
3765
- return this.routing.default;
3986
+ return void 0;
3987
+ case "skill":
3988
+ case "mode":
3989
+ return void 0;
3766
3990
  }
3767
3991
  }
3768
- /**
3769
- * Returns the BackendDef reference for the resolved name. Returns the
3770
- * exact reference held in `backends` (no copy) so identity comparisons
3771
- * succeed (SC21).
3772
- */
3773
- resolveDefinition(useCase) {
3774
- const name = this.resolve(useCase);
3775
- const def = this.backends[name];
3776
- if (!def) {
3777
- throw new Error(
3778
- `BackendRouter.resolveDefinition: routing target '${name}' is not in backends (useCase=${JSON.stringify(useCase)}).`
3779
- );
3780
- }
3781
- return def;
3782
- }
3783
3992
  validateReferences() {
3784
3993
  const known = new Set(Object.keys(this.backends));
3785
3994
  const missing = [];
3786
- const check = (path19, name) => {
3787
- if (name !== void 0 && !known.has(name)) missing.push({ path: path19, name });
3995
+ const check = (label, value) => {
3996
+ if (value === void 0) return;
3997
+ for (const name of toArray(value)) {
3998
+ if (!known.has(name)) missing.push({ path: label, name });
3999
+ }
3788
4000
  };
3789
4001
  check("default", this.routing.default);
3790
4002
  check("quick-fix", this.routing["quick-fix"]);
@@ -3796,8 +4008,14 @@ var BackendRouter = class {
3796
4008
  check("isolation.none", this.routing.isolation?.none);
3797
4009
  check("isolation.container", this.routing.isolation?.container);
3798
4010
  check("isolation.remote-sandbox", this.routing.isolation?.["remote-sandbox"]);
4011
+ for (const [skill, value] of Object.entries(this.routing.skills ?? {})) {
4012
+ check(`skills.${skill}`, value);
4013
+ }
4014
+ for (const [mode, value] of Object.entries(this.routing.modes ?? {})) {
4015
+ check(`modes.${mode}`, value);
4016
+ }
3799
4017
  if (missing.length > 0) {
3800
- const detail = missing.map(({ path: path19, name }) => `routing.${path19} -> '${name}'`).join("; ");
4018
+ const detail = missing.map(({ path: path24, name }) => `routing.${path24} -> '${name}'`).join("; ");
3801
4019
  const known_ = [...known].join(", ") || "(none)";
3802
4020
  throw new Error(
3803
4021
  `BackendRouter: routing references unknown backend(s): ${detail}. Defined backends: [${known_}].`
@@ -3814,11 +4032,11 @@ import {
3814
4032
  Ok as Ok10,
3815
4033
  Err as Err7
3816
4034
  } from "@harness-engineering/types";
3817
- function resolveExitCode(code, command, resolve6) {
4035
+ function resolveExitCode(code, command, resolve8) {
3818
4036
  if (code === 0) {
3819
- resolve6(Ok10(void 0));
4037
+ resolve8(Ok10(void 0));
3820
4038
  } else {
3821
- resolve6(
4039
+ resolve8(
3822
4040
  Err7({
3823
4041
  category: "agent_not_found",
3824
4042
  message: `Claude command '${command}' not found or failed`
@@ -3826,8 +4044,8 @@ function resolveExitCode(code, command, resolve6) {
3826
4044
  );
3827
4045
  }
3828
4046
  }
3829
- function resolveSpawnError(command, resolve6) {
3830
- resolve6(Err7({ category: "agent_not_found", message: `Claude command '${command}' not found` }));
4047
+ function resolveSpawnError(command, resolve8) {
4048
+ resolve8(Err7({ category: "agent_not_found", message: `Claude command '${command}' not found` }));
3831
4049
  }
3832
4050
  var JUST_PAST_GRACE_MS = 5 * 6e4;
3833
4051
  var PRIMARY_LIMIT_RE = /You[\u2019']ve hit your limit.*resets\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*\(([^)]+)\)/i;
@@ -4140,10 +4358,10 @@ var ClaudeBackend = class {
4140
4358
  errRl.close();
4141
4359
  }
4142
4360
  if (exitCode === null) {
4143
- await new Promise((resolve6) => {
4361
+ await new Promise((resolve8) => {
4144
4362
  child.on("exit", (code) => {
4145
4363
  exitCode = code;
4146
- resolve6(null);
4364
+ resolve8(null);
4147
4365
  });
4148
4366
  });
4149
4367
  }
@@ -4165,10 +4383,10 @@ var ClaudeBackend = class {
4165
4383
  return Ok10(void 0);
4166
4384
  }
4167
4385
  async healthCheck() {
4168
- return new Promise((resolve6) => {
4386
+ return new Promise((resolve8) => {
4169
4387
  const child = spawn2(this.command, ["--version"]);
4170
- child.on("exit", (code) => resolveExitCode(code, this.command, resolve6));
4171
- child.on("error", () => resolveSpawnError(this.command, resolve6));
4388
+ child.on("exit", (code) => resolveExitCode(code, this.command, resolve8));
4389
+ child.on("error", () => resolveSpawnError(this.command, resolve8));
4172
4390
  });
4173
4391
  }
4174
4392
  };
@@ -4791,7 +5009,7 @@ var PiBackend = class {
4791
5009
  } else {
4792
5010
  resolvedModelName = this.config.model;
4793
5011
  }
4794
- const piSdk = await import("@mariozechner/pi-coding-agent");
5012
+ const piSdk = await import("@earendil-works/pi-coding-agent");
4795
5013
  const model = buildLocalModel({
4796
5014
  model: resolvedModelName,
4797
5015
  endpoint: this.config.endpoint,
@@ -4946,7 +5164,7 @@ var PiBackend = class {
4946
5164
  }
4947
5165
  async healthCheck() {
4948
5166
  try {
4949
- await import("@mariozechner/pi-coding-agent");
5167
+ await import("@earendil-works/pi-coding-agent");
4950
5168
  return Ok15(void 0);
4951
5169
  } catch (err) {
4952
5170
  return Err12({
@@ -5100,14 +5318,14 @@ var SshBackend = class {
5100
5318
  async healthCheck() {
5101
5319
  const args = [...this.buildSshArgs()];
5102
5320
  args[args.length - 1] = "true";
5103
- return new Promise((resolve6) => {
5321
+ return new Promise((resolve8) => {
5104
5322
  let child;
5105
5323
  try {
5106
5324
  child = this.spawnImpl(this.config.sshBinary, args, {
5107
5325
  stdio: ["ignore", "ignore", "pipe"]
5108
5326
  });
5109
5327
  } catch (err) {
5110
- resolve6(
5328
+ resolve8(
5111
5329
  Err13({
5112
5330
  category: "agent_not_found",
5113
5331
  message: err instanceof Error ? err.message : "failed to spawn ssh"
@@ -5128,9 +5346,9 @@ var SshBackend = class {
5128
5346
  child.on("close", (code) => {
5129
5347
  clearTimeout(timer);
5130
5348
  if (code === 0) {
5131
- resolve6(Ok16(void 0));
5349
+ resolve8(Ok16(void 0));
5132
5350
  } else {
5133
- resolve6(
5351
+ resolve8(
5134
5352
  Err13({
5135
5353
  category: "agent_not_found",
5136
5354
  message: `ssh health check failed (exit=${code ?? "null"}): ${stderr.slice(0, 500)}`
@@ -5140,7 +5358,7 @@ var SshBackend = class {
5140
5358
  });
5141
5359
  child.on("error", (err) => {
5142
5360
  clearTimeout(timer);
5143
- resolve6(Err13({ category: "agent_not_found", message: err.message }));
5361
+ resolve8(Err13({ category: "agent_not_found", message: err.message }));
5144
5362
  });
5145
5363
  });
5146
5364
  }
@@ -5188,13 +5406,13 @@ async function* readLines(stream) {
5188
5406
  if (buffer.length > 0) yield buffer;
5189
5407
  }
5190
5408
  function waitForExit(child) {
5191
- return new Promise((resolve6) => {
5409
+ return new Promise((resolve8) => {
5192
5410
  if (child.exitCode !== null) {
5193
- resolve6(child.exitCode);
5411
+ resolve8(child.exitCode);
5194
5412
  return;
5195
5413
  }
5196
- child.once("close", (code) => resolve6(code));
5197
- child.once("error", () => resolve6(null));
5414
+ child.once("close", (code) => resolve8(code));
5415
+ child.once("error", () => resolve8(null));
5198
5416
  });
5199
5417
  }
5200
5418
 
@@ -5384,14 +5602,14 @@ var OciServerlessBackend = class extends ServerlessBackend {
5384
5602
  return out;
5385
5603
  }
5386
5604
  runOneShot(binary, args) {
5387
- return new Promise((resolve6) => {
5605
+ return new Promise((resolve8) => {
5388
5606
  let child;
5389
5607
  try {
5390
5608
  child = this.spawnImpl(binary, args, {
5391
5609
  stdio: ["ignore", "pipe", "pipe"]
5392
5610
  });
5393
5611
  } catch (err) {
5394
- resolve6(
5612
+ resolve8(
5395
5613
  Err14({
5396
5614
  category: "agent_not_found",
5397
5615
  message: err instanceof Error ? err.message : "failed to spawn runtime"
@@ -5416,9 +5634,9 @@ var OciServerlessBackend = class extends ServerlessBackend {
5416
5634
  child.on("close", (code) => {
5417
5635
  clearTimeout(timer);
5418
5636
  if (code === 0) {
5419
- resolve6(Ok17(stdout));
5637
+ resolve8(Ok17(stdout));
5420
5638
  } else {
5421
- resolve6(
5639
+ resolve8(
5422
5640
  Err14({
5423
5641
  category: "response_error",
5424
5642
  message: `runtime '${binary} ${args.join(" ")}' exited ${code ?? "null"}: ${stderr.slice(0, 500)}`
@@ -5428,7 +5646,7 @@ var OciServerlessBackend = class extends ServerlessBackend {
5428
5646
  });
5429
5647
  child.on("error", (err) => {
5430
5648
  clearTimeout(timer);
5431
- resolve6(Err14({ category: "agent_not_found", message: err.message }));
5649
+ resolve8(Err14({ category: "agent_not_found", message: err.message }));
5432
5650
  });
5433
5651
  });
5434
5652
  }
@@ -5488,13 +5706,13 @@ async function* readLines2(stream) {
5488
5706
  if (buffer.length > 0) yield buffer;
5489
5707
  }
5490
5708
  function waitForExit2(child) {
5491
- return new Promise((resolve6) => {
5709
+ return new Promise((resolve8) => {
5492
5710
  if (child.exitCode !== null) {
5493
- resolve6(child.exitCode);
5711
+ resolve8(child.exitCode);
5494
5712
  return;
5495
5713
  }
5496
- child.once("close", (code) => resolve6(code));
5497
- child.once("error", () => resolve6(null));
5714
+ child.once("close", (code) => resolve8(code));
5715
+ child.once("error", () => resolve8(null));
5498
5716
  });
5499
5717
  }
5500
5718
 
@@ -5694,13 +5912,13 @@ var ContainerBackend = class {
5694
5912
  import { execFile as execFile3, spawn as spawn5 } from "child_process";
5695
5913
  import { Ok as Ok18, Err as Err16 } from "@harness-engineering/types";
5696
5914
  function dockerExec(args) {
5697
- return new Promise((resolve6, reject) => {
5915
+ return new Promise((resolve8, reject) => {
5698
5916
  execFile3("docker", args, (error, stdout) => {
5699
5917
  if (error) {
5700
5918
  reject(error);
5701
5919
  return;
5702
5920
  }
5703
- resolve6(stdout.trim());
5921
+ resolve8(stdout.trim());
5704
5922
  });
5705
5923
  });
5706
5924
  }
@@ -5759,11 +5977,11 @@ var DockerRuntime = class {
5759
5977
  } finally {
5760
5978
  rl.close();
5761
5979
  }
5762
- const exitCode = await new Promise((resolve6) => {
5980
+ const exitCode = await new Promise((resolve8) => {
5763
5981
  if (child.exitCode !== null) {
5764
- resolve6(child.exitCode);
5982
+ resolve8(child.exitCode);
5765
5983
  } else {
5766
- child.on("exit", (code) => resolve6(code ?? 1));
5984
+ child.on("exit", (code) => resolve8(code ?? 1));
5767
5985
  }
5768
5986
  });
5769
5987
  return exitCode;
@@ -5822,13 +6040,13 @@ var EnvSecretBackend = class {
5822
6040
  import { execFile as execFile4 } from "child_process";
5823
6041
  import { Ok as Ok20, Err as Err18 } from "@harness-engineering/types";
5824
6042
  function opExec(args) {
5825
- return new Promise((resolve6, reject) => {
6043
+ return new Promise((resolve8, reject) => {
5826
6044
  execFile4("op", args, (error, stdout) => {
5827
6045
  if (error) {
5828
6046
  reject(error);
5829
6047
  return;
5830
6048
  }
5831
- resolve6(stdout.trim());
6049
+ resolve8(stdout.trim());
5832
6050
  });
5833
6051
  });
5834
6052
  }
@@ -5871,13 +6089,13 @@ var OnePasswordSecretBackend = class {
5871
6089
  import { execFile as execFile5 } from "child_process";
5872
6090
  import { Ok as Ok21, Err as Err19 } from "@harness-engineering/types";
5873
6091
  function vaultExec(args, env) {
5874
- return new Promise((resolve6, reject) => {
6092
+ return new Promise((resolve8, reject) => {
5875
6093
  execFile5("vault", args, { env: { ...process.env, ...env } }, (error, stdout) => {
5876
6094
  if (error) {
5877
6095
  reject(error);
5878
6096
  return;
5879
6097
  }
5880
- resolve6(stdout.trim());
6098
+ resolve8(stdout.trim());
5881
6099
  });
5882
6100
  });
5883
6101
  }
@@ -5952,7 +6170,11 @@ var OrchestratorBackendFactory = class {
5952
6170
  opts;
5953
6171
  constructor(opts) {
5954
6172
  this.opts = opts;
5955
- this.router = new BackendRouter({ backends: opts.backends, routing: opts.routing });
6173
+ this.router = new BackendRouter({
6174
+ backends: opts.backends,
6175
+ routing: opts.routing,
6176
+ ...opts.decisionBus !== void 0 ? { decisionBus: opts.decisionBus } : {}
6177
+ });
5956
6178
  }
5957
6179
  /**
5958
6180
  * Resolve `useCase` to a backend name, materialize a fresh
@@ -5971,12 +6193,21 @@ var OrchestratorBackendFactory = class {
5971
6193
  * is `undefined` for pure-modern configs. Threading the routed name
5972
6194
  * through dispatch eliminates that gap.
5973
6195
  */
5974
- resolveName(useCase) {
5975
- return this.router.resolve(useCase);
6196
+ resolveName(useCase, opts) {
6197
+ return this.router.resolve(useCase, opts).backendName;
5976
6198
  }
5977
- forUseCase(useCase) {
5978
- const def = this.router.resolveDefinition(useCase);
5979
- const name = this.router.resolve(useCase);
6199
+ /**
6200
+ * Spec B Phase 1: expose the underlying router for callers that need
6201
+ * it directly (e.g., {@link buildIntelligencePipeline} for the
6202
+ * I1 SEL/PESL comparison fix). Read-only access; consumers must not
6203
+ * mutate router state.
6204
+ */
6205
+ getRouter() {
6206
+ return this.router;
6207
+ }
6208
+ forUseCase(useCase, opts) {
6209
+ const { def, decision } = this.router.resolveDecisionAndDef(useCase, opts);
6210
+ const name = decision.backendName;
5980
6211
  let backend;
5981
6212
  const createOpts = this.opts.cacheMetrics ? { cacheMetrics: this.opts.cacheMetrics } : {};
5982
6213
  if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
@@ -6149,15 +6380,14 @@ function buildClaudeCliProvider(def, args, layerModel) {
6149
6380
 
6150
6381
  // src/agent/intelligence-factory.ts
6151
6382
  function buildIntelligencePipeline(deps) {
6152
- const { config } = deps;
6383
+ const { config, router } = deps;
6153
6384
  const intel = config.intelligence;
6154
6385
  if (!intel?.enabled) return null;
6155
6386
  const selProvider = buildAnalysisProviderForLayer("sel", deps);
6156
6387
  if (!selProvider) return null;
6157
- const routing = config.agent.routing;
6158
- const peslName = routing?.intelligence?.pesl;
6159
- const selName = routing?.intelligence?.sel ?? routing?.default;
6160
- const peslProvider = peslName !== void 0 && peslName !== selName ? buildAnalysisProviderForLayer("pesl", deps) : null;
6388
+ const peslName = router.resolve({ kind: "intelligence", layer: "pesl" }).backendName;
6389
+ const selName = router.resolve({ kind: "intelligence", layer: "sel" }).backendName;
6390
+ const peslProvider = peslName !== selName ? buildAnalysisProviderForLayer("pesl", deps) : null;
6161
6391
  const peslModel = intel.models?.pesl ?? config.agent.model;
6162
6392
  const graphStore = new GraphStore();
6163
6393
  const pipeline = new IntelligencePipeline(selProvider, graphStore, {
@@ -6174,7 +6404,7 @@ function buildAnalysisProviderForLayer(layer, deps) {
6174
6404
  const layerModel = layer === "sel" ? intel.models?.sel : intel.models?.pesl;
6175
6405
  return buildExplicitProvider(intel.provider, layerModel ?? config.agent.model, config);
6176
6406
  }
6177
- const routed = resolveRoutedBackend(layer, config, logger);
6407
+ const routed = resolveRoutedBackend(layer, deps);
6178
6408
  if (!routed) return null;
6179
6409
  const { name, def } = routed;
6180
6410
  const resolver = localResolvers.get(name);
@@ -6199,20 +6429,26 @@ function buildAnalysisProviderForLayer(layer, deps) {
6199
6429
  logger
6200
6430
  });
6201
6431
  }
6202
- function resolveRoutedBackend(layer, config, logger) {
6203
- const routing = config.agent.routing;
6432
+ function resolveRoutedBackend(layer, deps) {
6433
+ const { config, router, logger } = deps;
6204
6434
  const backends = config.agent.backends;
6205
- if (!routing || !backends) return null;
6206
- const layerName = routing.intelligence?.[layer];
6207
- const name = layerName ?? routing.default;
6208
- const def = backends[name];
6209
- if (!def) {
6435
+ if (!backends || !router) return null;
6436
+ try {
6437
+ const decision = router.resolve({ kind: "intelligence", layer });
6438
+ const def = backends[decision.backendName];
6439
+ if (!def) {
6440
+ logger.warn(
6441
+ `Intelligence pipeline: routed backend '${decision.backendName}' for layer '${layer}' is not in agent.backends.`
6442
+ );
6443
+ return null;
6444
+ }
6445
+ return { name: decision.backendName, def };
6446
+ } catch (err) {
6210
6447
  logger.warn(
6211
- `Intelligence pipeline: routed backend '${name}' for layer '${layer}' is not in agent.backends.`
6448
+ `Intelligence pipeline: router could not resolve intelligence.${layer}; intelligence disabled. error=${String(err)}`
6212
6449
  );
6213
6450
  return null;
6214
6451
  }
6215
- return { name, def };
6216
6452
  }
6217
6453
  function buildExplicitProvider(provider, selModel, config) {
6218
6454
  if (provider.kind === "anthropic") {
@@ -6247,9 +6483,104 @@ function buildExplicitProvider(provider, selModel, config) {
6247
6483
  });
6248
6484
  }
6249
6485
 
6486
+ // src/routing/decision-bus.ts
6487
+ var RoutingDecisionBus = class {
6488
+ ringBuffer = [];
6489
+ listeners = /* @__PURE__ */ new Set();
6490
+ capacity;
6491
+ logger;
6492
+ constructor(opts) {
6493
+ this.capacity = opts?.capacity ?? 500;
6494
+ this.logger = opts?.logger;
6495
+ }
6496
+ emit(decision) {
6497
+ this.ringBuffer.push(decision);
6498
+ if (this.ringBuffer.length > this.capacity) {
6499
+ this.ringBuffer.shift();
6500
+ }
6501
+ if (this.logger) {
6502
+ this.logger.info("routing-decision", {
6503
+ useCase: decision.useCase,
6504
+ backendName: decision.backendName,
6505
+ resolutionPathLength: decision.resolutionPath.length,
6506
+ durationMs: decision.durationMs
6507
+ });
6508
+ }
6509
+ for (const listener of this.listeners) {
6510
+ try {
6511
+ listener(decision);
6512
+ } catch (err) {
6513
+ if (this.logger) {
6514
+ this.logger.warn("RoutingDecisionBus subscriber threw", {
6515
+ error: String(err)
6516
+ });
6517
+ }
6518
+ }
6519
+ }
6520
+ }
6521
+ recent(filter) {
6522
+ let out = this.ringBuffer.slice();
6523
+ if (filter?.skillName !== void 0) {
6524
+ out = out.filter(
6525
+ (d) => d.useCase.kind === "skill" && d.useCase.skillName === filter.skillName
6526
+ );
6527
+ }
6528
+ if (filter?.mode !== void 0) {
6529
+ const m = filter.mode;
6530
+ out = out.filter(
6531
+ (d) => d.useCase.kind === "mode" && d.useCase.cognitiveMode === m || d.useCase.kind === "skill" && d.useCase.cognitiveMode === m
6532
+ );
6533
+ }
6534
+ if (filter?.backendName !== void 0) {
6535
+ out = out.filter((d) => d.backendName === filter.backendName);
6536
+ }
6537
+ if (filter?.limit !== void 0) {
6538
+ out = out.slice(-filter.limit).reverse();
6539
+ } else {
6540
+ out = out.reverse();
6541
+ }
6542
+ return out;
6543
+ }
6544
+ subscribe(listener) {
6545
+ this.listeners.add(listener);
6546
+ return () => {
6547
+ this.listeners.delete(listener);
6548
+ };
6549
+ }
6550
+ /**
6551
+ * Spec B Phase 5 (review-S2 fix): release all subscriber references so
6552
+ * teardown can complete without anchoring closures. Called from
6553
+ * `Orchestrator.stop()` before nulling the bus reference. The bus
6554
+ * remains usable after clear — `subscribe()` works as normal.
6555
+ */
6556
+ clearListeners() {
6557
+ this.listeners.clear();
6558
+ }
6559
+ };
6560
+
6561
+ // src/agent/triage-skill-mapping.ts
6562
+ function resolveSkillForTriage(triageSkill, catalog) {
6563
+ const expected = `harness-${triageSkill}`;
6564
+ const match = catalog.find((e) => e.name === expected);
6565
+ if (!match) return void 0;
6566
+ return match.cognitiveMode !== void 0 ? { name: match.name, cognitiveMode: match.cognitiveMode } : { name: match.name };
6567
+ }
6568
+
6569
+ // src/agent/use-case-builder.ts
6570
+ function buildRoutingUseCase(issue, backendParam, catalog) {
6571
+ if (backendParam === "local") return { kind: "tier", tier: "quick-fix" };
6572
+ const decision = triageIssue(issue, {});
6573
+ const resolved = resolveSkillForTriage(decision.skill, catalog);
6574
+ if (resolved) {
6575
+ return resolved.cognitiveMode !== void 0 ? { kind: "skill", skillName: resolved.name, cognitiveMode: resolved.cognitiveMode } : { kind: "skill", skillName: resolved.name };
6576
+ }
6577
+ const tier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
6578
+ return { kind: "tier", tier };
6579
+ }
6580
+
6250
6581
  // src/server/http.ts
6251
6582
  import * as http from "http";
6252
- import * as path14 from "path";
6583
+ import * as path17 from "path";
6253
6584
  import { assertPortUsable } from "@harness-engineering/core";
6254
6585
 
6255
6586
  // src/server/websocket.ts
@@ -6312,7 +6643,7 @@ import { z as z3 } from "zod";
6312
6643
  // src/server/utils.ts
6313
6644
  var DEFAULT_MAX_BYTES = 1048576;
6314
6645
  function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
6315
- return new Promise((resolve6, reject) => {
6646
+ return new Promise((resolve8, reject) => {
6316
6647
  let body = "";
6317
6648
  let bytes = 0;
6318
6649
  req.on("data", (chunk) => {
@@ -6324,7 +6655,7 @@ function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
6324
6655
  }
6325
6656
  body += String(chunk);
6326
6657
  });
6327
- req.on("end", () => resolve6(body));
6658
+ req.on("end", () => resolve8(body));
6328
6659
  req.on("error", reject);
6329
6660
  });
6330
6661
  }
@@ -6445,8 +6776,8 @@ function handleV1InteractionsResolveRoute(req, res, queue) {
6445
6776
 
6446
6777
  // src/server/routes/plans.ts
6447
6778
  import { z as z5 } from "zod";
6448
- import * as fs9 from "fs/promises";
6449
- import * as path9 from "path";
6779
+ import * as fs10 from "fs/promises";
6780
+ import * as path11 from "path";
6450
6781
  var PlanWriteSchema = z5.object({
6451
6782
  filename: z5.string().min(1),
6452
6783
  content: z5.string().min(1)
@@ -6466,7 +6797,7 @@ function handlePlansRoute(req, res, plansDir) {
6466
6797
  return;
6467
6798
  }
6468
6799
  const parsed = result.data;
6469
- const basename3 = path9.basename(parsed.filename);
6800
+ const basename3 = path11.basename(parsed.filename);
6470
6801
  if (basename3 !== parsed.filename || !basename3.endsWith(".md")) {
6471
6802
  res.writeHead(400, { "Content-Type": "application/json" });
6472
6803
  res.end(
@@ -6474,9 +6805,9 @@ function handlePlansRoute(req, res, plansDir) {
6474
6805
  );
6475
6806
  return;
6476
6807
  }
6477
- await fs9.mkdir(plansDir, { recursive: true });
6478
- const filePath = path9.join(plansDir, basename3);
6479
- await fs9.writeFile(filePath, parsed.content, "utf-8");
6808
+ await fs10.mkdir(plansDir, { recursive: true });
6809
+ const filePath = path11.join(plansDir, basename3);
6810
+ await fs10.writeFile(filePath, parsed.content, "utf-8");
6480
6811
  res.writeHead(201, { "Content-Type": "application/json" });
6481
6812
  res.end(JSON.stringify({ ok: true, filename: basename3 }));
6482
6813
  } catch {
@@ -6851,8 +7182,8 @@ function handleAnalyzeRoute(req, res, pipeline) {
6851
7182
  }
6852
7183
 
6853
7184
  // src/server/routes/roadmap-actions.ts
6854
- import * as fs10 from "fs/promises";
6855
- import * as path10 from "path";
7185
+ import * as fs11 from "fs/promises";
7186
+ import * as path12 from "path";
6856
7187
  import {
6857
7188
  parseRoadmap as parseRoadmap2,
6858
7189
  serializeRoadmap as serializeRoadmap2,
@@ -6888,7 +7219,7 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6888
7219
  sendJSON2(res, 503, { error: "Roadmap path not configured" });
6889
7220
  return;
6890
7221
  }
6891
- const projectRoot = path10.dirname(path10.dirname(roadmapPath));
7222
+ const projectRoot = path12.dirname(path12.dirname(roadmapPath));
6892
7223
  const mode = loadProjectRoadmapMode(projectRoot);
6893
7224
  if (mode === "file-less") {
6894
7225
  const trackerCfg = loadTrackerClientConfigFromProject(projectRoot);
@@ -6941,7 +7272,7 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6941
7272
  sendJSON2(res, 400, { error: "Title must not contain newlines or markdown headings" });
6942
7273
  return;
6943
7274
  }
6944
- const content = await fs10.readFile(roadmapPath, "utf-8");
7275
+ const content = await fs11.readFile(roadmapPath, "utf-8");
6945
7276
  const roadmapResult = parseRoadmap2(content);
6946
7277
  if (!roadmapResult.ok) {
6947
7278
  sendJSON2(res, 500, { error: "Failed to parse roadmap file" });
@@ -6972,8 +7303,8 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6972
7303
  roadmap.frontmatter.lastManualEdit = (/* @__PURE__ */ new Date()).toISOString();
6973
7304
  const tmpPath = roadmapPath + ".tmp";
6974
7305
  const serialized = serializeRoadmap2(roadmap);
6975
- await fs10.writeFile(tmpPath, serialized, "utf-8");
6976
- await fs10.rename(tmpPath, roadmapPath);
7306
+ await fs11.writeFile(tmpPath, serialized, "utf-8");
7307
+ await fs11.rename(tmpPath, roadmapPath);
6977
7308
  sendJSON2(res, 201, { ok: true, featureName: parsed.title });
6978
7309
  } catch (err) {
6979
7310
  const msg = err instanceof Error ? err.message : "Failed to append to roadmap";
@@ -7460,88 +7791,779 @@ function handleV1TelemetryRoute(req, res, deps) {
7460
7791
  return false;
7461
7792
  }
7462
7793
 
7463
- // src/server/routes/sessions.ts
7464
- import * as fs11 from "fs/promises";
7465
- import * as path11 from "path";
7794
+ // src/server/routes/v1/proposals.ts
7466
7795
  import { z as z13 } from "zod";
7467
- var SessionCreateSchema = z13.object({
7468
- sessionId: z13.string().min(1)
7469
- }).passthrough();
7470
- var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7471
- function isSafeId(id) {
7472
- return UUID_RE2.test(id) || path11.basename(id) === id && !id.includes("..");
7473
- }
7474
- function jsonResponse(res, status, data) {
7475
- res.writeHead(status, { "Content-Type": "application/json" });
7476
- res.end(JSON.stringify(data));
7796
+ import {
7797
+ getProposal as getProposal3,
7798
+ listProposals,
7799
+ updateProposal as updateProposal3,
7800
+ ProposalNotFoundError as ProposalNotFoundError3
7801
+ } from "@harness-engineering/core";
7802
+ import {
7803
+ EditProposalInputSchema
7804
+ } from "@harness-engineering/types";
7805
+
7806
+ // src/proposals/gate.ts
7807
+ import { parse as parseYaml2 } from "yaml";
7808
+ import {
7809
+ getProposal,
7810
+ updateProposal,
7811
+ ProposalNotFoundError
7812
+ } from "@harness-engineering/core";
7813
+ var GateRunError = class extends Error {
7814
+ constructor(message) {
7815
+ super(message);
7816
+ this.name = "GateRunError";
7817
+ }
7818
+ };
7819
+ var SKILL_NAME_RE = /^[a-z][a-z0-9-]*$/;
7820
+ function checkSkillYaml(yaml) {
7821
+ const findings = [];
7822
+ let doc;
7823
+ try {
7824
+ doc = parseYaml2(yaml);
7825
+ } catch (err) {
7826
+ findings.push({
7827
+ severity: "error",
7828
+ title: "skill.yaml does not parse",
7829
+ detail: err instanceof Error ? err.message : String(err)
7830
+ });
7831
+ return findings;
7832
+ }
7833
+ if (!doc || typeof doc !== "object") {
7834
+ findings.push({
7835
+ severity: "error",
7836
+ title: "skill.yaml top-level is not a mapping",
7837
+ detail: "Expected a YAML document with keys at the root (name, version, description, \u2026)."
7838
+ });
7839
+ return findings;
7840
+ }
7841
+ const obj = doc;
7842
+ if (typeof obj["name"] !== "string") {
7843
+ findings.push({
7844
+ severity: "error",
7845
+ title: "skill.yaml missing `name`",
7846
+ detail: "Every skill must declare its kebab-case name."
7847
+ });
7848
+ }
7849
+ if (typeof obj["version"] !== "string") {
7850
+ findings.push({
7851
+ severity: "error",
7852
+ title: "skill.yaml missing `version`",
7853
+ detail: "Every skill must declare a semver version string."
7854
+ });
7855
+ }
7856
+ if (typeof obj["description"] !== "string") {
7857
+ findings.push({
7858
+ severity: "warning",
7859
+ title: "skill.yaml missing `description`",
7860
+ detail: "Description is strongly recommended for discoverability."
7861
+ });
7862
+ }
7863
+ return findings;
7477
7864
  }
7478
- function extractSessionId(url) {
7479
- const segments = new URL(url, "http://localhost").pathname.split(path11.posix.sep);
7480
- const id = segments.pop();
7481
- return id && id !== "sessions" ? id : null;
7865
+ function checkSkillMd(md) {
7866
+ const findings = [];
7867
+ if (md.trim().length < 40) {
7868
+ findings.push({
7869
+ severity: "error",
7870
+ title: "SKILL.md is too short",
7871
+ detail: "A skill needs a meaningful description (at least 40 non-whitespace characters)."
7872
+ });
7873
+ }
7874
+ if (!/^#\s+\S/m.test(md)) {
7875
+ findings.push({
7876
+ severity: "warning",
7877
+ title: "SKILL.md has no top-level heading",
7878
+ detail: "Convention: open SKILL.md with `# <Skill Name>`."
7879
+ });
7880
+ }
7881
+ return findings;
7482
7882
  }
7483
- async function handleList(res, sessionsDir) {
7484
- try {
7485
- const entries = await fs11.readdir(sessionsDir, { withFileTypes: true });
7486
- const sessions = [];
7487
- for (const entry of entries) {
7488
- if (!entry.isDirectory()) continue;
7489
- try {
7490
- const content = await fs11.readFile(
7491
- path11.join(sessionsDir, entry.name, "session.json"),
7492
- "utf-8"
7493
- );
7494
- sessions.push(JSON.parse(content));
7495
- } catch {
7496
- }
7883
+ function checkName(name) {
7884
+ if (SKILL_NAME_RE.test(name)) return [];
7885
+ return [
7886
+ {
7887
+ severity: "error",
7888
+ title: "skill name violates the kebab-case rule",
7889
+ detail: `"${name}" must match /^[a-z][a-z0-9-]*$/. Use only lowercase letters, digits, and hyphens; start with a letter.`
7497
7890
  }
7498
- sessions.sort(
7499
- (a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime()
7891
+ ];
7892
+ }
7893
+ function checkDiff(diff) {
7894
+ const findings = [];
7895
+ if (!diff.includes("---") || !diff.includes("+++")) {
7896
+ findings.push({
7897
+ severity: "error",
7898
+ title: "Refinement diff is not in unified-diff format",
7899
+ detail: "Diffs must include both `---` and `+++` headers."
7900
+ });
7901
+ }
7902
+ if (!/^@@\s/m.test(diff)) {
7903
+ findings.push({
7904
+ severity: "warning",
7905
+ title: "Refinement diff has no hunk marker",
7906
+ detail: "A unified diff typically contains at least one `@@` line."
7907
+ });
7908
+ }
7909
+ return findings;
7910
+ }
7911
+ function deriveFindings(proposal) {
7912
+ const findings = [];
7913
+ findings.push(...checkName(proposal.content.name));
7914
+ if (proposal.kind === "new-skill") {
7915
+ findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
7916
+ findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
7917
+ } else if (proposal.kind === "refinement") {
7918
+ findings.push(...checkDiff(proposal.content.diff ?? ""));
7919
+ }
7920
+ return findings;
7921
+ }
7922
+ async function runGate(projectPath, proposalId) {
7923
+ const proposal = await getProposal(projectPath, proposalId);
7924
+ if (!proposal) throw new ProposalNotFoundError(proposalId);
7925
+ if (proposal.status === "approved" || proposal.status === "rejected") {
7926
+ throw new GateRunError(
7927
+ `proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
7500
7928
  );
7501
- jsonResponse(res, 200, sessions);
7502
- } catch (err) {
7503
- if (err.code === "ENOENT") {
7504
- jsonResponse(res, 200, []);
7505
- return;
7506
- }
7507
- jsonResponse(res, 500, { error: "Failed to list sessions" });
7508
7929
  }
7930
+ const findings = deriveFindings(proposal);
7931
+ const runAt = (/* @__PURE__ */ new Date()).toISOString();
7932
+ const hasError = findings.some((f) => f.severity === "error");
7933
+ const nextStatus = hasError ? "gate-failed" : "gate-running";
7934
+ const updated = await updateProposal(projectPath, proposalId, {
7935
+ status: nextStatus,
7936
+ gate: { lastRunAt: runAt, findings }
7937
+ });
7938
+ return {
7939
+ proposalId: updated.id,
7940
+ status: updated.status,
7941
+ findings,
7942
+ runAt
7943
+ };
7509
7944
  }
7510
- async function handleGet(res, id, sessionsDir) {
7511
- if (!isSafeId(id)) {
7512
- jsonResponse(res, 400, { error: "Invalid sessionId" });
7513
- return;
7945
+
7946
+ // src/proposals/promote.ts
7947
+ import * as fs12 from "fs";
7948
+ import * as path13 from "path";
7949
+ import { parse as parseYaml3, stringify as stringifyYaml } from "yaml";
7950
+ import {
7951
+ getProposal as getProposal2,
7952
+ updateProposal as updateProposal2,
7953
+ ProposalNotFoundError as ProposalNotFoundError2
7954
+ } from "@harness-engineering/core";
7955
+ var GateNotReadyError = class extends Error {
7956
+ constructor(message) {
7957
+ super(message);
7958
+ this.name = "GateNotReadyError";
7514
7959
  }
7515
- try {
7516
- const content = await fs11.readFile(path11.join(sessionsDir, id, "session.json"), "utf-8");
7517
- jsonResponse(res, 200, JSON.parse(content));
7518
- } catch (err) {
7519
- if (err.code === "ENOENT") {
7520
- jsonResponse(res, 404, { error: "Session not found" });
7521
- return;
7522
- }
7523
- jsonResponse(res, 500, { error: "Failed to read session" });
7960
+ };
7961
+ var PromotionError = class extends Error {
7962
+ constructor(message) {
7963
+ super(message);
7964
+ this.name = "PromotionError";
7524
7965
  }
7966
+ };
7967
+ var GATE_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
7968
+ function skillDir(projectPath, name) {
7969
+ return path13.join(projectPath, "agents", "skills", "claude-code", name);
7525
7970
  }
7526
- async function handleCreate(req, res, sessionsDir) {
7971
+ function readIfExists(p) {
7527
7972
  try {
7528
- const body = await readBody(req);
7529
- const result = SessionCreateSchema.safeParse(JSON.parse(body));
7530
- if (!result.success) {
7531
- jsonResponse(res, 400, { error: "Missing sessionId" });
7532
- return;
7533
- }
7534
- const session = result.data;
7535
- if (!isSafeId(session.sessionId)) {
7536
- jsonResponse(res, 400, { error: "Invalid sessionId" });
7537
- return;
7538
- }
7539
- const sessionDir = path11.join(sessionsDir, session.sessionId);
7540
- await fs11.mkdir(sessionDir, { recursive: true });
7541
- await fs11.writeFile(path11.join(sessionDir, "session.json"), JSON.stringify(session, null, 2));
7542
- jsonResponse(res, 200, { ok: true });
7973
+ return fs12.readFileSync(p, "utf-8");
7543
7974
  } catch {
7544
- jsonResponse(res, 500, { error: "Failed to save session" });
7975
+ return null;
7976
+ }
7977
+ }
7978
+ function injectProvenanceIntoYaml(yamlText, proposalId) {
7979
+ let doc;
7980
+ try {
7981
+ doc = parseYaml3(yamlText);
7982
+ } catch (err) {
7983
+ throw new PromotionError(
7984
+ `skill.yaml does not parse: ${err instanceof Error ? err.message : String(err)}`
7985
+ );
7986
+ }
7987
+ if (!doc || typeof doc !== "object") {
7988
+ throw new PromotionError("skill.yaml top-level is not a mapping");
7989
+ }
7990
+ const obj = doc;
7991
+ obj["provenance"] = "agent-proposed";
7992
+ obj["originatingProposalId"] = proposalId;
7993
+ return stringifyYaml(obj);
7994
+ }
7995
+ function assertGateReady(proposal) {
7996
+ if (proposal.status !== "gate-running") {
7997
+ throw new GateNotReadyError(
7998
+ `proposal ${proposal.id} is in status "${proposal.status}"; the gate must pass before promotion`
7999
+ );
8000
+ }
8001
+ const findings = proposal.gate?.findings ?? [];
8002
+ if (findings.some((f) => f.severity === "error")) {
8003
+ throw new GateNotReadyError(
8004
+ `proposal ${proposal.id} has unresolved gate errors; re-run the gate after edits`
8005
+ );
8006
+ }
8007
+ if (!proposal.gate?.lastRunAt) {
8008
+ throw new GateNotReadyError(`proposal ${proposal.id} has no gate run on record`);
8009
+ }
8010
+ const ageMs = Date.now() - Date.parse(proposal.gate.lastRunAt);
8011
+ if (!Number.isFinite(ageMs) || ageMs > GATE_FRESHNESS_MS) {
8012
+ throw new GateNotReadyError(
8013
+ `proposal ${proposal.id} gate run is older than 24h; re-run before approving`
8014
+ );
8015
+ }
8016
+ }
8017
+ async function promoteNewSkill(projectPath, proposal) {
8018
+ const target = skillDir(projectPath, proposal.content.name);
8019
+ if (fs12.existsSync(target)) {
8020
+ throw new PromotionError(
8021
+ `a catalog skill already exists at ${target}; use a refinement proposal to update it`
8022
+ );
8023
+ }
8024
+ fs12.mkdirSync(target, { recursive: true });
8025
+ const yamlOut = injectProvenanceIntoYaml(proposal.content.skillYaml ?? "", proposal.id);
8026
+ fs12.writeFileSync(path13.join(target, "skill.yaml"), yamlOut);
8027
+ fs12.writeFileSync(path13.join(target, "SKILL.md"), proposal.content.skillMd ?? "");
8028
+ return { skillPath: target };
8029
+ }
8030
+ async function promoteRefinement(projectPath, proposal) {
8031
+ if (!proposal.targetSkill) {
8032
+ throw new PromotionError("refinement proposal is missing targetSkill");
8033
+ }
8034
+ const target = skillDir(projectPath, proposal.targetSkill);
8035
+ if (!fs12.existsSync(target)) {
8036
+ throw new PromotionError(
8037
+ `target skill ${proposal.targetSkill} does not exist at ${target}; cannot refine`
8038
+ );
8039
+ }
8040
+ const yamlPath = path13.join(target, "skill.yaml");
8041
+ const before = readIfExists(yamlPath) ?? "";
8042
+ const after = injectProvenanceIntoYaml(before, proposal.id);
8043
+ if (after === before) {
8044
+ throw new PromotionError(
8045
+ "no metadata changes detected; check that the reviewer applied the proposed diff before approving"
8046
+ );
8047
+ }
8048
+ fs12.writeFileSync(yamlPath, after);
8049
+ return { skillPath: target };
8050
+ }
8051
+ async function promote(projectPath, proposalId, decidedBy) {
8052
+ const proposal = await getProposal2(projectPath, proposalId);
8053
+ if (!proposal) throw new ProposalNotFoundError2(proposalId);
8054
+ assertGateReady(proposal);
8055
+ const out = proposal.kind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8056
+ await updateProposal2(projectPath, proposalId, {
8057
+ status: "approved",
8058
+ decision: {
8059
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
8060
+ decidedBy,
8061
+ action: "approved"
8062
+ }
8063
+ });
8064
+ return {
8065
+ proposalId,
8066
+ skillPath: out.skillPath,
8067
+ provenance: "agent-proposed"
8068
+ };
8069
+ }
8070
+
8071
+ // src/proposals/events.ts
8072
+ function emit3(bus, topic, data) {
8073
+ bus.emit(topic, data);
8074
+ }
8075
+ function emitProposalCreated(bus, proposal) {
8076
+ const data = {
8077
+ id: proposal.id,
8078
+ kind: proposal.kind,
8079
+ name: proposal.content.name,
8080
+ proposedBy: proposal.proposedBy,
8081
+ justification: proposal.source.justification
8082
+ };
8083
+ if (proposal.targetSkill) data.targetSkill = proposal.targetSkill;
8084
+ emit3(bus, "proposal.created", data);
8085
+ }
8086
+ function emitProposalApproved(bus, proposal) {
8087
+ const data = {
8088
+ id: proposal.id,
8089
+ kind: proposal.kind,
8090
+ name: proposal.content.name,
8091
+ decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
8092
+ };
8093
+ if (proposal.targetSkill) data.targetSkill = proposal.targetSkill;
8094
+ emit3(bus, "proposal.approved", data);
8095
+ }
8096
+ function emitProposalRejected(bus, proposal) {
8097
+ const data = {
8098
+ id: proposal.id,
8099
+ kind: proposal.kind,
8100
+ name: proposal.content.name,
8101
+ decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
8102
+ reason: proposal.decision?.reason ?? "(no reason given)"
8103
+ };
8104
+ emit3(bus, "proposal.rejected", data);
8105
+ }
8106
+
8107
+ // src/server/routes/v1/proposals.ts
8108
+ var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
8109
+ var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
8110
+ var RUN_GATE_RE = /^\/api\/v1\/proposals\/([^/?]+)\/run-gate(?:\?.*)?$/;
8111
+ var APPROVE_RE = /^\/api\/v1\/proposals\/([^/?]+)\/approve(?:\?.*)?$/;
8112
+ var REJECT_RE = /^\/api\/v1\/proposals\/([^/?]+)\/reject(?:\?.*)?$/;
8113
+ var ProposalStatusValues = [
8114
+ "open",
8115
+ "gate-running",
8116
+ "gate-failed",
8117
+ "approved",
8118
+ "rejected"
8119
+ ];
8120
+ var RejectBody = z13.object({
8121
+ reason: z13.string().min(1).max(280)
8122
+ });
8123
+ function sendJSON8(res, status, body) {
8124
+ res.writeHead(status, { "Content-Type": "application/json" });
8125
+ res.end(JSON.stringify(body));
8126
+ }
8127
+ function getDecidedBy(req, deps) {
8128
+ if (deps.decidedByResolver) return deps.decidedByResolver(req);
8129
+ const token = req._authToken;
8130
+ return token?.id ?? "unknown";
8131
+ }
8132
+ function parseStatusFromQuery(url) {
8133
+ const queryIdx = url.indexOf("?");
8134
+ if (queryIdx === -1) return void 0;
8135
+ const params = new URLSearchParams(url.slice(queryIdx + 1));
8136
+ const raw = params.get("status");
8137
+ if (!raw) return void 0;
8138
+ if (raw === "all") return "all";
8139
+ if (ProposalStatusValues.includes(raw)) return raw;
8140
+ return void 0;
8141
+ }
8142
+ async function handleList(req, res, deps) {
8143
+ const url = req.url ?? "";
8144
+ const status = parseStatusFromQuery(url);
8145
+ const proposals = await listProposals(deps.projectPath, status ? { status } : {});
8146
+ sendJSON8(res, 200, proposals);
8147
+ }
8148
+ async function handleGet(res, deps, id) {
8149
+ const proposal = await getProposal3(deps.projectPath, id);
8150
+ if (!proposal) {
8151
+ sendJSON8(res, 404, { error: "Proposal not found" });
8152
+ return;
8153
+ }
8154
+ sendJSON8(res, 200, proposal);
8155
+ }
8156
+ async function handleRunGate(res, deps, id) {
8157
+ try {
8158
+ const result = await runGate(deps.projectPath, id);
8159
+ sendJSON8(res, 200, result);
8160
+ } catch (err) {
8161
+ if (err instanceof ProposalNotFoundError3) {
8162
+ sendJSON8(res, 404, { error: err.message });
8163
+ return;
8164
+ }
8165
+ if (err instanceof GateRunError) {
8166
+ sendJSON8(res, 409, { error: err.message });
8167
+ return;
8168
+ }
8169
+ sendJSON8(res, 500, {
8170
+ error: "gate run failed",
8171
+ detail: err instanceof Error ? err.message : "unknown"
8172
+ });
8173
+ }
8174
+ }
8175
+ async function handleApprove(req, res, deps, id) {
8176
+ const decidedBy = getDecidedBy(req, deps);
8177
+ try {
8178
+ const result = await promote(deps.projectPath, id, decidedBy);
8179
+ const proposal = await getProposal3(deps.projectPath, id);
8180
+ if (proposal) emitProposalApproved(deps.bus, proposal);
8181
+ sendJSON8(res, 200, { promotion: result, proposal });
8182
+ } catch (err) {
8183
+ if (err instanceof ProposalNotFoundError3) {
8184
+ sendJSON8(res, 404, { error: err.message });
8185
+ return;
8186
+ }
8187
+ if (err instanceof GateNotReadyError) {
8188
+ sendJSON8(res, 409, { error: err.message });
8189
+ return;
8190
+ }
8191
+ if (err instanceof PromotionError) {
8192
+ sendJSON8(res, 422, { error: err.message });
8193
+ return;
8194
+ }
8195
+ sendJSON8(res, 500, {
8196
+ error: "approve failed",
8197
+ detail: err instanceof Error ? err.message : "unknown"
8198
+ });
8199
+ }
8200
+ }
8201
+ async function handleReject(req, res, deps, id) {
8202
+ let raw;
8203
+ try {
8204
+ raw = await readBody(req);
8205
+ } catch (err) {
8206
+ sendJSON8(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
8207
+ return;
8208
+ }
8209
+ let json;
8210
+ try {
8211
+ json = raw.length > 0 ? JSON.parse(raw) : {};
8212
+ } catch {
8213
+ sendJSON8(res, 400, { error: "Invalid JSON body" });
8214
+ return;
8215
+ }
8216
+ const parsed = RejectBody.safeParse(json);
8217
+ if (!parsed.success) {
8218
+ sendJSON8(res, 400, { error: "Invalid body", issues: parsed.error.issues });
8219
+ return;
8220
+ }
8221
+ const proposal = await getProposal3(deps.projectPath, id);
8222
+ if (!proposal) {
8223
+ sendJSON8(res, 404, { error: "Proposal not found" });
8224
+ return;
8225
+ }
8226
+ if (proposal.status === "approved" || proposal.status === "rejected") {
8227
+ sendJSON8(res, 409, {
8228
+ error: `proposal already ${proposal.status}; cannot reject`
8229
+ });
8230
+ return;
8231
+ }
8232
+ const decidedBy = getDecidedBy(req, deps);
8233
+ const updated = await updateProposal3(deps.projectPath, id, {
8234
+ status: "rejected",
8235
+ decision: {
8236
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
8237
+ decidedBy,
8238
+ action: "rejected",
8239
+ reason: parsed.data.reason
8240
+ }
8241
+ });
8242
+ emitProposalRejected(deps.bus, updated);
8243
+ sendJSON8(res, 200, updated);
8244
+ }
8245
+ async function handleEdit(req, res, deps, id) {
8246
+ let raw;
8247
+ try {
8248
+ raw = await readBody(req);
8249
+ } catch (err) {
8250
+ sendJSON8(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
8251
+ return;
8252
+ }
8253
+ let json;
8254
+ try {
8255
+ json = JSON.parse(raw);
8256
+ } catch {
8257
+ sendJSON8(res, 400, { error: "Invalid JSON body" });
8258
+ return;
8259
+ }
8260
+ const parsed = EditProposalInputSchema.safeParse(json);
8261
+ if (!parsed.success) {
8262
+ sendJSON8(res, 400, { error: "Invalid body", issues: parsed.error.issues });
8263
+ return;
8264
+ }
8265
+ const existing = await getProposal3(deps.projectPath, id);
8266
+ if (!existing) {
8267
+ sendJSON8(res, 404, { error: "Proposal not found" });
8268
+ return;
8269
+ }
8270
+ if (existing.status === "approved" || existing.status === "rejected") {
8271
+ sendJSON8(res, 409, {
8272
+ error: `proposal already ${existing.status}; cannot edit`
8273
+ });
8274
+ return;
8275
+ }
8276
+ const mergedContent = {
8277
+ ...existing.content,
8278
+ ...parsed.data.content,
8279
+ name: parsed.data.content.name ?? existing.content.name,
8280
+ description: parsed.data.content.description ?? existing.content.description
8281
+ };
8282
+ try {
8283
+ const updated = await updateProposal3(deps.projectPath, id, {
8284
+ content: mergedContent,
8285
+ status: "open",
8286
+ gate: void 0
8287
+ });
8288
+ sendJSON8(res, 200, updated);
8289
+ } catch (err) {
8290
+ sendJSON8(res, 422, {
8291
+ error: "edit failed",
8292
+ detail: err instanceof Error ? err.message : "unknown"
8293
+ });
8294
+ }
8295
+ }
8296
+ function handleV1ProposalsRoute(req, res, deps) {
8297
+ const url = req.url ?? "";
8298
+ const method = req.method ?? "GET";
8299
+ if (method === "GET" && LIST_RE.test(url)) {
8300
+ void handleList(req, res, deps);
8301
+ return true;
8302
+ }
8303
+ const runGateMatch = method === "POST" ? RUN_GATE_RE.exec(url) : null;
8304
+ if (runGateMatch) {
8305
+ void handleRunGate(res, deps, runGateMatch[1]);
8306
+ return true;
8307
+ }
8308
+ const approveMatch = method === "POST" ? APPROVE_RE.exec(url) : null;
8309
+ if (approveMatch) {
8310
+ void handleApprove(req, res, deps, approveMatch[1]);
8311
+ return true;
8312
+ }
8313
+ const rejectMatch = method === "POST" ? REJECT_RE.exec(url) : null;
8314
+ if (rejectMatch) {
8315
+ void handleReject(req, res, deps, rejectMatch[1]);
8316
+ return true;
8317
+ }
8318
+ if (method === "PATCH") {
8319
+ const m = SINGLE_RE.exec(url);
8320
+ if (m) {
8321
+ void handleEdit(req, res, deps, m[1]);
8322
+ return true;
8323
+ }
8324
+ }
8325
+ if (method === "GET") {
8326
+ const m = SINGLE_RE.exec(url);
8327
+ if (m) {
8328
+ void handleGet(res, deps, m[1]);
8329
+ return true;
8330
+ }
8331
+ }
8332
+ return false;
8333
+ }
8334
+
8335
+ // src/server/routes/v1/routing.ts
8336
+ import { z as z14 } from "zod";
8337
+ var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
8338
+ var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
8339
+ var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
8340
+ function sendJSON9(res, status, body) {
8341
+ res.writeHead(status, { "Content-Type": "application/json" });
8342
+ res.end(JSON.stringify(body));
8343
+ }
8344
+ function unavailable(res) {
8345
+ sendJSON9(res, 503, { error: "BackendRouter not available" });
8346
+ return true;
8347
+ }
8348
+ function resolveChain(value, backends) {
8349
+ return toArray(value).map((c) => ({ candidate: c, exists: c in backends }));
8350
+ }
8351
+ function buildResolvedChains(routing, backends) {
8352
+ const out = {};
8353
+ out["default"] = resolveChain(routing.default, backends);
8354
+ for (const tier of ["quick-fix", "guided-change", "full-exploration", "diagnostic"]) {
8355
+ const v = routing[tier];
8356
+ if (v !== void 0) out[`tier:${tier}`] = resolveChain(v, backends);
8357
+ }
8358
+ if (routing.intelligence) {
8359
+ for (const [layer, v] of Object.entries(routing.intelligence)) {
8360
+ if (v !== void 0) out[`intelligence:${layer}`] = resolveChain(v, backends);
8361
+ }
8362
+ }
8363
+ if (routing.isolation) {
8364
+ for (const [tier, v] of Object.entries(routing.isolation)) {
8365
+ if (v !== void 0) out[`isolation:${tier}`] = resolveChain(v, backends);
8366
+ }
8367
+ }
8368
+ if (routing.skills) {
8369
+ for (const [name, v] of Object.entries(routing.skills)) {
8370
+ if (v !== void 0) out[`skill:${name}`] = resolveChain(v, backends);
8371
+ }
8372
+ }
8373
+ if (routing.modes) {
8374
+ for (const [mode, v] of Object.entries(routing.modes)) {
8375
+ if (v !== void 0) out[`mode:${mode}`] = resolveChain(v, backends);
8376
+ }
8377
+ }
8378
+ return out;
8379
+ }
8380
+ function handleConfig(res, deps) {
8381
+ if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
8382
+ sendJSON9(res, 200, {
8383
+ routing: deps.routing,
8384
+ resolvedChains: buildResolvedChains(deps.routing, deps.backends),
8385
+ backends: Object.keys(deps.backends)
8386
+ });
8387
+ return true;
8388
+ }
8389
+ function parseDecisionsQuery(url) {
8390
+ const qIdx = url.indexOf("?");
8391
+ if (qIdx === -1) return {};
8392
+ const p = new URLSearchParams(url.slice(qIdx + 1));
8393
+ const filter = {};
8394
+ const skill = p.get("skill");
8395
+ const mode = p.get("mode");
8396
+ const backend = p.get("backend");
8397
+ const limit = p.get("limit");
8398
+ if (skill) filter.skillName = skill;
8399
+ if (mode) filter.mode = mode;
8400
+ if (backend) filter.backendName = backend;
8401
+ if (limit) {
8402
+ const n = Number(limit);
8403
+ if (Number.isFinite(n) && n > 0) filter.limit = Math.floor(n);
8404
+ }
8405
+ return filter;
8406
+ }
8407
+ function handleDecisions(req, res, deps) {
8408
+ if (!deps.bus) return unavailable(res);
8409
+ const filter = parseDecisionsQuery(req.url ?? "");
8410
+ sendJSON9(res, 200, { decisions: deps.bus.recent(filter) });
8411
+ return true;
8412
+ }
8413
+ var UseCaseSchema = z14.discriminatedUnion("kind", [
8414
+ z14.object({
8415
+ kind: z14.literal("tier"),
8416
+ tier: z14.enum(["quick-fix", "guided-change", "full-exploration", "diagnostic"])
8417
+ }),
8418
+ z14.object({ kind: z14.literal("intelligence"), layer: z14.enum(["sel", "pesl"]) }),
8419
+ z14.object({ kind: z14.literal("isolation"), tier: z14.string() }),
8420
+ z14.object({ kind: z14.literal("maintenance") }),
8421
+ z14.object({ kind: z14.literal("chat") }),
8422
+ z14.object({
8423
+ kind: z14.literal("skill"),
8424
+ skillName: z14.string().min(1),
8425
+ cognitiveMode: z14.string().optional()
8426
+ }),
8427
+ z14.object({ kind: z14.literal("mode"), cognitiveMode: z14.string().min(1) })
8428
+ ]);
8429
+ var TraceBodySchema = z14.object({
8430
+ useCase: UseCaseSchema,
8431
+ invocationOverride: z14.string().min(1).optional()
8432
+ });
8433
+ async function handleTrace(req, res, deps) {
8434
+ if (!deps.routing || !deps.backends) {
8435
+ unavailable(res);
8436
+ return true;
8437
+ }
8438
+ let raw;
8439
+ try {
8440
+ raw = await readBody(req);
8441
+ } catch {
8442
+ sendJSON9(res, 400, { error: "body read failed" });
8443
+ return true;
8444
+ }
8445
+ let parsed;
8446
+ try {
8447
+ parsed = JSON.parse(raw);
8448
+ } catch {
8449
+ sendJSON9(res, 400, { error: "invalid JSON body" });
8450
+ return true;
8451
+ }
8452
+ const r = TraceBodySchema.safeParse(parsed);
8453
+ if (!r.success) {
8454
+ sendJSON9(res, 400, { error: r.error.message });
8455
+ return true;
8456
+ }
8457
+ const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
8458
+ try {
8459
+ const dryRunRouter = new BackendRouter({
8460
+ backends: deps.backends,
8461
+ routing: deps.routing
8462
+ });
8463
+ const { decision, def } = dryRunRouter.resolveDecisionAndDef(
8464
+ r.data.useCase,
8465
+ opts
8466
+ );
8467
+ sendJSON9(res, 200, { decision, def: { type: def.type } });
8468
+ } catch (err) {
8469
+ sendJSON9(res, 500, { error: String(err) });
8470
+ }
8471
+ return true;
8472
+ }
8473
+ function handleV1RoutingRoute(req, res, deps) {
8474
+ const url = req.url ?? "";
8475
+ const method = req.method ?? "GET";
8476
+ if (method === "GET" && CONFIG_RE.test(url)) return handleConfig(res, deps);
8477
+ if (method === "GET" && DECISIONS_RE.test(url)) return handleDecisions(req, res, deps);
8478
+ if (method === "POST" && TRACE_RE.test(url)) {
8479
+ void handleTrace(req, res, deps);
8480
+ return true;
8481
+ }
8482
+ return false;
8483
+ }
8484
+
8485
+ // src/server/routes/sessions.ts
8486
+ import * as fs13 from "fs/promises";
8487
+ import * as path14 from "path";
8488
+ import { z as z15 } from "zod";
8489
+ var SessionCreateSchema = z15.object({
8490
+ sessionId: z15.string().min(1)
8491
+ }).passthrough();
8492
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8493
+ function isSafeId(id) {
8494
+ return UUID_RE2.test(id) || path14.basename(id) === id && !id.includes("..");
8495
+ }
8496
+ function jsonResponse(res, status, data) {
8497
+ res.writeHead(status, { "Content-Type": "application/json" });
8498
+ res.end(JSON.stringify(data));
8499
+ }
8500
+ function extractSessionId(url) {
8501
+ const segments = new URL(url, "http://localhost").pathname.split(path14.posix.sep);
8502
+ const id = segments.pop();
8503
+ return id && id !== "sessions" ? id : null;
8504
+ }
8505
+ async function handleList2(res, sessionsDir) {
8506
+ try {
8507
+ const entries = await fs13.readdir(sessionsDir, { withFileTypes: true });
8508
+ const sessions = [];
8509
+ for (const entry of entries) {
8510
+ if (!entry.isDirectory()) continue;
8511
+ try {
8512
+ const content = await fs13.readFile(
8513
+ path14.join(sessionsDir, entry.name, "session.json"),
8514
+ "utf-8"
8515
+ );
8516
+ sessions.push(JSON.parse(content));
8517
+ } catch {
8518
+ }
8519
+ }
8520
+ sessions.sort(
8521
+ (a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime()
8522
+ );
8523
+ jsonResponse(res, 200, sessions);
8524
+ } catch (err) {
8525
+ if (err.code === "ENOENT") {
8526
+ jsonResponse(res, 200, []);
8527
+ return;
8528
+ }
8529
+ jsonResponse(res, 500, { error: "Failed to list sessions" });
8530
+ }
8531
+ }
8532
+ async function handleGet2(res, id, sessionsDir) {
8533
+ if (!isSafeId(id)) {
8534
+ jsonResponse(res, 400, { error: "Invalid sessionId" });
8535
+ return;
8536
+ }
8537
+ try {
8538
+ const content = await fs13.readFile(path14.join(sessionsDir, id, "session.json"), "utf-8");
8539
+ jsonResponse(res, 200, JSON.parse(content));
8540
+ } catch (err) {
8541
+ if (err.code === "ENOENT") {
8542
+ jsonResponse(res, 404, { error: "Session not found" });
8543
+ return;
8544
+ }
8545
+ jsonResponse(res, 500, { error: "Failed to read session" });
8546
+ }
8547
+ }
8548
+ async function handleCreate(req, res, sessionsDir) {
8549
+ try {
8550
+ const body = await readBody(req);
8551
+ const result = SessionCreateSchema.safeParse(JSON.parse(body));
8552
+ if (!result.success) {
8553
+ jsonResponse(res, 400, { error: "Missing sessionId" });
8554
+ return;
8555
+ }
8556
+ const session = result.data;
8557
+ if (!isSafeId(session.sessionId)) {
8558
+ jsonResponse(res, 400, { error: "Invalid sessionId" });
8559
+ return;
8560
+ }
8561
+ const sessionDir = path14.join(sessionsDir, session.sessionId);
8562
+ await fs13.mkdir(sessionDir, { recursive: true });
8563
+ await fs13.writeFile(path14.join(sessionDir, "session.json"), JSON.stringify(session, null, 2));
8564
+ jsonResponse(res, 200, { ok: true });
8565
+ } catch {
8566
+ jsonResponse(res, 500, { error: "Failed to save session" });
7545
8567
  }
7546
8568
  }
7547
8569
  async function handleUpdate(req, res, url, sessionsDir) {
@@ -7552,10 +8574,10 @@ async function handleUpdate(req, res, url, sessionsDir) {
7552
8574
  return;
7553
8575
  }
7554
8576
  const body = await readBody(req);
7555
- const updates = z13.record(z13.unknown()).parse(JSON.parse(body));
7556
- const sessionFilePath = path11.join(sessionsDir, id, "session.json");
7557
- const current = JSON.parse(await fs11.readFile(sessionFilePath, "utf-8"));
7558
- await fs11.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
8577
+ const updates = z15.record(z15.unknown()).parse(JSON.parse(body));
8578
+ const sessionFilePath = path14.join(sessionsDir, id, "session.json");
8579
+ const current = JSON.parse(await fs13.readFile(sessionFilePath, "utf-8"));
8580
+ await fs13.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
7559
8581
  jsonResponse(res, 200, { ok: true });
7560
8582
  } catch {
7561
8583
  jsonResponse(res, 500, { error: "Failed to update session" });
@@ -7568,7 +8590,7 @@ async function handleDelete(res, url, sessionsDir) {
7568
8590
  jsonResponse(res, 400, { error: "Missing or invalid sessionId" });
7569
8591
  return;
7570
8592
  }
7571
- await fs11.rm(path11.join(sessionsDir, id), { recursive: true, force: true });
8593
+ await fs13.rm(path14.join(sessionsDir, id), { recursive: true, force: true });
7572
8594
  jsonResponse(res, 200, { ok: true });
7573
8595
  } catch {
7574
8596
  jsonResponse(res, 500, { error: "Failed to delete session" });
@@ -7581,8 +8603,8 @@ function handleSessionsRoute(req, res, sessionsDir) {
7581
8603
  switch (method) {
7582
8604
  case "GET": {
7583
8605
  const id = extractSessionId(url);
7584
- if (id) void handleGet(res, id, sessionsDir);
7585
- else void handleList(res, sessionsDir);
8606
+ if (id) void handleGet2(res, id, sessionsDir);
8607
+ else void handleList2(res, sessionsDir);
7586
8608
  return true;
7587
8609
  }
7588
8610
  case "POST":
@@ -7672,20 +8694,20 @@ function handleStreamsRoute(req, res, recorder) {
7672
8694
  }
7673
8695
 
7674
8696
  // src/server/routes/auth.ts
7675
- import { z as z14 } from "zod";
8697
+ import { z as z16 } from "zod";
7676
8698
  import {
7677
8699
  TokenScopeSchema,
7678
8700
  BridgeKindSchema,
7679
8701
  AuthTokenPublicSchema
7680
8702
  } from "@harness-engineering/types";
7681
- var CreateBodySchema = z14.object({
7682
- name: z14.string().min(1).max(100),
7683
- scopes: z14.array(TokenScopeSchema).min(1),
8703
+ var CreateBodySchema = z16.object({
8704
+ name: z16.string().min(1).max(100),
8705
+ scopes: z16.array(TokenScopeSchema).min(1),
7684
8706
  bridgeKind: BridgeKindSchema.optional(),
7685
- tenantId: z14.string().optional(),
7686
- expiresAt: z14.string().datetime().optional()
8707
+ tenantId: z16.string().optional(),
8708
+ expiresAt: z16.string().datetime().optional()
7687
8709
  });
7688
- function sendJSON8(res, status, body) {
8710
+ function sendJSON10(res, status, body) {
7689
8711
  res.writeHead(status, { "Content-Type": "application/json" });
7690
8712
  res.end(JSON.stringify(body));
7691
8713
  }
@@ -7695,19 +8717,19 @@ async function handlePost(req, res, store) {
7695
8717
  raw = await readBody(req);
7696
8718
  } catch (err) {
7697
8719
  const msg = err instanceof Error ? err.message : "Failed to read body";
7698
- sendJSON8(res, 413, { error: msg });
8720
+ sendJSON10(res, 413, { error: msg });
7699
8721
  return;
7700
8722
  }
7701
8723
  let json;
7702
8724
  try {
7703
8725
  json = JSON.parse(raw);
7704
8726
  } catch {
7705
- sendJSON8(res, 400, { error: "Invalid JSON body" });
8727
+ sendJSON10(res, 400, { error: "Invalid JSON body" });
7706
8728
  return;
7707
8729
  }
7708
8730
  const parsed = CreateBodySchema.safeParse(json);
7709
8731
  if (!parsed.success) {
7710
- sendJSON8(res, 422, { error: "Invalid body", issues: parsed.error.issues });
8732
+ sendJSON10(res, 422, { error: "Invalid body", issues: parsed.error.issues });
7711
8733
  return;
7712
8734
  }
7713
8735
  try {
@@ -7720,37 +8742,37 @@ async function handlePost(req, res, store) {
7720
8742
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
7721
8743
  const result = await store.create(input);
7722
8744
  const publicRecord = AuthTokenPublicSchema.parse(result.record);
7723
- sendJSON8(res, 200, {
8745
+ sendJSON10(res, 200, {
7724
8746
  ...publicRecord,
7725
8747
  token: result.token
7726
8748
  });
7727
8749
  } catch (err) {
7728
8750
  const msg = err instanceof Error ? err.message : "Failed to create token";
7729
8751
  if (msg.includes("already exists")) {
7730
- sendJSON8(res, 409, { error: msg });
8752
+ sendJSON10(res, 409, { error: msg });
7731
8753
  return;
7732
8754
  }
7733
- sendJSON8(res, 500, { error: "Internal error creating token" });
8755
+ sendJSON10(res, 500, { error: "Internal error creating token" });
7734
8756
  }
7735
8757
  }
7736
- async function handleList2(res, store) {
8758
+ async function handleList3(res, store) {
7737
8759
  try {
7738
8760
  const list = await store.list();
7739
- sendJSON8(res, 200, list);
8761
+ sendJSON10(res, 200, list);
7740
8762
  } catch {
7741
- sendJSON8(res, 500, { error: "Internal error listing tokens" });
8763
+ sendJSON10(res, 500, { error: "Internal error listing tokens" });
7742
8764
  }
7743
8765
  }
7744
8766
  async function handleDelete2(res, store, id) {
7745
8767
  try {
7746
8768
  const ok = await store.revoke(id);
7747
8769
  if (!ok) {
7748
- sendJSON8(res, 404, { error: "Token not found" });
8770
+ sendJSON10(res, 404, { error: "Token not found" });
7749
8771
  return;
7750
8772
  }
7751
- sendJSON8(res, 200, { deleted: true });
8773
+ sendJSON10(res, 200, { deleted: true });
7752
8774
  } catch {
7753
- sendJSON8(res, 500, { error: "Internal error revoking token" });
8775
+ sendJSON10(res, 500, { error: "Internal error revoking token" });
7754
8776
  }
7755
8777
  }
7756
8778
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -7764,7 +8786,7 @@ function handleAuthRoute(req, res, store) {
7764
8786
  return true;
7765
8787
  }
7766
8788
  if (method === "GET" && pathname === "/api/v1/auth/tokens") {
7767
- void handleList2(res, store);
8789
+ void handleList3(res, store);
7768
8790
  return true;
7769
8791
  }
7770
8792
  if (method === "DELETE") {
@@ -7775,12 +8797,12 @@ function handleAuthRoute(req, res, store) {
7775
8797
  return true;
7776
8798
  }
7777
8799
  }
7778
- sendJSON8(res, 405, { error: "Method not allowed" });
8800
+ sendJSON10(res, 405, { error: "Method not allowed" });
7779
8801
  return true;
7780
8802
  }
7781
8803
 
7782
8804
  // src/server/routes/local-model.ts
7783
- function sendJSON9(res, status, body) {
8805
+ function sendJSON11(res, status, body) {
7784
8806
  res.writeHead(status, { "Content-Type": "application/json" });
7785
8807
  res.end(JSON.stringify(body));
7786
8808
  }
@@ -7788,36 +8810,36 @@ function handleLocalModelRoute(req, res, getStatus) {
7788
8810
  const { method, url } = req;
7789
8811
  if (url !== "/api/v1/local-model/status") return false;
7790
8812
  if (method !== "GET") {
7791
- sendJSON9(res, 405, { error: "Method not allowed" });
8813
+ sendJSON11(res, 405, { error: "Method not allowed" });
7792
8814
  return true;
7793
8815
  }
7794
8816
  if (!getStatus) {
7795
- sendJSON9(res, 503, { error: "Local backend not configured" });
8817
+ sendJSON11(res, 503, { error: "Local backend not configured" });
7796
8818
  return true;
7797
8819
  }
7798
8820
  const status = getStatus();
7799
8821
  if (!status) {
7800
- sendJSON9(res, 503, { error: "Local backend not configured" });
8822
+ sendJSON11(res, 503, { error: "Local backend not configured" });
7801
8823
  return true;
7802
8824
  }
7803
- sendJSON9(res, 200, status);
8825
+ sendJSON11(res, 200, status);
7804
8826
  return true;
7805
8827
  }
7806
8828
  function handleLocalModelsRoute(req, res, getStatuses) {
7807
8829
  const { method, url } = req;
7808
8830
  if (url !== "/api/v1/local-models/status") return false;
7809
8831
  if (method !== "GET") {
7810
- sendJSON9(res, 405, { error: "Method not allowed" });
8832
+ sendJSON11(res, 405, { error: "Method not allowed" });
7811
8833
  return true;
7812
8834
  }
7813
8835
  const statuses = getStatuses ? getStatuses() : [];
7814
- sendJSON9(res, 200, statuses);
8836
+ sendJSON11(res, 200, statuses);
7815
8837
  return true;
7816
8838
  }
7817
8839
 
7818
8840
  // src/server/static.ts
7819
- import * as fs12 from "fs";
7820
- import * as path12 from "path";
8841
+ import * as fs14 from "fs";
8842
+ import * as path15 from "path";
7821
8843
  var MIME_TYPES = {
7822
8844
  ".html": "text/html; charset=utf-8",
7823
8845
  ".js": "application/javascript; charset=utf-8",
@@ -7837,29 +8859,29 @@ var MIME_TYPES = {
7837
8859
  function handleStaticFile(req, res, dashboardDir) {
7838
8860
  const { method, url } = req;
7839
8861
  if (method !== "GET") return false;
7840
- const apiPrefix = path12.posix.join(path12.posix.sep, "api", path12.posix.sep);
7841
- const wsPath = path12.posix.join(path12.posix.sep, "ws");
8862
+ const apiPrefix = path15.posix.join(path15.posix.sep, "api", path15.posix.sep);
8863
+ const wsPath = path15.posix.join(path15.posix.sep, "ws");
7842
8864
  if (url?.startsWith(apiPrefix) || url === wsPath) return false;
7843
8865
  const urlPath = new URL(url ?? "/", "http://localhost").pathname;
7844
- const requestedPath = path12.join(dashboardDir, urlPath === "/" ? "index.html" : urlPath);
7845
- const resolved = path12.resolve(requestedPath);
7846
- if (!resolved.startsWith(path12.resolve(dashboardDir))) {
7847
- return serveFile(path12.join(dashboardDir, "index.html"), res);
8866
+ const requestedPath = path15.join(dashboardDir, urlPath === "/" ? "index.html" : urlPath);
8867
+ const resolved = path15.resolve(requestedPath);
8868
+ if (!resolved.startsWith(path15.resolve(dashboardDir))) {
8869
+ return serveFile(path15.join(dashboardDir, "index.html"), res);
7848
8870
  }
7849
- if (fs12.existsSync(resolved) && fs12.statSync(resolved).isFile()) {
8871
+ if (fs14.existsSync(resolved) && fs14.statSync(resolved).isFile()) {
7850
8872
  return serveFile(resolved, res);
7851
8873
  }
7852
- const indexPath = path12.join(dashboardDir, "index.html");
7853
- if (fs12.existsSync(indexPath)) {
8874
+ const indexPath = path15.join(dashboardDir, "index.html");
8875
+ if (fs14.existsSync(indexPath)) {
7854
8876
  return serveFile(indexPath, res);
7855
8877
  }
7856
8878
  return false;
7857
8879
  }
7858
8880
  function serveFile(filePath, res) {
7859
- const ext = path12.extname(filePath).toLowerCase();
8881
+ const ext = path15.extname(filePath).toLowerCase();
7860
8882
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
7861
8883
  try {
7862
- const content = fs12.readFileSync(filePath);
8884
+ const content = fs14.readFileSync(filePath);
7863
8885
  res.writeHead(200, { "Content-Type": contentType });
7864
8886
  res.end(content);
7865
8887
  return true;
@@ -7869,8 +8891,8 @@ function serveFile(filePath, res) {
7869
8891
  }
7870
8892
 
7871
8893
  // src/server/plan-watcher.ts
7872
- import * as fs13 from "fs";
7873
- import * as path13 from "path";
8894
+ import * as fs15 from "fs";
8895
+ import * as path16 from "path";
7874
8896
  var PlanWatcher = class {
7875
8897
  plansDir;
7876
8898
  queue;
@@ -7884,11 +8906,11 @@ var PlanWatcher = class {
7884
8906
  * Creates the directory if it does not exist.
7885
8907
  */
7886
8908
  start() {
7887
- fs13.mkdirSync(this.plansDir, { recursive: true });
7888
- this.watcher = fs13.watch(this.plansDir, (eventType, filename) => {
8909
+ fs15.mkdirSync(this.plansDir, { recursive: true });
8910
+ this.watcher = fs15.watch(this.plansDir, (eventType, filename) => {
7889
8911
  if (eventType === "rename" && filename && filename.endsWith(".md")) {
7890
- const filePath = path13.join(this.plansDir, filename);
7891
- if (fs13.existsSync(filePath)) {
8912
+ const filePath = path16.join(this.plansDir, filename);
8913
+ if (fs15.existsSync(filePath)) {
7892
8914
  void this.handleNewPlan(filename);
7893
8915
  }
7894
8916
  }
@@ -7921,7 +8943,7 @@ var PlanWatcher = class {
7921
8943
  // src/auth/tokens.ts
7922
8944
  import { randomBytes as randomBytes3, timingSafeEqual } from "crypto";
7923
8945
  import { readFile as readFile8, writeFile as writeFile8, mkdir as mkdir7, rename as rename2 } from "fs/promises";
7924
- import { dirname as dirname4 } from "path";
8946
+ import { dirname as dirname5 } from "path";
7925
8947
  import bcrypt from "bcryptjs";
7926
8948
  import {
7927
8949
  AuthTokenSchema,
@@ -7941,8 +8963,8 @@ function parseToken(raw) {
7941
8963
  return { id: raw.slice(0, dot), secret: raw.slice(dot + 1) };
7942
8964
  }
7943
8965
  var TokenStore = class {
7944
- constructor(path19) {
7945
- this.path = path19;
8966
+ constructor(path24) {
8967
+ this.path = path24;
7946
8968
  }
7947
8969
  path;
7948
8970
  cache = null;
@@ -7963,7 +8985,7 @@ var TokenStore = class {
7963
8985
  return this.cache;
7964
8986
  }
7965
8987
  async persist(records) {
7966
- await mkdir7(dirname4(this.path), { recursive: true });
8988
+ await mkdir7(dirname5(this.path), { recursive: true });
7967
8989
  const tmp = `${this.path}.tmp-${process.pid}-${Date.now()}-${randomBytes3(4).toString("hex")}`;
7968
8990
  await writeFile8(tmp, JSON.stringify(records, null, 2), "utf8");
7969
8991
  await rename2(tmp, this.path);
@@ -8046,11 +9068,11 @@ var TokenStore = class {
8046
9068
 
8047
9069
  // src/auth/audit.ts
8048
9070
  import { appendFile, mkdir as mkdir8 } from "fs/promises";
8049
- import { dirname as dirname5 } from "path";
9071
+ import { dirname as dirname6 } from "path";
8050
9072
  import { AuthAuditEntrySchema } from "@harness-engineering/types";
8051
9073
  var AuditLogger = class {
8052
- constructor(path19, opts = {}) {
8053
- this.path = path19;
9074
+ constructor(path24, opts = {}) {
9075
+ this.path = path24;
8054
9076
  this.opts = opts;
8055
9077
  }
8056
9078
  path;
@@ -8077,7 +9099,7 @@ var AuditLogger = class {
8077
9099
  async writeLine(line) {
8078
9100
  try {
8079
9101
  if (this.opts.createDir !== false && !this.dirEnsured) {
8080
- await mkdir8(dirname5(this.path), { recursive: true });
9102
+ await mkdir8(dirname6(this.path), { recursive: true });
8081
9103
  this.dirEnsured = true;
8082
9104
  }
8083
9105
  await appendFile(this.path, line, "utf8");
@@ -8134,20 +9156,79 @@ var V1_BRIDGE_ROUTES = [
8134
9156
  scope: "subscribe-webhook",
8135
9157
  description: "Webhook delivery queue depth + DLQ stats."
8136
9158
  },
9159
+ // Hermes Phase 4 — skill proposal review queue.
9160
+ {
9161
+ method: "GET",
9162
+ pattern: /^\/api\/v1\/proposals(?:\?.*)?$/,
9163
+ scope: "read-status",
9164
+ description: "List skill proposals (open + decided)."
9165
+ },
9166
+ {
9167
+ method: "GET",
9168
+ pattern: /^\/api\/v1\/proposals\/[^/]+(?:\?.*)?$/,
9169
+ scope: "read-status",
9170
+ description: "Get a single skill proposal."
9171
+ },
9172
+ {
9173
+ method: "POST",
9174
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/run-gate(?:\?.*)?$/,
9175
+ scope: "manage-proposals",
9176
+ description: "Run the soundness-review gate against a proposal."
9177
+ },
9178
+ {
9179
+ method: "POST",
9180
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/approve(?:\?.*)?$/,
9181
+ scope: "manage-proposals",
9182
+ description: "Approve a proposal \u2014 promotes the skill into the catalog."
9183
+ },
9184
+ {
9185
+ method: "POST",
9186
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/reject(?:\?.*)?$/,
9187
+ scope: "manage-proposals",
9188
+ description: "Reject a proposal with a one-line reason."
9189
+ },
9190
+ {
9191
+ method: "PATCH",
9192
+ pattern: /^\/api\/v1\/proposals\/[^/]+(?:\?.*)?$/,
9193
+ scope: "manage-proposals",
9194
+ description: "Edit proposal content (resets gate to not-run)."
9195
+ },
8137
9196
  // ── Phase 5 bridge primitives ──
8138
9197
  {
8139
9198
  method: "GET",
8140
9199
  pattern: /^\/api\/v1\/telemetry\/cache\/stats(?:\?.*)?$/,
8141
9200
  scope: "read-telemetry",
8142
9201
  description: "Prompt-cache hit/miss snapshot (rolling window)."
9202
+ },
9203
+ // ── Spec B Phase 5 routing observability ──
9204
+ // D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
9205
+ // precedent (read-only observability). A dedicated `read-routing`
9206
+ // scope was rejected to avoid a TokenScopeSchema + ADR cascade.
9207
+ {
9208
+ method: "GET",
9209
+ pattern: /^\/api\/v1\/routing\/config(?:\?.*)?$/,
9210
+ scope: "read-telemetry",
9211
+ description: "Current routing config + resolved fallback chains + known backends."
9212
+ },
9213
+ {
9214
+ method: "GET",
9215
+ pattern: /^\/api\/v1\/routing\/decisions(?:\?.*)?$/,
9216
+ scope: "read-telemetry",
9217
+ description: "Recent routing decisions (newest-first), filterable by skill/mode/backend."
9218
+ },
9219
+ {
9220
+ method: "POST",
9221
+ pattern: /^\/api\/v1\/routing\/trace(?:\?.*)?$/,
9222
+ scope: "read-telemetry",
9223
+ description: "Dry-run a routing decision without side effects (no bus emit, no dispatch)."
8143
9224
  }
8144
9225
  ];
8145
9226
  function isV1Bridge(method, url) {
8146
9227
  return V1_BRIDGE_ROUTES.some((r) => r.method === method && r.pattern.test(url));
8147
9228
  }
8148
- function requiredBridgeScope(method, path19) {
9229
+ function requiredBridgeScope(method, path24) {
8149
9230
  for (const r of V1_BRIDGE_ROUTES) {
8150
- if (r.method === method && r.pattern.test(path19)) return r.scope;
9231
+ if (r.method === method && r.pattern.test(path24)) return r.scope;
8151
9232
  }
8152
9233
  return null;
8153
9234
  }
@@ -8157,24 +9238,24 @@ function hasScope(held, required) {
8157
9238
  if (held.includes("admin")) return true;
8158
9239
  return held.includes(required);
8159
9240
  }
8160
- function requiredScopeForRoute(method, path19) {
8161
- const bridgeScope = requiredBridgeScope(method, path19);
9241
+ function requiredScopeForRoute(method, path24) {
9242
+ const bridgeScope = requiredBridgeScope(method, path24);
8162
9243
  if (bridgeScope) return bridgeScope;
8163
- if (path19 === "/api/v1/auth/token" && method === "POST") return "admin";
8164
- if (path19 === "/api/v1/auth/tokens" && method === "GET") return "admin";
8165
- if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path19) && method === "DELETE") return "admin";
8166
- if ((path19 === "/api/state" || path19 === "/api/v1/state") && method === "GET") return "read-status";
8167
- if (path19.startsWith("/api/interactions")) return "resolve-interaction";
8168
- if (path19.startsWith("/api/plans")) return "read-status";
8169
- if (path19.startsWith("/api/analyze") || path19.startsWith("/api/analyses")) return "read-status";
8170
- if (path19.startsWith("/api/roadmap-actions")) return "modify-roadmap";
8171
- if (path19.startsWith("/api/dispatch-actions")) return "trigger-job";
8172
- if (path19.startsWith("/api/local-model") || path19.startsWith("/api/local-models"))
9244
+ if (path24 === "/api/v1/auth/token" && method === "POST") return "admin";
9245
+ if (path24 === "/api/v1/auth/tokens" && method === "GET") return "admin";
9246
+ if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path24) && method === "DELETE") return "admin";
9247
+ if ((path24 === "/api/state" || path24 === "/api/v1/state") && method === "GET") return "read-status";
9248
+ if (path24.startsWith("/api/interactions")) return "resolve-interaction";
9249
+ if (path24.startsWith("/api/plans")) return "read-status";
9250
+ if (path24.startsWith("/api/analyze") || path24.startsWith("/api/analyses")) return "read-status";
9251
+ if (path24.startsWith("/api/roadmap-actions")) return "modify-roadmap";
9252
+ if (path24.startsWith("/api/dispatch-actions")) return "trigger-job";
9253
+ if (path24.startsWith("/api/local-model") || path24.startsWith("/api/local-models"))
8173
9254
  return "read-status";
8174
- if (path19.startsWith("/api/maintenance")) return "trigger-job";
8175
- if (path19.startsWith("/api/streams")) return "read-status";
8176
- if (path19.startsWith("/api/sessions")) return "read-status";
8177
- if (path19.startsWith("/api/chat-proxy")) return "trigger-job";
9255
+ if (path24.startsWith("/api/maintenance")) return "trigger-job";
9256
+ if (path24.startsWith("/api/streams")) return "read-status";
9257
+ if (path24.startsWith("/api/sessions")) return "read-status";
9258
+ if (path24.startsWith("/api/chat-proxy")) return "trigger-job";
8178
9259
  return null;
8179
9260
  }
8180
9261
 
@@ -8228,11 +9309,25 @@ var OrchestratorServer = class {
8228
9309
  roadmapPath;
8229
9310
  dispatchAdHoc;
8230
9311
  sessionsDir;
9312
+ /**
9313
+ * Project root used by file-backed routes (Phase 4 proposals at
9314
+ * `.harness/proposals/`). Defaults to process.cwd().
9315
+ */
9316
+ projectPath;
8231
9317
  maintenanceDeps = null;
8232
9318
  getLocalModelStatus = null;
8233
9319
  getLocalModelStatuses = null;
8234
9320
  webhooks;
8235
9321
  cacheMetrics;
9322
+ // Spec B Phase 5 — routing observability accessor closures + the WS
9323
+ // broadcaster unsubscribe handle (D-OP-4 dual safety net: server.stop()
9324
+ // calls it explicitly; clearListeners in Orchestrator.stop() is the
9325
+ // belt-and-suspenders second line).
9326
+ getBackendRouterFn = null;
9327
+ getRoutingDecisionBusFn = null;
9328
+ getRoutingConfigFn = null;
9329
+ getBackendsFn = null;
9330
+ routingDecisionUnsubscribe = null;
8236
9331
  recorder = null;
8237
9332
  planWatcher = null;
8238
9333
  tokenStore;
@@ -8245,8 +9340,8 @@ var OrchestratorServer = class {
8245
9340
  this.orchestrator = orchestrator;
8246
9341
  this.port = port;
8247
9342
  this.initDependencies(deps);
8248
- const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path14.resolve(".harness", "tokens.json");
8249
- const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path14.resolve(".harness", "audit.log");
9343
+ const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path17.resolve(".harness", "tokens.json");
9344
+ const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path17.resolve(".harness", "audit.log");
8250
9345
  this.tokenStore = new TokenStore(tokensPath);
8251
9346
  this.auditLogger = new AuditLogger(auditPath);
8252
9347
  this.httpServer = http.createServer(this.handleRequest.bind(this));
@@ -8259,19 +9354,24 @@ var OrchestratorServer = class {
8259
9354
  }
8260
9355
  initDependencies(deps) {
8261
9356
  this.interactionQueue = deps?.interactionQueue;
8262
- this.plansDir = deps?.plansDir ?? path14.resolve("docs", "plans");
8263
- this.dashboardDir = deps?.dashboardDir ?? path14.resolve("packages", "dashboard", "dist", "client");
9357
+ this.plansDir = deps?.plansDir ?? path17.resolve("docs", "plans");
9358
+ this.dashboardDir = deps?.dashboardDir ?? path17.resolve("packages", "dashboard", "dist", "client");
8264
9359
  this.claudeCommand = deps?.claudeCommand ?? "claude";
8265
9360
  this.pipeline = deps?.pipeline ?? null;
8266
9361
  this.analysisArchive = deps?.analysisArchive;
8267
9362
  this.roadmapPath = deps?.roadmapPath ?? null;
8268
9363
  this.dispatchAdHoc = deps?.dispatchAdHoc ?? null;
8269
- this.sessionsDir = deps?.sessionsDir ?? path14.resolve(".harness", "sessions");
9364
+ this.sessionsDir = deps?.sessionsDir ?? path17.resolve(".harness", "sessions");
9365
+ this.projectPath = deps?.projectPath ?? process.cwd();
8270
9366
  this.maintenanceDeps = deps?.maintenanceDeps ?? null;
8271
9367
  this.getLocalModelStatus = deps?.getLocalModelStatus ?? null;
8272
9368
  this.getLocalModelStatuses = deps?.getLocalModelStatuses ?? null;
8273
9369
  this.webhooks = deps?.webhooks;
8274
9370
  this.cacheMetrics = deps?.cacheMetrics;
9371
+ this.getBackendRouterFn = deps?.getBackendRouter ?? null;
9372
+ this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
9373
+ this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
9374
+ this.getBackendsFn = deps?.getBackends ?? null;
8275
9375
  }
8276
9376
  wireEvents() {
8277
9377
  this.stateChangeListener = (snapshot) => {
@@ -8282,6 +9382,12 @@ var OrchestratorServer = class {
8282
9382
  };
8283
9383
  this.orchestrator.on("state_change", this.stateChangeListener);
8284
9384
  this.orchestrator.on("agent_event", this.agentEventListener);
9385
+ const bus = this.getRoutingDecisionBusFn?.() ?? null;
9386
+ if (bus) {
9387
+ this.routingDecisionUnsubscribe = bus.subscribe((decision) => {
9388
+ this.broadcaster.broadcast("routing:decision", decision);
9389
+ });
9390
+ }
8285
9391
  }
8286
9392
  /**
8287
9393
  * Broadcast a new interaction to all WebSocket clients.
@@ -8437,6 +9543,23 @@ var OrchestratorServer = class {
8437
9543
  (req, res) => handleV1TelemetryRoute(req, res, {
8438
9544
  ...this.cacheMetrics ? { cacheMetrics: this.cacheMetrics } : {}
8439
9545
  }),
9546
+ // Spec B Phase 5 — routing observability. Returns 503 when the
9547
+ // backendFactory is null (legacy single-backend configs).
9548
+ (req, res) => handleV1RoutingRoute(req, res, {
9549
+ router: this.getBackendRouterFn?.() ?? null,
9550
+ bus: this.getRoutingDecisionBusFn?.() ?? null,
9551
+ routing: this.getRoutingConfigFn?.() ?? null,
9552
+ backends: this.getBackendsFn?.() ?? null
9553
+ }),
9554
+ // Hermes Phase 4 — skill proposal review queue. Read scopes
9555
+ // (`read-status`) and write scopes (`manage-proposals`) are enforced
9556
+ // upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
9557
+ // business logic. `projectPath` defaults to process.cwd() — that is
9558
+ // where `.harness/proposals/` lives in every deployment we ship.
9559
+ (req, res) => handleV1ProposalsRoute(req, res, {
9560
+ projectPath: this.projectPath,
9561
+ bus: this.orchestrator
9562
+ }),
8440
9563
  // Chat proxy route (spawns Claude Code CLI — no API key required)
8441
9564
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
8442
9565
  ];
@@ -8524,17 +9647,21 @@ var OrchestratorServer = class {
8524
9647
  this.planWatcher = new PlanWatcher(this.plansDir, this.interactionQueue);
8525
9648
  this.planWatcher.start();
8526
9649
  }
8527
- return new Promise((resolve6) => {
9650
+ return new Promise((resolve8) => {
8528
9651
  const host = getBindHost();
8529
9652
  this.httpServer.listen(this.port, host, () => {
8530
9653
  console.log(`Orchestrator API listening on ${host}:${this.port}`);
8531
- resolve6();
9654
+ resolve8();
8532
9655
  });
8533
9656
  });
8534
9657
  }
8535
9658
  stop() {
8536
9659
  this.orchestrator.removeListener("state_change", this.stateChangeListener);
8537
9660
  this.orchestrator.removeListener("agent_event", this.agentEventListener);
9661
+ if (this.routingDecisionUnsubscribe) {
9662
+ this.routingDecisionUnsubscribe();
9663
+ this.routingDecisionUnsubscribe = null;
9664
+ }
8538
9665
  if (this.planWatcher) {
8539
9666
  this.planWatcher.stop();
8540
9667
  this.planWatcher = null;
@@ -8547,7 +9674,7 @@ var OrchestratorServer = class {
8547
9674
  // src/gateway/webhooks/store.ts
8548
9675
  import { randomBytes as randomBytes4 } from "crypto";
8549
9676
  import { readFile as readFile9, writeFile as writeFile9, mkdir as mkdir9, rename as rename3, chmod } from "fs/promises";
8550
- import { dirname as dirname6 } from "path";
9677
+ import { dirname as dirname7 } from "path";
8551
9678
  import { WebhookSubscriptionSchema } from "@harness-engineering/types";
8552
9679
 
8553
9680
  // src/gateway/webhooks/signer.ts
@@ -8578,8 +9705,8 @@ function genSecret2() {
8578
9705
  return randomBytes4(32).toString("base64url");
8579
9706
  }
8580
9707
  var WebhookStore = class {
8581
- constructor(path19) {
8582
- this.path = path19;
9708
+ constructor(path24) {
9709
+ this.path = path24;
8583
9710
  }
8584
9711
  path;
8585
9712
  cache = null;
@@ -8602,7 +9729,7 @@ var WebhookStore = class {
8602
9729
  async persist(records) {
8603
9730
  const tmp = `${this.path}.tmp-${process.pid}-${Date.now()}-${randomBytes4(4).toString("hex")}`;
8604
9731
  try {
8605
- await mkdir9(dirname6(this.path), { recursive: true });
9732
+ await mkdir9(dirname7(this.path), { recursive: true });
8606
9733
  await writeFile9(tmp, JSON.stringify(records, null, 2), { encoding: "utf8", mode: 384 });
8607
9734
  await rename3(tmp, this.path);
8608
9735
  await chmod(this.path, 384);
@@ -8970,7 +10097,12 @@ var WEBHOOK_TOPICS = [
8970
10097
  "maintenance:completed",
8971
10098
  "maintenance:error",
8972
10099
  "webhook.subscription.created",
8973
- "webhook.subscription.deleted"
10100
+ "webhook.subscription.deleted",
10101
+ // Hermes Phase 4 — skill proposal lifecycle. Subscriptions can use the
10102
+ // `proposal.*` glob pattern to receive all three.
10103
+ "proposal.created",
10104
+ "proposal.approved",
10105
+ "proposal.rejected"
8974
10106
  ];
8975
10107
  function newEventId2() {
8976
10108
  return `evt_${randomBytes6(8).toString("hex")}`;
@@ -9395,6 +10527,33 @@ var ENVELOPE_DERIVERS = {
9395
10527
  summary: data.message ?? "If you see this, your notification sink is working.",
9396
10528
  severity: "info"
9397
10529
  };
10530
+ },
10531
+ // Hermes Phase 4 — skill proposal lifecycle events.
10532
+ "proposal.created": (event) => {
10533
+ const data = asObj(event.data);
10534
+ const label = data.kind === "refinement" ? `refinement of ${data.targetSkill ?? "(unknown skill)"}` : data.name ?? "(new skill)";
10535
+ return {
10536
+ title: `New skill proposal: ${label}`,
10537
+ summary: truncate(data.justification ?? "No justification provided.", 240),
10538
+ severity: "info"
10539
+ };
10540
+ },
10541
+ "proposal.approved": (event) => {
10542
+ const data = asObj(event.data);
10543
+ const label = data.name ?? data.targetSkill ?? "(unknown skill)";
10544
+ return {
10545
+ title: `Skill proposal approved: ${label}`,
10546
+ summary: `Approved by ${data.decidedBy ?? "(unknown reviewer)"}.`,
10547
+ severity: "success"
10548
+ };
10549
+ },
10550
+ "proposal.rejected": (event) => {
10551
+ const data = asObj(event.data);
10552
+ return {
10553
+ title: "Skill proposal rejected",
10554
+ summary: truncate(data.reason ?? "No reason provided.", 240),
10555
+ severity: "warning"
10556
+ };
9398
10557
  }
9399
10558
  };
9400
10559
  function truncate(s, max) {
@@ -9439,7 +10598,11 @@ var NOTIFICATION_TOPICS = [
9439
10598
  "interaction.resolved",
9440
10599
  "maintenance:started",
9441
10600
  "maintenance:completed",
9442
- "maintenance:error"
10601
+ "maintenance:error",
10602
+ // Hermes Phase 4 — skill proposal lifecycle.
10603
+ "proposal.created",
10604
+ "proposal.approved",
10605
+ "proposal.rejected"
9443
10606
  ];
9444
10607
  function newEventId4() {
9445
10608
  return `evt_${randomBytes8(8).toString("hex")}`;
@@ -9535,8 +10698,8 @@ var StructuredLogger = class {
9535
10698
  };
9536
10699
 
9537
10700
  // src/workspace/config-scanner.ts
9538
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
9539
- import { join as join13, relative } from "path";
10701
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
10702
+ import { join as join15, relative } from "path";
9540
10703
  import {
9541
10704
  scanForInjection,
9542
10705
  SecurityScanner,
@@ -9560,10 +10723,10 @@ function adjustFindingSeverity(findings) {
9560
10723
  });
9561
10724
  }
9562
10725
  async function scanSingleFile(filePath, targetDir, scanner) {
9563
- if (!existsSync4(filePath)) return null;
10726
+ if (!existsSync6(filePath)) return null;
9564
10727
  let content;
9565
10728
  try {
9566
- content = readFileSync4(filePath, "utf8");
10729
+ content = readFileSync6(filePath, "utf8");
9567
10730
  } catch {
9568
10731
  return null;
9569
10732
  }
@@ -9582,7 +10745,7 @@ async function scanWorkspaceConfig(workspacePath) {
9582
10745
  const scanner = new SecurityScanner(parseSecurityConfig({}));
9583
10746
  const results = [];
9584
10747
  for (const configFile of CONFIG_FILES) {
9585
- const result = await scanSingleFile(join13(workspacePath, configFile), workspacePath, scanner);
10748
+ const result = await scanSingleFile(join15(workspacePath, configFile), workspacePath, scanner);
9586
10749
  if (result) results.push(result);
9587
10750
  }
9588
10751
  return { exitCode: computeScanExitCode(results), results };
@@ -9768,6 +10931,19 @@ var BUILT_IN_TASKS = [
9768
10931
  schedule: "*/15 * * * *",
9769
10932
  branch: null,
9770
10933
  checkCommand: ["harness", "sync-main", "--json"]
10934
+ },
10935
+ // Hermes Phase 4 — one-shot backfill that stamps `provenance: user-authored`
10936
+ // on every existing catalog skill. Schedule is Feb 31 (a date that never
10937
+ // exists) so the cron loop never fires it automatically; operators trigger
10938
+ // it once via the dashboard "Run now" button or `harness backfill-skill-
10939
+ // provenance` after upgrading to Phase 4.
10940
+ {
10941
+ id: "proposal-provenance-backfill",
10942
+ type: "housekeeping",
10943
+ description: "Backfill provenance: user-authored on every existing skill (one-shot, idempotent)",
10944
+ schedule: "0 0 31 2 *",
10945
+ branch: null,
10946
+ checkCommand: ["backfill-skill-provenance"]
9771
10947
  }
9772
10948
  ];
9773
10949
 
@@ -9860,24 +11036,49 @@ var MaintenanceScheduler = class {
9860
11036
  this.resolvedTasks = this.resolveTasks();
9861
11037
  }
9862
11038
  /**
9863
- * Merge built-in task definitions with config overrides.
9864
- * Tasks with `enabled: false` are filtered out.
9865
- * Schedule overrides replace the default cron expression.
11039
+ * Merge built-in task definitions with config overrides, then append
11040
+ * Hermes Phase 2 `customTasks` (also respecting `tasks.<id>.enabled`
11041
+ * overrides). Tasks with `enabled: false` are filtered out. Schedule
11042
+ * overrides replace the default cron expression.
9866
11043
  */
9867
11044
  resolveTasks() {
9868
11045
  const overrides = this.config.tasks ?? {};
9869
- return BUILT_IN_TASKS.filter((task) => {
9870
- const override = overrides[task.id];
9871
- if (override?.enabled === false) return false;
9872
- return true;
9873
- }).map((task) => {
11046
+ const customs = this.config.customTasks ?? {};
11047
+ const merged = [];
11048
+ for (const task of BUILT_IN_TASKS) {
9874
11049
  const override = overrides[task.id];
9875
- if (!override) return { ...task };
9876
- return {
11050
+ if (override?.enabled === false) continue;
11051
+ merged.push({
9877
11052
  ...task,
9878
- ...override.schedule !== void 0 && { schedule: override.schedule }
9879
- };
9880
- });
11053
+ ...override?.schedule !== void 0 && { schedule: override.schedule }
11054
+ });
11055
+ }
11056
+ for (const [id, def] of Object.entries(customs)) {
11057
+ const override = overrides[id];
11058
+ if (override?.enabled === false) continue;
11059
+ merged.push({
11060
+ id,
11061
+ type: def.type,
11062
+ description: def.description,
11063
+ schedule: override?.schedule ?? def.schedule,
11064
+ branch: def.branch,
11065
+ ...def.checkCommand !== void 0 && { checkCommand: def.checkCommand },
11066
+ ...def.checkScript !== void 0 && { checkScript: def.checkScript },
11067
+ ...def.fixSkill !== void 0 && { fixSkill: def.fixSkill },
11068
+ ...def.inlineSkills !== void 0 && { inlineSkills: def.inlineSkills },
11069
+ ...def.inlineSkillsBudgetTokens !== void 0 && {
11070
+ inlineSkillsBudgetTokens: def.inlineSkillsBudgetTokens
11071
+ },
11072
+ ...def.contextFrom !== void 0 && { contextFrom: def.contextFrom },
11073
+ ...def.contextFromMaxAgeMinutes !== void 0 && {
11074
+ contextFromMaxAgeMinutes: def.contextFromMaxAgeMinutes
11075
+ },
11076
+ ...def.outputRetention !== void 0 && { outputRetention: def.outputRetention },
11077
+ ...def.costCeiling !== void 0 && { costCeiling: def.costCeiling },
11078
+ isCustom: true
11079
+ });
11080
+ }
11081
+ return merged;
9881
11082
  }
9882
11083
  /** Returns the resolved (merged) task list. Useful for testing and dashboard. */
9883
11084
  getResolvedTasks() {
@@ -10058,19 +11259,19 @@ var SingleProcessLeaderElector = class {
10058
11259
  };
10059
11260
 
10060
11261
  // src/maintenance/reporter.ts
10061
- import * as fs14 from "fs";
10062
- import * as path15 from "path";
10063
- import { z as z15 } from "zod";
10064
- var RunResultSchema = z15.object({
10065
- taskId: z15.string(),
10066
- startedAt: z15.string(),
10067
- completedAt: z15.string(),
10068
- status: z15.enum(["success", "failure", "skipped", "no-issues"]),
10069
- findings: z15.number(),
10070
- fixed: z15.number(),
10071
- prUrl: z15.string().nullable(),
10072
- prUpdated: z15.boolean(),
10073
- error: z15.string().optional()
11262
+ import * as fs16 from "fs";
11263
+ import * as path18 from "path";
11264
+ import { z as z17 } from "zod";
11265
+ var RunResultSchema = z17.object({
11266
+ taskId: z17.string(),
11267
+ startedAt: z17.string(),
11268
+ completedAt: z17.string(),
11269
+ status: z17.enum(["success", "failure", "skipped", "no-issues"]),
11270
+ findings: z17.number(),
11271
+ fixed: z17.number(),
11272
+ prUrl: z17.string().nullable(),
11273
+ prUpdated: z17.boolean(),
11274
+ error: z17.string().optional()
10074
11275
  });
10075
11276
  var MAX_HISTORY = 500;
10076
11277
  var fallbackLogger = {
@@ -10094,10 +11295,10 @@ var MaintenanceReporter = class {
10094
11295
  */
10095
11296
  async load() {
10096
11297
  try {
10097
- await fs14.promises.mkdir(this.persistDir, { recursive: true });
10098
- const filePath = path15.join(this.persistDir, "history.json");
10099
- const data = await fs14.promises.readFile(filePath, "utf-8");
10100
- const parsed = z15.array(RunResultSchema).safeParse(JSON.parse(data));
11298
+ await fs16.promises.mkdir(this.persistDir, { recursive: true });
11299
+ const filePath = path18.join(this.persistDir, "history.json");
11300
+ const data = await fs16.promises.readFile(filePath, "utf-8");
11301
+ const parsed = z17.array(RunResultSchema).safeParse(JSON.parse(data));
10101
11302
  if (parsed.success) {
10102
11303
  this.history = parsed.data.slice(0, MAX_HISTORY);
10103
11304
  }
@@ -10130,9 +11331,9 @@ var MaintenanceReporter = class {
10130
11331
  */
10131
11332
  async persist() {
10132
11333
  try {
10133
- await fs14.promises.mkdir(this.persistDir, { recursive: true });
10134
- const filePath = path15.join(this.persistDir, "history.json");
10135
- await fs14.promises.writeFile(filePath, JSON.stringify(this.history, null, 2), "utf-8");
11334
+ await fs16.promises.mkdir(this.persistDir, { recursive: true });
11335
+ const filePath = path18.join(this.persistDir, "history.json");
11336
+ await fs16.promises.writeFile(filePath, JSON.stringify(this.history, null, 2), "utf-8");
10136
11337
  } catch (err) {
10137
11338
  this.logger.error("MaintenanceReporter: failed to persist history", { error: String(err) });
10138
11339
  }
@@ -10148,6 +11349,9 @@ var TaskRunner = class {
10148
11349
  cwd;
10149
11350
  prManager;
10150
11351
  baseBranch;
11352
+ checkScriptRunner;
11353
+ contextResolver;
11354
+ outputStore;
10151
11355
  constructor(options) {
10152
11356
  this.config = options.config;
10153
11357
  this.checkRunner = options.checkRunner;
@@ -10156,27 +11360,49 @@ var TaskRunner = class {
10156
11360
  this.cwd = options.cwd;
10157
11361
  this.prManager = options.prManager ?? null;
10158
11362
  this.baseBranch = options.baseBranch ?? "main";
11363
+ this.checkScriptRunner = options.checkScriptRunner ?? null;
11364
+ this.contextResolver = options.contextResolver ?? null;
11365
+ this.outputStore = options.outputStore ?? null;
10159
11366
  }
10160
11367
  /**
10161
11368
  * Run a maintenance task and return the result.
10162
11369
  * Dispatches to the appropriate execution path based on task type.
10163
11370
  * Never throws -- errors are captured in the RunResult.
11371
+ *
11372
+ * @param task - Resolved task definition.
11373
+ * @param origin - Hermes Phase 2 trigger-source tag; defaults to `'cron'`
11374
+ * when called from the scheduler path.
10164
11375
  */
10165
- async run(task) {
11376
+ async run(task, origin = "cron") {
10166
11377
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
11378
+ let result;
11379
+ let captured;
10167
11380
  try {
10168
11381
  switch (task.type) {
10169
- case "mechanical-ai":
10170
- return await this.runMechanicalAI(task, startedAt);
11382
+ case "mechanical-ai": {
11383
+ const out = await this.runMechanicalAI(task, startedAt);
11384
+ result = out.result;
11385
+ captured = out.captured;
11386
+ break;
11387
+ }
10171
11388
  case "pure-ai":
10172
- return await this.runPureAI(task, startedAt);
10173
- case "report-only":
10174
- return await this.runReportOnly(task, startedAt);
10175
- case "housekeeping":
10176
- return await this.runHousekeeping(task, startedAt);
11389
+ result = await this.runPureAI(task, startedAt);
11390
+ break;
11391
+ case "report-only": {
11392
+ const out = await this.runReportOnly(task, startedAt);
11393
+ result = out.result;
11394
+ captured = out.captured;
11395
+ break;
11396
+ }
11397
+ case "housekeeping": {
11398
+ const out = await this.runHousekeeping(task, startedAt);
11399
+ result = out.result;
11400
+ captured = out.captured;
11401
+ break;
11402
+ }
10177
11403
  default: {
10178
11404
  const _exhaustive = task.type;
10179
- return this.failureResult(
11405
+ result = this.failureResult(
10180
11406
  task.id,
10181
11407
  startedAt,
10182
11408
  `Unknown task type: ${String(_exhaustive)}`
@@ -10184,69 +11410,174 @@ var TaskRunner = class {
10184
11410
  }
10185
11411
  }
10186
11412
  } catch (err) {
10187
- return this.failureResult(task.id, startedAt, String(err));
11413
+ result = this.failureResult(task.id, startedAt, String(err));
11414
+ }
11415
+ result.origin = origin;
11416
+ await this.persistOutput(task, result, captured, origin);
11417
+ return result;
11418
+ }
11419
+ async persistOutput(task, result, captured, origin) {
11420
+ if (!this.outputStore) return;
11421
+ const entry = {
11422
+ taskId: result.taskId,
11423
+ startedAt: result.startedAt,
11424
+ completedAt: result.completedAt,
11425
+ status: result.status,
11426
+ findings: result.findings,
11427
+ fixed: result.fixed,
11428
+ prUrl: result.prUrl,
11429
+ prUpdated: result.prUpdated,
11430
+ origin,
11431
+ ...result.error !== void 0 && { error: result.error },
11432
+ ...result.costUsd !== void 0 && { costUsd: result.costUsd },
11433
+ ...captured?.stdout !== void 0 && { stdout: captured.stdout },
11434
+ ...captured?.stderr !== void 0 && { stderr: captured.stderr },
11435
+ ...captured?.structured !== void 0 && { structured: captured.structured },
11436
+ ...captured?.context !== void 0 && { context: captured.context }
11437
+ };
11438
+ try {
11439
+ await this.outputStore.write(task.id, entry, task.outputRetention);
11440
+ } catch {
10188
11441
  }
10189
11442
  }
10190
11443
  /**
10191
- * Mechanical-AI: run check command, dispatch AI agent only if fixable findings exist.
11444
+ * Run the check step using whichever runner the task asks for. Custom
11445
+ * tasks that declare `checkScript` go through the Hermes Phase 2
11446
+ * `CheckScriptRunner`; built-ins (and customs that use the legacy
11447
+ * `checkCommand` shape) go through the original heuristic runner.
10192
11448
  */
10193
- async runMechanicalAI(task, startedAt) {
11449
+ async runCheckStep(task) {
11450
+ if (task.checkScript) {
11451
+ if (!this.checkScriptRunner) {
11452
+ throw new Error(
11453
+ `task '${task.id}' declares checkScript but no CheckScriptRunner is configured`
11454
+ );
11455
+ }
11456
+ const r2 = await this.checkScriptRunner.run(task.checkScript, this.cwd);
11457
+ return {
11458
+ passed: r2.passed,
11459
+ findings: r2.findings,
11460
+ stdout: r2.output,
11461
+ stderr: r2.stderr,
11462
+ structured: r2.structured ? r2.structured : null
11463
+ };
11464
+ }
10194
11465
  if (!task.checkCommand || task.checkCommand.length === 0) {
10195
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing checkCommand");
11466
+ throw new Error(`task '${task.id}' is missing checkCommand`);
10196
11467
  }
11468
+ const r = await this.checkRunner.run(task.checkCommand, this.cwd);
11469
+ return {
11470
+ passed: r.passed,
11471
+ findings: r.findings,
11472
+ stdout: r.output,
11473
+ stderr: "",
11474
+ structured: null
11475
+ };
11476
+ }
11477
+ /**
11478
+ * Hermes Phase 2 — Compose the agent prompt-context block from inlined
11479
+ * skills + upstream task outputs. Returns an empty string when nothing
11480
+ * is configured (or when the resolver is absent), which is the safe
11481
+ * no-op default.
11482
+ */
11483
+ async composePromptContext(task) {
11484
+ if (!this.contextResolver) return "";
11485
+ const skills = await this.contextResolver.resolveInlineSkills(
11486
+ task.inlineSkills,
11487
+ task.inlineSkillsBudgetTokens ?? 8e3
11488
+ );
11489
+ const upstream = await this.contextResolver.resolveContextFrom(task.contextFrom, {
11490
+ maxAgeMinutes: task.contextFromMaxAgeMinutes ?? 1440
11491
+ });
11492
+ return [skills, upstream].filter(Boolean).join("\n");
11493
+ }
11494
+ /**
11495
+ * Mechanical-AI: run check (legacy or Phase 2 script), dispatch AI agent
11496
+ * only if fixable findings exist; persist captured stdout/stderr/context
11497
+ * via the output store on the way out.
11498
+ */
11499
+ async runMechanicalAI(task, startedAt) {
10197
11500
  if (!task.fixSkill) {
10198
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing fixSkill");
11501
+ return wrap(this.failureResult(task.id, startedAt, "mechanical-ai task missing fixSkill"));
10199
11502
  }
10200
11503
  if (!task.branch) {
10201
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing branch");
11504
+ return wrap(this.failureResult(task.id, startedAt, "mechanical-ai task missing branch"));
11505
+ }
11506
+ if (!task.checkCommand && !task.checkScript) {
11507
+ return wrap(
11508
+ this.failureResult(
11509
+ task.id,
11510
+ startedAt,
11511
+ "mechanical-ai task missing checkCommand or checkScript"
11512
+ )
11513
+ );
10202
11514
  }
10203
- const checkResult = await this.checkRunner.run(task.checkCommand, this.cwd);
10204
- if (checkResult.findings === 0) {
11515
+ let check;
11516
+ try {
11517
+ check = await this.runCheckStep(task);
11518
+ } catch (err) {
11519
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11520
+ }
11521
+ const promptContext = await this.composePromptContext(task);
11522
+ const baseCaptured = {
11523
+ stdout: check.stdout,
11524
+ stderr: check.stderr,
11525
+ structured: check.structured,
11526
+ ...promptContext ? { context: promptContext } : {}
11527
+ };
11528
+ const wakeAgentExplicitlyFalse = check.structured !== null && typeof check.structured === "object" && check.structured.wakeAgent === false;
11529
+ if (check.findings === 0 || wakeAgentExplicitlyFalse) {
10205
11530
  return {
10206
- taskId: task.id,
10207
- startedAt,
10208
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
10209
- status: "no-issues",
10210
- findings: 0,
10211
- fixed: 0,
10212
- prUrl: null,
10213
- prUpdated: false
11531
+ result: {
11532
+ taskId: task.id,
11533
+ startedAt,
11534
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11535
+ status: "no-issues",
11536
+ findings: check.findings,
11537
+ fixed: 0,
11538
+ prUrl: null,
11539
+ prUpdated: false
11540
+ },
11541
+ captured: baseCaptured
10214
11542
  };
10215
11543
  }
10216
11544
  if (this.prManager) {
10217
11545
  try {
10218
11546
  await this.prManager.ensureBranch(task.branch, this.baseBranch);
10219
11547
  } catch (err) {
10220
- return this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`);
11548
+ return wrap(
11549
+ this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`),
11550
+ baseCaptured
11551
+ );
10221
11552
  }
10222
11553
  }
10223
11554
  const backendName = this.resolveBackend(task.id);
10224
11555
  let agentResult;
10225
11556
  try {
10226
- agentResult = await this.agentDispatcher.dispatch(
10227
- task.fixSkill,
10228
- task.branch,
10229
- backendName,
10230
- this.cwd
10231
- );
11557
+ agentResult = promptContext ? await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd, {
11558
+ promptContext
11559
+ }) : await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd);
10232
11560
  } catch (err) {
10233
11561
  return {
10234
- taskId: task.id,
10235
- startedAt,
10236
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
10237
- status: "failure",
10238
- findings: checkResult.findings,
10239
- fixed: 0,
10240
- prUrl: null,
10241
- prUpdated: false,
10242
- error: `Agent dispatch failed: ${String(err)}`
11562
+ result: {
11563
+ taskId: task.id,
11564
+ startedAt,
11565
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11566
+ status: "failure",
11567
+ findings: check.findings,
11568
+ fixed: 0,
11569
+ prUrl: null,
11570
+ prUpdated: false,
11571
+ error: `Agent dispatch failed: ${String(err)}`
11572
+ },
11573
+ captured: baseCaptured
10243
11574
  };
10244
11575
  }
10245
11576
  let prUrl = null;
10246
11577
  let prUpdated = false;
10247
11578
  if (this.prManager && agentResult.producedCommits) {
10248
11579
  try {
10249
- const summary = `Findings: ${checkResult.findings}, Fixed: ${agentResult.fixed}`;
11580
+ const summary = `Findings: ${check.findings}, Fixed: ${agentResult.fixed}`;
10250
11581
  const prResult = await this.prManager.ensurePR(task, summary);
10251
11582
  prUrl = prResult.prUrl;
10252
11583
  prUpdated = prResult.prUpdated;
@@ -10255,14 +11586,17 @@ var TaskRunner = class {
10255
11586
  }
10256
11587
  }
10257
11588
  return {
10258
- taskId: task.id,
10259
- startedAt,
10260
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
10261
- status: "success",
10262
- findings: checkResult.findings,
10263
- fixed: agentResult.fixed,
10264
- prUrl,
10265
- prUpdated
11589
+ result: {
11590
+ taskId: task.id,
11591
+ startedAt,
11592
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11593
+ status: "success",
11594
+ findings: check.findings,
11595
+ fixed: agentResult.fixed,
11596
+ prUrl,
11597
+ prUpdated
11598
+ },
11599
+ captured: baseCaptured
10266
11600
  };
10267
11601
  }
10268
11602
  /**
@@ -10282,15 +11616,13 @@ var TaskRunner = class {
10282
11616
  return this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`);
10283
11617
  }
10284
11618
  }
11619
+ const promptContext = await this.composePromptContext(task);
10285
11620
  const backendName = this.resolveBackend(task.id);
10286
11621
  let agentResult;
10287
11622
  try {
10288
- agentResult = await this.agentDispatcher.dispatch(
10289
- task.fixSkill,
10290
- task.branch,
10291
- backendName,
10292
- this.cwd
10293
- );
11623
+ agentResult = promptContext ? await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd, {
11624
+ promptContext
11625
+ }) : await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd);
10294
11626
  } catch (err) {
10295
11627
  return this.failureResult(task.id, startedAt, `Agent dispatch failed: ${String(err)}`);
10296
11628
  }
@@ -10318,7 +11650,7 @@ var TaskRunner = class {
10318
11650
  };
10319
11651
  }
10320
11652
  /**
10321
- * Report-only: run check command, record metrics, no AI dispatch.
11653
+ * Report-only: run check (legacy or Phase 2 script), record metrics, no AI dispatch.
10322
11654
  *
10323
11655
  * Honors the JSON status contract emitted by Phase 4/5 CLIs (`harness pulse run`
10324
11656
  * and `harness compound scan-candidates` in `--non-interactive` mode):
@@ -10328,13 +11660,24 @@ var TaskRunner = class {
10328
11660
  * Legacy report-only tasks emit free-form output and fall through to 'success'.
10329
11661
  */
10330
11662
  async runReportOnly(task, startedAt) {
10331
- if (!task.checkCommand || task.checkCommand.length === 0) {
10332
- return this.failureResult(task.id, startedAt, "report-only task missing checkCommand");
11663
+ if (!task.checkCommand && !task.checkScript) {
11664
+ return wrap(
11665
+ this.failureResult(
11666
+ task.id,
11667
+ startedAt,
11668
+ "report-only task missing checkCommand or checkScript"
11669
+ )
11670
+ );
10333
11671
  }
10334
- const checkResult = await this.checkRunner.run(task.checkCommand, this.cwd);
10335
- const parsed = parseStatusLine(checkResult.output);
11672
+ let check;
11673
+ try {
11674
+ check = await this.runCheckStep(task);
11675
+ } catch (err) {
11676
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11677
+ }
11678
+ const parsed = parseStatusLine(check.stdout);
10336
11679
  const status = parsed?.status ?? "success";
10337
- const findings = parsed === null ? checkResult.findings : typeof parsed.candidatesFound === "number" ? parsed.candidatesFound : 0;
11680
+ const findings = parsed === null ? check.findings : typeof parsed.candidatesFound === "number" ? parsed.candidatesFound : 0;
10338
11681
  const result = {
10339
11682
  taskId: task.id,
10340
11683
  startedAt,
@@ -10348,7 +11691,10 @@ var TaskRunner = class {
10348
11691
  if (parsed?.error) {
10349
11692
  result.error = parsed.error;
10350
11693
  }
10351
- return result;
11694
+ return {
11695
+ result,
11696
+ captured: { stdout: check.stdout, stderr: check.stderr, structured: check.structured }
11697
+ };
10352
11698
  }
10353
11699
  /**
10354
11700
  * Housekeeping: run command directly, no AI, no PR.
@@ -10359,17 +11705,39 @@ var TaskRunner = class {
10359
11705
  * - sync-main contract: updated/no-op/skipped/error → mapped onto the run-result status
10360
11706
  * Legacy housekeeping commands that emit no JSON keep the prior behavior:
10361
11707
  * status: 'success', findings: 0.
11708
+ *
11709
+ * Hermes Phase 2: a `checkScript` may replace `checkCommand` for housekeeping
11710
+ * tasks; the runner falls through to the same JSON-status parsing path.
10362
11711
  */
10363
11712
  async runHousekeeping(task, startedAt) {
10364
- if (!task.checkCommand || task.checkCommand.length === 0) {
10365
- return this.failureResult(task.id, startedAt, "housekeeping task missing checkCommand");
11713
+ if (!task.checkCommand && !task.checkScript) {
11714
+ return wrap(
11715
+ this.failureResult(
11716
+ task.id,
11717
+ startedAt,
11718
+ "housekeeping task missing checkCommand or checkScript"
11719
+ )
11720
+ );
10366
11721
  }
10367
11722
  let stdout;
10368
- try {
10369
- const out = await this.commandExecutor.exec(task.checkCommand, this.cwd);
10370
- stdout = out.stdout ?? "";
10371
- } catch (err) {
10372
- return this.failureResult(task.id, startedAt, String(err));
11723
+ let stderr = "";
11724
+ let structured = null;
11725
+ if (task.checkScript) {
11726
+ try {
11727
+ const r = await this.runCheckStep(task);
11728
+ stdout = r.stdout;
11729
+ stderr = r.stderr;
11730
+ structured = r.structured;
11731
+ } catch (err) {
11732
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11733
+ }
11734
+ } else {
11735
+ try {
11736
+ const out = await this.commandExecutor.exec(task.checkCommand, this.cwd);
11737
+ stdout = out.stdout ?? "";
11738
+ } catch (err) {
11739
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11740
+ }
10373
11741
  }
10374
11742
  const parsed = parseStatusLine(stdout);
10375
11743
  const status = parsed?.status ?? "success";
@@ -10384,7 +11752,7 @@ var TaskRunner = class {
10384
11752
  prUpdated: false
10385
11753
  };
10386
11754
  if (parsed?.error) result.error = parsed.error;
10387
- return result;
11755
+ return { result, captured: { stdout, stderr, structured } };
10388
11756
  }
10389
11757
  /**
10390
11758
  * Resolve which AI backend name to use for a given task.
@@ -10409,6 +11777,9 @@ var TaskRunner = class {
10409
11777
  };
10410
11778
  }
10411
11779
  };
11780
+ function wrap(result, captured) {
11781
+ return captured ? { result, captured } : { result };
11782
+ }
10412
11783
  function parseStatusLine(output) {
10413
11784
  const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
10414
11785
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -10446,12 +11817,561 @@ function parseStatusLine(output) {
10446
11817
  return null;
10447
11818
  }
10448
11819
 
10449
- // src/orchestrator.ts
10450
- function useCaseForBackendParam(issue, backendParam) {
10451
- if (backendParam === "local") return { kind: "tier", tier: "quick-fix" };
10452
- const tier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
10453
- return { kind: "tier", tier };
11820
+ // src/maintenance/check-script-runner.ts
11821
+ import { execFile as execFile6 } from "child_process";
11822
+ import { promisify as promisify3 } from "util";
11823
+ import * as path19 from "path";
11824
+ var execFileAsync = promisify3(execFile6);
11825
+ var CheckScriptRunner = class {
11826
+ constructor(cwd) {
11827
+ this.cwd = cwd;
11828
+ }
11829
+ cwd;
11830
+ async run(spec, cwd) {
11831
+ const projectRoot = cwd ?? this.cwd;
11832
+ const captured = await captureScript(spec, projectRoot);
11833
+ const parseJson = spec.parseStdoutJson !== false;
11834
+ const structured = parseJson ? parseStatusEnvelope(captured.stdout) : null;
11835
+ if (structured) {
11836
+ return mapStructured(structured, captured.stdout, captured.stderr);
11837
+ }
11838
+ return heuristicResult(captured.stdout, captured.stderr, captured.exitedAbnormally);
11839
+ }
11840
+ };
11841
+ async function captureScript(spec, projectRoot) {
11842
+ const resolved = path19.isAbsolute(spec.path) ? spec.path : path19.resolve(projectRoot, spec.path);
11843
+ const args = spec.args ?? [];
11844
+ const timeoutMs = spec.timeoutMs ?? 12e4;
11845
+ try {
11846
+ const result = await execFileAsync(resolved, args, { cwd: projectRoot, timeout: timeoutMs });
11847
+ return {
11848
+ stdout: String(result.stdout ?? ""),
11849
+ stderr: String(result.stderr ?? ""),
11850
+ exitedAbnormally: false
11851
+ };
11852
+ } catch (err) {
11853
+ const e = err;
11854
+ return {
11855
+ stdout: String(e.stdout ?? ""),
11856
+ stderr: String(e.stderr ?? ""),
11857
+ exitedAbnormally: true
11858
+ };
11859
+ }
11860
+ }
11861
+ function parseStatusEnvelope(stdout) {
11862
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
11863
+ for (let i = lines.length - 1; i >= 0; i--) {
11864
+ const env = classifyLine2(lines[i]);
11865
+ if (env) return env;
11866
+ }
11867
+ return null;
11868
+ }
11869
+ var ENVELOPE_STATUSES = /* @__PURE__ */ new Set(["ok", "findings", "skip", "error"]);
11870
+ function classifyLine2(line) {
11871
+ const obj = tryParseJsonObject(line);
11872
+ if (!obj) return null;
11873
+ const s = obj.status;
11874
+ if (typeof s !== "string" || !ENVELOPE_STATUSES.has(s)) return null;
11875
+ return buildEnvelope(s, obj);
11876
+ }
11877
+ function tryParseJsonObject(line) {
11878
+ if (!line || !line.startsWith("{") || !line.endsWith("}")) return null;
11879
+ try {
11880
+ return JSON.parse(line);
11881
+ } catch {
11882
+ return null;
11883
+ }
11884
+ }
11885
+ function buildEnvelope(status, obj) {
11886
+ const env = { status };
11887
+ if (typeof obj.findings === "number") env.findings = obj.findings;
11888
+ if (typeof obj.wakeAgent === "boolean") env.wakeAgent = obj.wakeAgent;
11889
+ if (typeof obj.message === "string") env.message = obj.message;
11890
+ if (obj.outputs && typeof obj.outputs === "object") {
11891
+ env.outputs = obj.outputs;
11892
+ }
11893
+ return env;
11894
+ }
11895
+ function mapStructured(env, stdout, stderr) {
11896
+ const findings = env.findings ?? (env.status === "findings" ? 1 : 0);
11897
+ switch (env.status) {
11898
+ case "ok":
11899
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11900
+ case "findings": {
11901
+ const wake = env.wakeAgent ?? findings > 0;
11902
+ return { passed: !wake, findings, output: stdout, stderr, structured: env };
11903
+ }
11904
+ case "skip":
11905
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11906
+ case "error":
11907
+ return {
11908
+ passed: false,
11909
+ findings: Math.max(findings, 1),
11910
+ output: stdout,
11911
+ stderr,
11912
+ structured: env
11913
+ };
11914
+ default:
11915
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11916
+ }
11917
+ }
11918
+ function heuristicResult(stdout, stderr, exitedAbnormally) {
11919
+ const combined = [stdout, stderr].filter(Boolean).join("\n");
11920
+ const findingsMatch = combined.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
11921
+ const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : exitedAbnormally ? 1 : 0;
11922
+ return {
11923
+ passed: findings === 0 && !exitedAbnormally,
11924
+ findings,
11925
+ output: stdout,
11926
+ stderr,
11927
+ structured: null
11928
+ };
11929
+ }
11930
+
11931
+ // src/maintenance/output-store.ts
11932
+ import * as fs17 from "fs";
11933
+ import * as path20 from "path";
11934
+ var DEFAULT_RETENTION = {
11935
+ runs: 50,
11936
+ maxAgeDays: 30
11937
+ };
11938
+ var fallbackLogger2 = {
11939
+ info: () => {
11940
+ },
11941
+ warn: (m, c) => console.warn(m, c),
11942
+ error: (m, c) => console.error(m, c)
11943
+ };
11944
+ var TaskOutputStore = class {
11945
+ rootDir;
11946
+ retentionDefaults;
11947
+ logger;
11948
+ constructor(options) {
11949
+ this.rootDir = options.rootDir;
11950
+ this.retentionDefaults = options.retentionDefaults ?? DEFAULT_RETENTION;
11951
+ this.logger = options.logger ?? fallbackLogger2;
11952
+ }
11953
+ /**
11954
+ * Reject task IDs that don't match the validator's kebab-case pattern —
11955
+ * defends `dirFor()` against caller-supplied path-traversal segments
11956
+ * (`'../foo'`) when the store is invoked from CLI surfaces that don't
11957
+ * round-trip through `validateCustomTasks`.
11958
+ */
11959
+ ensureSafeTaskId(taskId) {
11960
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(taskId)) {
11961
+ throw new Error(
11962
+ `TaskOutputStore: invalid task id '${taskId}' (must match ^[a-z0-9][a-z0-9-]*$)`
11963
+ );
11964
+ }
11965
+ }
11966
+ /**
11967
+ * Persist a single run entry. Retention is applied after the write so
11968
+ * the latest record is durable even if pruning fails.
11969
+ */
11970
+ async write(taskId, entry, retention) {
11971
+ this.ensureSafeTaskId(taskId);
11972
+ const dir = this.dirFor(taskId);
11973
+ await fs17.promises.mkdir(dir, { recursive: true });
11974
+ const fileName = `${sanitizeIso(entry.completedAt || (/* @__PURE__ */ new Date()).toISOString())}.json`;
11975
+ const filePath = path20.join(dir, fileName);
11976
+ const tmpPath = `${filePath}.tmp`;
11977
+ const payload = JSON.stringify(entry, null, 2);
11978
+ await fs17.promises.writeFile(tmpPath, payload, "utf-8");
11979
+ await fs17.promises.rename(tmpPath, filePath);
11980
+ try {
11981
+ await this.applyRetention(taskId, retention);
11982
+ } catch (err) {
11983
+ this.logger.warn("TaskOutputStore retention failed", { taskId, error: String(err) });
11984
+ }
11985
+ }
11986
+ /**
11987
+ * Return the most recent persisted entry for the task, or null if none.
11988
+ */
11989
+ async latest(taskId) {
11990
+ const entries = await this.list(taskId, 1, 0);
11991
+ return entries[0] ?? null;
11992
+ }
11993
+ /**
11994
+ * List entries newest-first with offset+limit pagination.
11995
+ */
11996
+ async list(taskId, limit, offset) {
11997
+ this.ensureSafeTaskId(taskId);
11998
+ const dir = this.dirFor(taskId);
11999
+ const fileNames = await listJsonFilesDescending(dir);
12000
+ const slice = fileNames.slice(offset, offset + limit);
12001
+ const out = [];
12002
+ for (const name of slice) {
12003
+ const entry = await this.readEntry(path20.join(dir, name));
12004
+ if (entry) out.push(entry);
12005
+ }
12006
+ return out;
12007
+ }
12008
+ /**
12009
+ * Lookup a specific run by its file name (without the `.json` suffix) or
12010
+ * by its raw completion timestamp.
12011
+ */
12012
+ async get(taskId, runId) {
12013
+ this.ensureSafeTaskId(taskId);
12014
+ if (/[\\/]|\.\./.test(runId)) {
12015
+ throw new Error(`TaskOutputStore: runId '${runId}' must not contain path separators or '..'`);
12016
+ }
12017
+ const dir = this.dirFor(taskId);
12018
+ const fileName = runId.endsWith(".json") ? runId : `${sanitizeIso(runId)}.json`;
12019
+ return this.readEntry(path20.join(dir, fileName));
12020
+ }
12021
+ /**
12022
+ * The on-disk root for a given task. Exposed for tooling that needs to walk
12023
+ * outputs from outside the store API.
12024
+ */
12025
+ dirFor(taskId) {
12026
+ return path20.join(this.rootDir, taskId, "outputs");
12027
+ }
12028
+ async readEntry(filePath) {
12029
+ try {
12030
+ const buf = await fs17.promises.readFile(filePath, "utf-8");
12031
+ const parsed = JSON.parse(buf);
12032
+ return parsed;
12033
+ } catch {
12034
+ return null;
12035
+ }
12036
+ }
12037
+ async applyRetention(taskId, retention) {
12038
+ const runs = retention?.runs ?? this.retentionDefaults.runs;
12039
+ const maxAgeDays = retention?.maxAgeDays ?? this.retentionDefaults.maxAgeDays;
12040
+ const dir = this.dirFor(taskId);
12041
+ const fileNames = await listJsonFilesDescending(dir);
12042
+ const overflow = fileNames.slice(runs);
12043
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1e3;
12044
+ const aged = [];
12045
+ for (const name of fileNames) {
12046
+ const ts = parseIsoFromFileName(name);
12047
+ if (ts !== null && ts < cutoffMs) aged.push(name);
12048
+ }
12049
+ const toRemove = /* @__PURE__ */ new Set([...overflow, ...aged]);
12050
+ for (const name of toRemove) {
12051
+ try {
12052
+ await fs17.promises.unlink(path20.join(dir, name));
12053
+ } catch {
12054
+ }
12055
+ }
12056
+ }
12057
+ };
12058
+ async function listJsonFilesDescending(dir) {
12059
+ let names;
12060
+ try {
12061
+ names = await fs17.promises.readdir(dir);
12062
+ } catch {
12063
+ return [];
12064
+ }
12065
+ return names.filter((n) => n.endsWith(".json")).sort().reverse();
12066
+ }
12067
+ function sanitizeIso(iso) {
12068
+ return iso.replace(/:/g, "-");
12069
+ }
12070
+ function parseIsoFromFileName(fileName) {
12071
+ const stem = fileName.replace(/\.json$/, "");
12072
+ const restored = stem.replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
12073
+ const ms = Date.parse(restored);
12074
+ return Number.isFinite(ms) ? ms : null;
12075
+ }
12076
+
12077
+ // src/maintenance/context-resolver.ts
12078
+ var ContextResolver = class {
12079
+ outputStore;
12080
+ skillReader;
12081
+ logger;
12082
+ perUpstreamMaxChars;
12083
+ constructor(options) {
12084
+ this.outputStore = options.outputStore;
12085
+ this.skillReader = options.skillReader ?? null;
12086
+ this.logger = options.logger ?? fallbackLogger3;
12087
+ this.perUpstreamMaxChars = options.perUpstreamMaxChars ?? 2e3;
12088
+ }
12089
+ async resolveContextFrom(upstreamTaskIds, options = {}) {
12090
+ if (!upstreamTaskIds || upstreamTaskIds.length === 0) return "";
12091
+ const maxAgeMs = (options.maxAgeMinutes ?? 1440) * 60 * 1e3;
12092
+ const now = Date.now();
12093
+ const sections = [];
12094
+ for (const id of upstreamTaskIds) {
12095
+ const entry = await this.outputStore.latest(id);
12096
+ sections.push(this.formatUpstream(id, entry, now, maxAgeMs));
12097
+ }
12098
+ return `## Upstream context
12099
+
12100
+ ${sections.join("\n\n")}
12101
+ `;
12102
+ }
12103
+ async resolveInlineSkills(skillNames, budgetTokens = 8e3) {
12104
+ if (!skillNames || skillNames.length === 0) return "";
12105
+ if (!this.skillReader) return "";
12106
+ const charBudget = budgetTokens * 4;
12107
+ let used = 0;
12108
+ const sections = [];
12109
+ let truncatedAt = -1;
12110
+ for (let i = 0; i < skillNames.length; i++) {
12111
+ const name = skillNames[i];
12112
+ const body = await this.skillReader.read(name);
12113
+ if (body === null) {
12114
+ this.logger.warn("inlineSkills: skill not found in registry", { name });
12115
+ continue;
12116
+ }
12117
+ const block = `### ${name}
12118
+
12119
+ ${body}`;
12120
+ if (used + block.length > charBudget) {
12121
+ truncatedAt = i;
12122
+ break;
12123
+ }
12124
+ used += block.length;
12125
+ sections.push(block);
12126
+ }
12127
+ if (truncatedAt >= 0) {
12128
+ this.logger.warn(
12129
+ `inlineSkillsBudgetTokens (${budgetTokens}) exhausted after ${sections.length} of ${skillNames.length} skills; truncated.`
12130
+ );
12131
+ }
12132
+ if (sections.length === 0) return "";
12133
+ return `## Reference skills
12134
+
12135
+ ${sections.join("\n\n")}
12136
+ `;
12137
+ }
12138
+ formatUpstream(id, entry, now, maxAgeMs) {
12139
+ if (!entry) {
12140
+ return `### ${id}
12141
+
12142
+ _[no prior run]_`;
12143
+ }
12144
+ const completedMs = Date.parse(entry.completedAt);
12145
+ if (Number.isFinite(completedMs) && now - completedMs > maxAgeMs) {
12146
+ return `### ${id} (last run ${entry.completedAt}, stale)
12147
+
12148
+ _[stale: omitted]_`;
12149
+ }
12150
+ const head = `### ${id} (last run ${entry.completedAt}, status=${entry.status}, findings=${entry.findings})`;
12151
+ const body = (entry.stdout ?? "").trim();
12152
+ const truncated = body.length > this.perUpstreamMaxChars ? `${body.slice(0, this.perUpstreamMaxChars)}
12153
+
12154
+ _[truncated]_` : body;
12155
+ return `${head}
12156
+
12157
+ ${truncated || "_[no stdout captured]_"}`;
12158
+ }
12159
+ };
12160
+ var fallbackLogger3 = {
12161
+ info: () => {
12162
+ },
12163
+ warn: () => {
12164
+ },
12165
+ error: () => {
12166
+ }
12167
+ };
12168
+
12169
+ // src/maintenance/custom-task-validator.ts
12170
+ import { Ok as Ok23, Err as Err20 } from "@harness-engineering/types";
12171
+ var ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
12172
+ var REQUIRED_FIELDS_BY_TYPE = {
12173
+ "mechanical-ai": ["branch", "fixSkill"],
12174
+ "pure-ai": ["branch", "fixSkill"],
12175
+ "report-only": [],
12176
+ housekeeping: []
12177
+ };
12178
+ function validateCustomTasks(customTasks, builtIns, deps = {}) {
12179
+ const errors = [];
12180
+ if (!customTasks) return Ok23(void 0);
12181
+ const builtInIds = new Set(builtIns.map((t) => t.id));
12182
+ const customIds = Object.keys(customTasks);
12183
+ const allIds = /* @__PURE__ */ new Set([...builtInIds, ...customIds]);
12184
+ for (const id of customIds) {
12185
+ const task = customTasks[id];
12186
+ if (!task) continue;
12187
+ validateOne(id, task, builtInIds, allIds, deps, errors);
12188
+ }
12189
+ detectCycles(customTasks, builtIns, errors);
12190
+ return errors.length === 0 ? Ok23(void 0) : Err20(errors);
12191
+ }
12192
+ function validateOne(id, task, builtInIds, allIds, deps, errors) {
12193
+ const prefix = `customTasks.${id}`;
12194
+ if (!ID_PATTERN.test(id)) {
12195
+ errors.push({
12196
+ path: prefix,
12197
+ message: `task ID '${id}' must match ^[a-z0-9][a-z0-9-]*$`
12198
+ });
12199
+ }
12200
+ if (builtInIds.has(id)) {
12201
+ errors.push({
12202
+ path: prefix,
12203
+ message: `task ID '${id}' collides with a built-in task; choose a different name`
12204
+ });
12205
+ }
12206
+ if (!task.description || task.description.trim().length === 0) {
12207
+ errors.push({ path: `${prefix}.description`, message: "description is required" });
12208
+ }
12209
+ if (!task.schedule || task.schedule.trim().length === 0) {
12210
+ errors.push({ path: `${prefix}.schedule`, message: "schedule (cron expression) is required" });
12211
+ }
12212
+ validateCheckShape(prefix, task, errors);
12213
+ validateRequiredByType(prefix, task, errors);
12214
+ validateContextFrom(prefix, id, task, allIds, errors);
12215
+ validateInlineSkills(prefix, task, deps, errors);
12216
+ validateScriptPath(prefix, task, deps, errors);
12217
+ }
12218
+ function validateCheckShape(prefix, task, errors) {
12219
+ const hasCommand = Array.isArray(task.checkCommand) && task.checkCommand.length > 0;
12220
+ const hasScript = task.checkScript !== void 0;
12221
+ if (hasCommand && hasScript) {
12222
+ errors.push({
12223
+ path: prefix,
12224
+ message: "a task may declare checkCommand OR checkScript, not both"
12225
+ });
12226
+ }
12227
+ const needsCheck = task.type === "mechanical-ai" || task.type === "report-only" || task.type === "housekeeping";
12228
+ if (needsCheck && !hasCommand && !hasScript) {
12229
+ errors.push({
12230
+ path: prefix,
12231
+ message: `${task.type} task must declare either checkCommand or checkScript`
12232
+ });
12233
+ }
12234
+ if (hasScript) {
12235
+ const path24 = task.checkScript?.path;
12236
+ if (!path24 || path24.trim().length === 0) {
12237
+ errors.push({ path: `${prefix}.checkScript.path`, message: "checkScript.path is required" });
12238
+ }
12239
+ if (task.checkScript?.timeoutMs !== void 0 && task.checkScript.timeoutMs <= 0) {
12240
+ errors.push({
12241
+ path: `${prefix}.checkScript.timeoutMs`,
12242
+ message: "timeoutMs must be a positive integer"
12243
+ });
12244
+ }
12245
+ }
12246
+ }
12247
+ function validateRequiredByType(prefix, task, errors) {
12248
+ const required = REQUIRED_FIELDS_BY_TYPE[task.type];
12249
+ if (!required) {
12250
+ errors.push({ path: `${prefix}.type`, message: `unknown task type '${String(task.type)}'` });
12251
+ return;
12252
+ }
12253
+ for (const field of required) {
12254
+ const value = task[field];
12255
+ if (value === void 0 || value === null || typeof value === "string" && value.length === 0) {
12256
+ errors.push({
12257
+ path: `${prefix}.${String(field)}`,
12258
+ message: `${task.type} task requires ${String(field)}`
12259
+ });
12260
+ }
12261
+ }
12262
+ if ((task.type === "mechanical-ai" || task.type === "pure-ai") && task.branch === null) {
12263
+ errors.push({
12264
+ path: `${prefix}.branch`,
12265
+ message: `${task.type} task requires a non-null branch`
12266
+ });
12267
+ }
12268
+ }
12269
+ function validateContextFrom(prefix, selfId, task, allIds, errors) {
12270
+ if (task.contextFromMaxAgeMinutes !== void 0 && task.contextFromMaxAgeMinutes <= 0) {
12271
+ errors.push({
12272
+ path: `${prefix}.contextFromMaxAgeMinutes`,
12273
+ message: "contextFromMaxAgeMinutes must be a positive integer"
12274
+ });
12275
+ }
12276
+ if (!task.contextFrom) return;
12277
+ for (let i = 0; i < task.contextFrom.length; i++) {
12278
+ const upstreamId = task.contextFrom[i];
12279
+ if (!upstreamId) continue;
12280
+ if (upstreamId === selfId) {
12281
+ errors.push({
12282
+ path: `${prefix}.contextFrom[${i}]`,
12283
+ message: `task '${selfId}' cannot reference itself in contextFrom`
12284
+ });
12285
+ }
12286
+ if (!allIds.has(upstreamId)) {
12287
+ errors.push({
12288
+ path: `${prefix}.contextFrom[${i}]`,
12289
+ message: `references unknown task '${upstreamId}'`
12290
+ });
12291
+ }
12292
+ }
12293
+ }
12294
+ function validateInlineSkills(prefix, task, deps, errors) {
12295
+ if (!task.inlineSkills) return;
12296
+ if (!deps.skillExists) return;
12297
+ for (let i = 0; i < task.inlineSkills.length; i++) {
12298
+ const name = task.inlineSkills[i];
12299
+ if (!name) continue;
12300
+ if (!deps.skillExists(name)) {
12301
+ errors.push({
12302
+ path: `${prefix}.inlineSkills[${i}]`,
12303
+ message: `skill '${name}' not found in the registry`
12304
+ });
12305
+ }
12306
+ }
12307
+ if (task.inlineSkillsBudgetTokens !== void 0 && task.inlineSkillsBudgetTokens <= 0) {
12308
+ errors.push({
12309
+ path: `${prefix}.inlineSkillsBudgetTokens`,
12310
+ message: "inlineSkillsBudgetTokens must be a positive integer"
12311
+ });
12312
+ }
12313
+ }
12314
+ function validateScriptPath(prefix, task, deps, errors) {
12315
+ if (!task.checkScript?.path) return;
12316
+ if (!deps.scriptExists) return;
12317
+ if (!deps.scriptExists(task.checkScript.path)) {
12318
+ errors.push({
12319
+ path: `${prefix}.checkScript.path`,
12320
+ message: `executable not found: ${task.checkScript.path}`
12321
+ });
12322
+ }
12323
+ }
12324
+ function detectCycles(customTasks, builtIns, errors) {
12325
+ const adjacency = /* @__PURE__ */ new Map();
12326
+ for (const t of builtIns) adjacency.set(t.id, []);
12327
+ for (const [id, task] of Object.entries(customTasks)) {
12328
+ adjacency.set(id, (task.contextFrom ?? []).slice());
12329
+ }
12330
+ const color = /* @__PURE__ */ new Map();
12331
+ for (const id of adjacency.keys()) color.set(id, "white");
12332
+ const reported = /* @__PURE__ */ new Set();
12333
+ for (const id of Object.keys(customTasks)) {
12334
+ if (color.get(id) === "white") visitFromRoot(id, adjacency, color, errors, reported);
12335
+ }
12336
+ }
12337
+ function visitFromRoot(start, adjacency, color, errors, reported) {
12338
+ const stack = [{ id: start, nextIdx: 0, path: [start] }];
12339
+ color.set(start, "grey");
12340
+ while (stack.length) {
12341
+ const top = stack[stack.length - 1];
12342
+ const neighbors = adjacency.get(top.id) ?? [];
12343
+ if (top.nextIdx >= neighbors.length) {
12344
+ color.set(top.id, "black");
12345
+ stack.pop();
12346
+ continue;
12347
+ }
12348
+ const next = neighbors[top.nextIdx++];
12349
+ if (!next || !adjacency.has(next)) continue;
12350
+ handleEdge(top, next, color, stack, errors, reported);
12351
+ }
12352
+ }
12353
+ function handleEdge(top, next, color, stack, errors, reported) {
12354
+ const nextColor = color.get(next);
12355
+ if (nextColor === "grey") {
12356
+ reportCycle(top.path, next, errors, reported);
12357
+ } else if (nextColor === "white") {
12358
+ color.set(next, "grey");
12359
+ stack.push({ id: next, nextIdx: 0, path: [...top.path, next] });
12360
+ }
12361
+ }
12362
+ function reportCycle(path24, next, errors, reported) {
12363
+ const cycleStart = path24.indexOf(next);
12364
+ const cyclePath = cycleStart >= 0 ? [...path24.slice(cycleStart), next] : [...path24, next];
12365
+ const key = cyclePath.join("\u2192");
12366
+ if (reported.has(key)) return;
12367
+ reported.add(key);
12368
+ errors.push({
12369
+ path: `customTasks.${cyclePath[0]}.contextFrom`,
12370
+ message: `contextFrom cycle detected: ${cyclePath.join(" \u2192 ")}`
12371
+ });
10454
12372
  }
12373
+
12374
+ // src/orchestrator.ts
10455
12375
  var Orchestrator = class extends EventEmitter {
10456
12376
  state;
10457
12377
  config;
@@ -10476,6 +12396,14 @@ var Orchestrator = class extends EventEmitter {
10476
12396
  * construction time. Eliminating this fallback is autopilot Phase 4+.
10477
12397
  */
10478
12398
  backendFactory;
12399
+ /**
12400
+ * Spec B Phase 4 (D8): per-orchestrator in-process bus for
12401
+ * `RoutingDecision` events. Constructed alongside backendFactory when
12402
+ * agent.backends synthesis succeeds; null when legacy single-backend
12403
+ * config bypassed backends. Phase 5+ consumers (HTTP, WS, dashboard)
12404
+ * subscribe via `getRoutingDecisionBus()`.
12405
+ */
12406
+ routingDecisionBus;
10479
12407
  /**
10480
12408
  * Test-only: when overrides.backend is provided, dispatch uses this
10481
12409
  * instance directly (bypassing the factory). Mirrors Phase 1
@@ -10498,6 +12426,15 @@ var Orchestrator = class extends EventEmitter {
10498
12426
  * so this map is the single source of truth post-migration.
10499
12427
  */
10500
12428
  localResolvers = /* @__PURE__ */ new Map();
12429
+ /**
12430
+ * Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
12431
+ * construction from `projectRoot/agents/skills/`. Consulted by
12432
+ * `buildRoutingUseCase` at dispatch start to construct
12433
+ * `{ kind: 'skill', skillName, cognitiveMode }` RoutingUseCases.
12434
+ * Empty when the orchestrator runs outside a harness project root
12435
+ * (then dispatch falls through to per-tier, preserving F11/N2).
12436
+ */
12437
+ skillCatalog;
10501
12438
  /**
10502
12439
  * Per-resolver `onStatusChange` unsubscribe callbacks. Spec 2 Phase 5
10503
12440
  * (SC39): each local/pi resolver gets its own listener emitting a
@@ -10546,7 +12483,7 @@ var Orchestrator = class extends EventEmitter {
10546
12483
  completionHandler;
10547
12484
  /** Project root directory, derived from workspace root. */
10548
12485
  get projectRoot() {
10549
- return path16.resolve(this.config.workspace.root, "..", "..");
12486
+ return path21.resolve(this.config.workspace.root, "..", "..");
10550
12487
  }
10551
12488
  enrichedSpecsByIssue = /* @__PURE__ */ new Map();
10552
12489
  /** Tracks recently-failed intelligence analysis to avoid re-requesting every tick */
@@ -10590,6 +12527,13 @@ var Orchestrator = class extends EventEmitter {
10590
12527
  `migrateAgentConfig failed; continuing with legacy fields. Error: ${err instanceof Error ? err.message : String(err)}`
10591
12528
  );
10592
12529
  }
12530
+ const skillCatalogRoot = path21.resolve(this.config.workspace.root, "..", "..");
12531
+ this.skillCatalog = discoverSkillCatalog(skillCatalogRoot);
12532
+ if (this.skillCatalog.length === 0) {
12533
+ this.logger.warn(
12534
+ `Spec B Phase 3: skill catalog discovery returned 0 entries; per-skill / per-mode routing will fall through to per-tier. Looked under ${path21.join(skillCatalogRoot, "agents/skills")}.`
12535
+ );
12536
+ }
10593
12537
  this.tracker = overrides?.tracker || this.createTracker();
10594
12538
  this.workspace = new WorkspaceManager(config.workspace, {
10595
12539
  emitEvent: (event) => {
@@ -10601,10 +12545,10 @@ var Orchestrator = class extends EventEmitter {
10601
12545
  this.renderer = new PromptRenderer();
10602
12546
  this.overrideBackend = overrides?.backend ?? null;
10603
12547
  this.interactionQueue = new InteractionQueue(
10604
- path16.join(config.workspace.root, "..", "interactions"),
12548
+ path21.join(config.workspace.root, "..", "interactions"),
10605
12549
  this
10606
12550
  );
10607
- this.analysisArchive = new AnalysisArchive(path16.join(config.workspace.root, "..", "analyses"));
12551
+ this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
10608
12552
  const backendsMap = this.config.agent.backends ?? {};
10609
12553
  for (const [name, def] of Object.entries(backendsMap)) {
10610
12554
  if (def.type === "local" || def.type === "pi") {
@@ -10625,6 +12569,10 @@ var Orchestrator = class extends EventEmitter {
10625
12569
  const routing = this.config.agent.routing ?? {
10626
12570
  default: firstBackendName ?? "primary"
10627
12571
  };
12572
+ this.routingDecisionBus = new RoutingDecisionBus({
12573
+ capacity: 500,
12574
+ logger: this.logger
12575
+ });
10628
12576
  this.backendFactory = new OrchestratorBackendFactory({
10629
12577
  backends: this.config.agent.backends,
10630
12578
  routing,
@@ -10632,6 +12580,7 @@ var Orchestrator = class extends EventEmitter {
10632
12580
  ...this.config.agent.container !== void 0 ? { container: this.config.agent.container } : {},
10633
12581
  ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
10634
12582
  cacheMetrics: this.cacheMetrics,
12583
+ decisionBus: this.routingDecisionBus,
10635
12584
  getResolverModelFor: (name) => {
10636
12585
  const resolver = this.localResolvers.get(name);
10637
12586
  return resolver ? () => resolver.resolveModel() : void 0;
@@ -10639,6 +12588,7 @@ var Orchestrator = class extends EventEmitter {
10639
12588
  });
10640
12589
  } else {
10641
12590
  this.backendFactory = null;
12591
+ this.routingDecisionBus = null;
10642
12592
  }
10643
12593
  this.pipeline = null;
10644
12594
  this.orchestratorIdPromise = resolveOrchestratorId(config.orchestratorId);
@@ -10648,7 +12598,7 @@ var Orchestrator = class extends EventEmitter {
10648
12598
  ...overrides?.execFileFn ? { execFileFn: overrides.execFileFn } : {}
10649
12599
  });
10650
12600
  this.recorder = new StreamRecorder(
10651
- path16.resolve(config.workspace.root, "..", "streams"),
12601
+ path21.resolve(config.workspace.root, "..", "streams"),
10652
12602
  this.logger
10653
12603
  );
10654
12604
  const self = this;
@@ -10679,10 +12629,10 @@ var Orchestrator = class extends EventEmitter {
10679
12629
  this.completionHandler = new CompletionHandler(ctx, this.postLifecycleComment.bind(this));
10680
12630
  if (config.server?.port) {
10681
12631
  const webhookStore = new WebhookStore(
10682
- path16.join(this.projectRoot, ".harness", "webhooks.json")
12632
+ path21.join(this.projectRoot, ".harness", "webhooks.json")
10683
12633
  );
10684
12634
  this.webhookQueue = new WebhookQueue(
10685
- path16.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
12635
+ path21.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
10686
12636
  );
10687
12637
  const webhookDelivery = new WebhookDelivery({
10688
12638
  queue: this.webhookQueue,
@@ -10720,7 +12670,16 @@ var Orchestrator = class extends EventEmitter {
10720
12670
  queue: this.webhookQueue
10721
12671
  },
10722
12672
  cacheMetrics: this.cacheMetrics,
10723
- plansDir: path16.resolve(config.workspace.root, "..", "docs", "plans"),
12673
+ // Spec B Phase 5: routing observability accessors. Closures so the
12674
+ // server re-reads on every request — stop() / start() do not
12675
+ // require server reconstruction. Returns null if no backendFactory
12676
+ // (legacy single-backend configs), and the route handler renders
12677
+ // 503 in that case.
12678
+ getBackendRouter: () => this.getBackendRouter(),
12679
+ getRoutingDecisionBus: () => this.getRoutingDecisionBus(),
12680
+ getRoutingConfig: () => this.getRoutingConfig(),
12681
+ getBackends: () => this.getBackends(),
12682
+ plansDir: path21.resolve(config.workspace.root, "..", "docs", "plans"),
10724
12683
  pipeline: this.pipeline,
10725
12684
  analysisArchive: this.analysisArchive,
10726
12685
  roadmapPath: config.tracker.filePath ?? null,
@@ -10776,13 +12735,13 @@ var Orchestrator = class extends EventEmitter {
10776
12735
  const logger = this.logger;
10777
12736
  const checkRunner = {
10778
12737
  run: async (command, cwd) => {
10779
- const { execFile: execFile6 } = await import("child_process");
10780
- const { promisify: promisify4 } = await import("util");
10781
- const execFileAsync = promisify4(execFile6);
12738
+ const { execFile: execFile7 } = await import("child_process");
12739
+ const { promisify: promisify5 } = await import("util");
12740
+ const execFileAsync2 = promisify5(execFile7);
10782
12741
  const [cmd, ...args] = command;
10783
12742
  if (!cmd) return { passed: true, findings: 0, output: "" };
10784
12743
  try {
10785
- const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
12744
+ const { stdout } = await execFileAsync2(cmd, args, { cwd, timeout: 12e4 });
10786
12745
  const findingsMatch = stdout.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
10787
12746
  const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : 0;
10788
12747
  return { passed: findings === 0, findings, output: stdout };
@@ -10811,13 +12770,13 @@ var Orchestrator = class extends EventEmitter {
10811
12770
  };
10812
12771
  const commandExecutor = {
10813
12772
  exec: async (command, cwd) => {
10814
- const { execFile: execFile6 } = await import("child_process");
10815
- const { promisify: promisify4 } = await import("util");
10816
- const execFileAsync = promisify4(execFile6);
12773
+ const { execFile: execFile7 } = await import("child_process");
12774
+ const { promisify: promisify5 } = await import("util");
12775
+ const execFileAsync2 = promisify5(execFile7);
10817
12776
  const [cmd, ...args] = command;
10818
12777
  if (!cmd) return { stdout: "" };
10819
12778
  try {
10820
- const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
12779
+ const { stdout } = await execFileAsync2(cmd, args, { cwd, timeout: 12e4 });
10821
12780
  return { stdout: String(stdout) };
10822
12781
  } catch (err) {
10823
12782
  logger.warn("Maintenance command execution failed", {
@@ -10829,12 +12788,31 @@ var Orchestrator = class extends EventEmitter {
10829
12788
  }
10830
12789
  }
10831
12790
  };
12791
+ const outputStore = new TaskOutputStore({
12792
+ rootDir: path21.join(this.projectRoot, ".harness", "maintenance"),
12793
+ logger: this.logger
12794
+ });
12795
+ const checkScriptRunner = new CheckScriptRunner(this.projectRoot);
12796
+ const skillReader = {
12797
+ // The orchestrator does not own the skill registry; CLI-side skill
12798
+ // resolution wires this in via direct injection. Default: skill not
12799
+ // resolvable from the orchestrator boundary.
12800
+ read: async () => null
12801
+ };
12802
+ const contextResolver = new ContextResolver({
12803
+ outputStore,
12804
+ skillReader,
12805
+ logger: this.logger
12806
+ });
10832
12807
  return new TaskRunner({
10833
12808
  config: maintenanceConfig,
10834
12809
  checkRunner,
10835
12810
  agentDispatcher,
10836
12811
  commandExecutor,
10837
- cwd: this.projectRoot
12812
+ cwd: this.projectRoot,
12813
+ checkScriptRunner,
12814
+ contextResolver,
12815
+ outputStore
10838
12816
  });
10839
12817
  }
10840
12818
  /**
@@ -10842,8 +12820,17 @@ var Orchestrator = class extends EventEmitter {
10842
12820
  * Extracted from start() to keep function length under threshold.
10843
12821
  */
10844
12822
  async initMaintenance(maintenanceConfig) {
12823
+ const validation = validateCustomTasks(
12824
+ maintenanceConfig.customTasks,
12825
+ BUILT_IN_TASKS
12826
+ );
12827
+ if (!validation.ok) {
12828
+ const messages = validation.error.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
12829
+ throw new Error(`Invalid maintenance.customTasks configuration:
12830
+ ${messages}`);
12831
+ }
10845
12832
  this.maintenanceReporter = new MaintenanceReporter({
10846
- persistDir: path16.join(this.projectRoot, ".harness", "maintenance"),
12833
+ persistDir: path21.join(this.projectRoot, ".harness", "maintenance"),
10847
12834
  logger: this.logger
10848
12835
  });
10849
12836
  await this.maintenanceReporter.load();
@@ -10894,10 +12881,17 @@ var Orchestrator = class extends EventEmitter {
10894
12881
  }
10895
12882
  }
10896
12883
  createIntelligencePipeline() {
12884
+ if (!this.backendFactory) {
12885
+ this.logger.warn(
12886
+ "intelligence pipeline disabled: no backendFactory available (legacy config without agent.backends)"
12887
+ );
12888
+ return null;
12889
+ }
10897
12890
  const bundle = buildIntelligencePipeline({
10898
12891
  config: this.config,
10899
12892
  localResolvers: this.localResolvers,
10900
- logger: this.logger
12893
+ logger: this.logger,
12894
+ router: this.backendFactory.getRouter()
10901
12895
  });
10902
12896
  if (!bundle) return null;
10903
12897
  this.graphStore = bundle.graphStore;
@@ -10948,11 +12942,13 @@ var Orchestrator = class extends EventEmitter {
10948
12942
  simulationResults,
10949
12943
  personaRecommendations
10950
12944
  } = pipelineResult ?? {};
12945
+ const selfAssignee = await this.orchestratorIdPromise;
10951
12946
  const tickEvent = {
10952
12947
  type: "tick",
10953
12948
  candidates,
10954
12949
  runningStates: runningStatesResult.value,
10955
12950
  nowMs,
12951
+ selfAssignee,
10956
12952
  ...concernSignals !== void 0 && { concernSignals },
10957
12953
  ...enrichedSpecs !== void 0 && { enrichedSpecs },
10958
12954
  ...complexityScores !== void 0 && { complexityScores },
@@ -11376,14 +13372,24 @@ var Orchestrator = class extends EventEmitter {
11376
13372
  issue,
11377
13373
  attempt: attempt || 1
11378
13374
  });
11379
- const useCase = useCaseForBackendParam(issue, backend);
13375
+ const useCase = buildRoutingUseCase(issue, backend, this.skillCatalog);
13376
+ const invocationOverride = process.env.HARNESS_BACKEND_OVERRIDE;
13377
+ const routerOpts = invocationOverride ? { invocationOverride } : void 0;
13378
+ if (invocationOverride) {
13379
+ this.logger.info(
13380
+ `Spec B Phase 3: HARNESS_BACKEND_OVERRIDE='${invocationOverride}' taking effect for ${issue.identifier}`,
13381
+ { issueId: issue.id }
13382
+ );
13383
+ }
11380
13384
  let routedBackendName;
11381
13385
  if (this.overrideBackend !== null) {
11382
13386
  routedBackendName = this.overrideBackend.name;
11383
13387
  } else if (this.backendFactory !== null) {
11384
- routedBackendName = this.backendFactory.resolveName(useCase);
13388
+ routedBackendName = this.backendFactory.resolveName(useCase, routerOpts);
11385
13389
  } else {
11386
- routedBackendName = this.config.agent.routing?.default ?? this.config.agent.backend ?? "unknown";
13390
+ const routingDefault = this.config.agent.routing?.default;
13391
+ const routingDefaultScalar = routingDefault !== void 0 ? toArray(routingDefault)[0] : void 0;
13392
+ routedBackendName = routingDefaultScalar ?? this.config.agent.backend ?? "unknown";
11387
13393
  }
11388
13394
  const session = {
11389
13395
  sessionId: `pending-${Date.now()}`,
@@ -11422,7 +13428,7 @@ var Orchestrator = class extends EventEmitter {
11422
13428
  if (this.overrideBackend !== null) {
11423
13429
  agentBackend = this.overrideBackend;
11424
13430
  } else if (this.backendFactory !== null) {
11425
- agentBackend = this.backendFactory.forUseCase(useCase);
13431
+ agentBackend = this.backendFactory.forUseCase(useCase, routerOpts);
11426
13432
  } else {
11427
13433
  throw new Error(
11428
13434
  `Cannot dispatch ${issue.identifier}: agent.backends not synthesized (migration failed) and no override backend supplied. Migrate to agent.backends/agent.routing per docs/guides/multi-backend-routing.md.`
@@ -11712,6 +13718,8 @@ var Orchestrator = class extends EventEmitter {
11712
13718
  unsub();
11713
13719
  }
11714
13720
  this.localModelStatusUnsubscribes = [];
13721
+ this.routingDecisionBus?.clearListeners();
13722
+ this.routingDecisionBus = null;
11715
13723
  for (const resolver of this.localResolvers.values()) {
11716
13724
  resolver.stop();
11717
13725
  }
@@ -11785,6 +13793,42 @@ var Orchestrator = class extends EventEmitter {
11785
13793
  tickActivity: this.tickActivity
11786
13794
  };
11787
13795
  }
13796
+ /**
13797
+ * Spec B Phase 4 (D8): expose the bus for Phase 5 (HTTP routes) and
13798
+ * Phase 7 (dashboard WS broadcast). Returns null when the legacy
13799
+ * single-backend config bypassed agent.backends synthesis.
13800
+ */
13801
+ getRoutingDecisionBus() {
13802
+ return this.routingDecisionBus;
13803
+ }
13804
+ /**
13805
+ * Spec B Phase 5: live BackendRouter for HTTP routes. The orchestrator
13806
+ * dispatch path uses the factory-owned router directly; observability
13807
+ * routes (config / decisions) reach it through this accessor. Returns
13808
+ * null when the legacy single-backend config bypassed agent.backends
13809
+ * synthesis (no backendFactory built).
13810
+ */
13811
+ getBackendRouter() {
13812
+ return this.backendFactory?.getRouter() ?? null;
13813
+ }
13814
+ /**
13815
+ * Spec B Phase 5: snapshot of the active RoutingConfig for the config
13816
+ * route and the trace route's bus-less router construction. Returns
13817
+ * null when the operator's harness.config.json carries no
13818
+ * `agent.routing` block.
13819
+ */
13820
+ getRoutingConfig() {
13821
+ return this.config.agent.routing ?? null;
13822
+ }
13823
+ /**
13824
+ * Spec B Phase 5: snapshot of `agent.backends` for the config route
13825
+ * (existence annotations) and the trace route (bus-less router
13826
+ * construction). Returns null when no synthesized backends map exists
13827
+ * (legacy single-backend configs).
13828
+ */
13829
+ getBackends() {
13830
+ return this.config.agent.backends ?? null;
13831
+ }
11788
13832
  /** Returns the maintenance scheduler status, or null if maintenance is not enabled. */
11789
13833
  getMaintenanceStatus() {
11790
13834
  return this.maintenanceScheduler?.getStatus() ?? null;
@@ -12020,10 +14064,10 @@ function launchTUI(orchestrator) {
12020
14064
 
12021
14065
  // src/maintenance/sync-main.ts
12022
14066
  import { execFile as nodeExecFile } from "child_process";
12023
- import { promisify as promisify3 } from "util";
14067
+ import { promisify as promisify4 } from "util";
12024
14068
  var DEFAULT_TIMEOUT_MS3 = 6e4;
12025
14069
  async function git(execFileFn, args, cwd, timeoutMs) {
12026
- const exec = promisify3(execFileFn);
14070
+ const exec = promisify4(execFileFn);
12027
14071
  const { stdout, stderr } = await exec("git", args, { cwd, timeout: timeoutMs });
12028
14072
  return { stdout: String(stdout), stderr: String(stderr) };
12029
14073
  }
@@ -12163,8 +14207,8 @@ async function syncMain(repoRoot, opts = {}) {
12163
14207
  }
12164
14208
 
12165
14209
  // src/sessions/search-index.ts
12166
- import * as fs15 from "fs";
12167
- import * as path17 from "path";
14210
+ import * as fs18 from "fs";
14211
+ import * as path22 from "path";
12168
14212
  import Database2 from "better-sqlite3";
12169
14213
  import { INDEXED_FILE_KINDS } from "@harness-engineering/types";
12170
14214
  var SEARCH_INDEX_FILE = "search-index.sqlite";
@@ -12209,7 +14253,7 @@ function normalizeFts5Query(query) {
12209
14253
  return query.split(/\s+/).filter((tok) => tok.length > 0).map((tok) => `"${tok.replace(/"/g, '""')}"`).join(" ");
12210
14254
  }
12211
14255
  function searchIndexPath(projectPath) {
12212
- return path17.join(projectPath, ".harness", SEARCH_INDEX_FILE);
14256
+ return path22.join(projectPath, ".harness", SEARCH_INDEX_FILE);
12213
14257
  }
12214
14258
  var FILE_KIND_TO_FILENAME = {
12215
14259
  summary: "summary.md",
@@ -12224,7 +14268,7 @@ var SqliteSearchIndex = class {
12224
14268
  removeSessionStmt;
12225
14269
  totalStmt;
12226
14270
  constructor(dbPath) {
12227
- fs15.mkdirSync(path17.dirname(dbPath), { recursive: true });
14271
+ fs18.mkdirSync(path22.dirname(dbPath), { recursive: true });
12228
14272
  this.db = new Database2(dbPath);
12229
14273
  this.db.pragma("journal_mode = WAL");
12230
14274
  this.db.pragma("synchronous = NORMAL");
@@ -12329,14 +14373,14 @@ function indexSessionDirectory(idx, args) {
12329
14373
  let docsWritten = 0;
12330
14374
  for (const kind of kinds) {
12331
14375
  const fileName = FILE_KIND_TO_FILENAME[kind];
12332
- const filePath = path17.join(args.sessionDir, fileName);
12333
- if (!fs15.existsSync(filePath)) continue;
12334
- let body = fs15.readFileSync(filePath, "utf8");
14376
+ const filePath = path22.join(args.sessionDir, fileName);
14377
+ if (!fs18.existsSync(filePath)) continue;
14378
+ let body = fs18.readFileSync(filePath, "utf8");
12335
14379
  if (Buffer.byteLength(body, "utf8") > cap) {
12336
14380
  body = body.slice(0, cap) + "\n\n[TRUNCATED]";
12337
14381
  }
12338
- const stat = fs15.statSync(filePath);
12339
- const relPath = path17.relative(args.projectPath, filePath).replaceAll("\\", "/");
14382
+ const stat = fs18.statSync(filePath);
14383
+ const relPath = path22.relative(args.projectPath, filePath).replaceAll("\\", "/");
12340
14384
  idx.upsertSessionDoc({
12341
14385
  sessionId: args.sessionId,
12342
14386
  archived: args.archived,
@@ -12351,17 +14395,17 @@ function indexSessionDirectory(idx, args) {
12351
14395
  }
12352
14396
  function reindexFromArchive(projectPath, opts = {}) {
12353
14397
  const start = Date.now();
12354
- const archiveBase = path17.join(projectPath, ".harness", "archive", "sessions");
14398
+ const archiveBase = path22.join(projectPath, ".harness", "archive", "sessions");
12355
14399
  const idx = openSearchIndex(projectPath);
12356
14400
  try {
12357
14401
  idx.resetArchived();
12358
14402
  let sessionsIndexed = 0;
12359
14403
  let docsWritten = 0;
12360
- if (fs15.existsSync(archiveBase)) {
12361
- const entries = fs15.readdirSync(archiveBase, { withFileTypes: true });
14404
+ if (fs18.existsSync(archiveBase)) {
14405
+ const entries = fs18.readdirSync(archiveBase, { withFileTypes: true });
12362
14406
  for (const entry of entries) {
12363
14407
  if (!entry.isDirectory()) continue;
12364
- const sessionDir = path17.join(archiveBase, entry.name);
14408
+ const sessionDir = path22.join(archiveBase, entry.name);
12365
14409
  const result = indexSessionDirectory(idx, {
12366
14410
  sessionId: entry.name,
12367
14411
  sessionDir,
@@ -12381,12 +14425,12 @@ function reindexFromArchive(projectPath, opts = {}) {
12381
14425
  }
12382
14426
 
12383
14427
  // src/sessions/summarize.ts
12384
- import * as fs16 from "fs";
12385
- import * as path18 from "path";
14428
+ import * as fs19 from "fs";
14429
+ import * as path23 from "path";
12386
14430
  import {
12387
14431
  SessionSummarySchema
12388
14432
  } from "@harness-engineering/types";
12389
- import { Ok as Ok23, Err as Err20 } from "@harness-engineering/types";
14433
+ import { Ok as Ok24, Err as Err21 } from "@harness-engineering/types";
12390
14434
  var LLM_SUMMARY_FILE = "llm-summary.md";
12391
14435
  var SUMMARY_INPUT_FILES = [
12392
14436
  { filename: "summary.md", kind: "summary" },
@@ -12412,10 +14456,10 @@ var USER_PROMPT_PREAMBLE = `Below are the archived files for a single harness-en
12412
14456
  function readInputCorpus(archiveDir) {
12413
14457
  const parts = [];
12414
14458
  for (const { filename, kind } of SUMMARY_INPUT_FILES) {
12415
- const p = path18.join(archiveDir, filename);
12416
- if (!fs16.existsSync(p)) continue;
14459
+ const p = path23.join(archiveDir, filename);
14460
+ if (!fs19.existsSync(p)) continue;
12417
14461
  try {
12418
- const content = fs16.readFileSync(p, "utf8");
14462
+ const content = fs19.readFileSync(p, "utf8");
12419
14463
  if (content.trim().length === 0) continue;
12420
14464
  parts.push(`## FILE: ${kind}
12421
14465
 
@@ -12466,7 +14510,7 @@ function renderLlmSummaryMarkdown(summary, meta) {
12466
14510
  return lines.join("\n");
12467
14511
  }
12468
14512
  function writeStubMarkdown(archiveDir, reason) {
12469
- const filePath = path18.join(archiveDir, LLM_SUMMARY_FILE);
14513
+ const filePath = path23.join(archiveDir, LLM_SUMMARY_FILE);
12470
14514
  const body = `---
12471
14515
  generatedAt: ${(/* @__PURE__ */ new Date()).toISOString()}
12472
14516
  schemaVersion: 1
@@ -12477,17 +14521,17 @@ status: failed
12477
14521
 
12478
14522
  - reason: ${reason}
12479
14523
  `;
12480
- fs16.writeFileSync(filePath, body, "utf8");
14524
+ fs19.writeFileSync(filePath, body, "utf8");
12481
14525
  return filePath;
12482
14526
  }
12483
14527
  async function summarizeArchivedSession(ctx) {
12484
14528
  const writeStubOnError = ctx.writeStubOnError ?? true;
12485
- if (!fs16.existsSync(ctx.archiveDir)) {
12486
- return Err20(new Error(`archive directory not found: ${ctx.archiveDir}`));
14529
+ if (!fs19.existsSync(ctx.archiveDir)) {
14530
+ return Err21(new Error(`archive directory not found: ${ctx.archiveDir}`));
12487
14531
  }
12488
14532
  const corpus = readInputCorpus(ctx.archiveDir);
12489
14533
  if (corpus.trim().length === 0) {
12490
- return Err20(new Error(`no summary input files found in ${ctx.archiveDir}`));
14534
+ return Err21(new Error(`no summary input files found in ${ctx.archiveDir}`));
12491
14535
  }
12492
14536
  const inputBudgetTokens = ctx.config?.inputBudgetTokens ?? DEFAULT_INPUT_BUDGET_TOKENS;
12493
14537
  const truncated = truncateForBudget(corpus, inputBudgetTokens);
@@ -12520,7 +14564,7 @@ async function summarizeArchivedSession(ctx) {
12520
14564
  } catch {
12521
14565
  }
12522
14566
  }
12523
- return Err20(
14567
+ return Err21(
12524
14568
  new Error(`session summary failed: ${reason}` + (stubPath ? ` (stub: ${stubPath})` : ""))
12525
14569
  );
12526
14570
  }
@@ -12534,7 +14578,7 @@ async function summarizeArchivedSession(ctx) {
12534
14578
  } catch {
12535
14579
  }
12536
14580
  }
12537
- return Err20(new Error(reason));
14581
+ return Err21(new Error(reason));
12538
14582
  }
12539
14583
  const meta = {
12540
14584
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -12543,10 +14587,10 @@ async function summarizeArchivedSession(ctx) {
12543
14587
  outputTokens: response.tokenUsage.outputTokens,
12544
14588
  schemaVersion: 1
12545
14589
  };
12546
- const filePath = path18.join(ctx.archiveDir, LLM_SUMMARY_FILE);
14590
+ const filePath = path23.join(ctx.archiveDir, LLM_SUMMARY_FILE);
12547
14591
  const body = renderLlmSummaryMarkdown(parsed.data, meta);
12548
- fs16.writeFileSync(filePath, body, "utf8");
12549
- return Ok23({ summary: parsed.data, meta, filePath });
14592
+ fs19.writeFileSync(filePath, body, "utf8");
14593
+ return Ok24({ summary: parsed.data, meta, filePath });
12550
14594
  }
12551
14595
  function isSummaryEnabled(config) {
12552
14596
  if (!config) return false;
@@ -12622,8 +14666,11 @@ function buildArchiveHooks(opts) {
12622
14666
  }
12623
14667
  export {
12624
14668
  AnalysisArchive,
14669
+ BUILT_IN_TASKS,
12625
14670
  BackendRouter,
12626
14671
  ClaimManager,
14672
+ GateNotReadyError,
14673
+ GateRunError,
12627
14674
  InteractionQueue,
12628
14675
  LinearGraphQLStub,
12629
14676
  MAX_ATTEMPTS,
@@ -12632,6 +14679,7 @@ export {
12632
14679
  Orchestrator,
12633
14680
  OrchestratorBackendFactory,
12634
14681
  PRDetector,
14682
+ PromotionError,
12635
14683
  PromptRenderer,
12636
14684
  RETRY_DELAYS_MS,
12637
14685
  RoadmapTrackerAdapter,
@@ -12640,6 +14688,7 @@ export {
12640
14688
  SlackSink,
12641
14689
  SqliteSearchIndex,
12642
14690
  StreamRecorder,
14691
+ TaskOutputStore,
12643
14692
  TokenStore,
12644
14693
  WebhookQueue,
12645
14694
  WorkflowLoader,
@@ -12653,7 +14702,13 @@ export {
12653
14702
  computeRateLimitDelay,
12654
14703
  createBackend,
12655
14704
  createEmptyState,
14705
+ crossFieldRoutingIssues,
12656
14706
  detectScopeTier,
14707
+ discoverSkillCatalog,
14708
+ discoverSkillCatalogNames,
14709
+ emitProposalApproved,
14710
+ emitProposalCreated,
14711
+ emitProposalRejected,
12657
14712
  extractHighlights,
12658
14713
  extractTitlePrefix,
12659
14714
  getAvailableSlots,
@@ -12667,6 +14722,7 @@ export {
12667
14722
  migrateAgentConfig,
12668
14723
  normalizeFts5Query,
12669
14724
  openSearchIndex,
14725
+ promote,
12670
14726
  reconcile,
12671
14727
  reindexFromArchive,
12672
14728
  renderAnalysisComment,
@@ -12675,6 +14731,8 @@ export {
12675
14731
  resolveEscalationConfig,
12676
14732
  resolveOrchestratorId,
12677
14733
  routeIssue,
14734
+ routingWarnings,
14735
+ runGate,
12678
14736
  savePublishedIndex,
12679
14737
  searchIndexPath,
12680
14738
  selectCandidates,
@@ -12683,6 +14741,7 @@ export {
12683
14741
  syncMain,
12684
14742
  triageIssue,
12685
14743
  truncateForBudget,
14744
+ validateCustomTasks,
12686
14745
  validateWorkflowConfig,
12687
14746
  wireNotificationSinks,
12688
14747
  wrapAsEnvelope