@indigoai-us/hq-cli 5.124.0 → 5.126.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
  3. package/assets/bot-workers/setup/skills/your-business.md +270 -0
  4. package/assets/bot-workers/setup/worker.yaml +189 -58
  5. package/dist/command-catalog.generated.d.ts +414 -5
  6. package/dist/command-catalog.generated.js +531 -5
  7. package/dist/command-registration-plan.d.ts +6 -0
  8. package/dist/command-registration-plan.js +1 -0
  9. package/dist/commands/bot.d.ts +10 -0
  10. package/dist/commands/bot.js +6 -0
  11. package/dist/commands/lanes.d.ts +75 -0
  12. package/dist/commands/lanes.js +2032 -0
  13. package/dist/commands/login.js +49 -6
  14. package/dist/commands/mesh.js +133 -28
  15. package/dist/commands/onboard-create-identity.d.ts +26 -0
  16. package/dist/commands/onboard-create-identity.js +23 -0
  17. package/dist/commands/onboard.js +13 -4
  18. package/dist/lib/bot/progress.d.ts +6 -0
  19. package/dist/lib/bot/progress.js +16 -0
  20. package/dist/lib/bot/runtime/grok.d.ts +30 -7
  21. package/dist/lib/bot/runtime/grok.js +86 -8
  22. package/dist/lib/bot/runtime/index.js +15 -8
  23. package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
  24. package/dist/lib/bot/runtime/messages-stream.js +49 -3
  25. package/dist/lib/doctor/checks/work-context.js +4 -2
  26. package/dist/lib/lanes/accounts.d.ts +43 -0
  27. package/dist/lib/lanes/accounts.js +128 -0
  28. package/dist/lib/lanes/config.d.ts +56 -0
  29. package/dist/lib/lanes/config.js +351 -0
  30. package/dist/lib/lanes/errors.d.ts +39 -0
  31. package/dist/lib/lanes/errors.js +72 -0
  32. package/dist/lib/lanes/index.d.ts +19 -0
  33. package/dist/lib/lanes/index.js +12 -0
  34. package/dist/lib/lanes/liveness.d.ts +89 -0
  35. package/dist/lib/lanes/liveness.js +273 -0
  36. package/dist/lib/lanes/registry.d.ts +103 -0
  37. package/dist/lib/lanes/registry.js +247 -0
  38. package/dist/lib/lanes/store.d.ts +64 -0
  39. package/dist/lib/lanes/store.js +627 -0
  40. package/dist/lib/lanes/throttle.d.ts +89 -0
  41. package/dist/lib/lanes/throttle.js +297 -0
  42. package/dist/lib/lanes/types.d.ts +193 -0
  43. package/dist/lib/lanes/types.js +217 -0
  44. package/dist/lib/mesh/live/backfill-held.d.ts +2 -0
  45. package/dist/lib/mesh/live/backfill-held.js +8 -4
  46. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +3 -0
  47. package/dist/lib/mesh/live/daemon/board-refresh.js +6 -0
  48. package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
  49. package/dist/lib/mesh/live/daemon/config.js +75 -0
  50. package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
  51. package/dist/lib/mesh/live/daemon/doctor.js +21 -1
  52. package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
  53. package/dist/lib/mesh/live/daemon/index.js +3 -3
  54. package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
  55. package/dist/lib/mesh/live/daemon/install.js +444 -2
  56. package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
  57. package/dist/lib/mesh/live/daemon/paths.js +2 -0
  58. package/dist/lib/mesh/live/daemon/run.d.ts +11 -0
  59. package/dist/lib/mesh/live/daemon/run.js +129 -10
  60. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  61. package/dist/lib/mesh/live/flush.d.ts +3 -0
  62. package/dist/lib/mesh/live/flush.js +11 -0
  63. package/dist/lib/scan-packages/index.js +17 -12
  64. package/dist/lib/work-context/company.d.ts +8 -0
  65. package/dist/lib/work-context/company.js +62 -18
  66. package/dist/lib/work-context/config.d.ts +55 -0
  67. package/dist/lib/work-context/config.js +333 -67
  68. package/dist/lib/work-context/contract.d.ts +2 -0
  69. package/dist/lib/work-context/default-company.d.ts +43 -0
  70. package/dist/lib/work-context/default-company.js +83 -0
  71. package/dist/lib/work-context/outbox.d.ts +5 -1
  72. package/dist/lib/work-context/outbox.js +5 -0
  73. package/dist/lib/work-context/reconcile.d.ts +4 -0
  74. package/dist/lib/work-context/reconcile.js +86 -9
  75. package/dist/lib/work-context/state.d.ts +2 -0
  76. package/dist/lib/work-context/state.js +14 -0
  77. package/dist/lib/work-context/windows-acl.d.ts +123 -0
  78. package/dist/lib/work-context/windows-acl.js +425 -0
  79. package/dist/utils/pack-contributions.js +5 -4
  80. package/package.json +1 -1
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Lane record, question routing record, envelope, and config types (spec §3,
3
+ * §4, §5.5, §6.2). Finite sets are string-literal unions plus `const` arrays
4
+ * so the command layer can validate flags explicitly.
5
+ */
6
+ import { LanesError, LaneSchemaError } from "./errors.js";
7
+ export const LANE_SCHEMA_VERSION = "hq-lane.v1";
8
+ export const QUESTION_SCHEMA_VERSION = "hq-lane-question.v1";
9
+ export const ENVELOPE_SCHEMA_VERSION = "hq-lane-envelope.v1";
10
+ export const CONFIG_SCHEMA_VERSION = "hq-lanes-config.v1";
11
+ export const LANE_STATES = [
12
+ "queued",
13
+ "running",
14
+ "awaiting_input",
15
+ "blocked",
16
+ "done",
17
+ "failed",
18
+ "interrupted",
19
+ "cancelled",
20
+ "killed",
21
+ ];
22
+ /** Exact terminal set from spec §6.7. `awaiting_input` and `blocked` are not terminal. */
23
+ export const TERMINAL_LANE_STATES = [
24
+ "done",
25
+ "failed",
26
+ "interrupted",
27
+ "cancelled",
28
+ "killed",
29
+ ];
30
+ export const LANE_PROVIDERS = ["claude", "codex", "grok"];
31
+ export const QUEUED_REASONS = ["cap", "queue_position", "throttle"];
32
+ export const HOST_KINDS = ["local", "outpost"];
33
+ export const SENIOR_KINDS = ["session", "lane", "user"];
34
+ export const ENVELOPE_DECISIONS = ["done", "ask", "blocked"];
35
+ export const STORY_STATUSES = [
36
+ "queued",
37
+ "in_progress",
38
+ "review",
39
+ "done",
40
+ ];
41
+ export const QUESTION_STATUSES = [
42
+ "pending",
43
+ "answered",
44
+ "cancelled",
45
+ "expired",
46
+ ];
47
+ export const RAISED_VIA = ["cli", "envelope"];
48
+ export const ROUTING_REASONS = [
49
+ "asked",
50
+ "escalated",
51
+ "audience_ended",
52
+ "reparented",
53
+ "unanswered_timeout",
54
+ "cancelled",
55
+ ];
56
+ export const QUESTION_BODY_STORES = ["company", "personal"];
57
+ export const LIVENESS_STATES = ["live", "idle", "ended", "lost"];
58
+ export const HISTORY_CAP = 50;
59
+ const TERMINAL_SET = new Set(TERMINAL_LANE_STATES);
60
+ export function isLaneState(value) {
61
+ return typeof value === "string" && LANE_STATES.includes(value);
62
+ }
63
+ export function isTerminalLaneState(value) {
64
+ return typeof value === "string" && TERMINAL_SET.has(value);
65
+ }
66
+ export function isLaneProvider(value) {
67
+ return (typeof value === "string" &&
68
+ LANE_PROVIDERS.includes(value));
69
+ }
70
+ export function isSeniorKind(value) {
71
+ return (typeof value === "string" &&
72
+ SENIOR_KINDS.includes(value));
73
+ }
74
+ export function isHostKind(value) {
75
+ return (typeof value === "string" &&
76
+ HOST_KINDS.includes(value));
77
+ }
78
+ export function isEnvelopeDecision(value) {
79
+ return (typeof value === "string" &&
80
+ ENVELOPE_DECISIONS.includes(value));
81
+ }
82
+ export function isQuestionStatus(value) {
83
+ return (typeof value === "string" &&
84
+ QUESTION_STATUSES.includes(value));
85
+ }
86
+ export function isLivenessState(value) {
87
+ return (typeof value === "string" &&
88
+ LIVENESS_STATES.includes(value));
89
+ }
90
+ export function parseLaneProvider(value) {
91
+ if (isLaneProvider(value))
92
+ return value;
93
+ throw new LanesError("unknown_provider", `Unknown provider ${JSON.stringify(value)}. Accepted: ${LANE_PROVIDERS.join(", ")}`, { accepted: [...LANE_PROVIDERS], value });
94
+ }
95
+ export function parseLaneState(value) {
96
+ if (isLaneState(value))
97
+ return value;
98
+ throw new LanesError("unknown_state", `Unknown lane state ${JSON.stringify(value)}. Accepted: ${LANE_STATES.join(", ")}`, { accepted: [...LANE_STATES], value });
99
+ }
100
+ export function parseQuestionStatus(value) {
101
+ if (isQuestionStatus(value))
102
+ return value;
103
+ throw new LanesError("unknown_status", `Unknown question status ${JSON.stringify(value)}. Accepted: ${QUESTION_STATUSES.join(", ")}`, { accepted: [...QUESTION_STATUSES], value });
104
+ }
105
+ export function parseLivenessState(value) {
106
+ if (isLivenessState(value))
107
+ return value;
108
+ throw new LanesError("unknown_state", `Unknown session liveness state ${JSON.stringify(value)}. Accepted: ${LIVENESS_STATES.join(", ")}`, { accepted: [...LIVENESS_STATES], value });
109
+ }
110
+ export function parseSeniorKind(value) {
111
+ if (isSeniorKind(value))
112
+ return value;
113
+ throw new LanesError("unknown_senior_kind", `Unknown senior kind ${JSON.stringify(value)}. Accepted: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value });
114
+ }
115
+ function isPlainObject(value) {
116
+ return value !== null && typeof value === "object" && !Array.isArray(value);
117
+ }
118
+ function requireString(value, field) {
119
+ if (typeof value !== "string" || value.length === 0) {
120
+ throw new LaneSchemaError(`Expected non-empty string for ${field}`, {
121
+ field,
122
+ });
123
+ }
124
+ return value;
125
+ }
126
+ export function parseMajorSchema(schemaVersion, expectedPrefix, expectedMajor) {
127
+ const match = new RegExp(`^${expectedPrefix}\\.v(\\d+)$`).exec(schemaVersion);
128
+ if (!match) {
129
+ throw new LaneSchemaError(`Unknown schema_version ${JSON.stringify(schemaVersion)} (expected ${expectedPrefix}.v${expectedMajor})`, { schema_version: schemaVersion, expected: `${expectedPrefix}.v${expectedMajor}` });
130
+ }
131
+ const major = Number(match[1]);
132
+ if (major !== expectedMajor) {
133
+ throw new LaneSchemaError(`Unsupported ${expectedPrefix} major version ${major} (found ${schemaVersion})`, { schema_version: schemaVersion, major });
134
+ }
135
+ }
136
+ export function parseLaneEnvelope(raw) {
137
+ if (!isPlainObject(raw)) {
138
+ throw new LaneSchemaError("Lane envelope must be a JSON object");
139
+ }
140
+ const extra = Object.keys(raw).filter((key) => ![
141
+ "schema_version",
142
+ "decision",
143
+ "summary",
144
+ "questions",
145
+ "story_status",
146
+ "artifacts",
147
+ "blocked_reason",
148
+ ].includes(key));
149
+ if (extra.length > 0) {
150
+ throw new LaneSchemaError(`Lane envelope rejects unknown properties: ${extra.join(", ")}`, { extra });
151
+ }
152
+ const schema_version = requireString(raw.schema_version, "schema_version");
153
+ parseMajorSchema(schema_version, "hq-lane-envelope", 1);
154
+ if (!isEnvelopeDecision(raw.decision)) {
155
+ throw new LaneSchemaError(`Unknown envelope decision ${JSON.stringify(raw.decision)}. Accepted: ${ENVELOPE_DECISIONS.join(", ")}`, { accepted: [...ENVELOPE_DECISIONS], value: raw.decision });
156
+ }
157
+ const summary = requireString(raw.summary, "summary");
158
+ if (typeof raw.story_status !== "string" ||
159
+ !STORY_STATUSES.includes(raw.story_status)) {
160
+ throw new LaneSchemaError(`Unknown story_status ${JSON.stringify(raw.story_status)}. Accepted: ${STORY_STATUSES.join(", ")}`, { accepted: [...STORY_STATUSES], value: raw.story_status });
161
+ }
162
+ if (!Array.isArray(raw.artifacts)) {
163
+ throw new LaneSchemaError("Envelope artifacts must be an array");
164
+ }
165
+ const questions = raw.questions;
166
+ if (raw.decision === "ask") {
167
+ if (!Array.isArray(questions) || questions.length === 0) {
168
+ throw new LaneSchemaError('questions must be non-empty when decision is "ask"');
169
+ }
170
+ }
171
+ else if (questions !== undefined) {
172
+ if (!Array.isArray(questions) || questions.length !== 0) {
173
+ throw new LaneSchemaError('questions must be omitted or empty unless decision is "ask"');
174
+ }
175
+ }
176
+ if (raw.decision === "blocked") {
177
+ if (typeof raw.blocked_reason !== "string" || raw.blocked_reason.length === 0) {
178
+ throw new LaneSchemaError('blocked_reason is required when decision is "blocked"');
179
+ }
180
+ }
181
+ else if (raw.blocked_reason !== undefined) {
182
+ throw new LaneSchemaError('blocked_reason is present exactly when decision is "blocked"');
183
+ }
184
+ const envelope = {
185
+ schema_version: ENVELOPE_SCHEMA_VERSION,
186
+ decision: raw.decision,
187
+ summary,
188
+ story_status: raw.story_status,
189
+ artifacts: raw.artifacts,
190
+ };
191
+ if (Array.isArray(questions) && questions.length > 0) {
192
+ envelope.questions = questions.map((item, index) => {
193
+ if (!isPlainObject(item)) {
194
+ throw new LaneSchemaError(`questions[${index}] must be an object`);
195
+ }
196
+ const options = item.options;
197
+ if (!Array.isArray(options) ||
198
+ options.some((opt) => typeof opt !== "string")) {
199
+ throw new LaneSchemaError(`questions[${index}].options must be string[]`);
200
+ }
201
+ const parsed = {
202
+ question: requireString(item.question, `questions[${index}].question`),
203
+ context: requireString(item.context, `questions[${index}].context`),
204
+ options,
205
+ };
206
+ if (item.recommended !== undefined) {
207
+ parsed.recommended = requireString(item.recommended, `questions[${index}].recommended`);
208
+ }
209
+ return parsed;
210
+ });
211
+ }
212
+ if (typeof raw.blocked_reason === "string") {
213
+ envelope.blocked_reason = raw.blocked_reason;
214
+ }
215
+ return envelope;
216
+ }
217
+ //# sourceMappingURL=types.js.map
@@ -111,6 +111,8 @@ export interface BackfillHeldDeps {
111
111
  * meta) in BOTH the dry-run predictor and the write path, so the two agree.
112
112
  */
113
113
  remoteOwnerHint?: string;
114
+ /** Historical device-default repair is opt-in; false keeps no-evidence sessions held. */
115
+ useDeviceDefault?: boolean;
114
116
  /** Env passed to the dry-run resolver (tests). Default process.env. */
115
117
  env?: NodeJS.ProcessEnv;
116
118
  /**
@@ -231,7 +231,7 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
231
231
  * the write run can still under-count; the gross all-or-nothing mismatch is
232
232
  * gone because both paths key off this same resolver + evidence.
233
233
  */
234
- function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
234
+ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault) {
235
235
  // This predictor is called only with a ref already rebuilt from trusted local
236
236
  // evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
237
237
  const remoteOwnerSlug = remoteOwnerHint?.trim();
@@ -241,6 +241,7 @@ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env) {
241
241
  env,
242
242
  trusted: ref.companySlug ? { companySlug: ref.companySlug } : undefined,
243
243
  remoteOwnerSlug,
244
+ allowDeviceDefault: useDeviceDefault,
244
245
  });
245
246
  return resolution.status === "resolved";
246
247
  }
@@ -385,7 +386,7 @@ export async function backfillHeldSessions(deps) {
385
386
  const env = deps.env ?? process.env;
386
387
  const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
387
388
  const predictCompany = deps.predictCompany ??
388
- ((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env));
389
+ ((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true));
389
390
  const sessions = readHeldSessions(deps.workMeshRoot);
390
391
  // An acked register is authoritative local evidence. Older clients could
391
392
  // lose its state projection on a later hook write; recover that projection
@@ -434,7 +435,10 @@ export async function backfillHeldSessions(deps) {
434
435
  continue;
435
436
  }
436
437
  }
437
- if (!evidence.candidate?.slug) {
438
+ // Historical repair may use the explicitly opted-in device default only
439
+ // when there is no durable local company evidence. Never manufacture a
440
+ // slug from the held event itself.
441
+ if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
438
442
  result.unresolved += 1;
439
443
  deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
440
444
  continue;
@@ -442,7 +446,7 @@ export async function backfillHeldSessions(deps) {
442
446
  const evidenceRef = {
443
447
  sessionId,
444
448
  harness,
445
- companySlug: evidence.candidate.slug,
449
+ companySlug: evidence.candidate?.slug,
446
450
  // Held-event fields are transport input, not trusted context. Preserve
447
451
  // project/task only when the durable session state already owns them.
448
452
  project: state?.projectId,
@@ -6,6 +6,7 @@
6
6
  * authoritative in ProjectView because `hq mesh story` writes there.
7
7
  * Metadata-only snapshot — never prompts or tokens.
8
8
  */
9
+ import type { SessionStateFile } from "../../../work-context/state.js";
9
10
  export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
10
11
  export interface BoardStorySnapshot {
11
12
  id: string;
@@ -26,6 +27,8 @@ export interface BoardRefreshDeps {
26
27
  /** Per-session last refresh timestamps (ms). */
27
28
  lastRefresh?: Map<string, number>;
28
29
  intervalMs?: number;
30
+ /** Authorizes a device-default-derived session before its tenant read. */
31
+ authorizeDeviceDefaultTenant?: (sessionId: string, state: SessionStateFile) => Promise<boolean>;
29
32
  }
30
33
  export declare function formatBoardMarkdown(input: {
31
34
  sessionId: string;
@@ -92,6 +92,12 @@ export async function refreshBoundSessionBoards(deps) {
92
92
  skipped += 1;
93
93
  continue;
94
94
  }
95
+ if (state.companyConfidence === "device_default" &&
96
+ deps.authorizeDeviceDefaultTenant &&
97
+ !(await deps.authorizeDeviceDefaultTenant(sessionId, state))) {
98
+ skipped += 1;
99
+ continue;
100
+ }
95
101
  try {
96
102
  const board = await deps.readBoard(state.companyUid, state.projectId);
97
103
  if (!board) {
@@ -3,6 +3,33 @@ export interface DaemonConfig {
3
3
  }
4
4
  export declare function daemonConfigPath(daemonDirectory: string): string;
5
5
  export declare function verifyHqRoot(candidate: string | undefined): string | null;
6
+ /**
7
+ * A directory the daemon may be installed for: it must carry
8
+ * companies/manifest.yaml (what verifyHqRoot demands at runtime) and one of
9
+ * the tree markers utils/hq-roots.ts accepts (core/, .claude/, personal/).
10
+ * A bare .claude/ directory — every repo with Claude settings has one — is
11
+ * not an HQ root and must never be persisted or passed as --hq-root.
12
+ */
13
+ export declare function isInstallableHqRoot(dir: string): boolean;
14
+ /** Nearest ancestor of startDir (inclusive) that is an installable HQ root, realpath'd; null if none. */
15
+ export declare function findAncestorHqRoot(startDir: string): string | null;
16
+ export interface InstallHqRootResolution {
17
+ root: string;
18
+ source: "flag" | "env" | "ancestor";
19
+ }
20
+ /**
21
+ * Resolve the HQ root `hq mesh daemon install` records and passes to the
22
+ * service. Precedence: --hq-root, then HQ_ROOT, then the nearest ancestor of
23
+ * cwd that is an installable HQ root. An explicit value that is not an HQ
24
+ * root is an error rather than a silent fallback, and with nothing
25
+ * qualifying the install is refused: an unverified root would be persisted
26
+ * and trusted by the daemon for company attribution.
27
+ */
28
+ export declare function resolveInstallHqRoot(opts?: {
29
+ explicit?: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ cwd?: string;
32
+ }): InstallHqRootResolution;
6
33
  export declare function readDaemonConfig(daemonDirectory: string): DaemonConfig;
7
34
  /** Record a verified local HQ checkout without trusting a caller-supplied path at runtime. */
8
35
  export declare function registerTrustedHqRoot(daemonDirectory: string, candidate: string | undefined): DaemonConfig;
@@ -16,6 +16,81 @@ export function verifyHqRoot(candidate) {
16
16
  return null;
17
17
  }
18
18
  }
19
+ /**
20
+ * A directory the daemon may be installed for: it must carry
21
+ * companies/manifest.yaml (what verifyHqRoot demands at runtime) and one of
22
+ * the tree markers utils/hq-roots.ts accepts (core/, .claude/, personal/).
23
+ * A bare .claude/ directory — every repo with Claude settings has one — is
24
+ * not an HQ root and must never be persisted or passed as --hq-root.
25
+ */
26
+ export function isInstallableHqRoot(dir) {
27
+ if (verifyHqRoot(dir) === null)
28
+ return false;
29
+ return ["core", ".claude", "personal"].some((marker) => {
30
+ try {
31
+ return fs.statSync(path.join(dir, marker)).isDirectory();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ });
37
+ }
38
+ /** Nearest ancestor of startDir (inclusive) that is an installable HQ root, realpath'd; null if none. */
39
+ export function findAncestorHqRoot(startDir) {
40
+ let dir;
41
+ try {
42
+ dir = fs.realpathSync(startDir);
43
+ }
44
+ catch {
45
+ dir = path.resolve(startDir);
46
+ }
47
+ for (;;) {
48
+ if (isInstallableHqRoot(dir))
49
+ return fs.realpathSync(dir);
50
+ const parent = path.dirname(dir);
51
+ if (parent === dir)
52
+ return null;
53
+ dir = parent;
54
+ }
55
+ }
56
+ /**
57
+ * Resolve the HQ root `hq mesh daemon install` records and passes to the
58
+ * service. Precedence: --hq-root, then HQ_ROOT, then the nearest ancestor of
59
+ * cwd that is an installable HQ root. An explicit value that is not an HQ
60
+ * root is an error rather than a silent fallback, and with nothing
61
+ * qualifying the install is refused: an unverified root would be persisted
62
+ * and trusted by the daemon for company attribution.
63
+ */
64
+ export function resolveInstallHqRoot(opts = {}) {
65
+ const env = opts.env ?? process.env;
66
+ const cwd = opts.cwd ?? process.cwd();
67
+ const explicit = opts.explicit?.trim();
68
+ if (explicit) {
69
+ if (!isInstallableHqRoot(explicit)) {
70
+ throw expectedError(`--hq-root ${explicit} is not an HQ root (needs companies/manifest.yaml plus core/, .claude/, or personal/).`);
71
+ }
72
+ return { root: fs.realpathSync(explicit), source: "flag" };
73
+ }
74
+ const fromEnv = env.HQ_ROOT?.trim();
75
+ if (fromEnv) {
76
+ if (!isInstallableHqRoot(fromEnv)) {
77
+ throw expectedError(`HQ_ROOT=${fromEnv} is not an HQ root (needs companies/manifest.yaml plus core/, .claude/, or personal/). ` +
78
+ `Fix HQ_ROOT or pass --hq-root <path>.`);
79
+ }
80
+ return { root: fs.realpathSync(fromEnv), source: "env" };
81
+ }
82
+ const ancestor = findAncestorHqRoot(cwd);
83
+ if (!ancestor) {
84
+ throw expectedError(`No HQ root found above ${cwd} (a directory with companies/manifest.yaml plus core/, .claude/, or personal/). ` +
85
+ `Run from inside your HQ tree or pass --hq-root <path>.`);
86
+ }
87
+ return { root: ancestor, source: "ancestor" };
88
+ }
89
+ function expectedError(message) {
90
+ const err = new Error(message);
91
+ err.expected = true;
92
+ return err;
93
+ }
19
94
  export function readDaemonConfig(daemonDirectory) {
20
95
  try {
21
96
  const raw = JSON.parse(fs.readFileSync(daemonConfigPath(daemonDirectory), "utf8"));
@@ -19,6 +19,7 @@ export interface DaemonDoctorReport {
19
19
  heldCount: number;
20
20
  /** Distinct held sessions with neither bound state nor local company evidence. */
21
21
  heldWithNoLocalEvidence: number;
22
+ attributedByDeviceDefault: number;
22
23
  deadLetterCount: number;
23
24
  /** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
24
25
  unattributedEvents: UnattributedEventCounts;
@@ -47,6 +48,8 @@ export interface DaemonDoctorReport {
47
48
  rejected?: number;
48
49
  error?: string;
49
50
  };
51
+ /** Work held or quarantined after default-derived membership was denied. */
52
+ deviceDefaultAuthorizationDenials: number;
50
53
  }
51
54
  export interface DaemonDoctorDeps {
52
55
  home?: string;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
+ import * as path from "node:path";
7
8
  import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
8
9
  import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
9
10
  import { readSessionState } from "../../../work-context/state.js";
@@ -52,6 +53,21 @@ function oldestJsonlAgeMs(filePath, nowMs) {
52
53
  return undefined;
53
54
  }
54
55
  }
56
+ function countDeviceDefaultAttribution(root) {
57
+ try {
58
+ return fs.readdirSync(path.join(root, "sessions")).filter((entry) => {
59
+ if (!entry.endsWith(".json"))
60
+ return false;
61
+ const state = readSessionState(entry.slice(0, -5), root);
62
+ return state?.companyConfidence === "device_default" &&
63
+ Boolean(state.companyUid) &&
64
+ state.contextStatus !== "needs_company";
65
+ }).length;
66
+ }
67
+ catch {
68
+ return 0;
69
+ }
70
+ }
55
71
  export function collectDaemonDoctor(deps = {}) {
56
72
  const home = deps.home ?? os.homedir();
57
73
  const env = deps.env ?? process.env;
@@ -88,6 +104,7 @@ export function collectDaemonDoctor(deps = {}) {
88
104
  !hasCurrentAcknowledgedRegistration(ref, state, outboxOperations, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots) &&
89
105
  !refWithLocalCompanyEvidence(ref, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots, state));
90
106
  }).length;
107
+ const attributedByDeviceDefault = countDeviceDefaultAttribution(ctxRoot);
91
108
  const deadLetterCount = countJsonlLines(deadPath);
92
109
  const unattributedEvents = countUnattributedEvents(meshRoot);
93
110
  const outbox = outboxStats(ctxRoot);
@@ -95,6 +112,7 @@ export function collectDaemonDoctor(deps = {}) {
95
112
  const companiesOnline = state?.companiesOnline ?? [];
96
113
  const emitMode = state?.emitMode ?? "legacy";
97
114
  const subscribed = mqttState === "connected";
115
+ const deviceDefaultAuthorizationDenials = state?.deviceDefaultAuthorizationDenials ?? 0;
98
116
  const oldestSpoolAgeMs = oldestJsonlAgeMs(spoolPath, now());
99
117
  const unhealthyReasons = [];
100
118
  const online = mqttState === "connected" && companiesOnline.length > 0;
@@ -140,6 +158,7 @@ export function collectDaemonDoctor(deps = {}) {
140
158
  spoolDepth,
141
159
  heldCount,
142
160
  heldWithNoLocalEvidence,
161
+ attributedByDeviceDefault,
143
162
  deadLetterCount,
144
163
  unattributedEvents,
145
164
  outboxDepth: outbox.depth,
@@ -164,6 +183,7 @@ export function collectDaemonDoctor(deps = {}) {
164
183
  error: emitState.lastError,
165
184
  }
166
185
  : undefined,
186
+ deviceDefaultAuthorizationDenials,
167
187
  };
168
188
  }
169
189
  export function formatDaemonDoctor(report) {
@@ -179,7 +199,7 @@ export function formatDaemonDoctor(report) {
179
199
  // receive-only (direct) mode.
180
200
  lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
181
201
  }
182
- lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
202
+ lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
183
203
  `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
184
204
  if (report.emitMode === "legacy") {
185
205
  lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
@@ -1,4 +1,4 @@
1
- export { DAEMON_DIRNAME, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_NAME, DAEMON_PID_NAME, DAEMON_STATE_NAME, LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, daemonPidPath, daemonStatePath, sessionBoardPath, } from "./paths.js";
1
+ export { DAEMON_DIRNAME, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_NAME, DAEMON_PID_NAME, DAEMON_STATE_NAME, LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, daemonStatePath, sessionBoardPath, } from "./paths.js";
2
2
  export { acquirePidLock, defaultPidLockDeps, parsePidLock, pidLockStatus, readPidLock, releasePidLock, resolveDaemonDir, } from "./pid-lock.js";
3
3
  export type { PidLockDeps, PidLockRecord, PidLockResult } from "./pid-lock.js";
4
4
  export { CREDENTIAL_RENEWAL_FRACTION, DEFAULT_REFUSED_RETRY_MS, MAX_RETRY_DELAY_MS, MAX_TIMER_DELAY_MS, MQTT_KEEPALIVE_SECONDS, REALTIME_CREDENTIALS_PATH, REFUSED_RETRY_ENV, CredentialRenewalManager, CredentialVendError, classifyCredentialVendFailure, clampRetryDelayMs, createContract3Fetcher, defaultRefusedRetryMs, extractVendErrorCode, isCredentialVendRefused, normalizeContract3Bundle, parseRetryAfterMs, realTimerHost, refusedRetryDelayMs, renewalDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
@@ -12,9 +12,10 @@ export type { DaemonStateFile, PresenceRefusalState } from "./state.js";
12
12
  export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
13
13
  export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
14
14
  export type { BoardReader, BoardRefreshDeps, BoardStorySnapshot } from "./board-refresh.js";
15
- export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
15
+ export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, parseSchtasksLastResult, parseWindowsTaskHqRoot, readRecordedWindowsHqRoot, schtasksLastResultFailed, uninstallDaemonService, windowsLaunchMode, windowsLogonNotice, } from "./install.js";
16
16
  export type { DaemonInstallPlatform, InstallPaths, InstallResult, } from "./install.js";
17
- export { daemonConfigPath, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
17
+ export { daemonConfigPath, findAncestorHqRoot, isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, resolveInstallHqRoot, verifyHqRoot, } from "./config.js";
18
+ export type { InstallHqRootResolution } from "./config.js";
18
19
  export type { DaemonConfig } from "./config.js";
19
20
  export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
20
21
  export type { DaemonDoctorDeps, DaemonDoctorReport } from "./doctor.js";
@@ -1,4 +1,4 @@
1
- export { DAEMON_DIRNAME, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_NAME, DAEMON_PID_NAME, DAEMON_STATE_NAME, LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, daemonPidPath, daemonStatePath, sessionBoardPath, } from "./paths.js";
1
+ export { DAEMON_DIRNAME, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_NAME, DAEMON_PID_NAME, DAEMON_STATE_NAME, LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, daemonStatePath, sessionBoardPath, } from "./paths.js";
2
2
  export { acquirePidLock, defaultPidLockDeps, parsePidLock, pidLockStatus, readPidLock, releasePidLock, resolveDaemonDir, } from "./pid-lock.js";
3
3
  export { CREDENTIAL_RENEWAL_FRACTION, DEFAULT_REFUSED_RETRY_MS, MAX_RETRY_DELAY_MS, MAX_TIMER_DELAY_MS, MQTT_KEEPALIVE_SECONDS, REALTIME_CREDENTIALS_PATH, REFUSED_RETRY_ENV, CredentialRenewalManager, CredentialVendError, classifyCredentialVendFailure, clampRetryDelayMs, createContract3Fetcher, defaultRefusedRetryMs, extractVendErrorCode, isCredentialVendRefused, normalizeContract3Bundle, parseRetryAfterMs, realTimerHost, refusedRetryDelayMs, renewalDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
4
4
  export { amzDateOf, hex, presignIotWssUrl, rfc3986Encode } from "./presign.js";
@@ -6,8 +6,8 @@ export { CLOSE_LOG_ALWAYS_COUNT, CLOSE_LOG_INTERVAL_MS, CONNECT_STABLE_GRACE_MS,
6
6
  export { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
7
7
  export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
8
8
  export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
9
- export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
10
- export { daemonConfigPath, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
9
+ export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, parseSchtasksLastResult, parseWindowsTaskHqRoot, readRecordedWindowsHqRoot, schtasksLastResultFailed, uninstallDaemonService, windowsLaunchMode, windowsLogonNotice, } from "./install.js";
10
+ export { daemonConfigPath, findAncestorHqRoot, isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, resolveInstallHqRoot, verifyHqRoot, } from "./config.js";
11
11
  export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
12
12
  export { scopeBundleToAgentIdentity, } from "./credentials.js";
13
13
  export { FLUSH_INTERVAL_MS, DAEMON_VERSION_DRIFT_EXIT_CODE, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";