@indigoai-us/hq-cli 5.108.25 → 5.108.26

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/commands/integrations-api.d.ts +15 -0
  3. package/dist/commands/integrations-connect.js +84 -3
  4. package/dist/commands/integrations-oauth.js +62 -3
  5. package/dist/commands/mcp-registration.d.ts +17 -7
  6. package/dist/commands/mcp-registration.js +16 -27
  7. package/dist/commands/mesh.js +174 -50
  8. package/dist/commands/pack-install.js +5 -5
  9. package/dist/commands/secrets.d.ts +7 -0
  10. package/dist/commands/secrets.js +26 -2
  11. package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
  12. package/dist/lib/mesh/live/backfill-held.js +95 -13
  13. package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
  14. package/dist/lib/mesh/live/daemon/doctor.js +41 -10
  15. package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
  16. package/dist/lib/mesh/live/daemon/mode.js +88 -0
  17. package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
  18. package/dist/lib/mesh/live/daemon/run.js +39 -28
  19. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  20. package/dist/lib/mesh/live/emit-client.d.ts +99 -0
  21. package/dist/lib/mesh/live/emit-client.js +193 -0
  22. package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
  23. package/dist/lib/mesh/live/emit-evidence.js +77 -0
  24. package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
  25. package/dist/lib/mesh/live/emit-replay.js +157 -0
  26. package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
  27. package/dist/lib/mesh/live/emit-retry.js +79 -0
  28. package/dist/lib/mesh/live/emit.d.ts +54 -0
  29. package/dist/lib/mesh/live/emit.js +153 -0
  30. package/dist/lib/narrow-hint-banner.d.ts +3 -7
  31. package/dist/lib/narrow-hint-banner.js +13 -34
  32. package/dist/lib/plan-limit-nag.d.ts +0 -3
  33. package/dist/lib/plan-limit-nag.js +10 -20
  34. package/package.json +1 -1
@@ -14,10 +14,17 @@ import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, cre
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
- import { CLI_KIND_TO_SCHEMA, EnqueueValidationError, enqueueSessionEvent, } from "../lib/mesh/live/index.js";
17
+ import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/index.js";
18
18
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
19
19
  import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
20
20
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
21
+ import { buildEmitEvidence } from "../lib/mesh/live/emit-evidence.js";
22
+ import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
23
+ import { emitEvents } from "../lib/mesh/live/emit.js";
24
+ import { readEmitRetry, writeEmitRetry } from "../lib/mesh/live/emit-retry.js";
25
+ import { generateUlid } from "../lib/mesh/live/ulid.js";
26
+ import { readLegacyBacklog } from "../lib/mesh/live/emit-replay.js";
27
+ import { resolveMeshEmitMode, writeMeshConfigMode, } from "../lib/mesh/live/daemon/mode.js";
21
28
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
22
29
  import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, detectPlatform, formatDaemonDoctor, installDaemonService, readDaemonState, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
23
30
  import { workContextRoot } from "../lib/work-context/paths.js";
