@indigoai-us/hq-cli 5.119.8 → 5.119.10

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.
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * `hq skill create <slug>` registers a canonical company skill, stamps its
5
5
  * immutable UID, surfaces its generated runtime wrapper, and syncs it.
6
+ * `hq skill register <slug>` reserves that UID from a proposal-lane source and
7
+ * writes server-stamped bytes only to `--stamped-output` — never under
8
+ * `companies/<company>/skills/`, and never as a discovery wrapper or sync.
9
+ * `hq skill promote <slug>` copies independently-cleared stamped bytes into the
10
+ * canonical tree only after the expected SHA-256 and registered skill_uid match.
6
11
  * `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
7
12
  * the same improvement thread shown in HQ Console. It never uploads a modified
8
13
  * SKILL.md and cannot overwrite live content. `hq skill delete <target>` removes
@@ -11,6 +16,7 @@
11
16
  */
12
17
  import * as fs from "node:fs";
13
18
  import * as path from "node:path";
19
+ import { createHash, timingSafeEqual } from "node:crypto";
14
20
  import chalk from "chalk";
15
21
  import yaml from "js-yaml";
16
22
  import { share } from "@indigoai-us/hq-cloud";
@@ -25,6 +31,7 @@ export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
25
31
  export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
26
32
  const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
27
33
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
34
+ const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
28
35
  async function defaultSyncFile(input) {
29
36
  const result = await share({
30
37
  paths: [input.filePath],
@@ -185,11 +192,19 @@ export function writeSkillFileAtomically(filePath, content) {
185
192
  : 0o644;
186
193
  const tempPath = path.join(dir, `.SKILL.md.${process.pid}.${Date.now()}.tmp`);
187
194
  try {
188
- fs.writeFileSync(tempPath, content, {
189
- encoding: "utf8",
190
- flag: "wx",
191
- mode: existingMode,
192
- });
195
+ if (typeof content === "string") {
196
+ fs.writeFileSync(tempPath, content, {
197
+ encoding: "utf8",
198
+ flag: "wx",
199
+ mode: existingMode,
200
+ });
201
+ }
202
+ else {
203
+ fs.writeFileSync(tempPath, content, {
204
+ flag: "wx",
205
+ mode: existingMode,
206
+ });
207
+ }
193
208
  fs.renameSync(tempPath, filePath);
194
209
  }
195
210
  finally {
@@ -197,6 +212,69 @@ export function writeSkillFileAtomically(filePath, content) {
197
212
  fs.rmSync(tempPath, { force: true });
198
213
  }
199
214
  }
215
+ export function sha256Hex(data) {
216
+ return createHash("sha256").update(data).digest("hex");
217
+ }
218
+ export function skillVaultPath(skillSlug) {
219
+ return `skills/${skillSlug}/SKILL.md`;
220
+ }
221
+ export function isInsideCompanySkillsTree(hqRoot, candidatePath) {
222
+ const companiesRoot = path.resolve(hqRoot, "companies");
223
+ const resolved = path.resolve(candidatePath);
224
+ const rel = path.relative(companiesRoot, resolved);
225
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
226
+ return false;
227
+ const parts = rel.split(path.sep);
228
+ return parts.length >= 2 && parts[1] === "skills";
229
+ }
230
+ export function resolveSkillMarkdownPath(target, cwd) {
231
+ let filePath = path.resolve(cwd, target);
232
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
233
+ filePath = path.join(filePath, "SKILL.md");
234
+ }
235
+ return filePath;
236
+ }
237
+ export function assertStagedOutputPath(hqRoot, outputPath) {
238
+ const resolved = path.resolve(outputPath);
239
+ if (isInsideCompanySkillsTree(hqRoot, resolved)) {
240
+ throw localSkillError(`Stamped output '${resolved}' is inside companies/<company>/skills/. Pass a proposal-lane path outside the canonical skill tree.`);
241
+ }
242
+ return resolved;
243
+ }
244
+ export function assertSha256HexMatch(actualHex, expectedHex) {
245
+ const want = expectedHex.trim().toLowerCase();
246
+ if (!SHA256_HEX_PATTERN.test(want)) {
247
+ throw localSkillError("--expected-sha256 must be a 64-character hex SHA-256 digest.");
248
+ }
249
+ const got = actualHex.trim().toLowerCase();
250
+ if (!SHA256_HEX_PATTERN.test(got)) {
251
+ throw localSkillError(`Stamped file SHA-256 ${got} does not match --expected-sha256. Canonical skill was not written.`);
252
+ }
253
+ if (!timingSafeEqual(Buffer.from(got, "hex"), Buffer.from(want, "hex"))) {
254
+ throw localSkillError(`Stamped file SHA-256 ${got} does not match --expected-sha256. Canonical skill was not written.`);
255
+ }
256
+ }
257
+ export function assertRegisteredSkillResponse(registered) {
258
+ if (typeof registered.skillUid !== "string" ||
259
+ !SKILL_UID_PATTERN.test(registered.skillUid) ||
260
+ typeof registered.content !== "string" ||
261
+ parseSkillUid(registered.content) !== registered.skillUid) {
262
+ throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
263
+ }
264
+ return { skillUid: registered.skillUid, content: registered.content };
265
+ }
266
+ function skillRecordPath(record) {
267
+ for (const value of [record?.vaultPath, record?.sourcePath, record?.path]) {
268
+ if (typeof value === "string" && value.length > 0)
269
+ return value;
270
+ }
271
+ return undefined;
272
+ }
273
+ export function normalizeLiveSkillVaultPath(rawPath) {
274
+ const normalized = rawPath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
275
+ const match = normalized.match(/(?:^|\/)skills\/([a-z0-9][a-z0-9-]*)(?:\/SKILL\.md)?$/);
276
+ return match ? skillVaultPath(match[1]) : normalized;
277
+ }
200
278
  export function mapSkillError(status, body) {
201
279
  const server = (typeof body.error === "string" && body.error) ||
202
280
  (typeof body.message === "string" && body.message) ||
@@ -360,7 +438,7 @@ export function registerSkillCommand(program, deps = {}) {
360
438
  const resolveCompanyUid = deps.resolveCompanyUid ?? getCompanyUid;
361
439
  const skill = program
362
440
  .command("skill")
363
- .description("Create company skills and discuss improvements")
441
+ .description("Create, stage, promote, and discuss company skills")
364
442
  .option("--company <slug>", "Company slug (defaults to the active company)")
365
443
  .option("--hq-root <path>", "Local HQ root", hqRoot);
366
444
  skill
@@ -420,7 +498,7 @@ export function registerSkillCommand(program, deps = {}) {
420
498
  path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/register`,
