@indigoai-us/hq-cli 5.129.0 → 5.131.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 (35) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/contracts/preflight/v1/fixtures.json +87 -0
  3. package/contracts/preflight/v1/schema.json +58 -0
  4. package/dist/command-catalog.generated.d.ts +27 -0
  5. package/dist/command-catalog.generated.js +34 -0
  6. package/dist/commands/core.js +7 -1
  7. package/dist/commands/dm.d.ts +2 -0
  8. package/dist/commands/dm.js +1 -1
  9. package/dist/commands/files.js +137 -9
  10. package/dist/commands/mesh.js +46 -12
  11. package/dist/lib/core-utils/sentry-report.d.ts +27 -7
  12. package/dist/lib/core-utils/sentry-report.js +428 -28
  13. package/dist/lib/mesh/api.d.ts +2 -0
  14. package/dist/lib/mesh/api.js +15 -2
  15. package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
  16. package/dist/lib/mesh/live/daemon/doctor.js +10 -2
  17. package/dist/lib/mesh/live/daemon/run.js +3 -0
  18. package/dist/lib/mesh/live/emit-client.d.ts +2 -0
  19. package/dist/lib/mesh/live/emit-client.js +15 -8
  20. package/dist/lib/mesh/live/emit.d.ts +9 -0
  21. package/dist/lib/mesh/live/emit.js +28 -2
  22. package/dist/lib/mesh/live/flush.js +9 -3
  23. package/dist/lib/search-index/index.js +22 -3
  24. package/dist/lib/work-context/preflight-contract.d.ts +28 -0
  25. package/dist/lib/work-context/preflight-contract.js +85 -0
  26. package/dist/main.js +11 -1
  27. package/dist/utils/cognito-session.d.ts +39 -0
  28. package/dist/utils/cognito-session.js +119 -3
  29. package/dist/utils/environmental-error.d.ts +18 -0
  30. package/dist/utils/environmental-error.js +240 -7
  31. package/dist/utils/expected-cli-error.d.ts +2 -0
  32. package/dist/utils/expected-cli-error.js +4 -0
  33. package/dist/utils/vault-api.d.ts +1 -0
  34. package/dist/utils/vault-api.js +11 -3
  35. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.131.0] — 2026-09-22
6
+
7
+ ### Added
8
+
9
+ - `hq files share` can post the share record to a project channel instead of a
10
+ DM: `--channel <name>` names the channel and `--mention <person>` (repeatable)
11
+ tags people in it. Using `--mention` without `--channel` is an error. If you
12
+ or a mentioned person are not members of that channel, access is still
13
+ granted and the record falls back to a DM, with a short note on the CLI.
14
+ - Offline preflight results the CLI can emit are published at
15
+ `contracts/preflight/v1/schema.json` and `fixtures.json` (included in the
16
+ package). `hq mesh contract preflight --print-fixtures` prints the same
17
+ document. A test fails if the committed fixtures drift from the emitter.
18
+ contractVersion stays pinned at 1 until hq-core's vendored copy moves with it.
19
+
20
+ ### Fixed
21
+
22
+ - Expected caller errors from company resolution, membership, and qmd lookups
23
+ no longer create Sentry issues. Windows filesystem failures remain visible
24
+ and now include redacted path context plus a sync-aware diagnostic.
25
+
26
+ - An acknowledged Work Mesh register keeps the company on the session even
27
+ when there is no project, or a project and no task. The next flush posts a
28
+ held event for that session instead of leaving it held for lack of a company.
29
+ Tests cover each acked-register branch.
30
+
31
+ - `hq mesh project set` sends the project version it just read (`If-Match` and
32
+ `expectedVersion`). A 409 prints "The Board changed while you were editing.
33
+ Re-run to pick up the latest." and exits non-zero. `--json` includes
34
+ `VERSION_CONFLICT`.
35
+
36
+ - `hq mesh session` prints each rejected event's server code (and `--json`
37
+ includes `rejections`). A flush dead-letter row for a per-event rejection
38
+ stores that code next to `reason`.
39
+
40
+ - A Cognito refresh that comes back for a different subject is refused and the
41
+ active `cognito-tokens.json` is left unchanged. `hq mesh daemon doctor`
42
+ prints the whoami subject and the subject the daemon is using.
43
+
44
+ ## [5.130.0] — 2026-09-22
45
+
46
+ ### Fixed
47
+
48
+ - Hook-timeout reports remain visible as warning events with stable grouping and
49
+ diagnostic tags. Repeated reports are rate-limited while the next capture
50
+ records the suppressed count.
51
+
5
52
  ## [5.129.0] — 2026-09-22
6
53
 
7
54
  ### Added
@@ -305,7 +352,11 @@
305
352
  with its tenant intact, the write reflects the true classification, and no
306
353
  `projectId`, `taskId`, or binding episode is fabricated on partial paths.
307
354
  Note: a company resolved by slug only (no uid) is still not attributed on
308
- either path.
355
+ either path. Tests disable each of those two writers in turn
356
+ (`writeAckedRegisterState`, and the post-ack `writeSessionState`) and
357
+ require the other one to still land the company for a company-only ack
358
+ and a company-plus-project ack. The production write itself landed in
359
+ `28aee9e` (#670).
309
360
 
310
361
  - The setup bot no longer posts the same question twice when a turn takes a
311
362
  while: a question asked in an interim update is held back if the final
@@ -0,0 +1,87 @@
1
+ {
2
+ "contractVersion": 1,
3
+ "source": "hq-cli contracts/preflight/v1/fixtures.json",
4
+ "classifications": [
5
+ "unresolved",
6
+ "needs_company",
7
+ "company_conflict",
8
+ "needs_project",
9
+ "needs_task",
10
+ "bound",
11
+ "untracked"
12
+ ],
13
+ "fixtures": [
14
+ {
15
+ "contractVersion": 1,
16
+ "kind": "queued",
17
+ "classification": "unresolved",
18
+ "delivery": "queued",
19
+ "lifecycle": "open",
20
+ "sessionId": "sid-preflight-fixture",
21
+ "clientOperationId": "op-preflight-fixture"
22
+ },
23
+ {
24
+ "contractVersion": 1,
25
+ "kind": "needs_company",
26
+ "classification": "needs_company",
27
+ "delivery": "clean",
28
+ "lifecycle": "open",
29
+ "sessionId": "sid-preflight-fixture",
30
+ "clientOperationId": "op-preflight-fixture"
31
+ },
32
+ {
33
+ "contractVersion": 1,
34
+ "kind": "company_conflict",
35
+ "classification": "company_conflict",
36
+ "delivery": "clean",
37
+ "lifecycle": "open",
38
+ "sessionId": "sid-preflight-fixture",
39
+ "clientOperationId": "op-preflight-fixture"
40
+ },
41
+ {
42
+ "contractVersion": 1,
43
+ "kind": "queued",
44
+ "classification": "needs_project",
45
+ "delivery": "queued",
46
+ "lifecycle": "open",
47
+ "sessionId": "sid-preflight-fixture",
48
+ "clientOperationId": "op-preflight-fixture",
49
+ "companySlug": "acme",
50
+ "companyUid": "cmp_acme"
51
+ },
52
+ {
53
+ "contractVersion": 1,
54
+ "kind": "queued",
55
+ "classification": "needs_task",
56
+ "delivery": "queued",
57
+ "lifecycle": "open",
58
+ "sessionId": "sid-preflight-fixture",
59
+ "clientOperationId": "op-preflight-fixture",
60
+ "companySlug": "acme",
61
+ "companyUid": "cmp_acme",
62
+ "projectId": "dispatch-proj"
63
+ },
64
+ {
65
+ "contractVersion": 1,
66
+ "kind": "queued",
67
+ "classification": "bound",
68
+ "delivery": "queued",
69
+ "lifecycle": "open",
70
+ "sessionId": "sid-preflight-fixture",
71
+ "clientOperationId": "op-preflight-fixture",
72
+ "companySlug": "acme",
73
+ "companyUid": "cmp_acme",
74
+ "projectId": "dispatch-proj",
75
+ "taskId": "US-9"
76
+ },
77
+ {
78
+ "contractVersion": 1,
79
+ "kind": "untracked",
80
+ "classification": "untracked",
81
+ "delivery": "clean",
82
+ "lifecycle": "open",
83
+ "sessionId": "sid-preflight-fixture",
84
+ "clientOperationId": "op-preflight-fixture"
85
+ }
86
+ ]
87
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hq.getindigo.ai/schemas/work-context/preflight/v1/schema.json",
4
+ "title": "Offline preflight ContextResult",
5
+ "description": "Shapes hq mesh context reconcile --offline emits. Hook consumers must accept every fixture and reject any classification outside this enum. Bump contractVersion together with hq-core core/hooks/SessionStart/preflight-fixtures.json.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "contractVersion",
10
+ "kind",
11
+ "classification",
12
+ "delivery",
13
+ "lifecycle",
14
+ "sessionId",
15
+ "clientOperationId"
16
+ ],
17
+ "properties": {
18
+ "contractVersion": { "type": "integer", "const": 1 },
19
+ "kind": {
20
+ "type": "string",
21
+ "enum": [
22
+ "queued",
23
+ "needs_company",
24
+ "company_conflict",
25
+ "untracked",
26
+ "bound",
27
+ "needs_project",
28
+ "needs_task",
29
+ "error"
30
+ ]
31
+ },
32
+ "classification": {
33
+ "type": "string",
34
+ "enum": [
35
+ "unresolved",
36
+ "needs_company",
37
+ "company_conflict",
38
+ "needs_project",
39
+ "needs_task",
40
+ "bound",
41
+ "untracked"
42
+ ]
43
+ },
44
+ "delivery": {
45
+ "type": "string",
46
+ "enum": ["clean", "queued", "acked", "quarantined"]
47
+ },
48
+ "lifecycle": { "type": "string", "enum": ["open", "terminal"] },
49
+ "sessionId": { "type": "string", "minLength": 1 },
50
+ "clientOperationId": { "type": "string", "minLength": 1 },
51
+ "companySlug": { "type": "string", "minLength": 1 },
52
+ "companyUid": { "type": "string", "minLength": 1 },
53
+ "projectId": { "type": "string", "minLength": 1 },
54
+ "taskId": { "type": "string", "minLength": 1 },
55
+ "bindingEpisodeId": { "type": "string", "minLength": 1 },
56
+ "errorCode": { "type": "string", "minLength": 1 }
57
+ }
58
+ }
@@ -1677,6 +1677,12 @@ export declare const COMMAND_CATALOG: readonly [{
1677
1677
  }, {
1678
1678
  readonly flags: "--no-open";
1679
1679
  readonly description: "Print the share-session URL but do not launch the browser";
1680
+ }, {
1681
+ readonly flags: "--channel <name>";
1682
+ readonly description: "Post the share record to this named channel instead of a DM";
1683
+ }, {
1684
+ readonly flags: "--mention <person>";
1685
+ readonly description: "Mention a person on the channel share record (repeatable)";
1680
1686
  }];
