@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.
- package/CHANGELOG.md +43 -0
- package/dist/command-catalog.generated.d.ts +54 -1
- package/dist/command-catalog.generated.js +71 -1
- package/dist/commands/agent-inbox.js +6 -0
- package/dist/commands/dm.d.ts +31 -0
- package/dist/commands/dm.js +24 -3
- package/dist/commands/skill.d.ts +20 -1
- package/dist/commands/skill.js +250 -15
- package/dist/commands/whoami.d.ts +3 -3
- package/dist/commands/whoami.js +5 -5
- package/dist/lib/agent-kit/inbox-state.d.ts +22 -2
- package/dist/lib/agent-kit/inbox-state.js +40 -1
- package/dist/lib/billing/plan-lock.d.ts +33 -18
- package/dist/lib/billing/plan-lock.js +46 -26
- package/dist/lib/plan-limit-nag.d.ts +71 -5
- package/dist/lib/plan-limit-nag.js +224 -37
- package/package.json +1 -1
package/dist/commands/skill.js
CHANGED
|
@@ -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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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:
|
|
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
|
|
434
|
-
|
|
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(
|
|
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
|
|
25
|
-
* workspace 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;
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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 {
|
|
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
|
|
73
|
-
* workspace 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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
3
|
-
* of
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
|
114
|
+
/** Why the workspace is over, one clause per reason. */
|
|
110
115
|
export declare function reasonLabel(reason: PlanLockReason): string;
|
|
111
116
|
/**
|
|
112
|
-
* The
|
|
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
|
|
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
|
|
132
|
+
export declare function renderPlanLimitNotice(status: PlanLockStatus): string;
|
|
122
133
|
/**
|
|
123
|
-
* The one-line form injected on every turn while the workspace stays
|
|
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
|
|
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
|
-
/**
|
|
133
|
-
|
|
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
|