@indigoai-us/hq-cli 5.122.2 → 5.123.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.
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
3
- * from GET /companies/{companyUid}/board at most every 60s.
3
+ * from the ProjectView read route at most every 60s.
4
+ *
5
+ * Board JSON remains the fallback for older projects, but story status is
6
+ * authoritative in ProjectView because `hq mesh story` writes there.
4
7
  * Metadata-only snapshot — never prompts or tokens.
5
8
  */
6
9
  export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
3
- * from GET /companies/{companyUid}/board at most every 60s.
3
+ * from the ProjectView read route at most every 60s.
4
+ *
5
+ * Board JSON remains the fallback for older projects, but story status is
6
+ * authoritative in ProjectView because `hq mesh story` writes there.
4
7
  * Metadata-only snapshot — never prompts or tokens.
5
8
  */
6
9
  import * as fs from "node:fs";
@@ -115,9 +118,72 @@ export async function refreshBoundSessionBoards(deps) {
115
118
  }
116
119
  return { refreshed, skipped, errors };
117
120
  }
121
+ function storiesFromProject(project) {
122
+ const storiesRaw = Array.isArray(project.stories) ? project.stories : [];
123
+ const stories = [];
124
+ for (const s of storiesRaw) {
125
+ if (!s || typeof s !== "object" || Array.isArray(s))
126
+ continue;
127
+ const row = s;
128
+ const id = typeof row.id === "string"
129
+ ? row.id
130
+ : typeof row.storyId === "string"
131
+ ? row.storyId
132
+ : typeof row.taskId === "string"
133
+ ? row.taskId
134
+ : "";
135
+ if (!id)
136
+ continue;
137
+ stories.push({
138
+ id,
139
+ title: typeof row.title === "string"
140
+ ? row.title.slice(0, 120)
141
+ : typeof row.name === "string"
142
+ ? row.name.slice(0, 120)
143
+ : undefined,
144
+ status: typeof row.status === "string" ? row.status : undefined,
145
+ });
146
+ }
147
+ return stories;
148
+ }
149
+ function projectViewSnapshot(raw, companyUid, projectId) {
150
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
151
+ return null;
152
+ const project = raw;
153
+ if (project.companyUid !== companyUid || project.projectId !== projectId)
154
+ return null;
155
+ return {
156
+ projectId,
157
+ projectName: typeof project.name === "string"
158
+ ? project.name
159
+ : typeof project.title === "string"
160
+ ? project.title
161
+ : undefined,
162
+ stories: storiesFromProject(project),
163
+ };
164
+ }
165
+ function isMissingProject(error) {
166
+ // hq-pro's GET ProjectView handler emits PROJECT_VIEW_NOT_FOUND; retain the
167
+ // legacy PROJECT_NOT_FOUND code for compatibility with older producers.
168
+ return (error instanceof Error &&
169
+ /^404\s+(?:PROJECT_VIEW_NOT_FOUND|PROJECT_NOT_FOUND)\b/.test(error.message));
170
+ }
118
171
  /** Build a BoardReader from meshJson-compatible fetch. */
