@indigoai-us/hq-cli 5.297.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 CHANGED
@@ -2,6 +2,12 @@
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
+
5
11
  ## [5.297.0] — 2026-09-30
6
12
  - When enabled by hq-flags, show the quarantined work-context outbox count and latest reason in hq doctor and mesh daemon status.
7
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.
@@ -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 appId = nonEmptyString(configuredSlack?.appId);
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: "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.",
617
+ instruction: SLACK_APP_TOKEN_INSTRUCTION,
572
618
  url: `https://api.slack.com/apps/${appId}/general`,
573
619
  urlLabel: "Get token",
574
- pasteUrl: `https://hq.computer/companies/${encodeURIComponent(companyUrlKey)}/agents?setup=${encodeURIComponent(agentUid)}`,
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): void;
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
- return rows.filter((r) => r.key.toLowerCase().includes(q));
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: defaultS3Factory,
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: defaultS3Factory,
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: defaultS3Factory,
1196
+ s3Factory: personalS3Factory,
1174
1197
  region: DEFAULT_COGNITO.region,
1175
1198
  });
1176
- console.log(formatBrowseTable(rows));
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(formatBrowseTable(rows));
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
- await raiseForResponse(res, "Failed to connect the app");
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) {
@@ -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;
@@ -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
- out.policyPaths = names
201
- .filter((n) => n.endsWith(".md") && !n.startsWith(".") && !n.includes(".conflict-"))
202
- .map((n) => path.join(policiesRel, n));
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
@@ -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);
@@ -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
- export function policyValidatorDiagnostic(content, filePath, env) {
84
- if (!isPolicyPath(filePath))
85
- return undefined;
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 `BLOCKED: policy file is missing required trigger frontmatter (missing: no-frontmatter).\n\nEvery policy under */policies/ must declare BOTH:\n when: <expression> # e.g. always | git && push | deploy || share\n on: [<events>] # any of PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent, SessionStart\nThese drive just-in-time policy injection. See\ncore/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").\nAdd both fields to the frontmatter, then retry.\n${VALIDATION_NOTE}`;
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 `BLOCKED: policy file is missing required trigger frontmatter (missing: ${missing.join(',')}).\n\nEvery policy under */policies/ must declare BOTH:\n when: <expression> # e.g. always | git && push | deploy || share\n on: [<events>] # any of PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent, SessionStart\nThese drive just-in-time policy injection. See\ncore/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").\nAdd both fields to the frontmatter, then retry.\n${VALIDATION_NOTE}`;
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\nreactive event (${events}).\n\n\`when: always\` is TRUE for every command, prompt, and message. On a reactive\nevent the injector ranks that as an event-specific match, so it takes the\nsession cap slot of a policy that genuinely matched the thing you are doing —\nthe loose trigger crowds out the precise one.\n\nPick one:\n when: always + on: [SessionStart] # ambient governance rule\n when: <real signal> + on: [PreToolUse, ...] # e.g. deploy || publish\n\nName the word(s) that actually appear when the rule is relevant. See\ncore/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").\n${VALIDATION_NOTE}`;
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
- let malformedWhen;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.297.0",
3
+ "version": "5.298.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {