@indigoai-us/hq-cli 5.86.0 → 5.88.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 (32) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/assets/scaffold/core/scripts/lint-shared-worker-skills.sh +143 -0
  3. package/assets/scaffold/core/scripts/share-worker-skill.sh +178 -0
  4. package/dist/commands/core.d.ts +29 -3
  5. package/dist/commands/core.js +121 -24
  6. package/dist/commands/index-cmd.d.ts +4 -0
  7. package/dist/commands/index-cmd.js +34 -0
  8. package/dist/lib/index-render/companies.d.ts +3 -0
  9. package/dist/lib/index-render/companies.js +57 -0
  10. package/dist/lib/index-render/company-knowledge.d.ts +3 -0
  11. package/dist/lib/index-render/company-knowledge.js +85 -0
  12. package/dist/lib/index-render/index.d.ts +5 -0
  13. package/dist/lib/index-render/index.js +43 -0
  14. package/dist/lib/index-render/orchestrator.d.ts +3 -0
  15. package/dist/lib/index-render/orchestrator.js +52 -0
  16. package/dist/lib/index-render/projects.d.ts +3 -0
  17. package/dist/lib/index-render/projects.js +33 -0
  18. package/dist/lib/index-render/public-knowledge.d.ts +3 -0
  19. package/dist/lib/index-render/public-knowledge.js +37 -0
  20. package/dist/lib/index-render/reports.d.ts +3 -0
  21. package/dist/lib/index-render/reports.js +37 -0
  22. package/dist/lib/index-render/shared.d.ts +35 -0
  23. package/dist/lib/index-render/shared.js +126 -0
  24. package/dist/lib/index-render/social-drafts.d.ts +3 -0
  25. package/dist/lib/index-render/social-drafts.js +53 -0
  26. package/dist/lib/index-render/threads.d.ts +3 -0
  27. package/dist/lib/index-render/threads.js +42 -0
  28. package/dist/lib/index-render/workers.d.ts +3 -0
  29. package/dist/lib/index-render/workers.js +49 -0
  30. package/dist/lib/search-index/background.d.ts +39 -0
  31. package/dist/lib/search-index/background.js +382 -0
  32. package/package.json +1 -1
