@turmind/halo 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/1607-bf227fc5e4db45b7.js +1 -0
  3. package/admin-out/_next/static/chunks/1989-dab5d5c24b8f4c9f.js +1 -0
  4. package/admin-out/_next/static/chunks/5287.7ea08918388fa804.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-cca81a2ed69100f5.js +1 -0
  6. package/admin-out/_next/static/chunks/{webpack-7f8156dbbdb54868.js → webpack-9532ec7f1fa9a757.js} +1 -1
  7. package/admin-out/_next/static/css/8fc43fce83e5ce5d.css +1 -0
  8. package/admin-out/index.html +1 -1
  9. package/admin-out/index.txt +5 -5
  10. package/bundled-docs/design/a2a.md +5 -5
  11. package/bundled-docs/design/agent.md +1 -1
  12. package/bundled-docs/design/agentcore.md +3 -2
  13. package/bundled-docs/design/architecture.md +3 -3
  14. package/bundled-docs/design/background-dispatch.md +1 -1
  15. package/bundled-docs/design/canvas-extensions.md +5 -5
  16. package/bundled-docs/design/cron.md +77 -53
  17. package/bundled-docs/design/evolution.md +111 -39
  18. package/bundled-docs/design/node.md +30 -13
  19. package/bundled-docs/design/observability.md +12 -12
  20. package/bundled-docs/design/prompt-system.md +1 -1
  21. package/bundled-docs/design/relay.md +4 -4
  22. package/bundled-docs/design/session.md +14 -12
  23. package/bundled-docs/design/slack.md +1 -1
  24. package/bundled-docs/design/storage.md +14 -11
  25. package/bundled-docs/design/telegram.md +1 -1
  26. package/bundled-docs/design/ws.md +4 -4
  27. package/bundled-docs/dev/api.md +16 -14
  28. package/bundled-docs/dev/deploy.md +2 -1
  29. package/bundled-docs/dev/env.md +2 -2
  30. package/bundled-docs/dev/logging.md +1 -1
  31. package/bundled-docs/dev/tools.md +16 -30
  32. package/bundled-docs/guide/cli.md +9 -3
  33. package/bundled-docs/guide/delegation-and-access.md +1 -1
  34. package/bundled-docs/guide/node.md +86 -11
  35. package/bundled-docs/guide/workspace.md +1 -1
  36. package/bundled-docs/requirements/command.md +1 -1
  37. package/bundled-docs/requirements/editor.md +5 -2
  38. package/bundled-docs/requirements/settings.md +2 -2
  39. package/bundled-docs/requirements/terminal.md +1 -1
  40. package/bundled-docs/requirements/workspace.md +1 -1
  41. package/dist/evo-wrapper.js +913 -201
  42. package/dist/index.js +2670 -1786
  43. package/package.json +1 -1
  44. package/templates/agents/__apply_agent__/AGENT.md +10 -6
  45. package/templates/agents/__evo_agent__/AGENT.md +15 -4
  46. package/templates/agents/__score__/AGENT.md +4 -0
  47. package/templates/skills/cron/SKILL.md +37 -10
  48. package/templates/skills/cron/manage-cron.py +90 -15
  49. package/admin-out/_next/static/chunks/1101-d2f818895a3aa32e.js +0 -1
  50. package/admin-out/_next/static/chunks/1989-3aeeefebf77a40f3.js +0 -1
  51. package/admin-out/_next/static/chunks/5058.8514aa6d6a947cce.js +0 -1
  52. package/admin-out/_next/static/chunks/app/page-dac9e957de1eb740.js +0 -1
  53. package/admin-out/_next/static/css/6da86a077e119a6c.css +0 -1
  54. /package/admin-out/_next/static/{nOzfkh9Uo1Cp5TFa-C4gG → Rs9tIYSSITPLxv274DU9f}/_buildManifest.js +0 -0
  55. /package/admin-out/_next/static/{nOzfkh9Uo1Cp5TFa-C4gG → Rs9tIYSSITPLxv274DU9f}/_ssgManifest.js +0 -0
@@ -1,12 +1,12 @@
1
1
  // ../server/dist/evolution/evo-wrapper.js
2
- import fs9 from "node:fs";
3
- import path12 from "node:path";
2
+ import fs11 from "node:fs";
3
+ import path13 from "node:path";
4
4
  import { spawn as spawn2 } from "node:child_process";
5
5
  import { homedir as homedir6 } from "node:os";
6
6
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
- import { eq, and } from "drizzle-orm";
9
- import YAML6 from "yaml";
8
+ import { eq as eq3, and as and3, inArray as inArray2 } from "drizzle-orm";
9
+ import YAML7 from "yaml";
10
10
 
11
11
  // ../server/dist/db/evo-db.js
12
12
  import Database from "better-sqlite3";
@@ -29,6 +29,12 @@ function runMigrations(sqlite, migrations) {
29
29
  })();
30
30
  }
31
31
  }
32
+ function addColumnIfMissing(sqlite, table, column, decl) {
33
+ const cols = sqlite.prepare(`PRAGMA table_info(${table})`).all();
34
+ if (cols.some((c) => c.name === column))
35
+ return;
36
+ sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
37
+ }
32
38
 
33
39
  // ../server/dist/db/evo-db.js
34
40
  var evolutionRuns = sqliteTable("evolution_runs", {
@@ -48,10 +54,24 @@ var evolutionRuns = sqliteTable("evolution_runs", {
48
54
  * Bumped on each `pending → running` claim. When it crosses the configured
49
55
  * retry budget, the ticker stops re-claiming after timeout/failure. */
50
56
  attempts: integer("attempts").notNull().default(0),
57
+ /** `attempts` when the latest reviewer retry reset the row. The retry
58
+ * budget is `attempts - attempts_base`; attempts itself never goes back
59
+ * down, so a stale wrapper's claim (ownsRun) can't match a later one. */
60
+ attemptsBase: integer("attempts_base").notNull().default(0),
51
61
  /** When archive job zipped the artifacts and deleted the run dir. Null
52
62
  * while the run is active. Rows with archived_at >= 30 days ago are
53
63
  * candidates for full deletion (zip + db row). */
54
- archivedAt: integer("archived_at")
64
+ archivedAt: integer("archived_at"),
65
+ /** The draft carries a `## Larger observation` section (patch.md body or
66
+ * .skip.md) — set by the wrapper after phase A so the admin list can flag
67
+ * and filter it without reading every run dir. */
68
+ hasObservation: integer("has_observation", { mode: "boolean" }).notNull().default(false),
69
+ /** Access level of the source session at enqueue ('full' | 'workspace' |
70
+ * 'readonly'), or 'webhook' for a session that took webhook events. Only
71
+ * a full source may be auto-applied (L2/L3): a lesser session's (or an
72
+ * outside payload's) conversation must not rewrite prompts without a
73
+ * reviewer. Pre-existing rows read null → never auto-applied. */
74
+ sourceAccess: text("source_access")
55
75
  });
56
76
  var evolutionApplies = sqliteTable("evolution_applies", {
57
77
  id: text("id").primaryKey(),
@@ -66,7 +86,13 @@ var evolutionApplies = sqliteTable("evolution_applies", {
66
86
  completedAt: integer("completed_at"),
67
87
  failureReason: text("failure_reason"),
68
88
  attempts: integer("attempts").notNull().default(0),
69
- archivedAt: integer("archived_at")
89
+ archivedAt: integer("archived_at"),
90
+ /** 'reviewer' (Approve button) | 'auto' (L2/L3 auto-apply, evolution/auto-apply.ts). */
91
+ approvedBy: text("approved_by").notNull().default("reviewer"),
92
+ /** When the source session got the one-line "auto-applied" notice (ticker
93
+ * notifyAutoApplied). Null = not yet sent; only auto applies are notified. */
94
+ notifiedAt: integer("notified_at"),
95
+ rolledBackAt: integer("rolled_back_at")
70
96
  });
71
97
  var CREATE_SQL = `
72
98
  CREATE TABLE IF NOT EXISTS evolution_runs (
@@ -83,7 +109,10 @@ CREATE TABLE IF NOT EXISTS evolution_runs (
83
109
  applied_at INTEGER,
84
110
  failure_reason TEXT,
85
111
  attempts INTEGER NOT NULL DEFAULT 0,
86
- archived_at INTEGER
112
+ archived_at INTEGER,
113
+ has_observation INTEGER NOT NULL DEFAULT 0,
114
+ source_access TEXT,
115
+ attempts_base INTEGER NOT NULL DEFAULT 0
87
116
  );
88
117
  CREATE INDEX IF NOT EXISTS idx_evo_runs_status ON evolution_runs(status, created_at);
89
118
  CREATE INDEX IF NOT EXISTS idx_evo_runs_ws ON evolution_runs(workspace_path);
@@ -101,19 +130,39 @@ CREATE TABLE IF NOT EXISTS evolution_applies (
101
130
  completed_at INTEGER,
102
131
  failure_reason TEXT,
103
132
  attempts INTEGER NOT NULL DEFAULT 0,
104
- archived_at INTEGER
133
+ archived_at INTEGER,
134
+ approved_by TEXT NOT NULL DEFAULT 'reviewer',
135
+ notified_at INTEGER,
136
+ rolled_back_at INTEGER
105
137
  );
106
138
  CREATE INDEX IF NOT EXISTS idx_evo_applies_status ON evolution_applies(status, created_at);
107
139
  CREATE INDEX IF NOT EXISTS idx_evo_applies_ws ON evolution_applies(workspace_path);
108
140
  CREATE INDEX IF NOT EXISTS idx_evo_applies_archived ON evolution_applies(archived_at);
109
141
  `;
142
+ var addV1Columns = (s) => {
143
+ addColumnIfMissing(s, "evolution_applies", "approved_by", "TEXT NOT NULL DEFAULT 'reviewer'");
144
+ addColumnIfMissing(s, "evolution_applies", "notified_at", "INTEGER");
145
+ addColumnIfMissing(s, "evolution_applies", "rolled_back_at", "INTEGER");
146
+ addColumnIfMissing(s, "evolution_runs", "has_observation", "INTEGER NOT NULL DEFAULT 0");
147
+ addColumnIfMissing(s, "evolution_runs", "source_access", "TEXT");
148
+ };
149
+ var EVO_MIGRATIONS = [
150
+ addV1Columns,
151
+ // v2: retry keeps attempts monotonic and records where the round started.
152
+ // Re-runs v1's adds (idempotent): dev builds stamped user_version 1 before
153
+ // v1 had all its columns (source_access).
154
+ (s) => {
155
+ addV1Columns(s);
156
+ addColumnIfMissing(s, "evolution_runs", "attempts_base", "INTEGER NOT NULL DEFAULT 0");
157
+ }
158
+ ];
110
159
  function createEvoDb(globalDir2) {
111
160
  fs.mkdirSync(globalDir2, { recursive: true });
112
161
  const dbPath = path.join(globalDir2, "evo.db");
113
162
  const sqlite = new Database(dbPath);
114
163
  sqlite.pragma("journal_mode = WAL");
115
164
  sqlite.exec(CREATE_SQL);
116
- runMigrations(sqlite, []);
165
+ runMigrations(sqlite, EVO_MIGRATIONS);
117
166
  return drizzle(sqlite, { schema: { evolutionRuns, evolutionApplies } });
118
167
  }
119
168
  var _evoDb = null;
@@ -211,8 +260,6 @@ function cleanChildEnv() {
211
260
  const env = { ...process.env };
212
261
  delete env.HALO_PASSWORD;
213
262
  delete env.HALO_JWT_SECRET;
214
- delete env.HALO_NODE_RELAY_URL;
215
- delete env.HALO_NODE_RELAY_TOKEN;
216
263
  delete env.HALO_NODE_TOKEN;
217
264
  for (const key of settingsOtelEnvKeys)
218
265
  delete env[key];
@@ -856,11 +903,12 @@ function generalSection() {
856
903
  // observability (OpenTelemetry). Read once at boot by observability/otel.ts
857
904
  // and mapped onto the standard OTEL_* env vars — hence "takes effect on
858
905
  // restart" everywhere below. Children don't inherit those settings-derived
859
- // vars (child-env.ts), so each cron / evo cli run re-reads settings.
860
- { key: "observability.endpoint", type: "string", globalOnly: true, restartRequired: true, description: "OTLP base URL of an OpenTelemetry collector (e.g. http://localhost:4318). Traces, metrics and logs are all exported there over OTLP http/protobuf \u2014 http:// or https://, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) is not supported. Empty = observability off. Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "OpenTelemetry collector \u7684 OTLP \u57FA\u7840\u5730\u5740\uFF08\u5982 http://localhost:4318\uFF09\uFF0Ctrace / metrics / \u65E5\u5FD7\u4E09\u7C7B\u4FE1\u53F7\u90FD\u4EE5 OTLP http/protobuf \u53D1\u5230\u8FD9\u91CC\u3002http:// \u6216 https:// \u5747\u53EF\uFF0C\u7AEF\u53E3\u987B\u662F collector \u7684 OTLP/HTTP \u7AEF\u53E3\uFF08\u9ED8\u8BA4 4318\uFF09\uFF0C\u4E0D\u652F\u6301 gRPC\uFF084317\uFF09\u3002\u7559\u7A7A = \u5173\u95ED\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "" },
861
- { key: "observability.service_name", type: "string", globalOnly: true, restartRequired: true, description: "OTel resource service.name reported for this server. Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "\u672C\u670D\u52A1\u5728 OTel \u4E2D\u4E0A\u62A5\u7684 service.name\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "halo" },
862
- { key: "observability.headers", type: "string", secret: true, globalOnly: true, restartRequired: true, description: "Extra headers for every OTLP request, comma-separated k=v (e.g. authorization=Bearer \u2026). Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "\u9644\u52A0\u5230\u6BCF\u4E2A OTLP \u8BF7\u6C42\u7684 header\uFF0C\u9017\u53F7\u5206\u9694\u7684 k=v\uFF08\u5982 authorization=Bearer \u2026\uFF09\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "" },
863
- { key: "observability.capture_content", type: "boolean", globalOnly: true, restartRequired: true, description: "Put prompt / completion / tool argument and result text on spans (gen_ai.*.messages etc.). Off = only metadata (model, tokens, latency, tool names). Env OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT (true / false) overrides. Takes effect on server restart; cron / evolution runs use the new value on their next run.", description_zh: "\u662F\u5426\u628A\u63D0\u793A\u8BCD / \u6A21\u578B\u8F93\u51FA / \u5DE5\u5177\u53C2\u6570\u4E0E\u7ED3\u679C\u6B63\u6587\u5199\u5230 span \u4E0A\uFF08gen_ai.*.messages \u7B49\uFF09\u3002\u5173 = \u53EA\u4E0A\u62A5\u5143\u6570\u636E\uFF08\u6A21\u578B\u3001token\u3001\u8017\u65F6\u3001\u5DE5\u5177\u540D\uFF09\u3002\u73AF\u5883\u53D8\u91CF OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT\uFF08true / false\uFF09\u4F18\u5148\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF1Bcron / evolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "false" },
906
+ // vars (child-env.ts), so each evo cli run re-reads settings; cron runs in
907
+ // the server process and follows its boot-time values.
908
+ { key: "observability.endpoint", type: "string", globalOnly: true, restartRequired: true, description: "OTLP base URL of an OpenTelemetry collector (e.g. http://localhost:4318). Traces, metrics and logs are all exported there over OTLP http/protobuf \u2014 http:// or https://, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) is not supported. Empty = observability off. Takes effect on server restart (cron runs included \u2014 they run in the server); evolution runs use the new value on their next run.", description_zh: "OpenTelemetry collector \u7684 OTLP \u57FA\u7840\u5730\u5740\uFF08\u5982 http://localhost:4318\uFF09\uFF0Ctrace / metrics / \u65E5\u5FD7\u4E09\u7C7B\u4FE1\u53F7\u90FD\u4EE5 OTLP http/protobuf \u53D1\u5230\u8FD9\u91CC\u3002http:// \u6216 https:// \u5747\u53EF\uFF0C\u7AEF\u53E3\u987B\u662F collector \u7684 OTLP/HTTP \u7AEF\u53E3\uFF08\u9ED8\u8BA4 4318\uFF09\uFF0C\u4E0D\u652F\u6301 gRPC\uFF084317\uFF09\u3002\u7559\u7A7A = \u5173\u95ED\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF08cron \u4EFB\u52A1\u5728\u670D\u52A1\u8FDB\u7A0B\u5185\u8FD0\u884C\uFF0C\u540C\u6837\u9700\u91CD\u542F\uFF09\uFF1Bevolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "" },
909
+ { key: "observability.service_name", type: "string", globalOnly: true, restartRequired: true, description: "OTel resource service.name reported for this server. Takes effect on server restart (cron runs included \u2014 they run in the server); evolution runs use the new value on their next run.", description_zh: "\u672C\u670D\u52A1\u5728 OTel \u4E2D\u4E0A\u62A5\u7684 service.name\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF08cron \u4EFB\u52A1\u5728\u670D\u52A1\u8FDB\u7A0B\u5185\u8FD0\u884C\uFF0C\u540C\u6837\u9700\u91CD\u542F\uFF09\uFF1Bevolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "halo" },
910
+ { key: "observability.headers", type: "string", secret: true, globalOnly: true, restartRequired: true, description: "Extra headers for every OTLP request, comma-separated k=v (e.g. authorization=Bearer \u2026). Takes effect on server restart (cron runs included \u2014 they run in the server); evolution runs use the new value on their next run.", description_zh: "\u9644\u52A0\u5230\u6BCF\u4E2A OTLP \u8BF7\u6C42\u7684 header\uFF0C\u9017\u53F7\u5206\u9694\u7684 k=v\uFF08\u5982 authorization=Bearer \u2026\uFF09\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF08cron \u4EFB\u52A1\u5728\u670D\u52A1\u8FDB\u7A0B\u5185\u8FD0\u884C\uFF0C\u540C\u6837\u9700\u91CD\u542F\uFF09\uFF1Bevolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "" },
911
+ { key: "observability.capture_content", type: "boolean", globalOnly: true, restartRequired: true, description: "Put prompt / completion / tool argument and result text on spans (gen_ai.*.messages etc.). Off = only metadata (model, tokens, latency, tool names). Env OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT (true / false) overrides. Takes effect on server restart (cron runs included \u2014 they run in the server); evolution runs use the new value on their next run.", description_zh: "\u662F\u5426\u628A\u63D0\u793A\u8BCD / \u6A21\u578B\u8F93\u51FA / \u5DE5\u5177\u53C2\u6570\u4E0E\u7ED3\u679C\u6B63\u6587\u5199\u5230 span \u4E0A\uFF08gen_ai.*.messages \u7B49\uFF09\u3002\u5173 = \u53EA\u4E0A\u62A5\u5143\u6570\u636E\uFF08\u6A21\u578B\u3001token\u3001\u8017\u65F6\u3001\u5DE5\u5177\u540D\uFF09\u3002\u73AF\u5883\u53D8\u91CF OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT\uFF08true / false\uFF09\u4F18\u5148\u3002\u91CD\u542F\u670D\u52A1\u540E\u751F\u6548\uFF08cron \u4EFB\u52A1\u5728\u670D\u52A1\u8FDB\u7A0B\u5185\u8FD0\u884C\uFF0C\u540C\u6837\u9700\u91CD\u542F\uFF09\uFF1Bevolution \u4EFB\u52A1\u4E0B\u6B21\u8FD0\u884C\u5373\u7528\u65B0\u503C\u3002", default: "false" },
864
912
  // Notebook kernels (jupyter/manager.ts). Global-only: kernels run
865
913
  // unsandboxed as the service user, like the terminal. Read per spawn.
866
914
  { key: "jupyter.enabled", type: "boolean", globalOnly: true, description: "Allow notebook execution: the ipynb viewer can start kernels through a local Jupyter server (runs as the Halo service user, unsandboxed, like the terminal).", description_zh: "\u5141\u8BB8\u8FD0\u884C notebook\uFF1Aipynb \u67E5\u770B\u5668\u53EF\u901A\u8FC7\u672C\u673A Jupyter server \u542F\u52A8\u5185\u6838\uFF08\u4EE5 Halo \u670D\u52A1\u7528\u6237\u8EAB\u4EFD\u8FD0\u884C\u3001\u4E0D\u8FDB\u6C99\u7BB1\uFF0C\u4E0E\u7EC8\u7AEF\u76F8\u540C\uFF09\u3002", default: "true" },
@@ -872,7 +920,8 @@ function generalSection() {
872
920
  // self-evolution (see plans/self-evolution.md). All evo settings are
873
921
  // global-only — the worker / ticker live in the server process and
874
922
  // would have to reload mid-flight if a workspace could override them.
875
- { key: "evolution.level", type: "enum", options: ["L0", "L1"], globalOnly: true, description: "Self-evolution capability ladder. L0: manual only \u2014 drafts via /evo, manual approve/reject. L1: L0 plus automatic drafting on pre-compact. (Queued runs always execute regardless of level; the level only gates how they get enqueued.)", description_zh: "\u81EA\u6211\u8FDB\u5316\u7B49\u7EA7\u3002L0\uFF1A\u4EC5\u624B\u52A8\u2014\u2014\u901A\u8FC7 /evo \u8D77\u8349\uFF0C\u624B\u52A8 approve/reject\u3002L1\uFF1A\u5728 L0 \u57FA\u7840\u4E0A\uFF0C\u538B\u7F29\u524D\uFF08pre-compact\uFF09\u81EA\u52A8\u8D77\u8349\u3002\uFF08\u5DF2\u6392\u961F\u7684 run \u59CB\u7EC8\u6267\u884C\uFF0Clevel \u53EA\u51B3\u5B9A\u5982\u4F55\u5165\u961F\u3002\uFF09", default: "L0" },
923
+ { key: "evolution.level", type: "enum", options: ["L0", "L1", "L2", "L3"], globalOnly: true, description: "Self-evolution capability ladder. L0: manual only \u2014 drafts via /evo, manual approve/reject. L1: L0 plus automatic drafting on pre-compact. L2: L1 plus auto-applying prompt-only changes that score above evolution.auto_apply_threshold. L3: L2 plus auto-applying capability changes (agent.yaml tools / model, skill invocation). Auto-applied changes still pass the regression check and can be rolled back from the Evolution tab. Queued runs always execute regardless of level.", description_zh: "\u81EA\u6211\u8FDB\u5316\u7B49\u7EA7\u3002L0\uFF1A\u4EC5\u624B\u52A8\u2014\u2014\u901A\u8FC7 /evo \u8D77\u8349\uFF0C\u624B\u52A8 approve/reject\u3002L1\uFF1A\u5728 L0 \u57FA\u7840\u4E0A\uFF0C\u538B\u7F29\u524D\uFF08pre-compact\uFF09\u81EA\u52A8\u8D77\u8349\u3002L2\uFF1A\u5728 L1 \u57FA\u7840\u4E0A\uFF0C\u5BF9\u8BC4\u5206\u8D85\u8FC7 evolution.auto_apply_threshold \u7684\u7EAF\u63D0\u793A\u8BCD\u6539\u52A8\u81EA\u52A8\u5E94\u7528\u3002L3\uFF1A\u5728 L2 \u57FA\u7840\u4E0A\uFF0C\u5BF9\u80FD\u529B\u7C7B\u6539\u52A8\uFF08agent.yaml \u7684\u5DE5\u5177/\u6A21\u578B\u3001skill \u8C03\u7528\u65B9\u5F0F\uFF09\u4E5F\u81EA\u52A8\u5E94\u7528\u3002\u81EA\u52A8\u5E94\u7528\u7167\u6837\u8FC7\u56DE\u5F52\u68C0\u67E5\uFF0C\u53EF\u5728 Evolution \u9875\u56DE\u6EDA\u3002\u5DF2\u6392\u961F\u7684 run \u4E0D\u53D7\u7B49\u7EA7\u5F71\u54CD\uFF0C\u59CB\u7EC8\u6267\u884C\u3002", default: "L0" },
924
+ { key: "evolution.auto_apply_threshold", type: "int", min: 0, max: 100, globalOnly: true, description: "Minimum average score (0\u2013100) for auto-apply at L2/L3. Also required: confidence high, behavior \u2265 70, lint 100, all sandbox checks passed. Ignored at L0/L1.", description_zh: "L2/L3 \u81EA\u52A8\u5E94\u7528\u8981\u6C42\u7684\u6700\u4F4E\u5E73\u5747\u5206\uFF080\u2013100\uFF09\u3002\u53E6\u9700\u540C\u65F6\u6EE1\u8DB3\uFF1Aconfidence \u4E3A high\u3001behavior \u2265 70\u3001lint \u4E3A 100\u3001\u6C99\u7BB1\u68C0\u67E5\u5168\u90E8\u901A\u8FC7\u3002L0/L1 \u4E0B\u5FFD\u7565\u3002", default: "80" },
876
925
  { key: "evolution.max_concurrent_run", type: "int", globalOnly: true, description: "Max concurrent evo run wrappers (drafting patches).", description_zh: "\u540C\u65F6\u8FD0\u884C\u7684 evo run wrapper \u4E0A\u9650\uFF08\u8D77\u8349 patch\uFF09\u3002", default: "1" },
877
926
  { key: "evolution.max_concurrent_apply", type: "int", globalOnly: true, description: "Max concurrent evo apply wrappers (merging patches).", description_zh: "\u540C\u65F6\u8FD0\u884C\u7684 evo apply wrapper \u4E0A\u9650\uFF08\u5408\u5E76 patch\uFF09\u3002", default: "1" },
878
927
  { key: "evolution.run_timeout_minutes", type: "int", globalOnly: true, description: "Heartbeat timeout for run wrappers. The wrapper writes a heartbeat every 60s for as long as it lives, so exceeding this means the wrapper process died \u2014 the row is requeued (or marked timeout past max_attempts). This is dead-wrapper detection latency, NOT a budget for slow models; the per-phase model budget is the wrapper-internal cap (draft 60min, other phases 45min).", description_zh: "run wrapper \u5FC3\u8DF3\u8D85\u65F6\uFF08\u5206\u949F\uFF09\u3002wrapper \u5B58\u6D3B\u671F\u95F4\u6BCF 60 \u79D2\u5199\u4E00\u6B21\u5FC3\u8DF3\uFF0C\u8D85\u65F6\u5373\u89C6\u4E3A wrapper \u8FDB\u7A0B\u5DF2\u6B7B\uFF0C\u4EFB\u52A1\u91CD\u6392\uFF08\u8D85\u8FC7 max_attempts \u5219\u6807 timeout\uFF09\u3002\u8FD9\u662F\u6B7B wrapper \u7684\u68C0\u6D4B\u5EF6\u8FDF\uFF0C\u4E0D\u662F\u6162\u6A21\u578B\u7684\u8FD0\u884C\u9884\u7B97\u2014\u2014\u5355\u9636\u6BB5\u6A21\u578B\u9884\u7B97\u662F wrapper \u5185\u90E8\u7684\u4E0A\u9650\uFF08\u8D77\u8349 60 \u5206\u949F\uFF0C\u5176\u4F59\u9636\u6BB5 45 \u5206\u949F\uFF09\u3002", default: "12" },
@@ -1254,6 +1303,13 @@ function systemString(envKey, configPath, fallback) {
1254
1303
  return cv;
1255
1304
  return fallback;
1256
1305
  }
1306
+ var EVO_LEVELS = ["L0", "L1", "L2", "L3"];
1307
+ function parseEvoLevel(raw) {
1308
+ return EVO_LEVELS.includes(raw) ? raw : "L0";
1309
+ }
1310
+ function evoLevelRank(level) {
1311
+ return EVO_LEVELS.indexOf(level);
1312
+ }
1257
1313
  var config = {
1258
1314
  // Goal entry points are retired, not the runtime. Internal/global-only, no UI:
1259
1315
  // set general.goal_mode_enabled in ~/.halo/secrets/settings.yaml, then restart.
@@ -1445,8 +1501,12 @@ var config = {
1445
1501
  // in settings.yaml take effect on the next ticker run without restart.
1446
1502
  evolution: {
1447
1503
  get level() {
1448
- const raw = settingsStr("general.evolution.level", "L0");
1449
- return raw === "L1" ? "L1" : "L0";
1504
+ return parseEvoLevel(settingsStr("general.evolution.level", "L0"));
1505
+ },
1506
+ // Minimum average score for L2/L3 auto-apply (see evolution/auto-apply.ts
1507
+ // for the extra fixed floors).
1508
+ get autoApplyThreshold() {
1509
+ return settingsInt("general.evolution.auto_apply_threshold", 80);
1450
1510
  },
1451
1511
  get maxConcurrentRun() {
1452
1512
  return settingsInt("general.evolution.max_concurrent_run", 1);
@@ -1477,6 +1537,477 @@ var config = {
1477
1537
  }
1478
1538
  };
1479
1539
 
1540
+ // ../server/dist/evolution/auto-apply.js
1541
+ import fs9 from "node:fs";
1542
+ import path12 from "node:path";
1543
+ import YAML6 from "yaml";
1544
+ var AUTO_APPLY_FILE = "auto-apply.json";
1545
+ var OVERRIDE_DIRS = /* @__PURE__ */ new Set(["agents", "skills", "prompts"]);
1546
+ function isDir(p) {
1547
+ try {
1548
+ return fs9.statSync(p).isDirectory();
1549
+ } catch {
1550
+ return false;
1551
+ }
1552
+ }
1553
+ function readOrNull(p) {
1554
+ try {
1555
+ return fs9.readFileSync(p);
1556
+ } catch {
1557
+ return null;
1558
+ }
1559
+ }
1560
+ function walkFiles(root, rel, out = []) {
1561
+ let st;
1562
+ try {
1563
+ st = fs9.statSync(path12.join(root, rel));
1564
+ } catch {
1565
+ return out;
1566
+ }
1567
+ if (st.isFile())
1568
+ out.push(rel);
1569
+ else if (st.isDirectory()) {
1570
+ for (const e of fs9.readdirSync(path12.join(root, rel)))
1571
+ walkFiles(root, path12.join(rel, e), out);
1572
+ }
1573
+ return out;
1574
+ }
1575
+ var toPosix = (rel) => rel.split(path12.sep).join("/");
1576
+ function isInternalAgentDir(dir) {
1577
+ const raw = readOrNull(path12.join(dir, "agent.yaml"));
1578
+ return raw !== null && parseYamlLoose(raw.toString("utf-8"))?.internal === true;
1579
+ }
1580
+ function liveBaseline(dirs, rel) {
1581
+ const parts = rel.split(path12.sep);
1582
+ if (!OVERRIDE_DIRS.has(parts[0]) || parts.length < 3)
1583
+ return { file: path12.join(dirs.mainHalo, rel), newDir: false };
1584
+ const dirRel = path12.join(parts[0], parts[1]);
1585
+ const internal = (root) => parts[0] === "agents" && isInternalAgentDir(path12.join(root, dirRel));
1586
+ if (isDir(path12.join(dirs.mainHalo, dirRel)))
1587
+ return { file: path12.join(dirs.mainHalo, rel), newDir: false, internalAgent: internal(dirs.mainHalo) };
1588
+ if (isDir(path12.join(dirs.globalHalo, dirRel)))
1589
+ return { file: path12.join(dirs.globalHalo, rel), newDir: false, globalDir: dirRel, internalAgent: internal(dirs.globalHalo) };
1590
+ return { file: null, newDir: true };
1591
+ }
1592
+ function fileChange(rel, base, after) {
1593
+ const before = base.file ? readOrNull(base.file) : null;
1594
+ if (before && before.equals(after))
1595
+ return null;
1596
+ return {
1597
+ rel: toPosix(rel),
1598
+ before: before?.toString("utf-8") ?? null,
1599
+ after: after.toString("utf-8"),
1600
+ newDir: base.newDir,
1601
+ ...base.internalAgent ? { internalAgent: true } : {}
1602
+ };
1603
+ }
1604
+ function publishChanges(dirs, files) {
1605
+ const out = [];
1606
+ const globalBacked = /* @__PURE__ */ new Set();
1607
+ for (const { rel, full } of files) {
1608
+ const base = liveBaseline(dirs, rel);
1609
+ if (base.globalDir)
1610
+ globalBacked.add(base.globalDir);
1611
+ const c = fileChange(rel, base, fs9.readFileSync(full));
1612
+ if (c)
1613
+ out.push(c);
1614
+ }
1615
+ for (const dirRel of globalBacked) {
1616
+ for (const rel of walkFiles(dirs.globalHalo, dirRel)) {
1617
+ if (fs9.existsSync(path12.join(dirs.sandboxHalo, rel)))
1618
+ continue;
1619
+ out.push({ rel: toPosix(rel), before: fs9.readFileSync(path12.join(dirs.globalHalo, rel), "utf-8"), after: null, newDir: false });
1620
+ }
1621
+ }
1622
+ return out;
1623
+ }
1624
+ function collectChanges(dirs) {
1625
+ const out = [];
1626
+ const seen = /* @__PURE__ */ new Set();
1627
+ const globalBacked = /* @__PURE__ */ new Set();
1628
+ for (const top of dirs.entries) {
1629
+ for (const rel of walkFiles(dirs.sandboxHalo, top)) {
1630
+ seen.add(rel);
1631
+ const base = liveBaseline(dirs, rel);
1632
+ if (base.globalDir)
1633
+ globalBacked.add(base.globalDir);
1634
+ const c = fileChange(rel, base, fs9.readFileSync(path12.join(dirs.sandboxHalo, rel)));
1635
+ if (c)
1636
+ out.push(c);
1637
+ }
1638
+ for (const rel of walkFiles(dirs.mainHalo, top)) {
1639
+ if (seen.has(rel))
1640
+ continue;
1641
+ out.push({ rel: toPosix(rel), before: fs9.readFileSync(path12.join(dirs.mainHalo, rel), "utf-8"), after: null, newDir: false });
1642
+ }
1643
+ }
1644
+ for (const dirRel of globalBacked) {
1645
+ for (const rel of walkFiles(dirs.globalHalo, dirRel)) {
1646
+ if (seen.has(rel))
1647
+ continue;
1648
+ out.push({ rel: toPosix(rel), before: fs9.readFileSync(path12.join(dirs.globalHalo, rel), "utf-8"), after: null, newDir: false });
1649
+ }
1650
+ }
1651
+ return out;
1652
+ }
1653
+ var PROMPT_SCOPES = /* @__PURE__ */ new Set(["bootstrap", "all", "root"]);
1654
+ var EGRESS_TOOLS = /* @__PURE__ */ new Set(["shell_exec", "web_fetch", "relay_send", "a2a_send"]);
1655
+ var isEgressTool = (t) => EGRESS_TOOLS.has(t) || t.startsWith("node_");
1656
+ var AGENT_CAPABILITY_KEYS = /* @__PURE__ */ new Set(["description", "system_prompt", "context"]);
1657
+ var MODEL_CAPABILITY_KEYS = /* @__PURE__ */ new Set(["id", "maxTokens", "promptCaching", "thinking", "verbosity"]);
1658
+ var SKILL_PROMPT_KEYS = /* @__PURE__ */ new Set(["name", "description"]);
1659
+ var SKILL_CAPABILITY_KEYS = /* @__PURE__ */ new Set(["disable-model-invocation", "disableModelInvocation", "user-invocable", "userInvocable"]);
1660
+ var manual = (rel, reason) => ({ rel, category: "manual", reason });
1661
+ function same(a, b) {
1662
+ const canon = (v) => {
1663
+ if (Array.isArray(v))
1664
+ return v.map(canon);
1665
+ if (v && typeof v === "object") {
1666
+ return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])]));
1667
+ }
1668
+ return v;
1669
+ };
1670
+ return JSON.stringify(canon(a)) === JSON.stringify(canon(b));
1671
+ }
1672
+ function asRecord(v) {
1673
+ if (v === void 0 || v === null)
1674
+ return {};
1675
+ return typeof v === "object" && !Array.isArray(v) ? v : null;
1676
+ }
1677
+ function parseYamlRecord(text2) {
1678
+ try {
1679
+ return asRecord(YAML6.parse(text2));
1680
+ } catch {
1681
+ return null;
1682
+ }
1683
+ }
1684
+ function classifyAgentYaml(rel, before, after) {
1685
+ const a = parseYamlRecord(before);
1686
+ const b = parseYamlRecord(after);
1687
+ if (!a || !b)
1688
+ return manual(rel, "agent_yaml_unparsable");
1689
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) {
1690
+ if (same(a[key], b[key]) || AGENT_CAPABILITY_KEYS.has(key))
1691
+ continue;
1692
+ if (key === "tools") {
1693
+ if (b.tools !== void 0 && !Array.isArray(b.tools) || a.tools !== void 0 && !Array.isArray(a.tools)) {
1694
+ return manual(rel, "agent_yaml:tools");
1695
+ }
1696
+ const had = new Set((a.tools ?? []).map(String));
1697
+ const egress = (b.tools ?? []).map(String).find((t) => !had.has(t) && isEgressTool(t));
1698
+ if (egress)
1699
+ return manual(rel, `agent_yaml:tools+${egress}`);
1700
+ continue;
1701
+ }
1702
+ if (key === "skills") {
1703
+ if (b.skills !== void 0 && !Array.isArray(b.skills) || a.skills !== void 0 && !Array.isArray(a.skills)) {
1704
+ return manual(rel, "agent_yaml:skills");
1705
+ }
1706
+ const had = new Set((a.skills ?? []).map(String));
1707
+ const added = (b.skills ?? []).map(String).find((s) => !had.has(s));
1708
+ if (added)
1709
+ return manual(rel, `agent_yaml:skills+${added}`);
1710
+ continue;
1711
+ }
1712
+ if (key === "model") {
1713
+ const ma = asRecord(a.model);
1714
+ const mb = asRecord(b.model);
1715
+ if (!ma || !mb)
1716
+ return manual(rel, "agent_yaml:model");
1717
+ for (const mk of /* @__PURE__ */ new Set([...Object.keys(ma), ...Object.keys(mb)])) {
1718
+ if (!same(ma[mk], mb[mk]) && !MODEL_CAPABILITY_KEYS.has(mk))
1719
+ return manual(rel, `agent_yaml:model.${mk}`);
1720
+ }
1721
+ continue;
1722
+ }
1723
+ return manual(rel, `agent_yaml:${key}`);
1724
+ }
1725
+ return { rel, category: "capability" };
1726
+ }
1727
+ function parseYamlLoose(text2) {
1728
+ let v;
1729
+ try {
1730
+ v = YAML6.parse(text2);
1731
+ } catch {
1732
+ return void 0;
1733
+ }
1734
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1735
+ }
1736
+ function splitSkillMd(raw) {
1737
+ const m = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
1738
+ if (!m)
1739
+ return { fm: {}, body: raw.trim() };
1740
+ const block = m[1];
1741
+ const fm = parseYamlLoose(block) ?? parseYamlLoose(block.replace(/^(name|description|command):.*$/gm, ""));
1742
+ if (!fm)
1743
+ return null;
1744
+ for (const key of ["name", "description", "command"]) {
1745
+ const lm = block.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
1746
+ if (lm)
1747
+ fm[key] = lm[1].trim();
1748
+ else
1749
+ delete fm[key];
1750
+ }
1751
+ return { fm, body: raw.slice(m[0].length).trim() };
1752
+ }
1753
+ var normAccess = (v) => {
1754
+ const t = typeof v === "string" ? v.trim() : "";
1755
+ return t === "full" || t === "workspace" || t === "readonly" ? t : null;
1756
+ };
1757
+ function verbAccess(fm) {
1758
+ const objectAccess = normAccess(fm.requiresAccess);
1759
+ const out = {};
1760
+ for (const v of Array.isArray(fm.verbs) ? fm.verbs : []) {
1761
+ const o = asRecord(v);
1762
+ if (!o || typeof o.name !== "string")
1763
+ continue;
1764
+ out[o.name.trim()] = normAccess(o.requiresAccess) ?? objectAccess;
1765
+ }
1766
+ return out;
1767
+ }
1768
+ function classifySkillMd(rel, before, after) {
1769
+ const a = splitSkillMd(before);
1770
+ const b = splitSkillMd(after);
1771
+ if (!a || !b)
1772
+ return manual(rel, "skill_frontmatter_unparsable");
1773
+ let category = "prompt";
1774
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(a.fm), ...Object.keys(b.fm)])) {
1775
+ if (same(a.fm[key], b.fm[key]) || SKILL_PROMPT_KEYS.has(key))
1776
+ continue;
1777
+ if (SKILL_CAPABILITY_KEYS.has(key)) {
1778
+ category = "capability";
1779
+ continue;
1780
+ }
1781
+ if (key === "verbs") {
1782
+ if (!same(verbAccess(a.fm), verbAccess(b.fm)))
1783
+ return manual(rel, "skill_fm:verbs.requiresAccess");
1784
+ category = "capability";
1785
+ continue;
1786
+ }
1787
+ return manual(rel, `skill_fm:${key}`);
1788
+ }
1789
+ return { rel, category };
1790
+ }
1791
+ function classifyChange(c) {
1792
+ const parts = c.rel.split("/");
1793
+ const [top, id] = parts;
1794
+ if (c.after === null)
1795
+ return manual(c.rel, "deleted");
1796
+ if (parts.length === 1) {
1797
+ if (top === "INSTRUCTIONS.md" || top === "INDEX.md")
1798
+ return { rel: c.rel, category: "prompt" };
1799
+ if (top === "USER.md")
1800
+ return manual(c.rel, "user_md");
1801
+ return manual(c.rel, "unrecognized");
1802
+ }
1803
+ if (top === "agents" && parts.length >= 3) {
1804
+ if (c.internalAgent || id.startsWith("__"))
1805
+ return manual(c.rel, "internal_agent");
1806
+ if (c.newDir)
1807
+ return manual(c.rel, "new_agent");
1808
+ if (parts.length === 3 && parts[2] === "AGENT.md")
1809
+ return { rel: c.rel, category: "prompt" };
1810
+ if (parts.length === 3 && parts[2] === "agent.yaml") {
1811
+ return c.before === null ? manual(c.rel, "new_file") : classifyAgentYaml(c.rel, c.before, c.after);
1812
+ }
1813
+ return manual(c.rel, "agent_extra_file");
1814
+ }
1815
+ if (top === "skills" && parts.length >= 3) {
1816
+ if (c.newDir)
1817
+ return manual(c.rel, "new_skill");
1818
+ if (parts.length === 3 && parts[2] === "SKILL.md") {
1819
+ return c.before === null ? manual(c.rel, "new_file") : classifySkillMd(c.rel, c.before, c.after);
1820
+ }
1821
+ return manual(c.rel, "skill_extra_file");
1822
+ }
1823
+ if (top === "prompts" && parts.length === 3 && PROMPT_SCOPES.has(id) && parts[2].endsWith(".md")) {
1824
+ return { rel: c.rel, category: "prompt" };
1825
+ }
1826
+ return manual(c.rel, "unrecognized");
1827
+ }
1828
+ var CATEGORY_RANK = { prompt: 0, capability: 1, manual: 2 };
1829
+ function classifyChanges(changes) {
1830
+ const files = changes.map(classifyChange);
1831
+ let category = "none";
1832
+ for (const f of files) {
1833
+ if (category === "none" || CATEGORY_RANK[f.category] > CATEGORY_RANK[category])
1834
+ category = f.category;
1835
+ }
1836
+ return { category, files };
1837
+ }
1838
+ var AUTO_APPLY_BEHAVIOR_FLOOR = 70;
1839
+ var AUTO_APPLY_LINT_REQUIRED = 100;
1840
+ var REQUIRED_CHECKS = {
1841
+ patchInSandbox: "pass",
1842
+ usedInDryRun: "yes",
1843
+ probe: "valid",
1844
+ probeLanguage: "same"
1845
+ };
1846
+ function parseScore(raw) {
1847
+ const r = asRecord(raw);
1848
+ if (!r)
1849
+ return null;
1850
+ const { lint, behavior, scope, confidence } = r;
1851
+ if (![lint, behavior, scope].every((n) => typeof n === "number" && Number.isFinite(n)))
1852
+ return null;
1853
+ if (typeof confidence !== "string")
1854
+ return null;
1855
+ const [l, b, s] = [lint, behavior, scope];
1856
+ return { lint: l, behavior: b, scope: s, avg: Math.round((l + b + s) / 3), confidence, checks: asRecord(r.checks) ?? {} };
1857
+ }
1858
+ function readScore(file) {
1859
+ try {
1860
+ return parseScore(JSON.parse(fs9.readFileSync(file, "utf-8")));
1861
+ } catch {
1862
+ return null;
1863
+ }
1864
+ }
1865
+ function categoryGate(level, cls) {
1866
+ const rank = evoLevelRank(level);
1867
+ if (rank < 2)
1868
+ return "level";
1869
+ if (cls.category === "none")
1870
+ return "no_effective_change";
1871
+ if (cls.category === "manual") {
1872
+ const f = cls.files.find((x) => x.category === "manual");
1873
+ return `category_manual:${f.rel}${f.reason ? `(${f.reason})` : ""}`;
1874
+ }
1875
+ if (cls.category === "capability" && rank < 3)
1876
+ return "level";
1877
+ return null;
1878
+ }
1879
+ function sourceGate(sourceAccess) {
1880
+ if (sourceAccess === SOURCE_WEBHOOK)
1881
+ return "source_webhook";
1882
+ return sourceAccess === "full" ? null : "source_access";
1883
+ }
1884
+ var SOURCE_WEBHOOK = "webhook";
1885
+ function scoreGate(score, threshold) {
1886
+ if (!score)
1887
+ return "score_missing";
1888
+ if (score.avg < threshold)
1889
+ return "below_threshold";
1890
+ if (score.confidence !== "high")
1891
+ return "confidence";
1892
+ if (score.behavior < AUTO_APPLY_BEHAVIOR_FLOOR)
1893
+ return "behavior_floor";
1894
+ if (score.lint !== AUTO_APPLY_LINT_REQUIRED)
1895
+ return "lint_floor";
1896
+ for (const [name, want] of Object.entries(REQUIRED_CHECKS)) {
1897
+ if (score.checks[name] !== want)
1898
+ return `checks:${name}`;
1899
+ }
1900
+ return null;
1901
+ }
1902
+ function decideAutoApply(input) {
1903
+ const reason = (evoLevelRank(input.level) >= 2 ? sourceGate(input.sourceAccess) : null) ?? categoryGate(input.level, input.classification) ?? scoreGate(input.score, input.threshold);
1904
+ return { auto: reason === null, reason };
1905
+ }
1906
+ function evaluateSandbox(input) {
1907
+ const classification = classifyChanges(collectChanges(input));
1908
+ const { auto, reason } = decideAutoApply({ level: input.level, threshold: input.threshold, sourceAccess: input.sourceAccess, classification, score: input.score });
1909
+ const sc = input.score;
1910
+ return {
1911
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
1912
+ level: input.level,
1913
+ threshold: input.threshold,
1914
+ sourceAccess: input.sourceAccess,
1915
+ category: classification.category,
1916
+ files: classification.files,
1917
+ auto,
1918
+ reason,
1919
+ score: sc ? { lint: sc.lint, behavior: sc.behavior, scope: sc.scope, avg: sc.avg, confidence: sc.confidence } : null,
1920
+ observation: input.observation
1921
+ };
1922
+ }
1923
+ var OBSERVATION_MAX_CHARS = 2e3;
1924
+ function extractLargerObservation(md) {
1925
+ const lines = md.split(/\r?\n/);
1926
+ const start = lines.findIndex((l) => /^#{2,3}\s+Larger observation\s*:?\s*$/i.test(l));
1927
+ if (start < 0)
1928
+ return null;
1929
+ const level = lines[start].match(/^#+/)[0].length;
1930
+ const stop = new RegExp(`^#{1,${level}}\\s`);
1931
+ const body = [];
1932
+ for (const l of lines.slice(start + 1)) {
1933
+ if (stop.test(l))
1934
+ break;
1935
+ body.push(l);
1936
+ }
1937
+ const text2 = body.join("\n").trim();
1938
+ return text2 ? text2.slice(0, OBSERVATION_MAX_CHARS) : null;
1939
+ }
1940
+ function readObservation(file) {
1941
+ try {
1942
+ return extractLargerObservation(fs9.readFileSync(file, "utf-8"));
1943
+ } catch {
1944
+ return null;
1945
+ }
1946
+ }
1947
+
1948
+ // ../server/dist/evolution/queue-apply.js
1949
+ import { and, eq, sql } from "drizzle-orm";
1950
+ var BLOCKING_APPLY_STATUSES = ["pending", "running", "syncing", "applied"];
1951
+ function queueApply(db, input) {
1952
+ const applyId = `apply-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1953
+ class Blocked extends Error {
1954
+ reason;
1955
+ constructor(reason) {
1956
+ super(reason);
1957
+ this.reason = reason;
1958
+ }
1959
+ }
1960
+ try {
1961
+ return db.transaction((tx) => {
1962
+ const fromState = input.mode === "reviewer" ? eq(evolutionRuns.status, "awaiting_review") : and(eq(evolutionRuns.status, "running"), eq(evolutionRuns.attempts, input.attempts));
1963
+ const flipped = tx.update(evolutionRuns).set(input.mode === "auto" ? { status: "approved", completedAt: Date.now(), failureReason: null } : { status: "approved" }).where(and(eq(evolutionRuns.id, input.runId), fromState)).returning({ workspacePath: evolutionRuns.workspacePath }).all();
1964
+ if (flipped.length === 0)
1965
+ throw new Blocked("state");
1966
+ const existing = tx.select({ id: evolutionApplies.id }).from(evolutionApplies).where(and(sql`EXISTS (SELECT 1 FROM json_each(${evolutionApplies.sourceRunIds}) WHERE value = ${input.runId})`, sql`${evolutionApplies.status} IN (${sql.join(BLOCKING_APPLY_STATUSES.map((s) => sql`${s}`), sql`, `)})`)).get();
1967
+ if (existing)
1968
+ throw new Blocked("apply_exists");
1969
+ tx.insert(evolutionApplies).values({
1970
+ id: applyId,
1971
+ workspacePath: flipped[0].workspacePath,
1972
+ status: "pending",
1973
+ sourceRunIds: JSON.stringify([input.runId]),
1974
+ reviewerHint: input.mode === "reviewer" ? input.reviewerHint ?? null : null,
1975
+ createdAt: Date.now(),
1976
+ approvedBy: input.mode
1977
+ }).run();
1978
+ return { ok: true, applyId, workspacePath: flipped[0].workspacePath };
1979
+ });
1980
+ } catch (err) {
1981
+ if (err instanceof Blocked)
1982
+ return { ok: false, reason: err.reason };
1983
+ throw err;
1984
+ }
1985
+ }
1986
+ function failApply(db, applyId, input) {
1987
+ return db.transaction((tx) => {
1988
+ const rows = tx.update(evolutionApplies).set({ status: input.status, failureReason: input.failureReason, completedAt: Date.now() }).where(and(eq(evolutionApplies.id, applyId), input.onlyIfStatus !== void 0 ? eq(evolutionApplies.status, input.onlyIfStatus) : void 0, input.onlyIfAttempts !== void 0 ? eq(evolutionApplies.attempts, input.onlyIfAttempts) : void 0)).returning({ approvedBy: evolutionApplies.approvedBy, sourceRunIds: evolutionApplies.sourceRunIds }).all();
1989
+ if (rows.length === 0)
1990
+ return { failed: false, revertedRunIds: [] };
1991
+ const revertedRunIds = [];
1992
+ if (rows[0].approvedBy === "auto") {
1993
+ for (const runId of JSON.parse(rows[0].sourceRunIds)) {
1994
+ const r = tx.update(evolutionRuns).set({ status: "awaiting_review" }).where(and(eq(evolutionRuns.id, runId), eq(evolutionRuns.status, "approved"))).run();
1995
+ if (r.changes > 0)
1996
+ revertedRunIds.push(runId);
1997
+ }
1998
+ }
1999
+ return { failed: true, revertedRunIds };
2000
+ });
2001
+ }
2002
+
2003
+ // ../server/dist/evolution/rollback.js
2004
+ import fs10 from "node:fs";
2005
+ import { createHash } from "node:crypto";
2006
+ import { and as and2, eq as eq2, inArray } from "drizzle-orm";
2007
+ function sha256File(file) {
2008
+ return createHash("sha256").update(fs10.readFileSync(file)).digest("hex");
2009
+ }
2010
+
1480
2011
  // ../server/dist/evolution/evo-wrapper.js
1481
2012
  var FIX_BUDGET = 1;
1482
2013
  var DRY_RUN_TIMEOUT_SEC = 2700;
@@ -1507,8 +2038,10 @@ function loadRunRow(id) {
1507
2038
  workspacePath: evolutionRuns.workspacePath,
1508
2039
  triggerKind: evolutionRuns.triggerKind,
1509
2040
  sourceSession: evolutionRuns.sourceSession,
1510
- userHint: evolutionRuns.userHint
1511
- }).from(evolutionRuns).where(eq(evolutionRuns.id, id)).get();
2041
+ userHint: evolutionRuns.userHint,
2042
+ attempts: evolutionRuns.attempts,
2043
+ sourceAccess: evolutionRuns.sourceAccess
2044
+ }).from(evolutionRuns).where(eq3(evolutionRuns.id, id)).get();
1512
2045
  if (!row)
1513
2046
  throw new Error(`evolution_runs row not found: ${id}`);
1514
2047
  return row;
@@ -1518,11 +2051,23 @@ function resolveHaloCli() {
1518
2051
  return process.env.HALO_CLI;
1519
2052
  return process.platform === "win32" ? "halo.cmd" : "halo";
1520
2053
  }
2054
+ var liveProcs = /* @__PURE__ */ new Set();
2055
+ var KILL_GRACE_MS = 1e4;
2056
+ var stopping = false;
2057
+ function resetStoppingForTests() {
2058
+ stopping = false;
2059
+ }
1521
2060
  function spawnProc(bin, args, logFd, teePath, stdinInput, timeoutSec) {
1522
2061
  const loggedArgs = stdinInput === void 0 ? args.slice(0, -1) : args;
2062
+ if (stopping) {
2063
+ writeLog(logFd, `[wrapper] stopping (claim lost) \u2014 not spawning ${bin}
2064
+ `);
2065
+ return new Promise(() => {
2066
+ });
2067
+ }
1523
2068
  writeLog(logFd, `[wrapper] $ ${bin} ${loggedArgs.join(" ")}${stdinInput === void 0 ? " <last-arg-omitted>" : " <prompt-on-stdin>"}${timeoutSec ? ` (timeout ${timeoutSec}s)` : ""}
1524
2069
  `);
1525
- const tee = teePath ? fs9.createWriteStream(teePath, { flags: "a" }) : null;
2070
+ const tee = teePath ? fs11.createWriteStream(teePath, { flags: "a" }) : null;
1526
2071
  const [spawnBin, spawnArgs] = process.platform === "win32" && bin.endsWith(".cmd") ? ["cmd.exe", ["/c", bin, ...args]] : [bin, args];
1527
2072
  return new Promise((resolve) => {
1528
2073
  const child = spawn2(spawnBin, spawnArgs, {
@@ -1563,16 +2108,17 @@ function spawnProc(bin, args, logFd, teePath, stdinInput, timeoutSec) {
1563
2108
  } catch {
1564
2109
  }
1565
2110
  };
2111
+ liveProcs.add(killTree);
1566
2112
  const killTimer = timeoutSec ? setTimeout(() => {
1567
2113
  timedOut = true;
1568
2114
  writeLog(logFd, `[wrapper] timeout ${timeoutSec}s \u2014 killing process group (SIGTERM)
1569
2115
  `);
1570
2116
  killTree("SIGTERM");
1571
2117
  sigkillTimer = setTimeout(() => {
1572
- writeLog(logFd, `[wrapper] still alive 10s after SIGTERM \u2014 SIGKILL
2118
+ writeLog(logFd, `[wrapper] still alive ${KILL_GRACE_MS / 1e3}s after SIGTERM \u2014 SIGKILL
1573
2119
  `);
1574
2120
  killTree("SIGKILL");
1575
- }, 1e4);
2121
+ }, KILL_GRACE_MS);
1576
2122
  sigkillTimer.unref?.();
1577
2123
  }, timeoutSec * 1e3) : null;
1578
2124
  child.stdout?.on("data", (chunk) => {
@@ -1584,6 +2130,7 @@ function spawnProc(bin, args, logFd, teePath, stdinInput, timeoutSec) {
1584
2130
  tee?.write(chunk);
1585
2131
  });
1586
2132
  child.on("exit", (code, signal) => {
2133
+ liveProcs.delete(killTree);
1587
2134
  if (killTimer)
1588
2135
  clearTimeout(killTimer);
1589
2136
  if (sigkillTimer)
@@ -1592,9 +2139,12 @@ function spawnProc(bin, args, logFd, teePath, stdinInput, timeoutSec) {
1592
2139
  writeLog(logFd, `[wrapper] proc exit code=${code} signal=${signal}${timedOut ? " (timed out \u2192 124)" : ""} stdout=${stdout.length}B stderr=${stderr.length}B
1593
2140
  `);
1594
2141
  tee?.end();
2142
+ if (stopping)
2143
+ return;
1595
2144
  resolve({ exitCode, stdout, stderr });
1596
2145
  });
1597
2146
  child.on("error", (err) => {
2147
+ liveProcs.delete(killTree);
1598
2148
  if (killTimer)
1599
2149
  clearTimeout(killTimer);
1600
2150
  if (sigkillTimer)
@@ -1605,17 +2155,19 @@ function spawnProc(bin, args, logFd, teePath, stdinInput, timeoutSec) {
1605
2155
  [spawn error] ${err.message}
1606
2156
  `);
1607
2157
  tee?.end();
2158
+ if (stopping)
2159
+ return;
1608
2160
  resolve({ exitCode: 1, stdout, stderr: stderr + `
1609
2161
  [spawn error] ${err.message}` });
1610
2162
  });
1611
2163
  });
1612
2164
  }
1613
2165
  function subCliLogPath(runDir) {
1614
- return path12.join(runDir, "sub-cli.log");
2166
+ return path13.join(runDir, "sub-cli.log");
1615
2167
  }
1616
2168
  function appendSubCliHeader(runDir, header) {
1617
2169
  try {
1618
- fs9.appendFileSync(subCliLogPath(runDir), `
2170
+ fs11.appendFileSync(subCliLogPath(runDir), `
1619
2171
  === ${(/* @__PURE__ */ new Date()).toISOString()} ${header} ===
1620
2172
  `);
1621
2173
  } catch {
@@ -1649,10 +2201,10 @@ function buildLanguageClause(langHint, scope) {
1649
2201
  ];
1650
2202
  }
1651
2203
  function readPatchFrontmatter(runDir) {
1652
- const patchPath = path12.join(runDir, "patch.md");
2204
+ const patchPath = path13.join(runDir, "patch.md");
1653
2205
  let raw;
1654
2206
  try {
1655
- raw = fs9.readFileSync(patchPath, "utf-8");
2207
+ raw = fs11.readFileSync(patchPath, "utf-8");
1656
2208
  } catch {
1657
2209
  return null;
1658
2210
  }
@@ -1660,7 +2212,7 @@ function readPatchFrontmatter(runDir) {
1660
2212
  if (!m)
1661
2213
  return null;
1662
2214
  try {
1663
- const parsed = YAML6.parse(m[1]);
2215
+ const parsed = YAML7.parse(m[1]);
1664
2216
  return parsed ?? null;
1665
2217
  } catch {
1666
2218
  return null;
@@ -1682,14 +2234,14 @@ function extractTestScenario(fm) {
1682
2234
  return { agentId: o.agentId, testMessage, originalMessage };
1683
2235
  }
1684
2236
  function readEvoContext(runDir, logFd) {
1685
- const p = path12.join(runDir, "evo-context.json");
1686
- if (!fs9.existsSync(p)) {
2237
+ const p = path13.join(runDir, "evo-context.json");
2238
+ if (!fs11.existsSync(p)) {
1687
2239
  writeLog(logFd, `[brief] evo-context.json missing at ${p}
1688
2240
  `);
1689
2241
  return null;
1690
2242
  }
1691
2243
  try {
1692
- return JSON.parse(fs9.readFileSync(p, "utf-8"));
2244
+ return JSON.parse(fs11.readFileSync(p, "utf-8"));
1693
2245
  } catch (err) {
1694
2246
  writeLog(logFd, `[brief] evo-context.json parse failed: ${err instanceof Error ? err.message : String(err)}
1695
2247
  `);
@@ -1697,12 +2249,12 @@ function readEvoContext(runDir, logFd) {
1697
2249
  }
1698
2250
  }
1699
2251
  function dumpSnapshotImages(runDir, logFd) {
1700
- const snapshotPath = path12.join(runDir, "source-snapshot.json");
1701
- if (!fs9.existsSync(snapshotPath))
2252
+ const snapshotPath = path13.join(runDir, "source-snapshot.json");
2253
+ if (!fs11.existsSync(snapshotPath))
1702
2254
  return [];
1703
2255
  let parsed;
1704
2256
  try {
1705
- parsed = JSON.parse(fs9.readFileSync(snapshotPath, "utf-8"));
2257
+ parsed = JSON.parse(fs11.readFileSync(snapshotPath, "utf-8"));
1706
2258
  } catch {
1707
2259
  writeLog(logFd, `[dumpSnapshotImages] snapshot parse failed
1708
2260
  `);
@@ -1711,8 +2263,8 @@ function dumpSnapshotImages(runDir, logFd) {
1711
2263
  const messages = Array.isArray(parsed.rawMessages) ? parsed.rawMessages : [];
1712
2264
  if (messages.length === 0)
1713
2265
  return [];
1714
- const imagesDir = path12.join(runDir, "images");
1715
- fs9.mkdirSync(imagesDir, { recursive: true });
2266
+ const imagesDir = path13.join(runDir, "images");
2267
+ fs11.mkdirSync(imagesDir, { recursive: true });
1716
2268
  const out = [];
1717
2269
  const extByMime = {
1718
2270
  "image/png": "png",
@@ -1751,10 +2303,10 @@ function dumpSnapshotImages(runDir, logFd) {
1751
2303
  continue;
1752
2304
  const ext = extByMime[mediaType] ?? "bin";
1753
2305
  const fileName = c.subBlockIdx == null ? `${m}-${b}.${ext}` : `${m}-${b}-${c.subBlockIdx}.${ext}`;
1754
- const fullPath = path12.join(imagesDir, fileName);
2306
+ const fullPath = path13.join(imagesDir, fileName);
1755
2307
  try {
1756
2308
  const buf = Buffer.from(data, "base64");
1757
- fs9.writeFileSync(fullPath, buf);
2309
+ fs11.writeFileSync(fullPath, buf);
1758
2310
  out.push({
1759
2311
  relPath: `images/${fileName}`,
1760
2312
  msgIdx: m,
@@ -1841,15 +2393,15 @@ function renderEvoContextSections(ctx) {
1841
2393
  lines.push("");
1842
2394
  }
1843
2395
  if (ctx.skills.length > 0) {
1844
- const globalSkillsDir = path12.join(homedir6(), ".halo", "global", "skills");
2396
+ const globalSkillsDir = path13.join(homedir6(), ".halo", "global", "skills");
1845
2397
  lines.push("=== Skills in this workspace ===");
1846
2398
  for (const s of ctx.skills)
1847
2399
  lines.push(` ${s.id} [${s.scope}]`);
1848
2400
  lines.push("");
1849
2401
  lines.push("To read a skill's content, file_read its SKILL.md (you have full");
1850
2402
  lines.push("file access \u2014 absolute paths resolve):");
1851
- lines.push(` [workspace] \u2192 ${path12.join("<workspace>", ".halo", "skills", "<id>", "SKILL.md")}`);
1852
- lines.push(` [global] / [builtin] \u2192 ${path12.join(globalSkillsDir, "<id>", "SKILL.md")}`);
2403
+ lines.push(` [workspace] \u2192 ${path13.join("<workspace>", ".halo", "skills", "<id>", "SKILL.md")}`);
2404
+ lines.push(` [global] / [builtin] \u2192 ${path13.join(globalSkillsDir, "<id>", "SKILL.md")}`);
1853
2405
  lines.push("A [global]/[builtin] skill is NOT inside your sandbox \u2014 read it");
1854
2406
  lines.push(`from ${globalSkillsDir} directly; don't glob the sandbox for it.`);
1855
2407
  lines.push("Sibling resource files live next to SKILL.md in that same dir.");
@@ -1872,13 +2424,13 @@ function buildDraftBrief(args) {
1872
2424
  ];
1873
2425
  if (args.userHint)
1874
2426
  lines.push(`Reviewer hint: ${args.userHint}`);
1875
- lines.push("", "The triggering agent's full message log lives on disk:", ` ${path12.join(args.runDir, "tool-flow.md")} \u2014 clipped, fast skim`, ` ${path12.join(args.runDir, "source-snapshot.json")} \u2014 full rawMessages`, "", "Read tool-flow.md first to understand what the user wanted, what the", "agent did, and where it diverged. Each tool_result is clipped to a", "~200-char peek (with the `is_error` flag when the tool failed) \u2014", "enough to see the *shape* of the flow without re-tokenizing large", "grep / shell outputs. Fall back to source-snapshot.json only for", "messages whose full tool result actually matters to your patch.", "", ...renderEvoContextSections(args.evoContext), ...renderImageManifest(args.images), "=== Non-text media note ===", "", "Inline images in source-snapshot.json are base64 \u2014 you can't read them", "directly. The `images/` dump", "(see manifest above) decodes them to disk so you can `view_image` if", "a specific image is load-bearing. Video, audio, and binary documents", "(PDF, etc.) are even more limited: at trigger time the source agent", "worked with them via shell tooling (ffmpeg, pdftotext, etc.), and your", "view is limited to the textual results those tools produced (visible", "inside source-snapshot.json as tool_result text).", "", "When the patch you have in mind requires understanding what's INSIDE", "the media file (a video's visual content, an audio's acoustic", "content, a PDF's layout), the dry-run test scenario can't reliably", "verify it \u2014 skipping the run is the right call. When the patch is", "about the shell-tooling pattern itself (how to call ffmpeg, how to", "grep its output, how to handle credential errors), it can be", "verified at the textual level and the run proceeds normally.", "", "=== Task ===", "", "Decide what change in the prompt surface above would help the next time", "a similar conversation comes up. Three shapes, in order of preference:", "rewrite an existing rule; tighten / reorganize what's there; or, only", "when neither applies, add a new rule.", "", `Sandbox path: ${evoSandboxDir(args.runDir)}`, "", `When you have a change worth making, write ${path12.join(args.runDir, "patch.md")}`, "(frontmatter + body) and one new file at", `${evoSandboxHaloDir(args.runDir)}/<target> with the`, "full new contents. AGENT.md describes the patch.md schema.", "", "When the conversation has no signal worth a patch (rule already covers,", "too short to learn from, agent did fine, depends on media content", `you can't see), write ${path12.join(args.runDir, ".skip.md")} with a`, "one or two sentence reason instead, and skip the rest.", "", ...buildLanguageClause(args.langHint, "all natural-language content (patch.md body, prose drafted into sandbox files, .skip.md body)"));
2427
+ lines.push("", "The triggering agent's full message log lives on disk:", ` ${path13.join(args.runDir, "tool-flow.md")} \u2014 clipped, fast skim`, ` ${path13.join(args.runDir, "source-snapshot.json")} \u2014 full rawMessages`, "", "Read tool-flow.md first to understand what the user wanted, what the", "agent did, and where it diverged. Each tool_result is clipped to a", "~200-char peek (with the `is_error` flag when the tool failed) \u2014", "enough to see the *shape* of the flow without re-tokenizing large", "grep / shell outputs. Fall back to source-snapshot.json only for", "messages whose full tool result actually matters to your patch.", "", ...renderEvoContextSections(args.evoContext), ...renderImageManifest(args.images), "=== Non-text media note ===", "", "Inline images in source-snapshot.json are base64 \u2014 you can't read them", "directly. The `images/` dump", "(see manifest above) decodes them to disk so you can `view_image` if", "a specific image is load-bearing. Video, audio, and binary documents", "(PDF, etc.) are even more limited: at trigger time the source agent", "worked with them via shell tooling (ffmpeg, pdftotext, etc.), and your", "view is limited to the textual results those tools produced (visible", "inside source-snapshot.json as tool_result text).", "", "When the patch you have in mind requires understanding what's INSIDE", "the media file (a video's visual content, an audio's acoustic", "content, a PDF's layout), the dry-run test scenario can't reliably", "verify it \u2014 skipping the run is the right call. When the patch is", "about the shell-tooling pattern itself (how to call ffmpeg, how to", "grep its output, how to handle credential errors), it can be", "verified at the textual level and the run proceeds normally.", "", "=== Task ===", "", "Decide what change in the prompt surface above would help the next time", "a similar conversation comes up. Three shapes, in order of preference:", "rewrite an existing rule; tighten / reorganize what's there; or, only", "when neither applies, add a new rule.", "", `Sandbox path: ${evoSandboxDir(args.runDir)}`, "", `When you have a change worth making, write ${path13.join(args.runDir, "patch.md")}`, "(frontmatter + body) and one new file at", `${evoSandboxHaloDir(args.runDir)}/<target> with the`, "full new contents. AGENT.md describes the patch.md schema.", "", "When the conversation has no signal worth a patch (rule already covers,", "too short to learn from, agent did fine, depends on media content", `you can't see), write ${path13.join(args.runDir, ".skip.md")} with a`, "one or two sentence reason instead, and skip the rest.", "", ...buildLanguageClause(args.langHint, "all natural-language content (patch.md body, prose drafted into sandbox files, .skip.md body)"));
1876
2428
  return lines.join("\n");
1877
2429
  }
1878
2430
  function buildFixBrief(args) {
1879
2431
  let failLogText = "";
1880
2432
  try {
1881
- failLogText = fs9.readFileSync(args.failLogPath, "utf-8");
2433
+ failLogText = fs11.readFileSync(args.failLogPath, "utf-8");
1882
2434
  } catch {
1883
2435
  failLogText = `(failed to read ${args.failLogPath})`;
1884
2436
  }
@@ -1893,7 +2445,7 @@ function buildFixBrief(args) {
1893
2445
  `Fix attempt: ${args.attempt}/${args.maxAttempts}`,
1894
2446
  "",
1895
2447
  `An earlier draft pass produced patch.md + a sandbox at`,
1896
- `${path12.join(args.runDir, "patch.md")} and`,
2448
+ `${path13.join(args.runDir, "patch.md")} and`,
1897
2449
  `${evoSandboxHaloDir(args.runDir)} respectively.`,
1898
2450
  "The dry-run failed; the wrapper's failure log:",
1899
2451
  "",
@@ -1914,16 +2466,16 @@ function buildFixBrief(args) {
1914
2466
  function buildScoreBrief(args) {
1915
2467
  function readOrEmpty(p, label) {
1916
2468
  try {
1917
- return fs9.readFileSync(p, "utf-8");
2469
+ return fs11.readFileSync(p, "utf-8");
1918
2470
  } catch (err) {
1919
2471
  writeLog(args.logFd, `[buildScoreBrief] ${label} read failed: ${err instanceof Error ? err.message : String(err)}
1920
2472
  `);
1921
2473
  return `(${label} unavailable)`;
1922
2474
  }
1923
2475
  }
1924
- const patchMd = readOrEmpty(path12.join(args.runDir, "patch.md"), "patch.md");
1925
- const dryRun = readOrEmpty(path12.join(args.runDir, "dry-run-output.txt"), "dry-run-output.txt");
1926
- const meta = readOrEmpty(path12.join(args.runDir, "meta.json"), "meta.json");
2476
+ const patchMd = readOrEmpty(path13.join(args.runDir, "patch.md"), "patch.md");
2477
+ const dryRun = readOrEmpty(path13.join(args.runDir, "dry-run-output.txt"), "dry-run-output.txt");
2478
+ const meta = readOrEmpty(path13.join(args.runDir, "meta.json"), "meta.json");
1927
2479
  return [
1928
2480
  "You are running as the Evolution scorer.",
1929
2481
  "",
@@ -1934,8 +2486,8 @@ function buildScoreBrief(args) {
1934
2486
  "write score.json with the absolute path given below)",
1935
2487
  "",
1936
2488
  `The original conversation lives on disk:`,
1937
- ` ${path12.join(args.runDir, "tool-flow.md")} \u2014 clipped, fast skim`,
1938
- ` ${path12.join(args.runDir, "source-snapshot.json")} \u2014 full rawMessages, when needed`,
2489
+ ` ${path13.join(args.runDir, "tool-flow.md")} \u2014 clipped, fast skim`,
2490
+ ` ${path13.join(args.runDir, "source-snapshot.json")} \u2014 full rawMessages, when needed`,
1939
2491
  "Read tool-flow.md first to find the user turn matching",
1940
2492
  "`testScenario.originalMessage` and the assistant reply that followed",
1941
2493
  "it. Fall back to source-snapshot.json only when the full tool result",
@@ -1985,7 +2537,7 @@ function buildScoreBrief(args) {
1985
2537
  "find the assistant turn that followed it in tool-flow.md (or",
1986
2538
  "source-snapshot.json) \u2014 that's your baseline. Compare baseline to",
1987
2539
  "dry-run-output.txt, rate lint / behavior / scope, write",
1988
- `${path12.join(args.runDir, "score.json")} with the shape AGENT.md describes.`,
2540
+ `${path13.join(args.runDir, "score.json")} with the shape AGENT.md describes.`,
1989
2541
  "",
1990
2542
  ...buildLanguageClause(args.langHint, 'the score.json "notes" field'),
1991
2543
  'The numeric fields and "confidence" enum stay in their canonical form.'
@@ -2028,14 +2580,14 @@ async function phaseDraft(args) {
2028
2580
  return outcome;
2029
2581
  }
2030
2582
  function judgeDraft(runDir, workspacePath, exitCode) {
2031
- if (fs9.existsSync(path12.join(runDir, ".skip.md"))) {
2583
+ if (fs11.existsSync(path13.join(runDir, ".skip.md"))) {
2032
2584
  return { outcome: "skipped", why: ".skip.md present \u2014 evo declared skip" };
2033
2585
  }
2034
2586
  if (exitCode !== 0) {
2035
2587
  const timeout = exitCode === 124 ? " (timeout)" : "";
2036
2588
  return { outcome: "failed", why: `drafter exited ${exitCode}${timeout} \u2014 draft interrupted, not trusted` };
2037
2589
  }
2038
- if (!fs9.existsSync(path12.join(runDir, "patch.md"))) {
2590
+ if (!fs11.existsSync(path13.join(runDir, "patch.md"))) {
2039
2591
  return { outcome: "failed", why: "patch.md missing" };
2040
2592
  }
2041
2593
  const target = readPatchFrontmatter(runDir)?.target;
@@ -2043,16 +2595,16 @@ function judgeDraft(runDir, workspacePath, exitCode) {
2043
2595
  return { outcome: "drafted", why: "patch.md present, no target in frontmatter \u2014 target file not checked" };
2044
2596
  }
2045
2597
  const sandboxDir = evoSandboxDir(runDir);
2046
- const rel = path12.relative(sandboxDir, path12.resolve(sandboxDir, target));
2047
- if (!rel.startsWith(".halo" + path12.sep)) {
2598
+ const rel = path13.relative(sandboxDir, path13.resolve(sandboxDir, target));
2599
+ if (!rel.startsWith(".halo" + path13.sep)) {
2048
2600
  return { outcome: "failed", why: `target ${JSON.stringify(target)} does not resolve under sandbox/.halo/` };
2049
2601
  }
2050
- const sandboxFile = path12.join(sandboxDir, rel);
2051
- if (!fs9.statSync(sandboxFile, { throwIfNoEntry: false })?.isFile()) {
2602
+ const sandboxFile = path13.join(sandboxDir, rel);
2603
+ if (!fs11.statSync(sandboxFile, { throwIfNoEntry: false })?.isFile()) {
2052
2604
  return { outcome: "failed", why: `target not written to sandbox: ${target}` };
2053
2605
  }
2054
- const wsFile = path12.join(workspacePath, rel);
2055
- if (fs9.statSync(wsFile, { throwIfNoEntry: false })?.isFile() && fs9.readFileSync(wsFile).equals(fs9.readFileSync(sandboxFile))) {
2606
+ const wsFile = path13.join(workspacePath, rel);
2607
+ if (fs11.statSync(wsFile, { throwIfNoEntry: false })?.isFile() && fs11.readFileSync(wsFile).equals(fs11.readFileSync(sandboxFile))) {
2056
2608
  return { outcome: "failed", why: `target unchanged from workspace: ${target}` };
2057
2609
  }
2058
2610
  return { outcome: "drafted", why: `patch.md + target ${target} present` };
@@ -2079,15 +2631,15 @@ async function phaseDryRun(args) {
2079
2631
  }
2080
2632
  async function tryDryRun(args, attemptIndex) {
2081
2633
  const sandboxDir = evoSandboxDir(args.runDir);
2082
- const outputPath = path12.join(args.runDir, "dry-run-output.txt");
2083
- const failLogPath = path12.join(args.runDir, `dry-run-fail-${attemptIndex}.log`);
2634
+ const outputPath = path13.join(args.runDir, "dry-run-output.txt");
2635
+ const failLogPath = path13.join(args.runDir, `dry-run-fail-${attemptIndex}.log`);
2084
2636
  const cli = resolveHaloCli();
2085
2637
  const fm = readPatchFrontmatter(args.runDir);
2086
2638
  const scenario = extractTestScenario(fm);
2087
2639
  if (!scenario) {
2088
2640
  writeLog(args.logFd, `[tryDryRun:${attemptIndex}] patch.md frontmatter missing testScenario.{agentId, testMessage}
2089
2641
  `);
2090
- fs9.writeFileSync(failLogPath, "patch.md frontmatter missing testScenario.{agentId, testMessage} \u2014 wrapper cannot run dry-run without it.\n", "utf-8");
2642
+ fs11.writeFileSync(failLogPath, "patch.md frontmatter missing testScenario.{agentId, testMessage} \u2014 wrapper cannot run dry-run without it.\n", "utf-8");
2091
2643
  return { kind: "no-scenario", failLogPath };
2092
2644
  }
2093
2645
  writeLog(args.logFd, `[tryDryRun:${attemptIndex}] agent=${scenario.agentId} testMessage=${JSON.stringify(scenario.testMessage).slice(0, 200)}
@@ -2105,14 +2657,14 @@ async function tryDryRun(args, attemptIndex) {
2105
2657
  const timedOut = result.exitCode === 124;
2106
2658
  const ok = result.exitCode === 0 && result.stdout.trim().length > 0;
2107
2659
  if (ok) {
2108
- fs9.writeFileSync(outputPath, result.stdout, "utf-8");
2660
+ fs11.writeFileSync(outputPath, result.stdout, "utf-8");
2109
2661
  writeLog(args.logFd, `[tryDryRun:${attemptIndex}] success: wrote ${outputPath} (${result.stdout.length}B)
2110
2662
  `);
2111
2663
  return { kind: "ok" };
2112
2664
  }
2113
2665
  const stdoutTail = result.stdout.length > 4e3 ? "\u2026" + result.stdout.slice(-4e3) : result.stdout;
2114
2666
  const stderrTail = result.stderr.length > 4e3 ? "\u2026" + result.stderr.slice(-4e3) : result.stderr;
2115
- fs9.writeFileSync(failLogPath, [
2667
+ fs11.writeFileSync(failLogPath, [
2116
2668
  `Wrapper command: halo cli -a ${scenario.agentId} -n -w ${sandboxDir} <testMessage>`,
2117
2669
  `Test message: ${scenario.testMessage}`,
2118
2670
  `Exit code: ${result.exitCode}${timedOut ? " (timeout)" : ""}`,
@@ -2168,6 +2720,7 @@ async function phaseScore(args) {
2168
2720
  const sandboxWs = evoSandboxDir(args.runDir);
2169
2721
  buildEvoSandbox(args.workspacePath, args.runDir, args.logFd);
2170
2722
  const cliArgs = ["cli", "-a", "__score__", "-n", "-w", sandboxWs];
2723
+ fs11.rmSync(path13.join(args.runDir, "score.json"), { force: true });
2171
2724
  appendSubCliHeader(args.runDir, "Phase C: __score__");
2172
2725
  const result = await spawnProc(
2173
2726
  resolveHaloCli(),
@@ -2188,7 +2741,7 @@ async function phaseScore(args) {
2188
2741
  writeLog(args.logFd, `[phaseScore] stdout (last 1KB): ${result.stdout.slice(-1e3)}
2189
2742
  `);
2190
2743
  }
2191
- const hasScore = fs9.existsSync(path12.join(args.runDir, "score.json"));
2744
+ const hasScore = fs11.existsSync(path13.join(args.runDir, "score.json"));
2192
2745
  writeLog(args.logFd, `[phaseScore] score.json=${hasScore}
2193
2746
  `);
2194
2747
  return hasScore;
@@ -2202,7 +2755,7 @@ var RUN_INPUT_FILES = /* @__PURE__ */ new Set([
2202
2755
  function resetRunArtifacts(runDir, logFd) {
2203
2756
  let entries;
2204
2757
  try {
2205
- entries = fs9.readdirSync(runDir);
2758
+ entries = fs11.readdirSync(runDir);
2206
2759
  } catch {
2207
2760
  return;
2208
2761
  }
@@ -2210,26 +2763,19 @@ function resetRunArtifacts(runDir, logFd) {
2210
2763
  if (RUN_INPUT_FILES.has(entry))
2211
2764
  continue;
2212
2765
  try {
2213
- fs9.rmSync(path12.join(runDir, entry), { recursive: true, force: true });
2766
+ fs11.rmSync(path13.join(runDir, entry), { recursive: true, force: true });
2214
2767
  } catch (err) {
2215
2768
  writeLog(logFd, `[resetRunArtifacts] failed to remove ${entry}: ${err instanceof Error ? err.message : String(err)}
2216
2769
  `);
2217
2770
  }
2218
2771
  }
2219
2772
  }
2220
- async function runMode(id, logFd) {
2221
- const row = loadRunRow(id);
2773
+ async function runMode(row, logFd) {
2774
+ const { id } = row;
2222
2775
  const runDir = wsEvoRunDir(row.workspacePath, id);
2223
- fs9.mkdirSync(runDir, { recursive: true });
2776
+ fs11.mkdirSync(runDir, { recursive: true });
2224
2777
  resetRunArtifacts(runDir, logFd);
2225
- const hb = setInterval(() => {
2226
- try {
2227
- getEvoDb().update(evolutionRuns).set({ heartbeatAt: Date.now() }).where(eq(evolutionRuns.id, id)).run();
2228
- } catch (err) {
2229
- writeLog(logFd, `[wrapper] heartbeat failed: ${err instanceof Error ? err.message : String(err)}
2230
- `);
2231
- }
2232
- }, 6e4);
2778
+ const hb = setInterval(() => heartbeat("run", id, row.attempts, logFd), 6e4);
2233
2779
  const langHint = resolveLangHint();
2234
2780
  writeLog(logFd, `[wrapper] resolved langHint=${langHint}
2235
2781
  `);
@@ -2244,35 +2790,118 @@ async function runMode(id, logFd) {
2244
2790
  };
2245
2791
  try {
2246
2792
  const outcome = await phaseDraft(ctx);
2793
+ markObservation(id, row.attempts, runDir, outcome, logFd);
2247
2794
  if (outcome === "failed") {
2248
- finalize(id, "failed", "phase A: draft incomplete (drafter exited non-zero, or patch.md / target file missing or unchanged) \u2014 see run log", logFd);
2795
+ finalize(id, row.attempts, "failed", "phase A: draft incomplete (drafter exited non-zero, or patch.md / target file missing or unchanged) \u2014 see run log", logFd);
2249
2796
  return;
2250
2797
  }
2251
2798
  if (outcome === "skipped") {
2252
- finalize(id, "skipped", null, logFd);
2799
+ finalize(id, row.attempts, "skipped", null, logFd);
2253
2800
  return;
2254
2801
  }
2255
2802
  const dryRunOk = await phaseDryRun(ctx);
2256
2803
  if (!dryRunOk) {
2257
- finalize(id, "failed", "phase B: dry-run failed (fix budget exhausted)", logFd);
2804
+ finalize(id, row.attempts, "failed", "phase B: dry-run failed (fix budget exhausted)", logFd);
2258
2805
  return;
2259
2806
  }
2260
2807
  const scored = await phaseScore(ctx);
2261
2808
  if (!scored) {
2262
- finalize(id, "failed", "phase C: scorer did not produce score.json", logFd);
2809
+ finalize(id, row.attempts, "failed", "phase C: scorer did not produce score.json", logFd);
2263
2810
  return;
2264
2811
  }
2265
- finalize(id, "awaiting_review", null, logFd);
2812
+ if (maybeAutoApply({ runId: id, workspacePath: row.workspacePath, runDir, attempts: row.attempts, sourceAccess: row.sourceAccess, logFd }))
2813
+ return;
2814
+ finalize(id, row.attempts, "awaiting_review", null, logFd);
2266
2815
  } finally {
2267
2816
  clearInterval(hb);
2268
2817
  }
2269
2818
  }
2270
- function finalize(id, status, failureReason, logFd) {
2819
+ function markObservation(id, attempts, runDir, outcome, logFd) {
2820
+ const has = outcome !== "failed" && readObservation(path13.join(runDir, outcome === "skipped" ? ".skip.md" : "patch.md")) !== null;
2821
+ getEvoDb().update(evolutionRuns).set({ hasObservation: has }).where(ownsRun(id, attempts)).run();
2822
+ if (has)
2823
+ writeLog(logFd, `[wrapper] draft carries a Larger observation
2824
+ `);
2825
+ }
2826
+ function maybeAutoApply(args) {
2827
+ const level = config.evolution.level;
2828
+ if (evoLevelRank(level) < 2)
2829
+ return false;
2830
+ let record;
2831
+ try {
2832
+ record = evaluateSandbox({
2833
+ mainHalo: wsHaloDir(args.workspacePath),
2834
+ globalHalo: globalDir(),
2835
+ sandboxHalo: evoSandboxHaloDir(args.runDir),
2836
+ entries: PUBLISH_WHITELIST,
2837
+ level,
2838
+ threshold: config.evolution.autoApplyThreshold,
2839
+ sourceAccess: args.sourceAccess,
2840
+ score: readScore(path13.join(args.runDir, "score.json")),
2841
+ observation: readObservation(path13.join(args.runDir, "patch.md"))
2842
+ });
2843
+ fs11.writeFileSync(path13.join(args.runDir, AUTO_APPLY_FILE), JSON.stringify(record, null, 2), "utf-8");
2844
+ } catch (err) {
2845
+ writeLog(args.logFd, `[autoApply] evaluation failed, leaving the run for review: ${err instanceof Error ? err.message : String(err)}
2846
+ `);
2847
+ return false;
2848
+ }
2849
+ writeLog(args.logFd, `[autoApply] level=${level} category=${record.category} auto=${record.auto}${record.reason ? ` reason=${record.reason}` : ""} files=${record.files.map((f) => `${f.rel}:${f.category}`).join(",")}
2850
+ `);
2851
+ if (!record.auto)
2852
+ return false;
2853
+ const queued = queueApply(getEvoDb(), { mode: "auto", runId: args.runId, attempts: args.attempts });
2854
+ if (queued.ok) {
2855
+ writeLog(args.logFd, `[autoApply] queued ${queued.applyId} (approved_by=auto)
2856
+ `);
2857
+ return true;
2858
+ }
2859
+ writeLog(args.logFd, `[autoApply] not queued: ${queued.reason}
2860
+ `);
2861
+ return queued.reason === "state";
2862
+ }
2863
+ function finalize(id, attempts, status, failureReason, logFd) {
2271
2864
  const now = Date.now();
2272
- getEvoDb().update(evolutionRuns).set({ status, failureReason, completedAt: now }).where(eq(evolutionRuns.id, id)).run();
2865
+ const r = getEvoDb().update(evolutionRuns).set({ status, failureReason, completedAt: now }).where(ownsRun(id, attempts)).run();
2866
+ if (r.changes === 0) {
2867
+ writeLog(logFd, `[wrapper] finalize skipped: run is no longer this wrapper's claim (re-claimed or moved on) \u2014 wanted status=${status}
2868
+ `);
2869
+ return;
2870
+ }
2273
2871
  writeLog(logFd, `[wrapper] finalize: status=${status}${failureReason ? ` reason=${failureReason}` : ""}
2274
2872
  `);
2275
2873
  }
2874
+ function ownsRun(id, attempts) {
2875
+ return and3(eq3(evolutionRuns.id, id), eq3(evolutionRuns.status, "running"), eq3(evolutionRuns.attempts, attempts));
2876
+ }
2877
+ function heartbeat(mode, id, attempts, logFd) {
2878
+ if (stopping)
2879
+ return;
2880
+ let owned;
2881
+ try {
2882
+ owned = mode === "run" ? getEvoDb().update(evolutionRuns).set({ heartbeatAt: Date.now() }).where(ownsRun(id, attempts)).run().changes > 0 : touchApply(id, attempts, ["running", "syncing"]);
2883
+ } catch (err) {
2884
+ writeLog(logFd, `[wrapper] heartbeat failed: ${err instanceof Error ? err.message : String(err)}
2885
+ `);
2886
+ return;
2887
+ }
2888
+ if (owned)
2889
+ return;
2890
+ stopping = true;
2891
+ writeLog(logFd, `[wrapper] heartbeat refused: ${mode} ${id} is no longer this wrapper's claim (attempt ${attempts}) \u2014 stopping ${liveProcs.size} sub-cli and exiting
2892
+ `);
2893
+ if (liveProcs.size === 0) {
2894
+ process.exit(1);
2895
+ return;
2896
+ }
2897
+ for (const kill of liveProcs)
2898
+ kill("SIGTERM");
2899
+ setTimeout(() => {
2900
+ for (const kill of liveProcs)
2901
+ kill("SIGKILL");
2902
+ process.exit(1);
2903
+ }, KILL_GRACE_MS);
2904
+ }
2276
2905
  var APPLY_REGRESSION_FLOOR = 50;
2277
2906
  function loadApplyRow(id) {
2278
2907
  const db = getEvoDb();
@@ -2281,8 +2910,10 @@ function loadApplyRow(id) {
2281
2910
  workspacePath: evolutionApplies.workspacePath,
2282
2911
  sourceRunIds: evolutionApplies.sourceRunIds,
2283
2912
  reviewerHint: evolutionApplies.reviewerHint,
2284
- status: evolutionApplies.status
2285
- }).from(evolutionApplies).where(eq(evolutionApplies.id, id)).get();
2913
+ status: evolutionApplies.status,
2914
+ approvedBy: evolutionApplies.approvedBy,
2915
+ attempts: evolutionApplies.attempts
2916
+ }).from(evolutionApplies).where(eq3(evolutionApplies.id, id)).get();
2286
2917
  if (!row)
2287
2918
  throw new Error(`evolution_applies row not found: ${id}`);
2288
2919
  let sourceRunIds;
@@ -2299,20 +2930,22 @@ function loadApplyRow(id) {
2299
2930
  workspacePath: row.workspacePath,
2300
2931
  sourceRunIds,
2301
2932
  reviewerHint: row.reviewerHint,
2302
- status: row.status
2933
+ status: row.status,
2934
+ approvedBy: row.approvedBy,
2935
+ attempts: row.attempts
2303
2936
  };
2304
2937
  }
2305
2938
  function buildEvoSandbox(workspacePath, sandboxParent, logFd) {
2306
2939
  const srcWs = wsHaloDir(workspacePath);
2307
2940
  const dstWs = evoSandboxHaloDir(sandboxParent);
2308
- fs9.mkdirSync(dstWs, { recursive: true });
2941
+ fs11.mkdirSync(dstWs, { recursive: true });
2309
2942
  const whitelist = SANDBOX_WHITELIST;
2310
2943
  for (const entry of whitelist) {
2311
- const srcEntry = path12.join(srcWs, entry);
2312
- if (!fs9.existsSync(srcEntry))
2944
+ const srcEntry = path13.join(srcWs, entry);
2945
+ if (!fs11.existsSync(srcEntry))
2313
2946
  continue;
2314
- const dstEntry = path12.join(dstWs, entry);
2315
- if (fs9.existsSync(dstEntry))
2947
+ const dstEntry = path13.join(dstWs, entry);
2948
+ if (fs11.existsSync(dstEntry))
2316
2949
  continue;
2317
2950
  copyDereferenced(srcEntry, dstEntry);
2318
2951
  }
@@ -2320,13 +2953,13 @@ function buildEvoSandbox(workspacePath, sandboxParent, logFd) {
2320
2953
  `);
2321
2954
  }
2322
2955
  function copyDereferenced(src, dst) {
2323
- if (fs9.statSync(src).isDirectory()) {
2324
- fs9.mkdirSync(dst, { recursive: true });
2325
- for (const entry of fs9.readdirSync(src))
2326
- copyDereferenced(path12.join(src, entry), path12.join(dst, entry));
2956
+ if (fs11.statSync(src).isDirectory()) {
2957
+ fs11.mkdirSync(dst, { recursive: true });
2958
+ for (const entry of fs11.readdirSync(src))
2959
+ copyDereferenced(path13.join(src, entry), path13.join(dst, entry));
2327
2960
  return;
2328
2961
  }
2329
- fs9.copyFileSync(src, dst);
2962
+ fs11.copyFileSync(src, dst);
2330
2963
  }
2331
2964
  function buildApplySandbox(workspacePath, applyDir, logFd) {
2332
2965
  buildEvoSandbox(workspacePath, applyDir, logFd);
@@ -2354,11 +2987,11 @@ function buildApplyMergeBrief(args) {
2354
2987
  "",
2355
2988
  "Hard rules: only edit files under sandbox/.halo/ \u2014 main workspace",
2356
2989
  "and ~/.halo/global/ are off-limits. Do NOT spawn halo cli. Do NOT",
2357
- `write score.json. Write ${path12.join(args.applyDir, "apply.log")}`,
2990
+ `write score.json. Write ${path13.join(args.applyDir, "apply.log")}`,
2358
2991
  "with a summary of what you did.",
2359
2992
  "",
2360
2993
  "If the merge cannot proceed (irreconcilable patch conflicts, malformed",
2361
- `input, missing sandbox), write ${path12.join(args.applyDir, "ABORT.md")}`,
2994
+ `input, missing sandbox), write ${path13.join(args.applyDir, "ABORT.md")}`,
2362
2995
  'with the diagnosis \u2014 the wrapper treats its presence as "abort this',
2363
2996
  'apply" and fails the apply instead of publishing. You cannot signal',
2364
2997
  "abort via exit code; ABORT.md is the only abort channel.",
@@ -2379,19 +3012,19 @@ function buildApplyScoreBrief(args) {
2379
3012
  "The apply agent has merged this patch (and possibly others) into",
2380
3013
  "an apply sandbox. The wrapper just ran the patch's testScenario",
2381
3014
  "against the merged sandbox; stdout is in",
2382
- `${path12.join(args.regressDir, "dry-run-output.txt")}.`,
3015
+ `${path13.join(args.regressDir, "dry-run-output.txt")}.`,
2383
3016
  "An empty dry-run-output.txt means the dry-run against the merged",
2384
3017
  "sandbox failed \u2014 score lint 0 (that is the regression signal the",
2385
3018
  `wrapper acts on); the failure detail is in`,
2386
- `${path12.join(args.regressDir, "dry-run-fail.log")} when present.`,
3019
+ `${path13.join(args.regressDir, "dry-run-fail.log")} when present.`,
2387
3020
  "",
2388
3021
  "Follow your AGENT.md procedure. Read:",
2389
3022
  ` patch.md \u2192 ${args.patchMdPath}`,
2390
- ` tool-flow.md \u2192 ${path12.join(path12.dirname(args.patchMdPath), "tool-flow.md")} (baseline conversation)`,
3023
+ ` tool-flow.md \u2192 ${path13.join(path13.dirname(args.patchMdPath), "tool-flow.md")} (baseline conversation)`,
2391
3024
  ` source-snapshot.json \u2192 ${args.snapshotPath}`,
2392
- ` dry-run output \u2192 ${path12.join(args.regressDir, "dry-run-output.txt")}`,
3025
+ ` dry-run output \u2192 ${path13.join(args.regressDir, "dry-run-output.txt")}`,
2393
3026
  "",
2394
- `Write ${path12.join(args.regressDir, "score.json")} \u2014 NOT the source`,
3027
+ `Write ${path13.join(args.regressDir, "score.json")} \u2014 NOT the source`,
2395
3028
  "run's score.json. Same format as run mode.",
2396
3029
  "Do not modify any other file.",
2397
3030
  ...buildLanguageClause(args.langHint, 'the score.json "notes" field'),
@@ -2403,9 +3036,9 @@ async function phaseApplyMerge(ctx) {
2403
3036
  === Phase A': merge ===
2404
3037
  `);
2405
3038
  buildApplySandbox(ctx.workspacePath, ctx.applyDir, ctx.logFd);
2406
- const abortPath = path12.join(ctx.applyDir, "ABORT.md");
3039
+ const abortPath = path13.join(ctx.applyDir, "ABORT.md");
2407
3040
  try {
2408
- fs9.rmSync(abortPath, { force: true });
3041
+ fs11.rmSync(abortPath, { force: true });
2409
3042
  } catch {
2410
3043
  }
2411
3044
  const brief = buildApplyMergeBrief(ctx);
@@ -2424,10 +3057,10 @@ async function phaseApplyMerge(ctx) {
2424
3057
  `);
2425
3058
  return { ok: false, reason: `apply cli exited ${result.exitCode}` };
2426
3059
  }
2427
- if (fs9.existsSync(abortPath)) {
3060
+ if (fs11.existsSync(abortPath)) {
2428
3061
  let abortText = "";
2429
3062
  try {
2430
- abortText = fs9.readFileSync(abortPath, "utf-8").trim();
3063
+ abortText = fs11.readFileSync(abortPath, "utf-8").trim();
2431
3064
  } catch {
2432
3065
  }
2433
3066
  writeLog(ctx.logFd, `[phaseApplyMerge] ABORT.md present \u2014 agent aborted the merge:
@@ -2436,7 +3069,7 @@ ${abortText}
2436
3069
  const firstLine = abortText.split("\n")[0]?.slice(0, 300) ?? "";
2437
3070
  return { ok: false, reason: `apply agent aborted: ${firstLine || "see ABORT.md in apply dir"}` };
2438
3071
  }
2439
- const hasLog = fs9.existsSync(path12.join(ctx.applyDir, "apply.log"));
3072
+ const hasLog = fs11.existsSync(path13.join(ctx.applyDir, "apply.log"));
2440
3073
  writeLog(ctx.logFd, `[phaseApplyMerge] apply.log=${hasLog}
2441
3074
  `);
2442
3075
  if (!hasLog)
@@ -2448,17 +3081,17 @@ async function phaseApplyRegress(ctx) {
2448
3081
  === Phase B': regress ===
2449
3082
  `);
2450
3083
  const sandboxDir = evoSandboxDir(ctx.applyDir);
2451
- fs9.mkdirSync(evoRegressDir(ctx.applyDir), { recursive: true });
3084
+ fs11.mkdirSync(evoRegressDir(ctx.applyDir), { recursive: true });
2452
3085
  const outcomes = [];
2453
3086
  for (const runId of ctx.sourceRunIds) {
2454
3087
  writeLog(ctx.logFd, `
2455
3088
  --- regress for source run ${runId} ---
2456
3089
  `);
2457
3090
  const regressDir = evoRegressRunDir(ctx.applyDir, runId);
2458
- fs9.mkdirSync(regressDir, { recursive: true });
3091
+ fs11.mkdirSync(regressDir, { recursive: true });
2459
3092
  const runDir = wsEvoRunDir(ctx.workspacePath, runId);
2460
- const patchMdPath = path12.join(runDir, "patch.md");
2461
- const snapshotPath = path12.join(runDir, "source-snapshot.json");
3093
+ const patchMdPath = path13.join(runDir, "patch.md");
3094
+ const snapshotPath = path13.join(runDir, "source-snapshot.json");
2462
3095
  const fm = readPatchFrontmatter(runDir);
2463
3096
  const scenario = extractTestScenario(fm);
2464
3097
  if (!scenario) {
@@ -2485,14 +3118,14 @@ async function phaseApplyRegress(ctx) {
2485
3118
  // stdin, not argv
2486
3119
  DRY_RUN_TIMEOUT_SEC
2487
3120
  );
2488
- const dryRunOut = path12.join(regressDir, "dry-run-output.txt");
3121
+ const dryRunOut = path13.join(regressDir, "dry-run-output.txt");
2489
3122
  if (dryRun.exitCode === 0 && dryRun.stdout.trim().length > 0) {
2490
- fs9.writeFileSync(dryRunOut, dryRun.stdout, "utf-8");
3123
+ fs11.writeFileSync(dryRunOut, dryRun.stdout, "utf-8");
2491
3124
  } else {
2492
3125
  const tail = dryRun.stdout.length > 4e3 ? "\u2026" + dryRun.stdout.slice(-4e3) : dryRun.stdout;
2493
3126
  const errTail = dryRun.stderr.length > 4e3 ? "\u2026" + dryRun.stderr.slice(-4e3) : dryRun.stderr;
2494
- fs9.writeFileSync(dryRunOut, "", "utf-8");
2495
- fs9.writeFileSync(path12.join(regressDir, "dry-run-fail.log"), [
3127
+ fs11.writeFileSync(dryRunOut, "", "utf-8");
3128
+ fs11.writeFileSync(path13.join(regressDir, "dry-run-fail.log"), [
2496
3129
  `Wrapper command: halo cli -a ${scenario.agentId} -n -w ${sandboxDir} <testMessage>`,
2497
3130
  `Test message: ${scenario.testMessage}`,
2498
3131
  `Exit code: ${dryRun.exitCode}${dryRun.exitCode === 124 ? " (timeout)" : ""}`,
@@ -2513,6 +3146,7 @@ async function phaseApplyRegress(ctx) {
2513
3146
  langHint: ctx.langHint
2514
3147
  });
2515
3148
  const scoreSandboxWs = evoSandboxDir(ctx.applyDir);
3149
+ fs11.rmSync(path13.join(regressDir, "score.json"), { force: true });
2516
3150
  const scoreRun = await spawnProc(
2517
3151
  resolveHaloCli(),
2518
3152
  ["cli", "-a", "__score__", "-n", "-w", scoreSandboxWs],
@@ -2526,10 +3160,10 @@ async function phaseApplyRegress(ctx) {
2526
3160
  writeLog(ctx.logFd, `[phaseApplyRegress:${runId}] scorer cli exited ${scoreRun.exitCode}
2527
3161
  `);
2528
3162
  }
2529
- const scorePath = path12.join(regressDir, "score.json");
3163
+ const scorePath = path13.join(regressDir, "score.json");
2530
3164
  let scoreJson = null;
2531
3165
  try {
2532
- scoreJson = JSON.parse(fs9.readFileSync(scorePath, "utf-8"));
3166
+ scoreJson = JSON.parse(fs11.readFileSync(scorePath, "utf-8"));
2533
3167
  } catch {
2534
3168
  }
2535
3169
  if (!scoreJson) {
@@ -2567,31 +3201,36 @@ async function phaseApplyRegress(ctx) {
2567
3201
  `);
2568
3202
  return { allOk, outcomes };
2569
3203
  }
2570
- function finalizeApply(applyId, status, failureReason, logFd) {
2571
- const now = Date.now();
2572
- getEvoDb().update(evolutionApplies).set({ status, failureReason, completedAt: now }).where(eq(evolutionApplies.id, applyId)).run();
2573
- writeLog(logFd, `[wrapper] finalize apply: status=${status}${failureReason ? ` reason=${failureReason}` : ""}
3204
+ function finalizeApply(applyId, attempts, failureReason, logFd) {
3205
+ const { failed, revertedRunIds } = failApply(getEvoDb(), applyId, { status: "failed", failureReason, onlyIfStatus: "running", onlyIfAttempts: attempts });
3206
+ if (!failed) {
3207
+ writeLog(logFd, `[wrapper] finalize apply skipped: row is no longer running at attempt ${attempts} (re-claimed, or syncing \u2014 left to the ticker) \u2014 wanted failed: ${failureReason}
3208
+ `);
3209
+ return;
3210
+ }
3211
+ writeLog(logFd, `[wrapper] finalize apply: status=failed reason=${failureReason}${revertedRunIds.length ? ` (auto: ${revertedRunIds.join(", ")} back to awaiting_review)` : ""}
2574
3212
  `);
2575
3213
  }
3214
+ function autoApplyRecheck(workspacePath, applyDir, changed) {
3215
+ const cls = classifyChanges(publishChanges({
3216
+ mainHalo: wsHaloDir(workspacePath),
3217
+ globalHalo: globalDir(),
3218
+ sandboxHalo: evoSandboxHaloDir(applyDir)
3219
+ }, changed));
3220
+ return categoryGate(config.evolution.level, cls);
3221
+ }
2576
3222
  async function applyMode(id, logFd) {
2577
3223
  const row = loadApplyRow(id);
2578
3224
  const applyDir = wsEvoApplyDir(row.workspacePath, id);
2579
- fs9.mkdirSync(applyDir, { recursive: true });
2580
- fs9.writeFileSync(path12.join(applyDir, "meta.json"), JSON.stringify({
3225
+ fs11.mkdirSync(applyDir, { recursive: true });
3226
+ fs11.writeFileSync(path13.join(applyDir, "meta.json"), JSON.stringify({
2581
3227
  id,
2582
3228
  sourceRunIds: row.sourceRunIds,
2583
3229
  reviewerHint: row.reviewerHint,
2584
3230
  workspacePath: row.workspacePath,
2585
3231
  createdAt: Date.now()
2586
3232
  }, null, 2), "utf-8");
2587
- const hb = setInterval(() => {
2588
- try {
2589
- getEvoDb().update(evolutionApplies).set({ heartbeatAt: Date.now() }).where(eq(evolutionApplies.id, id)).run();
2590
- } catch (err) {
2591
- writeLog(logFd, `[wrapper] heartbeat (apply) failed: ${err instanceof Error ? err.message : String(err)}
2592
- `);
2593
- }
2594
- }, 6e4);
3233
+ const hb = setInterval(() => heartbeat("apply", id, row.attempts, logFd), 6e4);
2595
3234
  const ctx = {
2596
3235
  applyId: id,
2597
3236
  workspacePath: row.workspacePath,
@@ -2610,75 +3249,93 @@ async function applyMode(id, logFd) {
2610
3249
  if (!isResume) {
2611
3250
  const merged = await phaseApplyMerge(ctx);
2612
3251
  if (!merged.ok) {
2613
- finalizeApply(id, "failed", `phase A': ${merged.reason}`, logFd);
3252
+ finalizeApply(id, row.attempts, `phase A': ${merged.reason}`, logFd);
2614
3253
  return;
2615
3254
  }
2616
3255
  const { allOk, outcomes } = await phaseApplyRegress(ctx);
2617
3256
  if (!allOk) {
2618
3257
  const regressedRuns = outcomes.filter((o) => o.regressed).map((o) => `${o.runId}(${o.reason ?? "?"})`).join("; ");
2619
- finalizeApply(id, "failed", `phase B': regression in ${regressedRuns}`, logFd);
3258
+ finalizeApply(id, row.attempts, `phase B': regression in ${regressedRuns}`, logFd);
2620
3259
  return;
2621
3260
  }
2622
3261
  }
2623
3262
  try {
2624
- const preflight = await phaseApplyPreflight(ctx);
2625
- if (!preflight.ok) {
2626
- finalizeApply(id, "failed", `phase 12 preflight: ${preflight.reason}`, logFd);
2627
- return;
2628
- }
2629
- if (!isResume)
2630
- markSyncing(id, logFd);
2631
- await phaseApplyPublish(ctx, preflight.result);
2632
- for (const runId of ctx.sourceRunIds) {
2633
- getEvoDb().update(evolutionRuns).set({ status: "applied", appliedAt: Date.now() }).where(eq(evolutionRuns.id, runId)).run();
2634
- }
2635
- finalizeApplied(id, logFd);
2636
- writeLog(logFd, `[wrapper] phase 12: published ${preflight.result.fileCount} files to main; history snapshot at ${preflight.result.historyDir}
2637
- `);
2638
- writeLog(logFd, `[wrapper] note: no explicit session-release step needed. Halo's SessionManager evicts every session from the in-memory cache as soon as its current message turn finishes (runSession's finally block). The next message \u2014 channel reply, web reload, /new \u2014 runs ensureSession \u2192 buildAgentInstance, which re-reads agent.yaml + INSTRUCTIONS.md + USER.md from disk. So the new prompts kick in on the next turn for free, and currently-running turns finish on the old prompts (which is the right behavior \u2014 we don't want to abort a user mid-conversation).
2639
- `);
3263
+ await phase12Sync(ctx, row, isResume);
2640
3264
  } catch (err) {
2641
- const currentStatus = getEvoDb().select({ status: evolutionApplies.status }).from(evolutionApplies).where(eq(evolutionApplies.id, id)).get()?.status;
3265
+ const currentStatus = getEvoDb().select({ status: evolutionApplies.status }).from(evolutionApplies).where(eq3(evolutionApplies.id, id)).get()?.status;
2642
3266
  if (currentStatus === "syncing") {
2643
- writeLog(logFd, `[wrapper] phase 12 publish threw (${err instanceof Error ? err.message : String(err)}); leaving status='syncing' so ticker can resume \u2014 main may be half-applied
2644
- `);
2645
- getEvoDb().update(evolutionApplies).set({ startedAt: null, heartbeatAt: null }).where(and(eq(evolutionApplies.id, id), eq(evolutionApplies.status, "syncing"))).run();
3267
+ releaseSyncing(id, row.attempts, `phase 12 publish: ${err instanceof Error ? err.message : String(err)}`, logFd);
2646
3268
  } else {
2647
- finalizeApply(id, "failed", `phase 12: ${err instanceof Error ? err.message : String(err)}`, logFd);
3269
+ finalizeApply(id, row.attempts, `phase 12: ${err instanceof Error ? err.message : String(err)}`, logFd);
2648
3270
  }
2649
3271
  }
2650
3272
  } finally {
2651
3273
  clearInterval(hb);
2652
3274
  }
2653
3275
  }
2654
- async function phaseApplyPreflight(ctx) {
3276
+ async function phase12Sync(ctx, row, isResume) {
3277
+ const { applyId: id, logFd } = ctx;
3278
+ if (!isResume && !touchApply(id, row.attempts, ["running"])) {
3279
+ writeLog(logFd, `[wrapper] phase 12: apply is no longer this wrapper's claim (attempt ${row.attempts}) \u2014 not writing history
3280
+ `);
3281
+ return;
3282
+ }
3283
+ const preflight = await phaseApplyPreflight(ctx, isResume);
3284
+ if (!preflight.ok) {
3285
+ const reason = `phase 12 preflight: ${preflight.reason}`;
3286
+ if (isResume)
3287
+ releaseSyncing(id, row.attempts, reason, logFd);
3288
+ else
3289
+ finalizeApply(id, row.attempts, reason, logFd);
3290
+ return;
3291
+ }
3292
+ if (!isResume && row.approvedBy === "auto") {
3293
+ const blocked = autoApplyRecheck(ctx.workspacePath, ctx.applyDir, preflight.result.changed);
3294
+ if (blocked) {
3295
+ finalizeApply(id, row.attempts, `auto_recheck:${blocked}`, logFd);
3296
+ return;
3297
+ }
3298
+ writeLog(logFd, `[wrapper] auto-apply recheck passed
3299
+ `);
3300
+ }
3301
+ if (!markSyncing(id, isResume ? "syncing" : "running", row.attempts, logFd))
3302
+ return;
3303
+ await phaseApplyPublish(ctx, preflight.result);
3304
+ if (!finalizeApplied(id, row.attempts, ctx.sourceRunIds, logFd))
3305
+ return;
3306
+ writeLog(logFd, `[wrapper] phase 12: published ${preflight.result.fileCount} files to main; history snapshot at ${preflight.result.historyDir}
3307
+ `);
3308
+ writeLog(logFd, `[wrapper] note: no explicit session-release step needed. Halo's SessionManager evicts every session from the in-memory cache as soon as its current message turn finishes (runSession's finally block). The next message \u2014 channel reply, web reload, /new \u2014 runs ensureSession \u2192 buildAgentInstance, which re-reads agent.yaml + INSTRUCTIONS.md + USER.md from disk. So the new prompts kick in on the next turn for free, and currently-running turns finish on the old prompts (which is the right behavior \u2014 we don't want to abort a user mid-conversation).
3309
+ `);
3310
+ }
3311
+ async function phaseApplyPreflight(ctx, isResume) {
2655
3312
  writeLog(ctx.logFd, `
2656
3313
  === Phase 12 preflight: diff + history backup ===
2657
3314
  `);
2658
3315
  const sandboxHalo = evoSandboxHaloDir(ctx.applyDir);
2659
3316
  const mainHalo = wsHaloDir(ctx.workspacePath);
2660
- if (!fs9.existsSync(sandboxHalo))
3317
+ if (!fs11.existsSync(sandboxHalo))
2661
3318
  return { ok: false, reason: "sandbox missing \u2014 phase 11 must run first" };
2662
3319
  const whitelistTop = new Set(PUBLISH_WHITELIST);
2663
3320
  const filesToConsider = [];
2664
- for (const top of fs9.readdirSync(sandboxHalo)) {
3321
+ for (const top of fs11.readdirSync(sandboxHalo)) {
2665
3322
  if (!whitelistTop.has(top))
2666
3323
  continue;
2667
- walkInto(path12.join(sandboxHalo, top), top, filesToConsider);
3324
+ walkInto(path13.join(sandboxHalo, top), top, filesToConsider);
2668
3325
  }
2669
3326
  const historyDir = wsEvoHistoryDir(ctx.workspacePath, ctx.applyId);
2670
3327
  const changed = [];
2671
3328
  for (const { rel, full } of filesToConsider) {
2672
- const mainPath = path12.join(mainHalo, rel);
3329
+ const mainPath = path13.join(mainHalo, rel);
2673
3330
  let existsInMain = false;
2674
3331
  try {
2675
- fs9.accessSync(mainPath);
3332
+ fs11.accessSync(mainPath);
2676
3333
  existsInMain = true;
2677
3334
  } catch {
2678
3335
  }
2679
3336
  if (existsInMain) {
2680
- const sandboxBuf = fs9.readFileSync(full);
2681
- const mainBuf = fs9.readFileSync(mainPath);
3337
+ const sandboxBuf = fs11.readFileSync(full);
3338
+ const mainBuf = fs11.readFileSync(mainPath);
2682
3339
  if (sandboxBuf.equals(mainBuf))
2683
3340
  continue;
2684
3341
  }
@@ -2686,29 +3343,39 @@ async function phaseApplyPreflight(ctx) {
2686
3343
  }
2687
3344
  writeLog(ctx.logFd, `[preflight] ${changed.length} of ${filesToConsider.length} whitelisted files differ from main
2688
3345
  `);
3346
+ if (!isResume)
3347
+ fs11.rmSync(historyDir, { recursive: true, force: true });
2689
3348
  if (changed.length === 0) {
2690
3349
  return { ok: true, result: { changed, historyDir, fileCount: 0 } };
2691
3350
  }
2692
- fs9.mkdirSync(historyDir, { recursive: true });
2693
- const manifestPath = path12.join(historyDir, "MANIFEST.json");
2694
- const isFirstPreflight = !fs9.existsSync(manifestPath);
2695
- if (isFirstPreflight) {
3351
+ fs11.mkdirSync(historyDir, { recursive: true });
3352
+ const manifestPath = path13.join(historyDir, "MANIFEST.json");
3353
+ if (!isResume) {
2696
3354
  for (const { rel, existsInMain } of changed) {
2697
3355
  if (!existsInMain)
2698
3356
  continue;
2699
- const mainPath = path12.join(mainHalo, rel);
2700
- const histPath = path12.join(historyDir, rel);
2701
- fs9.mkdirSync(path12.dirname(histPath), { recursive: true });
2702
- fs9.copyFileSync(mainPath, histPath);
3357
+ const mainPath = path13.join(mainHalo, rel);
3358
+ const histPath = path13.join(historyDir, rel);
3359
+ fs11.mkdirSync(path13.dirname(histPath), { recursive: true });
3360
+ fs11.copyFileSync(mainPath, histPath);
2703
3361
  }
2704
- fs9.writeFileSync(manifestPath, JSON.stringify({
3362
+ const effective = new Set(publishChanges({ mainHalo, globalHalo: globalDir(), sandboxHalo }, changed).map((c) => c.rel));
3363
+ const manifest = {
2705
3364
  applyId: ctx.applyId,
2706
3365
  sourceRunIds: ctx.sourceRunIds,
2707
3366
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
2708
- files: changed.map((c) => ({ rel: c.rel, kind: c.existsInMain ? "overwrite" : "new" }))
2709
- }, null, 2), "utf-8");
3367
+ files: changed.map((c) => ({
3368
+ rel: c.rel,
3369
+ kind: c.existsInMain ? "overwrite" : "new",
3370
+ before: c.existsInMain ? sha256File(path13.join(mainHalo, c.rel)) : null,
3371
+ after: sha256File(c.full),
3372
+ ...effective.has(c.rel.split(path13.sep).join("/")) ? {} : { unchanged: true }
3373
+ })),
3374
+ createdDirs: dirsCreatedBy(mainHalo, changed.filter((c) => !c.existsInMain).map((c) => c.rel))
3375
+ };
3376
+ fs11.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
2710
3377
  } else {
2711
- writeLog(ctx.logFd, `[preflight] MANIFEST.json already exists \u2014 resume path, skipping history backup (the pre-apply state is already preserved from the first preflight)
3378
+ writeLog(ctx.logFd, `[preflight] resume path, skipping history backup (the pre-apply state is already preserved from the fresh claim's preflight)
2712
3379
  `);
2713
3380
  }
2714
3381
  writeLog(ctx.logFd, `[preflight] history snapshot: ${historyDir} (${changed.filter((c) => c.existsInMain).length} backed up, ${changed.filter((c) => !c.existsInMain).length} new)
@@ -2721,59 +3388,96 @@ async function phaseApplyPublish(ctx, preflight) {
2721
3388
  `);
2722
3389
  const mainHalo = wsHaloDir(ctx.workspacePath);
2723
3390
  for (const { rel, full } of preflight.changed) {
2724
- const mainPath = path12.join(mainHalo, rel);
2725
- fs9.mkdirSync(path12.dirname(mainPath), { recursive: true });
2726
- fs9.copyFileSync(full, mainPath);
3391
+ const mainPath = path13.join(mainHalo, rel);
3392
+ fs11.mkdirSync(path13.dirname(mainPath), { recursive: true });
3393
+ fs11.copyFileSync(full, mainPath);
2727
3394
  }
2728
3395
  writeLog(ctx.logFd, `[publish] copied ${preflight.changed.length} files to ${mainHalo}
2729
3396
  `);
2730
3397
  }
2731
- function markSyncing(applyId, logFd) {
2732
- getEvoDb().update(evolutionApplies).set({ status: "syncing" }).where(eq(evolutionApplies.id, applyId)).run();
3398
+ function dirsCreatedBy(mainHalo, newFiles) {
3399
+ const dirs = /* @__PURE__ */ new Set();
3400
+ for (const rel of newFiles) {
3401
+ for (let d = path13.dirname(rel); d !== "." && !fs11.existsSync(path13.join(mainHalo, d)); d = path13.dirname(d))
3402
+ dirs.add(d);
3403
+ }
3404
+ return [...dirs].sort((a, b) => b.split(path13.sep).length - a.split(path13.sep).length || (a < b ? -1 : 1));
3405
+ }
3406
+ function ownsApply(id, status, attempts) {
3407
+ return and3(eq3(evolutionApplies.id, id), eq3(evolutionApplies.status, status), eq3(evolutionApplies.attempts, attempts));
3408
+ }
3409
+ function touchApply(id, attempts, statuses) {
3410
+ return getEvoDb().update(evolutionApplies).set({ heartbeatAt: Date.now() }).where(and3(eq3(evolutionApplies.id, id), inArray2(evolutionApplies.status, statuses), eq3(evolutionApplies.attempts, attempts))).run().changes > 0;
3411
+ }
3412
+ function markSyncing(applyId, from, attempts, logFd) {
3413
+ const r = getEvoDb().update(evolutionApplies).set(from === "running" ? { status: "syncing", heartbeatAt: Date.now() } : { heartbeatAt: Date.now() }).where(ownsApply(applyId, from, attempts)).run();
3414
+ if (r.changes === 0) {
3415
+ writeLog(logFd, `[wrapper] checkpoint refused: apply is no longer ${from} at attempt ${attempts} (re-claimed) \u2014 not publishing
3416
+ `);
3417
+ return false;
3418
+ }
2733
3419
  writeLog(logFd, `[wrapper] checkpoint: status=syncing (publishing main)
3420
+ `);
3421
+ return true;
3422
+ }
3423
+ function releaseSyncing(applyId, attempts, reason, logFd) {
3424
+ const r = getEvoDb().update(evolutionApplies).set({ startedAt: null, heartbeatAt: null, failureReason: reason }).where(ownsApply(applyId, "syncing", attempts)).run();
3425
+ writeLog(logFd, r.changes === 0 ? `[wrapper] ${reason}; row is no longer this wrapper's syncing claim \u2014 left alone
3426
+ ` : `[wrapper] ${reason}; leaving status='syncing' for the ticker to resume \u2014 main may be half-applied
2734
3427
  `);
2735
3428
  }
2736
3429
  function walkInto(absPath, relPath, out) {
2737
- const stat = fs9.statSync(absPath);
3430
+ const stat = fs11.statSync(absPath);
2738
3431
  if (stat.isFile()) {
2739
3432
  out.push({ rel: relPath, full: absPath });
2740
3433
  return;
2741
3434
  }
2742
3435
  if (!stat.isDirectory())
2743
3436
  return;
2744
- for (const entry of fs9.readdirSync(absPath)) {
2745
- walkInto(path12.join(absPath, entry), path12.join(relPath, entry), out);
3437
+ for (const entry of fs11.readdirSync(absPath)) {
3438
+ walkInto(path13.join(absPath, entry), path13.join(relPath, entry), out);
2746
3439
  }
2747
3440
  }
2748
- function finalizeApplied(applyId, logFd) {
3441
+ function finalizeApplied(applyId, attempts, sourceRunIds, logFd) {
2749
3442
  const now = Date.now();
2750
- getEvoDb().update(evolutionApplies).set({ status: "applied", completedAt: now }).where(eq(evolutionApplies.id, applyId)).run();
2751
- writeLog(logFd, `[wrapper] finalize apply: status=applied
3443
+ const ok = getEvoDb().transaction((tx) => {
3444
+ const r = tx.update(evolutionApplies).set({ status: "applied", completedAt: now, failureReason: null }).where(ownsApply(applyId, "syncing", attempts)).run();
3445
+ if (r.changes === 0)
3446
+ return false;
3447
+ tx.update(evolutionRuns).set({ status: "applied", appliedAt: now }).where(and3(inArray2(evolutionRuns.id, sourceRunIds), eq3(evolutionRuns.status, "approved"))).run();
3448
+ return true;
3449
+ });
3450
+ writeLog(logFd, ok ? `[wrapper] finalize apply: status=applied
3451
+ ` : `[wrapper] finalize apply skipped: row is no longer syncing at attempt ${attempts} (re-claimed)
2752
3452
  `);
3453
+ return ok;
2753
3454
  }
2754
3455
  function writeLog(fd, msg) {
2755
3456
  try {
2756
- fs9.writeSync(fd, msg);
3457
+ fs11.writeSync(fd, msg);
2757
3458
  } catch {
2758
3459
  }
2759
3460
  }
2760
3461
  function openLogFor(mode, id) {
2761
- fs9.mkdirSync(evoLogsDir(), { recursive: true });
3462
+ fs11.mkdirSync(evoLogsDir(), { recursive: true });
2762
3463
  const logPath = mode === "run" ? evoWrapperLogFile(id) : evoApplyLogFile(id);
2763
- const fd = fs9.openSync(logPath, "a");
3464
+ const fd = fs11.openSync(logPath, "a");
2764
3465
  return { fd, logPath };
2765
3466
  }
2766
3467
  async function main() {
2767
3468
  const { mode, id } = parseCli(process.argv.slice(2));
2768
- setEvoDb(createEvoDb(path12.join(homedir6(), ".halo", "global")));
3469
+ setEvoDb(createEvoDb(path13.join(homedir6(), ".halo", "global")));
2769
3470
  const { fd, logPath } = openLogFor(mode, id);
2770
3471
  writeLog(fd, `
2771
3472
  === ${(/* @__PURE__ */ new Date()).toISOString()} wrapper start mode=${mode} id=${id} pid=${process.pid} ===
2772
3473
  `);
3474
+ let runAttempts = null;
2773
3475
  try {
2774
- if (mode === "run")
2775
- await runMode(id, fd);
2776
- else
3476
+ if (mode === "run") {
3477
+ const row = loadRunRow(id);
3478
+ runAttempts = row.attempts;
3479
+ await runMode(row, fd);
3480
+ } else
2777
3481
  await applyMode(id, fd);
2778
3482
  writeLog(fd, `[wrapper] done; log: ${logPath}
2779
3483
  `);
@@ -2783,16 +3487,14 @@ async function main() {
2783
3487
  writeLog(fd, `[wrapper] fatal: ${msg}
2784
3488
  `);
2785
3489
  try {
2786
- const now = Date.now();
2787
- if (mode === "run") {
2788
- getEvoDb().update(evolutionRuns).set({ status: "failed", failureReason: `wrapper crash: ${msg}`, completedAt: now }).where(eq(evolutionRuns.id, id)).run();
2789
- }
3490
+ if (mode === "run" && runAttempts !== null)
3491
+ finalize(id, runAttempts, "failed", `wrapper crash: ${msg}`, fd);
2790
3492
  } catch {
2791
3493
  }
2792
3494
  process.exit(1);
2793
3495
  }
2794
3496
  }
2795
- if (process.argv[1] && path12.resolve(process.argv[1]) === fileURLToPath2(import.meta.url)) {
3497
+ if (process.argv[1] && path13.resolve(process.argv[1]) === fileURLToPath2(import.meta.url)) {
2796
3498
  main().catch((err) => {
2797
3499
  process.stderr.write(`[wrapper] startup error: ${err instanceof Error ? err.message : String(err)}
2798
3500
  `);
@@ -2802,11 +3504,21 @@ if (process.argv[1] && path12.resolve(process.argv[1]) === fileURLToPath2(import
2802
3504
  export {
2803
3505
  PUBLISH_WHITELIST,
2804
3506
  SANDBOX_WHITELIST,
3507
+ autoApplyRecheck,
2805
3508
  buildEvoSandbox,
2806
3509
  extractTestScenario,
3510
+ finalize,
3511
+ finalizeApplied,
3512
+ finalizeApply,
3513
+ heartbeat,
2807
3514
  judgeDraft,
3515
+ markObservation,
3516
+ markSyncing,
3517
+ maybeAutoApply,
3518
+ phase12Sync,
2808
3519
  phaseApplyPreflight,
2809
3520
  phaseApplyPublish,
2810
3521
  readPatchFrontmatter,
3522
+ resetStoppingForTests,
2811
3523
  spawnProc
2812
3524
  };