@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.js CHANGED
@@ -31,8 +31,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AnalysisArchive: () => AnalysisArchive,
34
+ BUILT_IN_TASKS: () => BUILT_IN_TASKS,
34
35
  BackendRouter: () => BackendRouter,
35
36
  ClaimManager: () => ClaimManager,
37
+ GateNotReadyError: () => GateNotReadyError,
38
+ GateRunError: () => GateRunError,
36
39
  InteractionQueue: () => InteractionQueue,
37
40
  LinearGraphQLStub: () => LinearGraphQLStub,
38
41
  MAX_ATTEMPTS: () => MAX_ATTEMPTS,
@@ -41,6 +44,7 @@ __export(index_exports, {
41
44
  Orchestrator: () => Orchestrator,
42
45
  OrchestratorBackendFactory: () => OrchestratorBackendFactory,
43
46
  PRDetector: () => PRDetector,
47
+ PromotionError: () => PromotionError,
44
48
  PromptRenderer: () => PromptRenderer,
45
49
  RETRY_DELAYS_MS: () => RETRY_DELAYS_MS,
46
50
  RoadmapTrackerAdapter: () => RoadmapTrackerAdapter,
@@ -49,6 +53,7 @@ __export(index_exports, {
49
53
  SlackSink: () => SlackSink,
50
54
  SqliteSearchIndex: () => SqliteSearchIndex,
51
55
  StreamRecorder: () => StreamRecorder,
56
+ TaskOutputStore: () => TaskOutputStore,
52
57
  TokenStore: () => TokenStore,
53
58
  WebhookQueue: () => WebhookQueue,
54
59
  WorkflowLoader: () => WorkflowLoader,
@@ -62,7 +67,13 @@ __export(index_exports, {
62
67
  computeRateLimitDelay: () => computeRateLimitDelay,
63
68
  createBackend: () => createBackend,
64
69
  createEmptyState: () => createEmptyState,
70
+ crossFieldRoutingIssues: () => crossFieldRoutingIssues,
65
71
  detectScopeTier: () => detectScopeTier,
72
+ discoverSkillCatalog: () => discoverSkillCatalog,
73
+ discoverSkillCatalogNames: () => discoverSkillCatalogNames,
74
+ emitProposalApproved: () => emitProposalApproved,
75
+ emitProposalCreated: () => emitProposalCreated,
76
+ emitProposalRejected: () => emitProposalRejected,
66
77
  extractHighlights: () => extractHighlights,
67
78
  extractTitlePrefix: () => extractTitlePrefix,
68
79
  getAvailableSlots: () => getAvailableSlots,
@@ -76,6 +87,7 @@ __export(index_exports, {
76
87
  migrateAgentConfig: () => migrateAgentConfig,
77
88
  normalizeFts5Query: () => normalizeFts5Query,
78
89
  openSearchIndex: () => openSearchIndex,
90
+ promote: () => promote,
79
91
  reconcile: () => reconcile,
80
92
  reindexFromArchive: () => reindexFromArchive,
81
93
  renderAnalysisComment: () => renderAnalysisComment,
@@ -84,6 +96,8 @@ __export(index_exports, {
84
96
  resolveEscalationConfig: () => resolveEscalationConfig,
85
97
  resolveOrchestratorId: () => resolveOrchestratorId,
86
98
  routeIssue: () => routeIssue,
99
+ routingWarnings: () => routingWarnings,
100
+ runGate: () => runGate,
87
101
  savePublishedIndex: () => savePublishedIndex,
88
102
  searchIndexPath: () => searchIndexPath,
89
103
  selectCandidates: () => selectCandidates,
@@ -92,6 +106,7 @@ __export(index_exports, {
92
106
  syncMain: () => syncMain,
93
107
  triageIssue: () => triageIssue,
94
108
  truncateForBudget: () => truncateForBudget,
109
+ validateCustomTasks: () => validateCustomTasks,
95
110
  validateWorkflowConfig: () => validateWorkflowConfig,
96
111
  wireNotificationSinks: () => wireNotificationSinks,
97
112
  wrapAsEnvelope: () => wrapAsEnvelope
@@ -127,7 +142,7 @@ function sortCandidates(issues) {
127
142
  return comparePriority(a, b) ?? compareCreatedAt(a, b) ?? a.identifier.localeCompare(b.identifier);
128
143
  });
129
144
  }
130
- function isEligible(issue, state, activeStates, terminalStates) {
145
+ function isEligible(issue, state, activeStates, terminalStates, selfAssignee) {
131
146
  if (!issue.id || !issue.identifier || !issue.title || !issue.state) {
132
147
  return false;
133
148
  }
@@ -149,6 +164,9 @@ function isEligible(issue, state, activeStates, terminalStates) {
149
164
  if (state.completed.has(issue.id)) {
150
165
  return false;
151
166
  }
167
+ if (selfAssignee !== void 0 && issue.assignee != null && issue.assignee !== selfAssignee) {
168
+ return false;
169
+ }
152
170
  if (normalizedState === "todo" && issue.blockedBy.length > 0) {
153
171
  const hasNonTerminalBlocker = issue.blockedBy.some((blocker) => {
154
172
  if (blocker.state === null) return true;
@@ -160,9 +178,11 @@ function isEligible(issue, state, activeStates, terminalStates) {
160
178
  }
161
179
  return true;
162
180
  }
163
- function selectCandidates(issues, state, activeStates, terminalStates) {
181
+ function selectCandidates(issues, state, activeStates, terminalStates, selfAssignee) {
164
182
  const sorted = sortCandidates(issues);
165
- return sorted.filter((issue) => isEligible(issue, state, activeStates, terminalStates));
183
+ return sorted.filter(
184
+ (issue) => isEligible(issue, state, activeStates, terminalStates, selfAssignee)
185
+ );
166
186
  }
167
187
 
168
188
  // src/core/concurrency.ts
@@ -758,7 +778,8 @@ function handleTick(state, event, config) {
758
778
  candidates,
759
779
  next,
760
780
  config.tracker.activeStates,
761
- config.tracker.terminalStates
781
+ config.tracker.terminalStates,
782
+ event.selfAssignee
762
783
  );
763
784
  const escalationConfig = resolveEscalationConfig(config);
764
785
  for (const issue of eligible) {
@@ -1244,7 +1265,7 @@ var ClaimManager = class {
1244
1265
  const claimResult = await this.tracker.claimIssue(issueId, this.orchestratorId);
1245
1266
  if (!claimResult.ok) return claimResult;
1246
1267
  if (this.verifyDelayMs > 0) {
1247
- await new Promise((resolve6) => setTimeout(resolve6, this.verifyDelayMs));
1268
+ await new Promise((resolve8) => setTimeout(resolve8, this.verifyDelayMs));
1248
1269
  }
1249
1270
  const statesResult = await this.tracker.fetchIssueStatesByIds([issueId]);
1250
1271
  if (!statesResult.ok) return statesResult;
@@ -1896,8 +1917,9 @@ function formatFilesList(files) {
1896
1917
  }
1897
1918
 
1898
1919
  // src/workflow/loader.ts
1899
- var fs6 = __toESM(require("fs/promises"));
1900
- var import_yaml = require("yaml");
1920
+ var fs7 = __toESM(require("fs/promises"));
1921
+ var path7 = __toESM(require("path"));
1922
+ var import_yaml2 = require("yaml");
1901
1923
  var import_types3 = require("@harness-engineering/types");
1902
1924
 
1903
1925
  // src/workflow/config.ts
@@ -1949,16 +1971,29 @@ var BackendDefSchema = import_zod.z.discriminatedUnion("type", [
1949
1971
  probeIntervalMs: import_zod.z.number().int().min(1e3).optional()
1950
1972
  }).strict()
1951
1973
  ]);
1974
+ var RoutingValueSchema = import_zod.z.union([
1975
+ import_zod.z.string().min(1),
1976
+ import_zod.z.array(import_zod.z.string().min(1)).nonempty("fallback chain must contain at least one backend name").readonly()
1977
+ ]);
1952
1978
  var RoutingConfigSchema = import_zod.z.object({
1953
- default: import_zod.z.string().min(1),
1954
- "quick-fix": import_zod.z.string().optional(),
1955
- "guided-change": import_zod.z.string().optional(),
1956
- "full-exploration": import_zod.z.string().optional(),
1957
- diagnostic: import_zod.z.string().optional(),
1979
+ default: RoutingValueSchema,
1980
+ "quick-fix": RoutingValueSchema.optional(),
1981
+ "guided-change": RoutingValueSchema.optional(),
1982
+ "full-exploration": RoutingValueSchema.optional(),
1983
+ diagnostic: RoutingValueSchema.optional(),
1958
1984
  intelligence: import_zod.z.object({
1959
- sel: import_zod.z.string().optional(),
1960
- pesl: import_zod.z.string().optional()
1961
- }).strict().optional()
1985
+ sel: RoutingValueSchema.optional(),
1986
+ pesl: RoutingValueSchema.optional()
1987
+ }).strict().optional(),
1988
+ // --- Spec B Phase 2: isolation block widened to RoutingValueSchema ---
1989
+ isolation: import_zod.z.object({
1990
+ none: RoutingValueSchema.optional(),
1991
+ container: RoutingValueSchema.optional(),
1992
+ "remote-sandbox": RoutingValueSchema.optional()
1993
+ }).strict().optional(),
1994
+ // --- Spec B Phase 0: new optional maps (resolver wired in Phase 1) ---
1995
+ skills: import_zod.z.record(import_zod.z.string().min(1), RoutingValueSchema).optional(),
1996
+ modes: import_zod.z.record(import_zod.z.string().min(1), RoutingValueSchema).optional()
1962
1997
  }).strict();
1963
1998
 
1964
1999
  // src/workflow/config.ts
@@ -1967,13 +2002,17 @@ var BackendsMapSchema = import_zod2.z.record(import_zod2.z.string(), BackendDefS
1967
2002
  function crossFieldRoutingIssues(backends, routing) {
1968
2003
  const issues = [];
1969
2004
  const names = new Set(Object.keys(backends));
1970
- const checkRef = (path19, name) => {
1971
- if (name !== void 0 && !names.has(name)) {
2005
+ const checkRef = (path24, value) => {
2006
+ if (value === void 0) return;
2007
+ const entries = Array.isArray(value) ? value : [value];
2008
+ entries.forEach((name, idx) => {
2009
+ if (names.has(name)) return;
2010
+ const pathWithIdx = Array.isArray(value) ? [...path24, String(idx)] : path24;
1972
2011
  issues.push({
1973
- path: path19,
1974
- message: `routing.${path19.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
2012
+ path: pathWithIdx,
2013
+ message: `routing.${pathWithIdx.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1975
2014
  });
1976
- }
2015
+ });
1977
2016
  };
1978
2017
  checkRef(["default"], routing.default);
1979
2018
  checkRef(["quick-fix"], routing["quick-fix"]);
@@ -1982,9 +2021,44 @@ function crossFieldRoutingIssues(backends, routing) {
1982
2021
  checkRef(["diagnostic"], routing.diagnostic);
1983
2022
  checkRef(["intelligence", "sel"], routing.intelligence?.sel);
1984
2023
  checkRef(["intelligence", "pesl"], routing.intelligence?.pesl);
2024
+ checkRef(["isolation", "none"], routing.isolation?.none);
2025
+ checkRef(["isolation", "container"], routing.isolation?.container);
2026
+ checkRef(["isolation", "remote-sandbox"], routing.isolation?.["remote-sandbox"]);
2027
+ if (routing.skills) {
2028
+ for (const [skill, value] of Object.entries(routing.skills)) {
2029
+ checkRef(["skills", skill], value);
2030
+ }
2031
+ }
2032
+ if (routing.modes) {
2033
+ for (const [mode, value] of Object.entries(routing.modes)) {
2034
+ checkRef(["modes", mode], value);
2035
+ }
2036
+ }
1985
2037
  return issues;
1986
2038
  }
1987
- function validateWorkflowConfig(config) {
2039
+ function routingWarnings(routing, knownSkillNames) {
2040
+ const warnings = [];
2041
+ if (knownSkillNames.length > 0 && routing.skills) {
2042
+ const known = new Set(knownSkillNames);
2043
+ for (const name of Object.keys(routing.skills)) {
2044
+ if (known.has(name)) continue;
2045
+ warnings.push(
2046
+ `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.`
2047
+ );
2048
+ }
2049
+ }
2050
+ if (routing.modes) {
2051
+ const standardModes = new Set(import_types2.STANDARD_COGNITIVE_MODES);
2052
+ for (const mode of Object.keys(routing.modes)) {
2053
+ if (standardModes.has(mode)) continue;
2054
+ warnings.push(
2055
+ `routing.modes.${mode} is not in STANDARD_COGNITIVE_MODES (${[...import_types2.STANDARD_COGNITIVE_MODES].join(", ")}). Custom cognitive modes are allowed but uncommon; verify this is not a typo.`
2056
+ );
2057
+ }
2058
+ }
2059
+ return warnings;
2060
+ }
2061
+ function validateWorkflowConfig(config, options = {}) {
1988
2062
  if (!config || typeof config !== "object")
1989
2063
  return (0, import_types2.Err)(new Error("Config is missing or not an object"));
1990
2064
  const c = config;
@@ -2000,6 +2074,7 @@ function validateWorkflowConfig(config) {
2000
2074
  if (!hasLegacyBackend && !hasModernBackends) {
2001
2075
  return (0, import_types2.Err)(new Error("Config must define agent.backend or agent.backends."));
2002
2076
  }
2077
+ const warnings = [];
2003
2078
  if (hasModernBackends) {
2004
2079
  const backendsParsed = BackendsMapSchema.safeParse(agent.backends);
2005
2080
  if (!backendsParsed.success) {
@@ -2010,9 +2085,10 @@ function validateWorkflowConfig(config) {
2010
2085
  return (0, import_types2.Err)(new Error(`agent.routing: ${routingParsed.error.message}`));
2011
2086
  }
2012
2087
  if (routingParsed.data) {
2088
+ const routingData = routingParsed.data;
2013
2089
  const cross = crossFieldRoutingIssues(
2014
2090
  backendsParsed.data,
2015
- routingParsed.data
2091
+ routingData
2016
2092
  );
2017
2093
  if (cross.length > 0) {
2018
2094
  return (0, import_types2.Err)(
@@ -2021,9 +2097,10 @@ function validateWorkflowConfig(config) {
2021
2097
  )
2022
2098
  );
2023
2099
  }
2100
+ warnings.push(...routingWarnings(routingData, options.knownSkillNames ?? []));
2024
2101
  }
2025
2102
  }
2026
- return (0, import_types2.Ok)(config);
2103
+ return (0, import_types2.Ok)({ config, warnings });
2027
2104
  }
2028
2105
  function getDefaultConfig() {
2029
2106
  return {
@@ -2076,11 +2153,55 @@ function getDefaultConfig() {
2076
2153
  };
2077
2154
  }
2078
2155
 
2156
+ // src/workflow/skill-catalog.ts
2157
+ var fs6 = __toESM(require("fs"));
2158
+ var path6 = __toESM(require("path"));
2159
+ var import_yaml = require("yaml");
2160
+ function discoverSkillCatalog(projectRoot) {
2161
+ const skillsRoot = path6.join(projectRoot, "agents", "skills");
2162
+ if (!fs6.existsSync(skillsRoot)) return [];
2163
+ const byName = /* @__PURE__ */ new Map();
2164
+ let hosts;
2165
+ try {
2166
+ hosts = fs6.readdirSync(skillsRoot, { withFileTypes: true });
2167
+ } catch {
2168
+ return [];
2169
+ }
2170
+ for (const host of hosts) {
2171
+ if (!host.isDirectory()) continue;
2172
+ const hostDir = path6.join(skillsRoot, host.name);
2173
+ let skills;
2174
+ try {
2175
+ skills = fs6.readdirSync(hostDir, { withFileTypes: true });
2176
+ } catch {
2177
+ continue;
2178
+ }
2179
+ for (const skill of skills) {
2180
+ if (!skill.isDirectory()) continue;
2181
+ const skillYamlPath = path6.join(hostDir, skill.name, "skill.yaml");
2182
+ if (!fs6.existsSync(skillYamlPath)) continue;
2183
+ try {
2184
+ const content = fs6.readFileSync(skillYamlPath, "utf-8");
2185
+ const parsed = (0, import_yaml.parse)(content);
2186
+ if (parsed && typeof parsed.name === "string" && parsed.name.length > 0 && !byName.has(parsed.name)) {
2187
+ const entry = typeof parsed.cognitive_mode === "string" && parsed.cognitive_mode.length > 0 ? { name: parsed.name, cognitiveMode: parsed.cognitive_mode } : { name: parsed.name };
2188
+ byName.set(parsed.name, entry);
2189
+ }
2190
+ } catch {
2191
+ }
2192
+ }
2193
+ }
2194
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
2195
+ }
2196
+ function discoverSkillCatalogNames(projectRoot) {
2197
+ return discoverSkillCatalog(projectRoot).map((e) => e.name);
2198
+ }
2199
+
2079
2200
  // src/workflow/loader.ts
2080
2201
  var WorkflowLoader = class {
2081
2202
  async loadWorkflow(filePath) {
2082
2203
  try {
2083
- const content = await fs6.readFile(filePath, "utf-8");
2204
+ const content = await fs7.readFile(filePath, "utf-8");
2084
2205
  const parts = content.split("---");
2085
2206
  if (parts.length < 3) {
2086
2207
  return (0, import_types3.Err)(
@@ -2091,14 +2212,17 @@ var WorkflowLoader = class {
2091
2212
  }
2092
2213
  const yamlContent = parts[1].trim();
2093
2214
  const promptTemplate = parts.slice(2).join("---").trim();
2094
- const configData = (0, import_yaml.parse)(yamlContent);
2095
- const configResult = validateWorkflowConfig(configData);
2215
+ const configData = (0, import_yaml2.parse)(yamlContent);
2216
+ const projectRoot = path7.dirname(path7.resolve(filePath));
2217
+ const knownSkillNames = discoverSkillCatalogNames(projectRoot);
2218
+ const configResult = validateWorkflowConfig(configData, { knownSkillNames });
2096
2219
  if (!configResult.ok) {
2097
2220
  return (0, import_types3.Err)(configResult.error);
2098
2221
  }
2099
2222
  return (0, import_types3.Ok)({
2100
- config: configResult.value,
2101
- promptTemplate
2223
+ config: configResult.value.config,
2224
+ promptTemplate,
2225
+ warnings: configResult.value.warnings
2102
2226
  });
2103
2227
  } catch (error) {
2104
2228
  return (0, import_types3.Err)(error instanceof Error ? error : new Error(String(error)));
@@ -2107,7 +2231,7 @@ var WorkflowLoader = class {
2107
2231
  };
2108
2232
 
2109
2233
  // src/tracker/adapters/roadmap.ts
2110
- var fs7 = __toESM(require("fs/promises"));
2234
+ var fs8 = __toESM(require("fs/promises"));
2111
2235
  var import_node_crypto2 = require("crypto");
2112
2236
  var import_core = require("@harness-engineering/core");
2113
2237
  var import_types4 = require("@harness-engineering/types");
@@ -2138,7 +2262,7 @@ var RoadmapTrackerAdapter = class {
2138
2262
  async fetchIssuesByStates(stateNames) {
2139
2263
  try {
2140
2264
  if (!this.config.filePath) return (0, import_types4.Err)(new Error("Missing filePath"));
2141
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2265
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2142
2266
  const roadmapResult = (0, import_core.parseRoadmap)(content);
2143
2267
  if (!roadmapResult.ok) return roadmapResult;
2144
2268
  const issues = [];
@@ -2170,7 +2294,7 @@ var RoadmapTrackerAdapter = class {
2170
2294
  if (!terminal) {
2171
2295
  return (0, import_types4.Err)(new Error("Tracker config has no terminalStates; cannot mark complete"));
2172
2296
  }
2173
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2297
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2174
2298
  const roadmapResult = (0, import_core.parseRoadmap)(content);
2175
2299
  if (!roadmapResult.ok) return roadmapResult;
2176
2300
  const roadmap = roadmapResult.value;
@@ -2179,7 +2303,7 @@ var RoadmapTrackerAdapter = class {
2179
2303
  const normalizedTerminal = this.config.terminalStates.map((s) => s.toLowerCase());
2180
2304
  if (normalizedTerminal.includes(target.status.toLowerCase())) return (0, import_types4.Ok)(void 0);
2181
2305
  target.status = terminal;
2182
- await fs7.writeFile(this.config.filePath, (0, import_core.serializeRoadmap)(roadmap), "utf-8");
2306
+ await fs8.writeFile(this.config.filePath, (0, import_core.serializeRoadmap)(roadmap), "utf-8");
2183
2307
  return (0, import_types4.Ok)(void 0);
2184
2308
  } catch (error) {
2185
2309
  return (0, import_types4.Err)(error instanceof Error ? error : new Error(String(error)));
@@ -2193,19 +2317,22 @@ var RoadmapTrackerAdapter = class {
2193
2317
  async claimIssue(issueId, orchestratorId) {
2194
2318
  try {
2195
2319
  if (!this.config.filePath) return (0, import_types4.Err)(new Error("Missing filePath"));
2196
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2320
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2197
2321
  const roadmapResult = (0, import_core.parseRoadmap)(content);
2198
2322
  if (!roadmapResult.ok) return roadmapResult;
2199
2323
  const roadmap = roadmapResult.value;
2200
2324
  const target = this.findFeatureById(roadmap.milestones, issueId);
2201
2325
  if (!target) return (0, import_types4.Ok)(void 0);
2326
+ if (target.assignee != null && target.assignee !== orchestratorId) {
2327
+ return (0, import_types4.Ok)(void 0);
2328
+ }
2202
2329
  if (target.status === "in-progress" && target.assignee === orchestratorId) {
2203
2330
  return (0, import_types4.Ok)(void 0);
2204
2331
  }
2205
2332
  target.status = "in-progress";
2206
2333
  target.assignee = orchestratorId;
2207
2334
  target.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2208
- await fs7.writeFile(this.config.filePath, (0, import_core.serializeRoadmap)(roadmap), "utf-8");
2335
+ await fs8.writeFile(this.config.filePath, (0, import_core.serializeRoadmap)(roadmap), "utf-8");
2209
2336
  return (0, import_types4.Ok)(void 0);
2210
2337
  } catch (error) {
2211
2338
  return (0, import_types4.Err)(error instanceof Error ? error : new Error(String(error)));
@@ -2222,7 +2349,7 @@ var RoadmapTrackerAdapter = class {
2222
2349
  if (!activeState) {
2223
2350
  return (0, import_types4.Err)(new Error("Tracker config has no activeStates; cannot release"));
2224
2351
  }
2225
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2352
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2226
2353
  const roadmapResult = (0, import_core.parseRoadmap)(content);
2227
2354
  if (!roadmapResult.ok) return roadmapResult;
2228
2355
  const roadmap = roadmapResult.value;
@@ -2234,7 +2361,7 @@ var RoadmapTrackerAdapter = class {
2234
2361
  target.status = activeState;
2235
2362
  target.assignee = null;
2236
2363
  target.updatedAt = null;
2237
- await fs7.writeFile(this.config.filePath, (0, import_core.serializeRoadmap)(roadmap), "utf-8");
2364
+ await fs8.writeFile(this.config.filePath, (0, import_core.serializeRoadmap)(roadmap), "utf-8");
2238
2365
  return (0, import_types4.Ok)(void 0);
2239
2366
  } catch (error) {
2240
2367
  return (0, import_types4.Err)(error instanceof Error ? error : new Error(String(error)));
@@ -2256,7 +2383,7 @@ var RoadmapTrackerAdapter = class {
2256
2383
  async fetchIssueStatesByIds(issueIds) {
2257
2384
  try {
2258
2385
  if (!this.config.filePath) return (0, import_types4.Err)(new Error("Missing filePath"));
2259
- const content = await fs7.readFile(this.config.filePath, "utf-8");
2386
+ const content = await fs8.readFile(this.config.filePath, "utf-8");
2260
2387
  const roadmapResult = (0, import_core.parseRoadmap)(content);
2261
2388
  if (!roadmapResult.ok) return roadmapResult;
2262
2389
  const issueMap = /* @__PURE__ */ new Map();
@@ -2321,8 +2448,8 @@ var LinearGraphQLStub = class {
2321
2448
  };
2322
2449
 
2323
2450
  // src/workspace/manager.ts
2324
- var fs8 = __toESM(require("fs/promises"));
2325
- var path6 = __toESM(require("path"));
2451
+ var fs9 = __toESM(require("fs/promises"));
2452
+ var path8 = __toESM(require("path"));
2326
2453
  var import_node_child_process2 = require("child_process");
2327
2454
  var import_node_util2 = require("util");
2328
2455
  var import_types6 = require("@harness-engineering/types");
@@ -2353,15 +2480,15 @@ var WorkspaceManager = class {
2353
2480
  */
2354
2481
  resolvePath(identifier) {
2355
2482
  const sanitized = this.sanitizeIdentifier(identifier);
2356
- return path6.join(this.config.root, sanitized);
2483
+ return path8.join(this.config.root, sanitized);
2357
2484
  }
2358
2485
  /**
2359
2486
  * Discovers the git repository root from the workspace root directory.
2360
2487
  */
2361
2488
  async getRepoRoot() {
2362
2489
  if (this.repoRoot) return this.repoRoot;
2363
- const root = path6.resolve(this.config.root);
2364
- await fs8.mkdir(root, { recursive: true });
2490
+ const root = path8.resolve(this.config.root);
2491
+ await fs9.mkdir(root, { recursive: true });
2365
2492
  const stdout = await this.git(["rev-parse", "--show-toplevel"], root);
2366
2493
  this.repoRoot = stdout.trim();
2367
2494
  return this.repoRoot;
@@ -2372,23 +2499,23 @@ var WorkspaceManager = class {
2372
2499
  */
2373
2500
  async ensureWorkspace(identifier) {
2374
2501
  try {
2375
- const workspacePath = path6.resolve(this.resolvePath(identifier));
2502
+ const workspacePath = path8.resolve(this.resolvePath(identifier));
2376
2503
  try {
2377
- await fs8.access(path6.join(workspacePath, ".git"));
2504
+ await fs9.access(path8.join(workspacePath, ".git"));
2378
2505
  const repoRoot2 = await this.getRepoRoot();
2379
2506
  try {
2380
2507
  await this.git(["worktree", "remove", "--force", workspacePath], repoRoot2);
2381
2508
  } catch {
2382
- await fs8.rm(workspacePath, { recursive: true, force: true });
2509
+ await fs9.rm(workspacePath, { recursive: true, force: true });
2383
2510
  }
2384
2511
  } catch {
2385
2512
  try {
2386
- await fs8.access(workspacePath);
2513
+ await fs9.access(workspacePath);
2387
2514
  const repoRoot2 = await this.getRepoRoot();
2388
2515
  try {
2389
2516
  await this.git(["worktree", "remove", "--force", workspacePath], repoRoot2);
2390
2517
  } catch {
2391
- await fs8.rm(workspacePath, { recursive: true, force: true });
2518
+ await fs9.rm(workspacePath, { recursive: true, force: true });
2392
2519
  }
2393
2520
  } catch {
2394
2521
  }
@@ -2484,7 +2611,7 @@ var WorkspaceManager = class {
2484
2611
  async exists(identifier) {
2485
2612
  try {
2486
2613
  const workspacePath = this.resolvePath(identifier);
2487
- await fs8.access(workspacePath);
2614
+ await fs9.access(workspacePath);
2488
2615
  return true;
2489
2616
  } catch {
2490
2617
  return false;
@@ -2497,9 +2624,9 @@ var WorkspaceManager = class {
2497
2624
  */
2498
2625
  async findPushedBranch(identifier) {
2499
2626
  try {
2500
- const workspacePath = path6.resolve(this.resolvePath(identifier));
2627
+ const workspacePath = path8.resolve(this.resolvePath(identifier));
2501
2628
  try {
2502
- await fs8.access(path6.join(workspacePath, ".git"));
2629
+ await fs9.access(path8.join(workspacePath, ".git"));
2503
2630
  } catch {
2504
2631
  return null;
2505
2632
  }
@@ -2605,12 +2732,12 @@ var WorkspaceManager = class {
2605
2732
  */
2606
2733
  async removeWorkspace(identifier) {
2607
2734
  try {
2608
- const workspacePath = path6.resolve(this.resolvePath(identifier));
2735
+ const workspacePath = path8.resolve(this.resolvePath(identifier));
2609
2736
  try {
2610
2737
  const repoRoot = await this.getRepoRoot();
2611
2738
  await this.git(["worktree", "remove", "--force", workspacePath], repoRoot);
2612
2739
  } catch {
2613
- await fs8.rm(workspacePath, { recursive: true, force: true });
2740
+ await fs9.rm(workspacePath, { recursive: true, force: true });
2614
2741
  }
2615
2742
  return (0, import_types6.Ok)(void 0);
2616
2743
  } catch (error) {
@@ -2635,7 +2762,7 @@ var WorkspaceHooks = class {
2635
2762
  if (!command) {
2636
2763
  return (0, import_types7.Ok)(void 0);
2637
2764
  }
2638
- return new Promise((resolve6) => {
2765
+ return new Promise((resolve8) => {
2639
2766
  const filteredEnv = {};
2640
2767
  for (const [k, v] of Object.entries(process.env)) {
2641
2768
  if (v != null && !k.includes("SECRET") && !k.includes("TOKEN") && !k.includes("PASSWORD")) {
@@ -2648,19 +2775,19 @@ var WorkspaceHooks = class {
2648
2775
  });
2649
2776
  const timeout = setTimeout(() => {
2650
2777
  child.kill();
2651
- resolve6((0, import_types7.Err)(new Error(`Hook ${hookName} timed out after ${this.config.timeoutMs}ms`)));
2778
+ resolve8((0, import_types7.Err)(new Error(`Hook ${hookName} timed out after ${this.config.timeoutMs}ms`)));
2652
2779
  }, this.config.timeoutMs);
2653
2780
  child.on("exit", (code) => {
2654
2781
  clearTimeout(timeout);
2655
2782
  if (code === 0) {
2656
- resolve6((0, import_types7.Ok)(void 0));
2783
+ resolve8((0, import_types7.Ok)(void 0));
2657
2784
  } else {
2658
- resolve6((0, import_types7.Err)(new Error(`Hook ${hookName} failed with exit code ${code}`)));
2785
+ resolve8((0, import_types7.Err)(new Error(`Hook ${hookName} failed with exit code ${code}`)));
2659
2786
  }
2660
2787
  });
2661
2788
  child.on("error", (error) => {
2662
2789
  clearTimeout(timeout);
2663
- resolve6((0, import_types7.Err)(error));
2790
+ resolve8((0, import_types7.Err)(error));
2664
2791
  });
2665
2792
  });
2666
2793
  }
@@ -2698,7 +2825,7 @@ var MockBackend = class {
2698
2825
  content: "Thinking...",
2699
2826
  sessionId: session.sessionId
2700
2827
  };
2701
- await new Promise((resolve6) => setTimeout(resolve6, 100));
2828
+ await new Promise((resolve8) => setTimeout(resolve8, 100));
2702
2829
  yield {
2703
2830
  type: "thought",
2704
2831
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2750,12 +2877,12 @@ var PromptRenderer = class {
2750
2877
 
2751
2878
  // src/orchestrator.ts
2752
2879
  var import_node_events = require("events");
2753
- var path16 = __toESM(require("path"));
2880
+ var path21 = __toESM(require("path"));
2754
2881
  var import_node_crypto16 = require("crypto");
2755
- var import_core11 = require("@harness-engineering/core");
2882
+ var import_core14 = require("@harness-engineering/core");
2756
2883
 
2757
2884
  // src/intelligence/pipeline-runner.ts
2758
- var path7 = __toESM(require("path"));
2885
+ var path9 = __toESM(require("path"));
2759
2886
  var import_intelligence = require("@harness-engineering/intelligence");
2760
2887
  var import_core2 = require("@harness-engineering/core");
2761
2888
  var CONNECTION_ERROR_PATTERNS = [
@@ -2874,7 +3001,7 @@ var IntelligencePipelineRunner = class {
2874
3001
  }
2875
3002
  async loadGraphStore() {
2876
3003
  try {
2877
- const graphDir = path7.join(this.ctx.config.workspace.root, "..", "graph");
3004
+ const graphDir = path9.join(this.ctx.config.workspace.root, "..", "graph");
2878
3005
  const loaded = await this.ctx.graphStore.load(graphDir);
2879
3006
  if (loaded) {
2880
3007
  this.ctx.logger.info("Graph store loaded from disk");
@@ -3152,7 +3279,7 @@ var IntelligencePipelineRunner = class {
3152
3279
  };
3153
3280
 
3154
3281
  // src/completion/handler.ts
3155
- var path8 = __toESM(require("path"));
3282
+ var path10 = __toESM(require("path"));
3156
3283
  var import_core3 = require("@harness-engineering/core");
3157
3284
  var CompletionHandler = class {
3158
3285
  ctx;
@@ -3235,7 +3362,7 @@ var CompletionHandler = class {
3235
3362
  result: outcome.result
3236
3363
  });
3237
3364
  if (this.ctx.graphStore) {
3238
- const graphDir = path8.join(this.ctx.config.workspace.root, "..", "graph");
3365
+ const graphDir = path10.join(this.ctx.config.workspace.root, "..", "graph");
3239
3366
  await this.ctx.graphStore.save(graphDir);
3240
3367
  }
3241
3368
  } catch (err) {
@@ -3313,7 +3440,7 @@ var CompletionHandler = class {
3313
3440
  };
3314
3441
 
3315
3442
  // src/orchestrator.ts
3316
- var import_core12 = require("@harness-engineering/core");
3443
+ var import_core15 = require("@harness-engineering/core");
3317
3444
 
3318
3445
  // src/tracker/adapters/github-issues-issue-tracker.ts
3319
3446
  var import_types9 = require("@harness-engineering/types");
@@ -3717,11 +3844,11 @@ function detectLegacyFields(agent) {
3717
3844
  }
3718
3845
  function buildCase1Warnings(presentLegacy, suppressLocalGroup) {
3719
3846
  const warnings = [];
3720
- for (const path19 of presentLegacy) {
3721
- if (CASE1_ALWAYS_SUPPRESS.has(path19)) continue;
3722
- if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path19)) continue;
3847
+ for (const path24 of presentLegacy) {
3848
+ if (CASE1_ALWAYS_SUPPRESS.has(path24)) continue;
3849
+ if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path24)) continue;
3723
3850
  warnings.push(
3724
- `Ignoring legacy field '${path19}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3851
+ `Ignoring legacy field '${path24}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3725
3852
  );
3726
3853
  }
3727
3854
  return warnings;
@@ -3749,7 +3876,7 @@ function migrateAgentConfig(agent) {
3749
3876
  }
3750
3877
  const { backends, routing } = synthesizeBackendsAndRouting(agent);
3751
3878
  const warnings = presentLegacy.map(
3752
- (path19) => `Deprecated config field '${path19}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3879
+ (path24) => `Deprecated config field '${path24}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3753
3880
  );
3754
3881
  return {
3755
3882
  config: { ...agent, backends, routing },
@@ -3812,61 +3939,160 @@ function synthesizeLocal(agent) {
3812
3939
  }
3813
3940
 
3814
3941
  // src/agent/backend-router.ts
3942
+ function toArray(value) {
3943
+ return Array.isArray(value) ? value : [value];
3944
+ }
3815
3945
  var BackendRouter = class {
3816
3946
  backends;
3817
3947
  routing;
3948
+ decisionBus;
3818
3949
  constructor(opts) {
3819
3950
  this.backends = opts.backends;
3820
3951
  this.routing = opts.routing;
3952
+ this.decisionBus = opts.decisionBus;
3821
3953
  this.validateReferences();
3822
3954
  }
3823
3955
  /**
3824
- * Returns the backend name for a given use case.
3956
+ * Resolve a {@link RoutingUseCase} to a {@link RoutingDecision}.
3957
+ *
3958
+ * @param useCase the routing query
3959
+ * @param opts.invocationOverride if set and the named backend exists,
3960
+ * beats all other sources (D7 — the `--backend <name>` escape hatch)
3961
+ */
3962
+ resolve(useCase, opts) {
3963
+ const startedAt = performance.now();
3964
+ const path24 = [];
3965
+ const tryChain = (source, value) => {
3966
+ if (value === void 0) return void 0;
3967
+ for (const name of toArray(value)) {
3968
+ const step = { source, candidate: name, outcome: "considered" };
3969
+ path24.push(step);
3970
+ if (this.backends[name]) {
3971
+ step.outcome = "chosen";
3972
+ return name;
3973
+ }
3974
+ step.outcome = "unknown-backend";
3975
+ }
3976
+ return void 0;
3977
+ };
3978
+ const decide = (backendName) => {
3979
+ const def = this.backends[backendName];
3980
+ if (!def) {
3981
+ throw new Error(
3982
+ `BackendRouter.resolve: internal invariant violated \u2014 backend '${backendName}' missing.`
3983
+ );
3984
+ }
3985
+ return {
3986
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3987
+ useCase,
3988
+ resolutionPath: path24,
3989
+ backendName,
3990
+ backendType: def.type,
3991
+ durationMs: performance.now() - startedAt
3992
+ };
3993
+ };
3994
+ const emitAndReturn = (decision) => {
3995
+ this.decisionBus?.emit(decision);
3996
+ return decision;
3997
+ };
3998
+ const fromInvocation = tryChain(
3999
+ "invocation",
4000
+ opts?.invocationOverride !== void 0 ? opts.invocationOverride : void 0
4001
+ );
4002
+ if (fromInvocation) return emitAndReturn(decide(fromInvocation));
4003
+ if (useCase.kind === "skill") {
4004
+ const fromSkill = tryChain("skill", this.routing.skills?.[useCase.skillName]);
4005
+ if (fromSkill) return emitAndReturn(decide(fromSkill));
4006
+ }
4007
+ const mode = useCase.kind === "skill" ? useCase.cognitiveMode : useCase.kind === "mode" ? useCase.cognitiveMode : void 0;
4008
+ if (mode !== void 0) {
4009
+ const fromMode = tryChain("mode", this.routing.modes?.[mode]);
4010
+ if (fromMode) return emitAndReturn(decide(fromMode));
4011
+ }
4012
+ const fromExisting = this.resolveExistingUseCase(useCase);
4013
+ if (fromExisting !== void 0) {
4014
+ const chained = tryChain("tier", fromExisting);
4015
+ if (chained) return emitAndReturn(decide(chained));
4016
+ }
4017
+ const fromDefault = tryChain("default", this.routing.default);
4018
+ if (fromDefault) return emitAndReturn(decide(fromDefault));
4019
+ const knownList = Object.keys(this.backends).join(", ") || "(none)";
4020
+ throw new Error(
4021
+ `BackendRouter.resolve: routing.default produced no available backend for useCase=${JSON.stringify(useCase)}. Resolution path: ${JSON.stringify(path24)}. Known backends: [${knownList}].`
4022
+ );
4023
+ }
4024
+ /**
4025
+ * Returns the {@link BackendDef} reference for the resolved name.
4026
+ * Identity-equal to the entry in `backends` (no copy) so callers
4027
+ * relying on reference equality (SC21) continue to work.
4028
+ */
4029
+ resolveDefinition(useCase, opts) {
4030
+ const decision = this.resolve(useCase, opts);
4031
+ const def = this.backends[decision.backendName];
4032
+ if (!def) {
4033
+ throw new Error(
4034
+ `BackendRouter.resolveDefinition: routing target '${decision.backendName}' is not in backends (useCase=${JSON.stringify(useCase)}).`
4035
+ );
4036
+ }
4037
+ return def;
4038
+ }
4039
+ /**
4040
+ * Spec B Phase 4 (closes P1-IMP-2): a single resolve() + def lookup
4041
+ * for callers that need both. Replaces the previous pattern of
4042
+ * `resolveDefinition(useCase) + resolve(useCase)` which produced two
4043
+ * RoutingDecision emissions per dispatch — doubling routing-decision
4044
+ * log volume now that Phase 4 emits.
3825
4045
  *
3826
- * - `tier`: per-tier override, falling back to `routing.default`.
3827
- * - `intelligence`: per-layer override under `routing.intelligence`,
3828
- * falling back to `routing.default`.
3829
- * - `maintenance` / `chat`: always `routing.default`.
4046
+ * Identity-equal `BackendDef` (no copy) so callers relying on
4047
+ * reference equality (SC21) continue to work.
4048
+ */
4049
+ resolveDecisionAndDef(useCase, opts) {
4050
+ const decision = this.resolve(useCase, opts);
4051
+ const def = this.backends[decision.backendName];
4052
+ if (!def) {
4053
+ throw new Error(
4054
+ `BackendRouter.resolveDecisionAndDef: routing target '${decision.backendName}' is not in backends (useCase=${JSON.stringify(useCase)}).`
4055
+ );
4056
+ }
4057
+ return { decision, def };
4058
+ }
4059
+ /**
4060
+ * The pre-Spec-B resolution helper: returns the configured
4061
+ * {@link RoutingValue} for tier/intelligence/isolation/maintenance/chat
4062
+ * use cases (or `undefined` for skill/mode use cases, which are owned
4063
+ * by the per-skill / per-mode steps in {@link resolve}). Returning
4064
+ * `undefined` lets the caller fall through to `routing.default`.
3830
4065
  */
3831
- resolve(useCase) {
4066
+ resolveExistingUseCase(useCase) {
3832
4067
  switch (useCase.kind) {
3833
4068
  case "tier": {
3834
- const named = this.routing[useCase.tier];
3835
- return named ?? this.routing.default;
4069
+ const tierMap = this.routing;
4070
+ return tierMap[useCase.tier];
3836
4071
  }
3837
4072
  case "intelligence": {
3838
4073
  const intel = this.routing.intelligence;
3839
- return intel?.[useCase.layer] ?? this.routing.default;
4074
+ return intel?.[useCase.layer];
3840
4075
  }
3841
4076
  case "isolation": {
3842
4077
  const iso = this.routing.isolation;
3843
- return iso?.[useCase.tier] ?? this.routing.default;
4078
+ return iso?.[useCase.tier];
3844
4079
  }
3845
4080
  case "maintenance":
3846
4081
  case "chat":
3847
- return this.routing.default;
3848
- }
3849
- }
3850
- /**
3851
- * Returns the BackendDef reference for the resolved name. Returns the
3852
- * exact reference held in `backends` (no copy) so identity comparisons
3853
- * succeed (SC21).
3854
- */
3855
- resolveDefinition(useCase) {
3856
- const name = this.resolve(useCase);
3857
- const def = this.backends[name];
3858
- if (!def) {
3859
- throw new Error(
3860
- `BackendRouter.resolveDefinition: routing target '${name}' is not in backends (useCase=${JSON.stringify(useCase)}).`
3861
- );
4082
+ return void 0;
4083
+ case "skill":
4084
+ case "mode":
4085
+ return void 0;
3862
4086
  }
3863
- return def;
3864
4087
  }
3865
4088
  validateReferences() {
3866
4089
  const known = new Set(Object.keys(this.backends));
3867
4090
  const missing = [];
3868
- const check = (path19, name) => {
3869
- if (name !== void 0 && !known.has(name)) missing.push({ path: path19, name });
4091
+ const check = (label, value) => {
4092
+ if (value === void 0) return;
4093
+ for (const name of toArray(value)) {
4094
+ if (!known.has(name)) missing.push({ path: label, name });
4095
+ }
3870
4096
  };
3871
4097
  check("default", this.routing.default);
3872
4098
  check("quick-fix", this.routing["quick-fix"]);
@@ -3878,8 +4104,14 @@ var BackendRouter = class {
3878
4104
  check("isolation.none", this.routing.isolation?.none);
3879
4105
  check("isolation.container", this.routing.isolation?.container);
3880
4106
  check("isolation.remote-sandbox", this.routing.isolation?.["remote-sandbox"]);
4107
+ for (const [skill, value] of Object.entries(this.routing.skills ?? {})) {
4108
+ check(`skills.${skill}`, value);
4109
+ }
4110
+ for (const [mode, value] of Object.entries(this.routing.modes ?? {})) {
4111
+ check(`modes.${mode}`, value);
4112
+ }
3881
4113
  if (missing.length > 0) {
3882
- const detail = missing.map(({ path: path19, name }) => `routing.${path19} -> '${name}'`).join("; ");
4114
+ const detail = missing.map(({ path: path24, name }) => `routing.${path24} -> '${name}'`).join("; ");
3883
4115
  const known_ = [...known].join(", ") || "(none)";
3884
4116
  throw new Error(
3885
4117
  `BackendRouter: routing references unknown backend(s): ${detail}. Defined backends: [${known_}].`
@@ -3893,11 +4125,11 @@ var import_node_child_process4 = require("child_process");
3893
4125
  var readline = __toESM(require("readline"));
3894
4126
  var import_node_crypto3 = require("crypto");
3895
4127
  var import_types10 = require("@harness-engineering/types");
3896
- function resolveExitCode(code, command, resolve6) {
4128
+ function resolveExitCode(code, command, resolve8) {
3897
4129
  if (code === 0) {
3898
- resolve6((0, import_types10.Ok)(void 0));
4130
+ resolve8((0, import_types10.Ok)(void 0));
3899
4131
  } else {
3900
- resolve6(
4132
+ resolve8(
3901
4133
  (0, import_types10.Err)({
3902
4134
  category: "agent_not_found",
3903
4135
  message: `Claude command '${command}' not found or failed`
@@ -3905,8 +4137,8 @@ function resolveExitCode(code, command, resolve6) {
3905
4137
  );
3906
4138
  }
3907
4139
  }
3908
- function resolveSpawnError(command, resolve6) {
3909
- resolve6((0, import_types10.Err)({ category: "agent_not_found", message: `Claude command '${command}' not found` }));
4140
+ function resolveSpawnError(command, resolve8) {
4141
+ resolve8((0, import_types10.Err)({ category: "agent_not_found", message: `Claude command '${command}' not found` }));
3910
4142
  }
3911
4143
  var JUST_PAST_GRACE_MS = 5 * 6e4;
3912
4144
  var PRIMARY_LIMIT_RE = /You[\u2019']ve hit your limit.*resets\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*\(([^)]+)\)/i;
@@ -4219,10 +4451,10 @@ var ClaudeBackend = class {
4219
4451
  errRl.close();
4220
4452
  }
4221
4453
  if (exitCode === null) {
4222
- await new Promise((resolve6) => {
4454
+ await new Promise((resolve8) => {
4223
4455
  child.on("exit", (code) => {
4224
4456
  exitCode = code;
4225
- resolve6(null);
4457
+ resolve8(null);
4226
4458
  });
4227
4459
  });
4228
4460
  }
@@ -4244,10 +4476,10 @@ var ClaudeBackend = class {
4244
4476
  return (0, import_types10.Ok)(void 0);
4245
4477
  }
4246
4478
  async healthCheck() {
4247
- return new Promise((resolve6) => {
4479
+ return new Promise((resolve8) => {
4248
4480
  const child = (0, import_node_child_process4.spawn)(this.command, ["--version"]);
4249
- child.on("exit", (code) => resolveExitCode(code, this.command, resolve6));
4250
- child.on("error", () => resolveSpawnError(this.command, resolve6));
4481
+ child.on("exit", (code) => resolveExitCode(code, this.command, resolve8));
4482
+ child.on("error", () => resolveSpawnError(this.command, resolve8));
4251
4483
  });
4252
4484
  }
4253
4485
  };
@@ -4855,7 +5087,7 @@ var PiBackend = class {
4855
5087
  } else {
4856
5088
  resolvedModelName = this.config.model;
4857
5089
  }
4858
- const piSdk = await import("@mariozechner/pi-coding-agent");
5090
+ const piSdk = await import("@earendil-works/pi-coding-agent");
4859
5091
  const model = buildLocalModel({
4860
5092
  model: resolvedModelName,
4861
5093
  endpoint: this.config.endpoint,
@@ -5010,7 +5242,7 @@ var PiBackend = class {
5010
5242
  }
5011
5243
  async healthCheck() {
5012
5244
  try {
5013
- await import("@mariozechner/pi-coding-agent");
5245
+ await import("@earendil-works/pi-coding-agent");
5014
5246
  return (0, import_types15.Ok)(void 0);
5015
5247
  } catch (err) {
5016
5248
  return (0, import_types15.Err)({
@@ -5161,14 +5393,14 @@ var SshBackend = class {
5161
5393
  async healthCheck() {
5162
5394
  const args = [...this.buildSshArgs()];
5163
5395
  args[args.length - 1] = "true";
5164
- return new Promise((resolve6) => {
5396
+ return new Promise((resolve8) => {
5165
5397
  let child;
5166
5398
  try {
5167
5399
  child = this.spawnImpl(this.config.sshBinary, args, {
5168
5400
  stdio: ["ignore", "ignore", "pipe"]
5169
5401
  });
5170
5402
  } catch (err) {
5171
- resolve6(
5403
+ resolve8(
5172
5404
  (0, import_types16.Err)({
5173
5405
  category: "agent_not_found",
5174
5406
  message: err instanceof Error ? err.message : "failed to spawn ssh"
@@ -5189,9 +5421,9 @@ var SshBackend = class {
5189
5421
  child.on("close", (code) => {
5190
5422
  clearTimeout(timer);
5191
5423
  if (code === 0) {
5192
- resolve6((0, import_types16.Ok)(void 0));
5424
+ resolve8((0, import_types16.Ok)(void 0));
5193
5425
  } else {
5194
- resolve6(
5426
+ resolve8(
5195
5427
  (0, import_types16.Err)({
5196
5428
  category: "agent_not_found",
5197
5429
  message: `ssh health check failed (exit=${code ?? "null"}): ${stderr.slice(0, 500)}`
@@ -5201,7 +5433,7 @@ var SshBackend = class {
5201
5433
  });
5202
5434
  child.on("error", (err) => {
5203
5435
  clearTimeout(timer);
5204
- resolve6((0, import_types16.Err)({ category: "agent_not_found", message: err.message }));
5436
+ resolve8((0, import_types16.Err)({ category: "agent_not_found", message: err.message }));
5205
5437
  });
5206
5438
  });
5207
5439
  }
@@ -5249,13 +5481,13 @@ async function* readLines(stream) {
5249
5481
  if (buffer.length > 0) yield buffer;
5250
5482
  }
5251
5483
  function waitForExit(child) {
5252
- return new Promise((resolve6) => {
5484
+ return new Promise((resolve8) => {
5253
5485
  if (child.exitCode !== null) {
5254
- resolve6(child.exitCode);
5486
+ resolve8(child.exitCode);
5255
5487
  return;
5256
5488
  }
5257
- child.once("close", (code) => resolve6(code));
5258
- child.once("error", () => resolve6(null));
5489
+ child.once("close", (code) => resolve8(code));
5490
+ child.once("error", () => resolve8(null));
5259
5491
  });
5260
5492
  }
5261
5493
 
@@ -5442,14 +5674,14 @@ var OciServerlessBackend = class extends ServerlessBackend {
5442
5674
  return out;
5443
5675
  }
5444
5676
  runOneShot(binary, args) {
5445
- return new Promise((resolve6) => {
5677
+ return new Promise((resolve8) => {
5446
5678
  let child;
5447
5679
  try {
5448
5680
  child = this.spawnImpl(binary, args, {
5449
5681
  stdio: ["ignore", "pipe", "pipe"]
5450
5682
  });
5451
5683
  } catch (err) {
5452
- resolve6(
5684
+ resolve8(
5453
5685
  (0, import_types17.Err)({
5454
5686
  category: "agent_not_found",
5455
5687
  message: err instanceof Error ? err.message : "failed to spawn runtime"
@@ -5474,9 +5706,9 @@ var OciServerlessBackend = class extends ServerlessBackend {
5474
5706
  child.on("close", (code) => {
5475
5707
  clearTimeout(timer);
5476
5708
  if (code === 0) {
5477
- resolve6((0, import_types17.Ok)(stdout));
5709
+ resolve8((0, import_types17.Ok)(stdout));
5478
5710
  } else {
5479
- resolve6(
5711
+ resolve8(
5480
5712
  (0, import_types17.Err)({
5481
5713
  category: "response_error",
5482
5714
  message: `runtime '${binary} ${args.join(" ")}' exited ${code ?? "null"}: ${stderr.slice(0, 500)}`
@@ -5486,7 +5718,7 @@ var OciServerlessBackend = class extends ServerlessBackend {
5486
5718
  });
5487
5719
  child.on("error", (err) => {
5488
5720
  clearTimeout(timer);
5489
- resolve6((0, import_types17.Err)({ category: "agent_not_found", message: err.message }));
5721
+ resolve8((0, import_types17.Err)({ category: "agent_not_found", message: err.message }));
5490
5722
  });
5491
5723
  });
5492
5724
  }
@@ -5546,13 +5778,13 @@ async function* readLines2(stream) {
5546
5778
  if (buffer.length > 0) yield buffer;
5547
5779
  }
5548
5780
  function waitForExit2(child) {
5549
- return new Promise((resolve6) => {
5781
+ return new Promise((resolve8) => {
5550
5782
  if (child.exitCode !== null) {
5551
- resolve6(child.exitCode);
5783
+ resolve8(child.exitCode);
5552
5784
  return;
5553
5785
  }
5554
- child.once("close", (code) => resolve6(code));
5555
- child.once("error", () => resolve6(null));
5786
+ child.once("close", (code) => resolve8(code));
5787
+ child.once("error", () => resolve8(null));
5556
5788
  });
5557
5789
  }
5558
5790
 
@@ -5750,13 +5982,13 @@ var ContainerBackend = class {
5750
5982
  var import_node_child_process7 = require("child_process");
5751
5983
  var import_types19 = require("@harness-engineering/types");
5752
5984
  function dockerExec(args) {
5753
- return new Promise((resolve6, reject) => {
5985
+ return new Promise((resolve8, reject) => {
5754
5986
  (0, import_node_child_process7.execFile)("docker", args, (error, stdout) => {
5755
5987
  if (error) {
5756
5988
  reject(error);
5757
5989
  return;
5758
5990
  }
5759
- resolve6(stdout.trim());
5991
+ resolve8(stdout.trim());
5760
5992
  });
5761
5993
  });
5762
5994
  }
@@ -5815,11 +6047,11 @@ var DockerRuntime = class {
5815
6047
  } finally {
5816
6048
  rl.close();
5817
6049
  }
5818
- const exitCode = await new Promise((resolve6) => {
6050
+ const exitCode = await new Promise((resolve8) => {
5819
6051
  if (child.exitCode !== null) {
5820
- resolve6(child.exitCode);
6052
+ resolve8(child.exitCode);
5821
6053
  } else {
5822
- child.on("exit", (code) => resolve6(code ?? 1));
6054
+ child.on("exit", (code) => resolve8(code ?? 1));
5823
6055
  }
5824
6056
  });
5825
6057
  return exitCode;
@@ -5878,13 +6110,13 @@ var EnvSecretBackend = class {
5878
6110
  var import_node_child_process8 = require("child_process");
5879
6111
  var import_types21 = require("@harness-engineering/types");
5880
6112
  function opExec(args) {
5881
- return new Promise((resolve6, reject) => {
6113
+ return new Promise((resolve8, reject) => {
5882
6114
  (0, import_node_child_process8.execFile)("op", args, (error, stdout) => {
5883
6115
  if (error) {
5884
6116
  reject(error);
5885
6117
  return;
5886
6118
  }
5887
- resolve6(stdout.trim());
6119
+ resolve8(stdout.trim());
5888
6120
  });
5889
6121
  });
5890
6122
  }
@@ -5927,13 +6159,13 @@ var OnePasswordSecretBackend = class {
5927
6159
  var import_node_child_process9 = require("child_process");
5928
6160
  var import_types22 = require("@harness-engineering/types");
5929
6161
  function vaultExec(args, env) {
5930
- return new Promise((resolve6, reject) => {
6162
+ return new Promise((resolve8, reject) => {
5931
6163
  (0, import_node_child_process9.execFile)("vault", args, { env: { ...process.env, ...env } }, (error, stdout) => {
5932
6164
  if (error) {
5933
6165
  reject(error);
5934
6166
  return;
5935
6167
  }
5936
- resolve6(stdout.trim());
6168
+ resolve8(stdout.trim());
5937
6169
  });
5938
6170
  });
5939
6171
  }
@@ -6008,7 +6240,11 @@ var OrchestratorBackendFactory = class {
6008
6240
  opts;
6009
6241
  constructor(opts) {
6010
6242
  this.opts = opts;
6011
- this.router = new BackendRouter({ backends: opts.backends, routing: opts.routing });
6243
+ this.router = new BackendRouter({
6244
+ backends: opts.backends,
6245
+ routing: opts.routing,
6246
+ ...opts.decisionBus !== void 0 ? { decisionBus: opts.decisionBus } : {}
6247
+ });
6012
6248
  }
6013
6249
  /**
6014
6250
  * Resolve `useCase` to a backend name, materialize a fresh
@@ -6027,12 +6263,21 @@ var OrchestratorBackendFactory = class {
6027
6263
  * is `undefined` for pure-modern configs. Threading the routed name
6028
6264
  * through dispatch eliminates that gap.
6029
6265
  */
6030
- resolveName(useCase) {
6031
- return this.router.resolve(useCase);
6266
+ resolveName(useCase, opts) {
6267
+ return this.router.resolve(useCase, opts).backendName;
6032
6268
  }
6033
- forUseCase(useCase) {
6034
- const def = this.router.resolveDefinition(useCase);
6035
- const name = this.router.resolve(useCase);
6269
+ /**
6270
+ * Spec B Phase 1: expose the underlying router for callers that need
6271
+ * it directly (e.g., {@link buildIntelligencePipeline} for the
6272
+ * I1 SEL/PESL comparison fix). Read-only access; consumers must not
6273
+ * mutate router state.
6274
+ */
6275
+ getRouter() {
6276
+ return this.router;
6277
+ }
6278
+ forUseCase(useCase, opts) {
6279
+ const { def, decision } = this.router.resolveDecisionAndDef(useCase, opts);
6280
+ const name = decision.backendName;
6036
6281
  let backend;
6037
6282
  const createOpts = this.opts.cacheMetrics ? { cacheMetrics: this.opts.cacheMetrics } : {};
6038
6283
  if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
@@ -6196,15 +6441,14 @@ function buildClaudeCliProvider(def, args, layerModel) {
6196
6441
 
6197
6442
  // src/agent/intelligence-factory.ts
6198
6443
  function buildIntelligencePipeline(deps) {
6199
- const { config } = deps;
6444
+ const { config, router } = deps;
6200
6445
  const intel = config.intelligence;
6201
6446
  if (!intel?.enabled) return null;
6202
6447
  const selProvider = buildAnalysisProviderForLayer("sel", deps);
6203
6448
  if (!selProvider) return null;
6204
- const routing = config.agent.routing;
6205
- const peslName = routing?.intelligence?.pesl;
6206
- const selName = routing?.intelligence?.sel ?? routing?.default;
6207
- const peslProvider = peslName !== void 0 && peslName !== selName ? buildAnalysisProviderForLayer("pesl", deps) : null;
6449
+ const peslName = router.resolve({ kind: "intelligence", layer: "pesl" }).backendName;
6450
+ const selName = router.resolve({ kind: "intelligence", layer: "sel" }).backendName;
6451
+ const peslProvider = peslName !== selName ? buildAnalysisProviderForLayer("pesl", deps) : null;
6208
6452
  const peslModel = intel.models?.pesl ?? config.agent.model;
6209
6453
  const graphStore = new import_graph.GraphStore();
6210
6454
  const pipeline = new import_intelligence3.IntelligencePipeline(selProvider, graphStore, {
@@ -6221,7 +6465,7 @@ function buildAnalysisProviderForLayer(layer, deps) {
6221
6465
  const layerModel = layer === "sel" ? intel.models?.sel : intel.models?.pesl;
6222
6466
  return buildExplicitProvider(intel.provider, layerModel ?? config.agent.model, config);
6223
6467
  }
6224
- const routed = resolveRoutedBackend(layer, config, logger);
6468
+ const routed = resolveRoutedBackend(layer, deps);
6225
6469
  if (!routed) return null;
6226
6470
  const { name, def } = routed;
6227
6471
  const resolver = localResolvers.get(name);
@@ -6246,20 +6490,26 @@ function buildAnalysisProviderForLayer(layer, deps) {
6246
6490
  logger
6247
6491
  });
6248
6492
  }
6249
- function resolveRoutedBackend(layer, config, logger) {
6250
- const routing = config.agent.routing;
6493
+ function resolveRoutedBackend(layer, deps) {
6494
+ const { config, router, logger } = deps;
6251
6495
  const backends = config.agent.backends;
6252
- if (!routing || !backends) return null;
6253
- const layerName = routing.intelligence?.[layer];
6254
- const name = layerName ?? routing.default;
6255
- const def = backends[name];
6256
- if (!def) {
6496
+ if (!backends || !router) return null;
6497
+ try {
6498
+ const decision = router.resolve({ kind: "intelligence", layer });
6499
+ const def = backends[decision.backendName];
6500
+ if (!def) {
6501
+ logger.warn(
6502
+ `Intelligence pipeline: routed backend '${decision.backendName}' for layer '${layer}' is not in agent.backends.`
6503
+ );
6504
+ return null;
6505
+ }
6506
+ return { name: decision.backendName, def };
6507
+ } catch (err) {
6257
6508
  logger.warn(
6258
- `Intelligence pipeline: routed backend '${name}' for layer '${layer}' is not in agent.backends.`
6509
+ `Intelligence pipeline: router could not resolve intelligence.${layer}; intelligence disabled. error=${String(err)}`
6259
6510
  );
6260
6511
  return null;
6261
6512
  }
6262
- return { name, def };
6263
6513
  }
6264
6514
  function buildExplicitProvider(provider, selModel, config) {
6265
6515
  if (provider.kind === "anthropic") {
@@ -6294,10 +6544,105 @@ function buildExplicitProvider(provider, selModel, config) {
6294
6544
  });
6295
6545
  }
6296
6546
 
6547
+ // src/routing/decision-bus.ts
6548
+ var RoutingDecisionBus = class {
6549
+ ringBuffer = [];
6550
+ listeners = /* @__PURE__ */ new Set();
6551
+ capacity;
6552
+ logger;
6553
+ constructor(opts) {
6554
+ this.capacity = opts?.capacity ?? 500;
6555
+ this.logger = opts?.logger;
6556
+ }
6557
+ emit(decision) {
6558
+ this.ringBuffer.push(decision);
6559
+ if (this.ringBuffer.length > this.capacity) {
6560
+ this.ringBuffer.shift();
6561
+ }
6562
+ if (this.logger) {
6563
+ this.logger.info("routing-decision", {
6564
+ useCase: decision.useCase,
6565
+ backendName: decision.backendName,
6566
+ resolutionPathLength: decision.resolutionPath.length,
6567
+ durationMs: decision.durationMs
6568
+ });
6569
+ }
6570
+ for (const listener of this.listeners) {
6571
+ try {
6572
+ listener(decision);
6573
+ } catch (err) {
6574
+ if (this.logger) {
6575
+ this.logger.warn("RoutingDecisionBus subscriber threw", {
6576
+ error: String(err)
6577
+ });
6578
+ }
6579
+ }
6580
+ }
6581
+ }
6582
+ recent(filter) {
6583
+ let out = this.ringBuffer.slice();
6584
+ if (filter?.skillName !== void 0) {
6585
+ out = out.filter(
6586
+ (d) => d.useCase.kind === "skill" && d.useCase.skillName === filter.skillName
6587
+ );
6588
+ }
6589
+ if (filter?.mode !== void 0) {
6590
+ const m = filter.mode;
6591
+ out = out.filter(
6592
+ (d) => d.useCase.kind === "mode" && d.useCase.cognitiveMode === m || d.useCase.kind === "skill" && d.useCase.cognitiveMode === m
6593
+ );
6594
+ }
6595
+ if (filter?.backendName !== void 0) {
6596
+ out = out.filter((d) => d.backendName === filter.backendName);
6597
+ }
6598
+ if (filter?.limit !== void 0) {
6599
+ out = out.slice(-filter.limit).reverse();
6600
+ } else {
6601
+ out = out.reverse();
6602
+ }
6603
+ return out;
6604
+ }
6605
+ subscribe(listener) {
6606
+ this.listeners.add(listener);
6607
+ return () => {
6608
+ this.listeners.delete(listener);
6609
+ };
6610
+ }
6611
+ /**
6612
+ * Spec B Phase 5 (review-S2 fix): release all subscriber references so
6613
+ * teardown can complete without anchoring closures. Called from
6614
+ * `Orchestrator.stop()` before nulling the bus reference. The bus
6615
+ * remains usable after clear — `subscribe()` works as normal.
6616
+ */
6617
+ clearListeners() {
6618
+ this.listeners.clear();
6619
+ }
6620
+ };
6621
+
6622
+ // src/agent/triage-skill-mapping.ts
6623
+ function resolveSkillForTriage(triageSkill, catalog) {
6624
+ const expected = `harness-${triageSkill}`;
6625
+ const match = catalog.find((e) => e.name === expected);
6626
+ if (!match) return void 0;
6627
+ return match.cognitiveMode !== void 0 ? { name: match.name, cognitiveMode: match.cognitiveMode } : { name: match.name };
6628
+ }
6629
+
6630
+ // src/agent/use-case-builder.ts
6631
+ function buildRoutingUseCase(issue, backendParam, catalog) {
6632
+ if (backendParam === "local") return { kind: "tier", tier: "quick-fix" };
6633
+ const decision = triageIssue(issue, {});
6634
+ const resolved = resolveSkillForTriage(decision.skill, catalog);
6635
+ if (resolved) {
6636
+ return resolved.cognitiveMode !== void 0 ? { kind: "skill", skillName: resolved.name, cognitiveMode: resolved.cognitiveMode } : { kind: "skill", skillName: resolved.name };
6637
+ }
6638
+ const tier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
6639
+ return { kind: "tier", tier };
6640
+ }
6641
+
6297
6642
  // src/server/http.ts
6298
6643
  var http = __toESM(require("http"));
6299
- var path14 = __toESM(require("path"));
6300
- var import_core8 = require("@harness-engineering/core");
6644
+ var path17 = __toESM(require("path"));
6645
+ var import_core11 = require("@harness-engineering/core");
6301
6646
 
6302
6647
  // src/server/websocket.ts
6303
6648
  var import_ws = require("ws");
@@ -6359,7 +6704,7 @@ var import_zod3 = require("zod");
6359
6704
  // src/server/utils.ts
6360
6705
  var DEFAULT_MAX_BYTES = 1048576;
6361
6706
  function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
6362
- return new Promise((resolve6, reject) => {
6707
+ return new Promise((resolve8, reject) => {
6363
6708
  let body = "";
6364
6709
  let bytes = 0;
6365
6710
  req.on("data", (chunk) => {
@@ -6371,7 +6716,7 @@ function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
6371
6716
  }
6372
6717
  body += String(chunk);
6373
6718
  });
6374
- req.on("end", () => resolve6(body));
6719
+ req.on("end", () => resolve8(body));
6375
6720
  req.on("error", reject);
6376
6721
  });
6377
6722
  }
@@ -6492,8 +6837,8 @@ function handleV1InteractionsResolveRoute(req, res, queue) {
6492
6837
 
6493
6838
  // src/server/routes/plans.ts
6494
6839
  var import_zod5 = require("zod");
6495
- var fs9 = __toESM(require("fs/promises"));
6496
- var path9 = __toESM(require("path"));
6840
+ var fs10 = __toESM(require("fs/promises"));
6841
+ var path11 = __toESM(require("path"));
6497
6842
  var PlanWriteSchema = import_zod5.z.object({
6498
6843
  filename: import_zod5.z.string().min(1),
6499
6844
  content: import_zod5.z.string().min(1)
@@ -6513,7 +6858,7 @@ function handlePlansRoute(req, res, plansDir) {
6513
6858
  return;
6514
6859
  }
6515
6860
  const parsed = result.data;
6516
- const basename3 = path9.basename(parsed.filename);
6861
+ const basename3 = path11.basename(parsed.filename);
6517
6862
  if (basename3 !== parsed.filename || !basename3.endsWith(".md")) {
6518
6863
  res.writeHead(400, { "Content-Type": "application/json" });
6519
6864
  res.end(
@@ -6521,9 +6866,9 @@ function handlePlansRoute(req, res, plansDir) {
6521
6866
  );
6522
6867
  return;
6523
6868
  }
6524
- await fs9.mkdir(plansDir, { recursive: true });
6525
- const filePath = path9.join(plansDir, basename3);
6526
- await fs9.writeFile(filePath, parsed.content, "utf-8");
6869
+ await fs10.mkdir(plansDir, { recursive: true });
6870
+ const filePath = path11.join(plansDir, basename3);
6871
+ await fs10.writeFile(filePath, parsed.content, "utf-8");
6527
6872
  res.writeHead(201, { "Content-Type": "application/json" });
6528
6873
  res.end(JSON.stringify({ ok: true, filename: basename3 }));
6529
6874
  } catch {
@@ -6898,8 +7243,8 @@ function handleAnalyzeRoute(req, res, pipeline) {
6898
7243
  }
6899
7244
 
6900
7245
  // src/server/routes/roadmap-actions.ts
6901
- var fs10 = __toESM(require("fs/promises"));
6902
- var path10 = __toESM(require("path"));
7246
+ var fs11 = __toESM(require("fs/promises"));
7247
+ var path12 = __toESM(require("path"));
6903
7248
  var import_core7 = require("@harness-engineering/core");
6904
7249
  var import_zod8 = require("zod");
6905
7250
  var AppendRoadmapRequestSchema = import_zod8.z.object({
@@ -6927,7 +7272,7 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6927
7272
  sendJSON2(res, 503, { error: "Roadmap path not configured" });
6928
7273
  return;
6929
7274
  }
6930
- const projectRoot = path10.dirname(path10.dirname(roadmapPath));
7275
+ const projectRoot = path12.dirname(path12.dirname(roadmapPath));
6931
7276
  const mode = (0, import_core7.loadProjectRoadmapMode)(projectRoot);
6932
7277
  if (mode === "file-less") {
6933
7278
  const trackerCfg = (0, import_core7.loadTrackerClientConfigFromProject)(projectRoot);
@@ -6980,7 +7325,7 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
6980
7325
  sendJSON2(res, 400, { error: "Title must not contain newlines or markdown headings" });
6981
7326
  return;
6982
7327
  }
6983
- const content = await fs10.readFile(roadmapPath, "utf-8");
7328
+ const content = await fs11.readFile(roadmapPath, "utf-8");
6984
7329
  const roadmapResult = (0, import_core7.parseRoadmap)(content);
6985
7330
  if (!roadmapResult.ok) {
6986
7331
  sendJSON2(res, 500, { error: "Failed to parse roadmap file" });
@@ -7011,8 +7356,8 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
7011
7356
  roadmap.frontmatter.lastManualEdit = (/* @__PURE__ */ new Date()).toISOString();
7012
7357
  const tmpPath = roadmapPath + ".tmp";
7013
7358
  const serialized = (0, import_core7.serializeRoadmap)(roadmap);
7014
- await fs10.writeFile(tmpPath, serialized, "utf-8");
7015
- await fs10.rename(tmpPath, roadmapPath);
7359
+ await fs11.writeFile(tmpPath, serialized, "utf-8");
7360
+ await fs11.rename(tmpPath, roadmapPath);
7016
7361
  sendJSON2(res, 201, { ok: true, featureName: parsed.title });
7017
7362
  } catch (err) {
7018
7363
  const msg = err instanceof Error ? err.message : "Failed to append to roadmap";
@@ -7499,85 +7844,761 @@ function handleV1TelemetryRoute(req, res, deps) {
7499
7844
  return false;
7500
7845
  }
7501
7846
 
7502
- // src/server/routes/sessions.ts
7503
- var fs11 = __toESM(require("fs/promises"));
7504
- var path11 = __toESM(require("path"));
7847
+ // src/server/routes/v1/proposals.ts
7505
7848
  var import_zod13 = require("zod");
7506
- var SessionCreateSchema = import_zod13.z.object({
7507
- sessionId: import_zod13.z.string().min(1)
7508
- }).passthrough();
7509
- var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7510
- function isSafeId(id) {
7511
- return UUID_RE2.test(id) || path11.basename(id) === id && !id.includes("..");
7512
- }
7513
- function jsonResponse(res, status, data) {
7514
- res.writeHead(status, { "Content-Type": "application/json" });
7515
- res.end(JSON.stringify(data));
7849
+ var import_core10 = require("@harness-engineering/core");
7850
+ var import_types24 = require("@harness-engineering/types");
7851
+
7852
+ // src/proposals/gate.ts
7853
+ var import_yaml3 = require("yaml");
7854
+ var import_core8 = require("@harness-engineering/core");
7855
+ var GateRunError = class extends Error {
7856
+ constructor(message) {
7857
+ super(message);
7858
+ this.name = "GateRunError";
7859
+ }
7860
+ };
7861
+ var SKILL_NAME_RE = /^[a-z][a-z0-9-]*$/;
7862
+ function checkSkillYaml(yaml) {
7863
+ const findings = [];
7864
+ let doc;
7865
+ try {
7866
+ doc = (0, import_yaml3.parse)(yaml);
7867
+ } catch (err) {
7868
+ findings.push({
7869
+ severity: "error",
7870
+ title: "skill.yaml does not parse",
7871
+ detail: err instanceof Error ? err.message : String(err)
7872
+ });
7873
+ return findings;
7874
+ }
7875
+ if (!doc || typeof doc !== "object") {
7876
+ findings.push({
7877
+ severity: "error",
7878
+ title: "skill.yaml top-level is not a mapping",
7879
+ detail: "Expected a YAML document with keys at the root (name, version, description, \u2026)."
7880
+ });
7881
+ return findings;
7882
+ }
7883
+ const obj = doc;
7884
+ if (typeof obj["name"] !== "string") {
7885
+ findings.push({
7886
+ severity: "error",
7887
+ title: "skill.yaml missing `name`",
7888
+ detail: "Every skill must declare its kebab-case name."
7889
+ });
7890
+ }
7891
+ if (typeof obj["version"] !== "string") {
7892
+ findings.push({
7893
+ severity: "error",
7894
+ title: "skill.yaml missing `version`",
7895
+ detail: "Every skill must declare a semver version string."
7896
+ });
7897
+ }
7898
+ if (typeof obj["description"] !== "string") {
7899
+ findings.push({
7900
+ severity: "warning",
7901
+ title: "skill.yaml missing `description`",
7902
+ detail: "Description is strongly recommended for discoverability."
7903
+ });
7904
+ }
7905
+ return findings;
7516
7906
  }
7517
- function extractSessionId(url) {
7518
- const segments = new URL(url, "http://localhost").pathname.split(path11.posix.sep);
7519
- const id = segments.pop();
7520
- return id && id !== "sessions" ? id : null;
7907
+ function checkSkillMd(md) {
7908
+ const findings = [];
7909
+ if (md.trim().length < 40) {
7910
+ findings.push({
7911
+ severity: "error",
7912
+ title: "SKILL.md is too short",
7913
+ detail: "A skill needs a meaningful description (at least 40 non-whitespace characters)."
7914
+ });
7915
+ }
7916
+ if (!/^#\s+\S/m.test(md)) {
7917
+ findings.push({
7918
+ severity: "warning",
7919
+ title: "SKILL.md has no top-level heading",
7920
+ detail: "Convention: open SKILL.md with `# <Skill Name>`."
7921
+ });
7922
+ }
7923
+ return findings;
7521
7924
  }
7522
- async function handleList(res, sessionsDir) {
7523
- try {
7524
- const entries = await fs11.readdir(sessionsDir, { withFileTypes: true });
7525
- const sessions = [];
7526
- for (const entry of entries) {
7527
- if (!entry.isDirectory()) continue;
7528
- try {
7529
- const content = await fs11.readFile(
7530
- path11.join(sessionsDir, entry.name, "session.json"),
7531
- "utf-8"
7532
- );
7533
- sessions.push(JSON.parse(content));
7534
- } catch {
7535
- }
7925
+ function checkName(name) {
7926
+ if (SKILL_NAME_RE.test(name)) return [];
7927
+ return [
7928
+ {
7929
+ severity: "error",
7930
+ title: "skill name violates the kebab-case rule",
7931
+ detail: `"${name}" must match /^[a-z][a-z0-9-]*$/. Use only lowercase letters, digits, and hyphens; start with a letter.`
7536
7932
  }
7537
- sessions.sort(
7538
- (a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime()
7933
+ ];
7934
+ }
7935
+ function checkDiff(diff) {
7936
+ const findings = [];
7937
+ if (!diff.includes("---") || !diff.includes("+++")) {
7938
+ findings.push({
7939
+ severity: "error",
7940
+ title: "Refinement diff is not in unified-diff format",
7941
+ detail: "Diffs must include both `---` and `+++` headers."
7942
+ });
7943
+ }
7944
+ if (!/^@@\s/m.test(diff)) {
7945
+ findings.push({
7946
+ severity: "warning",
7947
+ title: "Refinement diff has no hunk marker",
7948
+ detail: "A unified diff typically contains at least one `@@` line."
7949
+ });
7950
+ }
7951
+ return findings;
7952
+ }
7953
+ function deriveFindings(proposal) {
7954
+ const findings = [];
7955
+ findings.push(...checkName(proposal.content.name));
7956
+ if (proposal.kind === "new-skill") {
7957
+ findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
7958
+ findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
7959
+ } else if (proposal.kind === "refinement") {
7960
+ findings.push(...checkDiff(proposal.content.diff ?? ""));
7961
+ }
7962
+ return findings;
7963
+ }
7964
+ async function runGate(projectPath, proposalId) {
7965
+ const proposal = await (0, import_core8.getProposal)(projectPath, proposalId);
7966
+ if (!proposal) throw new import_core8.ProposalNotFoundError(proposalId);
7967
+ if (proposal.status === "approved" || proposal.status === "rejected") {
7968
+ throw new GateRunError(
7969
+ `proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
7539
7970
  );
7540
- jsonResponse(res, 200, sessions);
7541
- } catch (err) {
7542
- if (err.code === "ENOENT") {
7543
- jsonResponse(res, 200, []);
7544
- return;
7545
- }
7546
- jsonResponse(res, 500, { error: "Failed to list sessions" });
7547
7971
  }
7972
+ const findings = deriveFindings(proposal);
7973
+ const runAt = (/* @__PURE__ */ new Date()).toISOString();
7974
+ const hasError = findings.some((f) => f.severity === "error");
7975
+ const nextStatus = hasError ? "gate-failed" : "gate-running";
7976
+ const updated = await (0, import_core8.updateProposal)(projectPath, proposalId, {
7977
+ status: nextStatus,
7978
+ gate: { lastRunAt: runAt, findings }
7979
+ });
7980
+ return {
7981
+ proposalId: updated.id,
7982
+ status: updated.status,
7983
+ findings,
7984
+ runAt
7985
+ };
7548
7986
  }
7549
- async function handleGet(res, id, sessionsDir) {
7550
- if (!isSafeId(id)) {
7551
- jsonResponse(res, 400, { error: "Invalid sessionId" });
7552
- return;
7987
+
7988
+ // src/proposals/promote.ts
7989
+ var fs12 = __toESM(require("fs"));
7990
+ var path13 = __toESM(require("path"));
7991
+ var import_yaml4 = require("yaml");
7992
+ var import_core9 = require("@harness-engineering/core");
7993
+ var GateNotReadyError = class extends Error {
7994
+ constructor(message) {
7995
+ super(message);
7996
+ this.name = "GateNotReadyError";
7997
+ }
7998
+ };
7999
+ var PromotionError = class extends Error {
8000
+ constructor(message) {
8001
+ super(message);
8002
+ this.name = "PromotionError";
7553
8003
  }
8004
+ };
8005
+ var GATE_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
8006
+ function skillDir(projectPath, name) {
8007
+ return path13.join(projectPath, "agents", "skills", "claude-code", name);
8008
+ }
8009
+ function readIfExists(p) {
7554
8010
  try {
7555
- const content = await fs11.readFile(path11.join(sessionsDir, id, "session.json"), "utf-8");
7556
- jsonResponse(res, 200, JSON.parse(content));
7557
- } catch (err) {
7558
- if (err.code === "ENOENT") {
7559
- jsonResponse(res, 404, { error: "Session not found" });
7560
- return;
7561
- }
7562
- jsonResponse(res, 500, { error: "Failed to read session" });
8011
+ return fs12.readFileSync(p, "utf-8");
8012
+ } catch {
8013
+ return null;
7563
8014
  }
7564
8015
  }
7565
- async function handleCreate(req, res, sessionsDir) {
8016
+ function injectProvenanceIntoYaml(yamlText, proposalId) {
8017
+ let doc;
7566
8018
  try {
7567
- const body = await readBody(req);
7568
- const result = SessionCreateSchema.safeParse(JSON.parse(body));
7569
- if (!result.success) {
7570
- jsonResponse(res, 400, { error: "Missing sessionId" });
7571
- return;
7572
- }
7573
- const session = result.data;
7574
- if (!isSafeId(session.sessionId)) {
8019
+ doc = (0, import_yaml4.parse)(yamlText);
8020
+ } catch (err) {
8021
+ throw new PromotionError(
8022
+ `skill.yaml does not parse: ${err instanceof Error ? err.message : String(err)}`
8023
+ );
8024
+ }
8025
+ if (!doc || typeof doc !== "object") {
8026
+ throw new PromotionError("skill.yaml top-level is not a mapping");
8027
+ }
8028
+ const obj = doc;
8029
+ obj["provenance"] = "agent-proposed";
8030
+ obj["originatingProposalId"] = proposalId;
8031
+ return (0, import_yaml4.stringify)(obj);
8032
+ }
8033
+ function assertGateReady(proposal) {
8034
+ if (proposal.status !== "gate-running") {
8035
+ throw new GateNotReadyError(
8036
+ `proposal ${proposal.id} is in status "${proposal.status}"; the gate must pass before promotion`
8037
+ );
8038
+ }
8039
+ const findings = proposal.gate?.findings ?? [];
8040
+ if (findings.some((f) => f.severity === "error")) {
8041
+ throw new GateNotReadyError(
8042
+ `proposal ${proposal.id} has unresolved gate errors; re-run the gate after edits`
8043
+ );
8044
+ }
8045
+ if (!proposal.gate?.lastRunAt) {
8046
+ throw new GateNotReadyError(`proposal ${proposal.id} has no gate run on record`);
8047
+ }
8048
+ const ageMs = Date.now() - Date.parse(proposal.gate.lastRunAt);
8049
+ if (!Number.isFinite(ageMs) || ageMs > GATE_FRESHNESS_MS) {
8050
+ throw new GateNotReadyError(
8051
+ `proposal ${proposal.id} gate run is older than 24h; re-run before approving`
8052
+ );
8053
+ }
8054
+ }
8055
+ async function promoteNewSkill(projectPath, proposal) {
8056
+ const target = skillDir(projectPath, proposal.content.name);
8057
+ if (fs12.existsSync(target)) {
8058
+ throw new PromotionError(
8059
+ `a catalog skill already exists at ${target}; use a refinement proposal to update it`
8060
+ );
8061
+ }
8062
+ fs12.mkdirSync(target, { recursive: true });
8063
+ const yamlOut = injectProvenanceIntoYaml(proposal.content.skillYaml ?? "", proposal.id);
8064
+ fs12.writeFileSync(path13.join(target, "skill.yaml"), yamlOut);
8065
+ fs12.writeFileSync(path13.join(target, "SKILL.md"), proposal.content.skillMd ?? "");
8066
+ return { skillPath: target };
8067
+ }
8068
+ async function promoteRefinement(projectPath, proposal) {
8069
+ if (!proposal.targetSkill) {
8070
+ throw new PromotionError("refinement proposal is missing targetSkill");
8071
+ }
8072
+ const target = skillDir(projectPath, proposal.targetSkill);
8073
+ if (!fs12.existsSync(target)) {
8074
+ throw new PromotionError(
8075
+ `target skill ${proposal.targetSkill} does not exist at ${target}; cannot refine`
8076
+ );
8077
+ }
8078
+ const yamlPath = path13.join(target, "skill.yaml");
8079
+ const before = readIfExists(yamlPath) ?? "";
8080
+ const after = injectProvenanceIntoYaml(before, proposal.id);
8081
+ if (after === before) {
8082
+ throw new PromotionError(
8083
+ "no metadata changes detected; check that the reviewer applied the proposed diff before approving"
8084
+ );
8085
+ }
8086
+ fs12.writeFileSync(yamlPath, after);
8087
+ return { skillPath: target };
8088
+ }
8089
+ async function promote(projectPath, proposalId, decidedBy) {
8090
+ const proposal = await (0, import_core9.getProposal)(projectPath, proposalId);
8091
+ if (!proposal) throw new import_core9.ProposalNotFoundError(proposalId);
8092
+ assertGateReady(proposal);
8093
+ const out = proposal.kind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
8094
+ await (0, import_core9.updateProposal)(projectPath, proposalId, {
8095
+ status: "approved",
8096
+ decision: {
8097
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
8098
+ decidedBy,
8099
+ action: "approved"
8100
+ }
8101
+ });
8102
+ return {
8103
+ proposalId,
8104
+ skillPath: out.skillPath,
8105
+ provenance: "agent-proposed"
8106
+ };
8107
+ }
8108
+
8109
+ // src/proposals/events.ts
8110
+ function emit3(bus, topic, data) {
8111
+ bus.emit(topic, data);
8112
+ }
8113
+ function emitProposalCreated(bus, proposal) {
8114
+ const data = {
8115
+ id: proposal.id,
8116
+ kind: proposal.kind,
8117
+ name: proposal.content.name,
8118
+ proposedBy: proposal.proposedBy,
8119
+ justification: proposal.source.justification
8120
+ };
8121
+ if (proposal.targetSkill) data.targetSkill = proposal.targetSkill;
8122
+ emit3(bus, "proposal.created", data);
8123
+ }
8124
+ function emitProposalApproved(bus, proposal) {
8125
+ const data = {
8126
+ id: proposal.id,
8127
+ kind: proposal.kind,
8128
+ name: proposal.content.name,
8129
+ decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
8130
+ };
8131
+ if (proposal.targetSkill) data.targetSkill = proposal.targetSkill;
8132
+ emit3(bus, "proposal.approved", data);
8133
+ }
8134
+ function emitProposalRejected(bus, proposal) {
8135
+ const data = {
8136
+ id: proposal.id,
8137
+ kind: proposal.kind,
8138
+ name: proposal.content.name,
8139
+ decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
8140
+ reason: proposal.decision?.reason ?? "(no reason given)"
8141
+ };
8142
+ emit3(bus, "proposal.rejected", data);
8143
+ }
8144
+
8145
+ // src/server/routes/v1/proposals.ts
8146
+ var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
8147
+ var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
8148
+ var RUN_GATE_RE = /^\/api\/v1\/proposals\/([^/?]+)\/run-gate(?:\?.*)?$/;
8149
+ var APPROVE_RE = /^\/api\/v1\/proposals\/([^/?]+)\/approve(?:\?.*)?$/;
8150
+ var REJECT_RE = /^\/api\/v1\/proposals\/([^/?]+)\/reject(?:\?.*)?$/;
8151
+ var ProposalStatusValues = [
8152
+ "open",
8153
+ "gate-running",
8154
+ "gate-failed",
8155
+ "approved",
8156
+ "rejected"
8157
+ ];
8158
+ var RejectBody = import_zod13.z.object({
8159
+ reason: import_zod13.z.string().min(1).max(280)
8160
+ });
8161
+ function sendJSON8(res, status, body) {
8162
+ res.writeHead(status, { "Content-Type": "application/json" });
8163
+ res.end(JSON.stringify(body));
8164
+ }
8165
+ function getDecidedBy(req, deps) {
8166
+ if (deps.decidedByResolver) return deps.decidedByResolver(req);
8167
+ const token = req._authToken;
8168
+ return token?.id ?? "unknown";
8169
+ }
8170
+ function parseStatusFromQuery(url) {
8171
+ const queryIdx = url.indexOf("?");
8172
+ if (queryIdx === -1) return void 0;
8173
+ const params = new URLSearchParams(url.slice(queryIdx + 1));
8174
+ const raw = params.get("status");
8175
+ if (!raw) return void 0;
8176
+ if (raw === "all") return "all";
8177
+ if (ProposalStatusValues.includes(raw)) return raw;
8178
+ return void 0;
8179
+ }
8180
+ async function handleList(req, res, deps) {
8181
+ const url = req.url ?? "";
8182
+ const status = parseStatusFromQuery(url);
8183
+ const proposals = await (0, import_core10.listProposals)(deps.projectPath, status ? { status } : {});
8184
+ sendJSON8(res, 200, proposals);
8185
+ }
8186
+ async function handleGet(res, deps, id) {
8187
+ const proposal = await (0, import_core10.getProposal)(deps.projectPath, id);
8188
+ if (!proposal) {
8189
+ sendJSON8(res, 404, { error: "Proposal not found" });
8190
+ return;
8191
+ }
8192
+ sendJSON8(res, 200, proposal);
8193
+ }
8194
+ async function handleRunGate(res, deps, id) {
8195
+ try {
8196
+ const result = await runGate(deps.projectPath, id);
8197
+ sendJSON8(res, 200, result);
8198
+ } catch (err) {
8199
+ if (err instanceof import_core10.ProposalNotFoundError) {
8200
+ sendJSON8(res, 404, { error: err.message });
8201
+ return;
8202
+ }
8203
+ if (err instanceof GateRunError) {
8204
+ sendJSON8(res, 409, { error: err.message });
8205
+ return;
8206
+ }
8207
+ sendJSON8(res, 500, {
8208
+ error: "gate run failed",
8209
+ detail: err instanceof Error ? err.message : "unknown"
8210
+ });
8211
+ }
8212
+ }
8213
+ async function handleApprove(req, res, deps, id) {
8214
+ const decidedBy = getDecidedBy(req, deps);
8215
+ try {
8216
+ const result = await promote(deps.projectPath, id, decidedBy);
8217
+ const proposal = await (0, import_core10.getProposal)(deps.projectPath, id);
8218
+ if (proposal) emitProposalApproved(deps.bus, proposal);
8219
+ sendJSON8(res, 200, { promotion: result, proposal });
8220
+ } catch (err) {
8221
+ if (err instanceof import_core10.ProposalNotFoundError) {
8222
+ sendJSON8(res, 404, { error: err.message });
8223
+ return;
8224
+ }
8225
+ if (err instanceof GateNotReadyError) {
8226
+ sendJSON8(res, 409, { error: err.message });
8227
+ return;
8228
+ }
8229
+ if (err instanceof PromotionError) {
8230
+ sendJSON8(res, 422, { error: err.message });
8231
+ return;
8232
+ }
8233
+ sendJSON8(res, 500, {
8234
+ error: "approve failed",
8235
+ detail: err instanceof Error ? err.message : "unknown"
8236
+ });
8237
+ }
8238
+ }
8239
+ async function handleReject(req, res, deps, id) {
8240
+ let raw;
8241
+ try {
8242
+ raw = await readBody(req);
8243
+ } catch (err) {
8244
+ sendJSON8(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
8245
+ return;
8246
+ }
8247
+ let json;
8248
+ try {
8249
+ json = raw.length > 0 ? JSON.parse(raw) : {};
8250
+ } catch {
8251
+ sendJSON8(res, 400, { error: "Invalid JSON body" });
8252
+ return;
8253
+ }
8254
+ const parsed = RejectBody.safeParse(json);
8255
+ if (!parsed.success) {
8256
+ sendJSON8(res, 400, { error: "Invalid body", issues: parsed.error.issues });
8257
+ return;
8258
+ }
8259
+ const proposal = await (0, import_core10.getProposal)(deps.projectPath, id);
8260
+ if (!proposal) {
8261
+ sendJSON8(res, 404, { error: "Proposal not found" });
8262
+ return;
8263
+ }
8264
+ if (proposal.status === "approved" || proposal.status === "rejected") {
8265
+ sendJSON8(res, 409, {
8266
+ error: `proposal already ${proposal.status}; cannot reject`
8267
+ });
8268
+ return;
8269
+ }
8270
+ const decidedBy = getDecidedBy(req, deps);
8271
+ const updated = await (0, import_core10.updateProposal)(deps.projectPath, id, {
8272
+ status: "rejected",
8273
+ decision: {
8274
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
8275
+ decidedBy,
8276
+ action: "rejected",
8277
+ reason: parsed.data.reason
8278
+ }
8279
+ });
8280
+ emitProposalRejected(deps.bus, updated);
8281
+ sendJSON8(res, 200, updated);
8282
+ }
8283
+ async function handleEdit(req, res, deps, id) {
8284
+ let raw;
8285
+ try {
8286
+ raw = await readBody(req);
8287
+ } catch (err) {
8288
+ sendJSON8(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
8289
+ return;
8290
+ }
8291
+ let json;
8292
+ try {
8293
+ json = JSON.parse(raw);
8294
+ } catch {
8295
+ sendJSON8(res, 400, { error: "Invalid JSON body" });
8296
+ return;
8297
+ }
8298
+ const parsed = import_types24.EditProposalInputSchema.safeParse(json);
8299
+ if (!parsed.success) {
8300
+ sendJSON8(res, 400, { error: "Invalid body", issues: parsed.error.issues });
8301
+ return;
8302
+ }
8303
+ const existing = await (0, import_core10.getProposal)(deps.projectPath, id);
8304
+ if (!existing) {
8305
+ sendJSON8(res, 404, { error: "Proposal not found" });
8306
+ return;
8307
+ }
8308
+ if (existing.status === "approved" || existing.status === "rejected") {
8309
+ sendJSON8(res, 409, {
8310
+ error: `proposal already ${existing.status}; cannot edit`
8311
+ });
8312
+ return;
8313
+ }
8314
+ const mergedContent = {
8315
+ ...existing.content,
8316
+ ...parsed.data.content,
8317
+ name: parsed.data.content.name ?? existing.content.name,
8318
+ description: parsed.data.content.description ?? existing.content.description
8319
+ };
8320
+ try {
8321
+ const updated = await (0, import_core10.updateProposal)(deps.projectPath, id, {
8322
+ content: mergedContent,
8323
+ status: "open",
8324
+ gate: void 0
8325
+ });
8326
+ sendJSON8(res, 200, updated);
8327
+ } catch (err) {
8328
+ sendJSON8(res, 422, {
8329
+ error: "edit failed",
8330
+ detail: err instanceof Error ? err.message : "unknown"
8331
+ });
8332
+ }
8333
+ }
8334
+ function handleV1ProposalsRoute(req, res, deps) {
8335
+ const url = req.url ?? "";
8336
+ const method = req.method ?? "GET";
8337
+ if (method === "GET" && LIST_RE.test(url)) {
8338
+ void handleList(req, res, deps);
8339
+ return true;
8340
+ }
8341
+ const runGateMatch = method === "POST" ? RUN_GATE_RE.exec(url) : null;
8342
+ if (runGateMatch) {
8343
+ void handleRunGate(res, deps, runGateMatch[1]);
8344
+ return true;
8345
+ }
8346
+ const approveMatch = method === "POST" ? APPROVE_RE.exec(url) : null;
8347
+ if (approveMatch) {
8348
+ void handleApprove(req, res, deps, approveMatch[1]);
8349
+ return true;
8350
+ }
8351
+ const rejectMatch = method === "POST" ? REJECT_RE.exec(url) : null;
8352
+ if (rejectMatch) {
8353
+ void handleReject(req, res, deps, rejectMatch[1]);
8354
+ return true;
8355
+ }
8356
+ if (method === "PATCH") {
8357
+ const m = SINGLE_RE.exec(url);
8358
+ if (m) {
8359
+ void handleEdit(req, res, deps, m[1]);
8360
+ return true;
8361
+ }
8362
+ }
8363
+ if (method === "GET") {
8364
+ const m = SINGLE_RE.exec(url);
8365
+ if (m) {
8366
+ void handleGet(res, deps, m[1]);
8367
+ return true;
8368
+ }
8369
+ }
8370
+ return false;
8371
+ }
8372
+
8373
+ // src/server/routes/v1/routing.ts
8374
+ var import_zod14 = require("zod");
8375
+ var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
8376
+ var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
8377
+ var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
8378
+ function sendJSON9(res, status, body) {
8379
+ res.writeHead(status, { "Content-Type": "application/json" });
8380
+ res.end(JSON.stringify(body));
8381
+ }
8382
+ function unavailable(res) {
8383
+ sendJSON9(res, 503, { error: "BackendRouter not available" });
8384
+ return true;
8385
+ }
8386
+ function resolveChain(value, backends) {
8387
+ return toArray(value).map((c) => ({ candidate: c, exists: c in backends }));
8388
+ }
8389
+ function buildResolvedChains(routing, backends) {
8390
+ const out = {};
8391
+ out["default"] = resolveChain(routing.default, backends);
8392
+ for (const tier of ["quick-fix", "guided-change", "full-exploration", "diagnostic"]) {
8393
+ const v = routing[tier];
8394
+ if (v !== void 0) out[`tier:${tier}`] = resolveChain(v, backends);
8395
+ }
8396
+ if (routing.intelligence) {
8397
+ for (const [layer, v] of Object.entries(routing.intelligence)) {
8398
+ if (v !== void 0) out[`intelligence:${layer}`] = resolveChain(v, backends);
8399
+ }
8400
+ }
8401
+ if (routing.isolation) {
8402
+ for (const [tier, v] of Object.entries(routing.isolation)) {
8403
+ if (v !== void 0) out[`isolation:${tier}`] = resolveChain(v, backends);
8404
+ }
8405
+ }
8406
+ if (routing.skills) {
8407
+ for (const [name, v] of Object.entries(routing.skills)) {
8408
+ if (v !== void 0) out[`skill:${name}`] = resolveChain(v, backends);
8409
+ }
8410
+ }
8411
+ if (routing.modes) {
8412
+ for (const [mode, v] of Object.entries(routing.modes)) {
8413
+ if (v !== void 0) out[`mode:${mode}`] = resolveChain(v, backends);
8414
+ }
8415
+ }
8416
+ return out;
8417
+ }
8418
+ function handleConfig(res, deps) {
8419
+ if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
8420
+ sendJSON9(res, 200, {
8421
+ routing: deps.routing,
8422
+ resolvedChains: buildResolvedChains(deps.routing, deps.backends),
8423
+ backends: Object.keys(deps.backends)
8424
+ });
8425
+ return true;
8426
+ }
8427
+ function parseDecisionsQuery(url) {
8428
+ const qIdx = url.indexOf("?");
8429
+ if (qIdx === -1) return {};
8430
+ const p = new URLSearchParams(url.slice(qIdx + 1));
8431
+ const filter = {};
8432
+ const skill = p.get("skill");
8433
+ const mode = p.get("mode");
8434
+ const backend = p.get("backend");
8435
+ const limit = p.get("limit");
8436
+ if (skill) filter.skillName = skill;
8437
+ if (mode) filter.mode = mode;
8438
+ if (backend) filter.backendName = backend;
8439
+ if (limit) {
8440
+ const n = Number(limit);
8441
+ if (Number.isFinite(n) && n > 0) filter.limit = Math.floor(n);
8442
+ }
8443
+ return filter;
8444
+ }
8445
+ function handleDecisions(req, res, deps) {
8446
+ if (!deps.bus) return unavailable(res);
8447
+ const filter = parseDecisionsQuery(req.url ?? "");
8448
+ sendJSON9(res, 200, { decisions: deps.bus.recent(filter) });
8449
+ return true;
8450
+ }
8451
+ var UseCaseSchema = import_zod14.z.discriminatedUnion("kind", [
8452
+ import_zod14.z.object({
8453
+ kind: import_zod14.z.literal("tier"),
8454
+ tier: import_zod14.z.enum(["quick-fix", "guided-change", "full-exploration", "diagnostic"])
8455
+ }),
8456
+ import_zod14.z.object({ kind: import_zod14.z.literal("intelligence"), layer: import_zod14.z.enum(["sel", "pesl"]) }),
8457
+ import_zod14.z.object({ kind: import_zod14.z.literal("isolation"), tier: import_zod14.z.string() }),
8458
+ import_zod14.z.object({ kind: import_zod14.z.literal("maintenance") }),
8459
+ import_zod14.z.object({ kind: import_zod14.z.literal("chat") }),
8460
+ import_zod14.z.object({
8461
+ kind: import_zod14.z.literal("skill"),
8462
+ skillName: import_zod14.z.string().min(1),
8463
+ cognitiveMode: import_zod14.z.string().optional()
8464
+ }),
8465
+ import_zod14.z.object({ kind: import_zod14.z.literal("mode"), cognitiveMode: import_zod14.z.string().min(1) })
8466
+ ]);
8467
+ var TraceBodySchema = import_zod14.z.object({
8468
+ useCase: UseCaseSchema,
8469
+ invocationOverride: import_zod14.z.string().min(1).optional()
8470
+ });
8471
+ async function handleTrace(req, res, deps) {
8472
+ if (!deps.routing || !deps.backends) {
8473
+ unavailable(res);
8474
+ return true;
8475
+ }
8476
+ let raw;
8477
+ try {
8478
+ raw = await readBody(req);
8479
+ } catch {
8480
+ sendJSON9(res, 400, { error: "body read failed" });
8481
+ return true;
8482
+ }
8483
+ let parsed;
8484
+ try {
8485
+ parsed = JSON.parse(raw);
8486
+ } catch {
8487
+ sendJSON9(res, 400, { error: "invalid JSON body" });
8488
+ return true;
8489
+ }
8490
+ const r = TraceBodySchema.safeParse(parsed);
8491
+ if (!r.success) {
8492
+ sendJSON9(res, 400, { error: r.error.message });
8493
+ return true;
8494
+ }
8495
+ const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
8496
+ try {
8497
+ const dryRunRouter = new BackendRouter({
8498
+ backends: deps.backends,
8499
+ routing: deps.routing
8500
+ });
8501
+ const { decision, def } = dryRunRouter.resolveDecisionAndDef(
8502
+ r.data.useCase,
8503
+ opts
8504
+ );
8505
+ sendJSON9(res, 200, { decision, def: { type: def.type } });
8506
+ } catch (err) {
8507
+ sendJSON9(res, 500, { error: String(err) });
8508
+ }
8509
+ return true;
8510
+ }
8511
+ function handleV1RoutingRoute(req, res, deps) {
8512
+ const url = req.url ?? "";
8513
+ const method = req.method ?? "GET";
8514
+ if (method === "GET" && CONFIG_RE.test(url)) return handleConfig(res, deps);
8515
+ if (method === "GET" && DECISIONS_RE.test(url)) return handleDecisions(req, res, deps);
8516
+ if (method === "POST" && TRACE_RE.test(url)) {
8517
+ void handleTrace(req, res, deps);
8518
+ return true;
8519
+ }
8520
+ return false;
8521
+ }
8522
+
8523
+ // src/server/routes/sessions.ts
8524
+ var fs13 = __toESM(require("fs/promises"));
8525
+ var path14 = __toESM(require("path"));
8526
+ var import_zod15 = require("zod");
8527
+ var SessionCreateSchema = import_zod15.z.object({
8528
+ sessionId: import_zod15.z.string().min(1)
8529
+ }).passthrough();
8530
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8531
+ function isSafeId(id) {
8532
+ return UUID_RE2.test(id) || path14.basename(id) === id && !id.includes("..");
8533
+ }
8534
+ function jsonResponse(res, status, data) {
8535
+ res.writeHead(status, { "Content-Type": "application/json" });
8536
+ res.end(JSON.stringify(data));
8537
+ }
8538
+ function extractSessionId(url) {
8539
+ const segments = new URL(url, "http://localhost").pathname.split(path14.posix.sep);
8540
+ const id = segments.pop();
8541
+ return id && id !== "sessions" ? id : null;
8542
+ }
8543
+ async function handleList2(res, sessionsDir) {
8544
+ try {
8545
+ const entries = await fs13.readdir(sessionsDir, { withFileTypes: true });
8546
+ const sessions = [];
8547
+ for (const entry of entries) {
8548
+ if (!entry.isDirectory()) continue;
8549
+ try {
8550
+ const content = await fs13.readFile(
8551
+ path14.join(sessionsDir, entry.name, "session.json"),
8552
+ "utf-8"
8553
+ );
8554
+ sessions.push(JSON.parse(content));
8555
+ } catch {
8556
+ }
8557
+ }
8558
+ sessions.sort(
8559
+ (a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime()
8560
+ );
8561
+ jsonResponse(res, 200, sessions);
8562
+ } catch (err) {
8563
+ if (err.code === "ENOENT") {
8564
+ jsonResponse(res, 200, []);
8565
+ return;
8566
+ }
8567
+ jsonResponse(res, 500, { error: "Failed to list sessions" });
8568
+ }
8569
+ }
8570
+ async function handleGet2(res, id, sessionsDir) {
8571
+ if (!isSafeId(id)) {
8572
+ jsonResponse(res, 400, { error: "Invalid sessionId" });
8573
+ return;
8574
+ }
8575
+ try {
8576
+ const content = await fs13.readFile(path14.join(sessionsDir, id, "session.json"), "utf-8");
8577
+ jsonResponse(res, 200, JSON.parse(content));
8578
+ } catch (err) {
8579
+ if (err.code === "ENOENT") {
8580
+ jsonResponse(res, 404, { error: "Session not found" });
8581
+ return;
8582
+ }
8583
+ jsonResponse(res, 500, { error: "Failed to read session" });
8584
+ }
8585
+ }
8586
+ async function handleCreate(req, res, sessionsDir) {
8587
+ try {
8588
+ const body = await readBody(req);
8589
+ const result = SessionCreateSchema.safeParse(JSON.parse(body));
8590
+ if (!result.success) {
8591
+ jsonResponse(res, 400, { error: "Missing sessionId" });
8592
+ return;
8593
+ }
8594
+ const session = result.data;
8595
+ if (!isSafeId(session.sessionId)) {
7575
8596
  jsonResponse(res, 400, { error: "Invalid sessionId" });
7576
8597
  return;
7577
8598
  }
7578
- const sessionDir = path11.join(sessionsDir, session.sessionId);
7579
- await fs11.mkdir(sessionDir, { recursive: true });
7580
- await fs11.writeFile(path11.join(sessionDir, "session.json"), JSON.stringify(session, null, 2));
8599
+ const sessionDir = path14.join(sessionsDir, session.sessionId);
8600
+ await fs13.mkdir(sessionDir, { recursive: true });
8601
+ await fs13.writeFile(path14.join(sessionDir, "session.json"), JSON.stringify(session, null, 2));
7581
8602
  jsonResponse(res, 200, { ok: true });
7582
8603
  } catch {
7583
8604
  jsonResponse(res, 500, { error: "Failed to save session" });
@@ -7591,10 +8612,10 @@ async function handleUpdate(req, res, url, sessionsDir) {
7591
8612
  return;
7592
8613
  }
7593
8614
  const body = await readBody(req);
7594
- const updates = import_zod13.z.record(import_zod13.z.unknown()).parse(JSON.parse(body));
7595
- const sessionFilePath = path11.join(sessionsDir, id, "session.json");
7596
- const current = JSON.parse(await fs11.readFile(sessionFilePath, "utf-8"));
7597
- await fs11.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
8615
+ const updates = import_zod15.z.record(import_zod15.z.unknown()).parse(JSON.parse(body));
8616
+ const sessionFilePath = path14.join(sessionsDir, id, "session.json");
8617
+ const current = JSON.parse(await fs13.readFile(sessionFilePath, "utf-8"));
8618
+ await fs13.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
7598
8619
  jsonResponse(res, 200, { ok: true });
7599
8620
  } catch {
7600
8621
  jsonResponse(res, 500, { error: "Failed to update session" });
@@ -7607,7 +8628,7 @@ async function handleDelete(res, url, sessionsDir) {
7607
8628
  jsonResponse(res, 400, { error: "Missing or invalid sessionId" });
7608
8629
  return;
7609
8630
  }
7610
- await fs11.rm(path11.join(sessionsDir, id), { recursive: true, force: true });
8631
+ await fs13.rm(path14.join(sessionsDir, id), { recursive: true, force: true });
7611
8632
  jsonResponse(res, 200, { ok: true });
7612
8633
  } catch {
7613
8634
  jsonResponse(res, 500, { error: "Failed to delete session" });
@@ -7620,8 +8641,8 @@ function handleSessionsRoute(req, res, sessionsDir) {
7620
8641
  switch (method) {
7621
8642
  case "GET": {
7622
8643
  const id = extractSessionId(url);
7623
- if (id) void handleGet(res, id, sessionsDir);
7624
- else void handleList(res, sessionsDir);
8644
+ if (id) void handleGet2(res, id, sessionsDir);
8645
+ else void handleList2(res, sessionsDir);
7625
8646
  return true;
7626
8647
  }
7627
8648
  case "POST":
@@ -7711,16 +8732,16 @@ function handleStreamsRoute(req, res, recorder) {
7711
8732
  }
7712
8733
 
7713
8734
  // src/server/routes/auth.ts
7714
- var import_zod14 = require("zod");
7715
- var import_types24 = require("@harness-engineering/types");
7716
- var CreateBodySchema = import_zod14.z.object({
7717
- name: import_zod14.z.string().min(1).max(100),
7718
- scopes: import_zod14.z.array(import_types24.TokenScopeSchema).min(1),
7719
- bridgeKind: import_types24.BridgeKindSchema.optional(),
7720
- tenantId: import_zod14.z.string().optional(),
7721
- expiresAt: import_zod14.z.string().datetime().optional()
8735
+ var import_zod16 = require("zod");
8736
+ var import_types25 = require("@harness-engineering/types");
8737
+ var CreateBodySchema = import_zod16.z.object({
8738
+ name: import_zod16.z.string().min(1).max(100),
8739
+ scopes: import_zod16.z.array(import_types25.TokenScopeSchema).min(1),
8740
+ bridgeKind: import_types25.BridgeKindSchema.optional(),
8741
+ tenantId: import_zod16.z.string().optional(),
8742
+ expiresAt: import_zod16.z.string().datetime().optional()
7722
8743
  });
7723
- function sendJSON8(res, status, body) {
8744
+ function sendJSON10(res, status, body) {
7724
8745
  res.writeHead(status, { "Content-Type": "application/json" });
7725
8746
  res.end(JSON.stringify(body));
7726
8747
  }
@@ -7730,19 +8751,19 @@ async function handlePost(req, res, store) {
7730
8751
  raw = await readBody(req);
7731
8752
  } catch (err) {
7732
8753
  const msg = err instanceof Error ? err.message : "Failed to read body";
7733
- sendJSON8(res, 413, { error: msg });
8754
+ sendJSON10(res, 413, { error: msg });
7734
8755
  return;
7735
8756
  }
7736
8757
  let json;
7737
8758
  try {
7738
8759
  json = JSON.parse(raw);
7739
8760
  } catch {
7740
- sendJSON8(res, 400, { error: "Invalid JSON body" });
8761
+ sendJSON10(res, 400, { error: "Invalid JSON body" });
7741
8762
  return;
7742
8763
  }
7743
8764
  const parsed = CreateBodySchema.safeParse(json);
7744
8765
  if (!parsed.success) {
7745
- sendJSON8(res, 422, { error: "Invalid body", issues: parsed.error.issues });
8766
+ sendJSON10(res, 422, { error: "Invalid body", issues: parsed.error.issues });
7746
8767
  return;
7747
8768
  }
7748
8769
  try {
@@ -7754,38 +8775,38 @@ async function handlePost(req, res, store) {
7754
8775
  if (parsed.data.tenantId !== void 0) input.tenantId = parsed.data.tenantId;
7755
8776
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
7756
8777
  const result = await store.create(input);
7757
- const publicRecord = import_types24.AuthTokenPublicSchema.parse(result.record);
7758
- sendJSON8(res, 200, {
8778
+ const publicRecord = import_types25.AuthTokenPublicSchema.parse(result.record);
8779
+ sendJSON10(res, 200, {
7759
8780
  ...publicRecord,
7760
8781
  token: result.token
7761
8782
  });
7762
8783
  } catch (err) {
7763
8784
  const msg = err instanceof Error ? err.message : "Failed to create token";
7764
8785
  if (msg.includes("already exists")) {
7765
- sendJSON8(res, 409, { error: msg });
8786
+ sendJSON10(res, 409, { error: msg });
7766
8787
  return;
7767
8788
  }
7768
- sendJSON8(res, 500, { error: "Internal error creating token" });
8789
+ sendJSON10(res, 500, { error: "Internal error creating token" });
7769
8790
  }
7770
8791
  }
7771
- async function handleList2(res, store) {
8792
+ async function handleList3(res, store) {
7772
8793
  try {
7773
8794
  const list = await store.list();
7774
- sendJSON8(res, 200, list);
8795
+ sendJSON10(res, 200, list);
7775
8796
  } catch {
7776
- sendJSON8(res, 500, { error: "Internal error listing tokens" });
8797
+ sendJSON10(res, 500, { error: "Internal error listing tokens" });
7777
8798
  }
7778
8799
  }
7779
8800
  async function handleDelete2(res, store, id) {
7780
8801
  try {
7781
8802
  const ok = await store.revoke(id);
7782
8803
  if (!ok) {
7783
- sendJSON8(res, 404, { error: "Token not found" });
8804
+ sendJSON10(res, 404, { error: "Token not found" });
7784
8805
  return;
7785
8806
  }
7786
- sendJSON8(res, 200, { deleted: true });
8807
+ sendJSON10(res, 200, { deleted: true });
7787
8808
  } catch {
7788
- sendJSON8(res, 500, { error: "Internal error revoking token" });
8809
+ sendJSON10(res, 500, { error: "Internal error revoking token" });
7789
8810
  }
7790
8811
  }
7791
8812
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -7799,7 +8820,7 @@ function handleAuthRoute(req, res, store) {
7799
8820
  return true;
7800
8821
  }
7801
8822
  if (method === "GET" && pathname === "/api/v1/auth/tokens") {
7802
- void handleList2(res, store);
8823
+ void handleList3(res, store);
7803
8824
  return true;
7804
8825
  }
7805
8826
  if (method === "DELETE") {
@@ -7810,12 +8831,12 @@ function handleAuthRoute(req, res, store) {
7810
8831
  return true;
7811
8832
  }
7812
8833
  }
7813
- sendJSON8(res, 405, { error: "Method not allowed" });
8834
+ sendJSON10(res, 405, { error: "Method not allowed" });
7814
8835
  return true;
7815
8836
  }
7816
8837
 
7817
8838
  // src/server/routes/local-model.ts
7818
- function sendJSON9(res, status, body) {
8839
+ function sendJSON11(res, status, body) {
7819
8840
  res.writeHead(status, { "Content-Type": "application/json" });
7820
8841
  res.end(JSON.stringify(body));
7821
8842
  }
@@ -7823,36 +8844,36 @@ function handleLocalModelRoute(req, res, getStatus) {
7823
8844
  const { method, url } = req;
7824
8845
  if (url !== "/api/v1/local-model/status") return false;
7825
8846
  if (method !== "GET") {
7826
- sendJSON9(res, 405, { error: "Method not allowed" });
8847
+ sendJSON11(res, 405, { error: "Method not allowed" });
7827
8848
  return true;
7828
8849
  }
7829
8850
  if (!getStatus) {
7830
- sendJSON9(res, 503, { error: "Local backend not configured" });
8851
+ sendJSON11(res, 503, { error: "Local backend not configured" });
7831
8852
  return true;
7832
8853
  }
7833
8854
  const status = getStatus();
7834
8855
  if (!status) {
7835
- sendJSON9(res, 503, { error: "Local backend not configured" });
8856
+ sendJSON11(res, 503, { error: "Local backend not configured" });
7836
8857
  return true;
7837
8858
  }
7838
- sendJSON9(res, 200, status);
8859
+ sendJSON11(res, 200, status);
7839
8860
  return true;
7840
8861
  }
7841
8862
  function handleLocalModelsRoute(req, res, getStatuses) {
7842
8863
  const { method, url } = req;
7843
8864
  if (url !== "/api/v1/local-models/status") return false;
7844
8865
  if (method !== "GET") {
7845
- sendJSON9(res, 405, { error: "Method not allowed" });
8866
+ sendJSON11(res, 405, { error: "Method not allowed" });
7846
8867
  return true;
7847
8868
  }
7848
8869
  const statuses = getStatuses ? getStatuses() : [];
7849
- sendJSON9(res, 200, statuses);
8870
+ sendJSON11(res, 200, statuses);
7850
8871
  return true;
7851
8872
  }
7852
8873
 
7853
8874
  // src/server/static.ts
7854
- var fs12 = __toESM(require("fs"));
7855
- var path12 = __toESM(require("path"));
8875
+ var fs14 = __toESM(require("fs"));
8876
+ var path15 = __toESM(require("path"));
7856
8877
  var MIME_TYPES = {
7857
8878
  ".html": "text/html; charset=utf-8",
7858
8879
  ".js": "application/javascript; charset=utf-8",
@@ -7872,29 +8893,29 @@ var MIME_TYPES = {
7872
8893
  function handleStaticFile(req, res, dashboardDir) {
7873
8894
  const { method, url } = req;
7874
8895
  if (method !== "GET") return false;
7875
- const apiPrefix = path12.posix.join(path12.posix.sep, "api", path12.posix.sep);
7876
- const wsPath = path12.posix.join(path12.posix.sep, "ws");
8896
+ const apiPrefix = path15.posix.join(path15.posix.sep, "api", path15.posix.sep);
8897
+ const wsPath = path15.posix.join(path15.posix.sep, "ws");
7877
8898
  if (url?.startsWith(apiPrefix) || url === wsPath) return false;
7878
8899
  const urlPath = new URL(url ?? "/", "http://localhost").pathname;
7879
- const requestedPath = path12.join(dashboardDir, urlPath === "/" ? "index.html" : urlPath);
7880
- const resolved = path12.resolve(requestedPath);
7881
- if (!resolved.startsWith(path12.resolve(dashboardDir))) {
7882
- return serveFile(path12.join(dashboardDir, "index.html"), res);
8900
+ const requestedPath = path15.join(dashboardDir, urlPath === "/" ? "index.html" : urlPath);
8901
+ const resolved = path15.resolve(requestedPath);
8902
+ if (!resolved.startsWith(path15.resolve(dashboardDir))) {
8903
+ return serveFile(path15.join(dashboardDir, "index.html"), res);
7883
8904
  }
7884
- if (fs12.existsSync(resolved) && fs12.statSync(resolved).isFile()) {
8905
+ if (fs14.existsSync(resolved) && fs14.statSync(resolved).isFile()) {
7885
8906
  return serveFile(resolved, res);
7886
8907
  }
7887
- const indexPath = path12.join(dashboardDir, "index.html");
7888
- if (fs12.existsSync(indexPath)) {
8908
+ const indexPath = path15.join(dashboardDir, "index.html");
8909
+ if (fs14.existsSync(indexPath)) {
7889
8910
  return serveFile(indexPath, res);
7890
8911
  }
7891
8912
  return false;
7892
8913
  }
7893
8914
  function serveFile(filePath, res) {
7894
- const ext = path12.extname(filePath).toLowerCase();
8915
+ const ext = path15.extname(filePath).toLowerCase();
7895
8916
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
7896
8917
  try {
7897
- const content = fs12.readFileSync(filePath);
8918
+ const content = fs14.readFileSync(filePath);
7898
8919
  res.writeHead(200, { "Content-Type": contentType });
7899
8920
  res.end(content);
7900
8921
  return true;
@@ -7904,8 +8925,8 @@ function serveFile(filePath, res) {
7904
8925
  }
7905
8926
 
7906
8927
  // src/server/plan-watcher.ts
7907
- var fs13 = __toESM(require("fs"));
7908
- var path13 = __toESM(require("path"));
8928
+ var fs15 = __toESM(require("fs"));
8929
+ var path16 = __toESM(require("path"));
7909
8930
  var PlanWatcher = class {
7910
8931
  plansDir;
7911
8932
  queue;
@@ -7919,11 +8940,11 @@ var PlanWatcher = class {
7919
8940
  * Creates the directory if it does not exist.
7920
8941
  */
7921
8942
  start() {
7922
- fs13.mkdirSync(this.plansDir, { recursive: true });
7923
- this.watcher = fs13.watch(this.plansDir, (eventType, filename) => {
8943
+ fs15.mkdirSync(this.plansDir, { recursive: true });
8944
+ this.watcher = fs15.watch(this.plansDir, (eventType, filename) => {
7924
8945
  if (eventType === "rename" && filename && filename.endsWith(".md")) {
7925
- const filePath = path13.join(this.plansDir, filename);
7926
- if (fs13.existsSync(filePath)) {
8946
+ const filePath = path16.join(this.plansDir, filename);
8947
+ if (fs15.existsSync(filePath)) {
7927
8948
  void this.handleNewPlan(filename);
7928
8949
  }
7929
8950
  }
@@ -7958,7 +8979,7 @@ var import_node_crypto9 = require("crypto");
7958
8979
  var import_promises = require("fs/promises");
7959
8980
  var import_node_path = require("path");
7960
8981
  var import_bcryptjs = __toESM(require("bcryptjs"));
7961
- var import_types25 = require("@harness-engineering/types");
8982
+ var import_types26 = require("@harness-engineering/types");
7962
8983
  var BCRYPT_ROUNDS = 12;
7963
8984
  var LEGACY_ENV_ID = "tok_legacy_env";
7964
8985
  function genId() {
@@ -7973,8 +8994,8 @@ function parseToken(raw) {
7973
8994
  return { id: raw.slice(0, dot), secret: raw.slice(dot + 1) };
7974
8995
  }
7975
8996
  var TokenStore = class {
7976
- constructor(path19) {
7977
- this.path = path19;
8997
+ constructor(path24) {
8998
+ this.path = path24;
7978
8999
  }
7979
9000
  path;
7980
9001
  cache = null;
@@ -7985,7 +9006,7 @@ var TokenStore = class {
7985
9006
  const parsed = JSON.parse(raw);
7986
9007
  const list = Array.isArray(parsed) ? parsed : [];
7987
9008
  this.cache = list.map((entry) => {
7988
- const r = import_types25.AuthTokenSchema.safeParse(entry);
9009
+ const r = import_types26.AuthTokenSchema.safeParse(entry);
7989
9010
  return r.success ? r.data : null;
7990
9011
  }).filter((x) => x !== null);
7991
9012
  } catch (err) {
@@ -8047,7 +9068,7 @@ var TokenStore = class {
8047
9068
  }
8048
9069
  async list() {
8049
9070
  const records = await this.load();
8050
- return records.map((r) => import_types25.AuthTokenPublicSchema.parse(r));
9071
+ return records.map((r) => import_types26.AuthTokenPublicSchema.parse(r));
8051
9072
  }
8052
9073
  async revoke(id) {
8053
9074
  const records = await this.load();
@@ -8079,10 +9100,10 @@ var TokenStore = class {
8079
9100
  // src/auth/audit.ts
8080
9101
  var import_promises2 = require("fs/promises");
8081
9102
  var import_node_path2 = require("path");
8082
- var import_types26 = require("@harness-engineering/types");
9103
+ var import_types27 = require("@harness-engineering/types");
8083
9104
  var AuditLogger = class {
8084
- constructor(path19, opts = {}) {
8085
- this.path = path19;
9105
+ constructor(path24, opts = {}) {
9106
+ this.path = path24;
8086
9107
  this.opts = opts;
8087
9108
  }
8088
9109
  path;
@@ -8090,7 +9111,7 @@ var AuditLogger = class {
8090
9111
  queue = Promise.resolve();
8091
9112
  dirEnsured = false;
8092
9113
  async append(input) {
8093
- const entry = import_types26.AuthAuditEntrySchema.parse({
9114
+ const entry = import_types27.AuthAuditEntrySchema.parse({
8094
9115
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8095
9116
  tokenId: input.tokenId,
8096
9117
  ...input.tenantId ? { tenantId: input.tenantId } : {},
@@ -8166,20 +9187,79 @@ var V1_BRIDGE_ROUTES = [
8166
9187
  scope: "subscribe-webhook",
8167
9188
  description: "Webhook delivery queue depth + DLQ stats."
8168
9189
  },
9190
+ // Hermes Phase 4 — skill proposal review queue.
9191
+ {
9192
+ method: "GET",
9193
+ pattern: /^\/api\/v1\/proposals(?:\?.*)?$/,
9194
+ scope: "read-status",
9195
+ description: "List skill proposals (open + decided)."
9196
+ },
9197
+ {
9198
+ method: "GET",
9199
+ pattern: /^\/api\/v1\/proposals\/[^/]+(?:\?.*)?$/,
9200
+ scope: "read-status",
9201
+ description: "Get a single skill proposal."
9202
+ },
9203
+ {
9204
+ method: "POST",
9205
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/run-gate(?:\?.*)?$/,
9206
+ scope: "manage-proposals",
9207
+ description: "Run the soundness-review gate against a proposal."
9208
+ },
9209
+ {
9210
+ method: "POST",
9211
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/approve(?:\?.*)?$/,
9212
+ scope: "manage-proposals",
9213
+ description: "Approve a proposal \u2014 promotes the skill into the catalog."
9214
+ },
9215
+ {
9216
+ method: "POST",
9217
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/reject(?:\?.*)?$/,
9218
+ scope: "manage-proposals",
9219
+ description: "Reject a proposal with a one-line reason."
9220
+ },
9221
+ {
9222
+ method: "PATCH",
9223
+ pattern: /^\/api\/v1\/proposals\/[^/]+(?:\?.*)?$/,
9224
+ scope: "manage-proposals",
9225
+ description: "Edit proposal content (resets gate to not-run)."
9226
+ },
8169
9227
  // ── Phase 5 bridge primitives ──
8170
9228
  {
8171
9229
  method: "GET",
8172
9230
  pattern: /^\/api\/v1\/telemetry\/cache\/stats(?:\?.*)?$/,
8173
9231
  scope: "read-telemetry",
8174
9232
  description: "Prompt-cache hit/miss snapshot (rolling window)."
9233
+ },
9234
+ // ── Spec B Phase 5 routing observability ──
9235
+ // D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
9236
+ // precedent (read-only observability). A dedicated `read-routing`
9237
+ // scope was rejected to avoid a TokenScopeSchema + ADR cascade.
9238
+ {
9239
+ method: "GET",
9240
+ pattern: /^\/api\/v1\/routing\/config(?:\?.*)?$/,
9241
+ scope: "read-telemetry",
9242
+ description: "Current routing config + resolved fallback chains + known backends."
9243
+ },
9244
+ {
9245
+ method: "GET",
9246
+ pattern: /^\/api\/v1\/routing\/decisions(?:\?.*)?$/,
9247
+ scope: "read-telemetry",
9248
+ description: "Recent routing decisions (newest-first), filterable by skill/mode/backend."
9249
+ },
9250
+ {
9251
+ method: "POST",
9252
+ pattern: /^\/api\/v1\/routing\/trace(?:\?.*)?$/,
9253
+ scope: "read-telemetry",
9254
+ description: "Dry-run a routing decision without side effects (no bus emit, no dispatch)."
8175
9255
  }
8176
9256
  ];
8177
9257
  function isV1Bridge(method, url) {
8178
9258
  return V1_BRIDGE_ROUTES.some((r) => r.method === method && r.pattern.test(url));
8179
9259
  }
8180
- function requiredBridgeScope(method, path19) {
9260
+ function requiredBridgeScope(method, path24) {
8181
9261
  for (const r of V1_BRIDGE_ROUTES) {
8182
- if (r.method === method && r.pattern.test(path19)) return r.scope;
9262
+ if (r.method === method && r.pattern.test(path24)) return r.scope;
8183
9263
  }
8184
9264
  return null;
8185
9265
  }
@@ -8189,24 +9269,24 @@ function hasScope(held, required) {
8189
9269
  if (held.includes("admin")) return true;
8190
9270
  return held.includes(required);
8191
9271
  }
8192
- function requiredScopeForRoute(method, path19) {
8193
- const bridgeScope = requiredBridgeScope(method, path19);
9272
+ function requiredScopeForRoute(method, path24) {
9273
+ const bridgeScope = requiredBridgeScope(method, path24);
8194
9274
  if (bridgeScope) return bridgeScope;
8195
- if (path19 === "/api/v1/auth/token" && method === "POST") return "admin";
8196
- if (path19 === "/api/v1/auth/tokens" && method === "GET") return "admin";
8197
- if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path19) && method === "DELETE") return "admin";
8198
- if ((path19 === "/api/state" || path19 === "/api/v1/state") && method === "GET") return "read-status";
8199
- if (path19.startsWith("/api/interactions")) return "resolve-interaction";
8200
- if (path19.startsWith("/api/plans")) return "read-status";
8201
- if (path19.startsWith("/api/analyze") || path19.startsWith("/api/analyses")) return "read-status";
8202
- if (path19.startsWith("/api/roadmap-actions")) return "modify-roadmap";
8203
- if (path19.startsWith("/api/dispatch-actions")) return "trigger-job";
8204
- if (path19.startsWith("/api/local-model") || path19.startsWith("/api/local-models"))
9275
+ if (path24 === "/api/v1/auth/token" && method === "POST") return "admin";
9276
+ if (path24 === "/api/v1/auth/tokens" && method === "GET") return "admin";
9277
+ if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path24) && method === "DELETE") return "admin";
9278
+ if ((path24 === "/api/state" || path24 === "/api/v1/state") && method === "GET") return "read-status";
9279
+ if (path24.startsWith("/api/interactions")) return "resolve-interaction";
9280
+ if (path24.startsWith("/api/plans")) return "read-status";
9281
+ if (path24.startsWith("/api/analyze") || path24.startsWith("/api/analyses")) return "read-status";
9282
+ if (path24.startsWith("/api/roadmap-actions")) return "modify-roadmap";
9283
+ if (path24.startsWith("/api/dispatch-actions")) return "trigger-job";
9284
+ if (path24.startsWith("/api/local-model") || path24.startsWith("/api/local-models"))
8205
9285
  return "read-status";
8206
- if (path19.startsWith("/api/maintenance")) return "trigger-job";
8207
- if (path19.startsWith("/api/streams")) return "read-status";
8208
- if (path19.startsWith("/api/sessions")) return "read-status";
8209
- if (path19.startsWith("/api/chat-proxy")) return "trigger-job";
9286
+ if (path24.startsWith("/api/maintenance")) return "trigger-job";
9287
+ if (path24.startsWith("/api/streams")) return "read-status";
9288
+ if (path24.startsWith("/api/sessions")) return "read-status";
9289
+ if (path24.startsWith("/api/chat-proxy")) return "trigger-job";
8210
9290
  return null;
8211
9291
  }
8212
9292
 
@@ -8260,11 +9340,25 @@ var OrchestratorServer = class {
8260
9340
  roadmapPath;
8261
9341
  dispatchAdHoc;
8262
9342
  sessionsDir;
9343
+ /**
9344
+ * Project root used by file-backed routes (Phase 4 proposals at
9345
+ * `.harness/proposals/`). Defaults to process.cwd().
9346
+ */
9347
+ projectPath;
8263
9348
  maintenanceDeps = null;
8264
9349
  getLocalModelStatus = null;
8265
9350
  getLocalModelStatuses = null;
8266
9351
  webhooks;
8267
9352
  cacheMetrics;
9353
+ // Spec B Phase 5 — routing observability accessor closures + the WS
9354
+ // broadcaster unsubscribe handle (D-OP-4 dual safety net: server.stop()
9355
+ // calls it explicitly; clearListeners in Orchestrator.stop() is the
9356
+ // belt-and-suspenders second line).
9357
+ getBackendRouterFn = null;
9358
+ getRoutingDecisionBusFn = null;
9359
+ getRoutingConfigFn = null;
9360
+ getBackendsFn = null;
9361
+ routingDecisionUnsubscribe = null;
8268
9362
  recorder = null;
8269
9363
  planWatcher = null;
8270
9364
  tokenStore;
@@ -8277,8 +9371,8 @@ var OrchestratorServer = class {
8277
9371
  this.orchestrator = orchestrator;
8278
9372
  this.port = port;
8279
9373
  this.initDependencies(deps);
8280
- const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path14.resolve(".harness", "tokens.json");
8281
- const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path14.resolve(".harness", "audit.log");
9374
+ const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path17.resolve(".harness", "tokens.json");
9375
+ const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path17.resolve(".harness", "audit.log");
8282
9376
  this.tokenStore = new TokenStore(tokensPath);
8283
9377
  this.auditLogger = new AuditLogger(auditPath);
8284
9378
  this.httpServer = http.createServer(this.handleRequest.bind(this));
@@ -8291,19 +9385,24 @@ var OrchestratorServer = class {
8291
9385
  }
8292
9386
  initDependencies(deps) {
8293
9387
  this.interactionQueue = deps?.interactionQueue;
8294
- this.plansDir = deps?.plansDir ?? path14.resolve("docs", "plans");
8295
- this.dashboardDir = deps?.dashboardDir ?? path14.resolve("packages", "dashboard", "dist", "client");
9388
+ this.plansDir = deps?.plansDir ?? path17.resolve("docs", "plans");
9389
+ this.dashboardDir = deps?.dashboardDir ?? path17.resolve("packages", "dashboard", "dist", "client");
8296
9390
  this.claudeCommand = deps?.claudeCommand ?? "claude";
8297
9391
  this.pipeline = deps?.pipeline ?? null;
8298
9392
  this.analysisArchive = deps?.analysisArchive;
8299
9393
  this.roadmapPath = deps?.roadmapPath ?? null;
8300
9394
  this.dispatchAdHoc = deps?.dispatchAdHoc ?? null;
8301
- this.sessionsDir = deps?.sessionsDir ?? path14.resolve(".harness", "sessions");
9395
+ this.sessionsDir = deps?.sessionsDir ?? path17.resolve(".harness", "sessions");
9396
+ this.projectPath = deps?.projectPath ?? process.cwd();
8302
9397
  this.maintenanceDeps = deps?.maintenanceDeps ?? null;
8303
9398
  this.getLocalModelStatus = deps?.getLocalModelStatus ?? null;
8304
9399
  this.getLocalModelStatuses = deps?.getLocalModelStatuses ?? null;
8305
9400
  this.webhooks = deps?.webhooks;
8306
9401
  this.cacheMetrics = deps?.cacheMetrics;
9402
+ this.getBackendRouterFn = deps?.getBackendRouter ?? null;
9403
+ this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
9404
+ this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
9405
+ this.getBackendsFn = deps?.getBackends ?? null;
8307
9406
  }
8308
9407
  wireEvents() {
8309
9408
  this.stateChangeListener = (snapshot) => {
@@ -8314,6 +9413,12 @@ var OrchestratorServer = class {
8314
9413
  };
8315
9414
  this.orchestrator.on("state_change", this.stateChangeListener);
8316
9415
  this.orchestrator.on("agent_event", this.agentEventListener);
9416
+ const bus = this.getRoutingDecisionBusFn?.() ?? null;
9417
+ if (bus) {
9418
+ this.routingDecisionUnsubscribe = bus.subscribe((decision) => {
9419
+ this.broadcaster.broadcast("routing:decision", decision);
9420
+ });
9421
+ }
8317
9422
  }
8318
9423
  /**
8319
9424
  * Broadcast a new interaction to all WebSocket clients.
@@ -8469,6 +9574,23 @@ var OrchestratorServer = class {
8469
9574
  (req, res) => handleV1TelemetryRoute(req, res, {
8470
9575
  ...this.cacheMetrics ? { cacheMetrics: this.cacheMetrics } : {}
8471
9576
  }),
9577
+ // Spec B Phase 5 — routing observability. Returns 503 when the
9578
+ // backendFactory is null (legacy single-backend configs).
9579
+ (req, res) => handleV1RoutingRoute(req, res, {
9580
+ router: this.getBackendRouterFn?.() ?? null,
9581
+ bus: this.getRoutingDecisionBusFn?.() ?? null,
9582
+ routing: this.getRoutingConfigFn?.() ?? null,
9583
+ backends: this.getBackendsFn?.() ?? null
9584
+ }),
9585
+ // Hermes Phase 4 — skill proposal review queue. Read scopes
9586
+ // (`read-status`) and write scopes (`manage-proposals`) are enforced
9587
+ // upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
9588
+ // business logic. `projectPath` defaults to process.cwd() — that is
9589
+ // where `.harness/proposals/` lives in every deployment we ship.
9590
+ (req, res) => handleV1ProposalsRoute(req, res, {
9591
+ projectPath: this.projectPath,
9592
+ bus: this.orchestrator
9593
+ }),
8472
9594
  // Chat proxy route (spawns Claude Code CLI — no API key required)
8473
9595
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
8474
9596
  ];
@@ -8551,22 +9673,26 @@ var OrchestratorServer = class {
8551
9673
  return this.broadcaster.clientCount;
8552
9674
  }
8553
9675
  async start() {
8554
- (0, import_core8.assertPortUsable)(this.port, "orchestrator");
9676
+ (0, import_core11.assertPortUsable)(this.port, "orchestrator");
8555
9677
  if (this.interactionQueue) {
8556
9678
  this.planWatcher = new PlanWatcher(this.plansDir, this.interactionQueue);
8557
9679
  this.planWatcher.start();
8558
9680
  }
8559
- return new Promise((resolve6) => {
9681
+ return new Promise((resolve8) => {
8560
9682
  const host = getBindHost();
8561
9683
  this.httpServer.listen(this.port, host, () => {
8562
9684
  console.log(`Orchestrator API listening on ${host}:${this.port}`);
8563
- resolve6();
9685
+ resolve8();
8564
9686
  });
8565
9687
  });
8566
9688
  }
8567
9689
  stop() {
8568
9690
  this.orchestrator.removeListener("state_change", this.stateChangeListener);
8569
9691
  this.orchestrator.removeListener("agent_event", this.agentEventListener);
9692
+ if (this.routingDecisionUnsubscribe) {
9693
+ this.routingDecisionUnsubscribe();
9694
+ this.routingDecisionUnsubscribe = null;
9695
+ }
8570
9696
  if (this.planWatcher) {
8571
9697
  this.planWatcher.stop();
8572
9698
  this.planWatcher = null;
@@ -8580,7 +9706,7 @@ var OrchestratorServer = class {
8580
9706
  var import_node_crypto11 = require("crypto");
8581
9707
  var import_promises3 = require("fs/promises");
8582
9708
  var import_node_path3 = require("path");
8583
- var import_types27 = require("@harness-engineering/types");
9709
+ var import_types28 = require("@harness-engineering/types");
8584
9710
 
8585
9711
  // src/gateway/webhooks/signer.ts
8586
9712
  var import_node_crypto10 = require("crypto");
@@ -8610,8 +9736,8 @@ function genSecret2() {
8610
9736
  return (0, import_node_crypto11.randomBytes)(32).toString("base64url");
8611
9737
  }
8612
9738
  var WebhookStore = class {
8613
- constructor(path19) {
8614
- this.path = path19;
9739
+ constructor(path24) {
9740
+ this.path = path24;
8615
9741
  }
8616
9742
  path;
8617
9743
  cache = null;
@@ -8622,7 +9748,7 @@ var WebhookStore = class {
8622
9748
  const parsed = JSON.parse(raw);
8623
9749
  const list = Array.isArray(parsed) ? parsed : [];
8624
9750
  this.cache = list.map((entry) => {
8625
- const r = import_types27.WebhookSubscriptionSchema.safeParse(entry);
9751
+ const r = import_types28.WebhookSubscriptionSchema.safeParse(entry);
8626
9752
  return r.success ? r.data : null;
8627
9753
  }).filter((x) => x !== null);
8628
9754
  } catch (err) {
@@ -9002,7 +10128,12 @@ var WEBHOOK_TOPICS = [
9002
10128
  "maintenance:completed",
9003
10129
  "maintenance:error",
9004
10130
  "webhook.subscription.created",
9005
- "webhook.subscription.deleted"
10131
+ "webhook.subscription.deleted",
10132
+ // Hermes Phase 4 — skill proposal lifecycle. Subscriptions can use the
10133
+ // `proposal.*` glob pattern to receive all three.
10134
+ "proposal.created",
10135
+ "proposal.approved",
10136
+ "proposal.rejected"
9006
10137
  ];
9007
10138
  function newEventId2() {
9008
10139
  return `evt_${(0, import_node_crypto13.randomBytes)(8).toString("hex")}`;
@@ -9036,7 +10167,7 @@ function wireWebhookFanout({ bus, store, delivery }) {
9036
10167
 
9037
10168
  // src/gateway/telemetry/fanout.ts
9038
10169
  var import_node_crypto14 = require("crypto");
9039
- var import_core9 = require("@harness-engineering/core");
10170
+ var import_core12 = require("@harness-engineering/core");
9040
10171
  var TOPICS = {
9041
10172
  MAINTENANCE_STARTED: "maintenance:started",
9042
10173
  MAINTENANCE_COMPLETED: "maintenance:completed",
@@ -9171,7 +10302,7 @@ function wireTelemetryFanout(params) {
9171
10302
  spanId,
9172
10303
  ...parentSpanId !== void 0 ? { parentSpanId } : {},
9173
10304
  name: SPAN_NAME[topic],
9174
- kind: import_core9.SpanKind.INTERNAL,
10305
+ kind: import_core12.SpanKind.INTERNAL,
9175
10306
  startTimeNs: startNs,
9176
10307
  endTimeNs: startNs,
9177
10308
  attributes: buildAttributes(payload, { "harness.topic": topic }),
@@ -9427,6 +10558,33 @@ var ENVELOPE_DERIVERS = {
9427
10558
  summary: data.message ?? "If you see this, your notification sink is working.",
9428
10559
  severity: "info"
9429
10560
  };
10561
+ },
10562
+ // Hermes Phase 4 — skill proposal lifecycle events.
10563
+ "proposal.created": (event) => {
10564
+ const data = asObj(event.data);
10565
+ const label = data.kind === "refinement" ? `refinement of ${data.targetSkill ?? "(unknown skill)"}` : data.name ?? "(new skill)";
10566
+ return {
10567
+ title: `New skill proposal: ${label}`,
10568
+ summary: truncate(data.justification ?? "No justification provided.", 240),
10569
+ severity: "info"
10570
+ };
10571
+ },
10572
+ "proposal.approved": (event) => {
10573
+ const data = asObj(event.data);
10574
+ const label = data.name ?? data.targetSkill ?? "(unknown skill)";
10575
+ return {
10576
+ title: `Skill proposal approved: ${label}`,
10577
+ summary: `Approved by ${data.decidedBy ?? "(unknown reviewer)"}.`,
10578
+ severity: "success"
10579
+ };
10580
+ },
10581
+ "proposal.rejected": (event) => {
10582
+ const data = asObj(event.data);
10583
+ return {
10584
+ title: "Skill proposal rejected",
10585
+ summary: truncate(data.reason ?? "No reason provided.", 240),
10586
+ severity: "warning"
10587
+ };
9430
10588
  }
9431
10589
  };
9432
10590
  function truncate(s, max) {
@@ -9471,7 +10629,11 @@ var NOTIFICATION_TOPICS = [
9471
10629
  "interaction.resolved",
9472
10630
  "maintenance:started",
9473
10631
  "maintenance:completed",
9474
- "maintenance:error"
10632
+ "maintenance:error",
10633
+ // Hermes Phase 4 — skill proposal lifecycle.
10634
+ "proposal.created",
10635
+ "proposal.approved",
10636
+ "proposal.rejected"
9475
10637
  ];
9476
10638
  function newEventId4() {
9477
10639
  return `evt_${(0, import_node_crypto15.randomBytes)(8).toString("hex")}`;
@@ -9527,7 +10689,7 @@ function wireNotificationSinks({ bus, registry }) {
9527
10689
  }
9528
10690
 
9529
10691
  // src/orchestrator.ts
9530
- var import_core13 = require("@harness-engineering/core");
10692
+ var import_core16 = require("@harness-engineering/core");
9531
10693
 
9532
10694
  // src/logging/logger.ts
9533
10695
  var StructuredLogger = class {
@@ -9569,7 +10731,7 @@ var StructuredLogger = class {
9569
10731
  // src/workspace/config-scanner.ts
9570
10732
  var import_node_fs = require("fs");
9571
10733
  var import_node_path4 = require("path");
9572
- var import_core10 = require("@harness-engineering/core");
10734
+ var import_core13 = require("@harness-engineering/core");
9573
10735
  var CONFIG_FILES = ["CLAUDE.md", "AGENTS.md", ".gemini/settings.json", "skill.yaml"];
9574
10736
  var BLOCKING_INJECTION_PREFIXES = ["INJ-UNI-", "INJ-REROL-"];
9575
10737
  var DOWNGRADED_SECURITY_RULES = /* @__PURE__ */ new Set(["SEC-AGT-006"]);
@@ -9591,25 +10753,25 @@ async function scanSingleFile(filePath, targetDir, scanner) {
9591
10753
  } catch {
9592
10754
  return null;
9593
10755
  }
9594
- const injectionFindings = (0, import_core10.scanForInjection)(content);
9595
- const findings = (0, import_core10.mapInjectionFindings)(injectionFindings);
10756
+ const injectionFindings = (0, import_core13.scanForInjection)(content);
10757
+ const findings = (0, import_core13.mapInjectionFindings)(injectionFindings);
9596
10758
  const secFindings = await scanner.scanFile(filePath);
9597
- findings.push(...(0, import_core10.mapSecurityFindings)(secFindings, findings));
10759
+ findings.push(...(0, import_core13.mapSecurityFindings)(secFindings, findings));
9598
10760
  const adjusted = adjustFindingSeverity(findings);
9599
10761
  return {
9600
10762
  file: (0, import_node_path4.relative)(targetDir, filePath).replaceAll("\\", "/"),
9601
10763
  findings: adjusted,
9602
- overallSeverity: (0, import_core10.computeOverallSeverity)(adjusted)
10764
+ overallSeverity: (0, import_core13.computeOverallSeverity)(adjusted)
9603
10765
  };
9604
10766
  }
9605
10767
  async function scanWorkspaceConfig(workspacePath) {
9606
- const scanner = new import_core10.SecurityScanner((0, import_core10.parseSecurityConfig)({}));
10768
+ const scanner = new import_core13.SecurityScanner((0, import_core13.parseSecurityConfig)({}));
9607
10769
  const results = [];
9608
10770
  for (const configFile of CONFIG_FILES) {
9609
10771
  const result = await scanSingleFile((0, import_node_path4.join)(workspacePath, configFile), workspacePath, scanner);
9610
10772
  if (result) results.push(result);
9611
10773
  }
9612
- return { exitCode: (0, import_core10.computeScanExitCode)(results), results };
10774
+ return { exitCode: (0, import_core13.computeScanExitCode)(results), results };
9613
10775
  }
9614
10776
 
9615
10777
  // src/maintenance/task-registry.ts
@@ -9792,6 +10954,19 @@ var BUILT_IN_TASKS = [
9792
10954
  schedule: "*/15 * * * *",
9793
10955
  branch: null,
9794
10956
  checkCommand: ["harness", "sync-main", "--json"]
10957
+ },
10958
+ // Hermes Phase 4 — one-shot backfill that stamps `provenance: user-authored`
10959
+ // on every existing catalog skill. Schedule is Feb 31 (a date that never
10960
+ // exists) so the cron loop never fires it automatically; operators trigger
10961
+ // it once via the dashboard "Run now" button or `harness backfill-skill-
10962
+ // provenance` after upgrading to Phase 4.
10963
+ {
10964
+ id: "proposal-provenance-backfill",
10965
+ type: "housekeeping",
10966
+ description: "Backfill provenance: user-authored on every existing skill (one-shot, idempotent)",
10967
+ schedule: "0 0 31 2 *",
10968
+ branch: null,
10969
+ checkCommand: ["backfill-skill-provenance"]
9795
10970
  }
9796
10971
  ];
9797
10972
 
@@ -9884,24 +11059,49 @@ var MaintenanceScheduler = class {
9884
11059
  this.resolvedTasks = this.resolveTasks();
9885
11060
  }
9886
11061
  /**
9887
- * Merge built-in task definitions with config overrides.
9888
- * Tasks with `enabled: false` are filtered out.
9889
- * Schedule overrides replace the default cron expression.
11062
+ * Merge built-in task definitions with config overrides, then append
11063
+ * Hermes Phase 2 `customTasks` (also respecting `tasks.<id>.enabled`
11064
+ * overrides). Tasks with `enabled: false` are filtered out. Schedule
11065
+ * overrides replace the default cron expression.
9890
11066
  */
9891
11067
  resolveTasks() {
9892
11068
  const overrides = this.config.tasks ?? {};
9893
- return BUILT_IN_TASKS.filter((task) => {
11069
+ const customs = this.config.customTasks ?? {};
11070
+ const merged = [];
11071
+ for (const task of BUILT_IN_TASKS) {
9894
11072
  const override = overrides[task.id];
9895
- if (override?.enabled === false) return false;
9896
- return true;
9897
- }).map((task) => {
9898
- const override = overrides[task.id];
9899
- if (!override) return { ...task };
9900
- return {
11073
+ if (override?.enabled === false) continue;
11074
+ merged.push({
9901
11075
  ...task,
9902
- ...override.schedule !== void 0 && { schedule: override.schedule }
9903
- };
9904
- });
11076
+ ...override?.schedule !== void 0 && { schedule: override.schedule }
11077
+ });
11078
+ }
11079
+ for (const [id, def] of Object.entries(customs)) {
11080
+ const override = overrides[id];
11081
+ if (override?.enabled === false) continue;
11082
+ merged.push({
11083
+ id,
11084
+ type: def.type,
11085
+ description: def.description,
11086
+ schedule: override?.schedule ?? def.schedule,
11087
+ branch: def.branch,
11088
+ ...def.checkCommand !== void 0 && { checkCommand: def.checkCommand },
11089
+ ...def.checkScript !== void 0 && { checkScript: def.checkScript },
11090
+ ...def.fixSkill !== void 0 && { fixSkill: def.fixSkill },
11091
+ ...def.inlineSkills !== void 0 && { inlineSkills: def.inlineSkills },
11092
+ ...def.inlineSkillsBudgetTokens !== void 0 && {
11093
+ inlineSkillsBudgetTokens: def.inlineSkillsBudgetTokens
11094
+ },
11095
+ ...def.contextFrom !== void 0 && { contextFrom: def.contextFrom },
11096
+ ...def.contextFromMaxAgeMinutes !== void 0 && {
11097
+ contextFromMaxAgeMinutes: def.contextFromMaxAgeMinutes
11098
+ },
11099
+ ...def.outputRetention !== void 0 && { outputRetention: def.outputRetention },
11100
+ ...def.costCeiling !== void 0 && { costCeiling: def.costCeiling },
11101
+ isCustom: true
11102
+ });
11103
+ }
11104
+ return merged;
9905
11105
  }
9906
11106
  /** Returns the resolved (merged) task list. Useful for testing and dashboard. */
9907
11107
  getResolvedTasks() {
@@ -10074,27 +11274,27 @@ var MaintenanceScheduler = class {
10074
11274
  };
10075
11275
 
10076
11276
  // src/maintenance/leader-elector.ts
10077
- var import_types28 = require("@harness-engineering/types");
11277
+ var import_types29 = require("@harness-engineering/types");
10078
11278
  var SingleProcessLeaderElector = class {
10079
11279
  async electLeader() {
10080
- return (0, import_types28.Ok)("claimed");
11280
+ return (0, import_types29.Ok)("claimed");
10081
11281
  }
10082
11282
  };
10083
11283
 
10084
11284
  // src/maintenance/reporter.ts
10085
- var fs14 = __toESM(require("fs"));
10086
- var path15 = __toESM(require("path"));
10087
- var import_zod15 = require("zod");
10088
- var RunResultSchema = import_zod15.z.object({
10089
- taskId: import_zod15.z.string(),
10090
- startedAt: import_zod15.z.string(),
10091
- completedAt: import_zod15.z.string(),
10092
- status: import_zod15.z.enum(["success", "failure", "skipped", "no-issues"]),
10093
- findings: import_zod15.z.number(),
10094
- fixed: import_zod15.z.number(),
10095
- prUrl: import_zod15.z.string().nullable(),
10096
- prUpdated: import_zod15.z.boolean(),
10097
- error: import_zod15.z.string().optional()
11285
+ var fs16 = __toESM(require("fs"));
11286
+ var path18 = __toESM(require("path"));
11287
+ var import_zod17 = require("zod");
11288
+ var RunResultSchema = import_zod17.z.object({
11289
+ taskId: import_zod17.z.string(),
11290
+ startedAt: import_zod17.z.string(),
11291
+ completedAt: import_zod17.z.string(),
11292
+ status: import_zod17.z.enum(["success", "failure", "skipped", "no-issues"]),
11293
+ findings: import_zod17.z.number(),
11294
+ fixed: import_zod17.z.number(),
11295
+ prUrl: import_zod17.z.string().nullable(),
11296
+ prUpdated: import_zod17.z.boolean(),
11297
+ error: import_zod17.z.string().optional()
10098
11298
  });
10099
11299
  var MAX_HISTORY = 500;
10100
11300
  var fallbackLogger = {
@@ -10118,10 +11318,10 @@ var MaintenanceReporter = class {
10118
11318
  */
10119
11319
  async load() {
10120
11320
  try {
10121
- await fs14.promises.mkdir(this.persistDir, { recursive: true });
10122
- const filePath = path15.join(this.persistDir, "history.json");
10123
- const data = await fs14.promises.readFile(filePath, "utf-8");
10124
- const parsed = import_zod15.z.array(RunResultSchema).safeParse(JSON.parse(data));
11321
+ await fs16.promises.mkdir(this.persistDir, { recursive: true });
11322
+ const filePath = path18.join(this.persistDir, "history.json");
11323
+ const data = await fs16.promises.readFile(filePath, "utf-8");
11324
+ const parsed = import_zod17.z.array(RunResultSchema).safeParse(JSON.parse(data));
10125
11325
  if (parsed.success) {
10126
11326
  this.history = parsed.data.slice(0, MAX_HISTORY);
10127
11327
  }
@@ -10154,9 +11354,9 @@ var MaintenanceReporter = class {
10154
11354
  */
10155
11355
  async persist() {
10156
11356
  try {
10157
- await fs14.promises.mkdir(this.persistDir, { recursive: true });
10158
- const filePath = path15.join(this.persistDir, "history.json");
10159
- await fs14.promises.writeFile(filePath, JSON.stringify(this.history, null, 2), "utf-8");
11357
+ await fs16.promises.mkdir(this.persistDir, { recursive: true });
11358
+ const filePath = path18.join(this.persistDir, "history.json");
11359
+ await fs16.promises.writeFile(filePath, JSON.stringify(this.history, null, 2), "utf-8");
10160
11360
  } catch (err) {
10161
11361
  this.logger.error("MaintenanceReporter: failed to persist history", { error: String(err) });
10162
11362
  }
@@ -10172,6 +11372,9 @@ var TaskRunner = class {
10172
11372
  cwd;
10173
11373
  prManager;
10174
11374
  baseBranch;
11375
+ checkScriptRunner;
11376
+ contextResolver;
11377
+ outputStore;
10175
11378
  constructor(options) {
10176
11379
  this.config = options.config;
10177
11380
  this.checkRunner = options.checkRunner;
@@ -10180,27 +11383,49 @@ var TaskRunner = class {
10180
11383
  this.cwd = options.cwd;
10181
11384
  this.prManager = options.prManager ?? null;
10182
11385
  this.baseBranch = options.baseBranch ?? "main";
11386
+ this.checkScriptRunner = options.checkScriptRunner ?? null;
11387
+ this.contextResolver = options.contextResolver ?? null;
11388
+ this.outputStore = options.outputStore ?? null;
10183
11389
  }
10184
11390
  /**
10185
11391
  * Run a maintenance task and return the result.
10186
11392
  * Dispatches to the appropriate execution path based on task type.
10187
11393
  * Never throws -- errors are captured in the RunResult.
11394
+ *
11395
+ * @param task - Resolved task definition.
11396
+ * @param origin - Hermes Phase 2 trigger-source tag; defaults to `'cron'`
11397
+ * when called from the scheduler path.
10188
11398
  */
10189
- async run(task) {
11399
+ async run(task, origin = "cron") {
10190
11400
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
11401
+ let result;
11402
+ let captured;
10191
11403
  try {
10192
11404
  switch (task.type) {
10193
- case "mechanical-ai":
10194
- return await this.runMechanicalAI(task, startedAt);
11405
+ case "mechanical-ai": {
11406
+ const out = await this.runMechanicalAI(task, startedAt);
11407
+ result = out.result;
11408
+ captured = out.captured;
11409
+ break;
11410
+ }
10195
11411
  case "pure-ai":
10196
- return await this.runPureAI(task, startedAt);
10197
- case "report-only":
10198
- return await this.runReportOnly(task, startedAt);
10199
- case "housekeeping":
10200
- return await this.runHousekeeping(task, startedAt);
11412
+ result = await this.runPureAI(task, startedAt);
11413
+ break;
11414
+ case "report-only": {
11415
+ const out = await this.runReportOnly(task, startedAt);
11416
+ result = out.result;
11417
+ captured = out.captured;
11418
+ break;
11419
+ }
11420
+ case "housekeeping": {
11421
+ const out = await this.runHousekeeping(task, startedAt);
11422
+ result = out.result;
11423
+ captured = out.captured;
11424
+ break;
11425
+ }
10201
11426
  default: {
10202
11427
  const _exhaustive = task.type;
10203
- return this.failureResult(
11428
+ result = this.failureResult(
10204
11429
  task.id,
10205
11430
  startedAt,
10206
11431
  `Unknown task type: ${String(_exhaustive)}`
@@ -10208,85 +11433,193 @@ var TaskRunner = class {
10208
11433
  }
10209
11434
  }
10210
11435
  } catch (err) {
10211
- return this.failureResult(task.id, startedAt, String(err));
11436
+ result = this.failureResult(task.id, startedAt, String(err));
11437
+ }
11438
+ result.origin = origin;
11439
+ await this.persistOutput(task, result, captured, origin);
11440
+ return result;
11441
+ }
11442
+ async persistOutput(task, result, captured, origin) {
11443
+ if (!this.outputStore) return;
11444
+ const entry = {
11445
+ taskId: result.taskId,
11446
+ startedAt: result.startedAt,
11447
+ completedAt: result.completedAt,
11448
+ status: result.status,
11449
+ findings: result.findings,
11450
+ fixed: result.fixed,
11451
+ prUrl: result.prUrl,
11452
+ prUpdated: result.prUpdated,
11453
+ origin,
11454
+ ...result.error !== void 0 && { error: result.error },
11455
+ ...result.costUsd !== void 0 && { costUsd: result.costUsd },
11456
+ ...captured?.stdout !== void 0 && { stdout: captured.stdout },
11457
+ ...captured?.stderr !== void 0 && { stderr: captured.stderr },
11458
+ ...captured?.structured !== void 0 && { structured: captured.structured },
11459
+ ...captured?.context !== void 0 && { context: captured.context }
11460
+ };
11461
+ try {
11462
+ await this.outputStore.write(task.id, entry, task.outputRetention);
11463
+ } catch {
10212
11464
  }
10213
11465
  }
10214
11466
  /**
10215
- * Mechanical-AI: run check command, dispatch AI agent only if fixable findings exist.
11467
+ * Run the check step using whichever runner the task asks for. Custom
11468
+ * tasks that declare `checkScript` go through the Hermes Phase 2
11469
+ * `CheckScriptRunner`; built-ins (and customs that use the legacy
11470
+ * `checkCommand` shape) go through the original heuristic runner.
10216
11471
  */
10217
- async runMechanicalAI(task, startedAt) {
11472
+ async runCheckStep(task) {
11473
+ if (task.checkScript) {
11474
+ if (!this.checkScriptRunner) {
11475
+ throw new Error(
11476
+ `task '${task.id}' declares checkScript but no CheckScriptRunner is configured`
11477
+ );
11478
+ }
11479
+ const r2 = await this.checkScriptRunner.run(task.checkScript, this.cwd);
11480
+ return {
11481
+ passed: r2.passed,
11482
+ findings: r2.findings,
11483
+ stdout: r2.output,
11484
+ stderr: r2.stderr,
11485
+ structured: r2.structured ? r2.structured : null
11486
+ };
11487
+ }
10218
11488
  if (!task.checkCommand || task.checkCommand.length === 0) {
10219
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing checkCommand");
11489
+ throw new Error(`task '${task.id}' is missing checkCommand`);
10220
11490
  }
11491
+ const r = await this.checkRunner.run(task.checkCommand, this.cwd);
11492
+ return {
11493
+ passed: r.passed,
11494
+ findings: r.findings,
11495
+ stdout: r.output,
11496
+ stderr: "",
11497
+ structured: null
11498
+ };
11499
+ }
11500
+ /**
11501
+ * Hermes Phase 2 — Compose the agent prompt-context block from inlined
11502
+ * skills + upstream task outputs. Returns an empty string when nothing
11503
+ * is configured (or when the resolver is absent), which is the safe
11504
+ * no-op default.
11505
+ */
11506
+ async composePromptContext(task) {
11507
+ if (!this.contextResolver) return "";
11508
+ const skills = await this.contextResolver.resolveInlineSkills(
11509
+ task.inlineSkills,
11510
+ task.inlineSkillsBudgetTokens ?? 8e3
11511
+ );
11512
+ const upstream = await this.contextResolver.resolveContextFrom(task.contextFrom, {
11513
+ maxAgeMinutes: task.contextFromMaxAgeMinutes ?? 1440
11514
+ });
11515
+ return [skills, upstream].filter(Boolean).join("\n");
11516
+ }
11517
+ /**
11518
+ * Mechanical-AI: run check (legacy or Phase 2 script), dispatch AI agent
11519
+ * only if fixable findings exist; persist captured stdout/stderr/context
11520
+ * via the output store on the way out.
11521
+ */
11522
+ async runMechanicalAI(task, startedAt) {
10221
11523
  if (!task.fixSkill) {
10222
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing fixSkill");
11524
+ return wrap(this.failureResult(task.id, startedAt, "mechanical-ai task missing fixSkill"));
10223
11525
  }
10224
11526
  if (!task.branch) {
10225
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing branch");
11527
+ return wrap(this.failureResult(task.id, startedAt, "mechanical-ai task missing branch"));
11528
+ }
11529
+ if (!task.checkCommand && !task.checkScript) {
11530
+ return wrap(
11531
+ this.failureResult(
11532
+ task.id,
11533
+ startedAt,
11534
+ "mechanical-ai task missing checkCommand or checkScript"
11535
+ )
11536
+ );
10226
11537
  }
10227
- const checkResult = await this.checkRunner.run(task.checkCommand, this.cwd);
10228
- if (checkResult.findings === 0) {
11538
+ let check;
11539
+ try {
11540
+ check = await this.runCheckStep(task);
11541
+ } catch (err) {
11542
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11543
+ }
11544
+ const promptContext = await this.composePromptContext(task);
11545
+ const baseCaptured = {
11546
+ stdout: check.stdout,
11547
+ stderr: check.stderr,
11548
+ structured: check.structured,
11549
+ ...promptContext ? { context: promptContext } : {}
11550
+ };
11551
+ const wakeAgentExplicitlyFalse = check.structured !== null && typeof check.structured === "object" && check.structured.wakeAgent === false;
11552
+ if (check.findings === 0 || wakeAgentExplicitlyFalse) {
10229
11553
  return {
10230
- taskId: task.id,
10231
- startedAt,
10232
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
10233
- status: "no-issues",
10234
- findings: 0,
10235
- fixed: 0,
10236
- prUrl: null,
10237
- prUpdated: false
11554
+ result: {
11555
+ taskId: task.id,
11556
+ startedAt,
11557
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11558
+ status: "no-issues",
11559
+ findings: check.findings,
11560
+ fixed: 0,
11561
+ prUrl: null,
11562
+ prUpdated: false
11563
+ },
11564
+ captured: baseCaptured
10238
11565
  };
10239
11566
  }
10240
11567
  if (this.prManager) {
10241
11568
  try {
10242
11569
  await this.prManager.ensureBranch(task.branch, this.baseBranch);
10243
11570
  } catch (err) {
10244
- return this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`);
11571
+ return wrap(
11572
+ this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`),
11573
+ baseCaptured
11574
+ );
10245
11575
  }
10246
11576
  }
10247
11577
  const backendName = this.resolveBackend(task.id);
10248
11578
  let agentResult;
10249
11579
  try {
10250
- agentResult = await this.agentDispatcher.dispatch(
10251
- task.fixSkill,
10252
- task.branch,
10253
- backendName,
10254
- this.cwd
10255
- );
11580
+ agentResult = promptContext ? await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd, {
11581
+ promptContext
11582
+ }) : await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd);
10256
11583
  } catch (err) {
10257
11584
  return {
10258
- taskId: task.id,
10259
- startedAt,
10260
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
10261
- status: "failure",
10262
- findings: checkResult.findings,
10263
- fixed: 0,
10264
- prUrl: null,
10265
- prUpdated: false,
10266
- error: `Agent dispatch failed: ${String(err)}`
11585
+ result: {
11586
+ taskId: task.id,
11587
+ startedAt,
11588
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11589
+ status: "failure",
11590
+ findings: check.findings,
11591
+ fixed: 0,
11592
+ prUrl: null,
11593
+ prUpdated: false,
11594
+ error: `Agent dispatch failed: ${String(err)}`
11595
+ },
11596
+ captured: baseCaptured
10267
11597
  };
10268
11598
  }
10269
11599
  let prUrl = null;
10270
11600
  let prUpdated = false;
10271
11601
  if (this.prManager && agentResult.producedCommits) {
10272
11602
  try {
10273
- const summary = `Findings: ${checkResult.findings}, Fixed: ${agentResult.fixed}`;
11603
+ const summary = `Findings: ${check.findings}, Fixed: ${agentResult.fixed}`;
10274
11604
  const prResult = await this.prManager.ensurePR(task, summary);
10275
11605
  prUrl = prResult.prUrl;
10276
11606
  prUpdated = prResult.prUpdated;
10277
- } catch (err) {
10278
- console.warn(`[maintenance] PR creation failed for task ${task.id}: ${String(err)}`);
10279
- }
10280
- }
10281
- return {
10282
- taskId: task.id,
10283
- startedAt,
10284
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
10285
- status: "success",
10286
- findings: checkResult.findings,
10287
- fixed: agentResult.fixed,
10288
- prUrl,
10289
- prUpdated
11607
+ } catch (err) {
11608
+ console.warn(`[maintenance] PR creation failed for task ${task.id}: ${String(err)}`);
11609
+ }
11610
+ }
11611
+ return {
11612
+ result: {
11613
+ taskId: task.id,
11614
+ startedAt,
11615
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11616
+ status: "success",
11617
+ findings: check.findings,
11618
+ fixed: agentResult.fixed,
11619
+ prUrl,
11620
+ prUpdated
11621
+ },
11622
+ captured: baseCaptured
10290
11623
  };
10291
11624
  }
10292
11625
  /**
@@ -10306,15 +11639,13 @@ var TaskRunner = class {
10306
11639
  return this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`);
10307
11640
  }
10308
11641
  }
11642
+ const promptContext = await this.composePromptContext(task);
10309
11643
  const backendName = this.resolveBackend(task.id);
10310
11644
  let agentResult;
10311
11645
  try {
10312
- agentResult = await this.agentDispatcher.dispatch(
10313
- task.fixSkill,
10314
- task.branch,
10315
- backendName,
10316
- this.cwd
10317
- );
11646
+ agentResult = promptContext ? await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd, {
11647
+ promptContext
11648
+ }) : await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd);
10318
11649
  } catch (err) {
10319
11650
  return this.failureResult(task.id, startedAt, `Agent dispatch failed: ${String(err)}`);
10320
11651
  }
@@ -10342,7 +11673,7 @@ var TaskRunner = class {
10342
11673
  };
10343
11674
  }
10344
11675
  /**
10345
- * Report-only: run check command, record metrics, no AI dispatch.
11676
+ * Report-only: run check (legacy or Phase 2 script), record metrics, no AI dispatch.
10346
11677
  *
10347
11678
  * Honors the JSON status contract emitted by Phase 4/5 CLIs (`harness pulse run`
10348
11679
  * and `harness compound scan-candidates` in `--non-interactive` mode):
@@ -10352,13 +11683,24 @@ var TaskRunner = class {
10352
11683
  * Legacy report-only tasks emit free-form output and fall through to 'success'.
10353
11684
  */
10354
11685
  async runReportOnly(task, startedAt) {
10355
- if (!task.checkCommand || task.checkCommand.length === 0) {
10356
- return this.failureResult(task.id, startedAt, "report-only task missing checkCommand");
11686
+ if (!task.checkCommand && !task.checkScript) {
11687
+ return wrap(
11688
+ this.failureResult(
11689
+ task.id,
11690
+ startedAt,
11691
+ "report-only task missing checkCommand or checkScript"
11692
+ )
11693
+ );
11694
+ }
11695
+ let check;
11696
+ try {
11697
+ check = await this.runCheckStep(task);
11698
+ } catch (err) {
11699
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
10357
11700
  }
10358
- const checkResult = await this.checkRunner.run(task.checkCommand, this.cwd);
10359
- const parsed = parseStatusLine(checkResult.output);
11701
+ const parsed = parseStatusLine(check.stdout);
10360
11702
  const status = parsed?.status ?? "success";
10361
- const findings = parsed === null ? checkResult.findings : typeof parsed.candidatesFound === "number" ? parsed.candidatesFound : 0;
11703
+ const findings = parsed === null ? check.findings : typeof parsed.candidatesFound === "number" ? parsed.candidatesFound : 0;
10362
11704
  const result = {
10363
11705
  taskId: task.id,
10364
11706
  startedAt,
@@ -10372,7 +11714,10 @@ var TaskRunner = class {
10372
11714
  if (parsed?.error) {
10373
11715
  result.error = parsed.error;
10374
11716
  }
10375
- return result;
11717
+ return {
11718
+ result,
11719
+ captured: { stdout: check.stdout, stderr: check.stderr, structured: check.structured }
11720
+ };
10376
11721
  }
10377
11722
  /**
10378
11723
  * Housekeeping: run command directly, no AI, no PR.
@@ -10383,17 +11728,39 @@ var TaskRunner = class {
10383
11728
  * - sync-main contract: updated/no-op/skipped/error → mapped onto the run-result status
10384
11729
  * Legacy housekeeping commands that emit no JSON keep the prior behavior:
10385
11730
  * status: 'success', findings: 0.
11731
+ *
11732
+ * Hermes Phase 2: a `checkScript` may replace `checkCommand` for housekeeping
11733
+ * tasks; the runner falls through to the same JSON-status parsing path.
10386
11734
  */
10387
11735
  async runHousekeeping(task, startedAt) {
10388
- if (!task.checkCommand || task.checkCommand.length === 0) {
10389
- return this.failureResult(task.id, startedAt, "housekeeping task missing checkCommand");
11736
+ if (!task.checkCommand && !task.checkScript) {
11737
+ return wrap(
11738
+ this.failureResult(
11739
+ task.id,
11740
+ startedAt,
11741
+ "housekeeping task missing checkCommand or checkScript"
11742
+ )
11743
+ );
10390
11744
  }
10391
11745
  let stdout;
10392
- try {
10393
- const out = await this.commandExecutor.exec(task.checkCommand, this.cwd);
10394
- stdout = out.stdout ?? "";
10395
- } catch (err) {
10396
- return this.failureResult(task.id, startedAt, String(err));
11746
+ let stderr = "";
11747
+ let structured = null;
11748
+ if (task.checkScript) {
11749
+ try {
11750
+ const r = await this.runCheckStep(task);
11751
+ stdout = r.stdout;
11752
+ stderr = r.stderr;
11753
+ structured = r.structured;
11754
+ } catch (err) {
11755
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11756
+ }
11757
+ } else {
11758
+ try {
11759
+ const out = await this.commandExecutor.exec(task.checkCommand, this.cwd);
11760
+ stdout = out.stdout ?? "";
11761
+ } catch (err) {
11762
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11763
+ }
10397
11764
  }
10398
11765
  const parsed = parseStatusLine(stdout);
10399
11766
  const status = parsed?.status ?? "success";
@@ -10408,7 +11775,7 @@ var TaskRunner = class {
10408
11775
  prUpdated: false
10409
11776
  };
10410
11777
  if (parsed?.error) result.error = parsed.error;
10411
- return result;
11778
+ return { result, captured: { stdout, stderr, structured } };
10412
11779
  }
10413
11780
  /**
10414
11781
  * Resolve which AI backend name to use for a given task.
@@ -10433,6 +11800,9 @@ var TaskRunner = class {
10433
11800
  };
10434
11801
  }
10435
11802
  };
11803
+ function wrap(result, captured) {
11804
+ return captured ? { result, captured } : { result };
11805
+ }
10436
11806
  function parseStatusLine(output) {
10437
11807
  const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
10438
11808
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -10470,12 +11840,561 @@ function parseStatusLine(output) {
10470
11840
  return null;
10471
11841
  }
10472
11842
 
10473
- // src/orchestrator.ts
10474
- function useCaseForBackendParam(issue, backendParam) {
10475
- if (backendParam === "local") return { kind: "tier", tier: "quick-fix" };
10476
- const tier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
10477
- return { kind: "tier", tier };
11843
+ // src/maintenance/check-script-runner.ts
11844
+ var import_node_child_process11 = require("child_process");
11845
+ var import_node_util3 = require("util");
11846
+ var path19 = __toESM(require("path"));
11847
+ var execFileAsync = (0, import_node_util3.promisify)(import_node_child_process11.execFile);
11848
+ var CheckScriptRunner = class {
11849
+ constructor(cwd) {
11850
+ this.cwd = cwd;
11851
+ }
11852
+ cwd;
11853
+ async run(spec, cwd) {
11854
+ const projectRoot = cwd ?? this.cwd;
11855
+ const captured = await captureScript(spec, projectRoot);
11856
+ const parseJson = spec.parseStdoutJson !== false;
11857
+ const structured = parseJson ? parseStatusEnvelope(captured.stdout) : null;
11858
+ if (structured) {
11859
+ return mapStructured(structured, captured.stdout, captured.stderr);
11860
+ }
11861
+ return heuristicResult(captured.stdout, captured.stderr, captured.exitedAbnormally);
11862
+ }
11863
+ };
11864
+ async function captureScript(spec, projectRoot) {
11865
+ const resolved = path19.isAbsolute(spec.path) ? spec.path : path19.resolve(projectRoot, spec.path);
11866
+ const args = spec.args ?? [];
11867
+ const timeoutMs = spec.timeoutMs ?? 12e4;
11868
+ try {
11869
+ const result = await execFileAsync(resolved, args, { cwd: projectRoot, timeout: timeoutMs });
11870
+ return {
11871
+ stdout: String(result.stdout ?? ""),
11872
+ stderr: String(result.stderr ?? ""),
11873
+ exitedAbnormally: false
11874
+ };
11875
+ } catch (err) {
11876
+ const e = err;
11877
+ return {
11878
+ stdout: String(e.stdout ?? ""),
11879
+ stderr: String(e.stderr ?? ""),
11880
+ exitedAbnormally: true
11881
+ };
11882
+ }
11883
+ }
11884
+ function parseStatusEnvelope(stdout) {
11885
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
11886
+ for (let i = lines.length - 1; i >= 0; i--) {
11887
+ const env = classifyLine2(lines[i]);
11888
+ if (env) return env;
11889
+ }
11890
+ return null;
11891
+ }
11892
+ var ENVELOPE_STATUSES = /* @__PURE__ */ new Set(["ok", "findings", "skip", "error"]);
11893
+ function classifyLine2(line) {
11894
+ const obj = tryParseJsonObject(line);
11895
+ if (!obj) return null;
11896
+ const s = obj.status;
11897
+ if (typeof s !== "string" || !ENVELOPE_STATUSES.has(s)) return null;
11898
+ return buildEnvelope(s, obj);
11899
+ }
11900
+ function tryParseJsonObject(line) {
11901
+ if (!line || !line.startsWith("{") || !line.endsWith("}")) return null;
11902
+ try {
11903
+ return JSON.parse(line);
11904
+ } catch {
11905
+ return null;
11906
+ }
11907
+ }
11908
+ function buildEnvelope(status, obj) {
11909
+ const env = { status };
11910
+ if (typeof obj.findings === "number") env.findings = obj.findings;
11911
+ if (typeof obj.wakeAgent === "boolean") env.wakeAgent = obj.wakeAgent;
11912
+ if (typeof obj.message === "string") env.message = obj.message;
11913
+ if (obj.outputs && typeof obj.outputs === "object") {
11914
+ env.outputs = obj.outputs;
11915
+ }
11916
+ return env;
11917
+ }
11918
+ function mapStructured(env, stdout, stderr) {
11919
+ const findings = env.findings ?? (env.status === "findings" ? 1 : 0);
11920
+ switch (env.status) {
11921
+ case "ok":
11922
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11923
+ case "findings": {
11924
+ const wake = env.wakeAgent ?? findings > 0;
11925
+ return { passed: !wake, findings, output: stdout, stderr, structured: env };
11926
+ }
11927
+ case "skip":
11928
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11929
+ case "error":
11930
+ return {
11931
+ passed: false,
11932
+ findings: Math.max(findings, 1),
11933
+ output: stdout,
11934
+ stderr,
11935
+ structured: env
11936
+ };
11937
+ default:
11938
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11939
+ }
11940
+ }
11941
+ function heuristicResult(stdout, stderr, exitedAbnormally) {
11942
+ const combined = [stdout, stderr].filter(Boolean).join("\n");
11943
+ const findingsMatch = combined.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
11944
+ const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : exitedAbnormally ? 1 : 0;
11945
+ return {
11946
+ passed: findings === 0 && !exitedAbnormally,
11947
+ findings,
11948
+ output: stdout,
11949
+ stderr,
11950
+ structured: null
11951
+ };
11952
+ }
11953
+
11954
+ // src/maintenance/output-store.ts
11955
+ var fs17 = __toESM(require("fs"));
11956
+ var path20 = __toESM(require("path"));
11957
+ var DEFAULT_RETENTION = {
11958
+ runs: 50,
11959
+ maxAgeDays: 30
11960
+ };
11961
+ var fallbackLogger2 = {
11962
+ info: () => {
11963
+ },
11964
+ warn: (m, c) => console.warn(m, c),
11965
+ error: (m, c) => console.error(m, c)
11966
+ };
11967
+ var TaskOutputStore = class {
11968
+ rootDir;
11969
+ retentionDefaults;
11970
+ logger;
11971
+ constructor(options) {
11972
+ this.rootDir = options.rootDir;
11973
+ this.retentionDefaults = options.retentionDefaults ?? DEFAULT_RETENTION;
11974
+ this.logger = options.logger ?? fallbackLogger2;
11975
+ }
11976
+ /**
11977
+ * Reject task IDs that don't match the validator's kebab-case pattern —
11978
+ * defends `dirFor()` against caller-supplied path-traversal segments
11979
+ * (`'../foo'`) when the store is invoked from CLI surfaces that don't
11980
+ * round-trip through `validateCustomTasks`.
11981
+ */
11982
+ ensureSafeTaskId(taskId) {
11983
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(taskId)) {
11984
+ throw new Error(
11985
+ `TaskOutputStore: invalid task id '${taskId}' (must match ^[a-z0-9][a-z0-9-]*$)`
11986
+ );
11987
+ }
11988
+ }
11989
+ /**
11990
+ * Persist a single run entry. Retention is applied after the write so
11991
+ * the latest record is durable even if pruning fails.
11992
+ */
11993
+ async write(taskId, entry, retention) {
11994
+ this.ensureSafeTaskId(taskId);
11995
+ const dir = this.dirFor(taskId);
11996
+ await fs17.promises.mkdir(dir, { recursive: true });
11997
+ const fileName = `${sanitizeIso(entry.completedAt || (/* @__PURE__ */ new Date()).toISOString())}.json`;
11998
+ const filePath = path20.join(dir, fileName);
11999
+ const tmpPath = `${filePath}.tmp`;
12000
+ const payload = JSON.stringify(entry, null, 2);
12001
+ await fs17.promises.writeFile(tmpPath, payload, "utf-8");
12002
+ await fs17.promises.rename(tmpPath, filePath);
12003
+ try {
12004
+ await this.applyRetention(taskId, retention);
12005
+ } catch (err) {
12006
+ this.logger.warn("TaskOutputStore retention failed", { taskId, error: String(err) });
12007
+ }
12008
+ }
12009
+ /**
12010
+ * Return the most recent persisted entry for the task, or null if none.
12011
+ */
12012
+ async latest(taskId) {
12013
+ const entries = await this.list(taskId, 1, 0);
12014
+ return entries[0] ?? null;
12015
+ }
12016
+ /**
12017
+ * List entries newest-first with offset+limit pagination.
12018
+ */
12019
+ async list(taskId, limit, offset) {
12020
+ this.ensureSafeTaskId(taskId);
12021
+ const dir = this.dirFor(taskId);
12022
+ const fileNames = await listJsonFilesDescending(dir);
12023
+ const slice = fileNames.slice(offset, offset + limit);
12024
+ const out = [];
12025
+ for (const name of slice) {
12026
+ const entry = await this.readEntry(path20.join(dir, name));
12027
+ if (entry) out.push(entry);
12028
+ }
12029
+ return out;
12030
+ }
12031
+ /**
12032
+ * Lookup a specific run by its file name (without the `.json` suffix) or
12033
+ * by its raw completion timestamp.
12034
+ */
12035
+ async get(taskId, runId) {
12036
+ this.ensureSafeTaskId(taskId);
12037
+ if (/[\\/]|\.\./.test(runId)) {
12038
+ throw new Error(`TaskOutputStore: runId '${runId}' must not contain path separators or '..'`);
12039
+ }
12040
+ const dir = this.dirFor(taskId);
12041
+ const fileName = runId.endsWith(".json") ? runId : `${sanitizeIso(runId)}.json`;
12042
+ return this.readEntry(path20.join(dir, fileName));
12043
+ }
12044
+ /**
12045
+ * The on-disk root for a given task. Exposed for tooling that needs to walk
12046
+ * outputs from outside the store API.
12047
+ */
12048
+ dirFor(taskId) {
12049
+ return path20.join(this.rootDir, taskId, "outputs");
12050
+ }
12051
+ async readEntry(filePath) {
12052
+ try {
12053
+ const buf = await fs17.promises.readFile(filePath, "utf-8");
12054
+ const parsed = JSON.parse(buf);
12055
+ return parsed;
12056
+ } catch {
12057
+ return null;
12058
+ }
12059
+ }
12060
+ async applyRetention(taskId, retention) {
12061
+ const runs = retention?.runs ?? this.retentionDefaults.runs;
12062
+ const maxAgeDays = retention?.maxAgeDays ?? this.retentionDefaults.maxAgeDays;
12063
+ const dir = this.dirFor(taskId);
12064
+ const fileNames = await listJsonFilesDescending(dir);
12065
+ const overflow = fileNames.slice(runs);
12066
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1e3;
12067
+ const aged = [];
12068
+ for (const name of fileNames) {
12069
+ const ts = parseIsoFromFileName(name);
12070
+ if (ts !== null && ts < cutoffMs) aged.push(name);
12071
+ }
12072
+ const toRemove = /* @__PURE__ */ new Set([...overflow, ...aged]);
12073
+ for (const name of toRemove) {
12074
+ try {
12075
+ await fs17.promises.unlink(path20.join(dir, name));
12076
+ } catch {
12077
+ }
12078
+ }
12079
+ }
12080
+ };
12081
+ async function listJsonFilesDescending(dir) {
12082
+ let names;
12083
+ try {
12084
+ names = await fs17.promises.readdir(dir);
12085
+ } catch {
12086
+ return [];
12087
+ }
12088
+ return names.filter((n) => n.endsWith(".json")).sort().reverse();
12089
+ }
12090
+ function sanitizeIso(iso) {
12091
+ return iso.replace(/:/g, "-");
12092
+ }
12093
+ function parseIsoFromFileName(fileName) {
12094
+ const stem = fileName.replace(/\.json$/, "");
12095
+ const restored = stem.replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
12096
+ const ms = Date.parse(restored);
12097
+ return Number.isFinite(ms) ? ms : null;
10478
12098
  }
12099
+
12100
+ // src/maintenance/context-resolver.ts
12101
+ var ContextResolver = class {
12102
+ outputStore;
12103
+ skillReader;
12104
+ logger;
12105
+ perUpstreamMaxChars;
12106
+ constructor(options) {
12107
+ this.outputStore = options.outputStore;
12108
+ this.skillReader = options.skillReader ?? null;
12109
+ this.logger = options.logger ?? fallbackLogger3;
12110
+ this.perUpstreamMaxChars = options.perUpstreamMaxChars ?? 2e3;
12111
+ }
12112
+ async resolveContextFrom(upstreamTaskIds, options = {}) {
12113
+ if (!upstreamTaskIds || upstreamTaskIds.length === 0) return "";
12114
+ const maxAgeMs = (options.maxAgeMinutes ?? 1440) * 60 * 1e3;
12115
+ const now = Date.now();
12116
+ const sections = [];
12117
+ for (const id of upstreamTaskIds) {
12118
+ const entry = await this.outputStore.latest(id);
12119
+ sections.push(this.formatUpstream(id, entry, now, maxAgeMs));
12120
+ }
12121
+ return `## Upstream context
12122
+
12123
+ ${sections.join("\n\n")}
12124
+ `;
12125
+ }
12126
+ async resolveInlineSkills(skillNames, budgetTokens = 8e3) {
12127
+ if (!skillNames || skillNames.length === 0) return "";
12128
+ if (!this.skillReader) return "";
12129
+ const charBudget = budgetTokens * 4;
12130
+ let used = 0;
12131
+ const sections = [];
12132
+ let truncatedAt = -1;
12133
+ for (let i = 0; i < skillNames.length; i++) {
12134
+ const name = skillNames[i];
12135
+ const body = await this.skillReader.read(name);
12136
+ if (body === null) {
12137
+ this.logger.warn("inlineSkills: skill not found in registry", { name });
12138
+ continue;
12139
+ }
12140
+ const block = `### ${name}
12141
+
12142
+ ${body}`;
12143
+ if (used + block.length > charBudget) {
12144
+ truncatedAt = i;
12145
+ break;
12146
+ }
12147
+ used += block.length;
12148
+ sections.push(block);
12149
+ }
12150
+ if (truncatedAt >= 0) {
12151
+ this.logger.warn(
12152
+ `inlineSkillsBudgetTokens (${budgetTokens}) exhausted after ${sections.length} of ${skillNames.length} skills; truncated.`
12153
+ );
12154
+ }
12155
+ if (sections.length === 0) return "";
12156
+ return `## Reference skills
12157
+
12158
+ ${sections.join("\n\n")}
12159
+ `;
12160
+ }
12161
+ formatUpstream(id, entry, now, maxAgeMs) {
12162
+ if (!entry) {
12163
+ return `### ${id}
12164
+
12165
+ _[no prior run]_`;
12166
+ }
12167
+ const completedMs = Date.parse(entry.completedAt);
12168
+ if (Number.isFinite(completedMs) && now - completedMs > maxAgeMs) {
12169
+ return `### ${id} (last run ${entry.completedAt}, stale)
12170
+
12171
+ _[stale: omitted]_`;
12172
+ }
12173
+ const head = `### ${id} (last run ${entry.completedAt}, status=${entry.status}, findings=${entry.findings})`;
12174
+ const body = (entry.stdout ?? "").trim();
12175
+ const truncated = body.length > this.perUpstreamMaxChars ? `${body.slice(0, this.perUpstreamMaxChars)}
12176
+
12177
+ _[truncated]_` : body;
12178
+ return `${head}
12179
+
12180
+ ${truncated || "_[no stdout captured]_"}`;
12181
+ }
12182
+ };
12183
+ var fallbackLogger3 = {
12184
+ info: () => {
12185
+ },
12186
+ warn: () => {
12187
+ },
12188
+ error: () => {
12189
+ }
12190
+ };
12191
+
12192
+ // src/maintenance/custom-task-validator.ts
12193
+ var import_types30 = require("@harness-engineering/types");
12194
+ var ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
12195
+ var REQUIRED_FIELDS_BY_TYPE = {
12196
+ "mechanical-ai": ["branch", "fixSkill"],
12197
+ "pure-ai": ["branch", "fixSkill"],
12198
+ "report-only": [],
12199
+ housekeeping: []
12200
+ };
12201
+ function validateCustomTasks(customTasks, builtIns, deps = {}) {
12202
+ const errors = [];
12203
+ if (!customTasks) return (0, import_types30.Ok)(void 0);
12204
+ const builtInIds = new Set(builtIns.map((t) => t.id));
12205
+ const customIds = Object.keys(customTasks);
12206
+ const allIds = /* @__PURE__ */ new Set([...builtInIds, ...customIds]);
12207
+ for (const id of customIds) {
12208
+ const task = customTasks[id];
12209
+ if (!task) continue;
12210
+ validateOne(id, task, builtInIds, allIds, deps, errors);
12211
+ }
12212
+ detectCycles(customTasks, builtIns, errors);
12213
+ return errors.length === 0 ? (0, import_types30.Ok)(void 0) : (0, import_types30.Err)(errors);
12214
+ }
12215
+ function validateOne(id, task, builtInIds, allIds, deps, errors) {
12216
+ const prefix = `customTasks.${id}`;
12217
+ if (!ID_PATTERN.test(id)) {
12218
+ errors.push({
12219
+ path: prefix,
12220
+ message: `task ID '${id}' must match ^[a-z0-9][a-z0-9-]*$`
12221
+ });
12222
+ }
12223
+ if (builtInIds.has(id)) {
12224
+ errors.push({
12225
+ path: prefix,
12226
+ message: `task ID '${id}' collides with a built-in task; choose a different name`
12227
+ });
12228
+ }
12229
+ if (!task.description || task.description.trim().length === 0) {
12230
+ errors.push({ path: `${prefix}.description`, message: "description is required" });
12231
+ }
12232
+ if (!task.schedule || task.schedule.trim().length === 0) {
12233
+ errors.push({ path: `${prefix}.schedule`, message: "schedule (cron expression) is required" });
12234
+ }
12235
+ validateCheckShape(prefix, task, errors);
12236
+ validateRequiredByType(prefix, task, errors);
12237
+ validateContextFrom(prefix, id, task, allIds, errors);
12238
+ validateInlineSkills(prefix, task, deps, errors);
12239
+ validateScriptPath(prefix, task, deps, errors);
12240
+ }
12241
+ function validateCheckShape(prefix, task, errors) {
12242
+ const hasCommand = Array.isArray(task.checkCommand) && task.checkCommand.length > 0;
12243
+ const hasScript = task.checkScript !== void 0;
12244
+ if (hasCommand && hasScript) {
12245
+ errors.push({
12246
+ path: prefix,
12247
+ message: "a task may declare checkCommand OR checkScript, not both"
12248
+ });
12249
+ }
12250
+ const needsCheck = task.type === "mechanical-ai" || task.type === "report-only" || task.type === "housekeeping";
12251
+ if (needsCheck && !hasCommand && !hasScript) {
12252
+ errors.push({
12253
+ path: prefix,
12254
+ message: `${task.type} task must declare either checkCommand or checkScript`
12255
+ });
12256
+ }
12257
+ if (hasScript) {
12258
+ const path24 = task.checkScript?.path;
12259
+ if (!path24 || path24.trim().length === 0) {
12260
+ errors.push({ path: `${prefix}.checkScript.path`, message: "checkScript.path is required" });
12261
+ }
12262
+ if (task.checkScript?.timeoutMs !== void 0 && task.checkScript.timeoutMs <= 0) {
12263
+ errors.push({
12264
+ path: `${prefix}.checkScript.timeoutMs`,
12265
+ message: "timeoutMs must be a positive integer"
12266
+ });
12267
+ }
12268
+ }
12269
+ }
12270
+ function validateRequiredByType(prefix, task, errors) {
12271
+ const required = REQUIRED_FIELDS_BY_TYPE[task.type];
12272
+ if (!required) {
12273
+ errors.push({ path: `${prefix}.type`, message: `unknown task type '${String(task.type)}'` });
12274
+ return;
12275
+ }
12276
+ for (const field of required) {
12277
+ const value = task[field];
12278
+ if (value === void 0 || value === null || typeof value === "string" && value.length === 0) {
12279
+ errors.push({
12280
+ path: `${prefix}.${String(field)}`,
12281
+ message: `${task.type} task requires ${String(field)}`
12282
+ });
12283
+ }
12284
+ }
12285
+ if ((task.type === "mechanical-ai" || task.type === "pure-ai") && task.branch === null) {
12286
+ errors.push({
12287
+ path: `${prefix}.branch`,
12288
+ message: `${task.type} task requires a non-null branch`
12289
+ });
12290
+ }
12291
+ }
12292
+ function validateContextFrom(prefix, selfId, task, allIds, errors) {
12293
+ if (task.contextFromMaxAgeMinutes !== void 0 && task.contextFromMaxAgeMinutes <= 0) {
12294
+ errors.push({
12295
+ path: `${prefix}.contextFromMaxAgeMinutes`,
12296
+ message: "contextFromMaxAgeMinutes must be a positive integer"
12297
+ });
12298
+ }
12299
+ if (!task.contextFrom) return;
12300
+ for (let i = 0; i < task.contextFrom.length; i++) {
12301
+ const upstreamId = task.contextFrom[i];
12302
+ if (!upstreamId) continue;
12303
+ if (upstreamId === selfId) {
12304
+ errors.push({
12305
+ path: `${prefix}.contextFrom[${i}]`,
12306
+ message: `task '${selfId}' cannot reference itself in contextFrom`
12307
+ });
12308
+ }
12309
+ if (!allIds.has(upstreamId)) {
12310
+ errors.push({
12311
+ path: `${prefix}.contextFrom[${i}]`,
12312
+ message: `references unknown task '${upstreamId}'`
12313
+ });
12314
+ }
12315
+ }
12316
+ }
12317
+ function validateInlineSkills(prefix, task, deps, errors) {
12318
+ if (!task.inlineSkills) return;
12319
+ if (!deps.skillExists) return;
12320
+ for (let i = 0; i < task.inlineSkills.length; i++) {
12321
+ const name = task.inlineSkills[i];
12322
+ if (!name) continue;
12323
+ if (!deps.skillExists(name)) {
12324
+ errors.push({
12325
+ path: `${prefix}.inlineSkills[${i}]`,
12326
+ message: `skill '${name}' not found in the registry`
12327
+ });
12328
+ }
12329
+ }
12330
+ if (task.inlineSkillsBudgetTokens !== void 0 && task.inlineSkillsBudgetTokens <= 0) {
12331
+ errors.push({
12332
+ path: `${prefix}.inlineSkillsBudgetTokens`,
12333
+ message: "inlineSkillsBudgetTokens must be a positive integer"
12334
+ });
12335
+ }
12336
+ }
12337
+ function validateScriptPath(prefix, task, deps, errors) {
12338
+ if (!task.checkScript?.path) return;
12339
+ if (!deps.scriptExists) return;
12340
+ if (!deps.scriptExists(task.checkScript.path)) {
12341
+ errors.push({
12342
+ path: `${prefix}.checkScript.path`,
12343
+ message: `executable not found: ${task.checkScript.path}`
12344
+ });
12345
+ }
12346
+ }
12347
+ function detectCycles(customTasks, builtIns, errors) {
12348
+ const adjacency = /* @__PURE__ */ new Map();
12349
+ for (const t of builtIns) adjacency.set(t.id, []);
12350
+ for (const [id, task] of Object.entries(customTasks)) {
12351
+ adjacency.set(id, (task.contextFrom ?? []).slice());
12352
+ }
12353
+ const color = /* @__PURE__ */ new Map();
12354
+ for (const id of adjacency.keys()) color.set(id, "white");
12355
+ const reported = /* @__PURE__ */ new Set();
12356
+ for (const id of Object.keys(customTasks)) {
12357
+ if (color.get(id) === "white") visitFromRoot(id, adjacency, color, errors, reported);
12358
+ }
12359
+ }
12360
+ function visitFromRoot(start, adjacency, color, errors, reported) {
12361
+ const stack = [{ id: start, nextIdx: 0, path: [start] }];
12362
+ color.set(start, "grey");
12363
+ while (stack.length) {
12364
+ const top = stack[stack.length - 1];
12365
+ const neighbors = adjacency.get(top.id) ?? [];
12366
+ if (top.nextIdx >= neighbors.length) {
12367
+ color.set(top.id, "black");
12368
+ stack.pop();
12369
+ continue;
12370
+ }
12371
+ const next = neighbors[top.nextIdx++];
12372
+ if (!next || !adjacency.has(next)) continue;
12373
+ handleEdge(top, next, color, stack, errors, reported);
12374
+ }
12375
+ }
12376
+ function handleEdge(top, next, color, stack, errors, reported) {
12377
+ const nextColor = color.get(next);
12378
+ if (nextColor === "grey") {
12379
+ reportCycle(top.path, next, errors, reported);
12380
+ } else if (nextColor === "white") {
12381
+ color.set(next, "grey");
12382
+ stack.push({ id: next, nextIdx: 0, path: [...top.path, next] });
12383
+ }
12384
+ }
12385
+ function reportCycle(path24, next, errors, reported) {
12386
+ const cycleStart = path24.indexOf(next);
12387
+ const cyclePath = cycleStart >= 0 ? [...path24.slice(cycleStart), next] : [...path24, next];
12388
+ const key = cyclePath.join("\u2192");
12389
+ if (reported.has(key)) return;
12390
+ reported.add(key);
12391
+ errors.push({
12392
+ path: `customTasks.${cyclePath[0]}.contextFrom`,
12393
+ message: `contextFrom cycle detected: ${cyclePath.join(" \u2192 ")}`
12394
+ });
12395
+ }
12396
+
12397
+ // src/orchestrator.ts
10479
12398
  var Orchestrator = class extends import_node_events.EventEmitter {
10480
12399
  state;
10481
12400
  config;
@@ -10500,6 +12419,14 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10500
12419
  * construction time. Eliminating this fallback is autopilot Phase 4+.
10501
12420
  */
10502
12421
  backendFactory;
12422
+ /**
12423
+ * Spec B Phase 4 (D8): per-orchestrator in-process bus for
12424
+ * `RoutingDecision` events. Constructed alongside backendFactory when
12425
+ * agent.backends synthesis succeeds; null when legacy single-backend
12426
+ * config bypassed backends. Phase 5+ consumers (HTTP, WS, dashboard)
12427
+ * subscribe via `getRoutingDecisionBus()`.
12428
+ */
12429
+ routingDecisionBus;
10503
12430
  /**
10504
12431
  * Test-only: when overrides.backend is provided, dispatch uses this
10505
12432
  * instance directly (bypassing the factory). Mirrors Phase 1
@@ -10522,6 +12449,15 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10522
12449
  * so this map is the single source of truth post-migration.
10523
12450
  */
10524
12451
  localResolvers = /* @__PURE__ */ new Map();
12452
+ /**
12453
+ * Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
12454
+ * construction from `projectRoot/agents/skills/`. Consulted by
12455
+ * `buildRoutingUseCase` at dispatch start to construct
12456
+ * `{ kind: 'skill', skillName, cognitiveMode }` RoutingUseCases.
12457
+ * Empty when the orchestrator runs outside a harness project root
12458
+ * (then dispatch falls through to per-tier, preserving F11/N2).
12459
+ */
12460
+ skillCatalog;
10525
12461
  /**
10526
12462
  * Per-resolver `onStatusChange` unsubscribe callbacks. Spec 2 Phase 5
10527
12463
  * (SC39): each local/pi resolver gets its own listener emitting a
@@ -10570,7 +12506,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10570
12506
  completionHandler;
10571
12507
  /** Project root directory, derived from workspace root. */
10572
12508
  get projectRoot() {
10573
- return path16.resolve(this.config.workspace.root, "..", "..");
12509
+ return path21.resolve(this.config.workspace.root, "..", "..");
10574
12510
  }
10575
12511
  enrichedSpecsByIssue = /* @__PURE__ */ new Map();
10576
12512
  /** Tracks recently-failed intelligence analysis to avoid re-requesting every tick */
@@ -10614,6 +12550,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10614
12550
  `migrateAgentConfig failed; continuing with legacy fields. Error: ${err instanceof Error ? err.message : String(err)}`
10615
12551
  );
10616
12552
  }
12553
+ const skillCatalogRoot = path21.resolve(this.config.workspace.root, "..", "..");
12554
+ this.skillCatalog = discoverSkillCatalog(skillCatalogRoot);
12555
+ if (this.skillCatalog.length === 0) {
12556
+ this.logger.warn(
12557
+ `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")}.`
12558
+ );
12559
+ }
10617
12560
  this.tracker = overrides?.tracker || this.createTracker();
10618
12561
  this.workspace = new WorkspaceManager(config.workspace, {
10619
12562
  emitEvent: (event) => {
@@ -10625,10 +12568,10 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10625
12568
  this.renderer = new PromptRenderer();
10626
12569
  this.overrideBackend = overrides?.backend ?? null;
10627
12570
  this.interactionQueue = new InteractionQueue(
10628
- path16.join(config.workspace.root, "..", "interactions"),
12571
+ path21.join(config.workspace.root, "..", "interactions"),
10629
12572
  this
10630
12573
  );
10631
- this.analysisArchive = new AnalysisArchive(path16.join(config.workspace.root, "..", "analyses"));
12574
+ this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
10632
12575
  const backendsMap = this.config.agent.backends ?? {};
10633
12576
  for (const [name, def] of Object.entries(backendsMap)) {
10634
12577
  if (def.type === "local" || def.type === "pi") {
@@ -10642,13 +12585,17 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10642
12585
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
10643
12586
  }
10644
12587
  }
10645
- this.cacheMetrics = new import_core13.CacheMetricsRecorder();
12588
+ this.cacheMetrics = new import_core16.CacheMetricsRecorder();
10646
12589
  if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
10647
12590
  const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
10648
12591
  const firstBackendName = Object.keys(this.config.agent.backends)[0];
10649
12592
  const routing = this.config.agent.routing ?? {
10650
12593
  default: firstBackendName ?? "primary"
10651
12594
  };
12595
+ this.routingDecisionBus = new RoutingDecisionBus({
12596
+ capacity: 500,
12597
+ logger: this.logger
12598
+ });
10652
12599
  this.backendFactory = new OrchestratorBackendFactory({
10653
12600
  backends: this.config.agent.backends,
10654
12601
  routing,
@@ -10656,6 +12603,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10656
12603
  ...this.config.agent.container !== void 0 ? { container: this.config.agent.container } : {},
10657
12604
  ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
10658
12605
  cacheMetrics: this.cacheMetrics,
12606
+ decisionBus: this.routingDecisionBus,
10659
12607
  getResolverModelFor: (name) => {
10660
12608
  const resolver = this.localResolvers.get(name);
10661
12609
  return resolver ? () => resolver.resolveModel() : void 0;
@@ -10663,6 +12611,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10663
12611
  });
10664
12612
  } else {
10665
12613
  this.backendFactory = null;
12614
+ this.routingDecisionBus = null;
10666
12615
  }
10667
12616
  this.pipeline = null;
10668
12617
  this.orchestratorIdPromise = resolveOrchestratorId(config.orchestratorId);
@@ -10672,7 +12621,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10672
12621
  ...overrides?.execFileFn ? { execFileFn: overrides.execFileFn } : {}
10673
12622
  });
10674
12623
  this.recorder = new StreamRecorder(
10675
- path16.resolve(config.workspace.root, "..", "streams"),
12624
+ path21.resolve(config.workspace.root, "..", "streams"),
10676
12625
  this.logger
10677
12626
  );
10678
12627
  const self = this;
@@ -10703,10 +12652,10 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10703
12652
  this.completionHandler = new CompletionHandler(ctx, this.postLifecycleComment.bind(this));
10704
12653
  if (config.server?.port) {
10705
12654
  const webhookStore = new WebhookStore(
10706
- path16.join(this.projectRoot, ".harness", "webhooks.json")
12655
+ path21.join(this.projectRoot, ".harness", "webhooks.json")
10707
12656
  );
10708
12657
  this.webhookQueue = new WebhookQueue(
10709
- path16.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
12658
+ path21.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
10710
12659
  );
10711
12660
  const webhookDelivery = new WebhookDelivery({
10712
12661
  queue: this.webhookQueue,
@@ -10722,7 +12671,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10722
12671
  this.setupNotifications(config.notifications);
10723
12672
  const otlpCfg = config.telemetry?.export?.otlp;
10724
12673
  if (otlpCfg) {
10725
- this.otlpExporter = new import_core13.OTLPExporter({
12674
+ this.otlpExporter = new import_core16.OTLPExporter({
10726
12675
  endpoint: otlpCfg.endpoint,
10727
12676
  ...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
10728
12677
  ...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
@@ -10744,7 +12693,16 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10744
12693
  queue: this.webhookQueue
10745
12694
  },
10746
12695
  cacheMetrics: this.cacheMetrics,
10747
- plansDir: path16.resolve(config.workspace.root, "..", "docs", "plans"),
12696
+ // Spec B Phase 5: routing observability accessors. Closures so the
12697
+ // server re-reads on every request — stop() / start() do not
12698
+ // require server reconstruction. Returns null if no backendFactory
12699
+ // (legacy single-backend configs), and the route handler renders
12700
+ // 503 in that case.
12701
+ getBackendRouter: () => this.getBackendRouter(),
12702
+ getRoutingDecisionBus: () => this.getRoutingDecisionBus(),
12703
+ getRoutingConfig: () => this.getRoutingConfig(),
12704
+ getBackends: () => this.getBackends(),
12705
+ plansDir: path21.resolve(config.workspace.root, "..", "docs", "plans"),
10748
12706
  pipeline: this.pipeline,
10749
12707
  analysisArchive: this.analysisArchive,
10750
12708
  roadmapPath: config.tracker.filePath ?? null,
@@ -10782,7 +12740,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10782
12740
  ...this.config.tracker.apiKey ? { token: this.config.tracker.apiKey } : {},
10783
12741
  ...this.config.tracker.endpoint ? { apiBase: this.config.tracker.endpoint } : {}
10784
12742
  };
10785
- const clientResult = (0, import_core12.createTrackerClient)(trackerCfg);
12743
+ const clientResult = (0, import_core15.createTrackerClient)(trackerCfg);
10786
12744
  if (!clientResult.ok) throw clientResult.error;
10787
12745
  return new GitHubIssuesIssueTrackerAdapter(clientResult.value, this.config.tracker);
10788
12746
  }
@@ -10800,13 +12758,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10800
12758
  const logger = this.logger;
10801
12759
  const checkRunner = {
10802
12760
  run: async (command, cwd) => {
10803
- const { execFile: execFile6 } = await import("child_process");
10804
- const { promisify: promisify4 } = await import("util");
10805
- const execFileAsync = promisify4(execFile6);
12761
+ const { execFile: execFile7 } = await import("child_process");
12762
+ const { promisify: promisify5 } = await import("util");
12763
+ const execFileAsync2 = promisify5(execFile7);
10806
12764
  const [cmd, ...args] = command;
10807
12765
  if (!cmd) return { passed: true, findings: 0, output: "" };
10808
12766
  try {
10809
- const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
12767
+ const { stdout } = await execFileAsync2(cmd, args, { cwd, timeout: 12e4 });
10810
12768
  const findingsMatch = stdout.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
10811
12769
  const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : 0;
10812
12770
  return { passed: findings === 0, findings, output: stdout };
@@ -10835,13 +12793,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10835
12793
  };
10836
12794
  const commandExecutor = {
10837
12795
  exec: async (command, cwd) => {
10838
- const { execFile: execFile6 } = await import("child_process");
10839
- const { promisify: promisify4 } = await import("util");
10840
- const execFileAsync = promisify4(execFile6);
12796
+ const { execFile: execFile7 } = await import("child_process");
12797
+ const { promisify: promisify5 } = await import("util");
12798
+ const execFileAsync2 = promisify5(execFile7);
10841
12799
  const [cmd, ...args] = command;
10842
12800
  if (!cmd) return { stdout: "" };
10843
12801
  try {
10844
- const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
12802
+ const { stdout } = await execFileAsync2(cmd, args, { cwd, timeout: 12e4 });
10845
12803
  return { stdout: String(stdout) };
10846
12804
  } catch (err) {
10847
12805
  logger.warn("Maintenance command execution failed", {
@@ -10853,12 +12811,31 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10853
12811
  }
10854
12812
  }
10855
12813
  };
12814
+ const outputStore = new TaskOutputStore({
12815
+ rootDir: path21.join(this.projectRoot, ".harness", "maintenance"),
12816
+ logger: this.logger
12817
+ });
12818
+ const checkScriptRunner = new CheckScriptRunner(this.projectRoot);
12819
+ const skillReader = {
12820
+ // The orchestrator does not own the skill registry; CLI-side skill
12821
+ // resolution wires this in via direct injection. Default: skill not
12822
+ // resolvable from the orchestrator boundary.
12823
+ read: async () => null
12824
+ };
12825
+ const contextResolver = new ContextResolver({
12826
+ outputStore,
12827
+ skillReader,
12828
+ logger: this.logger
12829
+ });
10856
12830
  return new TaskRunner({
10857
12831
  config: maintenanceConfig,
10858
12832
  checkRunner,
10859
12833
  agentDispatcher,
10860
12834
  commandExecutor,
10861
- cwd: this.projectRoot
12835
+ cwd: this.projectRoot,
12836
+ checkScriptRunner,
12837
+ contextResolver,
12838
+ outputStore
10862
12839
  });
10863
12840
  }
10864
12841
  /**
@@ -10866,8 +12843,17 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10866
12843
  * Extracted from start() to keep function length under threshold.
10867
12844
  */
10868
12845
  async initMaintenance(maintenanceConfig) {
12846
+ const validation = validateCustomTasks(
12847
+ maintenanceConfig.customTasks,
12848
+ BUILT_IN_TASKS
12849
+ );
12850
+ if (!validation.ok) {
12851
+ const messages = validation.error.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
12852
+ throw new Error(`Invalid maintenance.customTasks configuration:
12853
+ ${messages}`);
12854
+ }
10869
12855
  this.maintenanceReporter = new MaintenanceReporter({
10870
- persistDir: path16.join(this.projectRoot, ".harness", "maintenance"),
12856
+ persistDir: path21.join(this.projectRoot, ".harness", "maintenance"),
10871
12857
  logger: this.logger
10872
12858
  });
10873
12859
  await this.maintenanceReporter.load();
@@ -10918,10 +12904,17 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10918
12904
  }
10919
12905
  }
10920
12906
  createIntelligencePipeline() {
12907
+ if (!this.backendFactory) {
12908
+ this.logger.warn(
12909
+ "intelligence pipeline disabled: no backendFactory available (legacy config without agent.backends)"
12910
+ );
12911
+ return null;
12912
+ }
10921
12913
  const bundle = buildIntelligencePipeline({
10922
12914
  config: this.config,
10923
12915
  localResolvers: this.localResolvers,
10924
- logger: this.logger
12916
+ logger: this.logger,
12917
+ router: this.backendFactory.getRouter()
10925
12918
  });
10926
12919
  if (!bundle) return null;
10927
12920
  this.graphStore = bundle.graphStore;
@@ -10972,11 +12965,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
10972
12965
  simulationResults,
10973
12966
  personaRecommendations
10974
12967
  } = pipelineResult ?? {};
12968
+ const selfAssignee = await this.orchestratorIdPromise;
10975
12969
  const tickEvent = {
10976
12970
  type: "tick",
10977
12971
  candidates,
10978
12972
  runningStates: runningStatesResult.value,
10979
12973
  nowMs,
12974
+ selfAssignee,
10980
12975
  ...concernSignals !== void 0 && { concernSignals },
10981
12976
  ...enrichedSpecs !== void 0 && { enrichedSpecs },
10982
12977
  ...complexityScores !== void 0 && { complexityScores },
@@ -11306,12 +13301,12 @@ var Orchestrator = class extends import_node_events.EventEmitter {
11306
13301
  async postLifecycleComment(identifier, externalId, event) {
11307
13302
  try {
11308
13303
  if (!externalId) return;
11309
- const trackerConfig = (0, import_core12.loadTrackerSyncConfig)(this.projectRoot);
13304
+ const trackerConfig = (0, import_core15.loadTrackerSyncConfig)(this.projectRoot);
11310
13305
  if (!trackerConfig) return;
11311
13306
  const token = process.env.GITHUB_TOKEN;
11312
13307
  if (!token) return;
11313
13308
  const orchestratorId = await this.orchestratorIdPromise;
11314
- const adapter = new import_core12.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
13309
+ const adapter = new import_core15.GitHubIssuesSyncAdapter({ token, config: trackerConfig });
11315
13310
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
11316
13311
  const actionMap = {
11317
13312
  claimed: "Dispatching agent for autonomous execution",
@@ -11384,7 +13379,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
11384
13379
  ...f.line !== void 0 ? { line: f.line } : {}
11385
13380
  }))
11386
13381
  );
11387
- (0, import_core11.writeTaint)(
13382
+ (0, import_core14.writeTaint)(
11388
13383
  workspacePath,
11389
13384
  issue.id,
11390
13385
  "Medium-severity injection patterns found in workspace config files",
@@ -11400,14 +13395,24 @@ var Orchestrator = class extends import_node_events.EventEmitter {
11400
13395
  issue,
11401
13396
  attempt: attempt || 1
11402
13397
  });
11403
- const useCase = useCaseForBackendParam(issue, backend);
13398
+ const useCase = buildRoutingUseCase(issue, backend, this.skillCatalog);
13399
+ const invocationOverride = process.env.HARNESS_BACKEND_OVERRIDE;
13400
+ const routerOpts = invocationOverride ? { invocationOverride } : void 0;
13401
+ if (invocationOverride) {
13402
+ this.logger.info(
13403
+ `Spec B Phase 3: HARNESS_BACKEND_OVERRIDE='${invocationOverride}' taking effect for ${issue.identifier}`,
13404
+ { issueId: issue.id }
13405
+ );
13406
+ }
11404
13407
  let routedBackendName;
11405
13408
  if (this.overrideBackend !== null) {
11406
13409
  routedBackendName = this.overrideBackend.name;
11407
13410
  } else if (this.backendFactory !== null) {
11408
- routedBackendName = this.backendFactory.resolveName(useCase);
13411
+ routedBackendName = this.backendFactory.resolveName(useCase, routerOpts);
11409
13412
  } else {
11410
- routedBackendName = this.config.agent.routing?.default ?? this.config.agent.backend ?? "unknown";
13413
+ const routingDefault = this.config.agent.routing?.default;
13414
+ const routingDefaultScalar = routingDefault !== void 0 ? toArray(routingDefault)[0] : void 0;
13415
+ routedBackendName = routingDefaultScalar ?? this.config.agent.backend ?? "unknown";
11411
13416
  }
11412
13417
  const session = {
11413
13418
  sessionId: `pending-${Date.now()}`,
@@ -11446,7 +13451,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
11446
13451
  if (this.overrideBackend !== null) {
11447
13452
  agentBackend = this.overrideBackend;
11448
13453
  } else if (this.backendFactory !== null) {
11449
- agentBackend = this.backendFactory.forUseCase(useCase);
13454
+ agentBackend = this.backendFactory.forUseCase(useCase, routerOpts);
11450
13455
  } else {
11451
13456
  throw new Error(
11452
13457
  `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.`
@@ -11736,6 +13741,8 @@ var Orchestrator = class extends import_node_events.EventEmitter {
11736
13741
  unsub();
11737
13742
  }
11738
13743
  this.localModelStatusUnsubscribes = [];
13744
+ this.routingDecisionBus?.clearListeners();
13745
+ this.routingDecisionBus = null;
11739
13746
  for (const resolver of this.localResolvers.values()) {
11740
13747
  resolver.stop();
11741
13748
  }
@@ -11809,6 +13816,42 @@ var Orchestrator = class extends import_node_events.EventEmitter {
11809
13816
  tickActivity: this.tickActivity
11810
13817
  };
11811
13818
  }
13819
+ /**
13820
+ * Spec B Phase 4 (D8): expose the bus for Phase 5 (HTTP routes) and
13821
+ * Phase 7 (dashboard WS broadcast). Returns null when the legacy
13822
+ * single-backend config bypassed agent.backends synthesis.
13823
+ */
13824
+ getRoutingDecisionBus() {
13825
+ return this.routingDecisionBus;
13826
+ }
13827
+ /**
13828
+ * Spec B Phase 5: live BackendRouter for HTTP routes. The orchestrator
13829
+ * dispatch path uses the factory-owned router directly; observability
13830
+ * routes (config / decisions) reach it through this accessor. Returns
13831
+ * null when the legacy single-backend config bypassed agent.backends
13832
+ * synthesis (no backendFactory built).
13833
+ */
13834
+ getBackendRouter() {
13835
+ return this.backendFactory?.getRouter() ?? null;
13836
+ }
13837
+ /**
13838
+ * Spec B Phase 5: snapshot of the active RoutingConfig for the config
13839
+ * route and the trace route's bus-less router construction. Returns
13840
+ * null when the operator's harness.config.json carries no
13841
+ * `agent.routing` block.
13842
+ */
13843
+ getRoutingConfig() {
13844
+ return this.config.agent.routing ?? null;
13845
+ }
13846
+ /**
13847
+ * Spec B Phase 5: snapshot of `agent.backends` for the config route
13848
+ * (existence annotations) and the trace route (bus-less router
13849
+ * construction). Returns null when no synthesized backends map exists
13850
+ * (legacy single-backend configs).
13851
+ */
13852
+ getBackends() {
13853
+ return this.config.agent.backends ?? null;
13854
+ }
11812
13855
  /** Returns the maintenance scheduler status, or null if maintenance is not enabled. */
11813
13856
  getMaintenanceStatus() {
11814
13857
  return this.maintenanceScheduler?.getStatus() ?? null;
@@ -12043,11 +14086,11 @@ function launchTUI(orchestrator) {
12043
14086
  }
12044
14087
 
12045
14088
  // src/maintenance/sync-main.ts
12046
- var import_node_child_process11 = require("child_process");
12047
- var import_node_util3 = require("util");
14089
+ var import_node_child_process12 = require("child_process");
14090
+ var import_node_util4 = require("util");
12048
14091
  var DEFAULT_TIMEOUT_MS3 = 6e4;
12049
14092
  async function git(execFileFn, args, cwd, timeoutMs) {
12050
- const exec = (0, import_node_util3.promisify)(execFileFn);
14093
+ const exec = (0, import_node_util4.promisify)(execFileFn);
12051
14094
  const { stdout, stderr } = await exec("git", args, { cwd, timeout: timeoutMs });
12052
14095
  return { stdout: String(stdout), stderr: String(stderr) };
12053
14096
  }
@@ -12109,7 +14152,7 @@ async function isAncestor(execFileFn, a, b, cwd, timeoutMs) {
12109
14152
  }
12110
14153
  }
12111
14154
  async function syncMain(repoRoot, opts = {}) {
12112
- const execFileFn = opts.execFileFn ?? import_node_child_process11.execFile;
14155
+ const execFileFn = opts.execFileFn ?? import_node_child_process12.execFile;
12113
14156
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
12114
14157
  try {
12115
14158
  const originRef = await resolveOriginDefault(execFileFn, repoRoot, timeoutMs);
@@ -12187,10 +14230,10 @@ async function syncMain(repoRoot, opts = {}) {
12187
14230
  }
12188
14231
 
12189
14232
  // src/sessions/search-index.ts
12190
- var fs15 = __toESM(require("fs"));
12191
- var path17 = __toESM(require("path"));
14233
+ var fs18 = __toESM(require("fs"));
14234
+ var path22 = __toESM(require("path"));
12192
14235
  var import_better_sqlite32 = __toESM(require("better-sqlite3"));
12193
- var import_types29 = require("@harness-engineering/types");
14236
+ var import_types31 = require("@harness-engineering/types");
12194
14237
  var SEARCH_INDEX_FILE = "search-index.sqlite";
12195
14238
  var SCHEMA_SQL2 = `
12196
14239
  CREATE TABLE IF NOT EXISTS session_docs (
@@ -12233,7 +14276,7 @@ function normalizeFts5Query(query) {
12233
14276
  return query.split(/\s+/).filter((tok) => tok.length > 0).map((tok) => `"${tok.replace(/"/g, '""')}"`).join(" ");
12234
14277
  }
12235
14278
  function searchIndexPath(projectPath) {
12236
- return path17.join(projectPath, ".harness", SEARCH_INDEX_FILE);
14279
+ return path22.join(projectPath, ".harness", SEARCH_INDEX_FILE);
12237
14280
  }
12238
14281
  var FILE_KIND_TO_FILENAME = {
12239
14282
  summary: "summary.md",
@@ -12248,7 +14291,7 @@ var SqliteSearchIndex = class {
12248
14291
  removeSessionStmt;
12249
14292
  totalStmt;
12250
14293
  constructor(dbPath) {
12251
- fs15.mkdirSync(path17.dirname(dbPath), { recursive: true });
14294
+ fs18.mkdirSync(path22.dirname(dbPath), { recursive: true });
12252
14295
  this.db = new import_better_sqlite32.default(dbPath);
12253
14296
  this.db.pragma("journal_mode = WAL");
12254
14297
  this.db.pragma("synchronous = NORMAL");
@@ -12348,19 +14391,19 @@ function openSearchIndex(projectPath) {
12348
14391
  return new SqliteSearchIndex(searchIndexPath(projectPath));
12349
14392
  }
12350
14393
  function indexSessionDirectory(idx, args) {
12351
- const kinds = args.fileKinds ?? [...import_types29.INDEXED_FILE_KINDS];
14394
+ const kinds = args.fileKinds ?? [...import_types31.INDEXED_FILE_KINDS];
12352
14395
  const cap = args.maxBytesPerBody ?? 256 * 1024;
12353
14396
  let docsWritten = 0;
12354
14397
  for (const kind of kinds) {
12355
14398
  const fileName = FILE_KIND_TO_FILENAME[kind];
12356
- const filePath = path17.join(args.sessionDir, fileName);
12357
- if (!fs15.existsSync(filePath)) continue;
12358
- let body = fs15.readFileSync(filePath, "utf8");
14399
+ const filePath = path22.join(args.sessionDir, fileName);
14400
+ if (!fs18.existsSync(filePath)) continue;
14401
+ let body = fs18.readFileSync(filePath, "utf8");
12359
14402
  if (Buffer.byteLength(body, "utf8") > cap) {
12360
14403
  body = body.slice(0, cap) + "\n\n[TRUNCATED]";
12361
14404
  }
12362
- const stat = fs15.statSync(filePath);
12363
- const relPath = path17.relative(args.projectPath, filePath).replaceAll("\\", "/");
14405
+ const stat = fs18.statSync(filePath);
14406
+ const relPath = path22.relative(args.projectPath, filePath).replaceAll("\\", "/");
12364
14407
  idx.upsertSessionDoc({
12365
14408
  sessionId: args.sessionId,
12366
14409
  archived: args.archived,
@@ -12375,17 +14418,17 @@ function indexSessionDirectory(idx, args) {
12375
14418
  }
12376
14419
  function reindexFromArchive(projectPath, opts = {}) {
12377
14420
  const start = Date.now();
12378
- const archiveBase = path17.join(projectPath, ".harness", "archive", "sessions");
14421
+ const archiveBase = path22.join(projectPath, ".harness", "archive", "sessions");
12379
14422
  const idx = openSearchIndex(projectPath);
12380
14423
  try {
12381
14424
  idx.resetArchived();
12382
14425
  let sessionsIndexed = 0;
12383
14426
  let docsWritten = 0;
12384
- if (fs15.existsSync(archiveBase)) {
12385
- const entries = fs15.readdirSync(archiveBase, { withFileTypes: true });
14427
+ if (fs18.existsSync(archiveBase)) {
14428
+ const entries = fs18.readdirSync(archiveBase, { withFileTypes: true });
12386
14429
  for (const entry of entries) {
12387
14430
  if (!entry.isDirectory()) continue;
12388
- const sessionDir = path17.join(archiveBase, entry.name);
14431
+ const sessionDir = path22.join(archiveBase, entry.name);
12389
14432
  const result = indexSessionDirectory(idx, {
12390
14433
  sessionId: entry.name,
12391
14434
  sessionDir,
@@ -12405,10 +14448,10 @@ function reindexFromArchive(projectPath, opts = {}) {
12405
14448
  }
12406
14449
 
12407
14450
  // src/sessions/summarize.ts
12408
- var fs16 = __toESM(require("fs"));
12409
- var path18 = __toESM(require("path"));
12410
- var import_types30 = require("@harness-engineering/types");
12411
- var import_types31 = require("@harness-engineering/types");
14451
+ var fs19 = __toESM(require("fs"));
14452
+ var path23 = __toESM(require("path"));
14453
+ var import_types32 = require("@harness-engineering/types");
14454
+ var import_types33 = require("@harness-engineering/types");
12412
14455
  var LLM_SUMMARY_FILE = "llm-summary.md";
12413
14456
  var SUMMARY_INPUT_FILES = [
12414
14457
  { filename: "summary.md", kind: "summary" },
@@ -12434,10 +14477,10 @@ var USER_PROMPT_PREAMBLE = `Below are the archived files for a single harness-en
12434
14477
  function readInputCorpus(archiveDir) {
12435
14478
  const parts = [];
12436
14479
  for (const { filename, kind } of SUMMARY_INPUT_FILES) {
12437
- const p = path18.join(archiveDir, filename);
12438
- if (!fs16.existsSync(p)) continue;
14480
+ const p = path23.join(archiveDir, filename);
14481
+ if (!fs19.existsSync(p)) continue;
12439
14482
  try {
12440
- const content = fs16.readFileSync(p, "utf8");
14483
+ const content = fs19.readFileSync(p, "utf8");
12441
14484
  if (content.trim().length === 0) continue;
12442
14485
  parts.push(`## FILE: ${kind}
12443
14486
 
@@ -12488,7 +14531,7 @@ function renderLlmSummaryMarkdown(summary, meta) {
12488
14531
  return lines.join("\n");
12489
14532
  }
12490
14533
  function writeStubMarkdown(archiveDir, reason) {
12491
- const filePath = path18.join(archiveDir, LLM_SUMMARY_FILE);
14534
+ const filePath = path23.join(archiveDir, LLM_SUMMARY_FILE);
12492
14535
  const body = `---
12493
14536
  generatedAt: ${(/* @__PURE__ */ new Date()).toISOString()}
12494
14537
  schemaVersion: 1
@@ -12499,17 +14542,17 @@ status: failed
12499
14542
 
12500
14543
  - reason: ${reason}
12501
14544
  `;
12502
- fs16.writeFileSync(filePath, body, "utf8");
14545
+ fs19.writeFileSync(filePath, body, "utf8");
12503
14546
  return filePath;
12504
14547
  }
12505
14548
  async function summarizeArchivedSession(ctx) {
12506
14549
  const writeStubOnError = ctx.writeStubOnError ?? true;
12507
- if (!fs16.existsSync(ctx.archiveDir)) {
12508
- return (0, import_types31.Err)(new Error(`archive directory not found: ${ctx.archiveDir}`));
14550
+ if (!fs19.existsSync(ctx.archiveDir)) {
14551
+ return (0, import_types33.Err)(new Error(`archive directory not found: ${ctx.archiveDir}`));
12509
14552
  }
12510
14553
  const corpus = readInputCorpus(ctx.archiveDir);
12511
14554
  if (corpus.trim().length === 0) {
12512
- return (0, import_types31.Err)(new Error(`no summary input files found in ${ctx.archiveDir}`));
14555
+ return (0, import_types33.Err)(new Error(`no summary input files found in ${ctx.archiveDir}`));
12513
14556
  }
12514
14557
  const inputBudgetTokens = ctx.config?.inputBudgetTokens ?? DEFAULT_INPUT_BUDGET_TOKENS;
12515
14558
  const truncated = truncateForBudget(corpus, inputBudgetTokens);
@@ -12518,7 +14561,7 @@ async function summarizeArchivedSession(ctx) {
12518
14561
  const analyzeOpts = {
12519
14562
  prompt,
12520
14563
  systemPrompt: SYSTEM_PROMPT,
12521
- responseSchema: import_types30.SessionSummarySchema,
14564
+ responseSchema: import_types32.SessionSummarySchema,
12522
14565
  ...ctx.config?.model && { model: ctx.config.model }
12523
14566
  };
12524
14567
  let response;
@@ -12542,11 +14585,11 @@ async function summarizeArchivedSession(ctx) {
12542
14585
  } catch {
12543
14586
  }
12544
14587
  }
12545
- return (0, import_types31.Err)(
14588
+ return (0, import_types33.Err)(
12546
14589
  new Error(`session summary failed: ${reason}` + (stubPath ? ` (stub: ${stubPath})` : ""))
12547
14590
  );
12548
14591
  }
12549
- const parsed = import_types30.SessionSummarySchema.safeParse(response.result);
14592
+ const parsed = import_types32.SessionSummarySchema.safeParse(response.result);
12550
14593
  if (!parsed.success) {
12551
14594
  const reason = `schema validation failed: ${parsed.error.message}`;
12552
14595
  ctx.logger?.warn?.("session summary: invalid provider payload", { reason });
@@ -12556,7 +14599,7 @@ async function summarizeArchivedSession(ctx) {
12556
14599
  } catch {
12557
14600
  }
12558
14601
  }
12559
- return (0, import_types31.Err)(new Error(reason));
14602
+ return (0, import_types33.Err)(new Error(reason));
12560
14603
  }
12561
14604
  const meta = {
12562
14605
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -12565,10 +14608,10 @@ async function summarizeArchivedSession(ctx) {
12565
14608
  outputTokens: response.tokenUsage.outputTokens,
12566
14609
  schemaVersion: 1
12567
14610
  };
12568
- const filePath = path18.join(ctx.archiveDir, LLM_SUMMARY_FILE);
14611
+ const filePath = path23.join(ctx.archiveDir, LLM_SUMMARY_FILE);
12569
14612
  const body = renderLlmSummaryMarkdown(parsed.data, meta);
12570
- fs16.writeFileSync(filePath, body, "utf8");
12571
- return (0, import_types31.Ok)({ summary: parsed.data, meta, filePath });
14613
+ fs19.writeFileSync(filePath, body, "utf8");
14614
+ return (0, import_types33.Ok)({ summary: parsed.data, meta, filePath });
12572
14615
  }
12573
14616
  function isSummaryEnabled(config) {
12574
14617
  if (!config) return false;
@@ -12645,8 +14688,11 @@ function buildArchiveHooks(opts) {
12645
14688
  // Annotate the CommonJS export names for ESM import in node:
12646
14689
  0 && (module.exports = {
12647
14690
  AnalysisArchive,
14691
+ BUILT_IN_TASKS,
12648
14692
  BackendRouter,
12649
14693
  ClaimManager,
14694
+ GateNotReadyError,
14695
+ GateRunError,
12650
14696
  InteractionQueue,
12651
14697
  LinearGraphQLStub,
12652
14698
  MAX_ATTEMPTS,
@@ -12655,6 +14701,7 @@ function buildArchiveHooks(opts) {
12655
14701
  Orchestrator,
12656
14702
  OrchestratorBackendFactory,
12657
14703
  PRDetector,
14704
+ PromotionError,
12658
14705
  PromptRenderer,
12659
14706
  RETRY_DELAYS_MS,
12660
14707
  RoadmapTrackerAdapter,
@@ -12663,6 +14710,7 @@ function buildArchiveHooks(opts) {
12663
14710
  SlackSink,
12664
14711
  SqliteSearchIndex,
12665
14712
  StreamRecorder,
14713
+ TaskOutputStore,
12666
14714
  TokenStore,
12667
14715
  WebhookQueue,
12668
14716
  WorkflowLoader,
@@ -12676,7 +14724,13 @@ function buildArchiveHooks(opts) {
12676
14724
  computeRateLimitDelay,
12677
14725
  createBackend,
12678
14726
  createEmptyState,
14727
+ crossFieldRoutingIssues,
12679
14728
  detectScopeTier,
14729
+ discoverSkillCatalog,
14730
+ discoverSkillCatalogNames,
14731
+ emitProposalApproved,
14732
+ emitProposalCreated,
14733
+ emitProposalRejected,
12680
14734
  extractHighlights,
12681
14735
  extractTitlePrefix,
12682
14736
  getAvailableSlots,
@@ -12690,6 +14744,7 @@ function buildArchiveHooks(opts) {
12690
14744
  migrateAgentConfig,
12691
14745
  normalizeFts5Query,
12692
14746
  openSearchIndex,
14747
+ promote,
12693
14748
  reconcile,
12694
14749
  reindexFromArchive,
12695
14750
  renderAnalysisComment,
@@ -12698,6 +14753,8 @@ function buildArchiveHooks(opts) {
12698
14753
  resolveEscalationConfig,
12699
14754
  resolveOrchestratorId,
12700
14755
  routeIssue,
14756
+ routingWarnings,
14757
+ runGate,
12701
14758
  savePublishedIndex,
12702
14759
  searchIndexPath,
12703
14760
  selectCandidates,
@@ -12706,6 +14763,7 @@ function buildArchiveHooks(opts) {
12706
14763
  syncMain,
12707
14764
  triageIssue,
12708
14765
  truncateForBudget,
14766
+ validateCustomTasks,
12709
14767
  validateWorkflowConfig,
12710
14768
  wireNotificationSinks,
12711
14769
  wrapAsEnvelope