@@ -0,0 +1,53 @@
1
+ import * as fs from "fs";
2
+ import { basename, countImmediate, date, exists, heading, immediateEntries, isHidden, log, mtime, sanitize, truncate, write } from "./shared.js";
3
+ function directoryDescription(context, name) {
4
+ const base = `workspace/social-drafts/${name}`;
5
+ const files = countImmediate(context.root, base, (child, file) => !isHidden(child) && fs.statSync(file).isFile());
6
+ const dirs = countImmediate(context.root, base, (child, file) => !isHidden(child) && fs.statSync(file).isDirectory());
7
+ if (name === "x")
8
+ return `X/Twitter post drafts (${files} files)`;
9
+ if (name === "linkedin")
10
+ return `LinkedIn post drafts (${files} files)`;
11
+ if (name === "blog")
12
+ return `Blog post drafts (${files} files)`;
13
+ if (name === "tiktok")
14
+ return `TikTok scripts/drafts (${files} files)`;
15
+ if (name === "images")
16
+ return `Infographics and post images (${dirs} dirs)`;
17
+ return dirs > 0 ? `${dirs} subdir(s), ${files} file(s)` : `${files} file(s)`;
18
+ }
19
+ function fileDescription(file) { const name = basename(file); if (name.endsWith(".md"))
20
+ return heading(file) || name.slice(0, -3); if (name.endsWith(".json"))
21
+ return `${name.slice(0, -5)} data`; if (/\.(py|sh)$/.test(name))
22
+ return `Script: ${name}`; return name; }
23
+ export function renderSocialDrafts(context) {
24
+ const base = "workspace/social-drafts";
25
+ if (!exists(context.root, base)) {
26
+ log(context, "rebuild-social-drafts-index: workspace/social-drafts/ missing, skipping");
27
+ return { written: [] };
28
+ }
29
+ const recent = ["x", "linkedin", "blog", "tiktok"].flatMap((channel) => immediateEntries(context.root, `${base}/${channel}`, "file").filter((file) => file.endsWith(".md"))).sort((a, b) => mtime(b) - mtime(a)).slice(0, 20);
30
+ const lines = ["# Social Drafts", "", `> Auto-generated. Updated: ${date(context.now)}`, "", "## Recent Drafts", ""];
31
+ for (const file of recent) {
32
+ const relative = file.slice(`${context.root}/workspace/social-drafts/`.length);
33
+ lines.push(`- \`${relative}\` - ${truncate(sanitize(heading(file) || basename(file).slice(0, -3)), 100)}`);
34
+ }
35
+ lines.push("", "## Directories", "", "| Name | Description |", "|------|-------------|");
36
+ for (const directory of immediateEntries(context.root, base, "dir")) {
37
+ const name = basename(directory);
38
+ if (!isHidden(name))
39
+ lines.push(`| \`${name}/\` | ${truncate(sanitize(directoryDescription(context, name)), 100)} |`);
40
+ }
41
+ for (const file of immediateEntries(context.root, base, "file")) {
42
+ const name = basename(file);
43
+ if (name !== "INDEX.md" && !isHidden(name))
44
+ lines.push(`| \`${name}\` | ${truncate(sanitize(fileDescription(file)), 100)} |`);
45
+ }
46
+ lines.push("");
47
+ const output = `${base}/INDEX.md`;
48
+ write(context, output, lines.join("\n"));
49
+ const draftCount = ["x", "linkedin", "blog", "tiktok"].reduce((sum, channel) => sum + immediateEntries(context.root, `${base}/${channel}`, "file").filter((file) => file.endsWith(".md")).length, 0);
50
+ log(context, `rebuild-social-drafts-index: wrote ${output} (${draftCount} draft file(s))`);
51
+ return { written: [output] };
52
+ }
53
+ //# sourceMappingURL=social-drafts.js.map
@@ -0,0 +1,3 @@
1
+ import { RenderContext, RenderResult } from "./shared.js";
2
+ export declare function renderThreads(context: RenderContext, args?: string[]): RenderResult;
3
+ //# sourceMappingURL=threads.d.ts.map
@@ -0,0 +1,42 @@
1
+ import * as fs from "fs";
2
+ import { at, log, readJson, sanitize, timestamp, write } from "./shared.js";
3
+ export function renderThreads(context, args = []) {
4
+ const mode = args[0] ?? "--index";
5
+ const directory = at(context.root, "workspace/threads");
6
+ fs.mkdirSync(directory, { recursive: true });
7
+ // Match `ls -t`: newest filesystem modification time first. `updated_at` is
8
+ // rendered metadata only, and must not influence the order.
9
+ const files = fs.readdirSync(directory)
10
+ .filter((name) => /^T-.*\.json$/.test(name) && !name.endsWith(".changeset.json"))
11
+ .map((name) => `${directory}/${name}`)
12
+ .sort((a, b) => {
13
+ const aTime = fs.statSync(a, { bigint: true }).mtimeNs;
14
+ const bTime = fs.statSync(b, { bigint: true }).mtimeNs;
15
+ return bTime > aTime ? 1 : bTime < aTime ? -1 : 0;
16
+ });
17
+ const rows = files.flatMap((file) => {
18
+ const data = readJson(file);
19
+ if (!data)
20
+ return [];
21
+ const metadata = data.metadata && typeof data.metadata === "object" ? data.metadata : {};
22
+ return [`| ${typeof data.thread_id === "string" ? data.thread_id : "-"} | ${typeof data.type === "string" ? data.type : "-"} | ${typeof data.updated_at === "string" ? data.updated_at : "-"} | ${sanitize(typeof metadata.title === "string" ? metadata.title.replace(/\|/g, "/") : "-")} |`];
23
+ });
24
+ const written = [];
25
+ const table = (title, limited) => [
26
+ `# ${title}`, "", `Generated: ${timestamp(context.now)}`, `Active threads: ${files.length} (archive/ excluded)`, "", "| Thread | Type | Updated | Title |", "|--------|------|---------|-------|", ...(limited ? rows.slice(0, limited) : rows), "",
27
+ ].join("\n");
28
+ if (mode === "--index" || mode === "--both") {
29
+ const output = "workspace/threads/INDEX.md";
30
+ write(context, output, table("Threads INDEX", 0));
31
+ written.push(output);
32
+ log(context, `rebuild-threads-index: wrote ${output} (${files.length} threads)`);
33
+ }
34
+ if (mode === "--recent" || mode === "--both") {
35
+ const output = "workspace/threads/recent.md";
36
+ write(context, output, table("Recent Threads", 15));
37
+ written.push(output);
38
+ log(context, `rebuild-threads-index: wrote ${output} (last 15)`);
39
+ }
40
+ return { written };
41
+ }
42
+ //# sourceMappingURL=threads.js.map
@@ -0,0 +1,3 @@
1
+ import { RenderContext, RenderResult } from "./shared.js";
2
+ export declare function renderWorkers(context: RenderContext): RenderResult;
3
+ //# sourceMappingURL=workers.d.ts.map
@@ -0,0 +1,49 @@
1
+ import * as yaml from "js-yaml";
2
+ import { basename, date, exists, immediateEntries, isHidden, log, readText, sanitize, truncate, write } from "./shared.js";
3
+ function workerDescription(file) {
4
+ const text = readText(file);
5
+ if (!text)
6
+ return "";
7
+ try {
8
+ const parsed = yaml.load(text);
9
+ const value = parsed?.worker?.description ?? parsed?.worker?.bio?.role;
10
+ return typeof value === "string" ? value : "";
11
+ }
12
+ catch {
13
+ return text.match(/^\s+description:\s*(.*)$/m)?.[1]?.replace(/^["']|["']$/g, "") ?? "";
14
+ }
15
+ }
16
+ function subindex(context, directory, label) {
17
+ if (!exists(context.root, directory))
18
+ return undefined;
19
+ const lines = [`# ${label}`, "", `> Auto-generated. Updated: ${date(context.now)}`, "", "| Name | Description |", "|------|-------------|"];
20
+ for (const entry of immediateEntries(context.root, directory, "dir")) {
21
+ const name = basename(entry);
22
+ if (!isHidden(name))
23
+ lines.push(`| \`${name}/\` | ${truncate(sanitize(workerDescription(`${entry}/worker.yaml`)), 100) || "—"} |`);
24
+ }
25
+ lines.push("");
26
+ const output = `${directory}/INDEX.md`;
27
+ write(context, output, lines.join("\n"));
28
+ log(context, `rebuild-workers-index: wrote ${output}`);
29
+ return output;
30
+ }
31
+ function rootIndex(context) {
32
+ const directory = "core/workers";
33
+ if (!exists(context.root, directory))
34
+ return undefined;
35
+ const lines = ["# Workers", "", `> Auto-generated. Updated: ${date(context.now)}`, "", "Shared workers live under `public/`. Private/local workers live under `personal/workers/`. `registry.yaml` is the auto-generated index (regenerated by core/scripts/generate-workers-registry.sh).", "", "| Name | Description |", "|------|-------------|"];
36
+ for (const entry of immediateEntries(context.root, directory, "dir")) {
37
+ const name = basename(entry);
38
+ if (isHidden(name))
39
+ continue;
40
+ lines.push(name === "public" ? "| `public/` | Shared public workers (see public/INDEX.md) |" : `| \`${name}/\` | ${truncate(sanitize(workerDescription(`${entry}/worker.yaml`)), 100) || "—"} |`);
41
+ }
42
+ lines.push("");
43
+ const output = `${directory}/INDEX.md`;
44
+ write(context, output, lines.join("\n"));
45
+ log(context, `rebuild-workers-index: wrote ${output}`);
46
+ return output;
47
+ }
48
+ export function renderWorkers(context) { const written = [subindex(context, "core/workers/public", "Public Workers"), subindex(context, "personal/workers", "Personal Workers"), rootIndex(context)].filter((value) => Boolean(value)); log(context, "rebuild-workers-index: done"); return { written }; }
49
+ //# sourceMappingURL=workers.js.map
@@ -0,0 +1,39 @@
1
+ import { type QmdProcessResult, type RunQmdOptions } from './index.js';
2
+ export type BackgroundResult = {
3
+ state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed';
4
+ } | {
5
+ state: 'launched';
6
+ pid: number;
7
+ };
8
+ export type BackgroundDependencies = {
9
+ env: NodeJS.ProcessEnv;
10
+ hqRoot: string;
11
+ now: () => number;
12
+ pid: number;
13
+ random: () => string;
14
+ isProcessAlive: (pid: number) => boolean;
15
+ resolveQmdBin: () => string;
16
+ reconcileCollections: (hqRoot: string) => unknown;
17
+ runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
18
+ spawnWorker: (options: {
19
+ logPath: string;
20
+ }) => number;
21
+ /** Test seam for simulating a competing owner replacing the atomic record. */
22
+ afterOwnerPublish?: (ownerFile: string) => void;
23
+ };
24
+ export type BackgroundStatus = {
25
+ lock: 'held' | 'stale' | 'free';
26
+ completedAt?: number;
27
+ };
28
+ /** Defaults used by the CLI; tests supply every nondeterministic dependency. */
29
+ export declare function defaultBackgroundDependencies(hqRoot: string): BackgroundDependencies;
30
+ /** Match the shell forwarder's hosted-agent markers before looking up qmd. */
31
+ export declare function isHostedAgent(env?: NodeJS.ProcessEnv): boolean;
32
+ export declare function installWorkerCleanup(cleanup: () => void, processEvents?: Pick<NodeJS.Process, 'once'>, exit?: (code: number) => void): void;
33
+ /** Start a detached worker; this public entry never owns the qmd pipeline. */
34
+ export declare function runBackgroundLauncher(dependencies: BackgroundDependencies): BackgroundResult;
35
+ /** Run the single-flight cleanup → update → embed pipeline in a worker only. */
36
+ export declare function runBackgroundWorker(dependencies: BackgroundDependencies): BackgroundResult;
37
+ /** Report the background lock and latest successful completion for `hq index status`. */
38
+ export declare function backgroundStatus(dependencies: BackgroundDependencies): BackgroundStatus;
39
+ //# sourceMappingURL=background.d.ts.map
@@ -0,0 +1,382 @@
1
+ import { spawn } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
5
+ const LOCK_NAME = 'qmd-reindex-bg.lock';
6
+ const COMPLETE_NAME = 'qmd-reindex-bg.completed';
7
+ function defaultSpawnWorker({ logPath }) {
8
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
9
+ const log = fs.openSync(logPath, 'a');
10
+ const entry = process.argv[1];
11
+ if (!entry)
12
+ throw new Error('Cannot determine hq CLI entrypoint for background worker');
13
+ const child = spawn(process.execPath, [entry, 'index', 'background', '--worker', '--log', logPath], {
14
+ detached: true,
15
+ stdio: ['ignore', log, log],
16
+ });
17
+ fs.closeSync(log);
18
+ child.unref();
19
+ if (!child.pid)
20
+ throw new Error('Unable to start qmd background worker');
21
+ return child.pid;
22
+ }
23
+ function alive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /** Defaults used by the CLI; tests supply every nondeterministic dependency. */
33
+ export function defaultBackgroundDependencies(hqRoot) {
34
+ return {
35
+ env: process.env,
36
+ hqRoot,
37
+ now: () => Math.floor(Date.now() / 1_000),
38
+ pid: process.pid,
39
+ random: () => Math.random().toString(36).slice(2),
40
+ isProcessAlive: alive,
41
+ resolveQmdBin: defaultResolveQmdBin,
42
+ reconcileCollections: defaultReconcileCollections,
43
+ runQmd: defaultRunQmd,
44
+ spawnWorker: defaultSpawnWorker,
45
+ };
46
+ }
47
+ /** Match the shell forwarder's hosted-agent markers before looking up qmd. */
48
+ export function isHostedAgent(env = process.env) {
49
+ if (env.HQ_QMD_REINDEX_MODE === 'skip-agent' || env.HQ_QMD_REINDEX_MODE === 'skip')
50
+ return true;
51
+ if (env.HQ_AGENT_BOX && env.HQ_AGENT_BOX !== '0')
52
+ return true;
53
+ try {
54
+ fs.accessSync('/usr/local/bin/hq-agent-qmd-index', fs.constants.X_OK);
55
+ return true;
56
+ }
57
+ catch { /* probe next marker */ }
58
+ try {
59
+ fs.accessSync('/usr/local/lib/hq-agent/qmd-index-user', fs.constants.X_OK);
60
+ return true;
61
+ }
62
+ catch { /* probe next marker */ }
63
+ return fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.timer')
64
+ || fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.service')
65
+ || fs.existsSync('/var/lib/hq-agent');
66
+ }
67
+ function lockRoot(home) {
68
+ return path.join(home, '.hq', 'locks');
69
+ }
70
+ function lockPath(home) {
71
+ return path.join(lockRoot(home), LOCK_NAME);
72
+ }
73
+ function completionPath(home) {
74
+ return path.join(lockRoot(home), COMPLETE_NAME);
75
+ }
76
+ function parseFields(file) {
77
+ try {
78
+ return Object.fromEntries(fs.readFileSync(file, 'utf8').split('\n').flatMap((line) => {
79
+ const index = line.indexOf('=');
80
+ return index === -1 ? [] : [[line.slice(0, index), line.slice(index + 1)]];
81
+ }));
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ function readOwner(directory) {
88
+ const fields = parseFields(path.join(directory, 'owner'));
89
+ if (!fields || !/^\d+$/.test(fields.pid ?? '') || !/^\d+$/.test(fields.ts ?? '') || !/^[A-Za-z0-9._-]+$/.test(fields.nonce ?? ''))
90
+ return undefined;
91
+ return { pid: Number(fields.pid), ts: Number(fields.ts), nonce: fields.nonce };
92
+ }
93
+ function graceSeconds(dependencies) {
94
+ const value = dependencies.env.QMD_HANDOFF_LOCK_GRACE_SEC;
95
+ return value && /^\d+$/.test(value) ? Number(value) : 5;
96
+ }
97
+ function dedupeSeconds(dependencies) {
98
+ const value = dependencies.env.QMD_HANDOFF_DEDUPE_SEC;
99
+ return value && /^\d+$/.test(value) ? Number(value) : 90;
100
+ }
101
+ function isRecentCompletion(home, dependencies) {
102
+ const dedupe = dedupeSeconds(dependencies);
103
+ if (dedupe === 0)
104
+ return false;
105
+ const fields = parseFields(completionPath(home));
106
+ if (!fields || !/^\d+$/.test(fields.ts ?? ''))
107
+ return false;
108
+ const age = dependencies.now() - Number(fields.ts);
109
+ return age >= 0 && age < dedupe;
110
+ }
111
+ function writeCompletion(home, dependencies) {
112
+ try {
113
+ fs.writeFileSync(completionPath(home), `ts=${dependencies.now()}\npid=${dependencies.pid}\nmode=raw\n`);
114
+ }
115
+ catch {
116
+ // Completion is a best-effort dedupe hint, never a worker failure.
117
+ }
118
+ }
119
+ function isOwnerlessLockWithinGrace(directory, dependencies) {
120
+ try {
121
+ const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
122
+ return age < graceSeconds(dependencies);
123
+ }
124
+ catch {
125
+ return true;
126
+ }
127
+ }
128
+ function lockState(directory, dependencies) {
129
+ if (!fs.existsSync(directory))
130
+ return 'free';
131
+ const owner = readOwner(directory);
132
+ if (owner)
133
+ return dependencies.isProcessAlive(owner.pid) ? 'held' : 'stale';
134
+ return isOwnerlessLockWithinGrace(directory, dependencies) ? 'held' : 'stale';
135
+ }
136
+ function generation(directory) {
137
+ const owner = readOwner(directory);
138
+ if (owner)
139
+ return owner.nonce;
140
+ if (!fs.existsSync(directory))
141
+ return undefined;
142
+ return 'empty';
143
+ }
144
+ function removeDirectoryIfEmpty(directory) {
145
+ try {
146
+ fs.rmdirSync(directory);
147
+ }
148
+ catch { /* another claimant owns it, or it is already gone */ }
149
+ }
150
+ function claimWithinGrace(directory, dependencies) {
151
+ try {
152
+ const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
153
+ return age < Math.max(5, graceSeconds(dependencies));
154
+ }
155
+ catch {
156
+ return true;
157
+ }
158
+ }
159
+ function acquireClaim(home, observedGeneration, dependencies) {
160
+ const claim = path.join(lockRoot(home), `qmd-reindex-bg.claim.${observedGeneration}`);
161
+ for (let attempt = 0; attempt < 2; attempt += 1) {
162
+ try {
163
+ fs.mkdirSync(claim);
164
+ }
165
+ catch {
166
+ if (attempt === 1 || !recoverAbandonedClaim(claim, dependencies))
167
+ return undefined;
168
+ continue;
169
+ }
170
+ const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
171
+ try {
172
+ fs.mkdirSync(claimant);
173
+ fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
174
+ return { claim, claimant };
175
+ }
176
+ catch {
177
+ fs.rmSync(claimant, { recursive: true, force: true });
178
+ removeDirectoryIfEmpty(claim);
179
+ return undefined;
180
+ }
181
+ }
182
+ return undefined;
183
+ }
184
+ /**
185
+ * Reclaim only exact dead claimant names, then remove the claim directory if
186
+ * empty. This mirrors the shell's no-fixed-path-reclaim rule: a peer that has
187
+ * recreated claim.G with a new marker makes rmdir fail and wins the race.
188
+ */
189
+ function recoverAbandonedClaim(claim, dependencies) {
190
+ let entries;
191
+ try {
192
+ entries = fs.readdirSync(claim, { withFileTypes: true });
193
+ }
194
+ catch {
195
+ return true;
196
+ }
197
+ for (const entry of entries.filter((entry) => entry.name.startsWith('c.'))) {
198
+ const marker = path.join(claim, entry.name);
199
+ const fields = parseFields(path.join(marker, 'owner'));
200
+ const pid = fields && /^\d+$/.test(fields.pid ?? '') ? Number(fields.pid) : undefined;
201
+ if (pid !== undefined && dependencies.isProcessAlive(pid))
202
+ return false;
203
+ if (pid === undefined && claimWithinGrace(claim, dependencies))
204
+ return false;
205
+ const abandoned = `${claim}.stale-claim.${dependencies.pid}.${dependencies.random()}`;
206
+ try {
207
+ fs.renameSync(marker, abandoned);
208
+ fs.rmSync(abandoned, { recursive: true, force: true });
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ if (entries.length === 0 && claimWithinGrace(claim, dependencies))
215
+ return false;
216
+ removeDirectoryIfEmpty(claim);
217
+ return !fs.existsSync(claim);
218
+ }
219
+ function createAndPublishLock(home, dependencies) {
220
+ const directory = lockPath(home);
221
+ try {
222
+ fs.mkdirSync(directory);
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ const marker = path.join(directory, `acq.${dependencies.pid}.${dependencies.random()}`);
228
+ try {
229
+ fs.mkdirSync(marker);
230
+ }
231
+ catch {
232
+ removeDirectoryIfEmpty(directory);
233
+ return false;
234
+ }
235
+ const nonce = `${dependencies.pid}.${dependencies.random()}`;
236
+ const ownerFile = path.join(directory, 'owner');
237
+ const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
238
+ try {
239
+ fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
240
+ fs.renameSync(temporary, ownerFile);
241
+ dependencies.afterOwnerPublish?.(ownerFile);
242
+ const verified = readOwner(directory);
243
+ if (!verified || verified.pid !== dependencies.pid || verified.nonce !== nonce)
244
+ throw new Error('owner publish verification failed');
245
+ fs.rmSync(marker, { recursive: true, force: true });
246
+ return true;
247
+ }
248
+ catch {
249
+ // The marker identifies only the directory this process created. Do not
250
+ // recursively remove the fixed lock path after a failed publication.
251
+ fs.rmSync(marker, { recursive: true, force: true });
252
+ removeDirectoryIfEmpty(directory);
253
+ return false;
254
+ }
255
+ }
256
+ function claimAndReclaim(home, dependencies) {
257
+ const directory = lockPath(home);
258
+ const observedGeneration = generation(directory);
259
+ if (!observedGeneration || lockState(directory, dependencies) !== 'stale')
260
+ return false;
261
+ const acquired = acquireClaim(home, observedGeneration, dependencies);
262
+ if (!acquired)
263
+ return false;
264
+ try {
265
+ // Generation fencing: only move exactly the stale generation we observed.
266
+ if (generation(directory) !== observedGeneration || lockState(directory, dependencies) !== 'stale')
267
+ return false;
268
+ const abandoned = `${directory}.stale.${dependencies.pid}.${dependencies.random()}`;
269
+ fs.renameSync(directory, abandoned);
270
+ fs.rmSync(abandoned, { recursive: true, force: true });
271
+ return createAndPublishLock(home, dependencies);
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ finally {
277
+ fs.rmSync(acquired.claimant, { recursive: true, force: true });
278
+ removeDirectoryIfEmpty(acquired.claim);
279
+ }
280
+ }
281
+ function acquireLock(home, dependencies) {
282
+ fs.mkdirSync(lockRoot(home), { recursive: true });
283
+ return createAndPublishLock(home, dependencies) || claimAndReclaim(home, dependencies);
284
+ }
285
+ function releaseLock(home, dependencies) {
286
+ const directory = lockPath(home);
287
+ if (readOwner(directory)?.pid === dependencies.pid)
288
+ fs.rmSync(directory, { recursive: true, force: true });
289
+ }
290
+ export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
291
+ processEvents.once('exit', cleanup);
292
+ // Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
293
+ // catchable cleanup. SIGINT/SIGTERM are registered here and the CLI's normal
294
+ // process exit then runs the same idempotent owner release. Exiting prevents
295
+ // a synchronous pipeline from continuing after it has released ownership.
296
+ processEvents.once('SIGINT', () => { cleanup(); exit(0); });
297
+ processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
298
+ }
299
+ /** Start a detached worker; this public entry never owns the qmd pipeline. */
300
+ export function runBackgroundLauncher(dependencies) {
301
+ if (isHostedAgent(dependencies.env))
302
+ return { state: 'skipped-agent' };
303
+ const home = dependencies.env.HOME;
304
+ if (!home)
305
+ return { state: 'quiet' };
306
+ try {
307
+ dependencies.resolveQmdBin();
308
+ }
309
+ catch {
310
+ return { state: 'skipped' };
311
+ }
312
+ const logPath = dependencies.env.QMD_REINDEX_LOG
313
+ ?? dependencies.env.QMD_HANDOFF_LOG
314
+ ?? path.join(dependencies.env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
315
+ return { state: 'launched', pid: dependencies.spawnWorker({ logPath }) };
316
+ }
317
+ /** Run the single-flight cleanup → update → embed pipeline in a worker only. */
318
+ export function runBackgroundWorker(dependencies) {
319
+ if (isHostedAgent(dependencies.env))
320
+ return { state: 'skipped-agent' };
321
+ const home = dependencies.env.HOME;
322
+ if (!home)
323
+ return { state: 'quiet' };
324
+ try {
325
+ dependencies.resolveQmdBin();
326
+ }
327
+ catch {
328
+ return { state: 'skipped' };
329
+ }
330
+ if (isRecentCompletion(home, dependencies) || !acquireLock(home, dependencies))
331
+ return { state: 'busy' };
332
+ let released = false;
333
+ const cleanup = () => {
334
+ if (released)
335
+ return;
336
+ released = true;
337
+ releaseLock(home, dependencies);
338
+ };
339
+ installWorkerCleanup(cleanup, process, (code) => process.exit(code));
340
+ try {
341
+ if (isRecentCompletion(home, dependencies))
342
+ return { state: 'busy' };
343
+ // Collection reconciliation is the existing #306 seam; cleanup/update/embed
344
+ // retain their shell-script ordering after that policy setup.
345
+ try {
346
+ dependencies.reconcileCollections(dependencies.hqRoot);
347
+ }
348
+ catch {
349
+ // The shell worker has no collection-registration step. Keep this #306
350
+ // integration best-effort so it cannot suppress a later index update.
351
+ }
352
+ try {
353
+ dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot });
354
+ }
355
+ catch { /* cleanup is intentionally best-effort */ }
356
+ try {
357
+ dependencies.runQmd(['update'], { cwd: dependencies.hqRoot });
358
+ }
359
+ catch {
360
+ return { state: 'update-failed' };
361
+ }
362
+ try {
363
+ dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot });
364
+ }
365
+ catch { /* a completed embed attempt still permits the completion stamp */ }
366
+ writeCompletion(home, dependencies);
367
+ return { state: 'completed' };
368
+ }
369
+ finally {
370
+ cleanup();
371
+ }
372
+ }
373
+ /** Report the background lock and latest successful completion for `hq index status`. */
374
+ export function backgroundStatus(dependencies) {
375
+ const home = dependencies.env.HOME;
376
+ if (!home)
377
+ return { lock: 'free' };
378
+ const fields = parseFields(completionPath(home));
379
+ const completedAt = fields && /^\d+$/.test(fields.ts ?? '') ? Number(fields.ts) : undefined;
380
+ return { lock: lockState(lockPath(home), dependencies), ...(completedAt === undefined ? {} : { completedAt }) };
381
+ }
382
+ //# sourceMappingURL=background.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.86.0",
3
+ "version": "5.88.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {