@indigoai-us/hq-cli 5.88.0 → 5.89.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.
- package/CHANGELOG.md +22 -0
- package/assets/scaffold/core/scripts/generate-workers-registry.sh +18 -0
- package/assets/scaffold/core/scripts/scan-packages.sh +17 -0
- package/dist/commands/core-checkpoint.js +52 -16
- package/dist/commands/core.d.ts +9 -0
- package/dist/commands/core.js +38 -0
- package/dist/lib/scan-packages/index.d.ts +13 -0
- package/dist/lib/scan-packages/index.js +218 -0
- package/dist/lib/workers-registry/index.d.ts +18 -0
- package/dist/lib/workers-registry/index.js +206 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.89.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Native worker-registry generation and pack contribution scanning via `hq core
|
|
10
|
+
generate-workers-registry` and `hq core scan-packages`, replacing the staging
|
|
11
|
+
shell scripts; `hq reindex` now uses the native generator, with output
|
|
12
|
+
differential-proven against frozen staging references. Syntactically invalid
|
|
13
|
+
`worker.yaml` files are now quarantined rather than aborting the registry run.
|
|
14
|
+
(#315)
|
|
15
|
+
|
|
16
|
+
## [5.88.1]
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `hq core checkpoint` no longer wedges its maintenance-sibling queue forever
|
|
21
|
+
when the recorded lock PID is recycled by another user's process. An `EPERM`
|
|
22
|
+
from the liveness probe now means "not our sibling" rather than "sibling
|
|
23
|
+
alive", locks expire after 60 minutes, and `pending.jsonl` is capped at the
|
|
24
|
+
50 newest payloads with the queue depth reported in the busy message so a
|
|
25
|
+
stall is visible instead of silent. (#313)
|
|
26
|
+
|
|
5
27
|
## [5.88.0]
|
|
6
28
|
|
|
7
29
|
### Changed
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# FORWARDER — native implementation: `hq core generate-workers-registry`.
|
|
3
|
+
#
|
|
4
|
+
# This preserves the legacy script ABI. It discovers the live HQ root exactly
|
|
5
|
+
# as the original script did, forwards every argument untouched, and execs the
|
|
6
|
+
# CLI so stdout, stderr, exit status, and signal disposition remain the child's.
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
11
|
+
|
|
12
|
+
if ! command -v hq >/dev/null 2>&1; then
|
|
13
|
+
echo "generate-workers-registry.sh: requires the hq CLI — this script's implementation now ships with it." >&2
|
|
14
|
+
echo "Install it with: npm install -g @indigoai-us/hq-cli" >&2
|
|
15
|
+
exit 127
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
exec hq core --hq-root "$HQ_ROOT" generate-workers-registry "$@"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# FORWARDER — native implementation: `hq core scan-packages`.
|
|
3
|
+
#
|
|
4
|
+
# The original script treated $HQ_ROOT (or the caller's cwd) as authoritative.
|
|
5
|
+
# Keep that root binding and exec the hidden command so the shell ABI survives.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
HQ_ROOT="${HQ_ROOT:-$PWD}"
|
|
10
|
+
|
|
11
|
+
if ! command -v hq >/dev/null 2>&1; then
|
|
12
|
+
echo "scan-packages.sh: requires the hq CLI — this script's implementation now ships with it." >&2
|
|
13
|
+
echo "Install it with: npm install -g @indigoai-us/hq-cli" >&2
|
|
14
|
+
exit 127
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
exec hq core --hq-root "$HQ_ROOT" scan-packages "$@"
|
|
@@ -19,6 +19,10 @@ const CODEX_SIBLING_MODEL = "gpt-5.6-terra";
|
|
|
19
19
|
const CODEX_SIBLING_REASONING_EFFORT = "high";
|
|
20
20
|
const CLAUDE_SIBLING_MODEL = "claude-opus-5";
|
|
21
21
|
const CLAUDE_SIBLING_EFFORT = "medium";
|
|
22
|
+
/** A sibling still holding the lock after this long is treated as abandoned. */
|
|
23
|
+
const SIBLING_LOCK_TTL_MS = 60 * 60 * 1000;
|
|
24
|
+
/** Newest-wins cap so a wedged sibling cannot grow an unbounded queue. */
|
|
25
|
+
const MAX_PENDING_PAYLOADS = 50;
|
|
22
26
|
class CheckpointUsageError extends Error {
|
|
23
27
|
}
|
|
24
28
|
function printResult(line) {
|
|
@@ -405,26 +409,58 @@ function siblingPayload(input, threadPath) {
|
|
|
405
409
|
pending_payloads: [],
|
|
406
410
|
};
|
|
407
411
|
}
|
|
408
|
-
|
|
412
|
+
/**
|
|
413
|
+
* PID of a sibling that is genuinely still working, or null when the lock is
|
|
414
|
+
* stale and this process should take it over.
|
|
415
|
+
*
|
|
416
|
+
* A lock is stale when the PID is gone, when it cannot be signalled, or when
|
|
417
|
+
* the run has outlived {@link SIBLING_LOCK_TTL_MS}. Siblings are spawned as
|
|
418
|
+
* this user, so an EPERM means the PID was recycled by somebody else's
|
|
419
|
+
* process — not that our sibling is alive. Treating an unsignalable PID as
|
|
420
|
+
* alive wedges the queue permanently: on 2026-08-03 a lock landed on a
|
|
421
|
+
* root-owned kernel thread and every checkpoint for the next two days queued
|
|
422
|
+
* behind a sibling that had never existed.
|
|
423
|
+
*/
|
|
424
|
+
function existingSiblingPid(lockPath, now) {
|
|
425
|
+
let raw;
|
|
426
|
+
let writtenAtMs;
|
|
427
|
+
try {
|
|
428
|
+
raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
429
|
+
writtenAtMs = fs.statSync(lockPath).mtimeMs;
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
const pid = Number.parseInt(raw, 10);
|
|
435
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
436
|
+
return null;
|
|
437
|
+
if (now - writtenAtMs >= SIBLING_LOCK_TTL_MS)
|
|
438
|
+
return null;
|
|
409
439
|
try {
|
|
410
|
-
const pid = Number.parseInt(fs.readFileSync(lockPath, "utf8").trim(), 10);
|
|
411
|
-
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
412
|
-
return null;
|
|
413
440
|
process.kill(pid, 0);
|
|
414
441
|
return pid;
|
|
415
442
|
}
|
|
416
|
-
catch
|
|
417
|
-
if (error?.code === "EPERM") {
|
|
418
|
-
try {
|
|
419
|
-
return Number.parseInt(fs.readFileSync(lockPath, "utf8").trim(), 10);
|
|
420
|
-
}
|
|
421
|
-
catch {
|
|
422
|
-
return null;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
443
|
+
catch {
|
|
425
444
|
return null;
|
|
426
445
|
}
|
|
427
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Queue a payload for the next sibling, keeping only the newest entries. An
|
|
449
|
+
* unbounded queue hides a wedged sibling instead of surfacing it. Returns the
|
|
450
|
+
* resulting queue depth so the caller can report it.
|
|
451
|
+
*/
|
|
452
|
+
function appendPending(pendingPath, payload) {
|
|
453
|
+
fs.appendFileSync(pendingPath, `${JSON.stringify(payload)}\n`);
|
|
454
|
+
const lines = fs
|
|
455
|
+
.readFileSync(pendingPath, "utf8")
|
|
456
|
+
.split("\n")
|
|
457
|
+
.filter((line) => line.trim());
|
|
458
|
+
if (lines.length <= MAX_PENDING_PAYLOADS)
|
|
459
|
+
return lines.length;
|
|
460
|
+
const kept = lines.slice(-MAX_PENDING_PAYLOADS);
|
|
461
|
+
fs.writeFileSync(pendingPath, `${kept.join("\n")}\n`);
|
|
462
|
+
return kept.length;
|
|
463
|
+
}
|
|
428
464
|
function drainPending(pendingPath) {
|
|
429
465
|
if (!fs.existsSync(pendingPath))
|
|
430
466
|
return [];
|
|
@@ -449,11 +485,11 @@ function startSibling(liveRoot, input, threadPath, backend) {
|
|
|
449
485
|
const pendingPath = path.join(siblingRoot, "pending.jsonl");
|
|
450
486
|
const lockPath = path.join(stateDir, "checkpoint-sibling.lock");
|
|
451
487
|
const payload = siblingPayload(input, threadPath);
|
|
452
|
-
const activePid = existingSiblingPid(lockPath);
|
|
488
|
+
const activePid = existingSiblingPid(lockPath, Date.now());
|
|
453
489
|
fs.mkdirSync(siblingRoot, { recursive: true });
|
|
454
490
|
if (activePid !== null) {
|
|
455
|
-
|
|
456
|
-
printResult(
|
|
491
|
+
const depth = appendPending(pendingPath, payload);
|
|
492
|
+
printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
|
|
457
493
|
return;
|
|
458
494
|
}
|
|
459
495
|
const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
|
package/dist/commands/core.d.ts
CHANGED
|
@@ -77,6 +77,15 @@ export type WorkerSubcommand = ScaffoldAsset & {
|
|
|
77
77
|
/** Subcommand name under the `worker` group — `hq core worker <name>`. */
|
|
78
78
|
name: string;
|
|
79
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* Native commands that still claim a scaffold forwarder asset. The forwarder
|
|
82
|
+
* is the backwards-compatible path for old callers; this command is the sole
|
|
83
|
+
* implementation and must never dispatch back through that asset.
|
|
84
|
+
*/
|
|
85
|
+
export type NativeScaffoldCommand = ScaffoldAsset & {
|
|
86
|
+
name: string;
|
|
87
|
+
};
|
|
88
|
+
export declare const NATIVE_SCAFFOLD_COMMANDS: NativeScaffoldCommand[];
|
|
80
89
|
/**
|
|
81
90
|
* The `hq core worker <name>` subgroup — worker-scoped skill maintenance.
|
|
82
91
|
*
|
package/dist/commands/core.js
CHANGED
|
@@ -33,6 +33,22 @@ import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
|
|
|
33
33
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
34
34
|
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
35
35
|
import { renderIndexTarget } from "../lib/index-render/index.js";
|
|
36
|
+
import { generateWorkersRegistry } from "../lib/workers-registry/index.js";
|
|
37
|
+
import { scanPackages } from "../lib/scan-packages/index.js";
|
|
38
|
+
export const NATIVE_SCAFFOLD_COMMANDS = [
|
|
39
|
+
{
|
|
40
|
+
name: "generate-workers-registry",
|
|
41
|
+
asset: "core/scripts/generate-workers-registry.sh",
|
|
42
|
+
root: "live",
|
|
43
|
+
summary: "Regenerate the derived workers registry",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "scan-packages",
|
|
47
|
+
asset: "core/scripts/scan-packages.sh",
|
|
48
|
+
root: "live",
|
|
49
|
+
summary: "Wire installed content-pack contributions",
|
|
50
|
+
},
|
|
51
|
+
];
|
|
36
52
|
/**
|
|
37
53
|
* The `hq core worker <name>` subgroup — worker-scoped skill maintenance.
|
|
38
54
|
*
|
|
@@ -252,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
|
|
|
252
268
|
/** Every claimed bundled asset, used by packaging and interpreter tests. */
|
|
253
269
|
export const SCAFFOLD_ASSETS = [
|
|
254
270
|
...SCAFFOLD_COMMANDS,
|
|
271
|
+
...NATIVE_SCAFFOLD_COMMANDS,
|
|
255
272
|
...SCAFFOLD_ONLY_ASSETS,
|
|
256
273
|
...WORKER_SUBCOMMANDS,
|
|
257
274
|
];
|
|
@@ -310,6 +327,27 @@ export function registerCoreCommands(program) {
|
|
|
310
327
|
// This group primarily hosts manifest-driven bundled assets, but it also
|
|
311
328
|
// hosts native TypeScript plumbing when a scaffold contract needs it.
|
|
312
329
|
registerCoreCheckpointCommand(core);
|
|
330
|
+
// These command names intentionally own native implementations. Their
|
|
331
|
+
// similarly named scaffold assets are forwarders, so sending them through
|
|
332
|
+
// runEntry would recurse back into this command forever.
|
|
333
|
+
for (const entry of NATIVE_SCAFFOLD_COMMANDS) {
|
|
334
|
+
core
|
|
335
|
+
.command(entry.name)
|
|
336
|
+
.description(entry.summary)
|
|
337
|
+
.allowUnknownOption()
|
|
338
|
+
.allowExcessArguments()
|
|
339
|
+
.helpOption(false)
|
|
340
|
+
.argument("[args...]", "arguments accepted for shell ABI compatibility")
|
|
341
|
+
.action(() => {
|
|
342
|
+
const scope = core.opts();
|
|
343
|
+
const hqRoot = resolveLiveRoot({ hqRoot: scope.hqRoot });
|
|
344
|
+
const result = entry.name === "generate-workers-registry"
|
|
345
|
+
? generateWorkersRegistry(hqRoot)
|
|
346
|
+
: scanPackages(hqRoot, { quiet: process.env.HQ_SCAN_QUIET === "1" });
|
|
347
|
+
if (result.status !== 0)
|
|
348
|
+
process.exit(result.status);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
313
351
|
// `hq core worker <name>` — a nested subgroup for worker-scoped skill
|
|
314
352
|
// maintenance. Nested (rather than flat `hq core worker-<name>`) so the two
|
|
315
353
|
// related operations read as one family.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type Logger = (message: string) => void;
|
|
2
|
+
export interface ScanPackagesOptions {
|
|
3
|
+
quiet?: boolean;
|
|
4
|
+
log?: Logger;
|
|
5
|
+
warn?: Logger;
|
|
6
|
+
}
|
|
7
|
+
export interface ScanPackagesResult {
|
|
8
|
+
status: 0 | 1;
|
|
9
|
+
}
|
|
10
|
+
/** Wire installed pack contributions into their table-declared host locations. */
|
|
11
|
+
export declare function scanPackages(hqRoot: string, options?: ScanPackagesOptions): ScanPackagesResult;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/** Native implementation of core/scripts/scan-packages.sh. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as yaml from 'js-yaml';
|
|
5
|
+
import { contributionLinks } from '../../utils/pack-contributions.js';
|
|
6
|
+
function isDirectory(target) { try {
|
|
7
|
+
return fs.statSync(target).isDirectory();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
} }
|
|
12
|
+
function existsOrSymlink(target) { try {
|
|
13
|
+
fs.lstatSync(target);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
} }
|
|
19
|
+
function normalized(relative) { return relative.replaceAll(path.sep, '/'); }
|
|
20
|
+
function readContributes(manifest) {
|
|
21
|
+
// This deliberately mirrors the staging script's shallow awk grammar rather
|
|
22
|
+
// than accepting YAML forms the legacy ABI never recognized (notably a
|
|
23
|
+
// non-empty inline list). Mapping remains single-sourced in
|
|
24
|
+
// contributionLinks/CONTRIBUTION_TABLE; this only preserves input syntax.
|
|
25
|
+
const contributes = {};
|
|
26
|
+
let inside = false;
|
|
27
|
+
let key;
|
|
28
|
+
for (const line of fs.readFileSync(manifest, 'utf8').split('\n')) {
|
|
29
|
+
if (/^\s*#/.test(line) || /^\s*$/.test(line))
|
|
30
|
+
continue;
|
|
31
|
+
if (/^contributes:\s*$/.test(line)) {
|
|
32
|
+
inside = true;
|
|
33
|
+
key = undefined;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (inside && /^[^\s]/.test(line)) {
|
|
37
|
+
inside = false;
|
|
38
|
+
key = undefined;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const empty = inside && line.match(/^ {2}([a-zA-Z_][a-zA-Z0-9_-]*):\s*\[\s*\]\s*$/);
|
|
42
|
+
if (empty) {
|
|
43
|
+
key = undefined;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const header = inside && line.match(/^ {2}([a-zA-Z_][a-zA-Z0-9_-]*):\s*$/);
|
|
47
|
+
if (header) {
|
|
48
|
+
key = header[1];
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const currentKey = key;
|
|
52
|
+
const item = inside && currentKey !== undefined && line.match(/^ {4}-\s+(.+)$/);
|
|
53
|
+
if (!item || currentKey === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
let value = item[1].replace(/\s*#.*$/, '').trim();
|
|
56
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
57
|
+
value = value.slice(1, -1);
|
|
58
|
+
if (!value)
|
|
59
|
+
continue;
|
|
60
|
+
(contributes[currentKey] ??= []).push(value);
|
|
61
|
+
}
|
|
62
|
+
return contributes;
|
|
63
|
+
}
|
|
64
|
+
function workerId(file) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = yaml.load(fs.readFileSync(file, 'utf8'));
|
|
67
|
+
const worker = parsed && typeof parsed === 'object' ? parsed.worker : undefined;
|
|
68
|
+
return worker && typeof worker === 'object' ? String(worker.id ?? '') : '';
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function allWorkerYamlFiles(root, relative) {
|
|
75
|
+
const output = [];
|
|
76
|
+
const walk = (next, seen) => {
|
|
77
|
+
const absolute = path.join(root, next);
|
|
78
|
+
if (!isDirectory(absolute))
|
|
79
|
+
return;
|
|
80
|
+
let physical;
|
|
81
|
+
try {
|
|
82
|
+
physical = fs.realpathSync(absolute);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (seen.has(physical))
|
|
88
|
+
return;
|
|
89
|
+
const beneath = new Set(seen).add(physical);
|
|
90
|
+
let names;
|
|
91
|
+
try {
|
|
92
|
+
names = fs.readdirSync(absolute);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
const child = path.join(next, name);
|
|
99
|
+
const childAbsolute = path.join(root, child);
|
|
100
|
+
if (name === 'worker.yaml' && (() => { try {
|
|
101
|
+
return fs.statSync(childAbsolute).isFile();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
} })())
|
|
106
|
+
output.push(child);
|
|
107
|
+
else if (isDirectory(childAbsolute))
|
|
108
|
+
walk(child, beneath);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(relative, new Set());
|
|
112
|
+
return output;
|
|
113
|
+
}
|
|
114
|
+
function workerIdClashes(hqRoot, source) {
|
|
115
|
+
const sourceYaml = path.join(source, 'worker.yaml');
|
|
116
|
+
if (!fs.existsSync(sourceYaml))
|
|
117
|
+
return undefined;
|
|
118
|
+
const id = workerId(sourceYaml);
|
|
119
|
+
if (!id)
|
|
120
|
+
return undefined;
|
|
121
|
+
let physical;
|
|
122
|
+
try {
|
|
123
|
+
physical = fs.realpathSync(sourceYaml);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
for (const file of [...allWorkerYamlFiles(hqRoot, 'core/workers'), ...allWorkerYamlFiles(hqRoot, 'companies')]) {
|
|
129
|
+
const relative = normalized(file);
|
|
130
|
+
if (relative.includes('/_template/') || relative.includes('/_overrides/'))
|
|
131
|
+
continue;
|
|
132
|
+
if (workerId(path.join(hqRoot, file)) !== id)
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
if (fs.realpathSync(path.join(hqRoot, file)) === physical)
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
catch { /* compare as a clash */ }
|
|
139
|
+
return relative;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
function ensureSymlink(link, info, warn) {
|
|
144
|
+
if (!existsOrSymlink(link.src)) {
|
|
145
|
+
warn(`payload missing: ${link.src} (declared but not shipped)`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
fs.mkdirSync(path.dirname(link.dst), { recursive: true });
|
|
149
|
+
try {
|
|
150
|
+
const stat = fs.lstatSync(link.dst);
|
|
151
|
+
if (stat.isSymbolicLink()) {
|
|
152
|
+
const existing = fs.readlinkSync(link.dst);
|
|
153
|
+
if (existing === link.src)
|
|
154
|
+
return;
|
|
155
|
+
warn(`collision: ${link.dst} already points at ${existing} (wanted ${link.src}) — skipping`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
warn(`collision: ${link.dst} exists as regular file/dir — host content wins, skipping`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error.code !== 'ENOENT')
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
fs.symlinkSync(link.src, link.dst);
|
|
166
|
+
info(`linked ${link.dst} -> ${link.src}`);
|
|
167
|
+
}
|
|
168
|
+
/** Wire installed pack contributions into their table-declared host locations. */
|
|
169
|
+
export function scanPackages(hqRoot, options = {}) {
|
|
170
|
+
const log = options.log ?? ((message) => process.stdout.write(`${message}\n`));
|
|
171
|
+
const warnSink = options.warn ?? ((message) => process.stderr.write(`${message}\n`));
|
|
172
|
+
const info = (message) => { if (!options.quiet)
|
|
173
|
+
log(` ${message}`); };
|
|
174
|
+
const warn = (message) => warnSink(` [warn] ${message}`);
|
|
175
|
+
const packages = path.join(hqRoot, 'core/packages');
|
|
176
|
+
if (!isDirectory(packages)) {
|
|
177
|
+
info('[scan-packages] no core/packages/ dir; nothing to wire');
|
|
178
|
+
return { status: 0 };
|
|
179
|
+
}
|
|
180
|
+
let packageNames;
|
|
181
|
+
try {
|
|
182
|
+
packageNames = fs.readdirSync(packages).filter((name) => !name.startsWith('.')).sort();
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
warnSink(`[scan-packages] error: ${error.message}`);
|
|
186
|
+
return { status: 1 };
|
|
187
|
+
}
|
|
188
|
+
let any = false;
|
|
189
|
+
try {
|
|
190
|
+
for (const name of packageNames) {
|
|
191
|
+
const packDir = path.join(packages, name);
|
|
192
|
+
const manifest = path.join(packDir, 'package.yaml');
|
|
193
|
+
if (!isDirectory(packDir) || !fs.existsSync(manifest))
|
|
194
|
+
continue;
|
|
195
|
+
any = true;
|
|
196
|
+
info(`[scan-packages] wiring ${name}`);
|
|
197
|
+
for (const link of contributionLinks(hqRoot, packDir, readContributes(manifest))) {
|
|
198
|
+
if (link.key === 'workers') {
|
|
199
|
+
const clash = workerIdClashes(hqRoot, link.src);
|
|
200
|
+
if (clash) {
|
|
201
|
+
const id = workerId(path.join(link.src, 'worker.yaml'));
|
|
202
|
+
warn(`worker-id collision: pack '${name}' worker id '${id}' is already registered at /${clash} — refusing to wire ${link.dst} (would hard-fail the worker registry on a duplicate id). Resolve the duplicate id (or namespace the pack worker), then re-run.`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
ensureSymlink(link, info, warn);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
warnSink(`[scan-packages] error: ${error.message}`);
|
|
212
|
+
return { status: 1 };
|
|
213
|
+
}
|
|
214
|
+
if (!any)
|
|
215
|
+
info('[scan-packages] no hq-pack manifests found in core/packages');
|
|
216
|
+
return { status: 0 };
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type Logger = (message: string) => void;
|
|
2
|
+
export interface GenerateWorkersRegistryOptions {
|
|
3
|
+
now?: Date;
|
|
4
|
+
/** Receives the shell-compatible stderr lines, without their trailing newline. */
|
|
5
|
+
log?: Logger;
|
|
6
|
+
}
|
|
7
|
+
export interface GenerateWorkersRegistryResult {
|
|
8
|
+
status: 0 | 1;
|
|
9
|
+
written: boolean;
|
|
10
|
+
quarantined: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
|
|
14
|
+
* are quarantined but do not prevent all valid workers from being registered.
|
|
15
|
+
*/
|
|
16
|
+
export declare function generateWorkersRegistry(hqRoot: string, options?: GenerateWorkersRegistryOptions): GenerateWorkersRegistryResult;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Native implementation of core/scripts/generate-workers-registry.sh. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as yaml from 'js-yaml';
|
|
5
|
+
function isDirectory(target) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.statSync(target).isDirectory();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function isFile(target) {
|
|
14
|
+
try {
|
|
15
|
+
return fs.statSync(target).isFile();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** A sorted `find -L … -name worker.yaml -type f` equivalent, including symlinked pack workers. */
|
|
22
|
+
function workerYamlFiles(root, relativeRoot) {
|
|
23
|
+
const output = [];
|
|
24
|
+
const walk = (relative, ancestors) => {
|
|
25
|
+
const absolute = path.join(root, relative);
|
|
26
|
+
if (!isDirectory(absolute))
|
|
27
|
+
return;
|
|
28
|
+
let physical;
|
|
29
|
+
try {
|
|
30
|
+
physical = fs.realpathSync(absolute);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (ancestors.has(physical))
|
|
36
|
+
return; // `find -L` reports loops; a bounded walk is safer here.
|
|
37
|
+
const nextAncestors = new Set(ancestors).add(physical);
|
|
38
|
+
let names;
|
|
39
|
+
try {
|
|
40
|
+
names = fs.readdirSync(absolute).sort();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const childRelative = path.join(relative, name);
|
|
47
|
+
const child = path.join(root, childRelative);
|
|
48
|
+
if (name === 'worker.yaml' && isFile(child))
|
|
49
|
+
output.push(childRelative);
|
|
50
|
+
else if (isDirectory(child))
|
|
51
|
+
walk(childRelative, nextAncestors);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(relativeRoot, new Set());
|
|
55
|
+
return output;
|
|
56
|
+
}
|
|
57
|
+
const UNIT_SEPARATOR = String.fromCharCode(31);
|
|
58
|
+
function scalar(value) {
|
|
59
|
+
if (value === null || value === undefined)
|
|
60
|
+
return '';
|
|
61
|
+
return String(value).replace(/\n+$/, '').replace(/\n/g, ' ').replaceAll(UNIT_SEPARATOR, ' ');
|
|
62
|
+
}
|
|
63
|
+
/** yq's `.worker.<field> // ""` shape; malformed YAML degrades to empty required fields. */
|
|
64
|
+
function readWorkerFields(file) {
|
|
65
|
+
try {
|
|
66
|
+
const document = yaml.load(fs.readFileSync(file, 'utf8'));
|
|
67
|
+
const worker = document && typeof document === 'object'
|
|
68
|
+
? document.worker
|
|
69
|
+
: undefined;
|
|
70
|
+
const fields = worker && typeof worker === 'object'
|
|
71
|
+
? worker
|
|
72
|
+
: {};
|
|
73
|
+
return {
|
|
74
|
+
id: scalar(fields.id), type: scalar(fields.type), description: scalar(fields.description),
|
|
75
|
+
status: scalar(fields.status), company: scalar(fields.company), team: scalar(fields.team),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { id: '', type: '', description: '', status: '', company: '', team: '' };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function visibilityOf(relativePath) {
|
|
83
|
+
return relativePath.startsWith('companies/') ? 'private' : 'public';
|
|
84
|
+
}
|
|
85
|
+
function packAttribution(root, relativeDirectory) {
|
|
86
|
+
try {
|
|
87
|
+
const physical = fs.realpathSync(path.join(root, relativeDirectory));
|
|
88
|
+
const match = physical.match(/(?:^|[/\\])core[/\\]packages[/\\]([^/\\]+)/);
|
|
89
|
+
return match ? ` [source: pack ${match[1]}]` : '';
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function quote(value) {
|
|
96
|
+
// Deliberately mirrors `${value//\"/\\\"}` in the source shell script.
|
|
97
|
+
return value.replace(/"/g, '\\"');
|
|
98
|
+
}
|
|
99
|
+
function isoSeconds(now) {
|
|
100
|
+
return now.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
101
|
+
}
|
|
102
|
+
function registryText(now, entries) {
|
|
103
|
+
const lines = [
|
|
104
|
+
'# Workers Registry — AUTO-GENERATED by core/scripts/generate-workers-registry.sh',
|
|
105
|
+
'# DO NOT EDIT. Source of truth: worker.yaml in each worker\'s directory.',
|
|
106
|
+
'# Triggered by .claude/hooks/reindex.sh on Stop / PostToolUse-Write.',
|
|
107
|
+
'# To register a new worker: create its worker.yaml. Registry regenerates.',
|
|
108
|
+
'', 'version: "5.0"', `generated_at: "${isoSeconds(now)}"`, '', 'workers:',
|
|
109
|
+
];
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
lines.push(` - id: "${quote(entry.id)}"`);
|
|
112
|
+
lines.push(` path: "${quote(entry.path)}"`);
|
|
113
|
+
lines.push(` type: "${quote(entry.type)}"`);
|
|
114
|
+
lines.push(` visibility: "${entry.visibility}"`);
|
|
115
|
+
if (entry.team)
|
|
116
|
+
lines.push(` team: "${quote(entry.team)}"`);
|
|
117
|
+
if (entry.company)
|
|
118
|
+
lines.push(` company: "${quote(entry.company)}"`);
|
|
119
|
+
lines.push(` status: "${quote(entry.status)}"`);
|
|
120
|
+
lines.push(` description: "${quote(entry.description)}"`);
|
|
121
|
+
}
|
|
122
|
+
return `${lines.join('\n')}\n`;
|
|
123
|
+
}
|
|
124
|
+
function withoutTimestamp(content) {
|
|
125
|
+
return content.split('\n').filter((line) => !line.startsWith('generated_at:')).join('\n');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
|
|
129
|
+
* are quarantined but do not prevent all valid workers from being registered.
|
|
130
|
+
*/
|
|
131
|
+
export function generateWorkersRegistry(hqRoot, options = {}) {
|
|
132
|
+
const log = options.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
133
|
+
const entries = [];
|
|
134
|
+
let quarantined = 0;
|
|
135
|
+
const files = [
|
|
136
|
+
...workerYamlFiles(hqRoot, 'core/workers'),
|
|
137
|
+
...workerYamlFiles(hqRoot, 'companies'),
|
|
138
|
+
...workerYamlFiles(hqRoot, 'personal/workers'),
|
|
139
|
+
].sort();
|
|
140
|
+
for (const relativeFile of files) {
|
|
141
|
+
if (relativeFile.startsWith('companies/_template/') || /(?:^|[/\\])_overrides(?:[/\\]|$)/.test(relativeFile))
|
|
142
|
+
continue;
|
|
143
|
+
const fields = readWorkerFields(path.join(hqRoot, relativeFile));
|
|
144
|
+
const missing = ['id', 'type', 'description'].filter((key) => !fields[key]);
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
const relativeDir = path.dirname(relativeFile);
|
|
147
|
+
const attribution = packAttribution(hqRoot, relativeDir);
|
|
148
|
+
const remedy = attribution
|
|
149
|
+
? `this worker ships from an installed pack — its on-disk copy is likely stale; run 'hq packs update ${attribution.slice(' [source: pack '.length, -1)}' to refresh it (you cannot edit the protected core/ copy directly). If it persists after refreshing, the pack's worker.yaml needs the field(s) added upstream`
|
|
150
|
+
: 'fix the worker.yaml and re-run';
|
|
151
|
+
log(`generate-workers-registry: QUARANTINED ${relativeFile.replaceAll(path.sep, '/')} — missing required field(s): ${missing.join(', ')}${attribution} (excluded from registry; ${remedy})`);
|
|
152
|
+
quarantined++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
entries.push({
|
|
156
|
+
...fields,
|
|
157
|
+
status: fields.status === 'null' || !fields.status ? 'active' : fields.status,
|
|
158
|
+
company: fields.company === 'null' ? '' : fields.company,
|
|
159
|
+
team: fields.team === 'null' ? '' : fields.team,
|
|
160
|
+
path: `${path.dirname(relativeFile).replaceAll(path.sep, '/')}/`,
|
|
161
|
+
visibility: visibilityOf(relativeFile),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
entries.sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path));
|
|
165
|
+
const kept = [];
|
|
166
|
+
const hasDuplicates = entries.some((entry, index) => index > 0 && entries[index - 1].id === entry.id);
|
|
167
|
+
if (hasDuplicates) {
|
|
168
|
+
log('generate-workers-registry: WARNING duplicate worker id(s) detected — keeping the first copy of each and skipping the rest (registry still generated; resolve the clash to silence this):');
|
|
169
|
+
}
|
|
170
|
+
for (let index = 0; index < entries.length;) {
|
|
171
|
+
let end = index + 1;
|
|
172
|
+
while (end < entries.length && entries[end].id === entries[index].id)
|
|
173
|
+
end++;
|
|
174
|
+
const group = entries.slice(index, end);
|
|
175
|
+
const winner = group[0];
|
|
176
|
+
kept.push(winner);
|
|
177
|
+
if (group.length > 1) {
|
|
178
|
+
log(` duplicate id '${winner.id}' — KEEPING ${winner.path}${packAttribution(hqRoot, winner.path)}`);
|
|
179
|
+
for (const skipped of group.slice(1)) {
|
|
180
|
+
log(` duplicate id '${skipped.id}' — SKIPPING ${skipped.path}${packAttribution(hqRoot, skipped.path)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
index = end;
|
|
184
|
+
}
|
|
185
|
+
if (hasDuplicates)
|
|
186
|
+
log(' Fix: change worker.id in one of the colliding worker.yaml files (or namespace the pack\'s id) to make it unique.');
|
|
187
|
+
const output = registryText(options.now ?? new Date(), kept);
|
|
188
|
+
const registry = path.join(hqRoot, 'core/workers/registry.yaml');
|
|
189
|
+
let written = true;
|
|
190
|
+
if (fs.existsSync(registry) && withoutTimestamp(fs.readFileSync(registry, 'utf8')) === withoutTimestamp(output))
|
|
191
|
+
written = false;
|
|
192
|
+
if (written) {
|
|
193
|
+
fs.mkdirSync(path.dirname(registry), { recursive: true });
|
|
194
|
+
fs.writeFileSync(registry, output);
|
|
195
|
+
log('generate-workers-registry: wrote core/workers/registry.yaml');
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
log('generate-workers-registry: core/workers/registry.yaml unchanged');
|
|
199
|
+
}
|
|
200
|
+
if (quarantined > 0) {
|
|
201
|
+
log(`generate-workers-registry: wrote registry for all VALID workers; quarantined ${quarantined} problem(s) above (loud + partial, never a silent total block). Fix the reported worker.yaml file(s) and re-run to register them.`);
|
|
202
|
+
return { status: 1, written, quarantined };
|
|
203
|
+
}
|
|
204
|
+
return { status: 0, written, quarantined };
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=index.js.map
|