@indigoai-us/hq-cli 5.290.0 → 5.291.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 (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
  3. package/assets/scaffold/core/scripts/hq-session.sh +182 -21
  4. package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
  5. package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
  6. package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
  7. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
  8. package/dist/command-catalog.generated.d.ts +59 -0
  9. package/dist/command-catalog.generated.js +73 -0
  10. package/dist/commands/cloud.d.ts +26 -14
  11. package/dist/commands/cloud.js +207 -56
  12. package/dist/commands/core-jobs-native.d.ts +8 -0
  13. package/dist/commands/core-jobs-native.js +624 -0
  14. package/dist/commands/core-native-jobs-flag.d.ts +4 -0
  15. package/dist/commands/core-native-jobs-flag.js +7 -0
  16. package/dist/commands/core-policy.js +24 -5
  17. package/dist/commands/core.js +7 -0
  18. package/dist/commands/integrations-core.js +7 -0
  19. package/dist/commands/lanes.js +111 -35
  20. package/dist/commands/members.js +5 -0
  21. package/dist/commands/mesh.js +46 -25
  22. package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
  23. package/dist/commands/personal-targeted-push-flag.js +7 -0
  24. package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
  25. package/dist/commands/personal-targeted-push-paths.js +67 -0
  26. package/dist/commands/secrets.js +9 -0
  27. package/dist/commands/skill.d.ts +3 -0
  28. package/dist/commands/skill.js +28 -0
  29. package/dist/fast-core.js +1 -0
  30. package/dist/lib/billing/free-month.d.ts +18 -0
  31. package/dist/lib/billing/free-month.js +31 -0
  32. package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
  33. package/dist/lib/billing/plan-limit-refusals.js +187 -0
  34. package/dist/lib/billing/plan-lock.d.ts +2 -6
  35. package/dist/lib/billing/plan-lock.js +5 -16
  36. package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
  37. package/dist/lib/core-utils/secret-patterns.js +3 -2
  38. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  39. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  40. package/dist/lib/lanes/autopilot.d.ts +69 -0
  41. package/dist/lib/lanes/autopilot.js +418 -0
  42. package/dist/lib/lanes/mesh-events.js +2 -0
  43. package/dist/lib/lanes/store.js +11 -6
  44. package/dist/lib/mesh/api.js +4 -0
  45. package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
  46. package/dist/lib/mesh/live/daemon/state.js +34 -2
  47. package/dist/main.js +23 -12
  48. package/dist/utils/plan-gate-error.d.ts +24 -0
  49. package/dist/utils/plan-gate-error.js +219 -9
  50. package/package.json +2 -2
@@ -35,6 +35,7 @@ import { registerCoreHandoffCommand } from "./core-handoff.js";
35
35
  import { registerCoreOntologyCommand } from "./core-ontology.js";
36
36
  import { registerCoreKnowledgePrefsCommand } from "./core-knowledge-prefs.js";
37
37
  import { registerCorePolicyCommand } from "./core-policy.js";
38
+ import { registerCoreJobsNativeCommand } from "./core-jobs-native.js";
38
39
  import { registerCoreCheckpointGateSettingCommand } from "./core-checkpoint-gate-setting.js";
39
40
  import { resolveLiveRoot } from "../utils/hq-roots.js";
40
41
  import { interpreterFor, runBundledScript } from "../utils/run-bundled-script.js";
@@ -211,6 +212,8 @@ export const SCAFFOLD_COMMANDS = [
211
212
  * direct `hq core` command.
212
213
  */
213
214
  export const SCAFFOLD_ONLY_ASSETS = [
215
+ { asset: "core/scripts/jobs-schedule-parse.sh", root: "live", summary: "Rollback companion for native jobs schedule-parse" },
216
+ { asset: "core/scripts/jobs-validate.sh", root: "live", summary: "Rollback companion for native jobs validate" },
214
217
  // Shared shell libs sourced by the two relocated scripts (and hooks). Never
215
218
  // dispatched on their own (so no `hq core` command), but they MUST ship in the
216
219
  // tarball, or the bundled hq-session / checkpoint-stop-gate die at `source`
@@ -308,6 +311,9 @@ function runEntry(entry, scope, args) {
308
311
  args,
309
312
  cwd,
310
313
  hqRoot,
314
+ env: entry.asset === "core/scripts/hq-session.sh"
315
+ ? { HQ_HQ_SESSION_NO_CLI: "1" }
316
+ : undefined,
311
317
  });
312
318
  // Propagate verbatim. Several of these scripts use their exit code as their
313
319
  // interface (drift detected, lint failed), so remapping it would break the
@@ -335,6 +341,7 @@ export function registerCoreCommands(program) {
335
341
  registerCoreHandoffCommand(core);
336
342
  registerCoreKnowledgePrefsCommand(core);
337
343
  registerCorePolicyCommand(core);
344
+ registerCoreJobsNativeCommand(core);
338
345
  // Wave 2 handoff/worktree commands retain their byte-identical shell assets
339
346
  // as a default-off rollback path for one release. Keep the gate helper small
340
347
  // so sibling Wave 2 work can reuse it without editing this command table.
@@ -12,6 +12,7 @@ import { randomUUID } from "node:crypto";
12
12
  import chalk from "chalk";
13
13
  import { vaultApiFetch } from "../utils/vault-api.js";
14
14
  import { AuthError } from "../utils/auth-error.js";
15
+ import { planGateErrorFromPayload } from "../utils/plan-gate-error.js";
15
16
  import { redactErrorText } from "../utils/redact-error-text.js";
16
17
  import { redactSensitiveUrls, redactUrlsInText } from "../utils/redact-url.js";
17
18
  import { connectionHealthNotice } from "../lib/integrations/health.js";
@@ -195,6 +196,12 @@ export async function raiseForResponse(res, fallback) {
195
196
  raiseIfUnauthorized(res);
196
197
  raiseIfUpstreamUnavailable(res);
197
198
  const body = (await res.json().catch(() => ({})));
199
+ // A plan-limit 402 is normally decoded by vaultApiFetch before it gets here.
200
+ // Decode it again so no integration route can print the wire token
201
+ // `plan_limit_reached` as its whole message; main.ts renders the typed gate.
202
+ const planGate = planGateErrorFromPayload(res.status, body);
203
+ if (planGate)
204
+ throw planGate;
198
205
  // hq-pro mints this error text, so it is untrusted input to a message we
199
206
  // print verbatim — scrub credentials out before it can reach a terminal or a
200
207
  // Sentry crash report (same reasoning as the gateway path below).
@@ -32,6 +32,7 @@ import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
32
32
  import { startOutpostLaneSnapshotRefresh } from "../lib/lanes/outpost-snapshot-refresh.js";
33
33
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
34
34
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
35
+ import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
35
36
  import { getOrFetchOutpostSnapshot, OUTPOST_SNAPSHOT_CACHE_TTL_MS, outpostLaneListSnapshotKey, OutpostSnapshotInvocationMemo, } from "../lib/lanes/outpost-snapshot-cache.js";
36
37
  import { createOutpostHireGrantContext, parseOutpostHireGrantContext, selectOutpostHireGrantHierarchy, sameHireGrantSenior, } from "../lib/lanes/hire-grant-dispatch.js";
37
38
  import { createOutpostProfileRepinContext, parseOutpostProfileRepinContext, } from "../lib/lanes/outpost-profile-repin.js";
@@ -2369,6 +2370,8 @@ async function emitLaneStartedForStories(ctx, company, lane, threadId, knownAbse
2369
2370
  failures.push(err);
2370
2371
  continue;
2371
2372
  }
2373
+ if (!story.project_id?.trim() || !story.story_id?.trim())
2374
+ continue;
2372
2375
  if (knownAbsentStories.has(storyBoardStoryKey(story.project_id, story.story_id)))
2373
2376
  continue;
2374
2377
  try {
@@ -3453,40 +3456,42 @@ async function runCreate(ctx, opts) {
3453
3456
  : undefined,
3454
3457
  });
3455
3458
  }
3456
- printJson({
3457
- ok: true,
3458
- action: "create",
3459
- lane_id: started.lane_id,
3460
- state: started.state,
3461
- queued_reason: started.queued_reason ?? null,
3462
- priority: started.priority,
3463
- provider: started.provider,
3464
- account: started.account ?? null,
3465
- model: started.model ?? null,
3466
- effort: started.effort ?? null,
3467
- effort_source: started.effort_source ?? null,
3468
- host: started.host,
3469
- ...(autoPlacementSkippedHosts.length > 0
3470
- ? { skipped_hosts: autoPlacementSkippedHosts }
3471
- : {}),
3472
- company: started.company,
3473
- project_id: started.project_id,
3474
- story_id: started.story_id,
3475
- stories: laneStories(started),
3476
- story_warning: combinedStoryWarning(started, createStoryWarnings),
3477
- thread_id: started.thread_id ?? null,
3478
- senior: started.senior,
3479
- depth: started.depth,
3480
- timeout_secs: timeoutSecs ?? null,
3481
- ...(validation ? { validation } : {}),
3482
- ...(started.outpost_command_id
3483
- ? {
3484
- outpost_command_id: started
3485
- .outpost_command_id,
3486
- }
3487
- : {}),
3488
- lane: started,
3489
- });
3459
+ if (!opts.quiet)
3460
+ printJson({
3461
+ ok: true,
3462
+ action: "create",
3463
+ lane_id: started.lane_id,
3464
+ state: started.state,
3465
+ queued_reason: started.queued_reason ?? null,
3466
+ priority: started.priority,
3467
+ provider: started.provider,
3468
+ account: started.account ?? null,
3469
+ model: started.model ?? null,
3470
+ effort: started.effort ?? null,
3471
+ effort_source: started.effort_source ?? null,
3472
+ host: started.host,
3473
+ ...(autoPlacementSkippedHosts.length > 0
3474
+ ? { skipped_hosts: autoPlacementSkippedHosts }
3475
+ : {}),
3476
+ company: started.company,
3477
+ project_id: started.project_id,
3478
+ story_id: started.story_id,
3479
+ stories: laneStories(started),
3480
+ story_warning: combinedStoryWarning(started, createStoryWarnings),
3481
+ thread_id: started.thread_id ?? null,
3482
+ senior: started.senior,
3483
+ depth: started.depth,
3484
+ timeout_secs: timeoutSecs ?? null,
3485
+ ...(validation ? { validation } : {}),
3486
+ ...(started.outpost_command_id
3487
+ ? {
3488
+ outpost_command_id: started
3489
+ .outpost_command_id,
3490
+ }
3491
+ : {}),
3492
+ lane: started,
3493
+ });
3494
+ return started;
3490
3495
  }
3491
3496
  async function dispatchCreatedOutpostLane(ctx, input) {
3492
3497
  const { hqRoot, lane, outpostId, briefText, timeout, preserveQueuedOnFailure = false, } = input;
@@ -6464,7 +6469,7 @@ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status,
6464
6469
  ...payload,
6465
6470
  lane_status: lane.state,
6466
6471
  });
6467
- if (!skipStoryStatusPatch) {
6472
+ if (!skipStoryStatusPatch && projectId?.trim() && storyId?.trim()) {
6468
6473
  await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
6469
6474
  }
6470
6475
  }
@@ -7824,8 +7829,44 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
7824
7829
  summary.ok = false;
7825
7830
  }
7826
7831
  const terminalizedRemoteIds = new Set(remoteLost.map(({ id }) => id));
7832
+ let autopilot;
7833
+ try {
7834
+ const host = reconcileHostFromCtx(ctx);
7835
+ autopilot = await runLeadAutopilotPass({
7836
+ hqRoot,
7837
+ host: host.outpostId ?? "local",
7838
+ hostname: host.hostname,
7839
+ outpostId: host.outpostId,
7840
+ now: ctx.now(),
7841
+ dryRun: Boolean(opts.dryRun),
7842
+ readFlag: readLanesRegistryFlag,
7843
+ keepAliveResumedLeads: new Set(summary.resumed.filter((entry) => entry.reason === "keep_alive").map((entry) => entry.id)),
7844
+ startWorker: async ({ lead, story }) => {
7845
+ const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
7846
+ const started = await runCreate(workerCtx, {
7847
+ company: lead.company?.slug,
7848
+ project: lead.project_id,
7849
+ story: story.id,
7850
+ briefFile: story.briefPath,
7851
+ provider: "codex",
7852
+ model: "gpt-6-luna",
7853
+ effort: "high",
7854
+ senior: `lane:${lead.lane_id}`,
7855
+ quiet: true,
7856
+ });
7857
+ return { lane_id: started.lane_id };
7858
+ },
7859
+ });
7860
+ }
7861
+ catch (error) {
7862
+ const message = error instanceof Error ? error.message : String(error);
7863
+ summary.ok = false;
7864
+ summary.errors.push({ code: "lead_autopilot_failed", message });
7865
+ process.stderr.write(`hq lanes autopilot: ${message}\n`);
7866
+ }
7827
7867
  printJson({
7828
7868
  ...summary,
7869
+ ...(autopilot ? { autopilot } : {}),
7829
7870
  ...(remoteLost.length > 0 ? { remote_lost: remoteLost } : {}),
7830
7871
  ...(mirrorRefresh.unverifiedRemote.size > 0
7831
7872
  ? {
@@ -8590,6 +8631,41 @@ export function registerLanesCommand(parent, deps = {}) {
8590
8631
  .action(wrap(async (opts) => {
8591
8632
  await runPickup(ctx, opts);
8592
8633
  }));
8634
+ lanes
8635
+ .command("autopilot")
8636
+ .description("Check registered team leads and wake them for ready work")
8637
+ .command("run")
8638
+ .description("Run one lead autopilot check pass")
8639
+ .option("--dry-run", "Report proposed wakes without writing them")
8640
+ .option("--json", "Emit JSON on stdout (default)")
8641
+ .action(wrap(async (opts) => {
8642
+ const host = reconcileHostFromCtx(ctx);
8643
+ const result = await runLeadAutopilotPass({
8644
+ hqRoot: ctx.hqRoot(),
8645
+ host: host.outpostId ?? "local",
8646
+ hostname: host.hostname,
8647
+ outpostId: host.outpostId,
8648
+ now: ctx.now(),
8649
+ dryRun: Boolean(opts.dryRun),
8650
+ readFlag: readLanesRegistryFlag,
8651
+ startWorker: async ({ lead, story }) => {
8652
+ const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
8653
+ const started = await runCreate(workerCtx, {
8654
+ company: lead.company?.slug,
8655
+ project: lead.project_id,
8656
+ story: story.id,
8657
+ briefFile: story.briefPath,
8658
+ provider: "codex",
8659
+ model: "gpt-6-luna",
8660
+ effort: "high",
8661
+ senior: `lane:${lead.lane_id}`,
8662
+ quiet: true,
8663
+ });
8664
+ return { lane_id: started.lane_id };
8665
+ },
8666
+ });
8667
+ printJson(result);
8668
+ }));
8593
8669
  const reconcile = lanes
8594
8670
  .command("reconcile", { hidden: true })
8595
8671
  .description("Run one machine-owned reconcile pass and exit")
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
3
3
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
4
4
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
5
6
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
6
7
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
7
8
  const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
@@ -558,6 +559,10 @@ export function registerMembersCommand(program) {
558
559
  }
559
560
  }
560
561
  catch (err) {
562
+ // A seat-limit refusal goes to main.ts's shared plan-gate renderer:
563
+ // reason, usage, fix options and upgrade link, then a non-zero exit.
564
+ if (isPlanGateError(err))
565
+ throw err;
561
566
  // A 409 may refer to either an active membership or an existing
562
567
  // pending invite. Probe both views so email targets get actionable
563
568
  // guidance while preserving the terminal exit-0 behavior for bulk
@@ -11,7 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import { findHqRoot } from "../utils/manifest.js";
12
12
  import { CLI_VERSION } from "../cli-version.js";
13
13
  import chalk from "chalk";
14
- import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
14
+ import { ensureCognitoToken, isExpiring, isMachineIdentity, loadCachedTokens, } from "../utils/cognito-session.js";
15
15
  import * as readline from "node:readline/promises";
16
16
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
17
17
  import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, boardStoryStatus, formatStoryStatusChanges, mergeProjectStories, prdSyncEventsThatChangeBoard, } from "../lib/mesh/project-board.js";
@@ -26,7 +26,9 @@ import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/i
26
26
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
27
27
  import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
28
28
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
29
- const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
29
+ const ONLINE_MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
30
+ // Leave 500 ms of headroom inside SessionStart's 2 s process-group limit.
31
+ const OFFLINE_MEMBERSHIP_VALIDATION_TIMEOUT_MS = 1_500;
30
32
  import { buildEmitEvidence, machineEmitDisposition } from "../lib/mesh/live/emit-evidence.js";
31
33
  import { appendHeldLine } from "../lib/mesh/live/spool.js";
32
34
  import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
@@ -860,29 +862,45 @@ async function runContextReconcile(opts) {
860
862
  let offline = Boolean(opts.offline);
861
863
  let token;
862
864
  try {
863
- // Offline is outbox-only delivery. It still refreshes a device default's
864
- // token-backed membership so a revoked default cannot be preflighted.
865
- token = await requireToken();
866
- const sessionToken = token;
867
- validateMembership = async (candidate) => {
868
- const membership = await new Promise((resolve, reject) => {
869
- const timer = setTimeout(() => reject(new Error("membership validation timed out")), MEMBERSHIP_VALIDATION_TIMEOUT_MS);
870
- void resolveActiveMembershipCompany(sessionToken, candidate).then((value) => {
871
- clearTimeout(timer);
872
- resolve(value);
873
- }, (error) => {
874
- clearTimeout(timer);
875
- reject(error);
865
+ if (offline) {
866
+ if (isMachineIdentity()) {
867
+ token = await ensureCognitoToken({ tokenSource: "machine", interactive: false });
868
+ }
869
+ else {
870
+ const cached = loadCachedTokens();
871
+ if (cached && !isExpiring(cached, 120)) {
872
+ token = cached.accessToken;
873
+ }
874
+ }
875
+ }
876
+ else {
877
+ token = await requireToken();
878
+ }
879
+ if (token) {
880
+ const sessionToken = token;
881
+ const membershipValidationTimeoutMs = offline
882
+ ? OFFLINE_MEMBERSHIP_VALIDATION_TIMEOUT_MS
883
+ : ONLINE_MEMBERSHIP_VALIDATION_TIMEOUT_MS;
884
+ validateMembership = async (candidate) => {
885
+ const membership = await new Promise((resolve, reject) => {
886
+ const timer = setTimeout(() => reject(new Error("membership validation timed out")), membershipValidationTimeoutMs);
887
+ void resolveActiveMembershipCompany(sessionToken, candidate).then((value) => {
888
+ clearTimeout(timer);
889
+ resolve(value);
890
+ }, (error) => {
891
+ clearTimeout(timer);
892
+ reject(error);
893
+ });
876
894
  });
877
- });
878
- if (!membership || typeof membership.companyUid !== "string" || !membership.companyUid) {
879
- return false;
895
+ if (!membership || typeof membership.companyUid !== "string" || !membership.companyUid) {
896
+ return false;
897
+ }
898
+ return { uid: membership.companyUid };
899
+ };
900
+ if (!offline) {
901
+ deliver = createWorkSessionDeliverer({ token: sessionToken });
902
+ fetchCandidates = createCandidatesFetcher({ token: sessionToken });
880
903
  }
881
- return { uid: membership.companyUid };
882
- };
883
- if (!offline) {
884
- deliver = createWorkSessionDeliverer({ token: sessionToken });
885
- fetchCandidates = createCandidatesFetcher({ token: sessionToken });
886
904
  }
887
905
  }
888
906
  catch {
@@ -900,7 +918,10 @@ async function runContextReconcile(opts) {
900
918
  log: (message) => console.error(chalk.dim(message)),
901
919
  });
902
920
  if (activation.status === "needs_choice") {
903
- const interactive = !opts.machine && Boolean(process.stdin.isTTY && process.stdout.isTTY) && !isMachineIdentity();
921
+ const interactive = !offline &&
922
+ !opts.machine &&
923
+ Boolean(process.stdin.isTTY && process.stdout.isTTY) &&
924
+ !isMachineIdentity();
904
925
  if (interactive) {
905
926
  const choices = activation.memberships
906
927
  .map((membership, index) => `${index + 1}. ${membership.slug}`)
@@ -920,7 +941,7 @@ async function runContextReconcile(opts) {
920
941
  rl.close();
921
942
  }
922
943
  }
923
- else if (!opts.machine) {
944
+ else if (!offline && !opts.machine) {
924
945
  console.error(chalk.yellow("Multiple active memberships. Run `hq mesh context default set <slug>` to choose this device's default company."));
925
946
  }
926
947
  }
@@ -0,0 +1,4 @@
1
+ export declare const CLI_PERSONAL_TARGETED_PUSH_PATHS_FLAG = "cli.personal-targeted-push-paths";
2
+ /** The established full-scope behavior remains in force until an explicit hq-flags enablement. */
3
+ export declare function personalTargetedPushPathsEnabled(): boolean;
4
+ //# sourceMappingURL=personal-targeted-push-flag.d.ts.map
@@ -0,0 +1,7 @@
1
+ import { resolveProcessFlagGate } from "../lib/flag-registry.js";
2
+ export const CLI_PERSONAL_TARGETED_PUSH_PATHS_FLAG = "cli.personal-targeted-push-paths";
3
+ /** The established full-scope behavior remains in force until an explicit hq-flags enablement. */
4
+ export function personalTargetedPushPathsEnabled() {
5
+ return resolveProcessFlagGate(CLI_PERSONAL_TARGETED_PUSH_PATHS_FLAG, { fallback: false }, () => false);
6
+ }
7
+ //# sourceMappingURL=personal-targeted-push-flag.js.map
@@ -0,0 +1,3 @@
1
+ /** Resolve explicit --personal paths against the same roots as a full personal push. */
2
+ export declare function resolveExplicitPersonalPushPaths(hqRoot: string, requestedPaths: readonly string[], cwd?: string): string[];
3
+ //# sourceMappingURL=personal-targeted-push-paths.d.ts.map
@@ -0,0 +1,67 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { computePersonalVaultPaths, matchPersonalVaultExclusion, } from "@indigoai-us/hq-cloud";
4
+ function isWithin(parent, candidate) {
5
+ const relative = path.relative(parent, candidate);
6
+ return relative === "" ||
7
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
8
+ }
9
+ function rejectPath(input, reason) {
10
+ throw new Error(`--personal cannot push path "${input}": ${reason}`);
11
+ }
12
+ /** Resolve explicit --personal paths against the same roots as a full personal push. */
13
+ export function resolveExplicitPersonalPushPaths(hqRoot, requestedPaths, cwd = process.cwd()) {
14
+ let realRoot;
15
+ try {
16
+ realRoot = fs.realpathSync.native(hqRoot);
17
+ }
18
+ catch (error) {
19
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unavailable";
20
+ throw new Error(`--personal cannot resolve HQ root "${hqRoot}" (${code})`);
21
+ }
22
+ const allowedRoots = computePersonalVaultPaths(realRoot)
23
+ .map((allowedPath) => {
24
+ try {
25
+ return fs.realpathSync.native(allowedPath);
26
+ }
27
+ catch (error) {
28
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unavailable";
29
+ throw new Error(`--personal personal-vault scope changed at "${allowedPath}" (${code})`);
30
+ }
31
+ })
32
+ .filter((allowedPath) => isWithin(realRoot, allowedPath));
33
+ const resolved = [];
34
+ for (const input of requestedPaths) {
35
+ const normalizedInput = path.resolve(cwd, input);
36
+ let realPath;
37
+ try {
38
+ realPath = fs.realpathSync.native(normalizedInput);
39
+ }
40
+ catch (error) {
41
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unavailable";
42
+ rejectPath(input, `path cannot be resolved (${code})`);
43
+ }
44
+ if (!isWithin(realRoot, realPath)) {
45
+ rejectPath(input, "resolved path escapes the HQ root");
46
+ }
47
+ let isDirectory;
48
+ try {
49
+ isDirectory = fs.statSync(realPath).isDirectory();
50
+ }
51
+ catch (error) {
52
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unavailable";
53
+ rejectPath(input, `path cannot be inspected (${code})`);
54
+ }
55
+ const relativePath = path.relative(realRoot, realPath).split(path.sep).join("/");
56
+ const exclusion = matchPersonalVaultExclusion(relativePath, isDirectory);
57
+ if (exclusion) {
58
+ rejectPath(input, `excluded by personal-vault rule ${exclusion.id}`);
59
+ }
60
+ if (!allowedRoots.some((allowedRoot) => isWithin(allowedRoot, realPath))) {
61
+ rejectPath(input, "resolved path is outside the personal-vault paths");
62
+ }
63
+ resolved.push(realPath);
64
+ }
65
+ return resolved;
66
+ }
67
+ //# sourceMappingURL=personal-targeted-push-paths.js.map
@@ -5,6 +5,7 @@ import { cognitoCachePrincipalUid, ensureCognitoToken } from "../utils/cognito-s
5
5
  import { isAuthError } from "../utils/auth-error.js";
6
6
  import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
7
7
  import { computeSha256 } from "../utils/integrity.js";
8
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
8
9
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
9
10
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
10
11
  import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
@@ -988,6 +989,11 @@ export function registerSecretsCommand(program) {
988
989
  }
989
990
  }
990
991
  catch (err) {
992
+ // A plan-limit refusal goes to main.ts's shared renderer, which prints
993
+ // the reason, usage, fix options and upgrade link, offers the upgrade
994
+ // interactively, and exits non-zero.
995
+ if (isPlanGateError(err))
996
+ throw err;
991
997
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
992
998
  process.exit(1);
993
999
  }
@@ -1650,6 +1656,9 @@ export function registerSecretsCommand(program) {
1650
1656
  console.log(chalk.dim(" One-time use — link is invalidated after first submission."));
1651
1657
  }
1652
1658
  catch (err) {
1659
+ // Same shared plan-limit renderer as `secrets set` (see main.ts).
1660
+ if (isPlanGateError(err))
1661
+ throw err;
1653
1662
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
1654
1663
  process.exit(1);
1655
1664
  }
@@ -18,6 +18,7 @@ import { Command } from "commander";
18
18
  import { ensureCognitoToken } from "../utils/cognito-session.js";
19
19
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
20
20
  import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
21
+ import { type PlanLimitRefusalSummary } from "../lib/billing/plan-limit-refusals.js";
21
22
  export declare const SKILL_UID_PATTERN: RegExp;
22
23
  export declare const SKILL_SLUG_PATTERN: RegExp;
23
24
  interface SkillSyncInput {
@@ -30,6 +31,8 @@ interface SkillSyncResult {
30
31
  filesUploaded: number;
31
32
  filesSkipped: number;
32
33
  aborted: boolean;
34
+ /** Set when a plan limit refused the upload; absent or null otherwise. */
35
+ planLimit?: PlanLimitRefusalSummary | null;
33
36
  }
34
37
  export declare function parseSkillUid(markdown: string): string | undefined;
35
38
  export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
@@ -27,25 +27,47 @@ import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
27
27
  import { AuthError } from "../utils/auth-error.js";
28
28
  import { redactErrorText } from "../utils/redact-error-text.js";
29
29
  import { stampVaultAccessDenied, vaultAccessDeniedDiagnostics, } from "../utils/vault-access-denied-error.js";
30
+ import { PlanGateError } from "../utils/plan-gate-error.js";
31
+ import { createPlanLimitRefusalTracker, trackingProgressRecorder, } from "../lib/billing/plan-limit-refusals.js";
30
32
  export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
31
33
  export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
32
34
  const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
33
35
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
34
36
  const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
35
37
  async function defaultSyncFile(input) {
38
+ // The runner reports a plan-limit refusal as an event, not a throw, and still
39
+ // returns normally. Track it so the command cannot claim "Sync: complete".
40
+ const tracker = createPlanLimitRefusalTracker({ companySlug: input.companySlug });
36
41
  const result = await share({
37
42
  paths: [input.filePath],
38
43
  company: input.companySlug,
39
44
  hqRoot: input.hqRoot,
40
45
  vaultConfig: buildVaultConfig(input.token),
41
46
  onConflict: "abort",
47
+ progressRecorder: trackingProgressRecorder(tracker, input.companySlug),
42
48
  });
43
49
  return {
44
50
  filesUploaded: result.filesUploaded,
45
51
  filesSkipped: result.filesSkipped,
46
52
  aborted: result.aborted,
53
+ planLimit: tracker.summary(),
47
54
  };
48
55
  }
56
+ /**
57
+ * The upload of a stamped skill file was refused by a plan limit. Registration
58
+ * and the local stamp already succeeded, so say that on stderr, then return a
59
+ * typed plan gate for main.ts to render (reason and upgrade link) and exit
60
+ * non-zero. Never "Skill ready … Sync: complete".
61
+ */
62
+ function skillUploadRefused(skillUid, filePath, companySlug, refused) {
63
+ console.warn(chalk.yellow(`⚠ Skill ${skillUid} is stamped locally at '${filePath}', but the upload was refused by your plan limit.`));
64
+ return new PlanGateError("PLAN_LIMIT_EXCEEDED", {
65
+ reason: `${refused.message ?? "New files are paused by your plan limit."} ` +
66
+ "The skill file was not uploaded. Once the limit is sorted, upload it with: " +
67
+ `hq sync push --company ${companySlug} '${filePath}'`,
68
+ ...(refused.upgradeUrl ? { upgradeUrl: refused.upgradeUrl } : {}),
69
+ });
70
+ }
49
71
  export function parseSkillUid(markdown) {
50
72
  const match = markdown.match(FRONTMATTER_RE);
51
73
  if (!match)
@@ -575,6 +597,9 @@ export function registerSkillCommand(program, deps = {}) {
575
597
  // Sentry rather than filing a crash on its first occurrence.
576
598
  throw localSkillError(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync aborted because the remote file conflicts.`);
577
599
  }
600
+ if (syncResult.planLimit) {
601
+ throw skillUploadRefused(registered.skillUid, filePath, companySlug, syncResult.planLimit);
602
+ }
578
603
  }
579
604
  console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
580
605
  console.log(` File: ${filePath}`);
@@ -739,6 +764,9 @@ export function registerSkillCommand(program, deps = {}) {
739
764
  if (syncResult.aborted) {
740
765
  throw localSkillError(`Skill ${skillUid} is stamped locally at '${canonicalPath}', but sync aborted because the remote file conflicts.`);
741
766
  }
767
+ if (syncResult.planLimit) {
768
+ throw skillUploadRefused(skillUid, canonicalPath, companySlug, syncResult.planLimit);
769
+ }
742
770
  }
743
771
  console.log(chalk.green(`Skill promoted: ${skillUid}`));
744
772
  console.log(` File: ${canonicalPath}`);
package/dist/fast-core.js CHANGED
@@ -54,6 +54,7 @@ export async function runFastCore(argv) {
54
54
  args: parsed.args,
55
55
  cwd: process.cwd(),
56
56
  hqRoot,
57
+ env: parsed.entry.name === "hq-session" ? { HQ_HQ_SESSION_NO_CLI: "1" } : undefined,
57
58
  });
58
59
  // Propagate verbatim: these scripts use their exit code as their interface.
59
60
  process.exit(code);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The free-month offer hq-pro attaches to Starter plan-limit payloads (the
3
+ * usage-limits nag and the hard-stop 402 body). Kept in a leaf module with no
4
+ * imports so the plan-gate error decoder can reuse the one parser without
5
+ * pulling in the HTTP layer that itself imports the decoder.
6
+ */
7
+ export interface PlanLimitFreeMonth {
8
+ eligible: boolean;
9
+ trialDays?: number;
10
+ url?: string;
11
+ }
12
+ /**
13
+ * Parse `freeMonth` off a server payload. Anything but an explicit
14
+ * `eligible: true` is "no offer": an absent or malformed field never invents
15
+ * one.
16
+ */
17
+ export declare function parseFreeMonth(value: unknown): PlanLimitFreeMonth | undefined;
18
+ //# sourceMappingURL=free-month.d.ts.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The free-month offer hq-pro attaches to Starter plan-limit payloads (the
3
+ * usage-limits nag and the hard-stop 402 body). Kept in a leaf module with no
4
+ * imports so the plan-gate error decoder can reuse the one parser without
5
+ * pulling in the HTTP layer that itself imports the decoder.
6
+ */
7
+ /**
8
+ * Parse `freeMonth` off a server payload. Anything but an explicit
9
+ * `eligible: true` is "no offer": an absent or malformed field never invents
10
+ * one.
11
+ */
12
+ export function parseFreeMonth(value) {
13
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
14
+ return undefined;
15
+ }
16
+ const rec = value;
17
+ if (rec.eligible !== true)
18
+ return undefined;
19
+ const url = typeof rec.url === "string" && rec.url.trim().length > 0
20
+ ? rec.url.trim()
21
+ : undefined;
22
+ const trialDays = typeof rec.trialDays === "number" && Number.isFinite(rec.trialDays)
23
+ ? rec.trialDays
24
+ : undefined;
25
+ return {
26
+ eligible: true,
27
+ ...(trialDays !== undefined ? { trialDays } : {}),
28
+ ...(url ? { url } : {}),
29
+ };
30
+ }
31
+ //# sourceMappingURL=free-month.js.map