@indigoai-us/hq-cli 5.274.1 → 5.276.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 (77) hide show
  1. package/CHANGELOG.md +88 -4
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
  5. package/dist/command-catalog.generated.d.ts +34 -1
  6. package/dist/command-catalog.generated.js +43 -1
  7. package/dist/commands/cloud.js +22 -9
  8. package/dist/commands/company.js +66 -3
  9. package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
  10. package/dist/commands/core-checkpoint-gate-setting.js +163 -0
  11. package/dist/commands/core-checkpoint.d.ts +4 -2
  12. package/dist/commands/core-checkpoint.js +34 -3
  13. package/dist/commands/core-command-catalog.d.ts +4 -2
  14. package/dist/commands/core-command-catalog.js +5 -2
  15. package/dist/commands/core-ontology.js +3 -3
  16. package/dist/commands/core.js +3 -1
  17. package/dist/commands/daemon.d.ts +1 -0
  18. package/dist/commands/daemon.js +9 -3
  19. package/dist/commands/feedback.d.ts +6 -0
  20. package/dist/commands/feedback.js +86 -37
  21. package/dist/commands/lanes.js +33 -11
  22. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  23. package/dist/commands/run-feature-telemetry.js +6 -0
  24. package/dist/commands/run.js +3 -2
  25. package/dist/commands/secrets.js +7 -1
  26. package/dist/commands/whoami.d.ts +4 -6
  27. package/dist/commands/whoami.js +39 -8
  28. package/dist/fast-core.d.ts +6 -7
  29. package/dist/fast-core.js +22 -8
  30. package/dist/fast-scaffold.d.ts +16 -7
  31. package/dist/fast-scaffold.js +13 -8
  32. package/dist/index.js +16 -1
  33. package/dist/lib/billing/plan-lock.d.ts +3 -1
  34. package/dist/lib/billing/plan-lock.js +7 -1
  35. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  36. package/dist/lib/checkpoint-ontology/company.js +15 -7
  37. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  38. package/dist/lib/checkpoint-ontology/plan.js +53 -4
  39. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  40. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  41. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  42. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  43. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  44. package/dist/lib/core-utils/timeout-guard.js +28 -13
  45. package/dist/lib/daemon/catalog.d.ts +7 -0
  46. package/dist/lib/daemon/catalog.js +6 -0
  47. package/dist/lib/daemon/flag.d.ts +23 -0
  48. package/dist/lib/daemon/flag.js +57 -0
  49. package/dist/lib/daemon/install.js +6 -1
  50. package/dist/lib/daemon/remote-control.d.ts +6 -0
  51. package/dist/lib/daemon/remote-control.js +12 -0
  52. package/dist/lib/daemon/run.js +115 -2
  53. package/dist/lib/daemon/succession.d.ts +116 -0
  54. package/dist/lib/daemon/succession.js +262 -0
  55. package/dist/lib/daemon/supervisor.d.ts +34 -1
  56. package/dist/lib/daemon/supervisor.js +190 -23
  57. package/dist/lib/flag-registry.d.ts +12 -7
  58. package/dist/lib/flag-registry.js +66 -75
  59. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  60. package/dist/lib/flag-snapshot-file.js +91 -0
  61. package/dist/lib/lanes/host-capacity.js +7 -1
  62. package/dist/lib/lanes/models.d.ts +2 -0
  63. package/dist/lib/lanes/models.js +6 -1
  64. package/dist/lib/lanes/reconcile.js +20 -4
  65. package/dist/lib/process-flag-company-context.d.ts +8 -0
  66. package/dist/lib/process-flag-company-context.js +67 -0
  67. package/dist/main.js +3 -1
  68. package/dist/native-fast-route.d.ts +3 -0
  69. package/dist/native-fast-route.js +39 -0
  70. package/dist/utils/cli-telemetry.d.ts +26 -2
  71. package/dist/utils/cli-telemetry.js +178 -3
  72. package/dist/utils/hq-root-resolver.d.ts +13 -0
  73. package/dist/utils/hq-root-resolver.js +103 -0
  74. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  75. package/dist/utils/vault-api.d.ts +8 -0
  76. package/dist/utils/vault-api.js +13 -1
  77. package/package.json +1 -1
