@indigoai-us/hq-cli 5.275.0 → 5.277.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +99 -0
  5. package/assets/scaffold/core/scripts/hq-delegate-repo.sh +158 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-resolve.sh +145 -0
  7. package/assets/scaffold/core/scripts/hq-delegate-secrets.sh +129 -0
  8. package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +175 -0
  9. package/assets/scaffold/core/scripts/lib/secret-patterns.sh +58 -0
  10. package/dist/command-catalog.generated.d.ts +239 -1
  11. package/dist/command-catalog.generated.js +302 -1
  12. package/dist/commands/core-checkpoint.d.ts +4 -2
  13. package/dist/commands/core-checkpoint.js +34 -3
  14. package/dist/commands/core-command-catalog.d.ts +4 -2
  15. package/dist/commands/core-command-catalog.js +5 -2
  16. package/dist/commands/core-delegate.d.ts +6 -0
  17. package/dist/commands/core-delegate.js +987 -0
  18. package/dist/commands/core-ontology.js +3 -3
  19. package/dist/commands/core.js +11 -1
  20. package/dist/commands/daemon.d.ts +4 -0
  21. package/dist/commands/daemon.js +175 -2
  22. package/dist/commands/dm.d.ts +2 -0
  23. package/dist/commands/dm.js +11 -4
  24. package/dist/commands/feedback.d.ts +6 -0
  25. package/dist/commands/feedback.js +86 -37
  26. package/dist/commands/lanes-accounts.js +44 -2
  27. package/dist/commands/lanes-message.js +69 -12
  28. package/dist/commands/lanes-resume.js +2 -0
  29. package/dist/commands/lanes-shared.d.ts +3 -0
  30. package/dist/commands/lanes-shared.js +12 -2
  31. package/dist/commands/lanes.d.ts +5 -1
  32. package/dist/commands/lanes.js +402 -49
  33. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  34. package/dist/commands/run-feature-telemetry.js +6 -0
  35. package/dist/commands/run.js +3 -2
  36. package/dist/commands/secrets.d.ts +16 -1
  37. package/dist/commands/secrets.js +80 -45
  38. package/dist/commands/sync-mode.d.ts +11 -0
  39. package/dist/commands/sync-mode.js +30 -0
  40. package/dist/commands/whoami.d.ts +4 -6
  41. package/dist/commands/whoami.js +39 -8
  42. package/dist/fast-core.d.ts +6 -7
  43. package/dist/fast-core.js +22 -8
  44. package/dist/fast-scaffold.d.ts +33 -7
  45. package/dist/fast-scaffold.js +104 -8
  46. package/dist/index.js +32 -1
  47. package/dist/lanes-drain-hook.js +154 -7
  48. package/dist/lib/agent-kit/run/inbox.js +3 -2
  49. package/dist/lib/billing/plan-lock.d.ts +3 -1
  50. package/dist/lib/billing/plan-lock.js +7 -1
  51. package/dist/lib/checkpoint-ontology/apply.d.ts +6 -0
  52. package/dist/lib/checkpoint-ontology/apply.js +60 -3
  53. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  54. package/dist/lib/checkpoint-ontology/company.js +15 -7
  55. package/dist/lib/checkpoint-ontology/plan.d.ts +36 -0
  56. package/dist/lib/checkpoint-ontology/plan.js +66 -4
  57. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  58. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  59. package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
  60. package/dist/lib/checkpoint-ontology/tree.js +34 -0
  61. package/dist/lib/core-utils/jq-json-serializer.d.ts +5 -0
  62. package/dist/lib/core-utils/jq-json-serializer.js +10 -0
  63. package/dist/lib/core-utils/secret-patterns.d.ts +41 -0
  64. package/dist/lib/core-utils/secret-patterns.js +33 -0
  65. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  66. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  67. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  68. package/dist/lib/core-utils/timeout-guard.js +28 -13
  69. package/dist/lib/daemon/catalog.d.ts +18 -2
  70. package/dist/lib/daemon/catalog.js +18 -2
  71. package/dist/lib/daemon/config.d.ts +6 -0
  72. package/dist/lib/daemon/config.js +15 -2
  73. package/dist/lib/daemon/requests.d.ts +4 -0
  74. package/dist/lib/daemon/requests.js +2 -0
  75. package/dist/lib/daemon/run.js +94 -8
  76. package/dist/lib/daemon/supervisor.d.ts +8 -0
  77. package/dist/lib/daemon/supervisor.js +101 -1
  78. package/dist/lib/daemon/sync-runner.d.ts +58 -0
  79. package/dist/lib/daemon/sync-runner.js +277 -0
  80. package/dist/lib/doctor/__testing__/run-doctor.d.ts +8 -0
  81. package/dist/lib/doctor/__testing__/run-doctor.js +52 -0
  82. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  83. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  84. package/dist/lib/flag-registry.d.ts +12 -7
  85. package/dist/lib/flag-registry.js +66 -75
  86. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  87. package/dist/lib/flag-snapshot-file.js +91 -0
  88. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  89. package/dist/lib/lanes/claude-balance.js +46 -8
  90. package/dist/lib/lanes/config.js +15 -0
  91. package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -0
  92. package/dist/lib/lanes/hire-grant-dispatch.js +41 -0
  93. package/dist/lib/lanes/index.d.ts +1 -1
  94. package/dist/lib/lanes/keep-alive.d.ts +3 -0
  95. package/dist/lib/lanes/keep-alive.js +3 -0
  96. package/dist/lib/lanes/outpost-dispatch.d.ts +4 -0
  97. package/dist/lib/lanes/outpost-dispatch.js +11 -2
  98. package/dist/lib/lanes/reconcile.js +20 -4
  99. package/dist/lib/lanes/spawn.js +17 -10
  100. package/dist/lib/lanes/store.js +45 -1
  101. package/dist/lib/lanes/types.d.ts +30 -1
  102. package/dist/lib/lanes/types.js +2 -2
  103. package/dist/lib/lanes/watcher.js +3 -2
  104. package/dist/lib/process-flag-company-context.d.ts +8 -0
  105. package/dist/lib/process-flag-company-context.js +67 -0
  106. package/dist/main.js +3 -1
  107. package/dist/native-fast-route.d.ts +3 -0
  108. package/dist/native-fast-route.js +39 -0
  109. package/dist/secrets-env-fast-path.d.ts +5 -0
  110. package/dist/secrets-env-fast-path.js +16 -0
  111. package/dist/secrets-env-fast-route.d.ts +18 -0
  112. package/dist/secrets-env-fast-route.js +54 -0
  113. package/dist/utils/cli-telemetry.d.ts +26 -2
  114. package/dist/utils/cli-telemetry.js +178 -3
  115. package/dist/utils/hq-root-resolver.d.ts +13 -0
  116. package/dist/utils/hq-root-resolver.js +103 -0
  117. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  118. package/dist/utils/vault-api.d.ts +8 -0
  119. package/dist/utils/vault-api.js +13 -1
  120. package/package.json +2 -2
