@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.
- package/CHANGELOG.md +19 -0
- package/dist/command-catalog.generated.d.ts +24 -6
- package/dist/command-catalog.generated.js +29 -6
- package/dist/commands/_patterns.d.ts +16 -0
- package/dist/commands/_patterns.js +20 -3
- package/dist/commands/files-browse.d.ts +21 -10
- package/dist/commands/files-browse.js +95 -32
- package/dist/commands/files.js +44 -13
- package/dist/commands/mesh.js +115 -1
- package/dist/commands/report.d.ts +13 -0
- package/dist/commands/report.js +35 -13
- package/dist/commands/sync-narrow.js +36 -5
- package/dist/commands/workers.js +5 -2
- package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/credentials.js +141 -10
- package/dist/lib/mesh/live/daemon/doctor.js +4 -3
- package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
- package/dist/lib/mesh/live/daemon/presence.js +69 -1
- package/dist/lib/mesh/live/daemon/run.js +3 -1
- package/dist/lib/mesh/project-board.d.ts +14 -0
- package/dist/lib/mesh/project-board.js +24 -1
- package/dist/lib/work-context/bind-best.d.ts +2 -0
- package/dist/lib/work-context/bind-best.js +34 -1
- package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
- package/dist/lib/work-context/bind-calling-session.js +63 -0
- package/dist/lib/work-context/prd-sync.d.ts +34 -0
- package/dist/lib/work-context/prd-sync.js +120 -0
- package/dist/lib/work-context/prompt-rebind.d.ts +21 -0
- package/dist/lib/work-context/prompt-rebind.js +76 -0
- package/dist/lib/work-context/reconcile.d.ts +2 -0
- package/dist/lib/work-context/reconcile.js +31 -0
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +8 -0
- package/package.json +1 -1
package/dist/commands/files.js
CHANGED
|
@@ -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
|
-
//
|
|
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);
|
package/dist/commands/mesh.js
CHANGED
|
@@ -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>;
|
package/dist/commands/report.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
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);
|
package/dist/commands/workers.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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;
|