@@ -319,6 +326,9 @@ async function runContextBackfillHeld(opts) {
319
326
  workContextRoot: root,
320
327
  dryRun,
321
328
  limit,
329
+ // Mirror the reconcile closure's non-forcing hint in the dry-run predictor
330
+ // so `--dry-run` and the real run report the same counts.
331
+ remoteOwnerHint: companyHint || undefined,
322
332
  reconcile: (obs) => reconcileObservation(
323
333
  // Pass --company as a non-forcing hint (remoteOwnerSlug feeds
324
334
  // deterministic resolution BELOW the identity-file / device default,
@@ -725,9 +735,9 @@ function workMeshHomeRoot() {
725
735
  return workMeshRoot(os.homedir(), process.env);
726
736
  }
727
737
  async function runSessionEnqueue(cliKind, opts) {
728
- if (!opts.enqueue) {
729
- fail("`hq mesh session …` requires --enqueue (local spool append; no network).");
730
- }
738
+ // `--enqueue` is retained for hook back-compat but now means "emit directly"
739
+ // (owner decision 2026-09-08: sessions POST events to the server over HTTPS;
740
+ // no local spool/daemon on the emit path).
731
741
  const kind = CLI_KIND_TO_SCHEMA[cliKind];
732
742
  if (!kind)
733
743
  fail(`Unknown session verb: ${cliKind}`);
@@ -741,57 +751,155 @@ async function runSessionEnqueue(cliKind, opts) {
741
751
  const seq = Number(opts.seq);
742
752
  if (!Number.isInteger(seq) || seq < 1)
743
753
  fail("--seq must be an integer >= 1");
744
- let toolWrites;
745
- if (opts.toolWrites !== undefined) {
746
- toolWrites = Number(opts.toolWrites);
747
- if (!Number.isInteger(toolWrites) || toolWrites < 0) {
748
- fail("--tool-writes must be an integer >= 0");
754
+ const sessionId = resolveEnqueueSessionId(opts.sessionId, process.env);
755
+ if (!sessionId)
756
+ fail("sessionId required (--session-id or HQ_SESSION_ID)");
757
+ const evidence = buildEmitEvidence({
758
+ sessionId: sessionId,
759
+ hqRoot: opts.hqRoot,
760
+ cwd: opts.cwd,
761
+ touchedPaths: opts.touchedPath,
762
+ repoPath: opts.repoPath,
763
+ companySlug: opts.companySlug,
764
+ project: opts.project,
765
+ task: opts.task,
766
+ });
767
+ const event = buildMeshEmitEvent({
768
+ eventId: opts.eventId?.trim() || generateUlid(Date.now()),
769
+ kind: kind,
770
+ sessionId: sessionId,
771
+ harness,
772
+ adapterVersion,
773
+ at: opts.at?.trim() || new Date().toISOString(),
774
+ seq,
775
+ runtimeVersion: opts.runtimeVersion,
776
+ source: "hooks",
777
+ taskId: opts.taskId,
778
+ status: opts.status,
779
+ reason: opts.reason,
780
+ summary: opts.summary,
781
+ evidence,
782
+ });
783
+ const meshRoot = workMeshHomeRoot();
784
+ // Resolve a token non-interactively (same path as every CLI call). If the box
785
+ // is not logged in, retain the event locally for the next invocation to drain.
786
+ let token;
787
+ try {
788
+ token = await requireToken();
789
+ }
790
+ catch {
791
+ token = null;
792
+ }
793
+ if (!token) {
794
+ const pending = readEmitRetry(meshRoot);
795
+ const { written } = writeEmitRetry(meshRoot, [...pending, event]);
796
+ if (opts.json) {
797
+ console.log(JSON.stringify({ ok: true, action: "emit", kind, deferred: true, retryDepth: written }, null, 2));
798
+ return;
749
799
  }
800
+ console.error(`work-mesh: no token; deferred ${kind} (retryDepth=${written})`);
801
+ return;
750
802
  }
751
- try {
752
- const result = enqueueSessionEvent({
753
- kind: kind,
754
- sessionId: opts.sessionId,
755
- harness,
756
- adapterVersion,
757
- runtimeVersion: opts.runtimeVersion,
758
- seq,
759
- eventId: opts.eventId,
760
- at: opts.at,
761
- taskId: opts.taskId,
762
- status: opts.status,
763
- reason: opts.reason,
764
- summary: opts.summary,
765
- cwd: opts.cwd,
766
- hqRoot: opts.hqRoot,
767
- companySlug: opts.companySlug,
768
- project: opts.project,
769
- task: opts.task,
770
- toolWrites,
771
- root: workMeshHomeRoot(),
772
- env: process.env,
773
- });
803
+ const poster = createEmitPoster({
804
+ token,
805
+ baseUrl: resolveVaultApiBase(process.env),
806
+ });
807
+ const summary = await emitEvents({
808
+ workMeshRoot: meshRoot,
809
+ poster,
810
+ newEvents: [event],
811
+ });
812
+ if (opts.json) {
813
+ console.log(JSON.stringify({ ok: true, action: "emit", kind, ...summary }, null, 2));
814
+ return;
815
+ }
816
+ console.error(`work-mesh: emit ${kind} accepted=${summary.accepted}` +
817
+ ` unassigned=${summary.unassigned} rejected=${summary.rejected}` +
818
+ ` retryDepth=${summary.retryDepth}`);
819
+ }
820
+ async function runMeshMode(action, opts) {
821
+ const meshRoot = workMeshHomeRoot();
822
+ const act = (action ?? "get").toLowerCase();
823
+ if (act === "get") {
824
+ const mode = resolveMeshEmitMode({ env: process.env, meshRoot });
774
825
  if (opts.json) {
775
- console.log(JSON.stringify({
776
- ok: true,
777
- action: "enqueue",
778
- kind,
779
- spoolPath: result.spoolPath,
780
- eventId: result.event.eventId,
781
- }, null, 2));
826
+ console.log(JSON.stringify({ ok: true, action: "mode", mode }, null, 2));
782
827
  return;
783
828
  }
784
- // Quiet success for hooks/scripts — eventId on stderr only when not json.
785
- console.error(`work-mesh: enqueued ${kind} ${result.event.eventId}`);
829
+ console.log(`mesh emit mode: ${mode}`);
830
+ return;
786
831
  }
787
- catch (err) {
788
- if (err instanceof EnqueueValidationError) {
789
- console.error(chalk.red(`enqueue validation failed: ${err.message}`));
790
- process.exitCode = 1;
832
+ if (act === "legacy" || act === "direct") {
833
+ writeMeshConfigMode(meshRoot, act);
834
+ const mode = resolveMeshEmitMode({ env: process.env, meshRoot });
835
+ if (opts.json) {
836
+ console.log(JSON.stringify({ ok: true, action: "mode", set: act, effective: mode }, null, 2));
791
837
  return;
792
838
  }
793
- throw err;
839
+ console.log(`mesh emit mode set to ${act}` +
840
+ (mode !== act
841
+ ? ` (note: HQ_MESH_MODE=${process.env.HQ_MESH_MODE} overrides → ${mode})`
842
+ : "") +
843
+ `. Restart the daemon (hq mesh daemon run) to apply.`);
844
+ return;
845
+ }
846
+ if (act === "check") {
847
+ // Probe the server route with an empty batch: 2xx/400 => route live,
848
+ // 404 => not deployed yet (safe to stay legacy).
849
+ let token;
850
+ try {
851
+ token = await requireToken();
852
+ }
853
+ catch (err) {
854
+ fail(err instanceof Error ? err.message : String(err));
855
+ return;
856
+ }
857
+ const poster = createEmitPoster({
858
+ token,
859
+ baseUrl: resolveVaultApiBase(process.env),
860
+ });
861
+ const res = await poster([]);
862
+ const ready = res.status === 200 || res.status === 400;
863
+ if (opts.json) {
864
+ console.log(JSON.stringify({ ok: true, action: "mode", check: true, status: res.status, ready }, null, 2));
865
+ return;
866
+ }
867
+ console.log(`mesh route /v1/mesh/events: status=${res.status} ${ready ? "READY (safe to `hq mesh mode direct`)" : "not ready (stay legacy)"}`);
868
+ return;
869
+ }
870
+ fail(`Unknown mode action: ${act} (use get|legacy|direct|check)`);
871
+ }
872
+ async function runMeshEmit(opts) {
873
+ const token = await requireToken();
874
+ const meshRoot = workMeshHomeRoot();
875
+ const poster = createEmitPoster({
876
+ token,
877
+ baseUrl: resolveVaultApiBase(process.env),
878
+ });
879
+ let scanned = 0;
880
+ let skipped = 0;
881
+ let newEvents = [];
882
+ if (opts.replayLegacy) {
883
+ const backlog = readLegacyBacklog(meshRoot);
884
+ scanned = backlog.scanned;
885
+ skipped = backlog.skipped;
886
+ newEvents = backlog.events;
887
+ }
888
+ const summary = await emitEvents({ workMeshRoot: meshRoot, poster, newEvents });
889
+ const out = {
890
+ ok: true,
891
+ action: "emit",
892
+ ...(opts.replayLegacy ? { replayLegacy: true, legacyScanned: scanned, legacySkipped: skipped } : {}),
893
+ ...summary,
894
+ };
895
+ if (opts.json) {
896
+ console.log(JSON.stringify(out, null, 2));
897
+ return;
794
898
  }
899
+ console.log(`work-mesh emit: attempted=${summary.attempted} accepted=${summary.accepted}` +
900
+ ` unassigned=${summary.unassigned} rejected=${summary.rejected}` +
901
+ ` retryDepth=${summary.retryDepth}` +
902
+ (opts.replayLegacy ? ` (legacy scanned=${scanned} skipped=${skipped})` : ""));
795
903
  }
796
904
  async function runSessionFlush(opts) {
797
905
  const token = await requireToken();
@@ -829,10 +937,12 @@ function addSessionEnqueueFlags(cmd) {
829
937
  .option("--summary <text>", "Short summary (max 280)")
830
938
  .option("--cwd <path>", "Local-only working directory")
831
939
  .option("--hq-root <path>", "Local-only HQ tree root")
832
- .option("--company-slug <slug>", "Local-only company slug hint")
833
- .option("--project <name>", "Local-only project hint")
834
- .option("--task <label>", "Local-only task label hint")
835
- .option("--tool-writes <n>", "Local-only tool write count")
940
+ .option("--company-slug <slug>", "Attribution evidence: bound company slug")
941
+ .option("--project <name>", "Attribution evidence: project id/slug")
942
+ .option("--task <label>", "Attribution evidence: task id/label")
943
+ .option("--touched-path <path>", "Attribution evidence: a file path this tool call touched (repeatable)", collectRepeatable)
944
+ .option("--repo-path <path>", "Attribution evidence: enclosing repo path")
945
+ .option("--tool-writes <n>", "Local-only tool write count (unused on emit)")
836
946
  .option("--json", "Print machine-readable JSON");
837
947
  }
838
948
  function wrap(action) {
@@ -1012,6 +1122,20 @@ export function registerMeshCommand(program) {
1012
1122
  .description("Claim spool/held by rename, hold or drop by context state, POST batches of ≤100")
1013
1123
  .option("--json", "Print machine-readable JSON")
1014
1124
  .action((opts) => wrap(() => runSessionFlush(opts))());
1125
+ mesh
1126
+ .command("mode")
1127
+ .description("Get/set the daemon emit mode (legacy = spool/flush; direct = receive-only). " +
1128
+ "`check` probes whether the server route is live.")
1129
+ .argument("[action]", "get | legacy | direct | check (default get)")
1130
+ .option("--json", "Print machine-readable JSON")
1131
+ .action((action, opts) => wrap(() => runMeshMode(action, opts))());
1132
+ mesh
1133
+ .command("emit")
1134
+ .description("Direct-emit drain: POST any deferred events (retry file) to /v1/mesh/events; " +
1135
+ "--replay-legacy also replays the legacy spool/held backlog through the new endpoint")
1136
+ .option("--replay-legacy", "Also replay spool.jsonl + held.jsonl with their evidence")
1137
+ .option("--json", "Print machine-readable JSON")
1138
+ .action((opts) => wrap(() => runMeshEmit(opts))());
1015
1139
  session
1016
1140
  .command("status")
1017
1141
  .description("Print the company-wide live read (US-004) as a table or --json")
@@ -52,7 +52,7 @@ import { safeExtractTarball } from './safe-extract.js';
52
52
  import { getCompanyUid, vaultApiFetch, vaultApiFetchPublic, } from '../utils/vault-api.js';
53
53
  import { ensureCognitoToken } from '../utils/cognito-session.js';
54
54
  import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
55
- import { redactSecrets, SECRET_REDACTION, captureMcpRegistrationDecision, registerMcpServers, McpManifestError, } from './mcp-registration.js';
55
+ import { redactSecrets, SECRET_REDACTION, isMcpRegistrationEnabled, registerMcpServers, McpManifestError, } from './mcp-registration.js';
56
56
  import { listSecretCacheScopes } from '../utils/secrets-cache.js';
57
57
  import { loadRevealedSecrets } from './secrets.js';
58
58
  const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
@@ -1492,9 +1492,9 @@ async function wireMcpServers(pkg, destDir, company) {
1492
1492
  const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
1493
1493
  const registered = [];
1494
1494
  const skipped = [];
1495
- // A refresh may arrive while secret resolution awaits. Freeze the gate before
1496
- // the loop so an install never writes only an arbitrary prefix of a pack.
1497
- const flagReader = captureMcpRegistrationDecision();
1495
+ // Resolve the kill-switch once and freeze it before the loop so an install
1496
+ // never writes only an arbitrary prefix of a pack's servers.
1497
+ const registrationEnabled = isMcpRegistrationEnabled();
1498
1498
  for (const name of pkg.contributes.mcp ?? []) {
1499
1499
  try {
1500
1500
  const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
@@ -1503,7 +1503,7 @@ async function wireMcpServers(pkg, destDir, company) {
1503
1503
  const results = registerMcpServers(pkg.name, [name], {
1504
1504
  loadManifest,
1505
1505
  resolveSecret,
1506
- flagReader,
1506
+ registrationEnabled,
1507
1507
  });
1508
1508
  if (results.length > 0)
1509
1509
  registered.push(name);
@@ -3,6 +3,13 @@ import { writeSync } from "node:fs";
3
3
  import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
4
4
  export type { VaultApiOptions } from "../utils/vault-api.js";
5
5
  export { vaultApiFetch, getCompanyUid, getEntityUid };
6
+ /**
7
+ * Mirrors `RESOURCE_SURFACES` in hq-pro
8
+ * `src/journey/limited-resource-telemetry.ts`. Keep this list in sync with
9
+ * the server so CLI telemetry cannot silently fall back to `api`.
10
+ */
11
+ export declare const HQ_PRO_RESOURCE_SURFACES: readonly ["api", "bot_invite", "bot_join_now", "calendar_auto_schedule", "calendar_preferences", "cli_secrets_set", "console_secrets_form", "deploy_precheck", "external_connection", "factory_install", "integrations_connect", "oauth_callback", "plan_limit_check", "secrets_input_link", "web"];
12
+ export declare const CLI_SECRETS_SET_SURFACE = "cli_secrets_set";
6
13
  export type SecretTier = "standard" | "sensitive" | "nuclear";
7
14
  export type SecretScriptLockMode = "off" | "enforced";
8
15
  export type SecretUsageChannel = "run" | "exec" | "env" | "sandbox" | "reveal" | "submit-link";
@@ -12,6 +12,29 @@ import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from
12
12
  import { HQ_API_KEY_PREFIX, formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
13
13
  import { SandboxRunnerClient, } from "../utils/sandbox-runner-client.js";
14
14
  export { vaultApiFetch, getCompanyUid, getEntityUid };
15
+ /**
16
+ * Mirrors `RESOURCE_SURFACES` in hq-pro
17
+ * `src/journey/limited-resource-telemetry.ts`. Keep this list in sync with
18
+ * the server so CLI telemetry cannot silently fall back to `api`.
19
+ */
20
+ export const HQ_PRO_RESOURCE_SURFACES = [
21
+ "api",
22
+ "bot_invite",
23
+ "bot_join_now",
24
+ "calendar_auto_schedule",
25
+ "calendar_preferences",
26
+ "cli_secrets_set",
27
+ "console_secrets_form",
28
+ "deploy_precheck",
29
+ "external_connection",
30
+ "factory_install",
31
+ "integrations_connect",
32
+ "oauth_callback",
33
+ "plan_limit_check",
34
+ "secrets_input_link",
35
+ "web",
36
+ ];
37
+ export const CLI_SECRETS_SET_SURFACE = "cli_secrets_set";
15
38
  /**
16
39
  * Cognito session for secrets surfaces hq-pro serves on the JWT routes only —
17
40
  * the capability would apply, but there is no Bearer `hqk_` route (no HEAD
@@ -829,9 +852,10 @@ export function registerSecretsCommand(program) {
829
852
  body: {
830
853
  name,
831
854
  value,
855
+ surface: CLI_SECRETS_SET_SURFACE,
832
856
  // Only present when --high-security was passed — an ordinary
833
- // `set` with no flags sends exactly `{ name, value }`, byte-for-
834
- // byte unchanged from before this story.
857
+ // `set` with no flags sends only its name/value plus this additive
858
+ // telemetry attribution field.
835
859
  ...(opts.highSecurity ? { highSecurity: true } : {}),
836
860
  ...(destinations ? { destinations } : {}),
837
861
  ...(injection ? { injection } : {}),
@@ -21,10 +21,28 @@
21
21
  */
22
22
  import type { ReconcileObservation, ReconcileOutcome } from "../../work-context/reconcile.js";
23
23
  import { type SessionStateFile } from "../../work-context/state.js";
24
- /** One distinct session discovered in held.jsonl, with its harness. */
24
+ /**
25
+ * One distinct session discovered in held.jsonl, carrying the company evidence
26
+ * its held events recorded at enqueue time (cwd / hqRoot / companySlug /
27
+ * project / task). ENDED sessions never reconcile again, so this per-event
28
+ * evidence is the only company signal a backfill can use — the synthesized
29
+ * observation forwards it so the shared resolver (cwd → companies/{slug}/,
30
+ * meta.yaml company_slug via hqRoot, or the event's own bound companySlug)
31
+ * can attribute the backlog instead of holding it forever.
32
+ */
25
33
  export interface HeldSessionRef {
26
34
  sessionId: string;
27
35
  harness?: string;
36
+ /** Working directory the events were emitted from (deterministic company/project evidence). */
37
+ cwd?: string;
38
+ /** HQ root, so the resolver can read workspace/sessions/<sid>/meta.yaml. */
39
+ hqRoot?: string;
40
+ /** Company the session was bound to at emit time (enqueue --company-slug). */
41
+ companySlug?: string;
42
+ /** Project id carried on the event, if any. */
43
+ project?: string;
44
+ /** Task id carried on the event, if any. */
45
+ task?: string;
28
46
  }
29
47
  export interface BackfillHeldResult {
30
48
  /** Distinct sessions found in held.jsonl (before --limit). */
@@ -63,9 +81,32 @@ export interface BackfillHeldDeps {
63
81
  * `hq mesh context reconcile` handler.
64
82
  */
65
83
  reconcile: (obs: ReconcileObservation) => Promise<ReconcileOutcome>;
84
+ /**
85
+ * Non-forcing company hint (the `--company` flag). Fed to the resolver as
86
+ * deterministic remote-owner evidence (below identity / explicit / session
87
+ * meta) in BOTH the dry-run predictor and the write path, so the two agree.
88
+ */
89
+ remoteOwnerHint?: string;
90
+ /** Env passed to the dry-run resolver (tests). Default process.env. */
91
+ env?: NodeJS.ProcessEnv;
92
+ /**
93
+ * Read-only company predictor for --dry-run. MUST mirror what the write
94
+ * path's reconcile would resolve, so dry-run and write report the same
95
+ * counts (the earlier bug: dry-run counted every session as would-reconcile
96
+ * while the write path, given no evidence, resolved none). Default runs the
97
+ * shared `resolveCompany` over the same reconstructed evidence.
98
+ */
99
+ predictCompany?: (ref: HeldSessionRef) => boolean;
66
100
  /** Optional progress logger. */
67
101
  log?: (message: string) => void;
68
102
  }
103
+ /**
104
+ * Build the reconcile observation for one held session, forwarding the company
105
+ * evidence its events recorded (cwd / hqRoot / bound companySlug / project /
106
+ * task). The event's own companySlug is trusted explicit — it is the company
107
+ * the session was bound to when the events were emitted on this box.
108
+ */
109
+ export declare function observationFromHeldRef(ref: HeldSessionRef, contractVersion: number, clientOperationId: string): ReconcileObservation;
69
110
  /**
70
111
  * Read held.jsonl and return distinct sessions (first occurrence wins),
71
112
  * carrying the harness from whichever held event we saw first for that session.
@@ -22,7 +22,65 @@
22
22
  import * as fs from "node:fs";
23
23
  import { readSessionState } from "../../work-context/state.js";
24
24
  import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
25
+ import { resolveCompany } from "../../work-context/company.js";
26
+ import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
25
27
  import { workMeshHeldPath } from "./paths.js";
28
+ /**
29
+ * Build the reconcile observation for one held session, forwarding the company
30
+ * evidence its events recorded (cwd / hqRoot / bound companySlug / project /
31
+ * task). The event's own companySlug is trusted explicit — it is the company
32
+ * the session was bound to when the events were emitted on this box.
33
+ */
34
+ export function observationFromHeldRef(ref, contractVersion, clientOperationId) {
35
+ const obs = {
36
+ contractVersion,
37
+ identity: ref.harness
38
+ ? { sessionId: ref.sessionId, harness: ref.harness }
39
+ : { sessionId: ref.sessionId },
40
+ clientOperationId,
41
+ };
42
+ if (ref.cwd)
43
+ obs.cwd = ref.cwd;
44
+ if (ref.hqRoot)
45
+ obs.hqRoot = ref.hqRoot;
46
+ if (ref.companySlug || ref.project || ref.task) {
47
+ obs.explicit = {
48
+ ...(ref.companySlug ? { companySlug: ref.companySlug } : {}),
49
+ ...(ref.project ? { projectId: ref.project } : {}),
50
+ ...(ref.task ? { taskId: ref.task } : {}),
51
+ };
52
+ }
53
+ return obs;
54
+ }
55
+ /**
56
+ * Default dry-run predictor: run the shared `resolveCompany` over the same
57
+ * evidence the write path forwards. Returns true when a company WOULD resolve
58
+ * (so dry-run's `reconciled` count matches the write run's). Purely local:
59
+ * reads config / meta / git-remote, never the network. A slug-only resolution
60
+ * that later fails online membership validation is the one narrow case where
61
+ * the write run can still under-count; the gross all-or-nothing mismatch is
62
+ * gone because both paths key off this same resolver + evidence.
63
+ */
64
+ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
65
+ const remoteOwnerSlug = remoteOwnerHint?.trim() ||
66
+ (ref.cwd
67
+ ? deriveRemoteOwnerSlug({ cwd: ref.cwd, hqRoot: ref.hqRoot }) ?? undefined
68
+ : undefined);
69
+ const repoIdentityKey = ref.cwd
70
+ ? deriveRepoIdentityKey({ cwd: ref.cwd })
71
+ : null;
72
+ const resolution = resolveCompany({
73
+ root: workContextRoot,
74
+ sessionId: ref.sessionId,
75
+ env,
76
+ trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
77
+ cwd: ref.cwd,
78
+ hqRoot: ref.hqRoot,
79
+ remoteOwnerSlug,
80
+ repoIdentityKey,
81
+ });
82
+ return resolution.status === "resolved";
83
+ }
26
84
  /**
27
85
  * Read held.jsonl and return distinct sessions (first occurrence wins),
28
86
  * carrying the harness from whichever held event we saw first for that session.
@@ -65,10 +123,27 @@ export function readHeldSessions(workMeshRoot) {
65
123
  : null;
66
124
  if (!sessionId || seen.has(sessionId))
67
125
  continue;
68
- const harness = typeof event.harness === "string" && event.harness.trim()
69
- ? event.harness.trim()
70
- : undefined;
71
- seen.set(sessionId, { sessionId, harness });
126
+ const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
127
+ const ref = { sessionId };
128
+ const harness = str(event.harness);
129
+ if (harness)
130
+ ref.harness = harness;
131
+ const cwd = str(event.cwd);
132
+ if (cwd)
133
+ ref.cwd = cwd;
134
+ const hqRoot = str(event.hqRoot);
135
+ if (hqRoot)
136
+ ref.hqRoot = hqRoot;
137
+ const companySlug = str(event.companySlug);
138
+ if (companySlug)
139
+ ref.companySlug = companySlug;
140
+ const project = str(event.project);
141
+ if (project)
142
+ ref.project = project;
143
+ const task = str(event.task);
144
+ if (task)
145
+ ref.task = task;
146
+ seen.set(sessionId, ref);
72
147
  }
73
148
  return [...seen.values()];
74
149
  }
@@ -81,7 +156,10 @@ export async function backfillHeldSessions(deps) {
81
156
  const limit = deps.limit && deps.limit > 0 ? deps.limit : 0;
82
157
  const contractVersion = deps.contractVersion ?? WORK_CONTEXT_CONTRACT_VERSION;
83
158
  const readState = deps.readState ?? readSessionState;
159
+ const env = deps.env ?? process.env;
84
160
  const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
161
+ const predictCompany = deps.predictCompany ??
162
+ ((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
85
163
  const sessions = readHeldSessions(deps.workMeshRoot);
86
164
  const considered = limit > 0 ? sessions.slice(0, limit) : sessions;
87
165
  const result = {
@@ -93,23 +171,27 @@ export async function backfillHeldSessions(deps) {
93
171
  errors: 0,
94
172
  dryRun,
95
173
  };
96
- for (const { sessionId, harness } of considered) {
174
+ for (const ref of considered) {
175
+ const { sessionId, harness } = ref;
97
176
  const state = readState(sessionId, deps.workContextRoot);
98
177
  if (state?.companyUid) {
99
178
  result.alreadyAttributed += 1;
100
179
  continue;
101
180
  }
102
181
  if (dryRun) {
103
- // Would reconcile: no state read/write beyond the skip check above.
104
- result.reconciled += 1;
105
- deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
182
+ // Predict with the SAME evidence + resolver the write path uses, so the
183
+ // dry-run counts match a real run (no blanket would-reconcile).
184
+ if (predictCompany(ref)) {
185
+ result.reconciled += 1;
186
+ deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
187
+ }
188
+ else {
189
+ result.unresolved += 1;
190
+ deps.log?.(`would stay unresolved ${sessionId} (no company evidence)`);
191
+ }
106
192
  continue;
107
193
  }
108
- const obs = {
109
- contractVersion,
110
- identity: harness ? { sessionId, harness } : { sessionId },
111
- clientOperationId: newOperationId(),
112
- };
194
+ const obs = observationFromHeldRef(ref, contractVersion, newOperationId());
113
195
  try {
114
196
  const outcome = await deps.reconcile(obs);
115
197
  if (outcome.result.companyUid) {
@@ -10,6 +10,10 @@ export interface DaemonDoctorReport {
10
10
  pid?: number;
11
11
  mqttState: string;
12
12
  companiesOnline: string[];
13
+ /** Daemon emit mode: "legacy" (spool/flush) or "direct" (receive-only). */
14
+ emitMode: "legacy" | "direct";
15
+ /** True when the receive subscriber is connected (mqtt connected). */
16
+ subscribed: boolean;
13
17
  spoolDepth: number;
14
18
  heldCount: number;
15
19
  deadLetterCount: number;
@@ -25,6 +29,17 @@ export interface DaemonDoctorReport {
25
29
  actorKind: CognitoActorKind;
26
30
  /** Credential vend refused (disabled / unsupported / 403). */
27
31
  presenceRefusal?: DaemonStateFile["presenceRefusal"];
32
+ /** Pending events awaiting a retry drain (POST /v1/mesh/events). */
33
+ emitRetryDepth: number;
34
+ /** Last successful direct-emit POST time. */
35
+ lastPostAt?: string;
36
+ /** Per-status counts from the last emit attempt. */
37
+ lastEmit?: {
38
+ accepted?: number;
39
+ unassigned?: number;
40
+ rejected?: number;
41
+ error?: string;
42
+ };
28
43
  }
29
44
  export interface DaemonDoctorDeps {
30
45
  home?: string;