@@ -27,6 +27,7 @@ import { registerLaneReportCommands } from "./lanes-report.js";
27
27
  import { registerLaneRepoMergeHoldCommand } from "./lanes-hold.js";
28
28
  import { registerLaneReviewCommand } from "./lanes-review.js";
29
29
  import { ensureCognitoToken } from "../utils/cognito-session.js";
30
+ import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
30
31
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
31
32
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
32
33
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
@@ -1158,7 +1159,7 @@ async function loadClaudeBalanceHostSnapshots(ctx, hqRoot, config) {
1158
1159
  const actualLocalLanes = hostname
1159
1160
  ? localLanes.filter((lane) => lane.host.kind === "local" && lane.host.id === hostname)
1160
1161
  : [];
1161
- if (actualLocalLanes.length > 0) {
1162
+ if (hostname) {
1162
1163
  snapshots.push({
1163
1164
  capacity: buildHostCapacitySnapshot({
1164
1165
  host: actualLocalHost,
@@ -2402,6 +2403,11 @@ async function runCreate(ctx, opts) {
2402
2403
  let placementHostOverride;
2403
2404
  let autoPlacementChoice;
2404
2405
  let autoPlacementSkippedHosts = [];
2406
+ let placementMachineIdentity;
2407
+ const resolvePlacementMachineIdentity = () => {
2408
+ placementMachineIdentity ??= ctx.resolveMachineIdentity();
2409
+ return placementMachineIdentity;
2410
+ };
2405
2411
  let balanceHostLoad;
2406
2412
  let balanceTargetHost;
2407
2413
  let inheritedPlacement;
@@ -2485,23 +2491,39 @@ async function runCreate(ctx, opts) {
2485
2491
  }
2486
2492
  throw err;
2487
2493
  }
2488
- placementHostOverride = autoPlacementChoice.host;
2489
- if (autoPlacementChoice.host.kind === currentHost.kind &&
2490
- autoPlacementChoice.host.id === currentHost.id) {
2491
- outpostId = undefined;
2494
+ const chosenHost = autoPlacementChoice.host;
2495
+ const localHostname = ctx.hostname().trim();
2496
+ if (!localHostname) {
2497
+ throw new LanesError("host_identity_unknown", "Cannot resolve this host for automatic lane placement", {});
2498
+ }
2499
+ const localHost = { kind: "local", id: localHostname };
2500
+ let chosenHostIsCaller = chosenHost.id === currentHost.id ||
2501
+ sanitizeHostComponent(chosenHost.id) === sanitizeHostComponent(localHostname);
2502
+ if (!chosenHostIsCaller && chosenHost.kind === "outpost") {
2503
+ const identity = resolvePlacementMachineIdentity();
2504
+ if (identity.status === "ok") {
2505
+ const machineIds = [identity.id, identity.imds, identity.hostname]
2506
+ .filter((id) => typeof id === "string" && id.trim() !== "");
2507
+ chosenHostIsCaller = machineIds.some((id) => sanitizeHostComponent(chosenHost.id) === sanitizeHostComponent(id.trim()));
2508
+ }
2492
2509
  }
2493
- else if (autoPlacementChoice.host.kind === "outpost") {
2494
- outpostId = autoPlacementChoice.host.id;
2510
+ if (chosenHostIsCaller) {
2511
+ // A capacity snapshot can identify this process as an Outpost (or an
2512
+ // alias of its machine). Auto placement must spawn locally so the
2513
+ // Outpost self-target guard is never reached.
2514
+ placementHostOverride = localHost;
2515
+ outpostId = undefined;
2495
2516
  }
2496
2517
  else {
2497
- outpostId = undefined;
2518
+ placementHostOverride = chosenHost;
2519
+ outpostId = chosenHost.kind === "outpost" ? chosenHost.id : undefined;
2498
2520
  }
2499
2521
  }
2500
2522
  }
2501
2523
  let originHost;
2502
2524
  let originHostSource;
2503
2525
  if (outpostId) {
2504
- const identity = ctx.resolveMachineIdentity();
2526
+ const identity = resolvePlacementMachineIdentity();
2505
2527
  if (identity.status !== "ok") {
2506
2528
  throw new LanesError("origin_host_unresolvable", "Cannot record origin_host; this machine's identity is unresolvable", { identity });
2507
2529
  }
@@ -6890,7 +6912,7 @@ export function registerLanesCommand(parent, deps = {}) {
6890
6912
  .option("--placement <mode>", "local | auto | outpost:<id>")
6891
6913
  .option("--default-placement <mode>", "Default placement for lanes launched by this lane: local | auto | outpost:<id>")
6892
6914
  .action(wrap(async (opts) => {
6893
- await runCreate(ctx, opts);
6915
+ await withCliFeatureUsed("lane_create", () => runCreate(ctx, opts));
6894
6916
  }));
6895
6917
  lanes
6896
6918
  .command("approve-target", { hidden: true })
@@ -7194,7 +7216,7 @@ export function registerLanesCommand(parent, deps = {}) {
7194
7216
  .option("--mesh-company <slug>", "Attach Work Mesh attribution after verifying company membership")
7195
7217
  .option("--no-compact", "Skip the resume-time compaction check for this resume")
7196
7218
  .action(wrap(async (laneId, opts) => {
7197
- await resumeHandlers.runResume(ctx, laneId, opts);
7219
+ await withCliFeatureUsed("lane_resume", () => resumeHandlers.runResume(ctx, laneId, opts));
7198
7220
  }));
7199
7221
  lanes
7200
7222
  .command("reparent")
@@ -0,0 +1,3 @@
1
+ /** Attribute hq run's batch secret read to its resolved company UID. */
2
+ export declare function withRunSecretReadTelemetry<T>(companyUid: string, action: () => Promise<T>): Promise<T>;
3
+ //# sourceMappingURL=run-feature-telemetry.d.ts.map
@@ -0,0 +1,6 @@
1
+ import { withCliFeatureUsed } from '../utils/cli-telemetry.js';
2
+ /** Attribute hq run's batch secret read to its resolved company UID. */
3
+ export function withRunSecretReadTelemetry(companyUid, action) {
4
+ return withCliFeatureUsed('secret_read', action, companyUid);
5
+ }
6
+ //# sourceMappingURL=run-feature-telemetry.js.map
@@ -11,6 +11,7 @@ import { discoverSchemas } from '../run/discover-schemas.js';
11
11
  import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
12
12
  import { assertEnvGraphLoaded, resolveEnvValuesOrThrow, EnvGraphLoadError, EnvResolutionError, } from '../run/env-graph-guard.js';
13
13
  import { hadFatalRejection } from '../unhandled-rejection-boundary.js';
14
+ import { withRunSecretReadTelemetry } from './run-feature-telemetry.js';
14
15
  const SECRET_LOAD_TIMEOUT_MS = 30_000;
15
16
  export async function buildRunUsage(scriptPath, scriptId) {
16
17
  if (scriptId && !scriptPath) {
@@ -87,7 +88,7 @@ export function registerRunCommand(program) {
87
88
  allowOfflineScopeFallback: true,
88
89
  });
89
90
  const usage = await buildRunUsage(opts.script, opts.scriptId);
90
- const fetchBatch = async (companyUid, names, requestUsage) => {
91
+ const fetchBatch = async (companyUid, names, requestUsage) => withRunSecretReadTelemetry(companyUid, async () => {
91
92
  const res = await vaultApiFetch({
92
93
  token,
93
94
  path: `/secrets/${encodeURIComponent(companyUid)}/load`,
@@ -113,7 +114,7 @@ export function registerRunCommand(program) {
113
114
  throw new Error(`Failed to batch-load secrets: ${message}`);
114
115
  }
115
116
  return res.json();
116
- };
117
+ });
117
118
  const pluginOpts = {
118
119
  companyOverride: opts.company,
119
120
  principalUid: cognitoCachePrincipalUid(token) ?? undefined,
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { writeSync } from "node:fs";
5
5
  import * as nodePath from "node:path";
6
6
  import { cognitoCachePrincipalUid, ensureCognitoToken } from "../utils/cognito-session.js";
7
+ import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
7
8
  import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
8
9
  import { computeSha256 } from "../utils/integrity.js";
9
10
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
@@ -1498,9 +1499,14 @@ export function registerSecretsCommand(program) {
1498
1499
  const companyUid = cred.kind === "api-key"
1499
1500
  ? "__api_key__"
1500
1501
  : await getEntityUid(cred.token, scopeOpts(secrets.opts()), { allowOfflineScopeFallback: true });
1501
- const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1502
+ const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1502
1503
  ? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
1503
1504
  : undefined, { allowOfflineFallback: true });
1505
+ // API-key auth has no signed-in person identity for the CLI telemetry
1506
+ // endpoint. Do not attribute that read to an unrelated cached session.
1507
+ const revealed = cred.kind === "cognito"
1508
+ ? await withCliFeatureUsed("secret_read", readSecrets, companyUid)
1509
+ : await readSecrets();
1504
1510
  const secretEnv = {};
1505
1511
  for (const key of keys) {
1506
1512
  const value = revealed.get(key);
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { Command } from 'commander';
5
5
  import { type PlanLimitStatus } from "../lib/billing/plan-lock.js";
6
+ export declare const CLI_LIMIT_INVITE_TEAMMATE_FLAG = "cli.limit-invite-teammate-v1";
6
7
  export interface WhoamiTokenIdentity {
7
8
  email?: string;
8
9
  sub?: string;
@@ -20,11 +21,8 @@ export interface WhoamiDisplayIdentity {
20
21
  * email with the token subject of the delegating machine.
21
22
  */
22
23
  export declare function resolveWhoamiIdentity(identity: WhoamiTokenIdentity): WhoamiDisplayIdentity;
23
- /**
24
- * Human rendering of the plan state: the full over-limit block when the
25
- * workspace is over its Starter limits, the one-line `Plan: Starter — …`
26
- * orientation when it is Starter and healthy, and nothing at all otherwise.
27
- */
28
- export declare function renderWhoamiPlanBlock(status: PlanLimitStatus): string | null;
24
+ export declare function renderWhoamiPlanBlock(status: PlanLimitStatus, opts?: {
25
+ inviteTeammateEnabled?: boolean;
26
+ }): string | null;
29
27
  export declare function registerWhoamiCommand(program: Command): void;
30
28
  //# sourceMappingURL=whoami.d.ts.map
@@ -6,6 +6,9 @@ import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq
6
6
  import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
7
7
  import { ensureCognitoToken, loadMachineCachedTokens, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
8
8
  import { colorizePlanLimitNotice, fetchPlanLimitStatus, renderPlanLine, renderPlanLimitNotice, } from "../lib/billing/plan-lock.js";
9
+ import { resolveCompanyFlagGate, } from "../lib/flag-registry.js";
10
+ import { queuePlanLimitInviteTeammateExposure } from "../utils/cli-telemetry.js";
11
+ export const CLI_LIMIT_INVITE_TEAMMATE_FLAG = "cli.limit-invite-teammate-v1";
9
12
  /**
10
13
  * Resolves the person represented by an ID token without pairing a delegated
11
14
  * email with the token subject of the delegating machine.
@@ -62,7 +65,13 @@ async function readPlanLimits(company) {
62
65
  // default `interactive: true`, a box with no cached session would launch a
63
66
  // browser sign-in and block the caller instead of resolving to UNKNOWN.
64
67
  const token = await ensureCognitoToken({ interactive: false });
65
- return await fetchPlanLimitStatus(token, slug);
68
+ const status = await fetchPlanLimitStatus(token, slug);
69
+ if (!status)
70
+ return null;
71
+ return {
72
+ status,
73
+ idToken: loadCachedTokens()?.idToken,
74
+ };
66
75
  }
67
76
  catch {
68
77
  return null;
@@ -73,19 +82,41 @@ async function readPlanLimits(company) {
73
82
  * workspace is over its Starter limits, the one-line `Plan: Starter — …`
74
83
  * orientation when it is Starter and healthy, and nothing at all otherwise.
75
84
  */
76
- export function renderWhoamiPlanBlock(status) {
85
+ function shouldOfferInviteTeammateSuggestion(status) {
86
+ if (!status.over ||
87
+ status.plan !== "free" ||
88
+ status.members?.used !== 1 ||
89
+ !status.companyUid) {
90
+ return false;
91
+ }
92
+ return true;
93
+ }
94
+ async function inviteTeammateSuggestionEnabled(status, idToken) {
95
+ if (!shouldOfferInviteTeammateSuggestion(status) || !status.companyUid) {
96
+ return false;
97
+ }
98
+ return resolveCompanyFlagGate(status.companyUid, idToken ?? "", CLI_LIMIT_INVITE_TEAMMATE_FLAG, { fallback: false }, () => false);
99
+ }
100
+ export function renderWhoamiPlanBlock(status, opts = {}) {
77
101
  if (status.over) {
78
- return colorizePlanLimitNotice(renderPlanLimitNotice(status));
102
+ const inviteTeammateEnabled = opts.inviteTeammateEnabled === true;
103
+ return colorizePlanLimitNotice(renderPlanLimitNotice(status, { inviteTeammateEnabled }));
79
104
  }
80
105
  return renderPlanLine(status);
81
106
  }
82
107
  async function printPlanBlock(company) {
83
- const status = await readPlanLimits(company);
84
- if (!status)
108
+ const planLimits = await readPlanLimits(company);
109
+ if (!planLimits)
110
+ return;
111
+ const { status, idToken } = planLimits;
112
+ const inviteTeammateEnabled = await inviteTeammateSuggestionEnabled(status, idToken);
113
+ const block = renderWhoamiPlanBlock(status, { inviteTeammateEnabled });
114
+ if (!block)
85
115
  return;
86
- const block = renderWhoamiPlanBlock(status);
87
- if (block)
88
- console.log(block);
116
+ console.log(block);
117
+ if (inviteTeammateEnabled && status.companyUid) {
118
+ queuePlanLimitInviteTeammateExposure(status.companyUid);
119
+ }
89
120
  }
90
121
  export function registerWhoamiCommand(program) {
91
122
  program
@@ -1,16 +1,15 @@
1
1
  /**
2
- * Fast-path dispatcher for relocated scaffold scripts.
2
+ * Fast-path dispatcher for selected core commands.
3
3
  *
4
4
  * Reached from the entrypoint BEFORE `main.ts` (and its heavy command graph) is
5
- * ever imported, so a hot `hq core hq-session` / `hq core checkpoint-stop-gate`
6
- * dispatches in roughly node's own startup time. It imports only the light root
7
- * resolver and the bundled-script runner. The slower commander registration in
8
- * `core.ts` remains the complete path for any invocation this does not match.
5
+ * ever imported, so hot hook commands avoid the full commander graph. Shell
6
+ * entries use the bundled-script runner; native entries import their one handler
7
+ * module. The registration in `core.ts` remains the complete fallback path.
9
8
  */
10
9
  /**
11
- * Run the fast-path scaffold script named in `argv` and exit with its code.
10
+ * Run the fast-path core command named in `argv` and exit with its code.
12
11
  * Only call when {@link isFastCoreRequest} is true; a non-match returns without
13
12
  * doing anything.
14
13
  */
15
- export declare function runFastCore(argv: readonly string[]): void;
14
+ export declare function runFastCore(argv: readonly string[]): Promise<void>;
16
15
  //# sourceMappingURL=fast-core.d.ts.map
package/dist/fast-core.js CHANGED
@@ -1,21 +1,20 @@
1
1
  /**
2
- * Fast-path dispatcher for relocated scaffold scripts.
2
+ * Fast-path dispatcher for selected core commands.
3
3
  *
4
4
  * Reached from the entrypoint BEFORE `main.ts` (and its heavy command graph) is
5
- * ever imported, so a hot `hq core hq-session` / `hq core checkpoint-stop-gate`
6
- * dispatches in roughly node's own startup time. It imports only the light root
7
- * resolver and the bundled-script runner. The slower commander registration in
8
- * `core.ts` remains the complete path for any invocation this does not match.
5
+ * ever imported, so hot hook commands avoid the full commander graph. Shell
6
+ * entries use the bundled-script runner; native entries import their one handler
7
+ * module. The registration in `core.ts` remains the complete fallback path.
9
8
  */
10
9
  import { resolveLiveRoot } from "./utils/hq-roots.js";
11
10
  import { runBundledScript } from "./utils/run-bundled-script.js";
12
11
  import { parseFastCore } from "./fast-scaffold.js";
13
12
  /**
14
- * Run the fast-path scaffold script named in `argv` and exit with its code.
13
+ * Run the fast-path core command named in `argv` and exit with its code.
15
14
  * Only call when {@link isFastCoreRequest} is true; a non-match returns without
16
15
  * doing anything.
17
16
  */
18
- export function runFastCore(argv) {
17
+ export async function runFastCore(argv) {
19
18
  const parsed = parseFastCore(argv);
20
19
  if (!parsed)
21
20
  return;
@@ -31,12 +30,27 @@ export function runFastCore(argv) {
31
30
  catch {
32
31
  hqRoot = undefined;
33
32
  }
33
+ if (parsed.entry.kind === "native") {
34
+ const handlerModule = await import(parsed.entry.modulePath);
35
+ const handler = handlerModule[parsed.entry.exportName];
36
+ if (typeof handler !== "function") {
37
+ throw new Error(`Native fast handler ${parsed.entry.exportName} was not exported`);
38
+ }
39
+ const chunks = [];
40
+ for await (const chunk of process.stdin)
41
+ chunks.push(chunk);
42
+ const code = await handler(argv, Buffer.concat(chunks).toString("utf8"), process.cwd(), hqRoot);
43
+ // Let Node drain handler output before terminating. The timeout guard's
44
+ // stderr message must survive the native route's process boundary.
45
+ process.exitCode = code;
46
+ return;
47
+ }
34
48
  // Keep the CHILD in the caller's cwd, unlike the generic "live" dispatch. The
35
49
  // relocated scripts read their data tree from the injected root, not cwd, and
36
50
  // hq-session's Work Mesh registration serializes $PWD — so changing directory
37
51
  // to the HQ root would mislabel a bind made from inside a project/worktree.
38
52
  const { code } = runBundledScript({
39
- asset: parsed.asset,
53
+ asset: parsed.entry.asset,
40
54
  args: parsed.args,
41
55
  cwd: process.cwd(),
42
56
  hqRoot,
@@ -1,27 +1,36 @@
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
  export type FastScaffoldScript = {
15
13
  /** Direct subcommand name — `hq core <name>`. */
16
14
  name: string;
15
+ kind: "shell";
17
16
  /** Path under `assets/scaffold/`, e.g. `core/scripts/foo.sh`. */
18
17
  asset: string;
18
+ } | {
19
+ /** Direct subcommand name — `hq core <name>`. */
20
+ name: string;
21
+ kind: "native";
22
+ /** Relative ESM module specifier, resolved from fast-core.js. */
23
+ modulePath: string;
24
+ /** Named export which accepts the native fast-handler contract. */
25
+ exportName: string;
26
+ /** Default-off registry flag for this native route. */
27
+ flagKey: string;
19
28
  };
20
29
  /** The relocated scripts that qualify for the entrypoint fast path. */
21
30
  export declare const FAST_SCAFFOLD_SCRIPTS: readonly FastScaffoldScript[];
22
31
  export type ParsedFastCore = {
23
- /** The bundled asset to run. */
24
- asset: string;
32
+ /** The manifest entry to run. */
33
+ entry: FastScaffoldScript;
25
34
  /** `--hq-root <path>` value, if the caller passed the group option. */
26
35
  hqRootArg?: string;
27
36
  /** Everything after the command name, passed to the script verbatim. */
@@ -1,20 +1,25 @@
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
  ];
19
24
  /**
20
25
  * Parse `hq core [--hq-root <path>] <name> [args...]` when `<name>` is a
@@ -48,7 +53,7 @@ export function parseFastCore(argv) {
48
53
  const entry = FAST_SCAFFOLD_SCRIPTS.find((candidate) => candidate.name === name);
49
54
  if (!entry)
50
55
  return null;
51
- return { asset: entry.asset, hqRootArg, args: argv.slice(i + 1) };
56
+ return { entry, hqRootArg, args: argv.slice(i + 1) };
52
57
  }
53
58
  /** Whether this invocation is a fast-path scaffold dispatch. */
54
59
  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 } 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,21 @@ 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
+ }
33
48
  else if (isFastCoreRequest(process.argv)) {
34
49
  // Hot relocated plumbing (`hq core hq-session`, `hq core checkpoint-stop-gate`)
35
50
  // dispatches straight to the bundled-script runner, so it never pays for the
@@ -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
  /**
@@ -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);
@@ -94,6 +94,15 @@ export declare const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-pr
94
94
  * and lost no gold link.
95
95
  */
96
96
  export declare const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
97
+ /**
98
+ * CO-59: consume the files touched by company checkpoints that were queued
99
+ * behind a busy sibling (see queued-touches.ts). On, the plan adds the
100
+ * company's queued touches to this run's session-touched set, and a run whose
101
+ * own payload names no company plans the company the parent picked for it
102
+ * (`ontology_company`), without that run's session text. Off, queued touches
103
+ * wait on disk and a company-less run skips the ontology step as before.
104
+ */
105
+ export declare const QUEUED_TOUCHES_FLAG = "checkpoint.ontology.queued-touches";
97
106
  export declare const PLAN_FILE = "ontology-plan.json";
98
107
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
99
108
  export declare const REPORT_FILE = "ontology-report.json";
@@ -183,6 +192,11 @@ export interface OntologyPlan {
183
192
  }
184
193
  export interface CheckpointPayloadLike {
185
194
  company?: unknown;
195
+ /**
196
+ * Set by `hq core checkpoint` only when the payload names no company and
197
+ * another company has queued touches (CO-59). Never scopes anything else.
198
+ */
199
+ ontology_company?: unknown;
186
200
  /** The sibling backend `hq core checkpoint` spawned for this payload. */
187
201
  backend?: unknown;
188
202
  files?: unknown;
@@ -234,6 +248,15 @@ export declare function refreshLedger(company: OntologyCompany, existing: Ledger
234
248
  ledger: Ledger;
235
249
  firstRun: boolean;
236
250
  };
251
+ /**
252
+ * The company this run's ontology step works on: the payload's own company,
253
+ * or else the `ontology_company` the checkpoint parent picked for queued
254
+ * touches (`borrowed`).
255
+ */
256
+ export declare function payloadOntologyCompany(payload: CheckpointPayloadLike): {
257
+ slug: string | null;
258
+ borrowed: boolean;
259
+ };
237
260
  /** Plan this run. Never throws for a skip; returns the reason instead. */
238
261
  export declare function planOntologyRun(options: PlanOptions): Promise<PlanOutcome>;
239
262
  /**