@indigoai-us/hq-cli 5.108.22 → 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 CHANGED
@@ -2,6 +2,55 @@
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
+
21
+ ## [5.108.23] — 2026-09-08
22
+
23
+ ### Added
24
+
25
+ - `hq mesh context requeue` flips AUTH_DENIED-quarantined work-context outbox
26
+ operations (registrations, reconciles) back to queued so a box whose
27
+ registrations were denied before the ID-token fix recovers without a manual
28
+ reset (gap 10, #539).
29
+ - `hq mesh context backfill-held` reconciles each unattributed held session
30
+ through the shared company resolver so the daemon's held retry can drain the
31
+ ended-session backlog that would otherwise never re-attribute. Opt-in and
32
+ per-box, with `--dry-run`/`--offline`/`--limit`/`--company`/`--json` (gap 2b,
33
+ #540).
34
+
35
+ ### Fixed
36
+
37
+ - Work-context commands (`hq mesh context reconcile`/register) on agent boxes now
38
+ authenticate with the machine ID token, matching the presence daemon, instead
39
+ of the access token the server cannot attribute to the agent entity (which
40
+ answered 403 NO_PERSON_ENTITY and quarantined registrations as AUTH_DENIED).
41
+ requireToken() mints the machine ID token whenever the box is a machine
42
+ identity (gap 10, #539).
43
+ - Work Mesh Live session events that carry the `agents-v2` runtime harness are
44
+ accepted by the shared session-event schema instead of being rejected as
45
+ SCHEMA_INVALID and dead-lettered; both copies of the schema stay byte-identical
46
+ (gap 9, #541).
47
+
48
+ ### Testing
49
+
50
+ - The held-TTL flush tests (`HELD_OVERFLOW`, the 150k mid-flush-throw) inject a
51
+ deterministic clock instead of the real wall clock, so they no longer flip red
52
+ the moment the hardcoded `heldAt` fixtures cross the 7-day held TTL (#542).
53
+
5
54
  ## [5.108.22] — 2026-09-07
6
55
 
7
56
  ### Fixed
@@ -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
@@ -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)"));
@@ -7,15 +7,16 @@
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";
16
16
  import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
17
17
  import { CLI_KIND_TO_SCHEMA, EnqueueValidationError, enqueueSessionEvent, } from "../lib/mesh/live/index.js";
18
18
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
19
+ import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
19
20
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
20
21
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
21
22
  import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, detectPlatform, formatDaemonDoctor, installDaemonService, readDaemonState, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
@@ -24,6 +25,10 @@ import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-
24
25
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
25
26
  import { readSessionState } from "../lib/work-context/state.js";
26
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";
31
+ import { requeueQuarantinedOutbox } from "../lib/work-context/outbox.js";
27
32
  export function formatCheckLines(threads, company, projectId) {
28
33
  if (threads.length === 0) {
29
34
  return ["Work mesh: no active project threads found."];
@@ -178,6 +183,172 @@ async function runContextReconcile(opts) {
178
183
  }
179
184
  process.exitCode = outcome.exitCode;
180
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
+ }
283
+ async function runContextBackfillHeld(opts) {
284
+ const root = workContextHomeRoot();
285
+ const meshRoot = workMeshRoot(undefined, process.env);
286
+ const dryRun = Boolean(opts.dryRun);
287
+ const parsedLimit = opts.limit ? Number.parseInt(opts.limit, 10) : 0;
288
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 0;
289
+ const companyHint = opts.company?.trim() || undefined;
290
+ // Build the reconcile network seam exactly like `hq mesh context reconcile`.
291
+ // Not needed in --dry-run (reconcile is never invoked then). Explicit types
292
+ // because these are captured in the reconcile closure below (no control-flow
293
+ // narrowing, unlike the inline reconcile handler).
294
+ let deliver;
295
+ let fetchCandidates;
296
+ let validateMembership;
297
+ let offline = Boolean(opts.offline);
298
+ if (!dryRun && !offline) {
299
+ try {
300
+ const token = await requireToken();
301
+ deliver = createWorkSessionDeliverer({ token });
302
+ fetchCandidates = createCandidatesFetcher({ token });
303
+ validateMembership = async (candidate) => {
304
+ const membership = await resolveActiveMembershipCompany(token, candidate);
305
+ if (!membership)
306
+ return false;
307
+ return { uid: membership.companyUid };
308
+ };
309
+ }
310
+ catch {
311
+ offline = true;
312
+ if (!opts.json) {
313
+ console.error(chalk.yellow("No Cognito session; reconciling offline (outbox only)."));
314
+ }
315
+ }
316
+ }
317
+ const result = await backfillHeldSessions({
318
+ workMeshRoot: meshRoot,
319
+ workContextRoot: root,
320
+ dryRun,
321
+ limit,
322
+ reconcile: (obs) => reconcileObservation(
323
+ // Pass --company as a non-forcing hint (remoteOwnerSlug feeds
324
+ // deterministic resolution BELOW the identity-file / device default,
325
+ // so the shared resolver still wins, as in normal reconcile).
326
+ companyHint ? { ...obs, remoteOwnerSlug: companyHint } : obs, {
327
+ root,
328
+ env: process.env,
329
+ deliver,
330
+ fetchCandidates,
331
+ offline,
332
+ validateMembership,
333
+ }),
334
+ });
335
+ if (opts.json) {
336
+ console.log(JSON.stringify({ ok: true, action: "backfill-held", ...result }, null, 2));
337
+ return;
338
+ }
339
+ const verb = dryRun ? "would reconcile" : "reconciled";
340
+ console.log(`Backfill held: scanned ${result.scanned} session(s)` +
341
+ `${limit > 0 ? ` (considered ${result.considered})` : ""}; ` +
342
+ `${result.alreadyAttributed} already attributed, ` +
343
+ `${verb} ${result.reconciled}, ` +
344
+ `${result.unresolved} unresolved, ` +
345
+ `${result.errors} error(s).` +
346
+ (dryRun ? " (dry-run: nothing written)" : ""));
347
+ if (!dryRun && result.reconciled > 0) {
348
+ console.log("Reconciled session state files were rewritten with a resolved company; " +
349
+ "the mesh daemon's held retry will re-attribute and post those events.");
350
+ }
351
+ }
181
352
  async function runContextDefaultGet(opts) {
182
353
  const root = workContextHomeRoot();
183
354
  const cfg = readDeviceConfig({ root });
@@ -393,6 +564,30 @@ async function runContextDefaultClear(opts) {
393
564
  }
394
565
  console.log("Default company cleared.");
395
566
  }
567
+ async function runContextRequeue(opts) {
568
+ const root = workContextHomeRoot();
569
+ // Default: only AUTH_DENIED (the gap 10 regression). --all clears the code
570
+ // filter entirely; --error-code targets one specific lastErrorCode.
571
+ const errorCode = opts.all
572
+ ? null
573
+ : opts.errorCode?.trim()
574
+ ? opts.errorCode.trim()
575
+ : undefined;
576
+ const requeued = requeueQuarantinedOutbox(root, { errorCode });
577
+ if (opts.json) {
578
+ console.log(JSON.stringify({
579
+ ok: true,
580
+ requeued: requeued.length,
581
+ errorCode: errorCode === undefined ? "AUTH_DENIED" : errorCode,
582
+ operationIds: requeued.map((op) => op.operationId),
583
+ }, null, 2));
584
+ return;
585
+ }
586
+ const scope = errorCode === null
587
+ ? "all quarantine reasons"
588
+ : `lastErrorCode=${errorCode === undefined ? "AUTH_DENIED" : errorCode}`;
589
+ console.log(`Requeued ${requeued.length} quarantined outbox operation${requeued.length === 1 ? "" : "s"} (${scope}). The mesh daemon will retry them on its next replay.`);
590
+ }
396
591
  async function runContextUntracked(sessionId, opts) {
397
592
  if (!isValidSessionId(sessionId)) {
398
593
  fail(`Invalid session id: ${sessionId}`);
@@ -524,6 +719,7 @@ const HARNESSES = new Set([
524
719
  "grok",
525
720
  "hq-sessions",
526
721
  "agent-box",
722
+ "agents-v2",
527
723
  ]);
528
724
  function workMeshHomeRoot() {
529
725
  return workMeshRoot(os.homedir(), process.env);
@@ -537,7 +733,7 @@ async function runSessionEnqueue(cliKind, opts) {
537
733
  fail(`Unknown session verb: ${cliKind}`);
538
734
  const harness = (opts.harness?.trim() || "");
539
735
  if (!HARNESSES.has(harness)) {
540
- fail("--harness is required (claude-code|codex|grok|hq-sessions|agent-box)");
736
+ fail("--harness is required (claude-code|codex|grok|hq-sessions|agent-box|agents-v2)");
541
737
  }
542
738
  const adapterVersion = opts.adapterVersion?.trim();
543
739
  if (!adapterVersion)
@@ -621,7 +817,7 @@ function addSessionEnqueueFlags(cmd) {
621
817
  return cmd
622
818
  .option("--enqueue", "Append one line to ~/.hq/work-mesh/spool.jsonl (no network)")
623
819
  .option("--session-id <id>", "Session id (else HQ_SESSION_ID / harness env)")
624
- .option("--harness <name>", "claude-code|codex|grok|hq-sessions|agent-box")
820
+ .option("--harness <name>", "claude-code|codex|grok|hq-sessions|agent-box|agents-v2")
625
821
  .option("--adapter-version <ver>", "Hook / adapter version")
626
822
  .option("--runtime-version <ver>", "Host runtime version")
627
823
  .option("--seq <n>", "Monotonic per-session sequence (>= 1)")
@@ -712,6 +908,24 @@ export function registerMeshCommand(program) {
712
908
  .option("--machine", "Write exactly one ContextResult JSON line to stdout")
713
909
  .option("--offline", "Skip network; leave register operations queued in the outbox")
714
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))());
920
+ context
921
+ .command("backfill-held")
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)")
923
+ .option("--dry-run", "Report what would be reconciled; write nothing, no network")
924
+ .option("--offline", "Reconcile outbox-only (no candidates/register network)")
925
+ .option("--limit <n>", "Cap sessions processed (default 0 = no cap)")
926
+ .option("--company <slug>", "Optional non-forcing company hint (resolver default still wins)")
927
+ .option("--json", "Print machine-readable JSON")
928
+ .action((opts) => wrap(() => runContextBackfillHeld(opts))());
715
929
  context
716
930
  .command("organize")
717
931
  .description("List or submit one-time project/task decisions (US-007B / US-005B)")
@@ -744,6 +958,13 @@ export function registerMeshCommand(program) {
744
958
  .option("--json", "Print machine-readable JSON")
745
959
  .option("--machine", "Compact JSON on stdout")
746
960
  .action((opts) => wrap(() => runContextCorrect(opts))());
961
+ context
962
+ .command("requeue")
963
+ .description("Requeue quarantined outbox operations (default: only AUTH_DENIED — work-mesh-live gap 10 recovery)")
964
+ .option("--all", "Requeue every quarantined op regardless of lastErrorCode")
965
+ .option("--error-code <code>", "Requeue quarantined ops with this lastErrorCode")
966
+ .option("--json", "Print machine-readable JSON")
967
+ .action((opts) => wrap(() => runContextRequeue(opts))());
747
968
  const def = context
748
969
  .command("default")
749
970
  .description("Manage the device-local default company preference");
@@ -5,7 +5,7 @@
5
5
  * machine cache apps already read. Does not spawn the pack helper and does
6
6
  * not start MQTT listen.
7
7
  */
8
- import { ensureCognitoToken } from "../../utils/cognito-session.js";
8
+ import { ensureCognitoToken, isMachineIdentity } from "../../utils/cognito-session.js";
9
9
  import { peekIdToken } from "../../utils/id-token.js";
10
10
  import { getCompanyUid, vaultApiFetch } from "../../utils/vault-api.js";
11
11
  import { meshCacheRoot, removeSessionFallbackFiles, writeMeshCacheFile, } from "./cache.js";
@@ -119,6 +119,19 @@ export async function resolveActiveMembershipCompany(token, slug) {
119
119
  return null;
120
120
  }
121
121
  export async function requireToken() {
122
+ // Agent/machine boxes must authenticate the way the mesh daemon does:
123
+ // an agent's identity claims (custom:entityType=agent, custom:entityUid)
124
+ // ride the ID token ONLY; a person-cache ACCESS token on the box carries
125
+ // no entity claims and the server answers 403 NO_PERSON_ENTITY, which the
126
+ // outbox then quarantines as AUTH_DENIED (work-mesh-live gap 10). Force
127
+ // the machine mint whenever machine creds are readable so we always send
128
+ // the agent ID token, matching src/lib/mesh/live/daemon/run.ts. Per the
129
+ // hard policy indigo-agent-caller-resolution-id-token, agent claims ride
130
+ // the ID token only; the server is correct to require it. Humans keep the
131
+ // person access token (unchanged).
132
+ if (isMachineIdentity()) {
133
+ return ensureCognitoToken({ tokenSource: "machine" });
134
+ }
122
135
  return ensureCognitoToken();
123
136
  }
124
137
  function asRecord(value) {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Backfill held session events by reconciling their ENDED sessions (gap 2b).
3
+ *
4
+ * Background: hq-cli 5.108.22 fixed company resolution for sessions reconciled
5
+ * from now on, but the daemon's held retry (see flush.ts `classifySession` →
6
+ * NEEDS_COMPANY) decides purely from the per-session work-context state file,
7
+ * and only a reconcile rewrites that file. Held events belong to sessions that
8
+ * already ENDED, so nothing reconciles them again — the held backlog sits held
9
+ * forever.
10
+ *
11
+ * This module re-runs `reconcileObservation` once per distinct held session
12
+ * that still lacks a companyUid, which rewrites its state file with the resolved
13
+ * company (via the identity-file default resolver shipped in 5.108.22). The
14
+ * daemon's next held retry then re-attributes and posts those events naturally.
15
+ *
16
+ * Constraints (owner directive):
17
+ * - Explicit, opt-in only. Never automatic; never wired into a hook or daemon.
18
+ * - Idempotent: sessions that already carry a companyUid are skipped.
19
+ * - Never deletes held events; the daemon posts them on the next retry.
20
+ * - Purely local to the box it runs on; no fleet fan-out.
21
+ */
22
+ import type { ReconcileObservation, ReconcileOutcome } from "../../work-context/reconcile.js";
23
+ import { type SessionStateFile } from "../../work-context/state.js";
24
+ /** One distinct session discovered in held.jsonl, with its harness. */
25
+ export interface HeldSessionRef {
26
+ sessionId: string;
27
+ harness?: string;
28
+ }
29
+ export interface BackfillHeldResult {
30
+ /** Distinct sessions found in held.jsonl (before --limit). */
31
+ scanned: number;
32
+ /** Distinct sessions considered this run (after --limit). */
33
+ considered: number;
34
+ /** Skipped because state already has a companyUid. */
35
+ alreadyAttributed: number;
36
+ /** Reconcile invoked and a company was resolved (would-reconcile in dry-run). */
37
+ reconciled: number;
38
+ /** Reconcile ran but resolver found no company. */
39
+ unresolved: number;
40
+ /** Sessions that errored during reconcile. */
41
+ errors: number;
42
+ dryRun: boolean;
43
+ }
44
+ export interface BackfillHeldDeps {
45
+ /** Work-mesh root that holds held.jsonl. */
46
+ workMeshRoot: string;
47
+ /** Work-context root that holds sessions/<sid>.json. */
48
+ workContextRoot: string;
49
+ /** Report only; write nothing, call no network, invoke no reconcile. */
50
+ dryRun?: boolean;
51
+ /** Cap distinct sessions processed. 0 (default) = no cap. */
52
+ limit?: number;
53
+ /** Contract version stamped on the synthesized observation. */
54
+ contractVersion?: number;
55
+ /** Fresh clientOperationId per reconcile. Default crypto.randomUUID. */
56
+ newOperationId?: () => string;
57
+ /** State reader seam. Default readSessionState. */
58
+ readState?: (sessionId: string, root: string) => SessionStateFile | null;
59
+ /**
60
+ * Reconcile one observation. NOT called in dry-run. The CLI wrapper wires
61
+ * this to reconcileObservation with the real deliver/fetchCandidates/
62
+ * validateMembership seam (or offline fallback), exactly mirroring the
63
+ * `hq mesh context reconcile` handler.
64
+ */
65
+ reconcile: (obs: ReconcileObservation) => Promise<ReconcileOutcome>;
66
+ /** Optional progress logger. */
67
+ log?: (message: string) => void;
68
+ }
69
+ /**
70
+ * Read held.jsonl and return distinct sessions (first occurrence wins),
71
+ * carrying the harness from whichever held event we saw first for that session.
72
+ * Lines that fail to parse or carry no sessionId are ignored (the daemon owns
73
+ * their disposition; this backfill never mutates or deletes held lines).
74
+ */
75
+ export declare function readHeldSessions(workMeshRoot: string): HeldSessionRef[];
76
+ /**
77
+ * Reconcile ended sessions whose held events lack a company. Idempotent:
78
+ * re-running skips sessions that already carry a companyUid.
79
+ */
80
+ export declare function backfillHeldSessions(deps: BackfillHeldDeps): Promise<BackfillHeldResult>;
81
+ //# sourceMappingURL=backfill-held.d.ts.map