1681
1687
  readonly subcommands: readonly [];
1682
1688
  }, {
@@ -5984,6 +5990,27 @@ export declare const COMMAND_CATALOG: readonly [{
5984
5990
  }];
5985
5991
  readonly subcommands: readonly [];
5986
5992
  }];
5993
+ }, {
5994
+ readonly name: "contract";
5995
+ readonly description: "Print versioned work-mesh contracts for other repositories";
5996
+ readonly aliases: readonly [];
5997
+ readonly hidden: false;
5998
+ readonly usage: "[options] [command]";
5999
+ readonly arguments: readonly [];
6000
+ readonly options: readonly [];
6001
+ readonly subcommands: readonly [{
6002
+ readonly name: "preflight";
6003
+ readonly description: "Offline preflight ContextResult contract (contracts/preflight/v1)";
6004
+ readonly aliases: readonly [];
6005
+ readonly hidden: false;
6006
+ readonly usage: "[options]";
6007
+ readonly arguments: readonly [];
6008
+ readonly options: readonly [{
6009
+ readonly flags: "--print-fixtures";
6010
+ readonly description: "Write the fixture file JSON to stdout";
6011
+ }];
6012
+ readonly subcommands: readonly [];
6013
+ }];
5987
6014
  }, {
5988
6015
  readonly name: "session";
5989
6016
  readonly description: "Local session-event spool: enqueue (printf-compatible), flush, and live status";
@@ -2138,6 +2138,14 @@ export const COMMAND_CATALOG = [
2138
2138
  {
2139
2139
  "flags": "--no-open",
2140
2140
  "description": "Print the share-session URL but do not launch the browser"
2141
+ },
2142
+ {
2143
+ "flags": "--channel <name>",
2144
+ "description": "Post the share record to this named channel instead of a DM"
2145
+ },
2146
+ {
2147
+ "flags": "--mention <person>",
2148
+ "description": "Mention a person on the channel share record (repeatable)"
2141
2149
  }
2142
2150
  ],
2143
2151
  "subcommands": []
@@ -7725,6 +7733,32 @@ export const COMMAND_CATALOG = [
7725
7733
  }
7726
7734
  ]
