@indigoai-us/hq-cli 5.197.0 → 5.198.1

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.
@@ -0,0 +1,277 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { LanesError } from "./errors.js";
5
+ const KIND_SET = new Set(["blocker", "kpi", "okr", "directive"]);
6
+ const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
7
+ function storeError(error, message, details = {}) {
8
+ return new LanesError(error, message, details);
9
+ }
10
+ function validateId(id) {
11
+ if (!ID_RE.test(id) || id === "." || id === "..") {
12
+ throw storeError("tracker_id_invalid", "Tracker record id must be a safe path segment", { id });
13
+ }
14
+ }
15
+ function recordPath(root, kind, id) {
16
+ validateId(id);
17
+ if (!KIND_SET.has(kind))
18
+ throw storeError("tracker_kind_invalid", "Unknown tracker record kind", { kind });
19
+ return path.join(root, kind, `${id}.json`);
20
+ }
21
+ function assertRealDirectory(directory) {
22
+ try {
23
+ const stat = fs.lstatSync(directory);
24
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
25
+ throw storeError("tracker_store_path_invalid", "Tracker storage path must be a real directory", { path: directory });
26
+ }
27
+ }
28
+ catch (err) {
29
+ if (err.code !== "ENOENT")
30
+ throw err;
31
+ fs.mkdirSync(directory, { mode: 0o700 });
32
+ }
33
+ }
34
+ function ensureKindDirectory(root, kind) {
35
+ assertRealDirectory(root);
36
+ const directory = path.join(root, kind);
37
+ assertRealDirectory(directory);
38
+ return directory;
39
+ }
40
+ function existingKindDirectory(root, kind) {
41
+ try {
42
+ const rootStat = fs.lstatSync(root);
43
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
44
+ throw storeError("tracker_store_path_invalid", "Tracker storage path must be a real directory", { path: root });
45
+ }
46
+ }
47
+ catch (err) {
48
+ if (err.code === "ENOENT")
49
+ return null;
50
+ throw err;
51
+ }
52
+ const directory = path.join(root, kind);
53
+ try {
54
+ const kindStat = fs.lstatSync(directory);
55
+ if (!kindStat.isDirectory() || kindStat.isSymbolicLink()) {
56
+ throw storeError("tracker_store_path_invalid", "Tracker kind path must be a real directory", { path: directory });
57
+ }
58
+ return directory;
59
+ }
60
+ catch (err) {
61
+ if (err.code === "ENOENT")
62
+ return null;
63
+ throw err;
64
+ }
65
+ }
66
+ function parseRecord(text, expectedKind, id, file) {
67
+ let value;
68
+ try {
69
+ value = JSON.parse(text);
70
+ }
71
+ catch {
72
+ throw storeError("tracker_record_malformed", "Tracker record contains invalid JSON", { kind: expectedKind, id, path: file });
73
+ }
74
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
75
+ throw storeError("tracker_record_invalid", "Tracker record must be a JSON object", { kind: expectedKind, id, path: file });
76
+ }
77
+ const record = value;
78
+ if (record.schema_version !== "hq-lanes-tracker-record.v1" ||
79
+ record.kind !== expectedKind ||
80
+ record.id !== id ||
81
+ typeof record.company !== "string" || !record.company ||
82
+ typeof record.project_id !== "string" || !record.project_id ||
83
+ !Number.isSafeInteger(record.version) || (record.version ?? 0) < 1 ||
84
+ typeof record.created_at !== "string" || typeof record.updated_at !== "string") {
85
+ throw storeError("tracker_record_invalid", "Tracker record is missing valid common fields", { kind: expectedKind, id, path: file });
86
+ }
87
+ return record;
88
+ }
89
+ function writeExclusive(file, contents) {
90
+ const dir = path.dirname(file);
91
+ const temp = path.join(dir, `.${path.basename(file)}.${randomUUID()}.tmp`);
92
+ try {
93
+ fs.writeFileSync(temp, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
94
+ // link is an atomic create-if-absent operation; rename would overwrite a
95
+ // concurrent creator's record if a generated id ever collided.
96
+ fs.linkSync(temp, file);
97
+ }
98
+ catch (err) {
99
+ if (err.code === "EEXIST") {
100
+ throw storeError("tracker_record_exists", "Tracker record id already exists", { path: file });
101
+ }
102
+ throw err;
103
+ }
104
+ finally {
105
+ removeTempFile(temp);
106
+ }
107
+ }
108
+ function removeTempFile(file) {
109
+ try {
110
+ fs.unlinkSync(file);
111
+ }
112
+ catch (err) {
113
+ if (err.code !== "ENOENT")
114
+ throw err;
115
+ }
116
+ }
117
+ function withRecordLock(file, action) {
118
+ const lock = `${file}.lock`;
119
+ let fd;
120
+ for (let attempt = 0; attempt < 2; attempt += 1) {
121
+ try {
122
+ fd = fs.openSync(lock, "wx", 0o600);
123
+ fs.writeSync(fd, `${process.pid}\n`);
124
+ break;
125
+ }
126
+ catch (err) {
127
+ if (err.code !== "EEXIST")
128
+ throw err;
129
+ // Recover a lock only when it is old and its owner is gone. Rechecking
130
+ // inode and mtime before unlinking avoids deleting a freshly replaced lock.
131
+ let first;
132
+ try {
133
+ first = fs.lstatSync(lock);
134
+ }
135
+ catch (statError) {
136
+ if (statError.code === "ENOENT")
137
+ continue;
138
+ throw statError;
139
+ }
140
+ if (Date.now() - first.mtimeMs < 5 * 60_000) {
141
+ throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
142
+ }
143
+ let owner = null;
144
+ try {
145
+ const raw = fs.readFileSync(lock, "utf8").trim();
146
+ const parsed = Number(raw);
147
+ if (Number.isSafeInteger(parsed) && parsed > 0)
148
+ owner = parsed;
149
+ }
150
+ catch (readError) {
151
+ if (readError.code !== "ENOENT")
152
+ throw readError;
153
+ }
154
+ if (owner !== null) {
155
+ try {
156
+ process.kill(owner, 0);
157
+ throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
158
+ }
159
+ catch (probeError) {
160
+ if (probeError instanceof LanesError)
161
+ throw probeError;
162
+ if (probeError.code !== "ESRCH")
163
+ throw probeError;
164
+ }
165
+ }
166
+ try {
167
+ const second = fs.lstatSync(lock);
168
+ if (second.ino === first.ino && second.dev === first.dev && second.mtimeMs === first.mtimeMs)
169
+ fs.unlinkSync(lock);
170
+ }
171
+ catch (unlinkError) {
172
+ if (unlinkError.code !== "ENOENT")
173
+ throw unlinkError;
174
+ }
175
+ }
176
+ }
177
+ if (fd === undefined)
178
+ throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
179
+ try {
180
+ return action();
181
+ }
182
+ finally {
183
+ fs.closeSync(fd);
184
+ fs.unlinkSync(lock);
185
+ }
186
+ }
187
+ export function createLocalTrackerStore(projectDirectory, now = () => new Date()) {
188
+ const root = path.join(projectDirectory, "tracker");
189
+ const read = (kind, id) => {
190
+ const file = recordPath(root, kind, id);
191
+ let text;
192
+ try {
193
+ const stat = fs.lstatSync(file);
194
+ if (!stat.isFile() || stat.isSymbolicLink()) {
195
+ throw storeError("tracker_record_invalid", "Tracker record path must be a regular file", { kind, id, path: file });
196
+ }
197
+ text = fs.readFileSync(file, "utf8");
198
+ }
199
+ catch (err) {
200
+ if (err.code === "ENOENT")
201
+ return null;
202
+ throw err;
203
+ }
204
+ return parseRecord(text, kind, id, file);
205
+ };
206
+ return {
207
+ read,
208
+ list(kind) {
209
+ const directory = existingKindDirectory(root, kind);
210
+ if (directory === null)
211
+ return [];
212
+ return fs.readdirSync(directory)
213
+ .filter((name) => name.endsWith(".json"))
214
+ .sort()
215
+ .map((name) => {
216
+ const id = name.slice(0, -5);
217
+ return read(kind, id);
218
+ })
219
+ .filter((record) => record !== null)
220
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at) || a.id.localeCompare(b.id));
221
+ },
222
+ create(input) {
223
+ const kind = input.kind;
224
+ const directory = ensureKindDirectory(root, kind);
225
+ const id = typeof input.id === "string" && input.id ? input.id : randomUUID();
226
+ const time = now().toISOString();
227
+ const record = {
228
+ ...input,
229
+ schema_version: "hq-lanes-tracker-record.v1",
230
+ kind,
231
+ id,
232
+ company: input.company,
233
+ project_id: input.project_id,
234
+ version: 1,
235
+ created_at: time,
236
+ updated_at: time,
237
+ };
238
+ writeExclusive(path.join(directory, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`);
239
+ return record;
240
+ },
241
+ update(kind, id, expectedVersion, change) {
242
+ const file = recordPath(root, kind, id);
243
+ const directory = ensureKindDirectory(root, kind);
244
+ return withRecordLock(file, () => {
245
+ const current = read(kind, id);
246
+ if (!current)
247
+ throw storeError("tracker_record_not_found", `Tracker ${kind} record ${id} was not found`, { kind, id });
248
+ if (current.version !== expectedVersion) {
249
+ throw storeError("tracker_version_conflict", `Tracker record version conflict: expected ${expectedVersion}, found ${current.version}`, {
250
+ kind, id, expected: expectedVersion, actual: current.version,
251
+ });
252
+ }
253
+ const changed = change(current);
254
+ if (changed.id !== current.id || changed.kind !== current.kind || changed.company !== current.company || changed.project_id !== current.project_id || changed.created_at !== current.created_at) {
255
+ throw storeError("tracker_update_invalid", "Tracker update cannot change record identity", { kind, id });
256
+ }
257
+ const next = {
258
+ ...changed,
259
+ schema_version: "hq-lanes-tracker-record.v1",
260
+ version: current.version + 1,
261
+ created_at: current.created_at,
262
+ updated_at: now().toISOString(),
263
+ };
264
+ const temp = path.join(directory, `.${id}.${randomUUID()}.tmp`);
265
+ try {
266
+ fs.writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
267
+ fs.renameSync(temp, file);
268
+ }
269
+ finally {
270
+ removeTempFile(temp);
271
+ }
272
+ return next;
273
+ });
274
+ },
275
+ };
276
+ }
277
+ //# sourceMappingURL=tracker-store.js.map
@@ -706,6 +706,8 @@ export interface LanesConfig {
706
706
  providers: Record<LaneProvider, ProviderConfig>;
707
707
  throttle: ThrottleConfig;
708
708
  liveness: LivenessWindows;
709
+ /** Seconds an unanswered question waits at a session audience before advancing. */
710
+ session_question_timeout_secs: number;
709
711
  }
710
712
  export declare function parseMajorSchema(schemaVersion: string, expectedPrefix: string, expectedMajor: number): void;
711
713
  export declare function parseLaneEnvelope(raw: unknown): LaneEnvelope;
@@ -12,9 +12,10 @@ export type EmbedLockProcessEvents = Pick<NodeJS.Process, 'once'> & {
12
12
  };
13
13
  export type EmbedLockRunResult = 'ran' | 'skipped' | 'busy' | 'unavailable';
14
14
  /**
15
- * Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
16
- * another tool that does not call this module) cannot participate, because qmd
17
- * is a third-party package and does not know about HQ's lock protocol.
15
+ * Serializes QMD writes started through HQ-managed hq-cli paths. A bare `qmd
16
+ * update` / `qmd embed` (or another tool that does not call this module) cannot
17
+ * participate, because qmd is a third-party package and does not know about
18
+ * HQ's lock protocol.
18
19
  */
19
20
  export declare const EMBED_LOCK_NAME = "qmd-reindex-bg.lock";
20
21
  export declare function defaultEmbedLockDependencies(env?: NodeJS.ProcessEnv): EmbedLockDependencies;
@@ -26,7 +27,7 @@ export declare function releaseEmbedLock(home: string, dependencies: EmbedLockDe
26
27
  /** Install signal and exit cleanup for a lock owner, returning an optional unregister hook. */
27
28
  export declare function installEmbedLockCleanup(cleanup: () => void, processEvents?: EmbedLockProcessEvents, exit?: (code: number) => void): () => void;
28
29
  /**
29
- * Run one synchronous embed while owning the shared lock.
30
+ * Run one synchronous QMD write or reindex pass while owning the shared lock.
30
31
  *
31
32
  * This deliberately does not install signal handlers. `run` is a synchronous
32
33
  * qmd child, so JavaScript cannot execute a cleanup handler until it returns;
@@ -1,9 +1,10 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  /**
4
- * Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
5
- * another tool that does not call this module) cannot participate, because qmd
6
- * is a third-party package and does not know about HQ's lock protocol.
4
+ * Serializes QMD writes started through HQ-managed hq-cli paths. A bare `qmd
5
+ * update` / `qmd embed` (or another tool that does not call this module) cannot
6
+ * participate, because qmd is a third-party package and does not know about
7
+ * HQ's lock protocol.
7
8
  */
8
9
  export const EMBED_LOCK_NAME = 'qmd-reindex-bg.lock';
9
10
  export function defaultEmbedLockDependencies(env = process.env) {
@@ -261,7 +262,7 @@ export function installEmbedLockCleanup(cleanup, processEvents = process, exit =
261
262
  };
262
263
  }
263
264
  /**
264
- * Run one synchronous embed while owning the shared lock.
265
+ * Run one synchronous QMD write or reindex pass while owning the shared lock.
265
266
  *
266
267
  * This deliberately does not install signal handlers. `run` is a synchronous
267
268
  * qmd child, so JavaScript cannot execute a cleanup handler until it returns;
@@ -0,0 +1,5 @@
1
+ export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
2
+ export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
3
+ /** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
4
+ export declare function qmdReindexSingleFlightEnabled(): boolean;
5
+ //# sourceMappingURL=reindex-single-flight-flag.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { resolveProcessFlagGate } from "../flag-registry.js";
2
+ export const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
3
+ export const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
4
+ /** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
5
+ export function qmdReindexSingleFlightEnabled() {
6
+ return resolveProcessFlagGate(QMD_REINDEX_SINGLE_FLIGHT_FLAG, { fallback: QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK }, () => QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK);
7
+ }
8
+ //# sourceMappingURL=reindex-single-flight-flag.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.197.0",
3
+ "version": "5.198.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {