@indigoai-us/hq-cli 5.108.23 → 5.108.24
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 +16 -0
- package/dist/commands/cloud.d.ts +10 -0
- package/dist/commands/cloud.js +76 -1
- 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,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.24] — 2026-09-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq sync doctor --reconcile-conflicts [--yes]` folds legacy sibling
|
|
10
|
+
`.conflict-<ts>-<machine>` twins back into their live files (higher
|
|
11
|
+
frontmatter `version:` wins, else newer mtime), parks the losing side under
|
|
12
|
+
`.hq/conflict-backups/`, and surfaces undecidable pairs for manual review.
|
|
13
|
+
Dry-run by default; `--yes` applies.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Bump `@indigoai-us/hq-cloud` to 6.16.28: sync no longer writes sibling
|
|
18
|
+
`.conflict-*` twins on conflict and instead picks the higher `version:`
|
|
19
|
+
frontmatter side, parking the loser under `.hq/conflict-backups/`.
|
|
20
|
+
|
|
5
21
|
## [5.108.23] — 2026-09-08
|
|
6
22
|
|
|
7
23
|
### 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/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.24",
|
|
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",
|