@indigoai-us/hq-cli 5.345.75 → 5.345.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.76] — 2026-10-10
6
+
7
+ - `hq bot` on macOS now finds the Codex CLI bundled in the ChatGPT or Codex
8
+ desktop app when `codex` is not on PATH, so a Mac with only the desktop app
9
+ can run a Codex assistant. PATH still wins.
10
+
11
+ - `hq bot create --display-name` now accepts numbers after the first letter,
12
+ so names like "R2D2" or "StefanTest123" work.
13
+ - Core delegate native commands now use the default-on kill-switch behavior when the flag snapshot or company context is unavailable. An explicit `core-native-delegate=false` override still selects the bundled shell.
14
+ - `hq core rebuild-index companies` now renders the same company INDEX.md on every machine and every day, and skips the write
15
+ when the content is unchanged (logged as `unchanged`). The header line is now `> Auto-generated.` with no date. The `repos/`
16
+ and `data/` rows show fixed labels instead of local counts, `knowledge/` always shows a subdirectory count, symlinked
17
+ children are no longer counted, and project status comes from prd.json only, not local orchestrator state.
18
+ - `hq lanes monitor-check` now recognizes a live `hq lanes watch` Monitor process when the hq
19
+ CLI is installed under a path containing spaces (for example the desktop toolchain under
20
+ `~/Library/Application Support/Indigo HQ/`). Previously those lanes stayed "uncovered" and the
21
+ Stop hook kept blocking the session.
5
22
  ## [5.345.75] — 2026-10-10
6
23
 
7
24
  - Native delegate manifest updates now use mode 0600 on POSIX, matching the
@@ -2,11 +2,16 @@
2
2
  # rebuild-companies-index.sh — regenerate companies/{co}/INDEX.md for each company.
3
3
  #
4
4
  # Pure bash + jq. Zero Claude context. Iterates companies/*/ (skips _template,
5
- # dotfiles), summarizes top-level dirs, scans projects/*/prd.json and
6
- # workspace/orchestrator/{name}/state.json to populate the Projects table.
5
+ # dotfiles), summarizes top-level dirs, scans projects/*/prd.json to populate
6
+ # the Projects table.
7
+ #
8
+ # Output is deterministic: the same synced tree renders the same bytes on every
9
+ # machine and every day. There is no date line, nothing reads local-only state
10
+ # (repos/, data/, an embedded knowledge .git, workspace/orchestrator), and an
11
+ # unchanged file is not rewritten.
7
12
  #
8
13
  # Schema per knowledge/public/hq-core/index-md-spec.md (Company Root variant):
9
- # - "# {Company Title}" + "> Auto-generated. Updated: YYYY-MM-DD"
14
+ # - "# {Company Title}" + "> Auto-generated."
10
15
  # - Top-level Name | Description table (dirs and 1-line desc)
11
16
  # - "## Projects" table (Project | Status | Description)
12
17
  # - "## Deployments" placeholder (None for now — manifest-driven enrichment deferred)
@@ -28,8 +33,6 @@ done
28
33
  HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
29
34
  cd "$HQ_ROOT"
30
35
 
31
- DATE=$(date -u +%Y-%m-%d)
32
-
33
36
  # Title-case a slug: "example-co" -> "Example Co"
34
37
  titleize() {
35
38
  echo "$1" | awk -F'-' '{
@@ -47,18 +50,13 @@ describe_dir() {
47
50
  [[ -d "$path" ]] || return 0
48
51
  case "$d" in
49
52
  data)
50
- local n
51
- n=$(find "$path" -mindepth 1 -maxdepth 1 ! -name '.*' 2>/dev/null | wc -l | tr -d ' ')
52
- echo "${n} item(s)"
53
+ echo "Company data"
53
54
  ;;
54
55
  knowledge)
55
- if [[ -e "${path}/.git" ]]; then
56
- echo "Embedded git repo"
57
- else
58
- local n
59
- n=$(find "$path" -mindepth 1 -maxdepth 1 -type d ! -name '.*' 2>/dev/null | wc -l | tr -d ' ')
60
- echo "Inline knowledge (${n} subdirs)"
61
- fi
56
+ local n
57
+ # Trailing slash: a symlinked knowledge/ is listed through its target.
58
+ n=$(find "${path}/" -mindepth 1 -maxdepth 1 -type d ! -name '.*' 2>/dev/null | wc -l | tr -d ' ')
59
+ echo "Company knowledge (${n} subdirs)"
62
60
  ;;
63
61
  settings)
64
62
  local n
@@ -81,9 +79,7 @@ describe_dir() {
81
79
  echo "${n} worker(s)"
82
80
  ;;
83
81
  repos)
84
- local n
85
- n=$(find "$path" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) ! -name '.*' 2>/dev/null | wc -l | tr -d ' ')
86
- echo "${n} repo link(s)"
82
+ echo "Local repo links (not synced)"
87
83
  ;;
88
84
  registry)
89
85
  local n
@@ -101,22 +97,10 @@ describe_dir() {
101
97
  esac
102
98
  }
103
99
 
104
- # Resolve project status: orchestrator state.json wins, else story-pass fallback.
100
+ # Resolve project status from prd.json alone (story-pass check). The local
101
+ # workspace/orchestrator state is not synced, so it is not consulted.
105
102
  project_status() {
106
- local project="$1"
107
- local prd="$2"
108
- local state="workspace/orchestrator/${project}/state.json"
109
- if [[ -f "$state" ]]; then
110
- local raw
111
- raw=$(jq -r '.status // "active"' "$state" 2>/dev/null || echo "active")
112
- case "$raw" in
113
- completed|complete) echo "complete" ;;
114
- in_progress|active|started) echo "active" ;;
115
- archived) echo "archived" ;;
116
- *) echo "$raw" ;;
117
- esac
118
- return
119
- fi
103
+ local prd="$1"
120
104
  if [[ -f "$prd" ]]; then
121
105
  local total passes
122
106
  total=$(jq '[.userStories[]?] | length' "$prd" 2>/dev/null || echo 0)
@@ -148,6 +132,7 @@ trunc() {
148
132
  write_company_index() {
149
133
  local co="$1"
150
134
  local out="companies/${co}/INDEX.md"
135
+ local tmp="companies/${co}/.INDEX.md.$$.tmp"
151
136
  local title
152
137
  title=$(titleize "$co")
153
138
 
@@ -155,7 +140,7 @@ write_company_index() {
155
140
  {
156
141
  echo "# ${title}"
157
142
  echo ""
158
- echo "> Auto-generated. Updated: ${DATE}"
143
+ echo "> Auto-generated."
159
144
  echo ""
160
145
  echo "| Name | Description |"
161
146
  echo "|------|-------------|"
@@ -187,7 +172,7 @@ write_company_index() {
187
172
  desc=$(jq -r '.description // ""' "$prd" 2>/dev/null || echo "")
188
173
  fi
189
174
  local status
190
- status=$(project_status "$name" "$prd")
175
+ status=$(project_status "$prd")
191
176
  desc=$(sanitize_cell "$desc")
192
177
  desc=$(trunc "$desc" 110)
193
178
  [[ -z "$desc" ]] && desc="—"
@@ -201,9 +186,17 @@ write_company_index() {
201
186
  echo "## Deployments"
202
187
  echo ""
203
188
  echo "_See \`companies/manifest.yaml\` for deployment targets._"
204
- } > "$out"
189
+ } > "$tmp"
205
190
 
206
- echo "rebuild-companies-index: wrote ${out}" >&2
191
+ # Rewrite only when the content changed, so an unchanged index keeps its
192
+ # mtime and is not re-uploaded by HQ Sync.
193
+ if [[ -f "$out" ]] && cmp -s "$tmp" "$out"; then
194
+ rm -f "$tmp"
195
+ echo "rebuild-companies-index: unchanged ${out}" >&2
196
+ else
197
+ mv -f "$tmp" "$out"
198
+ echo "rebuild-companies-index: wrote ${out}" >&2
199
+ fi
207
200
  }
208
201
 
209
202
  COUNT=0
@@ -13,7 +13,7 @@ export async function coreNativeFamilyEnabled(flagKey) {
13
13
  export const CORE_NATIVE_DELEGATE_FLAG = "core-native-delegate";
14
14
  export const CORE_NATIVE_ONTOLOGY_FLAG = "core-native-ontology";
15
15
  export function coreNativeDelegateEnabled() {
16
- return import("../lib/flag-registry.js").then(({ resolveProcessFlagGate }) => resolveProcessFlagGate(CORE_NATIVE_DELEGATE_FLAG, { fallback: false }, () => false));
16
+ return coreNativeFamilyEnabled(CORE_NATIVE_DELEGATE_FLAG);
17
17
  }
18
18
  export function coreNativeOntologyEnabled() {
19
19
  return coreNativeFamilyEnabled(CORE_NATIVE_ONTOLOGY_FLAG);
@@ -50,7 +50,7 @@ export type FastScaffoldScript = {
50
50
  requiresNodeNetworkCompatibility?: boolean;
51
51
  };
52
52
  /** Family kill switches whose owner-approved default is enabled. */
53
- export declare const CORE_NATIVE_DEFAULT_ON_FAMILY_FLAGS: readonly ["core-native-session", "core-native-hook-helpers", "core-native-policy", "core-native-utilities", "core-native-jobs", "core-native-ontology", "core-native-handoff"];
53
+ export declare const CORE_NATIVE_DEFAULT_ON_FAMILY_FLAGS: readonly ["core-native-session", "core-native-hook-helpers", "core-native-policy", "core-native-utilities", "core-native-jobs", "core-native-ontology", "core-native-handoff", "core-native-delegate"];
54
54
  export declare function isDefaultOnFastFamily(flagKey: string): boolean;
55
55
  /** The relocated scripts that qualify for the entrypoint fast path. */
56
56
  export declare const FAST_SCAFFOLD_SCRIPTS: readonly FastScaffoldScript[];
@@ -18,6 +18,7 @@ export const CORE_NATIVE_DEFAULT_ON_FAMILY_FLAGS = [
18
18
  "core-native-jobs",
19
19
  "core-native-ontology",
20
20
  "core-native-handoff",
21
+ "core-native-delegate",
21
22
  ];
22
23
  const defaultOnFamilyFlags = new Set(CORE_NATIVE_DEFAULT_ON_FAMILY_FLAGS);
23
24
  export function isDefaultOnFastFamily(flagKey) {
@@ -138,8 +138,10 @@ export function validateBotDisplayName(text) {
138
138
  expected: true,
139
139
  });
140
140
  }
141
- if (!/^[\p{L}\p{M}][\p{L}\p{M} .'-]*$/u.test(trimmed)) {
142
- throw Object.assign(new Error("--display-name can use letters, spaces, apostrophes, periods and hyphens."), {
141
+ // A letter first, then letters, numbers, spaces, apostrophes, periods and
142
+ // hyphens ("R2D2", "Scout 2").
143
+ if (!/^[\p{L}\p{M}][\p{L}\p{M}\p{N} .'-]*$/u.test(trimmed)) {
144
+ throw Object.assign(new Error("--display-name can use letters, numbers, spaces, apostrophes, periods and hyphens."), {
143
145
  expected: true,
144
146
  });
145
147
  }
@@ -24,5 +24,27 @@ export declare function codexPrompt(input: RuntimeTurnInput): string;
24
24
  * warnings (`item.completed` with `item.type === "error"`) are not failures.
25
25
  */
26
26
  export declare function codexFailureReason(stdout: string): string | undefined;
27
+ /** True when `candidate` is a regular file the current user can execute. */
28
+ export type IsExecutableFile = (candidate: string) => boolean;
29
+ export declare function isExecutableFile(candidate: string): boolean;
30
+ /** App bundles that carry the Codex CLI, in the HQ desktop app's lookup order. */
31
+ export declare const CODEX_APP_BUNDLES: readonly ["ChatGPT.app", "Codex.app"];
32
+ /** Where the CLI sits inside a bundle: current layout first, then the older one. */
33
+ export declare const CODEX_BUNDLE_CLI_PATHS: readonly [string, string];
34
+ /**
35
+ * The Codex CLI shipped inside the ChatGPT or Codex desktop app. A Mac that
36
+ * only has the desktop app has no `codex` on PATH, yet the HQ app counts it as
37
+ * installed, so a bot must be able to use it too. Order matches the HQ desktop
38
+ * app (apps/sync/src-tauri/src/commands/launch.rs, bundled_codex_bin_in):
39
+ * /Applications before ~/Applications, ChatGPT.app before Codex.app, and the
40
+ * codex-cli/bin layout before the older Resources/codex one.
41
+ */
42
+ export declare function bundledCodexBin(homeDir?: string, isExecutable?: IsExecutableFile): string | null;
43
+ /**
44
+ * PATH wins. On macOS, fall back to the desktop app's bundled CLI. When
45
+ * nothing is found the bare name is returned, so the spawn fails with the
46
+ * usual "codex is not installed or not on PATH" error.
47
+ */
48
+ export declare function resolveCodexBinary(env: NodeJS.ProcessEnv, homeDir?: string, platform?: NodeJS.Platform, cwd?: string, isExecutable?: IsExecutableFile): string;
27
49
  export declare function createCodexRuntime(): BotRuntime;
28
50
  //# sourceMappingURL=codex.d.ts.map
@@ -13,6 +13,9 @@
13
13
  * Verified against Codex CLI on 2026-09-10 (event names; the model itself
14
14
  * cannot run under this machine's account).
15
15
  */
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
16
19
  import { parseJsonLine, RuntimeError } from "./types.js";
17
20
  import { resolveRuntimeBinary } from "./windows.js";
18
21
  /** Progress events in one codex JSONL line: finished agent messages and tool starts. */
@@ -79,11 +82,70 @@ function unwrapApiError(raw) {
79
82
  }
80
83
  return t;
81
84
  }
85
+ export function isExecutableFile(candidate) {
86
+ try {
87
+ if (!fs.statSync(candidate).isFile())
88
+ return false;
89
+ fs.accessSync(candidate, fs.constants.X_OK);
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ /** App bundles that carry the Codex CLI, in the HQ desktop app's lookup order. */
97
+ export const CODEX_APP_BUNDLES = ["ChatGPT.app", "Codex.app"];
98
+ /** Where the CLI sits inside a bundle: current layout first, then the older one. */
99
+ export const CODEX_BUNDLE_CLI_PATHS = [
100
+ path.join("Contents", "Resources", "codex-cli", "bin", "codex"),
101
+ path.join("Contents", "Resources", "codex"),
102
+ ];
103
+ /**
104
+ * The Codex CLI shipped inside the ChatGPT or Codex desktop app. A Mac that
105
+ * only has the desktop app has no `codex` on PATH, yet the HQ app counts it as
106
+ * installed, so a bot must be able to use it too. Order matches the HQ desktop
107
+ * app (apps/sync/src-tauri/src/commands/launch.rs, bundled_codex_bin_in):
108
+ * /Applications before ~/Applications, ChatGPT.app before Codex.app, and the
109
+ * codex-cli/bin layout before the older Resources/codex one.
110
+ */
111
+ export function bundledCodexBin(homeDir = os.homedir(), isExecutable = isExecutableFile) {
112
+ const appDirs = ["/Applications", path.join(homeDir, "Applications")];
113
+ for (const dir of appDirs) {
114
+ for (const bundle of CODEX_APP_BUNDLES) {
115
+ for (const rel of CODEX_BUNDLE_CLI_PATHS) {
116
+ const candidate = path.join(dir, bundle, rel);
117
+ if (isExecutable(candidate))
118
+ return candidate;
119
+ }
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ function codexOnPath(env, isExecutable) {
125
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
126
+ if (dir && isExecutable(path.join(dir, "codex")))
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ /**
132
+ * PATH wins. On macOS, fall back to the desktop app's bundled CLI. When
133
+ * nothing is found the bare name is returned, so the spawn fails with the
134
+ * usual "codex is not installed or not on PATH" error.
135
+ */
136
+ export function resolveCodexBinary(env, homeDir, platform = process.platform, cwd = process.cwd(), isExecutable = isExecutableFile) {
137
+ if (platform === "win32")
138
+ return resolveRuntimeBinary("codex", env, platform, undefined, cwd);
139
+ if (platform !== "darwin" || codexOnPath(env, isExecutable))
140
+ return "codex";
141
+ // `||`, not `??`: an empty HOME must not turn ~/Applications into a relative path.
142
+ return bundledCodexBin(homeDir || env.HOME || os.homedir(), isExecutable) ?? "codex";
143
+ }
82
144
  export function createCodexRuntime() {
83
145
  return {
84
146
  id: "codex",
85
147
  binary: "codex",
86
- resolveBinary: (env, cwd, platform) => resolveRuntimeBinary("codex", env, platform ?? process.platform, undefined, cwd),
148
+ resolveBinary: (env, cwd, platform) => resolveCodexBinary(env, undefined, platform ?? process.platform, cwd),
87
149
  buildArgs(input, scratch) {
88
150
  const promptArg = input.platform === "win32" ? "-" : codexPrompt(input);
89
151
  const common = ["--json", "--skip-git-repo-check", "-o", scratch.outputFile];
@@ -1,26 +1,35 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { basename, countImmediate, date, exists, immediateEntries, isHidden, log, projectStatus, readJson, sanitize, titleize, truncate, write } from "./shared.js";
3
+ import { basename, countImmediate, exists, immediateEntries, isHidden, log, prdProjectStatus, readJson, sanitize, titleize, truncate, writeIfChanged } from "./shared.js";
4
4
  export class CompanyIndexUserError extends Error {
5
5
  constructor(message) {
6
6
  super(message);
7
7
  this.name = "CompanyIndexUserError";
8
8
  }
9
9
  }
10
+ /**
11
+ * One-line summary for a top-level company directory.
12
+ *
13
+ * Output must be identical on every machine that holds the same synced tree,
14
+ * because company INDEX.md files are synced. So nothing here reads local-only
15
+ * state: `repos/` (never synced), `data/` (partly synced), whether
16
+ * `knowledge/` is an embedded git checkout, or symlink targets. Counts use
17
+ * lstat so a symlinked child is never counted as a directory or file.
18
+ */
10
19
  function description(root, company, name) {
11
20
  const base = `companies/${company}/${name}`;
12
- const nonHidden = (child) => !isHidden(child);
21
+ const realDirectory = (n, f) => !isHidden(n) && fs.lstatSync(f).isDirectory();
13
22
  switch (name) {
14
- case "data": return `${countImmediate(root, base, nonHidden)} item(s)`;
15
- case "knowledge": return exists(root, `${base}/.git`) ? "Embedded git repo" : `Inline knowledge (${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} subdirs)`;
16
- case "settings": return `${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} integration(s)`;
23
+ case "data": return "Company data";
24
+ case "knowledge": return `Company knowledge (${countImmediate(root, base, realDirectory)} subdirs)`;
25
+ case "settings": return `${countImmediate(root, base, realDirectory)} integration(s)`;
17
26
  case "policies": return `${countImmediate(root, base, (n) => n.endsWith(".md"))} policy file(s)`;
18
27
  // Match `find -type d`: count actual directories, not symlinks to directories.
19
- case "projects": return `${countImmediate(root, base, (n, f) => !n.startsWith("_") && !isHidden(n) && fs.lstatSync(f).isDirectory())} project(s)`;
20
- case "workers": return `${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} worker(s)`;
21
- case "repos": return `${countImmediate(root, base, (n, f) => { const s = fs.lstatSync(f); return !isHidden(n) && (s.isDirectory() || s.isSymbolicLink()); })} repo link(s)`;
28
+ case "projects": return `${countImmediate(root, base, (n, f) => !n.startsWith("_") && realDirectory(n, f))} project(s)`;
29
+ case "workers": return `${countImmediate(root, base, realDirectory)} worker(s)`;
30
+ case "repos": return "Local repo links (not synced)";
22
31
  case "registry": return `Resource registry (${countImmediate(root, `${base}/resources`, (n) => n.endsWith(".yaml"))} resource(s))`;
23
- case "scripts": return `${countImmediate(root, base, (_n, f) => fs.statSync(f).isFile())} script(s)`;
32
+ case "scripts": return `${countImmediate(root, base, (_n, f) => fs.lstatSync(f).isFile())} script(s)`;
24
33
  default: return "—";
25
34
  }
26
35
  }
@@ -56,6 +65,7 @@ export function renderCompanies(context, args = []) {
56
65
  }
57
66
  }
58
67
  const written = [];
68
+ const unchanged = [];
59
69
  let scopedDirectories;
60
70
  if (selectedCompany === undefined) {
61
71
  scopedDirectories = immediateEntries(context.root, "companies", "dir");
@@ -87,7 +97,9 @@ export function renderCompanies(context, args = []) {
87
97
  const company = basename(directory);
88
98
  if (company.startsWith("_") || isHidden(company))
89
99
  continue;
90
- const lines = [`# ${titleize(company)}`, "", `> Auto-generated. Updated: ${date(context.now)}`, "", "| Name | Description |", "|------|-------------|"];
100
+ // No date line: the file must only change when its content changes. The
101
+ // `> Auto-generated.` marker stays, since hq-cloud detects generated INDEX.md by it.
102
+ const lines = [`# ${titleize(company)}`, "", "> Auto-generated.", "", "| Name | Description |", "|------|-------------|"];
91
103
  for (const name of ["data", "knowledge", "policies", "projects", "registry", "repos", "scripts", "settings", "workers"]) {
92
104
  if (exists(context.root, `companies/${company}/${name}`))
93
105
  lines.push(`| \`${name}/\` | ${sanitize(description(context.root, company, name))} |`);
@@ -104,18 +116,23 @@ export function renderCompanies(context, args = []) {
104
116
  const parsed = readJson(prd);
105
117
  let text = typeof parsed?.description === "string" ? parsed.description : "";
106
118
  text = truncate(sanitize(text), 110) || "—";
107
- lines.push(`| \`${project}\` | ${projectStatus(context.root, project, prd, "active", prd)} | ${text} |`);
119
+ lines.push(`| \`${project}\` | ${prdProjectStatus(prd, "active")} | ${text} |`);
108
120
  }
109
121
  }
110
122
  else
111
123
  lines.push("_No projects directory._");
112
124
  lines.push("", "## Deployments", "", "_See `companies/manifest.yaml` for deployment targets._", "");
113
125
  const output = `companies/${company}/INDEX.md`;
114
- write(context, output, lines.join("\n"));
115
- written.push(output);
116
- log(context, `rebuild-companies-index: wrote ${output}`);
126
+ if (writeIfChanged(context, output, lines.join("\n"))) {
127
+ written.push(output);
128
+ log(context, `rebuild-companies-index: wrote ${output}`);
129
+ }
130
+ else {
131
+ unchanged.push(output);
132
+ log(context, `rebuild-companies-index: unchanged ${output}`);
133
+ }
117
134
  }
118
- log(context, `rebuild-companies-index: regenerated ${written.length} company INDEX.md file(s)`);
119
- return { written };
135
+ log(context, `rebuild-companies-index: regenerated ${written.length + unchanged.length} company INDEX.md file(s)`);
136
+ return { written, unchanged };
120
137
  }
121
138
  //# sourceMappingURL=companies.js.map
@@ -10,6 +10,8 @@ export type RenderContext = {
10
10
  };
11
11
  export type RenderResult = {
12
12
  written: string[];
13
+ /** Relative paths whose rendered content matched the file on disk, so no write happened. */
14
+ unchanged?: string[];
13
15
  };
14
16
  export declare function rel(root: string, file: string): string;
15
17
  export declare function at(root: string, relative: string): string;
@@ -32,6 +34,15 @@ export declare function atomicWrite(file: string, content: string | Buffer, opti
32
34
  mode?: number;
33
35
  }): void;
34
36
  export declare function write(context: RenderContext, relative: string, content: string): string;
37
+ /**
38
+ * Write only when the rendered bytes differ from the file on disk. Returns
39
+ * true when the file was written, false when it was already identical.
40
+ *
41
+ * The comparison is exact rather than `generatedFingerprint`: that fingerprint
42
+ * drops keep fences and trailing newlines, which would hide real differences.
43
+ * Callers that want a skip must render deterministic content (no dates).
44
+ */
45
+ export declare function writeIfChanged(context: RenderContext, relative: string, content: string): boolean;
35
46
  /** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
36
47
  export declare function generatedFingerprint(content: string): string;
37
48
  /** Insert a content stamp after the Auto-generated line. Idempotent. */
@@ -42,6 +53,13 @@ export declare function looksAutoGenerated(content: string): boolean;
42
53
  export declare function generatedStamp(content: string): string | undefined;
43
54
  export declare function log(context: RenderContext, message: string): void;
44
55
  export declare function projectStatus(root: string, project: string, prdPath: string, fallback: string, expectedStatePrdPath?: string): string;
56
+ /**
57
+ * Project status from prd.json alone: "complete" when every story passes,
58
+ * otherwise `fallback`. Unlike `projectStatus`, this never reads the
59
+ * machine-local `workspace/orchestrator/<project>/state.json`, so synced
60
+ * output renders the same on every machine.
61
+ */
62
+ export declare function prdProjectStatus(prdPath: string, fallback: string): string;
45
63
  export declare function basename(file: string): string;
46
64
  export declare function isHidden(name: string): boolean;
47
65
  export declare function mtime(file: string): number;
@@ -100,6 +100,21 @@ export function write(context, relative, content) {
100
100
  atomicWrite(at(context.root, relative), content);
101
101
  return relative;
102
102
  }
103
+ /**
104
+ * Write only when the rendered bytes differ from the file on disk. Returns
105
+ * true when the file was written, false when it was already identical.
106
+ *
107
+ * The comparison is exact rather than `generatedFingerprint`: that fingerprint
108
+ * drops keep fences and trailing newlines, which would hide real differences.
109
+ * Callers that want a skip must render deterministic content (no dates).
110
+ */
111
+ export function writeIfChanged(context, relative, content) {
112
+ const file = at(context.root, relative);
113
+ if (readText(file) === content)
114
+ return false;
115
+ atomicWrite(file, content);
116
+ return true;
117
+ }
103
118
  const GENERATED_DATE_LINE = /^> Auto-generated\. Updated: \d{4}-\d{2}-\d{2}$/m;
104
119
  const GENERATED_STAMP_LINE = /^<!-- hq-generated-sha256: [a-f0-9]{64} -->\n?/m;
105
120
  function keepFencePattern() {
@@ -149,6 +164,15 @@ export function projectStatus(root, project, prdPath, fallback, expectedStatePrd
149
164
  return "active";
150
165
  return raw;
151
166
  }
167
+ return prdProjectStatus(prdPath, fallback);
168
+ }
169
+ /**
170
+ * Project status from prd.json alone: "complete" when every story passes,
171
+ * otherwise `fallback`. Unlike `projectStatus`, this never reads the
172
+ * machine-local `workspace/orchestrator/<project>/state.json`, so synced
173
+ * output renders the same on every machine.
174
+ */
175
+ export function prdProjectStatus(prdPath, fallback) {
152
176
  const prd = readJson(prdPath);
153
177
  if (prd && Array.isArray(prd.userStories) && prd.userStories.length > 0 && prd.userStories.every((story) => Boolean(story.passes)))
154
178
  return "complete";
@@ -347,26 +347,41 @@ function defaultProcessCommands() {
347
347
  return [];
348
348
  }
349
349
  }
350
+ const WATCH_RUNTIME_EXECUTABLES = ["bun", "deno", "node", "nodejs", "ts-node", "tsx"];
351
+ // The hq executable path may contain spaces (the desktop toolchain installs
352
+ // under "~/Library/Application Support/Indigo HQ/..."), so the ps line cannot
353
+ // be split on whitespace to find it. Match the fixed "lanes watch <ids>" suffix
354
+ // on the raw line, then validate whatever precedes it as an hq invocation.
355
+ // A path form (absolute or ./, ../) is required so an unrelated leading
356
+ // command such as "vim /tmp/hq" is not absorbed into the path.
357
+ function isHqExecutablePath(candidate) {
358
+ if (candidate === "hq")
359
+ return true;
360
+ if (!/^\.{0,2}\//u.test(candidate) || path.basename(candidate) !== "hq") {
361
+ return false;
362
+ }
363
+ return !candidate.split(" ").some((piece) => piece.startsWith("-"));
364
+ }
365
+ function isHqInvocationPrefix(prefix) {
366
+ if (isHqExecutablePath(prefix))
367
+ return true;
368
+ const runtimeEnd = prefix.indexOf(" ");
369
+ if (runtimeEnd === -1)
370
+ return false;
371
+ const runtime = path.basename(prefix.slice(0, runtimeEnd));
372
+ return (WATCH_RUNTIME_EXECUTABLES.includes(runtime) &&
373
+ isHqExecutablePath(prefix.slice(runtimeEnd + 1)));
374
+ }
350
375
  function processHasExactCommand(processCommands, command) {
351
376
  const laneIds = parseCanonicalWatchCommand(command);
352
377
  if (!laneIds)
353
378
  return false;
354
- const expectedSuffix = ["lanes", "watch", ...laneIds];
355
- return processCommands.some((line) => {
356
- const tokens = line.trim().split(/\s+/u);
357
- const suffixStart = tokens.length - expectedSuffix.length;
358
- if (suffixStart < 1)
359
- return false;
360
- if (!expectedSuffix.every((token, index) => tokens[suffixStart + index] === token)) {
361
- return false;
362
- }
363
- const hqToken = tokens[suffixStart - 1];
364
- if (path.basename(hqToken) !== "hq")
379
+ const expectedSuffix = ` lanes watch ${laneIds.join(" ")}`;
380
+ return processCommands.some((rawLine) => {
381
+ const line = rawLine.trim().replace(/\s+/gu, " ");
382
+ if (!line.endsWith(expectedSuffix))
365
383
  return false;
366
- const prefix = tokens.slice(0, suffixStart - 1);
367
- return (prefix.length === 0 ||
368
- (prefix.length === 1 &&
369
- ["bun", "deno", "node", "nodejs", "ts-node", "tsx"].includes(path.basename(prefix[0]))));
384
+ return isHqInvocationPrefix(line.slice(0, -expectedSuffix.length));
370
385
  });
371
386
  }
372
387
  export function readLiveMonitorEvidence(sessionId, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.75",
3
+ "version": "5.345.76",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {