@indigoai-us/hq-cli 5.328.0 → 5.330.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 +13 -0
- package/dist/command-catalog.generated.d.ts +3699 -3
- package/dist/command-catalog.generated.js +5462 -1759
- package/dist/command-help.d.ts +2 -0
- package/dist/command-help.js +13 -0
- package/dist/commands/cloud.d.ts +5 -0
- package/dist/commands/cloud.js +51 -1
- package/dist/commands/install-global.d.ts +21 -0
- package/dist/commands/install-global.js +267 -32
- package/dist/commands/lanes-message.js +59 -6
- package/dist/commands/lanes-resume.js +66 -15
- package/dist/commands/lanes-shared.d.ts +9 -2
- package/dist/commands/lanes.js +97 -8
- package/dist/commands/mcp-status.js +58 -1
- package/dist/commands/pack-install.d.ts +3 -1
- package/dist/commands/pack-install.js +17 -4
- package/dist/commands/packs.js +17 -0
- package/dist/commands/pkg-install.js +13 -1
- package/dist/fast-scaffold.d.ts +2 -0
- package/dist/fast-scaffold.js +16 -0
- package/dist/index.js +19 -2
- package/dist/lazy-commands.d.ts +1 -0
- package/dist/lazy-commands.js +64 -4
- package/dist/lib/cli-flag-keys.d.ts +3 -0
- package/dist/lib/cli-flag-keys.js +3 -0
- package/dist/lib/daemon/ops/mcp.js +11 -10
- package/dist/lib/daemon/ops/policy.d.ts +3 -2
- package/dist/lib/daemon/ops/policy.js +12 -6
- package/dist/lib/daemon/server.d.ts +3 -1
- package/dist/lib/daemon/server.js +63 -7
- package/dist/lib/doctor/checks/anywhere.js +58 -4
- package/dist/lib/install/chatgpt-targets.d.ts +8 -0
- package/dist/lib/install/chatgpt-targets.js +15 -0
- package/dist/lib/install/codex-pack-contributions.d.ts +8 -0
- package/dist/lib/install/codex-pack-contributions.js +205 -0
- package/dist/lib/install/codex-targets.d.ts +1 -1
- package/dist/lib/install/codex-targets.js +2 -1
- package/dist/lib/install/grok-targets.d.ts +8 -0
- package/dist/lib/install/grok-targets.js +15 -0
- package/dist/lib/lanes/flag-keys.d.ts +2 -0
- package/dist/lib/lanes/flag-keys.js +2 -0
- package/dist/lib/lanes/keep-alive-audit.d.ts +3 -2
- package/dist/lib/lanes/keep-alive-audit.js +36 -8
- package/dist/lib/lanes/spawn.js +14 -10
- package/dist/lib/lanes/store.js +29 -2
- package/dist/lib/lanes/types.d.ts +10 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/sync-invalid-entity-warning.d.ts +32 -0
- package/dist/lib/sync-invalid-entity-warning.js +205 -0
- package/dist/main.d.ts +2 -6
- package/dist/main.js +3 -41
- package/dist/stream-error-boundary.d.ts +7 -0
- package/dist/stream-error-boundary.js +33 -0
- package/package.json +1 -1
|
@@ -16,6 +16,8 @@ import { CLI_VERSION } from "../../../cli-version.js";
|
|
|
16
16
|
import { computeMcpStatus } from "../../../commands/mcp-status.js";
|
|
17
17
|
import { HQ_MCP_SERVER_NAME, SKILL_LINK_PREFIX, claudeTargets } from "../../install/claude-targets.js";
|
|
18
18
|
import { codexTargets } from "../../install/codex-targets.js";
|
|
19
|
+
import { CHATGPT_MODE } from "../../install/chatgpt-targets.js";
|
|
20
|
+
import { GROK_MODE } from "../../install/grok-targets.js";
|
|
19
21
|
import { pingHqdVersion } from "../../daemon/convergence.js";
|
|
20
22
|
import { daemonPresenceLabel, heartbeatFile, readHeartbeat } from "../../daemon/heartbeat.js";
|
|
21
23
|
import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK } from "../../daemon/install.js";
|
|
@@ -42,10 +44,16 @@ export async function runAnywhereChecks(_context, deps = {}) {
|
|
|
42
44
|
const claude = claudeTargets(home);
|
|
43
45
|
const codex = codexTargets(home);
|
|
44
46
|
const installed = [claude.hqPointer, claude.stateFile, codex.stateFile].some((p) => fs.existsSync(p));
|
|
47
|
+
const remoteModes = [
|
|
48
|
+
result("WARN", "chatgpt-mode", `ChatGPT integration mode: ${CHATGPT_MODE}; host connector setup is managed in ChatGPT`, "hq install --global --runtime chatgpt"),
|
|
49
|
+
result("WARN", "grok-mode", `Grok integration mode: ${GROK_MODE}; host connector setup is managed in Grok`, "hq install --global --runtime grok"),
|
|
50
|
+
];
|
|
45
51
|
if (!installed) {
|
|
46
|
-
return [result("NA", "installed", "HQ anywhere is not installed on this machine", REMEDIATION.installClaude)];
|
|
52
|
+
return [...remoteModes, result("NA", "installed", "HQ local anywhere runtime is not installed on this machine", REMEDIATION.installClaude)];
|
|
47
53
|
}
|
|
48
|
-
const out = [];
|
|
54
|
+
const out = [...remoteModes];
|
|
55
|
+
const pluginInstall = readClaudePluginInstall(claude.stateFile);
|
|
56
|
+
out.push(checkInstallMode(claude.stateFile));
|
|
49
57
|
out.push(checkPointer(claude.hqPointer));
|
|
50
58
|
out.push(checkRegistry(registryFile(registryDir(home, env))));
|
|
51
59
|
const service = (deps.serviceState ?? (() => defaultServiceState(home)))();
|
|
@@ -75,13 +83,59 @@ export async function runAnywhereChecks(_context, deps = {}) {
|
|
|
75
83
|
out.push(checkHeartbeat(heartbeatFile(home, env), (deps.now ?? (() => new Date()))()));
|
|
76
84
|
const mcp = computeMcpStatus({ home });
|
|
77
85
|
const server = mcp.servers.find((s) => s.server === HQ_MCP_SERVER_NAME);
|
|
78
|
-
|
|
86
|
+
if (pluginInstall)
|
|
87
|
+
out.push(checkClaudePlugin(claude.settingsJson, pluginInstall));
|
|
88
|
+
else
|
|
89
|
+
out.push(checkRegistration("claude", server?.claude, mcp.runtimes.claude, REMEDIATION.installClaude));
|
|
79
90
|
out.push(checkRegistration("codex", server?.codex, mcp.runtimes.codex, REMEDIATION.installCodex));
|
|
80
|
-
|
|
91
|
+
if (pluginInstall)
|
|
92
|
+
out.push(result("PASS", "claude-skills", `HQ Claude skills are provided by plugin ${pluginInstall.pluginId}`));
|
|
93
|
+
else
|
|
94
|
+
out.push(checkSkillLinks("claude", claude.skillsDir, REMEDIATION.installClaude));
|
|
81
95
|
if (mcp.runtimes.codex.installed)
|
|
82
96
|
out.push(checkSkillLinks("codex", codex.skillsDir, REMEDIATION.installCodex));
|
|
83
97
|
return out;
|
|
84
98
|
}
|
|
99
|
+
function checkInstallMode(file) {
|
|
100
|
+
try {
|
|
101
|
+
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
102
|
+
if (typeof state.plugin?.pluginId === 'string') {
|
|
103
|
+
return result('PASS', 'install-mode', `Claude Code install mode: plugin (${state.plugin.pluginId})`, undefined, file);
|
|
104
|
+
}
|
|
105
|
+
return result('PASS', 'install-mode', 'Claude Code install mode: direct hooks and MCP', undefined, file);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code === 'ENOENT') {
|
|
109
|
+
return result('WARN', 'install-mode', 'Claude Code install mode is unknown (install state is missing)', REMEDIATION.installClaude, file);
|
|
110
|
+
}
|
|
111
|
+
return result('FAIL', 'install-mode', `Claude Code install state is unreadable: ${error.message}`, REMEDIATION.installClaude, file);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function readClaudePluginInstall(file) {
|
|
115
|
+
try {
|
|
116
|
+
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
117
|
+
const plugin = state.plugin;
|
|
118
|
+
if (typeof plugin?.pluginId === 'string' && typeof plugin.marketplaceName === 'string' && typeof plugin.sourceDir === 'string')
|
|
119
|
+
return plugin;
|
|
120
|
+
}
|
|
121
|
+
catch { /* checkInstallMode reports missing or unreadable state */ }
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
function checkClaudePlugin(settingsFile, plugin) {
|
|
125
|
+
const remediation = `hq install --global --runtime claude --via plugin --marketplace-dir "${plugin.sourceDir}"`;
|
|
126
|
+
try {
|
|
127
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
128
|
+
if (settings.enabledPlugins?.[plugin.pluginId] !== true)
|
|
129
|
+
return result('FAIL', 'claude-registration', `HQ Claude plugin ${plugin.pluginId} is not enabled`, remediation, settingsFile);
|
|
130
|
+
const marketplace = settings.extraKnownMarketplaces?.[plugin.marketplaceName];
|
|
131
|
+
if (marketplace?.source?.source !== 'directory' || marketplace.source.path !== plugin.sourceDir)
|
|
132
|
+
return result('FAIL', 'claude-registration', `HQ Claude plugin marketplace ${plugin.marketplaceName} is not registered from ${plugin.sourceDir}`, remediation, settingsFile);
|
|
133
|
+
return result('PASS', 'claude-registration', `HQ Claude plugin ${plugin.pluginId} is enabled from its recorded marketplace`);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return result('FAIL', 'claude-registration', `Claude plugin settings are unreadable: ${error.message}`, remediation, settingsFile);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
85
139
|
function checkPointer(file) {
|
|
86
140
|
let root;
|
|
87
141
|
try {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** ChatGPT host integration for US-015.
|
|
2
|
+
*
|
|
3
|
+
* ChatGPT currently accepts remote MCP apps, not direct local stdio servers.
|
|
4
|
+
* Keep this target read-only: the user creates the hosted connector in ChatGPT.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CHATGPT_MODE: "remote-connector";
|
|
7
|
+
export declare function chatgptConnectorInstructions(endpoint: string): string[];
|
|
8
|
+
//# sourceMappingURL=chatgpt-targets.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** ChatGPT host integration for US-015.
|
|
2
|
+
*
|
|
3
|
+
* ChatGPT currently accepts remote MCP apps, not direct local stdio servers.
|
|
4
|
+
* Keep this target read-only: the user creates the hosted connector in ChatGPT.
|
|
5
|
+
*/
|
|
6
|
+
export const CHATGPT_MODE = "remote-connector";
|
|
7
|
+
export function chatgptConnectorInstructions(endpoint) {
|
|
8
|
+
return [
|
|
9
|
+
"ChatGPT supports hosted MCP connectors, not direct local MCP registration.",
|
|
10
|
+
`Hosted HQ MCP URL: ${endpoint}`,
|
|
11
|
+
"Enable it in ChatGPT web: Settings > Apps > Create, then add the URL as a custom MCP app. Workspace developer mode may need an admin to enable it first.",
|
|
12
|
+
"HQ did not write a ChatGPT configuration file.",
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=chatgpt-targets.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type Doc = Record<string, unknown>;
|
|
2
|
+
export declare function syncCodexPackSkills(packDir: string, packName: string, names: string[], remove?: boolean, home?: string): string[];
|
|
3
|
+
export declare function mergeCodexPackHooks(doc: Doc, packDir: string, items: string[]): Doc;
|
|
4
|
+
export declare function removeCodexPackHooks(doc: Doc, packDir: string, items: string[]): Doc;
|
|
5
|
+
export declare function syncCodexPackHooks(packDir: string, items: string[], remove?: boolean, home?: string): void;
|
|
6
|
+
export declare function readCodexPackHooks(target: string): Doc;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=codex-pack-contributions.d.ts.map
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { anywhereRuntimeEnabled } from '../../commands/anywhere-runtime-flag.js';
|
|
5
|
+
import { jsonFormat, readConfigDoc, writeConfigAtomic } from '../../commands/mcp-registration.js';
|
|
6
|
+
const configFormat = {
|
|
7
|
+
parse: jsonFormat.parse,
|
|
8
|
+
serialize: jsonFormat.serialize,
|
|
9
|
+
emptyDoc: () => ({}),
|
|
10
|
+
assertEntry(doc, name) {
|
|
11
|
+
const hooks = doc.hooks;
|
|
12
|
+
if (name.startsWith('hooks-remove:')) {
|
|
13
|
+
const removedRoot = name.slice('hooks-remove:'.length);
|
|
14
|
+
if (hooks && Object.values(hooks).some((groups) => groups.some((group) => group.hooks?.some((hook) => typeof hook.command === 'string' && hook.command.includes(removedRoot))))) {
|
|
15
|
+
throw new Error(`Codex hook registrations for ${removedRoot} remain after removal`);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!hooks || !Object.values(hooks).some((groups) => groups.some((group) => group.hooks?.some((hook) => typeof hook.command === 'string' && hook.command.includes(name))))) {
|
|
20
|
+
throw new Error(`Codex hook registrations for ${name} were not written`);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
function hooksPath(home) { return path.join(home, '.codex', 'hooks.json'); }
|
|
25
|
+
const ownerFile = '.hq-pack-owner';
|
|
26
|
+
const validContributionName = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
27
|
+
function assertContributionNames(items) {
|
|
28
|
+
for (const item of items)
|
|
29
|
+
if (!validContributionName.test(item))
|
|
30
|
+
throw new Error(`Invalid Codex pack contribution name: ${item}`);
|
|
31
|
+
}
|
|
32
|
+
function hasPackOwner(target, packName) {
|
|
33
|
+
try {
|
|
34
|
+
const dirStat = fs.lstatSync(target);
|
|
35
|
+
const markerPath = path.join(target, ownerFile);
|
|
36
|
+
const markerStat = fs.lstatSync(markerPath);
|
|
37
|
+
return dirStat.isDirectory() && !dirStat.isSymbolicLink() && markerStat.isFile() && !markerStat.isSymbolicLink() && fs.readFileSync(markerPath, 'utf8') === `${packName}\n`;
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e.code === 'ENOENT')
|
|
41
|
+
return false;
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function syncCodexPackSkills(packDir, packName, names, remove = false, home = os.homedir()) {
|
|
46
|
+
assertContributionNames(names);
|
|
47
|
+
if ((!remove && !anywhereRuntimeEnabled()) || !fs.existsSync(path.join(home, '.codex')))
|
|
48
|
+
return [];
|
|
49
|
+
const root = path.join(home, '.codex', 'skills');
|
|
50
|
+
const warnings = [];
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const source = path.join(packDir, 'skills', name);
|
|
53
|
+
const target = path.join(root, name);
|
|
54
|
+
if (!source.startsWith(`${path.resolve(packDir, 'skills')}${path.sep}`))
|
|
55
|
+
throw new Error(`Invalid Codex skill path: ${name}`);
|
|
56
|
+
if (remove) {
|
|
57
|
+
if (hasPackOwner(target, packName))
|
|
58
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
assertSafeSkillTree(source, packDir, name);
|
|
62
|
+
let targetExists = false;
|
|
63
|
+
try {
|
|
64
|
+
fs.lstatSync(target);
|
|
65
|
+
targetExists = true;
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e.code !== 'ENOENT')
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
if (targetExists) {
|
|
72
|
+
const owned = hasPackOwner(target, packName);
|
|
73
|
+
if (!owned) {
|
|
74
|
+
warnings.push(`left foreign Codex skill in place: ${name}`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
fs.mkdirSync(root, { recursive: true });
|
|
80
|
+
copySafeSkillTree(source, target);
|
|
81
|
+
fs.writeFileSync(path.join(target, ownerFile), `${packName}\n`, { flag: 'wx', mode: 0o644 });
|
|
82
|
+
}
|
|
83
|
+
return warnings;
|
|
84
|
+
}
|
|
85
|
+
function assertSafeSkillTree(source, packDir, name) {
|
|
86
|
+
const root = path.resolve(packDir);
|
|
87
|
+
const resolved = path.resolve(source);
|
|
88
|
+
const stat = fs.lstatSync(source);
|
|
89
|
+
if (!resolved.startsWith(`${root}${path.sep}`) || !stat.isDirectory() || stat.isSymbolicLink()) {
|
|
90
|
+
throw new Error(`Codex skill payload must be a real directory inside the pack: ${name}`);
|
|
91
|
+
}
|
|
92
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
93
|
+
const child = path.join(source, entry.name);
|
|
94
|
+
const childStat = fs.lstatSync(child);
|
|
95
|
+
if (childStat.isSymbolicLink())
|
|
96
|
+
throw new Error(`Codex skill payload contains a symlink: ${name}/${entry.name}`);
|
|
97
|
+
if (childStat.isDirectory())
|
|
98
|
+
assertSafeSkillTree(child, packDir, name);
|
|
99
|
+
else if (!childStat.isFile())
|
|
100
|
+
throw new Error(`Codex skill payload contains an unsupported entry: ${name}/${entry.name}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function copySafeSkillTree(source, target) {
|
|
104
|
+
fs.mkdirSync(target, { recursive: false });
|
|
105
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
106
|
+
const from = path.join(source, entry.name);
|
|
107
|
+
const to = path.join(target, entry.name);
|
|
108
|
+
const stat = fs.lstatSync(from);
|
|
109
|
+
if (stat.isSymbolicLink())
|
|
110
|
+
throw new Error(`Codex skill payload contains a symlink: ${entry.name}`);
|
|
111
|
+
if (stat.isDirectory())
|
|
112
|
+
copySafeSkillTree(from, to);
|
|
113
|
+
else if (stat.isFile())
|
|
114
|
+
fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
|
|
115
|
+
else
|
|
116
|
+
throw new Error(`Codex skill payload contains an unsupported entry: ${entry.name}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function readRegistrations(packDir, items) {
|
|
120
|
+
assertContributionNames(items);
|
|
121
|
+
const all = {};
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
const file = path.join(packDir, 'hooks', `${item}-hooks.json`);
|
|
124
|
+
if (!fs.existsSync(file))
|
|
125
|
+
continue;
|
|
126
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
127
|
+
if (!parsed || typeof parsed !== 'object' || !('hooks' in parsed) || !parsed.hooks || typeof parsed.hooks !== 'object' || Array.isArray(parsed.hooks)) {
|
|
128
|
+
throw new Error(`Invalid Codex hook registration template: ${file}`);
|
|
129
|
+
}
|
|
130
|
+
for (const [event, groups] of Object.entries(parsed.hooks)) {
|
|
131
|
+
if (!Array.isArray(groups))
|
|
132
|
+
throw new Error(`Invalid Codex hook event ${event} in ${file}`);
|
|
133
|
+
const resolved = groups.map((group) => ({
|
|
134
|
+
...group,
|
|
135
|
+
hooks: (group.hooks ?? []).map((hook) => {
|
|
136
|
+
if (typeof hook.command !== 'string' || !hook.command.startsWith('hooks/'))
|
|
137
|
+
throw new Error(`Invalid pack-relative hook command in ${file}`);
|
|
138
|
+
const rel = hook.command.slice('hooks/'.length);
|
|
139
|
+
const abs = path.resolve(packDir, 'hooks', rel);
|
|
140
|
+
if (!abs.startsWith(`${path.resolve(packDir)}${path.sep}`) || !fs.existsSync(abs))
|
|
141
|
+
throw new Error(`Hook target is missing or outside the installed pack: ${hook.command}`);
|
|
142
|
+
const timeout = event === 'SessionEnd' ? 3 : 300;
|
|
143
|
+
return { ...hook, command: `/bin/sh ${JSON.stringify(abs)}`, timeout };
|
|
144
|
+
}),
|
|
145
|
+
}));
|
|
146
|
+
all[event] = [...(all[event] ?? []), ...resolved];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return all;
|
|
150
|
+
}
|
|
151
|
+
export function mergeCodexPackHooks(doc, packDir, items) {
|
|
152
|
+
const hooks = { ...(doc.hooks ?? {}) };
|
|
153
|
+
const registrations = readRegistrations(packDir, items);
|
|
154
|
+
removeCommands(hooks, registeredCommands(registrations));
|
|
155
|
+
for (const [event, groups] of Object.entries(registrations))
|
|
156
|
+
hooks[event] = [...(hooks[event] ?? []), ...groups];
|
|
157
|
+
return { ...doc, ...(Object.keys(hooks).length ? { hooks } : {}) };
|
|
158
|
+
}
|
|
159
|
+
export function removeCodexPackHooks(doc, packDir, items) {
|
|
160
|
+
const hooks = { ...(doc.hooks ?? {}) };
|
|
161
|
+
removeCommands(hooks, registeredCommands(readRegistrations(packDir, items)));
|
|
162
|
+
const next = { ...doc };
|
|
163
|
+
if (Object.keys(hooks).length)
|
|
164
|
+
next.hooks = hooks;
|
|
165
|
+
else
|
|
166
|
+
delete next.hooks;
|
|
167
|
+
return next;
|
|
168
|
+
}
|
|
169
|
+
function registeredCommands(registrations) {
|
|
170
|
+
const commands = new Set();
|
|
171
|
+
for (const groups of Object.values(registrations))
|
|
172
|
+
for (const group of groups) {
|
|
173
|
+
for (const hook of group.hooks ?? [])
|
|
174
|
+
if (typeof hook.command === 'string')
|
|
175
|
+
commands.add(hook.command);
|
|
176
|
+
}
|
|
177
|
+
return commands;
|
|
178
|
+
}
|
|
179
|
+
function removeCommands(hooks, commands) {
|
|
180
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
181
|
+
hooks[event] = groups.flatMap((group) => {
|
|
182
|
+
const kept = (group.hooks ?? []).filter((hook) => typeof hook.command !== 'string' || !commands.has(hook.command));
|
|
183
|
+
return kept.length ? [{ ...group, hooks: kept }] : [];
|
|
184
|
+
});
|
|
185
|
+
if (hooks[event].length === 0)
|
|
186
|
+
delete hooks[event];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function syncCodexPackHooks(packDir, items, remove = false, home = os.homedir()) {
|
|
190
|
+
assertContributionNames(items);
|
|
191
|
+
if ((!remove && !anywhereRuntimeEnabled()) || !fs.existsSync(path.join(home, '.codex')))
|
|
192
|
+
return;
|
|
193
|
+
const codexItems = items.filter((item) => fs.existsSync(path.join(packDir, 'hooks', `${item}-hooks.json`)));
|
|
194
|
+
if (codexItems.length === 0)
|
|
195
|
+
return;
|
|
196
|
+
const target = hooksPath(home);
|
|
197
|
+
writeConfigAtomic({
|
|
198
|
+
target, pack: 'hq-anywhere', format: configFormat,
|
|
199
|
+
merge: (doc) => remove ? removeCodexPackHooks(doc, packDir, codexItems) : mergeCodexPackHooks(doc, packDir, codexItems),
|
|
200
|
+
assertName: remove ? `hooks-remove:${path.resolve(packDir)}${path.sep}` : codexItems[0],
|
|
201
|
+
env: { home },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
export function readCodexPackHooks(target) { return readConfigDoc(target, configFormat).doc; }
|
|
205
|
+
//# sourceMappingURL=codex-pack-contributions.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type HookTarget } from './claude-targets.js';
|
|
2
2
|
/** Events the Codex hook adapter is registered for at user scope. */
|
|
3
|
-
export declare const CODEX_HOOK_EVENTS: readonly ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"];
|
|
3
|
+
export declare const CODEX_HOOK_EVENTS: readonly ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop", "SessionEnd"];
|
|
4
4
|
export declare const AGENTS_MD_BEGIN = "<!-- hq-anywhere:begin (managed by `hq install --global --runtime codex`; remove with `hq uninstall --global --runtime codex`) -->";
|
|
5
5
|
export declare const AGENTS_MD_END = "<!-- hq-anywhere:end -->";
|
|
6
6
|
/** Name of the legacy ad-hoc skills mirror (~/.codex/skills/hq). */
|
|
@@ -19,6 +19,7 @@ export const CODEX_HOOK_EVENTS = [
|
|
|
19
19
|
'PreToolUse',
|
|
20
20
|
'PostToolUse',
|
|
21
21
|
'Stop',
|
|
22
|
+
'SessionEnd',
|
|
22
23
|
];
|
|
23
24
|
export const AGENTS_MD_BEGIN = '<!-- hq-anywhere:begin (managed by `hq install --global --runtime codex`; remove with `hq uninstall --global --runtime codex`) -->';
|
|
24
25
|
export const AGENTS_MD_END = '<!-- hq-anywhere:end -->';
|
|
@@ -108,7 +109,7 @@ export function addCodexHooks(doc, hqRoot, target = 'master') {
|
|
|
108
109
|
if (present)
|
|
109
110
|
continue;
|
|
110
111
|
groups = removeCodexHooks({ hooks: { [event]: groups } }, hqRoot).hooks?.[event] ?? [];
|
|
111
|
-
groups.push({ hooks: [{ type: 'command', command: want, timeout: 300 }] });
|
|
112
|
+
groups.push({ hooks: [{ type: 'command', command: want, timeout: event === 'SessionEnd' ? 3 : 300 }] });
|
|
112
113
|
hooks[event] = groups;
|
|
113
114
|
changed = true;
|
|
114
115
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Grok host integration for US-015.
|
|
2
|
+
*
|
|
3
|
+
* Grok custom MCP connectors are remote URL integrations. The local CLI does
|
|
4
|
+
* not write host configuration; users add the hosted HQ MCP endpoint in Grok.
|
|
5
|
+
*/
|
|
6
|
+
export declare const GROK_MODE: "remote-connector";
|
|
7
|
+
export declare function grokConnectorInstructions(endpoint: string): string[];
|
|
8
|
+
//# sourceMappingURL=grok-targets.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Grok host integration for US-015.
|
|
2
|
+
*
|
|
3
|
+
* Grok custom MCP connectors are remote URL integrations. The local CLI does
|
|
4
|
+
* not write host configuration; users add the hosted HQ MCP endpoint in Grok.
|
|
5
|
+
*/
|
|
6
|
+
export const GROK_MODE = "remote-connector";
|
|
7
|
+
export function grokConnectorInstructions(endpoint) {
|
|
8
|
+
return [
|
|
9
|
+
"Grok supports remote custom MCP connectors; it cannot register a local stdio server directly.",
|
|
10
|
+
`Hosted HQ MCP URL: ${endpoint}`,
|
|
11
|
+
"Enable it at grok.com/connectors > New Connector > Custom, then enter the URL and finish authentication.",
|
|
12
|
+
"HQ did not write a Grok configuration file.",
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=grok-targets.js.map
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** hq-flags keys used by lane command behavior. */
|
|
2
2
|
export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
3
|
+
/** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
|
|
4
|
+
export declare const LANE_REUSE_REFRESHES_PROFILE_FLAG = "lanes.reuse-refreshes-profile";
|
|
3
5
|
/** Enables one automatic continuation after a missing-envelope repair. */
|
|
4
6
|
export declare const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
|
|
5
7
|
/** Count only children with live work against their senior's hire grant. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** hq-flags keys used by lane command behavior. */
|
|
2
2
|
export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
3
|
+
/** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
|
|
4
|
+
export const LANE_REUSE_REFRESHES_PROFILE_FLAG = "lanes.reuse-refreshes-profile";
|
|
3
5
|
/** Enables one automatic continuation after a missing-envelope repair. */
|
|
4
6
|
export const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
|
|
5
7
|
/** Count only children with live work against their senior's hire grant. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "not_restarted" | "keep_alive_off" | "waiting";
|
|
1
|
+
type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "keep_alive_off" | "waiting";
|
|
2
2
|
interface KeepAliveAuditRestart {
|
|
3
|
-
at: string;
|
|
3
|
+
at: string | null;
|
|
4
4
|
trigger: string;
|
|
5
5
|
caller: string | null;
|
|
6
6
|
}
|
|
@@ -18,6 +18,7 @@ interface KeepAliveAuditRow {
|
|
|
18
18
|
interface KeepAliveAuditSummary {
|
|
19
19
|
by_verdict: Record<KeepAliveAuditVerdict, number>;
|
|
20
20
|
not_restarted: number;
|
|
21
|
+
restarted_untimed: number;
|
|
21
22
|
late_over_10m: number;
|
|
22
23
|
early_wake_by_others: number;
|
|
23
24
|
self_wake: number;
|
|
@@ -4,6 +4,7 @@ import { LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS } from "./keep-alive.js";
|
|
|
4
4
|
import { LanesError } from "./errors.js";
|
|
5
5
|
import { laneRunDir } from "./spawn.js";
|
|
6
6
|
import { listQuestionIds, readAllLanes, tryReadQuestion } from "./store.js";
|
|
7
|
+
import { HISTORY_CAP } from "./types.js";
|
|
7
8
|
function readDoneClassifications(hqRoot, laneId) {
|
|
8
9
|
const root = fs.realpathSync(hqRoot);
|
|
9
10
|
const watcherLog = path.join(laneRunDir(root, laneId), "logs", "watcher.out");
|
|
@@ -56,6 +57,7 @@ const VERDICTS = [
|
|
|
56
57
|
"message_wake",
|
|
57
58
|
"self_wake",
|
|
58
59
|
"early_by_keep_alive",
|
|
60
|
+
"restarted_untimed",
|
|
59
61
|
"keep_alive_off",
|
|
60
62
|
"not_restarted",
|
|
61
63
|
"waiting",
|
|
@@ -285,17 +287,42 @@ function firstRestartAfter(lane, stop, nextEnvelopeMs) {
|
|
|
285
287
|
.map((entry) => ({ entry, at: parseTime(entry.at) }))
|
|
286
288
|
.filter(({ entry, at }) => at !== undefined && at > stop.mtimeMs && (nextEnvelopeMs === undefined || at < nextEnvelopeMs) && typeof entry.trigger === "string")
|
|
287
289
|
.sort((a, b) => a.at - b.at)[0];
|
|
288
|
-
if (
|
|
290
|
+
if (restart?.at !== undefined) {
|
|
291
|
+
return {
|
|
292
|
+
at: iso(restart.at),
|
|
293
|
+
trigger: restart.entry.trigger,
|
|
294
|
+
caller: typeof restart.entry.caller === "string" ? restart.entry.caller : null,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// resume_history is capped and may no longer retain the launch that followed
|
|
298
|
+
// an older stop. keep_alive_history is the durable automatic-resume record;
|
|
299
|
+
// only count it when a later envelope confirms the queued turn actually ran.
|
|
300
|
+
if (nextEnvelopeMs === undefined)
|
|
289
301
|
return null;
|
|
290
|
-
|
|
291
|
-
at:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
302
|
+
const keepAliveResume = (lane.keep_alive_history ?? [])
|
|
303
|
+
.map((entry) => ({ entry, at: parseTime(entry.at) }))
|
|
304
|
+
.filter(({ entry, at }) => entry.resumed_from === "done" && at !== undefined && at > stop.mtimeMs && at < nextEnvelopeMs)
|
|
305
|
+
.sort((a, b) => a.at - b.at)[0];
|
|
306
|
+
if (keepAliveResume) {
|
|
307
|
+
// This timestamp records when reconcile queued the resume, not when the
|
|
308
|
+
// provider actually launched. The next envelope confirms a turn ran, but
|
|
309
|
+
// cannot establish its launch time.
|
|
310
|
+
return { at: null, trigger: "keep_alive", caller: null };
|
|
311
|
+
}
|
|
312
|
+
const resumeHistory = lane.resume_history ?? [];
|
|
313
|
+
const keepAliveHistory = lane.keep_alive_history ?? [];
|
|
314
|
+
if (resumeHistory.length >= HISTORY_CAP && keepAliveHistory.length >= HISTORY_CAP) {
|
|
315
|
+
// Both append-only histories can roll this resume out. The later envelope
|
|
316
|
+
// proves a turn ran, while its trigger and launch time are no longer known.
|
|
317
|
+
return { at: null, trigger: "unknown", caller: null };
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
295
320
|
}
|
|
296
321
|
function classifyVerdict(input) {
|
|
297
322
|
const { dueAt, restart, lane, nowMs } = input;
|
|
298
323
|
if (restart) {
|
|
324
|
+
if (restart.at === null)
|
|
325
|
+
return "restarted_untimed";
|
|
299
326
|
const restartAt = Date.parse(restart.at);
|
|
300
327
|
if (restart.caller === lane.lane_id)
|
|
301
328
|
return "self_wake";
|
|
@@ -388,7 +415,7 @@ export function buildKeepAliveAudit(input) {
|
|
|
388
415
|
let lateOverTenMinutes = 0;
|
|
389
416
|
for (const row of rows) {
|
|
390
417
|
byVerdict[row.verdict] += 1;
|
|
391
|
-
if (row.verdict === "late" && row.restart && Date.parse(row.restart.at) - Date.parse(row.due_at) > LATE_KPI_THRESHOLD_MS) {
|
|
418
|
+
if (row.verdict === "late" && row.restart !== null && row.restart.at !== null && Date.parse(row.restart.at) - Date.parse(row.due_at) > LATE_KPI_THRESHOLD_MS) {
|
|
392
419
|
lateOverTenMinutes += 1;
|
|
393
420
|
}
|
|
394
421
|
}
|
|
@@ -399,6 +426,7 @@ export function buildKeepAliveAudit(input) {
|
|
|
399
426
|
summary: {
|
|
400
427
|
by_verdict: byVerdict,
|
|
401
428
|
not_restarted: byVerdict.not_restarted,
|
|
429
|
+
restarted_untimed: byVerdict.restarted_untimed,
|
|
402
430
|
late_over_10m: lateOverTenMinutes,
|
|
403
431
|
early_wake_by_others: byVerdict.early_wake,
|
|
404
432
|
self_wake: byVerdict.self_wake,
|
|
@@ -416,7 +444,7 @@ export function formatKeepAliveAuditText(result) {
|
|
|
416
444
|
row.stopped_at,
|
|
417
445
|
row.next_check_at ?? "—",
|
|
418
446
|
row.due_at,
|
|
419
|
-
row.restart ? `${row.restart.at} ${row.restart.trigger}${row.restart.caller ? ` (${row.restart.caller})` : ""}` : "—",
|
|
447
|
+
row.restart ? `${row.restart.at ?? "untimed"} ${row.restart.trigger}${row.restart.caller ? ` (${row.restart.caller})` : ""}` : "—",
|
|
420
448
|
row.verdict,
|
|
421
449
|
]);
|
|
422
450
|
const widths = headings.map((heading, index) => Math.max(heading.length, ...values.map((row) => row[index].length)));
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -1733,8 +1733,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1733
1733
|
});
|
|
1734
1734
|
}
|
|
1735
1735
|
const profileDefaultsApply = profile !== undefined && profile.meta.provider === existing.provider;
|
|
1736
|
-
const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
|
|
1737
|
-
const profileEffort = profileDefaultsApply ? profile.meta.effort : undefined;
|
|
1736
|
+
const profileModel = profileDefaultsApply && existing.profile_settings_pinned !== true ? profile.meta.model : undefined;
|
|
1737
|
+
const profileEffort = profileDefaultsApply && existing.profile_settings_pinned !== true ? profile.meta.effort : undefined;
|
|
1738
1738
|
const ignoredProfileFields = profile
|
|
1739
1739
|
? ignoredWorkerProfileFields(existing.provider)
|
|
1740
1740
|
: [];
|
|
@@ -1765,7 +1765,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1765
1765
|
: undefined;
|
|
1766
1766
|
// `hq lanes set` on a running lane leaves the change pending until this spawn.
|
|
1767
1767
|
const pendingSettings = existing.pending_settings;
|
|
1768
|
-
let pinnedModel = pendingSettings?.model ?? (existing.model_source === undefined || existing.model_source === "cli" || existing.model_source === "preserved"
|
|
1768
|
+
let pinnedModel = pendingSettings?.model ?? (existing.profile_settings_pinned === true || existing.model_source === undefined || existing.model_source === "cli" || existing.model_source === "preserved"
|
|
1769
1769
|
? existing.model
|
|
1770
1770
|
: undefined);
|
|
1771
1771
|
const accountDeps = {
|
|
@@ -1929,7 +1929,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1929
1929
|
: undefined;
|
|
1930
1930
|
const effort = launch.effort ?? pendingResumeEffort ?? resolveLaneEffort({
|
|
1931
1931
|
cliEffort: pendingSettings?.effort ?? (existing.effort_source === "cli" ? existing.effort : undefined),
|
|
1932
|
-
profileEffort,
|
|
1932
|
+
profileEffort: existing.profile_settings_pinned === true ? existing.effort : profileEffort,
|
|
1933
1933
|
accountEffort: accountCfg?.effort,
|
|
1934
1934
|
providerEffort: config.providers[lane.provider].effort,
|
|
1935
1935
|
model,
|
|
@@ -2138,7 +2138,11 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2138
2138
|
? "cli"
|
|
2139
2139
|
: preservedAccountModel !== undefined || existing.model_source === "preserved"
|
|
2140
2140
|
? "preserved"
|
|
2141
|
-
: "cli"
|
|
2141
|
+
: existing.model_source === "cli"
|
|
2142
|
+
? "cli"
|
|
2143
|
+
: existing.profile_settings_pinned === true
|
|
2144
|
+
? "profile"
|
|
2145
|
+
: "cli"
|
|
2142
2146
|
: profileModel !== undefined
|
|
2143
2147
|
? "profile"
|
|
2144
2148
|
: accountCfg?.model !== undefined
|
|
@@ -2483,8 +2487,8 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2483
2487
|
})
|
|
2484
2488
|
: undefined;
|
|
2485
2489
|
const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
|
|
2486
|
-
const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
|
|
2487
|
-
const profileEffort = profileDefaultsApply ? profile.meta.effort : undefined;
|
|
2490
|
+
const profileModel = profileDefaultsApply && lane.profile_settings_pinned !== true ? profile.meta.model : undefined;
|
|
2491
|
+
const profileEffort = profileDefaultsApply && lane.profile_settings_pinned !== true ? profile.meta.effort : undefined;
|
|
2488
2492
|
const ignoredProfileFields = profile
|
|
2489
2493
|
? ignoredWorkerProfileFields(lane.provider)
|
|
2490
2494
|
: [];
|
|
@@ -2506,7 +2510,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2506
2510
|
ignoredFields: ignoredProfileFields,
|
|
2507
2511
|
}
|
|
2508
2512
|
: undefined;
|
|
2509
|
-
const pinnedModel = lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
|
|
2513
|
+
const pinnedModel = lane.profile_settings_pinned === true || lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
|
|
2510
2514
|
? lane.model
|
|
2511
2515
|
: undefined;
|
|
2512
2516
|
const resolved = resolveAccount(lane.provider, lane.account, config, {
|
|
@@ -2534,7 +2538,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2534
2538
|
});
|
|
2535
2539
|
const effort = resolveLaneEffort({
|
|
2536
2540
|
cliEffort: lane.effort_source === "cli" ? lane.effort : undefined,
|
|
2537
|
-
profileEffort,
|
|
2541
|
+
profileEffort: lane.profile_settings_pinned === true ? lane.effort : profileEffort,
|
|
2538
2542
|
accountEffort: accountCfg?.effort,
|
|
2539
2543
|
providerEffort: config.providers[lane.provider].effort,
|
|
2540
2544
|
model,
|
|
@@ -2618,7 +2622,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2618
2622
|
if (model !== undefined) {
|
|
2619
2623
|
next.model = model;
|
|
2620
2624
|
next.model_source = pinnedModel !== undefined
|
|
2621
|
-
? lane.model_source === "preserved" ? "preserved" : "cli"
|
|
2625
|
+
? lane.model_source === "preserved" ? "preserved" : lane.model_source === "profile" || lane.profile_settings_pinned === true ? "profile" : "cli"
|
|
2622
2626
|
: profileModel !== undefined
|
|
2623
2627
|
? "profile"
|
|
2624
2628
|
: accountCfg?.model !== undefined
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -652,6 +652,27 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
652
652
|
if (!isLaneProvider(raw.provider)) {
|
|
653
653
|
throw new LaneSchemaError(`Unknown provider ${JSON.stringify(raw.provider)}`, { path: filePath, value: raw.provider });
|
|
654
654
|
}
|
|
655
|
+
if (raw.provider_source !== undefined && !["cli", "profile", "default"].includes(String(raw.provider_source))) {
|
|
656
|
+
throw new LaneSchemaError("provider_source must be cli, profile, or default", {
|
|
657
|
+
path: filePath,
|
|
658
|
+
field: "provider_source",
|
|
659
|
+
value: raw.provider_source,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
if (raw.account_source !== undefined && !["cli", "profile", "balanced"].includes(String(raw.account_source))) {
|
|
663
|
+
throw new LaneSchemaError("account_source must be cli, profile, or balanced", {
|
|
664
|
+
path: filePath,
|
|
665
|
+
field: "account_source",
|
|
666
|
+
value: raw.account_source,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
if (raw.profile_settings_pinned !== undefined && typeof raw.profile_settings_pinned !== "boolean") {
|
|
670
|
+
throw new LaneSchemaError("profile_settings_pinned must be a boolean", {
|
|
671
|
+
path: filePath,
|
|
672
|
+
field: "profile_settings_pinned",
|
|
673
|
+
value: raw.profile_settings_pinned,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
655
676
|
if (raw.worker_policy_delivery !== undefined &&
|
|
656
677
|
raw.worker_policy_delivery !== "hook" &&
|
|
657
678
|
raw.worker_policy_delivery !== "prompt") {
|
|
@@ -867,8 +888,8 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
867
888
|
for (const [index, entry] of raw.settings_history.entries()) {
|
|
868
889
|
const field = `settings_history[${index}]`;
|
|
869
890
|
if (!isPlainObject(entry) ||
|
|
870
|
-
Object.keys(entry).some((key) => !["at", "actor", "model", "effort", "reason"].includes(key)) ||
|
|
871
|
-
(entry.model === undefined && entry.effort === undefined)) {
|
|
891
|
+
Object.keys(entry).some((key) => !["at", "actor", "provider", "model", "effort", "reason"].includes(key)) ||
|
|
892
|
+
(entry.provider === undefined && entry.model === undefined && entry.effort === undefined)) {
|
|
872
893
|
throw new LaneSchemaError(`${field} must be a settings history entry`, { path: filePath, field });
|
|
873
894
|
}
|
|
874
895
|
requireTimestamp(entry.at, `${field}.at`, filePath);
|
|
@@ -878,6 +899,12 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
878
899
|
if (entry.reason !== undefined && (typeof entry.reason !== "string" || entry.reason.trim() === "")) {
|
|
879
900
|
throw new LaneSchemaError(`${field}.reason must be a non-empty string when present`, { path: filePath, field });
|
|
880
901
|
}
|
|
902
|
+
if (entry.provider !== undefined && (!isPlainObject(entry.provider) ||
|
|
903
|
+
Object.keys(entry.provider).some((key) => key !== "from" && key !== "to") ||
|
|
904
|
+
!isLaneProvider(entry.provider.from) ||
|
|
905
|
+
!isLaneProvider(entry.provider.to))) {
|
|
906
|
+
throw new LaneSchemaError(`${field}.provider must be a {from, to} change`, { path: filePath, field });
|
|
907
|
+
}
|
|
881
908
|
for (const key of ["model", "effort"]) {
|
|
882
909
|
const change = entry[key];
|
|
883
910
|
if (change === undefined)
|