@indigoai-us/hq-cli 5.345.188 → 5.345.190

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 (38) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/dist/command-catalog.generated.d.ts +63 -0
  3. package/dist/command-catalog.generated.js +78 -0
  4. package/dist/commands/bot.js +15 -3
  5. package/dist/commands/cloud.js +16 -2
  6. package/dist/commands/core-company-index-scope-flag.d.ts +2 -1
  7. package/dist/commands/core-company-index-scope-flag.js +2 -1
  8. package/dist/commands/core.js +0 -5
  9. package/dist/commands/dm-attach.d.ts +73 -0
  10. package/dist/commands/dm-attach.js +104 -0
  11. package/dist/commands/dm.d.ts +10 -0
  12. package/dist/commands/dm.js +114 -2
  13. package/dist/commands/files.d.ts +18 -0
  14. package/dist/commands/files.js +73 -0
  15. package/dist/commands/sources.js +15 -0
  16. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +45054 -36140
  17. package/dist/hq-cloud-sync-runner/package.json +1 -1
  18. package/dist/lib/bot/fleet-local.d.ts +43 -1
  19. package/dist/lib/bot/fleet-local.js +204 -41
  20. package/dist/lib/bot/fleet-python.d.ts +6 -0
  21. package/dist/lib/bot/fleet-python.js +24 -5
  22. package/dist/lib/bot/probe.d.ts +7 -1
  23. package/dist/lib/bot/probe.js +11 -3
  24. package/dist/lib/bot/run.d.ts +9 -0
  25. package/dist/lib/bot/run.js +114 -8
  26. package/dist/lib/bot/runtime-failure.d.ts +3 -0
  27. package/dist/lib/bot/runtime-failure.js +20 -2
  28. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  29. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  30. package/dist/lib/index-render/session-scope.d.ts +3 -3
  31. package/dist/lib/index-render/session-scope.js +11 -9
  32. package/dist/lib/index-render/shared.d.ts +6 -4
  33. package/dist/lib/index-render/shared.js +33 -4
  34. package/dist/utils/cognito-session.d.ts +4 -2
  35. package/dist/utils/cognito-session.js +27 -42
  36. package/dist/utils/hq-root-resolver.d.ts +27 -1
  37. package/dist/utils/hq-root-resolver.js +65 -33
  38. package/package.json +14 -14
package/CHANGELOG.md CHANGED
@@ -2,6 +2,137 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.190] — 2026-11-01
6
+ - `hq files purge <path> --version-id <id>` permanently deletes one old version of a vault file, after a confirmation prompt.
7
+ - `hq bot create` / `hq bot start` on Windows: the shared HQ Fleet Runtime now
8
+ installs under `%USERPROFILE%\.hq-fleet\runtimes\<16 hex>\{v,s\r,s\d}` instead
9
+ of the 64-character identity plus `sources\runtime`, so the runtime's deepest
10
+ files stay under the 260-character Windows path limit when long paths are
11
+ off. Before, extraction stopped partway with "HQ Fleet Local runtime
12
+ extraction failed." Runtimes already installed under the old layout keep
13
+ working, and a half-extracted old one is removed. macOS and Linux paths are
14
+ unchanged.
15
+ - HQ Fleet Local install steps now include the last lines of the failing
16
+ command's stderr in their error, and on Windows the CLI checks the runtime
17
+ archive's longest path before extracting and names the path limit and
18
+ `LongPathsEnabled` setting when it would not fit.
19
+ - The CLI now warns on stderr when the HQ Fleet Local release manifest's
20
+ `compatibility.minimum_hq_cli_version` is newer than the running CLI. It does
21
+ not block the install.
22
+ - `hq-hook`: the `block-credential-file-read` port follows the fixed bash guard
23
+ (hq-core-staging #1753, sha256 7c6a8cfa) and its pin moves to that version.
24
+ Zero-padded brace ranges are read in base 10 and long or stepped ranges
25
+ block; letter ranges expand exactly; the mktemp scan replacement is literal
26
+ (no `patsub_replacement`) with capped loops; `function name { ... }` bodies
27
+ are analysed; extglob groups are an unknown word part; and `ls`, `stat` and
28
+ the other metadata commands are not trusted after an alias, hash, enable,
29
+ PATH change, a write into a bin directory, or when named by a path outside
30
+ /bin, /usr/bin, /sbin and /usr/sbin. The port also picks up the guard's
31
+ cygpath and `upstream-pin.env` changes. The vendored fixture and corpus are
32
+ refreshed. The port flag stays off.
33
+ - `hq sync now`: when the push leg holds files after an account sign-in change
34
+ on this computer, the push line now shows the held count, prints how to
35
+ release them (`hq sync held`, `hq sync release-held`), skips the "Sync now
36
+ complete" line and exits 1. Before, it printed "0 uploaded, 0 skipped" and a
37
+ success line (feedback_2356e54f).
38
+
39
+ ## [5.345.189] — 2026-11-01
40
+ - A local bot that cannot reach HQ when it starts now retries the identity check up to five times
41
+ (waits of 5 s, 15 s, 45 s and 2 min) on a network error (connection refused, reset, DNS failure,
42
+ timeout), 408, 429 or 5xx. Before, one network error at startup failed the bot with exit 0, which
43
+ launchd never restarts, so bots stayed down until someone restarted them. After the last try the bot
44
+ stops in the failed state with a plain reason that says to check the network and run
45
+ `hq bot start <name>`. Other 4xx answers, sign-in errors and internal errors fail at once, as before.
46
+ A stop during a retry wait ends the bot cleanly as stopped.
47
+ - Hosted agent Docker image now runs on Node 26 (was Node 22).
48
+ - Updated 16 bundled libraries to their latest minor and patch releases. The local search engine stays on its current version so installing HQ still works on machines without build tools.
49
+ - On Windows, the CLI's last-resort HQ folder is now `C:\hq` (`%SystemDrive%\hq`) instead of
50
+ `%USERPROFILE%\hq`. `$HQ_ROOT`, the HQ folder you are inside, and the location recorded in
51
+ `~/.hq/menubar.json` or `~/.hq/config.json` still win. Discovery checks the home folders first
52
+ (`C:\Users\<you>\hq` and the usual variants), then `C:\hq`, then `%LOCALAPPDATA%\hq`, so an
53
+ existing home install keeps working and is preferred over a stray `C:\hq`.
54
+ macOS and Linux are unchanged.
55
+ - `hq bot create` no longer hangs before it reaches the server. The Fleet
56
+ Runtime setup steps (`gh auth token`, `tar`, `uv python install`,
57
+ `uv python find`) now time out and report "timed out after Ns".
58
+ - Under `--json`, any create failure prints a `{ ok: false, reason:
59
+ "create-failed" }` document on stdout, so HQ Desktop can show it.
60
+ - `hq bot probe` and bot failure messages now recognize a coding tool's plan
61
+ usage limit (for example Claude Code's "You've hit your weekly limit · resets
62
+ 9pm"). They used to call it `unknown`. It is now classed `transient`, the
63
+ probe's JSON gains `usageLimit: true` and `resetsAt` when the tool gives a
64
+ reset time, and the detail says the plan hit its limit and when it resets.
65
+ - `hq dm attachment <eventId|vaultPath>` downloads a file attached to a DM.
66
+ - The recipient can now open a file held in the sender's vault.
67
+ - The sender can open its own attachment.
68
+ - Several files with the same name on one message are saved as
69
+ name.ext, name-1.ext, and so on.
70
+ - Every destination is checked before any file is written.
71
+ - A company's `INDEX.md` now shows a project's own recorded status instead of listing every unfinished project as active. `hq core rebuild-index companies` reads the project's `state.json` status first, then `prd.json` `status` or `metadata.status`, and only then falls back to the story-pass check.
72
+ - `hq core rebuild-index companies` in a company-bound session now rebuilds only that session's companies, even when the company-scope kill switch is off. Before, it could rewrite every company's INDEX.md. `--company <slug>` works again for a company inside the bound set.
73
+ - `hq sources schedule --at HH:MM` now turns the daily hot-channel sync on when a company doesn't have it yet. It registers the sync on your personal setup bot and sets the time. Before this change, the command failed with "No hot-channel sync job", so the desktop's Change time couldn't start the sync.
74
+ - `hq-hook dispatch <EVENT>` runs a hook event the way master-hook.sh does,
75
+ in one process (US-007).
76
+ - Registry hooks come from the compiled index, through the hook-gate
77
+ profiles, `HQ_DISABLED_HOOKS` and the prefilters. Folder hooks run after
78
+ them in master-hook's order.
79
+ - A fail-closed PreToolUse guard that is missing, crashes or times out
80
+ blocks the event and names the guard. Every hook failure is recorded in
81
+ `~/.hq/logs/hook-engine.jsonl`.
82
+ - Built-in hook ports run in-process under the bridge flags; a port that
83
+ panics makes the bash hook run.
84
+ - Events the engine cannot reproduce exactly (session start, per-agent
85
+ scope, the timeout sentry, HQ Anywhere routing) run through
86
+ master-hook.sh with the same input.
87
+ - Nothing is registered to use it yet; exec-form registration (US-015) and
88
+ the cutover (US-017) come later.
89
+ - `hq-hook` gains a compiled hook index (US-006): one memory-mapped file per
90
+ HQ root and policy-directory list with the hook registry rows, the
91
+ `policy_vocab` prefilter vocabulary and the policy injector's parsed records.
92
+ - It rebuilds only when a source file's metadata changes. A symlink is
93
+ tracked by the file it points to, so re-linking the personal overlay does
94
+ not rebuild it.
95
+ - Each company's index holds only that company's policies.
96
+ - `hq-hook index records|vocab|registry` print the same bytes as the shell
97
+ code they replace, which the new parity test checks.
98
+ - `hq-hook index probe|bench` measure the per-event cost for the
99
+ go/no-go report. Nothing in the hook path uses the index yet; the
100
+ dispatcher (US-007) will.
101
+ - `hq-hook run block-credential-file-read` is now a full native port of the
102
+ bash guard and its arithmetic library, under flag
103
+ `hook-engine-port-block-credential-file-read`, which stays off.
104
+ - It answers every call itself and never exits 75 (hand back to bash).
105
+ - Exit code, stdout and stderr match the guard byte for byte on the guard's
106
+ corpus (about 1,500 payloads), 330 recorded real calls and about 6,800
107
+ fuzz payloads.
108
+ - The bridge now also pins `bash-arith-subst-guard-lib.sh`. A changed or
109
+ missing library sends the call to bash, as a changed guard does.
110
+ - `hq-hook run block-credential-file-read` is the second hook port
111
+ master-hook.sh can bridge (US-022, flag
112
+ `hook-engine-port-block-credential-file-read`, default off).
113
+ - It answers natively the Read and Bash calls that the guard's own fast path
114
+ allows and that name no credential file, pass through no symlink and use
115
+ no substitution.
116
+ - Every other call exits 75 and the bash guard decides, keeping its
117
+ fail-closed behavior.
118
+ - The port never blocks.
119
+ - It is pinned to the guard version it was checked against.
120
+ - The guard's 400-payload corpus shows zero verdict differences with the
121
+ port in front.
122
+ - `hq-hook`: the `block-credential-file-read` port accepts the guard script on
123
+ hq-core-staging main (9be16758, #1727). The guard's fast path gained the
124
+ arithmetic guard, the split-name rule, the `git show <ref>:<path>` rule and
125
+ an xargs/parallel prefilter term since the port was written; the port now
126
+ declines every command those can refuse (`$`, `((`, declaration keywords,
127
+ the new prefilter term) and keeps answering only what bash's fast path
128
+ allows. The vendored fixture and corpus are refreshed to that version.
129
+ - `hq-hook`: the `policy-enforcement-gate` port accepts the gate script from
130
+ hq-core-staging #1717 (read-only `hq dm` subcommands skipped by the outbound
131
+ scan). Without this pin the bridge logged `script-mismatch` on every current
132
+ HQ and ran bash alone. The port's allow set is unchanged: it already leaves
133
+ every command naming `hq` and `dm` to bash. The vendored fixture is refreshed
134
+ to that version and the pin test covers it.
135
+
5
136
  ## [5.345.188] — 2026-10-30
6
137
  - `hq agent inbox` and the `hq_inbox_read` MCP tool now bound the server read (token mint plus GET) at 30 s
7
138
  (`HQ_AGENT_INBOX_TIMEOUT_MS`, floor 1000) and retry it once after a short delay, honouring the server's
@@ -3243,6 +3243,35 @@ export declare const COMMAND_CATALOG: readonly [{
3243
3243
  readonly hidden: false;
3244
3244
  }];
3245
3245
  readonly subcommands: readonly [];
3246
+ }, {
3247
+ readonly name: "purge";
3248
+ readonly description: "Permanently delete ONE retained (noncurrent) version of an exact vault key, for example a version that held data which must not live in HQ. Owner only; audited; cannot be undone. Delete the file first if the version is current. Use --personal for your personal vault.";
3249
+ readonly aliases: readonly [];
3250
+ readonly hidden: false;
3251
+ readonly helpOption: true;
3252
+ readonly helpCommand: false;
3253
+ readonly usage: "[options] <path>";
3254
+ readonly helpText: readonly [];
3255
+ readonly arguments: readonly [{
3256
+ readonly name: "path";
3257
+ readonly required: true;
3258
+ readonly variadic: false;
3259
+ readonly description: "";
3260
+ }];
3261
+ readonly options: readonly [{
3262
+ readonly flags: "--personal";
3263
+ readonly description: "Target your own personal vault instead of a company vault (mutually exclusive with --company)";
3264
+ readonly hidden: false;
3265
+ }, {
3266
+ readonly flags: "--version-id <id>";
3267
+ readonly description: "Exact version ID to purge (see `hq files versions`)";
3268
+ readonly hidden: false;
3269
+ }, {
3270
+ readonly flags: "-y, --yes";
3271
+ readonly description: "Skip the confirmation prompt (for scripts)";
3272
+ readonly hidden: false;
3273
+ }];
3274
+ readonly subcommands: readonly [];
3246
3275
  }, {
3247
3276
  readonly name: "trash";
3248
3277
  readonly description: "List deleted vault keys retained as tombstones. Use --personal for your personal vault.";
@@ -4175,6 +4204,40 @@ export declare const COMMAND_CATALOG: readonly [{
4175
4204
  readonly hidden: false;
4176
4205
  }];
4177
4206
  readonly subcommands: readonly [];
4207
+ }, {
4208
+ readonly name: "attachment";
4209
+ readonly description: "Download a file attached to a DM. REF is the message eventId from `hq dm inbox` (downloads every file on it) or the attachment's vault path (chat/attachments/...). The file is read from the sender's vault; only the two people in the conversation can open a DM attachment.";
4210
+ readonly aliases: readonly [];
4211
+ readonly hidden: false;
4212
+ readonly helpOption: true;
4213
+ readonly helpCommand: false;
4214
+ readonly usage: "[options] <ref>";
4215
+ readonly helpText: readonly [];
4216
+ readonly arguments: readonly [{
4217
+ readonly name: "ref";
4218
+ readonly required: true;
4219
+ readonly variadic: false;
4220
+ readonly description: "";
4221
+ }];
4222
+ readonly options: readonly [{
4223
+ readonly flags: "--out <path>";
4224
+ readonly description: "Where to write: a file path for one attachment, else a directory (default: current directory)";
4225
+ readonly hidden: false;
4226
+ }, {
4227
+ readonly flags: "--company <uid>";
4228
+ readonly description: "Vault scope the file lives in (the companyUid shown on the message). Derived from the DM pair when omitted";
4229
+ readonly hidden: false;
4230
+ }, {
4231
+ readonly flags: "--force";
4232
+ readonly description: "Overwrite an existing file";
4233
+ readonly hidden: false;
4234
+ }, {
4235
+ readonly flags: "--limit <n>";
4236
+ readonly description: "How many recent inbox messages to search for the eventId";
4237
+ readonly hidden: false;
4238
+ readonly defaultValue: "100";
4239
+ }];
4240
+ readonly subcommands: readonly [];
4178
4241
  }, {
4179
4242
  readonly name: "thread";
4180
4243
  readonly description: "Show your two-way conversation with a person (email, personUid, or agentUid). Reads oldest-first and marks their messages read unless --no-ack.";
@@ -3912,6 +3912,42 @@ export const COMMAND_CATALOG = [
3912
3912
  ],
3913
3913
  "subcommands": []
3914
3914
  },
3915
+ {
3916
+ "name": "purge",
3917
+ "description": "Permanently delete ONE retained (noncurrent) version of an exact vault key, for example a version that held data which must not live in HQ. Owner only; audited; cannot be undone. Delete the file first if the version is current. Use --personal for your personal vault.",
3918
+ "aliases": [],
3919
+ "hidden": false,
3920
+ "helpOption": true,
3921
+ "helpCommand": false,
3922
+ "usage": "[options] <path>",
3923
+ "helpText": [],
3924
+ "arguments": [
3925
+ {
3926
+ "name": "path",
3927
+ "required": true,
3928
+ "variadic": false,
3929
+ "description": ""
3930
+ }
3931
+ ],
3932
+ "options": [
3933
+ {
3934
+ "flags": "--personal",
3935
+ "description": "Target your own personal vault instead of a company vault (mutually exclusive with --company)",
3936
+ "hidden": false
3937
+ },
3938
+ {
3939
+ "flags": "--version-id <id>",
3940
+ "description": "Exact version ID to purge (see `hq files versions`)",
3941
+ "hidden": false
3942
+ },
3943
+ {
3944
+ "flags": "-y, --yes",
3945
+ "description": "Skip the confirmation prompt (for scripts)",
3946
+ "hidden": false
3947
+ }
3948
+ ],
3949
+ "subcommands": []
3950
+ },
3915
3951
  {
3916
3952
  "name": "trash",
3917
3953
  "description": "List deleted vault keys retained as tombstones. Use --personal for your personal vault.",
@@ -5059,6 +5095,48 @@ export const COMMAND_CATALOG = [
5059
5095
  ],
5060
5096
  "subcommands": []
5061
5097
  },
5098
+ {
5099
+ "name": "attachment",
5100
+ "description": "Download a file attached to a DM. REF is the message eventId from `hq dm inbox` (downloads every file on it) or the attachment's vault path (chat/attachments/...). The file is read from the sender's vault; only the two people in the conversation can open a DM attachment.",
5101
+ "aliases": [],
5102
+ "hidden": false,
5103
+ "helpOption": true,
5104
+ "helpCommand": false,
5105
+ "usage": "[options] <ref>",
5106
+ "helpText": [],
5107
+ "arguments": [
5108
+ {
5109
+ "name": "ref",
5110
+ "required": true,
5111
+ "variadic": false,
5112
+ "description": ""
5113
+ }
5114
+ ],
5115
+ "options": [
5116
+ {
5117
+ "flags": "--out <path>",
5118
+ "description": "Where to write: a file path for one attachment, else a directory (default: current directory)",
5119
+ "hidden": false
5120
+ },
5121
+ {
5122
+ "flags": "--company <uid>",
5123
+ "description": "Vault scope the file lives in (the companyUid shown on the message). Derived from the DM pair when omitted",
5124
+ "hidden": false
5125
+ },
5126
+ {
5127
+ "flags": "--force",
5128
+ "description": "Overwrite an existing file",
5129
+ "hidden": false
5130
+ },
5131
+ {
5132
+ "flags": "--limit <n>",
5133
+ "description": "How many recent inbox messages to search for the eventId",
5134
+ "hidden": false,
5135
+ "defaultValue": "100"
5136
+ }
5137
+ ],
5138
+ "subcommands": []
5139
+ },
5062
5140
  {
5063
5141
  "name": "thread",
5064
5142
  "description": "Show your two-way conversation with a person (email, personUid, or agentUid). Reads oldest-first and marks their messages read unless --no-ack.",
@@ -665,8 +665,20 @@ async function promptFleetProvider() {
665
665
  * its half-written folder.
666
666
  */
667
667
  export async function runBotCreate(nameArg, opts) {
668
- const name = validateBotName(nameArg);
669
- await withBotLock(name, () => runBotCreateLocked(name, opts), { command: "hq bot create" });
668
+ try {
669
+ const name = validateBotName(nameArg);
670
+ await withBotLock(name, () => runBotCreateLocked(name, opts), { command: "hq bot create" });
671
+ }
672
+ catch (err) {
673
+ // Under --json the desktop app reads stdout: a failure, including one in
674
+ // the local steps before the create reaches HQ, must leave a document
675
+ // there too, not only a line on stderr.
676
+ if (opts.json && !err?.jsonReported) {
677
+ const message = err instanceof Error ? err.message : String(err);
678
+ console.log(JSON.stringify({ ok: false, name: nameArg, reason: "create-failed", message }, null, 2));
679
+ }
680
+ throw err;
681
+ }
670
682
  }
671
683
  /**
672
684
  * Write a bot's new name to the cloud. Never fails the caller's command when
@@ -880,7 +892,7 @@ async function runBotCreateLocked(name, opts) {
880
892
  return false;
881
893
  if (opts.json)
882
894
  console.log(JSON.stringify({ ok: false, ...failure }, null, 2));
883
- throw Object.assign(new Error(failure.message), { expected: true });
895
+ throw Object.assign(new Error(failure.message), { expected: true, jsonReported: opts.json === true });
884
896
  }
885
897
  const matches = matchRemoteBots(mine, name);
886
898
  if (matches.length > 1) {
@@ -2351,12 +2351,19 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2351
2351
  }
2352
2352
  const pushProgressCompany = personalMode ? null : (pushSlug ?? null);
2353
2353
  const planLimitTracker = createPlanLimitRefusalTracker(companySlugForBilling(pushProgressCompany));
2354
+ // feedback_2356e54f: the push leg held files after an account switch and
2355
+ // still printed "0 uploaded, 0 skipped" and "Sync now complete".
2356
+ const pushIdentityHeld = createIdentityHeldTracker();
2357
+ const pushRecord = trackingProgressRecorder(planLimitTracker, pushProgressCompany);
2354
2358
  const pushResult = await share({
2355
2359
  paths: pushPaths,
2356
2360
  company: targetCompany,
2357
2361
  vaultConfig,
2358
2362
  hqRoot,
2359
- progressRecorder: trackingProgressRecorder(planLimitTracker, pushProgressCompany),
2363
+ progressRecorder: (event) => {
2364
+ pushIdentityHeld.observe(event);
2365
+ pushRecord(event);
2366
+ },
2360
2367
  skipUnchanged: true,
2361
2368
  serverOwnedPathPolicy: "skip",
2362
2369
  propagateDeletes: true,
@@ -2370,7 +2377,9 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2370
2377
  });
2371
2378
  const pushRefused = planLimitTracker.summary();
2372
2379
  const pushUploadFailed = readUploadFailures(pushResult);
2380
+ const pushHeldCount = pushIdentityHeld.count();
2373
2381
  const pushStatus = pushResult.aborted ||
2382
+ pushHeldCount > 0 ||
2374
2383
  pushResult.filesExcludedByScope > 0 ||
2375
2384
  (pushResult.filesRefusedStale ?? 0) > 0 ||
2376
2385
  pushUploadFailed.count > 0 ||
@@ -2380,6 +2389,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2380
2389
  console.log(` ${pushStatus} ` +
2381
2390
  `${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
2382
2391
  `${pushResult.filesDeleted} deleted, ${pushResult.filesExcludedByScope} scope-excluded` +
2392
+ (pushHeldCount > 0 ? `, ${pushHeldCount} held` : "") +
2383
2393
  (pushResult.aborted ? " — aborted" : "") +
2384
2394
  (pushRefused
2385
2395
  ? ` — ${describeRefusedCount(pushRefused)} refused (new files paused)`
@@ -2397,6 +2407,9 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2397
2407
  for (const line of uploadFailedLines(pushUploadFailed.count, pushUploadFailed.paths)) {
2398
2408
  console.log(chalk.yellow(line));
2399
2409
  }
2410
+ for (const line of identityHeldLines(pushHeldCount)) {
2411
+ console.log(chalk.yellow(line));
2412
+ }
2400
2413
  if (pushResult.aborted) {
2401
2414
  console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
2402
2415
  throw new SyncExitError(1);
@@ -2519,7 +2532,8 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2519
2532
  }
2520
2533
  if (pushResult.filesExcludedByScope > 0 ||
2521
2534
  (pushResult.filesRefusedStale ?? 0) > 0 ||
2522
- pushUploadFailed.count > 0) {
2535
+ pushUploadFailed.count > 0 ||
2536
+ pushHeldCount > 0) {
2523
2537
  console.log(chalk.yellow("\n⚠ Sync now finished, but one or more requested uploads were refused."));
2524
2538
  throw new SyncExitError(1, { health: "succeeded" });
2525
2539
  }
@@ -4,7 +4,8 @@ export { CORE_COMPANY_INDEX_SCOPE_FLAG };
4
4
  * Kill switch for company-scoped index rebuilds, default ON
5
5
  * (indigo-feature-flags-default-on-as-fail-safes). An absent, archived or
6
6
  * unreadable flag row keeps scoping on; only an explicit false turns it off,
7
- * and then `companies` rebuilds every company as it did before scoping.
7
+ * and then an unbound `companies` rebuild writes every company as it did
8
+ * before scoping. A bound session stays scoped either way.
8
9
  */
9
10
  export declare function coreCompanyIndexScopeEnabled(): boolean;
10
11
  //# sourceMappingURL=core-company-index-scope-flag.d.ts.map
@@ -5,7 +5,8 @@ export { CORE_COMPANY_INDEX_SCOPE_FLAG };
5
5
  * Kill switch for company-scoped index rebuilds, default ON
6
6
  * (indigo-feature-flags-default-on-as-fail-safes). An absent, archived or
7
7
  * unreadable flag row keeps scoping on; only an explicit false turns it off,
8
- * and then `companies` rebuilds every company as it did before scoping.
8
+ * and then an unbound `companies` rebuild writes every company as it did
9
+ * before scoping. A bound session stays scoped either way.
9
10
  */
10
11
  export function coreCompanyIndexScopeEnabled() {
11
12
  return resolveProcessFlagGate(CORE_COMPANY_INDEX_SCOPE_FLAG, { fallback: true }, () => true, undefined, { acceptStaleSnapshot: true, killSwitchWithoutCompany: true });
@@ -668,7 +668,6 @@ export function registerCoreCommands(program) {
668
668
  const passthrough = operands.slice(1);
669
669
  const force = passthrough.includes("--accept");
670
670
  const rendererArgs = passthrough.filter((arg) => arg !== "--accept");
671
- const isCompaniesTarget = entry.renderer === "companies";
672
671
  const wantsHelp = rendererArgs.some((arg) => arg === "--help" || arg === "-h");
673
672
  if (wantsHelp && ["companies", "company-knowledge", "projects"].includes(entry.renderer)) {
674
673
  renderIndexTarget(entry.renderer, { root: "", output: (message) => process.stdout.write(message) }, rendererArgs);
@@ -681,10 +680,6 @@ export function registerCoreCommands(program) {
681
680
  process.stdout.write(`Usage: hq core rebuild-index ${entry.target} [--accept]\n\n${entry.summary}. Help performs no writes.\n`);
682
681
  return;
683
682
  }
684
- const requestsCompanyScope = isCompaniesTarget && rendererArgs.some((arg) => arg === "--company" || arg.startsWith("--company="));
685
- if (requestsCompanyScope && !coreCompanyIndexScopeEnabled()) {
686
- throw expectedUserError("rebuild-index companies --company is disabled; enable cli.rebuild-index-company-scope for a canary.");
687
- }
688
683
  const hqRoot = resolveLiveRoot({ hqRoot: scope.hqRoot });
689
684
  const refused = [];
690
685
  try {
@@ -103,4 +103,77 @@ export declare function attachmentScopeForChannel(channel: {
103
103
  export declare function personalScopeForCaller(callerUid: string | undefined): string;
104
104
  /** Render the confirmation lines listing where each attachment landed. */
105
105
  export declare function formatUploadedAttachments(files: readonly DmAttachmentWire[]): string;
106
+ /** A parsed chat attachment key: chat/attachments/<dm|chan>/<scopeId>/<file>. */
107
+ export interface ParsedChatAttachmentKey {
108
+ scope: "dm" | "chan";
109
+ scopeId: string;
110
+ file: string;
111
+ }
112
+ /**
113
+ * Parse a chat attachment vault key. Returns null for anything that is not a
114
+ * single file directly under a conversation folder (no nesting, no `..`). Pure.
115
+ */
116
+ export declare function parseChatAttachmentKey(key: string): ParsedChatAttachmentKey | null;
117
+ /** One attachment reference as it appears on a message row. */
118
+ export interface AttachmentRef {
119
+ vaultPath: string;
120
+ companyUid?: string;
121
+ name?: string;
122
+ /** Who sent the message carrying this attachment, when known. */
123
+ senderUid?: string;
124
+ }
125
+ /**
126
+ * The vault scopes to try, in order, for a READ of a chat attachment. An
127
+ * explicit `companyUid` (message metadata or `--company`) wins. Otherwise a
128
+ * 1:1 DM attachment lives in the SENDER's personal vault (see
129
+ * `personalScopeForCaller`): when `senderUid` is known and in the pair, that
130
+ * is the only scope. With no sender (a bare vault path), the pair id is sorted
131
+ * and does not say who sent it, so the counterparty's vault is tried first and
132
+ * then the caller's own (the caller may be the sender). The server enforces
133
+ * that the caller is one of the two; this only picks the vault. Throws when no
134
+ * scope can be decided. Pure.
135
+ */
136
+ export declare function attachmentReadScopes(args: {
137
+ vaultPath: string;
138
+ companyUid?: string;
139
+ callerUid?: string;
140
+ senderUid?: string;
141
+ }): string[];
142
+ /** The first (most likely) read scope from `attachmentReadScopes`. Pure. */
143
+ export declare function attachmentReadScope(args: {
144
+ vaultPath: string;
145
+ companyUid?: string;
146
+ callerUid?: string;
147
+ senderUid?: string;
148
+ }): string;
149
+ /**
150
+ * The attachments on the message with `eventId` (or `messageId`) in a list of
151
+ * message rows. Returns null when no row matches. Pure.
152
+ */
153
+ export declare function findMessageAttachments(rows: ReadonlyArray<{
154
+ eventId?: string;
155
+ messageId?: string;
156
+ fromPersonUid?: string;
157
+ attachments?: AttachmentRef[];
158
+ attachment?: AttachmentRef;
159
+ }>, eventId: string): AttachmentRef[] | null;
160
+ export interface AttachmentDownloadDeps {
161
+ /** POST /v1/files/presign with `{ company, keys }`. */
162
+ presign: (token: string, body: Record<string, unknown>) => Promise<Response>;
163
+ /** GET the presigned URL. */
164
+ get: (url: string) => Promise<Response>;
165
+ }
166
+ export declare const defaultAttachmentDownloadDeps: AttachmentDownloadDeps;
167
+ /**
168
+ * Presign a GET for one chat attachment in `scopeUid` and return its bytes.
169
+ * hq-pro files-presign admits a counterparty read of the sender's personal
170
+ * vault only when the caller is a member of the DM pair; a refusal (whole
171
+ * request or per key) surfaces as an Error.
172
+ */
173
+ export declare function downloadChatAttachment(args: {
174
+ token: string;
175
+ scopeUid: string;
176
+ vaultPath: string;
177
+ deps?: AttachmentDownloadDeps;
178
+ }): Promise<Buffer>;
106
179
  //# sourceMappingURL=dm-attach.d.ts.map
@@ -193,4 +193,108 @@ export function formatUploadedAttachments(files) {
193
193
  .map((f) => ` [file] ${f.name} (${formatBytes(f.sizeBytes)})\n vault: ${f.vaultPath}`)
194
194
  .join("\n");
195
195
  }
196
+ /**
197
+ * Parse a chat attachment vault key. Returns null for anything that is not a
198
+ * single file directly under a conversation folder (no nesting, no `..`). Pure.
199
+ */
200
+ export function parseChatAttachmentKey(key) {
201
+ const parts = key.trim().split("/");
202
+ if (parts.length !== 5)
203
+ return null;
204
+ const [a, b, scope, scopeId, file] = parts;
205
+ if (a !== "chat" || b !== "attachments")
206
+ return null;
207
+ if (scope !== "dm" && scope !== "chan")
208
+ return null;
209
+ if (!scopeId || scopeId === "." || scopeId === "..")
210
+ return null;
211
+ if (!file || file === "." || file === "..")
212
+ return null;
213
+ return { scope, scopeId, file };
214
+ }
215
+ /**
216
+ * The vault scopes to try, in order, for a READ of a chat attachment. An
217
+ * explicit `companyUid` (message metadata or `--company`) wins. Otherwise a
218
+ * 1:1 DM attachment lives in the SENDER's personal vault (see
219
+ * `personalScopeForCaller`): when `senderUid` is known and in the pair, that
220
+ * is the only scope. With no sender (a bare vault path), the pair id is sorted
221
+ * and does not say who sent it, so the counterparty's vault is tried first and
222
+ * then the caller's own (the caller may be the sender). The server enforces
223
+ * that the caller is one of the two; this only picks the vault. Throws when no
224
+ * scope can be decided. Pure.
225
+ */
226
+ export function attachmentReadScopes(args) {
227
+ const parsed = parseChatAttachmentKey(args.vaultPath);
228
+ if (!parsed) {
229
+ throw new Error(`Not a chat attachment path: ${args.vaultPath}. Expected chat/attachments/dm/<uidA--uidB>/<file> or chat/attachments/chan/<channelId>/<file>.`);
230
+ }
231
+ const explicit = args.companyUid?.trim();
232
+ if (explicit)
233
+ return [explicit];
234
+ if (parsed.scope === "chan") {
235
+ throw new Error("A channel attachment needs its vault scope. Pass --company <uid> with the companyUid shown on the message.");
236
+ }
237
+ const pair = parsed.scopeId.split("--").filter(Boolean);
238
+ const me = args.callerUid?.trim();
239
+ if (pair.length !== 2 || !me || !pair.includes(me)) {
240
+ throw new Error("This DM attachment is not from a conversation you are part of, or your uid could not be read. Pass --company <uid> with the companyUid shown on the message.");
241
+ }
242
+ const sender = args.senderUid?.trim();
243
+ if (sender && pair.includes(sender))
244
+ return [sender];
245
+ const other = pair[0] === me ? pair[1] : pair[0];
246
+ return [other, me];
247
+ }
248
+ /** The first (most likely) read scope from `attachmentReadScopes`. Pure. */
249
+ export function attachmentReadScope(args) {
250
+ return attachmentReadScopes(args)[0];
251
+ }
252
+ /**
253
+ * The attachments on the message with `eventId` (or `messageId`) in a list of
254
+ * message rows. Returns null when no row matches. Pure.
255
+ */
256
+ export function findMessageAttachments(rows, eventId) {
257
+ const id = eventId.trim();
258
+ const row = rows.find((r) => r.eventId === id || r.messageId === id);
259
+ if (!row)
260
+ return null;
261
+ const list = row.attachments?.length ? row.attachments : row.attachment ? [row.attachment] : [];
262
+ const sender = row.fromPersonUid?.trim();
263
+ return list
264
+ .filter((a) => Boolean(a?.vaultPath))
265
+ .map((a) => (sender && !a.senderUid ? { ...a, senderUid: sender } : a));
266
+ }
267
+ export const defaultAttachmentDownloadDeps = {
268
+ presign: (token, body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
269
+ get: (url) => fetch(url),
270
+ };
271
+ /**
272
+ * Presign a GET for one chat attachment in `scopeUid` and return its bytes.
273
+ * hq-pro files-presign admits a counterparty read of the sender's personal
274
+ * vault only when the caller is a member of the DM pair; a refusal (whole
275
+ * request or per key) surfaces as an Error.
276
+ */
277
+ export async function downloadChatAttachment(args) {
278
+ const deps = args.deps ?? defaultAttachmentDownloadDeps;
279
+ const res = await deps.presign(args.token, {
280
+ company: args.scopeUid,
281
+ keys: [{ key: args.vaultPath, op: "get" }],
282
+ });
283
+ if (!res.ok) {
284
+ const err = (await res.json().catch(() => ({})));
285
+ const message = typeof err.error === "string" ? err.error : typeof err.message === "string" ? err.message : res.statusText;
286
+ throw new Error(`Could not open the attachment (HTTP ${res.status}): ${message}`);
287
+ }
288
+ const data = (await res.json().catch(() => ({})));
289
+ const row = (data.results ?? []).find((r) => Boolean(r) && typeof r === "object" && r.key === args.vaultPath);
290
+ if (!row || typeof row.url !== "string") {
291
+ const reason = typeof row?.error === "string" ? row.error : "the vault refused the read";
292
+ throw new Error(`Attachment refused: ${args.vaultPath}: ${reason}`);
293
+ }
294
+ const got = await deps.get(row.url);
295
+ if (!got.ok) {
296
+ throw new Error(`Download failed for ${args.vaultPath} (HTTP ${got.status}).`);
297
+ }
298
+ return Buffer.from(await got.arrayBuffer());
299
+ }
196
300
  //# sourceMappingURL=dm-attach.js.map
@@ -443,5 +443,15 @@ export declare function formatChannelTranscript(messages: ChannelMessageItem[],
443
443
  */
444
444
  export declare function agentCallerUid(token: string, machine?: () => boolean): string | null;
445
445
  export declare const AGENT_THREAD_UNAVAILABLE: string;
446
+ /**
447
+ * Where to write each downloaded attachment. One file and an `--out` that is
448
+ * not an existing directory: write exactly there. Otherwise `--out` (default
449
+ * cwd) is a directory and each file keeps its sanitized name. Pure apart from
450
+ * the injected `isDir`.
451
+ */
452
+ export declare function attachmentOutputPaths(refs: ReadonlyArray<{
453
+ vaultPath: string;
454
+ name?: string;
455
+ }>, out: string | undefined, isDir: (p: string) => boolean): string[];
446
456
  export declare function registerDmCommand(program: Command): void;
447
457
  //# sourceMappingURL=dm.d.ts.map