7727
7735
  },
7736
+ {
7737
+ "name": "contract",
7738
+ "description": "Print versioned work-mesh contracts for other repositories",
7739
+ "aliases": [],
7740
+ "hidden": false,
7741
+ "usage": "[options] [command]",
7742
+ "arguments": [],
7743
+ "options": [],
7744
+ "subcommands": [
7745
+ {
7746
+ "name": "preflight",
7747
+ "description": "Offline preflight ContextResult contract (contracts/preflight/v1)",
7748
+ "aliases": [],
7749
+ "hidden": false,
7750
+ "usage": "[options]",
7751
+ "arguments": [],
7752
+ "options": [
7753
+ {
7754
+ "flags": "--print-fixtures",
7755
+ "description": "Write the fixture file JSON to stdout"
7756
+ }
7757
+ ],
7758
+ "subcommands": []
7759
+ }
7760
+ ]
7761
+ },
7728
7762
  {
7729
7763
  "name": "session",
7730
7764
  "description": "Local session-event spool: enqueue (printf-compatible), flush, and live status",
@@ -483,7 +483,13 @@ export function registerCoreCommands(program) {
483
483
  .action(async (options) => {
484
484
  const stdin = await readSentryReportStdin(process.stdin);
485
485
  const timeoutMs = options.timeoutMs === undefined ? undefined : Number(options.timeoutMs);
486
- const result = await reportSentryEvent(stdin ?? "", { dryRun: options.dryRun, timeoutMs });
486
+ const scope = core.opts();
487
+ const hqRoot = scope.hqRoot ? resolveLiveRoot({ hqRoot: scope.hqRoot }) : undefined;
488
+ const result = await reportSentryEvent(stdin ?? "", {
489
+ dryRun: options.dryRun,
490
+ timeoutMs,
491
+ hqRoot,
492
+ });
487
493
  if (result.code !== 0)
488
494
  process.exitCode = result.code;
489
495
  });
@@ -190,6 +190,8 @@ export declare function buildConnectionActionBody(identifier: string, matched: C
190
190
  } | {
191
191
  withEmail: string;
192
192
  };
193
+ /** Fetch the caller's channels (GET /v1/notify/channels). */
194
+ export declare function fetchChannels(token: string): Promise<ChannelSummary[]>;
193
195
  /** One incoming DM as returned by GET /v1/notify/inbox. */
194
196
  export interface DmInboxEvent {
195
197
  eventId: string;
@@ -489,7 +489,7 @@ async function runGroupSend(recipients, message) {
489
489
  }
490
490
  }
491
491
  /** Fetch the caller's channels (GET /v1/notify/channels). */
492
- async function fetchChannels(token) {
492
+ export async function fetchChannels(token) {
493
493
  const res = await vaultApiFetch({ token, path: "/v1/notify/channels" });
494
494
  if (!res.ok) {
495
495
  const err = (await res.json().catch(() => ({})));
@@ -8,6 +8,13 @@ import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_pattern
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";
11
+ import { fetchChannels, matchChannelsByName } from "./dm.js";
12
+ import { resolveCompanySlug, resolvePersonWithRosterFallback, } from "./people.js";
13
+ import { findHqRoot } from "../utils/manifest.js";
14
+ const PERSON_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
15
+ function collectMentions(value, previous) {
16
+ return [...previous, value];
17
+ }
11
18
  // ---------------------------------------------------------------------------
12
19
  // Pure helpers (exported for unit tests)
13
20
  // ---------------------------------------------------------------------------
@@ -129,8 +136,23 @@ export function registerFilesCommand(program) {
129
136
  .option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
130
137
  .option("--expires <duration>", "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.")
131
138
  .option("--no-open", "Print the share-session URL but do not launch the browser")
139
+ .option("--channel <name>", "Post the share record to this named channel instead of a DM")
140
+ .option("--mention <person>", "Mention a person on the channel share record (repeatable)", collectMentions, [])
132
141
  .action(async (paths, opts) => {
133
142
  try {
143
+ const mentions = opts.mention ?? [];
144
+ if (mentions.length > 0 && opts.channel === undefined) {
145
+ console.error(chalk.red("--mention requires --channel <name>."));
146
+ process.exit(1);
147
+ }
148
+ const companySlug = files.opts().company;
149
+ const channelTarget = opts.channel !== undefined
150
+ ? await resolveShareChannelTarget({
151
+ channelName: opts.channel,
152
+ mentions,
153
+ companySlug,
154
+ })
155
+ : undefined;
134
156
  // Full-vault grant: a glob-safe affordance for "give this person the
135
157
  // whole vault" so admins never have to quote a `*` (an unquoted glob
136
158
  // expands to local filenames and instantly fails the one-prefix
@@ -150,8 +172,9 @@ export function registerFilesCommand(program) {
150
172
  prefix: "*",
151
173
  principal: opts.with,
152
174
  permission: opts.permission ?? "write",
153
- companySlug: files.opts().company,
175
+ companySlug,
154
176
  fullVault: true,
177
+ ...channelTarget,
155
178
  });
156
179
  return;
157
180
  }
@@ -159,9 +182,9 @@ export function registerFilesCommand(program) {
159
182
  console.error(chalk.red("usage: hq files share <paths...> [--with <principal>]"));
160
183
  process.exit(1);
161
184
  }
162
- // Fork: --with present → existing direct-grant path. No --with →
163
- // browser-launch share-session path. The direct-grant path
164
- // operates on a single prefix.
185
+ // Fork: --with present → existing direct-grant path. --channel without
186
+ // --with grants each --mention as a person. No --with and no --channel
187
+ // → browser-launch share-session path.
165
188
  if (opts.with !== undefined) {
166
189
  if (paths.length !== 1) {
167
190
  console.error(chalk.red("Direct grant (--with) takes exactly one prefix. Pass multiple paths only when minting a share-session URL."));
@@ -171,15 +194,42 @@ export function registerFilesCommand(program) {
171
194
  prefix: paths[0],
172
195
  principal: opts.with,
173
196
  permission: opts.permission,
174
- companySlug: files.opts().company,
197
+ companySlug,
198
+ ...channelTarget,
175
199
  });
176
200
  return;
177
201
  }
202
+ if (channelTarget) {
203
+ if (channelTarget.mentionPersonUids.length === 0) {
204
+ console.error(chalk.red("--channel requires --with <principal> or --mention <person>."));
205
+ process.exit(1);
206
+ }
207
+ if (paths.length !== 1) {
208
+ console.error(chalk.red("Channel share takes exactly one prefix. Pass multiple paths only when minting a share-session URL."));
209
+ process.exit(1);
210
+ }
211
+ const permission = opts.permission ?? "read";
212
+ for (const [index, uid] of channelTarget.mentionPersonUids.entries()) {
213
+ await runDirectGrant({
214
+ prefix: paths[0],
215
+ principal: uid,
216
+ permission,
217
+ companySlug,
218
+ ...(index === 0
219
+ ? {
220
+ channelId: channelTarget.channelId,
221
+ mentionPersonUids: channelTarget.mentionPersonUids,
222
+ }
223
+ : {}),
224
+ });
225
+ }
226
+ return;
227
+ }
178
228
  await runShareSession({
179
229
  paths,
180
230
  expires: opts.expires,
181
231
  launchBrowser: opts.open,
182
- companySlug: files.opts().company,
232
+ companySlug,
183
233
  });
184
234
  }
185
235
  catch (err) {
@@ -511,6 +561,64 @@ export function registerFilesCommand(program) {
511
561
  // files-browse.ts) onto the same group without re-creating it.
512
562
  return files;
513
563
  }
564
+ async function resolveShareChannelTarget(params) {
565
+ const token = (await resolveVaultCredential()).token;
566
+ const channels = await fetchChannels(token);
567
+ const matches = matchChannelsByName(channels, params.channelName);
568
+ if (matches.length === 0) {
569
+ console.error(chalk.red(`No channel named '${params.channelName}' — run \`hq channels\` to see your channels.`));
570
+ process.exit(1);
571
+ }
572
+ if (matches.length > 1) {
573
+ const scopes = matches
574
+ .map((c) => c.scope ?? "unknown")
575
+ .join(", ");
576
+ console.error(chalk.red(`'${params.channelName}' matches ${matches.length} channels (${scopes}) — this is ambiguous.`));
577
+ process.exit(1);
578
+ }
579
+ const channelId = matches[0].channelId;
580
+ const mentionPersonUids = [];
581
+ for (const mention of params.mentions) {
582
+ mentionPersonUids.push(await resolveMentionPersonUid(mention, params.companySlug));
583
+ }
584
+ return { channelId, mentionPersonUids };
585
+ }
586
+ async function resolveMentionPersonUid(mention, companySlug) {
587
+ const trimmed = mention.trim();
588
+ if (PERSON_UID_PATTERN.test(trimmed))
589
+ return trimmed;
590
+ const hqRoot = findHqRoot();
591
+ const slug = resolveCompanySlug(hqRoot, companySlug);
592
+ const result = await resolvePersonWithRosterFallback({
593
+ hqRoot,
594
+ slug,
595
+ name: trimmed,
596
+ });
597
+ if (result.status === "found" && result.person.personUid) {
598
+ return result.person.personUid;
599
+ }
600
+ if (result.status === "ambiguous") {
601
+ const names = result.matches.map((p) => p.name).join(", ");
602
+ console.error(chalk.red(`'${trimmed}' matches multiple people (${names}) — use a person uid or email.`));
603
+ process.exit(1);
604
+ }
605
+ const membership = await resolvePersonWithRosterFallback({
606
+ hqRoot,
607
+ slug,
608
+ name: trimmed,
609
+ opts: { membershipOnly: true },
610
+ });
611
+ if (membership.status === "found" && membership.person.personUid) {
612
+ return membership.person.personUid;
613
+ }
614
+ if (membership.status === "ambiguous") {
615
+ const names = membership.matches.map((p) => p.name).join(", ");
616
+ console.error(chalk.red(`'${trimmed}' matches multiple people (${names}) — use a person uid or email.`));
617
+ process.exit(1);
618
+ }
619
+ console.error(chalk.red(`Could not resolve --mention '${trimmed}' to a person.`));
620
+ process.exit(1);
621
+ }
514
622
  async function runDirectGrant(params) {
515
623
  const canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
516
624
  mutating: true,
@@ -535,11 +643,18 @@ async function runDirectGrant(params) {
535
643
  const isAll = principal === "@all";
536
644
  const isEmail = !isAll && EMAIL_PATTERN.test(principal);
537
645
  const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
538
- if (!isAll && !isEmail && !isGroup) {
539
- console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`));
646
+ const isPerson = !isAll && !isEmail && !isGroup && PERSON_UID_PATTERN.test(principal);
647
+ if (!isAll && !isEmail && !isGroup && !isPerson) {
648
+ console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid, or a group id matching grp_<alphanumeric>`));
540
649
  process.exit(1);
541
650
  }
542
- const granteeType = isAll ? "company-wide" : isEmail ? "email" : "group";
651
+ const granteeType = isAll
652
+ ? "company-wide"
653
+ : isEmail
654
+ ? "email"
655
+ : isPerson
656
+ ? "person"
657
+ : "group";
543
658
  const granteeId = isAll
544
659
  ? ""
545
660
  : isEmail
@@ -558,6 +673,10 @@ async function runDirectGrant(params) {
558
673
  granteeType,
559
674
  granteeId,
560
675
  permission: params.permission,
676
+ ...(params.channelId ? { channelId: params.channelId } : {}),
677
+ ...(params.mentionPersonUids && params.mentionPersonUids.length > 0
678
+ ? { mentionPersonUids: params.mentionPersonUids }
679
+ : {}),
561
680
  },
562
681
  });
563
682
  // No ACL row exists yet for this prefix. Auto-create one with this
@@ -572,6 +691,10 @@ async function runDirectGrant(params) {
572
691
  body: {
573
692
  prefix: canonicalPrefix,
574
693
  entries: [{ granteeType, granteeId, permission: params.permission }],
694
+ ...(params.channelId ? { channelId: params.channelId } : {}),
695
+ ...(params.mentionPersonUids && params.mentionPersonUids.length > 0
696
+ ? { mentionPersonUids: params.mentionPersonUids }
697
+ : {}),
575
698
  },
576
699
  });
577
700
  autoCreated = res.ok;
@@ -610,6 +733,11 @@ async function runDirectGrant(params) {
610
733
  else {
611
734
  console.log(chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`));
612
735
  }
736
+ if (typeof data.record?.error === "string" &&
737
+ typeof data.record.status === "number" &&
738
+ data.record.status >= 400) {
739
+ console.log(data.record.error);
740
+ }
613
741
  }
614
742
  async function runShareSession(params) {
615
743
  // Validate every path before minting a session; an anchored path must never
@@ -12,6 +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 { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
15
16
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
16
17
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
17
18
  import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
@@ -24,7 +25,7 @@ import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/liv
24
25
  const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
25
26
  import { buildEmitEvidence } from "../lib/mesh/live/emit-evidence.js";
26
27
  import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
27
- import { emitEvents } from "../lib/mesh/live/emit.js";
28
+ import { emitEvents, formatEmitRejectionLines } from "../lib/mesh/live/emit.js";
28
29
  import { readEmitRetry, writeEmitRetry } from "../lib/mesh/live/emit-retry.js";
29
30
  import { generateUlid } from "../lib/mesh/live/ulid.js";
30
31
  import { readLegacyBacklog } from "../lib/mesh/live/emit-replay.js";
@@ -300,17 +301,31 @@ async function runProjectSet(projectId, opts) {
300
301
  const { token, company } = await withCompany(opts);
301
302
  const current = await getProjectView(token, company.companyUid, projectId);
302
303
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
303
- const view = await putProjectView(token, company.companyUid, projectId, {
304
- name: opts.name?.trim() || source?.name || current.name,
305
- ...(opts.description !== undefined
306
- ? { description: opts.description }
307
- : source?.description !== undefined
308
- ? { description: source.description }
309
- : { description: current.description }),
310
- stories: source?.stories ?? current.stories,
311
- repos: explicitRepos ?? source?.repos ?? current.repos,
312
- ...(current.version !== undefined ? { version: current.version } : {}),
313
- });
304
+ const PROJECT_CHANGED = "The Board changed while you were editing. Re-run to pick up the latest.";
305
+ let view;
306
+ try {
307
+ view = await putProjectView(token, company.companyUid, projectId, {
308
+ name: opts.name?.trim() || source?.name || current.name,
309
+ ...(opts.description !== undefined
310
+ ? { description: opts.description }
311
+ : source?.description !== undefined
312
+ ? { description: source.description }
313
+ : { description: current.description }),
314
+ stories: source?.stories ?? current.stories,
315
+ repos: explicitRepos ?? source?.repos ?? current.repos,
316
+ ...(current.version !== undefined ? { expectedVersion: current.version } : {}),
317
+ });
318
+ }
319
+ catch (err) {
320
+ const conflict = err;
321
+ if (conflict.status === 409 && conflict.code === "VERSION_CONFLICT") {
322
+ if (opts.json) {
323
+ console.log(JSON.stringify({ ok: false, code: "VERSION_CONFLICT", error: PROJECT_CHANGED }));
324
+ }
325
+ fail(PROJECT_CHANGED);
326
+ }
327
+ throw err;
328
+ }
314
329
  if (opts.json) {
315
330
  console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
316
331
  return;
@@ -1108,6 +1123,9 @@ async function runSessionEnqueue(cliKind, opts) {
1108
1123
  console.error(`work-mesh: emit ${kind} accepted=${summary.accepted}` +
1109
1124
  ` unassigned=${summary.unassigned} rejected=${summary.rejected}` +
1110
1125
  ` retryDepth=${summary.retryDepth}`);
1126
+ for (const line of formatEmitRejectionLines(summary.rejections)) {
1127
+ console.error(line);
1128
+ }
1111
1129
  }
1112
1130
  async function runMeshMode(action, opts) {
1113
1131
  const meshRoot = workMeshHomeRoot();
@@ -1192,6 +1210,9 @@ async function runMeshEmit(opts) {
1192
1210
  ` unassigned=${summary.unassigned} rejected=${summary.rejected}` +
1193
1211
  ` retryDepth=${summary.retryDepth}` +
1194
1212
  (opts.replayLegacy ? ` (legacy scanned=${scanned} skipped=${skipped})` : ""));
1213
+ for (const line of formatEmitRejectionLines(summary.rejections)) {
1214
+ console.log(line);
1215
+ }
1195
1216
  }
1196
1217
  async function runSessionFlush(opts) {
1197
1218
  const token = await requireToken();
@@ -1419,6 +1440,19 @@ export function registerMeshCommand(program) {
1419
1440
  .argument("<sessionId>", "Canonical session id")
1420
1441
  .option("--machine", "Write exactly one ContextResult JSON line to stdout")
1421
1442
  .action((sessionId, opts) => wrap(() => runContextUntracked(sessionId, opts))());
1443
+ const contract = mesh
1444
+ .command("contract")
1445
+ .description("Print versioned work-mesh contracts for other repositories");
1446
+ contract
1447
+ .command("preflight")
1448
+ .description("Offline preflight ContextResult contract (contracts/preflight/v1)")
1449
+ .option("--print-fixtures", "Write the fixture file JSON to stdout")
1450
+ .action((opts) => wrap(async () => {
1451
+ if (!opts.printFixtures) {
1452
+ throw new Error("Pass --print-fixtures. The stable package path is contracts/preflight/v1/fixtures.json.");
1453
+ }
1454
+ process.stdout.write(`${JSON.stringify(buildPreflightFixtures(), null, 2)}\n`);
1455
+ })());
1422
1456
  const session = mesh
1423
1457
  .command("session")
1424
1458
  .description("Local session-event spool: enqueue (printf-compatible), flush, and live status");