@indigoai-us/hq-cli 5.267.0 → 5.269.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.
@@ -0,0 +1,395 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { assertNoSecretText } from "./text-scan.js";
5
+ import { LanesError } from "./errors.js";
6
+ export const LANE_REVIEWS_REL = path.join("workspace", "lanes", "reviews");
7
+ export const LANE_REVIEW_SCHEMA_VERSION = "hq-lanes-review.v1";
8
+ export const LANE_REVIEW_HISTORY_CAP = 50;
9
+ const REVIEW_LOCK_WAIT_MS = 5_000;
10
+ const REVIEW_LOCK_STALE_MS = 5 * 60_000;
11
+ function fail(error, message, details = {}) {
12
+ throw new LanesError(error, message, details);
13
+ }
14
+ export function canonicalLaneReviewRepo(raw) {
15
+ const value = raw.trim();
16
+ const parts = value.split("/");
17
+ if (parts.length !== 2) {
18
+ return fail("review_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
19
+ }
20
+ const [owner, repository] = parts;
21
+ if (!owner || !repository ||
22
+ !/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(owner) ||
23
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
24
+ owner === "." || owner === ".." || repository === "." || repository === "..") {
25
+ return fail("review_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
26
+ }
27
+ return `${owner.toLowerCase()}/${repository.toLowerCase()}`;
28
+ }
29
+ export function parseLaneReviewPr(raw) {
30
+ const text = String(raw).trim();
31
+ if (!/^\d+$/.test(text)) {
32
+ return fail("review_pr_invalid", "Pull request number must be a positive integer", { field: "pr" });
33
+ }
34
+ const pr = Number(text);
35
+ if (!Number.isSafeInteger(pr) || pr <= 0) {
36
+ return fail("review_pr_invalid", "Pull request number must be a positive integer", { field: "pr" });
37
+ }
38
+ return pr;
39
+ }
40
+ function parseHeadSha(value, field = "head") {
41
+ const head = value.trim();
42
+ if (!/^[a-fA-F0-9]{40}$/.test(head)) {
43
+ return fail("review_head_invalid", "Head must be a 40-character hexadecimal commit SHA", { field });
44
+ }
45
+ return head.toLowerCase();
46
+ }
47
+ function reviewPath(hqRoot, repo, pr) {
48
+ const [owner, name] = canonicalLaneReviewRepo(repo).split("/");
49
+ return path.join(hqRoot, LANE_REVIEWS_REL, `${owner}__${name}`, `${parseLaneReviewPr(pr)}.json`);
50
+ }
51
+ export function laneReviewPath(hqRoot, repo, pr) {
52
+ return reviewPath(hqRoot, repo, pr);
53
+ }
54
+ function isObject(value) {
55
+ return value !== null && typeof value === "object" && !Array.isArray(value);
56
+ }
57
+ function isTimestamp(value) {
58
+ if (typeof value !== "string")
59
+ return false;
60
+ const date = Date.parse(value);
61
+ return Number.isFinite(date) && new Date(date).toISOString() === value;
62
+ }
63
+ function isNullableText(value) {
64
+ return value === null || (typeof value === "string" && value.length > 0);
65
+ }
66
+ function storedTextIsSafe(field, value, file) {
67
+ try {
68
+ assertNoSecretText(field, value);
69
+ }
70
+ catch {
71
+ fail("review_record_invalid", "Review record contains unsafe text", { path: file, field });
72
+ }
73
+ }
74
+ function parseHistoryEntry(value, file) {
75
+ if (!isObject(value) || typeof value.repo !== "string" ||
76
+ !Number.isSafeInteger(value.pr) || value.pr <= 0 ||
77
+ typeof value.head_sha !== "string" || !/^[a-f0-9]{40}$/.test(value.head_sha) ||
78
+ !isNullableText(value.reviewer_lane) || !isNullableText(value.reviewer_session) ||
79
+ !isNullableText(value.worker_lane) || !isNullableText(value.note) ||
80
+ !isTimestamp(value.recorded_at)) {
81
+ return fail("review_record_invalid", "Review history entry has invalid fields", { path: file });
82
+ }
83
+ let repo;
84
+ try {
85
+ repo = canonicalLaneReviewRepo(value.repo);
86
+ }
87
+ catch {
88
+ return fail("review_record_invalid", "Review history entry has an invalid repository", { path: file });
89
+ }
90
+ if (repo !== value.repo) {
91
+ return fail("review_record_invalid", "Review history repository is not canonical", { path: file });
92
+ }
93
+ for (const field of ["reviewer_lane", "reviewer_session", "worker_lane", "note"]) {
94
+ const text = value[field];
95
+ if (typeof text === "string")
96
+ storedTextIsSafe(`review.${field}`, text, file);
97
+ }
98
+ return value;
99
+ }
100
+ function parseReview(value, file) {
101
+ if (!isObject(value) || value.schema_version !== LANE_REVIEW_SCHEMA_VERSION || !Array.isArray(value.history)) {
102
+ return fail("review_record_invalid", "Review record has invalid fields", { path: file });
103
+ }
104
+ const current = parseHistoryEntry(value, file);
105
+ if (value.history.length > LANE_REVIEW_HISTORY_CAP) {
106
+ return fail("review_record_invalid", "Review record history exceeds its cap", { path: file });
107
+ }
108
+ const history = value.history.map((entry) => parseHistoryEntry(entry, file));
109
+ return { ...current, schema_version: LANE_REVIEW_SCHEMA_VERSION, history };
110
+ }
111
+ export function readLaneReview(hqRoot, repo, pr) {
112
+ const canonicalRepo = canonicalLaneReviewRepo(repo);
113
+ const canonicalPr = parseLaneReviewPr(pr);
114
+ const file = reviewPath(hqRoot, canonicalRepo, canonicalPr);
115
+ let contents;
116
+ try {
117
+ contents = fs.readFileSync(file, "utf8");
118
+ }
119
+ catch (error) {
120
+ if (error.code === "ENOENT")
121
+ return null;
122
+ return fail("review_record_unreadable", "Review record could not be read", { path: file });
123
+ }
124
+ let value;
125
+ try {
126
+ value = JSON.parse(contents);
127
+ }
128
+ catch {
129
+ return fail("review_record_invalid", "Review record is not valid JSON", { path: file });
130
+ }
131
+ const review = parseReview(value, file);
132
+ if (review.repo !== canonicalRepo || review.pr !== canonicalPr || reviewPath(hqRoot, review.repo, review.pr) !== file) {
133
+ return fail("review_record_invalid", "Review record identity does not match its path", { path: file });
134
+ }
135
+ return review;
136
+ }
137
+ export function listLaneReviews(hqRoot, reviewerLane) {
138
+ const directory = path.join(hqRoot, LANE_REVIEWS_REL);
139
+ let repoDirectories;
140
+ try {
141
+ repoDirectories = fs.readdirSync(directory).sort();
142
+ }
143
+ catch (error) {
144
+ if (error.code === "ENOENT")
145
+ return { reviews: [], skipped: 0 };
146
+ return { reviews: [], skipped: 1 };
147
+ }
148
+ const reviews = [];
149
+ let skipped = 0;
150
+ for (const repoDirectory of repoDirectories) {
151
+ const match = /^([a-z0-9-]+)__([a-z0-9._-]+)$/.exec(repoDirectory);
152
+ if (!match || repoDirectory.startsWith(".")) {
153
+ skipped += 1;
154
+ continue;
155
+ }
156
+ const repoPath = path.join(directory, repoDirectory);
157
+ try {
158
+ const stat = fs.lstatSync(repoPath);
159
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
160
+ skipped += 1;
161
+ continue;
162
+ }
163
+ }
164
+ catch {
165
+ skipped += 1;
166
+ continue;
167
+ }
168
+ const repo = `${match[1]}/${match[2]}`;
169
+ let names;
170
+ try {
171
+ names = fs.readdirSync(repoPath).sort();
172
+ }
173
+ catch {
174
+ skipped += 1;
175
+ continue;
176
+ }
177
+ for (const name of names) {
178
+ if (/^\d+\.json\.lock$/.test(name) || /^\d+\.json\.\d+\.[a-f0-9]{12}\.tmp$/.test(name))
179
+ continue;
180
+ if (name.startsWith(".") || !/^\d+\.json$/.test(name)) {
181
+ skipped += 1;
182
+ continue;
183
+ }
184
+ const file = path.join(repoPath, name);
185
+ try {
186
+ const stat = fs.lstatSync(file);
187
+ if (!stat.isFile() || stat.isSymbolicLink()) {
188
+ skipped += 1;
189
+ continue;
190
+ }
191
+ }
192
+ catch {
193
+ skipped += 1;
194
+ continue;
195
+ }
196
+ let pr;
197
+ try {
198
+ pr = parseLaneReviewPr(name.slice(0, -5));
199
+ }
200
+ catch {
201
+ skipped += 1;
202
+ continue;
203
+ }
204
+ let review;
205
+ try {
206
+ review = readLaneReview(hqRoot, repo, pr);
207
+ }
208
+ catch {
209
+ skipped += 1;
210
+ continue;
211
+ }
212
+ if (!review) {
213
+ skipped += 1;
214
+ continue;
215
+ }
216
+ if (reviewerLane === undefined || review.reviewer_lane === reviewerLane)
217
+ reviews.push(review);
218
+ }
219
+ }
220
+ return {
221
+ reviews: reviews.sort((left, right) => right.recorded_at.localeCompare(left.recorded_at) ||
222
+ left.repo.localeCompare(right.repo) || left.pr - right.pr),
223
+ skipped,
224
+ };
225
+ }
226
+ export async function recordLaneReview(hqRoot, input) {
227
+ const repo = canonicalLaneReviewRepo(input.repo);
228
+ const pr = parseLaneReviewPr(input.pr);
229
+ const head_sha = parseHeadSha(input.headSha);
230
+ const reviewer_lane = input.reviewerLane?.trim() || null;
231
+ const reviewer_session = input.reviewerSession?.trim() || null;
232
+ const worker_lane = input.workerLane?.trim() || null;
233
+ const note = input.note?.trim() || null;
234
+ if (worker_lane && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(worker_lane)) {
235
+ return fail("review_worker_invalid", "Worker lane id has an invalid format", { field: "worker" });
236
+ }
237
+ if (reviewer_lane)
238
+ assertNoSecretText("reviewer_lane", reviewer_lane);
239
+ if (reviewer_session)
240
+ assertNoSecretText("reviewer_session", reviewer_session);
241
+ if (note)
242
+ assertNoSecretText("note", note);
243
+ if (worker_lane)
244
+ assertNoSecretText("worker", worker_lane);
245
+ const file = reviewPath(hqRoot, repo, pr);
246
+ return withReviewRecordLock(file, async () => {
247
+ const previous = readLaneReview(hqRoot, repo, pr);
248
+ const entry = {
249
+ repo,
250
+ pr,
251
+ head_sha,
252
+ reviewer_lane,
253
+ reviewer_session,
254
+ worker_lane,
255
+ note,
256
+ recorded_at: input.recordedAt.toISOString(),
257
+ };
258
+ const review = {
259
+ ...entry,
260
+ schema_version: LANE_REVIEW_SCHEMA_VERSION,
261
+ history: [...(previous?.history ?? []), ...(previous ? [currentHistoryEntry(previous)] : [])]
262
+ .slice(-LANE_REVIEW_HISTORY_CAP),
263
+ };
264
+ writeAtomic(file, review);
265
+ return review;
266
+ });
267
+ }
268
+ function reviewLockOwnerAlive(pid) {
269
+ try {
270
+ process.kill(pid, 0);
271
+ return true;
272
+ }
273
+ catch (error) {
274
+ return error.code !== "ESRCH";
275
+ }
276
+ }
277
+ function removeStaleReviewLock(lockPath) {
278
+ let first;
279
+ try {
280
+ first = fs.lstatSync(lockPath);
281
+ }
282
+ catch (error) {
283
+ if (error.code === "ENOENT")
284
+ return true;
285
+ throw error;
286
+ }
287
+ if (Date.now() - first.mtimeMs < REVIEW_LOCK_STALE_MS)
288
+ return false;
289
+ let pid;
290
+ try {
291
+ pid = Number(fs.readFileSync(lockPath, "utf8").split("\n", 1)[0]);
292
+ }
293
+ catch (error) {
294
+ if (error.code === "ENOENT")
295
+ return true;
296
+ throw error;
297
+ }
298
+ if (!Number.isSafeInteger(pid) || pid <= 0 || reviewLockOwnerAlive(pid))
299
+ return false;
300
+ let second;
301
+ try {
302
+ second = fs.lstatSync(lockPath);
303
+ }
304
+ catch (error) {
305
+ if (error.code === "ENOENT")
306
+ return true;
307
+ throw error;
308
+ }
309
+ if (second.dev !== first.dev || second.ino !== first.ino || second.mtimeMs !== first.mtimeMs)
310
+ return false;
311
+ try {
312
+ fs.unlinkSync(lockPath);
313
+ return true;
314
+ }
315
+ catch (error) {
316
+ if (error.code === "ENOENT")
317
+ return true;
318
+ throw error;
319
+ }
320
+ }
321
+ async function withReviewRecordLock(file, action) {
322
+ const lockPath = `${file}.lock`;
323
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
324
+ const started = Date.now();
325
+ let fd;
326
+ while (fd === undefined) {
327
+ try {
328
+ fd = fs.openSync(lockPath, "wx", 0o600);
329
+ }
330
+ catch (error) {
331
+ if (error.code !== "EEXIST")
332
+ throw error;
333
+ if (Date.now() - started >= REVIEW_LOCK_WAIT_MS) {
334
+ return fail("review_record_write_busy", "This review record is being updated; retry the command", { path: file });
335
+ }
336
+ if (!removeStaleReviewLock(lockPath))
337
+ await new Promise((resolve) => setTimeout(resolve, 10));
338
+ }
339
+ }
340
+ let ownedStat;
341
+ try {
342
+ ownedStat = fs.fstatSync(fd);
343
+ fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
344
+ return await action();
345
+ }
346
+ finally {
347
+ try {
348
+ fs.closeSync(fd);
349
+ }
350
+ catch (error) {
351
+ process.stderr.write(`hq lanes review: lock close failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
352
+ }
353
+ if (ownedStat) {
354
+ try {
355
+ const current = fs.lstatSync(lockPath);
356
+ if (current.dev === ownedStat.dev && current.ino === ownedStat.ino)
357
+ fs.unlinkSync(lockPath);
358
+ }
359
+ catch (error) {
360
+ if (error.code !== "ENOENT") {
361
+ process.stderr.write(`hq lanes review: lock cleanup failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
367
+ function currentHistoryEntry(review) {
368
+ const { repo, pr, head_sha, reviewer_lane, reviewer_session, worker_lane, note, recorded_at } = review;
369
+ return { repo, pr, head_sha, reviewer_lane, reviewer_session, worker_lane, note, recorded_at };
370
+ }
371
+ function writeAtomic(file, review) {
372
+ const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
373
+ try {
374
+ fs.writeFileSync(temporary, `${JSON.stringify(review, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
375
+ fs.renameSync(temporary, file);
376
+ fs.chmodSync(file, 0o600);
377
+ }
378
+ catch (error) {
379
+ try {
380
+ fs.rmSync(temporary, { force: true });
381
+ }
382
+ catch (cleanupError) {
383
+ throw new LanesError("review_record_write_failed", "Review record could not be written and temporary cleanup failed", {
384
+ path: file,
385
+ error: error instanceof Error ? error.name : "UnknownError",
386
+ cleanup_error: cleanupError instanceof Error ? cleanupError.name : "UnknownError",
387
+ });
388
+ }
389
+ throw new LanesError("review_record_write_failed", "Review record could not be written", {
390
+ path: file,
391
+ error: error instanceof Error ? error.name : "UnknownError",
392
+ });
393
+ }
394
+ }
395
+ //# sourceMappingURL=reviews.js.map
@@ -48,6 +48,7 @@ import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission
48
48
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
49
49
  import { envelopeQuestionId } from "./questions.js";
50
50
  import { readLanesRegistryFlag } from "./registry-flag.js";
51
+ import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
51
52
  import { blockerFreshnessFingerprint, resolveBlockerFreshnessForLane, } from "./blocker-freshness.js";
52
53
  import { krFreshnessFingerprint, resolveKrFreshnessForLane, } from "./kr-freshness.js";
53
54
  import { MANAGER_STATUS_GATE_FLAG, managerStatusWasWritten, readStatusSnapshot, resolveManagerStatusTarget, managerStatusGateEnabledForLane, } from "./manager-status-gate.js";
@@ -647,11 +648,12 @@ async function settleManagerStatusGate(hqRoot, lane, manifestTurnId, at, deps, l
647
648
  const readFlag = deps.readManagerStatusGateFlag ?? readLanesRegistryFlag;
648
649
  let enabled = false;
649
650
  try {
650
- const company = lane.company ?? lane.mesh_company ?? undefined;
651
+ const resolution = resolveLaneCompany(hqRoot, lane);
652
+ const company = resolution.company ?? undefined;
651
653
  enabled = await readFlag(MANAGER_STATUS_GATE_FLAG, {
652
654
  company,
653
655
  ...(company ? {
654
- companyUidSource: lane.company ? "lane.company.uid" : "lane.mesh_company.uid",
656
+ companyUidSource: laneCompanyUidSource(resolution.source),
655
657
  } : {}),
656
658
  });
657
659
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.267.0",
3
+ "version": "5.269.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {