@harness-engineering/orchestrator 0.4.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1144,7 +1144,7 @@ var ClaimManager = class {
1144
1144
  const claimResult = await this.tracker.claimIssue(issueId, this.orchestratorId);
1145
1145
  if (!claimResult.ok) return claimResult;
1146
1146
  if (this.verifyDelayMs > 0) {
1147
- await new Promise((resolve6) => setTimeout(resolve6, this.verifyDelayMs));
1147
+ await new Promise((resolve7) => setTimeout(resolve7, this.verifyDelayMs));
1148
1148
  }
1149
1149
  const statesResult = await this.tracker.fetchIssueStatesByIds([issueId]);
1150
1150
  if (!statesResult.ok) return statesResult;
@@ -1870,11 +1870,11 @@ var BackendsMapSchema = z2.record(z2.string(), BackendDefSchema);
1870
1870
  function crossFieldRoutingIssues(backends, routing) {
1871
1871
  const issues = [];
1872
1872
  const names = new Set(Object.keys(backends));
1873
- const checkRef = (path17, name) => {
1873
+ const checkRef = (path22, name) => {
1874
1874
  if (name !== void 0 && !names.has(name)) {
1875
1875
  issues.push({
1876
- path: path17,
1877
- message: `routing.${path17.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1876
+ path: path22,
1877
+ message: `routing.${path22.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1878
1878
  });
1879
1879
  }
1880
1880
  };
@@ -2544,7 +2544,7 @@ var WorkspaceHooks = class {
2544
2544
  if (!command) {
2545
2545
  return Ok7(void 0);
2546
2546
  }
2547
- return new Promise((resolve6) => {
2547
+ return new Promise((resolve7) => {
2548
2548
  const filteredEnv = {};
2549
2549
  for (const [k, v] of Object.entries(process.env)) {
2550
2550
  if (v != null && !k.includes("SECRET") && !k.includes("TOKEN") && !k.includes("PASSWORD")) {
@@ -2557,19 +2557,19 @@ var WorkspaceHooks = class {
2557
2557
  });
2558
2558
  const timeout = setTimeout(() => {
2559
2559
  child.kill();
2560
- resolve6(Err5(new Error(`Hook ${hookName} timed out after ${this.config.timeoutMs}ms`)));
2560
+ resolve7(Err5(new Error(`Hook ${hookName} timed out after ${this.config.timeoutMs}ms`)));
2561
2561
  }, this.config.timeoutMs);
2562
2562
  child.on("exit", (code) => {
2563
2563
  clearTimeout(timeout);
2564
2564
  if (code === 0) {
2565
- resolve6(Ok7(void 0));
2565
+ resolve7(Ok7(void 0));
2566
2566
  } else {
2567
- resolve6(Err5(new Error(`Hook ${hookName} failed with exit code ${code}`)));
2567
+ resolve7(Err5(new Error(`Hook ${hookName} failed with exit code ${code}`)));
2568
2568
  }
2569
2569
  });
2570
2570
  child.on("error", (error) => {
2571
2571
  clearTimeout(timeout);
2572
- resolve6(Err5(error));
2572
+ resolve7(Err5(error));
2573
2573
  });
2574
2574
  });
2575
2575
  }
@@ -2609,7 +2609,7 @@ var MockBackend = class {
2609
2609
  content: "Thinking...",
2610
2610
  sessionId: session.sessionId
2611
2611
  };
2612
- await new Promise((resolve6) => setTimeout(resolve6, 100));
2612
+ await new Promise((resolve7) => setTimeout(resolve7, 100));
2613
2613
  yield {
2614
2614
  type: "thought",
2615
2615
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2661,7 +2661,7 @@ var PromptRenderer = class {
2661
2661
 
2662
2662
  // src/orchestrator.ts
2663
2663
  import { EventEmitter } from "events";
2664
- import * as path16 from "path";
2664
+ import * as path19 from "path";
2665
2665
  import { randomUUID as randomUUID5 } from "crypto";
2666
2666
  import { writeTaint } from "@harness-engineering/core";
2667
2667
 
@@ -3635,11 +3635,11 @@ function detectLegacyFields(agent) {
3635
3635
  }
3636
3636
  function buildCase1Warnings(presentLegacy, suppressLocalGroup) {
3637
3637
  const warnings = [];
3638
- for (const path17 of presentLegacy) {
3639
- if (CASE1_ALWAYS_SUPPRESS.has(path17)) continue;
3640
- if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path17)) continue;
3638
+ for (const path22 of presentLegacy) {
3639
+ if (CASE1_ALWAYS_SUPPRESS.has(path22)) continue;
3640
+ if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path22)) continue;
3641
3641
  warnings.push(
3642
- `Ignoring legacy field '${path17}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3642
+ `Ignoring legacy field '${path22}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3643
3643
  );
3644
3644
  }
3645
3645
  return warnings;
@@ -3667,7 +3667,7 @@ function migrateAgentConfig(agent) {
3667
3667
  }
3668
3668
  const { backends, routing } = synthesizeBackendsAndRouting(agent);
3669
3669
  const warnings = presentLegacy.map(
3670
- (path17) => `Deprecated config field '${path17}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3670
+ (path22) => `Deprecated config field '${path22}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3671
3671
  );
3672
3672
  return {
3673
3673
  config: { ...agent, backends, routing },
@@ -3756,6 +3756,10 @@ var BackendRouter = class {
3756
3756
  const intel = this.routing.intelligence;
3757
3757
  return intel?.[useCase.layer] ?? this.routing.default;
3758
3758
  }
3759
+ case "isolation": {
3760
+ const iso = this.routing.isolation;
3761
+ return iso?.[useCase.tier] ?? this.routing.default;
3762
+ }
3759
3763
  case "maintenance":
3760
3764
  case "chat":
3761
3765
  return this.routing.default;
@@ -3779,8 +3783,8 @@ var BackendRouter = class {
3779
3783
  validateReferences() {
3780
3784
  const known = new Set(Object.keys(this.backends));
3781
3785
  const missing = [];
3782
- const check = (path17, name) => {
3783
- if (name !== void 0 && !known.has(name)) missing.push({ path: path17, name });
3786
+ const check = (path22, name) => {
3787
+ if (name !== void 0 && !known.has(name)) missing.push({ path: path22, name });
3784
3788
  };
3785
3789
  check("default", this.routing.default);
3786
3790
  check("quick-fix", this.routing["quick-fix"]);
@@ -3789,8 +3793,11 @@ var BackendRouter = class {
3789
3793
  check("diagnostic", this.routing.diagnostic);
3790
3794
  check("intelligence.sel", this.routing.intelligence?.sel);
3791
3795
  check("intelligence.pesl", this.routing.intelligence?.pesl);
3796
+ check("isolation.none", this.routing.isolation?.none);
3797
+ check("isolation.container", this.routing.isolation?.container);
3798
+ check("isolation.remote-sandbox", this.routing.isolation?.["remote-sandbox"]);
3792
3799
  if (missing.length > 0) {
3793
- const detail = missing.map(({ path: path17, name }) => `routing.${path17} -> '${name}'`).join("; ");
3800
+ const detail = missing.map(({ path: path22, name }) => `routing.${path22} -> '${name}'`).join("; ");
3794
3801
  const known_ = [...known].join(", ") || "(none)";
3795
3802
  throw new Error(
3796
3803
  `BackendRouter: routing references unknown backend(s): ${detail}. Defined backends: [${known_}].`
@@ -3807,11 +3814,11 @@ import {
3807
3814
  Ok as Ok10,
3808
3815
  Err as Err7
3809
3816
  } from "@harness-engineering/types";
3810
- function resolveExitCode(code, command, resolve6) {
3817
+ function resolveExitCode(code, command, resolve7) {
3811
3818
  if (code === 0) {
3812
- resolve6(Ok10(void 0));
3819
+ resolve7(Ok10(void 0));
3813
3820
  } else {
3814
- resolve6(
3821
+ resolve7(
3815
3822
  Err7({
3816
3823
  category: "agent_not_found",
3817
3824
  message: `Claude command '${command}' not found or failed`
@@ -3819,8 +3826,8 @@ function resolveExitCode(code, command, resolve6) {
3819
3826
  );
3820
3827
  }
3821
3828
  }
3822
- function resolveSpawnError(command, resolve6) {
3823
- resolve6(Err7({ category: "agent_not_found", message: `Claude command '${command}' not found` }));
3829
+ function resolveSpawnError(command, resolve7) {
3830
+ resolve7(Err7({ category: "agent_not_found", message: `Claude command '${command}' not found` }));
3824
3831
  }
3825
3832
  var JUST_PAST_GRACE_MS = 5 * 6e4;
3826
3833
  var PRIMARY_LIMIT_RE = /You[\u2019']ve hit your limit.*resets\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*\(([^)]+)\)/i;
@@ -4133,10 +4140,10 @@ var ClaudeBackend = class {
4133
4140
  errRl.close();
4134
4141
  }
4135
4142
  if (exitCode === null) {
4136
- await new Promise((resolve6) => {
4143
+ await new Promise((resolve7) => {
4137
4144
  child.on("exit", (code) => {
4138
4145
  exitCode = code;
4139
- resolve6(null);
4146
+ resolve7(null);
4140
4147
  });
4141
4148
  });
4142
4149
  }
@@ -4158,10 +4165,10 @@ var ClaudeBackend = class {
4158
4165
  return Ok10(void 0);
4159
4166
  }
4160
4167
  async healthCheck() {
4161
- return new Promise((resolve6) => {
4168
+ return new Promise((resolve7) => {
4162
4169
  const child = spawn2(this.command, ["--version"]);
4163
- child.on("exit", (code) => resolveExitCode(code, this.command, resolve6));
4164
- child.on("error", () => resolveSpawnError(this.command, resolve6));
4170
+ child.on("exit", (code) => resolveExitCode(code, this.command, resolve7));
4171
+ child.on("error", () => resolveSpawnError(this.command, resolve7));
4165
4172
  });
4166
4173
  }
4167
4174
  };
@@ -4784,7 +4791,7 @@ var PiBackend = class {
4784
4791
  } else {
4785
4792
  resolvedModelName = this.config.model;
4786
4793
  }
4787
- const piSdk = await import("@mariozechner/pi-coding-agent");
4794
+ const piSdk = await import("@earendil-works/pi-coding-agent");
4788
4795
  const model = buildLocalModel({
4789
4796
  model: resolvedModelName,
4790
4797
  endpoint: this.config.endpoint,
@@ -4939,7 +4946,7 @@ var PiBackend = class {
4939
4946
  }
4940
4947
  async healthCheck() {
4941
4948
  try {
4942
- await import("@mariozechner/pi-coding-agent");
4949
+ await import("@earendil-works/pi-coding-agent");
4943
4950
  return Ok15(void 0);
4944
4951
  } catch (err) {
4945
4952
  return Err12({
@@ -4950,6 +4957,547 @@ var PiBackend = class {
4950
4957
  }
4951
4958
  };
4952
4959
 
4960
+ // src/agent/backends/ssh.ts
4961
+ import { spawn as spawn3 } from "child_process";
4962
+ import {
4963
+ Ok as Ok16,
4964
+ Err as Err13
4965
+ } from "@harness-engineering/types";
4966
+ var DEFAULT_TIMEOUT_MS2 = 9e4;
4967
+ var FORBIDDEN_HOST_CHARS = /[;&|`$()\n\r<>]/;
4968
+ var SshBackend = class {
4969
+ name = "ssh";
4970
+ config;
4971
+ spawnImpl;
4972
+ constructor(config) {
4973
+ if (!config.host || typeof config.host !== "string") {
4974
+ throw new Error("SshBackend: `host` is required");
4975
+ }
4976
+ if (FORBIDDEN_HOST_CHARS.test(config.host) || config.host.startsWith("-")) {
4977
+ throw new Error(
4978
+ `SshBackend: invalid host '${config.host}' (contains shell metacharacters or starts with '-')`
4979
+ );
4980
+ }
4981
+ if (!config.remoteCommand || typeof config.remoteCommand !== "string") {
4982
+ throw new Error("SshBackend: `remoteCommand` is required");
4983
+ }
4984
+ if (config.user !== void 0 && /[\s;&|`$]/.test(config.user)) {
4985
+ throw new Error(`SshBackend: invalid user '${config.user}'`);
4986
+ }
4987
+ this.config = {
4988
+ host: config.host,
4989
+ remoteCommand: config.remoteCommand,
4990
+ sshBinary: config.sshBinary ?? "ssh",
4991
+ sshOptions: config.sshOptions ?? [],
4992
+ timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS2,
4993
+ ...config.user !== void 0 ? { user: config.user } : {},
4994
+ ...config.port !== void 0 ? { port: config.port } : {},
4995
+ ...config.identityFile !== void 0 ? { identityFile: config.identityFile } : {}
4996
+ };
4997
+ this.spawnImpl = config.spawnImpl ?? spawn3;
4998
+ }
4999
+ /**
5000
+ * Builds the argv passed to the `ssh` binary. Exported as a method on
5001
+ * the class so tests can assert the exact shape without spawning.
5002
+ *
5003
+ * Layout: `[options..., target, '--', remoteCommand]`
5004
+ */
5005
+ buildSshArgs() {
5006
+ const args = [];
5007
+ if (this.config.identityFile) {
5008
+ args.push("-i", this.config.identityFile);
5009
+ }
5010
+ if (this.config.port !== void 0) {
5011
+ args.push("-p", String(this.config.port));
5012
+ }
5013
+ args.push("-o", "BatchMode=yes");
5014
+ for (const opt of this.config.sshOptions) {
5015
+ args.push("-o", opt);
5016
+ }
5017
+ const target = this.config.user ? `${this.config.user}@${this.config.host}` : this.config.host;
5018
+ args.push(target);
5019
+ args.push("--");
5020
+ args.push(this.config.remoteCommand);
5021
+ return args;
5022
+ }
5023
+ async startSession(params) {
5024
+ const session = {
5025
+ sessionId: `ssh-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
5026
+ workspacePath: params.workspacePath,
5027
+ backendName: this.name,
5028
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5029
+ ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
5030
+ };
5031
+ return Ok16(session);
5032
+ }
5033
+ async *runTurn(session, params) {
5034
+ const child = this.spawnImpl(this.config.sshBinary, this.buildSshArgs(), {
5035
+ stdio: ["pipe", "pipe", "pipe"]
5036
+ });
5037
+ const payload = JSON.stringify({
5038
+ kind: "turn",
5039
+ prompt: params.prompt,
5040
+ isContinuation: params.isContinuation,
5041
+ systemPrompt: session.systemPrompt
5042
+ });
5043
+ try {
5044
+ child.stdin.write(payload + "\n");
5045
+ child.stdin.end();
5046
+ } catch (err) {
5047
+ const message = err instanceof Error ? err.message : "failed to write to ssh stdin";
5048
+ try {
5049
+ child.kill("SIGTERM");
5050
+ } catch {
5051
+ }
5052
+ return errResult(session.sessionId, message);
5053
+ }
5054
+ const timeout = setTimeout(() => {
5055
+ try {
5056
+ child.kill("SIGTERM");
5057
+ } catch {
5058
+ }
5059
+ }, this.config.timeoutMs);
5060
+ let finalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
5061
+ let success = true;
5062
+ let lastError;
5063
+ try {
5064
+ for await (const line of readLines(child.stdout)) {
5065
+ let event;
5066
+ try {
5067
+ event = parseEvent(line, session.sessionId);
5068
+ } catch (err) {
5069
+ const message = err instanceof Error ? err.message : "unparseable ssh event";
5070
+ success = false;
5071
+ lastError = message;
5072
+ break;
5073
+ }
5074
+ if (!event) continue;
5075
+ if (event.usage) finalUsage = event.usage;
5076
+ if (event.type === "error" && typeof event.content === "string") {
5077
+ lastError = event.content;
5078
+ success = false;
5079
+ }
5080
+ yield event;
5081
+ }
5082
+ const exitCode = await waitForExit(child);
5083
+ if (exitCode !== 0 && exitCode !== null) {
5084
+ success = false;
5085
+ lastError = lastError ?? `ssh exited with code ${exitCode}`;
5086
+ }
5087
+ } finally {
5088
+ clearTimeout(timeout);
5089
+ }
5090
+ return {
5091
+ success,
5092
+ sessionId: session.sessionId,
5093
+ usage: finalUsage,
5094
+ ...lastError !== void 0 ? { error: lastError } : {}
5095
+ };
5096
+ }
5097
+ async stopSession(_session) {
5098
+ return Ok16(void 0);
5099
+ }
5100
+ async healthCheck() {
5101
+ const args = [...this.buildSshArgs()];
5102
+ args[args.length - 1] = "true";
5103
+ return new Promise((resolve7) => {
5104
+ let child;
5105
+ try {
5106
+ child = this.spawnImpl(this.config.sshBinary, args, {
5107
+ stdio: ["ignore", "ignore", "pipe"]
5108
+ });
5109
+ } catch (err) {
5110
+ resolve7(
5111
+ Err13({
5112
+ category: "agent_not_found",
5113
+ message: err instanceof Error ? err.message : "failed to spawn ssh"
5114
+ })
5115
+ );
5116
+ return;
5117
+ }
5118
+ let stderr = "";
5119
+ child.stderr?.on("data", (chunk) => {
5120
+ stderr += chunk.toString();
5121
+ });
5122
+ const timer = setTimeout(() => {
5123
+ try {
5124
+ child.kill("SIGTERM");
5125
+ } catch {
5126
+ }
5127
+ }, this.config.timeoutMs);
5128
+ child.on("close", (code) => {
5129
+ clearTimeout(timer);
5130
+ if (code === 0) {
5131
+ resolve7(Ok16(void 0));
5132
+ } else {
5133
+ resolve7(
5134
+ Err13({
5135
+ category: "agent_not_found",
5136
+ message: `ssh health check failed (exit=${code ?? "null"}): ${stderr.slice(0, 500)}`
5137
+ })
5138
+ );
5139
+ }
5140
+ });
5141
+ child.on("error", (err) => {
5142
+ clearTimeout(timer);
5143
+ resolve7(Err13({ category: "agent_not_found", message: err.message }));
5144
+ });
5145
+ });
5146
+ }
5147
+ };
5148
+ function errResult(sessionId, message) {
5149
+ return {
5150
+ success: false,
5151
+ sessionId,
5152
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5153
+ error: message
5154
+ };
5155
+ }
5156
+ function parseEvent(line, sessionId) {
5157
+ const trimmed = line.trim();
5158
+ if (trimmed.length === 0) return null;
5159
+ const raw = JSON.parse(trimmed);
5160
+ if (typeof raw.type !== "string") {
5161
+ throw new Error(`ssh event missing 'type': ${trimmed.slice(0, 200)}`);
5162
+ }
5163
+ const ev = {
5164
+ type: raw.type,
5165
+ timestamp: typeof raw.timestamp === "string" ? raw.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
5166
+ sessionId
5167
+ };
5168
+ if (typeof raw.subtype === "string") ev.subtype = raw.subtype;
5169
+ if (raw.content !== void 0) ev.content = raw.content;
5170
+ if (isUsage(raw.usage)) ev.usage = raw.usage;
5171
+ return ev;
5172
+ }
5173
+ function isUsage(u) {
5174
+ if (!u || typeof u !== "object") return false;
5175
+ const o = u;
5176
+ return typeof o.inputTokens === "number" && typeof o.outputTokens === "number" && typeof o.totalTokens === "number";
5177
+ }
5178
+ async function* readLines(stream) {
5179
+ let buffer = "";
5180
+ for await (const chunk of stream) {
5181
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5182
+ let idx;
5183
+ while ((idx = buffer.indexOf("\n")) >= 0) {
5184
+ yield buffer.slice(0, idx);
5185
+ buffer = buffer.slice(idx + 1);
5186
+ }
5187
+ }
5188
+ if (buffer.length > 0) yield buffer;
5189
+ }
5190
+ function waitForExit(child) {
5191
+ return new Promise((resolve7) => {
5192
+ if (child.exitCode !== null) {
5193
+ resolve7(child.exitCode);
5194
+ return;
5195
+ }
5196
+ child.once("close", (code) => resolve7(code));
5197
+ child.once("error", () => resolve7(null));
5198
+ });
5199
+ }
5200
+
5201
+ // src/agent/backends/serverless.ts
5202
+ import { spawn as spawn4 } from "child_process";
5203
+ import {
5204
+ Ok as Ok17,
5205
+ Err as Err14
5206
+ } from "@harness-engineering/types";
5207
+ var ServerlessBackend = class {
5208
+ handles = /* @__PURE__ */ new Map();
5209
+ async startSession(params) {
5210
+ const start = await this.coldStart(params);
5211
+ if (!start.ok) return start;
5212
+ const session = {
5213
+ sessionId: `${this.name}-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
5214
+ workspacePath: params.workspacePath,
5215
+ backendName: this.name,
5216
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
5217
+ };
5218
+ this.handles.set(session.sessionId, start.value);
5219
+ return Ok17(session);
5220
+ }
5221
+ async *runTurn(session, params) {
5222
+ const handle = this.handles.get(session.sessionId);
5223
+ if (!handle) {
5224
+ return {
5225
+ success: false,
5226
+ sessionId: session.sessionId,
5227
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5228
+ error: `no serverless handle for session ${session.sessionId}`
5229
+ };
5230
+ }
5231
+ return yield* this.runOnHandle(handle, params, session);
5232
+ }
5233
+ async stopSession(session) {
5234
+ const handle = this.handles.get(session.sessionId);
5235
+ if (!handle) return Ok17(void 0);
5236
+ this.handles.delete(session.sessionId);
5237
+ return this.teardown(handle);
5238
+ }
5239
+ };
5240
+ var FORBIDDEN_IMAGE_CHARS = /[;&|`$()\n\r<>]/;
5241
+ var BLOCKED_DOCKER_FLAGS = [
5242
+ "--privileged",
5243
+ "--cap-add",
5244
+ "--security-opt",
5245
+ "--pid",
5246
+ "--ipc",
5247
+ "--userns"
5248
+ ];
5249
+ var DEFAULT_OCI_TIMEOUT_MS = 9e4;
5250
+ var OciServerlessBackend = class extends ServerlessBackend {
5251
+ name = "serverless:oci";
5252
+ config;
5253
+ spawnImpl;
5254
+ envSource;
5255
+ constructor(config) {
5256
+ super();
5257
+ if (!config.image || typeof config.image !== "string") {
5258
+ throw new Error("OciServerlessBackend: `image` is required");
5259
+ }
5260
+ if (FORBIDDEN_IMAGE_CHARS.test(config.image) || config.image.startsWith("-")) {
5261
+ throw new Error(
5262
+ `OciServerlessBackend: invalid image '${config.image}' (contains shell metacharacters or starts with '-')`
5263
+ );
5264
+ }
5265
+ this.config = {
5266
+ image: config.image,
5267
+ pullPolicy: config.pullPolicy ?? "if-not-present",
5268
+ runtime: config.runtime ?? "docker",
5269
+ envPassthrough: config.envPassthrough ?? [],
5270
+ timeoutMs: config.timeoutMs ?? DEFAULT_OCI_TIMEOUT_MS,
5271
+ extraArgs: sanitizeExtraArgs(config.extraArgs),
5272
+ ...config.registry !== void 0 ? { registry: config.registry } : {}
5273
+ };
5274
+ this.spawnImpl = config.spawnImpl ?? spawn4;
5275
+ this.envSource = config.envSource ?? process.env;
5276
+ }
5277
+ /** Builds the argv for `docker run -d ...`. Exposed for tests. */
5278
+ buildRunArgs() {
5279
+ const env = this.collectEnv();
5280
+ const args = ["run", "-d", "--rm"];
5281
+ for (const [k, v] of Object.entries(env)) {
5282
+ args.push("-e", `${k}=${v}`);
5283
+ }
5284
+ for (const ea of this.config.extraArgs) {
5285
+ args.push(ea);
5286
+ }
5287
+ args.push("--");
5288
+ args.push(this.config.image);
5289
+ return args;
5290
+ }
5291
+ /** Builds the argv for `docker exec <id> -- agent`. Exposed for tests. */
5292
+ buildExecArgs(handleId) {
5293
+ return ["exec", "-i", handleId, "/agent"];
5294
+ }
5295
+ async coldStart(_params) {
5296
+ if (this.config.pullPolicy === "always") {
5297
+ const pull = await this.runOneShot(this.config.runtime, ["pull", this.config.image]);
5298
+ if (!pull.ok) return pull;
5299
+ }
5300
+ const result = await this.runOneShot(this.config.runtime, this.buildRunArgs());
5301
+ if (!result.ok) return result;
5302
+ const id = result.value.trim().split(/\s+/)[0] ?? "";
5303
+ if (!id) {
5304
+ return Err14({
5305
+ category: "response_error",
5306
+ message: "OciServerlessBackend: empty container id from runtime"
5307
+ });
5308
+ }
5309
+ return Ok17({ id, adapter: this.name });
5310
+ }
5311
+ async *runOnHandle(handle, params, session) {
5312
+ const child = this.spawnImpl(this.config.runtime, this.buildExecArgs(handle.id), {
5313
+ stdio: ["pipe", "pipe", "pipe"]
5314
+ });
5315
+ const payload = JSON.stringify({
5316
+ kind: "turn",
5317
+ prompt: params.prompt,
5318
+ isContinuation: params.isContinuation
5319
+ });
5320
+ try {
5321
+ child.stdin.write(payload + "\n");
5322
+ child.stdin.end();
5323
+ } catch (err) {
5324
+ const message = err instanceof Error ? err.message : "failed to write to docker stdin";
5325
+ return turnFailure(session.sessionId, message);
5326
+ }
5327
+ const timeout = setTimeout(() => {
5328
+ try {
5329
+ child.kill("SIGTERM");
5330
+ } catch {
5331
+ }
5332
+ }, this.config.timeoutMs);
5333
+ let finalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
5334
+ let success = true;
5335
+ let lastError;
5336
+ try {
5337
+ for await (const line of readLines2(child.stdout)) {
5338
+ const ev = tryParseEvent(line, session.sessionId);
5339
+ if (!ev) continue;
5340
+ if (ev.usage) finalUsage = ev.usage;
5341
+ if (ev.type === "error" && typeof ev.content === "string") {
5342
+ success = false;
5343
+ lastError = ev.content;
5344
+ }
5345
+ yield ev;
5346
+ }
5347
+ const code = await waitForExit2(child);
5348
+ if (code !== 0 && code !== null) {
5349
+ success = false;
5350
+ lastError = lastError ?? `runtime exec exited with code ${code}`;
5351
+ }
5352
+ } finally {
5353
+ clearTimeout(timeout);
5354
+ }
5355
+ return {
5356
+ success,
5357
+ sessionId: session.sessionId,
5358
+ usage: finalUsage,
5359
+ ...lastError !== void 0 ? { error: lastError } : {}
5360
+ };
5361
+ }
5362
+ async teardown(handle) {
5363
+ if (handle.adapter !== this.name) {
5364
+ return Err14({
5365
+ category: "response_error",
5366
+ message: `handle adapter mismatch: got '${handle.adapter}', expected '${this.name}'`
5367
+ });
5368
+ }
5369
+ const stop = await this.runOneShot(this.config.runtime, ["stop", handle.id]);
5370
+ if (!stop.ok) return stop;
5371
+ return Ok17(void 0);
5372
+ }
5373
+ async healthCheck() {
5374
+ return mapOk(
5375
+ await this.runOneShot(this.config.runtime, ["version", "--format", "{{.Server.Version}}"])
5376
+ );
5377
+ }
5378
+ collectEnv() {
5379
+ const out = {};
5380
+ for (const key of this.config.envPassthrough) {
5381
+ const val = this.envSource[key];
5382
+ if (typeof val === "string") out[key] = val;
5383
+ }
5384
+ return out;
5385
+ }
5386
+ runOneShot(binary, args) {
5387
+ return new Promise((resolve7) => {
5388
+ let child;
5389
+ try {
5390
+ child = this.spawnImpl(binary, args, {
5391
+ stdio: ["ignore", "pipe", "pipe"]
5392
+ });
5393
+ } catch (err) {
5394
+ resolve7(
5395
+ Err14({
5396
+ category: "agent_not_found",
5397
+ message: err instanceof Error ? err.message : "failed to spawn runtime"
5398
+ })
5399
+ );
5400
+ return;
5401
+ }
5402
+ let stdout = "";
5403
+ let stderr = "";
5404
+ child.stdout?.on("data", (chunk) => {
5405
+ stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5406
+ });
5407
+ child.stderr?.on("data", (chunk) => {
5408
+ stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5409
+ });
5410
+ const timer = setTimeout(() => {
5411
+ try {
5412
+ child.kill("SIGTERM");
5413
+ } catch {
5414
+ }
5415
+ }, this.config.timeoutMs);
5416
+ child.on("close", (code) => {
5417
+ clearTimeout(timer);
5418
+ if (code === 0) {
5419
+ resolve7(Ok17(stdout));
5420
+ } else {
5421
+ resolve7(
5422
+ Err14({
5423
+ category: "response_error",
5424
+ message: `runtime '${binary} ${args.join(" ")}' exited ${code ?? "null"}: ${stderr.slice(0, 500)}`
5425
+ })
5426
+ );
5427
+ }
5428
+ });
5429
+ child.on("error", (err) => {
5430
+ clearTimeout(timer);
5431
+ resolve7(Err14({ category: "agent_not_found", message: err.message }));
5432
+ });
5433
+ });
5434
+ }
5435
+ };
5436
+ function sanitizeExtraArgs(extraArgs) {
5437
+ if (!extraArgs) return [];
5438
+ return extraArgs.filter((arg) => !BLOCKED_DOCKER_FLAGS.some((flag) => arg.startsWith(flag)));
5439
+ }
5440
+ function mapOk(r) {
5441
+ return r.ok ? Ok17(void 0) : r;
5442
+ }
5443
+ function turnFailure(sessionId, message) {
5444
+ return {
5445
+ success: false,
5446
+ sessionId,
5447
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5448
+ error: message
5449
+ };
5450
+ }
5451
+ function tryParseEvent(line, sessionId) {
5452
+ const trimmed = line.trim();
5453
+ if (!trimmed) return null;
5454
+ let raw;
5455
+ try {
5456
+ raw = JSON.parse(trimmed);
5457
+ } catch {
5458
+ return null;
5459
+ }
5460
+ if (!raw || typeof raw !== "object") return null;
5461
+ const o = raw;
5462
+ if (typeof o.type !== "string") return null;
5463
+ const ev = {
5464
+ type: o.type,
5465
+ timestamp: typeof o.timestamp === "string" ? o.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
5466
+ sessionId
5467
+ };
5468
+ if (typeof o.subtype === "string") ev.subtype = o.subtype;
5469
+ if (o.content !== void 0) ev.content = o.content;
5470
+ if (isUsage2(o.usage)) ev.usage = o.usage;
5471
+ return ev;
5472
+ }
5473
+ function isUsage2(u) {
5474
+ if (!u || typeof u !== "object") return false;
5475
+ const o = u;
5476
+ return typeof o.inputTokens === "number" && typeof o.outputTokens === "number" && typeof o.totalTokens === "number";
5477
+ }
5478
+ async function* readLines2(stream) {
5479
+ let buffer = "";
5480
+ for await (const chunk of stream) {
5481
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
5482
+ let idx;
5483
+ while ((idx = buffer.indexOf("\n")) >= 0) {
5484
+ yield buffer.slice(0, idx);
5485
+ buffer = buffer.slice(idx + 1);
5486
+ }
5487
+ }
5488
+ if (buffer.length > 0) yield buffer;
5489
+ }
5490
+ function waitForExit2(child) {
5491
+ return new Promise((resolve7) => {
5492
+ if (child.exitCode !== null) {
5493
+ resolve7(child.exitCode);
5494
+ return;
5495
+ }
5496
+ child.once("close", (code) => resolve7(code));
5497
+ child.once("error", () => resolve7(null));
5498
+ });
5499
+ }
5500
+
4953
5501
  // src/agent/backend-factory.ts
4954
5502
  function makeGetModel(model) {
4955
5503
  if (typeof model === "string") return () => model;
@@ -4999,6 +5547,35 @@ function createBackend(def, options = {}) {
4999
5547
  ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
5000
5548
  });
5001
5549
  }
5550
+ case "ssh": {
5551
+ return new SshBackend({
5552
+ host: def.host,
5553
+ remoteCommand: def.remoteCommand,
5554
+ ...def.user !== void 0 ? { user: def.user } : {},
5555
+ ...def.port !== void 0 ? { port: def.port } : {},
5556
+ ...def.identityFile !== void 0 ? { identityFile: def.identityFile } : {},
5557
+ ...def.sshOptions !== void 0 ? { sshOptions: def.sshOptions } : {},
5558
+ ...def.sshBinary !== void 0 ? { sshBinary: def.sshBinary } : {}
5559
+ });
5560
+ }
5561
+ case "serverless": {
5562
+ switch (def.adapter) {
5563
+ case "oci":
5564
+ return new OciServerlessBackend({
5565
+ image: def.image,
5566
+ ...def.registry !== void 0 ? { registry: def.registry } : {},
5567
+ ...def.pullPolicy !== void 0 ? { pullPolicy: def.pullPolicy } : {},
5568
+ ...def.envPassthrough !== void 0 ? { envPassthrough: def.envPassthrough } : {},
5569
+ ...def.runtime !== void 0 ? { runtime: def.runtime } : {}
5570
+ });
5571
+ default: {
5572
+ const exhaustive = def.adapter;
5573
+ throw new Error(
5574
+ `createBackend: unknown serverless adapter ${JSON.stringify(exhaustive)}`
5575
+ );
5576
+ }
5577
+ }
5578
+ }
5002
5579
  default: {
5003
5580
  const exhaustive = def;
5004
5581
  throw new Error(`createBackend: unknown backend type ${JSON.stringify(exhaustive)}`);
@@ -5008,13 +5585,13 @@ function createBackend(def, options = {}) {
5008
5585
 
5009
5586
  // src/agent/backends/container.ts
5010
5587
  import {
5011
- Err as Err13
5588
+ Err as Err15
5012
5589
  } from "@harness-engineering/types";
5013
5590
  function toAgentError(message, details) {
5014
5591
  return { category: "response_error", message, details };
5015
5592
  }
5016
5593
  var BLOCKED_FLAGS = ["--privileged", "--cap-add", "--security-opt", "--pid", "--ipc", "--userns"];
5017
- function sanitizeExtraArgs(extraArgs) {
5594
+ function sanitizeExtraArgs2(extraArgs) {
5018
5595
  if (!extraArgs) return [];
5019
5596
  return extraArgs.filter((arg) => !BLOCKED_FLAGS.some((flag) => arg.startsWith(flag)));
5020
5597
  }
@@ -5040,7 +5617,7 @@ var ContainerBackend = class {
5040
5617
  }
5041
5618
  const result = await this.secretBackend.resolveSecrets(this.secretKeys);
5042
5619
  if (!result.ok) {
5043
- return Err13(toAgentError(`Secret resolution failed: ${result.error.message}`, result.error));
5620
+ return Err15(toAgentError(`Secret resolution failed: ${result.error.message}`, result.error));
5044
5621
  }
5045
5622
  return { ok: true, value: result.value };
5046
5623
  }
@@ -5053,7 +5630,7 @@ var ContainerBackend = class {
5053
5630
  network: this.containerConfig.network ?? "none",
5054
5631
  env
5055
5632
  };
5056
- const sanitized = sanitizeExtraArgs(this.containerConfig.extraArgs);
5633
+ const sanitized = sanitizeExtraArgs2(this.containerConfig.extraArgs);
5057
5634
  if (sanitized.length > 0) {
5058
5635
  opts.extraArgs = sanitized;
5059
5636
  }
@@ -5065,7 +5642,7 @@ var ContainerBackend = class {
5065
5642
  const createOpts = this.buildCreateOpts(params, envResult.value);
5066
5643
  const containerResult = await this.runtime.createContainer(createOpts);
5067
5644
  if (!containerResult.ok) {
5068
- return Err13(
5645
+ return Err15(
5069
5646
  toAgentError(
5070
5647
  `Container creation failed: ${containerResult.error.message}`,
5071
5648
  containerResult.error
@@ -5090,7 +5667,7 @@ var ContainerBackend = class {
5090
5667
  this.containerHandles.delete(session.sessionId);
5091
5668
  const removeResult = await this.runtime.removeContainer(handle);
5092
5669
  if (!removeResult.ok) {
5093
- return Err13(
5670
+ return Err15(
5094
5671
  toAgentError(
5095
5672
  `Container removal failed: ${removeResult.error.message}`,
5096
5673
  removeResult.error
@@ -5103,7 +5680,7 @@ var ContainerBackend = class {
5103
5680
  async healthCheck() {
5104
5681
  const runtimeResult = await this.runtime.healthCheck();
5105
5682
  if (!runtimeResult.ok) {
5106
- return Err13({
5683
+ return Err15({
5107
5684
  category: "agent_not_found",
5108
5685
  message: `Container runtime unhealthy: ${runtimeResult.error.message}`,
5109
5686
  details: runtimeResult.error
@@ -5114,16 +5691,16 @@ var ContainerBackend = class {
5114
5691
  };
5115
5692
 
5116
5693
  // src/agent/runtime/docker.ts
5117
- import { execFile as execFile3, spawn as spawn3 } from "child_process";
5118
- import { Ok as Ok16, Err as Err14 } from "@harness-engineering/types";
5694
+ import { execFile as execFile3, spawn as spawn5 } from "child_process";
5695
+ import { Ok as Ok18, Err as Err16 } from "@harness-engineering/types";
5119
5696
  function dockerExec(args) {
5120
- return new Promise((resolve6, reject) => {
5697
+ return new Promise((resolve7, reject) => {
5121
5698
  execFile3("docker", args, (error, stdout) => {
5122
5699
  if (error) {
5123
5700
  reject(error);
5124
5701
  return;
5125
5702
  }
5126
- resolve6(stdout.trim());
5703
+ resolve7(stdout.trim());
5127
5704
  });
5128
5705
  });
5129
5706
  }
@@ -5148,9 +5725,9 @@ var DockerRuntime = class {
5148
5725
  args.push(opts.image);
5149
5726
  args.push("sleep", "infinity");
5150
5727
  const containerId = await dockerExec(args);
5151
- return Ok16({ containerId, runtime: this.name });
5728
+ return Ok18({ containerId, runtime: this.name });
5152
5729
  } catch (error) {
5153
- return Err14({
5730
+ return Err16({
5154
5731
  category: "container_create_failed",
5155
5732
  message: `Failed to create container: ${error instanceof Error ? error.message : String(error)}`,
5156
5733
  details: error
@@ -5172,7 +5749,7 @@ var DockerRuntime = class {
5172
5749
  }
5173
5750
  }
5174
5751
  execArgs.push(handle.containerId, ...cmd);
5175
- const child = spawn3("docker", execArgs);
5752
+ const child = spawn5("docker", execArgs);
5176
5753
  const readline3 = await import("readline");
5177
5754
  const rl = readline3.createInterface({ input: child.stdout, terminal: false });
5178
5755
  try {
@@ -5182,11 +5759,11 @@ var DockerRuntime = class {
5182
5759
  } finally {
5183
5760
  rl.close();
5184
5761
  }
5185
- const exitCode = await new Promise((resolve6) => {
5762
+ const exitCode = await new Promise((resolve7) => {
5186
5763
  if (child.exitCode !== null) {
5187
- resolve6(child.exitCode);
5764
+ resolve7(child.exitCode);
5188
5765
  } else {
5189
- child.on("exit", (code) => resolve6(code ?? 1));
5766
+ child.on("exit", (code) => resolve7(code ?? 1));
5190
5767
  }
5191
5768
  });
5192
5769
  return exitCode;
@@ -5194,9 +5771,9 @@ var DockerRuntime = class {
5194
5771
  async removeContainer(handle) {
5195
5772
  try {
5196
5773
  await dockerExec(["rm", "-f", handle.containerId]);
5197
- return Ok16(void 0);
5774
+ return Ok18(void 0);
5198
5775
  } catch (error) {
5199
- return Err14({
5776
+ return Err16({
5200
5777
  category: "container_remove_failed",
5201
5778
  message: `Failed to remove container: ${error instanceof Error ? error.message : String(error)}`,
5202
5779
  details: error
@@ -5206,9 +5783,9 @@ var DockerRuntime = class {
5206
5783
  async healthCheck() {
5207
5784
  try {
5208
5785
  await dockerExec(["info", "--format", "{{.ServerVersion}}"]);
5209
- return Ok16(void 0);
5786
+ return Ok18(void 0);
5210
5787
  } catch (error) {
5211
- return Err14({
5788
+ return Err16({
5212
5789
  category: "runtime_not_found",
5213
5790
  message: `Docker is not available: ${error instanceof Error ? error.message : String(error)}`,
5214
5791
  details: error
@@ -5218,7 +5795,7 @@ var DockerRuntime = class {
5218
5795
  };
5219
5796
 
5220
5797
  // src/agent/secrets/env.ts
5221
- import { Ok as Ok17, Err as Err15 } from "@harness-engineering/types";
5798
+ import { Ok as Ok19, Err as Err17 } from "@harness-engineering/types";
5222
5799
  var EnvSecretBackend = class {
5223
5800
  name = "env";
5224
5801
  async resolveSecrets(keys) {
@@ -5226,7 +5803,7 @@ var EnvSecretBackend = class {
5226
5803
  for (const key of keys) {
5227
5804
  const value = process.env[key];
5228
5805
  if (value === void 0) {
5229
- return Err15({
5806
+ return Err17({
5230
5807
  category: "secret_not_found",
5231
5808
  message: `Environment variable '${key}' is not set`,
5232
5809
  key
@@ -5234,24 +5811,24 @@ var EnvSecretBackend = class {
5234
5811
  }
5235
5812
  secrets[key] = value;
5236
5813
  }
5237
- return Ok17(secrets);
5814
+ return Ok19(secrets);
5238
5815
  }
5239
5816
  async healthCheck() {
5240
- return Ok17(void 0);
5817
+ return Ok19(void 0);
5241
5818
  }
5242
5819
  };
5243
5820
 
5244
5821
  // src/agent/secrets/onepassword.ts
5245
5822
  import { execFile as execFile4 } from "child_process";
5246
- import { Ok as Ok18, Err as Err16 } from "@harness-engineering/types";
5823
+ import { Ok as Ok20, Err as Err18 } from "@harness-engineering/types";
5247
5824
  function opExec(args) {
5248
- return new Promise((resolve6, reject) => {
5825
+ return new Promise((resolve7, reject) => {
5249
5826
  execFile4("op", args, (error, stdout) => {
5250
5827
  if (error) {
5251
5828
  reject(error);
5252
5829
  return;
5253
5830
  }
5254
- resolve6(stdout.trim());
5831
+ resolve7(stdout.trim());
5255
5832
  });
5256
5833
  });
5257
5834
  }
@@ -5268,21 +5845,21 @@ var OnePasswordSecretBackend = class {
5268
5845
  const value = await opExec(["read", `op://${this.vault}/${key}/password`]);
5269
5846
  secrets[key] = value;
5270
5847
  } catch (error) {
5271
- return Err16({
5848
+ return Err18({
5272
5849
  category: "access_denied",
5273
5850
  message: `Failed to read secret '${key}' from 1Password: ${error instanceof Error ? error.message : String(error)}`,
5274
5851
  key
5275
5852
  });
5276
5853
  }
5277
5854
  }
5278
- return Ok18(secrets);
5855
+ return Ok20(secrets);
5279
5856
  }
5280
5857
  async healthCheck() {
5281
5858
  try {
5282
5859
  await opExec(["--version"]);
5283
- return Ok18(void 0);
5860
+ return Ok20(void 0);
5284
5861
  } catch (error) {
5285
- return Err16({
5862
+ return Err18({
5286
5863
  category: "provider_unavailable",
5287
5864
  message: `1Password CLI is not available: ${error instanceof Error ? error.message : String(error)}`
5288
5865
  });
@@ -5292,15 +5869,15 @@ var OnePasswordSecretBackend = class {
5292
5869
 
5293
5870
  // src/agent/secrets/vault.ts
5294
5871
  import { execFile as execFile5 } from "child_process";
5295
- import { Ok as Ok19, Err as Err17 } from "@harness-engineering/types";
5872
+ import { Ok as Ok21, Err as Err19 } from "@harness-engineering/types";
5296
5873
  function vaultExec(args, env) {
5297
- return new Promise((resolve6, reject) => {
5874
+ return new Promise((resolve7, reject) => {
5298
5875
  execFile5("vault", args, { env: { ...process.env, ...env } }, (error, stdout) => {
5299
5876
  if (error) {
5300
5877
  reject(error);
5301
5878
  return;
5302
5879
  }
5303
- resolve6(stdout.trim());
5880
+ resolve7(stdout.trim());
5304
5881
  });
5305
5882
  });
5306
5883
  }
@@ -5323,11 +5900,11 @@ var VaultSecretBackend = class {
5323
5900
  } catch (error) {
5324
5901
  const msg = error instanceof Error ? error.message : String(error);
5325
5902
  const category = error instanceof SyntaxError ? "access_denied" : "access_denied";
5326
- return Err17({ category, message: `Failed to read from Vault: ${msg}` });
5903
+ return Err19({ category, message: `Failed to read from Vault: ${msg}` });
5327
5904
  }
5328
5905
  const missing = keys.find((k) => !(k in data));
5329
5906
  if (missing) {
5330
- return Err17({
5907
+ return Err19({
5331
5908
  category: "secret_not_found",
5332
5909
  message: `Secret key '${missing}' not found in Vault path '${this.path}'`,
5333
5910
  key: missing
@@ -5335,14 +5912,14 @@ var VaultSecretBackend = class {
5335
5912
  }
5336
5913
  const secrets = {};
5337
5914
  for (const key of keys) secrets[key] = data[key];
5338
- return Ok19(secrets);
5915
+ return Ok21(secrets);
5339
5916
  }
5340
5917
  async healthCheck() {
5341
5918
  try {
5342
5919
  await vaultExec(["version"]);
5343
- return Ok19(void 0);
5920
+ return Ok21(void 0);
5344
5921
  } catch (error) {
5345
- return Err17({
5922
+ return Err19({
5346
5923
  category: "provider_unavailable",
5347
5924
  message: `Vault CLI is not available: ${error instanceof Error ? error.message : String(error)}`
5348
5925
  });
@@ -5488,6 +6065,8 @@ function buildAnalysisProvider(args) {
5488
6065
  return buildClaudeCliProvider(def, args, layerModel);
5489
6066
  case "mock":
5490
6067
  case "gemini":
6068
+ case "ssh":
6069
+ case "serverless":
5491
6070
  logger.warn(
5492
6071
  `Intelligence pipeline disabled for layer '${layer}': routed backend '${backendName}' has type '${def.type}' which has no AnalysisProvider implementation.`
5493
6072
  );
@@ -5670,7 +6249,7 @@ function buildExplicitProvider(provider, selModel, config) {
5670
6249
 
5671
6250
  // src/server/http.ts
5672
6251
  import * as http from "http";
5673
- import * as path14 from "path";
6252
+ import * as path15 from "path";
5674
6253
  import { assertPortUsable } from "@harness-engineering/core";
5675
6254
 
5676
6255
  // src/server/websocket.ts
@@ -5733,7 +6312,7 @@ import { z as z3 } from "zod";
5733
6312
  // src/server/utils.ts
5734
6313
  var DEFAULT_MAX_BYTES = 1048576;
5735
6314
  function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
5736
- return new Promise((resolve6, reject) => {
6315
+ return new Promise((resolve7, reject) => {
5737
6316
  let body = "";
5738
6317
  let bytes = 0;
5739
6318
  req.on("data", (chunk) => {
@@ -5745,7 +6324,7 @@ function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
5745
6324
  }
5746
6325
  body += String(chunk);
5747
6326
  });
5748
- req.on("end", () => resolve6(body));
6327
+ req.on("end", () => resolve7(body));
5749
6328
  req.on("error", reject);
5750
6329
  });
5751
6330
  }
@@ -5911,7 +6490,7 @@ function handlePlansRoute(req, res, plansDir) {
5911
6490
  }
5912
6491
 
5913
6492
  // src/server/routes/chat-proxy.ts
5914
- import { spawn as spawn4 } from "child_process";
6493
+ import { spawn as spawn6 } from "child_process";
5915
6494
  import { randomUUID as randomUUID4 } from "crypto";
5916
6495
  import * as readline2 from "readline";
5917
6496
  import { z as z6 } from "zod";
@@ -5997,7 +6576,7 @@ async function handleChatRequest(req, res, command) {
5997
6576
  });
5998
6577
  emit(res, { type: "session", sessionId });
5999
6578
  const args = buildArgs(parsed.prompt, sessionId, isFirstTurn, parsed.system);
6000
- child = spawn4(command, args, { env: buildChildEnv(), stdio: "pipe" });
6579
+ child = spawn6(command, args, { env: buildChildEnv(), stdio: "pipe" });
6001
6580
  child.stdin?.end();
6002
6581
  let clientDisconnected = false;
6003
6582
  res.on("close", () => {
@@ -6881,36 +7460,577 @@ function handleV1TelemetryRoute(req, res, deps) {
6881
7460
  return false;
6882
7461
  }
6883
7462
 
6884
- // src/server/routes/sessions.ts
6885
- import * as fs11 from "fs/promises";
6886
- import * as path11 from "path";
7463
+ // src/server/routes/v1/proposals.ts
6887
7464
  import { z as z13 } from "zod";
6888
- var SessionCreateSchema = z13.object({
6889
- sessionId: z13.string().min(1)
6890
- }).passthrough();
6891
- var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6892
- function isSafeId(id) {
6893
- return UUID_RE2.test(id) || path11.basename(id) === id && !id.includes("..");
6894
- }
6895
- function jsonResponse(res, status, data) {
6896
- res.writeHead(status, { "Content-Type": "application/json" });
6897
- res.end(JSON.stringify(data));
6898
- }
6899
- function extractSessionId(url) {
6900
- const segments = new URL(url, "http://localhost").pathname.split(path11.posix.sep);
6901
- const id = segments.pop();
6902
- return id && id !== "sessions" ? id : null;
6903
- }
6904
- async function handleList(res, sessionsDir) {
6905
- try {
6906
- const entries = await fs11.readdir(sessionsDir, { withFileTypes: true });
6907
- const sessions = [];
6908
- for (const entry of entries) {
6909
- if (!entry.isDirectory()) continue;
6910
- try {
6911
- const content = await fs11.readFile(
6912
- path11.join(sessionsDir, entry.name, "session.json"),
6913
- "utf-8"
7465
+ import {
7466
+ getProposal as getProposal3,
7467
+ listProposals,
7468
+ updateProposal as updateProposal3,
7469
+ ProposalNotFoundError as ProposalNotFoundError3
7470
+ } from "@harness-engineering/core";
7471
+ import {
7472
+ EditProposalInputSchema
7473
+ } from "@harness-engineering/types";
7474
+
7475
+ // src/proposals/gate.ts
7476
+ import { parse as parseYaml } from "yaml";
7477
+ import {
7478
+ getProposal,
7479
+ updateProposal,
7480
+ ProposalNotFoundError
7481
+ } from "@harness-engineering/core";
7482
+ var GateRunError = class extends Error {
7483
+ constructor(message) {
7484
+ super(message);
7485
+ this.name = "GateRunError";
7486
+ }
7487
+ };
7488
+ var SKILL_NAME_RE = /^[a-z][a-z0-9-]*$/;
7489
+ function checkSkillYaml(yaml) {
7490
+ const findings = [];
7491
+ let doc;
7492
+ try {
7493
+ doc = parseYaml(yaml);
7494
+ } catch (err) {
7495
+ findings.push({
7496
+ severity: "error",
7497
+ title: "skill.yaml does not parse",
7498
+ detail: err instanceof Error ? err.message : String(err)
7499
+ });
7500
+ return findings;
7501
+ }
7502
+ if (!doc || typeof doc !== "object") {
7503
+ findings.push({
7504
+ severity: "error",
7505
+ title: "skill.yaml top-level is not a mapping",
7506
+ detail: "Expected a YAML document with keys at the root (name, version, description, \u2026)."
7507
+ });
7508
+ return findings;
7509
+ }
7510
+ const obj = doc;
7511
+ if (typeof obj["name"] !== "string") {
7512
+ findings.push({
7513
+ severity: "error",
7514
+ title: "skill.yaml missing `name`",
7515
+ detail: "Every skill must declare its kebab-case name."
7516
+ });
7517
+ }
7518
+ if (typeof obj["version"] !== "string") {
7519
+ findings.push({
7520
+ severity: "error",
7521
+ title: "skill.yaml missing `version`",
7522
+ detail: "Every skill must declare a semver version string."
7523
+ });
7524
+ }
7525
+ if (typeof obj["description"] !== "string") {
7526
+ findings.push({
7527
+ severity: "warning",
7528
+ title: "skill.yaml missing `description`",
7529
+ detail: "Description is strongly recommended for discoverability."
7530
+ });
7531
+ }
7532
+ return findings;
7533
+ }
7534
+ function checkSkillMd(md) {
7535
+ const findings = [];
7536
+ if (md.trim().length < 40) {
7537
+ findings.push({
7538
+ severity: "error",
7539
+ title: "SKILL.md is too short",
7540
+ detail: "A skill needs a meaningful description (at least 40 non-whitespace characters)."
7541
+ });
7542
+ }
7543
+ if (!/^#\s+\S/m.test(md)) {
7544
+ findings.push({
7545
+ severity: "warning",
7546
+ title: "SKILL.md has no top-level heading",
7547
+ detail: "Convention: open SKILL.md with `# <Skill Name>`."
7548
+ });
7549
+ }
7550
+ return findings;
7551
+ }
7552
+ function checkName(name) {
7553
+ if (SKILL_NAME_RE.test(name)) return [];
7554
+ return [
7555
+ {
7556
+ severity: "error",
7557
+ title: "skill name violates the kebab-case rule",
7558
+ detail: `"${name}" must match /^[a-z][a-z0-9-]*$/. Use only lowercase letters, digits, and hyphens; start with a letter.`
7559
+ }
7560
+ ];
7561
+ }
7562
+ function checkDiff(diff) {
7563
+ const findings = [];
7564
+ if (!diff.includes("---") || !diff.includes("+++")) {
7565
+ findings.push({
7566
+ severity: "error",
7567
+ title: "Refinement diff is not in unified-diff format",
7568
+ detail: "Diffs must include both `---` and `+++` headers."
7569
+ });
7570
+ }
7571
+ if (!/^@@\s/m.test(diff)) {
7572
+ findings.push({
7573
+ severity: "warning",
7574
+ title: "Refinement diff has no hunk marker",
7575
+ detail: "A unified diff typically contains at least one `@@` line."
7576
+ });
7577
+ }
7578
+ return findings;
7579
+ }
7580
+ function deriveFindings(proposal) {
7581
+ const findings = [];
7582
+ findings.push(...checkName(proposal.content.name));
7583
+ if (proposal.kind === "new-skill") {
7584
+ findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
7585
+ findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
7586
+ } else if (proposal.kind === "refinement") {
7587
+ findings.push(...checkDiff(proposal.content.diff ?? ""));
7588
+ }
7589
+ return findings;
7590
+ }
7591
+ async function runGate(projectPath, proposalId) {
7592
+ const proposal = await getProposal(projectPath, proposalId);
7593
+ if (!proposal) throw new ProposalNotFoundError(proposalId);
7594
+ if (proposal.status === "approved" || proposal.status === "rejected") {
7595
+ throw new GateRunError(
7596
+ `proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
7597
+ );
7598
+ }
7599
+ const findings = deriveFindings(proposal);
7600
+ const runAt = (/* @__PURE__ */ new Date()).toISOString();
7601
+ const hasError = findings.some((f) => f.severity === "error");
7602
+ const nextStatus = hasError ? "gate-failed" : "gate-running";
7603
+ const updated = await updateProposal(projectPath, proposalId, {
7604
+ status: nextStatus,
7605
+ gate: { lastRunAt: runAt, findings }
7606
+ });
7607
+ return {
7608
+ proposalId: updated.id,
7609
+ status: updated.status,
7610
+ findings,
7611
+ runAt
7612
+ };
7613
+ }
7614
+
7615
+ // src/proposals/promote.ts
7616
+ import * as fs11 from "fs";
7617
+ import * as path11 from "path";
7618
+ import { parse as parseYaml2, stringify as stringifyYaml } from "yaml";
7619
+ import {
7620
+ getProposal as getProposal2,
7621
+ updateProposal as updateProposal2,
7622
+ ProposalNotFoundError as ProposalNotFoundError2
7623
+ } from "@harness-engineering/core";
7624
+ var GateNotReadyError = class extends Error {
7625
+ constructor(message) {
7626
+ super(message);
7627
+ this.name = "GateNotReadyError";
7628
+ }
7629
+ };
7630
+ var PromotionError = class extends Error {
7631
+ constructor(message) {
7632
+ super(message);
7633
+ this.name = "PromotionError";
7634
+ }
7635
+ };
7636
+ var GATE_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
7637
+ function skillDir(projectPath, name) {
7638
+ return path11.join(projectPath, "agents", "skills", "claude-code", name);
7639
+ }
7640
+ function readIfExists(p) {
7641
+ try {
7642
+ return fs11.readFileSync(p, "utf-8");
7643
+ } catch {
7644
+ return null;
7645
+ }
7646
+ }
7647
+ function injectProvenanceIntoYaml(yamlText, proposalId) {
7648
+ let doc;
7649
+ try {
7650
+ doc = parseYaml2(yamlText);
7651
+ } catch (err) {
7652
+ throw new PromotionError(
7653
+ `skill.yaml does not parse: ${err instanceof Error ? err.message : String(err)}`
7654
+ );
7655
+ }
7656
+ if (!doc || typeof doc !== "object") {
7657
+ throw new PromotionError("skill.yaml top-level is not a mapping");
7658
+ }
7659
+ const obj = doc;
7660
+ obj["provenance"] = "agent-proposed";
7661
+ obj["originatingProposalId"] = proposalId;
7662
+ return stringifyYaml(obj);
7663
+ }
7664
+ function assertGateReady(proposal) {
7665
+ if (proposal.status !== "gate-running") {
7666
+ throw new GateNotReadyError(
7667
+ `proposal ${proposal.id} is in status "${proposal.status}"; the gate must pass before promotion`
7668
+ );
7669
+ }
7670
+ const findings = proposal.gate?.findings ?? [];
7671
+ if (findings.some((f) => f.severity === "error")) {
7672
+ throw new GateNotReadyError(
7673
+ `proposal ${proposal.id} has unresolved gate errors; re-run the gate after edits`
7674
+ );
7675
+ }
7676
+ if (!proposal.gate?.lastRunAt) {
7677
+ throw new GateNotReadyError(`proposal ${proposal.id} has no gate run on record`);
7678
+ }
7679
+ const ageMs = Date.now() - Date.parse(proposal.gate.lastRunAt);
7680
+ if (!Number.isFinite(ageMs) || ageMs > GATE_FRESHNESS_MS) {
7681
+ throw new GateNotReadyError(
7682
+ `proposal ${proposal.id} gate run is older than 24h; re-run before approving`
7683
+ );
7684
+ }
7685
+ }
7686
+ async function promoteNewSkill(projectPath, proposal) {
7687
+ const target = skillDir(projectPath, proposal.content.name);
7688
+ if (fs11.existsSync(target)) {
7689
+ throw new PromotionError(
7690
+ `a catalog skill already exists at ${target}; use a refinement proposal to update it`
7691
+ );
7692
+ }
7693
+ fs11.mkdirSync(target, { recursive: true });
7694
+ const yamlOut = injectProvenanceIntoYaml(proposal.content.skillYaml ?? "", proposal.id);
7695
+ fs11.writeFileSync(path11.join(target, "skill.yaml"), yamlOut);
7696
+ fs11.writeFileSync(path11.join(target, "SKILL.md"), proposal.content.skillMd ?? "");
7697
+ return { skillPath: target };
7698
+ }
7699
+ async function promoteRefinement(projectPath, proposal) {
7700
+ if (!proposal.targetSkill) {
7701
+ throw new PromotionError("refinement proposal is missing targetSkill");
7702
+ }
7703
+ const target = skillDir(projectPath, proposal.targetSkill);
7704
+ if (!fs11.existsSync(target)) {
7705
+ throw new PromotionError(
7706
+ `target skill ${proposal.targetSkill} does not exist at ${target}; cannot refine`
7707
+ );
7708
+ }
7709
+ const yamlPath = path11.join(target, "skill.yaml");
7710
+ const before = readIfExists(yamlPath) ?? "";
7711
+ const after = injectProvenanceIntoYaml(before, proposal.id);
7712
+ if (after === before) {
7713
+ throw new PromotionError(
7714
+ "no metadata changes detected; check that the reviewer applied the proposed diff before approving"
7715
+ );
7716
+ }
7717
+ fs11.writeFileSync(yamlPath, after);
7718
+ return { skillPath: target };
7719
+ }
7720
+ async function promote(projectPath, proposalId, decidedBy) {
7721
+ const proposal = await getProposal2(projectPath, proposalId);
7722
+ if (!proposal) throw new ProposalNotFoundError2(proposalId);
7723
+ assertGateReady(proposal);
7724
+ const out = proposal.kind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
7725
+ await updateProposal2(projectPath, proposalId, {
7726
+ status: "approved",
7727
+ decision: {
7728
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
7729
+ decidedBy,
7730
+ action: "approved"
7731
+ }
7732
+ });
7733
+ return {
7734
+ proposalId,
7735
+ skillPath: out.skillPath,
7736
+ provenance: "agent-proposed"
7737
+ };
7738
+ }
7739
+
7740
+ // src/proposals/events.ts
7741
+ function emit3(bus, topic, data) {
7742
+ bus.emit(topic, data);
7743
+ }
7744
+ function emitProposalCreated(bus, proposal) {
7745
+ const data = {
7746
+ id: proposal.id,
7747
+ kind: proposal.kind,
7748
+ name: proposal.content.name,
7749
+ proposedBy: proposal.proposedBy,
7750
+ justification: proposal.source.justification
7751
+ };
7752
+ if (proposal.targetSkill) data.targetSkill = proposal.targetSkill;
7753
+ emit3(bus, "proposal.created", data);
7754
+ }
7755
+ function emitProposalApproved(bus, proposal) {
7756
+ const data = {
7757
+ id: proposal.id,
7758
+ kind: proposal.kind,
7759
+ name: proposal.content.name,
7760
+ decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
7761
+ };
7762
+ if (proposal.targetSkill) data.targetSkill = proposal.targetSkill;
7763
+ emit3(bus, "proposal.approved", data);
7764
+ }
7765
+ function emitProposalRejected(bus, proposal) {
7766
+ const data = {
7767
+ id: proposal.id,
7768
+ kind: proposal.kind,
7769
+ name: proposal.content.name,
7770
+ decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
7771
+ reason: proposal.decision?.reason ?? "(no reason given)"
7772
+ };
7773
+ emit3(bus, "proposal.rejected", data);
7774
+ }
7775
+
7776
+ // src/server/routes/v1/proposals.ts
7777
+ var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
7778
+ var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
7779
+ var RUN_GATE_RE = /^\/api\/v1\/proposals\/([^/?]+)\/run-gate(?:\?.*)?$/;
7780
+ var APPROVE_RE = /^\/api\/v1\/proposals\/([^/?]+)\/approve(?:\?.*)?$/;
7781
+ var REJECT_RE = /^\/api\/v1\/proposals\/([^/?]+)\/reject(?:\?.*)?$/;
7782
+ var ProposalStatusValues = [
7783
+ "open",
7784
+ "gate-running",
7785
+ "gate-failed",
7786
+ "approved",
7787
+ "rejected"
7788
+ ];
7789
+ var RejectBody = z13.object({
7790
+ reason: z13.string().min(1).max(280)
7791
+ });
7792
+ function sendJSON8(res, status, body) {
7793
+ res.writeHead(status, { "Content-Type": "application/json" });
7794
+ res.end(JSON.stringify(body));
7795
+ }
7796
+ function getDecidedBy(req, deps) {
7797
+ if (deps.decidedByResolver) return deps.decidedByResolver(req);
7798
+ const token = req._authToken;
7799
+ return token?.id ?? "unknown";
7800
+ }
7801
+ function parseStatusFromQuery(url) {
7802
+ const queryIdx = url.indexOf("?");
7803
+ if (queryIdx === -1) return void 0;
7804
+ const params = new URLSearchParams(url.slice(queryIdx + 1));
7805
+ const raw = params.get("status");
7806
+ if (!raw) return void 0;
7807
+ if (raw === "all") return "all";
7808
+ if (ProposalStatusValues.includes(raw)) return raw;
7809
+ return void 0;
7810
+ }
7811
+ async function handleList(req, res, deps) {
7812
+ const url = req.url ?? "";
7813
+ const status = parseStatusFromQuery(url);
7814
+ const proposals = await listProposals(deps.projectPath, status ? { status } : {});
7815
+ sendJSON8(res, 200, proposals);
7816
+ }
7817
+ async function handleGet(res, deps, id) {
7818
+ const proposal = await getProposal3(deps.projectPath, id);
7819
+ if (!proposal) {
7820
+ sendJSON8(res, 404, { error: "Proposal not found" });
7821
+ return;
7822
+ }
7823
+ sendJSON8(res, 200, proposal);
7824
+ }
7825
+ async function handleRunGate(res, deps, id) {
7826
+ try {
7827
+ const result = await runGate(deps.projectPath, id);
7828
+ sendJSON8(res, 200, result);
7829
+ } catch (err) {
7830
+ if (err instanceof ProposalNotFoundError3) {
7831
+ sendJSON8(res, 404, { error: err.message });
7832
+ return;
7833
+ }
7834
+ if (err instanceof GateRunError) {
7835
+ sendJSON8(res, 409, { error: err.message });
7836
+ return;
7837
+ }
7838
+ sendJSON8(res, 500, {
7839
+ error: "gate run failed",
7840
+ detail: err instanceof Error ? err.message : "unknown"
7841
+ });
7842
+ }
7843
+ }
7844
+ async function handleApprove(req, res, deps, id) {
7845
+ const decidedBy = getDecidedBy(req, deps);
7846
+ try {
7847
+ const result = await promote(deps.projectPath, id, decidedBy);
7848
+ const proposal = await getProposal3(deps.projectPath, id);
7849
+ if (proposal) emitProposalApproved(deps.bus, proposal);
7850
+ sendJSON8(res, 200, { promotion: result, proposal });
7851
+ } catch (err) {
7852
+ if (err instanceof ProposalNotFoundError3) {
7853
+ sendJSON8(res, 404, { error: err.message });
7854
+ return;
7855
+ }
7856
+ if (err instanceof GateNotReadyError) {
7857
+ sendJSON8(res, 409, { error: err.message });
7858
+ return;
7859
+ }
7860
+ if (err instanceof PromotionError) {
7861
+ sendJSON8(res, 422, { error: err.message });
7862
+ return;
7863
+ }
7864
+ sendJSON8(res, 500, {
7865
+ error: "approve failed",
7866
+ detail: err instanceof Error ? err.message : "unknown"
7867
+ });
7868
+ }
7869
+ }
7870
+ async function handleReject(req, res, deps, id) {
7871
+ let raw;
7872
+ try {
7873
+ raw = await readBody(req);
7874
+ } catch (err) {
7875
+ sendJSON8(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
7876
+ return;
7877
+ }
7878
+ let json;
7879
+ try {
7880
+ json = raw.length > 0 ? JSON.parse(raw) : {};
7881
+ } catch {
7882
+ sendJSON8(res, 400, { error: "Invalid JSON body" });
7883
+ return;
7884
+ }
7885
+ const parsed = RejectBody.safeParse(json);
7886
+ if (!parsed.success) {
7887
+ sendJSON8(res, 400, { error: "Invalid body", issues: parsed.error.issues });
7888
+ return;
7889
+ }
7890
+ const proposal = await getProposal3(deps.projectPath, id);
7891
+ if (!proposal) {
7892
+ sendJSON8(res, 404, { error: "Proposal not found" });
7893
+ return;
7894
+ }
7895
+ if (proposal.status === "approved" || proposal.status === "rejected") {
7896
+ sendJSON8(res, 409, {
7897
+ error: `proposal already ${proposal.status}; cannot reject`
7898
+ });
7899
+ return;
7900
+ }
7901
+ const decidedBy = getDecidedBy(req, deps);
7902
+ const updated = await updateProposal3(deps.projectPath, id, {
7903
+ status: "rejected",
7904
+ decision: {
7905
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
7906
+ decidedBy,
7907
+ action: "rejected",
7908
+ reason: parsed.data.reason
7909
+ }
7910
+ });
7911
+ emitProposalRejected(deps.bus, updated);
7912
+ sendJSON8(res, 200, updated);
7913
+ }
7914
+ async function handleEdit(req, res, deps, id) {
7915
+ let raw;
7916
+ try {
7917
+ raw = await readBody(req);
7918
+ } catch (err) {
7919
+ sendJSON8(res, 413, { error: err instanceof Error ? err.message : "Body too large" });
7920
+ return;
7921
+ }
7922
+ let json;
7923
+ try {
7924
+ json = JSON.parse(raw);
7925
+ } catch {
7926
+ sendJSON8(res, 400, { error: "Invalid JSON body" });
7927
+ return;
7928
+ }
7929
+ const parsed = EditProposalInputSchema.safeParse(json);
7930
+ if (!parsed.success) {
7931
+ sendJSON8(res, 400, { error: "Invalid body", issues: parsed.error.issues });
7932
+ return;
7933
+ }
7934
+ const existing = await getProposal3(deps.projectPath, id);
7935
+ if (!existing) {
7936
+ sendJSON8(res, 404, { error: "Proposal not found" });
7937
+ return;
7938
+ }
7939
+ if (existing.status === "approved" || existing.status === "rejected") {
7940
+ sendJSON8(res, 409, {
7941
+ error: `proposal already ${existing.status}; cannot edit`
7942
+ });
7943
+ return;
7944
+ }
7945
+ const mergedContent = {
7946
+ ...existing.content,
7947
+ ...parsed.data.content,
7948
+ name: parsed.data.content.name ?? existing.content.name,
7949
+ description: parsed.data.content.description ?? existing.content.description
7950
+ };
7951
+ try {
7952
+ const updated = await updateProposal3(deps.projectPath, id, {
7953
+ content: mergedContent,
7954
+ status: "open",
7955
+ gate: void 0
7956
+ });
7957
+ sendJSON8(res, 200, updated);
7958
+ } catch (err) {
7959
+ sendJSON8(res, 422, {
7960
+ error: "edit failed",
7961
+ detail: err instanceof Error ? err.message : "unknown"
7962
+ });
7963
+ }
7964
+ }
7965
+ function handleV1ProposalsRoute(req, res, deps) {
7966
+ const url = req.url ?? "";
7967
+ const method = req.method ?? "GET";
7968
+ if (method === "GET" && LIST_RE.test(url)) {
7969
+ void handleList(req, res, deps);
7970
+ return true;
7971
+ }
7972
+ const runGateMatch = method === "POST" ? RUN_GATE_RE.exec(url) : null;
7973
+ if (runGateMatch) {
7974
+ void handleRunGate(res, deps, runGateMatch[1]);
7975
+ return true;
7976
+ }
7977
+ const approveMatch = method === "POST" ? APPROVE_RE.exec(url) : null;
7978
+ if (approveMatch) {
7979
+ void handleApprove(req, res, deps, approveMatch[1]);
7980
+ return true;
7981
+ }
7982
+ const rejectMatch = method === "POST" ? REJECT_RE.exec(url) : null;
7983
+ if (rejectMatch) {
7984
+ void handleReject(req, res, deps, rejectMatch[1]);
7985
+ return true;
7986
+ }
7987
+ if (method === "PATCH") {
7988
+ const m = SINGLE_RE.exec(url);
7989
+ if (m) {
7990
+ void handleEdit(req, res, deps, m[1]);
7991
+ return true;
7992
+ }
7993
+ }
7994
+ if (method === "GET") {
7995
+ const m = SINGLE_RE.exec(url);
7996
+ if (m) {
7997
+ void handleGet(res, deps, m[1]);
7998
+ return true;
7999
+ }
8000
+ }
8001
+ return false;
8002
+ }
8003
+
8004
+ // src/server/routes/sessions.ts
8005
+ import * as fs12 from "fs/promises";
8006
+ import * as path12 from "path";
8007
+ import { z as z14 } from "zod";
8008
+ var SessionCreateSchema = z14.object({
8009
+ sessionId: z14.string().min(1)
8010
+ }).passthrough();
8011
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8012
+ function isSafeId(id) {
8013
+ return UUID_RE2.test(id) || path12.basename(id) === id && !id.includes("..");
8014
+ }
8015
+ function jsonResponse(res, status, data) {
8016
+ res.writeHead(status, { "Content-Type": "application/json" });
8017
+ res.end(JSON.stringify(data));
8018
+ }
8019
+ function extractSessionId(url) {
8020
+ const segments = new URL(url, "http://localhost").pathname.split(path12.posix.sep);
8021
+ const id = segments.pop();
8022
+ return id && id !== "sessions" ? id : null;
8023
+ }
8024
+ async function handleList2(res, sessionsDir) {
8025
+ try {
8026
+ const entries = await fs12.readdir(sessionsDir, { withFileTypes: true });
8027
+ const sessions = [];
8028
+ for (const entry of entries) {
8029
+ if (!entry.isDirectory()) continue;
8030
+ try {
8031
+ const content = await fs12.readFile(
8032
+ path12.join(sessionsDir, entry.name, "session.json"),
8033
+ "utf-8"
6914
8034
  );
6915
8035
  sessions.push(JSON.parse(content));
6916
8036
  } catch {
@@ -6928,13 +8048,13 @@ async function handleList(res, sessionsDir) {
6928
8048
  jsonResponse(res, 500, { error: "Failed to list sessions" });
6929
8049
  }
6930
8050
  }
6931
- async function handleGet(res, id, sessionsDir) {
8051
+ async function handleGet2(res, id, sessionsDir) {
6932
8052
  if (!isSafeId(id)) {
6933
8053
  jsonResponse(res, 400, { error: "Invalid sessionId" });
6934
8054
  return;
6935
8055
  }
6936
8056
  try {
6937
- const content = await fs11.readFile(path11.join(sessionsDir, id, "session.json"), "utf-8");
8057
+ const content = await fs12.readFile(path12.join(sessionsDir, id, "session.json"), "utf-8");
6938
8058
  jsonResponse(res, 200, JSON.parse(content));
6939
8059
  } catch (err) {
6940
8060
  if (err.code === "ENOENT") {
@@ -6957,9 +8077,9 @@ async function handleCreate(req, res, sessionsDir) {
6957
8077
  jsonResponse(res, 400, { error: "Invalid sessionId" });
6958
8078
  return;
6959
8079
  }
6960
- const sessionDir = path11.join(sessionsDir, session.sessionId);
6961
- await fs11.mkdir(sessionDir, { recursive: true });
6962
- await fs11.writeFile(path11.join(sessionDir, "session.json"), JSON.stringify(session, null, 2));
8080
+ const sessionDir = path12.join(sessionsDir, session.sessionId);
8081
+ await fs12.mkdir(sessionDir, { recursive: true });
8082
+ await fs12.writeFile(path12.join(sessionDir, "session.json"), JSON.stringify(session, null, 2));
6963
8083
  jsonResponse(res, 200, { ok: true });
6964
8084
  } catch {
6965
8085
  jsonResponse(res, 500, { error: "Failed to save session" });
@@ -6973,10 +8093,10 @@ async function handleUpdate(req, res, url, sessionsDir) {
6973
8093
  return;
6974
8094
  }
6975
8095
  const body = await readBody(req);
6976
- const updates = z13.record(z13.unknown()).parse(JSON.parse(body));
6977
- const sessionFilePath = path11.join(sessionsDir, id, "session.json");
6978
- const current = JSON.parse(await fs11.readFile(sessionFilePath, "utf-8"));
6979
- await fs11.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
8096
+ const updates = z14.record(z14.unknown()).parse(JSON.parse(body));
8097
+ const sessionFilePath = path12.join(sessionsDir, id, "session.json");
8098
+ const current = JSON.parse(await fs12.readFile(sessionFilePath, "utf-8"));
8099
+ await fs12.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
6980
8100
  jsonResponse(res, 200, { ok: true });
6981
8101
  } catch {
6982
8102
  jsonResponse(res, 500, { error: "Failed to update session" });
@@ -6989,7 +8109,7 @@ async function handleDelete(res, url, sessionsDir) {
6989
8109
  jsonResponse(res, 400, { error: "Missing or invalid sessionId" });
6990
8110
  return;
6991
8111
  }
6992
- await fs11.rm(path11.join(sessionsDir, id), { recursive: true, force: true });
8112
+ await fs12.rm(path12.join(sessionsDir, id), { recursive: true, force: true });
6993
8113
  jsonResponse(res, 200, { ok: true });
6994
8114
  } catch {
6995
8115
  jsonResponse(res, 500, { error: "Failed to delete session" });
@@ -7002,8 +8122,8 @@ function handleSessionsRoute(req, res, sessionsDir) {
7002
8122
  switch (method) {
7003
8123
  case "GET": {
7004
8124
  const id = extractSessionId(url);
7005
- if (id) void handleGet(res, id, sessionsDir);
7006
- else void handleList(res, sessionsDir);
8125
+ if (id) void handleGet2(res, id, sessionsDir);
8126
+ else void handleList2(res, sessionsDir);
7007
8127
  return true;
7008
8128
  }
7009
8129
  case "POST":
@@ -7093,20 +8213,20 @@ function handleStreamsRoute(req, res, recorder) {
7093
8213
  }
7094
8214
 
7095
8215
  // src/server/routes/auth.ts
7096
- import { z as z14 } from "zod";
8216
+ import { z as z15 } from "zod";
7097
8217
  import {
7098
8218
  TokenScopeSchema,
7099
8219
  BridgeKindSchema,
7100
8220
  AuthTokenPublicSchema
7101
8221
  } from "@harness-engineering/types";
7102
- var CreateBodySchema = z14.object({
7103
- name: z14.string().min(1).max(100),
7104
- scopes: z14.array(TokenScopeSchema).min(1),
8222
+ var CreateBodySchema = z15.object({
8223
+ name: z15.string().min(1).max(100),
8224
+ scopes: z15.array(TokenScopeSchema).min(1),
7105
8225
  bridgeKind: BridgeKindSchema.optional(),
7106
- tenantId: z14.string().optional(),
7107
- expiresAt: z14.string().datetime().optional()
8226
+ tenantId: z15.string().optional(),
8227
+ expiresAt: z15.string().datetime().optional()
7108
8228
  });
7109
- function sendJSON8(res, status, body) {
8229
+ function sendJSON9(res, status, body) {
7110
8230
  res.writeHead(status, { "Content-Type": "application/json" });
7111
8231
  res.end(JSON.stringify(body));
7112
8232
  }
@@ -7116,19 +8236,19 @@ async function handlePost(req, res, store) {
7116
8236
  raw = await readBody(req);
7117
8237
  } catch (err) {
7118
8238
  const msg = err instanceof Error ? err.message : "Failed to read body";
7119
- sendJSON8(res, 413, { error: msg });
8239
+ sendJSON9(res, 413, { error: msg });
7120
8240
  return;
7121
8241
  }
7122
8242
  let json;
7123
8243
  try {
7124
8244
  json = JSON.parse(raw);
7125
8245
  } catch {
7126
- sendJSON8(res, 400, { error: "Invalid JSON body" });
8246
+ sendJSON9(res, 400, { error: "Invalid JSON body" });
7127
8247
  return;
7128
8248
  }
7129
8249
  const parsed = CreateBodySchema.safeParse(json);
7130
8250
  if (!parsed.success) {
7131
- sendJSON8(res, 422, { error: "Invalid body", issues: parsed.error.issues });
8251
+ sendJSON9(res, 422, { error: "Invalid body", issues: parsed.error.issues });
7132
8252
  return;
7133
8253
  }
7134
8254
  try {
@@ -7141,37 +8261,37 @@ async function handlePost(req, res, store) {
7141
8261
  if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
7142
8262
  const result = await store.create(input);
7143
8263
  const publicRecord = AuthTokenPublicSchema.parse(result.record);
7144
- sendJSON8(res, 200, {
8264
+ sendJSON9(res, 200, {
7145
8265
  ...publicRecord,
7146
8266
  token: result.token
7147
8267
  });
7148
8268
  } catch (err) {
7149
8269
  const msg = err instanceof Error ? err.message : "Failed to create token";
7150
8270
  if (msg.includes("already exists")) {
7151
- sendJSON8(res, 409, { error: msg });
8271
+ sendJSON9(res, 409, { error: msg });
7152
8272
  return;
7153
8273
  }
7154
- sendJSON8(res, 500, { error: "Internal error creating token" });
8274
+ sendJSON9(res, 500, { error: "Internal error creating token" });
7155
8275
  }
7156
8276
  }
7157
- async function handleList2(res, store) {
8277
+ async function handleList3(res, store) {
7158
8278
  try {
7159
8279
  const list = await store.list();
7160
- sendJSON8(res, 200, list);
8280
+ sendJSON9(res, 200, list);
7161
8281
  } catch {
7162
- sendJSON8(res, 500, { error: "Internal error listing tokens" });
8282
+ sendJSON9(res, 500, { error: "Internal error listing tokens" });
7163
8283
  }
7164
8284
  }
7165
8285
  async function handleDelete2(res, store, id) {
7166
8286
  try {
7167
8287
  const ok = await store.revoke(id);
7168
8288
  if (!ok) {
7169
- sendJSON8(res, 404, { error: "Token not found" });
8289
+ sendJSON9(res, 404, { error: "Token not found" });
7170
8290
  return;
7171
8291
  }
7172
- sendJSON8(res, 200, { deleted: true });
8292
+ sendJSON9(res, 200, { deleted: true });
7173
8293
  } catch {
7174
- sendJSON8(res, 500, { error: "Internal error revoking token" });
8294
+ sendJSON9(res, 500, { error: "Internal error revoking token" });
7175
8295
  }
7176
8296
  }
7177
8297
  var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
@@ -7185,7 +8305,7 @@ function handleAuthRoute(req, res, store) {
7185
8305
  return true;
7186
8306
  }
7187
8307
  if (method === "GET" && pathname === "/api/v1/auth/tokens") {
7188
- void handleList2(res, store);
8308
+ void handleList3(res, store);
7189
8309
  return true;
7190
8310
  }
7191
8311
  if (method === "DELETE") {
@@ -7196,12 +8316,12 @@ function handleAuthRoute(req, res, store) {
7196
8316
  return true;
7197
8317
  }
7198
8318
  }
7199
- sendJSON8(res, 405, { error: "Method not allowed" });
8319
+ sendJSON9(res, 405, { error: "Method not allowed" });
7200
8320
  return true;
7201
8321
  }
7202
8322
 
7203
8323
  // src/server/routes/local-model.ts
7204
- function sendJSON9(res, status, body) {
8324
+ function sendJSON10(res, status, body) {
7205
8325
  res.writeHead(status, { "Content-Type": "application/json" });
7206
8326
  res.end(JSON.stringify(body));
7207
8327
  }
@@ -7209,36 +8329,36 @@ function handleLocalModelRoute(req, res, getStatus) {
7209
8329
  const { method, url } = req;
7210
8330
  if (url !== "/api/v1/local-model/status") return false;
7211
8331
  if (method !== "GET") {
7212
- sendJSON9(res, 405, { error: "Method not allowed" });
8332
+ sendJSON10(res, 405, { error: "Method not allowed" });
7213
8333
  return true;
7214
8334
  }
7215
8335
  if (!getStatus) {
7216
- sendJSON9(res, 503, { error: "Local backend not configured" });
8336
+ sendJSON10(res, 503, { error: "Local backend not configured" });
7217
8337
  return true;
7218
8338
  }
7219
8339
  const status = getStatus();
7220
8340
  if (!status) {
7221
- sendJSON9(res, 503, { error: "Local backend not configured" });
8341
+ sendJSON10(res, 503, { error: "Local backend not configured" });
7222
8342
  return true;
7223
8343
  }
7224
- sendJSON9(res, 200, status);
8344
+ sendJSON10(res, 200, status);
7225
8345
  return true;
7226
8346
  }
7227
8347
  function handleLocalModelsRoute(req, res, getStatuses) {
7228
8348
  const { method, url } = req;
7229
8349
  if (url !== "/api/v1/local-models/status") return false;
7230
8350
  if (method !== "GET") {
7231
- sendJSON9(res, 405, { error: "Method not allowed" });
8351
+ sendJSON10(res, 405, { error: "Method not allowed" });
7232
8352
  return true;
7233
8353
  }
7234
8354
  const statuses = getStatuses ? getStatuses() : [];
7235
- sendJSON9(res, 200, statuses);
8355
+ sendJSON10(res, 200, statuses);
7236
8356
  return true;
7237
8357
  }
7238
8358
 
7239
8359
  // src/server/static.ts
7240
- import * as fs12 from "fs";
7241
- import * as path12 from "path";
8360
+ import * as fs13 from "fs";
8361
+ import * as path13 from "path";
7242
8362
  var MIME_TYPES = {
7243
8363
  ".html": "text/html; charset=utf-8",
7244
8364
  ".js": "application/javascript; charset=utf-8",
@@ -7258,29 +8378,29 @@ var MIME_TYPES = {
7258
8378
  function handleStaticFile(req, res, dashboardDir) {
7259
8379
  const { method, url } = req;
7260
8380
  if (method !== "GET") return false;
7261
- const apiPrefix = path12.posix.join(path12.posix.sep, "api", path12.posix.sep);
7262
- const wsPath = path12.posix.join(path12.posix.sep, "ws");
8381
+ const apiPrefix = path13.posix.join(path13.posix.sep, "api", path13.posix.sep);
8382
+ const wsPath = path13.posix.join(path13.posix.sep, "ws");
7263
8383
  if (url?.startsWith(apiPrefix) || url === wsPath) return false;
7264
8384
  const urlPath = new URL(url ?? "/", "http://localhost").pathname;
7265
- const requestedPath = path12.join(dashboardDir, urlPath === "/" ? "index.html" : urlPath);
7266
- const resolved = path12.resolve(requestedPath);
7267
- if (!resolved.startsWith(path12.resolve(dashboardDir))) {
7268
- return serveFile(path12.join(dashboardDir, "index.html"), res);
8385
+ const requestedPath = path13.join(dashboardDir, urlPath === "/" ? "index.html" : urlPath);
8386
+ const resolved = path13.resolve(requestedPath);
8387
+ if (!resolved.startsWith(path13.resolve(dashboardDir))) {
8388
+ return serveFile(path13.join(dashboardDir, "index.html"), res);
7269
8389
  }
7270
- if (fs12.existsSync(resolved) && fs12.statSync(resolved).isFile()) {
8390
+ if (fs13.existsSync(resolved) && fs13.statSync(resolved).isFile()) {
7271
8391
  return serveFile(resolved, res);
7272
8392
  }
7273
- const indexPath = path12.join(dashboardDir, "index.html");
7274
- if (fs12.existsSync(indexPath)) {
8393
+ const indexPath = path13.join(dashboardDir, "index.html");
8394
+ if (fs13.existsSync(indexPath)) {
7275
8395
  return serveFile(indexPath, res);
7276
8396
  }
7277
8397
  return false;
7278
8398
  }
7279
8399
  function serveFile(filePath, res) {
7280
- const ext = path12.extname(filePath).toLowerCase();
8400
+ const ext = path13.extname(filePath).toLowerCase();
7281
8401
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
7282
8402
  try {
7283
- const content = fs12.readFileSync(filePath);
8403
+ const content = fs13.readFileSync(filePath);
7284
8404
  res.writeHead(200, { "Content-Type": contentType });
7285
8405
  res.end(content);
7286
8406
  return true;
@@ -7290,8 +8410,8 @@ function serveFile(filePath, res) {
7290
8410
  }
7291
8411
 
7292
8412
  // src/server/plan-watcher.ts
7293
- import * as fs13 from "fs";
7294
- import * as path13 from "path";
8413
+ import * as fs14 from "fs";
8414
+ import * as path14 from "path";
7295
8415
  var PlanWatcher = class {
7296
8416
  plansDir;
7297
8417
  queue;
@@ -7305,11 +8425,11 @@ var PlanWatcher = class {
7305
8425
  * Creates the directory if it does not exist.
7306
8426
  */
7307
8427
  start() {
7308
- fs13.mkdirSync(this.plansDir, { recursive: true });
7309
- this.watcher = fs13.watch(this.plansDir, (eventType, filename) => {
8428
+ fs14.mkdirSync(this.plansDir, { recursive: true });
8429
+ this.watcher = fs14.watch(this.plansDir, (eventType, filename) => {
7310
8430
  if (eventType === "rename" && filename && filename.endsWith(".md")) {
7311
- const filePath = path13.join(this.plansDir, filename);
7312
- if (fs13.existsSync(filePath)) {
8431
+ const filePath = path14.join(this.plansDir, filename);
8432
+ if (fs14.existsSync(filePath)) {
7313
8433
  void this.handleNewPlan(filename);
7314
8434
  }
7315
8435
  }
@@ -7362,8 +8482,8 @@ function parseToken(raw) {
7362
8482
  return { id: raw.slice(0, dot), secret: raw.slice(dot + 1) };
7363
8483
  }
7364
8484
  var TokenStore = class {
7365
- constructor(path17) {
7366
- this.path = path17;
8485
+ constructor(path22) {
8486
+ this.path = path22;
7367
8487
  }
7368
8488
  path;
7369
8489
  cache = null;
@@ -7470,8 +8590,8 @@ import { appendFile, mkdir as mkdir8 } from "fs/promises";
7470
8590
  import { dirname as dirname5 } from "path";
7471
8591
  import { AuthAuditEntrySchema } from "@harness-engineering/types";
7472
8592
  var AuditLogger = class {
7473
- constructor(path17, opts = {}) {
7474
- this.path = path17;
8593
+ constructor(path22, opts = {}) {
8594
+ this.path = path22;
7475
8595
  this.opts = opts;
7476
8596
  }
7477
8597
  path;
@@ -7555,6 +8675,43 @@ var V1_BRIDGE_ROUTES = [
7555
8675
  scope: "subscribe-webhook",
7556
8676
  description: "Webhook delivery queue depth + DLQ stats."
7557
8677
  },
8678
+ // Hermes Phase 4 — skill proposal review queue.
8679
+ {
8680
+ method: "GET",
8681
+ pattern: /^\/api\/v1\/proposals(?:\?.*)?$/,
8682
+ scope: "read-status",
8683
+ description: "List skill proposals (open + decided)."
8684
+ },
8685
+ {
8686
+ method: "GET",
8687
+ pattern: /^\/api\/v1\/proposals\/[^/]+(?:\?.*)?$/,
8688
+ scope: "read-status",
8689
+ description: "Get a single skill proposal."
8690
+ },
8691
+ {
8692
+ method: "POST",
8693
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/run-gate(?:\?.*)?$/,
8694
+ scope: "manage-proposals",
8695
+ description: "Run the soundness-review gate against a proposal."
8696
+ },
8697
+ {
8698
+ method: "POST",
8699
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/approve(?:\?.*)?$/,
8700
+ scope: "manage-proposals",
8701
+ description: "Approve a proposal \u2014 promotes the skill into the catalog."
8702
+ },
8703
+ {
8704
+ method: "POST",
8705
+ pattern: /^\/api\/v1\/proposals\/[^/]+\/reject(?:\?.*)?$/,
8706
+ scope: "manage-proposals",
8707
+ description: "Reject a proposal with a one-line reason."
8708
+ },
8709
+ {
8710
+ method: "PATCH",
8711
+ pattern: /^\/api\/v1\/proposals\/[^/]+(?:\?.*)?$/,
8712
+ scope: "manage-proposals",
8713
+ description: "Edit proposal content (resets gate to not-run)."
8714
+ },
7558
8715
  // ── Phase 5 bridge primitives ──
7559
8716
  {
7560
8717
  method: "GET",
@@ -7566,9 +8723,9 @@ var V1_BRIDGE_ROUTES = [
7566
8723
  function isV1Bridge(method, url) {
7567
8724
  return V1_BRIDGE_ROUTES.some((r) => r.method === method && r.pattern.test(url));
7568
8725
  }
7569
- function requiredBridgeScope(method, path17) {
8726
+ function requiredBridgeScope(method, path22) {
7570
8727
  for (const r of V1_BRIDGE_ROUTES) {
7571
- if (r.method === method && r.pattern.test(path17)) return r.scope;
8728
+ if (r.method === method && r.pattern.test(path22)) return r.scope;
7572
8729
  }
7573
8730
  return null;
7574
8731
  }
@@ -7578,24 +8735,24 @@ function hasScope(held, required) {
7578
8735
  if (held.includes("admin")) return true;
7579
8736
  return held.includes(required);
7580
8737
  }
7581
- function requiredScopeForRoute(method, path17) {
7582
- const bridgeScope = requiredBridgeScope(method, path17);
8738
+ function requiredScopeForRoute(method, path22) {
8739
+ const bridgeScope = requiredBridgeScope(method, path22);
7583
8740
  if (bridgeScope) return bridgeScope;
7584
- if (path17 === "/api/v1/auth/token" && method === "POST") return "admin";
7585
- if (path17 === "/api/v1/auth/tokens" && method === "GET") return "admin";
7586
- if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path17) && method === "DELETE") return "admin";
7587
- if ((path17 === "/api/state" || path17 === "/api/v1/state") && method === "GET") return "read-status";
7588
- if (path17.startsWith("/api/interactions")) return "resolve-interaction";
7589
- if (path17.startsWith("/api/plans")) return "read-status";
7590
- if (path17.startsWith("/api/analyze") || path17.startsWith("/api/analyses")) return "read-status";
7591
- if (path17.startsWith("/api/roadmap-actions")) return "modify-roadmap";
7592
- if (path17.startsWith("/api/dispatch-actions")) return "trigger-job";
7593
- if (path17.startsWith("/api/local-model") || path17.startsWith("/api/local-models"))
8741
+ if (path22 === "/api/v1/auth/token" && method === "POST") return "admin";
8742
+ if (path22 === "/api/v1/auth/tokens" && method === "GET") return "admin";
8743
+ if (/^\/api\/v1\/auth\/tokens\/[^/]+$/.test(path22) && method === "DELETE") return "admin";
8744
+ if ((path22 === "/api/state" || path22 === "/api/v1/state") && method === "GET") return "read-status";
8745
+ if (path22.startsWith("/api/interactions")) return "resolve-interaction";
8746
+ if (path22.startsWith("/api/plans")) return "read-status";
8747
+ if (path22.startsWith("/api/analyze") || path22.startsWith("/api/analyses")) return "read-status";
8748
+ if (path22.startsWith("/api/roadmap-actions")) return "modify-roadmap";
8749
+ if (path22.startsWith("/api/dispatch-actions")) return "trigger-job";
8750
+ if (path22.startsWith("/api/local-model") || path22.startsWith("/api/local-models"))
7594
8751
  return "read-status";
7595
- if (path17.startsWith("/api/maintenance")) return "trigger-job";
7596
- if (path17.startsWith("/api/streams")) return "read-status";
7597
- if (path17.startsWith("/api/sessions")) return "read-status";
7598
- if (path17.startsWith("/api/chat-proxy")) return "trigger-job";
8752
+ if (path22.startsWith("/api/maintenance")) return "trigger-job";
8753
+ if (path22.startsWith("/api/streams")) return "read-status";
8754
+ if (path22.startsWith("/api/sessions")) return "read-status";
8755
+ if (path22.startsWith("/api/chat-proxy")) return "trigger-job";
7599
8756
  return null;
7600
8757
  }
7601
8758
 
@@ -7649,6 +8806,11 @@ var OrchestratorServer = class {
7649
8806
  roadmapPath;
7650
8807
  dispatchAdHoc;
7651
8808
  sessionsDir;
8809
+ /**
8810
+ * Project root used by file-backed routes (Phase 4 proposals at
8811
+ * `.harness/proposals/`). Defaults to process.cwd().
8812
+ */
8813
+ projectPath;
7652
8814
  maintenanceDeps = null;
7653
8815
  getLocalModelStatus = null;
7654
8816
  getLocalModelStatuses = null;
@@ -7666,8 +8828,8 @@ var OrchestratorServer = class {
7666
8828
  this.orchestrator = orchestrator;
7667
8829
  this.port = port;
7668
8830
  this.initDependencies(deps);
7669
- const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path14.resolve(".harness", "tokens.json");
7670
- const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path14.resolve(".harness", "audit.log");
8831
+ const tokensPath = process.env["HARNESS_TOKENS_PATH"] ?? path15.resolve(".harness", "tokens.json");
8832
+ const auditPath = process.env["HARNESS_AUDIT_PATH"] ?? path15.resolve(".harness", "audit.log");
7671
8833
  this.tokenStore = new TokenStore(tokensPath);
7672
8834
  this.auditLogger = new AuditLogger(auditPath);
7673
8835
  this.httpServer = http.createServer(this.handleRequest.bind(this));
@@ -7680,14 +8842,15 @@ var OrchestratorServer = class {
7680
8842
  }
7681
8843
  initDependencies(deps) {
7682
8844
  this.interactionQueue = deps?.interactionQueue;
7683
- this.plansDir = deps?.plansDir ?? path14.resolve("docs", "plans");
7684
- this.dashboardDir = deps?.dashboardDir ?? path14.resolve("packages", "dashboard", "dist", "client");
8845
+ this.plansDir = deps?.plansDir ?? path15.resolve("docs", "plans");
8846
+ this.dashboardDir = deps?.dashboardDir ?? path15.resolve("packages", "dashboard", "dist", "client");
7685
8847
  this.claudeCommand = deps?.claudeCommand ?? "claude";
7686
8848
  this.pipeline = deps?.pipeline ?? null;
7687
8849
  this.analysisArchive = deps?.analysisArchive;
7688
8850
  this.roadmapPath = deps?.roadmapPath ?? null;
7689
8851
  this.dispatchAdHoc = deps?.dispatchAdHoc ?? null;
7690
- this.sessionsDir = deps?.sessionsDir ?? path14.resolve(".harness", "sessions");
8852
+ this.sessionsDir = deps?.sessionsDir ?? path15.resolve(".harness", "sessions");
8853
+ this.projectPath = deps?.projectPath ?? process.cwd();
7691
8854
  this.maintenanceDeps = deps?.maintenanceDeps ?? null;
7692
8855
  this.getLocalModelStatus = deps?.getLocalModelStatus ?? null;
7693
8856
  this.getLocalModelStatuses = deps?.getLocalModelStatuses ?? null;
@@ -7858,6 +9021,15 @@ var OrchestratorServer = class {
7858
9021
  (req, res) => handleV1TelemetryRoute(req, res, {
7859
9022
  ...this.cacheMetrics ? { cacheMetrics: this.cacheMetrics } : {}
7860
9023
  }),
9024
+ // Hermes Phase 4 — skill proposal review queue. Read scopes
9025
+ // (`read-status`) and write scopes (`manage-proposals`) are enforced
9026
+ // upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
9027
+ // business logic. `projectPath` defaults to process.cwd() — that is
9028
+ // where `.harness/proposals/` lives in every deployment we ship.
9029
+ (req, res) => handleV1ProposalsRoute(req, res, {
9030
+ projectPath: this.projectPath,
9031
+ bus: this.orchestrator
9032
+ }),
7861
9033
  // Chat proxy route (spawns Claude Code CLI — no API key required)
7862
9034
  (req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
7863
9035
  ];
@@ -7945,11 +9117,11 @@ var OrchestratorServer = class {
7945
9117
  this.planWatcher = new PlanWatcher(this.plansDir, this.interactionQueue);
7946
9118
  this.planWatcher.start();
7947
9119
  }
7948
- return new Promise((resolve6) => {
9120
+ return new Promise((resolve7) => {
7949
9121
  const host = getBindHost();
7950
9122
  this.httpServer.listen(this.port, host, () => {
7951
9123
  console.log(`Orchestrator API listening on ${host}:${this.port}`);
7952
- resolve6();
9124
+ resolve7();
7953
9125
  });
7954
9126
  });
7955
9127
  }
@@ -7999,8 +9171,8 @@ function genSecret2() {
7999
9171
  return randomBytes4(32).toString("base64url");
8000
9172
  }
8001
9173
  var WebhookStore = class {
8002
- constructor(path17) {
8003
- this.path = path17;
9174
+ constructor(path22) {
9175
+ this.path = path22;
8004
9176
  }
8005
9177
  path;
8006
9178
  cache = null;
@@ -8391,7 +9563,12 @@ var WEBHOOK_TOPICS = [
8391
9563
  "maintenance:completed",
8392
9564
  "maintenance:error",
8393
9565
  "webhook.subscription.created",
8394
- "webhook.subscription.deleted"
9566
+ "webhook.subscription.deleted",
9567
+ // Hermes Phase 4 — skill proposal lifecycle. Subscriptions can use the
9568
+ // `proposal.*` glob pattern to receive all three.
9569
+ "proposal.created",
9570
+ "proposal.approved",
9571
+ "proposal.rejected"
8395
9572
  ];
8396
9573
  function newEventId2() {
8397
9574
  return `evt_${randomBytes6(8).toString("hex")}`;
@@ -8586,13 +9763,373 @@ function wireTelemetryFanout(params) {
8586
9763
  };
8587
9764
  }
8588
9765
 
8589
- // src/orchestrator.ts
8590
- import { CacheMetricsRecorder, OTLPExporter } from "@harness-engineering/core";
8591
-
8592
- // src/logging/logger.ts
8593
- var StructuredLogger = class {
8594
- debug(message, context) {
8595
- this.log("debug", message, context);
9766
+ // src/notifications/slack-sink.ts
9767
+ var SEVERITY_PREFIX = {
9768
+ info: ":information_source:",
9769
+ success: ":white_check_mark:",
9770
+ warning: ":warning:",
9771
+ error: ":x:"
9772
+ };
9773
+ var SlackSink = class {
9774
+ kind = "slack";
9775
+ id;
9776
+ webhookUrl;
9777
+ fetchImpl;
9778
+ timeoutMs;
9779
+ constructor(opts) {
9780
+ this.id = opts.id;
9781
+ this.webhookUrl = opts.webhookUrl;
9782
+ this.fetchImpl = opts.fetchImpl ?? fetch;
9783
+ this.timeoutMs = opts.timeoutMs ?? 5e3;
9784
+ }
9785
+ async deliver(input) {
9786
+ const body = input.wrapped ? this.renderEnvelope(input.payload) : this.renderRawEvent(input.payload);
9787
+ const ctrl = new AbortController();
9788
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
9789
+ try {
9790
+ const res = await this.fetchImpl(this.webhookUrl, {
9791
+ method: "POST",
9792
+ headers: { "Content-Type": "application/json" },
9793
+ body: JSON.stringify(body),
9794
+ signal: ctrl.signal
9795
+ });
9796
+ if (res.ok) {
9797
+ return { ok: true, deliveredAt: Date.now() };
9798
+ }
9799
+ return { ok: false, error: `HTTP ${res.status}`, httpStatus: res.status };
9800
+ } catch (err) {
9801
+ const msg = err instanceof Error ? err.message : String(err);
9802
+ return { ok: false, error: ctrl.signal.aborted ? "timeout" : msg };
9803
+ } finally {
9804
+ clearTimeout(timer);
9805
+ }
9806
+ }
9807
+ renderEnvelope(env) {
9808
+ const prefix = SEVERITY_PREFIX[env.severity] ?? "";
9809
+ const headline = `${prefix} ${env.title}`.trim();
9810
+ const blocks = [
9811
+ { type: "section", text: { type: "mrkdwn", text: `*${headline}*
9812
+ ${env.summary}` } }
9813
+ ];
9814
+ if (env.actions && env.actions.length > 0) {
9815
+ blocks.push({
9816
+ type: "actions",
9817
+ elements: env.actions.map((a) => ({
9818
+ type: "button",
9819
+ text: { type: "plain_text", text: a.label },
9820
+ url: a.url
9821
+ }))
9822
+ });
9823
+ }
9824
+ if (env.permalink) {
9825
+ blocks.push({
9826
+ type: "section",
9827
+ text: { type: "mrkdwn", text: `<${env.permalink}|View details>` }
9828
+ });
9829
+ }
9830
+ return { text: headline, blocks };
9831
+ }
9832
+ renderRawEvent(event) {
9833
+ const dump = (() => {
9834
+ try {
9835
+ return JSON.stringify(event.data, null, 2);
9836
+ } catch {
9837
+ return String(event.data);
9838
+ }
9839
+ })();
9840
+ const text = `harness event: \`${event.type}\``;
9841
+ return {
9842
+ text,
9843
+ blocks: [
9844
+ { type: "section", text: { type: "mrkdwn", text: `*${text}*
9845
+ \`\`\`
9846
+ ${dump}
9847
+ \`\`\`` } }
9848
+ ]
9849
+ };
9850
+ }
9851
+ };
9852
+
9853
+ // src/notifications/registry.ts
9854
+ var SinkConfigError = class extends Error {
9855
+ constructor(sinkId, message) {
9856
+ super(`[sink:${sinkId}] ${message}`);
9857
+ this.sinkId = sinkId;
9858
+ this.name = "SinkConfigError";
9859
+ }
9860
+ sinkId;
9861
+ };
9862
+ var SinkRegistry = class _SinkRegistry {
9863
+ entries;
9864
+ constructor(entries) {
9865
+ this.entries = entries;
9866
+ }
9867
+ static fromConfig(config, options) {
9868
+ const entries = [];
9869
+ for (const sinkConfig of config.sinks) {
9870
+ entries.push({
9871
+ config: sinkConfig,
9872
+ adapter: buildSink(sinkConfig, options)
9873
+ });
9874
+ }
9875
+ return new _SinkRegistry(entries);
9876
+ }
9877
+ list() {
9878
+ return this.entries;
9879
+ }
9880
+ get(id) {
9881
+ return this.entries.find((e) => e.config.id === id) ?? null;
9882
+ }
9883
+ ids() {
9884
+ return this.entries.map((e) => e.config.id);
9885
+ }
9886
+ async dispose() {
9887
+ for (const entry of this.entries) {
9888
+ if (entry.adapter.dispose) {
9889
+ await entry.adapter.dispose();
9890
+ }
9891
+ }
9892
+ }
9893
+ };
9894
+ function buildSink(config, options) {
9895
+ const kind = config.kind;
9896
+ switch (kind) {
9897
+ case "slack":
9898
+ return buildSlackSink(config, options);
9899
+ default: {
9900
+ const _exhaustive = kind;
9901
+ throw new SinkConfigError(config.id, `unknown sink kind '${String(_exhaustive)}'`);
9902
+ }
9903
+ }
9904
+ }
9905
+ function buildSlackSink(config, options) {
9906
+ const rawConfig = config.config;
9907
+ const envKey = typeof rawConfig.webhookUrlEnv === "string" ? rawConfig.webhookUrlEnv : null;
9908
+ const inlineUrl = typeof rawConfig.webhookUrl === "string" ? rawConfig.webhookUrl : null;
9909
+ let url;
9910
+ if (envKey) {
9911
+ const v = options.env[envKey];
9912
+ if (!v) {
9913
+ throw new SinkConfigError(
9914
+ config.id,
9915
+ `Slack webhook env var '${envKey}' is not set in the environment`
9916
+ );
9917
+ }
9918
+ url = v;
9919
+ } else if (inlineUrl) {
9920
+ url = inlineUrl;
9921
+ } else {
9922
+ throw new SinkConfigError(
9923
+ config.id,
9924
+ `Slack sink requires 'config.webhookUrlEnv' (preferred) or 'config.webhookUrl'`
9925
+ );
9926
+ }
9927
+ if (!/^https:\/\/hooks\.slack\.com\//.test(url)) {
9928
+ throw new SinkConfigError(
9929
+ config.id,
9930
+ `Slack webhook URL must be an https://hooks.slack.com/ URL`
9931
+ );
9932
+ }
9933
+ const sinkOpts = {
9934
+ id: config.id,
9935
+ webhookUrl: url
9936
+ };
9937
+ if (options.fetchImpl) sinkOpts.fetchImpl = options.fetchImpl;
9938
+ return new SlackSink(sinkOpts);
9939
+ }
9940
+
9941
+ // src/notifications/events.ts
9942
+ import { randomBytes as randomBytes8 } from "crypto";
9943
+
9944
+ // src/notifications/envelope.ts
9945
+ function asObj(data) {
9946
+ return typeof data === "object" && data !== null ? data : {};
9947
+ }
9948
+ var ENVELOPE_DERIVERS = {
9949
+ "maintenance.started": (event) => {
9950
+ const data = asObj(event.data);
9951
+ return {
9952
+ title: `Maintenance started: ${data.taskId ?? "(unknown task)"}`,
9953
+ summary: `Task \`${data.taskId ?? "(unknown)"}\` is running.`,
9954
+ severity: "info"
9955
+ };
9956
+ },
9957
+ "maintenance.completed": (event) => {
9958
+ const data = asObj(event.data);
9959
+ return {
9960
+ title: `Maintenance done: ${data.taskId ?? "(unknown task)"}`,
9961
+ summary: `Task \`${data.taskId ?? "(unknown)"}\` completed successfully.`,
9962
+ severity: "success"
9963
+ };
9964
+ },
9965
+ "maintenance.error": (event) => {
9966
+ const data = asObj(event.data);
9967
+ return {
9968
+ title: `Maintenance failed: ${data.taskId ?? "(unknown task)"}`,
9969
+ summary: data.error ?? "No error message provided.",
9970
+ severity: "error"
9971
+ };
9972
+ },
9973
+ "interaction.created": (event) => {
9974
+ const data = asObj(event.data);
9975
+ return {
9976
+ title: `Action required: ${truncate(data.question ?? "pending interaction", 80)}`,
9977
+ summary: data.question ?? "(no question text)",
9978
+ severity: "warning"
9979
+ };
9980
+ },
9981
+ "interaction.resolved": (event) => {
9982
+ const data = asObj(event.data);
9983
+ return {
9984
+ title: `Interaction resolved`,
9985
+ summary: data.resolution ?? "(no resolution text)",
9986
+ severity: "info"
9987
+ };
9988
+ },
9989
+ "notification.test": (event) => {
9990
+ const data = asObj(event.data);
9991
+ return {
9992
+ title: "Test notification from harness",
9993
+ summary: data.message ?? "If you see this, your notification sink is working.",
9994
+ severity: "info"
9995
+ };
9996
+ },
9997
+ // Hermes Phase 4 — skill proposal lifecycle events.
9998
+ "proposal.created": (event) => {
9999
+ const data = asObj(event.data);
10000
+ const label = data.kind === "refinement" ? `refinement of ${data.targetSkill ?? "(unknown skill)"}` : data.name ?? "(new skill)";
10001
+ return {
10002
+ title: `New skill proposal: ${label}`,
10003
+ summary: truncate(data.justification ?? "No justification provided.", 240),
10004
+ severity: "info"
10005
+ };
10006
+ },
10007
+ "proposal.approved": (event) => {
10008
+ const data = asObj(event.data);
10009
+ const label = data.name ?? data.targetSkill ?? "(unknown skill)";
10010
+ return {
10011
+ title: `Skill proposal approved: ${label}`,
10012
+ summary: `Approved by ${data.decidedBy ?? "(unknown reviewer)"}.`,
10013
+ severity: "success"
10014
+ };
10015
+ },
10016
+ "proposal.rejected": (event) => {
10017
+ const data = asObj(event.data);
10018
+ return {
10019
+ title: "Skill proposal rejected",
10020
+ summary: truncate(data.reason ?? "No reason provided.", 240),
10021
+ severity: "warning"
10022
+ };
10023
+ }
10024
+ };
10025
+ function truncate(s, max) {
10026
+ return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
10027
+ }
10028
+ function fallbackTitle(event) {
10029
+ return event.type;
10030
+ }
10031
+ function fallbackSummary(event) {
10032
+ try {
10033
+ return "```\n" + JSON.stringify(event.data, null, 2) + "\n```";
10034
+ } catch {
10035
+ return String(event.data);
10036
+ }
10037
+ }
10038
+ function severityFromType(type) {
10039
+ if (type.endsWith(".error") || type.endsWith(".failed")) return "error";
10040
+ if (type.endsWith(".completed") || type.endsWith(".resolved")) return "success";
10041
+ if (type.endsWith(".created") || type.startsWith("interaction.")) return "warning";
10042
+ return "info";
10043
+ }
10044
+ function backfillEnvelope(event, partial) {
10045
+ return {
10046
+ title: truncate(partial.title ?? fallbackTitle(event), 280),
10047
+ summary: partial.summary ?? fallbackSummary(event),
10048
+ severity: partial.severity ?? severityFromType(event.type)
10049
+ };
10050
+ }
10051
+ function wrapAsEnvelope(event) {
10052
+ const deriver = ENVELOPE_DERIVERS[event.type];
10053
+ const partial = deriver ? deriver(event) : {};
10054
+ const envelope = backfillEnvelope(event, partial);
10055
+ if (partial.actions) envelope.actions = partial.actions;
10056
+ if (partial.permalink) envelope.permalink = partial.permalink;
10057
+ if (event.correlationId) envelope.correlationId = event.correlationId;
10058
+ return envelope;
10059
+ }
10060
+
10061
+ // src/notifications/events.ts
10062
+ var NOTIFICATION_TOPICS = [
10063
+ "interaction.created",
10064
+ "interaction.resolved",
10065
+ "maintenance:started",
10066
+ "maintenance:completed",
10067
+ "maintenance:error",
10068
+ // Hermes Phase 4 — skill proposal lifecycle.
10069
+ "proposal.created",
10070
+ "proposal.approved",
10071
+ "proposal.rejected"
10072
+ ];
10073
+ function newEventId4() {
10074
+ return `evt_${randomBytes8(8).toString("hex")}`;
10075
+ }
10076
+ function dispatchToEntry(bus, entry, event) {
10077
+ const eventType = event.type;
10078
+ const matches = entry.config.events.some((p) => eventMatches(p, eventType));
10079
+ if (!matches) return;
10080
+ const payload = entry.config.wrap_response ? wrapAsEnvelope(event) : event;
10081
+ const summaryBase = {
10082
+ sinkId: entry.adapter.id,
10083
+ kind: entry.adapter.kind,
10084
+ eventType,
10085
+ eventId: event.id
10086
+ };
10087
+ void entry.adapter.deliver({ payload, wrapped: entry.config.wrap_response }).then((result) => {
10088
+ bus.emit("notification.delivery.attempted", { ...summaryBase, ok: result.ok });
10089
+ if (!result.ok) {
10090
+ bus.emit("notification.delivery.failed", {
10091
+ ...summaryBase,
10092
+ ok: false,
10093
+ error: result.error
10094
+ });
10095
+ }
10096
+ }).catch((err) => {
10097
+ const msg = err instanceof Error ? err.message : String(err);
10098
+ bus.emit("notification.delivery.failed", { ...summaryBase, ok: false, error: msg });
10099
+ });
10100
+ }
10101
+ function wireNotificationSinks({ bus, registry }) {
10102
+ const handlers = [];
10103
+ for (const topic of NOTIFICATION_TOPICS) {
10104
+ const eventType = topic.replace(":", ".");
10105
+ const fn = (data) => {
10106
+ const entries = registry.list();
10107
+ if (entries.length === 0) return;
10108
+ const event = {
10109
+ id: newEventId4(),
10110
+ type: eventType,
10111
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
10112
+ data
10113
+ };
10114
+ for (const entry of entries) {
10115
+ dispatchToEntry(bus, entry, event);
10116
+ }
10117
+ };
10118
+ bus.on(topic, fn);
10119
+ handlers.push({ topic, fn });
10120
+ }
10121
+ return () => {
10122
+ for (const { topic, fn } of handlers) bus.removeListener(topic, fn);
10123
+ };
10124
+ }
10125
+
10126
+ // src/orchestrator.ts
10127
+ import { CacheMetricsRecorder, OTLPExporter } from "@harness-engineering/core";
10128
+
10129
+ // src/logging/logger.ts
10130
+ var StructuredLogger = class {
10131
+ debug(message, context) {
10132
+ this.log("debug", message, context);
8596
10133
  }
8597
10134
  info(message, context) {
8598
10135
  this.log("info", message, context);
@@ -8627,8 +10164,8 @@ var StructuredLogger = class {
8627
10164
  };
8628
10165
 
8629
10166
  // src/workspace/config-scanner.ts
8630
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
8631
- import { join as join13, relative } from "path";
10167
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
10168
+ import { join as join14, relative } from "path";
8632
10169
  import {
8633
10170
  scanForInjection,
8634
10171
  SecurityScanner,
@@ -8652,10 +10189,10 @@ function adjustFindingSeverity(findings) {
8652
10189
  });
8653
10190
  }
8654
10191
  async function scanSingleFile(filePath, targetDir, scanner) {
8655
- if (!existsSync4(filePath)) return null;
10192
+ if (!existsSync5(filePath)) return null;
8656
10193
  let content;
8657
10194
  try {
8658
- content = readFileSync4(filePath, "utf8");
10195
+ content = readFileSync5(filePath, "utf8");
8659
10196
  } catch {
8660
10197
  return null;
8661
10198
  }
@@ -8674,7 +10211,7 @@ async function scanWorkspaceConfig(workspacePath) {
8674
10211
  const scanner = new SecurityScanner(parseSecurityConfig({}));
8675
10212
  const results = [];
8676
10213
  for (const configFile of CONFIG_FILES) {
8677
- const result = await scanSingleFile(join13(workspacePath, configFile), workspacePath, scanner);
10214
+ const result = await scanSingleFile(join14(workspacePath, configFile), workspacePath, scanner);
8678
10215
  if (result) results.push(result);
8679
10216
  }
8680
10217
  return { exitCode: computeScanExitCode(results), results };
@@ -8860,6 +10397,19 @@ var BUILT_IN_TASKS = [
8860
10397
  schedule: "*/15 * * * *",
8861
10398
  branch: null,
8862
10399
  checkCommand: ["harness", "sync-main", "--json"]
10400
+ },
10401
+ // Hermes Phase 4 — one-shot backfill that stamps `provenance: user-authored`
10402
+ // on every existing catalog skill. Schedule is Feb 31 (a date that never
10403
+ // exists) so the cron loop never fires it automatically; operators trigger
10404
+ // it once via the dashboard "Run now" button or `harness backfill-skill-
10405
+ // provenance` after upgrading to Phase 4.
10406
+ {
10407
+ id: "proposal-provenance-backfill",
10408
+ type: "housekeeping",
10409
+ description: "Backfill provenance: user-authored on every existing skill (one-shot, idempotent)",
10410
+ schedule: "0 0 31 2 *",
10411
+ branch: null,
10412
+ checkCommand: ["backfill-skill-provenance"]
8863
10413
  }
8864
10414
  ];
8865
10415
 
@@ -8952,24 +10502,49 @@ var MaintenanceScheduler = class {
8952
10502
  this.resolvedTasks = this.resolveTasks();
8953
10503
  }
8954
10504
  /**
8955
- * Merge built-in task definitions with config overrides.
8956
- * Tasks with `enabled: false` are filtered out.
8957
- * Schedule overrides replace the default cron expression.
10505
+ * Merge built-in task definitions with config overrides, then append
10506
+ * Hermes Phase 2 `customTasks` (also respecting `tasks.<id>.enabled`
10507
+ * overrides). Tasks with `enabled: false` are filtered out. Schedule
10508
+ * overrides replace the default cron expression.
8958
10509
  */
8959
10510
  resolveTasks() {
8960
10511
  const overrides = this.config.tasks ?? {};
8961
- return BUILT_IN_TASKS.filter((task) => {
8962
- const override = overrides[task.id];
8963
- if (override?.enabled === false) return false;
8964
- return true;
8965
- }).map((task) => {
10512
+ const customs = this.config.customTasks ?? {};
10513
+ const merged = [];
10514
+ for (const task of BUILT_IN_TASKS) {
8966
10515
  const override = overrides[task.id];
8967
- if (!override) return { ...task };
8968
- return {
10516
+ if (override?.enabled === false) continue;
10517
+ merged.push({
8969
10518
  ...task,
8970
- ...override.schedule !== void 0 && { schedule: override.schedule }
8971
- };
8972
- });
10519
+ ...override?.schedule !== void 0 && { schedule: override.schedule }
10520
+ });
10521
+ }
10522
+ for (const [id, def] of Object.entries(customs)) {
10523
+ const override = overrides[id];
10524
+ if (override?.enabled === false) continue;
10525
+ merged.push({
10526
+ id,
10527
+ type: def.type,
10528
+ description: def.description,
10529
+ schedule: override?.schedule ?? def.schedule,
10530
+ branch: def.branch,
10531
+ ...def.checkCommand !== void 0 && { checkCommand: def.checkCommand },
10532
+ ...def.checkScript !== void 0 && { checkScript: def.checkScript },
10533
+ ...def.fixSkill !== void 0 && { fixSkill: def.fixSkill },
10534
+ ...def.inlineSkills !== void 0 && { inlineSkills: def.inlineSkills },
10535
+ ...def.inlineSkillsBudgetTokens !== void 0 && {
10536
+ inlineSkillsBudgetTokens: def.inlineSkillsBudgetTokens
10537
+ },
10538
+ ...def.contextFrom !== void 0 && { contextFrom: def.contextFrom },
10539
+ ...def.contextFromMaxAgeMinutes !== void 0 && {
10540
+ contextFromMaxAgeMinutes: def.contextFromMaxAgeMinutes
10541
+ },
10542
+ ...def.outputRetention !== void 0 && { outputRetention: def.outputRetention },
10543
+ ...def.costCeiling !== void 0 && { costCeiling: def.costCeiling },
10544
+ isCustom: true
10545
+ });
10546
+ }
10547
+ return merged;
8973
10548
  }
8974
10549
  /** Returns the resolved (merged) task list. Useful for testing and dashboard. */
8975
10550
  getResolvedTasks() {
@@ -9142,27 +10717,27 @@ var MaintenanceScheduler = class {
9142
10717
  };
9143
10718
 
9144
10719
  // src/maintenance/leader-elector.ts
9145
- import { Ok as Ok20 } from "@harness-engineering/types";
10720
+ import { Ok as Ok22 } from "@harness-engineering/types";
9146
10721
  var SingleProcessLeaderElector = class {
9147
10722
  async electLeader() {
9148
- return Ok20("claimed");
10723
+ return Ok22("claimed");
9149
10724
  }
9150
10725
  };
9151
10726
 
9152
10727
  // src/maintenance/reporter.ts
9153
- import * as fs14 from "fs";
9154
- import * as path15 from "path";
9155
- import { z as z15 } from "zod";
9156
- var RunResultSchema = z15.object({
9157
- taskId: z15.string(),
9158
- startedAt: z15.string(),
9159
- completedAt: z15.string(),
9160
- status: z15.enum(["success", "failure", "skipped", "no-issues"]),
9161
- findings: z15.number(),
9162
- fixed: z15.number(),
9163
- prUrl: z15.string().nullable(),
9164
- prUpdated: z15.boolean(),
9165
- error: z15.string().optional()
10728
+ import * as fs15 from "fs";
10729
+ import * as path16 from "path";
10730
+ import { z as z16 } from "zod";
10731
+ var RunResultSchema = z16.object({
10732
+ taskId: z16.string(),
10733
+ startedAt: z16.string(),
10734
+ completedAt: z16.string(),
10735
+ status: z16.enum(["success", "failure", "skipped", "no-issues"]),
10736
+ findings: z16.number(),
10737
+ fixed: z16.number(),
10738
+ prUrl: z16.string().nullable(),
10739
+ prUpdated: z16.boolean(),
10740
+ error: z16.string().optional()
9166
10741
  });
9167
10742
  var MAX_HISTORY = 500;
9168
10743
  var fallbackLogger = {
@@ -9186,10 +10761,10 @@ var MaintenanceReporter = class {
9186
10761
  */
9187
10762
  async load() {
9188
10763
  try {
9189
- await fs14.promises.mkdir(this.persistDir, { recursive: true });
9190
- const filePath = path15.join(this.persistDir, "history.json");
9191
- const data = await fs14.promises.readFile(filePath, "utf-8");
9192
- const parsed = z15.array(RunResultSchema).safeParse(JSON.parse(data));
10764
+ await fs15.promises.mkdir(this.persistDir, { recursive: true });
10765
+ const filePath = path16.join(this.persistDir, "history.json");
10766
+ const data = await fs15.promises.readFile(filePath, "utf-8");
10767
+ const parsed = z16.array(RunResultSchema).safeParse(JSON.parse(data));
9193
10768
  if (parsed.success) {
9194
10769
  this.history = parsed.data.slice(0, MAX_HISTORY);
9195
10770
  }
@@ -9222,9 +10797,9 @@ var MaintenanceReporter = class {
9222
10797
  */
9223
10798
  async persist() {
9224
10799
  try {
9225
- await fs14.promises.mkdir(this.persistDir, { recursive: true });
9226
- const filePath = path15.join(this.persistDir, "history.json");
9227
- await fs14.promises.writeFile(filePath, JSON.stringify(this.history, null, 2), "utf-8");
10800
+ await fs15.promises.mkdir(this.persistDir, { recursive: true });
10801
+ const filePath = path16.join(this.persistDir, "history.json");
10802
+ await fs15.promises.writeFile(filePath, JSON.stringify(this.history, null, 2), "utf-8");
9228
10803
  } catch (err) {
9229
10804
  this.logger.error("MaintenanceReporter: failed to persist history", { error: String(err) });
9230
10805
  }
@@ -9240,6 +10815,9 @@ var TaskRunner = class {
9240
10815
  cwd;
9241
10816
  prManager;
9242
10817
  baseBranch;
10818
+ checkScriptRunner;
10819
+ contextResolver;
10820
+ outputStore;
9243
10821
  constructor(options) {
9244
10822
  this.config = options.config;
9245
10823
  this.checkRunner = options.checkRunner;
@@ -9248,27 +10826,49 @@ var TaskRunner = class {
9248
10826
  this.cwd = options.cwd;
9249
10827
  this.prManager = options.prManager ?? null;
9250
10828
  this.baseBranch = options.baseBranch ?? "main";
10829
+ this.checkScriptRunner = options.checkScriptRunner ?? null;
10830
+ this.contextResolver = options.contextResolver ?? null;
10831
+ this.outputStore = options.outputStore ?? null;
9251
10832
  }
9252
10833
  /**
9253
10834
  * Run a maintenance task and return the result.
9254
10835
  * Dispatches to the appropriate execution path based on task type.
9255
10836
  * Never throws -- errors are captured in the RunResult.
10837
+ *
10838
+ * @param task - Resolved task definition.
10839
+ * @param origin - Hermes Phase 2 trigger-source tag; defaults to `'cron'`
10840
+ * when called from the scheduler path.
9256
10841
  */
9257
- async run(task) {
10842
+ async run(task, origin = "cron") {
9258
10843
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
10844
+ let result;
10845
+ let captured;
9259
10846
  try {
9260
10847
  switch (task.type) {
9261
- case "mechanical-ai":
9262
- return await this.runMechanicalAI(task, startedAt);
10848
+ case "mechanical-ai": {
10849
+ const out = await this.runMechanicalAI(task, startedAt);
10850
+ result = out.result;
10851
+ captured = out.captured;
10852
+ break;
10853
+ }
9263
10854
  case "pure-ai":
9264
- return await this.runPureAI(task, startedAt);
9265
- case "report-only":
9266
- return await this.runReportOnly(task, startedAt);
9267
- case "housekeeping":
9268
- return await this.runHousekeeping(task, startedAt);
10855
+ result = await this.runPureAI(task, startedAt);
10856
+ break;
10857
+ case "report-only": {
10858
+ const out = await this.runReportOnly(task, startedAt);
10859
+ result = out.result;
10860
+ captured = out.captured;
10861
+ break;
10862
+ }
10863
+ case "housekeeping": {
10864
+ const out = await this.runHousekeeping(task, startedAt);
10865
+ result = out.result;
10866
+ captured = out.captured;
10867
+ break;
10868
+ }
9269
10869
  default: {
9270
10870
  const _exhaustive = task.type;
9271
- return this.failureResult(
10871
+ result = this.failureResult(
9272
10872
  task.id,
9273
10873
  startedAt,
9274
10874
  `Unknown task type: ${String(_exhaustive)}`
@@ -9276,69 +10876,174 @@ var TaskRunner = class {
9276
10876
  }
9277
10877
  }
9278
10878
  } catch (err) {
9279
- return this.failureResult(task.id, startedAt, String(err));
10879
+ result = this.failureResult(task.id, startedAt, String(err));
10880
+ }
10881
+ result.origin = origin;
10882
+ await this.persistOutput(task, result, captured, origin);
10883
+ return result;
10884
+ }
10885
+ async persistOutput(task, result, captured, origin) {
10886
+ if (!this.outputStore) return;
10887
+ const entry = {
10888
+ taskId: result.taskId,
10889
+ startedAt: result.startedAt,
10890
+ completedAt: result.completedAt,
10891
+ status: result.status,
10892
+ findings: result.findings,
10893
+ fixed: result.fixed,
10894
+ prUrl: result.prUrl,
10895
+ prUpdated: result.prUpdated,
10896
+ origin,
10897
+ ...result.error !== void 0 && { error: result.error },
10898
+ ...result.costUsd !== void 0 && { costUsd: result.costUsd },
10899
+ ...captured?.stdout !== void 0 && { stdout: captured.stdout },
10900
+ ...captured?.stderr !== void 0 && { stderr: captured.stderr },
10901
+ ...captured?.structured !== void 0 && { structured: captured.structured },
10902
+ ...captured?.context !== void 0 && { context: captured.context }
10903
+ };
10904
+ try {
10905
+ await this.outputStore.write(task.id, entry, task.outputRetention);
10906
+ } catch {
9280
10907
  }
9281
10908
  }
9282
10909
  /**
9283
- * Mechanical-AI: run check command, dispatch AI agent only if fixable findings exist.
10910
+ * Run the check step using whichever runner the task asks for. Custom
10911
+ * tasks that declare `checkScript` go through the Hermes Phase 2
10912
+ * `CheckScriptRunner`; built-ins (and customs that use the legacy
10913
+ * `checkCommand` shape) go through the original heuristic runner.
9284
10914
  */
9285
- async runMechanicalAI(task, startedAt) {
10915
+ async runCheckStep(task) {
10916
+ if (task.checkScript) {
10917
+ if (!this.checkScriptRunner) {
10918
+ throw new Error(
10919
+ `task '${task.id}' declares checkScript but no CheckScriptRunner is configured`
10920
+ );
10921
+ }
10922
+ const r2 = await this.checkScriptRunner.run(task.checkScript, this.cwd);
10923
+ return {
10924
+ passed: r2.passed,
10925
+ findings: r2.findings,
10926
+ stdout: r2.output,
10927
+ stderr: r2.stderr,
10928
+ structured: r2.structured ? r2.structured : null
10929
+ };
10930
+ }
9286
10931
  if (!task.checkCommand || task.checkCommand.length === 0) {
9287
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing checkCommand");
10932
+ throw new Error(`task '${task.id}' is missing checkCommand`);
9288
10933
  }
10934
+ const r = await this.checkRunner.run(task.checkCommand, this.cwd);
10935
+ return {
10936
+ passed: r.passed,
10937
+ findings: r.findings,
10938
+ stdout: r.output,
10939
+ stderr: "",
10940
+ structured: null
10941
+ };
10942
+ }
10943
+ /**
10944
+ * Hermes Phase 2 — Compose the agent prompt-context block from inlined
10945
+ * skills + upstream task outputs. Returns an empty string when nothing
10946
+ * is configured (or when the resolver is absent), which is the safe
10947
+ * no-op default.
10948
+ */
10949
+ async composePromptContext(task) {
10950
+ if (!this.contextResolver) return "";
10951
+ const skills = await this.contextResolver.resolveInlineSkills(
10952
+ task.inlineSkills,
10953
+ task.inlineSkillsBudgetTokens ?? 8e3
10954
+ );
10955
+ const upstream = await this.contextResolver.resolveContextFrom(task.contextFrom, {
10956
+ maxAgeMinutes: task.contextFromMaxAgeMinutes ?? 1440
10957
+ });
10958
+ return [skills, upstream].filter(Boolean).join("\n");
10959
+ }
10960
+ /**
10961
+ * Mechanical-AI: run check (legacy or Phase 2 script), dispatch AI agent
10962
+ * only if fixable findings exist; persist captured stdout/stderr/context
10963
+ * via the output store on the way out.
10964
+ */
10965
+ async runMechanicalAI(task, startedAt) {
9289
10966
  if (!task.fixSkill) {
9290
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing fixSkill");
10967
+ return wrap(this.failureResult(task.id, startedAt, "mechanical-ai task missing fixSkill"));
9291
10968
  }
9292
10969
  if (!task.branch) {
9293
- return this.failureResult(task.id, startedAt, "mechanical-ai task missing branch");
10970
+ return wrap(this.failureResult(task.id, startedAt, "mechanical-ai task missing branch"));
9294
10971
  }
9295
- const checkResult = await this.checkRunner.run(task.checkCommand, this.cwd);
9296
- if (checkResult.findings === 0) {
10972
+ if (!task.checkCommand && !task.checkScript) {
10973
+ return wrap(
10974
+ this.failureResult(
10975
+ task.id,
10976
+ startedAt,
10977
+ "mechanical-ai task missing checkCommand or checkScript"
10978
+ )
10979
+ );
10980
+ }
10981
+ let check;
10982
+ try {
10983
+ check = await this.runCheckStep(task);
10984
+ } catch (err) {
10985
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
10986
+ }
10987
+ const promptContext = await this.composePromptContext(task);
10988
+ const baseCaptured = {
10989
+ stdout: check.stdout,
10990
+ stderr: check.stderr,
10991
+ structured: check.structured,
10992
+ ...promptContext ? { context: promptContext } : {}
10993
+ };
10994
+ const wakeAgentExplicitlyFalse = check.structured !== null && typeof check.structured === "object" && check.structured.wakeAgent === false;
10995
+ if (check.findings === 0 || wakeAgentExplicitlyFalse) {
9297
10996
  return {
9298
- taskId: task.id,
9299
- startedAt,
9300
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
9301
- status: "no-issues",
9302
- findings: 0,
9303
- fixed: 0,
9304
- prUrl: null,
9305
- prUpdated: false
10997
+ result: {
10998
+ taskId: task.id,
10999
+ startedAt,
11000
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11001
+ status: "no-issues",
11002
+ findings: check.findings,
11003
+ fixed: 0,
11004
+ prUrl: null,
11005
+ prUpdated: false
11006
+ },
11007
+ captured: baseCaptured
9306
11008
  };
9307
11009
  }
9308
11010
  if (this.prManager) {
9309
11011
  try {
9310
11012
  await this.prManager.ensureBranch(task.branch, this.baseBranch);
9311
11013
  } catch (err) {
9312
- return this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`);
11014
+ return wrap(
11015
+ this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`),
11016
+ baseCaptured
11017
+ );
9313
11018
  }
9314
11019
  }
9315
11020
  const backendName = this.resolveBackend(task.id);
9316
11021
  let agentResult;
9317
11022
  try {
9318
- agentResult = await this.agentDispatcher.dispatch(
9319
- task.fixSkill,
9320
- task.branch,
9321
- backendName,
9322
- this.cwd
9323
- );
11023
+ agentResult = promptContext ? await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd, {
11024
+ promptContext
11025
+ }) : await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd);
9324
11026
  } catch (err) {
9325
11027
  return {
9326
- taskId: task.id,
9327
- startedAt,
9328
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
9329
- status: "failure",
9330
- findings: checkResult.findings,
9331
- fixed: 0,
9332
- prUrl: null,
9333
- prUpdated: false,
9334
- error: `Agent dispatch failed: ${String(err)}`
11028
+ result: {
11029
+ taskId: task.id,
11030
+ startedAt,
11031
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11032
+ status: "failure",
11033
+ findings: check.findings,
11034
+ fixed: 0,
11035
+ prUrl: null,
11036
+ prUpdated: false,
11037
+ error: `Agent dispatch failed: ${String(err)}`
11038
+ },
11039
+ captured: baseCaptured
9335
11040
  };
9336
11041
  }
9337
11042
  let prUrl = null;
9338
11043
  let prUpdated = false;
9339
11044
  if (this.prManager && agentResult.producedCommits) {
9340
11045
  try {
9341
- const summary = `Findings: ${checkResult.findings}, Fixed: ${agentResult.fixed}`;
11046
+ const summary = `Findings: ${check.findings}, Fixed: ${agentResult.fixed}`;
9342
11047
  const prResult = await this.prManager.ensurePR(task, summary);
9343
11048
  prUrl = prResult.prUrl;
9344
11049
  prUpdated = prResult.prUpdated;
@@ -9347,14 +11052,17 @@ var TaskRunner = class {
9347
11052
  }
9348
11053
  }
9349
11054
  return {
9350
- taskId: task.id,
9351
- startedAt,
9352
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
9353
- status: "success",
9354
- findings: checkResult.findings,
9355
- fixed: agentResult.fixed,
9356
- prUrl,
9357
- prUpdated
11055
+ result: {
11056
+ taskId: task.id,
11057
+ startedAt,
11058
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11059
+ status: "success",
11060
+ findings: check.findings,
11061
+ fixed: agentResult.fixed,
11062
+ prUrl,
11063
+ prUpdated
11064
+ },
11065
+ captured: baseCaptured
9358
11066
  };
9359
11067
  }
9360
11068
  /**
@@ -9374,15 +11082,13 @@ var TaskRunner = class {
9374
11082
  return this.failureResult(task.id, startedAt, `ensureBranch failed: ${String(err)}`);
9375
11083
  }
9376
11084
  }
11085
+ const promptContext = await this.composePromptContext(task);
9377
11086
  const backendName = this.resolveBackend(task.id);
9378
11087
  let agentResult;
9379
11088
  try {
9380
- agentResult = await this.agentDispatcher.dispatch(
9381
- task.fixSkill,
9382
- task.branch,
9383
- backendName,
9384
- this.cwd
9385
- );
11089
+ agentResult = promptContext ? await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd, {
11090
+ promptContext
11091
+ }) : await this.agentDispatcher.dispatch(task.fixSkill, task.branch, backendName, this.cwd);
9386
11092
  } catch (err) {
9387
11093
  return this.failureResult(task.id, startedAt, `Agent dispatch failed: ${String(err)}`);
9388
11094
  }
@@ -9410,7 +11116,7 @@ var TaskRunner = class {
9410
11116
  };
9411
11117
  }
9412
11118
  /**
9413
- * Report-only: run check command, record metrics, no AI dispatch.
11119
+ * Report-only: run check (legacy or Phase 2 script), record metrics, no AI dispatch.
9414
11120
  *
9415
11121
  * Honors the JSON status contract emitted by Phase 4/5 CLIs (`harness pulse run`
9416
11122
  * and `harness compound scan-candidates` in `--non-interactive` mode):
@@ -9420,122 +11126,715 @@ var TaskRunner = class {
9420
11126
  * Legacy report-only tasks emit free-form output and fall through to 'success'.
9421
11127
  */
9422
11128
  async runReportOnly(task, startedAt) {
9423
- if (!task.checkCommand || task.checkCommand.length === 0) {
9424
- return this.failureResult(task.id, startedAt, "report-only task missing checkCommand");
11129
+ if (!task.checkCommand && !task.checkScript) {
11130
+ return wrap(
11131
+ this.failureResult(
11132
+ task.id,
11133
+ startedAt,
11134
+ "report-only task missing checkCommand or checkScript"
11135
+ )
11136
+ );
11137
+ }
11138
+ let check;
11139
+ try {
11140
+ check = await this.runCheckStep(task);
11141
+ } catch (err) {
11142
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11143
+ }
11144
+ const parsed = parseStatusLine(check.stdout);
11145
+ const status = parsed?.status ?? "success";
11146
+ const findings = parsed === null ? check.findings : typeof parsed.candidatesFound === "number" ? parsed.candidatesFound : 0;
11147
+ const result = {
11148
+ taskId: task.id,
11149
+ startedAt,
11150
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11151
+ status,
11152
+ findings,
11153
+ fixed: 0,
11154
+ prUrl: null,
11155
+ prUpdated: false
11156
+ };
11157
+ if (parsed?.error) {
11158
+ result.error = parsed.error;
11159
+ }
11160
+ return {
11161
+ result,
11162
+ captured: { stdout: check.stdout, stderr: check.stderr, structured: check.structured }
11163
+ };
11164
+ }
11165
+ /**
11166
+ * Housekeeping: run command directly, no AI, no PR.
11167
+ *
11168
+ * Captures stdout and parses a trailing JSON status line if present.
11169
+ * Recognized contracts:
11170
+ * - Phase 4/5 status contract (e.g., harness pulse run): success/skipped/failure/no-issues
11171
+ * - sync-main contract: updated/no-op/skipped/error → mapped onto the run-result status
11172
+ * Legacy housekeeping commands that emit no JSON keep the prior behavior:
11173
+ * status: 'success', findings: 0.
11174
+ *
11175
+ * Hermes Phase 2: a `checkScript` may replace `checkCommand` for housekeeping
11176
+ * tasks; the runner falls through to the same JSON-status parsing path.
11177
+ */
11178
+ async runHousekeeping(task, startedAt) {
11179
+ if (!task.checkCommand && !task.checkScript) {
11180
+ return wrap(
11181
+ this.failureResult(
11182
+ task.id,
11183
+ startedAt,
11184
+ "housekeeping task missing checkCommand or checkScript"
11185
+ )
11186
+ );
11187
+ }
11188
+ let stdout;
11189
+ let stderr = "";
11190
+ let structured = null;
11191
+ if (task.checkScript) {
11192
+ try {
11193
+ const r = await this.runCheckStep(task);
11194
+ stdout = r.stdout;
11195
+ stderr = r.stderr;
11196
+ structured = r.structured;
11197
+ } catch (err) {
11198
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11199
+ }
11200
+ } else {
11201
+ try {
11202
+ const out = await this.commandExecutor.exec(task.checkCommand, this.cwd);
11203
+ stdout = out.stdout ?? "";
11204
+ } catch (err) {
11205
+ return wrap(this.failureResult(task.id, startedAt, String(err)));
11206
+ }
11207
+ }
11208
+ const parsed = parseStatusLine(stdout);
11209
+ const status = parsed?.status ?? "success";
11210
+ const result = {
11211
+ taskId: task.id,
11212
+ startedAt,
11213
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11214
+ status,
11215
+ findings: 0,
11216
+ fixed: 0,
11217
+ prUrl: null,
11218
+ prUpdated: false
11219
+ };
11220
+ if (parsed?.error) result.error = parsed.error;
11221
+ return { result, captured: { stdout, stderr, structured } };
11222
+ }
11223
+ /**
11224
+ * Resolve which AI backend name to use for a given task.
11225
+ * Priority: per-task override > global config > 'local' default.
11226
+ */
11227
+ resolveBackend(taskId) {
11228
+ const taskOverride = this.config.tasks?.[taskId]?.aiBackend;
11229
+ if (taskOverride) return taskOverride;
11230
+ return this.config.aiBackend ?? "local";
11231
+ }
11232
+ failureResult(taskId, startedAt, error) {
11233
+ return {
11234
+ taskId,
11235
+ startedAt,
11236
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
11237
+ status: "failure",
11238
+ findings: 0,
11239
+ fixed: 0,
11240
+ prUrl: null,
11241
+ prUpdated: false,
11242
+ error
11243
+ };
11244
+ }
11245
+ };
11246
+ function wrap(result, captured) {
11247
+ return captured ? { result, captured } : { result };
11248
+ }
11249
+ function parseStatusLine(output) {
11250
+ const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
11251
+ for (let i = lines.length - 1; i >= 0; i--) {
11252
+ const line = lines[i];
11253
+ if (!line || !line.startsWith("{") || !line.endsWith("}")) continue;
11254
+ try {
11255
+ const obj = JSON.parse(line);
11256
+ const s = obj.status;
11257
+ if (s === "success" || s === "skipped" || s === "failure" || s === "no-issues") {
11258
+ const parsed = { status: s, rawStatus: s };
11259
+ if (typeof obj.candidatesFound === "number") {
11260
+ parsed.candidatesFound = obj.candidatesFound;
11261
+ }
11262
+ if (typeof obj.error === "string") {
11263
+ parsed.error = obj.error;
11264
+ }
11265
+ if (typeof obj.reason === "string") {
11266
+ parsed.reason = obj.reason;
11267
+ }
11268
+ if (typeof obj.detail === "string" && !parsed.error) {
11269
+ parsed.error = `${parsed.reason ?? "skipped"}: ${obj.detail}`;
11270
+ }
11271
+ return parsed;
11272
+ }
11273
+ if (s === "updated" || s === "no-op") {
11274
+ return { status: "success", rawStatus: s };
11275
+ }
11276
+ if (s === "error") {
11277
+ const message = typeof obj.message === "string" ? obj.message : "unknown error";
11278
+ return { status: "failure", error: message, rawStatus: "error" };
11279
+ }
11280
+ } catch {
11281
+ }
11282
+ }
11283
+ return null;
11284
+ }
11285
+
11286
+ // src/maintenance/check-script-runner.ts
11287
+ import { execFile as execFile6 } from "child_process";
11288
+ import { promisify as promisify3 } from "util";
11289
+ import * as path17 from "path";
11290
+ var execFileAsync = promisify3(execFile6);
11291
+ var CheckScriptRunner = class {
11292
+ constructor(cwd) {
11293
+ this.cwd = cwd;
11294
+ }
11295
+ cwd;
11296
+ async run(spec, cwd) {
11297
+ const projectRoot = cwd ?? this.cwd;
11298
+ const captured = await captureScript(spec, projectRoot);
11299
+ const parseJson = spec.parseStdoutJson !== false;
11300
+ const structured = parseJson ? parseStatusEnvelope(captured.stdout) : null;
11301
+ if (structured) {
11302
+ return mapStructured(structured, captured.stdout, captured.stderr);
11303
+ }
11304
+ return heuristicResult(captured.stdout, captured.stderr, captured.exitedAbnormally);
11305
+ }
11306
+ };
11307
+ async function captureScript(spec, projectRoot) {
11308
+ const resolved = path17.isAbsolute(spec.path) ? spec.path : path17.resolve(projectRoot, spec.path);
11309
+ const args = spec.args ?? [];
11310
+ const timeoutMs = spec.timeoutMs ?? 12e4;
11311
+ try {
11312
+ const result = await execFileAsync(resolved, args, { cwd: projectRoot, timeout: timeoutMs });
11313
+ return {
11314
+ stdout: String(result.stdout ?? ""),
11315
+ stderr: String(result.stderr ?? ""),
11316
+ exitedAbnormally: false
11317
+ };
11318
+ } catch (err) {
11319
+ const e = err;
11320
+ return {
11321
+ stdout: String(e.stdout ?? ""),
11322
+ stderr: String(e.stderr ?? ""),
11323
+ exitedAbnormally: true
11324
+ };
11325
+ }
11326
+ }
11327
+ function parseStatusEnvelope(stdout) {
11328
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
11329
+ for (let i = lines.length - 1; i >= 0; i--) {
11330
+ const env = classifyLine2(lines[i]);
11331
+ if (env) return env;
11332
+ }
11333
+ return null;
11334
+ }
11335
+ var ENVELOPE_STATUSES = /* @__PURE__ */ new Set(["ok", "findings", "skip", "error"]);
11336
+ function classifyLine2(line) {
11337
+ const obj = tryParseJsonObject(line);
11338
+ if (!obj) return null;
11339
+ const s = obj.status;
11340
+ if (typeof s !== "string" || !ENVELOPE_STATUSES.has(s)) return null;
11341
+ return buildEnvelope(s, obj);
11342
+ }
11343
+ function tryParseJsonObject(line) {
11344
+ if (!line || !line.startsWith("{") || !line.endsWith("}")) return null;
11345
+ try {
11346
+ return JSON.parse(line);
11347
+ } catch {
11348
+ return null;
11349
+ }
11350
+ }
11351
+ function buildEnvelope(status, obj) {
11352
+ const env = { status };
11353
+ if (typeof obj.findings === "number") env.findings = obj.findings;
11354
+ if (typeof obj.wakeAgent === "boolean") env.wakeAgent = obj.wakeAgent;
11355
+ if (typeof obj.message === "string") env.message = obj.message;
11356
+ if (obj.outputs && typeof obj.outputs === "object") {
11357
+ env.outputs = obj.outputs;
11358
+ }
11359
+ return env;
11360
+ }
11361
+ function mapStructured(env, stdout, stderr) {
11362
+ const findings = env.findings ?? (env.status === "findings" ? 1 : 0);
11363
+ switch (env.status) {
11364
+ case "ok":
11365
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11366
+ case "findings": {
11367
+ const wake = env.wakeAgent ?? findings > 0;
11368
+ return { passed: !wake, findings, output: stdout, stderr, structured: env };
11369
+ }
11370
+ case "skip":
11371
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11372
+ case "error":
11373
+ return {
11374
+ passed: false,
11375
+ findings: Math.max(findings, 1),
11376
+ output: stdout,
11377
+ stderr,
11378
+ structured: env
11379
+ };
11380
+ default:
11381
+ return { passed: true, findings: 0, output: stdout, stderr, structured: env };
11382
+ }
11383
+ }
11384
+ function heuristicResult(stdout, stderr, exitedAbnormally) {
11385
+ const combined = [stdout, stderr].filter(Boolean).join("\n");
11386
+ const findingsMatch = combined.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
11387
+ const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : exitedAbnormally ? 1 : 0;
11388
+ return {
11389
+ passed: findings === 0 && !exitedAbnormally,
11390
+ findings,
11391
+ output: stdout,
11392
+ stderr,
11393
+ structured: null
11394
+ };
11395
+ }
11396
+
11397
+ // src/maintenance/output-store.ts
11398
+ import * as fs16 from "fs";
11399
+ import * as path18 from "path";
11400
+ var DEFAULT_RETENTION = {
11401
+ runs: 50,
11402
+ maxAgeDays: 30
11403
+ };
11404
+ var fallbackLogger2 = {
11405
+ info: () => {
11406
+ },
11407
+ warn: (m, c) => console.warn(m, c),
11408
+ error: (m, c) => console.error(m, c)
11409
+ };
11410
+ var TaskOutputStore = class {
11411
+ rootDir;
11412
+ retentionDefaults;
11413
+ logger;
11414
+ constructor(options) {
11415
+ this.rootDir = options.rootDir;
11416
+ this.retentionDefaults = options.retentionDefaults ?? DEFAULT_RETENTION;
11417
+ this.logger = options.logger ?? fallbackLogger2;
11418
+ }
11419
+ /**
11420
+ * Reject task IDs that don't match the validator's kebab-case pattern —
11421
+ * defends `dirFor()` against caller-supplied path-traversal segments
11422
+ * (`'../foo'`) when the store is invoked from CLI surfaces that don't
11423
+ * round-trip through `validateCustomTasks`.
11424
+ */
11425
+ ensureSafeTaskId(taskId) {
11426
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(taskId)) {
11427
+ throw new Error(
11428
+ `TaskOutputStore: invalid task id '${taskId}' (must match ^[a-z0-9][a-z0-9-]*$)`
11429
+ );
11430
+ }
11431
+ }
11432
+ /**
11433
+ * Persist a single run entry. Retention is applied after the write so
11434
+ * the latest record is durable even if pruning fails.
11435
+ */
11436
+ async write(taskId, entry, retention) {
11437
+ this.ensureSafeTaskId(taskId);
11438
+ const dir = this.dirFor(taskId);
11439
+ await fs16.promises.mkdir(dir, { recursive: true });
11440
+ const fileName = `${sanitizeIso(entry.completedAt || (/* @__PURE__ */ new Date()).toISOString())}.json`;
11441
+ const filePath = path18.join(dir, fileName);
11442
+ const tmpPath = `${filePath}.tmp`;
11443
+ const payload = JSON.stringify(entry, null, 2);
11444
+ await fs16.promises.writeFile(tmpPath, payload, "utf-8");
11445
+ await fs16.promises.rename(tmpPath, filePath);
11446
+ try {
11447
+ await this.applyRetention(taskId, retention);
11448
+ } catch (err) {
11449
+ this.logger.warn("TaskOutputStore retention failed", { taskId, error: String(err) });
11450
+ }
11451
+ }
11452
+ /**
11453
+ * Return the most recent persisted entry for the task, or null if none.
11454
+ */
11455
+ async latest(taskId) {
11456
+ const entries = await this.list(taskId, 1, 0);
11457
+ return entries[0] ?? null;
11458
+ }
11459
+ /**
11460
+ * List entries newest-first with offset+limit pagination.
11461
+ */
11462
+ async list(taskId, limit, offset) {
11463
+ this.ensureSafeTaskId(taskId);
11464
+ const dir = this.dirFor(taskId);
11465
+ const fileNames = await listJsonFilesDescending(dir);
11466
+ const slice = fileNames.slice(offset, offset + limit);
11467
+ const out = [];
11468
+ for (const name of slice) {
11469
+ const entry = await this.readEntry(path18.join(dir, name));
11470
+ if (entry) out.push(entry);
11471
+ }
11472
+ return out;
11473
+ }
11474
+ /**
11475
+ * Lookup a specific run by its file name (without the `.json` suffix) or
11476
+ * by its raw completion timestamp.
11477
+ */
11478
+ async get(taskId, runId) {
11479
+ this.ensureSafeTaskId(taskId);
11480
+ if (/[\\/]|\.\./.test(runId)) {
11481
+ throw new Error(`TaskOutputStore: runId '${runId}' must not contain path separators or '..'`);
11482
+ }
11483
+ const dir = this.dirFor(taskId);
11484
+ const fileName = runId.endsWith(".json") ? runId : `${sanitizeIso(runId)}.json`;
11485
+ return this.readEntry(path18.join(dir, fileName));
11486
+ }
11487
+ /**
11488
+ * The on-disk root for a given task. Exposed for tooling that needs to walk
11489
+ * outputs from outside the store API.
11490
+ */
11491
+ dirFor(taskId) {
11492
+ return path18.join(this.rootDir, taskId, "outputs");
11493
+ }
11494
+ async readEntry(filePath) {
11495
+ try {
11496
+ const buf = await fs16.promises.readFile(filePath, "utf-8");
11497
+ const parsed = JSON.parse(buf);
11498
+ return parsed;
11499
+ } catch {
11500
+ return null;
11501
+ }
11502
+ }
11503
+ async applyRetention(taskId, retention) {
11504
+ const runs = retention?.runs ?? this.retentionDefaults.runs;
11505
+ const maxAgeDays = retention?.maxAgeDays ?? this.retentionDefaults.maxAgeDays;
11506
+ const dir = this.dirFor(taskId);
11507
+ const fileNames = await listJsonFilesDescending(dir);
11508
+ const overflow = fileNames.slice(runs);
11509
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1e3;
11510
+ const aged = [];
11511
+ for (const name of fileNames) {
11512
+ const ts = parseIsoFromFileName(name);
11513
+ if (ts !== null && ts < cutoffMs) aged.push(name);
11514
+ }
11515
+ const toRemove = /* @__PURE__ */ new Set([...overflow, ...aged]);
11516
+ for (const name of toRemove) {
11517
+ try {
11518
+ await fs16.promises.unlink(path18.join(dir, name));
11519
+ } catch {
11520
+ }
11521
+ }
11522
+ }
11523
+ };
11524
+ async function listJsonFilesDescending(dir) {
11525
+ let names;
11526
+ try {
11527
+ names = await fs16.promises.readdir(dir);
11528
+ } catch {
11529
+ return [];
11530
+ }
11531
+ return names.filter((n) => n.endsWith(".json")).sort().reverse();
11532
+ }
11533
+ function sanitizeIso(iso) {
11534
+ return iso.replace(/:/g, "-");
11535
+ }
11536
+ function parseIsoFromFileName(fileName) {
11537
+ const stem = fileName.replace(/\.json$/, "");
11538
+ const restored = stem.replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
11539
+ const ms = Date.parse(restored);
11540
+ return Number.isFinite(ms) ? ms : null;
11541
+ }
11542
+
11543
+ // src/maintenance/context-resolver.ts
11544
+ var ContextResolver = class {
11545
+ outputStore;
11546
+ skillReader;
11547
+ logger;
11548
+ perUpstreamMaxChars;
11549
+ constructor(options) {
11550
+ this.outputStore = options.outputStore;
11551
+ this.skillReader = options.skillReader ?? null;
11552
+ this.logger = options.logger ?? fallbackLogger3;
11553
+ this.perUpstreamMaxChars = options.perUpstreamMaxChars ?? 2e3;
11554
+ }
11555
+ async resolveContextFrom(upstreamTaskIds, options = {}) {
11556
+ if (!upstreamTaskIds || upstreamTaskIds.length === 0) return "";
11557
+ const maxAgeMs = (options.maxAgeMinutes ?? 1440) * 60 * 1e3;
11558
+ const now = Date.now();
11559
+ const sections = [];
11560
+ for (const id of upstreamTaskIds) {
11561
+ const entry = await this.outputStore.latest(id);
11562
+ sections.push(this.formatUpstream(id, entry, now, maxAgeMs));
11563
+ }
11564
+ return `## Upstream context
11565
+
11566
+ ${sections.join("\n\n")}
11567
+ `;
11568
+ }
11569
+ async resolveInlineSkills(skillNames, budgetTokens = 8e3) {
11570
+ if (!skillNames || skillNames.length === 0) return "";
11571
+ if (!this.skillReader) return "";
11572
+ const charBudget = budgetTokens * 4;
11573
+ let used = 0;
11574
+ const sections = [];
11575
+ let truncatedAt = -1;
11576
+ for (let i = 0; i < skillNames.length; i++) {
11577
+ const name = skillNames[i];
11578
+ const body = await this.skillReader.read(name);
11579
+ if (body === null) {
11580
+ this.logger.warn("inlineSkills: skill not found in registry", { name });
11581
+ continue;
11582
+ }
11583
+ const block = `### ${name}
11584
+
11585
+ ${body}`;
11586
+ if (used + block.length > charBudget) {
11587
+ truncatedAt = i;
11588
+ break;
11589
+ }
11590
+ used += block.length;
11591
+ sections.push(block);
11592
+ }
11593
+ if (truncatedAt >= 0) {
11594
+ this.logger.warn(
11595
+ `inlineSkillsBudgetTokens (${budgetTokens}) exhausted after ${sections.length} of ${skillNames.length} skills; truncated.`
11596
+ );
11597
+ }
11598
+ if (sections.length === 0) return "";
11599
+ return `## Reference skills
11600
+
11601
+ ${sections.join("\n\n")}
11602
+ `;
11603
+ }
11604
+ formatUpstream(id, entry, now, maxAgeMs) {
11605
+ if (!entry) {
11606
+ return `### ${id}
11607
+
11608
+ _[no prior run]_`;
11609
+ }
11610
+ const completedMs = Date.parse(entry.completedAt);
11611
+ if (Number.isFinite(completedMs) && now - completedMs > maxAgeMs) {
11612
+ return `### ${id} (last run ${entry.completedAt}, stale)
11613
+
11614
+ _[stale: omitted]_`;
11615
+ }
11616
+ const head = `### ${id} (last run ${entry.completedAt}, status=${entry.status}, findings=${entry.findings})`;
11617
+ const body = (entry.stdout ?? "").trim();
11618
+ const truncated = body.length > this.perUpstreamMaxChars ? `${body.slice(0, this.perUpstreamMaxChars)}
11619
+
11620
+ _[truncated]_` : body;
11621
+ return `${head}
11622
+
11623
+ ${truncated || "_[no stdout captured]_"}`;
11624
+ }
11625
+ };
11626
+ var fallbackLogger3 = {
11627
+ info: () => {
11628
+ },
11629
+ warn: () => {
11630
+ },
11631
+ error: () => {
11632
+ }
11633
+ };
11634
+
11635
+ // src/maintenance/custom-task-validator.ts
11636
+ import { Ok as Ok23, Err as Err20 } from "@harness-engineering/types";
11637
+ var ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
11638
+ var REQUIRED_FIELDS_BY_TYPE = {
11639
+ "mechanical-ai": ["branch", "fixSkill"],
11640
+ "pure-ai": ["branch", "fixSkill"],
11641
+ "report-only": [],
11642
+ housekeeping: []
11643
+ };
11644
+ function validateCustomTasks(customTasks, builtIns, deps = {}) {
11645
+ const errors = [];
11646
+ if (!customTasks) return Ok23(void 0);
11647
+ const builtInIds = new Set(builtIns.map((t) => t.id));
11648
+ const customIds = Object.keys(customTasks);
11649
+ const allIds = /* @__PURE__ */ new Set([...builtInIds, ...customIds]);
11650
+ for (const id of customIds) {
11651
+ const task = customTasks[id];
11652
+ if (!task) continue;
11653
+ validateOne(id, task, builtInIds, allIds, deps, errors);
11654
+ }
11655
+ detectCycles(customTasks, builtIns, errors);
11656
+ return errors.length === 0 ? Ok23(void 0) : Err20(errors);
11657
+ }
11658
+ function validateOne(id, task, builtInIds, allIds, deps, errors) {
11659
+ const prefix = `customTasks.${id}`;
11660
+ if (!ID_PATTERN.test(id)) {
11661
+ errors.push({
11662
+ path: prefix,
11663
+ message: `task ID '${id}' must match ^[a-z0-9][a-z0-9-]*$`
11664
+ });
11665
+ }
11666
+ if (builtInIds.has(id)) {
11667
+ errors.push({
11668
+ path: prefix,
11669
+ message: `task ID '${id}' collides with a built-in task; choose a different name`
11670
+ });
11671
+ }
11672
+ if (!task.description || task.description.trim().length === 0) {
11673
+ errors.push({ path: `${prefix}.description`, message: "description is required" });
11674
+ }
11675
+ if (!task.schedule || task.schedule.trim().length === 0) {
11676
+ errors.push({ path: `${prefix}.schedule`, message: "schedule (cron expression) is required" });
11677
+ }
11678
+ validateCheckShape(prefix, task, errors);
11679
+ validateRequiredByType(prefix, task, errors);
11680
+ validateContextFrom(prefix, id, task, allIds, errors);
11681
+ validateInlineSkills(prefix, task, deps, errors);
11682
+ validateScriptPath(prefix, task, deps, errors);
11683
+ }
11684
+ function validateCheckShape(prefix, task, errors) {
11685
+ const hasCommand = Array.isArray(task.checkCommand) && task.checkCommand.length > 0;
11686
+ const hasScript = task.checkScript !== void 0;
11687
+ if (hasCommand && hasScript) {
11688
+ errors.push({
11689
+ path: prefix,
11690
+ message: "a task may declare checkCommand OR checkScript, not both"
11691
+ });
11692
+ }
11693
+ const needsCheck = task.type === "mechanical-ai" || task.type === "report-only" || task.type === "housekeeping";
11694
+ if (needsCheck && !hasCommand && !hasScript) {
11695
+ errors.push({
11696
+ path: prefix,
11697
+ message: `${task.type} task must declare either checkCommand or checkScript`
11698
+ });
11699
+ }
11700
+ if (hasScript) {
11701
+ const path22 = task.checkScript?.path;
11702
+ if (!path22 || path22.trim().length === 0) {
11703
+ errors.push({ path: `${prefix}.checkScript.path`, message: "checkScript.path is required" });
9425
11704
  }
9426
- const checkResult = await this.checkRunner.run(task.checkCommand, this.cwd);
9427
- const parsed = parseStatusLine(checkResult.output);
9428
- const status = parsed?.status ?? "success";
9429
- const findings = parsed === null ? checkResult.findings : typeof parsed.candidatesFound === "number" ? parsed.candidatesFound : 0;
9430
- const result = {
9431
- taskId: task.id,
9432
- startedAt,
9433
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
9434
- status,
9435
- findings,
9436
- fixed: 0,
9437
- prUrl: null,
9438
- prUpdated: false
9439
- };
9440
- if (parsed?.error) {
9441
- result.error = parsed.error;
11705
+ if (task.checkScript?.timeoutMs !== void 0 && task.checkScript.timeoutMs <= 0) {
11706
+ errors.push({
11707
+ path: `${prefix}.checkScript.timeoutMs`,
11708
+ message: "timeoutMs must be a positive integer"
11709
+ });
9442
11710
  }
9443
- return result;
9444
11711
  }
9445
- /**
9446
- * Housekeeping: run command directly, no AI, no PR.
9447
- *
9448
- * Captures stdout and parses a trailing JSON status line if present.
9449
- * Recognized contracts:
9450
- * - Phase 4/5 status contract (e.g., harness pulse run): success/skipped/failure/no-issues
9451
- * - sync-main contract: updated/no-op/skipped/error → mapped onto the run-result status
9452
- * Legacy housekeeping commands that emit no JSON keep the prior behavior:
9453
- * status: 'success', findings: 0.
9454
- */
9455
- async runHousekeeping(task, startedAt) {
9456
- if (!task.checkCommand || task.checkCommand.length === 0) {
9457
- return this.failureResult(task.id, startedAt, "housekeeping task missing checkCommand");
9458
- }
9459
- let stdout;
9460
- try {
9461
- const out = await this.commandExecutor.exec(task.checkCommand, this.cwd);
9462
- stdout = out.stdout ?? "";
9463
- } catch (err) {
9464
- return this.failureResult(task.id, startedAt, String(err));
11712
+ }
11713
+ function validateRequiredByType(prefix, task, errors) {
11714
+ const required = REQUIRED_FIELDS_BY_TYPE[task.type];
11715
+ if (!required) {
11716
+ errors.push({ path: `${prefix}.type`, message: `unknown task type '${String(task.type)}'` });
11717
+ return;
11718
+ }
11719
+ for (const field of required) {
11720
+ const value = task[field];
11721
+ if (value === void 0 || value === null || typeof value === "string" && value.length === 0) {
11722
+ errors.push({
11723
+ path: `${prefix}.${String(field)}`,
11724
+ message: `${task.type} task requires ${String(field)}`
11725
+ });
9465
11726
  }
9466
- const parsed = parseStatusLine(stdout);
9467
- const status = parsed?.status ?? "success";
9468
- const result = {
9469
- taskId: task.id,
9470
- startedAt,
9471
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
9472
- status,
9473
- findings: 0,
9474
- fixed: 0,
9475
- prUrl: null,
9476
- prUpdated: false
9477
- };
9478
- if (parsed?.error) result.error = parsed.error;
9479
- return result;
9480
11727
  }
9481
- /**
9482
- * Resolve which AI backend name to use for a given task.
9483
- * Priority: per-task override > global config > 'local' default.
9484
- */
9485
- resolveBackend(taskId) {
9486
- const taskOverride = this.config.tasks?.[taskId]?.aiBackend;
9487
- if (taskOverride) return taskOverride;
9488
- return this.config.aiBackend ?? "local";
11728
+ if ((task.type === "mechanical-ai" || task.type === "pure-ai") && task.branch === null) {
11729
+ errors.push({
11730
+ path: `${prefix}.branch`,
11731
+ message: `${task.type} task requires a non-null branch`
11732
+ });
9489
11733
  }
9490
- failureResult(taskId, startedAt, error) {
9491
- return {
9492
- taskId,
9493
- startedAt,
9494
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
9495
- status: "failure",
9496
- findings: 0,
9497
- fixed: 0,
9498
- prUrl: null,
9499
- prUpdated: false,
9500
- error
9501
- };
11734
+ }
11735
+ function validateContextFrom(prefix, selfId, task, allIds, errors) {
11736
+ if (task.contextFromMaxAgeMinutes !== void 0 && task.contextFromMaxAgeMinutes <= 0) {
11737
+ errors.push({
11738
+ path: `${prefix}.contextFromMaxAgeMinutes`,
11739
+ message: "contextFromMaxAgeMinutes must be a positive integer"
11740
+ });
9502
11741
  }
9503
- };
9504
- function parseStatusLine(output) {
9505
- const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
9506
- for (let i = lines.length - 1; i >= 0; i--) {
9507
- const line = lines[i];
9508
- if (!line || !line.startsWith("{") || !line.endsWith("}")) continue;
9509
- try {
9510
- const obj = JSON.parse(line);
9511
- const s = obj.status;
9512
- if (s === "success" || s === "skipped" || s === "failure" || s === "no-issues") {
9513
- const parsed = { status: s, rawStatus: s };
9514
- if (typeof obj.candidatesFound === "number") {
9515
- parsed.candidatesFound = obj.candidatesFound;
9516
- }
9517
- if (typeof obj.error === "string") {
9518
- parsed.error = obj.error;
9519
- }
9520
- if (typeof obj.reason === "string") {
9521
- parsed.reason = obj.reason;
9522
- }
9523
- if (typeof obj.detail === "string" && !parsed.error) {
9524
- parsed.error = `${parsed.reason ?? "skipped"}: ${obj.detail}`;
9525
- }
9526
- return parsed;
9527
- }
9528
- if (s === "updated" || s === "no-op") {
9529
- return { status: "success", rawStatus: s };
9530
- }
9531
- if (s === "error") {
9532
- const message = typeof obj.message === "string" ? obj.message : "unknown error";
9533
- return { status: "failure", error: message, rawStatus: "error" };
9534
- }
9535
- } catch {
11742
+ if (!task.contextFrom) return;
11743
+ for (let i = 0; i < task.contextFrom.length; i++) {
11744
+ const upstreamId = task.contextFrom[i];
11745
+ if (!upstreamId) continue;
11746
+ if (upstreamId === selfId) {
11747
+ errors.push({
11748
+ path: `${prefix}.contextFrom[${i}]`,
11749
+ message: `task '${selfId}' cannot reference itself in contextFrom`
11750
+ });
11751
+ }
11752
+ if (!allIds.has(upstreamId)) {
11753
+ errors.push({
11754
+ path: `${prefix}.contextFrom[${i}]`,
11755
+ message: `references unknown task '${upstreamId}'`
11756
+ });
9536
11757
  }
9537
11758
  }
9538
- return null;
11759
+ }
11760
+ function validateInlineSkills(prefix, task, deps, errors) {
11761
+ if (!task.inlineSkills) return;
11762
+ if (!deps.skillExists) return;
11763
+ for (let i = 0; i < task.inlineSkills.length; i++) {
11764
+ const name = task.inlineSkills[i];
11765
+ if (!name) continue;
11766
+ if (!deps.skillExists(name)) {
11767
+ errors.push({
11768
+ path: `${prefix}.inlineSkills[${i}]`,
11769
+ message: `skill '${name}' not found in the registry`
11770
+ });
11771
+ }
11772
+ }
11773
+ if (task.inlineSkillsBudgetTokens !== void 0 && task.inlineSkillsBudgetTokens <= 0) {
11774
+ errors.push({
11775
+ path: `${prefix}.inlineSkillsBudgetTokens`,
11776
+ message: "inlineSkillsBudgetTokens must be a positive integer"
11777
+ });
11778
+ }
11779
+ }
11780
+ function validateScriptPath(prefix, task, deps, errors) {
11781
+ if (!task.checkScript?.path) return;
11782
+ if (!deps.scriptExists) return;
11783
+ if (!deps.scriptExists(task.checkScript.path)) {
11784
+ errors.push({
11785
+ path: `${prefix}.checkScript.path`,
11786
+ message: `executable not found: ${task.checkScript.path}`
11787
+ });
11788
+ }
11789
+ }
11790
+ function detectCycles(customTasks, builtIns, errors) {
11791
+ const adjacency = /* @__PURE__ */ new Map();
11792
+ for (const t of builtIns) adjacency.set(t.id, []);
11793
+ for (const [id, task] of Object.entries(customTasks)) {
11794
+ adjacency.set(id, (task.contextFrom ?? []).slice());
11795
+ }
11796
+ const color = /* @__PURE__ */ new Map();
11797
+ for (const id of adjacency.keys()) color.set(id, "white");
11798
+ const reported = /* @__PURE__ */ new Set();
11799
+ for (const id of Object.keys(customTasks)) {
11800
+ if (color.get(id) === "white") visitFromRoot(id, adjacency, color, errors, reported);
11801
+ }
11802
+ }
11803
+ function visitFromRoot(start, adjacency, color, errors, reported) {
11804
+ const stack = [{ id: start, nextIdx: 0, path: [start] }];
11805
+ color.set(start, "grey");
11806
+ while (stack.length) {
11807
+ const top = stack[stack.length - 1];
11808
+ const neighbors = adjacency.get(top.id) ?? [];
11809
+ if (top.nextIdx >= neighbors.length) {
11810
+ color.set(top.id, "black");
11811
+ stack.pop();
11812
+ continue;
11813
+ }
11814
+ const next = neighbors[top.nextIdx++];
11815
+ if (!next || !adjacency.has(next)) continue;
11816
+ handleEdge(top, next, color, stack, errors, reported);
11817
+ }
11818
+ }
11819
+ function handleEdge(top, next, color, stack, errors, reported) {
11820
+ const nextColor = color.get(next);
11821
+ if (nextColor === "grey") {
11822
+ reportCycle(top.path, next, errors, reported);
11823
+ } else if (nextColor === "white") {
11824
+ color.set(next, "grey");
11825
+ stack.push({ id: next, nextIdx: 0, path: [...top.path, next] });
11826
+ }
11827
+ }
11828
+ function reportCycle(path22, next, errors, reported) {
11829
+ const cycleStart = path22.indexOf(next);
11830
+ const cyclePath = cycleStart >= 0 ? [...path22.slice(cycleStart), next] : [...path22, next];
11831
+ const key = cyclePath.join("\u2192");
11832
+ if (reported.has(key)) return;
11833
+ reported.add(key);
11834
+ errors.push({
11835
+ path: `customTasks.${cyclePath[0]}.contextFrom`,
11836
+ message: `contextFrom cycle detected: ${cyclePath.join(" \u2192 ")}`
11837
+ });
9539
11838
  }
9540
11839
 
9541
11840
  // src/orchestrator.ts
@@ -9625,13 +11924,20 @@ var Orchestrator = class extends EventEmitter {
9625
11924
  cacheMetrics;
9626
11925
  otlpExporter;
9627
11926
  telemetryFanoutOff;
11927
+ // Hermes Phase 3: in-process notification sinks subscribe to the same
11928
+ // event bus (`this`) that webhook fanout uses, applying envelope
11929
+ // formatting before delivering to Slack/etc. The registry + unwire
11930
+ // handle are kept on the instance so stop() can detach listeners and
11931
+ // call adapter dispose() in deterministic order.
11932
+ notificationsRegistry;
11933
+ notificationFanoutOff;
9628
11934
  orchestratorIdPromise;
9629
11935
  recorder;
9630
11936
  intelligenceRunner;
9631
11937
  completionHandler;
9632
11938
  /** Project root directory, derived from workspace root. */
9633
11939
  get projectRoot() {
9634
- return path16.resolve(this.config.workspace.root, "..", "..");
11940
+ return path19.resolve(this.config.workspace.root, "..", "..");
9635
11941
  }
9636
11942
  enrichedSpecsByIssue = /* @__PURE__ */ new Map();
9637
11943
  /** Tracks recently-failed intelligence analysis to avoid re-requesting every tick */
@@ -9686,10 +11992,10 @@ var Orchestrator = class extends EventEmitter {
9686
11992
  this.renderer = new PromptRenderer();
9687
11993
  this.overrideBackend = overrides?.backend ?? null;
9688
11994
  this.interactionQueue = new InteractionQueue(
9689
- path16.join(config.workspace.root, "..", "interactions"),
11995
+ path19.join(config.workspace.root, "..", "interactions"),
9690
11996
  this
9691
11997
  );
9692
- this.analysisArchive = new AnalysisArchive(path16.join(config.workspace.root, "..", "analyses"));
11998
+ this.analysisArchive = new AnalysisArchive(path19.join(config.workspace.root, "..", "analyses"));
9693
11999
  const backendsMap = this.config.agent.backends ?? {};
9694
12000
  for (const [name, def] of Object.entries(backendsMap)) {
9695
12001
  if (def.type === "local" || def.type === "pi") {
@@ -9733,7 +12039,7 @@ var Orchestrator = class extends EventEmitter {
9733
12039
  ...overrides?.execFileFn ? { execFileFn: overrides.execFileFn } : {}
9734
12040
  });
9735
12041
  this.recorder = new StreamRecorder(
9736
- path16.resolve(config.workspace.root, "..", "streams"),
12042
+ path19.resolve(config.workspace.root, "..", "streams"),
9737
12043
  this.logger
9738
12044
  );
9739
12045
  const self = this;
@@ -9764,10 +12070,10 @@ var Orchestrator = class extends EventEmitter {
9764
12070
  this.completionHandler = new CompletionHandler(ctx, this.postLifecycleComment.bind(this));
9765
12071
  if (config.server?.port) {
9766
12072
  const webhookStore = new WebhookStore(
9767
- path16.join(this.projectRoot, ".harness", "webhooks.json")
12073
+ path19.join(this.projectRoot, ".harness", "webhooks.json")
9768
12074
  );
9769
12075
  this.webhookQueue = new WebhookQueue(
9770
- path16.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
12076
+ path19.join(this.projectRoot, ".harness", "webhook-queue.sqlite")
9771
12077
  );
9772
12078
  const webhookDelivery = new WebhookDelivery({
9773
12079
  queue: this.webhookQueue,
@@ -9780,6 +12086,7 @@ var Orchestrator = class extends EventEmitter {
9780
12086
  delivery: webhookDelivery
9781
12087
  });
9782
12088
  webhookDelivery.start();
12089
+ this.setupNotifications(config.notifications);
9783
12090
  const otlpCfg = config.telemetry?.export?.otlp;
9784
12091
  if (otlpCfg) {
9785
12092
  this.otlpExporter = new OTLPExporter({
@@ -9804,7 +12111,7 @@ var Orchestrator = class extends EventEmitter {
9804
12111
  queue: this.webhookQueue
9805
12112
  },
9806
12113
  cacheMetrics: this.cacheMetrics,
9807
- plansDir: path16.resolve(config.workspace.root, "..", "docs", "plans"),
12114
+ plansDir: path19.resolve(config.workspace.root, "..", "docs", "plans"),
9808
12115
  pipeline: this.pipeline,
9809
12116
  analysisArchive: this.analysisArchive,
9810
12117
  roadmapPath: config.tracker.filePath ?? null,
@@ -9860,13 +12167,13 @@ var Orchestrator = class extends EventEmitter {
9860
12167
  const logger = this.logger;
9861
12168
  const checkRunner = {
9862
12169
  run: async (command, cwd) => {
9863
- const { execFile: execFile6 } = await import("child_process");
9864
- const { promisify: promisify4 } = await import("util");
9865
- const execFileAsync = promisify4(execFile6);
12170
+ const { execFile: execFile7 } = await import("child_process");
12171
+ const { promisify: promisify5 } = await import("util");
12172
+ const execFileAsync2 = promisify5(execFile7);
9866
12173
  const [cmd, ...args] = command;
9867
12174
  if (!cmd) return { passed: true, findings: 0, output: "" };
9868
12175
  try {
9869
- const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
12176
+ const { stdout } = await execFileAsync2(cmd, args, { cwd, timeout: 12e4 });
9870
12177
  const findingsMatch = stdout.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
9871
12178
  const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : 0;
9872
12179
  return { passed: findings === 0, findings, output: stdout };
@@ -9895,13 +12202,13 @@ var Orchestrator = class extends EventEmitter {
9895
12202
  };
9896
12203
  const commandExecutor = {
9897
12204
  exec: async (command, cwd) => {
9898
- const { execFile: execFile6 } = await import("child_process");
9899
- const { promisify: promisify4 } = await import("util");
9900
- const execFileAsync = promisify4(execFile6);
12205
+ const { execFile: execFile7 } = await import("child_process");
12206
+ const { promisify: promisify5 } = await import("util");
12207
+ const execFileAsync2 = promisify5(execFile7);
9901
12208
  const [cmd, ...args] = command;
9902
12209
  if (!cmd) return { stdout: "" };
9903
12210
  try {
9904
- const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
12211
+ const { stdout } = await execFileAsync2(cmd, args, { cwd, timeout: 12e4 });
9905
12212
  return { stdout: String(stdout) };
9906
12213
  } catch (err) {
9907
12214
  logger.warn("Maintenance command execution failed", {
@@ -9913,12 +12220,31 @@ var Orchestrator = class extends EventEmitter {
9913
12220
  }
9914
12221
  }
9915
12222
  };
12223
+ const outputStore = new TaskOutputStore({
12224
+ rootDir: path19.join(this.projectRoot, ".harness", "maintenance"),
12225
+ logger: this.logger
12226
+ });
12227
+ const checkScriptRunner = new CheckScriptRunner(this.projectRoot);
12228
+ const skillReader = {
12229
+ // The orchestrator does not own the skill registry; CLI-side skill
12230
+ // resolution wires this in via direct injection. Default: skill not
12231
+ // resolvable from the orchestrator boundary.
12232
+ read: async () => null
12233
+ };
12234
+ const contextResolver = new ContextResolver({
12235
+ outputStore,
12236
+ skillReader,
12237
+ logger: this.logger
12238
+ });
9916
12239
  return new TaskRunner({
9917
12240
  config: maintenanceConfig,
9918
12241
  checkRunner,
9919
12242
  agentDispatcher,
9920
12243
  commandExecutor,
9921
- cwd: this.projectRoot
12244
+ cwd: this.projectRoot,
12245
+ checkScriptRunner,
12246
+ contextResolver,
12247
+ outputStore
9922
12248
  });
9923
12249
  }
9924
12250
  /**
@@ -9926,8 +12252,17 @@ var Orchestrator = class extends EventEmitter {
9926
12252
  * Extracted from start() to keep function length under threshold.
9927
12253
  */
9928
12254
  async initMaintenance(maintenanceConfig) {
12255
+ const validation = validateCustomTasks(
12256
+ maintenanceConfig.customTasks,
12257
+ BUILT_IN_TASKS
12258
+ );
12259
+ if (!validation.ok) {
12260
+ const messages = validation.error.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
12261
+ throw new Error(`Invalid maintenance.customTasks configuration:
12262
+ ${messages}`);
12263
+ }
9929
12264
  this.maintenanceReporter = new MaintenanceReporter({
9930
- persistDir: path16.join(this.projectRoot, ".harness", "maintenance"),
12265
+ persistDir: path19.join(this.projectRoot, ".harness", "maintenance"),
9931
12266
  logger: this.logger
9932
12267
  });
9933
12268
  await this.maintenanceReporter.load();
@@ -10615,6 +12950,31 @@ var Orchestrator = class extends EventEmitter {
10615
12950
  );
10616
12951
  this.emit("state_change", this.getSnapshot());
10617
12952
  }
12953
+ /**
12954
+ * Hermes Phase 3: wire in-process notification sinks against the
12955
+ * orchestrator's event bus (`this`). A misconfigured sink (unknown kind,
12956
+ * missing env var) logs + skips rather than breaking startup — the
12957
+ * hardened doctor (`harness doctor`) surfaces the gap. Sinks subscribe
12958
+ * to the same topics as `wireWebhookFanout`; a slow Slack call cannot
12959
+ * block webhook delivery because the two paths fan out independently.
12960
+ */
12961
+ setupNotifications(notifConfig) {
12962
+ if (!notifConfig || !notifConfig.sinks || notifConfig.sinks.length === 0) return;
12963
+ try {
12964
+ this.notificationsRegistry = SinkRegistry.fromConfig(notifConfig, {
12965
+ env: process.env
12966
+ });
12967
+ this.notificationFanoutOff = wireNotificationSinks({
12968
+ bus: this,
12969
+ registry: this.notificationsRegistry
12970
+ });
12971
+ } catch (err) {
12972
+ this.logger.warn(
12973
+ `notifications sink registry failed: ${err instanceof Error ? err.message : String(err)}; sinks disabled`
12974
+ );
12975
+ delete this.notificationsRegistry;
12976
+ }
12977
+ }
10618
12978
  /**
10619
12979
  * Stops execution for a specific issue.
10620
12980
  *
@@ -10782,6 +13142,14 @@ var Orchestrator = class extends EventEmitter {
10782
13142
  this.webhookFanoutOff();
10783
13143
  delete this.webhookFanoutOff;
10784
13144
  }
13145
+ if (this.notificationFanoutOff) {
13146
+ this.notificationFanoutOff();
13147
+ delete this.notificationFanoutOff;
13148
+ }
13149
+ if (this.notificationsRegistry) {
13150
+ await this.notificationsRegistry.dispose();
13151
+ delete this.notificationsRegistry;
13152
+ }
10785
13153
  if (this.telemetryFanoutOff) {
10786
13154
  this.telemetryFanoutOff();
10787
13155
  delete this.telemetryFanoutOff;
@@ -11071,10 +13439,10 @@ function launchTUI(orchestrator) {
11071
13439
 
11072
13440
  // src/maintenance/sync-main.ts
11073
13441
  import { execFile as nodeExecFile } from "child_process";
11074
- import { promisify as promisify3 } from "util";
11075
- var DEFAULT_TIMEOUT_MS2 = 6e4;
13442
+ import { promisify as promisify4 } from "util";
13443
+ var DEFAULT_TIMEOUT_MS3 = 6e4;
11076
13444
  async function git(execFileFn, args, cwd, timeoutMs) {
11077
- const exec = promisify3(execFileFn);
13445
+ const exec = promisify4(execFileFn);
11078
13446
  const { stdout, stderr } = await exec("git", args, { cwd, timeout: timeoutMs });
11079
13447
  return { stdout: String(stdout), stderr: String(stderr) };
11080
13448
  }
@@ -11137,7 +13505,7 @@ async function isAncestor(execFileFn, a, b, cwd, timeoutMs) {
11137
13505
  }
11138
13506
  async function syncMain(repoRoot, opts = {}) {
11139
13507
  const execFileFn = opts.execFileFn ?? nodeExecFile;
11140
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
13508
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
11141
13509
  try {
11142
13510
  const originRef = await resolveOriginDefault(execFileFn, repoRoot, timeoutMs);
11143
13511
  if (!originRef) {
@@ -11212,10 +13580,472 @@ async function syncMain(repoRoot, opts = {}) {
11212
13580
  };
11213
13581
  }
11214
13582
  }
13583
+
13584
+ // src/sessions/search-index.ts
13585
+ import * as fs17 from "fs";
13586
+ import * as path20 from "path";
13587
+ import Database2 from "better-sqlite3";
13588
+ import { INDEXED_FILE_KINDS } from "@harness-engineering/types";
13589
+ var SEARCH_INDEX_FILE = "search-index.sqlite";
13590
+ var SCHEMA_SQL2 = `
13591
+ CREATE TABLE IF NOT EXISTS session_docs (
13592
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13593
+ session_id TEXT NOT NULL,
13594
+ archived INTEGER NOT NULL,
13595
+ file_kind TEXT NOT NULL,
13596
+ path TEXT NOT NULL,
13597
+ mtime_ms INTEGER NOT NULL,
13598
+ body TEXT NOT NULL,
13599
+ UNIQUE (session_id, archived, file_kind)
13600
+ );
13601
+
13602
+ CREATE VIRTUAL TABLE IF NOT EXISTS session_docs_fts USING fts5 (
13603
+ body,
13604
+ content='session_docs',
13605
+ content_rowid='id',
13606
+ tokenize='unicode61 remove_diacritics 2'
13607
+ );
13608
+
13609
+ CREATE TRIGGER IF NOT EXISTS session_docs_ai
13610
+ AFTER INSERT ON session_docs
13611
+ BEGIN INSERT INTO session_docs_fts(rowid, body) VALUES (new.id, new.body); END;
13612
+
13613
+ CREATE TRIGGER IF NOT EXISTS session_docs_ad
13614
+ AFTER DELETE ON session_docs
13615
+ BEGIN INSERT INTO session_docs_fts(session_docs_fts, rowid, body) VALUES('delete', old.id, old.body); END;
13616
+
13617
+ CREATE TRIGGER IF NOT EXISTS session_docs_au
13618
+ AFTER UPDATE ON session_docs
13619
+ BEGIN
13620
+ INSERT INTO session_docs_fts(session_docs_fts, rowid, body) VALUES('delete', old.id, old.body);
13621
+ INSERT INTO session_docs_fts(rowid, body) VALUES (new.id, new.body);
13622
+ END;
13623
+ `;
13624
+ var DEFAULT_LIMIT = 20;
13625
+ function normalizeFts5Query(query) {
13626
+ const advancedSyntax = /["()*^+]|\bAND\b|\bOR\b|\bNOT\b|[A-Za-z_]+:/;
13627
+ if (advancedSyntax.test(query)) return query;
13628
+ return query.split(/\s+/).filter((tok) => tok.length > 0).map((tok) => `"${tok.replace(/"/g, '""')}"`).join(" ");
13629
+ }
13630
+ function searchIndexPath(projectPath) {
13631
+ return path20.join(projectPath, ".harness", SEARCH_INDEX_FILE);
13632
+ }
13633
+ var FILE_KIND_TO_FILENAME = {
13634
+ summary: "summary.md",
13635
+ learnings: "learnings.md",
13636
+ failures: "failures.md",
13637
+ sections: "session-sections.md",
13638
+ llm_summary: "llm-summary.md"
13639
+ };
13640
+ var SqliteSearchIndex = class {
13641
+ db;
13642
+ upsertStmt;
13643
+ removeSessionStmt;
13644
+ totalStmt;
13645
+ constructor(dbPath) {
13646
+ fs17.mkdirSync(path20.dirname(dbPath), { recursive: true });
13647
+ this.db = new Database2(dbPath);
13648
+ this.db.pragma("journal_mode = WAL");
13649
+ this.db.pragma("synchronous = NORMAL");
13650
+ this.db.exec(SCHEMA_SQL2);
13651
+ this.upsertStmt = this.db.prepare(
13652
+ `INSERT INTO session_docs (session_id, archived, file_kind, path, mtime_ms, body)
13653
+ VALUES (@sessionId, @archived, @fileKind, @path, @mtimeMs, @body)
13654
+ ON CONFLICT(session_id, archived, file_kind) DO UPDATE SET
13655
+ path = excluded.path,
13656
+ mtime_ms = excluded.mtime_ms,
13657
+ body = excluded.body`
13658
+ );
13659
+ this.removeSessionStmt = this.db.prepare(`DELETE FROM session_docs WHERE session_id = ?`);
13660
+ this.totalStmt = this.db.prepare(`SELECT COUNT(*) AS n FROM session_docs`);
13661
+ }
13662
+ upsertSessionDoc(doc) {
13663
+ this.upsertStmt.run({
13664
+ sessionId: doc.sessionId,
13665
+ archived: doc.archived ? 1 : 0,
13666
+ fileKind: doc.fileKind,
13667
+ path: doc.path,
13668
+ mtimeMs: Math.floor(doc.mtimeMs),
13669
+ body: doc.body
13670
+ });
13671
+ }
13672
+ removeSession(sessionId) {
13673
+ const info = this.removeSessionStmt.run(sessionId);
13674
+ return info.changes;
13675
+ }
13676
+ /**
13677
+ * Drop all `archived=1` rows. Used by `reindexFromArchive` before a full
13678
+ * re-walk. Live (archived=0) rows are preserved.
13679
+ */
13680
+ resetArchived() {
13681
+ this.db.prepare(`DELETE FROM session_docs WHERE archived = 1`).run();
13682
+ }
13683
+ /** Total rows currently indexed (across both live and archived). */
13684
+ totalIndexed() {
13685
+ const row = this.totalStmt.get();
13686
+ return row.n;
13687
+ }
13688
+ /**
13689
+ * Ranked FTS5 query. Returns BM25-sorted matches. The `query` is passed to
13690
+ * FTS5 as-is; FTS5 syntax (phrases with quotes, AND/OR/NOT, `column:term`)
13691
+ * is therefore the user-facing language. Errors from malformed queries
13692
+ * surface as thrown `SqliteError` so the CLI can catch + render them.
13693
+ */
13694
+ search(query, opts = {}) {
13695
+ const limit = opts.limit ?? DEFAULT_LIMIT;
13696
+ const filters = [];
13697
+ const params = { q: normalizeFts5Query(query), limit };
13698
+ if (opts.archivedOnly) {
13699
+ filters.push("d.archived = 1");
13700
+ }
13701
+ const fileKinds = opts.fileKinds && opts.fileKinds.length > 0 ? opts.fileKinds : null;
13702
+ if (fileKinds) {
13703
+ const placeholders = fileKinds.map((_, i) => `@fk${i}`).join(", ");
13704
+ filters.push(`d.file_kind IN (${placeholders})`);
13705
+ fileKinds.forEach((k, i) => {
13706
+ params[`fk${i}`] = k;
13707
+ });
13708
+ }
13709
+ const whereClause = filters.length > 0 ? `AND ${filters.join(" AND ")}` : "";
13710
+ const sql = `
13711
+ SELECT
13712
+ d.session_id AS sessionId,
13713
+ d.archived AS archived,
13714
+ d.file_kind AS fileKind,
13715
+ d.path AS path,
13716
+ bm25(session_docs_fts) AS bm25,
13717
+ snippet(session_docs_fts, 0, '\u2026', '\u2026', '\u2026', 16) AS snippet
13718
+ FROM session_docs_fts
13719
+ JOIN session_docs d ON d.id = session_docs_fts.rowid
13720
+ WHERE session_docs_fts MATCH @q
13721
+ ${whereClause}
13722
+ ORDER BY bm25 ASC
13723
+ LIMIT @limit
13724
+ `;
13725
+ const start = Date.now();
13726
+ const rows = this.db.prepare(sql).all(params);
13727
+ const durationMs = Date.now() - start;
13728
+ const matches = rows.map((r) => ({
13729
+ sessionId: r.sessionId,
13730
+ archived: r.archived === 1,
13731
+ fileKind: r.fileKind,
13732
+ path: r.path,
13733
+ bm25: r.bm25,
13734
+ snippet: r.snippet
13735
+ }));
13736
+ return { matches, durationMs, totalIndexed: this.totalIndexed() };
13737
+ }
13738
+ close() {
13739
+ this.db.close();
13740
+ }
13741
+ };
13742
+ function openSearchIndex(projectPath) {
13743
+ return new SqliteSearchIndex(searchIndexPath(projectPath));
13744
+ }
13745
+ function indexSessionDirectory(idx, args) {
13746
+ const kinds = args.fileKinds ?? [...INDEXED_FILE_KINDS];
13747
+ const cap = args.maxBytesPerBody ?? 256 * 1024;
13748
+ let docsWritten = 0;
13749
+ for (const kind of kinds) {
13750
+ const fileName = FILE_KIND_TO_FILENAME[kind];
13751
+ const filePath = path20.join(args.sessionDir, fileName);
13752
+ if (!fs17.existsSync(filePath)) continue;
13753
+ let body = fs17.readFileSync(filePath, "utf8");
13754
+ if (Buffer.byteLength(body, "utf8") > cap) {
13755
+ body = body.slice(0, cap) + "\n\n[TRUNCATED]";
13756
+ }
13757
+ const stat = fs17.statSync(filePath);
13758
+ const relPath = path20.relative(args.projectPath, filePath).replaceAll("\\", "/");
13759
+ idx.upsertSessionDoc({
13760
+ sessionId: args.sessionId,
13761
+ archived: args.archived,
13762
+ fileKind: kind,
13763
+ path: relPath,
13764
+ mtimeMs: stat.mtimeMs,
13765
+ body
13766
+ });
13767
+ docsWritten++;
13768
+ }
13769
+ return { docsWritten };
13770
+ }
13771
+ function reindexFromArchive(projectPath, opts = {}) {
13772
+ const start = Date.now();
13773
+ const archiveBase = path20.join(projectPath, ".harness", "archive", "sessions");
13774
+ const idx = openSearchIndex(projectPath);
13775
+ try {
13776
+ idx.resetArchived();
13777
+ let sessionsIndexed = 0;
13778
+ let docsWritten = 0;
13779
+ if (fs17.existsSync(archiveBase)) {
13780
+ const entries = fs17.readdirSync(archiveBase, { withFileTypes: true });
13781
+ for (const entry of entries) {
13782
+ if (!entry.isDirectory()) continue;
13783
+ const sessionDir = path20.join(archiveBase, entry.name);
13784
+ const result = indexSessionDirectory(idx, {
13785
+ sessionId: entry.name,
13786
+ sessionDir,
13787
+ archived: true,
13788
+ projectPath,
13789
+ ...opts.fileKinds && { fileKinds: opts.fileKinds },
13790
+ ...opts.maxBytesPerBody !== void 0 && { maxBytesPerBody: opts.maxBytesPerBody }
13791
+ });
13792
+ if (result.docsWritten > 0) sessionsIndexed++;
13793
+ docsWritten += result.docsWritten;
13794
+ }
13795
+ }
13796
+ return { sessionsIndexed, docsWritten, durationMs: Date.now() - start };
13797
+ } finally {
13798
+ idx.close();
13799
+ }
13800
+ }
13801
+
13802
+ // src/sessions/summarize.ts
13803
+ import * as fs18 from "fs";
13804
+ import * as path21 from "path";
13805
+ import {
13806
+ SessionSummarySchema
13807
+ } from "@harness-engineering/types";
13808
+ import { Ok as Ok24, Err as Err21 } from "@harness-engineering/types";
13809
+ var LLM_SUMMARY_FILE = "llm-summary.md";
13810
+ var SUMMARY_INPUT_FILES = [
13811
+ { filename: "summary.md", kind: "summary" },
13812
+ { filename: "learnings.md", kind: "learnings" },
13813
+ { filename: "failures.md", kind: "failures" },
13814
+ { filename: "session-sections.md", kind: "sections" }
13815
+ ];
13816
+ var DEFAULT_INPUT_BUDGET_TOKENS = 16e3;
13817
+ var DEFAULT_TIMEOUT_MS4 = 6e4;
13818
+ var CHARS_PER_TOKEN = 4;
13819
+ var SYSTEM_PROMPT = `You produce concise, structured retrospectives of completed harness-engineering sessions.
13820
+
13821
+ Read the session's archived markdown files and emit a JSON object that conforms exactly to the provided schema. Be specific and grounded \u2014 quote artefacts (file names, skill names, error messages) verbatim when relevant. Do not invent. If a field has no content, return an empty array.`;
13822
+ var USER_PROMPT_PREAMBLE = `Below are the archived files for a single harness-engineering session. Produce a structured summary capturing:
13823
+ - headline: one-sentence retrospective (\u2264 120 chars)
13824
+ - keyOutcomes: concrete things that shipped / decisions made (\u2264 20 strings)
13825
+ - openQuestions: items still open (\u2264 20 strings)
13826
+ - relatedSessions: other session slugs referenced (may be empty)
13827
+
13828
+ ---
13829
+
13830
+ `;
13831
+ function readInputCorpus(archiveDir) {
13832
+ const parts = [];
13833
+ for (const { filename, kind } of SUMMARY_INPUT_FILES) {
13834
+ const p = path21.join(archiveDir, filename);
13835
+ if (!fs18.existsSync(p)) continue;
13836
+ try {
13837
+ const content = fs18.readFileSync(p, "utf8");
13838
+ if (content.trim().length === 0) continue;
13839
+ parts.push(`## FILE: ${kind}
13840
+
13841
+ ${content.trim()}`);
13842
+ } catch {
13843
+ }
13844
+ }
13845
+ return parts.join("\n\n");
13846
+ }
13847
+ function truncateForBudget(text, inputBudgetTokens) {
13848
+ const cap = Math.max(0, inputBudgetTokens * CHARS_PER_TOKEN);
13849
+ if (text.length <= cap) return text;
13850
+ return text.slice(0, cap) + "\n\n[TRUNCATED \u2014 input exceeded token budget]";
13851
+ }
13852
+ function renderLlmSummaryMarkdown(summary, meta) {
13853
+ const lines = [
13854
+ "---",
13855
+ `generatedAt: ${meta.generatedAt}`,
13856
+ `model: ${meta.model}`,
13857
+ `inputTokens: ${meta.inputTokens}`,
13858
+ `outputTokens: ${meta.outputTokens}`,
13859
+ `schemaVersion: ${meta.schemaVersion}`,
13860
+ "---",
13861
+ "",
13862
+ "## Headline",
13863
+ summary.headline,
13864
+ "",
13865
+ "## Key outcomes"
13866
+ ];
13867
+ if (summary.keyOutcomes.length === 0) {
13868
+ lines.push("_(none)_");
13869
+ } else {
13870
+ for (const item of summary.keyOutcomes) lines.push(`- ${item}`);
13871
+ }
13872
+ lines.push("", "## Open questions");
13873
+ if (summary.openQuestions.length === 0) {
13874
+ lines.push("_(none)_");
13875
+ } else {
13876
+ for (const item of summary.openQuestions) lines.push(`- ${item}`);
13877
+ }
13878
+ lines.push("", "## Related sessions");
13879
+ if (summary.relatedSessions.length === 0) {
13880
+ lines.push("_(none)_");
13881
+ } else {
13882
+ for (const item of summary.relatedSessions) lines.push(`- ${item}`);
13883
+ }
13884
+ lines.push("");
13885
+ return lines.join("\n");
13886
+ }
13887
+ function writeStubMarkdown(archiveDir, reason) {
13888
+ const filePath = path21.join(archiveDir, LLM_SUMMARY_FILE);
13889
+ const body = `---
13890
+ generatedAt: ${(/* @__PURE__ */ new Date()).toISOString()}
13891
+ schemaVersion: 1
13892
+ status: failed
13893
+ ---
13894
+
13895
+ ## Summary unavailable
13896
+
13897
+ - reason: ${reason}
13898
+ `;
13899
+ fs18.writeFileSync(filePath, body, "utf8");
13900
+ return filePath;
13901
+ }
13902
+ async function summarizeArchivedSession(ctx) {
13903
+ const writeStubOnError = ctx.writeStubOnError ?? true;
13904
+ if (!fs18.existsSync(ctx.archiveDir)) {
13905
+ return Err21(new Error(`archive directory not found: ${ctx.archiveDir}`));
13906
+ }
13907
+ const corpus = readInputCorpus(ctx.archiveDir);
13908
+ if (corpus.trim().length === 0) {
13909
+ return Err21(new Error(`no summary input files found in ${ctx.archiveDir}`));
13910
+ }
13911
+ const inputBudgetTokens = ctx.config?.inputBudgetTokens ?? DEFAULT_INPUT_BUDGET_TOKENS;
13912
+ const truncated = truncateForBudget(corpus, inputBudgetTokens);
13913
+ const prompt = USER_PROMPT_PREAMBLE + truncated;
13914
+ const timeoutMs = ctx.config?.timeoutMs ?? DEFAULT_TIMEOUT_MS4;
13915
+ const analyzeOpts = {
13916
+ prompt,
13917
+ systemPrompt: SYSTEM_PROMPT,
13918
+ responseSchema: SessionSummarySchema,
13919
+ ...ctx.config?.model && { model: ctx.config.model }
13920
+ };
13921
+ let response;
13922
+ try {
13923
+ response = await Promise.race([
13924
+ ctx.provider.analyze(analyzeOpts),
13925
+ new Promise(
13926
+ (_, reject) => setTimeout(
13927
+ () => reject(new Error(`provider call timed out after ${timeoutMs}ms`)),
13928
+ timeoutMs
13929
+ )
13930
+ )
13931
+ ]);
13932
+ } catch (e) {
13933
+ const reason = e instanceof Error ? e.message : String(e);
13934
+ ctx.logger?.warn?.("session summary: provider call failed", { reason });
13935
+ let stubPath;
13936
+ if (writeStubOnError) {
13937
+ try {
13938
+ stubPath = writeStubMarkdown(ctx.archiveDir, reason);
13939
+ } catch {
13940
+ }
13941
+ }
13942
+ return Err21(
13943
+ new Error(`session summary failed: ${reason}` + (stubPath ? ` (stub: ${stubPath})` : ""))
13944
+ );
13945
+ }
13946
+ const parsed = SessionSummarySchema.safeParse(response.result);
13947
+ if (!parsed.success) {
13948
+ const reason = `schema validation failed: ${parsed.error.message}`;
13949
+ ctx.logger?.warn?.("session summary: invalid provider payload", { reason });
13950
+ if (writeStubOnError) {
13951
+ try {
13952
+ writeStubMarkdown(ctx.archiveDir, reason);
13953
+ } catch {
13954
+ }
13955
+ }
13956
+ return Err21(new Error(reason));
13957
+ }
13958
+ const meta = {
13959
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
13960
+ model: response.model,
13961
+ inputTokens: response.tokenUsage.inputTokens,
13962
+ outputTokens: response.tokenUsage.outputTokens,
13963
+ schemaVersion: 1
13964
+ };
13965
+ const filePath = path21.join(ctx.archiveDir, LLM_SUMMARY_FILE);
13966
+ const body = renderLlmSummaryMarkdown(parsed.data, meta);
13967
+ fs18.writeFileSync(filePath, body, "utf8");
13968
+ return Ok24({ summary: parsed.data, meta, filePath });
13969
+ }
13970
+ function isSummaryEnabled(config) {
13971
+ if (!config) return false;
13972
+ if (config.enabled === false) return false;
13973
+ return true;
13974
+ }
13975
+
13976
+ // src/sessions/archive-hooks.ts
13977
+ var defaultLogger = {
13978
+ warn: (msg, meta) => console.warn(`[sessions] ${msg}`, meta)
13979
+ };
13980
+ async function runSummaryStep(opts, logger, sessionId, archiveDir) {
13981
+ const enabled = isSummaryEnabled(opts.config?.summary) && opts.provider != null;
13982
+ if (!enabled || !opts.provider) return;
13983
+ const ctx = {
13984
+ archiveDir,
13985
+ provider: opts.provider,
13986
+ ...opts.config?.summary && { config: opts.config.summary },
13987
+ ...logger && { logger }
13988
+ };
13989
+ try {
13990
+ const result = await summarizeArchivedSession(ctx);
13991
+ if (!result.ok) {
13992
+ logger.warn?.("session summary: failed", {
13993
+ sessionId,
13994
+ error: result.error.message
13995
+ });
13996
+ }
13997
+ } catch (e) {
13998
+ logger.warn?.("session summary: threw", {
13999
+ sessionId,
14000
+ error: e instanceof Error ? e.message : String(e)
14001
+ });
14002
+ }
14003
+ }
14004
+ function runIndexStep(opts, logger, sessionId, archiveDir) {
14005
+ try {
14006
+ const idx = openSearchIndex(opts.projectPath);
14007
+ try {
14008
+ const result = indexSessionDirectory(idx, {
14009
+ sessionId,
14010
+ sessionDir: archiveDir,
14011
+ archived: true,
14012
+ projectPath: opts.projectPath,
14013
+ ...opts.config?.search?.indexedFileKinds && {
14014
+ fileKinds: opts.config.search.indexedFileKinds
14015
+ },
14016
+ ...opts.config?.search?.maxIndexBytesPerFile !== void 0 && {
14017
+ maxBytesPerBody: opts.config.search.maxIndexBytesPerFile
14018
+ }
14019
+ });
14020
+ if (result.docsWritten === 0) {
14021
+ logger.warn?.("session index: no docs written", { sessionId, archiveDir });
14022
+ }
14023
+ } finally {
14024
+ idx.close();
14025
+ }
14026
+ } catch (e) {
14027
+ logger.warn?.("session index: failed", {
14028
+ sessionId,
14029
+ error: e instanceof Error ? e.message : String(e)
14030
+ });
14031
+ }
14032
+ }
14033
+ function buildArchiveHooks(opts) {
14034
+ const logger = opts.logger ?? defaultLogger;
14035
+ return {
14036
+ async onArchived({ sessionId, archiveDir }) {
14037
+ await runSummaryStep(opts, logger, sessionId, archiveDir);
14038
+ runIndexStep(opts, logger, sessionId, archiveDir);
14039
+ }
14040
+ };
14041
+ }
11215
14042
  export {
11216
14043
  AnalysisArchive,
14044
+ BUILT_IN_TASKS,
11217
14045
  BackendRouter,
11218
14046
  ClaimManager,
14047
+ GateNotReadyError,
14048
+ GateRunError,
11219
14049
  InteractionQueue,
11220
14050
  LinearGraphQLStub,
11221
14051
  MAX_ATTEMPTS,
@@ -11224,10 +14054,16 @@ export {
11224
14054
  Orchestrator,
11225
14055
  OrchestratorBackendFactory,
11226
14056
  PRDetector,
14057
+ PromotionError,
11227
14058
  PromptRenderer,
11228
14059
  RETRY_DELAYS_MS,
11229
14060
  RoadmapTrackerAdapter,
14061
+ SinkConfigError,
14062
+ SinkRegistry,
14063
+ SlackSink,
14064
+ SqliteSearchIndex,
11230
14065
  StreamRecorder,
14066
+ TaskOutputStore,
11231
14067
  TokenStore,
11232
14068
  WebhookQueue,
11233
14069
  WorkflowLoader,
@@ -11235,31 +14071,49 @@ export {
11235
14071
  WorkspaceManager,
11236
14072
  applyEvent,
11237
14073
  artifactPresenceFromIssue,
14074
+ buildArchiveHooks,
11238
14075
  calculateRetryDelay,
11239
14076
  canDispatch,
11240
14077
  computeRateLimitDelay,
11241
14078
  createBackend,
11242
14079
  createEmptyState,
11243
14080
  detectScopeTier,
14081
+ emitProposalApproved,
14082
+ emitProposalCreated,
14083
+ emitProposalRejected,
11244
14084
  extractHighlights,
11245
14085
  extractTitlePrefix,
11246
14086
  getAvailableSlots,
11247
14087
  getDefaultConfig,
11248
14088
  getPerStateCount,
14089
+ indexSessionDirectory,
11249
14090
  isEligible,
14091
+ isSummaryEnabled,
11250
14092
  launchTUI,
11251
14093
  loadPublishedIndex,
11252
14094
  migrateAgentConfig,
14095
+ normalizeFts5Query,
14096
+ openSearchIndex,
14097
+ promote,
11253
14098
  reconcile,
14099
+ reindexFromArchive,
11254
14100
  renderAnalysisComment,
14101
+ renderLlmSummaryMarkdown,
11255
14102
  renderPRComment,
11256
14103
  resolveEscalationConfig,
11257
14104
  resolveOrchestratorId,
11258
14105
  routeIssue,
14106
+ runGate,
11259
14107
  savePublishedIndex,
14108
+ searchIndexPath,
11260
14109
  selectCandidates,
11261
14110
  sortCandidates,
14111
+ summarizeArchivedSession,
11262
14112
  syncMain,
11263
14113
  triageIssue,
11264
- validateWorkflowConfig
14114
+ truncateForBudget,
14115
+ validateCustomTasks,
14116
+ validateWorkflowConfig,
14117
+ wireNotificationSinks,
14118
+ wrapAsEnvelope
11265
14119
  };