@indigoai-us/hq-cli 5.289.0 → 5.290.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,1240 @@
1
+ /** Native implementations of the handoff scaffold command family. */
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { archiveOldThreads } from "../lib/core-utils/archive-old-threads.js";
8
+ import { serializeJqJson } from "../lib/core-utils/jq-json-serializer.js";
9
+ import { summarizeHqStatus } from "../lib/core-utils/hq-status-summary.js";
10
+ import { enqueueSessionEvent } from "../lib/mesh/live/enqueue.js";
11
+ import { renderIndexTarget } from "../lib/index-render/index.js";
12
+ import { defaultBackgroundDependencies, runBackgroundLauncher, } from "../lib/search-index/background.js";
13
+ import { resolveLiveRoot } from "../utils/hq-roots.js";
14
+ import { runBundledScript } from "../utils/run-bundled-script.js";
15
+ import { describeCoreCommand } from "./core-command-catalog.js";
16
+ import { coreNativeHandoffEnabled } from "./core-native-handoff-flag.js";
17
+ const ASSETS = {
18
+ finalize: "core/scripts/handoff-finalize.sh",
19
+ post: "core/scripts/handoff-post.sh",
20
+ openSteps: "core/scripts/handoff-open-steps.sh",
21
+ };
22
+ const DESCRIPTIONS = {
23
+ finalize: "Write and commit the current session handoff",
24
+ post: "Run best-effort post-handoff maintenance",
25
+ openSteps: "List and update open handoff steps",
26
+ };
27
+ const OPEN_STEPS_UNKNOWN_USAGE = `# hq-core: public
28
+ # handoff-open-steps.sh — list and close next steps across handoff threads
29
+ # (2026-09-07).
30
+ #
31
+ # Handoff threads record next_steps; since 2026-09-07 each carries an \x60id\x60
32
+ # (\x60<thread_id>#<n>\x60) and a \x60status\x60 (open|done|dropped). This script is the
33
+ # closure side: /startwork and /resumework list what is still open instead of
34
+ # re-copying it forward, and a session closes a step when it is actually done.
35
+ #
36
+ # Usage:
37
+ # handoff-open-steps.sh list [--limit N] [--company <slug>] [--json]
38
+ # handoff-open-steps.sh close <step-id> [--as done|dropped] [--note "<why>"]
39
+ # handoff-open-steps.sh reopen <step-id>
40
+ #
41
+ # Threads live in workspace/threads/T-*.json (and archive/**). Closing edits
42
+ # the step in place inside its thread file (only \x60status\x60, \x60closed_at\x60,
43
+ # \x60closed_by\x60, \x60note\x60) and bumps \x60updated_at\x60; nothing else in the thread is
44
+ # touched. Legacy steps without an id are listed with a synthetic id and can be
45
+ # closed the same way (the id is written on first close).
46
+ set -uo pipefail
47
+ HQ_ROOT="\${HQ_ROOT:-\${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "\${BASH_SOURCE[0]}")/../.." && pwd)}}"
48
+ `;
49
+ function nowFor(context) {
50
+ return context.now?.() ?? new Date();
51
+ }
52
+ function stamp(date) {
53
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
54
+ }
55
+ function dayStamp(date) {
56
+ return date.toISOString().slice(0, 10).replaceAll("-", "");
57
+ }
58
+ function timeStamp(date) {
59
+ return date.toISOString().slice(11, 19).replaceAll(":", "");
60
+ }
61
+ function git(args, cwd) {
62
+ const result = spawnSync("git", args, {
63
+ cwd,
64
+ env: process.env,
65
+ encoding: "utf8",
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ });
68
+ if (result.error)
69
+ return { code: 127, stdout: "", stderr: result.error.message };
70
+ return {
71
+ code: result.status ?? (result.signal ? 128 : 1),
72
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
73
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
74
+ };
75
+ }
76
+ function commandAvailable(name) {
77
+ for (const entry of (process.env.PATH ?? "").split(path.delimiter)) {
78
+ const candidate = path.resolve(entry || ".", name);
79
+ try {
80
+ fs.accessSync(candidate, fs.constants.X_OK);
81
+ if (fs.statSync(candidate).isFile())
82
+ return true;
83
+ }
84
+ catch (error) {
85
+ const code = error.code;
86
+ if (code !== "ENOENT" && code !== "ENOTDIR" && code !== "EACCES")
87
+ throw error;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+ function copyClipboardText(value, cwd) {
93
+ if (!value)
94
+ return false;
95
+ const command = [
96
+ { name: "pbcopy", args: [] },
97
+ { name: "wl-copy", args: [] },
98
+ { name: "xclip", args: ["-selection", "clipboard"] },
99
+ ].find((candidate) => commandAvailable(candidate.name));
100
+ if (!command)
101
+ return false;
102
+ try {
103
+ const result = spawnSync(command.name, command.args, {
104
+ cwd,
105
+ env: process.env,
106
+ input: value,
107
+ stdio: ["pipe", "ignore", "ignore"],
108
+ });
109
+ return result.status === 0;
110
+ }
111
+ catch {
112
+ // The shell implementation suppresses clipboard stderr and treats failure as false.
113
+ return false;
114
+ }
115
+ }
116
+ function prettyJson(value) {
117
+ return serializeJqJson(value, "pretty");
118
+ }
119
+ function compactJson(value) {
120
+ return serializeJqJson(value, "compact");
121
+ }
122
+ function isRecord(value) {
123
+ return value !== null && typeof value === "object" && !Array.isArray(value);
124
+ }
125
+ function jqTruthy(value) {
126
+ return value !== undefined && value !== null && value !== false;
127
+ }
128
+ function mkdirParent(file) {
129
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
+ }
131
+ function writeAtomic(file, text, mode) {
132
+ mkdirParent(file);
133
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
134
+ fs.writeFileSync(temporary, text, mode === undefined ? undefined : { mode });
135
+ fs.renameSync(temporary, file);
136
+ }
137
+ function listFiles(root, predicate) {
138
+ const found = [];
139
+ const visit = (directory) => {
140
+ let entries;
141
+ try {
142
+ entries = fs.readdirSync(directory, { withFileTypes: true });
143
+ }
144
+ catch {
145
+ return;
146
+ }
147
+ for (const entry of entries) {
148
+ const file = path.join(directory, entry.name);
149
+ if (entry.isDirectory())
150
+ visit(file);
151
+ else if (entry.isFile() && predicate(file, entry))
152
+ found.push(file);
153
+ }
154
+ };
155
+ visit(root);
156
+ return found;
157
+ }
158
+ function sortThreadFilesNewest(files) {
159
+ return files.sort((left, right) => {
160
+ const leftMtime = Math.floor(fs.statSync(left).mtimeMs / 1000);
161
+ const rightMtime = Math.floor(fs.statSync(right).mtimeMs / 1000);
162
+ if (leftMtime !== rightMtime)
163
+ return rightMtime - leftMtime;
164
+ // GNU sort -rn falls back to reverse lexical order for equal numeric keys.
165
+ return right.localeCompare(left);
166
+ });
167
+ }
168
+ function normalizedSteps(file, value) {
169
+ if (!isRecord(value))
170
+ return undefined;
171
+ const steps = value.next_steps ?? [];
172
+ if (!Array.isArray(steps))
173
+ return undefined;
174
+ const basename = path.basename(file).replace(/\.json$/, "");
175
+ const normalized = [];
176
+ for (let index = 0; index < steps.length; index += 1) {
177
+ const raw = steps[index];
178
+ if (typeof raw !== "string" && !isRecord(raw))
179
+ return undefined;
180
+ const step = typeof raw === "string" ? { step: raw } : { ...raw };
181
+ if (!jqTruthy(step.id))
182
+ step.id = `${basename}#${index + 1}`;
183
+ if (!jqTruthy(step.status))
184
+ step.status = "open";
185
+ delete step.key;
186
+ normalized.push(step);
187
+ }
188
+ return normalized;
189
+ }
190
+ function parseJson(file) {
191
+ return JSON.parse(fs.readFileSync(file, "utf8"));
192
+ }
193
+ function shellDate(date) {
194
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
195
+ }
196
+ export function runHandoffOpenSteps(args, context) {
197
+ const root = context.root;
198
+ const threads = path.join(root, "workspace/threads");
199
+ const argv = [...args];
200
+ const command = argv[0] || "list";
201
+ if (argv.length > 0)
202
+ argv.shift();
203
+ if (command === "list") {
204
+ let limitText = "20";
205
+ let company = "";
206
+ let json = false;
207
+ for (let index = 0; index < argv.length;) {
208
+ const token = argv[index];
209
+ if (token === "--limit") {
210
+ limitText = argv[index + 1] ?? "";
211
+ index += 2;
212
+ }
213
+ else if (token === "--company") {
214
+ company = argv[index + 1] ?? "";
215
+ index += 2;
216
+ }
217
+ else if (token === "--json") {
218
+ json = true;
219
+ index += 1;
220
+ }
221
+ else
222
+ index += 1;
223
+ }
224
+ const numericLimit = /^-?\d+$/.test(limitText) ? Number(limitText) : Number.NaN;
225
+ const candidates = sortThreadFilesNewest(listFiles(threads, (file) => {
226
+ const name = path.basename(file);
227
+ return /^T-.*\.json$/.test(name) && !name.endsWith(".changeset.json") && !file.split(path.sep).includes("resume-locks");
228
+ }));
229
+ let count = 0;
230
+ const rows = [];
231
+ for (const file of candidates) {
232
+ if (!Number.isNaN(numericLimit) && count >= numericLimit)
233
+ break;
234
+ count += 1;
235
+ let thread;
236
+ let nextSteps;
237
+ try {
238
+ thread = parseJson(file);
239
+ nextSteps = normalizedSteps(file, thread);
240
+ }
241
+ catch {
242
+ continue;
243
+ }
244
+ if (!nextSteps || !isRecord(thread))
245
+ continue;
246
+ if (company) {
247
+ const companies = (isRecord(thread.metadata) ? thread.metadata.company : undefined) ?? [];
248
+ const contains = Array.isArray(companies)
249
+ ? companies.includes(company)
250
+ : typeof companies === "string" && companies.includes(company);
251
+ if (!contains)
252
+ continue;
253
+ }
254
+ for (const step of nextSteps) {
255
+ if (step.status !== "open")
256
+ continue;
257
+ rows.push({
258
+ thread: thread.thread_id,
259
+ title: (isRecord(thread.metadata) ? thread.metadata.title : undefined) ?? "",
260
+ created: thread.created_at,
261
+ id: step.id,
262
+ step: step.step,
263
+ });
264
+ }
265
+ }
266
+ if (json)
267
+ return { code: 0, stdout: prettyJson(rows), stderr: "" };
268
+ if (rows.length === 0)
269
+ return { code: 0, stdout: `No open handoff steps in the last ${count} thread(s).\n`, stderr: "" };
270
+ const jqString = (value) => value === null || value === undefined
271
+ ? "null"
272
+ : typeof value === "string" ? value : compactJson(value).trimEnd();
273
+ const output = rows.map((row) => `${jqString(row.id)} ${jqString(row.step)} [${jqString(row.title)}]\n`).join("");
274
+ return { code: 0, stdout: output, stderr: "" };
275
+ }
276
+ if (command === "close" || command === "reopen") {
277
+ const id = argv[0] ?? "";
278
+ const rest = argv.slice(1);
279
+ if (!id)
280
+ return { code: 2, stdout: "", stderr: `usage: ${command} <step-id>\n` };
281
+ let as = "done";
282
+ let note = "";
283
+ for (let index = 0; index < rest.length;) {
284
+ if (rest[index] === "--as") {
285
+ as = rest[index + 1] ?? "";
286
+ index += 2;
287
+ }
288
+ else if (rest[index] === "--note") {
289
+ note = rest[index + 1] ?? "";
290
+ index += 2;
291
+ }
292
+ else
293
+ index += 1;
294
+ }
295
+ if (command === "reopen")
296
+ as = "open";
297
+ if (!new Set(["done", "dropped", "open"]).has(as))
298
+ return { code: 2, stdout: "", stderr: "--as must be done|dropped\n" };
299
+ const threadId = id.split("#", 1)[0] ?? "";
300
+ const candidates = listFiles(threads, (file) => path.basename(file) === `${threadId}.json` && !file.split(path.sep).includes("resume-locks"));
301
+ const file = candidates[0];
302
+ if (!file)
303
+ return { code: 3, stdout: "", stderr: `handoff-open-steps: thread ${threadId} not found under ${threads}\n` };
304
+ const now = shellDate(nowFor(context));
305
+ const by = process.env.HQ_SESSION_ID || process.env.CLAUDE_SESSION_ID || "unknown-session";
306
+ let text;
307
+ try {
308
+ const thread = parseJson(file);
309
+ const steps = normalizedSteps(file, thread);
310
+ if (!steps || !isRecord(thread))
311
+ throw new Error("Cannot iterate over null (null)");
312
+ if (!steps.some((step) => step.id === id))
313
+ throw new Error(`step ${id} not in thread`);
314
+ const updated = steps.map((step) => {
315
+ if (step.id !== id)
316
+ return step;
317
+ if (as === "open") {
318
+ delete step.closed_at;
319
+ delete step.closed_by;
320
+ step.status = "open";
321
+ }
322
+ else {
323
+ step.status = as;
324
+ step.closed_at = now;
325
+ step.closed_by = by;
326
+ if (note !== "")
327
+ step.note = note;
328
+ }
329
+ return step;
330
+ });
331
+ thread.next_steps = updated;
332
+ thread.updated_at = now;
333
+ text = prettyJson(thread);
334
+ }
335
+ catch (error) {
336
+ const message = error instanceof Error ? error.message : String(error);
337
+ const jqMessage = message.startsWith("step ")
338
+ ? `jq: error (at <stdin>:1): ${message}`
339
+ : `jq: error: ${message}`;
340
+ return { code: 3, stdout: "", stderr: `${jqMessage}\n` };
341
+ }
342
+ // jq > file.tmp followed by mv uses the caller's umask for this new inode.
343
+ const temporary = `${file}.tmp`;
344
+ fs.writeFileSync(temporary, text);
345
+ fs.renameSync(temporary, file);
346
+ return { code: 0, stdout: `handoff-open-steps: ${id} -> ${as}\n`, stderr: "" };
347
+ }
348
+ return { code: 2, stdout: OPEN_STEPS_UNKNOWN_USAGE, stderr: "" };
349
+ }
350
+ function isSensitivePath(value) {
351
+ const exceptions = new Set(["core/settings/.gitkeep", "core/settings/pure-ralph.json"]);
352
+ if (exceptions.has(value))
353
+ return false;
354
+ const patterns = [
355
+ ".git", ".git/*", ".env", ".env.*", "*.credentials.json", "*.secret.*",
356
+ ".hq/deploy-passwords.json", ".hq/cognito-tokens.json", ".claude/settings.local.json",
357
+ ".claude/settings.local.*", "companies/*/settings/*", "settings/*.json",
358
+ "settings/**/*.json", "core/settings/*.json", "core/settings/**/*.json",
359
+ ];
360
+ return patterns.some((pattern) => globMatches(value, pattern));
361
+ }
362
+ function globMatches(value, pattern) {
363
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*").replaceAll("?", ".");
364
+ return new RegExp(`^${escaped}$`).test(value);
365
+ }
366
+ function normalizeRelativePath(raw, root) {
367
+ let relative = raw;
368
+ if (raw.startsWith(`${root}/`))
369
+ relative = raw.slice(root.length + 1);
370
+ else if (raw.startsWith("/"))
371
+ return undefined;
372
+ if (relative.startsWith("./"))
373
+ relative = relative.slice(2);
374
+ if (!relative || relative.startsWith("-") || relative === ".." || relative.startsWith("../")
375
+ || relative.includes("/../") || relative === ".git" || relative.startsWith(".git/"))
376
+ return undefined;
377
+ return relative;
378
+ }
379
+ function pathEntries(value) {
380
+ if (!Array.isArray(value))
381
+ return [];
382
+ const entries = [];
383
+ for (const entry of value) {
384
+ if (typeof entry === "string")
385
+ entries.push({ raw: entry, deleted: false });
386
+ else if (isRecord(entry)) {
387
+ const deleted = entry.deleted === true || entry.status === "deleted";
388
+ entries.push({ raw: typeof entry.path === "string" ? entry.path : "", deleted });
389
+ }
390
+ }
391
+ return entries;
392
+ }
393
+ function touchedCompanyPaths(paths) {
394
+ return [...new Set(paths.flatMap((item) => {
395
+ const match = /^companies\/([a-z][a-z0-9_-]*)\//.exec(item);
396
+ return match?.[1] ? [match[1]] : [];
397
+ }))].sort();
398
+ }
399
+ function mirrorThread(root, threadPath) {
400
+ try {
401
+ const thread = JSON.parse(fs.readFileSync(threadPath, "utf8"));
402
+ if (!isRecord(thread) || !isRecord(thread.metadata))
403
+ return;
404
+ const rawCompanies = thread.metadata.company;
405
+ if (!jqTruthy(rawCompanies))
406
+ return;
407
+ const companies = Array.isArray(rawCompanies) ? rawCompanies : [rawCompanies];
408
+ if (typeof thread.thread_id !== "string" || !thread.thread_id)
409
+ return;
410
+ const updatedAt = thread.updated_at ?? thread.created_at ?? "";
411
+ const kind = thread.type ?? "unknown";
412
+ const rawTitle = thread.metadata.title ?? thread.conversation_summary ?? "";
413
+ const title = Buffer.from(typeof rawTitle === "string" ? rawTitle : compactJson(rawTitle)).subarray(0, 200).toString("utf8");
414
+ const rowFor = (company) => compactJson({ thread_id: thread.thread_id, ts: updatedAt, kind, company, title }).trimEnd();
415
+ for (const company of companies) {
416
+ if (company === "")
417
+ continue;
418
+ const companyRoot = path.join(root, "companies", String(company));
419
+ if (!fs.existsSync(companyRoot))
420
+ continue;
421
+ const workspace = path.join(companyRoot, "workspace");
422
+ const sessions = path.join(workspace, "sessions");
423
+ const indexFile = path.join(workspace, "index.jsonl");
424
+ const ignoreFile = path.join(workspace, ".gitignore");
425
+ fs.mkdirSync(sessions, { recursive: true });
426
+ if (!fs.existsSync(ignoreFile)) {
427
+ fs.writeFileSync(ignoreFile, "# HQ workspace mirror — sessions are gitignored, index.jsonl is committed\nsessions/\n");
428
+ }
429
+ const target = path.join(sessions, `${thread.thread_id}.json`);
430
+ try {
431
+ fs.rmSync(target, { force: true });
432
+ fs.linkSync(threadPath, target);
433
+ }
434
+ catch {
435
+ try {
436
+ fs.copyFileSync(threadPath, target);
437
+ }
438
+ catch { /* legacy hook is fail-soft at the caller */ }
439
+ }
440
+ const row = rowFor(company);
441
+ const dedupKey = `"thread_id":"${thread.thread_id}","ts":"${updatedAt}","kind":"${kind}"`;
442
+ if (fs.existsSync(indexFile) && fs.readFileSync(indexFile, "utf8").includes(dedupKey))
443
+ continue;
444
+ fs.appendFileSync(indexFile, `${row}\n`);
445
+ }
446
+ }
447
+ catch {
448
+ // The legacy finalizer ignores every mirror-hook error.
449
+ }
450
+ }
451
+ function statusSummary(root, touchedFile) {
452
+ let output = "";
453
+ try {
454
+ const code = summarizeHqStatus(["--session-files-file", touchedFile, "--json"], {
455
+ cwd: root,
456
+ root,
457
+ stdout: (text) => { output += text; },
458
+ stderr: () => { },
459
+ });
460
+ if (code !== 0 || !output.trim())
461
+ return {};
462
+ return JSON.parse(output);
463
+ }
464
+ catch {
465
+ return {};
466
+ }
467
+ }
468
+ function renderIndex(root, target, args = []) {
469
+ try {
470
+ renderIndexTarget(target, { root, output: () => { }, log: () => { } }, args);
471
+ }
472
+ catch { /* both legacy rebuild calls are fail-soft */ }
473
+ }
474
+ function mainCheckoutRoot(commonDir, root) {
475
+ const absolute = path.isAbsolute(commonDir) || /^[A-Za-z]:\//.test(commonDir)
476
+ ? commonDir
477
+ : path.join(root, commonDir);
478
+ return fs.realpathSync(path.dirname(absolute));
479
+ }
480
+ function finalizeGitProbe(root) {
481
+ const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
482
+ cwd: root,
483
+ env: { ...process.env, LC_ALL: "C" },
484
+ encoding: "utf8",
485
+ stdio: ["ignore", "pipe", "pipe"],
486
+ });
487
+ if (!result.error && result.status === 0)
488
+ return { hasGit: true, commonDir: String(result.stdout ?? "").trim() };
489
+ const output = `${String(result.stdout ?? "")}${String(result.stderr ?? "")}`.trimEnd();
490
+ const checkoutMarker = path.join(root, ".git");
491
+ if (!fs.existsSync(checkoutMarker) && !fs.lstatSync(checkoutMarker, { throwIfNoEntry: false })
492
+ && output.includes("not a git repository"))
493
+ return { hasGit: false, commonDir: "" };
494
+ return {
495
+ code: 4,
496
+ stdout: "",
497
+ stderr: `handoff-finalize: Git checkout probe failed (exit ${result.status ?? 127}): ${output}\n`,
498
+ };
499
+ }
500
+ function printGitStatus(root) {
501
+ return git(["status", "--porcelain"], root).stdout;
502
+ }
503
+ function stagePath(root, pathname) {
504
+ const absolute = path.join(root, pathname);
505
+ const exists = fs.existsSync(absolute) || git(["status", "--porcelain", "--", pathname], root).stdout !== "";
506
+ if (!exists)
507
+ return { attempted: false, ok: false, failed: false };
508
+ if (git(["add", "--", pathname], root).code === 0)
509
+ return { attempted: true, ok: true, failed: false };
510
+ if (git(["ls-files", "--error-unmatch", "--", pathname], root).code === 0) {
511
+ return git(["add", "-f", "--", pathname], root).code === 0
512
+ ? { attempted: true, ok: true, failed: false }
513
+ : { attempted: true, ok: false, failed: true };
514
+ }
515
+ if (git(["check-ignore", "--no-index", "-q", "--", pathname], root).code === 0) {
516
+ return { attempted: true, ok: false, failed: false };
517
+ }
518
+ return { attempted: true, ok: false, failed: true };
519
+ }
520
+ function tidyGitMessage(value) {
521
+ const flattened = value.replace(/[\r\n]/g, " ").replace(/ */g, " ").replace(/^ | $/g, "");
522
+ return Buffer.from(flattened).subarray(0, 500).toString("utf8").replace(/\uFFFD+$/, "");
523
+ }
524
+ function resolveFinalizeOptions(args) {
525
+ const values = {
526
+ title: "Handoff: session continuation", summary: "", message: "", nextSteps: "[]",
527
+ filesTouched: "[]", filesTouchedFile: "", learnings: "[]", tags: '["handoff"]',
528
+ slug: "handoff", nextCommand: "__auto__",
529
+ };
530
+ const map = {
531
+ "--title": "title", "--summary": "summary", "--message": "message",
532
+ "--next-steps-json": "nextSteps", "--files-touched-json": "filesTouched",
533
+ "--files-touched-json-file": "filesTouchedFile", "--learnings-json": "learnings",
534
+ "--tags-json": "tags", "--slug": "slug", "--next-command": "nextCommand",
535
+ };
536
+ for (let index = 0; index < args.length;) {
537
+ const key = args[index] ?? "";
538
+ const target = map[key];
539
+ if (!target)
540
+ return { values, error: { code: 2, stdout: "", stderr: `Unknown arg: ${key}\n` } };
541
+ if (index + 1 >= args.length) {
542
+ const shellAsset = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/scaffold", ASSETS.finalize);
543
+ return { values, error: { code: 1, stdout: "", stderr: `${shellAsset}: line 91: $2: unbound variable\n` } };
544
+ }
545
+ values[target] = args[index + 1] ?? "";
546
+ index += 2;
547
+ }
548
+ return { values };
549
+ }
550
+ export function runHandoffFinalize(args, context) {
551
+ const root = fs.realpathSync(context.root);
552
+ const probe = finalizeGitProbe(root);
553
+ if ("code" in probe)
554
+ return probe;
555
+ let gitBgErrors = "";
556
+ const tmpRoot = context.tmpDir ?? "/tmp";
557
+ const gitPidFile = path.join(tmpRoot, "handoff-git-bg.pid");
558
+ if (fs.existsSync(gitPidFile)) {
559
+ let pid = "";
560
+ try {
561
+ pid = fs.readFileSync(gitPidFile, "utf8").trim();
562
+ }
563
+ catch { /* legacy reads fail-soft */ }
564
+ if (pid) {
565
+ const gitLog = path.join(tmpRoot, "handoff-git-bg.log");
566
+ if (fs.existsSync(gitLog)) {
567
+ try {
568
+ gitBgErrors = fs.readFileSync(gitLog, "utf8").split(/\r?\n/).filter((entry) => entry.startsWith("ERR:")).join("\n");
569
+ }
570
+ catch {
571
+ gitBgErrors = "";
572
+ }
573
+ }
574
+ }
575
+ try {
576
+ fs.rmSync(gitPidFile, { force: true });
577
+ }
578
+ catch { /* shell cleanup ignores rm failures */ }
579
+ }
580
+ const { values, error } = resolveFinalizeOptions(args);
581
+ if (error)
582
+ return error;
583
+ const timestamp = nowFor(context);
584
+ const ts = stamp(timestamp);
585
+ const date = dayStamp(timestamp);
586
+ const clock = timeStamp(timestamp);
587
+ const threadId = `T-${date}-${clock}-${values.slug}`;
588
+ const threadPath = `workspace/threads/${threadId}.json`;
589
+ const changesetPath = `workspace/threads/${threadId}.changeset.json`;
590
+ const threadsDir = path.join(root, "workspace/threads");
591
+ const tempDirectory = path.join(threadsDir, ".handoff-tmp");
592
+ fs.mkdirSync(tempDirectory, { recursive: true });
593
+ const cleanup = [];
594
+ const tempFile = () => {
595
+ const file = path.join(tempDirectory, `.handoff-${process.pid}-${Math.random().toString(36).slice(2)}`);
596
+ fs.writeFileSync(file, "");
597
+ cleanup.push(file);
598
+ return file;
599
+ };
600
+ try {
601
+ const safePaths = [];
602
+ const skipped = [];
603
+ let touchedValue;
604
+ if (values.filesTouchedFile) {
605
+ const file = path.isAbsolute(values.filesTouchedFile) ? values.filesTouchedFile : path.join(root, values.filesTouchedFile);
606
+ if (!fs.existsSync(file))
607
+ return { code: 3, stdout: "", stderr: `handoff-finalize: --files-touched-json-file not found: ${values.filesTouchedFile}\n` };
608
+ touchedValue = JSON.parse(fs.readFileSync(file, "utf8"));
609
+ }
610
+ else {
611
+ touchedValue = JSON.parse(values.filesTouched);
612
+ }
613
+ const touchedFile = values.filesTouchedFile
614
+ ? (path.isAbsolute(values.filesTouchedFile) ? values.filesTouchedFile : path.join(root, values.filesTouchedFile))
615
+ : tempFile();
616
+ if (!values.filesTouchedFile)
617
+ fs.writeFileSync(touchedFile, values.filesTouched);
618
+ for (const entry of pathEntries(touchedValue)) {
619
+ if (!entry.raw)
620
+ continue;
621
+ const relative = normalizeRelativePath(entry.raw, root);
622
+ if (relative === undefined) {
623
+ skipped.push({ path: entry.raw, reason: "unsafe-path" });
624
+ continue;
625
+ }
626
+ if (isSensitivePath(relative)) {
627
+ skipped.push({ path: relative, reason: "sensitive-path" });
628
+ continue;
629
+ }
630
+ if (!fs.existsSync(path.join(root, relative))) {
631
+ if (probe.hasGit && entry.deleted && git(["ls-files", "--error-unmatch", relative], root).code === 0)
632
+ safePaths.push(relative);
633
+ else
634
+ skipped.push({ path: relative, reason: "missing" });
635
+ continue;
636
+ }
637
+ safePaths.push(relative);
638
+ }
639
+ const companies = touchedCompanyPaths(safePaths);
640
+ const dirty = probe.hasGit && printGitStatus(root) !== "";
641
+ const branchResult = probe.hasGit ? git(["symbolic-ref", "--short", "HEAD"], root) : { code: 1, stdout: "", stderr: "" };
642
+ const commitResult = probe.hasGit ? git(["rev-parse", "--short", "HEAD"], root) : { code: 1, stdout: "", stderr: "" };
643
+ const branch = branchResult.code === 0 ? branchResult.stdout.trim() : "DETACHED";
644
+ const startCommit = commitResult.code === 0 ? commitResult.stdout.trim() : "unknown";
645
+ const status = statusSummary(root, touchedFile);
646
+ const stepsInput = JSON.parse(values.nextSteps);
647
+ const nextSteps = Array.isArray(stepsInput) ? stepsInput.map((raw, index) => {
648
+ const step = typeof raw === "string" ? { step: raw } : isRecord(raw) ? { ...raw } : {};
649
+ if (!jqTruthy(step.id))
650
+ step.id = `${threadId}#${index + 1}`;
651
+ if (!jqTruthy(step.status))
652
+ step.status = "open";
653
+ delete step.key;
654
+ return step;
655
+ }) : [];
656
+ const thread = {
657
+ thread_id: threadId,
658
+ version: 1,
659
+ type: "handoff",
660
+ created_at: ts,
661
+ updated_at: ts,
662
+ workspace_root: root,
663
+ cwd: root,
664
+ git: { branch, current_commit: startCommit, dirty },
665
+ changeset_path: changesetPath,
666
+ conversation_summary: values.summary,
667
+ next_steps: nextSteps,
668
+ files_touched: touchedValue,
669
+ learnings: JSON.parse(values.learnings),
670
+ metadata: { title: values.title, tags: JSON.parse(values.tags), company: companies },
671
+ };
672
+ fs.mkdirSync(threadsDir, { recursive: true });
673
+ fs.writeFileSync(path.join(root, threadPath), prettyJson(thread));
674
+ // Replay the old PostToolUse mirror in process; the hook was fail-soft.
675
+ if (fs.existsSync(path.join(root, ".claude/hooks/mirror-thread-to-company.sh"))) {
676
+ mirrorThread(root, path.join(root, threadPath));
677
+ }
678
+ const mirrored = companies.filter((company) => fs.existsSync(path.join(root, "companies", company, "workspace/sessions", `${threadId}.json`)));
679
+ const mirrorStagePaths = mirrored.flatMap((company) => [
680
+ `companies/${company}/workspace/index.jsonl`,
681
+ `companies/${company}/workspace/.gitignore`,
682
+ ]);
683
+ const changeset = {
684
+ thread_id: threadId,
685
+ thread_path: threadPath,
686
+ created_at: ts,
687
+ intent: { title: values.title, summary: values.summary },
688
+ git: {
689
+ branch,
690
+ started_commit: startCommit,
691
+ finalized_commit: null,
692
+ finalized_commit_note: "The exact handoff commit is emitted by handoff-finalize.sh as commit_after_finalize; a file cannot contain the hash of the commit that contains itself.",
693
+ },
694
+ files_touched: touchedValue,
695
+ staged_paths: probe.hasGit ? safePaths : [],
696
+ skipped_paths: skipped,
697
+ verification: { notes: [], upstream_refs_compared: [] },
698
+ status_summary: status,
699
+ };
700
+ fs.writeFileSync(path.join(root, changesetPath), prettyJson(changeset));
701
+ const pointerPath = path.join(root, "workspace/threads/handoff.json");
702
+ writeAtomic(pointerPath, prettyJson({
703
+ created_at: ts,
704
+ message: values.message,
705
+ last_thread: threadId,
706
+ thread_path: threadPath,
707
+ context_notes: values.summary,
708
+ }), 0o600);
709
+ if (probe.hasGit) {
710
+ const main = mainCheckoutRoot(probe.commonDir, root);
711
+ if (main !== root) {
712
+ writeAtomic(path.join(main, threadPath), fs.readFileSync(path.join(root, threadPath), "utf8"), 0o600);
713
+ writeAtomic(path.join(main, "workspace/threads/handoff.json"), prettyJson({
714
+ created_at: ts,
715
+ message: values.message,
716
+ last_thread: threadId,
717
+ thread_path: threadPath,
718
+ context_notes: values.summary,
719
+ }), 0o600);
720
+ }
721
+ }
722
+ renderIndex(root, "threads", ["--both"]);
723
+ renderIndex(root, "orchestrator");
724
+ const indexPath = path.join(root, "INDEX.md");
725
+ if (fs.existsSync(indexPath)) {
726
+ const current = fs.readFileSync(indexPath, "utf8");
727
+ if (/^Generated:/m.test(current))
728
+ writeAtomic(indexPath, current.replace(/^Generated:.*$/gm, `Generated: ${ts}`), 0o600);
729
+ }
730
+ const explicit = [
731
+ ...safePaths,
732
+ ...mirrorStagePaths,
733
+ threadPath,
734
+ changesetPath,
735
+ "workspace/threads/handoff.json",
736
+ "workspace/threads/recent.md",
737
+ "workspace/threads/INDEX.md",
738
+ "workspace/orchestrator/INDEX.md",
739
+ "INDEX.md",
740
+ ];
741
+ let staged = 0;
742
+ const stageFailures = [];
743
+ if (probe.hasGit) {
744
+ for (const pathname of explicit) {
745
+ if (!pathname)
746
+ continue;
747
+ const result = stagePath(root, pathname);
748
+ if (result.ok)
749
+ staged += 1;
750
+ else if (result.failed)
751
+ stageFailures.push(pathname);
752
+ }
753
+ }
754
+ let hqCommitted = false;
755
+ let commitStatus = probe.hasGit ? "nothing-to-commit" : "no-git-checkout";
756
+ let commitError = "";
757
+ let committedPaths = [];
758
+ if (probe.hasGit && staged > 0) {
759
+ const pending = git(["diff", "--cached", "--name-only"], root).stdout.split(/\r?\n/).filter(Boolean);
760
+ if (pending.length > 0) {
761
+ const committed = git(["commit", "-m", `checkpoint: handoff ${threadId}`], root);
762
+ if (committed.code === 0) {
763
+ hqCommitted = true;
764
+ commitStatus = "committed";
765
+ committedPaths = pending;
766
+ }
767
+ else {
768
+ commitStatus = "failed";
769
+ commitError = tidyGitMessage(`${committed.stdout}${committed.stderr}`) || `git commit exited ${committed.code} with no output`;
770
+ }
771
+ }
772
+ }
773
+ if (stageFailures.length > 0 && commitStatus !== "failed") {
774
+ commitStatus = "stage-failed";
775
+ if (!commitError)
776
+ commitError = `git add failed for ${stageFailures.length} path(s) — see stage_failures`;
777
+ }
778
+ let qmdPid = "";
779
+ // This is the same TypeScript launcher reached by qmd-reindex-bg.sh.
780
+ try {
781
+ const qmd = runIndexBackground(root, path.join(tmpRoot, "qmd-handoff.log"));
782
+ if (qmd.output)
783
+ qmdPid = qmd.output;
784
+ }
785
+ catch { /* the shell helper is fail-soft */ }
786
+ const nextCommand = values.nextCommand === "__auto__" ? `/resumework ${threadId}` : values.nextCommand;
787
+ const clipboardCopied = copyClipboardText(nextCommand, root);
788
+ const payload = {
789
+ thread_id: threadId,
790
+ thread_path: threadPath,
791
+ changeset_path: changesetPath,
792
+ handoff_path: "workspace/threads/handoff.json",
793
+ hq_committed: hqCommitted,
794
+ hq_commit_status: commitStatus,
795
+ hq_commit_error: commitError,
796
+ commit_after_finalize: probe.hasGit ? (git(["rev-parse", "--short", "HEAD"], root).stdout.trim() || "unknown") : "unknown",
797
+ committed_paths: committedPaths,
798
+ stage_failures: stageFailures,
799
+ skipped_paths: skipped,
800
+ baseline_noise_count: isRecord(status) && isRecord(status.counts) ? status.counts.baseline_noise ?? 0 : 0,
801
+ indexes_regen: true,
802
+ qmd_pid: qmdPid,
803
+ git_bg_errors: gitBgErrors,
804
+ next_command: nextCommand,
805
+ clipboard_copied: clipboardCopied,
806
+ mirror_companies: mirrored,
807
+ };
808
+ const stdout = prettyJson(payload);
809
+ const stderrParts = [];
810
+ if (gitBgErrors)
811
+ stderrParts.push(`handoff-finalize: WARNING background git step reported errors: ${gitBgErrors}\n`);
812
+ if (stageFailures.length > 0) {
813
+ stderrParts.push(`handoff-finalize: ERROR git add failed for ${stageFailures.length} path(s):\n`);
814
+ for (const pathname of stageFailures)
815
+ stderrParts.push(`handoff-finalize: ${pathname}\n`);
816
+ }
817
+ if (commitStatus === "failed" || commitStatus === "stage-failed") {
818
+ stderrParts.push("handoff-finalize: ERROR the HQ handoff commit FAILED — thread files were written but are NOT committed.\n");
819
+ stderrParts.push(`handoff-finalize: git said: ${commitError}\n`);
820
+ stderrParts.push("handoff-finalize: resolve the git error and re-run /handoff before ending the session.\n");
821
+ return { code: 4, stdout, stderr: stderrParts.join("") };
822
+ }
823
+ return { code: 0, stdout, stderr: stderrParts.join("") };
824
+ }
825
+ catch (error) {
826
+ const message = error instanceof Error ? error.message : String(error);
827
+ return { code: 1, stdout: "", stderr: `${message}\n` };
828
+ }
829
+ finally {
830
+ for (const file of cleanup) {
831
+ try {
832
+ fs.rmSync(file, { force: true });
833
+ }
834
+ catch { /* trap cleanup is best-effort */ }
835
+ }
836
+ }
837
+ }
838
+ function indexRenderer(root, target) {
839
+ let stderr = "";
840
+ try {
841
+ renderIndexTarget(target, {
842
+ root,
843
+ output: () => { },
844
+ log: (message) => { stderr += `${message}\n`; },
845
+ }, target === "threads" ? ["--both"] : []);
846
+ return { code: 0, stdout: "", stderr };
847
+ }
848
+ catch (error) {
849
+ const message = error instanceof Error ? error.message : String(error);
850
+ return { code: 1, stdout: "", stderr: `${message}\n` };
851
+ }
852
+ }
853
+ function appendLog(file, text) {
854
+ fs.appendFileSync(file, text);
855
+ }
856
+ function recordCommand(log, result) {
857
+ if (result.stdout)
858
+ appendLog(log, result.stdout);
859
+ if (result.stderr)
860
+ appendLog(log, result.stderr);
861
+ }
862
+ function runArchive(root, now) {
863
+ let stderr = "";
864
+ const code = archiveOldThreads(["--gated"], {
865
+ hqRoot: root,
866
+ now: () => now,
867
+ stdout: () => { },
868
+ stderr: (text) => { stderr += text; },
869
+ });
870
+ return { code, stdout: "", stderr };
871
+ }
872
+ function runIndexBackground(root, logPath) {
873
+ let stderr = "";
874
+ const dependencies = defaultBackgroundDependencies(root);
875
+ dependencies.env = { ...dependencies.env, QMD_REINDEX_LOG: logPath };
876
+ dependencies.writeStderr = (text) => { stderr += text; };
877
+ const result = runBackgroundLauncher(dependencies);
878
+ const output = result.state === "launched"
879
+ ? String(result.pid)
880
+ : result.state === "skipped-agent" || result.state === "skipped"
881
+ ? result.state
882
+ : "";
883
+ return { output, stderr };
884
+ }
885
+ function readThreadCompanyNames(file) {
886
+ try {
887
+ const thread = JSON.parse(fs.readFileSync(file, "utf8"));
888
+ if (!isRecord(thread) || !isRecord(thread.metadata))
889
+ return [];
890
+ const value = thread.metadata.company;
891
+ if (!jqTruthy(value))
892
+ return [];
893
+ const values = Array.isArray(value) ? value : [value];
894
+ return values.map((item) => typeof item === "string" ? item : compactJson(item).trimEnd());
895
+ }
896
+ catch {
897
+ return [];
898
+ }
899
+ }
900
+ function documentReleaseCounts(file) {
901
+ try {
902
+ const thread = JSON.parse(fs.readFileSync(file, "utf8"));
903
+ const touched = isRecord(thread) ? thread.files_touched : undefined;
904
+ const files = Array.isArray(touched) ? touched : [];
905
+ const paths = files.map((entry) => {
906
+ if (typeof entry === "string")
907
+ return entry;
908
+ if (isRecord(entry) && typeof entry.path === "string")
909
+ return entry.path;
910
+ return null;
911
+ });
912
+ return {
913
+ scope: paths.filter((item) => item !== null && /^(companies|repos)\//.test(item)).length,
914
+ skipped: paths.filter((item) => item === null).length,
915
+ invalid: false,
916
+ };
917
+ }
918
+ catch {
919
+ return { scope: 0, skipped: 0, invalid: true };
920
+ }
921
+ }
922
+ function workMeshSessionEnd(root) {
923
+ const hook = path.join(root, "core/hooks/SessionEnd/35-work-mesh-session-end.sh");
924
+ if (!fs.existsSync(hook))
925
+ return "work-mesh-session-end: skipped (hook absent)";
926
+ if (["1", "true", "TRUE", "yes", "YES", "on", "ON"].includes(process.env.HQ_WORK_MESH_DISABLED ?? ""))
927
+ return "work-mesh-session-end: skipped (hook disabled)";
928
+ if (/(^|,)(work-mesh|work-mesh-live|\*)(,|$)/.test(`,${process.env.HQ_DISABLED_HOOKS ?? ""},`))
929
+ return "work-mesh-session-end: skipped (hook disabled)";
930
+ let sessionId = "";
931
+ const current = path.join(root, "workspace/sessions/.current");
932
+ if (fs.existsSync(current)) {
933
+ try {
934
+ sessionId = fs.readFileSync(current, "utf8").replace(/\s/g, "");
935
+ }
936
+ catch {
937
+ sessionId = "";
938
+ }
939
+ }
940
+ sessionId = sessionId || process.env.CLAUDE_SESSION_ID || process.env.CODEX_SESSION_ID || process.env.CODEX_THREAD_ID || "";
941
+ if (!sessionId)
942
+ return undefined;
943
+ let harness = process.env.HQ_HARNESS || process.env.HQ_WORK_MESH_HARNESS || process.env.HQ_CHECKPOINT_RUNTIME || "claude-code";
944
+ if (["claude", "Claude", "ClaudeCode"].includes(harness))
945
+ harness = "claude-code";
946
+ if (harness === "Codex")
947
+ harness = "codex";
948
+ if (harness === "Grok")
949
+ harness = "grok";
950
+ const adapterVersion = process.env.HQ_ADAPTER_CONTRACT_VERSION || "1.0.0";
951
+ const sequenceDirectory = process.env.WORK_MESH_SEQ_DIR || path.join(process.env.HOME || os.homedir(), ".hq/work-mesh/seq");
952
+ const sequenceFile = path.join(sequenceDirectory, sessionId);
953
+ let sequence = 0;
954
+ try {
955
+ const raw = fs.readFileSync(sequenceFile, "utf8").replace(/\s/g, "");
956
+ if (/^\d+$/.test(raw))
957
+ sequence = Number(raw);
958
+ }
959
+ catch { /* sequence file is optional */ }
960
+ sequence += 1;
961
+ try {
962
+ fs.mkdirSync(sequenceDirectory, { recursive: true, mode: 0o700 });
963
+ fs.chmodSync(sequenceDirectory, 0o700);
964
+ fs.writeFileSync(sequenceFile, `${sequence}\n`);
965
+ enqueueSessionEvent({
966
+ kind: "session_end",
967
+ sessionId,
968
+ harness: harness,
969
+ adapterVersion,
970
+ seq: sequence,
971
+ cwd: root,
972
+ hqRoot: root,
973
+ env: { ...process.env, HQ_ROOT: root, CLAUDE_CODE_SESSION_ID: sessionId },
974
+ });
975
+ }
976
+ catch {
977
+ // The SessionEnd hook suppresses work-mesh enqueue failures.
978
+ }
979
+ // The legacy entry is detached. The in-process implementation has completed
980
+ // the same local spool write before returning from the already-detached post.
981
+ return "work-mesh-session-end: launched";
982
+ }
983
+ function spawnBashDetached(script, args, cwd, root, logFile) {
984
+ let fd;
985
+ try {
986
+ mkdirParent(logFile);
987
+ fd = fs.openSync(logFile, "a");
988
+ const child = spawn("bash", [script, ...args], {
989
+ cwd,
990
+ env: { ...process.env, HQ_ROOT: root },
991
+ detached: true,
992
+ stdio: ["ignore", fd, fd],
993
+ });
994
+ child.once("error", (error) => appendLog(logFile, `${error.message}\n`));
995
+ child.unref();
996
+ fs.closeSync(fd);
997
+ fd = undefined;
998
+ return child.pid;
999
+ }
1000
+ catch {
1001
+ return undefined;
1002
+ }
1003
+ finally {
1004
+ if (fd !== undefined) {
1005
+ try {
1006
+ fs.closeSync(fd);
1007
+ }
1008
+ catch (error) {
1009
+ const message = error instanceof Error ? error.message : String(error);
1010
+ appendLog(logFile, `worktree-gc: log close failed: ${message}\n`);
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+ function parseLearningCount(file) {
1016
+ try {
1017
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
1018
+ return Array.isArray(value) ? value.length : 0;
1019
+ }
1020
+ catch {
1021
+ return 0;
1022
+ }
1023
+ }
1024
+ async function launchHandoffPost(args, root) {
1025
+ return await new Promise((resolve) => {
1026
+ try {
1027
+ const child = spawn("hq", ["core", "--hq-root", root, "handoff", "post", ...args], {
1028
+ cwd: root,
1029
+ env: { ...process.env, HQ_NATIVE_HANDOFF_POST_CHILD: "1" },
1030
+ detached: true,
1031
+ stdio: "ignore",
1032
+ });
1033
+ child.once("error", (error) => resolve({ code: 127, stdout: "", stderr: `${error.message}\n` }));
1034
+ child.once("spawn", () => {
1035
+ child.unref();
1036
+ resolve({ code: 0, stdout: "", stderr: "" });
1037
+ });
1038
+ }
1039
+ catch (error) {
1040
+ const message = error instanceof Error ? error.message : String(error);
1041
+ resolve({ code: 1, stdout: "", stderr: `${message}\n` });
1042
+ }
1043
+ });
1044
+ }
1045
+ export function runHandoffPost(args, context) {
1046
+ const root = fs.realpathSync(context.root);
1047
+ if (process.env.HQ_NATIVE_HANDOFF_POST_CHILD !== "1")
1048
+ return launchHandoffPost(args, root);
1049
+ return runHandoffPostWorker(args, { ...context, root });
1050
+ }
1051
+ export function runHandoffPostWorker(args, context) {
1052
+ const root = fs.realpathSync(context.root);
1053
+ const threadPath = args[0] ?? "";
1054
+ const learningsFile = args[1] ?? "";
1055
+ const threadFile = threadPath ? path.resolve(root, threadPath) : "";
1056
+ const learningsFilePath = learningsFile ? path.resolve(root, learningsFile) : "";
1057
+ const logDir = process.env.HANDOFF_LOG_DIR || "/tmp";
1058
+ const logMain = path.join(logDir, "handoff-post.log");
1059
+ const logQmd = path.join(logDir, "qmd-handoff.log");
1060
+ const time = shellDate(nowFor(context));
1061
+ const log = (message) => appendLog(logMain, `[${time}] ${message}\n`);
1062
+ fs.writeFileSync(logMain, "");
1063
+ log(`handoff-post starting (thread=${threadPath || "none"}, learnings=${learningsFile || "none"})`);
1064
+ const archive = runArchive(root, nowFor(context));
1065
+ recordCommand(logMain, archive);
1066
+ log(archive.code === 0 ? "archive: ok" : "archive: error (continuing)");
1067
+ const threadsIndex = indexRenderer(root, "threads");
1068
+ recordCommand(logMain, threadsIndex);
1069
+ log(threadsIndex.code === 0 ? "threads-index: ok" : "threads-index: error (continuing)");
1070
+ const orchestratorIndex = indexRenderer(root, "orchestrator");
1071
+ recordCommand(logMain, orchestratorIndex);
1072
+ log(orchestratorIndex.code === 0 ? "orchestrator-index: ok" : "orchestrator-index: error (continuing)");
1073
+ if (learningsFilePath && fs.existsSync(learningsFilePath) && fs.statSync(learningsFilePath).size > 0) {
1074
+ const count = parseLearningCount(learningsFilePath);
1075
+ if (count > 0)
1076
+ log(`learn: eligible and pending runtime dispatch by handoff skill (${count} learning(s); no dispatch proof)`);
1077
+ else
1078
+ log("learn: no learnings to dispatch");
1079
+ }
1080
+ else
1081
+ log("learn: no learnings file provided");
1082
+ if (threadFile && fs.existsSync(threadFile)) {
1083
+ const counts = documentReleaseCounts(threadFile);
1084
+ if (counts.invalid)
1085
+ log("document-release: skipped (invalid thread JSON)");
1086
+ for (let index = 0; index < counts.skipped; index += 1)
1087
+ log("document-release: skipped unsupported files_touched entry");
1088
+ if (counts.scope > 0) {
1089
+ const installedScript = path.join(root, "core/scripts/skill-installed.sh");
1090
+ const logFd = fs.openSync(logMain, "a");
1091
+ let installedStatus = null;
1092
+ try {
1093
+ const installed = spawnSync("bash", [installedScript, "document-release", process.env.HQ_ACTIVE_COMPANY || ""], {
1094
+ cwd: root,
1095
+ env: process.env,
1096
+ stdio: ["ignore", "ignore", logFd],
1097
+ });
1098
+ installedStatus = installed.status;
1099
+ }
1100
+ finally {
1101
+ fs.closeSync(logFd);
1102
+ }
1103
+ if (installedStatus === 0) {
1104
+ log(`document-release: eligible and pending runtime dispatch by handoff skill (${counts.scope} scoped files; no dispatch proof)`);
1105
+ }
1106
+ else
1107
+ log("document-release: skipped (skill not installed)");
1108
+ }
1109
+ else
1110
+ log("document-release: skipped (no company/repo files in files_touched)");
1111
+ }
1112
+ else
1113
+ log("document-release: skipped (no thread path)");
1114
+ if (threadFile && fs.existsSync(threadFile) && commandAvailable("hq")) {
1115
+ for (const company of readThreadCompanyNames(threadFile)) {
1116
+ if (!/^[a-z][a-z0-9_-]*$/.test(company))
1117
+ continue;
1118
+ if (!fs.existsSync(path.join(root, "companies", company, "workspace")))
1119
+ continue;
1120
+ const pushed = spawnSync("hq", ["sync", "push", "--company", company, `companies/${company}/workspace`], {
1121
+ cwd: root,
1122
+ env: process.env,
1123
+ encoding: "utf8",
1124
+ stdio: ["ignore", "pipe", "pipe"],
1125
+ });
1126
+ recordCommand(logMain, {
1127
+ code: pushed.status ?? 1,
1128
+ stdout: typeof pushed.stdout === "string" ? pushed.stdout : "",
1129
+ stderr: typeof pushed.stderr === "string" ? pushed.stderr : "",
1130
+ });
1131
+ log(pushed.status === 0 ? `workspace-sync: pushed companies/${company}/workspace` : `workspace-sync: failed companies/${company}/workspace`);
1132
+ }
1133
+ }
1134
+ else if (threadFile && fs.existsSync(threadFile))
1135
+ log("workspace-sync: skipped (hq CLI unavailable)");
1136
+ const qmdHelper = path.join(root, "core/scripts/qmd-reindex-bg.sh");
1137
+ const fleetQmdHelper = "/usr/local/bin/hq-agent-qmd-reindex-bg";
1138
+ let fleetQmdHelperExecutable = false;
1139
+ if (!fs.existsSync(qmdHelper)) {
1140
+ try {
1141
+ fs.accessSync(fleetQmdHelper, fs.constants.X_OK);
1142
+ fleetQmdHelperExecutable = true;
1143
+ }
1144
+ catch (error) {
1145
+ const code = error.code;
1146
+ if (code !== "ENOENT" && code !== "ENOTDIR" && code !== "EACCES")
1147
+ throw error;
1148
+ }
1149
+ }
1150
+ if (fs.existsSync(qmdHelper) || fleetQmdHelperExecutable) {
1151
+ const background = runIndexBackground(root, logQmd);
1152
+ if (background.stderr)
1153
+ appendLog(logMain, background.stderr);
1154
+ log(`qmd: reindex-bg → ${background.output || "busy-or-quiet"}`);
1155
+ }
1156
+ else if (fs.existsSync("/var/lib/hq-agent"))
1157
+ log("qmd: skipped-agent (no helper; indexer owns freshness)");
1158
+ else
1159
+ log("qmd: skipped (helper missing)");
1160
+ const meshMessage = workMeshSessionEnd(root);
1161
+ if (meshMessage)
1162
+ log(meshMessage);
1163
+ else
1164
+ log("work-mesh-session-end: skipped (no session id)");
1165
+ if (fs.existsSync(path.join(root, "core/scripts/worktree-gc.sh"))) {
1166
+ const script = path.join(root, "core/scripts/worktree-gc.sh");
1167
+ const pid = spawnBashDetached(script, ["--apply", "--gated"], root, root, path.join(logDir, "worktree-gc.log"));
1168
+ if (pid !== undefined)
1169
+ log(`worktree-gc: launched PID ${pid} (--apply --gated)`);
1170
+ else
1171
+ log("worktree-gc: launched PID 0 (--apply --gated)");
1172
+ }
1173
+ else
1174
+ log("worktree-gc: skipped (script absent)");
1175
+ log("handoff-post complete");
1176
+ return { code: 0, stdout: "", stderr: "" };
1177
+ }
1178
+ function withRootEnvironment(root, action) {
1179
+ const saved = new Map();
1180
+ for (const key of ["HQ_ROOT", "CLAUDE_PROJECT_DIR", "PWD"])
1181
+ saved.set(key, process.env[key]);
1182
+ process.env.HQ_ROOT = root;
1183
+ process.env.CLAUDE_PROJECT_DIR = root;
1184
+ process.env.PWD = root;
1185
+ try {
1186
+ return action();
1187
+ }
1188
+ finally {
1189
+ for (const [key, value] of saved) {
1190
+ if (value === undefined)
1191
+ delete process.env[key];
1192
+ else
1193
+ process.env[key] = value;
1194
+ }
1195
+ }
1196
+ }
1197
+ function emitResult(result) {
1198
+ if (result.stdout)
1199
+ process.stdout.write(result.stdout);
1200
+ if (result.stderr)
1201
+ process.stderr.write(result.stderr);
1202
+ if (result.code !== 0)
1203
+ process.exit(result.code);
1204
+ }
1205
+ /** Register native handoff commands behind the shared default-off flag. */
1206
+ export function registerCoreHandoffCommand(core) {
1207
+ const handoff = core.command("handoff").description("Handoff session helpers");
1208
+ const names = ["finalize", "post", "open-steps"];
1209
+ for (const name of names) {
1210
+ const command = handoff.command(name)
1211
+ .description(name === "open-steps" ? DESCRIPTIONS.openSteps : DESCRIPTIONS[name])
1212
+ .allowUnknownOption()
1213
+ .allowExcessArguments()
1214
+ .helpOption(false)
1215
+ .argument("[args...]", "arguments accepted for shell ABI compatibility")
1216
+ .action(async (args = [], _options, cmd) => {
1217
+ const scope = core.opts();
1218
+ const root = resolveLiveRoot({ hqRoot: scope.hqRoot });
1219
+ const operands = cmd.args.length > 0 ? cmd.args : args;
1220
+ if (!await coreNativeHandoffEnabled()) {
1221
+ const bundled = runBundledScript({ asset: ASSETS[name === "open-steps" ? "openSteps" : name], args: operands, cwd: root, hqRoot: root });
1222
+ if (bundled.code !== 0)
1223
+ process.exit(bundled.code);
1224
+ return;
1225
+ }
1226
+ const result = withRootEnvironment(root, () => {
1227
+ const context = { root, cwd: root };
1228
+ if (name === "finalize")
1229
+ return runHandoffFinalize(operands, context);
1230
+ if (name === "post")
1231
+ return runHandoffPost(operands, context);
1232
+ return runHandoffOpenSteps(operands, context);
1233
+ });
1234
+ emitResult(await result);
1235
+ });
1236
+ const asset = ASSETS[name === "open-steps" ? "openSteps" : name];
1237
+ describeCoreCommand(command, { execution: "native", asset, root: "live" });
1238
+ }
1239
+ }
1240
+ //# sourceMappingURL=core-handoff.js.map