@indigoai-us/hq-cli 5.345.103 → 5.345.105

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 (74) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/assets/bot-workers/setup/skills/add-a-pack.md +130 -0
  3. package/assets/bot-workers/setup/worker.yaml +3 -0
  4. package/dist/command-catalog.generated.d.ts +206 -0
  5. package/dist/command-catalog.generated.js +246 -0
  6. package/dist/commands/bot.d.ts +10 -0
  7. package/dist/commands/bot.js +20 -12
  8. package/dist/commands/daemon.js +385 -1
  9. package/dist/commands/integrations-connect.d.ts +6 -0
  10. package/dist/commands/integrations-connect.js +36 -0
  11. package/dist/commands/integrations-slack.d.ts +30 -0
  12. package/dist/commands/integrations-slack.js +81 -0
  13. package/dist/commands/integrations.js +2 -0
  14. package/dist/commands/pack-install.d.ts +20 -1
  15. package/dist/commands/pack-install.js +80 -5
  16. package/dist/commands/pack-start-bot.d.ts +24 -0
  17. package/dist/commands/pack-start-bot.js +76 -0
  18. package/dist/commands/packs.d.ts +9 -0
  19. package/dist/commands/packs.js +26 -3
  20. package/dist/commands/pkg-install.js +11 -2
  21. package/dist/commands/secrets.d.ts +1 -5
  22. package/dist/commands/secrets.js +2 -92
  23. package/dist/lib/bot/config.d.ts +7 -0
  24. package/dist/lib/bot/daemon.js +2 -1
  25. package/dist/lib/bot/index.d.ts +2 -0
  26. package/dist/lib/bot/index.js +2 -0
  27. package/dist/lib/bot/lean-settings.d.ts +16 -0
  28. package/dist/lib/bot/lean-settings.js +34 -0
  29. package/dist/lib/bot/pack-link.d.ts +66 -0
  30. package/dist/lib/bot/pack-link.js +153 -0
  31. package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
  32. package/dist/lib/bot/pack-setup-driver.js +83 -0
  33. package/dist/lib/bot/pack-setup.d.ts +62 -0
  34. package/dist/lib/bot/pack-setup.js +103 -0
  35. package/dist/lib/bot/prompt.d.ts +8 -0
  36. package/dist/lib/bot/prompt.js +5 -0
  37. package/dist/lib/bot/run.d.ts +18 -2
  38. package/dist/lib/bot/run.js +163 -25
  39. package/dist/lib/bot/runtime/claude.d.ts +27 -2
  40. package/dist/lib/bot/runtime/claude.js +49 -4
  41. package/dist/lib/bot/runtime/types.d.ts +5 -0
  42. package/dist/lib/bot/runtime-failure.d.ts +18 -2
  43. package/dist/lib/bot/runtime-failure.js +21 -1
  44. package/dist/lib/bot/toolchain.d.ts +25 -0
  45. package/dist/lib/bot/toolchain.js +33 -0
  46. package/dist/lib/bot/worker-source.d.ts +1 -0
  47. package/dist/lib/bot/worker-source.js +7 -1
  48. package/dist/lib/daemon/catalog.d.ts +15 -0
  49. package/dist/lib/daemon/catalog.js +49 -0
  50. package/dist/lib/daemon/paths.d.ts +1 -0
  51. package/dist/lib/daemon/paths.js +1 -0
  52. package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
  53. package/dist/lib/daemon/remote-control-forwarder.js +104 -0
  54. package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
  55. package/dist/lib/daemon/remote-control-pools.js +245 -0
  56. package/dist/lib/daemon/remote-control.d.ts +4 -0
  57. package/dist/lib/daemon/remote-control.js +17 -2
  58. package/dist/lib/daemon/run.js +126 -0
  59. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
  60. package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
  61. package/dist/lib/doctor/json-output.d.ts +19 -7
  62. package/dist/lib/doctor/json-output.js +16 -6
  63. package/dist/lib/packages/setup-block.d.ts +43 -0
  64. package/dist/lib/packages/setup-block.js +236 -0
  65. package/dist/lib/packages/setup-prompt.d.ts +34 -0
  66. package/dist/lib/packages/setup-prompt.js +128 -0
  67. package/dist/lib/packages/setup-takeover.d.ts +50 -0
  68. package/dist/lib/packages/setup-takeover.js +75 -0
  69. package/dist/lib/packages/start-bot.d.ts +47 -0
  70. package/dist/lib/packages/start-bot.js +66 -0
  71. package/dist/lib/session-company-lock.d.ts +7 -0
  72. package/dist/lib/session-company-lock.js +97 -0
  73. package/dist/types.d.ts +82 -0
  74. package/package.json +1 -1
@@ -22,6 +22,8 @@ import { readBotConfig } from "../bot/config.js";
22
22
  import { botsRoot } from "../bot/paths.js";
23
23
  import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
24
24
  import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
25
+ import { assertPersonalServiceSessionCapability, readRemoteControlPoolsConfig, remoteControlPoolSecretsEnv, } from "./remote-control-pools.js";
26
+ import { loadLanesConfig } from "../lanes/config.js";
25
27
  import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
26
28
  import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
27
29
  import { readLegacySyncEnv } from "./legacy-sync-env.js";
@@ -636,6 +638,45 @@ export async function runDaemon(opts) {
636
638
  };
637
639
  let warnedMissingPin = null;
638
640
  let warnedMissingSyncRunner = false;
641
+ const poolProbeCache = new Map();
642
+ const poolProbeProcess = (file, args, options) => new Promise((resolve) => {
643
+ const child = spawn(file, args, { cwd: options.cwd, env: options.env, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
644
+ let stdout = "";
645
+ let error = false;
646
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString().slice(0, 256); });
647
+ child.on("error", () => { error = true; });
648
+ const timer = setTimeout(() => child.kill("SIGKILL"), options.timeout);
649
+ child.once("close", (status) => { clearTimeout(timer); resolve({ status, stdout, error }); });
650
+ });
651
+ const poolProbe = (key, probe) => {
652
+ const cached = poolProbeCache.get(key);
653
+ if (cached && (cached.pending || Date.now() - cached.at < 30_000))
654
+ return cached.value;
655
+ let result;
656
+ try {
657
+ result = probe();
658
+ }
659
+ catch (error) {
660
+ log("error", `remote-control pool probe ${key} failed: ${error.message}`);
661
+ poolProbeCache.set(key, { at: Date.now(), value: "unavailable" });
662
+ return "unavailable";
663
+ }
664
+ if (typeof result === "string") {
665
+ poolProbeCache.set(key, { at: Date.now(), value: result });
666
+ return result;
667
+ }
668
+ const pending = { at: Date.now(), value: cached?.value ?? "unavailable", pending: true };
669
+ poolProbeCache.set(key, pending);
670
+ result.then((value) => {
671
+ if (poolProbeCache.get(key) === pending)
672
+ poolProbeCache.set(key, { at: Date.now(), value });
673
+ }, (error) => {
674
+ log("error", `remote-control pool probe ${key} failed: ${error.message}`);
675
+ if (poolProbeCache.get(key) === pending)
676
+ poolProbeCache.set(key, { at: Date.now(), value: "unavailable" });
677
+ });
678
+ return pending.value;
679
+ };
639
680
  const daemonSyncRunnerPids = new Set();
640
681
  let supervisor = null;
641
682
  const plan = () => {
@@ -643,6 +684,83 @@ export async function runDaemon(opts) {
643
684
  const legacy = legacyUnits();
644
685
  const configured = config.hqRoot ?? (env.HQ_ROOT?.trim() || undefined);
645
686
  const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
687
+ let remoteControlPools = { version: 1, pools: [] };
688
+ let remoteControlPoolsError;
689
+ try {
690
+ if (fs.existsSync(paths.remoteControlPoolsPath)) {
691
+ const knownAccounts = hqRoot ? Object.keys(loadLanesConfig(hqRoot).providers.claude.accounts) : undefined;
692
+ remoteControlPools = readRemoteControlPoolsConfig(paths.remoteControlPoolsPath, knownAccounts, home);
693
+ }
694
+ }
695
+ catch (error) {
696
+ remoteControlPoolsError = error.message;
697
+ log("error", `remote-control pool config is invalid: ${remoteControlPoolsError}`);
698
+ }
699
+ const availablePoolSecrets = new Set();
700
+ const unverifiablePoolSecrets = new Set();
701
+ const invalidPoolServiceSessions = new Set();
702
+ const availablePoolFrontends = new Set();
703
+ const unverifiablePoolFrontends = new Set();
704
+ for (const pool of remoteControlPools.pools) {
705
+ const serviceSession = poolProbe(`service-session:${remoteControlPools.serviceSessionId ?? ""}`, () => {
706
+ try {
707
+ if (!remoteControlPools.serviceSessionId)
708
+ return "unavailable";
709
+ assertPersonalServiceSessionCapability(hqRoot ?? home, remoteControlPools.serviceSessionId);
710
+ return "available";
711
+ }
712
+ catch {
713
+ return "unavailable";
714
+ }
715
+ });
716
+ if (serviceSession === "unavailable")
717
+ invalidPoolServiceSessions.add(pool.accountId);
718
+ const secret = serviceSession === "available" ? poolProbe(`secret:${pool.secretName}`, async () => {
719
+ const result = await poolProbeProcess(opts.hqBinary, ["secrets", "--personal", "exists", pool.secretName, "--quiet"], {
720
+ cwd: hqRoot ?? home,
721
+ env: remoteControlPoolSecretsEnv({
722
+ ...env,
723
+ ...(hqRoot ? { HQ_ROOT: hqRoot } : {}),
724
+ HQ_NO_UPDATE_CHECK: "1",
725
+ }, remoteControlPools.serviceSessionId),
726
+ timeout: 15_000,
727
+ });
728
+ if (result.status === 0)
729
+ return "available";
730
+ return result.status === 1 ? "missing" : "unavailable";
731
+ }) : "unavailable";
732
+ if (secret === "available")
733
+ availablePoolSecrets.add(pool.secretName);
734
+ if (secret === "unavailable" && serviceSession === "available")
735
+ unverifiablePoolSecrets.add(pool.secretName);
736
+ let caValid = false;
737
+ try {
738
+ const caStat = fs.statSync(remoteControlPools.frontendCaPath);
739
+ caValid = caStat.isFile() && fs.accessSync(remoteControlPools.frontendCaPath, fs.constants.R_OK) === undefined;
740
+ }
741
+ catch {
742
+ caValid = false;
743
+ }
744
+ const frontend = poolProbe(`frontend:${pool.accountId}:${pool.frontendAddress}`, async () => {
745
+ if (!caValid)
746
+ return "unavailable";
747
+ const result = await poolProbeProcess("curl", [
748
+ "--silent", "--show-error", "--output", "/dev/null", "--write-out", "%{http_code}",
749
+ "--connect-timeout", "1", "--max-time", "2", "--head",
750
+ "--cacert", remoteControlPools.frontendCaPath,
751
+ "--resolve", `api.anthropic.com:443:${pool.frontendAddress}`,
752
+ "https://api.anthropic.com/v1/messages",
753
+ ], { timeout: 3_000 });
754
+ const code = Number.parseInt(result.stdout.trim(), 10);
755
+ if (result.status === 0 && code >= 100 && code <= 599)
756
+ return "available";
757
+ return result.error ? "unavailable" : "missing";
758
+ });
759
+ if (frontend === "available")
760
+ availablePoolFrontends.add(pool.accountId);
761
+ if (frontend === "unavailable")
762
+ unverifiablePoolFrontends.add(pool.accountId);
763
+ }
646
764
  getStagedHqChildBootstrap();
647
765
  const staged = getStagedSyncBundle();
648
766
  // Before release, preserve today's selection. Later plans can only use the
@@ -746,6 +864,14 @@ export async function runDaemon(opts) {
746
864
  remoteControlName: runnerSessionName(readIfExists(CLAUDE_RUNNER_SCRIPT) ?? ""),
747
865
  tmuxPath: findOnPath("tmux", env),
748
866
  hostName,
867
+ claudeConfigDir: env.CLAUDE_CONFIG_DIR?.trim() || path.join(home, ".claude"),
868
+ remoteControlPools,
869
+ remoteControlPoolsError,
870
+ availablePoolSecrets,
871
+ unverifiablePoolSecrets,
872
+ invalidPoolServiceSessions,
873
+ availablePoolFrontends,
874
+ unverifiablePoolFrontends,
749
875
  });
750
876
  if (startupPlanOpen) {
751
877
  const replaceablePackageRoots = cliPackageRoot ? [cliPackageRoot] : [];
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * `lastAttemptedSlug` is written before each pull so a killed repair resumes
5
5
  * after that company instead of starving later journals. `lastRunAttemptedCheckIds`
6
- * is written only when a repair pass finishes, so `--client-health-report`
7
- * can omit company journals this pass did not try — the SessionStart hook
8
- * otherwise files those leftover rows as unrepaired.
6
+ * is written only when a repair pass finishes, so doctor JSON
7
+ * (`--json` and `--client-health-report`) can omit company journals this
8
+ * pass did not try — SessionStart hooks, including older ones that only
9
+ * call `hq doctor --json`, otherwise file those leftover rows as unrepaired.
9
10
  */
10
11
  import type { CheckResult } from "../types.js";
11
12
  export declare const REFRESH_SYNC_PROGRESS_RELATIVE_PATH: string;
@@ -27,6 +28,6 @@ export declare function writeRefreshSyncLastRun(hqRoot: string, input: {
27
28
  lastRunAtMs: number;
28
29
  }): void;
29
30
  export declare function freshAttemptedCompanyJournalCheckIds(hqRoot: string, nowMs: number): ReadonlySet<string> | null;
30
- /** True when a client-health report should hide this leftover company journal. */
31
+ /** True when doctor JSON should hide this leftover company journal. */
31
32
  export declare function isUnreachedCompanyJournalForClientHealth(result: CheckResult, attemptedCheckIds: ReadonlySet<string> | null): boolean;
32
33
  //# sourceMappingURL=refresh-sync-progress.d.ts.map
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * `lastAttemptedSlug` is written before each pull so a killed repair resumes
5
5
  * after that company instead of starving later journals. `lastRunAttemptedCheckIds`
6
- * is written only when a repair pass finishes, so `--client-health-report`
7
- * can omit company journals this pass did not try — the SessionStart hook
8
- * otherwise files those leftover rows as unrepaired.
6
+ * is written only when a repair pass finishes, so doctor JSON
7
+ * (`--json` and `--client-health-report`) can omit company journals this
8
+ * pass did not try — SessionStart hooks, including older ones that only
9
+ * call `hq doctor --json`, otherwise file those leftover rows as unrepaired.
9
10
  */
10
11
  import * as fs from "node:fs";
11
12
  import * as path from "node:path";
@@ -93,7 +94,7 @@ export function freshAttemptedCompanyJournalCheckIds(hqRoot, nowMs) {
93
94
  }
94
95
  return new Set(progress.lastRunAttemptedCheckIds);
95
96
  }
96
- /** True when a client-health report should hide this leftover company journal. */
97
+ /** True when doctor JSON should hide this leftover company journal. */
97
98
  export function isUnreachedCompanyJournalForClientHealth(result, attemptedCheckIds) {
98
99
  if (!attemptedCheckIds)
99
100
  return false;
@@ -106,13 +106,14 @@ export interface BuildDoctorJsonInput {
106
106
  clientHealthCheckStatusEnabledCheckIds?: ReadonlySet<string>;
107
107
  /**
108
108
  * JSON consumed by the SessionStart health hook (`--client-health-report`).
109
- * Ordinary hq-core upgrade WARNs are omitted from every JSON document
110
- * (`--json` and `--client-health-report`), including `summary` tallies:
111
- * `--fix` cannot apply a release, and both current hooks and older ones
112
- * that only call `hq doctor --json` treated the leftover WARN as an
113
- * unrepaired failure. Human `hq doctor` still prints the upgrade. Company
114
- * journals this `--fix` pass did not try to refresh are omitted only from
115
- * `--client-health-report`: the repair cap leaves them for a later session.
109
+ * Ordinary hq-core upgrade WARNs and leftover `sync.journal.personal` WARNs
110
+ * are omitted from every JSON document (`--json` and `--client-health-report`),
111
+ * including `summary` tallies: `--fix` cannot apply a release, and the leftover
112
+ * personal shard is not a live sync target. Company journals this `--fix`
113
+ * pass did not try to refresh are omitted the same way: the repair cap leaves
114
+ * them for a later session, and older hooks that only call `hq doctor --json`
115
+ * treated those leftover WARNs as unrepaired failures. Human `hq doctor`
116
+ * still prints them.
116
117
  */
117
118
  clientHealthReport?: boolean;
118
119
  /** Clock for the leftover-journal freshness window. Default: `Date.now`. */
@@ -129,6 +130,17 @@ export declare function isOrdinaryCoreUpdateWarn(result: {
129
130
  checkId: string;
130
131
  status: DoctorStatus;
131
132
  }): boolean;
133
+ /**
134
+ * True when doctor still WARNs on the leftover `personal` journal shard.
135
+ * That file is not the live personal-files target; `--fix` refreshes the vault
136
+ * journal instead. JSON omits the WARN so older `--json` health hooks do not
137
+ * file it as a failed repair. FAIL stays visible. NA leftover rows stay in
138
+ * JSON so operators can still see the unused shard.
139
+ */
140
+ export declare function isLeftoverPersonalJournalWarn(result: {
141
+ checkId: string;
142
+ status: DoctorStatus;
143
+ }): boolean;
132
144
  /** Assemble the machine-readable document from a completed run. */
133
145
  export declare function buildDoctorJson(input: BuildDoctorJsonInput): DoctorJsonDocument;
134
146
  /** Serialise the document as pretty-printed JSON with a trailing newline. */
@@ -40,6 +40,19 @@ export const DOCTOR_JSON_SCHEMA_VERSION = 2;
40
40
  export function isOrdinaryCoreUpdateWarn(result) {
41
41
  return result.checkId === "sync.update.core" && result.status === "WARN";
42
42
  }
43
+ /**
44
+ * True when doctor still WARNs on the leftover `personal` journal shard.
45
+ * That file is not the live personal-files target; `--fix` refreshes the vault
46
+ * journal instead. JSON omits the WARN so older `--json` health hooks do not
47
+ * file it as a failed repair. FAIL stays visible. NA leftover rows stay in
48
+ * JSON so operators can still see the unused shard.
49
+ */
50
+ export function isLeftoverPersonalJournalWarn(result) {
51
+ return result.checkId === "sync.journal.personal" && result.status === "WARN";
52
+ }
53
+ function omitFromDoctorJson(result) {
54
+ return isOrdinaryCoreUpdateWarn(result) || isLeftoverPersonalJournalWarn(result);
55
+ }
43
56
  /** Tally the results that actually ship in the JSON document. */
44
57
  function summarizeJsonResults(results) {
45
58
  const counts = emptyStatusCounts();
@@ -49,17 +62,14 @@ function summarizeJsonResults(results) {
49
62
  }
50
63
  /** Assemble the machine-readable document from a completed run. */
51
64
  export function buildDoctorJson(input) {
52
- const attemptedRefreshSyncCheckIds = input.clientHealthReport === true
53
- ? freshAttemptedCompanyJournalCheckIds(input.hqRoot, (input.now ?? (() => new Date()))().getTime())
54
- : null;
65
+ const attemptedRefreshSyncCheckIds = freshAttemptedCompanyJournalCheckIds(input.hqRoot, (input.now ?? (() => new Date()))().getTime());
55
66
  const results = [];
56
67
  for (const run of input.families) {
57
68
  for (const result of run.results) {
58
- if (isOrdinaryCoreUpdateWarn(result)) {
69
+ if (omitFromDoctorJson(result)) {
59
70
  continue;
60
71
  }
61
- if (input.clientHealthReport === true &&
62
- isUnreachedCompanyJournalForClientHealth(result, attemptedRefreshSyncCheckIds)) {
72
+ if (isUnreachedCompanyJournalForClientHealth(result, attemptedRefreshSyncCheckIds)) {
63
73
  continue;
64
74
  }
65
75
  results.push(toJsonResult(run.family.id, result, input.clientHealthReasonCodesEnabledCheckIds, input.clientHealthCheckStatusEnabledCheckIds));
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `setup:` block of a content pack's package.yaml (sample-pack-in-app-setup
3
+ * US-001, build contract section 1).
4
+ *
5
+ * A pack that declares one can be set up from the HQ app chat: the pack's own
6
+ * bot (`setup.bot.name`, also a worker under `contributes.workers`) asks the
7
+ * questions in order with tap-to-answer suggestions, then runs `setup.run`
8
+ * with the answers. `hq packages install` validates the block and fails naming
9
+ * the field; a pack without the block is untouched.
10
+ *
11
+ * Validation (contract):
12
+ * - `setup.bot.name` matches ^[a-z0-9-]{2,32}$ and appears in contributes.workers
13
+ * - question ids unique, ^[a-z_]+$; options is a list of strings (may be empty)
14
+ * - `run.script` exists in the pack; every `run.args` key is a question id;
15
+ * every `then` entry's first token exists in the pack
16
+ */
17
+ import type { PackManifest, PackSetup } from "../../types.js";
18
+ export declare const SETUP_BOT_NAME_RE: RegExp;
19
+ export declare const SETUP_QUESTION_ID_RE: RegExp;
20
+ /** A validation failure; `field` is the dotted path of the offending field. */
21
+ export declare class PackSetupError extends Error {
22
+ readonly field: string;
23
+ constructor(field: string, detail: string);
24
+ }
25
+ export interface SetupBlockContext {
26
+ /** Pack root on disk (the payload at install, `core/packages/<name>` later). */
27
+ packDir: string;
28
+ /** `contributes.workers` of the same manifest. */
29
+ workers: readonly string[];
30
+ /** Filesystem seam for tests; defaults to node:fs. */
31
+ existsSync?: (p: string) => boolean;
32
+ }
33
+ /**
34
+ * Validate a raw `setup:` value. Returns the typed block, or undefined when the
35
+ * manifest has no block (a legacy pack). Throws PackSetupError naming the field.
36
+ */
37
+ export declare function parseSetupBlock(raw: unknown, ctx: SetupBlockContext): PackSetup | undefined;
38
+ /**
39
+ * The validated setup block of an installed pack, or undefined when the pack
40
+ * has none or the block is invalid (`hq packages list` prints `setup: no`).
41
+ */
42
+ export declare function readInstalledPackSetup(manifest: Pick<PackManifest, "setup" | "contributes"> | null | undefined, packDir: string, existsSync?: (p: string) => boolean): PackSetup | undefined;
43
+ //# sourceMappingURL=setup-block.d.ts.map
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The `setup:` block of a content pack's package.yaml (sample-pack-in-app-setup
3
+ * US-001, build contract section 1).
4
+ *
5
+ * A pack that declares one can be set up from the HQ app chat: the pack's own
6
+ * bot (`setup.bot.name`, also a worker under `contributes.workers`) asks the
7
+ * questions in order with tap-to-answer suggestions, then runs `setup.run`
8
+ * with the answers. `hq packages install` validates the block and fails naming
9
+ * the field; a pack without the block is untouched.
10
+ *
11
+ * Validation (contract):
12
+ * - `setup.bot.name` matches ^[a-z0-9-]{2,32}$ and appears in contributes.workers
13
+ * - question ids unique, ^[a-z_]+$; options is a list of strings (may be empty)
14
+ * - `run.script` exists in the pack; every `run.args` key is a question id;
15
+ * every `then` entry's first token exists in the pack
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ export const SETUP_BOT_NAME_RE = /^[a-z0-9-]{2,32}$/;
20
+ export const SETUP_QUESTION_ID_RE = /^[a-z_]+$/;
21
+ /** A validation failure; `field` is the dotted path of the offending field. */
22
+ export class PackSetupError extends Error {
23
+ field;
24
+ constructor(field, detail) {
25
+ super(`${field} ${detail}`);
26
+ this.field = field;
27
+ this.name = "PackSetupError";
28
+ }
29
+ }
30
+ function isMapping(v) {
31
+ return !!v && typeof v === "object" && !Array.isArray(v);
32
+ }
33
+ function requireString(value, field) {
34
+ if (typeof value !== "string" || value.trim() === "") {
35
+ throw new PackSetupError(field, "must be a non-empty string");
36
+ }
37
+ return value;
38
+ }
39
+ function optionalBoolean(value, field, fallback) {
40
+ if (value === undefined)
41
+ return fallback;
42
+ if (typeof value !== "boolean")
43
+ throw new PackSetupError(field, "must be true or false");
44
+ return value;
45
+ }
46
+ /** A relative pack path that stays inside the pack root; null when it escapes. */
47
+ function insidePack(packDir, rel) {
48
+ if (path.isAbsolute(rel))
49
+ return null;
50
+ const root = path.resolve(packDir);
51
+ const abs = path.resolve(root, rel);
52
+ if (abs !== root && !abs.startsWith(`${root}${path.sep}`))
53
+ return null;
54
+ return abs;
55
+ }
56
+ function requirePackFile(ctx, rel, field) {
57
+ const exists = ctx.existsSync ?? fs.existsSync;
58
+ const abs = insidePack(ctx.packDir, rel);
59
+ if (!abs)
60
+ throw new PackSetupError(field, `"${rel}" must be a relative path inside the pack`);
61
+ if (!exists(abs))
62
+ throw new PackSetupError(field, `"${rel}" does not exist in the pack`);
63
+ }
64
+ function parseQuestion(raw, index) {
65
+ const field = `setup.questions[${index}]`;
66
+ if (!isMapping(raw))
67
+ throw new PackSetupError(field, "must be a mapping with id and text");
68
+ const id = requireString(raw.id, `${field}.id`);
69
+ if (!SETUP_QUESTION_ID_RE.test(id)) {
70
+ throw new PackSetupError(`${field}.id`, `"${id}" must match ${SETUP_QUESTION_ID_RE.source}`);
71
+ }
72
+ const text = requireString(raw.text, `${field}.text`);
73
+ let options = [];
74
+ if (raw.options !== undefined) {
75
+ if (!Array.isArray(raw.options) || !raw.options.every((o) => typeof o === "string")) {
76
+ throw new PackSetupError(`${field}.options`, "must be a list of strings (it may be empty)");
77
+ }
78
+ options = raw.options;
79
+ }
80
+ const skippable = optionalBoolean(raw.skippable, `${field}.skippable`, false);
81
+ let defaultFrom;
82
+ if (raw.default_from !== undefined)
83
+ defaultFrom = requireString(raw.default_from, `${field}.default_from`);
84
+ let goal;
85
+ if (raw.goal !== undefined)
86
+ goal = requireString(raw.goal, `${field}.goal`);
87
+ return { id, text, options, skippable, ...(defaultFrom ? { default_from: defaultFrom } : {}), ...(goal ? { goal } : {}) };
88
+ }
89
+ /**
90
+ * Validate a raw `setup:` value. Returns the typed block, or undefined when the
91
+ * manifest has no block (a legacy pack). Throws PackSetupError naming the field.
92
+ */
93
+ export function parseSetupBlock(raw, ctx) {
94
+ if (raw === undefined || raw === null)
95
+ return undefined;
96
+ if (!isMapping(raw))
97
+ throw new PackSetupError("setup", "must be a mapping (bot, questions, run, done)");
98
+ // bot
99
+ if (!isMapping(raw.bot))
100
+ throw new PackSetupError("setup.bot", "must be a mapping with name, display_name, intro");
101
+ const botName = requireString(raw.bot.name, "setup.bot.name");
102
+ if (!SETUP_BOT_NAME_RE.test(botName)) {
103
+ throw new PackSetupError("setup.bot.name", `"${botName}" must match ${SETUP_BOT_NAME_RE.source}`);
104
+ }
105
+ if (!ctx.workers.includes(botName)) {
106
+ throw new PackSetupError("setup.bot.name", `"${botName}" is not listed under contributes.workers` +
107
+ (ctx.workers.length ? ` (declared: ${ctx.workers.join(", ")})` : " (none declared)"));
108
+ }
109
+ const displayName = requireString(raw.bot.display_name, "setup.bot.display_name");
110
+ const intro = requireString(raw.bot.intro, "setup.bot.intro");
111
+ const takeoverSetup = optionalBoolean(raw.takeover_setup, "setup.takeover_setup", false);
112
+ // questions
113
+ if (!Array.isArray(raw.questions) || raw.questions.length === 0) {
114
+ throw new PackSetupError("setup.questions", "must be a non-empty list");
115
+ }
116
+ const questions = raw.questions.map(parseQuestion);
117
+ const ids = new Set();
118
+ questions.forEach((q, i) => {
119
+ if (ids.has(q.id))
120
+ throw new PackSetupError(`setup.questions[${i}].id`, `"${q.id}" is used more than once`);
121
+ ids.add(q.id);
122
+ });
123
+ // run
124
+ if (!isMapping(raw.run))
125
+ throw new PackSetupError("setup.run", "must be a mapping with script (and optional args, then)");
126
+ const script = requireString(raw.run.script, "setup.run.script");
127
+ requirePackFile(ctx, script, "setup.run.script");
128
+ const args = {};
129
+ if (raw.run.args !== undefined) {
130
+ if (!isMapping(raw.run.args))
131
+ throw new PackSetupError("setup.run.args", "must be a mapping of question id to flag");
132
+ for (const [key, flag] of Object.entries(raw.run.args)) {
133
+ if (!ids.has(key)) {
134
+ throw new PackSetupError(`setup.run.args.${key}`, `is not a question id (questions: ${[...ids].join(", ")})`);
135
+ }
136
+ args[key] = requireString(flag, `setup.run.args.${key}`);
137
+ }
138
+ }
139
+ const then = [];
140
+ if (raw.run.then !== undefined) {
141
+ if (!Array.isArray(raw.run.then))
142
+ throw new PackSetupError("setup.run.then", "must be a list of commands");
143
+ raw.run.then.forEach((entry, i) => {
144
+ const field = `setup.run.then[${i}]`;
145
+ const command = requireString(entry, field);
146
+ const first = command.trim().split(/\s+/)[0];
147
+ requirePackFile(ctx, first, field);
148
+ then.push(command.trim());
149
+ });
150
+ }
151
+ // done
152
+ if (!isMapping(raw.done))
153
+ throw new PackSetupError("setup.done", "must be a mapping with message (and optional suggestion)");
154
+ const message = requireString(raw.done.message, "setup.done.message");
155
+ let suggestion;
156
+ if (raw.done.suggestion !== undefined)
157
+ suggestion = requireString(raw.done.suggestion, "setup.done.suggestion");
158
+ // documents
159
+ let documents;
160
+ if (raw.documents !== undefined) {
161
+ if (!Array.isArray(raw.documents) || raw.documents.length === 0 || !raw.documents.every((d) => typeof d === "string" && d.trim())) {
162
+ throw new PackSetupError("setup.documents", "must be a non-empty list of document descriptions");
163
+ }
164
+ documents = raw.documents.map((d) => d.trim());
165
+ }
166
+ // sources
167
+ let sources;
168
+ if (raw.sources !== undefined) {
169
+ if (!Array.isArray(raw.sources) || raw.sources.length === 0) {
170
+ throw new PackSetupError("setup.sources", "must be a non-empty list of { kind, read, informs, options }");
171
+ }
172
+ sources = raw.sources.map((entry, i) => {
173
+ const field = `setup.sources[${i}]`;
174
+ if (!isMapping(entry))
175
+ throw new PackSetupError(field, "must be a mapping with kind, read, informs and options");
176
+ const kind = requireString(entry.kind, `${field}.kind`);
177
+ const read = requireString(entry.read, `${field}.read`);
178
+ if (!Array.isArray(entry.informs) || entry.informs.length === 0 || !entry.informs.every((x) => typeof x === "string")) {
179
+ throw new PackSetupError(`${field}.informs`, "must be a non-empty list of question ids");
180
+ }
181
+ for (const id of entry.informs) {
182
+ if (!ids.has(id))
183
+ throw new PackSetupError(`${field}.informs`, `"${id}" is not a question id (questions: ${[...ids].join(", ")})`);
184
+ }
185
+ if (!Array.isArray(entry.options) || entry.options.length === 0) {
186
+ throw new PackSetupError(`${field}.options`, "must be a non-empty list of { name, domain }");
187
+ }
188
+ const options = entry.options.map((o, j) => {
189
+ const of = `${field}.options[${j}]`;
190
+ if (!isMapping(o))
191
+ throw new PackSetupError(of, "must be a mapping with name and a domain or a tool");
192
+ const name = requireString(o.name, `${of}.name`);
193
+ const available = optionalBoolean(o.available, `${of}.available`, true);
194
+ const opt = { name, ...(available ? {} : { available: false }) };
195
+ for (const key of ["domain", "tool", "connect", "status", "usage", "note"]) {
196
+ if (o[key] !== undefined)
197
+ opt[key] = requireString(o[key], `${of}.${key}`);
198
+ }
199
+ if (opt.tool)
200
+ requirePackFile(ctx, opt.tool, `${of}.tool`);
201
+ if (available && !opt.domain && !opt.tool)
202
+ throw new PackSetupError(of, "needs a domain (HQ integrations) or a tool (pack script), or available: false");
203
+ return opt;
204
+ });
205
+ return { kind, read, informs: [...entry.informs], options };
206
+ });
207
+ }
208
+ return {
209
+ bot: { name: botName, display_name: displayName, intro },
210
+ takeover_setup: takeoverSetup,
211
+ questions,
212
+ run: { script, args, then },
213
+ done: { message, ...(suggestion ? { suggestion } : {}) },
214
+ ...(documents ? { documents } : {}),
215
+ ...(sources ? { sources } : {}),
216
+ };
217
+ }
218
+ /**
219
+ * The validated setup block of an installed pack, or undefined when the pack
220
+ * has none or the block is invalid (`hq packages list` prints `setup: no`).
221
+ */
222
+ export function readInstalledPackSetup(manifest, packDir, existsSync) {
223
+ if (!manifest || manifest.setup === undefined)
224
+ return undefined;
225
+ try {
226
+ return parseSetupBlock(manifest.setup, {
227
+ packDir,
228
+ workers: manifest.contributes?.workers ?? [],
229
+ ...(existsSync ? { existsSync } : {}),
230
+ });
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ }
236
+ //# sourceMappingURL=setup-block.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The auto-generated "Pack setup" section of a pack bot's system prompt
3
+ * (build contract section 3). Rendered from the pack's validated `setup:`
4
+ * block: the questions in order with their options, the one-at-a-time rule
5
+ * with the `hq-block` suggestions envelope (contract section 2), the exact
6
+ * command to run with the collected answers, and the done message.
7
+ */
8
+ import type { PackSetup, PackSetupQuestion } from "../../types.js";
9
+ export declare const SKIP_SUGGESTION = "Skip";
10
+ export declare const HQ_BLOCK_FENCE = "hq-block";
11
+ export interface PackSetupPromptInput {
12
+ /** Manifest name, e.g. hq-pack-sample. */
13
+ packName: string;
14
+ /** Absolute installed pack root (core/packages/<pack>). */
15
+ packRoot: string;
16
+ setup: PackSetup;
17
+ }
18
+ /** The fenced JSON envelope the app draws as buttons (contract section 2). */
19
+ export declare function suggestionsEnvelope(items: readonly string[]): string;
20
+ /** The buttons for one question: its options, plus Skip when it is skippable. */
21
+ export declare function questionSuggestions(q: PackSetupQuestion): string[];
22
+ /** `bash <packRoot>/<script> --firm <company slug> --flag '<answer>' ...` */
23
+ export declare function renderRunCommand(input: PackSetupPromptInput): string;
24
+ /** The `then` commands, each run from the pack root with the same --firm. */
25
+ export declare function renderThenCommands(input: PackSetupPromptInput): string[];
26
+ /**
27
+ * "Their tools": what the bot needs to know to learn from the person's tools
28
+ * before asking. Facts (how each tool connects and is read) and principles;
29
+ * the bot decides how to run the conversation.
30
+ */
31
+ export declare function renderLearnFirst(setup: PackSetup, packRoot: string): string;
32
+ /** The whole "## Pack setup" section: a brief, not a script. */
33
+ export declare function renderPackSetupSection(input: PackSetupPromptInput): string;
34
+ //# sourceMappingURL=setup-prompt.d.ts.map