harnery 0.46.0 → 0.47.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 (187) hide show
  1. package/README.md +24 -0
  2. package/bin/README.md +10 -0
  3. package/bin/agent-coord.mjs +3 -0
  4. package/bin/agent-hook.mjs +3 -0
  5. package/bin/harn.mjs +3 -0
  6. package/bin/run.mjs +43 -0
  7. package/dist/commander.d.ts +18 -6
  8. package/dist/commander.d.ts.map +1 -1
  9. package/dist/commander.js +74 -2
  10. package/dist/commands/agents.d.ts +25 -0
  11. package/dist/commands/agents.d.ts.map +1 -1
  12. package/dist/commands/agents.js +70 -27
  13. package/dist/commands/autoupdate.d.ts +12 -0
  14. package/dist/commands/autoupdate.d.ts.map +1 -0
  15. package/dist/commands/autoupdate.js +190 -0
  16. package/dist/commands/computer.d.ts +45 -0
  17. package/dist/commands/computer.d.ts.map +1 -0
  18. package/dist/commands/computer.js +509 -0
  19. package/dist/commands/docs.js +3 -1
  20. package/dist/commands/fetch.js +11 -1
  21. package/dist/commands/grep.d.ts +72 -0
  22. package/dist/commands/grep.d.ts.map +1 -1
  23. package/dist/commands/grep.js +200 -47
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +15 -9
  26. package/dist/commands/rm.js +2 -2
  27. package/dist/commands/web.d.ts.map +1 -1
  28. package/dist/commands/web.js +2 -0
  29. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  30. package/dist/core/agents/canonical-emit.js +2 -3
  31. package/dist/core/agents/coord-bin.d.ts +3 -0
  32. package/dist/core/agents/coord-bin.d.ts.map +1 -1
  33. package/dist/core/agents/coord-bin.js +6 -0
  34. package/dist/core/agents/coord-client.d.ts.map +1 -1
  35. package/dist/core/agents/coord-client.js +6 -2
  36. package/dist/core/agents/render/prompt-context.d.ts +55 -7
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +122 -48
  39. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  40. package/dist/core/agents/rules/claim-conflict.js +36 -16
  41. package/dist/core/agents/rules/stop-hook.d.ts +6 -0
  42. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  43. package/dist/core/agents/rules/stop-hook.js +20 -1
  44. package/dist/core/agents/state/proc-start.d.ts +3 -1
  45. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  46. package/dist/core/agents/state/proc-start.js +26 -4
  47. package/dist/core/autoupdate/engine.d.ts +97 -0
  48. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  49. package/dist/core/autoupdate/engine.js +548 -0
  50. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  51. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  52. package/dist/core/autoupdate/fingerprint.js +73 -0
  53. package/dist/core/autoupdate/git.d.ts +30 -0
  54. package/dist/core/autoupdate/git.d.ts.map +1 -0
  55. package/dist/core/autoupdate/git.js +82 -0
  56. package/dist/core/autoupdate/index.d.ts +6 -0
  57. package/dist/core/autoupdate/index.d.ts.map +1 -0
  58. package/dist/core/autoupdate/index.js +4 -0
  59. package/dist/core/autoupdate/state.d.ts +59 -0
  60. package/dist/core/autoupdate/state.d.ts.map +1 -0
  61. package/dist/core/autoupdate/state.js +214 -0
  62. package/dist/core/autoupdate/types.d.ts +113 -0
  63. package/dist/core/autoupdate/types.d.ts.map +1 -0
  64. package/dist/core/autoupdate/types.js +35 -0
  65. package/dist/core/config.d.ts +53 -1
  66. package/dist/core/config.d.ts.map +1 -1
  67. package/dist/core/config.js +114 -1
  68. package/dist/core/events/v3/bootstrap.js +1 -0
  69. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  70. package/dist/core/events/v3/runtime-build.js +1 -0
  71. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +38 -8
  74. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  75. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  76. package/dist/core/hooks/autoupdate-cue.js +53 -0
  77. package/dist/core/hooks/cli.js +52 -4
  78. package/dist/core/hooks/effects/index.js +4 -4
  79. package/dist/core/hooks/resolve/coord-root.js +1 -0
  80. package/dist/core/hooks/resolve/transcript.d.ts +12 -0
  81. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  82. package/dist/core/hooks/resolve/transcript.js +54 -0
  83. package/dist/core/semantic/contract.d.ts +3 -3
  84. package/dist/core/semantic/service.d.ts.map +1 -1
  85. package/dist/core/semantic/service.js +4 -2
  86. package/dist/core/storage/modes.d.ts +1 -1
  87. package/dist/core/storage/modes.d.ts.map +1 -1
  88. package/dist/core/storage/modes.js +5 -1
  89. package/dist/core/workflow/durable-record.d.ts +2 -0
  90. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  91. package/dist/core/workflow/durable-record.js +13 -1
  92. package/dist/core/workflow/engine.js +4 -0
  93. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  94. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  95. package/dist/core/workflow/spawn-claude.js +16 -4
  96. package/dist/core/workflow/specialists.d.ts.map +1 -1
  97. package/dist/core/workflow/specialists.js +1 -0
  98. package/dist/core/workflow/types.d.ts +10 -0
  99. package/dist/core/workflow/types.d.ts.map +1 -1
  100. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  101. package/dist/core/workflow/workspaces/leases.js +1 -9
  102. package/dist/lib/computer/cua.d.ts +50 -0
  103. package/dist/lib/computer/cua.d.ts.map +1 -0
  104. package/dist/lib/computer/cua.js +474 -0
  105. package/dist/lib/computer/host.d.ts +19 -0
  106. package/dist/lib/computer/host.d.ts.map +1 -0
  107. package/dist/lib/computer/host.js +72 -0
  108. package/dist/lib/computer/lease.d.ts +13 -0
  109. package/dist/lib/computer/lease.d.ts.map +1 -0
  110. package/dist/lib/computer/lease.js +269 -0
  111. package/dist/lib/computer/types.d.ts +163 -0
  112. package/dist/lib/computer/types.d.ts.map +1 -0
  113. package/dist/lib/computer/types.js +10 -0
  114. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  115. package/dist/lib/docs-metadata-v2.js +6 -5
  116. package/dist/lib/exec.d.ts.map +1 -1
  117. package/dist/lib/exec.js +1 -0
  118. package/dist/lib/guarded-remove.d.ts.map +1 -1
  119. package/dist/lib/guarded-remove.js +5 -4
  120. package/dist/lib/headless/index.d.ts +33 -0
  121. package/dist/lib/headless/index.d.ts.map +1 -1
  122. package/dist/lib/headless/index.js +38 -2
  123. package/dist/lib/http/client.d.ts +2 -9
  124. package/dist/lib/http/client.d.ts.map +1 -1
  125. package/dist/lib/http/client.js +30 -9
  126. package/dist/lib/instructions/apply.d.ts +9 -0
  127. package/dist/lib/instructions/apply.d.ts.map +1 -1
  128. package/dist/lib/instructions/apply.js +92 -14
  129. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  130. package/dist/lib/instructions/git-hooks.js +8 -4
  131. package/dist/lib/instructions/templates.d.ts +11 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +81 -1
  134. package/package.json +5 -5
  135. package/schemas/config.schema.json +89 -1
  136. package/src/commander.ts +98 -8
  137. package/src/commands/agents.ts +100 -32
  138. package/src/commands/autoupdate.ts +250 -0
  139. package/src/commands/computer.ts +682 -0
  140. package/src/commands/docs.ts +3 -1
  141. package/src/commands/fetch.ts +11 -1
  142. package/src/commands/grep.ts +274 -59
  143. package/src/commands/init.ts +17 -7
  144. package/src/commands/rm.ts +2 -2
  145. package/src/commands/web.ts +2 -0
  146. package/src/core/agents/canonical-emit.ts +2 -3
  147. package/src/core/agents/coord-bin.ts +30 -0
  148. package/src/core/agents/coord-client.ts +6 -2
  149. package/src/core/agents/render/prompt-context.ts +152 -53
  150. package/src/core/agents/rules/claim-conflict.ts +50 -18
  151. package/src/core/agents/rules/stop-hook.ts +28 -1
  152. package/src/core/agents/state/proc-start.ts +29 -5
  153. package/src/core/autoupdate/engine.ts +691 -0
  154. package/src/core/autoupdate/fingerprint.ts +89 -0
  155. package/src/core/autoupdate/git.ts +120 -0
  156. package/src/core/autoupdate/index.ts +29 -0
  157. package/src/core/autoupdate/state.ts +269 -0
  158. package/src/core/autoupdate/types.ts +135 -0
  159. package/src/core/config.ts +142 -1
  160. package/src/core/events/v3/bootstrap.ts +1 -0
  161. package/src/core/events/v3/runtime-build.ts +1 -0
  162. package/src/core/hooks/adapter/wiring.ts +51 -6
  163. package/src/core/hooks/autoupdate-cue.ts +72 -0
  164. package/src/core/hooks/cli.ts +54 -1
  165. package/src/core/hooks/effects/index.ts +4 -4
  166. package/src/core/hooks/resolve/coord-root.ts +1 -0
  167. package/src/core/hooks/resolve/transcript.ts +61 -0
  168. package/src/core/semantic/service.ts +6 -2
  169. package/src/core/storage/modes.ts +7 -1
  170. package/src/core/workflow/durable-record.ts +15 -1
  171. package/src/core/workflow/engine.ts +3 -0
  172. package/src/core/workflow/spawn-claude.ts +16 -4
  173. package/src/core/workflow/specialists.ts +1 -0
  174. package/src/core/workflow/types.ts +10 -0
  175. package/src/core/workflow/workspaces/leases.ts +1 -9
  176. package/src/lib/computer/cua.ts +572 -0
  177. package/src/lib/computer/host.ts +87 -0
  178. package/src/lib/computer/lease.ts +299 -0
  179. package/src/lib/computer/types.ts +177 -0
  180. package/src/lib/docs-metadata-v2.ts +24 -5
  181. package/src/lib/exec.ts +1 -0
  182. package/src/lib/guarded-remove.ts +5 -3
  183. package/src/lib/headless/index.ts +66 -2
  184. package/src/lib/http/client.ts +33 -19
  185. package/src/lib/instructions/apply.ts +100 -12
  186. package/src/lib/instructions/git-hooks.ts +8 -4
  187. package/src/lib/instructions/templates.ts +95 -1
@@ -0,0 +1,691 @@
1
+ /**
2
+ * Project self-update engine (ADR 0229).
3
+ *
4
+ * `autoUpdateTick` is the hot path every prompt, session start, and host CLI
5
+ * command calls: one stat of the throttle stamp when nothing is due. When the
6
+ * stamp is stale it claims the stamp and launches a detached
7
+ * `<harn> autoupdate run --background`, which decides with `checkForUpdate`
8
+ * and, only when needed, runs the host's configured steps under a lock.
9
+ */
10
+
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import {
13
+ appendFileSync,
14
+ closeSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readSync,
19
+ renameSync,
20
+ statSync,
21
+ utimesSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { dirname, extname, resolve, win32 } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { readAutoUpdateConfig, resolveBinName } from "../config.ts";
27
+ import { fingerprint } from "./fingerprint.ts";
28
+ import {
29
+ changedFiles,
30
+ contains,
31
+ defaultGitRunner,
32
+ type GitRunner,
33
+ head,
34
+ lsRemote,
35
+ upstream,
36
+ } from "./git.ts";
37
+ import {
38
+ acquireRunLock,
39
+ addNotice,
40
+ autoUpdateDir,
41
+ autoUpdateLogPath,
42
+ autoUpdateStampPath,
43
+ inspectLock,
44
+ type LockInspection,
45
+ type LockOwner,
46
+ mutateAutoUpdateState,
47
+ readAutoUpdateState,
48
+ } from "./state.ts";
49
+ import {
50
+ AUTOUPDATE_DEFAULTS,
51
+ AUTOUPDATE_FILES,
52
+ type AutoUpdateConfig,
53
+ type AutoUpdateNotice,
54
+ type AutoUpdateState,
55
+ type CheckDecision,
56
+ EXIT_NEEDS_PERSON,
57
+ NOTICE_PREFIX,
58
+ type NoticeKind,
59
+ type RunOutcome,
60
+ type RunRecord,
61
+ type StepRecord,
62
+ type TickResult,
63
+ } from "./types.ts";
64
+
65
+ /** Set on the detached runner and every step so nested CLI calls never tick. */
66
+ export const AUTOUPDATE_CHILD_ENV = "HARNERY_AUTOUPDATE_CHILD";
67
+
68
+ const MINUTE_MS = 60_000;
69
+ const DAY_MS = AUTOUPDATE_DEFAULTS.dailyHours * 60 * MINUTE_MS;
70
+ const LOG_ROTATE_BYTES = 256 * 1024;
71
+ const LOG_KEEP_BYTES = 64 * 1024;
72
+ const CLEANUP_TIMEOUT_MS = 5 * MINUTE_MS;
73
+ const KILL_GRACE_MS = 5_000;
74
+
75
+ export interface StepRunResult {
76
+ exitCode: number | null;
77
+ timedOut: boolean;
78
+ output: string;
79
+ ms: number;
80
+ }
81
+
82
+ export type StepRunner = (
83
+ argv: string[],
84
+ opts: {
85
+ cwd: string;
86
+ timeoutMs: number;
87
+ env: NodeJS.ProcessEnv;
88
+ onChunk?: (chunk: string, stream: "stdout" | "stderr") => void;
89
+ },
90
+ ) => Promise<StepRunResult>;
91
+
92
+ export interface AutoUpdateDeps {
93
+ now?: () => number;
94
+ env?: NodeJS.ProcessEnv;
95
+ readConfig?: (coordRoot: string) => AutoUpdateConfig | null;
96
+ git?: GitRunner;
97
+ /** Launch the detached runner (tick). */
98
+ spawnRunner?: (coordRoot: string) => void;
99
+ runStep?: StepRunner;
100
+ /** Expired-artifact cleanup; best effort. */
101
+ runCleanup?: (coordRoot: string) => void;
102
+ binName?: string;
103
+ }
104
+
105
+ interface Resolved {
106
+ now: () => number;
107
+ env: NodeJS.ProcessEnv;
108
+ readConfig: (coordRoot: string) => AutoUpdateConfig | null;
109
+ git: GitRunner;
110
+ }
111
+
112
+ function resolveDeps(deps: AutoUpdateDeps): Resolved {
113
+ return {
114
+ now: deps.now ?? Date.now,
115
+ env: deps.env ?? process.env,
116
+ readConfig: deps.readConfig ?? readAutoUpdateConfig,
117
+ git: deps.git ?? defaultGitRunner,
118
+ };
119
+ }
120
+
121
+ // ------------------------------------------------------------------ helpers
122
+
123
+ /** The CLI entry the detached runner executes with `process.execPath`. */
124
+ export function harnCliEntry(): string {
125
+ const ext = extname(fileURLToPath(import.meta.url)) || ".js";
126
+ const entry = fileURLToPath(new URL(`../../cli${ext}`, import.meta.url));
127
+ if (existsSync(entry)) return entry;
128
+ return fileURLToPath(new URL("../../../bin/harn.mjs", import.meta.url));
129
+ }
130
+
131
+ /** Set the throttle stamp's mtime (creating it), which is all the tick reads. */
132
+ export function setStamp(coordRoot: string, atMs: number): void {
133
+ const path = autoUpdateStampPath(coordRoot);
134
+ if (!existsSync(path)) {
135
+ mkdirSync(autoUpdateDir(coordRoot), { recursive: true });
136
+ writeFileSync(path, "");
137
+ }
138
+ const at = atMs / 1000;
139
+ utimesSync(path, at, at);
140
+ }
141
+
142
+ function stampMtime(coordRoot: string): number | null {
143
+ try {
144
+ return statSync(autoUpdateStampPath(coordRoot)).mtimeMs;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /** Keep the log bounded: past 256 KB, keep only its last 64 KB. */
151
+ export function rotateLog(path: string): void {
152
+ let size: number;
153
+ try {
154
+ size = statSync(path).size;
155
+ } catch {
156
+ return;
157
+ }
158
+ if (size <= LOG_ROTATE_BYTES) return;
159
+ const fd = openSync(path, "r");
160
+ const buffer = Buffer.alloc(LOG_KEEP_BYTES);
161
+ try {
162
+ readSync(fd, buffer, 0, LOG_KEEP_BYTES, size - LOG_KEEP_BYTES);
163
+ } finally {
164
+ closeSync(fd);
165
+ }
166
+ const tmp = `${path}.rotating`;
167
+ writeFileSync(tmp, buffer);
168
+ renameSync(tmp, path);
169
+ }
170
+
171
+ function appendLog(coordRoot: string, text: string): void {
172
+ try {
173
+ const path = autoUpdateLogPath(coordRoot);
174
+ mkdirSync(dirname(path), { recursive: true });
175
+ appendFileSync(path, text);
176
+ } catch {
177
+ // Logging is best effort.
178
+ }
179
+ }
180
+
181
+ function logLine(coordRoot: string, now: number, line: string): void {
182
+ appendLog(coordRoot, `${new Date(now).toISOString()} ${line}\n`);
183
+ }
184
+
185
+ function isChild(env: NodeJS.ProcessEnv): boolean {
186
+ return env[AUTOUPDATE_CHILD_ENV] === "1";
187
+ }
188
+
189
+ function dailyDue(cfg: AutoUpdateConfig, state: AutoUpdateState, now: number): boolean {
190
+ if (cfg.dailySteps.length === 0) return false;
191
+ if (!state.dailyAt) return true;
192
+ const at = Date.parse(state.dailyAt);
193
+ return !Number.isFinite(at) || now - at >= DAY_MS;
194
+ }
195
+
196
+ function short(sha: string | null): string {
197
+ return sha ? sha.slice(0, 7) : "unknown";
198
+ }
199
+
200
+ const INSTRUCTION_PREFIXES = [".agents/", ".claude/skills/", ".claude/rules/", ".cursor/rules/"];
201
+ const HOOK_PATHS = new Set([
202
+ ".claude/settings.json",
203
+ ".codex/hooks.json",
204
+ ".codex/config.toml",
205
+ ".cursor/hooks.json",
206
+ ]);
207
+
208
+ export function isInstructionPath(path: string): boolean {
209
+ const normalized = path.replace(/\\/g, "/");
210
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
211
+ if (base === "AGENTS.md" || base === "CLAUDE.md" || base === "SKILL.md") return true;
212
+ if (base.endsWith(".mdc")) return true;
213
+ return INSTRUCTION_PREFIXES.some((prefix) => normalized.startsWith(prefix));
214
+ }
215
+
216
+ export function isHookConfigPath(path: string): boolean {
217
+ return HOOK_PATHS.has(path.replace(/\\/g, "/"));
218
+ }
219
+
220
+ /** `harnery-notice:` lines in step output. */
221
+ export function parseNoticeLines(output: string): string[] {
222
+ const texts: string[] = [];
223
+ for (const raw of output.split(/\r?\n/)) {
224
+ const line = raw.trimStart();
225
+ if (!line.startsWith(NOTICE_PREFIX)) continue;
226
+ const text = line.slice(NOTICE_PREFIX.length).trim();
227
+ if (text) texts.push(text);
228
+ }
229
+ return texts;
230
+ }
231
+
232
+ // ------------------------------------------------------------------ tick
233
+
234
+ function defaultSpawnRunner(coordRoot: string): void {
235
+ const root = resolve(coordRoot);
236
+ const logPath = autoUpdateLogPath(root);
237
+ mkdirSync(dirname(logPath), { recursive: true });
238
+ rotateLog(logPath);
239
+ const fd = openSync(logPath, "a");
240
+ try {
241
+ const child = spawn(process.execPath, [harnCliEntry(), "autoupdate", "run", "--background"], {
242
+ cwd: root,
243
+ detached: true,
244
+ windowsHide: true,
245
+ stdio: ["ignore", fd, fd],
246
+ env: {
247
+ ...process.env,
248
+ [AUTOUPDATE_CHILD_ENV]: "1",
249
+ HARNERY_COORD_ROOT_OVERRIDE: root,
250
+ },
251
+ });
252
+ child.once("error", () => {
253
+ logLine(root, Date.now(), "runner could not start");
254
+ });
255
+ child.unref();
256
+ } finally {
257
+ closeSync(fd);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Activity hook. Never throws. When throttled it reads the config and stats
263
+ * one file; it runs no Git and parses no state.
264
+ */
265
+ export function autoUpdateTick(coordRoot: string, deps: AutoUpdateDeps = {}): TickResult {
266
+ try {
267
+ const d = resolveDeps(deps);
268
+ if (isChild(d.env)) return { action: "disabled", detail: "inside an auto-update run" };
269
+ const cfg = d.readConfig(coordRoot);
270
+ if (!cfg?.enabled) return { action: "disabled" };
271
+ const now = d.now();
272
+ const mtime = stampMtime(coordRoot);
273
+ if (mtime !== null && now - mtime < cfg.checkMinutes * MINUTE_MS) {
274
+ return { action: "throttled" };
275
+ }
276
+ const lock = inspectLock(coordRoot, cfg, now);
277
+ if (lock.held && !lock.stale) {
278
+ return { action: "locked", detail: lock.owner ? `pid ${lock.owner.pid}` : undefined };
279
+ }
280
+ // Claim before launching so concurrent ticks see a fresh stamp.
281
+ setStamp(coordRoot, now);
282
+ (deps.spawnRunner ?? defaultSpawnRunner)(coordRoot);
283
+ return { action: "launched" };
284
+ } catch (err) {
285
+ return { action: "error", detail: (err as Error)?.message ?? String(err) };
286
+ }
287
+ }
288
+
289
+ // ------------------------------------------------------------------ check
290
+
291
+ export function checkForUpdate(
292
+ coordRoot: string,
293
+ cfg: AutoUpdateConfig,
294
+ state: AutoUpdateState,
295
+ deps: AutoUpdateDeps & { force?: boolean } = {},
296
+ ): CheckDecision {
297
+ const d = resolveDeps(deps);
298
+ const now = d.now();
299
+ const root = resolve(coordRoot);
300
+ const fp = fingerprint(cfg.watch, root, d.env);
301
+ const currentHead = head(root, d.git);
302
+ const base = {
303
+ head: currentHead,
304
+ upstream: null as string | null,
305
+ remoteSha: null as string | null,
306
+ fingerprint: fp,
307
+ dailyOnly: false,
308
+ };
309
+ if (deps.force) return { ...base, needed: true, reason: "forced" };
310
+ const drift = !state.converged || state.converged.fingerprint !== fp;
311
+ const up = upstream(root, d.git);
312
+ if (!up) {
313
+ return drift
314
+ ? { ...base, needed: true, reason: "drift", detail: "watched paths changed" }
315
+ : { ...base, needed: false, reason: "no-upstream" };
316
+ }
317
+ const withUp = { ...base, upstream: up.display };
318
+ const remoteSha = lsRemote(root, up.remote, up.branch, d.git);
319
+ if (!remoteSha) {
320
+ return { ...withUp, needed: false, reason: "offline", detail: `could not reach ${up.remote}` };
321
+ }
322
+ const withRemote = { ...withUp, remoteSha };
323
+ if (!contains(root, remoteSha, d.git)) {
324
+ return {
325
+ ...withRemote,
326
+ needed: true,
327
+ reason: "behind",
328
+ detail: `${up.display} at ${short(remoteSha)}`,
329
+ };
330
+ }
331
+ if (drift)
332
+ return { ...withRemote, needed: true, reason: "drift", detail: "watched paths changed" };
333
+ const last = state.lastRun;
334
+ if (last?.outcome === "failed") {
335
+ const finished = Date.parse(last.finishedAt);
336
+ if (
337
+ !Number.isFinite(finished) ||
338
+ now - finished >= AUTOUPDATE_DEFAULTS.failureBackoffMinutes * MINUTE_MS
339
+ ) {
340
+ return { ...withRemote, needed: true, reason: "retry" };
341
+ }
342
+ }
343
+ if (dailyDue(cfg, state, now)) {
344
+ return { ...withRemote, needed: true, reason: "daily", dailyOnly: true };
345
+ }
346
+ return { ...withRemote, needed: false, reason: "current" };
347
+ }
348
+
349
+ // ------------------------------------------------------------------ run
350
+
351
+ /**
352
+ * Resolve a step's argv for the current platform. On Windows a bare command
353
+ * name such as `bun` or `git` is looked up on PATH with PATHEXT, because
354
+ * spawning without a shell does not try `.cmd` shims; a `.cmd` or `.bat`
355
+ * match runs through `cmd.exe`. Elsewhere the argv is used as is.
356
+ */
357
+ export function resolveStepCommand(
358
+ cmd: string,
359
+ args: string[],
360
+ env: NodeJS.ProcessEnv,
361
+ platform: NodeJS.Platform = process.platform,
362
+ exists: (path: string) => boolean = existsSync,
363
+ ): { cmd: string; args: string[]; verbatim: boolean } {
364
+ if (platform !== "win32" || /[\\/]/.test(cmd) || extname(cmd) !== "") {
365
+ return { cmd, args, verbatim: false };
366
+ }
367
+ const pathVar = env.Path ?? env.PATH ?? "";
368
+ const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
369
+ for (const dir of pathVar.split(";").filter(Boolean)) {
370
+ for (const ext of exts) {
371
+ const candidate = win32.join(dir, cmd + ext.toLowerCase());
372
+ if (!exists(candidate)) continue;
373
+ if (/^\.(cmd|bat)$/i.test(ext)) {
374
+ const quote = (s: string) => (/[\s"&|<>^]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
375
+ const line = [candidate, ...args].map(quote).join(" ");
376
+ return {
377
+ cmd: env.ComSpec ?? "cmd.exe",
378
+ args: ["/d", "/s", "/c", `"${line}"`],
379
+ verbatim: true,
380
+ };
381
+ }
382
+ return { cmd: candidate, args, verbatim: false };
383
+ }
384
+ }
385
+ return { cmd, args, verbatim: false };
386
+ }
387
+ export const defaultStepRunner: StepRunner = (argv, opts) =>
388
+ new Promise((resolveRun) => {
389
+ const started = Date.now();
390
+ let output = "";
391
+ let timedOut = false;
392
+ let settled = false;
393
+ const finish = (exitCode: number | null) => {
394
+ if (settled) return;
395
+ settled = true;
396
+ clearTimeout(timer);
397
+ resolveRun({ exitCode, timedOut, output, ms: Date.now() - started });
398
+ };
399
+ const [rawCmd, ...rawArgs] = argv;
400
+ if (!rawCmd) {
401
+ finish(null);
402
+ return;
403
+ }
404
+ const { cmd, args, verbatim } = resolveStepCommand(rawCmd, rawArgs, opts.env ?? process.env);
405
+ const child = spawn(cmd, args, {
406
+ cwd: opts.cwd,
407
+ env: opts.env,
408
+ windowsHide: true,
409
+ windowsVerbatimArguments: verbatim,
410
+ stdio: ["ignore", "pipe", "pipe"],
411
+ });
412
+ const onData = (stream: "stdout" | "stderr") => (data: Buffer) => {
413
+ const chunk = data.toString("utf8");
414
+ output += chunk;
415
+ opts.onChunk?.(chunk, stream);
416
+ };
417
+ child.stdout?.on("data", onData("stdout"));
418
+ child.stderr?.on("data", onData("stderr"));
419
+ const timer = setTimeout(() => {
420
+ timedOut = true;
421
+ child.kill("SIGTERM");
422
+ setTimeout(() => {
423
+ if (!settled) child.kill("SIGKILL");
424
+ }, KILL_GRACE_MS).unref();
425
+ }, opts.timeoutMs);
426
+ child.once("error", (err) => {
427
+ output += `${err.message}\n`;
428
+ finish(null);
429
+ });
430
+ child.once("exit", (code) => {
431
+ // A grandchild that keeps the pipes open must not hold the run.
432
+ setTimeout(() => finish(timedOut ? null : code), 2_000).unref();
433
+ });
434
+ child.once("close", (code) => finish(timedOut ? null : code));
435
+ });
436
+
437
+ function defaultCleanup(coordRoot: string): void {
438
+ spawnSync(process.execPath, [harnCliEntry(), "artifacts", "clean", "--yes"], {
439
+ cwd: coordRoot,
440
+ env: { ...process.env, [AUTOUPDATE_CHILD_ENV]: "1", HARNERY_COORD_ROOT_OVERRIDE: coordRoot },
441
+ stdio: "ignore",
442
+ timeout: CLEANUP_TIMEOUT_MS,
443
+ windowsHide: true,
444
+ });
445
+ }
446
+
447
+ export interface RunAutoUpdateOptions {
448
+ force?: boolean;
449
+ /** Detached runner: output goes to the log only. */
450
+ background?: boolean;
451
+ /** Foreground streaming of step output (in addition to the log). */
452
+ onOutput?: (chunk: string, stream: "stdout" | "stderr") => void;
453
+ deps?: AutoUpdateDeps;
454
+ }
455
+
456
+ export interface RunAutoUpdateResult {
457
+ outcome: RunOutcome;
458
+ decision: CheckDecision | null;
459
+ record: RunRecord | null;
460
+ /** Notices recorded by this run. */
461
+ notices: AutoUpdateNotice[];
462
+ /** Holder of the lock when `skipped-locked`. */
463
+ lockOwner?: LockOwner | null;
464
+ }
465
+
466
+ export async function runAutoUpdate(
467
+ coordRootRaw: string,
468
+ opts: RunAutoUpdateOptions = {},
469
+ ): Promise<RunAutoUpdateResult> {
470
+ const deps = opts.deps ?? {};
471
+ const d = resolveDeps(deps);
472
+ const root = resolve(coordRootRaw);
473
+ const cfg = d.readConfig(root);
474
+ if (!cfg || (!cfg.enabled && !opts.force)) {
475
+ return { outcome: "current", decision: null, record: null, notices: [] };
476
+ }
477
+ const startedMs = d.now();
478
+ const lock = acquireRunLock(root, cfg, startedMs);
479
+ if (!lock.acquired) {
480
+ return {
481
+ outcome: "skipped-locked",
482
+ decision: null,
483
+ record: null,
484
+ notices: [],
485
+ lockOwner: lock.owner,
486
+ };
487
+ }
488
+ const binName = deps.binName ?? resolveBinName(root);
489
+ const runStep = deps.runStep ?? defaultStepRunner;
490
+ const stepEnv: NodeJS.ProcessEnv = { ...d.env, [AUTOUPDATE_CHILD_ENV]: "1" };
491
+ const pending: Array<{ kind: NoticeKind; text: string }> = [];
492
+ const steps: StepRecord[] = [];
493
+ const startedAt = new Date(startedMs).toISOString();
494
+ let decision: CheckDecision | null = null;
495
+ let outcome: RunOutcome = "current";
496
+ let fromHead: string | null = null;
497
+ let toHead: string | null = null;
498
+ let error: string | undefined;
499
+ let convergedUpdate: AutoUpdateState["converged"] | undefined;
500
+ let dailyAt: string | undefined;
501
+ let backoff = false;
502
+
503
+ const say = (line: string) => {
504
+ logLine(root, d.now(), line);
505
+ opts.onOutput?.(`${line}\n`, "stderr");
506
+ };
507
+
508
+ // Returns "ok" | "blocked" | "failed".
509
+ const runPhase = async (
510
+ list: string[][],
511
+ label: string,
512
+ ): Promise<"ok" | "blocked" | "failed"> => {
513
+ for (const argv of list) {
514
+ const shown = argv.join(" ");
515
+ say(`${label} start: ${shown}`);
516
+ const result = await runStep(argv, {
517
+ cwd: root,
518
+ timeoutMs: cfg.timeoutMinutes * MINUTE_MS,
519
+ env: stepEnv,
520
+ onChunk: (chunk, stream) => {
521
+ appendLog(root, chunk);
522
+ opts.onOutput?.(chunk, stream);
523
+ },
524
+ });
525
+ steps.push({
526
+ argv,
527
+ exitCode: result.exitCode,
528
+ ms: result.ms,
529
+ ...(result.timedOut ? { timedOut: true } : {}),
530
+ });
531
+ const needsPerson = result.exitCode === EXIT_NEEDS_PERSON;
532
+ for (const text of parseNoticeLines(result.output)) {
533
+ pending.push({ kind: needsPerson ? "person" : "info", text });
534
+ }
535
+ say(
536
+ `${label} exit ${result.exitCode ?? "none"}${result.timedOut ? " (timed out)" : ""} in ${result.ms}ms: ${shown}`,
537
+ );
538
+ if (needsPerson) return "blocked";
539
+ if (result.exitCode !== 0) {
540
+ const how = result.timedOut
541
+ ? `timed out after ${cfg.timeoutMinutes} min`
542
+ : `exit ${result.exitCode ?? "none"}`;
543
+ pending.push({
544
+ kind: "failure",
545
+ text: `Auto-update step \`${shown}\` failed (${how}); see .harnery/logs/${AUTOUPDATE_FILES.log} or run \`${binName} autoupdate run\`.`,
546
+ });
547
+ return "failed";
548
+ }
549
+ }
550
+ return "ok";
551
+ };
552
+
553
+ try {
554
+ const state = readAutoUpdateState(root);
555
+ decision = checkForUpdate(root, cfg, state, { ...deps, force: opts.force });
556
+ fromHead = decision.head;
557
+ toHead = decision.head;
558
+ say(`check: ${decision.reason}${decision.detail ? ` (${decision.detail})` : ""}`);
559
+ if (!decision.needed) {
560
+ outcome = decision.reason === "offline" ? "offline" : "current";
561
+ } else {
562
+ let phase: "ok" | "blocked" | "failed" = "ok";
563
+ if (!decision.dailyOnly) phase = await runPhase(cfg.steps, "step");
564
+ if (phase === "ok") {
565
+ toHead = head(root, d.git);
566
+ if (fromHead && toHead && fromHead !== toHead) {
567
+ const changed = changedFiles(root, fromHead, toHead, d.git);
568
+ const moved = `This checkout updated ${short(fromHead)}→${short(toHead)}.`;
569
+ if (changed.some(isInstructionPath)) {
570
+ pending.push({
571
+ kind: "instructions",
572
+ text: `${moved} Re-read AGENTS.md and any skill you are using before continuing.`,
573
+ });
574
+ }
575
+ if (changed.some(isHookConfigPath)) {
576
+ pending.push({
577
+ kind: "hooks",
578
+ text: `${moved} Hook settings changed; start a new session so they load.`,
579
+ });
580
+ }
581
+ }
582
+ convergedUpdate = {
583
+ head: toHead ?? "",
584
+ remoteSha: decision.remoteSha,
585
+ fingerprint: fingerprint(cfg.watch, root, d.env),
586
+ at: new Date(d.now()).toISOString(),
587
+ };
588
+ if (dailyDue(cfg, state, d.now())) {
589
+ const daily = await runPhase(cfg.dailySteps, "daily");
590
+ if (daily !== "failed") dailyAt = new Date(d.now()).toISOString();
591
+ phase = daily;
592
+ }
593
+ }
594
+ outcome =
595
+ phase === "blocked"
596
+ ? "blocked"
597
+ : phase === "failed"
598
+ ? "failed"
599
+ : decision.dailyOnly
600
+ ? "current"
601
+ : "updated";
602
+ backoff = phase === "failed";
603
+ }
604
+ } catch (err) {
605
+ outcome = "failed";
606
+ backoff = true;
607
+ error = (err as Error)?.message ?? String(err);
608
+ say(`error: ${error}`);
609
+ }
610
+
611
+ const notices: AutoUpdateNotice[] = [];
612
+ const finishedMs = d.now();
613
+ const record: RunRecord = {
614
+ startedAt,
615
+ finishedAt: new Date(finishedMs).toISOString(),
616
+ outcome,
617
+ reason: decision?.reason ?? "forced",
618
+ fromHead,
619
+ toHead,
620
+ steps,
621
+ ...(error ? { error } : {}),
622
+ };
623
+ let cleanupAt: string | undefined;
624
+ try {
625
+ const priorState = readAutoUpdateState(root);
626
+ const lastCleanup = priorState.cleanupAt ? Date.parse(priorState.cleanupAt) : Number.NaN;
627
+ const cleanupDue =
628
+ cfg.cleanup &&
629
+ (outcome === "updated" || outcome === "current") &&
630
+ (!Number.isFinite(lastCleanup) || finishedMs - lastCleanup >= DAY_MS);
631
+ if (cleanupDue) {
632
+ try {
633
+ (deps.runCleanup ?? defaultCleanup)(root);
634
+ } catch {
635
+ // Cleanup is best effort.
636
+ }
637
+ cleanupAt = new Date(d.now()).toISOString();
638
+ }
639
+ mutateAutoUpdateState(root, (s) => {
640
+ s.lastRun = record;
641
+ if (convergedUpdate) s.converged = convergedUpdate;
642
+ if (dailyAt) s.dailyAt = dailyAt;
643
+ if (cleanupAt) s.cleanupAt = cleanupAt;
644
+ // A run that succeeded resolves earlier failures and person requests,
645
+ // so sessions that start later are not told about a fixed problem.
646
+ if (outcome === "updated" || outcome === "current") {
647
+ s.notices = s.notices.filter((n) => n.kind !== "failure" && n.kind !== "person");
648
+ }
649
+ for (const n of pending) notices.push(addNotice(s, n.kind, n.text, finishedMs));
650
+ });
651
+ // A failed run pushes the stamp forward so the tick stays one stat while backing off.
652
+ const backoffMs = AUTOUPDATE_DEFAULTS.failureBackoffMinutes * MINUTE_MS;
653
+ setStamp(root, backoff ? finishedMs + backoffMs - cfg.checkMinutes * MINUTE_MS : finishedMs);
654
+ say(`run ${outcome} (${record.reason})`);
655
+ } finally {
656
+ lock.release();
657
+ }
658
+ return { outcome, decision, record, notices };
659
+ }
660
+
661
+ // ------------------------------------------------------------------ status
662
+
663
+ export interface AutoUpdateStatus {
664
+ config: AutoUpdateConfig | null;
665
+ state: AutoUpdateState;
666
+ /** Milliseconds since the throttle stamp (negative while backing off); null when never checked. */
667
+ stampAge: number | null;
668
+ /** Milliseconds until a tick would launch a check; 0 when due. */
669
+ nextCheckMs: number | null;
670
+ locked: LockInspection;
671
+ }
672
+
673
+ export function autoUpdateStatus(
674
+ coordRootRaw: string,
675
+ deps: AutoUpdateDeps = {},
676
+ ): AutoUpdateStatus {
677
+ const d = resolveDeps(deps);
678
+ const root = resolve(coordRootRaw);
679
+ const config = d.readConfig(root);
680
+ const now = d.now();
681
+ const mtime = stampMtime(root);
682
+ const stampAge = mtime === null ? null : now - mtime;
683
+ const checkMs = (config?.checkMinutes ?? AUTOUPDATE_DEFAULTS.checkMinutes) * MINUTE_MS;
684
+ const nextCheckMs = stampAge === null ? 0 : Math.max(0, checkMs - stampAge);
685
+ const locked = inspectLock(
686
+ root,
687
+ config ?? { timeoutMinutes: AUTOUPDATE_DEFAULTS.timeoutMinutes, steps: [], dailySteps: [] },
688
+ now,
689
+ );
690
+ return { config, state: readAutoUpdateState(root), stampAge, nextCheckMs, locked };
691
+ }