@indigoai-us/hq-cli 5.345.11 → 5.345.12

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 (47) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/assets/scaffold/core/scripts/register-project.sh +5 -5
  3. package/assets/scaffold/core/scripts/repo-run-registry.sh +8 -8
  4. package/assets/scaffold/core/scripts/resume-thread-lock.sh +4 -4
  5. package/assets/scaffold/core/scripts/verify-story-deliverables.sh +1 -1
  6. package/assets/scaffold/core/scripts/worktree-gc.sh +1 -1
  7. package/dist/commands/core-handoff.js +69 -34
  8. package/dist/commands/core-hq-session-native.js +26 -5
  9. package/dist/commands/core-job-notify-fast.d.ts +2 -0
  10. package/dist/commands/core-job-notify-fast.js +6 -0
  11. package/dist/commands/core-job-probe-fast.d.ts +2 -0
  12. package/dist/commands/core-job-probe-fast.js +11 -0
  13. package/dist/commands/core-job-probe-native.js +1 -1
  14. package/dist/commands/core-job-remediate-fast.d.ts +2 -0
  15. package/dist/commands/core-job-remediate-fast.js +6 -0
  16. package/dist/commands/core-job-run-fast.d.ts +2 -0
  17. package/dist/commands/core-job-run-fast.js +6 -0
  18. package/dist/commands/core-pin-fast.d.ts +2 -0
  19. package/dist/commands/core-pin-fast.js +15 -0
  20. package/dist/commands/core-rebuild-index-fast.d.ts +6 -0
  21. package/dist/commands/core-rebuild-index-fast.js +39 -0
  22. package/dist/commands/core-register-project-native.js +11 -2
  23. package/dist/commands/core-repo-run-registry-native.js +47 -4
  24. package/dist/commands/core-session-journal-native.d.ts +1 -1
  25. package/dist/commands/core-session-journal-native.js +2 -2
  26. package/dist/commands/core-share-suggestion-state-native.js +6 -2
  27. package/dist/commands/core-work-mesh-live-rebind-native.js +5 -2
  28. package/dist/commands/core-worker-lint-fast.d.ts +2 -0
  29. package/dist/commands/core-worker-lint-fast.js +11 -0
  30. package/dist/commands/core.js +9 -6
  31. package/dist/fast-scaffold.js +49 -0
  32. package/dist/lib/core-utils/worktree-resume.d.ts +1 -0
  33. package/dist/lib/core-utils/worktree-resume.js +68 -31
  34. package/dist/lib/daemon/catalog.d.ts +10 -0
  35. package/dist/lib/daemon/catalog.js +8 -0
  36. package/dist/lib/daemon/lanes-reconcile-guard.d.ts +43 -0
  37. package/dist/lib/daemon/lanes-reconcile-guard.js +115 -0
  38. package/dist/lib/daemon/run.js +2 -0
  39. package/dist/lib/daemon/supervisor.d.ts +2 -0
  40. package/dist/lib/daemon/supervisor.js +28 -0
  41. package/dist/lib/lanes/autopilot-registry.d.ts +2 -5
  42. package/dist/lib/lanes/autopilot-registry.js +4 -4
  43. package/dist/lib/lanes/reconcile.d.ts +0 -5
  44. package/dist/lib/lanes/reconcile.js +4 -4
  45. package/dist/lib/lanes/types.d.ts +6 -5
  46. package/dist/utils/run-bundled-script.js +1 -1
  47. package/package.json +1 -1
@@ -28,7 +28,16 @@ function epoch(value, parseDate) {
28
28
  }
29
29
  return parseDate(value);
30
30
  }
31
+ function nativePath(value) {
32
+ if (process.platform === "win32") {
33
+ const gitBashPath = /^\/([A-Za-z])\/(.*)$/u.exec(value);
34
+ if (gitBashPath)
35
+ return `${gitBashPath[1]}:\\${gitBashPath[2].replaceAll("/", "\\")}`;
36
+ }
37
+ return value;
38
+ }
31
39
  function pathAbs(value, cwd, f) {
40
+ value = nativePath(value);
32
41
  if (value.startsWith("/"))
33
42
  return value;
34
43
  const parent = path.dirname(value);
@@ -40,8 +49,29 @@ function pathAbs(value, cwd, f) {
40
49
  return value;
41
50
  }
42
51
  }
52
+ function shellAbsolutePath(value) {
53
+ if (process.platform !== "win32")
54
+ return value;
55
+ // The shell companion stores drive paths with forward slashes. The shared
56
+ // shellDisplayPath helper emits Git Bash mount paths for command output, but
57
+ // that is not the registry's persisted path format.
58
+ if (/^\/[A-Za-z]\//u.test(value))
59
+ return value;
60
+ return path.win32.resolve(nativePath(value)).replaceAll("\\", "/");
61
+ }
62
+ function shellScope(value) {
63
+ return value.startsWith("worktree:") ? `worktree:${shellAbsolutePath(value.slice("worktree:".length))}` : value;
64
+ }
43
65
  function worktreeScopeCovers(target, scope) {
44
66
  const rawRoot = scope.slice("worktree:".length);
67
+ if (process.platform === "win32") {
68
+ const canonical = (value) => {
69
+ return path.win32.normalize(nativePath(value).replaceAll("/", "\\")).replace(/[\\/]+$/u, "").toLowerCase();
70
+ };
71
+ const canonicalTarget = canonical(target);
72
+ const canonicalRoot = canonical(rawRoot);
73
+ return canonicalTarget === canonicalRoot || canonicalTarget.startsWith(`${canonicalRoot}\\`);
74
+ }
45
75
  const trimmedRoot = rawRoot.replace(/\/+$/, "");
46
76
  const root = trimmedRoot === "" && rawRoot.startsWith("/") ? "/" : trimmedRoot;
47
77
  if (root === "/")
@@ -135,7 +165,10 @@ export function runRepoRunRegistry(args, deps) {
135
165
  f.rmSync(lockDir, { recursive: true, force: true });
136
166
  }
137
167
  catch {
138
- throw new Error(`ERROR: could not remove stale registry lock: ${lockDir}`);
168
+ const displayLock = process.platform === "win32"
169
+ ? `${root.replace(/[\\/]+$/u, "")}/workspace/orchestrator/active-runs.json.lock`
170
+ : lockDir;
171
+ throw new Error(`ERROR: could not remove stale registry lock: ${displayLock}`);
139
172
  }
140
173
  continue;
141
174
  }
@@ -157,6 +190,10 @@ export function runRepoRunRegistry(args, deps) {
157
190
  const value = Number(raw);
158
191
  if (!Number.isFinite(value))
159
192
  return false;
193
+ // Git Bash's kill -0 -1 probes all signalable processes and succeeds on
194
+ // Windows; Node's process.kill does not accept the same sentinel there.
195
+ if (process.platform === "win32" && value === -1)
196
+ return true;
160
197
  try {
161
198
  return deps.isPidAlive ? deps.isPidAlive(value) : (process.kill(value, 0), true);
162
199
  }
@@ -207,7 +244,7 @@ export function runRepoRunRegistry(args, deps) {
207
244
  catch { /* keep resolved path */ }
208
245
  while (dir !== "/" && dir) {
209
246
  if (f.existsSync(path.join(dir, ".git")))
210
- return dir;
247
+ return process.platform === "win32" ? shellAbsolutePath(dir) : dir;
211
248
  const parent = path.dirname(dir);
212
249
  if (parent === dir)
213
250
  break;
@@ -256,7 +293,8 @@ export function runRepoRunRegistry(args, deps) {
256
293
  let runId = valueAfter(rest, "--run-id"), runPid = valueAfter(rest, "--pid"), sessionId = valueAfter(rest, "--session-id"), command = valueAfter(rest, "--command"), project = valueAfter(rest, "--project"), repo = valueAfter(rest, "--repo"), scope = valueAfter(rest, "--scope"), host = valueAfter(rest, "--host");
257
294
  if (!runPid || !command || !repo || !scope)
258
295
  return die("register requires --pid --command --repo --scope");
259
- repo = pathAbs(repo, deps.cwd, f);
296
+ repo = shellAbsolutePath(pathAbs(repo, deps.cwd, f));
297
+ scope = shellScope(scope);
260
298
  if (!runId)
261
299
  runId = `${command.replaceAll("/", "-")}-${Math.floor(now().getTime() / 1000)}-${project || "none"}-${runPid}`;
262
300
  if (!host)
@@ -357,7 +395,12 @@ export function runRepoRunRegistry(args, deps) {
357
395
  const owners = runs.filter((run) => covers(run) && String(run?.pid) !== myPid && run?.session_id !== mySession);
358
396
  if (!owners.length)
359
397
  return output();
360
- const lines = owners.map((run) => `run_id=${run?.run_id} pid=${run?.pid} command=${run?.command} project=${run?.project} scope=${run?.scope} repo=${run?.repo_path} started=${run?.started_at} heartbeat=${run?.heartbeat_at}`).join("\n") + "\n";
398
+ const lines = owners.map((run) => {
399
+ // This diagnostic interpolates the stored fields directly in Bash.
400
+ // Keep their original spelling (including a trailing slash) rather
401
+ // than canonicalizing paths only on the native side.
402
+ return `run_id=${run?.run_id} pid=${run?.pid} command=${run?.command} project=${run?.project} scope=${run?.scope} repo=${run?.repo_path} started=${run?.started_at} heartbeat=${run?.heartbeat_at}`;
403
+ }).join("\n") + "\n";
361
404
  return output(2, "", lines);
362
405
  }
363
406
  return die(`unknown subcommand: ${sub}`);
@@ -10,6 +10,6 @@ export type SessionJournalOperationResult = {
10
10
  /** Execute one native helper call while retaining its two output byte streams. */
11
11
  export declare function runSessionJournalOperation(args: readonly string[], _stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): SessionJournalOperationResult;
12
12
  /** Native implementation of the fail-soft session journal helper. */
13
- export declare function runSessionJournalFast(args: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): number;
13
+ export declare function runSessionJournalFast(args: readonly string[], _stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): number;
14
14
  export {};
15
15
  //# sourceMappingURL=core-session-journal-native.d.ts.map
@@ -243,8 +243,8 @@ export function runSessionJournalOperation(args, _stdin, cwd, hqRoot, dependenci
243
243
  return { code, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) };
244
244
  }
245
245
  /** Native implementation of the fail-soft session journal helper. */
246
- export function runSessionJournalFast(args, stdin, cwd, hqRoot, dependencies = {}) {
247
- const result = runSessionJournalOperation(args, stdin, cwd, hqRoot, dependencies);
246
+ export function runSessionJournalFast(args, _stdin, cwd, hqRoot, dependencies = {}) {
247
+ const result = runSessionJournalOperation(args, _stdin, cwd, hqRoot, dependencies);
248
248
  process.stdout.write(result.stdout);
249
249
  process.stderr.write(result.stderr);
250
250
  return result.code;
@@ -1,6 +1,7 @@
1
1
  /** Native port of share-suggestion-state.sh; kept dependency-light for hooks. */
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { shellDisplayPath } from "../lib/core-utils/shell-display-path.js";
4
5
  import { isByteOrientedLocale } from "../lib/core-utils/byte-oriented-locale.js";
5
6
  export const SHARE_SUGGESTION_STATE_ASSET = "core/scripts/share-suggestion-state.sh";
6
7
  const isObj = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -170,10 +171,13 @@ export function runShareSuggestionState(args, stdin, deps) {
170
171
  }
171
172
  catch { /* locate the first existing non-directory parent when present */ }
172
173
  }
174
+ const displayBlockedPath = platform === "win32"
175
+ ? shellDisplayPath(blockedPath)
176
+ : blockedPath;
173
177
  const reason = code === "ENOTDIR" ? "Not a directory" : code === "EACCES" || code === "EPERM" ? "Permission denied" : code === "EEXIST" ? "File exists" : (error instanceof Error ? error.message : String(error));
174
178
  const mkdirError = platform === "darwin"
175
- ? `mkdir: ${blockedPath}: ${reason}\n`
176
- : `mkdir: cannot create directory ‘${blockedPath}’: ${reason}\n`;
179
+ ? `mkdir: ${displayBlockedPath}: ${reason}\n`
180
+ : `mkdir: cannot create directory ‘${displayBlockedPath}’: ${reason}\n`;
177
181
  return failFromShellMain(`${mkdirError}share-suggestion-state: unable to create state directory\n`);
178
182
  }
179
183
  const sessionUnits = platform === "darwin" && !isByteOrientedLocale(env, platform)
@@ -4,6 +4,7 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { generateUlid } from "../lib/mesh/live/ulid.js";
6
6
  import { formatSpoolLine } from "../lib/mesh/live/format-spool-line.js";
7
+ import { shellDisplayPath } from "../lib/core-utils/shell-display-path.js";
7
8
  export const WORK_MESH_LIVE_REBIND_ASSET = "core/scripts/work-mesh-live-rebind.sh";
8
9
  const WORK_MESH_LIVE_REBIND_LIBRARY_ASSET = "core/scripts/lib/work-mesh-live-rebind.sh";
9
10
  const USAGE = "usage: work-mesh-live-rebind.sh --session <sid> ...\n";
@@ -118,7 +119,7 @@ function enqueueLifecycle(kind, sid, harness, adapter, company, project, task, d
118
119
  at: `${now.toISOString().slice(0, 19)}.000Z`,
119
120
  seq,
120
121
  cwd: deps.env.PWD ?? "",
121
- hqRoot: deps.env.HQ_ROOT ?? deps.root,
122
+ hqRoot: shellDisplayPath(deps.env.HQ_ROOT ?? deps.root),
122
123
  };
123
124
  if (company)
124
125
  event.companySlug = company;
@@ -247,7 +248,9 @@ export function runWorkMeshLiveRebind(args, deps) {
247
248
  : cause?.code === "ENOSPC" ? "No space left on device"
248
249
  : "Input/output error";
249
250
  const script = path.join(deps.root, WORK_MESH_LIVE_REBIND_LIBRARY_ASSET);
250
- return { code: 1, stdout: "", stderr: `${script}: line 34: ${error.target}: ${message}\n` };
251
+ const scriptPath = shellDisplayPath(script);
252
+ const targetPath = shellDisplayPath(error.target);
253
+ return { code: 1, stdout: "", stderr: `${scriptPath}: line 34: ${targetPath}: ${message}\n` };
251
254
  }
252
255
  }
253
256
  export async function runWorkMeshLiveRebindFast(args, _stdin, cwd, root) {
@@ -0,0 +1,2 @@
1
+ export declare function runWorkerSkillLintFast(args: readonly string[], _stdin: string, cwd: string): number;
2
+ //# sourceMappingURL=core-worker-lint-fast.d.ts.map
@@ -0,0 +1,11 @@
1
+ /** Isolated fast-entry adapter for native worker-skill linting. */
2
+ import { runWorkerSkillLint } from "./core-worker-lint-native.js";
3
+ export function runWorkerSkillLintFast(args, _stdin, cwd) {
4
+ const result = runWorkerSkillLint([...args], { cwd });
5
+ if (result.stdout)
6
+ process.stdout.write(result.stdout);
7
+ if (result.stderr)
8
+ process.stderr.write(result.stderr);
9
+ return result.code;
10
+ }
11
+ //# sourceMappingURL=core-worker-lint-fast.js.map
@@ -631,12 +631,15 @@ export function registerCoreCommands(program) {
631
631
  }
632
632
  });
633
633
  describeCoreCommand(rebuildCommand, {
634
- entries: REBUILD_INDEX_TARGETS.map((entry) => ({
635
- name: `rebuild-index ${entry.target}`,
636
- execution: "native",
637
- asset: entry.asset,
638
- root: "live",
639
- })),
634
+ entries: [
635
+ { name: "rebuild-index", execution: "native", root: "live" },
636
+ ...REBUILD_INDEX_TARGETS.map((entry) => ({
637
+ name: `rebuild-index ${entry.target}`,
638
+ execution: "native",
639
+ asset: entry.asset,
640
+ root: "live",
641
+ })),
642
+ ],
640
643
  });
641
644
  for (const entry of SCAFFOLD_COMMANDS) {
642
645
  const command = core
@@ -11,6 +11,55 @@
11
11
  */
12
12
  /** The relocated scripts that qualify for the entrypoint fast path. */
13
13
  export const FAST_SCAFFOLD_SCRIPTS = [
14
+ ...["notify", "probe", "remediate", "run"].map((name) => ({
15
+ name: `job ${name}`,
16
+ kind: "native",
17
+ modulePath: `./commands/core-job-${name}-fast.js`,
18
+ exportName: name === "notify" ? "runJobNotifyFast"
19
+ : name === "probe" ? "runJobProbeFast"
20
+ : name === "remediate" ? "runJobRemediateFast"
21
+ : "runJobFast",
22
+ flagKey: "hq-core-native-fast-route",
23
+ familyFlagKey: "core-native-jobs",
24
+ asset: `core/scripts/hq-job-${name}.sh`,
25
+ root: "live",
26
+ shellCwd: "live",
27
+ shellHqRoot: "live",
28
+ requiresNodeNetworkCompatibility: name === "probe" || name === "run",
29
+ })),
30
+ {
31
+ name: "pin",
32
+ kind: "native",
33
+ modulePath: "./commands/core-pin-fast.js",
34
+ exportName: "runPinFast",
35
+ flagKey: "hq-core-native-fast-route",
36
+ familyFlagKey: "core-native-utilities",
37
+ asset: "core/scripts/hq-pin.sh",
38
+ root: "live",
39
+ shellCwd: "live",
40
+ shellHqRoot: "live",
41
+ },
42
+ {
43
+ name: "worker lint",
44
+ kind: "native",
45
+ modulePath: "./commands/core-worker-lint-fast.js",
46
+ exportName: "runWorkerSkillLintFast",
47
+ flagKey: "hq-core-native-fast-route",
48
+ familyFlagKey: "core-native-worker-skills",
49
+ asset: "core/scripts/lint-shared-worker-skills.sh",
50
+ root: "cwd",
51
+ shellCwd: "caller",
52
+ shellHqRoot: "none",
53
+ },
54
+ {
55
+ name: "rebuild-index",
56
+ kind: "native",
57
+ modulePath: "./commands/core-rebuild-index-fast.js",
58
+ exportName: "runRebuildIndexFast",
59
+ flagKey: "hq-core-native-fast-route",
60
+ root: "live",
61
+ fallbackToCommandGraph: true,
62
+ },
14
63
  {
15
64
  name: "checkpoint-stop-gate",
16
65
  kind: "native",
@@ -9,6 +9,7 @@ export type CoreIo = {
9
9
  env?: NodeJS.ProcessEnv;
10
10
  cwd?: string;
11
11
  now?: () => Date;
12
+ randomBytes?: (size: number) => Buffer;
12
13
  };
13
14
  export declare function worktreeGcPathKey(value: string): string;
14
15
  export declare function runWorktreeGc(args: readonly string[], hqRoot: string, io?: CoreIo): number;
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { serializeJqJson } from "./jq-json-serializer.js";
8
+ import { shellDisplayPath } from "./shell-display-path.js";
8
9
  export const WORKTREE_GC_ASSET = "core/scripts/worktree-gc.sh";
9
10
  export const CLEAN_HANDOFF_ASSET = "core/scripts/clean-worktree-reconcile-handoff.sh";
10
11
  export const RESUME_LOCK_ASSET = "core/scripts/resume-thread-lock.sh";
@@ -13,6 +14,7 @@ const stdout = (io, value) => (io.stdout ?? ((v) => process.stdout.write(v)))(va
13
14
  const stderr = (io, value) => (io.stderr ?? ((v) => process.stderr.write(v)))(value);
14
15
  const envOf = (io) => io.env ?? process.env;
15
16
  const nowOf = (io) => (io.now ?? (() => new Date()))();
17
+ const randomBytesOf = (io, size) => (io.randomBytes ?? randomBytes)(size);
16
18
  const stripTrailingNewlines = (value) => value.replace(/\n+$/u, "");
17
19
  function git(args, options = {}) {
18
20
  const childEnv = options.env;
@@ -61,6 +63,23 @@ function fileExists(file) {
61
63
  return false;
62
64
  }
63
65
  }
66
+ function sameDirectory(left, right) {
67
+ try {
68
+ const a = fs.statSync(left), b = fs.statSync(right);
69
+ if (a.dev !== 0 && b.dev !== 0 && a.ino !== 0 && b.ino !== 0)
70
+ return a.dev === b.dev && a.ino === b.ino;
71
+ }
72
+ catch { /* compare canonical spellings below */ }
73
+ const canonical = (value) => path.resolve(value).replace(/^\\\\\?\\/u, "").replaceAll("/", "\\").toLowerCase();
74
+ return canonical(left) === canonical(right);
75
+ }
76
+ function shellJsonPath(value) {
77
+ const display = shellDisplayPath(value);
78
+ if (process.platform !== "win32")
79
+ return display;
80
+ const match = /^\/([a-z])\/(.*)$/u.exec(display);
81
+ return match?.[1] && match[2] !== undefined ? `${match[1].toUpperCase()}:/${match[2]}` : display;
82
+ }
64
83
  function statMtime(file) {
65
84
  try {
66
85
  return Math.floor(fs.statSync(file).mtimeMs / 1000);
@@ -119,7 +138,10 @@ function sessionReferences(hqRoot, worktree) {
119
138
  path.join(hqRoot, "workspace/threads/handoff.json"),
120
139
  path.join(hqRoot, "workspace/locks"),
121
140
  ];
122
- const needle = Buffer.from(worktree, "utf8");
141
+ // The shell receives the Git Bash spelling of this path. Session references
142
+ // written by shell hooks use that spelling too, while Node's win32 path is
143
+ // drive-letter based. Compare the representation the shell actually sees.
144
+ const needle = Buffer.from(shellDisplayPath(worktree), "utf8");
123
145
  const visit = (target) => {
124
146
  let info;
125
147
  try {
@@ -187,7 +209,16 @@ function duSizeKb(root) {
187
209
  return;
188
210
  if (info.isFile())
189
211
  seen.add(inode);
190
- bytes += typeof info.blocks === "number" ? info.blocks * 512 : info.size;
212
+ // Windows Git Bash du counts file payloads in 1 KiB units. Node's Windows
213
+ // directory sizes include metadata that du does not count, so omit those
214
+ // directory entries and round file payloads as du -sk does.
215
+ bytes += process.platform === "win32" && info.isDirectory()
216
+ ? 0
217
+ : typeof info.blocks === "number" && !(process.platform === "win32" && info.blocks === 0)
218
+ ? info.blocks * 512
219
+ : process.platform === "win32"
220
+ ? Math.max(1, Math.ceil(info.size / 1024)) * 1024
221
+ : info.size;
191
222
  if (info.isDirectory()) {
192
223
  let children;
193
224
  try {
@@ -338,9 +369,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
338
369
  const skip = (wt, repo, branch, isHqRoot, ageDays, sizeKb, reason) => {
339
370
  skipped++;
340
371
  if (reason === "remove-failed")
341
- record({ path: wt, repo, branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "skipped", reason });
372
+ record({ path: shellJsonPath(wt), repo: shellJsonPath(repo), branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "skipped", reason });
342
373
  if (!json)
343
- stdout(io, ` skip [${reason}] ${wt}\n`);
374
+ stdout(io, ` skip [${reason}] ${shellDisplayPath(wt)}\n`);
344
375
  };
345
376
  const runFetch = (repo) => {
346
377
  const timeoutName = executableOnPath("timeout", env) ? "timeout" : executableOnPath("gtimeout", env) ? "gtimeout" : undefined;
@@ -372,11 +403,7 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
372
403
  skip(wt, "", "", false, null, null, "git-error");
373
404
  continue;
374
405
  }
375
- let isHqRoot = false;
376
- try {
377
- isHqRoot = fs.realpathSync(mainRepo) === fs.realpathSync(hqRoot);
378
- }
379
- catch { /* both paths are already resolved by the core root resolver */ }
406
+ const isHqRoot = sameDirectory(mainRepo, hqRoot);
380
407
  const branchResult = gitText(["-C", wt, "symbolic-ref", "--short", "HEAD"]);
381
408
  const branch = branchResult.status === 0 ? commandOutput(branchResult) : "";
382
409
  const headResult = gitText(["-C", wt, "rev-parse", "HEAD"]);
@@ -401,7 +428,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
401
428
  continue;
402
429
  }
403
430
  let createdEpoch = wtMtime;
404
- const stampFile = path.join(metaDir, `${worktreeGcPathKey(wt)}.json`);
431
+ // worktree-gc.sh hashes the path as Bash/Git Bash spells it. On Windows a
432
+ // native path (C:\...) and the shell path (/c/...) have different keys.
433
+ const stampFile = path.join(metaDir, `${worktreeGcPathKey(shellDisplayPath(wt))}.json`);
405
434
  if (fileExists(stampFile)) {
406
435
  try {
407
436
  const stamp = JSON.parse(fs.readFileSync(stampFile, "utf8"));
@@ -465,9 +494,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
465
494
  if (!apply) {
466
495
  would++;
467
496
  reclaimedKb += sizeKb;
468
- record({ path: wt, repo: mainRepo, branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "would-remove", reason: "safe" });
497
+ record({ path: shellJsonPath(wt), repo: shellJsonPath(mainRepo), branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "would-remove", reason: "safe" });
469
498
  if (!json)
470
- stdout(io, ` DRY would remove (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${wt}\n`);
499
+ stdout(io, ` DRY would remove (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${shellDisplayPath(wt)}\n`);
471
500
  continue;
472
501
  }
473
502
  if (wtGit(mainRepo, isHqRoot, ["worktree", "remove", wt]).status !== 0) {
@@ -483,9 +512,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
483
512
  catch { /* rm -f */ }
484
513
  removed++;
485
514
  reclaimedKb += sizeKb;
486
- record({ path: wt, repo: mainRepo, branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "removed", reason: "safe" });
515
+ record({ path: shellJsonPath(wt), repo: shellJsonPath(mainRepo), branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "removed", reason: "safe" });
487
516
  if (!json)
488
- stdout(io, ` GC removed (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${wt}\n`);
517
+ stdout(io, ` GC removed (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${shellDisplayPath(wt)}\n`);
489
518
  }
490
519
  const reclaimedGb = Number((reclaimedKb / 1024 / 1024).toFixed(2));
491
520
  if (json) {
@@ -554,18 +583,18 @@ export function runCleanWorktreeReconcileHandoff(args, io = {}) {
554
583
  }
555
584
  const rawRoot = args[0] ?? "";
556
585
  const branch = args[1] ?? "";
557
- if (!rawRoot.startsWith("/")) {
586
+ if (!rawRoot.startsWith("/") && !(process.platform === "win32" && path.win32.isAbsolute(rawRoot))) {
558
587
  stderr(io, `ERROR: HQ root must be an absolute path\n`);
559
588
  cleanUsage(io);
560
589
  return 64;
561
590
  }
562
591
  if (!fileExists(rawRoot) || !fs.statSync(rawRoot).isDirectory()) {
563
- return cleanGitError(io, `ERROR: HQ root is not a directory: ${rawRoot}`);
592
+ return cleanGitError(io, `ERROR: HQ root is not a directory: ${shellDisplayPath(rawRoot)}`);
564
593
  }
565
594
  const hqRoot = realPathOrResolved(rawRoot);
566
595
  const top = gitText(["-C", hqRoot, "rev-parse", "--show-toplevel"]);
567
596
  if (top.status !== 0)
568
- return cleanGitError(io, `ERROR: HQ root is not a Git worktree: ${hqRoot}`);
597
+ return cleanGitError(io, `ERROR: HQ root is not a Git worktree: ${shellDisplayPath(hqRoot)}`);
569
598
  const topLevel = realPathOrResolved(commandOutput(top));
570
599
  if (topLevel !== hqRoot)
571
600
  return cleanGitError(io, `ERROR: supplied path is not the Git worktree root: ${hqRoot}`);
@@ -640,7 +669,7 @@ export function runCleanWorktreeReconcileHandoff(args, io = {}) {
640
669
  let movedFile = "";
641
670
  try {
642
671
  for (let attempt = 0; attempt < 10; attempt++) {
643
- const candidate = path.join(destinationDir, `.clean-worktree-handoff.${randomBytes(4).toString("hex").slice(0, 6)}`);
672
+ const candidate = path.join(destinationDir, `.clean-worktree-handoff.${randomBytesOf(io, 4).toString("hex").slice(0, 6)}`);
644
673
  try {
645
674
  fs.writeFileSync(candidate, "", { flag: "wx", mode: 0o600 });
646
675
  movedFile = candidate;
@@ -769,12 +798,12 @@ function lockState(threadId, relativeLockPath, lockDir, recordPath, staleSeconds
769
798
  }
770
799
  return locked(record.session_id, record.resumed_at, record.generation);
771
800
  }
772
- function writeLockRecord(lockDir, recordPath, threadId, sessionId, now) {
801
+ function writeLockRecord(lockDir, recordPath, threadId, sessionId, now, io) {
773
802
  const epoch = Math.floor(now.getTime() / 1000);
774
803
  const timestamp = now.toISOString().replace(/\.\d{3}Z$/u, "Z");
775
804
  let temporary = "";
776
805
  for (let attempt = 0; attempt < 10; attempt++) {
777
- const generation = `.current.${randomBytes(8).toString("base64url").slice(0, 6)}`;
806
+ const generation = `.current.${randomBytesOf(io, 8).toString("base64url").slice(0, 6)}`;
778
807
  const candidate = path.join(lockDir, generation);
779
808
  try {
780
809
  fs.writeFileSync(candidate, serializeJqJson({ version: 1, thread_id: threadId, session_id: sessionId, resumed_at: timestamp, resumed_epoch: epoch, generation }, "compact"), { flag: "wx", mode: 0o600 });
@@ -858,7 +887,7 @@ export function runResumeThreadLock(args, hqRoot, io = {}) {
858
887
  fs.mkdirSync(locksDir, { recursive: true });
859
888
  try {
860
889
  fs.mkdirSync(lockDir);
861
- const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now());
890
+ const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now(), io);
862
891
  emit({ status: "acquired", thread_id: threadId, lock_path: relativeLockPath, session_id: sessionId, lock_generation: generation });
863
892
  return 0;
864
893
  }
@@ -915,7 +944,7 @@ export function runResumeThreadLock(args, hqRoot, io = {}) {
915
944
  emit(current);
916
945
  return 4;
917
946
  }
918
- const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now());
947
+ const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now(), io);
919
948
  emit({ status: "acquired", thread_id: threadId, lock_path: relativeLockPath, session_id: sessionId, lock_generation: generation });
920
949
  return 0;
921
950
  }
@@ -1008,7 +1037,7 @@ async function drainResponseBody(response) {
1008
1037
  export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1009
1038
  const env = envOf(io);
1010
1039
  let prdPath = "", storyId = "", repo = "";
1011
- let json = false, strict = false, write = false;
1040
+ let json = false, strict = false, write = false, repoExplicit = false;
1012
1041
  let evidenceJson = "[]", commitsJson = "[]";
1013
1042
  let offline = env.HQ_DELIVERABLES_OFFLINE ?? "0";
1014
1043
  for (let index = 0; index < args.length;) {
@@ -1021,8 +1050,10 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1021
1050
  prdPath = value;
1022
1051
  else if (arg === "--story")
1023
1052
  storyId = value;
1024
- else if (arg === "--repo")
1053
+ else if (arg === "--repo") {
1025
1054
  repo = value;
1055
+ repoExplicit = true;
1056
+ }
1026
1057
  else if (arg === "--evidence-json")
1027
1058
  evidenceJson = value;
1028
1059
  else
@@ -1058,9 +1089,12 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1058
1089
  stderr(io, `usage: --prd <prd.json> --story <id> [--repo <path>]\n`);
1059
1090
  return 2;
1060
1091
  }
1092
+ const displayedPrdPath = process.platform === "win32" && path.win32.isAbsolute(prdPath)
1093
+ ? shellDisplayPath(prdPath)
1094
+ : prdPath;
1061
1095
  const prdFilePath = path.resolve(hqRoot, prdPath);
1062
1096
  if (!isReadable(prdFilePath)) {
1063
- stderr(io, `verify-story-deliverables: cannot read ${prdPath}\n`);
1097
+ stderr(io, `verify-story-deliverables: cannot read ${displayedPrdPath}\n`);
1064
1098
  return 2;
1065
1099
  }
1066
1100
  let prd;
@@ -1073,7 +1107,7 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1073
1107
  const userStories = Array.isArray(prd.userStories) ? prd.userStories : [];
1074
1108
  const story = userStories.find((item) => item && item.id === storyId);
1075
1109
  if (!story) {
1076
- stderr(io, `verify-story-deliverables: story ${storyId} not in ${prdPath}\n`);
1110
+ stderr(io, `verify-story-deliverables: story ${storyId} not in ${displayedPrdPath}\n`);
1077
1111
  return 2;
1078
1112
  }
1079
1113
  if (!repo) {
@@ -1081,7 +1115,10 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1081
1115
  ? prd.metadata : {};
1082
1116
  repo = jqText(metadata.repoPath);
1083
1117
  }
1084
- if (repo && !repo.startsWith("/"))
1118
+ // The shell sees Git Bash's /c/... spelling, which remains absolute. Native
1119
+ // receives the corresponding Win32 C:\\... path; preserve that absolute
1120
+ // location instead of prefixing HQ_ROOT and pointing at a nonexistent path.
1121
+ if (repo && !repo.startsWith("/") && !(repoExplicit && path.win32.isAbsolute(repo)))
1085
1122
  repo = `${hqRoot}/${repo}`;
1086
1123
  const evidence = jsonDecode(evidenceJson);
1087
1124
  if (!jqArrayCheck(evidence)) {
@@ -1099,7 +1136,7 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1099
1136
  // deliverables are left to jq's normal processing behavior.
1100
1137
  const refsRaw = declaredRaw;
1101
1138
  if (!Array.isArray(refsRaw)) {
1102
- stderr(io, `verify-story-deliverables: story ${storyId} not in ${prdPath}\n`);
1139
+ stderr(io, `verify-story-deliverables: story ${storyId} not in ${displayedPrdPath}\n`);
1103
1140
  return 2;
1104
1141
  }
1105
1142
  }
@@ -1138,11 +1175,11 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1138
1175
  else if (kind === "repo-path")
1139
1176
  ok = Boolean(repo) && fileExists(`${repo}/${value}`);
1140
1177
  else if (kind === "branch" && repo && fileExists(repo)) {
1141
- ok = git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/heads/${value}`]).status === 0 ||
1142
- git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${value}`]).status === 0;
1178
+ ok = git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/heads/${value}`], { env }).status === 0 ||
1179
+ git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${value}`], { env }).status === 0;
1143
1180
  }
1144
1181
  else if (kind === "commit" && repo && fileExists(repo)) {
1145
- ok = git(["-C", repo, "cat-file", "-e", `${value}^{commit}`]).status === 0;
1182
+ ok = git(["-C", repo, "cat-file", "-e", `${value}^{commit}`], { env }).status === 0;
1146
1183
  }
1147
1184
  else if (kind === "url")
1148
1185
  ok = offline === "1" || await urlIsPresent(value);
@@ -65,6 +65,16 @@ export interface UnitPlan {
65
65
  drainSignal?: "SIGUSR2";
66
66
  /** A new CLI version alone does not restart this unit; it restarts itself when idle. */
67
67
  keepAcrossCliUpdates?: true;
68
+ /**
69
+ * Scheduled units only: before each timed run the supervisor asks
70
+ * `SupervisorDeps.runGuard` whether there is work. `spawn` runs as usual,
71
+ * `idle` runs at most once per `idleIntervalSec`, `skip` does not run. A
72
+ * guard error, or no guard in the supervisor, runs as usual.
73
+ */
74
+ runGuard?: {
75
+ name: "lanes-reconcile";
76
+ idleIntervalSec: number;
77
+ };
68
78
  }
69
79
  export interface PlanContext {
70
80
  machineType: MachineType;
@@ -230,10 +230,18 @@ function planService(service, ctx) {
230
230
  return [
231
231
  base(service, {
232
232
  kind: "scheduled",
233
+ // Polls every 15 s so keep-alive resumes are admitted on time (#1396).
234
+ // Each pass is a fresh `hq` node process (~1.8 s CPU, ~200 MB RSS), so
235
+ // desktops first check the lane store in-process
236
+ // (lanes-reconcile-guard.ts) and fall back to the pre-#1396 60 s
237
+ // cadence, or no run, when no lane can need the 15 s poll.
233
238
  intervalSec: 15,
234
239
  initialDelaySec: 30,
235
240
  command: { type: "hq", args: ["lanes", "reconcile"] },
236
241
  env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
242
+ ...(ctx.machineType === "desktop"
243
+ ? { runGuard: { name: "lanes-reconcile", idleIntervalSec: 60 } }
244
+ : {}),
237
245
  blocked: firstBlock([[!ctx.hqRoot, NO_HQ_ROOT]]),
238
246
  }),
239
247
  ];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pre-spawn check for the desktop `lanes-reconcile` unit.
3
+ *
4
+ * Every reconcile pass is a fresh `hq` node process (~1.8 s CPU, ~200 MB RSS),
5
+ * so a 15-second poll costs about 12% of a core even when the lane store holds
6
+ * nothing to do. This check reads the lane store in-process (readdir + small
7
+ * JSON reads, no child process) and tells the supervisor how urgent the next
8
+ * pass is:
9
+ *
10
+ * - `spawn`: something the 15-second cadence exists for may be due — lane
11
+ * admission, a running lane's heartbeat, or a keep-alive resume (#1396).
12
+ * - `idle`: only work that ran on the pre-#1396 60-second cadence remains
13
+ * (terminal lanes, worker-profile resumes, question aging, Codex rollout
14
+ * retention, lead-autopilot registry). The supervisor runs it at most once
15
+ * per `idleIntervalSec`.
16
+ * - `skip`: the store holds no lane or question records, no lead registry and
17
+ * no lanes config, so a reconcile pass has no input to act on.
18
+ *
19
+ * Any error reading the store returns `spawn` (fail open), as does any record
20
+ * this check does not understand.
21
+ *
22
+ * The paths below mirror `LANES_STORE_REL`, `LANE_RECORDS_DIR`,
23
+ * `QUESTION_RECORDS_DIR` (lanes/store.ts), `LANES_CONFIG_REL` (lanes/config.ts)
24
+ * and `LEAD_REGISTRY_REL` (lanes/autopilot-registry.ts). They are copied, not
25
+ * imported, so the daemon does not load the lanes modules; a test pins them to
26
+ * the originals.
27
+ */
28
+ import type { UnitPlan } from "./catalog.js";
29
+ export type LanesReconcileVerdict = "spawn" | "idle" | "skip";
30
+ export declare const LANES_STORE_DIR: string;
31
+ export declare const LANE_RECORDS_SUBDIR = "lanes";
32
+ export declare const QUESTION_RECORDS_SUBDIR = "questions";
33
+ export declare const LANES_CONFIG_FILE: string;
34
+ export declare const LEAD_REGISTRY_FILE: string;
35
+ export interface LanesReconcileGuardFs {
36
+ readdirSync: (dir: string) => string[];
37
+ readFileSync: (file: string, encoding: "utf8") => string;
38
+ existsSync: (file: string) => boolean;
39
+ }
40
+ export declare function lanesReconcileVerdict(hqRoot: string | undefined, fsDeps?: LanesReconcileGuardFs): LanesReconcileVerdict;
41
+ /** The supervisor's `runGuard`: dispatches on the unit's declared guard. */
42
+ export declare function daemonRunGuard(unit: Pick<UnitPlan, "runGuard" | "env">): LanesReconcileVerdict;
43
+ //# sourceMappingURL=lanes-reconcile-guard.d.ts.map