@indigoai-us/hq-cli 5.108.23 → 5.108.25
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 +18 -0
- package/dist/commands/cloud.d.ts +10 -0
- package/dist/commands/cloud.js +76 -1
- package/dist/commands/files.d.ts +11 -0
- package/dist/commands/files.js +206 -30
- package/dist/commands/mesh.js +111 -2
- package/dist/lib/mesh/live/daemon/transcript-watch.js +6 -1
- package/dist/lib/work-context/company.d.ts +11 -1
- package/dist/lib/work-context/company.js +17 -1
- package/dist/lib/work-context/config.d.ts +28 -0
- package/dist/lib/work-context/config.js +75 -0
- package/dist/lib/work-context/index.d.ts +3 -1
- package/dist/lib/work-context/index.js +2 -1
- package/dist/lib/work-context/reconcile.js +5 -1
- package/dist/lib/work-context/repo-prompt.d.ts +74 -0
- package/dist/lib/work-context/repo-prompt.js +98 -0
- package/dist/lib/work-context/repo-remote.d.ts +17 -0
- package/dist/lib/work-context/repo-remote.js +45 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.25] — 2026-09-08
|
|
6
|
+
|
|
7
|
+
## [5.108.24] — 2026-09-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `hq sync doctor --reconcile-conflicts [--yes]` folds legacy sibling
|
|
12
|
+
`.conflict-<ts>-<machine>` twins back into their live files (higher
|
|
13
|
+
frontmatter `version:` wins, else newer mtime), parks the losing side under
|
|
14
|
+
`.hq/conflict-backups/`, and surfaces undecidable pairs for manual review.
|
|
15
|
+
Dry-run by default; `--yes` applies.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Bump `@indigoai-us/hq-cloud` to 6.16.28: sync no longer writes sibling
|
|
20
|
+
`.conflict-*` twins on conflict and instead picks the higher `version:`
|
|
21
|
+
frontmatter side, parking the loser under `.hq/conflict-backups/`.
|
|
22
|
+
|
|
5
23
|
## [5.108.23] — 2026-09-08
|
|
6
24
|
|
|
7
25
|
### Added
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -336,6 +336,16 @@ export interface PerCompanyPullResolveResult {
|
|
|
336
336
|
}
|
|
337
337
|
export declare function resolvePerCompanyPullPlan(client: PerCompanyPullResolveClient, targetCompany: string | undefined): Promise<PerCompanyPullResolveResult>;
|
|
338
338
|
export declare function registerCloudCommands(program: Command): void;
|
|
339
|
+
/**
|
|
340
|
+
* `hq sync doctor --reconcile-conflicts [--yes]`.
|
|
341
|
+
*
|
|
342
|
+
* Reconcile mode is purely local — the engine walks the HQ root for legacy
|
|
343
|
+
* sibling conflict twins and never touches the vault — but the engine's
|
|
344
|
+
* `SyncDoctorOptions` type still requires `entity` / `vaultConfig` for its
|
|
345
|
+
* skill-key dedupe mode. Placeholders are passed; the reconcile branch returns
|
|
346
|
+
* before either is read (verified against hq-cloud's `doctor.js`).
|
|
347
|
+
*/
|
|
348
|
+
export declare function runSyncDoctorReconcileConflicts(hqRoot: string, yes: boolean): Promise<void>;
|
|
339
349
|
/** Exported for the US-003 sync-health exit-path regression test. */
|
|
340
350
|
export declare function runPullPersonal(hqRoot: string, onConflict?: ConflictStrategy): Promise<void>;
|
|
341
351
|
//# sourceMappingURL=cloud.d.ts.map
|
package/dist/commands/cloud.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import chalk from "chalk";
|
|
16
16
|
import * as fs from "fs";
|
|
17
17
|
import * as path from "path";
|
|
18
|
-
import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
|
|
18
|
+
import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, syncDoctor, } from "@indigoai-us/hq-cloud";
|
|
19
19
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
20
20
|
import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
|
|
21
21
|
import { beginSyncHealthReport, } from "../utils/client-health.js";
|
|
@@ -1098,6 +1098,81 @@ export function registerCloudCommands(program) {
|
|
|
1098
1098
|
process.exit(1);
|
|
1099
1099
|
}
|
|
1100
1100
|
});
|
|
1101
|
+
program
|
|
1102
|
+
.command("doctor")
|
|
1103
|
+
.description("Repair the local HQ tree. `--reconcile-conflicts` folds legacy " +
|
|
1104
|
+
"sibling `.conflict-*` twins back into their live files (higher " +
|
|
1105
|
+
"frontmatter `version:` wins; losers are parked under " +
|
|
1106
|
+
".hq/conflict-backups/). Dry-run unless --yes.")
|
|
1107
|
+
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
1108
|
+
.option("--reconcile-conflicts", "Reconcile legacy sibling `.conflict-<ts>-<machine>` twins against " +
|
|
1109
|
+
"their live files (purely local; no vault access).")
|
|
1110
|
+
.option("--yes", "Apply the plan (default: dry-run, print only)")
|
|
1111
|
+
.action(async (options) => {
|
|
1112
|
+
try {
|
|
1113
|
+
if (!options.reconcileConflicts) {
|
|
1114
|
+
console.error(chalk.red("✗ hq sync doctor:"), "only `--reconcile-conflicts` mode is supported right now.");
|
|
1115
|
+
console.error(chalk.dim(" Usage: hq sync doctor --reconcile-conflicts [--yes] [--hq-root <path>]"));
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
await runSyncDoctorReconcileConflicts(options.hqRoot, options.yes === true);
|
|
1120
|
+
}
|
|
1121
|
+
catch (err) {
|
|
1122
|
+
console.error(chalk.red("\n✗ Sync doctor failed:"), err instanceof Error ? err.message : String(err));
|
|
1123
|
+
process.exit(1);
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* `hq sync doctor --reconcile-conflicts [--yes]`.
|
|
1129
|
+
*
|
|
1130
|
+
* Reconcile mode is purely local — the engine walks the HQ root for legacy
|
|
1131
|
+
* sibling conflict twins and never touches the vault — but the engine's
|
|
1132
|
+
* `SyncDoctorOptions` type still requires `entity` / `vaultConfig` for its
|
|
1133
|
+
* skill-key dedupe mode. Placeholders are passed; the reconcile branch returns
|
|
1134
|
+
* before either is read (verified against hq-cloud's `doctor.js`).
|
|
1135
|
+
*/
|
|
1136
|
+
export async function runSyncDoctorReconcileConflicts(hqRoot, yes) {
|
|
1137
|
+
console.log(chalk.bold("\nHQ Sync — Doctor (reconcile conflicts)"));
|
|
1138
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
1139
|
+
console.log(` Mode: ${yes ? "apply (--yes)" : "dry-run (pass --yes to apply)"}`);
|
|
1140
|
+
console.log("");
|
|
1141
|
+
const result = await syncDoctor({
|
|
1142
|
+
entity: "local",
|
|
1143
|
+
vaultConfig: {},
|
|
1144
|
+
hqRoot,
|
|
1145
|
+
yes,
|
|
1146
|
+
reconcileConflicts: true,
|
|
1147
|
+
});
|
|
1148
|
+
const twins = result.conflictTwins;
|
|
1149
|
+
const backupsDir = path.join(hqRoot, ".hq", "conflict-backups");
|
|
1150
|
+
console.log(chalk.bold(twins?.applied ? "\nReconcile — applied" : "\nReconcile — dry-run (nothing written)"));
|
|
1151
|
+
console.log(` Twins found: ${twins?.plan.length ?? 0}`);
|
|
1152
|
+
console.log(` Promoted: ${twins?.promoted ?? 0}`);
|
|
1153
|
+
console.log(` Kept live: ${twins?.removed ?? 0}`);
|
|
1154
|
+
console.log(` Backed up: ${twins?.backedUp ?? 0}`);
|
|
1155
|
+
console.log(` Orphans parked: ${twins?.orphansParked ?? 0}`);
|
|
1156
|
+
console.log(` Index rows dropped: ${twins?.indexRowsDropped ?? 0}`);
|
|
1157
|
+
if ((twins?.warnings ?? result.warnings) > 0) {
|
|
1158
|
+
console.log(chalk.yellow(` Warnings: ${twins?.warnings ?? result.warnings}`));
|
|
1159
|
+
}
|
|
1160
|
+
const manual = twins?.manualReview ?? [];
|
|
1161
|
+
if (manual.length > 0) {
|
|
1162
|
+
console.log(chalk.yellow(`\n Manual review (${manual.length}) — NOT auto-resolved:`));
|
|
1163
|
+
for (const item of manual) {
|
|
1164
|
+
console.log(` • ${item.livePath}`);
|
|
1165
|
+
console.log(chalk.dim(` twin: ${item.twinPath}`));
|
|
1166
|
+
console.log(chalk.dim(` reason: ${item.reason}`));
|
|
1167
|
+
if (item.backupPath) {
|
|
1168
|
+
console.log(chalk.dim(` backup: ${item.backupPath}`));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
console.log(chalk.dim(`\n Backups live under: ${backupsDir}`));
|
|
1173
|
+
if (!twins?.applied) {
|
|
1174
|
+
console.log(chalk.dim(" Re-run with --yes to apply the plan above."));
|
|
1175
|
+
}
|
|
1101
1176
|
}
|
|
1102
1177
|
async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink) {
|
|
1103
1178
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
package/dist/commands/files.d.ts
CHANGED
|
@@ -89,6 +89,17 @@ export declare function stripRedundantCompanyScope(prefix: string): {
|
|
|
89
89
|
prefix: string;
|
|
90
90
|
strippedSlug: string;
|
|
91
91
|
} | null;
|
|
92
|
+
/**
|
|
93
|
+
* The company an anchored path names, or `undefined` for a relative/personal
|
|
94
|
+
* path.
|
|
95
|
+
*
|
|
96
|
+
* Discarding this slug is what makes retargeting possible: strip the anchor,
|
|
97
|
+
* then resolve the company from somewhere else, and the operation lands on a
|
|
98
|
+
* DIFFERENT company than the operator pasted. Callers pair this with
|
|
99
|
+
* `normalizeCompanyFilePrefix` so the anchor either MATCHES the explicit
|
|
100
|
+
* `--company` (or is refused), or BECOMES the company when none was given.
|
|
101
|
+
*/
|
|
102
|
+
export declare function companySlugFromAnchor(prefix: string, personal?: boolean): string | undefined;
|
|
92
103
|
export declare function runFilesDelete(params: RunFilesDeleteParams, deps?: {
|
|
93
104
|
confirm?: ConfirmFn;
|
|
94
105
|
}): Promise<void>;
|
package/dist/commands/files.js
CHANGED
|
@@ -5,6 +5,8 @@ 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
7
|
import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
|
|
8
|
+
import { looksLikeCompanyUid } from "../utils/vault-api.js";
|
|
9
|
+
import { AuthError } from "../utils/auth-error.js";
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Pure helpers (exported for unit tests)
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
@@ -157,8 +159,8 @@ export function registerFilesCommand(program) {
|
|
|
157
159
|
process.exit(1);
|
|
158
160
|
}
|
|
159
161
|
// Fork: --with present → existing direct-grant path. No --with →
|
|
160
|
-
// browser-launch share-session path.
|
|
161
|
-
//
|
|
162
|
+
// browser-launch share-session path. The direct-grant path
|
|
163
|
+
// operates on a single prefix.
|
|
162
164
|
if (opts.with !== undefined) {
|
|
163
165
|
if (paths.length !== 1) {
|
|
164
166
|
console.error(chalk.red("Direct grant (--with) takes exactly one prefix. Pass multiple paths only when minting a share-session URL."));
|
|
@@ -190,7 +192,15 @@ export function registerFilesCommand(program) {
|
|
|
190
192
|
.requiredOption("--with <principal>", "Email address, group id, or '@all' to remove the company-wide grant")
|
|
191
193
|
.action(async (prefix, opts) => {
|
|
192
194
|
try {
|
|
193
|
-
|
|
195
|
+
// Unshare MUTATES access with no preview, and it resolves the target
|
|
196
|
+
// company from --company (not from the pasted anchor), so a mismatched
|
|
197
|
+
// anchor would revoke a real grant in the wrong company.
|
|
198
|
+
const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
|
|
199
|
+
mutating: true,
|
|
200
|
+
...(files.opts().company !== undefined
|
|
201
|
+
? { companySlug: files.opts().company }
|
|
202
|
+
: {}),
|
|
203
|
+
});
|
|
194
204
|
const principal = opts.with;
|
|
195
205
|
const isAll = principal === "@all";
|
|
196
206
|
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
@@ -264,10 +274,21 @@ export function registerFilesCommand(program) {
|
|
|
264
274
|
.description("Show the ACL (access control list) for a file prefix")
|
|
265
275
|
.action(async (prefix) => {
|
|
266
276
|
try {
|
|
267
|
-
|
|
277
|
+
// Read-only, so stripping is safe — but a mismatched anchor would show
|
|
278
|
+
// a DIFFERENT company's ACL than the one the operator pasted.
|
|
279
|
+
const explicitCompany = files.opts().company;
|
|
280
|
+
const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
|
|
281
|
+
...(explicitCompany !== undefined
|
|
282
|
+
? { companySlug: explicitCompany }
|
|
283
|
+
: {}),
|
|
284
|
+
});
|
|
268
285
|
const token = (await resolveVaultCredential()).token;
|
|
269
|
-
|
|
270
|
-
|
|
286
|
+
// Resolve through the anchor so the answer describes the company the
|
|
287
|
+
// operator actually pasted, and never a different one.
|
|
288
|
+
const companyUid = await resolveCompanyUidForAnchoredPath(token, {
|
|
289
|
+
explicit: explicitCompany,
|
|
290
|
+
anchorSlug: companySlugFromAnchor(prefix),
|
|
291
|
+
});
|
|
271
292
|
// `/acl/tree` carries the prefix's own row metadata (directRow) and the
|
|
272
293
|
// caller's effectivePermission alongside direct/inherited/children, so
|
|
273
294
|
// a single request returns everything the "files acl" view needs.
|
|
@@ -474,7 +495,13 @@ export function registerFilesCommand(program) {
|
|
|
474
495
|
return files;
|
|
475
496
|
}
|
|
476
497
|
async function runDirectGrant(params) {
|
|
477
|
-
const canonicalPrefix =
|
|
498
|
+
const canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
|
|
499
|
+
mutating: true,
|
|
500
|
+
rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
|
|
501
|
+
...(params.companySlug !== undefined
|
|
502
|
+
? { companySlug: params.companySlug }
|
|
503
|
+
: {}),
|
|
504
|
+
});
|
|
478
505
|
if (!params.permission) {
|
|
479
506
|
console.error(chalk.red("--permission is required when --with is set (read | write)"));
|
|
480
507
|
process.exit(1);
|
|
@@ -565,9 +592,15 @@ async function runDirectGrant(params) {
|
|
|
565
592
|
}
|
|
566
593
|
}
|
|
567
594
|
async function runShareSession(params) {
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
const normalizedPaths = params.paths.map(
|
|
595
|
+
// Validate every path before minting a session; an anchored path must never
|
|
596
|
+
// silently become a share of the corresponding company-relative objects.
|
|
597
|
+
const normalizedPaths = params.paths.map((prefix) => normalizeCompanyFilePrefix(prefix, {
|
|
598
|
+
mutating: true,
|
|
599
|
+
rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
|
|
600
|
+
...(params.companySlug !== undefined
|
|
601
|
+
? { companySlug: params.companySlug }
|
|
602
|
+
: {}),
|
|
603
|
+
}));
|
|
571
604
|
let expiresInMs;
|
|
572
605
|
if (params.expires !== undefined) {
|
|
573
606
|
const parsed = parseDuration(params.expires);
|
|
@@ -723,36 +756,179 @@ export function stripRedundantCompanyScope(prefix) {
|
|
|
723
756
|
return null;
|
|
724
757
|
return { prefix: m[2] ?? "", strippedSlug: m[1] };
|
|
725
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Resolve the company for a possibly-anchored path, refusing any retarget.
|
|
761
|
+
*
|
|
762
|
+
* The pure normalizer can only compare slug against slug. That left a hole: a
|
|
763
|
+
* UID-form `--company` was skipped by the string check, so
|
|
764
|
+
* `--company cmp_beta delete <alpha-anchored-path>` stripped the anchor,
|
|
765
|
+
* resolved beta, and previewed/deleted from beta. Comparing IDENTITIES rather
|
|
766
|
+
* than strings is the only way to close it, and that needs resolution.
|
|
767
|
+
*
|
|
768
|
+
* - no anchor -> unchanged behaviour
|
|
769
|
+
* - anchor, no --company -> the anchor IS the company
|
|
770
|
+
* - UID anchor -> compare with the selected company UID
|
|
771
|
+
* - slug + explicit UID -> validate the selected entity's slug directly
|
|
772
|
+
* - slug + slug -> resolve BOTH and require the same uid
|
|
773
|
+
*
|
|
774
|
+
* API keys can verify UID anchors through their company selector or keyed
|
|
775
|
+
* membership. Slug anchors still fail closed because keys cannot read entities.
|
|
776
|
+
*/
|
|
777
|
+
async function resolveCompanyUidForAnchoredPath(token, opts) {
|
|
778
|
+
if (opts.anchorSlug === undefined) {
|
|
779
|
+
return getCompanyUid(token, opts.explicit);
|
|
780
|
+
}
|
|
781
|
+
if (looksLikeCompanyUid(opts.anchorSlug)) {
|
|
782
|
+
const selectedUid = await getCompanyUid(token, opts.explicit ?? (token.startsWith("hqk_") ? undefined : opts.anchorSlug));
|
|
783
|
+
if (selectedUid !== opts.anchorSlug) {
|
|
784
|
+
throw new Error(`Refusing anchored path: it names company '${opts.anchorSlug}' ` +
|
|
785
|
+
`but this command targets '${selectedUid}'. Pass a matching company or path.`);
|
|
786
|
+
}
|
|
787
|
+
return selectedUid;
|
|
788
|
+
}
|
|
789
|
+
if (token.startsWith("hqk_")) {
|
|
790
|
+
throw new Error(`Refusing anchored path under an API key: the anchor names ` +
|
|
791
|
+
`'${opts.anchorSlug}', and an API key cannot resolve a slug to verify ` +
|
|
792
|
+
`that is the company it is bound to. Pass the bucket-relative path.`);
|
|
793
|
+
}
|
|
794
|
+
if (opts.explicit !== undefined && looksLikeCompanyUid(opts.explicit)) {
|
|
795
|
+
// An explicit UID disambiguates duplicate slugs. Resolving the slug first
|
|
796
|
+
// would fail before the caller's unambiguous selector could be considered.
|
|
797
|
+
const response = await vaultApiFetch({
|
|
798
|
+
token,
|
|
799
|
+
path: `/entity/${encodeURIComponent(opts.explicit)}`,
|
|
800
|
+
signal: AbortSignal.timeout(15_000),
|
|
801
|
+
});
|
|
802
|
+
if (response.status === 401)
|
|
803
|
+
throw new AuthError();
|
|
804
|
+
if (!response.ok) {
|
|
805
|
+
throw new Error(`Failed to resolve company '${opts.explicit}' (HTTP ${response.status})`);
|
|
806
|
+
}
|
|
807
|
+
const data = (await response.json());
|
|
808
|
+
if (data.entity?.uid !== opts.explicit ||
|
|
809
|
+
data.entity.type !== "company" ||
|
|
810
|
+
data.entity.slug !== opts.anchorSlug) {
|
|
811
|
+
throw new Error(`Refusing anchored path: cannot verify that company '${opts.explicit}' ` +
|
|
812
|
+
`has the anchored slug '${opts.anchorSlug}'. Pass a matching company or path.`);
|
|
813
|
+
}
|
|
814
|
+
return opts.explicit;
|
|
815
|
+
}
|
|
816
|
+
const anchorUid = await getCompanyUid(token, opts.anchorSlug);
|
|
817
|
+
if (opts.explicit === undefined)
|
|
818
|
+
return anchorUid;
|
|
819
|
+
const explicitUid = await getCompanyUid(token, opts.explicit);
|
|
820
|
+
if (explicitUid !== anchorUid) {
|
|
821
|
+
throw new Error(`Refusing anchored path: it names company '${opts.anchorSlug}' ` +
|
|
822
|
+
`(${anchorUid}) but this command targets '${opts.explicit}' ` +
|
|
823
|
+
`(${explicitUid}). Re-run with --company ${opts.anchorSlug}, or pass ` +
|
|
824
|
+
`the bucket-relative path.`);
|
|
825
|
+
}
|
|
826
|
+
return anchorUid;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* The company an anchored path names, or `undefined` for a relative/personal
|
|
830
|
+
* path.
|
|
831
|
+
*
|
|
832
|
+
* Discarding this slug is what makes retargeting possible: strip the anchor,
|
|
833
|
+
* then resolve the company from somewhere else, and the operation lands on a
|
|
834
|
+
* DIFFERENT company than the operator pasted. Callers pair this with
|
|
835
|
+
* `normalizeCompanyFilePrefix` so the anchor either MATCHES the explicit
|
|
836
|
+
* `--company` (or is refused), or BECOMES the company when none was given.
|
|
837
|
+
*/
|
|
838
|
+
export function companySlugFromAnchor(prefix, personal) {
|
|
839
|
+
if (personal)
|
|
840
|
+
return undefined;
|
|
841
|
+
return stripRedundantCompanyScope(prefix)?.strippedSlug;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Normalize company paths at the CLI boundary, preserving personal keys.
|
|
845
|
+
*
|
|
846
|
+
* `mutating` marks a route that CHANGES access and has no preview or
|
|
847
|
+
* confirmation step (grant, share, unshare). Those refuse an anchored path
|
|
848
|
+
* outright rather than guessing, because stripping the anchor changes which
|
|
849
|
+
* objects the operation reaches and there is nothing downstream to catch it.
|
|
850
|
+
*
|
|
851
|
+
* `companySlug` is the company the command is actually targeting. When the
|
|
852
|
+
* anchor names a DIFFERENT company we refuse in every mode: stripping would
|
|
853
|
+
* silently retarget the operation at the active company, so
|
|
854
|
+
* `--company beta unshare <alpha-anchored-path>` would revoke a real grant in
|
|
855
|
+
* beta while leaving alpha untouched.
|
|
856
|
+
*/
|
|
857
|
+
function normalizeCompanyFilePrefix(prefix, options = {}) {
|
|
858
|
+
const scope = options.personal ? null : stripRedundantCompanyScope(prefix);
|
|
859
|
+
if (!scope)
|
|
860
|
+
return normalizeFilePrefix(prefix);
|
|
861
|
+
// A `cmp_…` reference is a UID, not a slug, so it can only be compared to the
|
|
862
|
+
// anchor by RESOLVING it — which this pure helper cannot do. Comparing the
|
|
863
|
+
// strings would refuse `--company cmp_acme` against an `acme` anchor even
|
|
864
|
+
// though both name the same company, and `getCompanyUid`'s ambiguity error
|
|
865
|
+
// explicitly tells callers to retry with `--company <uid>`. Skip the check
|
|
866
|
+
// rather than break that recovery path; the UID is authoritative either way.
|
|
867
|
+
if (options.companySlug !== undefined &&
|
|
868
|
+
!looksLikeCompanyUid(options.companySlug) &&
|
|
869
|
+
!looksLikeCompanyUid(scope.strippedSlug) &&
|
|
870
|
+
options.companySlug !== scope.strippedSlug) {
|
|
871
|
+
throw new Error(`Refusing anchored path '${prefix}': it names company ` +
|
|
872
|
+
`'${scope.strippedSlug}' but this command targets ` +
|
|
873
|
+
`'${options.companySlug}'. Stripping the anchor would apply the ` +
|
|
874
|
+
`operation to '${options.companySlug}' instead. Re-run with ` +
|
|
875
|
+
`--company ${scope.strippedSlug}, or pass the bucket-relative path.`);
|
|
876
|
+
}
|
|
877
|
+
// A bare anchor strips to nothing. The ACL routes would ship that empty
|
|
878
|
+
// prefix to a server that answers 400, so fail here with a message that says
|
|
879
|
+
// what to do instead. `deferRootReject` is for callers that already have a
|
|
880
|
+
// root guard with better copy — `runFilesDelete` says "Refusing to delete the
|
|
881
|
+
// vault root", which beats anything this generic helper could write.
|
|
882
|
+
if (!scope.prefix && !options.deferRootReject) {
|
|
883
|
+
// Route-specific: `--full` exists ONLY on `files share`. Suggesting it for
|
|
884
|
+
// `unshare` fails with an unknown option and names the opposite operation.
|
|
885
|
+
const advice = options.rootAdvice ??
|
|
886
|
+
"Pass a bounded bucket-relative path (e.g. 'projects/foo').";
|
|
887
|
+
throw new Error(`Refusing anchored path '${prefix}': it resolves to the vault root. ` +
|
|
888
|
+
advice);
|
|
889
|
+
}
|
|
890
|
+
if (options.mutating) {
|
|
891
|
+
throw new Error(`Refusing anchored path '${prefix}': the vault is already ` +
|
|
892
|
+
`company-scoped, so removing 'companies/${scope.strippedSlug}/' would ` +
|
|
893
|
+
`change which objects this affects. Retry with the bucket-relative ` +
|
|
894
|
+
`path '${normalizeFilePrefix(scope.prefix)}' if that is what you intend.`);
|
|
895
|
+
}
|
|
896
|
+
console.error(chalk.yellow(`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
|
|
897
|
+
`is already company-scoped; using bucket-relative '${scope.prefix}'.`));
|
|
898
|
+
return normalizeFilePrefix(scope.prefix);
|
|
899
|
+
}
|
|
726
900
|
export async function runFilesDelete(params, deps = {}) {
|
|
727
901
|
const confirm = deps.confirm ?? realConfirm;
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// server as a vault-wide delete. The server enforces this too (defense in
|
|
744
|
-
// depth), but failing fast here is clearer and avoids a wasted round-trip.
|
|
745
|
-
const normalized = normalizeFilePrefix(rawPrefix);
|
|
902
|
+
// Strip the local company anchor before preview/confirmation (HQ-8F/HQ-CA).
|
|
903
|
+
// Personal keys are already bucket-relative and must keep any such anchor.
|
|
904
|
+
// Normalize trailing `/` to `/*`, then reject the root/empty prefix before
|
|
905
|
+
// any network call, including when stripping a bare company anchor empties it.
|
|
906
|
+
// The anchor names a company. Compare it with --company (refusing a
|
|
907
|
+
// mismatch), and when --company was omitted ADOPT it rather than letting the
|
|
908
|
+
// membership fallback pick a different company to delete from.
|
|
909
|
+
const anchorSlug = companySlugFromAnchor(params.prefix, params.personal);
|
|
910
|
+
const normalized = normalizeCompanyFilePrefix(params.prefix, {
|
|
911
|
+
personal: params.personal,
|
|
912
|
+
deferRootReject: true,
|
|
913
|
+
...(params.companySlug !== undefined
|
|
914
|
+
? { companySlug: params.companySlug }
|
|
915
|
+
: {}),
|
|
916
|
+
});
|
|
746
917
|
if (normalized === "" || normalized === "*" || normalized === "/*") {
|
|
747
918
|
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."));
|
|
748
919
|
process.exit(1);
|
|
749
920
|
}
|
|
750
921
|
const token = (await resolveVaultCredential()).token;
|
|
751
922
|
// Personal scope resolves the vault server-side from the caller's identity —
|
|
752
|
-
// no company to look up. Company scope resolves the companyUid
|
|
923
|
+
// no company to look up. Company scope resolves the companyUid through the
|
|
924
|
+
// anchor-aware resolver so an anchored path can never delete from a company
|
|
925
|
+
// other than the one it names.
|
|
753
926
|
const companyUid = params.personal
|
|
754
927
|
? undefined
|
|
755
|
-
: await
|
|
928
|
+
: await resolveCompanyUidForAnchoredPath(token, {
|
|
929
|
+
explicit: params.companySlug,
|
|
930
|
+
anchorSlug,
|
|
931
|
+
});
|
|
756
932
|
const scopeArgs = params.personal
|
|
757
933
|
? { personal: true }
|
|
758
934
|
: { companyUid };
|
package/dist/commands/mesh.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import chalk from "chalk";
|
|
10
|
-
import { loadCachedTokens } from "../utils/cognito-session.js";
|
|
10
|
+
import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
|
|
11
11
|
import * as readline from "node:readline/promises";
|
|
12
|
-
import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
12
|
+
import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
13
13
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
14
14
|
import { clearDefaultCompany, getDefaultCompany, readDeviceConfig, recordMigrationCapabilitySnapshot, setDefaultCompany, } from "../lib/work-context/config.js";
|
|
15
15
|
import { DefaultCompanyLockedError, DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
|
|
@@ -25,6 +25,9 @@ import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-
|
|
|
25
25
|
import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
|
|
26
26
|
import { readSessionState } from "../lib/work-context/state.js";
|
|
27
27
|
import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
|
|
28
|
+
import { resolveCompany } from "../lib/work-context/company.js";
|
|
29
|
+
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../lib/work-context/repo-remote.js";
|
|
30
|
+
import { promptRepoCompany, } from "../lib/work-context/repo-prompt.js";
|
|
28
31
|
import { requeueQuarantinedOutbox } from "../lib/work-context/outbox.js";
|
|
29
32
|
export function formatCheckLines(threads, company, projectId) {
|
|
30
33
|
if (threads.length === 0) {
|
|
@@ -180,6 +183,103 @@ async function runContextReconcile(opts) {
|
|
|
180
183
|
}
|
|
181
184
|
process.exitCode = outcome.exitCode;
|
|
182
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* `hq mesh context resolve` — interactive per-repo company prompt (gap 4).
|
|
188
|
+
*
|
|
189
|
+
* Owner decision: "Always ask per repo". When a person session is unresolved
|
|
190
|
+
* (needs_company) and this is an interactive person TTY, ask ONCE which company
|
|
191
|
+
* this repo's work is filed under, then remember it (persisted repo→company
|
|
192
|
+
* map). Never prompts on machine / agent-box identities (they resolve from the
|
|
193
|
+
* identity file) or in non-interactive / --json / --machine contexts — those
|
|
194
|
+
* fall through to needs_company exactly as before.
|
|
195
|
+
*/
|
|
196
|
+
async function runContextResolve(opts) {
|
|
197
|
+
const sessionId = opts.session?.trim();
|
|
198
|
+
if (!sessionId || !isValidSessionId(sessionId)) {
|
|
199
|
+
fail("`--session <sid>` is required and must be a valid session id");
|
|
200
|
+
}
|
|
201
|
+
const root = workContextHomeRoot();
|
|
202
|
+
const cwd = opts.cwd?.trim() || process.cwd();
|
|
203
|
+
const hqRoot = opts.hqRoot?.trim() || undefined;
|
|
204
|
+
const jsonMode = Boolean(opts.json || opts.machine);
|
|
205
|
+
const emit = (payload) => {
|
|
206
|
+
if (jsonMode) {
|
|
207
|
+
console.log(JSON.stringify(payload, null, opts.machine ? 0 : 2));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
// Only prompt when the deterministic resolver leaves the session unresolved.
|
|
211
|
+
const remoteOwnerSlug = deriveRemoteOwnerSlug({ cwd, hqRoot }) ?? undefined;
|
|
212
|
+
const repoIdentityKey = deriveRepoIdentityKey({ cwd });
|
|
213
|
+
const resolution = resolveCompany({
|
|
214
|
+
root,
|
|
215
|
+
sessionId,
|
|
216
|
+
env: process.env,
|
|
217
|
+
cwd,
|
|
218
|
+
hqRoot,
|
|
219
|
+
remoteOwnerSlug,
|
|
220
|
+
repoIdentityKey,
|
|
221
|
+
});
|
|
222
|
+
if (resolution.status === "resolved") {
|
|
223
|
+
const c = resolution.company;
|
|
224
|
+
if (!jsonMode) {
|
|
225
|
+
console.error(chalk.dim(`Company already resolved via ${c.source}: ${c.slug ?? c.uid}`));
|
|
226
|
+
}
|
|
227
|
+
emit({ ok: true, status: "resolved", already: true, company: c });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (resolution.status === "company_conflict") {
|
|
231
|
+
if (!jsonMode) {
|
|
232
|
+
console.error(chalk.yellow("Company conflict — resolve with `hq mesh context correct`, not the per-repo prompt."));
|
|
233
|
+
}
|
|
234
|
+
emit({ ok: false, ...resolution });
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// needs_company → interactive per-repo prompt (gated inside promptRepoCompany).
|
|
239
|
+
const outcome = await promptRepoCompany({
|
|
240
|
+
root,
|
|
241
|
+
cwd,
|
|
242
|
+
isMachineIdentity: () => isMachineIdentity(),
|
|
243
|
+
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY) && !jsonMode,
|
|
244
|
+
listMemberships: async () => {
|
|
245
|
+
const token = await requireToken();
|
|
246
|
+
return listActiveMembershipCompanies(token);
|
|
247
|
+
},
|
|
248
|
+
ask: async (question) => {
|
|
249
|
+
const rl = readline.createInterface({
|
|
250
|
+
input: process.stdin,
|
|
251
|
+
output: process.stderr,
|
|
252
|
+
});
|
|
253
|
+
try {
|
|
254
|
+
return await rl.question(question);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
rl.close();
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
if (outcome.status === "resolved") {
|
|
262
|
+
if (!jsonMode) {
|
|
263
|
+
const verb = outcome.alreadyMapped ? "already filed under" : "filed under";
|
|
264
|
+
console.error(chalk.green(`This repo is ${verb} ${outcome.company.slug}` +
|
|
265
|
+
`${outcome.company.uid ? ` (${outcome.company.uid})` : ""}.`));
|
|
266
|
+
}
|
|
267
|
+
emit({ ok: true, ...outcome });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Skipped — fall through to needs_company exactly as today.
|
|
271
|
+
if (!jsonMode) {
|
|
272
|
+
const reasons = {
|
|
273
|
+
machine: "Machine/agent-box identity — company comes from the identity file.",
|
|
274
|
+
non_interactive: "Non-interactive context — no prompt; session stays needs_company.",
|
|
275
|
+
no_repo_key: "Not inside a git repo — nothing to remember; needs_company.",
|
|
276
|
+
no_memberships: "No active memberships to choose from; needs_company.",
|
|
277
|
+
cancelled: "No selection made; session stays needs_company.",
|
|
278
|
+
};
|
|
279
|
+
console.error(chalk.dim(reasons[outcome.reason] ?? "needs_company"));
|
|
280
|
+
}
|
|
281
|
+
emit({ ok: true, status: "needs_company", skipped: outcome.reason });
|
|
282
|
+
}
|
|
183
283
|
async function runContextBackfillHeld(opts) {
|
|
184
284
|
const root = workContextHomeRoot();
|
|
185
285
|
const meshRoot = workMeshRoot(undefined, process.env);
|
|
@@ -808,6 +908,15 @@ export function registerMeshCommand(program) {
|
|
|
808
908
|
.option("--machine", "Write exactly one ContextResult JSON line to stdout")
|
|
809
909
|
.option("--offline", "Skip network; leave register operations queued in the outbox")
|
|
810
910
|
.action((opts) => wrap(() => runContextReconcile(opts))());
|
|
911
|
+
context
|
|
912
|
+
.command("resolve")
|
|
913
|
+
.description("Interactively ask (once per repo) which company this repo's work is filed under, and remember it (gap 4). Person TTYs only — never agent boxes / non-interactive.")
|
|
914
|
+
.requiredOption("--session <sid>", "Canonical session id")
|
|
915
|
+
.option("--cwd <path>", "Working directory to derive the repo identity (defaults to cwd)")
|
|
916
|
+
.option("--hq-root <path>", "HQ root for deterministic company evidence")
|
|
917
|
+
.option("--json", "Print machine-readable JSON (suppresses the prompt)")
|
|
918
|
+
.option("--machine", "Compact JSON on stdout (suppresses the prompt)")
|
|
919
|
+
.action((opts) => wrap(() => runContextResolve(opts))());
|
|
811
920
|
context
|
|
812
921
|
.command("backfill-held")
|
|
813
922
|
.description("Reconcile ENDED sessions whose held events lack a company so the daemon re-attributes the backlog (explicit, opt-in; no fleet fan-out)")
|
|
@@ -32,7 +32,7 @@ import { CLI_VERSION } from "../../../../cli-version.js";
|
|
|
32
32
|
import { companySlugFromCwd, projectIdFromCwd, resolveCompany, } from "../../../work-context/company.js";
|
|
33
33
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../../work-context/contract.js";
|
|
34
34
|
import { reconcileObservation, } from "../../../work-context/reconcile.js";
|
|
35
|
-
import { deriveRemoteOwnerSlug } from "../../../work-context/repo-remote.js";
|
|
35
|
+
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../../work-context/repo-remote.js";
|
|
36
36
|
import { isHookWrittenSessionState, readSessionState, writeSessionState, } from "../../../work-context/state.js";
|
|
37
37
|
import { enqueueSessionEvent } from "../enqueue.js";
|
|
38
38
|
import { isValidSessionId } from "../session-identity.js";
|
|
@@ -448,6 +448,10 @@ export function resolveTranscriptRegistration(input) {
|
|
|
448
448
|
const remoteOwnerSlug = input.cwd
|
|
449
449
|
? deriveRemoteOwnerSlug({ cwd: input.cwd, hqRoot: input.hqRoot })
|
|
450
450
|
: null;
|
|
451
|
+
// Detached transcript watch never prompts, but honours a persisted repo map.
|
|
452
|
+
const repoIdentityKey = input.cwd
|
|
453
|
+
? deriveRepoIdentityKey({ cwd: input.cwd })
|
|
454
|
+
: null;
|
|
451
455
|
const resolution = resolveCompany({
|
|
452
456
|
root: input.workContextRoot,
|
|
453
457
|
sessionId: input.sessionId,
|
|
@@ -455,6 +459,7 @@ export function resolveTranscriptRegistration(input) {
|
|
|
455
459
|
cwd: input.cwd,
|
|
456
460
|
hqRoot: input.hqRoot,
|
|
457
461
|
remoteOwnerSlug,
|
|
462
|
+
repoIdentityKey,
|
|
458
463
|
});
|
|
459
464
|
const company = resolution.status === "resolved" ? resolution.company : undefined;
|
|
460
465
|
const companySlug = company?.slug;
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
9
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
10
|
* above device default)
|
|
11
|
+
* 4b. persisted per-repo company map (gap 4 — person "always ask per repo",
|
|
12
|
+
* remembered by repoIdentityKey; above the git-remote heuristic + device
|
|
13
|
+
* default, below the identity file so an agent box always wins)
|
|
11
14
|
* 5. enabled device default
|
|
12
15
|
* 6. exactly one deterministic mapping (cwd under companies/{slug}/ or repo remote)
|
|
13
16
|
*
|
|
@@ -16,7 +19,7 @@
|
|
|
16
19
|
*/
|
|
17
20
|
import type { TrustedExplicitContext } from "./contract.js";
|
|
18
21
|
import { type SessionStateFile } from "./state.js";
|
|
19
|
-
export type CompanyResolutionSource = "trusted_explicit" | "existing_scope" | "session_meta" | "device_default" | "deterministic_cwd" | "deterministic_remote";
|
|
22
|
+
export type CompanyResolutionSource = "trusted_explicit" | "existing_scope" | "session_meta" | "repo_map" | "device_default" | "deterministic_cwd" | "deterministic_remote";
|
|
20
23
|
export interface ResolvedCompany {
|
|
21
24
|
slug?: string;
|
|
22
25
|
uid?: string;
|
|
@@ -55,6 +58,13 @@ export interface CompanyResolveInput {
|
|
|
55
58
|
metaCompanySlug?: string | null;
|
|
56
59
|
/** Optional injected deterministic remote ownership slug. */
|
|
57
60
|
remoteOwnerSlug?: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Normalised per-repo identity key (deriveRepoIdentityKey). When present, the
|
|
63
|
+
* resolver consults the persisted repo→company map (gap 4) — above the
|
|
64
|
+
* git-remote heuristic and device default, below identity file / explicit /
|
|
65
|
+
* session-meta. Omitted (or unmapped) → the ladder falls through unchanged.
|
|
66
|
+
*/
|
|
67
|
+
repoIdentityKey?: string | null;
|
|
58
68
|
}
|
|
59
69
|
/** Env naming the on-box identity.json (fleet agent boxes). */
|
|
60
70
|
export declare const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
9
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
10
|
* above device default)
|
|
11
|
+
* 4b. persisted per-repo company map (gap 4 — person "always ask per repo",
|
|
12
|
+
* remembered by repoIdentityKey; above the git-remote heuristic + device
|
|
13
|
+
* default, below the identity file so an agent box always wins)
|
|
11
14
|
* 5. enabled device default
|
|
12
15
|
* 6. exactly one deterministic mapping (cwd under companies/{slug}/ or repo remote)
|
|
13
16
|
*
|
|
@@ -16,7 +19,7 @@
|
|
|
16
19
|
*/
|
|
17
20
|
import * as fs from "node:fs";
|
|
18
21
|
import * as path from "node:path";
|
|
19
|
-
import { getDefaultCompany } from "./config.js";
|
|
22
|
+
import { getDefaultCompany, getRepoCompany } from "./config.js";
|
|
20
23
|
import { authoritativeCompanyFromState, readSessionState, } from "./state.js";
|
|
21
24
|
/** Pointer to the US-017B correction client (never auto-switches). */
|
|
22
25
|
export const COMPANY_CORRECTION_PATH_PREFIX = "hq mesh context correct --session";
|
|
@@ -247,6 +250,19 @@ export function resolveCompany(input) {
|
|
|
247
250
|
company: { uid: identityUid, source: "trusted_explicit" },
|
|
248
251
|
};
|
|
249
252
|
}
|
|
253
|
+
// 4b. Persisted per-repo company map (gap 4). A person answered once for this
|
|
254
|
+
// repo; remember it. Above the git-remote heuristic + device default, below
|
|
255
|
+
// the identity file / explicit / session-meta so an agent box always wins.
|
|
256
|
+
const repoKey = input.repoIdentityKey?.trim();
|
|
257
|
+
if (repoKey) {
|
|
258
|
+
const mapped = getRepoCompany(repoKey, { root: input.root });
|
|
259
|
+
if (mapped?.slug) {
|
|
260
|
+
return {
|
|
261
|
+
status: "resolved",
|
|
262
|
+
company: { slug: mapped.slug, uid: mapped.uid, source: "repo_map" },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
250
266
|
// 5 + 6. Device default vs deterministic evidence.
|
|
251
267
|
const deviceDefault = getDefaultCompany({ root: input.root });
|
|
252
268
|
const deterministic = resolveDeterministicCompany({
|
|
@@ -20,12 +20,27 @@ export interface MigrationCapabilitySnapshot {
|
|
|
20
20
|
migration: boolean;
|
|
21
21
|
}>;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Persisted per-repo company mapping (gap 4 — person "always ask per repo").
|
|
25
|
+
* Keyed by the normalised repo identity from deriveRepoIdentityKey
|
|
26
|
+
* (`remote:owner/name` or `root:<abs work-tree>`). Written only after a person
|
|
27
|
+
* answers the one-time interactive prompt; consulted by the resolver so the
|
|
28
|
+
* same repo never re-asks.
|
|
29
|
+
*/
|
|
30
|
+
export interface RepoCompanyMapping {
|
|
31
|
+
slug: string;
|
|
32
|
+
uid?: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
export type RepoCompanyMap = Record<string, RepoCompanyMapping>;
|
|
23
36
|
export interface WorkContextDeviceConfig {
|
|
24
37
|
schemaVersion: typeof DEVICE_CONFIG_SCHEMA_VERSION;
|
|
25
38
|
/** Explicit device default. Never copied from activeCompany. */
|
|
26
39
|
defaultCompany?: DeviceDefaultCompany | null;
|
|
27
40
|
/** Last-known migration capability across memberships (doctor reports this). */
|
|
28
41
|
migrationCapability?: MigrationCapabilitySnapshot | null;
|
|
42
|
+
/** Per-repo company map (gap 4). Remembers each repo's filed-under company. */
|
|
43
|
+
repoCompanyMap?: RepoCompanyMap | null;
|
|
29
44
|
updatedAt: string;
|
|
30
45
|
}
|
|
31
46
|
/** true / { uid } = member; false = not a member. */
|
|
@@ -50,6 +65,19 @@ export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps &
|
|
|
50
65
|
allowWithoutMigration?: boolean;
|
|
51
66
|
}): Promise<WorkContextDeviceConfig>;
|
|
52
67
|
export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
|
|
68
|
+
/**
|
|
69
|
+
* Read the persisted company mapping for a repo identity key (gap 4).
|
|
70
|
+
* Returns null when unmapped. Never throws.
|
|
71
|
+
*/
|
|
72
|
+
export declare function getRepoCompany(key: string, deps: Pick<DeviceConfigDeps, "root">): RepoCompanyMapping | null;
|
|
73
|
+
/**
|
|
74
|
+
* Persist (or overwrite) the company mapping for a repo identity key (gap 4).
|
|
75
|
+
* Called after a person answers the one-time interactive prompt.
|
|
76
|
+
*/
|
|
77
|
+
export declare function setRepoCompany(key: string, mapping: {
|
|
78
|
+
slug: string;
|
|
79
|
+
uid?: string;
|
|
80
|
+
}, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
53
81
|
/** Persist the last memberships-wide migration capability probe (US-017B). */
|
|
54
82
|
export declare function recordMigrationCapabilitySnapshot(snapshot: MigrationCapabilitySnapshot, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
55
83
|
/** Convenience: build deps from HOME / injectable root. */
|
|
@@ -15,9 +15,36 @@ function emptyConfig(now) {
|
|
|
15
15
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
16
16
|
defaultCompany: null,
|
|
17
17
|
migrationCapability: null,
|
|
18
|
+
repoCompanyMap: null,
|
|
18
19
|
updatedAt: now().toISOString(),
|
|
19
20
|
};
|
|
20
21
|
}
|
|
22
|
+
/** Sanitize a raw repoCompanyMap from disk: drop malformed entries. */
|
|
23
|
+
function sanitizeRepoCompanyMap(raw) {
|
|
24
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
25
|
+
return null;
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
28
|
+
if (!key.trim())
|
|
29
|
+
continue;
|
|
30
|
+
if (!value || typeof value !== "object")
|
|
31
|
+
continue;
|
|
32
|
+
const v = value;
|
|
33
|
+
const slug = typeof v.slug === "string" ? v.slug.trim() : "";
|
|
34
|
+
if (!slug)
|
|
35
|
+
continue;
|
|
36
|
+
const entry = {
|
|
37
|
+
slug,
|
|
38
|
+
updatedAt: typeof v.updatedAt === "string" && v.updatedAt.trim()
|
|
39
|
+
? v.updatedAt
|
|
40
|
+
: new Date(0).toISOString(),
|
|
41
|
+
};
|
|
42
|
+
if (typeof v.uid === "string" && v.uid.trim())
|
|
43
|
+
entry.uid = v.uid.trim();
|
|
44
|
+
out[key] = entry;
|
|
45
|
+
}
|
|
46
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
47
|
+
}
|
|
21
48
|
function assertSafeConfigPath(configPath) {
|
|
22
49
|
const dir = path.dirname(configPath);
|
|
23
50
|
try {
|
|
@@ -60,6 +87,7 @@ export function readDeviceConfig(deps) {
|
|
|
60
87
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
61
88
|
defaultCompany: raw.defaultCompany ?? null,
|
|
62
89
|
migrationCapability: raw.migrationCapability ?? null,
|
|
90
|
+
repoCompanyMap: sanitizeRepoCompanyMap(raw.repoCompanyMap),
|
|
63
91
|
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : new Date(0).toISOString(),
|
|
64
92
|
};
|
|
65
93
|
return cleaned;
|
|
@@ -122,6 +150,7 @@ export async function setDefaultCompany(slug, deps) {
|
|
|
122
150
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
123
151
|
defaultCompany,
|
|
124
152
|
migrationCapability: prior.migrationCapability ?? null,
|
|
153
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
125
154
|
updatedAt: now().toISOString(),
|
|
126
155
|
};
|
|
127
156
|
writeDeviceConfig(deps, next);
|
|
@@ -134,11 +163,56 @@ export function clearDefaultCompany(deps) {
|
|
|
134
163
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
135
164
|
defaultCompany: null,
|
|
136
165
|
migrationCapability: prior.migrationCapability ?? null,
|
|
166
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
137
167
|
updatedAt: now().toISOString(),
|
|
138
168
|
};
|
|
139
169
|
writeDeviceConfig(deps, next);
|
|
140
170
|
return next;
|
|
141
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Read the persisted company mapping for a repo identity key (gap 4).
|
|
174
|
+
* Returns null when unmapped. Never throws.
|
|
175
|
+
*/
|
|
176
|
+
export function getRepoCompany(key, deps) {
|
|
177
|
+
const trimmed = key?.trim();
|
|
178
|
+
if (!trimmed)
|
|
179
|
+
return null;
|
|
180
|
+
const cfg = readDeviceConfig(deps);
|
|
181
|
+
const entry = cfg.repoCompanyMap?.[trimmed];
|
|
182
|
+
if (!entry || !entry.slug?.trim())
|
|
183
|
+
return null;
|
|
184
|
+
return entry;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Persist (or overwrite) the company mapping for a repo identity key (gap 4).
|
|
188
|
+
* Called after a person answers the one-time interactive prompt.
|
|
189
|
+
*/
|
|
190
|
+
export function setRepoCompany(key, mapping, deps) {
|
|
191
|
+
const trimmed = key?.trim();
|
|
192
|
+
if (!trimmed) {
|
|
193
|
+
throw new WorkContextError("InvalidRepoKey", "Empty repo identity key");
|
|
194
|
+
}
|
|
195
|
+
const slug = mapping.slug?.trim();
|
|
196
|
+
if (!slug || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
|
|
197
|
+
throw new WorkContextError("InvalidCompanySlug", `Invalid company slug: ${mapping.slug}`);
|
|
198
|
+
}
|
|
199
|
+
const now = deps.now ?? (() => new Date());
|
|
200
|
+
const prior = readDeviceConfig({ root: deps.root });
|
|
201
|
+
const entry = { slug, updatedAt: now().toISOString() };
|
|
202
|
+
const uid = mapping.uid?.trim();
|
|
203
|
+
if (uid)
|
|
204
|
+
entry.uid = uid;
|
|
205
|
+
const nextMap = { ...(prior.repoCompanyMap ?? {}), [trimmed]: entry };
|
|
206
|
+
const next = {
|
|
207
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
208
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
209
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
210
|
+
repoCompanyMap: nextMap,
|
|
211
|
+
updatedAt: now().toISOString(),
|
|
212
|
+
};
|
|
213
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
142
216
|
/** Persist the last memberships-wide migration capability probe (US-017B). */
|
|
143
217
|
export function recordMigrationCapabilitySnapshot(snapshot, deps) {
|
|
144
218
|
const prior = readDeviceConfig(deps);
|
|
@@ -155,6 +229,7 @@ export function recordMigrationCapabilitySnapshot(snapshot, deps) {
|
|
|
155
229
|
migration: c.migration,
|
|
156
230
|
})),
|
|
157
231
|
},
|
|
232
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
158
233
|
updatedAt: now().toISOString(),
|
|
159
234
|
};
|
|
160
235
|
writeDeviceConfig({ root: deps.root }, next);
|
|
@@ -17,7 +17,9 @@ export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prep
|
|
|
17
17
|
export type { CandidatesResponse, FetchCandidatesFn, OrganizeCandidate, OrganizeListResult, OrganizeReceipt, OrganizeSubmitDecision, OrganizeSubmitResult, PostOrganizeFn, } from "./organize.js";
|
|
18
18
|
export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
|
|
19
19
|
export type { MigrateDestination, MigrateReceipt, MigrateSubmitResult, PostMigrateFn, } from "./migrate.js";
|
|
20
|
-
export { deriveRemoteOwnerSlug, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
20
|
+
export { deriveRemoteOwnerSlug, deriveRepoIdentityKey, findWorkTreeRoot, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
21
|
+
export { matchMembershipAnswer, promptRepoCompany, } from "./repo-prompt.js";
|
|
22
|
+
export type { RepoPromptCompany, RepoPromptDeps, RepoPromptOutcome, RepoPromptSkipReason, } from "./repo-prompt.js";
|
|
21
23
|
export { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "./reconcile.js";
|
|
22
24
|
export type { ReconcileDeps, ReconcileObservation, ReconcileOutcome, } from "./reconcile.js";
|
|
23
25
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -15,6 +15,7 @@ export * from "./company.js";
|
|
|
15
15
|
export * from "./project.js";
|
|
16
16
|
export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
|
|
17
17
|
export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
|
|
18
|
-
export { deriveRemoteOwnerSlug, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
18
|
+
export { deriveRemoteOwnerSlug, deriveRepoIdentityKey, findWorkTreeRoot, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
19
|
+
export { matchMembershipAnswer, promptRepoCompany, } from "./repo-prompt.js";
|
|
19
20
|
export { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "./reconcile.js";
|
|
20
21
|
//# sourceMappingURL=index.js.map
|
|
@@ -9,7 +9,7 @@ import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
|
|
|
9
9
|
import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
|
|
10
10
|
import { decisionFromCandidates, } from "./organize.js";
|
|
11
11
|
import { resolveProjectTask, shouldAskAfter } from "./project.js";
|
|
12
|
-
import { deriveRemoteOwnerSlug } from "./repo-remote.js";
|
|
12
|
+
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
|
|
13
13
|
import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, } from "./outbox.js";
|
|
14
14
|
import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
|
|
15
15
|
function resultOf(parts) {
|
|
@@ -210,6 +210,9 @@ export async function reconcileObservation(obs, deps) {
|
|
|
210
210
|
cwd: cwd ?? process.cwd(),
|
|
211
211
|
hqRoot,
|
|
212
212
|
}) ?? undefined;
|
|
213
|
+
// Persisted per-repo company map key (gap 4). The detached reconcile path
|
|
214
|
+
// NEVER prompts, but it does honour a mapping a person already answered.
|
|
215
|
+
const repoIdentityKey = deriveRepoIdentityKey({ cwd: cwd ?? process.cwd() });
|
|
213
216
|
const company = resolveCompany({
|
|
214
217
|
root: deps.root,
|
|
215
218
|
sessionId,
|
|
@@ -218,6 +221,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
218
221
|
cwd,
|
|
219
222
|
hqRoot,
|
|
220
223
|
remoteOwnerSlug,
|
|
224
|
+
repoIdentityKey,
|
|
221
225
|
existingState: prior,
|
|
222
226
|
});
|
|
223
227
|
const nowIso = nowFn().toISOString();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive per-repo company prompt (Work Mesh Live gap 4 — person attribution).
|
|
3
|
+
*
|
|
4
|
+
* Owner decision ("Always ask per repo"): every person is asked ONCE PER REPO
|
|
5
|
+
* which company the work is filed under, then the answer is remembered for that
|
|
6
|
+
* repo (persisted repo→company map in the device config).
|
|
7
|
+
*
|
|
8
|
+
* Hard gates — the prompt NEVER fires:
|
|
9
|
+
* - on machine / agent-box identities (isMachineIdentity) — those resolve from
|
|
10
|
+
* the identity file, never a prompt;
|
|
11
|
+
* - when stdin/stdout is not a TTY, or in --json / --machine / non-interactive
|
|
12
|
+
* contexts;
|
|
13
|
+
* - when cwd is not inside a git repo (no stable key to remember an answer);
|
|
14
|
+
* - when the repo is already mapped (a second resolve is silent).
|
|
15
|
+
*
|
|
16
|
+
* There is NO auto-pick from a sole membership: even a caller with exactly one
|
|
17
|
+
* active membership is asked once. The core is dependency-injected so tests never
|
|
18
|
+
* need a real TTY, network, or identity file.
|
|
19
|
+
*/
|
|
20
|
+
import { type RepoCompanyMapping } from "./config.js";
|
|
21
|
+
export interface RepoPromptCompany {
|
|
22
|
+
companyUid: string;
|
|
23
|
+
companySlug: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RepoPromptDeps {
|
|
26
|
+
/** Work-context config root (~/.hq/work-context or injected tmp). */
|
|
27
|
+
root: string;
|
|
28
|
+
/** cwd used to derive the repo identity key. */
|
|
29
|
+
cwd?: string;
|
|
30
|
+
/** True when the caller is a machine / agent-box identity. */
|
|
31
|
+
isMachineIdentity: () => boolean;
|
|
32
|
+
/** True only for an interactive person TTY (not --json/--machine/piped). */
|
|
33
|
+
isInteractive: () => boolean;
|
|
34
|
+
/** Caller's active membership companies (reused across memberships). */
|
|
35
|
+
listMemberships: () => Promise<RepoPromptCompany[]>;
|
|
36
|
+
/** Ask a single plain question; returns the raw answer (trimmed by caller). */
|
|
37
|
+
ask: (question: string) => Promise<string>;
|
|
38
|
+
/** Injectable repo key derivation (defaults to deriveRepoIdentityKey). */
|
|
39
|
+
deriveKey?: (cwd?: string) => string | null;
|
|
40
|
+
/** Injectable persisted-map lookup (defaults to getRepoCompany). */
|
|
41
|
+
lookup?: (key: string, root: string) => RepoCompanyMapping | null;
|
|
42
|
+
/** Injectable persistence (defaults to setRepoCompany). */
|
|
43
|
+
persist?: (key: string, mapping: {
|
|
44
|
+
slug: string;
|
|
45
|
+
uid?: string;
|
|
46
|
+
}, root: string) => void;
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
}
|
|
49
|
+
export type RepoPromptSkipReason = "machine" | "non_interactive" | "no_repo_key" | "no_memberships" | "cancelled";
|
|
50
|
+
export type RepoPromptOutcome = {
|
|
51
|
+
status: "resolved";
|
|
52
|
+
repoKey: string;
|
|
53
|
+
company: {
|
|
54
|
+
slug: string;
|
|
55
|
+
uid?: string;
|
|
56
|
+
};
|
|
57
|
+
/** true when this call wrote the mapping; false when already remembered. */
|
|
58
|
+
persisted: boolean;
|
|
59
|
+
alreadyMapped: boolean;
|
|
60
|
+
} | {
|
|
61
|
+
status: "skipped";
|
|
62
|
+
reason: RepoPromptSkipReason;
|
|
63
|
+
repoKey?: string;
|
|
64
|
+
};
|
|
65
|
+
/** Match a raw answer to a membership by 1-based number or slug/uid. */
|
|
66
|
+
export declare function matchMembershipAnswer(answer: string, companies: RepoPromptCompany[]): RepoPromptCompany | null;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a repo's company via the one-time interactive prompt (gap 4).
|
|
69
|
+
* Pure control flow over injected effects — no direct TTY/network/fs beyond the
|
|
70
|
+
* default config helpers. Callers must first confirm the session is unresolved
|
|
71
|
+
* (needs_company); this only decides whether/how to prompt and persist.
|
|
72
|
+
*/
|
|
73
|
+
export declare function promptRepoCompany(deps: RepoPromptDeps): Promise<RepoPromptOutcome>;
|
|
74
|
+
//# sourceMappingURL=repo-prompt.d.ts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive per-repo company prompt (Work Mesh Live gap 4 — person attribution).
|
|
3
|
+
*
|
|
4
|
+
* Owner decision ("Always ask per repo"): every person is asked ONCE PER REPO
|
|
5
|
+
* which company the work is filed under, then the answer is remembered for that
|
|
6
|
+
* repo (persisted repo→company map in the device config).
|
|
7
|
+
*
|
|
8
|
+
* Hard gates — the prompt NEVER fires:
|
|
9
|
+
* - on machine / agent-box identities (isMachineIdentity) — those resolve from
|
|
10
|
+
* the identity file, never a prompt;
|
|
11
|
+
* - when stdin/stdout is not a TTY, or in --json / --machine / non-interactive
|
|
12
|
+
* contexts;
|
|
13
|
+
* - when cwd is not inside a git repo (no stable key to remember an answer);
|
|
14
|
+
* - when the repo is already mapped (a second resolve is silent).
|
|
15
|
+
*
|
|
16
|
+
* There is NO auto-pick from a sole membership: even a caller with exactly one
|
|
17
|
+
* active membership is asked once. The core is dependency-injected so tests never
|
|
18
|
+
* need a real TTY, network, or identity file.
|
|
19
|
+
*/
|
|
20
|
+
import { setRepoCompany, getRepoCompany } from "./config.js";
|
|
21
|
+
import { deriveRepoIdentityKey } from "./repo-remote.js";
|
|
22
|
+
function buildQuestion(companies) {
|
|
23
|
+
const lines = companies.map((c, i) => ` ${i + 1}) ${c.companySlug}${c.companyUid ? ` (${c.companyUid})` : ""}`);
|
|
24
|
+
return [
|
|
25
|
+
"Which company is this repo's work filed under?",
|
|
26
|
+
...lines,
|
|
27
|
+
"Enter a number (or company slug): ",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
/** Match a raw answer to a membership by 1-based number or slug/uid. */
|
|
31
|
+
export function matchMembershipAnswer(answer, companies) {
|
|
32
|
+
const trimmed = answer.trim();
|
|
33
|
+
if (!trimmed)
|
|
34
|
+
return null;
|
|
35
|
+
if (/^\d+$/.test(trimmed)) {
|
|
36
|
+
const idx = Number.parseInt(trimmed, 10) - 1;
|
|
37
|
+
return idx >= 0 && idx < companies.length ? companies[idx] : null;
|
|
38
|
+
}
|
|
39
|
+
const needle = trimmed.toLowerCase();
|
|
40
|
+
const matches = companies.filter((c) => c.companySlug.toLowerCase() === needle ||
|
|
41
|
+
c.companyUid.toLowerCase() === needle);
|
|
42
|
+
return matches.length === 1 ? matches[0] : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a repo's company via the one-time interactive prompt (gap 4).
|
|
46
|
+
* Pure control flow over injected effects — no direct TTY/network/fs beyond the
|
|
47
|
+
* default config helpers. Callers must first confirm the session is unresolved
|
|
48
|
+
* (needs_company); this only decides whether/how to prompt and persist.
|
|
49
|
+
*/
|
|
50
|
+
export async function promptRepoCompany(deps) {
|
|
51
|
+
// Gate 1: machine / agent-box identities never prompt.
|
|
52
|
+
if (deps.isMachineIdentity()) {
|
|
53
|
+
return { status: "skipped", reason: "machine" };
|
|
54
|
+
}
|
|
55
|
+
// Gate 2: only interactive person TTYs prompt.
|
|
56
|
+
if (!deps.isInteractive()) {
|
|
57
|
+
return { status: "skipped", reason: "non_interactive" };
|
|
58
|
+
}
|
|
59
|
+
const deriveKey = deps.deriveKey ?? ((cwd) => deriveRepoIdentityKey({ cwd }));
|
|
60
|
+
const repoKey = deriveKey(deps.cwd);
|
|
61
|
+
// Gate 3: no repo → nothing stable to remember; do not prompt.
|
|
62
|
+
if (!repoKey) {
|
|
63
|
+
return { status: "skipped", reason: "no_repo_key" };
|
|
64
|
+
}
|
|
65
|
+
// Already remembered → silent resolve (a second resolve never re-asks).
|
|
66
|
+
const lookup = deps.lookup ?? ((key, root) => getRepoCompany(key, { root }));
|
|
67
|
+
const existing = lookup(repoKey, deps.root);
|
|
68
|
+
if (existing?.slug) {
|
|
69
|
+
return {
|
|
70
|
+
status: "resolved",
|
|
71
|
+
repoKey,
|
|
72
|
+
company: { slug: existing.slug, uid: existing.uid },
|
|
73
|
+
persisted: false,
|
|
74
|
+
alreadyMapped: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const memberships = await deps.listMemberships();
|
|
78
|
+
if (!memberships || memberships.length === 0) {
|
|
79
|
+
return { status: "skipped", reason: "no_memberships", repoKey };
|
|
80
|
+
}
|
|
81
|
+
// Always ask — NO auto-pick, even for a sole membership.
|
|
82
|
+
const answer = await deps.ask(buildQuestion(memberships));
|
|
83
|
+
const chosen = matchMembershipAnswer(answer, memberships);
|
|
84
|
+
if (!chosen) {
|
|
85
|
+
return { status: "skipped", reason: "cancelled", repoKey };
|
|
86
|
+
}
|
|
87
|
+
const persist = deps.persist ??
|
|
88
|
+
((key, mapping, root) => setRepoCompany(key, mapping, { root, now: deps.now }));
|
|
89
|
+
persist(repoKey, { slug: chosen.companySlug, uid: chosen.companyUid }, deps.root);
|
|
90
|
+
return {
|
|
91
|
+
status: "resolved",
|
|
92
|
+
repoKey,
|
|
93
|
+
company: { slug: chosen.companySlug, uid: chosen.companyUid },
|
|
94
|
+
persisted: true,
|
|
95
|
+
alreadyMapped: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=repo-prompt.js.map
|
|
@@ -30,6 +30,23 @@ export declare function readOriginRemoteUrl(gitDir: string): string | null;
|
|
|
30
30
|
*/
|
|
31
31
|
export declare function matchCompanySlugForRepo(ownerName: string, companies: CompaniesManifestMap): string | null;
|
|
32
32
|
export declare function readCompaniesManifestMap(hqRoot: string): CompaniesManifestMap | null;
|
|
33
|
+
/**
|
|
34
|
+
* Walk up from startDir to the working-tree root (the dir that *contains* a
|
|
35
|
+
* `.git` entry). Unlike findEnclosingGitDir this returns the work tree, not the
|
|
36
|
+
* gitdir, so it is a stable per-repo identity for the fallback repo key.
|
|
37
|
+
*/
|
|
38
|
+
export declare function findWorkTreeRoot(startDir: string): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Stable per-repo identity key for the persisted repo→company map (gap 4).
|
|
41
|
+
*
|
|
42
|
+
* Prefers the normalised git-remote `remote:owner/name` (lower-cased) so a repo
|
|
43
|
+
* keeps one mapping across clones/worktrees; falls back to `root:<abs work-tree>`
|
|
44
|
+
* when there is no recognised origin. Returns null when cwd is not inside a repo.
|
|
45
|
+
* Never runs git; never throws.
|
|
46
|
+
*/
|
|
47
|
+
export declare function deriveRepoIdentityKey(opts: {
|
|
48
|
+
cwd?: string;
|
|
49
|
+
}): string | null;
|
|
33
50
|
/**
|
|
34
51
|
* Derive the deterministic remote-owner company slug from cwd + HQ manifest.
|
|
35
52
|
* Returns null when there is no unique match.
|
|
@@ -173,6 +173,51 @@ export function readCompaniesManifestMap(hqRoot) {
|
|
|
173
173
|
return null;
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Walk up from startDir to the working-tree root (the dir that *contains* a
|
|
178
|
+
* `.git` entry). Unlike findEnclosingGitDir this returns the work tree, not the
|
|
179
|
+
* gitdir, so it is a stable per-repo identity for the fallback repo key.
|
|
180
|
+
*/
|
|
181
|
+
export function findWorkTreeRoot(startDir) {
|
|
182
|
+
let cur = path.resolve(startDir);
|
|
183
|
+
for (;;) {
|
|
184
|
+
try {
|
|
185
|
+
if (fs.existsSync(path.join(cur, ".git")))
|
|
186
|
+
return cur;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const parent = path.dirname(cur);
|
|
192
|
+
if (parent === cur)
|
|
193
|
+
return null;
|
|
194
|
+
cur = parent;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Stable per-repo identity key for the persisted repo→company map (gap 4).
|
|
199
|
+
*
|
|
200
|
+
* Prefers the normalised git-remote `remote:owner/name` (lower-cased) so a repo
|
|
201
|
+
* keeps one mapping across clones/worktrees; falls back to `root:<abs work-tree>`
|
|
202
|
+
* when there is no recognised origin. Returns null when cwd is not inside a repo.
|
|
203
|
+
* Never runs git; never throws.
|
|
204
|
+
*/
|
|
205
|
+
export function deriveRepoIdentityKey(opts) {
|
|
206
|
+
const cwd = opts.cwd?.trim() ? path.resolve(opts.cwd) : process.cwd();
|
|
207
|
+
const gitDir = findEnclosingGitDir(cwd);
|
|
208
|
+
if (gitDir) {
|
|
209
|
+
const origin = readOriginRemoteUrl(gitDir);
|
|
210
|
+
if (origin) {
|
|
211
|
+
const ownerName = normalizeRemoteOwnerName(origin);
|
|
212
|
+
if (ownerName)
|
|
213
|
+
return `remote:${ownerName.toLowerCase()}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const workTree = findWorkTreeRoot(cwd);
|
|
217
|
+
if (workTree)
|
|
218
|
+
return `root:${workTree}`;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
176
221
|
/**
|
|
177
222
|
* Derive the deterministic remote-owner company slug from cwd + HQ manifest.
|
|
178
223
|
* Returns null when there is no unique match.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.108.
|
|
3
|
+
"version": "5.108.25",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
33
33
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
34
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
34
|
+
"@indigoai-us/hq-cloud": "~6.16.28",
|
|
35
35
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
36
36
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
37
37
|
"@sentry/node": "^10.49.0",
|