@indigoai-us/hq-cli 5.345.55 → 5.345.56

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 (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
  3. package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
  4. package/assets/scaffold/core/scripts/session-title.sh +18 -15
  5. package/dist/command-catalog.generated.d.ts +40 -0
  6. package/dist/command-catalog.generated.js +51 -0
  7. package/dist/commands/core-delegate-w1b.js +18 -5
  8. package/dist/commands/core-derive-trigger-facts-command.js +14 -0
  9. package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
  10. package/dist/commands/core-derive-trigger-facts-native.js +20 -1
  11. package/dist/commands/doctor.d.ts +1 -0
  12. package/dist/commands/doctor.js +15 -2
  13. package/dist/commands/lanes-checks.d.ts +3 -0
  14. package/dist/commands/lanes-checks.js +34 -0
  15. package/dist/commands/lanes-session-start.d.ts +2 -7
  16. package/dist/commands/lanes-session-start.js +9 -29
  17. package/dist/commands/lanes-shared.d.ts +2 -1
  18. package/dist/commands/lanes.d.ts +3 -1
  19. package/dist/commands/lanes.js +117 -16
  20. package/dist/commands/members.d.ts +4 -0
  21. package/dist/commands/members.js +23 -2
  22. package/dist/lanes-drain-hook.js +6 -2
  23. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  24. package/dist/lib/doctor/checks/sync-health.js +47 -23
  25. package/dist/lib/lanes/dropbox.d.ts +4 -2
  26. package/dist/lib/lanes/dropbox.js +54 -9
  27. package/dist/lib/lanes/flag-keys.d.ts +8 -0
  28. package/dist/lib/lanes/flag-keys.js +8 -0
  29. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  30. package/dist/lib/lanes/hooks-install.js +70 -50
  31. package/dist/lib/lanes/reaper.d.ts +58 -0
  32. package/dist/lib/lanes/reaper.js +403 -0
  33. package/dist/lib/lanes/spawn.d.ts +2 -0
  34. package/dist/lib/lanes/spawn.js +32 -2
  35. package/dist/lib/lanes/status-fields.d.ts +12 -0
  36. package/dist/lib/lanes/status-fields.js +189 -0
  37. package/dist/lib/lanes/types.js +1 -1
  38. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  39. package/dist/lib/lanes/worker-continuity.js +81 -0
  40. package/dist/lib/lanes/worker-process.d.ts +4 -0
  41. package/dist/lib/lanes/worker-process.js +77 -0
  42. package/dist/main.js +9 -3
  43. package/dist/utils/cli-telemetry.d.ts +4 -1
  44. package/dist/utils/cli-telemetry.js +8 -0
  45. package/dist/utils/jq-windows-compat.d.ts +13 -0
  46. package/dist/utils/jq-windows-compat.js +48 -0
  47. package/dist/utils/run-bundled-script.d.ts +3 -0
  48. package/dist/utils/run-bundled-script.js +9 -80
  49. package/dist/utils/windows-bash.d.ts +5 -0
  50. package/dist/utils/windows-bash.js +75 -0
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.56] — 2026-10-09
6
+ - Add `hq lanes reap` to report local lanes whose session senior is confirmed ended, clear stale pid markers on request, and keep process-tree stopping behind the `lanes.reap-stop` hq-flags key. SessionStart runs the report-only sweep detached and stores its report in host-specific user state.
7
+ - Record privacy-limited `hq members invite` success and failure outcomes through the existing CLI feature telemetry event.
8
+
9
+ - Continue a senior's most recent same-company, same-provider worker lane on `hq lanes create`, refuse live duplicate worker lanes, and filter `hq lanes list` by worker.
10
+
11
+ - Pass jq diagnostic filters to Windows batch wrappers as single-line arguments so malformed-ACL parity reports the original jq diagnostic instead of failing on an invalid command line.
12
+ - Keep the bundled derive-trigger-facts helper's closed-stdin error prompt on macOS instead of blocking the hook call.
13
+ - Native delegate diagnostics match the bundled helper's Git Bash jq byte handling, including jq.exe CRLF output when `--binary` is unavailable.
14
+ - Lane drop-box hooks deliver queued messages after tool calls and at a deliverable Stop event. Lane list and show JSON include bounded output, PR, inbox, elapsed time, and worker exit fields.
15
+ - hq doctor now finds a company's sync manifest status instead of reporting the company as unresolved.
16
+
5
17
  ## [5.345.55] — 2026-10-08
6
18
  - Added `hq daemon sync conflicts acknowledge <id>` so the desktop can clear a parked-conflict notice without writing runner-owned sync state.
7
19
  - Add `hq import scan`, which runs the HQ import-context scanner on this
@@ -65,7 +65,16 @@ case "${OSTYPE:-}" in
65
65
  STDIN_JSON="${STDIN_JSON%$'\n'}"
66
66
  done
67
67
  ;;
68
- *) STDIN_JSON="$(</dev/stdin)" || STDIN_JSON='{}' ;;
68
+ *)
69
+ # Bash 3.2 on macOS can block while opening /dev/stdin when fd 0 is closed.
70
+ # Detect that case with a builtin redirection so the existing fail-closed
71
+ # behavior returns promptly on every POSIX host.
72
+ if [[ ! -e /dev/stdin ]]; then
73
+ printf '%s: line 68: /dev/stdin: No such file or directory\n' "$0" >&2
74
+ exit 1
75
+ fi
76
+ STDIN_JSON="$(</dev/stdin)" || STDIN_JSON='{}'
77
+ ;;
69
78
  esac
70
79
  [ -n "$STDIN_JSON" ] || STDIN_JSON='{}'
71
80
  JQ="$(command -v jq || true)"
@@ -219,7 +219,8 @@ validate_job_file() {
219
219
  err "$file" "yaml" "failed to parse YAML"
220
220
  return
221
221
  fi
222
- if [ "$(jq -r 'type' <<<"$json")" != "object" ]; then
222
+ # Use jq's exit status instead of its text output: jq.exe writes CRLF on Windows.
223
+ if ! jq -es 'length == 1 and (.[0] | type == "object")' <<<"$json" >/dev/null; then
223
224
  err "$file" "yaml" "job root must be a mapping"
224
225
  return
225
226
  fi
@@ -38,13 +38,14 @@ done
38
38
 
39
39
  command -v node >/dev/null 2>&1 || exit 0
40
40
 
41
- HQ_ROOT="$HQ_ROOT" SESSION_ID="$SESSION_ID" CMD="$COMMAND" SESSION_CWD="$SESSION_CWD" node - <<'JS'
41
+ HQ_ROOT="$HQ_ROOT" SESSION_ID="$SESSION_ID" SESSION_CWD="$SESSION_CWD" \
42
+ MSYS2_ARG_CONV_EXCL="*" node - "$COMMAND" <<'JS'
42
43
  const fs = require("fs");
43
44
  const path = require("path");
44
45
 
45
46
  const hq = process.env.HQ_ROOT || "";
46
47
  const sid = process.env.SESSION_ID || "default";
47
- let command = (process.env.CMD || "").trim().replace(/^\/+/, "");
48
+ let command = (process.argv[2] || "").trim().replace(/^\/+/, "");
48
49
 
49
50
  const key = sid.replace(/[^A-Za-z0-9._-]/g, "_") || "default";
50
51
  const state = path.join(hq, ".claude", "state");
@@ -133,12 +134,12 @@ if (project) {
133
134
  if (matchesProject) {
134
135
  const st = p.state || "";
135
136
  if (st === "IN_PROGRESS") {
136
- emoji = "▶️";
137
+ emoji = "\u25b6\ufe0f";
137
138
  } else if (st === "COMPLETED") {
138
139
  const ts = p.updatedAt || p.updated_at || "";
139
140
  const t = new Date(ts);
140
141
  if (!isNaN(t.getTime()) && (Date.now() - t.getTime()) / 1000 < 86400) {
141
- emoji = "✅"; // only recent completions
142
+ emoji = "\u2705"; // only recent completions
142
143
  }
143
144
  }
144
145
  break;
@@ -171,15 +172,15 @@ if (project) {
171
172
  // while /handoff runs; only the model can know when it finished, so it sets 📤.
172
173
  // Both are distinct from ✅ "the work shipped" and 🤝 "a person owns it now".
173
174
  const MODES = [
174
- [/^(brainstorm|idea|dream|discover|strategize)$/, "💭"],
175
- [/^(plan|prd|deep-plan|storyboard|architect|review-plan|codebase-design)$/, "📐"],
176
- [/^(run-project|execute-task|ship|land|land-batch|run-pipeline|orchestrate|tdd|deploy)$/, "⚡"],
177
- [/^(review|code-review|security-review)$/, "👀"],
178
- [/^(quality-gate|smoke|verify|diagnose|investigate)$/, "🧪"],
179
- [/^(handoff|checkpoint-handoff|retro)$/, "📝"],
180
- [/^(delegate|new-hire|new-agent|promote)$/, "🤝"],
181
- [/^(dm|hq-slack|meeting-notes|work-broadcast|signals|hq-share)$/, "💬"],
182
- [/^(schedule|job|loop)$/, "🔁"],
175
+ [/^(brainstorm|idea|dream|discover|strategize)$/, "\u{1f4ad}"],
176
+ [/^(plan|prd|deep-plan|storyboard|architect|review-plan|codebase-design)$/, "\u{1f4d0}"],
177
+ [/^(run-project|execute-task|ship|land|land-batch|run-pipeline|orchestrate|tdd|deploy)$/, "\u{26a1}"],
178
+ [/^(review|code-review|security-review)$/, "\u{1f440}"],
179
+ [/^(quality-gate|smoke|verify|diagnose|investigate)$/, "\u{1f9ea}"],
180
+ [/^(handoff|checkpoint-handoff|retro)$/, "\u{1f4dd}"],
181
+ [/^(delegate|new-hire|new-agent|promote)$/, "\u{1f91d}"],
182
+ [/^(dm|hq-slack|meeting-notes|work-broadcast|signals|hq-share)$/, "\u{1f4ac}"],
183
+ [/^(schedule|job|loop)$/, "\u{1f501}"],
183
184
  ];
184
185
 
185
186
  const modeFor = (word) => {
@@ -192,7 +193,7 @@ if (!command) command = "chat";
192
193
  // Precedence: a finished or running project outranks the command word — "it
193
194
  // shipped" and "it is executing right now" beat "you typed /plan an hour ago".
194
195
  // `emoji` is set above from orchestrator state (✅ recent completion, ▶️ running).
195
- const mode = emoji === "▶️" ? "⚡" : (emoji || modeFor(command));
196
+ const mode = emoji === "\u25b6\ufe0f" ? "\u{26a1}" : (emoji || modeFor(command));
196
197
 
197
198
  // --- company ---
198
199
  // Always the first text token. Long slugs get an explicit short form from the
@@ -301,5 +302,7 @@ let title = compose(parts);
301
302
  if (title.length > MAX && parts.length > 1) title = compose([parts[parts.length - 1]]);
302
303
  if (title.length > MAX) title = title.slice(0, MAX - 1).replace(/\s+$/, "") + "…";
303
304
 
304
- console.log(title);
305
+ // The script is also run under Git Bash with LC_ALL=C from its caller. Write
306
+ // the protocol bytes explicitly so the host locale cannot replace UTF-8 glyphs.
307
+ process.stdout.write(Buffer.from(`${title}\n`, "utf8"));
305
308
  JS
@@ -11008,6 +11008,38 @@ export declare const COMMAND_CATALOG: readonly [{
11008
11008
  readonly hidden: false;
11009
11009
  }];
11010
11010
  readonly subcommands: readonly [];
11011
+ }, {
11012
+ readonly name: "reap";
11013
+ readonly description: "Classify local lanes whose session senior has ended; report only by default";
11014
+ readonly aliases: readonly ["_reap"];
11015
+ readonly hidden: false;
11016
+ readonly helpOption: true;
11017
+ readonly helpCommand: false;
11018
+ readonly usage: "[options]";
11019
+ readonly helpText: readonly [];
11020
+ readonly arguments: readonly [];
11021
+ readonly options: readonly [{
11022
+ readonly flags: "--min-age <minutes>";
11023
+ readonly description: "Minimum lane age in minutes (default: 120)";
11024
+ readonly hidden: false;
11025
+ }, {
11026
+ readonly flags: "--apply-stale-state";
11027
+ readonly description: "Remove stale worker and watcher pid markers";
11028
+ readonly hidden: false;
11029
+ }, {
11030
+ readonly flags: "--stop";
11031
+ readonly description: "Stop candidate process trees when the hq-flags stop key is enabled";
11032
+ readonly hidden: false;
11033
+ }, {
11034
+ readonly flags: "--reason <text>";
11035
+ readonly description: "Required audit reason when --stop is set";
11036
+ readonly hidden: false;
11037
+ }, {
11038
+ readonly flags: "--json";
11039
+ readonly description: "Print machine-readable JSON (default)";
11040
+ readonly hidden: false;
11041
+ }];
11042
+ readonly subcommands: readonly [];
11011
11043
  }, {
11012
11044
  readonly name: "checks";
11013
11045
  readonly description: "Evaluate tracker and lane health checks";
@@ -11618,6 +11650,10 @@ export declare const COMMAND_CATALOG: readonly [{
11618
11650
  readonly flags: "--worker <name>";
11619
11651
  readonly description: "HQ worker or reusable profile id";
11620
11652
  readonly hidden: false;
11653
+ }, {
11654
+ readonly flags: "--fresh-thread";
11655
+ readonly description: "Skip worker continuity and start a cold provider session";
11656
+ readonly hidden: false;
11621
11657
  }, {
11622
11658
  readonly flags: "--provider <name>";
11623
11659
  readonly description: "claude | codex | grok";
@@ -11950,6 +11986,10 @@ export declare const COMMAND_CATALOG: readonly [{
11950
11986
  readonly flags: "--senior <ref>";
11951
11987
  readonly description: "Filter by senior ref";
11952
11988
  readonly hidden: false;
11989
+ }, {
11990
+ readonly flags: "--worker <id>";
11991
+ readonly description: "Filter by worker id";
11992
+ readonly hidden: false;
11953
11993
  }, {
11954
11994
  readonly flags: "--lane <id>";
11955
11995
  readonly description: "Filter by lane id (repeatable)";
@@ -13374,6 +13374,47 @@ export const COMMAND_CATALOG = [
13374
13374
  ],
13375
13375
  "subcommands": []
13376
13376
  },
13377
+ {
13378
+ "name": "reap",
13379
+ "description": "Classify local lanes whose session senior has ended; report only by default",
13380
+ "aliases": [
13381
+ "_reap"
13382
+ ],
13383
+ "hidden": false,
13384
+ "helpOption": true,
13385
+ "helpCommand": false,
13386
+ "usage": "[options]",
13387
+ "helpText": [],
13388
+ "arguments": [],
13389
+ "options": [
13390
+ {
13391
+ "flags": "--min-age <minutes>",
13392
+ "description": "Minimum lane age in minutes (default: 120)",
13393
+ "hidden": false
13394
+ },
13395
+ {
13396
+ "flags": "--apply-stale-state",
13397
+ "description": "Remove stale worker and watcher pid markers",
13398
+ "hidden": false
13399
+ },
13400
+ {
13401
+ "flags": "--stop",
13402
+ "description": "Stop candidate process trees when the hq-flags stop key is enabled",
13403
+ "hidden": false
13404
+ },
13405
+ {
13406
+ "flags": "--reason <text>",
13407
+ "description": "Required audit reason when --stop is set",
13408
+ "hidden": false
13409
+ },
13410
+ {
13411
+ "flags": "--json",
13412
+ "description": "Print machine-readable JSON (default)",
13413
+ "hidden": false
13414
+ }
13415
+ ],
13416
+ "subcommands": []
13417
+ },
13377
13418
  {
13378
13419
  "name": "checks",
13379
13420
  "description": "Evaluate tracker and lane health checks",
@@ -14116,6 +14157,11 @@ export const COMMAND_CATALOG = [
14116
14157
  "description": "HQ worker or reusable profile id",
14117
14158
  "hidden": false
14118
14159
  },
14160
+ {
14161
+ "flags": "--fresh-thread",
14162
+ "description": "Skip worker continuity and start a cold provider session",
14163
+ "hidden": false
14164
+ },
14119
14165
  {
14120
14166
  "flags": "--provider <name>",
14121
14167
  "description": "claude | codex | grok",
@@ -14526,6 +14572,11 @@ export const COMMAND_CATALOG = [
14526
14572
  "description": "Filter by senior ref",
14527
14573
  "hidden": false
14528
14574
  },
14575
+ {
14576
+ "flags": "--worker <id>",
14577
+ "description": "Filter by worker id",
14578
+ "hidden": false
14579
+ },
14529
14580
  {
14530
14581
  "flags": "--lane <id>",
14531
14582
  "description": "Filter by lane id (repeatable)",
@@ -11,6 +11,8 @@ import { commandExists, delegateDiagnosticPath, isRecord, jsonAt, jqFallback, jq
11
11
  import { resolveLiveRoot } from "../utils/hq-roots.js";
12
12
  import { ACL_SOURCE_DISPLAY_LABELS } from "../utils/files-browse-acl.js";
13
13
  import { runBundledScript } from "../utils/run-bundled-script.js";
14
+ import { resolveWindowsBash } from "../utils/windows-bash.js";
15
+ import { createShellCompatibleJqInvoker } from "../utils/jq-windows-compat.js";
14
16
  const FLAG = "core-native-delegate";
15
17
  const ASSETS = {
16
18
  bundle: "core/scripts/hq-delegate-bundle.sh",
@@ -367,6 +369,13 @@ function runBundle(args, context, onMalformedWhoami) {
367
369
  function runHq(args, context, stdout = "capture") {
368
370
  return subprocess("hq", args, context.cwd, { stdout });
369
371
  }
372
+ const runJq = createShellCompatibleJqInvoker((args, input, cwd, options) => subprocess("jq", args, cwd, { input, ...options }), process.platform, process.env, (cwd) => {
373
+ const bash = resolveWindowsBash(process.env).executable;
374
+ if (!bash)
375
+ return undefined;
376
+ const result = subprocess(bash, ["-c", "printf '%s' \"$OSTYPE\""], cwd, { stderr: "ignore" });
377
+ return result.code === 0 ? result.stdout?.trim() : undefined;
378
+ });
370
379
  function commandSubstitutionBytes(value) {
371
380
  const bytes = value.stdoutBytes ?? Buffer.from(value.stdout ?? "");
372
381
  let end = bytes.length;
@@ -377,11 +386,15 @@ function commandSubstitutionBytes(value) {
377
386
  function emitJqParseDiagnostic(input, args, context) {
378
387
  if (!commandExists("jq", context.cwd))
379
388
  return;
380
- const result = subprocess("jq", args, context.cwd, { input, stdout: "ignore", stderr: "pipe" });
381
- const rawDiagnostic = result.stderrBytes ?? Buffer.from(result.stderr ?? "");
382
- const diagnostic = process.platform === "win32"
383
- ? Buffer.from(rawDiagnostic.toString("latin1").replace(/\r(?=\n|$)/g, ""), "latin1")
384
- : rawDiagnostic;
389
+ // cmd.exe cannot pass CR, LF, or NUL inside an argument. jq treats line
390
+ // breaks between tokens as whitespace, and these fixed filters contain no
391
+ // multiline string literals, so send the program as one argument on Windows.
392
+ const jqArgs = [...args];
393
+ const programIndex = jqArgs.length - 1;
394
+ if (programIndex >= 0)
395
+ jqArgs[programIndex] = (jqArgs[programIndex] ?? "").replace(/[\r\n\0]+/gu, " ");
396
+ const result = runJq(jqArgs, input, context.cwd, { stdout: "ignore", stderr: "pipe" });
397
+ const diagnostic = result.stderrBytes ?? Buffer.from(result.stderr ?? "");
385
398
  if (diagnostic.length)
386
399
  err(diagnostic);
387
400
  }
@@ -13,6 +13,20 @@ export function registerCoreDeriveTriggerFactsCommand(core) {
13
13
  .helpOption(false)
14
14
  .argument("[args...]")
15
15
  .action(async (args = [], _options, commandContext) => {
16
+ if (process.env.HQ_CORE_STDIN_CLOSED === "1") {
17
+ const root = liveRoot();
18
+ const result = runBundledScript({
19
+ asset: DERIVE_TRIGGER_FACTS_ASSET,
20
+ args: commandContext.args.length ? commandContext.args : args,
21
+ cwd: root,
22
+ hqRoot: root,
23
+ msysScriptPath: process.platform === "win32",
24
+ closeStdin: true,
25
+ });
26
+ if (result.code !== 0)
27
+ process.exit(result.code);
28
+ return;
29
+ }
16
30
  if (!await coreNativeHookHelpersEnabled()) {
17
31
  const root = liveRoot();
18
32
  const stdin = process.platform === "win32" ? await readStdin() : undefined;
@@ -20,4 +20,5 @@ export declare function commandOnPath(command: string, env: Record<string, strin
20
20
  /** Run the native handler contract, retaining exact bytes for differential tests. */
21
21
  export declare function runDeriveTriggerFacts(options: DeriveTriggerRunOptions): DeriveTriggerRunResult;
22
22
  export declare function runDeriveTriggerFactsFast(argv: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined): number;
23
+ export declare function runDeriveTriggerFactsFastResult(argv: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined): DeriveTriggerRunResult;
23
24
  //# sourceMappingURL=core-derive-trigger-facts-native.d.ts.map
@@ -583,11 +583,30 @@ export function runDeriveTriggerFacts(options) {
583
583
  }
584
584
  }
585
585
  export function runDeriveTriggerFactsFast(argv, stdin, cwd, hqRoot) {
586
- const result = runDeriveTriggerFacts({ argv, stdin, cwd, hqRoot: hqRoot ?? cwd });
586
+ const result = runDeriveTriggerFactsFastResult(argv, stdin, cwd, hqRoot);
587
587
  if (result.stdout.length)
588
588
  process.stdout.write(result.stdout);
589
589
  if (result.stderr.length)
590
590
  process.stderr.write(result.stderr);
591
591
  return result.code;
592
592
  }
593
+ export function runDeriveTriggerFactsFastResult(argv, stdin, cwd, hqRoot) {
594
+ if (process.env.HQ_CORE_STDIN_CLOSED === "1") {
595
+ const result = runBundledScript({
596
+ asset: DERIVE_TRIGGER_FACTS_ASSET,
597
+ args: [...argv],
598
+ cwd,
599
+ hqRoot: hqRoot ?? cwd,
600
+ msysScriptPath: process.platform === "win32",
601
+ closeStdin: true,
602
+ captureOutput: true,
603
+ });
604
+ return {
605
+ code: result.code,
606
+ stdout: result.stdout ?? Buffer.alloc(0),
607
+ stderr: result.stderr ?? Buffer.alloc(0),
608
+ };
609
+ }
610
+ return runDeriveTriggerFacts({ argv, stdin, cwd, hqRoot: hqRoot ?? cwd });
611
+ }
593
612
  //# sourceMappingURL=core-derive-trigger-facts-native.js.map
@@ -119,6 +119,7 @@ export interface RunDoctorResult {
119
119
  * read; all other default tiers remain local unless explicitly opted in.
120
120
  */
121
121
  export declare function runDoctor(options?: RunDoctorOptions): Promise<RunDoctorResult>;
122
+ export declare function createClientHealthReasonCodeCompanyResolver(hqRoot: string): (companySlug: string | null) => Promise<boolean>;
122
123
  export declare function createClientHealthCheckStatusCompanyResolver(hqRoot: string): (companySlug: string | null) => Promise<boolean>;
123
124
  /** Register the top-level `hq doctor` command so it appears in `hq --help`. */
124
125
  export declare function registerDoctorCommand(program: Command): void;
@@ -207,7 +207,14 @@ async function resolveClientHealthReasonCodesCheckIds(families, resolveForCompan
207
207
  continue;
208
208
  }
209
209
  const scoped = /^sync\.(?:journal|manifest)\.(.+)$/.exec(result.checkId);
210
- const companySlug = scoped && isCompanyJournalSlug(scoped[1]) ? scoped[1] : null;
210
+ const companyUidScope = scoped
211
+ ? /^company:(cmp_[A-Za-z0-9]{3,128})$/.exec(scoped[1])
212
+ : null;
213
+ const companySlug = companyUidScope
214
+ ? `company:${companyUidScope[1]}`
215
+ : scoped && isCompanyJournalSlug(scoped[1])
216
+ ? scoped[1]
217
+ : null;
211
218
  const checkIds = checksByCompany.get(companySlug) ?? [];
212
219
  checkIds.push(result.checkId);
213
220
  checksByCompany.set(companySlug, checkIds);
@@ -254,12 +261,18 @@ async function resolveClientHealthCheckStatusCheckIds(families, resolveForCompan
254
261
  }
255
262
  return enabledChecks;
256
263
  }
257
- function createClientHealthReasonCodeCompanyResolver(hqRoot) {
264
+ export function createClientHealthReasonCodeCompanyResolver(hqRoot) {
258
265
  const companies = readCompaniesManifestMap(hqRoot);
259
266
  return async (companySlug) => {
260
267
  if (companySlug === null) {
261
268
  return isActiveCompanyClientHealthReasonCodesEnabled();
262
269
  }
270
+ const scopedCompanyUid = /^company:(cmp_[A-Za-z0-9]{3,128})$/.exec(companySlug);
271
+ if (scopedCompanyUid) {
272
+ return clientHealthReasonCodesEnabledForCompany({
273
+ companyUid: scopedCompanyUid[1],
274
+ });
275
+ }
263
276
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(companySlug)) {
264
277
  return false;
265
278
  }
@@ -2,12 +2,15 @@ import type { Command } from "commander";
2
2
  import { type TrackerCheckTeam } from "../lib/lanes/tracker-checks.js";
3
3
  import { type TrackerKind, type TrackerRecord } from "../lib/lanes/tracker-store.js";
4
4
  import type { LaneRecord, LaneCompanyRef, QuestionRecord } from "../lib/lanes/types.js";
5
+ import { type ReapDeps } from "../lib/lanes/reaper.js";
5
6
  export interface LaneChecksDeps {
6
7
  hqRoot: () => string;
7
8
  resolveCompany: (company?: string) => Promise<LaneCompanyRef>;
8
9
  now: () => Date;
9
10
  stopLane?: (laneId: string, reason: string) => Promise<void>;
10
11
  sessionState?: (root: string, sessionId: string, now: Date) => string | null;
12
+ stopEnabled?: (lane: LaneRecord) => Promise<boolean> | boolean;
13
+ reaperDeps?: Partial<ReapDeps>;
11
14
  }
12
15
  type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
13
16
  export interface ProjectMapping {
@@ -13,6 +13,7 @@ import { LANES_RUNS_REL } from "../lib/lanes/spawn.js";
13
13
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
14
14
  import { checkCompanyPath } from "../lib/lanes/checked-company-path.js";
15
15
  import { findOrphanedBareIdDropboxes } from "../lib/lanes/orphaned-dropbox.js";
16
+ import { parseReapMinAge, reapLocalLanes } from "../lib/lanes/reaper.js";
16
17
  export function mappingIncludesStory(mapping, storyId) {
17
18
  return (mapping.storyPrefix === undefined || storyId.startsWith(mapping.storyPrefix))
18
19
  && (mapping.excludePrefix === undefined || !storyId.startsWith(mapping.excludePrefix));
@@ -905,6 +906,39 @@ async function orphanReport(root, company, now, minAgeMinutes, userIdleHours, de
905
906
  output("orphans.stop", stopped);
906
907
  }
907
908
  export function registerLaneChecksCommand(parent, deps, wrap) {
909
+ parent.command("reap")
910
+ // CL-5's SessionStart hook launches this alias detached and does not wait for it.
911
+ .alias("_reap")
912
+ .description("Classify local lanes whose session senior has ended; report only by default")
913
+ .option("--min-age <minutes>", "Minimum lane age in minutes (default: 120)")
914
+ .option("--apply-stale-state", "Remove stale worker and watcher pid markers")
915
+ .option("--stop", "Stop candidate process trees when the hq-flags stop key is enabled")
916
+ .option("--reason <text>", "Required audit reason when --stop is set")
917
+ .option("--json", "Print machine-readable JSON (default)")
918
+ .action(wrap(async (options) => {
919
+ const reason = options.reason?.trim();
920
+ if (options.stop && !reason)
921
+ fail("lane_reap_reason_required", "--stop requires a non-empty --reason", {});
922
+ if (!options.stop && options.reason !== undefined)
923
+ fail("lane_reap_reason_requires_stop", "--reason can only be used with --stop", {});
924
+ if (reason)
925
+ assertNoSecretText("stop reason", reason);
926
+ const minAgeMinutes = parseReapMinAge(options.minAge);
927
+ const report = await reapLocalLanes(deps.hqRoot(), {
928
+ minAgeMinutes,
929
+ stop: options.stop === true,
930
+ applyStaleState: options.applyStaleState === true,
931
+ reason: reason ?? "Orphaned lane: owning session is confirmed ended",
932
+ json: true,
933
+ }, {
934
+ ...deps.reaperDeps,
935
+ now: deps.now,
936
+ sessionState: deps.sessionState,
937
+ stopEnabled: deps.stopEnabled,
938
+ stopLane: deps.stopLane,
939
+ });
940
+ console.log(JSON.stringify(report, null, 2));
941
+ }));
908
942
  parent.command("checks")
909
943
  .description("Evaluate tracker and lane health checks")
910
944
  .option("--company <slug>", "Select the company by slug")
@@ -7,16 +7,11 @@ export interface SessionStartOptions {
7
7
  stdout?: Pick<NodeJS.WriteStream, "write">;
8
8
  stderr?: Pick<NodeJS.WriteStream, "write">;
9
9
  resolveRoot?: (env: NodeJS.ProcessEnv, cwd?: string) => string;
10
- startSweep?: (root: string, env: NodeJS.ProcessEnv, context: SessionStartContext) => void;
11
- }
12
- interface SessionStartContext {
13
- sessionId?: string;
14
- companySlug?: string;
10
+ startSweep?: (root: string, env: NodeJS.ProcessEnv) => void;
15
11
  }
16
12
  /** Starts the CL-4 read-only orphan report once per host, without holding SessionStart open. */
17
- export declare function startSessionStartOrphanSweep(root: string, env?: NodeJS.ProcessEnv, spawnDetached?: typeof spawn, context?: SessionStartContext): void;
13
+ export declare function startSessionStartOrphanSweep(root: string, env?: NodeJS.ProcessEnv, spawnDetached?: typeof spawn): void;
18
14
  /** SessionStart-compatible, fail-open conduct bootstrap. */
19
15
  export declare function runLaneSessionStart(options?: SessionStartOptions): Promise<void>;
20
16
  export declare function registerLaneSessionStartCommand(lanes: Command): void;
21
- export {};
22
17
  //# sourceMappingURL=lanes-session-start.d.ts.map
@@ -27,22 +27,6 @@ function sessionWasDisabled(root, env, payloadSessionId) {
27
27
  return /^\s*(?:conduct_mode|conduct_enabled|conduct_default):\s*(?:off|false|no|0)\s*(?:#.*)?$/mi.test(text) ||
28
28
  /^\s*conduct_engine:\s*(?:""|''|#.*)?\s*$/mi.test(text);
29
29
  }
30
- function sessionStartContext(root, env, payloadSessionId) {
31
- const sessionId = payloadSessionId || env.HQ_SESSION_ID;
32
- let companySlug = "";
33
- if (sessionId && /^[A-Za-z0-9_-]+$/.test(sessionId)) {
34
- const meta = path.join(root, "workspace", "sessions", sessionId, "meta.yaml");
35
- if (fs.existsSync(meta)) {
36
- const text = fs.readFileSync(meta, "utf8");
37
- companySlug = /^\s*company_slug:\s*["']?([A-Za-z0-9][A-Za-z0-9-]*)["']?\s*(?:#.*)?$/mi.exec(text)?.[1] ?? "";
38
- }
39
- }
40
- if (!companySlug)
41
- companySlug = env.HQ_COMPANY_SLUG?.trim() ?? "";
42
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(companySlug))
43
- companySlug = "";
44
- return { ...(sessionId ? { sessionId } : {}), ...(companySlug ? { companySlug } : {}) };
45
- }
46
30
  function extractConductorBlock(core) {
47
31
  const start = core.indexOf("<!-- inject:start -->");
48
32
  const end = core.indexOf("<!-- inject:end -->");
@@ -52,15 +36,12 @@ function extractConductorBlock(core) {
52
36
  }
53
37
  const ORPHAN_SWEEP_LOCK_TTL_MS = 15 * 60_000;
54
38
  /** Starts the CL-4 read-only orphan report once per host, without holding SessionStart open. */
55
- export function startSessionStartOrphanSweep(root, env = process.env, spawnDetached = spawn, context = {}) {
56
- // The orphan command resolves company membership when --company is absent;
57
- // skip here if this session has no tenant context rather than risk ambiguity.
58
- if (!context.companySlug)
59
- return;
39
+ export function startSessionStartOrphanSweep(root, env = process.env, spawnDetached = spawn) {
60
40
  const stateRoot = env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
61
- const stateDir = path.join(stateRoot, "hq-cli");
62
- const lockPath = path.join(stateDir, "lanes-session-start-orphan-sweep.json");
63
- const reportDir = path.join(stateDir, "lanes-orphan-reports", context.companySlug);
41
+ const host = os.hostname().replace(/[^A-Za-z0-9._-]/g, "_") || "unknown-host";
42
+ const stateDir = path.join(stateRoot, "hq-cli", "lanes-session-start", host);
43
+ const lockPath = path.join(stateDir, "orphan-sweep.lock");
44
+ const reportDir = path.join(stateRoot, "hq-cli", "lanes-orphan-reports", host);
64
45
  const ownerToken = randomUUID();
65
46
  try {
66
47
  fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -108,17 +89,17 @@ export function startSessionStartOrphanSweep(root, env = process.env, spawnDetac
108
89
  }
109
90
  const wrapper = [
110
91
  "const cp=require('node:child_process'),fs=require('node:fs'),path=require('node:path');",
111
- "const [cli,lock,root,token,report,company]=process.argv.slice(1);",
92
+ "const [cli,lock,root,token,report]=process.argv.slice(1);",
112
93
  "const cleanup=()=>{try{const lease=JSON.parse(fs.readFileSync(lock,'utf8'));if(lease.owner_token===token)fs.rmSync(lock,{force:true})}catch{}};",
113
94
  "const start=()=>{let lease;try{lease=JSON.parse(fs.readFileSync(lock,'utf8'))}catch{}if(lease?.owner_token!==token){if(Date.now()<Number(lease?.started_at||0)+1000)return setTimeout(start,10);process.exit(0)}",
114
95
  "try{fs.writeFileSync(lock,JSON.stringify({...lease,pid:process.pid}))}catch{}",
115
96
  "const tmp=report+'.tmp';let output;try{output=fs.createWriteStream(tmp,{flags:'wx',mode:0o600})}catch{cleanup();process.exit(0)}",
116
- "const child=cp.spawn(process.execPath,[cli,'lanes','orphans','--company',company,'--json'],{stdio:['ignore','pipe','ignore'],env:{...process.env,HQ_ROOT:root,HQ_NO_UPDATE_CHECK:'1'}});",
97
+ "const child=cp.spawn(process.execPath,[cli,'lanes','_reap','--json'],{stdio:['ignore','pipe','ignore'],env:{...process.env,HQ_ROOT:root,HQ_NO_UPDATE_CHECK:'1'}});",
117
98
  "child.stdout.pipe(output);child.on('error',()=>{output.destroy();try{fs.rmSync(tmp,{force:true})}catch{}cleanup()});",
118
99
  "child.on('exit',(code)=>output.end(()=>{try{if(code===0)fs.renameSync(tmp,report);else fs.rmSync(tmp,{force:true})}catch{}cleanup()}));};start();",
119
100
  ].join("");
120
101
  const reportPath = path.join(reportDir, `orphans-${startedAt}-${ownerToken}.json`);
121
- const launcher = spawnDetached(process.execPath, ["-e", wrapper, cli, lockPath, root, ownerToken, reportPath, context.companySlug], {
102
+ const launcher = spawnDetached(process.execPath, ["-e", wrapper, cli, lockPath, root, ownerToken, reportPath], {
122
103
  detached: true,
123
104
  stdio: "ignore",
124
105
  env: { ...env, HQ_ROOT: root, HQ_NO_UPDATE_CHECK: "1" },
@@ -185,8 +166,7 @@ export async function runLaneSessionStart(options = {}) {
185
166
  });
186
167
  if (!flagEnabled)
187
168
  return;
188
- const context = sessionStartContext(root, env, payloadSessionId);
189
- (options.startSweep ?? ((r, e, c) => startSessionStartOrphanSweep(r, e, spawn, c)))(root, env, context);
169
+ (options.startSweep ?? ((r, e) => startSessionStartOrphanSweep(r, e, spawn)))(root, env);
190
170
  if (sessionWasDisabled(root, env, payloadSessionId))
191
171
  return;
192
172
  let enabled = enabledFromSettings(root);
@@ -1,4 +1,4 @@
1
- import type { LaneCompanyRef, LanePlacementPolicy, LaneRecord, LanesConfig, QuestionAnswer, QuestionBody, QuestionRecord, SeniorRef, AccountHistoryEntry, LaneAccountSelection, PendingResume, QueuedReason, ResolvedLaneWorker, LivenessDeps, MachineIdentity, MonitorEvidenceReader, ReconcileHost, RunCommand, ThrottleDeps, withLaneHireLocks } from "../lib/lanes/index.js";
1
+ import type { LaneCompanyRef, LanePlacementPolicy, LaneRecord, LanesConfig, QuestionAnswer, QuestionBody, QuestionRecord, SeniorRef, AccountHistoryEntry, LaneAccountSelection, PendingResume, QueuedReason, ResolvedLaneWorker, LivenessDeps, MachineIdentity, MonitorEvidenceReader, ReconcileHost, RunCommand, ThrottleDeps, withLaneAdmissionLock, withLaneHireLocks } from "../lib/lanes/index.js";
2
2
  import type { OutpostIdentity } from "../lib/lanes/admission-host.js";
3
3
  import type { OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
4
4
  import type { CpuBusySource } from "../lib/lanes/throttle.js";
@@ -313,6 +313,7 @@ export interface Ctx {
313
313
  storyLedgerEnabled: () => boolean;
314
314
  isSsmOwnerDispatch: () => boolean;
315
315
  withGrantHireLocks: typeof withLaneHireLocks;
316
+ withAdmissionLock: typeof withLaneAdmissionLock;
316
317
  mergeHoldEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
317
318
  mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
318
319
  }
@@ -17,7 +17,7 @@ export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/
17
17
  export type { OutpostIdentity } from "../lib/lanes/admission-host.js";
18
18
  import { type OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
19
19
  import { emitLaneDecisionEvents } from "../lib/lanes/mesh-events.js";
20
- import { MAX_LANE_DEPTH, withLaneHireLocks, type LaneCompanyRef, type QuestionBody, type LaneRecord, type QuestionRecord, type ResolvedLaneWorker, type SeniorRef, type LivenessDeps, type MachineIdentity, type MonitorEvidenceReader, type RunCommand, type ThrottleDeps } from "../lib/lanes/index.js";
20
+ import { MAX_LANE_DEPTH, withLaneAdmissionLock, withLaneHireLocks, type LaneCompanyRef, type QuestionBody, type LaneRecord, type QuestionRecord, type ResolvedLaneWorker, type SeniorRef, type LivenessDeps, type MachineIdentity, type MonitorEvidenceReader, type RunCommand, type ThrottleDeps } from "../lib/lanes/index.js";
21
21
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
22
22
  import { type RunCompactionFn } from "../lib/lanes/compaction.js";
23
23
  import type { WatcherLoopDeps } from "../lib/lanes/watcher.js";
@@ -106,6 +106,8 @@ export interface LanesCommandDeps {
106
106
  isSsmOwnerDispatch?: () => boolean;
107
107
  /** Test seam for changing a lane between pre-lock and locked grant reads. */
108
108
  withGrantHireLocks?: typeof withLaneHireLocks;
109
+ /** Test seam proving create admission encloses the worker busy check. */
110
+ withAdmissionLock?: typeof withLaneAdmissionLock;
109
111
  /** Test seam for the review-before-merge registry flag. */
110
112
  mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
111
113
  /** Test seam for the repository-merge-hold registry flag. */