@indigoai-us/hq-cli 5.345.54 → 5.345.56
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.
- package/CHANGELOG.md +31 -0
- package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
- package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
- package/assets/scaffold/core/scripts/session-title.sh +18 -15
- package/dist/command-catalog.generated.d.ts +143 -2
- package/dist/command-catalog.generated.js +175 -2
- package/dist/command-registration-plan.d.ts +6 -1
- package/dist/command-registration-plan.js +2 -1
- package/dist/commands/cloud.d.ts +7 -0
- package/dist/commands/cloud.js +92 -16
- package/dist/commands/core-delegate-w1b.js +18 -5
- package/dist/commands/core-derive-trigger-facts-command.js +14 -0
- package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
- package/dist/commands/core-derive-trigger-facts-native.js +20 -1
- package/dist/commands/daemon.js +16 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +15 -2
- package/dist/commands/import-scan.d.ts +24 -0
- package/dist/commands/import-scan.js +75 -0
- package/dist/commands/lanes-checks.d.ts +3 -0
- package/dist/commands/lanes-checks.js +34 -0
- package/dist/commands/lanes-session-start.d.ts +17 -0
- package/dist/commands/lanes-session-start.js +195 -0
- package/dist/commands/lanes-shared.d.ts +2 -1
- package/dist/commands/lanes-watch.d.ts +2 -0
- package/dist/commands/lanes-watch.js +2 -1
- package/dist/commands/lanes.d.ts +3 -1
- package/dist/commands/lanes.js +121 -18
- package/dist/commands/members.d.ts +4 -0
- package/dist/commands/members.js +23 -2
- package/dist/lanes-drain-hook.js +6 -2
- package/dist/lib/daemon/conflict-notices.d.ts +4 -0
- package/dist/lib/daemon/conflict-notices.js +80 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
- package/dist/lib/doctor/checks/sync-health.js +47 -23
- package/dist/lib/import-scan/events.d.ts +135 -0
- package/dist/lib/import-scan/events.js +71 -0
- package/dist/lib/import-scan/run.d.ts +84 -0
- package/dist/lib/import-scan/run.js +308 -0
- package/dist/lib/import-scan/scan-event.schema.json +127 -0
- package/dist/lib/import-scan/validate.d.ts +31 -0
- package/dist/lib/import-scan/validate.js +111 -0
- package/dist/lib/lanes/dropbox.d.ts +4 -2
- package/dist/lib/lanes/dropbox.js +54 -9
- package/dist/lib/lanes/flag-keys.d.ts +10 -0
- package/dist/lib/lanes/flag-keys.js +10 -0
- package/dist/lib/lanes/hooks-install.d.ts +14 -11
- package/dist/lib/lanes/hooks-install.js +70 -50
- package/dist/lib/lanes/monitor.js +1 -1
- package/dist/lib/lanes/reaper.d.ts +58 -0
- package/dist/lib/lanes/reaper.js +403 -0
- package/dist/lib/lanes/registry-flag.d.ts +2 -0
- package/dist/lib/lanes/registry-flag.js +1 -1
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +32 -2
- package/dist/lib/lanes/status-fields.d.ts +12 -0
- package/dist/lib/lanes/status-fields.js +189 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/worker-continuity.d.ts +18 -0
- package/dist/lib/lanes/worker-continuity.js +81 -0
- package/dist/lib/lanes/worker-process.d.ts +4 -0
- package/dist/lib/lanes/worker-process.js +77 -0
- package/dist/main.js +9 -3
- package/dist/utils/cli-telemetry.d.ts +4 -1
- package/dist/utils/cli-telemetry.js +8 -0
- package/dist/utils/jq-windows-compat.d.ts +13 -0
- package/dist/utils/jq-windows-compat.js +48 -0
- package/dist/utils/run-bundled-script.d.ts +3 -0
- package/dist/utils/run-bundled-script.js +9 -80
- package/dist/utils/windows-bash.d.ts +5 -0
- package/dist/utils/windows-bash.js +75 -0
- package/package.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the HQ import-context scanner with `--progress-json` and relays its
|
|
3
|
+
* JSON Lines event stream (contract: ./events.ts).
|
|
4
|
+
*
|
|
5
|
+
* The scanner is the HQ tree's own copy at
|
|
6
|
+
* `<hq root>/.claude/skills/import-context/scan.sh`, the same place the
|
|
7
|
+
* /import-context skill runs it from. The scan is local only: the scanner
|
|
8
|
+
* reads the filesystem and writes one report.json; nothing here touches the
|
|
9
|
+
* network.
|
|
10
|
+
*
|
|
11
|
+
* HQ root: `--hq-root` when given, else the root the HQ installer recorded in
|
|
12
|
+
* `~/.hq/menubar.json` (`hqPath`). Nothing else is trusted: no walk up from
|
|
13
|
+
* the current folder and no `$HQ_ROOT`, because this command runs a script
|
|
14
|
+
* from that tree.
|
|
15
|
+
*
|
|
16
|
+
* Output modes:
|
|
17
|
+
* stream every valid event, rebuilt from its v1 fields (invalid lines are
|
|
18
|
+
* dropped with a warning on stderr that never echoes the line)
|
|
19
|
+
* final only the final `done` object
|
|
20
|
+
* human short progress lines and a summary
|
|
21
|
+
*
|
|
22
|
+
* The `done` event is held until the scanner exits. When the scan cannot run
|
|
23
|
+
* or does not finish cleanly (HQ not found, scanner missing or too old,
|
|
24
|
+
* scanner exited non-zero or without `done`, cancelled), an `error` event
|
|
25
|
+
* (source "scanner") comes first, then `done` (zeroed with `report: null`
|
|
26
|
+
* unless the scanner produced one), and the command exits non-zero.
|
|
27
|
+
*
|
|
28
|
+
* Process handling: the scanner runs in its own process group. SIGINT/SIGTERM
|
|
29
|
+
* to hq-cli are forwarded to the whole group and escalated to SIGKILL after
|
|
30
|
+
* `killTimeoutMs`. When the scanner exits, anything still left in its group
|
|
31
|
+
* is terminated so an orphan holding stdout open cannot stall the command.
|
|
32
|
+
*
|
|
33
|
+
* Scanner stderr is not forwarded verbatim: each line is passed through with
|
|
34
|
+
* paths and token-like strings replaced, at most MAX_STDERR_LINES lines, then
|
|
35
|
+
* a count of the rest.
|
|
36
|
+
*/
|
|
37
|
+
import { spawn } from "node:child_process";
|
|
38
|
+
import * as fs from "node:fs";
|
|
39
|
+
import * as os from "node:os";
|
|
40
|
+
import * as path from "node:path";
|
|
41
|
+
import * as readline from "node:readline";
|
|
42
|
+
import { SCAN_CLI_ERROR_SOURCE, SCAN_EVENT_VERSION, } from "./events.js";
|
|
43
|
+
import { parseScanEventLine } from "./validate.js";
|
|
44
|
+
export const SCANNER_RELATIVE_PATH = path.join(".claude", "skills", "import-context", "scan.sh");
|
|
45
|
+
const MAX_STDERR_LINES = 20;
|
|
46
|
+
const DEFAULT_KILL_TIMEOUT_MS = 5000;
|
|
47
|
+
const DRAIN_TIMEOUT_MS = 2000;
|
|
48
|
+
/** UTC scan id in the /import-context skill's format: YYYYMMDDTHHMMSSZ. */
|
|
49
|
+
export function scanId(date) {
|
|
50
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
51
|
+
}
|
|
52
|
+
export class HqRootError extends Error {
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* --hq-root, else the installer-recorded root in ~/.hq/menubar.json. Throws
|
|
56
|
+
* HqRootError with a plain-words message otherwise.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveScanHqRoot(opts) {
|
|
59
|
+
if (opts.hqRoot) {
|
|
60
|
+
const root = path.resolve(opts.hqRoot);
|
|
61
|
+
if (!isDirectory(root))
|
|
62
|
+
throw new HqRootError("The folder given with --hq-root does not exist.");
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
const menubar = path.join(opts.home ?? os.homedir(), ".hq", "menubar.json");
|
|
66
|
+
let recorded;
|
|
67
|
+
try {
|
|
68
|
+
recorded = JSON.parse(fs.readFileSync(menubar, "utf8")).hqPath;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
recorded = undefined;
|
|
72
|
+
}
|
|
73
|
+
if (typeof recorded !== "string" || !path.isAbsolute(recorded) || !isDirectory(recorded)) {
|
|
74
|
+
throw new HqRootError("Could not find your HQ folder. Pass --hq-root <path>, or open the HQ app once so it records where HQ is installed.");
|
|
75
|
+
}
|
|
76
|
+
return path.resolve(recorded);
|
|
77
|
+
}
|
|
78
|
+
function isDirectory(p) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.statSync(p).isDirectory();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Replace paths and token-like strings in one scanner stderr line. A path
|
|
88
|
+
* runs from its first "/" (or a leading ~ / $HOME) to the next quote or the
|
|
89
|
+
* end of the line, because folder names may contain spaces: stopping at
|
|
90
|
+
* whitespace would leak the rest of "/Users/x/My Project/secret".
|
|
91
|
+
*/
|
|
92
|
+
export function redactScannerLine(line) {
|
|
93
|
+
return line
|
|
94
|
+
.replace(/(?<![^\s'"`=:([])(?:\$HOME|~)?\/[^'"`]*/g, "<path>")
|
|
95
|
+
.replace(/\b[A-Za-z0-9_+=-]{24,}\b/g, "<redacted>")
|
|
96
|
+
.replace(/\S+@\S+/g, "<redacted>")
|
|
97
|
+
.slice(0, 300);
|
|
98
|
+
}
|
|
99
|
+
function zeroDone() {
|
|
100
|
+
return {
|
|
101
|
+
v: SCAN_EVENT_VERSION,
|
|
102
|
+
type: "done",
|
|
103
|
+
report: null,
|
|
104
|
+
summary: { companies: 0, projects: 0, sessions: 0 },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function cliError(message, code) {
|
|
108
|
+
return { v: SCAN_EVENT_VERSION, type: "error", source: SCAN_CLI_ERROR_SOURCE, message, code };
|
|
109
|
+
}
|
|
110
|
+
const failed = (message) => ({ message, code: "scan_failed" });
|
|
111
|
+
const outdated = (message) => ({ message, code: "scanner_outdated" });
|
|
112
|
+
function humanLine(event, labels) {
|
|
113
|
+
switch (event.type) {
|
|
114
|
+
case "source": {
|
|
115
|
+
const label = labels.get(event.id) ?? event.id;
|
|
116
|
+
if (event.status === "scanning")
|
|
117
|
+
return `Scanning ${label}...`;
|
|
118
|
+
if (event.status === "done") {
|
|
119
|
+
const parts = Object.entries(event.counts ?? {})
|
|
120
|
+
.filter(([, n]) => n > 0)
|
|
121
|
+
.map(([k, n]) => `${n} ${k.replace(/_/g, " ")}`);
|
|
122
|
+
return ` ${label}: ${parts.length ? parts.join(", ") : "nothing found"}`;
|
|
123
|
+
}
|
|
124
|
+
return ` ${label}: ${event.status}${event.message ? ` - ${event.message}` : ""}`;
|
|
125
|
+
}
|
|
126
|
+
case "company":
|
|
127
|
+
return ` company: ${event.name}`;
|
|
128
|
+
case "error":
|
|
129
|
+
return ` warning: ${event.message}`;
|
|
130
|
+
case "done":
|
|
131
|
+
return (`Found ${event.summary.companies} companies, ${event.summary.projects} projects, ` +
|
|
132
|
+
`${event.summary.sessions} sessions.` + (event.report ? `\nReport: ${event.report}` : ""));
|
|
133
|
+
default:
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function signalGroup(child, signal) {
|
|
138
|
+
if (child.pid === undefined)
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
process.kill(-child.pid, signal);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
try {
|
|
145
|
+
child.kill(signal);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// already gone
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Run the scan. Resolves to the process exit code. */
|
|
153
|
+
export async function runImportScan(opts, io) {
|
|
154
|
+
const labels = new Map();
|
|
155
|
+
const write = (event) => {
|
|
156
|
+
if (event.type === "start")
|
|
157
|
+
for (const s of event.sources)
|
|
158
|
+
labels.set(s.id, s.label);
|
|
159
|
+
if (opts.mode === "stream") {
|
|
160
|
+
io.stdout.write(`${JSON.stringify(event)}\n`);
|
|
161
|
+
}
|
|
162
|
+
else if (opts.mode === "human") {
|
|
163
|
+
const line = humanLine(event, labels);
|
|
164
|
+
if (line)
|
|
165
|
+
io.stdout.write(`${line}\n`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const finish = (done, error, code = 1) => {
|
|
169
|
+
if (error)
|
|
170
|
+
write(cliError(error.message, error.code));
|
|
171
|
+
write(done);
|
|
172
|
+
if (opts.mode === "final")
|
|
173
|
+
io.stdout.write(`${JSON.stringify(done)}\n`);
|
|
174
|
+
return error ? code : 0;
|
|
175
|
+
};
|
|
176
|
+
let hqRoot;
|
|
177
|
+
try {
|
|
178
|
+
hqRoot = resolveScanHqRoot({ hqRoot: opts.hqRoot, home: opts.home });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return finish(zeroDone(), failed(err instanceof HqRootError ? err.message : "Could not find your HQ folder."));
|
|
182
|
+
}
|
|
183
|
+
const scanner = path.join(hqRoot, SCANNER_RELATIVE_PATH);
|
|
184
|
+
if (!fs.existsSync(scanner)) {
|
|
185
|
+
return finish(zeroDone(), outdated("The import-context scanner is not installed in this HQ. Update HQ and try again."));
|
|
186
|
+
}
|
|
187
|
+
const output = path.resolve(opts.output ??
|
|
188
|
+
path.join(hqRoot, "workspace", "imports", scanId((opts.now ?? (() => new Date()))()), "report.json"));
|
|
189
|
+
const args = [scanner, `--hq-root=${hqRoot}`, `--output=${output}`, "--progress-json"];
|
|
190
|
+
for (const scope of opts.scopes ?? [])
|
|
191
|
+
args.push(`--scope=${scope}`);
|
|
192
|
+
if (opts.claudeExport)
|
|
193
|
+
args.push(`--claude-export=${opts.claudeExport}`);
|
|
194
|
+
if (opts.noDefaultScopes)
|
|
195
|
+
args.push("--no-default-scopes");
|
|
196
|
+
const child = spawn(opts.bash ?? "bash", args, {
|
|
197
|
+
cwd: hqRoot,
|
|
198
|
+
env: opts.env ?? process.env,
|
|
199
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
200
|
+
detached: true,
|
|
201
|
+
});
|
|
202
|
+
// Signal forwarding to the whole scanner process group, with escalation.
|
|
203
|
+
let cancelledBy;
|
|
204
|
+
let killTimer;
|
|
205
|
+
const onSignal = (signal) => {
|
|
206
|
+
if (!cancelledBy)
|
|
207
|
+
cancelledBy = signal;
|
|
208
|
+
signalGroup(child, signal);
|
|
209
|
+
if (!killTimer) {
|
|
210
|
+
killTimer = setTimeout(() => signalGroup(child, "SIGKILL"), opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS);
|
|
211
|
+
killTimer.unref();
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const onInt = () => onSignal("SIGINT");
|
|
215
|
+
const onTerm = () => onSignal("SIGTERM");
|
|
216
|
+
process.on("SIGINT", onInt);
|
|
217
|
+
process.on("SIGTERM", onTerm);
|
|
218
|
+
opts.abortSignal?.addEventListener("abort", onTerm, { once: true });
|
|
219
|
+
// Scanner stderr: redacted, capped.
|
|
220
|
+
let stderrTail = "";
|
|
221
|
+
let stderrShown = 0;
|
|
222
|
+
let stderrHidden = 0;
|
|
223
|
+
const errLines = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
224
|
+
errLines.on("line", (line) => {
|
|
225
|
+
stderrTail = (stderrTail + line + "\n").slice(-4096);
|
|
226
|
+
if (!line.trim())
|
|
227
|
+
return;
|
|
228
|
+
if (stderrShown < MAX_STDERR_LINES) {
|
|
229
|
+
stderrShown += 1;
|
|
230
|
+
io.stderr.write(`hq import scan: scanner: ${redactScannerLine(line)}\n`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
stderrHidden += 1;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
let lineNo = 0;
|
|
237
|
+
let dropped = 0;
|
|
238
|
+
let heldDone;
|
|
239
|
+
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
240
|
+
const consumed = (async () => {
|
|
241
|
+
for await (const raw of lines) {
|
|
242
|
+
lineNo += 1;
|
|
243
|
+
const line = raw.trim();
|
|
244
|
+
if (!line)
|
|
245
|
+
continue;
|
|
246
|
+
if (heldDone) {
|
|
247
|
+
dropped += 1;
|
|
248
|
+
io.stderr.write(`hq import scan: dropped scan event on line ${lineNo}: received after done\n`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const parsed = parseScanEventLine(line);
|
|
252
|
+
if (!parsed.ok) {
|
|
253
|
+
dropped += 1;
|
|
254
|
+
io.stderr.write(`hq import scan: dropped scan event on line ${lineNo}: ${parsed.reason}\n`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (parsed.event.type === "done")
|
|
258
|
+
heldDone = parsed.event;
|
|
259
|
+
else
|
|
260
|
+
write(parsed.event);
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
const exit = await new Promise((resolve) => {
|
|
264
|
+
child.once("error", (error) => resolve({ code: null, signal: null, error }));
|
|
265
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
266
|
+
});
|
|
267
|
+
// The scanner is gone; stop anything it left behind so stdout can close.
|
|
268
|
+
signalGroup(child, "SIGTERM");
|
|
269
|
+
const drained = await Promise.race([
|
|
270
|
+
consumed.then(() => true),
|
|
271
|
+
new Promise((r) => setTimeout(() => r(false), DRAIN_TIMEOUT_MS).unref()),
|
|
272
|
+
]);
|
|
273
|
+
if (!drained) {
|
|
274
|
+
signalGroup(child, "SIGKILL");
|
|
275
|
+
child.stdout?.destroy();
|
|
276
|
+
child.stderr?.destroy();
|
|
277
|
+
lines.close();
|
|
278
|
+
}
|
|
279
|
+
errLines.close();
|
|
280
|
+
if (killTimer)
|
|
281
|
+
clearTimeout(killTimer);
|
|
282
|
+
process.removeListener("SIGINT", onInt);
|
|
283
|
+
process.removeListener("SIGTERM", onTerm);
|
|
284
|
+
opts.abortSignal?.removeEventListener("abort", onTerm);
|
|
285
|
+
if (stderrHidden > 0) {
|
|
286
|
+
io.stderr.write(`hq import scan: ${stderrHidden} more scanner diagnostic line(s) not shown\n`);
|
|
287
|
+
}
|
|
288
|
+
if (dropped > 0 && opts.mode !== "stream") {
|
|
289
|
+
io.stderr.write(`hq import scan: ${dropped} invalid scan event(s) were dropped\n`);
|
|
290
|
+
}
|
|
291
|
+
if (exit.error) {
|
|
292
|
+
return finish(zeroDone(), failed("The scanner could not be started."));
|
|
293
|
+
}
|
|
294
|
+
if (cancelledBy) {
|
|
295
|
+
return finish(heldDone ?? zeroDone(), failed("The scan was cancelled."), cancelledBy === "SIGINT" ? 130 : 143);
|
|
296
|
+
}
|
|
297
|
+
if (!heldDone) {
|
|
298
|
+
if (exit.code === 2 && /unknown arg: --progress-json/.test(stderrTail)) {
|
|
299
|
+
return finish(zeroDone(), outdated("The installed import-context scanner does not support streaming. Update HQ and try again."));
|
|
300
|
+
}
|
|
301
|
+
return finish(zeroDone(), failed(`The scan stopped before it finished (scanner exit ${exit.code ?? exit.signal ?? "unknown"}).`));
|
|
302
|
+
}
|
|
303
|
+
if (exit.code !== 0) {
|
|
304
|
+
return finish(heldDone, failed(`The scanner reported a problem after finishing (exit ${exit.code ?? exit.signal ?? "unknown"}).`));
|
|
305
|
+
}
|
|
306
|
+
return finish(heldDone);
|
|
307
|
+
}
|
|
308
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://hq.computer/schemas/import-scan/scan-event.v1.schema.json",
|
|
4
|
+
"title": "HQ import scan event (v1)",
|
|
5
|
+
"description": "One line of `hq import scan --json --stream` / `scan.sh --progress-json`. Each type is also addressable as #/$defs/<type>. Unknown extra fields are allowed within v1; hq-cli forwards only the fields listed here.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["v", "type"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"v": { "const": 1 },
|
|
10
|
+
"type": { "enum": ["start", "source", "count", "company", "project", "error", "done"] }
|
|
11
|
+
},
|
|
12
|
+
"oneOf": [
|
|
13
|
+
{ "$ref": "#/$defs/start" },
|
|
14
|
+
{ "$ref": "#/$defs/source" },
|
|
15
|
+
{ "$ref": "#/$defs/nonNegative" },
|
|
16
|
+
{ "$ref": "#/$defs/company" },
|
|
17
|
+
{ "$ref": "#/$defs/project" },
|
|
18
|
+
{ "$ref": "#/$defs/error" },
|
|
19
|
+
{ "$ref": "#/$defs/done" }
|
|
20
|
+
],
|
|
21
|
+
"$defs": {
|
|
22
|
+
"name": { "type": "string", "minLength": 1, "maxLength": 512 },
|
|
23
|
+
"nonNegative": { "type": "integer", "minimum": 0 },
|
|
24
|
+
"baseName": {
|
|
25
|
+
"description": "A folder or repository basename: a name without \"/\".",
|
|
26
|
+
"allOf": [{ "$ref": "#/$defs/name" }, { "pattern": "^[^/]+$" }]
|
|
27
|
+
},
|
|
28
|
+
"start": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"required": ["v", "type", "sources"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"v": { "const": 1 },
|
|
33
|
+
"type": { "const": "start" },
|
|
34
|
+
"sources": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"required": ["id", "label"],
|
|
39
|
+
"properties": {
|
|
40
|
+
"id": { "$ref": "#/$defs/name" },
|
|
41
|
+
"label": { "$ref": "#/$defs/name" }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"source": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": ["v", "type", "id", "status"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"v": { "const": 1 },
|
|
52
|
+
"type": { "const": "source" },
|
|
53
|
+
"id": { "$ref": "#/$defs/name" },
|
|
54
|
+
"status": { "enum": ["scanning", "done", "skipped", "error"] },
|
|
55
|
+
"counts": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"propertyNames": { "pattern": "^[a-z0-9_-]{1,64}$" },
|
|
58
|
+
"additionalProperties": { "$ref": "#/$defs/nonNegative" }
|
|
59
|
+
},
|
|
60
|
+
"message": { "type": "string", "maxLength": 2000 }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"count": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"required": ["v", "type", "source", "key", "value"],
|
|
66
|
+
"properties": {
|
|
67
|
+
"v": { "const": 1 },
|
|
68
|
+
"type": { "const": "count" },
|
|
69
|
+
"source": { "$ref": "#/$defs/name" },
|
|
70
|
+
"key": { "type": "string", "pattern": "^[a-z0-9_-]{1,64}$" },
|
|
71
|
+
"value": { "$ref": "#/$defs/nonNegative" }
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"company": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"required": ["v", "type", "id", "name", "basis"],
|
|
77
|
+
"properties": {
|
|
78
|
+
"v": { "const": 1 },
|
|
79
|
+
"type": { "const": "company" },
|
|
80
|
+
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$", "maxLength": 128 },
|
|
81
|
+
"name": { "$ref": "#/$defs/name" },
|
|
82
|
+
"basis": { "enum": ["hq-company", "repo-org", "folder"] }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"project": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"required": ["v", "type", "id", "name", "company", "basis"],
|
|
88
|
+
"properties": {
|
|
89
|
+
"v": { "const": 1 },
|
|
90
|
+
"type": { "const": "project" },
|
|
91
|
+
"id": { "type": "string", "pattern": "^p_[0-9a-f]{12}$" },
|
|
92
|
+
"name": { "$ref": "#/$defs/baseName" },
|
|
93
|
+
"company": { "type": ["string", "null"] },
|
|
94
|
+
"basis": { "enum": ["repo", "claude-code-cwd", "codex-cwd"] }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"error": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"required": ["v", "type", "source", "message"],
|
|
100
|
+
"properties": {
|
|
101
|
+
"v": { "const": 1 },
|
|
102
|
+
"type": { "const": "error" },
|
|
103
|
+
"source": { "$ref": "#/$defs/name" },
|
|
104
|
+
"message": { "type": "string", "minLength": 1, "maxLength": 2000 },
|
|
105
|
+
"code": { "enum": ["scanner_outdated", "scan_failed"] }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"done": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"required": ["v", "type", "report", "summary"],
|
|
111
|
+
"properties": {
|
|
112
|
+
"v": { "const": 1 },
|
|
113
|
+
"type": { "const": "done" },
|
|
114
|
+
"report": { "type": ["string", "null"] },
|
|
115
|
+
"summary": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"required": ["companies", "projects", "sessions"],
|
|
118
|
+
"properties": {
|
|
119
|
+
"companies": { "$ref": "#/$defs/nonNegative" },
|
|
120
|
+
"projects": { "$ref": "#/$defs/nonNegative" },
|
|
121
|
+
"sessions": { "$ref": "#/$defs/nonNegative" }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates one line of the import-scan event stream against
|
|
3
|
+
* `scan-event.schema.json` (Ajv, draft 2020-12) and rebuilds the event from
|
|
4
|
+
* the v1 fields only.
|
|
5
|
+
*
|
|
6
|
+
* Validation dispatches on `type`: the common envelope is checked first, then
|
|
7
|
+
* the line is validated against `#/$defs/<type>` alone, so a drop reason names
|
|
8
|
+
* the real problem instead of the first failing `oneOf` branch.
|
|
9
|
+
*
|
|
10
|
+
* Failure reasons name the type, the schema path and Ajv's generic message
|
|
11
|
+
* only. They never echo values from the line, so a malformed event that
|
|
12
|
+
* happens to carry content cannot leak through the warning hq-cli prints.
|
|
13
|
+
*/
|
|
14
|
+
import { type ScanEvent } from "./events.js";
|
|
15
|
+
export declare const SCAN_EVENT_SCHEMA_PATH: string;
|
|
16
|
+
export type ParsedScanLine = {
|
|
17
|
+
ok: true;
|
|
18
|
+
event: ScanEvent;
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
reason: string;
|
|
22
|
+
};
|
|
23
|
+
/** Parse and validate one JSON Lines record. */
|
|
24
|
+
export declare function parseScanEventLine(line: string): ParsedScanLine;
|
|
25
|
+
export declare function validateScanEvent(value: unknown): ParsedScanLine;
|
|
26
|
+
/**
|
|
27
|
+
* Rebuild an already-validated event from the v1 fields only, so fields a
|
|
28
|
+
* scanner adds (or smuggles) never reach stdout.
|
|
29
|
+
*/
|
|
30
|
+
export declare function pickScanEvent(event: ScanEvent): ScanEvent;
|
|
31
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates one line of the import-scan event stream against
|
|
3
|
+
* `scan-event.schema.json` (Ajv, draft 2020-12) and rebuilds the event from
|
|
4
|
+
* the v1 fields only.
|
|
5
|
+
*
|
|
6
|
+
* Validation dispatches on `type`: the common envelope is checked first, then
|
|
7
|
+
* the line is validated against `#/$defs/<type>` alone, so a drop reason names
|
|
8
|
+
* the real problem instead of the first failing `oneOf` branch.
|
|
9
|
+
*
|
|
10
|
+
* Failure reasons name the type, the schema path and Ajv's generic message
|
|
11
|
+
* only. They never echo values from the line, so a malformed event that
|
|
12
|
+
* happens to carry content cannot leak through the warning hq-cli prints.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
18
|
+
import { SCAN_EVENT_TYPES, SCAN_EVENT_VERSION } from "./events.js";
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
export const SCAN_EVENT_SCHEMA_PATH = join(HERE, "scan-event.schema.json");
|
|
21
|
+
let validators;
|
|
22
|
+
function getValidators() {
|
|
23
|
+
if (validators)
|
|
24
|
+
return validators;
|
|
25
|
+
const schema = JSON.parse(readFileSync(SCAN_EVENT_SCHEMA_PATH, "utf8"));
|
|
26
|
+
const ajv = new Ajv2020({ allErrors: false, strict: false, validateFormats: false });
|
|
27
|
+
ajv.addSchema(schema);
|
|
28
|
+
const map = new Map();
|
|
29
|
+
for (const type of SCAN_EVENT_TYPES) {
|
|
30
|
+
const fn = ajv.getSchema(`${schema.$id}#/$defs/${type}`);
|
|
31
|
+
if (!fn)
|
|
32
|
+
throw new Error(`scan-event schema has no $defs/${type}`);
|
|
33
|
+
map.set(type, fn);
|
|
34
|
+
}
|
|
35
|
+
validators = map;
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
/** Parse and validate one JSON Lines record. */
|
|
39
|
+
export function parseScanEventLine(line) {
|
|
40
|
+
let value;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(line);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { ok: false, reason: "not valid JSON" };
|
|
46
|
+
}
|
|
47
|
+
return validateScanEvent(value);
|
|
48
|
+
}
|
|
49
|
+
function isRecord(value) {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
export function validateScanEvent(value) {
|
|
53
|
+
if (!isRecord(value))
|
|
54
|
+
return { ok: false, reason: "not a JSON object" };
|
|
55
|
+
if (value.v !== SCAN_EVENT_VERSION)
|
|
56
|
+
return { ok: false, reason: "missing or unsupported version (v)" };
|
|
57
|
+
const type = value.type;
|
|
58
|
+
if (typeof type !== "string" || !SCAN_EVENT_TYPES.includes(type)) {
|
|
59
|
+
return { ok: false, reason: "missing or unknown event type" };
|
|
60
|
+
}
|
|
61
|
+
const validate = getValidators().get(type);
|
|
62
|
+
if (!validate(value)) {
|
|
63
|
+
const first = validate.errors?.[0];
|
|
64
|
+
const where = first ? `${first.instancePath || "/"} ${first.message ?? first.keyword}` : "schema";
|
|
65
|
+
return { ok: false, reason: `event "${type}" does not match the v1 schema (${where})` };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, event: pickScanEvent(value) };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Rebuild an already-validated event from the v1 fields only, so fields a
|
|
71
|
+
* scanner adds (or smuggles) never reach stdout.
|
|
72
|
+
*/
|
|
73
|
+
export function pickScanEvent(event) {
|
|
74
|
+
const v = SCAN_EVENT_VERSION;
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
case "start":
|
|
77
|
+
return { v, type: "start", sources: event.sources.map((s) => ({ id: s.id, label: s.label })) };
|
|
78
|
+
case "source": {
|
|
79
|
+
const out = { v, type: "source", id: event.id, status: event.status };
|
|
80
|
+
if (event.counts)
|
|
81
|
+
out.counts = Object.fromEntries(Object.entries(event.counts));
|
|
82
|
+
if (event.message !== undefined)
|
|
83
|
+
out.message = event.message;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
case "count":
|
|
87
|
+
return { v, type: "count", source: event.source, key: event.key, value: event.value };
|
|
88
|
+
case "company":
|
|
89
|
+
return { v, type: "company", id: event.id, name: event.name, basis: event.basis };
|
|
90
|
+
case "project":
|
|
91
|
+
return { v, type: "project", id: event.id, name: event.name, company: event.company, basis: event.basis };
|
|
92
|
+
case "error": {
|
|
93
|
+
const out = { v, type: "error", source: event.source, message: event.message };
|
|
94
|
+
if (event.code !== undefined)
|
|
95
|
+
out.code = event.code;
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
case "done":
|
|
99
|
+
return {
|
|
100
|
+
v,
|
|
101
|
+
type: "done",
|
|
102
|
+
report: event.report,
|
|
103
|
+
summary: {
|
|
104
|
+
companies: event.summary.companies,
|
|
105
|
+
projects: event.summary.projects,
|
|
106
|
+
sessions: event.summary.sessions,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -53,12 +53,14 @@ export interface DrainDeps {
|
|
|
53
53
|
}
|
|
54
54
|
export interface LaneDrainHookInput {
|
|
55
55
|
transcript_path?: string | null;
|
|
56
|
-
session_id?: string;
|
|
57
56
|
hook_event_name?: string;
|
|
57
|
+
hookEventName?: string;
|
|
58
|
+
session_id?: string;
|
|
58
59
|
}
|
|
59
60
|
export interface RunDrainHookDeps {
|
|
60
61
|
readLane?: typeof tryReadLane;
|
|
61
62
|
probeCompactionsSince?: typeof import("./compaction.js").probeProviderCompactionsSince;
|
|
63
|
+
readLifecycleDeliveryFlag?: () => Promise<boolean>;
|
|
62
64
|
}
|
|
63
65
|
export declare function mintClaimId(): string;
|
|
64
66
|
export declare function assertSafeLaneId(laneId: string): void;
|
|
@@ -102,7 +104,7 @@ export declare function formatOperatorInstruction(messages: DropboxMessage[]): s
|
|
|
102
104
|
export declare function formatMonitorEvent(message: DropboxMessage): string;
|
|
103
105
|
/** Keep event order while framing monitor output separately from operator messages. */
|
|
104
106
|
export declare function formatDropboxMessages(messages: DropboxMessage[]): string;
|
|
105
|
-
export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "UserPromptSubmit", leadingNote?: string): string;
|
|
107
|
+
export declare function hookAdditionalContextJson(messages: DropboxMessage[], hookEventName?: "PreToolUse" | "PostToolUse" | "UserPromptSubmit", leadingNote?: string): string;
|
|
106
108
|
export interface DrainHookResult {
|
|
107
109
|
exitCode: number;
|
|
108
110
|
stdout: string;
|