@indigoai-us/hq-cli 5.296.0 → 5.298.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 +10 -0
- package/dist/commands/agents.js +59 -5
- package/dist/commands/files-browse.d.ts +3 -1
- package/dist/commands/files-browse.js +30 -7
- package/dist/commands/integrations-api.js +14 -3
- package/dist/commands/mesh.js +3 -1
- package/dist/lib/bot/prompt.d.ts +2 -0
- package/dist/lib/bot/prompt.js +29 -4
- package/dist/lib/daemon/handoff.d.ts +1 -0
- package/dist/lib/daemon/handoff.js +18 -2
- package/dist/lib/daemon/legacy.d.ts +7 -0
- package/dist/lib/daemon/legacy.js +6 -0
- package/dist/lib/daemon/lock.js +1 -1
- package/dist/lib/daemon/run.js +57 -2
- package/dist/lib/daemon/supervisor.d.ts +2 -0
- package/dist/lib/daemon/supervisor.js +26 -0
- package/dist/lib/doctor/checks/work-context.js +5 -1
- package/dist/lib/lanes/profile.js +5 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/doctor.js +10 -0
- package/dist/lib/policy-validator.d.ts +5 -0
- package/dist/lib/policy-validator.js +69 -18
- package/dist/lib/work-context/outbox-quarantine-status-flag.d.ts +4 -0
- package/dist/lib/work-context/outbox-quarantine-status-flag.js +7 -0
- package/dist/lib/work-context/outbox.d.ts +1 -0
- package/dist/lib/work-context/outbox.js +7 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.298.0] — 2026-09-30
|
|
6
|
+
- [feedback_d4a080e9-de79-4290-81c3-06ba26990a38] Preserve raw substring matches while normalizing company path searches.
|
|
7
|
+
- `hq agents` now prints the Slack app settings link and the console paste link whenever an agent is waiting on its Slack app-level token, and follows the pending actions and paste URL the server returns.
|
|
8
|
+
- When an app install is already running, `hq integrations connect` tells you to wait instead of showing a generic conflict error.
|
|
9
|
+
- Validate policy trigger expressions when loading worker policies (feedback_3982db47-ceb8-4148-8ee0-4dd533f5138b).
|
|
10
|
+
|
|
11
|
+
## [5.297.0] — 2026-09-30
|
|
12
|
+
- When enabled by hq-flags, show the quarantined work-context outbox count and latest reason in hq doctor and mesh daemon status.
|
|
13
|
+
- `hq daemon` now exits after shutdown when handles remain open, and its mesh service waits when another live mesh daemon owns the process lock.
|
|
14
|
+
|
|
5
15
|
## [5.296.0] — 2026-09-30
|
|
6
16
|
- Upgrade varlock to 1.21.1. `hq run` skips Varlock cache setup and reports malformed env files promptly without sending them to Sentry.
|
|
7
17
|
- Record bounded actor and trigger history for lane worker starts and expose it through `hq lanes show --json`. Preserve history through conflict merges and envelope repair, and record answered-question callers for immediate and queued resumes.
|
package/dist/commands/agents.js
CHANGED
|
@@ -549,13 +549,59 @@ function currentDeviceSignInAction(status) {
|
|
|
549
549
|
code,
|
|
550
550
|
};
|
|
551
551
|
}
|
|
552
|
+
const SLACK_APP_TOKEN_INSTRUCTION = "Create an app-level token with the connections:write scope, then paste it into HQ. HQ verifies the token and resumes setup; if Slack asks you to install the app, follow the install link shown there.";
|
|
553
|
+
function consolePasteUrl(companyKey, agentUid) {
|
|
554
|
+
const base = process.env.HQ_CONSOLE_URL?.trim().replace(/\/+$/, "") || "https://hq.computer";
|
|
555
|
+
return `${base}/companies/${encodeURIComponent(companyKey)}/agents?setup=${encodeURIComponent(agentUid)}`;
|
|
556
|
+
}
|
|
557
|
+
function slackAppIdFromSetupState(status) {
|
|
558
|
+
const steps = recordValue(status.setupState)?.steps;
|
|
559
|
+
if (!Array.isArray(steps))
|
|
560
|
+
return null;
|
|
561
|
+
for (const step of steps) {
|
|
562
|
+
const item = recordValue(step);
|
|
563
|
+
if (item?.name !== "channels" || item.status === "done")
|
|
564
|
+
continue;
|
|
565
|
+
const match = /https:\/\/api\.slack\.com\/apps\/(A[A-Z0-9]+)/.exec(JSON.stringify(item));
|
|
566
|
+
if (match)
|
|
567
|
+
return match[1];
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
function serverPendingActions(status) {
|
|
572
|
+
if (!Array.isArray(status.pendingActions))
|
|
573
|
+
return [];
|
|
574
|
+
const actions = [];
|
|
575
|
+
for (const raw of status.pendingActions) {
|
|
576
|
+
const item = recordValue(raw);
|
|
577
|
+
const type = nonEmptyString(item?.type);
|
|
578
|
+
const title = nonEmptyString(item?.title);
|
|
579
|
+
if (!item || !type || !title)
|
|
580
|
+
continue;
|
|
581
|
+
const url = safeActionUrl(item.url) ?? undefined;
|
|
582
|
+
const pasteUrl = safeActionUrl(item.pasteUrl) ?? undefined;
|
|
583
|
+
const rawCode = nonEmptyString(item.code);
|
|
584
|
+
actions.push({
|
|
585
|
+
type,
|
|
586
|
+
title,
|
|
587
|
+
summary: title.charAt(0).toLowerCase() + title.slice(1),
|
|
588
|
+
instruction: nonEmptyString(item.instruction) ?? "Open this page and follow the instructions.",
|
|
589
|
+
...(url ? { url, urlLabel: type === "slack-app-token" ? "Get token" : "Open" } : {}),
|
|
590
|
+
...(pasteUrl ? { pasteUrl } : {}),
|
|
591
|
+
...(rawCode && /^[A-Z0-9]{4,8}-[A-Z0-9]{4,8}$/i.test(rawCode) ? { code: rawCode } : {}),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return actions;
|
|
595
|
+
}
|
|
552
596
|
function pendingSlackAction(status, companySlug) {
|
|
553
597
|
const agent = recordValue(status.agent);
|
|
554
598
|
const configuredSlack = recordValue(recordValue(agent?.channels)?.slack);
|
|
555
|
-
const
|
|
599
|
+
const stepAppId = slackAppIdFromSetupState(status);
|
|
600
|
+
const appId = nonEmptyString(configuredSlack?.appId) ?? stepAppId;
|
|
556
601
|
const agentUid = nonEmptyString(agent?.uid);
|
|
557
602
|
const companyUid = nonEmptyString(agent?.companyUid);
|
|
558
|
-
const tokenPage = safeActionUrl(configuredSlack?.appTokenPendingUrl)
|
|
603
|
+
const tokenPage = safeActionUrl(configuredSlack?.appTokenPendingUrl) ??
|
|
604
|
+
(stepAppId ? `https://api.slack.com/apps/${stepAppId}/general` : null);
|
|
559
605
|
if (tokenPage &&
|
|
560
606
|
appId && /^A[A-Z0-9]+$/.test(appId) &&
|
|
561
607
|
new URL(tokenPage).hostname === "api.slack.com" &&
|
|
@@ -568,10 +614,10 @@ function pendingSlackAction(status, companySlug) {
|
|
|
568
614
|
type: "slack-app-token",
|
|
569
615
|
title: `Finish ${nonEmptyString(agent?.name) ?? "the agent"}'s Slack connection`,
|
|
570
616
|
summary: "connect it to Slack",
|
|
571
|
-
instruction:
|
|
617
|
+
instruction: SLACK_APP_TOKEN_INSTRUCTION,
|
|
572
618
|
url: `https://api.slack.com/apps/${appId}/general`,
|
|
573
619
|
urlLabel: "Get token",
|
|
574
|
-
pasteUrl:
|
|
620
|
+
pasteUrl: consolePasteUrl(companyUrlKey, agentUid),
|
|
575
621
|
};
|
|
576
622
|
}
|
|
577
623
|
const diagnostics = recordValue(agent?.channelDiagnostics);
|
|
@@ -587,6 +633,11 @@ function pendingSlackAction(status, companySlug) {
|
|
|
587
633
|
instruction: "Open this page, create the app-level token, then provide it in the agent's Slack setup.",
|
|
588
634
|
url,
|
|
589
635
|
urlLabel: "Open",
|
|
636
|
+
...(agentUid && AGENT_UID_PATTERN.test(agentUid) && companyUid && /^cmp_[A-Za-z0-9_-]+$/.test(companyUid)
|
|
637
|
+
? {
|
|
638
|
+
pasteUrl: consolePasteUrl(companySlug && /^[a-z0-9][a-z0-9-]*$/.test(companySlug) ? companySlug : companyUid, agentUid),
|
|
639
|
+
}
|
|
640
|
+
: {}),
|
|
590
641
|
};
|
|
591
642
|
}
|
|
592
643
|
if (slack?.inboundCapability !== "pending-install")
|
|
@@ -606,6 +657,9 @@ function pendingSlackAction(status, companySlug) {
|
|
|
606
657
|
* changing the renderers or command control flow.
|
|
607
658
|
*/
|
|
608
659
|
function pendingOperatorActions(status, companySlug) {
|
|
660
|
+
const fromServer = serverPendingActions(status);
|
|
661
|
+
if (fromServer.length > 0)
|
|
662
|
+
return fromServer;
|
|
609
663
|
return [
|
|
610
664
|
currentDeviceSignInAction(status),
|
|
611
665
|
pendingSlackAction(status, companySlug),
|
|
@@ -616,7 +670,7 @@ async function consoleCompanySlug(token, status, companyRef) {
|
|
|
616
670
|
return companyRef;
|
|
617
671
|
const agent = recordValue(status.agent);
|
|
618
672
|
const slack = recordValue(recordValue(agent?.channels)?.slack);
|
|
619
|
-
if (!slack?.appTokenPendingUrl)
|
|
673
|
+
if (!slack?.appTokenPendingUrl && !slackAppIdFromSetupState(status))
|
|
620
674
|
return undefined;
|
|
621
675
|
const companyUid = nonEmptyString(agent?.companyUid);
|
|
622
676
|
if (!companyUid)
|
|
@@ -502,6 +502,8 @@ export declare function resolveBrowseSession(): Promise<{
|
|
|
502
502
|
* and registers `share`/`unshare`/`acl`; this function appends the two
|
|
503
503
|
* new browse-vs-sync subcommands so they share the `--company` switch.
|
|
504
504
|
*/
|
|
505
|
-
export declare function registerFilesBrowseCommands(filesCmd: Command
|
|
505
|
+
export declare function registerFilesBrowseCommands(filesCmd: Command, deps?: {
|
|
506
|
+
personalS3Factory?: S3ClientFactory;
|
|
507
|
+
}): void;
|
|
506
508
|
export {};
|
|
507
509
|
//# sourceMappingURL=files-browse.d.ts.map
|
|
@@ -192,6 +192,18 @@ export function formatBrowseTable(rows) {
|
|
|
192
192
|
lines.push(renderRow(row));
|
|
193
193
|
return lines.join("\n");
|
|
194
194
|
}
|
|
195
|
+
function formatSearchResults(rows, query) {
|
|
196
|
+
if (rows.length === 0) {
|
|
197
|
+
return `No files found for search ${JSON.stringify(query)}.`;
|
|
198
|
+
}
|
|
199
|
+
return formatBrowseTable(rows);
|
|
200
|
+
}
|
|
201
|
+
function normalizeCompanySearchPath(value) {
|
|
202
|
+
return value
|
|
203
|
+
.split("/")
|
|
204
|
+
.map((segment) => (segment === "company" ? "companies" : segment))
|
|
205
|
+
.join("/");
|
|
206
|
+
}
|
|
195
207
|
/** Guard: the personal path needs an S3 factory. */
|
|
196
208
|
function requirePersonalS3Factory(f) {
|
|
197
209
|
if (!f) {
|
|
@@ -530,7 +542,17 @@ export async function runSearch(input) {
|
|
|
530
542
|
region: input.region,
|
|
531
543
|
});
|
|
532
544
|
const q = input.query.toLowerCase();
|
|
533
|
-
|
|
545
|
+
// Only a leading `company/<slug>...` path form is an alias for the
|
|
546
|
+
// `companies/` anchor. A bare `company` stays a plain substring, since every
|
|
547
|
+
// company-mode key starts with `companies/<slug>/`.
|
|
548
|
+
const normalizedQuery = /^company\/[^/]+/.test(q)
|
|
549
|
+
? normalizeCompanySearchPath(q)
|
|
550
|
+
: q;
|
|
551
|
+
return rows.filter((r) => {
|
|
552
|
+
const key = r.key.toLowerCase();
|
|
553
|
+
return (key.includes(q) ||
|
|
554
|
+
normalizeCompanySearchPath(key).includes(normalizedQuery));
|
|
555
|
+
});
|
|
534
556
|
}
|
|
535
557
|
/** Per-machine pin set path: `<hqRoot>/.hq/pins.json`. */
|
|
536
558
|
export function pinFilePath(hqRoot) {
|
|
@@ -927,7 +949,8 @@ export async function resolveBrowseSession() {
|
|
|
927
949
|
* and registers `share`/`unshare`/`acl`; this function appends the two
|
|
928
950
|
* new browse-vs-sync subcommands so they share the `--company` switch.
|
|
929
951
|
*/
|
|
930
|
-
export function registerFilesBrowseCommands(filesCmd) {
|
|
952
|
+
export function registerFilesBrowseCommands(filesCmd, deps = {}) {
|
|
953
|
+
const personalS3Factory = deps.personalS3Factory ?? defaultS3Factory;
|
|
931
954
|
filesCmd
|
|
932
955
|
.command("browse [path]")
|
|
933
956
|
.description("List vault objects under [path] without syncing them locally. Private-folder rows (foo/) are labelled and do not imply child access; their children are creator-only. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
|
|
@@ -961,7 +984,7 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
961
984
|
personalMode: true,
|
|
962
985
|
personalUid,
|
|
963
986
|
vaultClient: cognito,
|
|
964
|
-
s3Factory:
|
|
987
|
+
s3Factory: personalS3Factory,
|
|
965
988
|
region: DEFAULT_COGNITO.region,
|
|
966
989
|
});
|
|
967
990
|
console.log(formatBrowseTable(result.rows));
|
|
@@ -1053,7 +1076,7 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1053
1076
|
personalMode: true,
|
|
1054
1077
|
personalUid,
|
|
1055
1078
|
vaultClient: cognito,
|
|
1056
|
-
s3Factory:
|
|
1079
|
+
s3Factory: personalS3Factory,
|
|
1057
1080
|
region: DEFAULT_COGNITO.region,
|
|
1058
1081
|
});
|
|
1059
1082
|
if (result.destination.kind === "file") {
|
|
@@ -1170,10 +1193,10 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1170
1193
|
personalMode: true,
|
|
1171
1194
|
personalUid,
|
|
1172
1195
|
vaultClient: cognito,
|
|
1173
|
-
s3Factory:
|
|
1196
|
+
s3Factory: personalS3Factory,
|
|
1174
1197
|
region: DEFAULT_COGNITO.region,
|
|
1175
1198
|
});
|
|
1176
|
-
console.log(
|
|
1199
|
+
console.log(formatSearchResults(rows, query));
|
|
1177
1200
|
return;
|
|
1178
1201
|
}
|
|
1179
1202
|
if (!company) {
|
|
@@ -1187,7 +1210,7 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
1187
1210
|
companyClient: makeCompanyPresignFactory(accessToken, company),
|
|
1188
1211
|
region: DEFAULT_COGNITO.region,
|
|
1189
1212
|
});
|
|
1190
|
-
console.log(
|
|
1213
|
+
console.log(formatSearchResults(rows, query));
|
|
1191
1214
|
}
|
|
1192
1215
|
catch (err) {
|
|
1193
1216
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* is optional and read defensively.
|
|
9
9
|
*/
|
|
10
10
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
11
|
-
import { raiseForResponse, } from "./integrations-core.js";
|
|
11
|
+
import { IntegrationsCliError, raiseForResponse, } from "./integrations-core.js";
|
|
12
12
|
export async function listCatalog(token, companyUid, opts = {}) {
|
|
13
13
|
const query = {
|
|
14
14
|
companyUid,
|
|
@@ -59,8 +59,19 @@ export async function installIntegration(token, companyUid, input) {
|
|
|
59
59
|
method: "POST",
|
|
60
60
|
body: { companyUid, ...input },
|
|
61
61
|
});
|
|
62
|
-
if (!res.ok)
|
|
63
|
-
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
try {
|
|
64
|
+
await raiseForResponse(res, "Failed to connect the app");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof IntegrationsCliError &&
|
|
68
|
+
error.status === 409 &&
|
|
69
|
+
error.code === "INTEGRATION_FACTORY_INSTALL_IN_PROGRESS") {
|
|
70
|
+
throw new IntegrationsCliError("An install for this app is already running. Wait for it to finish before trying again.", { expected: true, status: 409, code: error.code });
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
64
75
|
return (await res.json());
|
|
65
76
|
}
|
|
66
77
|
export async function uninstallIntegration(token, companyUid, installationId) {
|
package/dist/commands/mesh.js
CHANGED
|
@@ -2129,7 +2129,9 @@ async function runDaemonInstall(opts) {
|
|
|
2129
2129
|
// `hq daemon` runs mesh on this machine: installing a second unit would run it twice.
|
|
2130
2130
|
// An explicit --hq-root is verified the same way as below and saved for the daemon.
|
|
2131
2131
|
const explicitRoot = opts.hqRoot ? resolveInstallHqRoot({ explicit: opts.hqRoot }).root : undefined;
|
|
2132
|
-
const handedOff = opts.systemUser
|
|
2132
|
+
const handedOff = opts.systemUser
|
|
2133
|
+
? null
|
|
2134
|
+
: handOffInstallHere(["mesh"], process.env, { hqRoot: explicitRoot, ifStale: opts.ifStale });
|
|
2133
2135
|
if (handedOff) {
|
|
2134
2136
|
console.log(opts.json ? JSON.stringify({ ok: true, action: "hq-daemon", reinstalled: false, message: handedOff }) : handedOff);
|
|
2135
2137
|
return;
|
package/dist/lib/bot/prompt.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface WorkerPromptSource {
|
|
|
47
47
|
contextPaths: string[];
|
|
48
48
|
/** companies/<co>/policies/*.md, hqRoot-relative. */
|
|
49
49
|
policyPaths: string[];
|
|
50
|
+
/** Invalid policy files excluded from policyPaths, with a safe diagnostic. */
|
|
51
|
+
policyDiagnostics?: string[];
|
|
50
52
|
}
|
|
51
53
|
export interface PromptSources {
|
|
52
54
|
workerYaml?: string;
|
package/dist/lib/bot/prompt.js
CHANGED
|
@@ -20,6 +20,7 @@ import * as fs from "node:fs";
|
|
|
20
20
|
import * as path from "node:path";
|
|
21
21
|
import * as yaml from "js-yaml";
|
|
22
22
|
import { packageRoot } from "../../utils/hq-roots.js";
|
|
23
|
+
import { policyLoadDiagnostic } from "../policy-validator.js";
|
|
23
24
|
export const MEMORY_FILE_BUDGET_BYTES = 64 * 1024;
|
|
24
25
|
export const MEMORY_TOTAL_BUDGET_BYTES = 192 * 1024;
|
|
25
26
|
export const CONTEXT_FILE_BUDGET_BYTES = 32 * 1024;
|
|
@@ -117,7 +118,7 @@ function resolveSkillFile(hqRoot, workerDir, name, doc, io) {
|
|
|
117
118
|
return undefined;
|
|
118
119
|
}
|
|
119
120
|
function readWorkerSource(hqRoot, workerDir, workerYaml, io, companyOverride) {
|
|
120
|
-
const out = { skills: [], contextFiles: [], contextPaths: [], policyPaths: [] };
|
|
121
|
+
const out = { skills: [], contextFiles: [], contextPaths: [], policyPaths: [], policyDiagnostics: [] };
|
|
121
122
|
let doc = {};
|
|
122
123
|
if (workerYaml) {
|
|
123
124
|
try {
|
|
@@ -197,9 +198,28 @@ function readWorkerSource(hqRoot, workerDir, workerYaml, io, companyOverride) {
|
|
|
197
198
|
catch {
|
|
198
199
|
names = [];
|
|
199
200
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
for (const name of names) {
|
|
202
|
+
if (!name.endsWith(".md") || name.startsWith(".") || name.includes(".conflict-"))
|
|
203
|
+
continue;
|
|
204
|
+
const relative = path.join(policiesRel, name);
|
|
205
|
+
const absolute = insideHqRoot(hqRoot, relative);
|
|
206
|
+
if (!absolute)
|
|
207
|
+
continue;
|
|
208
|
+
let content;
|
|
209
|
+
try {
|
|
210
|
+
content = io.readFileSync(absolute);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
out.policyPaths.push(relative);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const diagnostic = policyLoadDiagnostic(content, absolute);
|
|
217
|
+
if (diagnostic) {
|
|
218
|
+
out.policyDiagnostics.push(`${relative}: ${diagnostic.split("\n", 1)[0]}`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
out.policyPaths.push(relative);
|
|
222
|
+
}
|
|
203
223
|
}
|
|
204
224
|
}
|
|
205
225
|
return out;
|
|
@@ -331,6 +351,11 @@ export function buildSystemPrompt(input) {
|
|
|
331
351
|
`Hard rules for \`${company}\`. Read the relevant ones before acting; they override anything else in this prompt.\n\n` +
|
|
332
352
|
worker.policyPaths.map((p) => `- ${p}`).join("\n"));
|
|
333
353
|
}
|
|
354
|
+
if (worker.policyDiagnostics && worker.policyDiagnostics.length > 0) {
|
|
355
|
+
parts.push(`## Policy load diagnostics\n\n` +
|
|
356
|
+
`These company policy files were not loaded because their frontmatter is invalid. Correct each file before relying on its rules.\n\n` +
|
|
357
|
+
worker.policyDiagnostics.map((diagnostic) => `- ${diagnostic}`).join("\n"));
|
|
358
|
+
}
|
|
334
359
|
if (worker.contextFiles.length > 0 || worker.contextPaths.length > 0) {
|
|
335
360
|
const blocks = worker.contextFiles.map((c) => `### ${c.path}\n\n${c.body.trim()}`);
|
|
336
361
|
const more = worker.contextPaths.length > 0
|
|
@@ -51,5 +51,6 @@ export declare function handOffInstall(opts: {
|
|
|
51
51
|
/** handOffInstall for this machine and user, for the older install commands. */
|
|
52
52
|
export declare function handOffInstallHere(services: readonly ServiceName[], env?: NodeJS.ProcessEnv, opts?: {
|
|
53
53
|
hqRoot?: string;
|
|
54
|
+
ifStale?: boolean;
|
|
54
55
|
}): string | null;
|
|
55
56
|
//# sourceMappingURL=handoff.d.ts.map
|
|
@@ -17,6 +17,15 @@ import { daemonPaths } from "./paths.js";
|
|
|
17
17
|
import { writeControlRequest } from "./requests.js";
|
|
18
18
|
/** Statuses where the daemon itself owns the unit's process. */
|
|
19
19
|
const OWNED = new Set(["running", "backoff", "exited", "idle", "stopped"]);
|
|
20
|
+
const RECENT_HOSTED_DAEMON_MS = 5 * 60_000;
|
|
21
|
+
/** The updater's postinstall may run in the short gap before the desktop app relaunches its daemon. */
|
|
22
|
+
function recentlyHostedDaemon(paths) {
|
|
23
|
+
if (!fs.existsSync(paths.statePath))
|
|
24
|
+
return false;
|
|
25
|
+
const state = JSON.parse(fs.readFileSync(paths.statePath, "utf8"));
|
|
26
|
+
const age = Date.now() - (state.updatedAt ?? 0);
|
|
27
|
+
return state.host === "desktop" && age >= 0 && age <= RECENT_HOSTED_DAEMON_MS;
|
|
28
|
+
}
|
|
20
29
|
/** The live daemon's state, or null when no daemon is running. */
|
|
21
30
|
function liveState(paths, alive) {
|
|
22
31
|
if (runningDaemonPid(paths.lockPath, alive) === null)
|
|
@@ -124,14 +133,21 @@ export function handOffInstall(opts) {
|
|
|
124
133
|
export function handOffInstallHere(services, env = process.env, opts = {}) {
|
|
125
134
|
const home = os.homedir();
|
|
126
135
|
const paths = daemonPaths(home, env);
|
|
136
|
+
const config = readDaemonConfig(paths.configPath);
|
|
137
|
+
if (opts.ifStale && services.every((service) => config.disable.includes(service))) {
|
|
138
|
+
return `hq daemon has ${services.join(", ")} disabled, so the older service will not be installed.`;
|
|
139
|
+
}
|
|
127
140
|
const installed = daemonUnitInstalled({
|
|
128
141
|
platform: process.platform,
|
|
129
142
|
home,
|
|
130
143
|
windowsTaskExists: (task) => spawnSync("schtasks.exe", ["/Query", "/TN", task], { encoding: "utf8", windowsHide: true }).status === 0,
|
|
131
144
|
}) || hostedDaemonRunning(paths);
|
|
132
|
-
if (!installed)
|
|
145
|
+
if (!installed) {
|
|
146
|
+
if (opts.ifStale && services.includes("mesh") && recentlyHostedDaemon(paths)) {
|
|
147
|
+
return `hq daemon recently hosted mesh on this machine, so the older mesh service will not be installed during its restart.`;
|
|
148
|
+
}
|
|
133
149
|
return null;
|
|
134
|
-
|
|
150
|
+
}
|
|
135
151
|
const machineType = detectMachineType(undefined, {
|
|
136
152
|
platform: process.platform,
|
|
137
153
|
env,
|
|
@@ -32,4 +32,11 @@ export declare function findLegacyUnits(deps: LegacyProbeDeps): LegacyUnit[];
|
|
|
32
32
|
* own checks, because `hq daemon migrate` will never take them over.
|
|
33
33
|
*/
|
|
34
34
|
export declare function legacyOwnerLookup(units: readonly LegacyUnit[]): (unitId: string) => string | null;
|
|
35
|
+
/** A daemon-managed macOS mesh service takes over the old LaunchAgent on startup. */
|
|
36
|
+
export declare function legacyMeshUnitsToRetire(units: readonly LegacyUnit[], opts: {
|
|
37
|
+
platform: NodeJS.Platform;
|
|
38
|
+
managed: boolean;
|
|
39
|
+
hosted: boolean;
|
|
40
|
+
meshEnabled: boolean;
|
|
41
|
+
}): LegacyUnit[];
|
|
35
42
|
//# sourceMappingURL=legacy.d.ts.map
|
|
@@ -122,4 +122,10 @@ export function legacyOwnerLookup(units) {
|
|
|
122
122
|
return hit ? describeLegacyUnit(hit) : null;
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
|
+
/** A daemon-managed macOS mesh service takes over the old LaunchAgent on startup. */
|
|
126
|
+
export function legacyMeshUnitsToRetire(units, opts) {
|
|
127
|
+
if (opts.platform !== "darwin" || (!opts.managed && !opts.hosted) || !opts.meshEnabled)
|
|
128
|
+
return [];
|
|
129
|
+
return units.filter((unit) => unit.service === "mesh" && unit.kind === "launchd" && unit.migratable);
|
|
130
|
+
}
|
|
125
131
|
//# sourceMappingURL=legacy.js.map
|
package/dist/lib/daemon/lock.js
CHANGED
|
@@ -43,7 +43,7 @@ export function processCommandLine(pid, deps = defaultCommandLineDeps()) {
|
|
|
43
43
|
"-Command",
|
|
44
44
|
`(Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}").CommandLine`,
|
|
45
45
|
])
|
|
46
|
-
: deps.run("ps", ["-o", "
|
|
46
|
+
: deps.run("ps", ["-ww", "-o", "args=", "-p", String(pid)]);
|
|
47
47
|
if (r.status !== 0)
|
|
48
48
|
return null;
|
|
49
49
|
const line = r.stdout.trim();
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -19,9 +19,12 @@ import { botsRoot } from "../bot/paths.js";
|
|
|
19
19
|
import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
|
|
20
20
|
import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
|
|
21
21
|
import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
|
|
22
|
-
import { findLegacyUnits, legacyOwnerLookup } from "./legacy.js";
|
|
22
|
+
import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
|
|
23
23
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
24
|
-
import { acquireDaemonLock, releaseDaemonLock } from "./lock.js";
|
|
24
|
+
import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
|
|
25
|
+
import { adoptLegacyUnits } from "./migrate.js";
|
|
26
|
+
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
27
|
+
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
25
28
|
import { daemonRootRefusal } from "./install.js";
|
|
26
29
|
import { detectMachineType } from "./machine-type.js";
|
|
27
30
|
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
@@ -332,6 +335,54 @@ export async function runDaemon(opts) {
|
|
|
332
335
|
}
|
|
333
336
|
return legacyCache.units;
|
|
334
337
|
};
|
|
338
|
+
const legacyMesh = legacyMeshUnitsToRetire(legacyUnits(), {
|
|
339
|
+
platform,
|
|
340
|
+
managed: opts.managed === true,
|
|
341
|
+
hosted: opts.host !== undefined,
|
|
342
|
+
meshEnabled: enabledServices(machineType, initial).includes("mesh"),
|
|
343
|
+
});
|
|
344
|
+
if (legacyMesh.length > 0) {
|
|
345
|
+
try {
|
|
346
|
+
const uid = process.getuid?.() ?? 0;
|
|
347
|
+
const result = adoptLegacyUnits(legacyMesh, {
|
|
348
|
+
run: (command, args) => {
|
|
349
|
+
const r = spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15_000 });
|
|
350
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
351
|
+
},
|
|
352
|
+
uid,
|
|
353
|
+
isRoot: uid === 0,
|
|
354
|
+
adoptedDir: path.join(paths.root, "adopted"),
|
|
355
|
+
recordPath: path.join(paths.root, "adopted.json"),
|
|
356
|
+
systemAdoptedDir: "/var/lib/hq/daemon/adopted",
|
|
357
|
+
systemRecordPath: "/var/lib/hq/daemon/adopted.json",
|
|
358
|
+
systemUnitDir: "/etc/systemd/system",
|
|
359
|
+
});
|
|
360
|
+
for (const unit of result.adopted)
|
|
361
|
+
log("info", `retired legacy ${unit.unitId} unit ${unit.name}; hq daemon now owns mesh`);
|
|
362
|
+
for (const failure of result.failed)
|
|
363
|
+
log("warn", `could not retire legacy ${failure.unit.unitId} unit ${failure.unit.name}: ${failure.error}`);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
log("error", `could not retire legacy mesh units: ${err.message}`);
|
|
367
|
+
}
|
|
368
|
+
legacyCache = null;
|
|
369
|
+
}
|
|
370
|
+
const meshOwnerReason = () => {
|
|
371
|
+
const lockPath = meshDaemonPidPath(meshDaemonDir(undefined, home, env));
|
|
372
|
+
const owner = readPidLock(lockPath);
|
|
373
|
+
if (!owner || owner.pid === process.pid || !isProcessAlive(owner.pid))
|
|
374
|
+
return undefined;
|
|
375
|
+
const command = processCommandLine(owner.pid, {
|
|
376
|
+
platform,
|
|
377
|
+
run: (file, args) => {
|
|
378
|
+
const r = spawnSync(file, args, { encoding: "utf8", windowsHide: true, timeout: 15_000 });
|
|
379
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
if (command && !/\bmesh\s+daemon\s+run\b/.test(command))
|
|
383
|
+
return undefined;
|
|
384
|
+
return `mesh daemon already running (pid ${owner.pid}); waiting for it to exit`;
|
|
385
|
+
};
|
|
335
386
|
let warnedMissingPin = null;
|
|
336
387
|
const daemonSyncRunnerPids = new Set();
|
|
337
388
|
const plan = () => {
|
|
@@ -554,6 +605,7 @@ export async function runDaemon(opts) {
|
|
|
554
605
|
return undefined;
|
|
555
606
|
}
|
|
556
607
|
},
|
|
608
|
+
meshOwnerReason,
|
|
557
609
|
cliVersion: CLI_VERSION,
|
|
558
610
|
adopt,
|
|
559
611
|
});
|
|
@@ -603,6 +655,9 @@ export async function runDaemon(opts) {
|
|
|
603
655
|
log("info", "all services stopped");
|
|
604
656
|
releaseDaemonLock(paths.lockPath);
|
|
605
657
|
resolve(exitCode);
|
|
658
|
+
// Pipes, sockets and unrelated timers can outlive a clean shutdown.
|
|
659
|
+
// Keep restart and signal exits bounded just like the handoff path.
|
|
660
|
+
setTimeout(() => process.exit(exitCode), 5_000).unref();
|
|
606
661
|
});
|
|
607
662
|
};
|
|
608
663
|
/** Exit for a restart but leave the services running for the next daemon to adopt. */
|
|
@@ -107,6 +107,8 @@ export interface SupervisorDeps {
|
|
|
107
107
|
requestPollMs?: number;
|
|
108
108
|
/** Read hq-cloud's lease status after it exits with the lease-conflict code. */
|
|
109
109
|
syncOwnerBlockReason?: () => string | undefined;
|
|
110
|
+
/** A separate mesh daemon currently owns its pid lock after our mesh child exits. */
|
|
111
|
+
meshOwnerReason?: () => string | undefined;
|
|
110
112
|
/** The running CLI's version; part of the key of units that run `hq` commands. */
|
|
111
113
|
cliVersion?: string;
|
|
112
114
|
/** Services to take over from the previous daemon instead of starting them. */
|
|
@@ -27,6 +27,7 @@ export const BACKOFF_BASE_MS = 1_000;
|
|
|
27
27
|
export const BACKOFF_MAX_MS = 60_000;
|
|
28
28
|
export const HEALTHY_RUN_MS = 60_000;
|
|
29
29
|
export const STOP_ESCALATION_MS = 10_000;
|
|
30
|
+
const MESH_OWNER_RECHECK_MS = 15_000;
|
|
30
31
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
31
32
|
export const HANDOFF_STAGGER_MS = 15_000;
|
|
32
33
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
@@ -294,6 +295,31 @@ export function createSupervisor(deps) {
|
|
|
294
295
|
emit();
|
|
295
296
|
return;
|
|
296
297
|
}
|
|
298
|
+
const meshOwnerReason = slot.plan.service === "mesh" ? deps.meshOwnerReason?.() : undefined;
|
|
299
|
+
if (meshOwnerReason) {
|
|
300
|
+
const waitForOwner = (reason) => {
|
|
301
|
+
slot.state.status = "waiting";
|
|
302
|
+
slot.state.reason = reason;
|
|
303
|
+
slot.state.nextRunAt = now() + MESH_OWNER_RECHECK_MS;
|
|
304
|
+
deps.log("warn", `${reason}; retrying mesh ownership check in ${MESH_OWNER_RECHECK_MS}ms`);
|
|
305
|
+
slot.timer = setT(() => {
|
|
306
|
+
slot.timer = undefined;
|
|
307
|
+
const current = deps.meshOwnerReason?.();
|
|
308
|
+
if (current)
|
|
309
|
+
waitForOwner(current);
|
|
310
|
+
else {
|
|
311
|
+
slot.attempts = 0;
|
|
312
|
+
delete slot.state.reason;
|
|
313
|
+
delete slot.state.nextRunAt;
|
|
314
|
+
startLong(slot);
|
|
315
|
+
}
|
|
316
|
+
emit();
|
|
317
|
+
}, MESH_OWNER_RECHECK_MS);
|
|
318
|
+
};
|
|
319
|
+
waitForOwner(meshOwnerReason);
|
|
320
|
+
emit();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
297
323
|
deps.log("warn", `${slot.plan.id} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
|
|
298
324
|
if (ranFor >= HEALTHY_RUN_MS)
|
|
299
325
|
slot.attempts = 0;
|
|
@@ -8,6 +8,7 @@ import * as os from "node:os";
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { readDeviceConfig, } from "../../work-context/config.js";
|
|
10
10
|
import { outboxStats, listOutboxOperations } from "../../work-context/outbox.js";
|
|
11
|
+
import { outboxQuarantineStatusEnabled } from "../../work-context/outbox-quarantine-status-flag.js";
|
|
11
12
|
import { workContextRoot, workContextSessionsDir } from "../../work-context/paths.js";
|
|
12
13
|
import { countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
|
|
13
14
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../../mesh/live/paths.js";
|
|
@@ -87,11 +88,14 @@ function runChecks(deps) {
|
|
|
87
88
|
target: path.join(root, "outbox"),
|
|
88
89
|
message: `Outbox queued depth=${stats.depth} (acked=${stats.acked}).`,
|
|
89
90
|
});
|
|
91
|
+
const showQuarantineReason = stats.quarantined > 0 && outboxQuarantineStatusEnabled();
|
|
90
92
|
results.push({
|
|
91
93
|
status: stats.quarantined > 0 ? "WARN" : "PASS",
|
|
92
94
|
checkId: "work-context.outbox-quarantined",
|
|
93
95
|
target: path.join(root, "outbox"),
|
|
94
|
-
message: `Outbox quarantined count=${stats.quarantined}
|
|
96
|
+
message: `Outbox quarantined count=${stats.quarantined}${showQuarantineReason
|
|
97
|
+
? ` (last reason=${stats.lastQuarantineReason ?? "unknown"})`
|
|
98
|
+
: ""}.`,
|
|
95
99
|
});
|
|
96
100
|
// Default-company mode + migration capability (US-017B)
|
|
97
101
|
try {
|
|
@@ -10,6 +10,7 @@ import { createHash } from "node:crypto";
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
13
|
+
import { policyLoadDiagnostic } from "../policy-validator.js";
|
|
13
14
|
import { WorkerResolutionError } from "./errors.js";
|
|
14
15
|
import { PROVIDER_EFFORT_LEVELS } from "./effort.js";
|
|
15
16
|
import { LANE_PROVIDERS, isLaneProvider, } from "./types.js";
|
|
@@ -261,6 +262,10 @@ function validatePolicyFrontmatter(id, profileDir, relative) {
|
|
|
261
262
|
frontmatter.on.some((value) => typeof value !== "string" || value.trim().length === 0)) {
|
|
262
263
|
throw new WorkerResolutionError("worker_policy_invalid", `Worker policy ${filePath} frontmatter must include a non-empty on string array`, { worker: id, file: filePath, field: "on" });
|
|
263
264
|
}
|
|
265
|
+
const diagnostic = policyLoadDiagnostic(content, filePath);
|
|
266
|
+
if (diagnostic) {
|
|
267
|
+
throw new WorkerResolutionError("worker_policy_invalid", `Worker policy ${filePath} failed policy validation: ${diagnostic.split("\n", 1)[0]}`, { worker: id, file: filePath, field: "frontmatter", diagnostic });
|
|
268
|
+
}
|
|
264
269
|
}
|
|
265
270
|
function readSettings(id, dir) {
|
|
266
271
|
const filePath = path.join(dir, PROFILE_SETTINGS);
|
|
@@ -25,6 +25,8 @@ export interface DaemonDoctorReport {
|
|
|
25
25
|
/** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
|
|
26
26
|
unattributedEvents: UnattributedEventCounts;
|
|
27
27
|
outboxDepth: number;
|
|
28
|
+
outboxQuarantined?: number;
|
|
29
|
+
outboxLastQuarantineReason?: string;
|
|
28
30
|
lastFlushAt?: string;
|
|
29
31
|
lastFlushResult?: DaemonStateFile["lastFlushResult"];
|
|
30
32
|
unhealthy: boolean;
|
|
@@ -9,6 +9,7 @@ import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedToke
|
|
|
9
9
|
import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
|
|
10
10
|
import { readSessionState } from "../../../work-context/state.js";
|
|
11
11
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
12
|
+
import { outboxQuarantineStatusEnabled } from "../../../work-context/outbox-quarantine-status-flag.js";
|
|
12
13
|
import { formatDroppedPresenceCompany } from "./credentials.js";
|
|
13
14
|
import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
14
15
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
@@ -258,6 +259,12 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
258
259
|
deadLetterCount,
|
|
259
260
|
unattributedEvents,
|
|
260
261
|
outboxDepth: outbox.depth,
|
|
262
|
+
...(outbox.quarantined > 0 && outboxQuarantineStatusEnabled()
|
|
263
|
+
? {
|
|
264
|
+
outboxQuarantined: outbox.quarantined,
|
|
265
|
+
outboxLastQuarantineReason: outbox.lastQuarantineReason ?? undefined,
|
|
266
|
+
}
|
|
267
|
+
: {}),
|
|
261
268
|
lastFlushAt: state?.lastFlushAt,
|
|
262
269
|
lastFlushResult: state?.lastFlushResult,
|
|
263
270
|
unhealthy: unhealthyReasons.length > 0,
|
|
@@ -302,6 +309,9 @@ export function formatDaemonDoctor(report) {
|
|
|
302
309
|
// receive-only (direct) mode.
|
|
303
310
|
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}${report.heldCount > 0 ? " (needs_company; not sent as NONE)" : ""}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
|
|
304
311
|
}
|
|
312
|
+
if (report.outboxQuarantined !== undefined) {
|
|
313
|
+
lines.push(`outbox quarantined: ${report.outboxQuarantined} (last reason=${report.outboxLastQuarantineReason ?? "unknown"})`);
|
|
314
|
+
}
|
|
305
315
|
if (report.emitMode !== "legacy" && report.heldCount > 0) {
|
|
306
316
|
lines.push(`held: ${report.heldCount} (needs_company; not sent as NONE)`);
|
|
307
317
|
}
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-time validation: reject policy files that cannot participate in the
|
|
3
|
+
* trigger system. Authoring-only hard-policy constraints remain in the hook.
|
|
4
|
+
*/
|
|
5
|
+
export declare function policyLoadDiagnostic(content: string, filePath: string): string | undefined;
|
|
1
6
|
export declare function policyValidatorDiagnostic(content: string, filePath: string, env: NodeJS.ProcessEnv): string | undefined;
|
|
2
7
|
//# sourceMappingURL=policy-validator.d.ts.map
|
|
@@ -80,13 +80,21 @@ function hasValidTriggerGrammar(expression) {
|
|
|
80
80
|
skip();
|
|
81
81
|
return valid && position === input.length;
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const MISSING_TRIGGER_HELP = `Every policy under */policies/ must declare BOTH:
|
|
84
|
+
when: <expression> # e.g. always | git && push | deploy || share
|
|
85
|
+
on: [<events>] # any of PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent, SessionStart
|
|
86
|
+
These drive just-in-time policy injection. See
|
|
87
|
+
core/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").
|
|
88
|
+
Add both fields to the frontmatter, then retry.
|
|
89
|
+
${VALIDATION_NOTE}`;
|
|
90
|
+
function inspectPolicyTriggerFrontmatter(content) {
|
|
86
91
|
const normalized = normalizeValidatorText(content);
|
|
87
92
|
const match = /^\s*---[ \t]*\n([\s\S]*?)\n---[ \t]*(\n|$)/.exec(normalized);
|
|
88
|
-
if (!match)
|
|
89
|
-
return
|
|
93
|
+
if (!match) {
|
|
94
|
+
return {
|
|
95
|
+
diagnostic: `BLOCKED: policy file is missing required trigger frontmatter (missing: no-frontmatter).\n\n${MISSING_TRIGGER_HELP}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
90
98
|
const frontmatter = match[1];
|
|
91
99
|
const whenLines = [...frontmatter.matchAll(/^[ \t]*when:[ \t]*(.*)$/gm)].map((entry) => entry[1] ?? '');
|
|
92
100
|
const hasWhen = whenLines.some((line) => /\S/.test(line));
|
|
@@ -96,8 +104,46 @@ export function policyValidatorDiagnostic(content, filePath, env) {
|
|
|
96
104
|
missing.push('when');
|
|
97
105
|
if (!onMatch)
|
|
98
106
|
missing.push('on');
|
|
99
|
-
if (missing.length > 0)
|
|
100
|
-
return
|
|
107
|
+
if (missing.length > 0) {
|
|
108
|
+
return {
|
|
109
|
+
diagnostic: `BLOCKED: policy file is missing required trigger frontmatter (missing: ${missing.join(',')}).\n\n${MISSING_TRIGGER_HELP}`,
|
|
110
|
+
whenLines,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { parsed: { normalized, match, frontmatter, whenLines }, whenLines };
|
|
114
|
+
}
|
|
115
|
+
function malformedTriggerDiagnostic(whenLines) {
|
|
116
|
+
let malformedWhen;
|
|
117
|
+
for (const when of whenLines) {
|
|
118
|
+
if (!hasValidTriggerGrammar(when))
|
|
119
|
+
malformedWhen = when;
|
|
120
|
+
}
|
|
121
|
+
if (malformedWhen === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
const when = malformedWhen;
|
|
124
|
+
return `BLOCKED: policy file has a malformed when: trigger expression:\n\n when: ${when}\n\nThe canonical trigger evaluator rejected it. A space is NOT an AND, and quoted\nphrases are NOT tokens. Use explicit boolean operators instead:\n "pull request" -> (pull && request)\n "force-push" -> force-push\n\nUse only identifiers joined by the documented boolean grammar:\n when: <identifier> # e.g. always | git | /deep-plan\n when: <expr> && <expr> # AND\n when: <expr> || <expr> # OR\n when: ! <expr> # NOT\n when: ( <expr> ) # grouping\nIdentifiers may contain letters, digits, _, ., /, and internal -. Quotes,\nYAML block scalars, adjacent identifiers without an operator, and other\npunctuation are not valid. See core/knowledge/public/hq-core/policies-spec.md\n("Trigger Expressions"). Fix the expression, then retry.\n${VALIDATION_NOTE}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Load-time validation: reject policy files that cannot participate in the
|
|
128
|
+
* trigger system. Authoring-only hard-policy constraints remain in the hook.
|
|
129
|
+
*/
|
|
130
|
+
export function policyLoadDiagnostic(content, filePath) {
|
|
131
|
+
if (!isPolicyPath(filePath))
|
|
132
|
+
return undefined;
|
|
133
|
+
// Partial frontmatter (for example `when:` without `on:`) still has its
|
|
134
|
+
// `when:` grammar checked, so a malformed expression is never loaded.
|
|
135
|
+
const { whenLines } = inspectPolicyTriggerFrontmatter(content);
|
|
136
|
+
if (!whenLines)
|
|
137
|
+
return undefined;
|
|
138
|
+
return malformedTriggerDiagnostic(whenLines);
|
|
139
|
+
}
|
|
140
|
+
export function policyValidatorDiagnostic(content, filePath, env) {
|
|
141
|
+
if (!isPolicyPath(filePath))
|
|
142
|
+
return undefined;
|
|
143
|
+
const inspected = inspectPolicyTriggerFrontmatter(content);
|
|
144
|
+
if (inspected.diagnostic)
|
|
145
|
+
return inspected.diagnostic;
|
|
146
|
+
const { normalized, match, frontmatter, whenLines } = inspected.parsed;
|
|
101
147
|
const enforcement = /^[ \t]*enforcement:[ \t]*["']?([A-Za-z]+)["']?/m.exec(frontmatter)?.[1]?.toLowerCase();
|
|
102
148
|
if (enforcement === 'hard') {
|
|
103
149
|
const tautological = whenLines.some((line) => !/[&!]/.test(line.toLowerCase()) && /(^|[^A-Za-z0-9_./-])always([^A-Za-z0-9_./-]|$)/i.test(line));
|
|
@@ -105,7 +151,21 @@ export function policyValidatorDiagnostic(content, filePath, env) {
|
|
|
105
151
|
const reactive = ['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'AssistantIntent'].filter((event) => onLine.includes(event));
|
|
106
152
|
if (tautological && reactive.length > 0) {
|
|
107
153
|
const events = reactive.join(', ');
|
|
108
|
-
return `BLOCKED: an enforcement: hard policy pairs an unconditional \`when:\` with a
|
|
154
|
+
return `BLOCKED: an enforcement: hard policy pairs an unconditional \`when:\` with a
|
|
155
|
+
reactive event (${events}).
|
|
156
|
+
|
|
157
|
+
\`when: always\` is TRUE for every command, prompt, and message. On a reactive
|
|
158
|
+
event the injector ranks that as an event-specific match, so it takes the
|
|
159
|
+
session cap slot of a policy that genuinely matched the thing you are doing —
|
|
160
|
+
the loose trigger crowds out the precise one.
|
|
161
|
+
|
|
162
|
+
Pick one:
|
|
163
|
+
when: always + on: [SessionStart] # ambient governance rule
|
|
164
|
+
when: <real signal> + on: [PreToolUse, ...] # e.g. deploy || publish
|
|
165
|
+
|
|
166
|
+
Name the word(s) that actually appear when the rule is relevant. See
|
|
167
|
+
core/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").
|
|
168
|
+
${VALIDATION_NOTE}`;
|
|
109
169
|
}
|
|
110
170
|
const after = normalized.slice(match.index + match[0].length);
|
|
111
171
|
const binding = [];
|
|
@@ -122,15 +182,6 @@ export function policyValidatorDiagnostic(content, filePath, env) {
|
|
|
122
182
|
return `BLOCKED: enforcement: hard policy body is ${byteCount} bytes; the limit is ${rawMax}.\n\nA hard policy is injected VERBATIM into the session, so its length is a\nrecurring context cost for every session it fires in. Keep the binding rule\ntight and move the reasoning out of the injected span: everything from the\nfirst \`## Rationale\` / \`## Background\` / \`## Change history\` /\n\`## Examples\` / \`## References\` heading onward is NOT counted and NOT\ninjected, so long-form context belongs there.\n\nIf the rule genuinely needs more than that, either split it into separate\npolicies with distinct triggers, or raise HQ_POLICY_HARD_RULE_MAX_BYTES in\n.claude/settings.local.json "env".\n${VALIDATION_NOTE}`;
|
|
123
183
|
}
|
|
124
184
|
}
|
|
125
|
-
|
|
126
|
-
for (const when of whenLines) {
|
|
127
|
-
if (!hasValidTriggerGrammar(when))
|
|
128
|
-
malformedWhen = when;
|
|
129
|
-
}
|
|
130
|
-
if (malformedWhen !== undefined) {
|
|
131
|
-
const when = malformedWhen;
|
|
132
|
-
return `BLOCKED: policy file has a malformed when: trigger expression:\n\n when: ${when}\n\nThe canonical trigger evaluator rejected it. A space is NOT an AND, and quoted\nphrases are NOT tokens. Use explicit boolean operators instead:\n "pull request" -> (pull && request)\n "force-push" -> force-push\n\nUse only identifiers joined by the documented boolean grammar:\n when: <identifier> # e.g. always | git | /deep-plan\n when: <expr> && <expr> # AND\n when: <expr> || <expr> # OR\n when: ! <expr> # NOT\n when: ( <expr> ) # grouping\nIdentifiers may contain letters, digits, _, ., /, and internal -. Quotes,\nYAML block scalars, adjacent identifiers without an operator, and other\npunctuation are not valid. See core/knowledge/public/hq-core/policies-spec.md\n("Trigger Expressions"). Fix the expression, then retry.\n${VALIDATION_NOTE}`;
|
|
133
|
-
}
|
|
134
|
-
return undefined;
|
|
185
|
+
return malformedTriggerDiagnostic(whenLines);
|
|
135
186
|
}
|
|
136
187
|
//# sourceMappingURL=policy-validator.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const OUTBOX_QUARANTINE_STATUS_FLAG = "agents.work-context-outbox-quarantine-status";
|
|
2
|
+
export declare const OUTBOX_QUARANTINE_STATUS_FALLBACK = false;
|
|
3
|
+
export declare function outboxQuarantineStatusEnabled(): boolean;
|
|
4
|
+
//# sourceMappingURL=outbox-quarantine-status-flag.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { resolveProcessFlagGate } from "../flag-registry.js";
|
|
2
|
+
export const OUTBOX_QUARANTINE_STATUS_FLAG = "agents.work-context-outbox-quarantine-status";
|
|
3
|
+
export const OUTBOX_QUARANTINE_STATUS_FALLBACK = false;
|
|
4
|
+
export function outboxQuarantineStatusEnabled() {
|
|
5
|
+
return resolveProcessFlagGate(OUTBOX_QUARANTINE_STATUS_FLAG, { fallback: OUTBOX_QUARANTINE_STATUS_FALLBACK }, () => OUTBOX_QUARANTINE_STATUS_FALLBACK);
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=outbox-quarantine-status-flag.js.map
|
|
@@ -118,6 +118,7 @@ export interface OutboxStats {
|
|
|
118
118
|
quarantined: number;
|
|
119
119
|
queued: number;
|
|
120
120
|
acked: number;
|
|
121
|
+
lastQuarantineReason: string | null;
|
|
121
122
|
}
|
|
122
123
|
export declare function outboxStats(root: string): OutboxStats;
|
|
123
124
|
export type DeliverFn = (op: OutboxOperation) => Promise<{
|
|
@@ -458,11 +458,17 @@ export function listOutboxOperations(root, deps = {}) {
|
|
|
458
458
|
}
|
|
459
459
|
export function outboxStats(root) {
|
|
460
460
|
const ops = listOutboxOperations(root);
|
|
461
|
+
const quarantinedOps = ops.filter((o) => o.delivery === "quarantined");
|
|
462
|
+
const latestQuarantine = quarantinedOps.reduce((latest, op) => !latest || op.updatedAt > latest.updatedAt ||
|
|
463
|
+
(op.updatedAt === latest.updatedAt && op.operationId > latest.operationId)
|
|
464
|
+
? op
|
|
465
|
+
: latest, null);
|
|
461
466
|
return {
|
|
462
467
|
depth: ops.filter((o) => o.delivery === "queued").length,
|
|
463
|
-
quarantined:
|
|
468
|
+
quarantined: quarantinedOps.length,
|
|
464
469
|
queued: ops.filter((o) => o.delivery === "queued").length,
|
|
465
470
|
acked: ops.filter((o) => o.delivery === "acked").length,
|
|
471
|
+
lastQuarantineReason: latestQuarantine?.lastErrorCode ?? null,
|
|
466
472
|
};
|
|
467
473
|
}
|
|
468
474
|
function isOutboxDue(op, nowIso) {
|