@@ -1,21 +1,117 @@
1
1
  /**
2
- * Fast-path manifest for relocated scaffold scripts, kept deliberately
2
+ * Fast-path manifest for selected core commands, kept deliberately
3
3
  * dependency-light.
4
4
  *
5
5
  * `main.ts` eagerly imports the CLI's entire (~60-module) command graph, so any
6
6
  * command routed through it pays seconds of startup — fine for a human typing a
7
7
  * command, ruinous for the hot plumbing this hosts: `hq core hq-session` is
8
8
  * called several times per skill/hook, and `hq core checkpoint-stop-gate` runs
9
- * on every turn's Stop. The entrypoint routes those directly to
10
- * `runBundledScript` instead, and this module holds the tiny parser + table that
11
- * routing needs WITHOUT importing anything heavy. `core.ts` names the same
12
- * assets, and a test pins the two lists together so they cannot drift.
9
+ * on every turn's Stop. The entrypoint routes eligible commands before loading
10
+ * the graph, and this module holds the parser + table that routing needs.
13
11
  */
14
12
  /** The relocated scripts that qualify for the entrypoint fast path. */
15
13
  export const FAST_SCAFFOLD_SCRIPTS = [
16
- { name: "checkpoint-stop-gate", asset: "core/scripts/checkpoint-stop-gate.sh" },
17
- { name: "hq-session", asset: "core/scripts/hq-session.sh" },
14
+ { name: "checkpoint-stop-gate", kind: "shell", asset: "core/scripts/checkpoint-stop-gate.sh" },
15
+ { name: "hq-session", kind: "shell", asset: "core/scripts/hq-session.sh" },
16
+ {
17
+ name: "timeout-guard",
18
+ kind: "native",
19
+ modulePath: "./lib/core-utils/timeout-guard-native.js",
20
+ exportName: "runTimeoutGuardFast",
21
+ flagKey: "hq-core-native-fast-route",
22
+ },
18
23
  ];
24
+ /**
25
+ * Parse the small, non-ambiguous `hq secrets env` surface for the gated entry
26
+ * fast path. Anything this parser does not understand stays with Commander in
27
+ * the full CLI, which remains the source of truth for help and errors.
28
+ */
29
+ export function parseSecretsEnvFastRequest(argv) {
30
+ if (argv[2] !== "secrets")
31
+ return null;
32
+ let index = 3;
33
+ const scope = {};
34
+ let hasCompanyScope = false;
35
+ let hasPersonalScope = false;
36
+ while (index < argv.length && argv[index] !== "env") {
37
+ const arg = argv[index];
38
+ if (arg === "--company") {
39
+ const value = argv[index + 1];
40
+ if (!value || value.startsWith("-") || hasPersonalScope)
41
+ return null;
42
+ hasCompanyScope = true;
43
+ scope.company = value;
44
+ index += 2;
45
+ continue;
46
+ }
47
+ if (arg?.startsWith("--company=")) {
48
+ const value = arg.slice("--company=".length);
49
+ if (!value || hasPersonalScope)
50
+ return null;
51
+ hasCompanyScope = true;
52
+ scope.company = value;
53
+ index += 1;
54
+ continue;
55
+ }
56
+ if (arg === "--personal") {
57
+ if (hasCompanyScope)
58
+ return null;
59
+ hasPersonalScope = true;
60
+ scope.personal = true;
61
+ index += 1;
62
+ continue;
63
+ }
64
+ return null;
65
+ }
66
+ if (argv[index] !== "env")
67
+ return null;
68
+ index += 1;
69
+ const options = { only: [] };
70
+ let hasOnly = false;
71
+ while (index < argv.length) {
72
+ const arg = argv[index];
73
+ if (arg === "-h" || arg === "--help" || arg === "--")
74
+ return null;
75
+ let option;
76
+ let value;
77
+ if (arg === "--only" || arg === "--script" || arg === "--script-id") {
78
+ option = arg;
79
+ value = argv[index + 1];
80
+ if (value === undefined || value.startsWith("-"))
81
+ return null;
82
+ index += 2;
83
+ }
84
+ else if (arg?.startsWith("--only=")) {
85
+ option = "--only";
86
+ value = arg.slice("--only=".length);
87
+ index += 1;
88
+ }
89
+ else if (arg?.startsWith("--script=")) {
90
+ option = "--script";
91
+ value = arg.slice("--script=".length);
92
+ index += 1;
93
+ }
94
+ else if (arg?.startsWith("--script-id=")) {
95
+ option = "--script-id";
96
+ value = arg.slice("--script-id=".length);
97
+ index += 1;
98
+ }
99
+ else {
100
+ return null;
101
+ }
102
+ if (option === "--only") {
103
+ hasOnly = true;
104
+ options.only.push(...(value ?? "").split(",").map((key) => key.trim()).filter(Boolean));
105
+ }
106
+ else if (option === "--script") {
107
+ options.script = value;
108
+ }
109
+ else if (option === "--script-id") {
110
+ options.scriptId = value;
111
+ }
112
+ }
113
+ return hasOnly ? { options, scope } : null;
114
+ }
19
115
  /**
20
116
  * Parse `hq core [--hq-root <path>] <name> [args...]` when `<name>` is a
21
117
  * fast-path script. Returns null for anything else so the caller defers to the
@@ -48,7 +144,7 @@ export function parseFastCore(argv) {
48
144
  const entry = FAST_SCAFFOLD_SCRIPTS.find((candidate) => candidate.name === name);
49
145
  if (!entry)
50
146
  return null;
51
- return { asset: entry.asset, hqRootArg, args: argv.slice(i + 1) };
147
+ return { entry, hqRootArg, args: argv.slice(i + 1) };
52
148
  }
53
149
  /** Whether this invocation is a fast-path scaffold dispatch. */
54
150
  export function isFastCoreRequest(argv) {
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import "./node-network-compat.js";
6
6
  import { isVersionRequest, wantsLabelledVersionReport } from "./version-request.js";
7
7
  // Dependency-light: a pure parser + table, no command graph. Safe to load on
8
8
  // every path (including --version) without reintroducing the heavy startup.
9
- import { isFastCoreRequest } from "./fast-scaffold.js";
9
+ import { isFastCoreRequest, parseFastCore, parseSecretsEnvFastRequest, } from "./fast-scaffold.js";
10
10
  // Also dependency-light: registers a process-level rejection boundary; its only
11
11
  // heavy import (main.js) is lazy and reached only when a rejection fires.
12
12
  import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
@@ -30,6 +30,37 @@ if (isVersionRequest(process.argv)) {
30
30
  });
31
31
  }
32
32
  }
33
+ else if (parseFastCore(process.argv)?.entry.kind === "native") {
34
+ // Native routes are default-off. Resolve the company-scoped persisted flag
35
+ // without loading the command graph; a miss or stale snapshot takes the
36
+ // existing full-graph route.
37
+ void import("./native-fast-route.js").then(async ({ nativeFastRouteEnabled }) => {
38
+ if (await nativeFastRouteEnabled(process.argv)) {
39
+ const { runFastCore } = await import("./fast-core.js");
40
+ await runFastCore(process.argv);
41
+ }
42
+ else {
43
+ const { runCli } = await import("./main.js");
44
+ await runCli();
45
+ }
46
+ });
47
+ }
48
+ else if (parseSecretsEnvFastRequest(process.argv)) {
49
+ const request = parseSecretsEnvFastRequest(process.argv);
50
+ void import("./secrets-env-fast-route.js").then(async (route) => {
51
+ await route.dispatchSecretsEnvFastRequest(request, {
52
+ isEnabled: () => route.secretsEnvFastRouteEnabled(request),
53
+ runFast: async () => {
54
+ const { runSecretsEnvFastPath } = await import("./secrets-env-fast-path.js");
55
+ return runSecretsEnvFastPath(request);
56
+ },
57
+ runCurrent: async () => {
58
+ const { runCli } = await import("./main.js");
59
+ await runCli();
60
+ },
61
+ });
62
+ });
63
+ }
33
64
  else if (isFastCoreRequest(process.argv)) {
34
65
  // Hot relocated plumbing (`hq core hq-session`, `hq core checkpoint-stop-gate`)
35
66
  // dispatches straight to the bundled-script runner, so it never pays for the
@@ -1,4 +1,6 @@
1
- import { runDrainHook } from "./lib/lanes/dropbox.js";
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  function parseHookInput(text) {
3
5
  if (text.trim() === "")
4
6
  return {};
@@ -24,6 +26,116 @@ function parseHookInput(text) {
24
26
  ...(typeof value.hook_event_name === "string" ? { hook_event_name: value.hook_event_name } : {}),
25
27
  };
26
28
  }
29
+ function laneIdIsSafe(laneId) {
30
+ return laneId !== "" && laneId.trim() === laneId &&
31
+ !laneId.includes("/") && !laneId.includes("\\") &&
32
+ !laneId.includes("\0") && laneId !== "." && laneId !== ".." &&
33
+ !laneId.includes("..");
34
+ }
35
+ function fileExists(filePath) {
36
+ try {
37
+ fs.statSync(filePath);
38
+ return true;
39
+ }
40
+ catch (err) {
41
+ if (err.code === "ENOENT")
42
+ return false;
43
+ throw err;
44
+ }
45
+ }
46
+ function listVisibleNames(dir) {
47
+ try {
48
+ return fs.readdirSync(dir).filter((name) => !name.startsWith("."));
49
+ }
50
+ catch (err) {
51
+ if (err.code === "ENOENT")
52
+ return [];
53
+ throw err;
54
+ }
55
+ }
56
+ /** Return true whenever the full drainer may have work or a condition to report. */
57
+ function dropboxMayNeedDrain(box) {
58
+ const inbox = path.join(box, "inbox.jsonl");
59
+ if (fileExists(inbox))
60
+ return true;
61
+ const draining = path.join(box, "draining");
62
+ if (listVisibleNames(draining).length > 0)
63
+ return true;
64
+ const inflightRoot = path.join(box, "inflight");
65
+ for (const name of listVisibleNames(inflightRoot)) {
66
+ const inflight = path.join(inflightRoot, name);
67
+ const stat = fs.statSync(inflight);
68
+ if (!stat.isDirectory())
69
+ return true;
70
+ if (listVisibleNames(inflight).length > 0)
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ function replayMayNeedWork(box, turnId, input) {
76
+ if (!/^[A-Za-z0-9-]{1,80}$/.test(turnId))
77
+ return false;
78
+ if (input.transcript_path)
79
+ return true;
80
+ let text;
81
+ const statePath = path.join(box, "message-compaction-replay.json");
82
+ try {
83
+ text = fs.readFileSync(statePath, "utf8");
84
+ }
85
+ catch {
86
+ // Keep the existing named replay error behavior for missing or unreadable state.
87
+ return true;
88
+ }
89
+ try {
90
+ const parsed = JSON.parse(text);
91
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
92
+ return true;
93
+ const state = parsed;
94
+ if (state.schema_version !== 1 ||
95
+ state.turn_id !== turnId ||
96
+ (state.last_compaction_marker !== null &&
97
+ (!Number.isSafeInteger(state.last_compaction_marker) || Number(state.last_compaction_marker) < 0)) ||
98
+ (state.transcript_offset !== undefined &&
99
+ (!Number.isSafeInteger(state.transcript_offset) || Number(state.transcript_offset) < 0)) ||
100
+ (state.transcript_path !== undefined && typeof state.transcript_path !== "string") ||
101
+ state.flag_enabled !== undefined) {
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ catch {
107
+ return true;
108
+ }
109
+ }
110
+ function recordToolActivity(hqRoot, laneId) {
111
+ try {
112
+ const activityPath = path.join(hqRoot, "workspace", "lanes-runs", laneId, "logs", "tool-activity.json");
113
+ fs.mkdirSync(path.dirname(activityPath), { recursive: true });
114
+ const record = {
115
+ at: new Date().toISOString(),
116
+ hook_pid: process.pid,
117
+ hook_event: "PreToolUse",
118
+ };
119
+ const tempPath = `${activityPath}.${process.pid}.${process.hrtime.bigint()}.${randomBytes(4).toString("hex")}.tmp`;
120
+ fs.writeFileSync(tempPath, `${JSON.stringify(record)}\n`, "utf8");
121
+ fs.renameSync(tempPath, activityPath);
122
+ return "";
123
+ }
124
+ catch (err) {
125
+ return `hq-lanes-drain: failed to record tool activity: ${err instanceof Error ? err.message : String(err)}\n`;
126
+ }
127
+ }
128
+ function finish(result, inputError) {
129
+ if (result.stdout)
130
+ process.stdout.write(result.stdout);
131
+ if (inputError || result.stderr)
132
+ process.stderr.write(`${inputError}${result.stderr}`);
133
+ process.exitCode = result.exitCode;
134
+ }
135
+ async function runFullPath(input, inputError) {
136
+ const { runDrainHook } = await import("./lib/lanes/dropbox.js");
137
+ finish(await runDrainHook(process.env, input), inputError);
138
+ }
27
139
  async function main() {
28
140
  const chunks = [];
29
141
  for await (const chunk of process.stdin)
@@ -37,12 +149,47 @@ async function main() {
37
149
  catch (err) {
38
150
  inputError = `hq-lanes-drain: ${err instanceof Error ? err.message : String(err)}\n`;
39
151
  }
40
- const result = await runDrainHook(process.env, input);
41
- if (result.stdout)
42
- process.stdout.write(result.stdout);
43
- if (inputError || result.stderr)
44
- process.stderr.write(`${inputError}${result.stderr}`);
45
- process.exitCode = result.exitCode;
152
+ const laneId = process.env.HQ_LANE_ID ?? "";
153
+ if (laneId === "") {
154
+ finish({ exitCode: 0, stdout: "", stderr: "" }, inputError);
155
+ return;
156
+ }
157
+ const hqRoot = process.env.HQ_ROOT ?? "";
158
+ if (hqRoot === "" || !laneIdIsSafe(laneId)) {
159
+ await runFullPath(input, inputError);
160
+ return;
161
+ }
162
+ const box = path.join(hqRoot, "workspace", "lanes-runs", laneId, "dropbox");
163
+ let noWork = false;
164
+ try {
165
+ noWork = !dropboxMayNeedDrain(box) &&
166
+ !replayMayNeedWork(box, process.env.HQ_LANE_TURN_ID ?? "", input);
167
+ }
168
+ catch {
169
+ // Filesystem uncertainty must use the full path so it retains its named errors.
170
+ }
171
+ if (!noWork) {
172
+ await runFullPath(input, inputError);
173
+ return;
174
+ }
175
+ let stderr = recordToolActivity(hqRoot, laneId);
176
+ const directories = [
177
+ box,
178
+ path.join(box, "draining"),
179
+ path.join(box, "delivered"),
180
+ path.join(box, "inflight"),
181
+ ];
182
+ try {
183
+ for (const directory of directories)
184
+ fs.mkdirSync(directory, { recursive: true });
185
+ }
186
+ catch (err) {
187
+ const message = `Failed to open drop box ${box}: ${err instanceof Error ? err.message : String(err)}`;
188
+ stderr += `hq-lanes-drain: ${message}\n`;
189
+ finish({ exitCode: 1, stdout: "", stderr }, inputError);
190
+ return;
191
+ }
192
+ finish({ exitCode: 0, stdout: "", stderr }, inputError);
46
193
  }
47
194
  main().catch((err) => {
48
195
  process.stderr.write(`hq-lanes-drain: hook_runner_failed: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -138,8 +138,9 @@ export async function pollInboxOnce(deps) {
138
138
  if (res.status !== 200) {
139
139
  writeComponentStatus(deps.paths, "inbox", "error");
140
140
  const url = res.url ?? agentInboxUrl(deps.apiBaseUrl, deps.agentUid);
141
- const bodyText = truncateBody(res.bodyText ?? (res.body === undefined ? "" : JSON.stringify(res.body)));
142
- deps.log("warn", `inbox poll GET ${url} → ${res.status}${bodyText ? ` body=${bodyText}` : ""}`);
141
+ // Non-OK bodies are not constrained to an error envelope and could carry
142
+ // inbox items. Keep the status and route, but do not copy the body to logs.
143
+ deps.log("warn", `inbox poll GET ${url} → ${res.status}`);
143
144
  return { ok: false, mirrored: 0 };
144
145
  }
145
146
  writeComponentStatus(deps.paths, "inbox", "ok");
@@ -125,7 +125,9 @@ export declare const HARD_STOP_SENTENCE: string;
125
125
  * dimension this CLI does not know) renders a generic line rather than a claim
126
126
  * about a dimension nobody measured.
127
127
  */
128
- export declare function renderPlanLimitNotice(status: PlanLimitStatus): string;
128
+ export declare function renderPlanLimitNotice(status: PlanLimitStatus, opts?: {
129
+ inviteTeammateEnabled?: boolean;
130
+ }): string;
129
131
  /**
130
132
  * The one-line form injected on every turn while the workspace stays over.
131
133
  * Kept to a single line on purpose — it repeats each turn.
@@ -375,7 +375,7 @@ function capitalize(text) {
375
375
  * dimension this CLI does not know) renders a generic line rather than a claim
376
376
  * about a dimension nobody measured.
377
377
  */
378
- export function renderPlanLimitNotice(status) {
378
+ export function renderPlanLimitNotice(status, opts = {}) {
379
379
  const { companySlug, freeMonth } = status;
380
380
  const lines = [];
381
381
  lines.push(`HQ Starter — ${companySlug} is over its plan limits.`);
@@ -405,6 +405,12 @@ export function renderPlanLimitNotice(status) {
405
405
  lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
406
406
  }
407
407
  lines.push(` Upgrade: ${status.upgradeUrl}`);
408
+ if (opts.inviteTeammateEnabled === true &&
409
+ status.plan === "free" &&
410
+ status.over &&
411
+ status.members?.used === 1) {
412
+ lines.push(` Invite a teammate with: hq members invite <email> --company ${companySlug} --role member`);
413
+ }
408
414
  return lines.join("\n");
409
415
  }
410
416
  /**
@@ -53,6 +53,12 @@ export interface ApplyReport {
53
53
  canonical_name: string;
54
54
  path: string;
55
55
  }>;
56
+ /** Present only when `checkpoint.ontology.dominant-sibling-link` was on: knowledge links moved from a weak sibling to the dominant one. */
57
+ dominant_sibling_redirects?: Array<{
58
+ input: string;
59
+ from: string;
60
+ to: string;
61
+ }>;
56
62
  rejected: Array<{
57
63
  input: string;
58
64
  type: string;
@@ -16,7 +16,7 @@ import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
16
16
  import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
17
17
  import { canonicalizeName, entityId, isRejectedName, isWritableEntityType, normalizeEntityName, squash, toSlug, } from "./identity.js";
18
18
  import { CANDIDATES_FILE, CANDIDATES_SCHEMA, PLAN_FILE, PLAN_SCHEMA, REPORT_FILE, readLedger, writeLedger, } from "./plan.js";
19
- import { buildEntityIndex, indexAlias, indexEntity, listSignalFiles, readSignal } from "./tree.js";
19
+ import { buildEntityIndex, countInboundLinks, indexAlias, indexEntity, listSignalFiles, readSignal } from "./tree.js";
20
20
  export const CAPS = {
21
21
  creates: 25,
22
22
  updates: 100,
@@ -26,6 +26,10 @@ export const CAPS = {
26
26
  nameLength: MAX_NAME_LENGTH,
27
27
  };
28
28
  export const MIN_CREATE_CONFIDENCE = 0.7;
29
+ /** Inbound links the dominant sibling must have for each one the named entity has. */
30
+ const DOMINANT_SIBLING_RATIO = 3;
31
+ /** Inbound links the dominant sibling must have before a redirect. */
32
+ const DOMINANT_SIBLING_MIN_INBOUND = 5;
29
33
  export class ApplyError extends Error {
30
34
  constructor(message) {
31
35
  super(message);
@@ -211,6 +215,7 @@ export function applyOntologyRun(options) {
211
215
  matched: 0,
212
216
  session_links: [],
213
217
  links: [],
218
+ ...(plan.dominant_sibling_link === true ? { dominant_sibling_redirects: [] } : {}),
214
219
  rejected: [],
215
220
  deferred: [],
216
221
  suspect_duplicates: [],
@@ -300,6 +305,44 @@ export function applyOntologyRun(options) {
300
305
  if (types.size > 1)
301
306
  crossTypeNames.add(name);
302
307
  }
308
+ // checkpoint.ontology.dominant-sibling-link: cross-type names, and how many other entity files link to each.
309
+ const inbound = plan.dominant_sibling_link === true ? countInboundLinks(company.dir, index) : new Map();
310
+ const siblings = new Map();
311
+ if (plan.dominant_sibling_link === true) {
312
+ const grouped = new Map();
313
+ for (const entity of index.byRel.values()) {
314
+ const key = crossTypeKey(entity.canonical_name);
315
+ if (key === "")
316
+ continue;
317
+ const members = grouped.get(key) ?? [];
318
+ members.push({ rel: entity.rel, type: entity.type });
319
+ grouped.set(key, members);
320
+ }
321
+ for (const [key, members] of grouped) {
322
+ if (new Set(members.map((member) => member.type)).size < 2)
323
+ continue;
324
+ siblings.set(key, members.map((member) => member.rel));
325
+ }
326
+ }
327
+ const dominantSibling = (rel) => {
328
+ const group = siblings.get(crossTypeKey(index.byRel.get(rel).canonical_name));
329
+ if (!group)
330
+ return null;
331
+ let best = group[0];
332
+ for (const member of group) {
333
+ const count = inbound.get(member) ?? 0;
334
+ const bestCount = inbound.get(best) ?? 0;
335
+ if (count > bestCount || (count === bestCount && member.localeCompare(best) < 0))
336
+ best = member;
337
+ }
338
+ const relCount = inbound.get(rel) ?? 0;
339
+ const bestCount = inbound.get(best) ?? 0;
340
+ if (relCount >= bestCount)
341
+ return null;
342
+ if (bestCount >= DOMINANT_SIBLING_RATIO * relCount && bestCount >= DOMINANT_SIBLING_MIN_INBOUND)
343
+ return best;
344
+ return null;
345
+ };
303
346
  // checkpoint.ontology.named-evidence: the company's own company entity.
304
347
  const ownCompanyRel = `ontology/entities/company/${company.slug}.md`;
305
348
  const isOwnCompany = (entity, rel) => entity.type === "company" && (rel === ownCompanyRel || crossTypeKey(entity.canonical_name) === crossTypeKey(company.slug));
@@ -361,19 +404,33 @@ export function applyOntologyRun(options) {
361
404
  note(report.rejected, "ambiguous_type");
362
405
  continue;
363
406
  }
407
+ const redirect = input.kind === "knowledge" && plan.dominant_sibling_link === true ? dominantSibling(resolved.target) : null;
408
+ const targetRel = redirect ?? resolved.target;
409
+ const targetEntity = redirect ? index.byRel.get(targetRel) : linked;
364
410
  if (plan.named_evidence === true &&
365
411
  input.kind === "knowledge" &&
366
- isOwnCompany(linked, resolved.target) &&
412
+ isOwnCompany(targetEntity, targetRel) &&
367
413
  !evidenceNames(candidate.evidence, [candidate.name, ...candidate.aliases])) {
368
414
  note(report.rejected, "unnamed_in_evidence");
369
415
  continue;
370
416
  }
371
417
  report.matched += 1;
372
- report.links.push({ input: candidate.input, action: "link", type: linked.type, canonical_name: linked.canonical_name, path: resolved.target });
418
+ report.links.push({
419
+ input: candidate.input,
420
+ action: "link",
421
+ type: targetEntity.type,
422
+ canonical_name: targetEntity.canonical_name,
423
+ path: targetRel,
424
+ });
373
425
  if (resolved.tier === 1 && resolved.duplicates.length > 0) {
374
426
  report.suspect_duplicates.push({ name: canonical, type: candidate.type, paths: [resolved.target, ...resolved.duplicates] });
427
+ }
428
+ if (redirect) {
429
+ report.dominant_sibling_redirects?.push({ input: candidate.input, from: resolved.target, to: targetRel });
375
430
  continue;
376
431
  }
432
+ if (resolved.tier === 1 && resolved.duplicates.length > 0)
433
+ continue;
377
434
  // checkpoint.ontology.no-link-aliases: the link is reported and the entity file is left as it is.
378
435
  if (plan.link_aliases === false)
379
436
  continue;
@@ -29,10 +29,14 @@ export declare class WriteBoundsError extends Error {
29
29
  constructor(message: string);
30
30
  }
31
31
  /**
32
- * Write `content` to `target` only when it stays inside one of `roots` with no
33
- * symlink anywhere between `anchor` (the HQ root) and the file. A root or a
34
- * directory under it that is a symlink fails closed, and so does a target that
35
- * is itself a symlink. Missing directories are created only after that check.
32
+ * Resolve `target` only when it stays inside one of `roots` with no symlink
33
+ * anywhere between `anchor` (the HQ root) and the file. A root or a directory
34
+ * under it that is a symlink fails closed, and so does a target that is itself
35
+ * a symlink. Missing directories are created only after that check.
36
+ */
37
+ export declare function assertSafeTarget(anchor: string, roots: string[], target: string): string;
38
+ /**
39
+ * Write `content` to `target` only when {@link assertSafeTarget} accepts it.
36
40
  * The file is written to a temporary sibling and renamed, so a reader never
37
41
  * sees half a file.
38
42
  */
@@ -71,14 +71,12 @@ function nearestExisting(target) {
71
71
  return current;
72
72
  }
73
73
  /**
74
- * Write `content` to `target` only when it stays inside one of `roots` with no
75
- * symlink anywhere between `anchor` (the HQ root) and the file. A root or a
76
- * directory under it that is a symlink fails closed, and so does a target that
77
- * is itself a symlink. Missing directories are created only after that check.
78
- * The file is written to a temporary sibling and renamed, so a reader never
79
- * sees half a file.
74
+ * Resolve `target` only when it stays inside one of `roots` with no symlink
75
+ * anywhere between `anchor` (the HQ root) and the file. A root or a directory
76
+ * under it that is a symlink fails closed, and so does a target that is itself
77
+ * a symlink. Missing directories are created only after that check.
80
78
  */
81
- export function safeWriteFile(anchor, roots, target, content) {
79
+ export function assertSafeTarget(anchor, roots, target) {
82
80
  const resolved = path.resolve(target);
83
81
  const root = roots.map((candidate) => path.resolve(candidate)).find((candidate) => isInside(candidate, resolved));
84
82
  if (!root)
@@ -103,6 +101,16 @@ export function safeWriteFile(anchor, roots, target, content) {
103
101
  if (error.code !== "ENOENT")
104
102
  throw error;
105
103
  }
104
+ return path.resolve(target);
105
+ }
106
+ /**
107
+ * Write `content` to `target` only when {@link assertSafeTarget} accepts it.
108
+ * The file is written to a temporary sibling and renamed, so a reader never
109
+ * sees half a file.
110
+ */
111
+ export function safeWriteFile(anchor, roots, target, content) {
112
+ const resolved = assertSafeTarget(anchor, roots, target);
113
+ const parent = path.dirname(resolved);
106
114
  const tmp = path.join(parent, `.${path.basename(resolved)}.${process.pid}.tmp`);
107
115
  fs.writeFileSync(tmp, content);
108
116
  fs.renameSync(tmp, resolved);
@@ -70,6 +70,17 @@ export declare const FIRST_NAME_PEOPLE_FLAG = "checkpoint.ontology.first-name-pe
70
70
  * to 0.803 and cost recall 0.023 on a replay of six runs.
71
71
  */
72
72
  export declare const SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG = "checkpoint.ontology.skip-ambiguous-concept-links";
73
+ /**
74
+ * A knowledge link to the weaker of two entities that share a canonical name
75
+ * across types is redirected to the sibling other entity files link to
76
+ * (`project/build-your-own-agi` beside `concept/build-your-own-agi`, or
77
+ * `project/hq-core 2` beside `project/hq-core` when a concept also holds the
78
+ * name). The plan carries `dominant_sibling_link: true`, and apply reports
79
+ * the move in `dominant_sibling_redirects` without writing the entity files.
80
+ * CO-60: a replay of six runs raised knowledge precision from 0.725 to 0.754
81
+ * and recall from 0.784 to 0.816, and never changed a create.
82
+ */
83
+ export declare const DOMINANT_SIBLING_LINK_FLAG = "checkpoint.ontology.dominant-sibling-link";
73
84
  /**
74
85
  * A knowledge concept candidate whose name is an alias of more than one
75
86
  * entity, and which apply would defer as `ambiguous_alias`, links instead to
@@ -94,6 +105,15 @@ export declare const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-pr
94
105
  * and lost no gold link.
95
106
  */
96
107
  export declare const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
108
+ /**
109
+ * CO-59: consume the files touched by company checkpoints that were queued
110
+ * behind a busy sibling (see queued-touches.ts). On, the plan adds the
111
+ * company's queued touches to this run's session-touched set, and a run whose
112
+ * own payload names no company plans the company the parent picked for it
113
+ * (`ontology_company`), without that run's session text. Off, queued touches
114
+ * wait on disk and a company-less run skips the ontology step as before.
115
+ */
116
+ export declare const QUEUED_TOUCHES_FLAG = "checkpoint.ontology.queued-touches";
97
117
  export declare const PLAN_FILE = "ontology-plan.json";
98
118
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
99
119
  export declare const REPORT_FILE = "ontology-report.json";
@@ -164,6 +184,8 @@ export interface OntologyPlan {
164
184
  first_name_people?: true;
165
185
  /** Set by checkpoint.ontology.skip-ambiguous-concept-links: apply rejects a knowledge link to a concept whose name another type also holds. */
166
186
  skip_ambiguous_concept_links?: true;
187
+ /** Set by checkpoint.ontology.dominant-sibling-link: apply redirects a knowledge link from a weak cross-type sibling to the one other files link to. */
188
+ dominant_sibling_link?: true;
167
189
  /** Set by checkpoint.ontology.concept-project-link: apply links a knowledge concept with an ambiguous alias to the one project of that canonical name. */
168
190
  concept_project_link?: true;
169
191
  /** Set by checkpoint.ontology.named-evidence: apply rejects a knowledge link to the company's own entity, or a concept-project-link redirect, whose evidence does not name it as a word. */
@@ -183,6 +205,11 @@ export interface OntologyPlan {
183
205
  }
184
206
  export interface CheckpointPayloadLike {
185
207
  company?: unknown;
208
+ /**
209
+ * Set by `hq core checkpoint` only when the payload names no company and
210
+ * another company has queued touches (CO-59). Never scopes anything else.
211
+ */
212
+ ontology_company?: unknown;
186
213
  /** The sibling backend `hq core checkpoint` spawned for this payload. */
187
214
  backend?: unknown;
188
215
  files?: unknown;
@@ -234,6 +261,15 @@ export declare function refreshLedger(company: OntologyCompany, existing: Ledger
234
261
  ledger: Ledger;
235
262
  firstRun: boolean;
236
263
  };
264
+ /**
265
+ * The company this run's ontology step works on: the payload's own company,
266
+ * or else the `ontology_company` the checkpoint parent picked for queued
267
+ * touches (`borrowed`).
268
+ */
269
+ export declare function payloadOntologyCompany(payload: CheckpointPayloadLike): {
270
+ slug: string | null;
271
+ borrowed: boolean;
272
+ };
237
273
  /** Plan this run. Never throws for a skip; returns the reason instead. */
238
274
  export declare function planOntologyRun(options: PlanOptions): Promise<PlanOutcome>;
239
275
  /**