@indigoai-us/hq-cli 5.345.75 → 5.345.77

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 (40) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +30 -37
  3. package/dist/command-catalog.generated.d.ts +83 -3
  4. package/dist/command-catalog.generated.js +100 -3
  5. package/dist/commands/access.d.ts +1 -0
  6. package/dist/commands/access.js +17 -5
  7. package/dist/commands/bot.d.ts +12 -0
  8. package/dist/commands/bot.js +54 -2
  9. package/dist/commands/core-native-family-flag.js +1 -1
  10. package/dist/commands/files.js +7 -2
  11. package/dist/commands/members.d.ts +42 -0
  12. package/dist/commands/members.js +151 -1
  13. package/dist/commands/secrets.js +1 -1
  14. package/dist/fast-scaffold.d.ts +1 -1
  15. package/dist/fast-scaffold.js +1 -0
  16. package/dist/lib/bot/api.d.ts +11 -0
  17. package/dist/lib/bot/api.js +2 -0
  18. package/dist/lib/bot/config.js +4 -2
  19. package/dist/lib/bot/inflight.d.ts +3 -0
  20. package/dist/lib/bot/probe.d.ts +57 -0
  21. package/dist/lib/bot/probe.js +100 -0
  22. package/dist/lib/bot/run.d.ts +4 -0
  23. package/dist/lib/bot/run.js +171 -37
  24. package/dist/lib/bot/runtime/codex.d.ts +67 -1
  25. package/dist/lib/bot/runtime/codex.js +197 -3
  26. package/dist/lib/bot/runtime/grok.js +2 -2
  27. package/dist/lib/bot/runtime/index.d.ts +8 -0
  28. package/dist/lib/bot/runtime/index.js +91 -5
  29. package/dist/lib/bot/runtime/messages-stream.js +1 -1
  30. package/dist/lib/bot/runtime/types.d.ts +33 -0
  31. package/dist/lib/bot/runtime/types.js +10 -0
  32. package/dist/lib/bot/runtime-failure.d.ts +113 -0
  33. package/dist/lib/bot/runtime-failure.js +180 -0
  34. package/dist/lib/bot/runtime-sign-in.js +2 -2
  35. package/dist/lib/bot/status.d.ts +21 -0
  36. package/dist/lib/index-render/companies.js +33 -16
  37. package/dist/lib/index-render/shared.d.ts +18 -0
  38. package/dist/lib/index-render/shared.js +24 -0
  39. package/dist/lib/lanes/monitor.js +30 -15
  40. package/package.json +1 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * What went wrong with a bot's coding tool (Claude Code, Codex, Grok), in a
3
+ * class a person can act on.
4
+ *
5
+ * A failed model turn used to be reported by quoting the runtime's own error
6
+ * in the DM ("Sorry, I couldn't get started on my own (codex failed: The
7
+ * 'gpt-6-astra' model requires a newer version of Codex ...)"), and the
8
+ * fallback copy told the person to check the sign-in even when the cause was
9
+ * something else. Now every failure is classified here, the person gets one
10
+ * plain sentence plus one next step, and the raw text goes only to the bot
11
+ * log. For the four repairable classes the DM also carries a machine-readable
12
+ * `runtime-repair` block in its `richContent` so the desktop can render a
13
+ * repair card; the plain body stays the fallback.
14
+ *
15
+ * Matching is narrow on purpose: each class has its own known error shapes,
16
+ * and anything unrecognised is `unknown`, which never blames the sign-in.
17
+ */
18
+ import { isRuntimeSignInFailure, runtimeDisplayName } from "./runtime-sign-in.js";
19
+ export const RUNTIME_REPAIR_ACTIONS = {
20
+ "signed-out": "signIn",
21
+ "cli-outdated": "update",
22
+ "model-unsupported": "switchModel",
23
+ transient: "tryAgain",
24
+ };
25
+ export function isRuntimeRepairClass(cls) {
26
+ return cls in RUNTIME_REPAIR_ACTIONS;
27
+ }
28
+ const NOT_INSTALLED = /\bis not installed or not on PATH\b|\bspawn \S+ ENOENT\b|\bcommand not found\b/i;
29
+ /**
30
+ * The tool says it is too old for the model. Codex: "The 'gpt-6-astra' model
31
+ * requires a newer version of Codex. Please upgrade to the latest app or CLI
32
+ * and try again." Claude Code words it "... requires a newer version of
33
+ * Claude Code".
34
+ */
35
+ const VERSION_TOO_OLD = /\brequires a newer version of (?:codex|claude code|grok|the (?:app|cli))\b|\bplease upgrade to the latest (?:app or cli|version of (?:codex|claude code|grok))\b/i;
36
+ /** The failing model id inside a version-too-old message, when it names one. */
37
+ const VERSION_TOO_OLD_MODEL = /\bthe ['"`]([^'"`\s]{1,128})['"`] model requires a newer version of\b/i;
38
+ /**
39
+ * Claude Code: "There's an issue with the selected model" (its own wording for
40
+ * a model the account cannot use) and the API's 404 not_found_error whose
41
+ * message starts "model: <id>". Codex and the OpenAI API: model_not_found,
42
+ * "does not exist or you do not have access", "not supported when using
43
+ * Codex with a ChatGPT account".
44
+ */
45
+ const MODEL_UNSUPPORTED = /there'?s an issue with the selected model|\bnot_found_error\b[^\n]{0,200}\bmodel:\s|\bmodel_not_found\b|\b(?:unknown|invalid|unsupported) model\b|\bthe (?:requested )?model ['"`]?[^'"`\s]{1,128}['"`]? (?:does not exist|is not supported|is not available|was not found|has been deprecated)\b|\bmodel ['"`][^'"`\s]{1,128}['"`] (?:does not exist|is not supported|is not available|not found)\b|\bdo(?:es)? not have access to (?:the )?model\b|\bmodel is not supported when using codex with a chatgpt account\b/i;
46
+ const TRANSIENT = /\brate[_ ]limit|\busage[_ ]limit|\byou've hit your usage limit\b|\btoo many requests\b|\b429\b|\boverloaded\b|\b(?:502|503|504|529)\b|\bservice unavailable\b|\bbad gateway\b|\bgateway time-?out\b|\binternal server error\b|\bECONNRESET\b|\bECONNREFUSED\b|\bETIMEDOUT\b|\bENOTFOUND\b|\bEAI_AGAIN\b|\bENETUNREACH\b|\bsocket hang up\b|\bnetwork (?:error|is unreachable)\b|\bstream (?:disconnected|error)\b|\bconnection (?:reset|refused|closed|error)\b|\btimed out\b|\bexceeded \d+ms \(HQ_BOT_TURN_TIMEOUT_MS\)/i;
47
+ /** Sort a runtime's failure text into one class. Order matters: most specific first. */
48
+ export function classifyRuntimeFailure(text) {
49
+ const t = (text ?? "").trim();
50
+ if (!t)
51
+ return "unknown";
52
+ if (NOT_INSTALLED.test(t))
53
+ return "not-installed";
54
+ if (VERSION_TOO_OLD.test(t))
55
+ return "cli-outdated";
56
+ if (isRuntimeSignInFailure(t))
57
+ return "signed-out";
58
+ if (MODEL_UNSUPPORTED.test(t))
59
+ return "model-unsupported";
60
+ if (TRANSIENT.test(t))
61
+ return "transient";
62
+ return "unknown";
63
+ }
64
+ /**
65
+ * Class of a failed turn's error. A RuntimeError's excerpt (the runtime's own
66
+ * reported error: failureReason, turn.failed, stderr) is the better evidence;
67
+ * its message (what HQ saw, e.g. "codex is not installed or not on PATH")
68
+ * decides when the excerpt says nothing known. An excerpt that is only a tail
69
+ * of the runtime's ordinary output (`excerptIsOutputTail`) is never read: the
70
+ * model may have printed "command not found" or a 503 itself.
71
+ */
72
+ export function classifyRuntimeError(err) {
73
+ if (err && typeof err === "object" && "stderrExcerpt" in err) {
74
+ const e = err;
75
+ const message = typeof e.message === "string" ? e.message : "";
76
+ if (e.excerptIsOutputTail === true)
77
+ return classifyRuntimeFailure(message);
78
+ const fromExcerpt = classifyRuntimeFailure(typeof e.stderrExcerpt === "string" ? e.stderrExcerpt : "");
79
+ if (fromExcerpt !== "unknown")
80
+ return fromExcerpt;
81
+ return classifyRuntimeFailure(message);
82
+ }
83
+ return classifyRuntimeFailure(err instanceof Error ? err.message : String(err ?? ""));
84
+ }
85
+ export function isRuntimeVersionTooOld(text) {
86
+ return VERSION_TOO_OLD.test((text ?? "").trim());
87
+ }
88
+ /** "gpt-6-astra" from "The 'gpt-6-astra' model requires a newer version of Codex ...". */
89
+ export function versionTooOldModel(text) {
90
+ return VERSION_TOO_OLD_MODEL.exec(text ?? "")?.[1];
91
+ }
92
+ /**
93
+ * The plain reason, as a sentence fragment without a closing period
94
+ * ("my Codex app is out of date"). Used in job run dispositions.
95
+ */
96
+ export function runtimeFailureReason(cls, runtime) {
97
+ const tool = runtimeDisplayName(runtime);
98
+ switch (cls) {
99
+ case "signed-out":
100
+ return `${tool} needs you to sign in again on this computer`;
101
+ case "cli-outdated":
102
+ return `my ${tool} app is out of date`;
103
+ case "model-unsupported":
104
+ return `the model I'm set to use isn't available in ${tool} here`;
105
+ case "transient":
106
+ return `${tool} is busy or out of reach right now`;
107
+ case "not-installed":
108
+ return `${tool} isn't installed on this computer`;
109
+ default:
110
+ return `${tool} stopped with an error`;
111
+ }
112
+ }
113
+ /** The one next step, as a full sentence. */
114
+ export function runtimeFailureNextStep(cls, runtime, context = "message") {
115
+ const tool = runtimeDisplayName(runtime);
116
+ switch (cls) {
117
+ case "signed-out":
118
+ return `Use "Sign in to ${tool}" in HQ, then send me any message.`;
119
+ case "cli-outdated":
120
+ return `Update ${tool}, then send me any message.`;
121
+ case "model-unsupported":
122
+ return `Choose a different model in my settings, then send me any message.`;
123
+ case "transient":
124
+ return context === "kickoff"
125
+ ? `Send me any message in a few minutes and I'll get started.`
126
+ : `Send your message again in a few minutes.`;
127
+ case "not-installed":
128
+ return `Install ${tool} from HQ, then send me any message.`;
129
+ default:
130
+ return context === "kickoff"
131
+ ? `Send me any message and I'll try again. If it keeps happening, restart me from HQ.`
132
+ : `Send your message again. If it keeps happening, restart me from HQ.`;
133
+ }
134
+ }
135
+ function capitalize(s) {
136
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
137
+ }
138
+ /** "My Codex app is out of date. Update Codex, then send me any message." */
139
+ export function runtimeFailureText(cls, runtime, context = "message") {
140
+ return `${capitalize(runtimeFailureReason(cls, runtime))}. ${runtimeFailureNextStep(cls, runtime, context)}`;
141
+ }
142
+ /** A failure after the model started (and maybe posted part of the answer). */
143
+ export function runtimeDidNotFinishText(cls, runtime, progressPosted) {
144
+ return `I didn't finish that. ${runtimeFailureText(cls, runtime)}${progressPosted ? " The messages above show how far I got." : ""}`;
145
+ }
146
+ export function runtimeRepairBlock(args) {
147
+ return {
148
+ v: 1,
149
+ kind: "runtime-repair",
150
+ class: args.cls,
151
+ runtime: args.runtime,
152
+ action: RUNTIME_REPAIR_ACTIONS[args.cls],
153
+ botName: args.botName,
154
+ ...(args.retryOf ? { retryOf: args.retryOf } : {}),
155
+ ...(args.suggestedModel ? { suggestedModel: args.suggestedModel.slice(0, 128) } : {}),
156
+ };
157
+ }
158
+ /** The plain body plus, for a repairable class, the repair block. */
159
+ export function runtimeRepairNotice(args) {
160
+ const text = args.text ?? runtimeFailureText(args.cls, args.runtime, args.context);
161
+ if (!isRuntimeRepairClass(args.cls))
162
+ return { cls: args.cls, text };
163
+ return {
164
+ cls: args.cls,
165
+ text,
166
+ richContent: {
167
+ v: 1,
168
+ blocks: [
169
+ runtimeRepairBlock({
170
+ cls: args.cls,
171
+ runtime: args.runtime,
172
+ botName: args.botName,
173
+ ...(args.retryOf ? { retryOf: args.retryOf } : {}),
174
+ ...(args.suggestedModel ? { suggestedModel: args.suggestedModel } : {}),
175
+ }),
176
+ ],
177
+ },
178
+ };
179
+ }
180
+ //# sourceMappingURL=runtime-failure.js.map
@@ -10,7 +10,7 @@
10
10
  */
11
11
  /** Time between the bot's own retries while its sign-in is broken. */
12
12
  export const SIGN_IN_RETRY_MS = 60_000;
13
- const SIGN_IN_FAILURE = /failed to authenticate|authentication[_ ]failed|not logged in|not signed in|please (?:run \/login|log ?in|sign ?in)|(?:oauth|access|refresh) token (?:has been |was )?(?:expired|revoked|invalid)|oauth session expired|invalid api key|\b401\b|unauthori[sz]ed|login (?:required|expired)|sign-?in (?:required|expired)/i;
13
+ const SIGN_IN_FAILURE = /failed to authenticate|authentication[_ ]failed|not logged in|not signed in|please (?:run \/login|log ?in|sign ?in)|(?:oauth|access|refresh) token (?:has been |has |was )?(?:expired|revoked|invalid)|oauth session expired|invalid api key|\b401\b|unauthori[sz]ed|login (?:required|expired)|sign-?in (?:required|expired)/i;
14
14
  /** Usage and rate limits are a different problem; a new sign-in does not fix them. */
15
15
  const NOT_SIGN_IN = /usage[_ ]limit|rate[_ ]limit|quota|too many requests|credits/i;
16
16
  export function isRuntimeSignInFailure(text) {
@@ -26,6 +26,6 @@ export function runtimeDisplayName(runtime) {
26
26
  export function signInNeededText(runtime, kickoff = false) {
27
27
  const tool = runtimeDisplayName(runtime);
28
28
  return (`I can't work right now: ${tool} needs you to sign in again on this computer. ` +
29
- `Use "Sign in to ${tool}" in HQ, and I'll ${kickoff ? "get started" : "answer this"} as soon as it works again — no need to send it twice.`);
29
+ `Use "Sign in to ${tool}" in HQ, and I'll ${kickoff ? "get started" : "answer this"} as soon as it works again. No need to send it twice.`);
30
30
  }
31
31
  //# sourceMappingURL=runtime-sign-in.js.map
@@ -44,6 +44,27 @@ export interface BotStatusFile {
44
44
  * reason); cleared by the next turn that works. `null` = known to work.
45
45
  */
46
46
  runtimeSignIn?: RuntimeSignInIssue | null;
47
+ /**
48
+ * Set when the installed coding tool is too old for the model it was asked
49
+ * to use and the last turn ran on `to` instead (`from` is "" when the model
50
+ * came from the tool's own config). `null` once a turn runs on the model it
51
+ * was asked for again. Updating the tool clears it.
52
+ */
53
+ runtimeModelFallback?: {
54
+ from: string;
55
+ to: string;
56
+ at: string;
57
+ } | null;
58
+ /**
59
+ * Set while the bot holds its model turns after FAILURE_LIMIT failures in a
60
+ * window (it used to exit instead, and a bot that exited could neither load
61
+ * an updated hq-cli nor answer the next message). `null` once the hold is
62
+ * over; the next message then runs a fresh turn.
63
+ */
64
+ modelHold?: {
65
+ until: string;
66
+ reason: string;
67
+ } | null;
47
68
  /** hq-cli version this process loaded (a bot keeps it until it restarts). */
48
69
  cliVersion?: string;
49
70
  updatedAt: string;
@@ -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.77",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {