@indigoai-us/hq-cli 5.345.169 → 5.345.170

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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-catalog.generated.d.ts +49 -4
  3. package/dist/command-catalog.generated.js +60 -4
  4. package/dist/commands/access.js +7 -2
  5. package/dist/commands/bot.js +4 -0
  6. package/dist/commands/cloud.d.ts +20 -0
  7. package/dist/commands/cloud.js +55 -4
  8. package/dist/commands/feedback-triage.d.ts +43 -1
  9. package/dist/commands/feedback-triage.js +123 -23
  10. package/dist/commands/feedback.d.ts +40 -1
  11. package/dist/commands/feedback.js +184 -4
  12. package/dist/commands/files.d.ts +42 -0
  13. package/dist/commands/files.js +223 -30
  14. package/dist/commands/mesh.js +6 -0
  15. package/dist/commands/sync-manifest.d.ts +16 -0
  16. package/dist/commands/sync-manifest.js +17 -1
  17. package/dist/lib/bot/decisions.d.ts +62 -0
  18. package/dist/lib/bot/decisions.js +228 -0
  19. package/dist/lib/bot/index.d.ts +1 -0
  20. package/dist/lib/bot/index.js +1 -0
  21. package/dist/lib/bot/run.d.ts +7 -0
  22. package/dist/lib/bot/run.js +104 -1
  23. package/dist/lib/daemon/catalog.d.ts +4 -0
  24. package/dist/lib/daemon/catalog.js +60 -29
  25. package/dist/lib/daemon/config.d.ts +7 -0
  26. package/dist/lib/daemon/config.js +32 -0
  27. package/dist/lib/daemon/run.d.ts +3 -2
  28. package/dist/lib/daemon/run.js +23 -3
  29. package/dist/lib/daemon/supervisor.d.ts +1 -0
  30. package/dist/lib/daemon/supervisor.js +4 -0
  31. package/dist/lib/daemon/sync-core-canary.d.ts +9 -0
  32. package/dist/lib/daemon/sync-core-canary.js +56 -0
  33. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  34. package/dist/lib/doctor/checks/sync-health.js +54 -1
  35. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  36. package/dist/lib/doctor/fix/apply.js +95 -11
  37. package/dist/lib/doctor/types.d.ts +1 -1
  38. package/dist/lib/doctor/types.js +1 -0
  39. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  40. package/dist/lib/work-context/organize.d.ts +7 -0
  41. package/dist/lib/work-context/organize.js +21 -1
  42. package/dist/sentry-before-send.js +1 -0
  43. package/package.json +7 -7
@@ -196,6 +196,7 @@ export function registerFilesCommand(program) {
196
196
  .description("Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
197
197
  .option("--with <principal>", "Email address, group id, company Outpost id (out_...), or '@all' to share with every active company member")
198
198
  .option("--permission <level>", "Permission level (only with --with): read | write")
199
+ .option("--exact", "With --with: grant exactly the object named by <prefix>, even when it looks like a folder (by default a bare folder name such as 'knowledge' is granted as the folder glob 'knowledge/*').")
199
200
  .option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
200
201
  .option("--expires <duration>", "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.")
201
202
  .option("--no-open", "Print the share-session URL but do not launch the browser")
@@ -208,6 +209,14 @@ export function registerFilesCommand(program) {
208
209
  console.error(chalk.red("--mention requires --channel <name>."));
209
210
  process.exit(1);
210
211
  }
212
+ if (opts.exact && opts.with === undefined && opts.channel === undefined) {
213
+ console.error(chalk.red("--exact applies only to a direct grant and requires --with <principal> (or --channel with --mention)."));
214
+ process.exit(1);
215
+ }
216
+ if (opts.exact && opts.full) {
217
+ console.error(chalk.red("--exact cannot be combined with --full."));
218
+ process.exit(1);
219
+ }
211
220
  const companySlug = files.opts().company;
212
221
  const channelTarget = opts.channel !== undefined
213
222
  ? await resolveShareChannelTarget({
@@ -258,6 +267,7 @@ export function registerFilesCommand(program) {
258
267
  principal: opts.with,
259
268
  permission: opts.permission,
260
269
  companySlug,
270
+ ...(opts.exact ? { exact: true } : {}),
261
271
  ...channelTarget,
262
272
  });
263
273
  return;
@@ -278,6 +288,7 @@ export function registerFilesCommand(program) {
278
288
  principal: uid,
279
289
  permission,
280
290
  companySlug,
291
+ ...(opts.exact ? { exact: true } : {}),
281
292
  ...(index === 0
282
293
  ? {
283
294
  channelId: channelTarget.channelId,
@@ -302,14 +313,22 @@ export function registerFilesCommand(program) {
302
313
  });
303
314
  files
304
315
  .command("unshare <prefix>")
305
- .description("Remove a named file access grant, or remove an empty ACL row or grantee-less entries without --with.")
316
+ .description("Remove a named file access grant, or remove an empty ACL row or grantee-less entries without --with. " +
317
+ "With --with, a bare name such as 'knowledge' first removes the exact 'knowledge' grant (rows from older " +
318
+ "`hq files share` versions) and, when none matches, the folder-glob 'knowledge/*' grant. Without --with, " +
319
+ "the prefix is used exactly as typed: pass 'knowledge/*' (quoted) to clean up the folder-glob row.")
306
320
  .option("--with <principal>", "Person uid, agent uid, company Outpost id (out_...), email address, group id, or '@all' to remove the grant")
321
+ .option("--exact", "With --with: remove the grant on exactly the object named by <prefix>. By default a bare folder name such as 'knowledge' resolves to the folder glob 'knowledge/*', the same way `hq files share` resolves it.")
307
322
  .action(async (prefix, opts) => {
308
323
  try {
324
+ if (opts.exact && !opts.with) {
325
+ console.error(chalk.red("--exact requires --with <principal>."));
326
+ process.exit(1);
327
+ }
309
328
  // Unshare MUTATES access with no preview. Anchored content paths remain
310
329
  // refused; the exact company-root ACL row is the sole exception and
311
330
  // resolves its anchor against the selected company before revoking.
312
- const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
331
+ let canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
313
332
  mutating: true,
314
333
  allowCompanyRootAclRow: true,
315
334
  ...(files.opts().company !== undefined
@@ -385,40 +404,66 @@ export function registerFilesCommand(program) {
385
404
  : `${cleanupMessage || `Removed empty ACL pattern '${canonicalPrefix}'. `}It now follows its parent folder's access, so anyone who can see that parent can see this folder.`));
386
405
  return;
387
406
  }
388
- const res = await vaultApiFetch({
389
- token,
390
- path: `/files/${encodeURIComponent(companyUid)}/acl/revoke`,
391
- method: "POST",
392
- body: { prefix: canonicalPrefix, granteeType, granteeId },
393
- });
394
- if (!res.ok) {
407
+ // A bare name may carry an exact-key row from older `hq files share`
408
+ // versions (DEV-5173) or the folder-glob row the current share
409
+ // creates. Try the exact row first, then `X/*` unless the vault says
410
+ // X is only a file. A revoke only reduces access, so the vault lookup
411
+ // never blocks it: a deleted path or a failed lookup still tries both.
412
+ const bare = !opts.exact &&
413
+ companyRootSlug === undefined &&
414
+ looksLikeBareFolderPrefix(canonicalPrefix) &&
415
+ malformedBareSharePrefix(canonicalPrefix) === null;
416
+ const candidates = [canonicalPrefix];
417
+ const tried = [];
418
+ let aclMissingEverywhere = true;
419
+ for (let index = 0; index < candidates.length; index++) {
420
+ const candidate = candidates[index];
421
+ tried.push(candidate);
422
+ const res = await vaultApiFetch({
423
+ token,
424
+ path: `/files/${encodeURIComponent(companyUid)}/acl/revoke`,
425
+ method: "POST",
426
+ body: { prefix: candidate, granteeType, granteeId },
427
+ });
428
+ if (res.ok) {
429
+ console.log(chalk.green(`Removed grant for ${principalLabel} on '${displayAclPattern(candidate)}'`));
430
+ if (bare && candidate === canonicalPrefix) {
431
+ console.log(chalk.dim(`This removed the exact '${candidate}' grant. A grant for ${principalLabel} on ` +
432
+ `'${candidate}/*', if one exists, remains; remove it with ` +
433
+ `hq files unshare '${candidate}/*' --with ${principal}.`));
434
+ }
435
+ return;
436
+ }
395
437
  const body = await res.json().catch(() => ({}));
438
+ if (res.status === 404) {
439
+ // A 404 means ZERO rows were removed — never report a green success
440
+ // that implies a revoke happened. ACL_NOT_FOUND means the prefix
441
+ // has no ACL at all; GRANT_NOT_FOUND (or an older server with no
442
+ // code) means the ACL exists but no grant matched this principal.
443
+ if (body.code !== "ACL_NOT_FOUND")
444
+ aclMissingEverywhere = false;
445
+ if (bare && candidate === canonicalPrefix) {
446
+ const resolution = await resolveBareSharePrefix({
447
+ token,
448
+ companyUid,
449
+ prefix: canonicalPrefix,
450
+ });
451
+ // Fall back to the folder glob unless the vault confirms X is a
452
+ // file with nothing under it.
453
+ if (resolution.kind !== "unchanged")
454
+ candidates.push(`${canonicalPrefix}/*`);
455
+ }
456
+ continue;
457
+ }
396
458
  if (res.status === 401) {
397
459
  console.error(chalk.red("Not authenticated — please run `hq login`"));
398
460
  }
399
461
  else if (res.status === 403) {
400
462
  console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
401
- await offerAccessLadder(canonicalPrefix, {
463
+ await offerAccessLadder(candidate, {
402
464
  company: files.opts().company,
403
465
  });
404
466
  }
405
- else if (res.status === 404) {
406
- // A 404 means ZERO rows were removed — never report a green success
407
- // that implies a revoke happened. Distinguish the two causes:
408
- // ACL_NOT_FOUND — the prefix has no ACL at all (nothing was ever
409
- // shared here): genuinely, benignly absent.
410
- // GRANT_NOT_FOUND (or an older server with no code) — the ACL
411
- // exists but no grant matched this principal:
412
- // surface it as a warning so a typo'd/stale
413
- // principal isn't mistaken for a real removal.
414
- if (body.code === "ACL_NOT_FOUND") {
415
- console.log(chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no sharing grants.`));
416
- }
417
- else {
418
- console.warn(chalk.yellow(`No matching grant for ${principalLabel} on '${canonicalPrefix}' — nothing was removed.`));
419
- }
420
- return;
421
- }
422
467
  else if (res.status >= 500) {
423
468
  console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
424
469
  }
@@ -427,7 +472,18 @@ export function registerFilesCommand(program) {
427
472
  }
428
473
  process.exit(1);
429
474
  }
430
- console.log(chalk.green(`Removed grant for ${principalLabel} on '${displayAclPattern(canonicalPrefix)}'`));
475
+ if (tried.length === 1) {
476
+ if (aclMissingEverywhere) {
477
+ console.log(chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no sharing grants.`));
478
+ }
479
+ else {
480
+ console.warn(chalk.yellow(`No matching grant for ${principalLabel} on '${canonicalPrefix}' — nothing was removed.`));
481
+ }
482
+ return;
483
+ }
484
+ console.warn(chalk.yellow(`No matching grant for ${principalLabel} on ${tried.map((row) => `'${displayAclPattern(row)}'`).join(" or ")} ` +
485
+ `— nothing was removed. Check the grant with hq files acl, then re-run with --exact for the exact ` +
486
+ `'${canonicalPrefix}' row or pass '${canonicalPrefix}/*' (quoted) for the folder glob.`));
431
487
  }
432
488
  catch (err) {
433
489
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -811,14 +867,130 @@ async function resolveMentionPersonUid(mention, companySlug) {
811
867
  console.error(chalk.red(`Could not resolve --mention '${trimmed}' to a person.`));
812
868
  process.exit(1);
813
869
  }
870
+ /**
871
+ * True for a prefix that might name a folder without saying so: no trailing
872
+ * `/` (private folder), no `/*` (shared folder glob), and not the `*`
873
+ * wildcard, and a last segment with no extension (a name with an extension is
874
+ * taken as the file it names). The vault ACL treats such a path as an exact
875
+ * object key, so
876
+ * `hq files share knowledge --with x` used to grant an object named
877
+ * "knowledge" and nothing under `knowledge/` (DEV-5173: members were blocked
878
+ * under a folder their admin had "shared"). The name alone does not decide
879
+ * whether it is a folder (`Makefile`, `bin/hq`); resolveBareSharePrefix asks
880
+ * the vault.
881
+ */
882
+ export function looksLikeBareFolderPrefix(prefix) {
883
+ if (prefix === "*" || prefix.endsWith("/") || prefix.endsWith("/*"))
884
+ return false;
885
+ const last = prefix.slice(prefix.lastIndexOf("/") + 1);
886
+ return last.length > 0 && !last.includes(".");
887
+ }
888
+ /**
889
+ * Refuse a bare prefix that would become a malformed grant if it were
890
+ * rewritten to a folder glob: a `*` inside a segment (`**`, `know*`) or an
891
+ * empty segment (`/knowledge`, `a//b`). Returns the error text, or null.
892
+ */
893
+ export function malformedBareSharePrefix(prefix) {
894
+ if (!looksLikeBareFolderPrefix(prefix))
895
+ return null;
896
+ if (prefix.includes("*")) {
897
+ return (`'${prefix}' has a '*' inside a path segment. Only a whole trailing '/*' segment ` +
898
+ `(for example 'knowledge/*') or '*' alone (--full) is a glob.`);
899
+ }
900
+ if (prefix.split("/").some((segment) => segment.length === 0)) {
901
+ return `'${prefix}' has an empty path segment. Pass a company-relative path such as 'knowledge'.`;
902
+ }
903
+ return null;
904
+ }
905
+ /**
906
+ * Decide what a bare prefix means by looking it up in the vault. Rewrite `X`
907
+ * to `X/*` only when `X/` has children and no object `X` exists. Keep `X` when
908
+ * only the object exists. Refuse when both exist, when neither does, or when
909
+ * the lookup fails; the caller exits non-zero and points to --exact or `X/*`.
910
+ */
911
+ export async function resolveBareSharePrefix(params) {
912
+ const { prefix } = params;
913
+ if (!looksLikeBareFolderPrefix(prefix))
914
+ return { kind: "unchanged", prefix };
915
+ const malformed = malformedBareSharePrefix(prefix);
916
+ if (malformed)
917
+ return { kind: "refused", prefix, message: malformed };
918
+ const folderGlob = `${prefix}/*`;
919
+ const choose = `Re-run with --exact to use exactly the object '${prefix}', or pass '${folderGlob}' ` +
920
+ `(quoted) to cover everything under the folder.`;
921
+ const list = async (listPrefix) => {
922
+ const res = await vaultApiFetch({
923
+ token: params.token,
924
+ path: "/v1/files/list",
925
+ query: { company: params.companyUid, prefix: listPrefix },
926
+ });
927
+ if (!res.ok)
928
+ throw new Error(`files list failed (${res.status})`);
929
+ const body = (await res.json());
930
+ return Array.isArray(body.objects) ? body.objects : [];
931
+ };
932
+ let objectExists;
933
+ let folderHasChildren;
934
+ try {
935
+ // The object `X` sorts first among keys that start with `X`, so the first
936
+ // page holds it when it exists. Children are listed separately so sibling
937
+ // keys such as `X-old.md` cannot push them off the page.
938
+ objectExists = (await list(prefix)).some((object) => object.key === prefix);
939
+ folderHasChildren = (await list(`${prefix}/`)).length > 0;
940
+ }
941
+ catch (error) {
942
+ const reason = error instanceof Error ? error.message : String(error);
943
+ return {
944
+ kind: "refused",
945
+ prefix,
946
+ message: `Could not check whether '${prefix}' is a file or a folder (${reason}). ${choose}`,
947
+ };
948
+ }
949
+ if (folderHasChildren && !objectExists)
950
+ return { kind: "folder", prefix, folderGlob };
951
+ if (objectExists && !folderHasChildren)
952
+ return { kind: "unchanged", prefix };
953
+ return {
954
+ kind: "refused",
955
+ prefix,
956
+ message: objectExists
957
+ ? `'${prefix}' is both a file and a folder in the vault. ${choose}`
958
+ : `Nothing exists at '${prefix}' or under '${prefix}/' in the vault, so it is unclear ` +
959
+ `whether it is a file or a folder. ${choose}`,
960
+ };
961
+ }
962
+ /** Resolve a bare prefix for a mutating command: print the note, or exit 1 on refusal. */
963
+ async function resolveBareSharePrefixOrExit(params) {
964
+ if (params.exact)
965
+ return params.prefix;
966
+ const resolution = await resolveBareSharePrefix(params);
967
+ if (resolution.kind === "refused") {
968
+ console.error(chalk.red(resolution.message));
969
+ process.exit(1);
970
+ }
971
+ if (resolution.kind === "folder") {
972
+ console.error(chalk.yellow(`Note: '${resolution.prefix}' is a folder in the vault, so this uses the folder glob ` +
973
+ `'${resolution.folderGlob}' (everything under it). Pass --exact to use the exact ` +
974
+ `object key '${resolution.prefix}' instead.`));
975
+ return resolution.folderGlob;
976
+ }
977
+ return resolution.prefix;
978
+ }
814
979
  async function runDirectGrant(params) {
815
- const canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
980
+ let canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
816
981
  mutating: true,
817
982
  rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
818
983
  ...(params.companySlug !== undefined
819
984
  ? { companySlug: params.companySlug }
820
985
  : {}),
821
986
  });
987
+ if (!params.exact) {
988
+ const malformed = malformedBareSharePrefix(canonicalPrefix);
989
+ if (malformed) {
990
+ console.error(chalk.red(malformed));
991
+ process.exit(1);
992
+ }
993
+ }
822
994
  if (!params.permission) {
823
995
  console.error(chalk.red("--permission is required when --with is set (read | write)"));
824
996
  process.exit(1);
@@ -864,6 +1036,14 @@ async function runDirectGrant(params) {
864
1036
  const principalLabel = isAll ? "everyone in the company" : granteeId;
865
1037
  const token = (await resolveVaultCredential()).token;
866
1038
  const companyUid = await getCompanyUid(token, params.companySlug);
1039
+ if (!params.fullVault) {
1040
+ canonicalPrefix = await resolveBareSharePrefixOrExit({
1041
+ token,
1042
+ companyUid,
1043
+ prefix: canonicalPrefix,
1044
+ exact: params.exact,
1045
+ });
1046
+ }
867
1047
  let res = await vaultApiFetch({
868
1048
  token,
869
1049
  path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
@@ -966,13 +1146,20 @@ async function runDirectGrant(params) {
966
1146
  async function runShareSession(params) {
967
1147
  // Validate every path before minting a session; an anchored path must never
968
1148
  // silently become a share of the corresponding company-relative objects.
969
- const normalizedPaths = params.paths.map((prefix) => normalizeCompanyFilePrefix(prefix, {
1149
+ const canonicalPaths = params.paths.map((prefix) => normalizeCompanyFilePrefix(prefix, {
970
1150
  mutating: true,
971
1151
  rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
972
1152
  ...(params.companySlug !== undefined
973
1153
  ? { companySlug: params.companySlug }
974
1154
  : {}),
975
1155
  }));
1156
+ for (const prefix of canonicalPaths) {
1157
+ const malformed = malformedBareSharePrefix(prefix);
1158
+ if (malformed) {
1159
+ console.error(chalk.red(malformed));
1160
+ process.exit(1);
1161
+ }
1162
+ }
976
1163
  let expiresInMs;
977
1164
  if (params.expires !== undefined) {
978
1165
  const parsed = parseDuration(params.expires);
@@ -988,6 +1175,12 @@ async function runShareSession(params) {
988
1175
  }
989
1176
  const token = (await resolveVaultCredential()).token;
990
1177
  const companyUid = await getCompanyUid(token, params.companySlug);
1178
+ // A bare folder name in a share session would create the same exact-key row
1179
+ // that blocked members in DEV-5173, so resolve each path like a direct grant.
1180
+ const normalizedPaths = [];
1181
+ for (const prefix of canonicalPaths) {
1182
+ normalizedPaths.push(await resolveBareSharePrefixOrExit({ token, companyUid, prefix }));
1183
+ }
991
1184
  let session;
992
1185
  try {
993
1186
  session = await mintShareSession({
@@ -2070,11 +2070,16 @@ async function runContextOrganize(opts) {
2070
2070
  !opts.untracked);
2071
2071
  if (isList) {
2072
2072
  let fetchCandidates;
2073
+ let resolveCompanyUid;
2073
2074
  let offline = Boolean(opts.offline);
2074
2075
  if (!offline) {
2075
2076
  try {
2076
2077
  const token = await requireToken();
2077
2078
  fetchCandidates = createCandidatesFetcher({ token });
2079
+ resolveCompanyUid = async (slug) => {
2080
+ const membership = await resolveActiveMembershipCompany(token, slug, AbortSignal.timeout(ONLINE_MEMBERSHIP_VALIDATION_TIMEOUT_MS));
2081
+ return membership?.companyUid ?? null;
2082
+ };
2078
2083
  }
2079
2084
  catch {
2080
2085
  offline = true;
@@ -2084,6 +2089,7 @@ async function runContextOrganize(opts) {
2084
2089
  sessionId,
2085
2090
  root,
2086
2091
  fetchCandidates,
2092
+ resolveCompanyUid,
2087
2093
  offline,
2088
2094
  });
2089
2095
  if (opts.json || opts.machine) {
@@ -103,7 +103,23 @@ export interface RunSyncManifestOptions {
103
103
  * of forcing a pass on every tick.
104
104
  */
105
105
  respectThrottle?: boolean;
106
+ /**
107
+ * Print one JSON line ({@link manifestOutcomeLine}) instead of the human
108
+ * summary. `hq doctor --fix` reads it, because the exit code is 0 for
109
+ * `soft_skipped`, `throttled`, `disabled`, `locked` and
110
+ * `resend_full_scheduled` too, and none of those uploaded anything.
111
+ */
112
+ json?: boolean;
113
+ }
114
+ /** The machine-readable outcome `--json` prints. Fixed tokens only, no paths. */
115
+ export interface ManifestOutcomeLine {
116
+ status: string;
117
+ mode?: string;
118
+ errorKind?: string;
119
+ errorStatus?: number;
106
120
  }
121
+ /** Build the `--json` line for one pass. The free-text error detail is left out. */
122
+ export declare function manifestOutcomeLine(result: RunManifestUploadPassResult): ManifestOutcomeLine;
107
123
  export interface RunSyncManifestOutcome {
108
124
  result: RunManifestUploadPassResult;
109
125
  scope: ResolvedManifestScope;
@@ -64,6 +64,15 @@ export const MANIFEST_UPLOAD_PATH = "/v1/sync-manifest/upload";
64
64
  export { MANIFEST_DISABLED_ENV, manifestUploadsDisabled };
65
65
  /** The literal `--scope` value that selects the personal (non-company) tree. */
66
66
  export const PERSONAL_SCOPE = "personal";
67
+ /** Build the `--json` line for one pass. The free-text error detail is left out. */
68
+ export function manifestOutcomeLine(result) {
69
+ return {
70
+ status: result.status,
71
+ ...(result.mode ? { mode: result.mode } : {}),
72
+ ...(result.error?.kind ? { errorKind: result.error.kind } : {}),
73
+ ...(typeof result.error?.status === "number" ? { errorStatus: result.error.status } : {}),
74
+ };
75
+ }
67
76
  // ── Pure helpers ────────────────────────────────────────────────────────────
68
77
  /**
69
78
  * Resolve `--scope` into the wire scope.
@@ -301,7 +310,9 @@ export async function runSyncManifest(options, deps) {
301
310
  status: "disabled",
302
311
  scopeKey: "unknown",
303
312
  };
304
- deps.log(`${chalk.dim("·")} Manifest upload disabled by ${MANIFEST_DISABLED_ENV} — nothing to do.`);
313
+ deps.log(options.json === true
314
+ ? JSON.stringify(manifestOutcomeLine(result))
315
+ : `${chalk.dim("·")} Manifest upload disabled by ${MANIFEST_DISABLED_ENV} — nothing to do.`);
305
316
  return { result, scope: { kind: "personal" }, exitCode: 0 };
306
317
  }
307
318
  const scope = await resolveManifestScope(options.scopeArg, options.hqRoot, deps.resolveCompanyUid);
@@ -326,6 +337,9 @@ export async function runSyncManifest(options, deps) {
326
337
  if (print) {
327
338
  deps.log(JSON.stringify(result.chunks ?? [], null, 2));
328
339
  }
340
+ else if (options.json === true) {
341
+ deps.log(JSON.stringify(manifestOutcomeLine(result)));
342
+ }
329
343
  else {
330
344
  deps.log(formatManifestSummary(result, scope));
331
345
  }
@@ -342,6 +356,7 @@ export function registerSyncManifestCommand(syncCmd) {
342
356
  .option("--scope <scope>", `'${PERSONAL_SCOPE}' or a company slug (defaults to the active company, else ${PERSONAL_SCOPE})`)
343
357
  .option("--full", "Send a fresh full baseline instead of a delta")
344
358
  .option("--print", "Build and print the chunks as JSON; upload nothing")
359
+ .option("--json", "Print the pass outcome as one JSON line instead of a summary")
345
360
  .option("--respect-throttle", "Honour the 24h per-scope upload throttle instead of forcing a pass (for scheduled callers)")
346
361
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
347
362
  .action(async (options) => {
@@ -360,6 +375,7 @@ export function registerSyncManifestCommand(syncCmd) {
360
375
  full: options.full,
361
376
  print: options.print,
362
377
  respectThrottle: options.respectThrottle,
378
+ json: options.json,
363
379
  }, createDefaultDeps());
364
380
  if (outcome.exitCode !== 0)
365
381
  process.exit(outcome.exitCode);
@@ -0,0 +1,62 @@
1
+ import { type Decision, type DecisionOption } from "../decisions/client.js";
2
+ export interface DecisionDmClient {
3
+ list(open?: boolean): Promise<Decision[]>;
4
+ answer(id: string, choiceId: string, source: "thread"): Promise<Decision>;
5
+ }
6
+ export interface DecisionDmInput {
7
+ text: string;
8
+ speakerUid: string | undefined;
9
+ rootEventId?: string;
10
+ ownerUid: string;
11
+ agentUid: string;
12
+ /** The owner's company uids. Null when they could not be read: refuse. */
13
+ companyUids: readonly string[] | null;
14
+ nowMs: number;
15
+ /** Decision ids already restated after 24 hours. */
16
+ restated: ReadonlySet<string>;
17
+ }
18
+ export type DecisionDmResult = {
19
+ kind: "none";
20
+ notLive?: true;
21
+ } | {
22
+ kind: "reply";
23
+ text: string;
24
+ decisionId?: string;
25
+ recorded: boolean;
26
+ notLive?: true;
27
+ } | {
28
+ kind: "model";
29
+ note: string;
30
+ decisionId: string;
31
+ restate?: string;
32
+ };
33
+ export declare const DECISIONS_NOT_LIVE_REPLY = "Decisions are not live on this HQ server yet (the decisions API returned 404), so I did not record that pick.";
34
+ export declare const RESTATE_AFTER_MS: number;
35
+ /** Same key hq-pro uses for the owner-bot DM pair. */
36
+ export declare function decisionTalkThreadId(ownerUid: string, botUid: string): string;
37
+ export declare function looksLikePick(text: string): boolean;
38
+ export declare function matchDecisionPick(text: string, options: readonly DecisionOption[], opts?: {
39
+ inCardThread?: boolean;
40
+ }): {
41
+ kind: "pick";
42
+ option: DecisionOption;
43
+ } | {
44
+ kind: "ambiguous";
45
+ options: DecisionOption[];
46
+ } | {
47
+ kind: "none";
48
+ };
49
+ /** Full step, for the one restate after 24 hours. Never says "waiting on". */
50
+ export declare function restateDecision(decision: Decision): string;
51
+ export declare function decisionContextNote(decision: Decision): string;
52
+ export declare function handleDecisionDm(input: DecisionDmInput, client: DecisionDmClient): Promise<DecisionDmResult>;
53
+ export interface DecisionTalkState {
54
+ v: 1;
55
+ restated: Record<string, string>;
56
+ /** Replies decided but not yet delivered, by inbox message id. Resent on retry so a saved pick is never re-run. */
57
+ pending?: Record<string, string>;
58
+ }
59
+ export declare function decisionTalkStatePath(dir: string): string;
60
+ export declare function readDecisionTalkState(dir: string): DecisionTalkState;
61
+ export declare function writeDecisionTalkState(dir: string, state: DecisionTalkState): void;
62
+ //# sourceMappingURL=decisions.d.ts.map