119
172
  export function createVaultBoardReader(opts) {
120
173
  return async (companyUid, projectId) => {
174
+ // ProjectView is the live story-status source. Only use board.json when
175
+ // this project has not been migrated into ProjectView yet.
176
+ try {
177
+ const view = await opts.getJson(`/v1/work-mesh/projects/${encodeURIComponent(projectId)}?companyUid=${encodeURIComponent(companyUid)}`);
178
+ const snapshot = projectViewSnapshot(view, companyUid, projectId);
179
+ if (snapshot)
180
+ return snapshot;
181
+ throw new Error("ProjectView response did not match the requested company and project");
182
+ }
183
+ catch (error) {
184
+ if (!isMissingProject(error))
185
+ throw error;
186
+ }
121
187
  const raw = await opts.getJson(`/companies/${encodeURIComponent(companyUid)}/board`);
122
188
  if (!raw || typeof raw !== "object")
123
189
  return null;
@@ -140,35 +206,10 @@ export function createVaultBoardReader(opts) {
140
206
  const id = typeof p.id === "string" ? p.id : typeof p.projectId === "string" ? p.projectId : "";
141
207
  if (id !== projectId)
142
208
  continue;
143
- const storiesRaw = Array.isArray(p.stories)
144
- ? p.stories
145
- : Array.isArray(p.tasks)
146
- ? p.tasks
147
- : [];
148
- const stories = [];
149
- for (const s of storiesRaw) {
150
- if (!s || typeof s !== "object" || Array.isArray(s))
151
- continue;
152
- const row = s;
153
- const sid = typeof row.id === "string"
154
- ? row.id
155
- : typeof row.storyId === "string"
156
- ? row.storyId
157
- : typeof row.taskId === "string"
158
- ? row.taskId
159
- : "";
160
- if (!sid)
161
- continue;
162
- stories.push({
163
- id: sid,
164
- title: typeof row.title === "string"
165
- ? row.title.slice(0, 120)
166
- : typeof row.name === "string"
167
- ? row.name.slice(0, 120)
168
- : undefined,
169
- status: typeof row.status === "string" ? row.status : undefined,
170
- });
171
- }
209
+ const stories = storiesFromProject({
210
+ ...p,
211
+ stories: Array.isArray(p.stories) ? p.stories : p.tasks,
212
+ });
172
213
  return {
173
214
  projectId,
174
215
  projectName: typeof p.name === "string"
@@ -49,6 +49,11 @@ export interface PresenceCompany {
49
49
  companyUid: string;
50
50
  presenceTopic: string;
51
51
  }
52
+ export interface DroppedPresenceCompany {
53
+ companyUid: string;
54
+ /** Server-supplied cause when available; otherwise a deliberately neutral label. */
55
+ reason: string;
56
+ }
52
57
  export interface Contract3Bundle {
53
58
  contractVersion: 3;
54
59
  credentials: IotCredentials;
@@ -58,7 +63,7 @@ export interface Contract3Bundle {
58
63
  actorUid: string;
59
64
  actorType: "human" | "agent";
60
65
  companies: PresenceCompany[];
61
- droppedCompanies: string[];
66
+ droppedCompanies: DroppedPresenceCompany[];
62
67
  expiresAt: string;
63
68
  }
64
69
  export type CredentialsFetcher = () => Promise<Contract3Bundle>;
@@ -229,9 +229,25 @@ export function normalizeContract3Bundle(raw) {
229
229
  if (!expiresAt) {
230
230
  throw new Error("contract-3 vend missing expiresAt");
231
231
  }
232
- const dropped = Array.isArray(r.droppedCompanies)
233
- ? r.droppedCompanies.filter((x) => typeof x === "string")
234
- : [];
232
+ const droppedCompanies = [];
233
+ for (const item of Array.isArray(r.droppedCompanies) ? r.droppedCompanies : []) {
234
+ if (typeof item === "string" && item.trim()) {
235
+ droppedCompanies.push({ companyUid: item, reason: "unknown" });
236
+ continue;
237
+ }
238
+ if (!item || typeof item !== "object" || Array.isArray(item))
239
+ continue;
240
+ const row = item;
241
+ const companyUid = typeof row.companyUid === "string" ? row.companyUid.trim() : "";
242
+ if (!companyUid)
243
+ continue;
244
+ droppedCompanies.push({
245
+ companyUid,
246
+ reason: typeof row.reason === "string" && row.reason.trim()
247
+ ? row.reason.trim()
248
+ : "not included",
249
+ });
250
+ }
235
251
  return {
236
252
  contractVersion: 3,
237
253
  credentials: {
@@ -246,7 +262,7 @@ export function normalizeContract3Bundle(raw) {
246
262
  actorUid,
247
263
  actorType,
248
264
  companies,
249
- droppedCompanies: dropped,
265
+ droppedCompanies,
250
266
  expiresAt,
251
267
  };
252
268
  }
@@ -32,6 +32,8 @@ export interface DaemonDoctorReport {
32
32
  actorKind: CognitoActorKind;
33
33
  /** Credential vend refused (disabled / unsupported / 403). */
34
34
  presenceRefusal?: DaemonStateFile["presenceRefusal"];
35
+ /** Companies the current presence credential did not include. */
36
+ presenceDroppedCompanies: NonNullable<DaemonStateFile["presenceDroppedCompanies"]>;
35
37
  /** Pending events awaiting a retry drain (POST /v1/mesh/events). */
36
38
  emitRetryDepth: number;
37
39
  /** Last successful direct-emit POST time. */
@@ -105,6 +105,10 @@ export function collectDaemonDoctor(deps = {}) {
105
105
  `(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
106
106
  }
107
107
  const presenceRefusal = state?.presenceRefusal ?? null;
108
+ const presenceDroppedCompanies = state?.presenceDroppedCompanies ?? [];
109
+ if (presenceDroppedCompanies.length > 0) {
110
+ unhealthyReasons.push(`presence unavailable for ${presenceDroppedCompanies.length} companies`);
111
+ }
108
112
  if (presenceRefusal) {
109
113
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
110
114
  }
@@ -132,6 +136,7 @@ export function collectDaemonDoctor(deps = {}) {
132
136
  tokenSource: auth.tokenSource,
133
137
  actorKind: auth.actorKind,
134
138
  presenceRefusal,
139
+ presenceDroppedCompanies,
135
140
  emitMode,
136
141
  subscribed,
137
142
  emitRetryDepth: emitRetry,
@@ -174,6 +179,13 @@ export function formatDaemonDoctor(report) {
174
179
  if (report.presenceRefusal) {
175
180
  lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
176
181
  }
182
+ if (report.presenceDroppedCompanies.length > 0) {
183
+ lines.push(`presence not published for ${report.presenceDroppedCompanies.length} companies: ` +
184
+ report.presenceDroppedCompanies
185
+ .map((company) => `${company.companyUid} (${company.reason})`)
186
+ .join(", ") +
187
+ `; presence credential holds ${report.companiesOnline.length} companies; these were not included`);
188
+ }
177
189
  if (report.unhealthy) {
178
190
  lines.push(`health: UNHEALTHY`);
179
191
  for (const reason of report.unhealthyReasons) {
@@ -162,7 +162,7 @@ export async function runMeshDaemon(deps = {}) {
162
162
  });
163
163
  let presence = null;
164
164
  if (deps.enablePresence !== false) {
165
- const fetchCredentials = deps.fetchCredentials ??
165
+ const fetchCredentialsRaw = deps.fetchCredentials ??
166
166
  (async () => {
167
167
  const t = await getToken();
168
168
  return createContract3Fetcher({
@@ -171,6 +171,11 @@ export async function runMeshDaemon(deps = {}) {
171
171
  env,
172
172
  })();
173
173
  });
174
+ const fetchCredentials = async () => {
175
+ const bundle = await fetchCredentialsRaw();
176
+ patchDaemonState(dir, { presenceDroppedCompanies: bundle.droppedCompanies }, now);
177
+ return bundle;
178
+ };
174
179
  const presenceOpts = {
175
180
  fetchCredentials,
176
181
  mqttConnect: deps.mqttConnect,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { MqttConnectionState } from "./presence.js";
5
5
  import type { FlushSummary } from "../flush.js";
6
+ import type { DroppedPresenceCompany } from "./credentials.js";
6
7
  export interface PresenceRefusalState {
7
8
  code: string;
8
9
  status: number;
@@ -14,6 +15,8 @@ export interface DaemonStateFile {
14
15
  startedAt: string;
15
16
  mqttState: MqttConnectionState;
16
17
  companiesOnline: string[];
18
+ /** Companies omitted from the current presence credential bundle. */
19
+ presenceDroppedCompanies?: DroppedPresenceCompany[];
17
20
  actorUid?: string;
18
21
  lastFlushAt?: string;
19
22
  lastFlushResult?: FlushSummary & {
@@ -1,4 +1,25 @@
1
1
  export declare const TELEMETRY_TIMEOUT_MS = 1200;
2
+ /**
3
+ * Exact stderr line printed once on the first signed-in TTY run that is not
4
+ * `--json`. Wording matches the event body: CLI version + timestamp only.
5
+ */
6
+ export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
7
+ /**
8
+ * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
9
+ * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
10
+ */
11
+ export declare function isCliTelemetryDisabled(env?: NodeJS.ProcessEnv): boolean;
12
+ export declare function telemetryDisclosureMarkerPath(): string;
13
+ /**
14
+ * Print the one-line disclosure at most once per machine. Returns whether
15
+ * the notice has been shown (this run or a previous one). `--json` and
16
+ * non-TTY stdout skip the print and do not record the marker.
17
+ */
18
+ export declare function maybeDiscloseCliTelemetry(stderr?: {
19
+ write: (chunk: string) => unknown;
20
+ }, stdout?: {
21
+ isTTY?: boolean;
22
+ }, argv?: readonly string[]): boolean;
2
23
  /**
3
24
  * Emit the authenticated CLI session signal using an already-cached token.
4
25
  * This deliberately never refreshes a session or opens a browser.
@@ -1,8 +1,80 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
1
4
  import { CLI_VERSION } from "../cli-version.js";
2
5
  import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
3
6
  import { vaultApiFetch } from "./vault-api.js";
4
7
  export const TELEMETRY_TIMEOUT_MS = 1_200;
8
+ /**
9
+ * Exact stderr line printed once on the first signed-in TTY run that is not
10
+ * `--json`. Wording matches the event body: CLI version + timestamp only.
11
+ */
12
+ export const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
13
+ const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "off"]);
14
+ const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
5
15
  let cliSessionStartedPromise;
16
+ /**
17
+ * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
18
+ * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
19
+ */
20
+ export function isCliTelemetryDisabled(env = process.env) {
21
+ const raw = env.HQ_TELEMETRY?.trim().toLowerCase();
22
+ if (!raw)
23
+ return false;
24
+ return TELEMETRY_DISABLED_VALUES.has(raw);
25
+ }
26
+ function hqConfigDir() {
27
+ const home = process.env.HOME?.trim() || os.homedir();
28
+ return path.join(home, ".hq");
29
+ }
30
+ export function telemetryDisclosureMarkerPath() {
31
+ return path.join(hqConfigDir(), DISCLOSURE_MARKER);
32
+ }
33
+ function wantsJsonOutput(argv = process.argv) {
34
+ return argv.includes("--json");
35
+ }
36
+ function canPrintDisclosure(stdout = process.stdout, argv = process.argv) {
37
+ return Boolean(stdout.isTTY) && !wantsJsonOutput(argv);
38
+ }
39
+ function hasShownDisclosure() {
40
+ try {
41
+ return fs.existsSync(telemetryDisclosureMarkerPath());
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function recordDisclosureShown() {
48
+ try {
49
+ const dir = hqConfigDir();
50
+ // Do not create ~/.hq. Signed-in runs already have it (cached tokens).
51
+ if (!fs.existsSync(dir))
52
+ return;
53
+ fs.writeFileSync(telemetryDisclosureMarkerPath(), `${new Date().toISOString()}\n`);
54
+ }
55
+ catch {
56
+ // Marker write is best effort; a repeat print is better than crashing.
57
+ }
58
+ }
59
+ /**
60
+ * Print the one-line disclosure at most once per machine. Returns whether
61
+ * the notice has been shown (this run or a previous one). `--json` and
62
+ * non-TTY stdout skip the print and do not record the marker.
63
+ */
64
+ export function maybeDiscloseCliTelemetry(stderr = process.stderr, stdout = process.stdout, argv = process.argv) {
65
+ if (hasShownDisclosure())
66
+ return true;
67
+ if (!canPrintDisclosure(stdout, argv))
68
+ return false;
69
+ try {
70
+ stderr.write(`${TELEMETRY_DISCLOSURE_LINE}\n`);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ recordDisclosureShown();
76
+ return true;
77
+ }
6
78
  /**
7
79
  * Emit the authenticated CLI session signal using an already-cached token.
8
80
  * This deliberately never refreshes a session or opens a browser.
@@ -13,12 +85,16 @@ export function emitCliSessionStarted(timeoutMs = TELEMETRY_TIMEOUT_MS) {
13
85
  }
14
86
  async function emitCachedCliSessionStarted(timeoutMs) {
15
87
  try {
88
+ if (isCliTelemetryDisabled())
89
+ return;
16
90
  const cached = loadCachedTokens();
17
91
  if (!cached || isExpiring(cached, 120))
18
92
  return;
19
93
  const token = isMachineIdentity() ? cached.idToken : cached.accessToken;
20
94
  if (!token)
21
95
  return;
96
+ const disclosed = maybeDiscloseCliTelemetry();
97
+ const consentBasis = disclosed ? "disclosed" : "no-consent";
22
98
  const controller = new AbortController();
23
99
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
24
100
  try {
@@ -33,7 +109,7 @@ async function emitCachedCliSessionStarted(timeoutMs) {
33
109
  app: "hq-cli",
34
110
  source: "cli",
35
111
  occurredAt: new Date().toISOString(),
36
- consentBasis: "no-consent",
112
+ consentBasis,
37
113
  schemaVersion: 1,
38
114
  properties: { version: CLI_VERSION },
39
115
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.122.2",
3
+ "version": "5.123.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "assets/scaffold",
14
14
  "assets/bot-workers",
15
15
  "assets/mesh-daemon",
16
- "CHANGELOG.md"
16
+ "CHANGELOG.md",
17
+ "README.md"
17
18
  ],
18
19
  "scripts": {
19
20
  "build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",