@indigoai-us/hq-cli 5.144.0 → 5.146.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/command-catalog.generated.d.ts +24 -6
  3. package/dist/command-catalog.generated.js +29 -6
  4. package/dist/commands/_patterns.d.ts +16 -0
  5. package/dist/commands/_patterns.js +20 -3
  6. package/dist/commands/files-browse.d.ts +21 -10
  7. package/dist/commands/files-browse.js +95 -32
  8. package/dist/commands/files.js +44 -13
  9. package/dist/commands/mesh.js +115 -1
  10. package/dist/commands/report.d.ts +13 -0
  11. package/dist/commands/report.js +35 -13
  12. package/dist/commands/sync-narrow.js +36 -5
  13. package/dist/commands/workers.js +5 -2
  14. package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
  15. package/dist/lib/mesh/live/daemon/credentials.js +141 -10
  16. package/dist/lib/mesh/live/daemon/doctor.js +4 -3
  17. package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
  18. package/dist/lib/mesh/live/daemon/presence.js +69 -1
  19. package/dist/lib/mesh/live/daemon/run.js +3 -1
  20. package/dist/lib/mesh/project-board.d.ts +14 -0
  21. package/dist/lib/mesh/project-board.js +24 -1
  22. package/dist/lib/work-context/bind-best.d.ts +2 -0
  23. package/dist/lib/work-context/bind-best.js +34 -1
  24. package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
  25. package/dist/lib/work-context/bind-calling-session.js +63 -0
  26. package/dist/lib/work-context/prd-sync.d.ts +34 -0
  27. package/dist/lib/work-context/prd-sync.js +120 -0
  28. package/dist/lib/work-context/prompt-rebind.d.ts +21 -0
  29. package/dist/lib/work-context/prompt-rebind.js +76 -0
  30. package/dist/lib/work-context/reconcile.d.ts +2 -0
  31. package/dist/lib/work-context/reconcile.js +31 -0
  32. package/dist/lib/work-context/state.d.ts +2 -0
  33. package/dist/lib/work-context/state.js +8 -0
  34. package/package.json +1 -1
@@ -4,7 +4,7 @@ import * as readline from "node:readline";
4
4
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
5
5
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
6
6
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
7
- import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
7
+ import { GROUP_ID_PATTERN, EMAIL_PATTERN, isPrivateFolderPattern, normalizeFilePrefix, toSharedFolderGlob, } from "./_patterns.js";
8
8
  import { looksLikeCompanyUid } from "../utils/vault-api.js";
9
9
  import { AuthError } from "../utils/auth-error.js";
10
10
  import { formatAccessDenied, offerAccessLadder, } from "../utils/access-denied-hint.js";
@@ -40,6 +40,27 @@ export function parseDuration(input) {
40
40
  }
41
41
  /** PRD upper bound on browser-launch share-session expiry. */
42
42
  export const MAX_SHARE_SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24h
43
+ /** Render the ACL-specific meaning of a path without changing its wire value. */
44
+ function displayAclPattern(prefix) {
45
+ return isPrivateFolderPattern(prefix)
46
+ ? `${prefix} (private folder)`
47
+ : prefix;
48
+ }
49
+ function privateFolderExplanation(prefix) {
50
+ return (`Private folder '${prefix}' — write can create only a new direct child. ` +
51
+ "Children are creator-only; this grant does not read, list, overwrite, or delete them.");
52
+ }
53
+ function formatAclPatternConflict(prefix, serverMessage) {
54
+ const privatePattern = prefix.endsWith("/*")
55
+ ? prefix.slice(0, -1)
56
+ : prefix.endsWith("/")
57
+ ? prefix
58
+ : `${prefix}/`;
59
+ const sharedPattern = `${privatePattern}*`;
60
+ const detail = serverMessage ? `${serverMessage}. ` : "";
61
+ return (`${detail}A private folder '${privatePattern}' and shared folder glob ` +
62
+ `'${sharedPattern}' cannot both exist. Remove the conflicting ACL pattern and retry.`);
63
+ }
43
64
  /**
44
65
  * POST /files/{companyUid}/share-session — mint a browser-launch share
45
66
  * session token. Returns the parsed response. Throws ShareSessionHttpError
@@ -101,7 +122,7 @@ export function formatShareSessionError(err) {
101
122
  export function registerFilesCommand(program) {
102
123
  const files = program
103
124
  .command("files")
104
- .description("Manage file access controls in HQ vault")
125
+ .description("Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children.")
105
126
  .option("--company <slug>", "Company slug (resolves to companyUid)");
106
127
  // An exported HQ_API_KEY must never fall back to the caller's Cognito session
107
128
  // (that would ignore the key's scope entirely), so every subcommand is gated.
@@ -130,7 +151,7 @@ export function registerFilesCommand(program) {
130
151
  });
131
152
  files
132
153
  .command("share [paths...]")
133
- .description("Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
154
+ .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.")
134
155
  .option("--with <principal>", "Email address, group id, or '@all' to share with every active company member")
135
156
  .option("--permission <level>", "Permission level (only with --with): read | write")
136
157
  .option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
@@ -239,7 +260,7 @@ export function registerFilesCommand(program) {
239
260
  });
240
261
  files
241
262
  .command("unshare <prefix>")
242
- .description("Remove a file access grant")
263
+ .description("Remove a file access grant. A trailing slash targets the private-folder row exactly.")
243
264
  .requiredOption("--with <principal>", "Email address, group id, or '@all' to remove the company-wide grant")
244
265
  .action(async (prefix, opts) => {
245
266
  try {
@@ -316,7 +337,7 @@ export function registerFilesCommand(program) {
316
337
  }
317
338
  process.exit(1);
318
339
  }
319
- console.log(chalk.green(`Removed grant for ${principalLabel} on '${canonicalPrefix}'`));
340
+ console.log(chalk.green(`Removed grant for ${principalLabel} on '${displayAclPattern(canonicalPrefix)}'`));
320
341
  }
321
342
  catch (err) {
322
343
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -325,7 +346,7 @@ export function registerFilesCommand(program) {
325
346
  });
326
347
  files
327
348
  .command("acl <prefix>")
328
- .description("Show the ACL (access control list) for a file prefix")
349
+ .description("Show the ACL (access control list) for a file prefix. foo/ is a private folder whose children are creator-only; the quoted glob 'foo/*' is shared.")
329
350
  .option("--json", "Output structured ACL data, including explicit absence")
330
351
  .action(async (prefix, opts) => {
331
352
  try {
@@ -389,7 +410,10 @@ export function registerFilesCommand(program) {
389
410
  process.exit(1);
390
411
  }
391
412
  const aclStatus = row?.open ? "open" : "restricted";
392
- console.log(chalk.green(`ACL for ${tree.prefix} (${aclStatus})`));
413
+ console.log(chalk.green(`ACL for ${displayAclPattern(tree.prefix)} (${aclStatus})`));
414
+ if (isPrivateFolderPattern(tree.prefix)) {
415
+ console.log(chalk.gray(privateFolderExplanation(tree.prefix)));
416
+ }
393
417
  if (row) {
394
418
  console.log(`Creator: ${row.creatorUid}`);
395
419
  }
@@ -413,7 +437,7 @@ export function registerFilesCommand(program) {
413
437
  const PERM_W = Math.max(10, ...rows.map((e) => e.permission.length));
414
438
  const BY_W = Math.max(10, ...rows.map((e) => e.grantedBy.length));
415
439
  const SRC_W = showSource
416
- ? Math.max(6, ...rows.map((e) => (e.sourcePrefix ?? "").length))
440
+ ? Math.max(6, ...rows.map((e) => displayAclPattern(e.sourcePrefix ?? "").length))
417
441
  : 0;
418
442
  const headerCols = [
419
443
  "TYPE".padEnd(TYPE_W),
@@ -435,7 +459,7 @@ export function registerFilesCommand(program) {
435
459
  grantedAt,
436
460
  ];
437
461
  if (showSource)
438
- cols.splice(4, 0, (e.sourcePrefix ?? "").padEnd(SRC_W));
462
+ cols.splice(4, 0, displayAclPattern(e.sourcePrefix ?? "").padEnd(SRC_W));
439
463
  console.log(cols.join(" "));
440
464
  }
441
465
  }
@@ -713,6 +737,9 @@ async function runDirectGrant(params) {
713
737
  else if (res.status === 404) {
714
738
  console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
715
739
  }
740
+ else if (res.status === 409 && body.code === "ACL_PATTERN_CONFLICT") {
741
+ console.error(chalk.red(formatAclPatternConflict(canonicalPrefix, body.message)));
742
+ }
716
743
  else if (res.status === 409) {
717
744
  console.error(chalk.red("Concurrent modification — please retry"));
718
745
  }
@@ -731,7 +758,10 @@ async function runDirectGrant(params) {
731
758
  console.log(chalk.green(`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`));
732
759
  }
733
760
  else {
734
- console.log(chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`));
761
+ console.log(chalk.green(`${verb} ${params.permission} on ${displayAclPattern(printedPrefix)} to ${principalLabel}`));
762
+ }
763
+ if (isPrivateFolderPattern(printedPrefix)) {
764
+ console.log(chalk.gray(privateFolderExplanation(printedPrefix)));
735
765
  }
736
766
  if (typeof data.record?.error === "string" &&
737
767
  typeof data.record.status === "number" &&
@@ -1049,19 +1079,20 @@ export async function runFilesDelete(params, deps = {}) {
1049
1079
  const confirm = deps.confirm ?? realConfirm;
1050
1080
  // Strip the local company anchor before preview/confirmation (HQ-8F/HQ-CA).
1051
1081
  // Personal keys are already bucket-relative and must keep any such anchor.
1052
- // Normalize trailing `/` to `/*`, then reject the root/empty prefix before
1082
+ // Deletion intentionally expands a folder spelling to a recursive glob, then
1083
+ // rejects the root/empty prefix before
1053
1084
  // any network call, including when stripping a bare company anchor empties it.
1054
1085
  // The anchor names a company. Compare it with --company (refusing a
1055
1086
  // mismatch), and when --company was omitted ADOPT it rather than letting the
1056
1087
  // membership fallback pick a different company to delete from.
1057
1088
  const anchorSlug = companySlugFromAnchor(params.prefix, params.personal);
1058
- const normalized = normalizeCompanyFilePrefix(params.prefix, {
1089
+ const normalized = toSharedFolderGlob(normalizeCompanyFilePrefix(params.prefix, {
1059
1090
  personal: params.personal,
1060
1091
  deferRootReject: true,
1061
1092
  ...(params.companySlug !== undefined
1062
1093
  ? { companySlug: params.companySlug }
1063
1094
  : {}),
1064
- });
1095
+ }));
1065
1096
  if (normalized === "" || normalized === "*" || normalized === "/*") {
1066
1097
  console.error(chalk.red("Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key."));
1067
1098
  process.exit(1);
@@ -12,7 +12,7 @@ import chalk from "chalk";
12
12
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
13
13
  import * as readline from "node:readline/promises";
14
14
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
15
- import { ensureProjectBoard } from "../lib/mesh/project-board.js";
15
+ import { closeSupersededProjectThread, ensureProjectBoard } from "../lib/mesh/project-board.js";
16
16
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
17
17
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
18
18
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
@@ -39,6 +39,9 @@ import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-
39
39
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
40
40
  import { readSessionState, updateSessionState } from "../lib/work-context/state.js";
41
41
  import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
42
+ import { bindCallingSession, formatSessionBoundLine } from "../lib/work-context/bind-calling-session.js";
43
+ import { rebindNeedsProjectOnPrompt } from "../lib/work-context/prompt-rebind.js";
44
+ import { planPrdSync } from "../lib/work-context/prd-sync.js";
42
45
  import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
43
46
  import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
44
47
  import { resolveCompany } from "../lib/work-context/company.js";
@@ -388,6 +391,9 @@ async function runProjectSet(projectId, opts) {
388
391
  throw err;
389
392
  }
390
393
  }
394
+ if (!current && opts.create) {
395
+ noteCallingSessionBound(view.projectId, company);
396
+ }
391
397
  if (opts.json) {
392
398
  console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
393
399
  return;
@@ -488,6 +494,13 @@ async function runProjectEnsure(projectId, opts) {
488
494
  }
489
495
  }
490
496
  registration = await registerMeshProject(token, company.companyUid, projectId);
497
+ await closeSupersededProjectThread({
498
+ token,
499
+ companyUid: company.companyUid,
500
+ openedThreadId: genesis.threadId,
501
+ canonicalThreadId: registration.threadId,
502
+ created: genesis.created,
503
+ });
491
504
  if (!registration.threadId || !registration.channelId) {
492
505
  fail("Work mesh registration did not return a threadId and channelId.");
493
506
  }
@@ -519,12 +532,91 @@ async function runProjectEnsure(projectId, opts) {
519
532
  version: verified.version,
520
533
  stories: verified.stories,
521
534
  };
535
+ noteCallingSessionBound(payload.projectId, company);
522
536
  if (opts.json) {
523
537
  console.log(JSON.stringify(payload));
524
538
  return;
525
539
  }
526
540
  console.log(`Work mesh: project ${payload.projectId} ensured (thread ${payload.threadId}, channel ${payload.channelId}, ${payload.stories.length} stories)`);
527
541
  }
542
+ async function runPrdSync(opts) {
543
+ const sessionId = opts.session.trim();
544
+ const file = opts.file.trim();
545
+ if (!sessionId || !file)
546
+ fail("Usage: hq mesh context prd-sync --session <sid> --file <prd.json>");
547
+ const root = workContextHomeRoot();
548
+ const plan = planPrdSync({ sessionId, file, root });
549
+ if (!plan.changed) {
550
+ const payload = { ok: true, action: "context.prd-sync", changed: 0, ...(plan.skip ? { skip: plan.skip } : {}) };
551
+ if (opts.json)
552
+ console.log(JSON.stringify(payload));
553
+ else
554
+ console.log(plan.skip ? `Work mesh: prd-sync skipped (${plan.skip})` : "Work mesh: prd-sync unchanged");
555
+ return;
556
+ }
557
+ let token;
558
+ try {
559
+ token = await requireToken();
560
+ }
561
+ catch {
562
+ token = null;
563
+ }
564
+ if (!token) {
565
+ console.error("work-mesh: prd-sync has changes but no token; snapshot not advanced");
566
+ return;
567
+ }
568
+ const events = plan.events.map((event, index) => buildMeshEmitEvent({
569
+ eventId: generateUlid(Date.now() + index),
570
+ kind: "task_status",
571
+ sessionId,
572
+ harness: "hq-sessions",
573
+ adapterVersion: "1.0.0",
574
+ at: new Date().toISOString(),
575
+ seq: plan.seqStart + index + 1,
576
+ source: "hooks",
577
+ taskId: event.taskId,
578
+ status: event.status,
579
+ summary: event.status === "done" ? `${event.taskId} passes` : `${event.taskId} in progress`,
580
+ }));
581
+ const poster = createEmitPoster({ token, baseUrl: resolveVaultApiBase(process.env) });
582
+ await emitEvents({
583
+ workMeshRoot: workMeshHomeRoot(),
584
+ poster,
585
+ newEvents: events,
586
+ });
587
+ plan.commit();
588
+ if (plan.taskId) {
589
+ updateSessionState(sessionId, root, (current) => {
590
+ if (!current)
591
+ return null;
592
+ return {
593
+ ...current,
594
+ taskId: plan.taskId,
595
+ contextStatus: current.projectId ? "bound" : current.contextStatus,
596
+ updatedAt: new Date().toISOString(),
597
+ };
598
+ });
599
+ }
600
+ if (opts.json) {
601
+ console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: plan.events.length, taskId: plan.taskId }));
602
+ return;
603
+ }
604
+ console.log(`Work mesh: prd-sync emitted ${plan.events.length} task_status`);
605
+ }
606
+ function noteCallingSessionBound(projectId, company) {
607
+ try {
608
+ const bound = bindCallingSession({
609
+ root: workContextHomeRoot(),
610
+ projectId,
611
+ company,
612
+ });
613
+ if (bound.bound)
614
+ console.error(formatSessionBoundLine(bound.sessionId, bound.projectId));
615
+ }
616
+ catch {
617
+ /* session bind is local and must not fail project ensure */
618
+ }
619
+ }
528
620
  async function runDoctor(opts) {
529
621
  if (opts.apply) {
530
622
  fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
@@ -1327,6 +1419,21 @@ async function runSessionEnqueue(cliKind, opts) {
1327
1419
  const sessionId = resolveEnqueueSessionId(opts.sessionId, process.env);
1328
1420
  if (!sessionId)
1329
1421
  fail("sessionId required (--session-id or HQ_SESSION_ID)");
1422
+ if (kind === "turn_start") {
1423
+ try {
1424
+ rebindNeedsProjectOnPrompt({
1425
+ sessionId,
1426
+ root: workContextHomeRoot(),
1427
+ hqRoot: opts.hqRoot?.trim() || process.env.HQ_ROOT,
1428
+ cwd: opts.cwd,
1429
+ companySlug: opts.companySlug,
1430
+ env: process.env,
1431
+ });
1432
+ }
1433
+ catch {
1434
+ /* prompt rebind is fail-soft */
1435
+ }
1436
+ }
1330
1437
  const evidence = buildEmitEvidence({
1331
1438
  sessionId: sessionId,
1332
1439
  hqRoot: opts.hqRoot,
@@ -1674,6 +1781,13 @@ export function registerMeshCommand(program) {
1674
1781
  .option("--company <slug|uid>", "Company override when creating a Board")
1675
1782
  .option("--json", "Print machine-readable JSON")
1676
1783
  .action((target, slug, opts) => wrap(() => runBindProject(target, { ...opts, slug }))());
1784
+ context
1785
+ .command("prd-sync")
1786
+ .description("Emit task_status for PRD story passes/status changes on a bound session")
1787
+ .requiredOption("--session <sid>", "Canonical session id")
1788
+ .requiredOption("--file <path>", "prd.json written by the bound session")
1789
+ .option("--json", "Print machine-readable JSON")
1790
+ .action((opts) => wrap(() => runPrdSync(opts))());
1677
1791
  context
1678
1792
  .command("organize")
1679
1793
  .description("List or submit one-time project/task decisions (US-007B / US-005B)")
@@ -1,6 +1,19 @@
1
1
  import { Command } from "commander";
2
2
  import { buildReportTarball, deployOrgContext, type ChannelReport, type DeployAccessPolicy, type ReportDeployment, type ReportPatch } from "../lib/report-publish.js";
3
+ type ChannelRow = {
4
+ channelId?: string;
5
+ scope?: string;
6
+ companyUid?: string | null;
7
+ };
3
8
  type ReportPatchInput = ReportPatch | ((current: ChannelReport) => ReportPatch);
9
+ /** Any company-owned channel (company or project scope) can carry reports; personal channels cannot. */
10
+ export declare function isCompanyReportChannel(channel: ChannelRow): boolean;
11
+ /**
12
+ * Records created before hq-pro stamped companyUid carry null. The channel the
13
+ * caller can see knows its company, and publish needs it to pick the deploy
14
+ * org, so fill it from there rather than failing every publish.
15
+ */
16
+ export declare function withChannelCompany(report: ChannelReport, channels: ChannelRow[]): ChannelReport;
4
17
  /** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
5
18
  export declare function resolveReport(token: string, reportId: string, requestedChannelId?: string): Promise<ChannelReport>;
6
19
  export declare function patchReport(token: string, report: ChannelReport, patch: ReportPatchInput): Promise<ChannelReport>;
@@ -87,22 +87,26 @@ function print(value, json) {
87
87
  console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
88
88
  }
89
89
  async function listReportChannels(token) {
90
- const channels = await vaultApiFetch({ token, path: "/v1/notify/channels" });
90
+ // scope=all: the default list is personal channels only, which hid every
91
+ // project and company channel a report can live on.
92
+ const channels = await vaultApiFetch({ token, path: "/v1/notify/channels?scope=all" });
91
93
  if (!channels.ok)
92
- throw new ReportPublishError("Could not resolve the report channel.");
94
+ throw new ReportPublishError("Could not resolve the report channel.", "source-invalid");
93
95
  const listed = await channels.json();
94
96
  return Array.isArray(listed.channels) ? listed.channels : [];
95
97
  }
96
- function isCompanyReportChannel(channel) {
97
- return channel.scope === "company"
98
- || (typeof channel.scope !== "string" && Boolean(channel.companyUid));
98
+ /** Any company-owned channel (company or project scope) can carry reports; personal channels cannot. */
99
+ export function isCompanyReportChannel(channel) {
100
+ if (channel.scope === "personal")
101
+ return false;
102
+ return channel.scope === "company" || channel.scope === "project" || Boolean(channel.companyUid);
99
103
  }
100
104
  function requireCompanyReportChannel(channels, channelId) {
101
105
  const channel = channels.find((row) => row.channelId === channelId);
102
106
  if (!channel)
103
- throw new ReportPublishError("Report channel was not found in your channels.");
107
+ throw new ReportPublishError("Report channel was not found in your channels.", "source-invalid");
104
108
  if (!isCompanyReportChannel(channel))
105
- throw new ReportPublishError("Channel reports require a company channel.");
109
+ throw new ReportPublishError("Channel reports require a company channel.", "source-invalid");
106
110
  }
107
111
  async function readReportInChannel(token, channelId, reportId) {
108
112
  const res = await vaultApiFetch({ token, path: reportPath(channelId, reportId) });
@@ -113,6 +117,17 @@ async function readReportInChannel(token, channelId, reportId) {
113
117
  throw new ReportPublishError("Report response was incomplete.");
114
118
  return body.report;
115
119
  }
120
+ /**
121
+ * Records created before hq-pro stamped companyUid carry null. The channel the
122
+ * caller can see knows its company, and publish needs it to pick the deploy
123
+ * org, so fill it from there rather than failing every publish.
124
+ */
125
+ export function withChannelCompany(report, channels) {
126
+ if (report.companyUid)
127
+ return report;
128
+ const companyUid = channels.find((row) => row.channelId === report.channelId)?.companyUid;
129
+ return companyUid ? { ...report, companyUid } : report;
130
+ }
116
131
  /** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
117
132
  export async function resolveReport(token, reportId, requestedChannelId) {
118
133
  const explicit = requestedChannelId ?? process.env.HQ_REPORT_CHANNEL_ID;
@@ -121,18 +136,18 @@ export async function resolveReport(token, reportId, requestedChannelId) {
121
136
  requireCompanyReportChannel(channels, explicit);
122
137
  const res = await vaultApiFetch({ token, path: reportPath(explicit, reportId) });
123
138
  if (!res.ok)
124
- throw new ReportPublishError("Report was not found in the supplied channel context.");
139
+ throw new ReportPublishError("Report was not found in the supplied channel context.", "source-invalid");
125
140
  const body = await res.json();
126
141
  if (!body.report)
127
142
  throw new ReportPublishError("Report response was incomplete.");
128
- return body.report;
143
+ return withChannelCompany(body.report, channels);
129
144
  }
130
145
  const recordedChannel = locallyRecordedChannel(reportId);
131
146
  if (recordedChannel) {
132
147
  requireCompanyReportChannel(channels, recordedChannel);
133
148
  const recorded = await readReportInChannel(token, recordedChannel, reportId);
134
149
  if (recorded)
135
- return recorded;
150
+ return withChannelCompany(recorded, channels);
136
151
  }
137
152
  // The report API is intentionally channel-scoped. Discover only among the
138
153
  // caller's existing channels, using list endpoints so missing reports never
@@ -153,13 +168,13 @@ export async function resolveReport(token, reportId, requestedChannelId) {
153
168
  const reports = Array.isArray(body.reports) ? body.reports : [];
154
169
  const match = reports.find((report) => report.reportId === reportId);
155
170
  if (match)
156
- return match;
171
+ return withChannelCompany(match, channels);
157
172
  }
158
173
  throw new ReportPublishError(notFoundMessage(scannedChannels));
159
174
  }
160
175
  export async function patchReport(token, report, patch) {
161
176
  if (!report.companyUid)
162
- throw new ReportPublishError("Channel reports require a company channel.");
177
+ throw new ReportPublishError("Channel reports require a company channel.", "source-invalid");
163
178
  const patchOnce = (current) => vaultApiFetch({
164
179
  token,
165
180
  path: reportPath(current.channelId, current.reportId),
@@ -275,7 +290,14 @@ export async function deployReportStatic(token, input) {
275
290
  }
276
291
  async function publishWithOrg(token, report, directory, draft) {
277
292
  const published = await publishReport(report, directory, draft, {
278
- patch: (current, patch) => patchReport(token, current, patch),
293
+ // The server echoes the stored record, whose companyUid may still be null
294
+ // on reports created before hq-pro stamped it. Keep the company resolved
295
+ // from the channel so the access policy and deploy org stay correct.
296
+ patch: async (current, patch) => {
297
+ const next = await patchReport(token, current, patch);
298
+ const companyUid = next.companyUid ?? current.companyUid ?? report.companyUid;
299
+ return companyUid ? { ...next, companyUid } : next;
300
+ },
279
301
  deploy: async (input) => {
280
302
  const org = await orgFor(token, report);
281
303
  return deployReportStatic(token, { ...input, org });
@@ -103,6 +103,31 @@ export async function resolveNarrowTarget(input) {
103
103
  syncConfig,
104
104
  };
105
105
  }
106
+ /**
107
+ * Normalize one ACL row for the local pull scope used by `sync narrow`.
108
+ *
109
+ * A trailing slash is a private-folder row, not a recursive read prefix. It
110
+ * lets its grantee create a new direct child, but existing children remain
111
+ * creator-only. Dropping that row from the prospective shared scope keeps the
112
+ * narrow plan from retaining private children merely because their parent
113
+ * happens to end in `/`. A creator's exact/child grant is still retained by
114
+ * the normal `grantPathToPrefix` path below.
115
+ */
116
+ function narrowPrefixForGrant(grant, companySlug) {
117
+ let path = (grant.path ?? "").replace(/^\/+/, "");
118
+ const companyAnchor = `companies/${companySlug}/`;
119
+ const slugAnchor = `${companySlug}/`;
120
+ if (path.startsWith(companyAnchor)) {
121
+ path = path.slice(companyAnchor.length);
122
+ }
123
+ else if (path.startsWith(slugAnchor)) {
124
+ path = path.slice(slugAnchor.length);
125
+ }
126
+ if (path.length > 0 && path.endsWith("/") && !path.endsWith("/*")) {
127
+ return null;
128
+ }
129
+ return grantPathToPrefix(grant.path, companySlug);
130
+ }
106
131
  /**
107
132
  * Compute the narrow plan for one company: fetch explicit grants, coalesce,
108
133
  * read the per-company journal, walk the local tree, classify.
@@ -118,11 +143,17 @@ export async function computeNarrowPlan(input) {
118
143
  // (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
119
144
  // (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
120
145
  // none of which startsWith-match the company-relative local-tree keys
121
- // buildNarrowPlan emits. A wildcard grant normalizes to "" (everything);
122
- // coalescePrefixes drops empties, so guard it explicitly to `[""]` (which
123
- // isCoveredByAny treats as covering everything → nothing orphaned) rather
124
- // than letting it collapse to "nothing" and propose deleting the tree.
125
- const normalizedPrefixes = grants.map((g) => grantPathToPrefix(g.path, companySlug));
146
+ // buildNarrowPlan emits. Private-folder rows (`x/`) are deliberately
147
+ // excluded: they do not grant read/list access to existing children, so
148
+ // treating them as recursive prefixes would leave private files on disk
149
+ // after an all → shared migration. A wildcard grant normalizes to ""
150
+ // (everything); coalescePrefixes drops empties, so guard it explicitly to
151
+ // `[""]` (which isCoveredByAny treats as covering everything → nothing
152
+ // orphaned) rather than letting it collapse to "nothing" and propose
153
+ // deleting the tree.
154
+ const normalizedPrefixes = grants
155
+ .map((g) => narrowPrefixForGrant(g, companySlug))
156
+ .filter((p) => p !== null);
126
157
  const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
127
158
  ? [""]
128
159
  : coalescePrefixes(normalizedPrefixes);
@@ -5,7 +5,7 @@ import * as yaml from "js-yaml";
5
5
  import { parseUserYaml } from "../utils/user-yaml-error.js";
6
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
7
7
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
8
- import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
8
+ import { GROUP_ID_PATTERN, EMAIL_PATTERN, toSharedFolderGlob } from "./_patterns.js";
9
9
  import { findHqRoot } from "../utils/manifest.js";
10
10
  export { readWorkerRegistry } from "../lib/workers-registry/read.js";
11
11
  import { readWorkerRegistry } from "../lib/workers-registry/read.js";
@@ -56,7 +56,10 @@ export function workerVaultPrefix(w) {
56
56
  const rel = w.path.startsWith(companyRoot)
57
57
  ? w.path.slice(companyRoot.length)
58
58
  : w.path;
59
- return normalizeFilePrefix(rel);
59
+ // Worker sharing intentionally covers every child in the generated worker
60
+ // directory. Keep that conversion explicit: a user-supplied trailing slash
61
+ // on an ACL command instead means a private folder.
62
+ return toSharedFolderGlob(rel);
60
63
  }
61
64
  /** Classify a share principal (@all | email | grp_*) — null when invalid. */
62
65
  export function classifyPrincipal(principal) {
@@ -18,6 +18,12 @@ export declare const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
18
18
  export declare const MAX_TIMER_DELAY_MS = 2147483647;
19
19
  /** Cap any single retry so a bogus Retry-After cannot park the daemon. */
20
20
  export declare const MAX_RETRY_DELAY_MS: number;
21
+ /** Contract-3 vend accepts at most this many preferred company uids. */
22
+ export declare const PREFERRED_COMPANY_LIMIT = 5;
23
+ /** Session files older than this do not count as companies in use. */
24
+ export declare const PREFERRED_SESSION_WINDOW_MS: number;
25
+ /** Preferred-set changes re-vend at most this often. */
26
+ export declare const PREFERRED_REVEND_MIN_MS = 60000;
21
27
  export type CredentialVendFailureKind = "refused" | "transient";
22
28
  /**
23
29
  * Typed vend failure. `refused` = server will not vend right now
@@ -51,6 +57,8 @@ export interface PresenceCompany {
51
57
  }
52
58
  export interface DroppedPresenceCompany {
53
59
  companyUid: string;
60
+ /** Short name for doctor text when the server sent one. */
61
+ label?: string;
54
62
  /** Server-supplied cause when available; otherwise a deliberately neutral label. */
55
63
  reason: string;
56
64
  }
@@ -98,6 +106,30 @@ export declare function normalizeContract3Bundle(raw: unknown): Contract3Bundle;
98
106
  * No identity file → return the bundle unchanged (person laptop).
99
107
  */
100
108
  export declare function scopeBundleToAgentIdentity(bundle: Contract3Bundle, env?: NodeJS.ProcessEnv): Contract3Bundle;
109
+ /**
110
+ * Device default first, then distinct company uids from session files updated
111
+ * in the last 24 hours, newest first, max 5. Any read error returns undefined
112
+ * so the vend body omits the field.
113
+ */
114
+ export declare function readPreferredCompanyUids(input: {
115
+ workContextRoot: string;
116
+ nowMs?: number;
117
+ }): string[] | undefined;
118
+ export declare function preferredCompanyKey(uids: readonly string[] | undefined): string;
119
+ /** True when a new preferred set should force a vend, at most once per minute. */
120
+ export declare function shouldRevendPreferredCompanies(input: {
121
+ previous: readonly string[] | undefined;
122
+ next: readonly string[] | undefined;
123
+ lastVendMs: number;
124
+ nowMs: number;
125
+ minIntervalMs?: number;
126
+ }): boolean;
127
+ export declare function contract3VendBody(preferredCompanyUids: readonly string[] | undefined): {
128
+ contractVersion: 3;
129
+ preferredCompanyUids?: string[];
130
+ };
131
+ /** Plain doctor line for one company the vend left out. */
132
+ export declare function formatDroppedPresenceCompany(company: DroppedPresenceCompany): string;
101
133
  export type CredentialVendPostResult = {
102
134
  status: number;
103
135
  body: unknown;
@@ -110,6 +142,8 @@ export declare function createContract3Fetcher(opts: {
110
142
  post?: (path: string, body: unknown) => Promise<CredentialVendPostResult>;
111
143
  /** Process env for identity-file scoping (tests). */
112
144
  env?: NodeJS.ProcessEnv;
145
+ /** Companies to ask for. Undefined (read error) omits the field. */
146
+ readPreferredCompanyUids?: () => string[] | undefined;
113
147
  }): CredentialsFetcher;
114
148
  export interface CredentialRenewalErrorInfo {
115
149
  /** Absolute ISO timestamp when the renewal retry is scheduled to fire. */
@@ -130,8 +164,11 @@ export declare class CredentialRenewalManager {
130
164
  private readonly random;
131
165
  private handle;
132
166
  private stopped;
167
+ private inflight;
133
168
  constructor(fetchCredentials: CredentialsFetcher, onRenewed: (bundle: Contract3Bundle) => void, onError?: (err: unknown, info?: CredentialRenewalErrorInfo) => void, timers?: TimerHost, retryDelayMs?: number, refusedRetryMs?: number, random?: () => number);
134
169
  schedule(bundle: Contract3Bundle): void;
170
+ /** Vend now (preferred companies changed). No-op while a vend is in flight. */
171
+ renewNow(): void;
135
172
  private renew;
136
173
  stop(): void;
137
174
  private clear;