421
499
  method: "POST",
422
500
  body: {
423
- path: `skills/${slug}/SKILL.md`,
501
+ path: skillVaultPath(slug),
424
502
  content: localContent,
425
503
  },
426
504
  });
@@ -430,13 +508,8 @@ export function registerSkillCommand(program, deps = {}) {
430
508
  machineIdentity: isMachineIdentity(),
431
509
  });
432
510
  }
433
- const registered = (await response.json());
434
- if (typeof registered.skillUid !== "string" ||
435
- !SKILL_UID_PATTERN.test(registered.skillUid) ||
436
- typeof registered.content !== "string" ||
437
- parseSkillUid(registered.content) !== registered.skillUid) {
438
- throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
439
- }
511
+ const payload = (await response.json());
512
+ const registered = assertRegisteredSkillResponse(payload);
440
513
  writeSkillFileAtomically(filePath, registered.content);
441
514
  let discoveryStatus = null;
442
515
  try {
@@ -506,11 +579,173 @@ export function registerSkillCommand(program, deps = {}) {
506
579
  console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
507
580
  console.log(` File: ${filePath}`);
508
581
  console.log(` Discovery: ${discoveryStatus === 0 ? "ready" : "needs attention"}`);
509
- console.log(registered.accessPolicy === "open"
582
+ console.log(payload.accessPolicy === "open"
510
583
  ? ` Access: Open — every active ${companySlug} member can edit`
511
584
  : " Access: preserved existing policy");
512
585
  console.log(` Sync: ${opts.sync === false ? "not requested" : "complete"}`);
513
586
  });
587
+ skill
588
+ .command("register <slug>")
589
+ .description("Register a company skill to a proposal-lane stamped file without writing the canonical tree")
590
+ .requiredOption("--source <path>", "Caller-authored SKILL.md (or its directory) to send to the server validator")
591
+ .requiredOption("--stamped-output <path>", "Write the complete server-stamped bytes here, outside companies/<company>/skills/")
592
+ .option("--no-canonical-write", "Refuse to write companies/<company>/skills/ (always the register behavior)")
593
+ .option("--no-surface", "Do not create a discovery wrapper (always the register behavior)")
594
+ .option("--no-sync", "Do not upload the skill file (always the register behavior)")
595
+ .action(async (slug, opts) => {
596
+ if (!SKILL_SLUG_PATTERN.test(slug)) {
597
+ throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
598
+ }
599
+ const parentOpts = skill.opts();
600
+ const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
601
+ const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
602
+ canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
603
+ const sourcePath = resolveSkillMarkdownPath(opts.source, cwd());
604
+ if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
605
+ throw localSkillError(`No SKILL.md found at '${opts.source}'. Pass --source <file> or a directory that contains SKILL.md.`);
606
+ }
607
+ const stampedOutputPath = assertStagedOutputPath(resolvedRoot, path.resolve(cwd(), opts.stampedOutput));
608
+ const localContent = fs.readFileSync(sourcePath, "utf8");
609
+ const sourceHash = sha256Hex(localContent);
610
+ const token = await ensureToken();
611
+ const response = await apiFetch({
612
+ token,
613
+ path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/register`,
614
+ method: "POST",
615
+ body: {
616
+ path: skillVaultPath(slug),
617
+ content: localContent,
618
+ },
619
+ });
620
+ if (!response.ok) {
621
+ const body = (await response.json().catch(() => ({})));
622
+ throw skillApiError(response.status, body, {
623
+ machineIdentity: isMachineIdentity(),
624
+ });
625
+ }
626
+ const registered = assertRegisteredSkillResponse((await response.json()));
627
+ writeSkillFileAtomically(stampedOutputPath, registered.content);
628
+ const stampedHash = sha256Hex(registered.content);
629
+ console.log(chalk.green(`Skill registered (staged): ${registered.skillUid}`));
630
+ console.log(` Source: ${sourcePath}`);
631
+ console.log(` Source SHA-256: ${sourceHash}`);
632
+ console.log(` Stamped output: ${stampedOutputPath}`);
633
+ console.log(` Stamped SHA-256: ${stampedHash}`);
634
+ console.log(" Canonical write: not requested");
635
+ console.log(" Discovery: not requested");
636
+ console.log(" Sync: not requested");
637
+ });
638
+ skill
639
+ .command("promote <slug>")
640
+ .description("Copy independently-cleared stamped bytes into the canonical skill path after SHA-256 and skill_uid checks")
641
+ .requiredOption("--from <path>", "Independently-cleared stamped SKILL.md (or its directory)")
642
+ .requiredOption("--expected-sha256 <hex>", "SHA-256 of the exact stamped file bytes that were cleared")
643
+ .option("--reviewed-update", "Replace an existing canonical SKILL.md whose bytes differ from the stamped file")
644
+ .option("--no-sync", "Promote locally without uploading the canonical file")
645
+ .action(async (slug, opts) => {
646
+ if (!SKILL_SLUG_PATTERN.test(slug)) {
647
+ throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
648
+ }
649
+ const parentOpts = skill.opts();
650
+ const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
651
+ const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
652
+ const canonicalPath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
653
+ const fromPath = resolveSkillMarkdownPath(opts.from, cwd());
654
+ if (!fs.existsSync(fromPath) || !fs.statSync(fromPath).isFile()) {
655
+ throw localSkillError(`No stamped SKILL.md found at '${opts.from}'. Pass --from <file> or a directory that contains SKILL.md.`);
656
+ }
657
+ const stampedBytes = fs.readFileSync(fromPath);
658
+ assertSha256HexMatch(sha256Hex(stampedBytes), opts.expectedSha256);
659
+ const stampedContent = stampedBytes.toString("utf8");
660
+ const skillUid = parseSkillUid(stampedContent);
661
+ if (!skillUid) {
662
+ throw localSkillError(`The stamped SKILL.md at '${fromPath}' has no registered skill_uid. Canonical skill was not written.`);
663
+ }
664
+ const token = await ensureToken();
665
+ const companyUid = await resolveCompanyUid(token, companySlug);
666
+ const detail = await apiFetch({
667
+ token,
668
+ path: `/v1/skills/${encodeURIComponent(companyUid)}/${encodeURIComponent(skillUid)}`,
669
+ method: "GET",
670
+ });
671
+ if (!detail.ok) {
672
+ const body = (await detail.json().catch(() => ({})));
673
+ throw skillApiError(detail.status, body, {
674
+ machineIdentity: isMachineIdentity(),
675
+ });
676
+ }
677
+ const { skill: record } = (await detail.json());
678
+ if (typeof record?.skillUid === "string" &&
679
+ record.skillUid !== skillUid) {
680
+ throw localSkillError(`Stamped skill_uid ${skillUid} does not match the server registration. Canonical skill was not written.`);
681
+ }
682
+ const recordPath = skillRecordPath(record);
683
+ if (recordPath &&
684
+ normalizeLiveSkillVaultPath(recordPath) !== skillVaultPath(slug)) {
685
+ throw localSkillError(`Stamped skill_uid ${skillUid} is registered at '${recordPath}', not '${skillVaultPath(slug)}'. Canonical skill was not written.`);
686
+ }
687
+ if (fs.existsSync(canonicalPath) && !fs.statSync(canonicalPath).isFile()) {
688
+ throw localSkillError(`Expected a SKILL.md file at '${canonicalPath}'.`);
689
+ }
690
+ const existing = fs.existsSync(canonicalPath)
691
+ ? fs.readFileSync(canonicalPath)
692
+ : null;
693
+ if (existing && !existing.equals(stampedBytes)) {
694
+ if (opts.reviewedUpdate !== true) {
695
+ throw localSkillError(`Canonical skill '${canonicalPath}' already exists and differs from the stamped file. Re-run with --reviewed-update after that exact change is cleared.`);
696
+ }
697
+ }
698
+ if (!existing || !existing.equals(stampedBytes)) {
699
+ writeSkillFileAtomically(canonicalPath, stampedBytes);
700
+ }
701
+ const legacyPrefix = readCompanyPrefix(resolvedRoot, companySlug);
702
+ let discoveryStatus = null;
703
+ try {
704
+ discoveryStatus = surfaceSkillFn({
705
+ hqRoot: resolvedRoot,
706
+ companySlug,
707
+ skillSlug: slug,
708
+ ...(legacyPrefix ? { legacyPrefix } : {}),
709
+ }).status;
710
+ }
711
+ catch (err) {
712
+ console.warn(chalk.yellow(`⚠ Skill promoted, but local discovery failed: ${err instanceof Error ? err.message : String(err)}`));
713
+ }
714
+ if (discoveryStatus !== null && discoveryStatus !== 0) {
715
+ console.warn(chalk.yellow(`⚠ Skill promoted, but local discovery exited ${discoveryStatus}. Run 'hq skill --company ${companySlug} create ${slug} --surface-only' to retry without another registration request.`));
716
+ }
717
+ if (opts.sync !== false) {
718
+ let syncResult;
719
+ try {
720
+ syncResult = await syncFile({
721
+ filePath: canonicalPath,
722
+ companySlug,
723
+ hqRoot: resolvedRoot,
724
+ token,
725
+ });
726
+ }
727
+ catch (err) {
728
+ console.warn(chalk.yellow(`⚠ Skill ${skillUid} is stamped locally at '${canonicalPath}', but sync failed.`));
729
+ if (err && typeof err === "object") {
730
+ const diagnostics = vaultAccessDeniedDiagnostics(err, {
731
+ companySlug,
732
+ objectKey: skillVaultPath(slug),
733
+ });
734
+ if (diagnostics)
735
+ stampVaultAccessDenied(err, diagnostics);
736
+ }
737
+ throw err;
738
+ }
739
+ if (syncResult.aborted) {
740
+ throw localSkillError(`Skill ${skillUid} is stamped locally at '${canonicalPath}', but sync aborted because the remote file conflicts.`);
741
+ }
742
+ }
743
+ console.log(chalk.green(`Skill promoted: ${skillUid}`));
744
+ console.log(` File: ${canonicalPath}`);
745
+ console.log(` SHA-256: ${sha256Hex(stampedBytes)}`);
746
+ console.log(` Discovery: ${discoveryStatus === 0 ? "ready" : "needs attention"}`);
747
+ console.log(` Sync: ${opts.sync === false ? "not requested" : "complete"}`);
748
+ });
514
749
  skill
515
750
  .command("propose <target>")
516
751
  .description("Post a comment-only improvement for a skill")
@@ -21,9 +21,9 @@ export interface WhoamiDisplayIdentity {
21
21
  */
22
22
  export declare function resolveWhoamiIdentity(identity: WhoamiTokenIdentity): WhoamiDisplayIdentity;
23
23
  /**
24
- * Human rendering of the plan state: the full WORKSPACE LOCKED block when the
25
- * workspace is locked, the one-line `Plan: Starter — …` orientation when it is
26
- * Starter and healthy, and nothing at all otherwise.
24
+ * Human rendering of the plan state: the full over-limit block when the
25
+ * workspace is over its Starter limits, the one-line `Plan: Starter — …`
26
+ * orientation when it is Starter and healthy, and nothing at all otherwise.
27
27
  */
28
28
  export declare function renderWhoamiPlanBlock(status: PlanLockStatus): string | null;
29
29
  export declare function registerWhoamiCommand(program: Command): void;
@@ -5,7 +5,7 @@ import chalk from 'chalk';
5
5
  import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq-cloud';
6
6
  import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
7
7
  import { ensureCognitoToken, loadMachineCachedTokens, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
8
- import { colorizePlanLockNotice, fetchPlanLockStatus, renderPlanLine, renderPlanLockNotice, } from "../lib/billing/plan-lock.js";
8
+ import { colorizePlanLimitNotice, fetchPlanLockStatus, renderPlanLine, renderPlanLimitNotice, } from "../lib/billing/plan-lock.js";
9
9
  /**
10
10
  * Resolves the person represented by an ID token without pairing a delegated
11
11
  * email with the token subject of the delegating machine.
@@ -69,13 +69,13 @@ async function readPlanLock(company) {
69
69
  }
70
70
  }
71
71
  /**
72
- * Human rendering of the plan state: the full WORKSPACE LOCKED block when the
73
- * workspace is locked, the one-line `Plan: Starter — …` orientation when it is
74
- * Starter and healthy, and nothing at all otherwise.
72
+ * Human rendering of the plan state: the full over-limit block when the
73
+ * workspace is over its Starter limits, the one-line `Plan: Starter — …`
74
+ * orientation when it is Starter and healthy, and nothing at all otherwise.
75
75
  */
76
76
  export function renderWhoamiPlanBlock(status) {
77
77
  if (status.lock.locked) {
78
- return colorizePlanLockNotice(renderPlanLockNotice(status));
78
+ return colorizePlanLimitNotice(renderPlanLimitNotice(status));
79
79
  }
80
80
  return renderPlanLine(status);
81
81
  }
@@ -21,15 +21,35 @@ export declare function markInboxDone(paths: Pick<AgentKitPaths, "inboxDir">, id
21
21
  /** Every mirrored entry, oldest first, de-duplicated by id (last write wins). */
22
22
  export declare function readMirroredInbox(paths: Pick<AgentKitPaths, "inboxDir">): MirroredInboxEntry[];
23
23
  export declare function pendingInbox(paths: Pick<AgentKitPaths, "inboxDir">): MirroredInboxEntry[];
24
- /** Compact view for bots: who, when, which channel, and the text. */
24
+ /** One file reference carried on an inbox item (US-007 chat attachments). */
25
+ export interface InboxAttachment {
26
+ vaultPath: string;
27
+ name?: string;
28
+ contentType?: string;
29
+ sizeBytes?: number;
30
+ companyUid?: string;
31
+ }
32
+ /**
33
+ * The file references on an inbox entry, plural `attachments[]` first and the
34
+ * legacy singular `attachment` as a fallback. Entries without a `vaultPath` are
35
+ * dropped: a reference the bot cannot fetch is worse than none. Pure.
36
+ */
37
+ export declare function inboxEntryAttachments(e: MirroredInboxEntry): InboxAttachment[];
38
+ /**
39
+ * Compact view for bots: who, when, which channel, the text, and any attached
40
+ * files. The attachments belong here because this summary IS the message as far
41
+ * as a bot is concerned — omitting them made an agent answer "no image came
42
+ * through" for a DM that carried a screenshot.
43
+ */
25
44
  export declare function summarizeInboxEntry(e: MirroredInboxEntry, done: boolean): {
45
+ done: boolean;
46
+ attachments?: InboxAttachment[] | undefined;
26
47
  id: string;
27
48
  channel: string | undefined;
28
49
  from: string;
29
50
  fromUid: string | undefined;
30
51
  at: string | undefined;
31
52
  text: string | undefined;
32
- done: boolean;
33
53
  };
34
54
  /**
35
55
  * The agent's inbox as the bot should see it: the kit's local mirror merged
@@ -65,7 +65,44 @@ export function pendingInbox(paths) {
65
65
  const done = readDoneIds(paths);
66
66
  return readMirroredInbox(paths).filter((e) => !done.has(e.id));
67
67
  }
68
- /** Compact view for bots: who, when, which channel, and the text. */
68
+ /**
69
+ * The file references on an inbox entry, plural `attachments[]` first and the
70
+ * legacy singular `attachment` as a fallback. Entries without a `vaultPath` are
71
+ * dropped: a reference the bot cannot fetch is worse than none. Pure.
72
+ */
73
+ export function inboxEntryAttachments(e) {
74
+ const raw = Array.isArray(e.attachments)
75
+ ? e.attachments
76
+ : e.attachment
77
+ ? [e.attachment]
78
+ : [];
79
+ const out = [];
80
+ for (const entry of raw) {
81
+ if (!entry || typeof entry !== "object")
82
+ continue;
83
+ const rec = entry;
84
+ const vaultPath = typeof rec.vaultPath === "string" ? rec.vaultPath.trim() : "";
85
+ if (!vaultPath)
86
+ continue;
87
+ const str = (k) => typeof rec[k] === "string" && rec[k].trim()
88
+ ? rec[k].trim()
89
+ : undefined;
90
+ out.push({
91
+ vaultPath,
92
+ ...(str("name") ? { name: str("name") } : {}),
93
+ ...(str("contentType") ? { contentType: str("contentType") } : {}),
94
+ ...(typeof rec.sizeBytes === "number" ? { sizeBytes: rec.sizeBytes } : {}),
95
+ ...(str("companyUid") ? { companyUid: str("companyUid") } : {}),
96
+ });
97
+ }
98
+ return out;
99
+ }
100
+ /**
101
+ * Compact view for bots: who, when, which channel, the text, and any attached
102
+ * files. The attachments belong here because this summary IS the message as far
103
+ * as a bot is concerned — omitting them made an agent answer "no image came
104
+ * through" for a DM that carried a screenshot.
105
+ */
69
106
  export function summarizeInboxEntry(e, done) {
70
107
  const pick = (...keys) => {
71
108
  for (const k of keys)
@@ -73,6 +110,7 @@ export function summarizeInboxEntry(e, done) {
73
110
  return e[k];
74
111
  return undefined;
75
112
  };
113
+ const attachments = inboxEntryAttachments(e);
76
114
  return {
77
115
  id: e.id,
78
116
  channel: pick("channel"),
@@ -80,6 +118,7 @@ export function summarizeInboxEntry(e, done) {
80
118
  fromUid: pick("fromPersonUid"),
81
119
  at: pick("receivedAt", "createdAt", "mirroredAt"),
82
120
  text: pick("text", "body"),
121
+ ...(attachments.length ? { attachments } : {}),
83
122
  done,
84
123
  };
85
124
  }
@@ -1,14 +1,19 @@
1
1
  /**
2
- * `plan-lock` (starter-plan-hard-limits / US-011) the CLI-side read + render
3
- * of the workspace plan lock.
2
+ * `plan-lock` (starter-plan-hard-limits / US-011, rewritten for US-036) the
3
+ * CLI-side read + render of a workspace's Starter plan-limit state.
4
4
  *
5
- * Starter (free) workspaces are capped on four locking dimensions — members,
6
- * integrations, secrets and agents (owner decision 7, 2026-09-17; deployments
7
- * and storage nag but never lock). Going over locks the workspace immediately: it becomes read-only until the owner
8
- * trims back under the caps or upgrades to HQ Workforce. The lock decision is
9
- * NOT made here hq-pro's `src/billing/plan-lock.ts` is the single source of
10
- * truth and ships the answer on `GET /membership/me` as a per-company
11
- * `planLock` object. This module only reads that field and renders it.
5
+ * Starter (free) workspaces are capped on members, integrations, secrets and
6
+ * agents (deployments and storage nag but never arm a stop). Going over does
7
+ * NOT stop the workspace working (US-033 owner decision 12): everything that
8
+ * exists keeps working, and the only two things that pause are adding new
9
+ * files to the vault and adding new secrets (owner decision 13). Every string
10
+ * this module renders is a nag, never a claim that HQ has stopped. The state
11
+ * is NOT decided here hq-pro is the single source of truth and ships the
12
+ * answer on `GET /membership/me` as a per-company `planLock` object. This
13
+ * module only reads that field and renders it.
14
+ *
15
+ * Copy rule (US-036): no string a customer reads may contain "locked" or
16
+ * "read-only". `plan-lock.test.ts` scans every rendered string for both.
12
17
  *
13
18
  * Member counts are decoration, never a second opinion: the count comes from
14
19
  * `GET /v1/billing/usage-limits` on a best-effort basis and its absence only
@@ -18,7 +23,7 @@
18
23
  */
19
24
  /**
20
25
  * Mirror of hq-pro's `PlanLockReason`. Owner decision 7 (2026-09-17) fixes the
21
- * locking set at these four: `deployments` and `storageBytes` are nag-only and
26
+ * nagging set at these four: `deployments` and `storageBytes` are nag-only and
22
27
  * never appear here. An unrecognised reason is dropped by `parsePlanLock`, so a
23
28
  * server that adds a fifth dimension renders as the generic line rather than as
24
29
  * a false claim about members.
@@ -106,29 +111,39 @@ export declare function selectMemberUsage(body: unknown): PlanLockMembers | null
106
111
  export declare function fetchPlanLockStatus(token: string, companyRef: string, opts?: {
107
112
  timeoutMs?: number;
108
113
  }): Promise<PlanLockStatus | null>;
109
- /** Why the workspace locked, one clause per reason. */
114
+ /** Why the workspace is over, one clause per reason. */
110
115
  export declare function reasonLabel(reason: PlanLockReason): string;
111
116
  /**
112
- * The full WORKSPACE LOCKED block: why it locked, where the workspace stands
117
+ * The one sentence every over-limit surface says about the two hard stops
118
+ * (US-033 §3.3). Written once so the CLI, the console and the emails cannot
119
+ * drift into three different promises.
120
+ */
121
+ export declare const HARD_STOP_SENTENCE: string;
122
+ /**
123
+ * The full over-limit block: why the workspace is over, where it stands
113
124
  * against the cap, and the ways out. Plain text — colour is applied by the
114
125
  * caller so scripts capturing stdout get a clean block.
115
126
  *
116
- * Every line is derived from `lock.reasons`. A workspace locked on secrets is
127
+ * Every line is derived from `lock.reasons`. A workspace over on secrets is
117
128
  * never told it has too many members, and an empty reason list (a server
118
129
  * dimension this CLI does not know) renders a generic line rather than a claim
119
130
  * about a dimension nobody measured.
120
131
  */
121
- export declare function renderPlanLockNotice(status: PlanLockStatus): string;
132
+ export declare function renderPlanLimitNotice(status: PlanLockStatus): string;
122
133
  /**
123
- * The one-line form injected on every turn while the workspace stays locked.
134
+ * The one-line form injected on every turn while the workspace stays over.
124
135
  * Kept to a single line on purpose — it repeats each turn.
125
136
  */
126
- export declare function renderPlanLockLine(status: PlanLockStatus): string;
137
+ export declare function renderPlanLimitLine(status: PlanLockStatus): string;
127
138
  /**
128
139
  * The `Plan: …` orientation line. Starter only: a paid or enterprise
129
140
  * workspace — and an UNKNOWN plan — gets no line at all.
130
141
  */
131
142
  export declare function renderPlanLine(status: PlanLockStatus): string | null;
132
- /** Colourised block for interactive output. */
133
- export declare function colorizePlanLockNotice(notice: string): string;
143
+ /**
144
+ * Colourised block for interactive output. Yellow, not red: this is a nag, and
145
+ * an error colour would read as "HQ has stopped working", which is the exact
146
+ * impression US-033 removes.
147
+ */
148
+ export declare function colorizePlanLimitNotice(notice: string): string;
134
149
  //# sourceMappingURL=plan-lock.d.ts.map