@indigoai-us/hq-cli 5.345.152 → 5.345.154

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 (82) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +4 -0
  3. package/dist/bin/hq-auth-refresh.js +9 -1
  4. package/dist/command-catalog.generated.d.ts +185 -0
  5. package/dist/command-catalog.generated.js +227 -0
  6. package/dist/command-registration-plan.d.ts +6 -0
  7. package/dist/command-registration-plan.js +1 -0
  8. package/dist/commands/agent-enroll.d.ts +12 -0
  9. package/dist/commands/agent-enroll.js +37 -4
  10. package/dist/commands/cloud.d.ts +17 -0
  11. package/dist/commands/cloud.js +48 -1
  12. package/dist/commands/core-policy.js +4 -4
  13. package/dist/commands/core-session-project-native.js +19 -4
  14. package/dist/commands/decide.d.ts +13 -0
  15. package/dist/commands/decide.js +74 -0
  16. package/dist/commands/integrations-connect.js +24 -1
  17. package/dist/commands/integrations-core.d.ts +14 -0
  18. package/dist/commands/integrations-core.js +19 -1
  19. package/dist/commands/integrations-import.js +22 -1
  20. package/dist/commands/integrations.js +43 -2
  21. package/dist/commands/lanes-message.js +4 -11
  22. package/dist/commands/lanes-questions.js +3 -0
  23. package/dist/commands/lanes-resume.js +4 -11
  24. package/dist/commands/lanes-stories.js +3 -0
  25. package/dist/commands/lanes.js +7 -1
  26. package/dist/commands/run.js +14 -5
  27. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  28. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  29. package/dist/lib/agent-kit/services.d.ts +3 -1
  30. package/dist/lib/agent-kit/services.js +5 -1
  31. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  32. package/dist/lib/daemon/ops/policy.js +37 -2
  33. package/dist/lib/decisions/client.d.ts +67 -0
  34. package/dist/lib/decisions/client.js +120 -0
  35. package/dist/lib/decisions/expiry.d.ts +2 -0
  36. package/dist/lib/decisions/expiry.js +12 -0
  37. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  38. package/dist/lib/decisions/fixtures/us001.js +21 -0
  39. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  40. package/dist/lib/decisions/import-lanes.js +152 -0
  41. package/dist/lib/decisions/principal.d.ts +11 -0
  42. package/dist/lib/decisions/principal.js +52 -0
  43. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  44. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  45. package/dist/lib/doctor/checks/work-context.js +7 -2
  46. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  47. package/dist/lib/doctor/fix/apply.js +19 -15
  48. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  49. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  50. package/dist/lib/doctor/fix/remediation.js +4 -2
  51. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  52. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  53. package/dist/lib/doctor/types.d.ts +5 -0
  54. package/dist/lib/install/provider-wrappers.js +5 -1
  55. package/dist/lib/lanes/autopilot.js +4 -1
  56. package/dist/lib/lanes/imported-question.d.ts +5 -0
  57. package/dist/lib/lanes/imported-question.js +17 -0
  58. package/dist/lib/lanes/questions.d.ts +0 -5
  59. package/dist/lib/lanes/questions.js +2 -0
  60. package/dist/lib/lanes/reconcile.js +21 -10
  61. package/dist/lib/lanes/store.d.ts +0 -8
  62. package/dist/lib/lanes/store.js +2 -1
  63. package/dist/lib/lanes/stories.d.ts +15 -0
  64. package/dist/lib/lanes/stories.js +25 -0
  65. package/dist/lib/mesh/client.d.ts +8 -0
  66. package/dist/lib/mesh/client.js +44 -3
  67. package/dist/lib/mesh/denial-codes.js +1 -1
  68. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  69. package/dist/lib/mesh/live/daemon/run.js +30 -1
  70. package/dist/lib/mesh/live/flush.d.ts +7 -0
  71. package/dist/lib/mesh/live/flush.js +53 -10
  72. package/dist/lib/registry/registry.js +6 -0
  73. package/dist/lib/scan-packages/index.js +7 -3
  74. package/dist/lib/sync/read-only-local-only.d.ts +45 -0
  75. package/dist/lib/sync/read-only-local-only.js +122 -0
  76. package/dist/lib/work-context/organize.d.ts +2 -0
  77. package/dist/lib/work-context/organize.js +22 -4
  78. package/dist/lib/workers-registry/index.d.ts +12 -1
  79. package/dist/lib/workers-registry/index.js +39 -1
  80. package/dist/utils/cognito-session.d.ts +4 -1
  81. package/dist/utils/cognito-session.js +11 -1
  82. package/package.json +7 -7
@@ -4,7 +4,7 @@ import { appendCappedHistory, isFailedQuotaKeepAliveLane, providerSessionPortabl
4
4
  import { parseCliEffort } from "../lib/lanes/effort.js";
5
5
  import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
6
6
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
7
- import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
7
+ import { attributedStoryRow, laneStories, storiesAfterAttribution, storyWarning } from "../lib/lanes/stories.js";
8
8
  import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
9
9
  import { markSpawnFailure, spawnLaneWorker } from "../lib/lanes/spawn.js";
10
10
  import { appendProjectFocusOverride, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
@@ -359,15 +359,13 @@ export function createLaneResumeHandlers(deps) {
359
359
  const already = current.attribution_history.some((entry) => entry.message_id === messageId);
360
360
  if (already)
361
361
  return current;
362
- const existingStory = laneStory(current, projectId, storyId);
362
+ const addedStory = attributedStoryRow(current, projectId, storyId, at);
363
363
  storySwitchRollback = {
364
364
  message_id: messageId,
365
365
  project_id: current.project_id,
366
366
  story_id: current.story_id,
367
367
  no_story_reason: current.no_story_reason,
368
- ...(!existingStory ? {
369
- added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
370
- } : {}),
368
+ ...(addedStory ? { added_story: addedStory } : {}),
371
369
  story_switch_unacknowledged: current.story_switch_unacknowledged,
372
370
  };
373
371
  return {
@@ -375,12 +373,7 @@ export function createLaneResumeHandlers(deps) {
375
373
  project_id: projectId,
376
374
  story_id: storyId,
377
375
  no_story_reason: storyId ? undefined : current.no_story_reason,
378
- stories: laneStory(current, projectId, storyId)
379
- ? laneStories(current)
380
- : [
381
- ...laneStories(current),
382
- { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
383
- ],
376
+ stories: storiesAfterAttribution(current, projectId, storyId, at),
384
377
  attribution_history: appendCappedHistory(current.attribution_history, {
385
378
  project_id: projectId,
386
379
  story_id: storyId,
@@ -44,6 +44,9 @@ export function createLaneStoryHandlers(deps) {
44
44
  };
45
45
  }
46
46
  function addCarriedStory(lane, projectId, storyId, at) {
47
+ if (!storyId.trim()) {
48
+ throw new LanesError("missing_story", "A story id is required to add a story to a lane", { lane_id: lane.lane_id });
49
+ }
47
50
  if (laneStory(lane, projectId, storyId)) {
48
51
  throw new LanesError("story_already_on_lane", `Story ${projectId}/${storyId} is already carried by lane ${lane.lane_id}`, {
49
52
  lane_id: lane.lane_id,
@@ -7801,7 +7801,13 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
7801
7801
  accountModel: lane.account === undefined ? undefined : config.providers.claude.accounts[lane.account]?.model,
7802
7802
  providerModel: config.providers.claude.model,
7803
7803
  });
7804
- const mayRebalance = BALANCE_MOVABLE_STATES.includes(lane.state);
7804
+ // An account the operator chose with --account (account_source "cli") is a
7805
+ // pin, not a balancing hint. Plain resumes and messages keep it; only a repair
7806
+ // of an unusable account may move the lane, and only an explicit --account
7807
+ // may pick another healthy account. Rebalancing a pinned lane moved company
7808
+ // lanes onto another company's account (feedback_4a563e61).
7809
+ const cliAccountPin = lane.account !== undefined && lane.account_source === "cli";
7810
+ const mayRebalance = !cliAccountPin && BALANCE_MOVABLE_STATES.includes(lane.state);
7805
7811
  if (!accountNeedsRepair && !mayRebalance)
7806
7812
  return undefined;
7807
7813
  const balanceHosts = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config, {
@@ -149,11 +149,20 @@ export function registerRunCommand(program) {
149
149
  // error instead of hanging on varlock's never-settling promise, in case
150
150
  // the shape-dependent guard above ever silently degrades.
151
151
  await resolveEnvValuesOrThrow(graph);
152
- const schemaErrors = Object.entries(graph.configSchema)
153
- .filter(([, item]) => item.errors?.length > 0);
154
- if (schemaErrors.length > 0) {
155
- const msgs = schemaErrors.flatMap(([k, item]) => item.errors.map((e) => ` ${k}: ${e.message ?? String(e)}`));
156
- process.stderr.write(`Error: failed to resolve env vars:\n${msgs.join('\n')}\n`);
152
+ // varlock's item.errors includes warning-severity entries (isWarning),
153
+ // e.g. "Value is very short! Is it actually sensitive?" for a short
154
+ // sensitive value. Print those and carry on; only real errors are fatal.
155
+ const schemaItems = Object.entries(graph.configSchema);
156
+ const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
157
+ .filter((e) => Boolean(e?.isWarning) === wantWarnings)
158
+ .map((e) => ` ${k}: ${e?.message ?? String(e)}`));
159
+ const warningMsgs = itemMessages(true);
160
+ if (warningMsgs.length > 0) {
161
+ process.stderr.write(`Warning: env var checks reported warnings:\n${warningMsgs.join('\n')}\n`);
162
+ }
163
+ const errorMsgs = itemMessages(false);
164
+ if (errorMsgs.length > 0) {
165
+ process.stderr.write(`Error: failed to resolve env vars:\n${errorMsgs.join('\n')}\n`);
157
166
  process.exit(1);
158
167
  }
159
168
  const resolvedEnv = graph.getResolvedEnvObject();
@@ -12,7 +12,11 @@
12
12
  *
13
13
  * So before any `hq agent` subcommand runs, the resolved tree is published to
14
14
  * the environment. An explicit `HQ_MACHINE_CREDS_FILE` is never overwritten —
15
- * a daemon unit that pins one stays pinned.
15
+ * a daemon unit that pins one stays pinned. A pinned creds file still gets its
16
+ * own token cache (beside the creds file) when `HQ_MACHINE_TOKEN_STATE_DIR` is
17
+ * unset, so an agent's minted tokens never land in the shared default cache
18
+ * at ~/.hq/work-mesh/daemon, where the deploy resolver on a person's laptop
19
+ * would read them as machine mode.
16
20
  *
17
21
  * The person's own session file is not read, moved, or changed by any of this.
18
22
  */
@@ -24,4 +28,6 @@ export declare const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
24
28
  * Returns the variables it set, for tests and for `--verbose` output.
25
29
  */
26
30
  export declare function adoptAgentIdentityEnv(paths: Pick<AgentKitPaths, "agentDir" | "machineCredsPath">, env?: NodeJS.ProcessEnv): Record<string, string>;
31
+ /** The agent's own minted-token cache directory, inside its tree. */
32
+ export declare function agentTokenStateDir(paths: Pick<AgentKitPaths, "agentDir">): string;
27
33
  //# sourceMappingURL=adopt-identity.d.ts.map
@@ -12,7 +12,11 @@
12
12
  *
13
13
  * So before any `hq agent` subcommand runs, the resolved tree is published to
14
14
  * the environment. An explicit `HQ_MACHINE_CREDS_FILE` is never overwritten —
15
- * a daemon unit that pins one stays pinned.
15
+ * a daemon unit that pins one stays pinned. A pinned creds file still gets its
16
+ * own token cache (beside the creds file) when `HQ_MACHINE_TOKEN_STATE_DIR` is
17
+ * unset, so an agent's minted tokens never land in the shared default cache
18
+ * at ~/.hq/work-mesh/daemon, where the deploy resolver on a person's laptop
19
+ * would read them as machine mode.
16
20
  *
17
21
  * The person's own session file is not read, moved, or changed by any of this.
18
22
  */
@@ -27,8 +31,15 @@ export const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
27
31
  */
28
32
  export function adoptAgentIdentityEnv(paths, env = process.env) {
29
33
  const applied = {};
30
- if (env[MACHINE_CREDS_FILE_ENV]?.trim())
34
+ const pinnedCreds = env[MACHINE_CREDS_FILE_ENV]?.trim();
35
+ if (pinnedCreds) {
36
+ if (!env[MACHINE_TOKEN_STATE_DIR_ENV]?.trim()) {
37
+ const dir = path.join(path.dirname(pinnedCreds), "token-state");
38
+ applied[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
39
+ env[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
40
+ }
31
41
  return applied;
42
+ }
32
43
  // Only adopt a tree that actually holds an identity: on a host that has not
33
44
  // enrolled yet, leaving the environment alone keeps today's error messages.
34
45
  try {
@@ -51,10 +62,14 @@ export function adoptAgentIdentityEnv(paths, env = process.env) {
51
62
  // Per-agent token cache. Two identities on one computer must never share a
52
63
  // minted-token file; the agent's belongs inside the agent's own tree.
53
64
  if (!env[MACHINE_TOKEN_STATE_DIR_ENV]?.trim()) {
54
- const dir = path.join(paths.agentDir, "token-state");
65
+ const dir = agentTokenStateDir(paths);
55
66
  applied[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
56
67
  env[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
57
68
  }
58
69
  return applied;
59
70
  }
71
+ /** The agent's own minted-token cache directory, inside its tree. */
72
+ export function agentTokenStateDir(paths) {
73
+ return path.join(paths.agentDir, "token-state");
74
+ }
60
75
  //# sourceMappingURL=adopt-identity.js.map
@@ -12,7 +12,9 @@ export declare function isKitService(value: string): value is KitService;
12
12
  * Environment every service inherits. HQ_MACHINE_CREDS_FILE pins hq-cloud's
13
13
  * mint to the kit's creds file; HQ_REQUIRE_MACHINE_IDENTITY makes a missing
14
14
  * file a loud error instead of a browser-login hang; HQ_AGENT_DIR keeps a
15
- * relocated kit consistent across restarts.
15
+ * relocated kit consistent across restarts. HQ_MACHINE_TOKEN_STATE_DIR keeps
16
+ * the agent's minted tokens in its own tree instead of the shared default
17
+ * cache a person's tools also read.
16
18
  */
17
19
  export declare function kitServiceEnv(paths: AgentKitPaths, env?: NodeJS.ProcessEnv): Record<string, string>;
18
20
  export declare function kitServiceSpec(service: KitService, paths: AgentKitPaths, env?: NodeJS.ProcessEnv): ServiceSpec;
@@ -4,6 +4,7 @@
4
4
  * machine-creds file itself, so the units carry paths and nothing else.
5
5
  */
6
6
  import { serviceLogPath } from "./paths.js";
7
+ import { agentTokenStateDir } from "./adopt-identity.js";
7
8
  export const KIT_SERVICES = ["sync", "inbox", "heartbeat"];
8
9
  const KIT_LABEL_PREFIX = "ai.getindigo.hq-agent";
9
10
  export function isKitService(value) {
@@ -18,13 +19,16 @@ const DESCRIPTIONS = {
18
19
  * Environment every service inherits. HQ_MACHINE_CREDS_FILE pins hq-cloud's
19
20
  * mint to the kit's creds file; HQ_REQUIRE_MACHINE_IDENTITY makes a missing
20
21
  * file a loud error instead of a browser-login hang; HQ_AGENT_DIR keeps a
21
- * relocated kit consistent across restarts.
22
+ * relocated kit consistent across restarts. HQ_MACHINE_TOKEN_STATE_DIR keeps
23
+ * the agent's minted tokens in its own tree instead of the shared default
24
+ * cache a person's tools also read.
22
25
  */
23
26
  export function kitServiceEnv(paths, env = process.env) {
24
27
  const flagsApiUrl = env.HQ_FLAGS_API_URL;
25
28
  return {
26
29
  HQ_AGENT_DIR: paths.agentDir,
27
30
  HQ_MACHINE_CREDS_FILE: paths.machineCredsPath,
31
+ HQ_MACHINE_TOKEN_STATE_DIR: agentTokenStateDir(paths),
28
32
  HQ_REQUIRE_MACHINE_IDENTITY: "1",
29
33
  ...(flagsApiUrl?.trim() ? { HQ_FLAGS_API_URL: flagsApiUrl } : {}),
30
34
  };
@@ -11,7 +11,11 @@
11
11
  *
12
12
  * The policy index (which policy files exist for a company) is cached
13
13
  * in-process and invalidated by fs.watch on core/policies, personal/policies
14
- * and companies/<co>/policies.
14
+ * and companies/<co>/policies. Each list() also compares the scanned
15
+ * directories' stat stamps with those taken during the last rebuild, because
16
+ * a watcher can miss a change: on macOS fs.watch is backed by an FSEvents
17
+ * stream that starts asynchronously, so a change made right after a rebuild
18
+ * re-arms the watchers is never reported.
15
19
  */
16
20
  export declare const SCOPE_AUTHORIZER = ".claude/hooks/mandatory-scope-authorizer.sh";
17
21
  export declare const POLICY_TRIGGER = ".claude/hooks/inject-policy-on-trigger.sh";
@@ -30,6 +34,8 @@ export declare class PolicyIndex {
30
34
  readonly hqRoot: string;
31
35
  private readonly log;
32
36
  private entries;
37
+ /** Directory stat stamps taken while building `entries` (dir -> stamp). */
38
+ private stamps;
33
39
  private watchers;
34
40
  private closed;
35
41
  /** Bumped on every invalidation; lets callers see that the cache was rebuilt. */
@@ -39,6 +45,8 @@ export declare class PolicyIndex {
39
45
  list(company?: string): PolicyEntry[];
40
46
  invalidate(): void;
41
47
  close(): void;
48
+ /** True when any directory scanned by the last rebuild changed since. */
49
+ private isStale;
42
50
  private rebuild;
43
51
  private rewatch;
44
52
  }
@@ -11,7 +11,11 @@
11
11
  *
12
12
  * The policy index (which policy files exist for a company) is cached
13
13
  * in-process and invalidated by fs.watch on core/policies, personal/policies
14
- * and companies/<co>/policies.
14
+ * and companies/<co>/policies. Each list() also compares the scanned
15
+ * directories' stat stamps with those taken during the last rebuild, because
16
+ * a watcher can miss a change: on macOS fs.watch is backed by an FSEvents
17
+ * stream that starts asynchronously, so a change made right after a rebuild
18
+ * re-arms the watchers is never reported.
15
19
  */
16
20
  import { spawn } from "node:child_process";
17
21
  import * as fs from "node:fs";
@@ -33,6 +37,8 @@ export class PolicyIndex {
33
37
  hqRoot;
34
38
  log;
35
39
  entries = null;
40
+ /** Directory stat stamps taken while building `entries` (dir -> stamp). */
41
+ stamps = new Map();
36
42
  watchers = [];
37
43
  closed = false;
38
44
  /** Bumped on every invalidation; lets callers see that the cache was rebuilt. */
@@ -43,6 +49,8 @@ export class PolicyIndex {
43
49
  }
44
50
  /** Policies visible to `company`: core, personal, and that company only. */
45
51
  list(company) {
52
+ if (this.entries && this.isStale())
53
+ this.invalidate();
46
54
  if (!this.entries)
47
55
  this.rebuild();
48
56
  return this.entries.filter((e) => e.scope !== "company" || e.company === company);
@@ -59,22 +67,38 @@ export class PolicyIndex {
59
67
  w.close();
60
68
  this.watchers = [];
61
69
  }
70
+ /** True when any directory scanned by the last rebuild changed since. */
71
+ isStale() {
72
+ for (const [dir, stamp] of this.stamps)
73
+ if (dirStamp(dir) !== stamp)
74
+ return true;
75
+ return false;
76
+ }
62
77
  rebuild() {
78
+ // Stamp each directory before reading it, so a change racing the read
79
+ // either shows up in the read or leaves a stale stamp for the next list().
80
+ const stamps = new Map();
63
81
  const dirs = [
64
82
  { dir: path.join(this.hqRoot, "core", "policies"), scope: "core" },
65
83
  { dir: path.join(this.hqRoot, "personal", "policies"), scope: "personal" },
66
84
  ];
67
85
  const companiesDir = path.join(this.hqRoot, "companies");
68
86
  const companyDirs = [];
87
+ for (const d of dirs)
88
+ stamps.set(d.dir, dirStamp(d.dir));
89
+ stamps.set(companiesDir, dirStamp(companiesDir));
69
90
  for (const co of readDirNames(companiesDir)) {
70
91
  if (!SLUG_RE.test(co))
71
92
  continue;
72
93
  const companyDir = path.join(companiesDir, co);
94
+ stamps.set(companyDir, dirStamp(companyDir));
73
95
  if (isDir(companyDir))
74
96
  companyDirs.push(companyDir);
75
97
  const dir = path.join(companiesDir, co, "policies");
76
- if (isDir(dir))
98
+ if (isDir(dir)) {
99
+ stamps.set(dir, dirStamp(dir));
77
100
  dirs.push({ dir, scope: "company", company: co });
101
+ }
78
102
  }
79
103
  const entries = [];
80
104
  for (const d of dirs) {
@@ -85,6 +109,7 @@ export class PolicyIndex {
85
109
  }
86
110
  }
87
111
  this.entries = entries;
112
+ this.stamps = stamps;
88
113
  this.rewatch([...dirs.map((d) => d.dir), ...companyDirs, companiesDir]);
89
114
  }
90
115
  rewatch(dirs) {
@@ -120,6 +145,16 @@ function readDirNames(dir) {
120
145
  throw err;
121
146
  }
122
147
  }
148
+ /** Identity plus modification time of a directory, or "missing". */
149
+ function dirStamp(dir) {
150
+ try {
151
+ const st = fs.statSync(dir, { bigint: true });
152
+ return `${st.dev}:${st.ino}:${st.mtimeNs}`;
153
+ }
154
+ catch {
155
+ return "missing";
156
+ }
157
+ }
123
158
  function isDir(dir) {
124
159
  try {
125
160
  return fs.statSync(dir).isDirectory();
@@ -0,0 +1,67 @@
1
+ import { vaultApiFetch } from "../../utils/vault-api.js";
2
+ export type DecisionState = "open" | "talking" | "answered" | "expired" | "withdrawn";
3
+ export interface DecisionOption {
4
+ id: string;
5
+ label: string;
6
+ recommended: boolean;
7
+ }
8
+ export interface Decision {
9
+ id: string;
10
+ askerPrincipal: string;
11
+ companyUid: string;
12
+ audience: {
13
+ kind: "person";
14
+ personUid: string;
15
+ } | {
16
+ kind: "company_admins";
17
+ };
18
+ statement: string;
19
+ options: DecisionOption[];
20
+ context: {
21
+ links: string[];
22
+ brief: string;
23
+ commitmentId: string | null;
24
+ };
25
+ state: DecisionState;
26
+ answer: {
27
+ choiceId: string;
28
+ by: string;
29
+ at: string;
30
+ source: "tap" | "thread" | "desktop";
31
+ } | null;
32
+ talkThreadId: string | null;
33
+ createdAt: string;
34
+ expiresAt: string;
35
+ updatedAt: string;
36
+ }
37
+ export interface AskDecision {
38
+ companyUid: string;
39
+ expiresAt: string;
40
+ audience: {
41
+ kind: "person";
42
+ personUid: string;
43
+ };
44
+ statement: string;
45
+ options: DecisionOption[];
46
+ context: Decision["context"];
47
+ }
48
+ export declare class DecisionError extends Error {
49
+ readonly code: string;
50
+ readonly status?: number | undefined;
51
+ constructor(code: string, message: string, status?: number | undefined);
52
+ }
53
+ /** US-001 transport envelopes live here so real producer fixtures replace one boundary. */
54
+ export declare function parseDecision(raw: unknown): Decision;
55
+ export declare function parseOptions(raw: string[], recommend?: string): DecisionOption[];
56
+ export declare class DecisionsClient {
57
+ private readonly token;
58
+ private readonly request;
59
+ constructor(token: () => Promise<string>, request?: typeof vaultApiFetch);
60
+ private call;
61
+ ask(input: AskDecision): Promise<Decision>;
62
+ list(open?: boolean): Promise<Decision[]>;
63
+ show(id: string): Promise<Decision>;
64
+ answer(id: string, choiceId: string, source: "desktop" | "thread"): Promise<Decision>;
65
+ transition(id: string, action: "answer" | "talk" | "withdraw", body?: Record<string, unknown>): Promise<Decision>;
66
+ }
67
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,120 @@
1
+ import { vaultApiFetch } from "../../utils/vault-api.js";
2
+ import { assertNoSecretText } from "../lanes/text-scan.js";
3
+ export class DecisionError extends Error {
4
+ code;
5
+ status;
6
+ constructor(code, message, status) {
7
+ super(`${code}: ${message}`);
8
+ this.code = code;
9
+ this.status = status;
10
+ }
11
+ }
12
+ function object(value) {
13
+ if (!value || typeof value !== "object" || Array.isArray(value))
14
+ throw new DecisionError("invalid_decision_response", "Expected an object");
15
+ return value;
16
+ }
17
+ function string(value) {
18
+ if (typeof value !== "string" || !value.trim())
19
+ throw new DecisionError("invalid_decision_response", "Expected a non-empty string");
20
+ return value;
21
+ }
22
+ /** US-001 transport envelopes live here so real producer fixtures replace one boundary. */
23
+ export function parseDecision(raw) {
24
+ const row = object(raw);
25
+ const context = object(row.context);
26
+ const audience = object(row.audience);
27
+ if (audience.kind !== "person" && audience.kind !== "company_admins")
28
+ throw new DecisionError("invalid_decision_response", "Unknown audience kind");
29
+ if (!Array.isArray(row.options) || !Array.isArray(context.links))
30
+ throw new DecisionError("invalid_decision_response", "Expected options and context.links arrays");
31
+ const state = string(row.state);
32
+ if (!["open", "talking", "answered", "expired", "withdrawn"].includes(state))
33
+ throw new DecisionError("invalid_decision_response", "Unknown state");
34
+ const options = row.options.map(rawOption => {
35
+ const option = object(rawOption);
36
+ if (typeof option.recommended !== "boolean")
37
+ throw new DecisionError("invalid_decision_response", "Expected recommended boolean");
38
+ return { id: string(option.id), label: string(option.label), recommended: option.recommended };
39
+ });
40
+ if (new Set(options.map(option => option.id)).size !== options.length)
41
+ throw new DecisionError("invalid_decision_response", "Duplicate option ids");
42
+ if (typeof context.brief !== "string")
43
+ throw new DecisionError("invalid_decision_response", "Expected context brief");
44
+ if (context.commitmentId !== null && typeof context.commitmentId !== "string")
45
+ throw new DecisionError("invalid_decision_response", "Expected nullable commitment id");
46
+ const answer = row.answer == null ? row.answer : object(row.answer);
47
+ if (answer && !["tap", "thread", "desktop"].includes(string(answer.source)))
48
+ throw new DecisionError("invalid_decision_response", "Unknown answer source");
49
+ return {
50
+ id: string(row.id), askerPrincipal: string(row.askerPrincipal), companyUid: string(row.companyUid),
51
+ audience: audience.kind === "person" ? { kind: "person", personUid: string(audience.personUid) } : { kind: "company_admins" },
52
+ statement: string(row.statement), options, state,
53
+ context: { links: context.links.map(string), brief: context.brief, commitmentId: context.commitmentId },
54
+ ...(answer == null ? { answer: null } : { answer: { choiceId: string(answer.choiceId), by: string(answer.by), at: string(answer.at), source: answer.source } }),
55
+ ...(row.talkThreadId == null ? { talkThreadId: null } : { talkThreadId: string(row.talkThreadId) }),
56
+ createdAt: string(row.createdAt), updatedAt: string(row.updatedAt),
57
+ expiresAt: string(row.expiresAt),
58
+ };
59
+ }
60
+ export function parseOptions(raw, recommend) {
61
+ if (raw.length < 2)
62
+ throw new DecisionError("invalid_options", "Supply at least two --option 'id|label' choices");
63
+ const options = raw.map(value => {
64
+ const index = value.indexOf("|");
65
+ const id = value.slice(0, index).trim();
66
+ const label = value.slice(index + 1).trim();
67
+ if (index < 1 || !/^[A-Za-z0-9_-]+$/.test(id) || !label)
68
+ throw new DecisionError("invalid_options", "Each option must be id|label");
69
+ return { id, label, recommended: id === recommend };
70
+ });
71
+ if (new Set(options.map(option => option.id)).size !== options.length || (recommend !== undefined && !options.some(option => option.id === recommend))) {
72
+ throw new DecisionError("invalid_options", "Option ids must be unique and --recommend must name one");
73
+ }
74
+ return options;
75
+ }
76
+ export class DecisionsClient {
77
+ token;
78
+ request;
79
+ constructor(token, request = vaultApiFetch) {
80
+ this.token = token;
81
+ this.request = request;
82
+ }
83
+ async call(path, method = "GET", body, query) {
84
+ const response = await this.request({ token: await this.token(), path, method, body, query, signal: AbortSignal.timeout(30_000) });
85
+ if (!response.ok) {
86
+ // Do not echo arbitrary server bodies (which may contain confidential input).
87
+ const codes = new Set(["invalid_decision", "invalid_answer", "invalid_expiry", "invalid_audience", "invalid_state", "invalid_json", "invalid_audience_filter", "asker_not_allowed", "company_forbidden", "agent_owner_only", "agent_owner_required", "decision_not_found", "already_answered", "decision_expired", "decision_withdrawn", "decision_closed", "decision_conflict", "route_not_found"]);
88
+ const payload = await response.json().catch(() => null);
89
+ const code = typeof payload?.code === "string" && codes.has(payload.code) ? payload.code : "decision_request_failed";
90
+ throw new DecisionError(code, `${method} ${path} returned ${response.status}`, response.status);
91
+ }
92
+ return object(await response.json());
93
+ }
94
+ async ask(input) {
95
+ assertNoSecretText("decision", JSON.stringify(input));
96
+ return parseDecision((await this.call("/v1/decisions", "POST", { ...input })).decision);
97
+ }
98
+ async list(open = false) {
99
+ const result = await this.call("/v1/decisions", "GET", undefined, { for: "me", ...(open ? { state: "open" } : {}) });
100
+ if (!Array.isArray(result.decisions))
101
+ throw new DecisionError("invalid_decision_response", "Expected decisions array");
102
+ return result.decisions.map(parseDecision).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
103
+ }
104
+ async show(id) {
105
+ // US-001 has no GET-by-id route. Use the authorised across-company list.
106
+ const record = (await this.list()).find(decision => decision.id === id);
107
+ if (!record)
108
+ throw new DecisionError("decision_not_found", `Decision ${id} is not visible to this principal`);
109
+ return record;
110
+ }
111
+ async answer(id, choiceId, source) {
112
+ return this.transition(id, "answer", { choiceId, source });
113
+ }
114
+ async transition(id, action, body = {}) {
115
+ if (!id.trim())
116
+ throw new DecisionError("invalid_decision_id", "Decision id is required");
117
+ return parseDecision((await this.call(`/v1/decisions/${encodeURIComponent(id)}/${action}`, "POST", body)).decision);
118
+ }
119
+ }
120
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,2 @@
1
+ export declare function decisionExpiry(value?: string, now?: number): string;
2
+ //# sourceMappingURL=expiry.d.ts.map
@@ -0,0 +1,12 @@
1
+ import { DecisionError } from "./client.js";
2
+ export function decisionExpiry(value = "72h", now = Date.now()) {
3
+ const duration = /^(\d+(?:\.\d+)?)(m|h|d)$/.exec(value);
4
+ const timestamp = duration
5
+ ? now + Number(duration[1]) * ({ m: 60_000, h: 3_600_000, d: 86_400_000 }[duration[2]] ?? 0)
6
+ : /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) ? Date.parse(value) : NaN;
7
+ if (!Number.isFinite(timestamp) || timestamp <= now || timestamp > 8.64e15) {
8
+ throw new DecisionError("invalid_expiry", "Use a future ISO date-time or positive duration such as 72h");
9
+ }
10
+ return new Date(timestamp).toISOString();
11
+ }
12
+ //# sourceMappingURL=expiry.js.map