@indigoai-us/hq-cli 5.297.0 → 5.299.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.
@@ -22,7 +22,7 @@ import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
22
22
  import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
23
23
  import { readLegacySyncEnv } from "./legacy-sync-env.js";
24
24
  import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
25
- import { adoptLegacyUnits } from "./migrate.js";
25
+ import { retireLegacyMeshUnits } from "./migrate.js";
26
26
  import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
27
27
  import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
28
28
  import { daemonRootRefusal } from "./install.js";
@@ -72,6 +72,17 @@ export function probeLegacyUnits(platform, home) {
72
72
  home,
73
73
  existsSync: fs.existsSync,
74
74
  listDir: (dir) => fs.readdirSync(dir),
75
+ launchdJobLoaded: (label) => {
76
+ if (platform !== "darwin")
77
+ return false;
78
+ const uid = process.getuid?.() ?? 0;
79
+ const result = spawnSync("launchctl", ["print", `gui/${uid}/${label}`], {
80
+ encoding: "utf8",
81
+ windowsHide: true,
82
+ timeout: 15_000,
83
+ });
84
+ return result.status === 0;
85
+ },
75
86
  windowsTaskExists,
76
87
  });
77
88
  }
@@ -344,7 +355,12 @@ export async function runDaemon(opts) {
344
355
  if (legacyMesh.length > 0) {
345
356
  try {
346
357
  const uid = process.getuid?.() ?? 0;
347
- const result = adoptLegacyUnits(legacyMesh, {
358
+ retireLegacyMeshUnits(legacyMesh, {
359
+ platform,
360
+ managed: opts.managed === true,
361
+ hosted: opts.host !== undefined,
362
+ meshEnabled: enabledServices(machineType, initial).includes("mesh"),
363
+ }, {
348
364
  run: (command, args) => {
349
365
  const r = spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15_000 });
350
366
  return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
@@ -356,11 +372,7 @@ export async function runDaemon(opts) {
356
372
  systemAdoptedDir: "/var/lib/hq/daemon/adopted",
357
373
  systemRecordPath: "/var/lib/hq/daemon/adopted.json",
358
374
  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}`);
375
+ }, (level, message) => log(level, message));
364
376
  }
365
377
  catch (err) {
366
378
  log("error", `could not retire legacy mesh units: ${err.message}`);
@@ -28,6 +28,7 @@ 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
30
  const MESH_OWNER_RECHECK_MS = 15_000;
31
+ const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
31
32
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
32
33
  export const HANDOFF_STAGGER_MS = 15_000;
33
34
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
@@ -297,11 +298,18 @@ export function createSupervisor(deps) {
297
298
  }
298
299
  const meshOwnerReason = slot.plan.service === "mesh" ? deps.meshOwnerReason?.() : undefined;
299
300
  if (meshOwnerReason) {
301
+ let lastLoggedReason;
302
+ let lastLoggedAt = Number.NEGATIVE_INFINITY;
300
303
  const waitForOwner = (reason) => {
301
304
  slot.state.status = "waiting";
302
305
  slot.state.reason = reason;
303
306
  slot.state.nextRunAt = now() + MESH_OWNER_RECHECK_MS;
304
- deps.log("warn", `${reason}; retrying mesh ownership check in ${MESH_OWNER_RECHECK_MS}ms`);
307
+ const at = now();
308
+ if (reason !== lastLoggedReason || at - lastLoggedAt >= MESH_OWNER_LOG_INTERVAL_MS) {
309
+ deps.log("warn", `${reason}; retrying mesh ownership check in ${MESH_OWNER_RECHECK_MS}ms`);
310
+ lastLoggedReason = reason;
311
+ lastLoggedAt = at;
312
+ }
305
313
  slot.timer = setT(() => {
306
314
  slot.timer = undefined;
307
315
  const current = deps.meshOwnerReason?.();
@@ -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);
@@ -89,6 +89,25 @@ export declare function quotaSignalFromFields(fields: {
89
89
  }, model: string | undefined, now: Date): QuotaSignal | null;
90
90
  export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
91
91
  export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, signal: QuotaSignal): QuotaState;
92
+ export interface ClearedModelQuotaReset {
93
+ model: string;
94
+ quota_reset_at: string;
95
+ }
96
+ export interface AccountQuotaClearResult {
97
+ all_models_quota_reset_at: string | null;
98
+ model_quota_resets: ClearedModelQuotaReset[];
99
+ }
100
+ export interface ClearAccountQuotaOptions {
101
+ /** Clear only this model's reset instead of every per-model reset. */
102
+ model?: string;
103
+ /** Clear only marks whose reset time is still ahead of now. */
104
+ futureOnly?: boolean;
105
+ now?: Date;
106
+ }
107
+ /** Clear quota reset marks and persist them with the quota file's atomic writer. */
108
+ export declare function clearAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, options?: ClearAccountQuotaOptions): AccountQuotaClearResult;
109
+ export declare function clearAccountQuotaAfterSuccessfulTurn(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): AccountQuotaClearResult;
110
+ export declare function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): void;
92
111
  export interface QuotaBlock {
93
112
  blocked: boolean;
94
113
  reason?: "account_quota_exhausted" | "account_model_unavailable";
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
+ import { withLaneRecordFileLock } from "./store.js";
22
23
  export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
23
24
  export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
24
25
  /**
@@ -278,40 +279,116 @@ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
278
279
  return quotaSignalFromFields(fields, model, now);
279
280
  }
280
281
  export function recordAccountQuota(hqRoot, provider, accountId, signal) {
281
- const state = loadQuotaState(hqRoot);
282
- const accounts = state.providers[provider] ?? {};
283
- const current = accounts[accountId] ?? { models: {} };
284
- const models = { ...current.models };
285
- if (signal.model) {
286
- const prior = models[signal.model] ?? {};
287
- const next = { ...prior };
288
- if (signal.needs_credits) {
289
- next.needs_credits = true;
290
- next.marker = signal.marker ?? `needs credits for ${signal.model}`;
291
- delete next.quota_reset_at;
282
+ return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
283
+ const state = loadQuotaState(hqRoot);
284
+ const accounts = state.providers[provider] ?? {};
285
+ const current = accounts[accountId] ?? { models: {} };
286
+ const models = { ...current.models };
287
+ if (signal.model) {
288
+ const prior = models[signal.model] ?? {};
289
+ const next = { ...prior };
290
+ if (signal.needs_credits) {
291
+ next.needs_credits = true;
292
+ next.marker = signal.marker ?? `needs credits for ${signal.model}`;
293
+ delete next.quota_reset_at;
294
+ }
295
+ if (signal.quota_reset_at &&
296
+ !signal.needs_credits &&
297
+ shouldReplaceReset(next.quota_reset_at, signal.quota_reset_at)) {
298
+ next.quota_reset_at = signal.quota_reset_at;
299
+ }
300
+ models[signal.model] = next;
292
301
  }
293
- if (signal.quota_reset_at &&
294
- !signal.needs_credits &&
295
- shouldReplaceReset(next.quota_reset_at, signal.quota_reset_at)) {
296
- next.quota_reset_at = signal.quota_reset_at;
302
+ const account = { ...current, models };
303
+ if (signal.account_wide && signal.quota_reset_at) {
304
+ if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
305
+ account.all_models_quota_reset_at = signal.quota_reset_at;
306
+ account.reset_source = signal.reset_source ?? "provider";
307
+ }
308
+ else if (current.all_models_quota_reset_at) {
309
+ account.all_models_quota_reset_at = current.all_models_quota_reset_at;
310
+ account.reset_source = current.reset_source ?? "provider";
311
+ }
297
312
  }
298
- models[signal.model] = next;
299
- }
300
- const account = { ...current, models };
301
- if (signal.account_wide && signal.quota_reset_at) {
302
- if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
303
- account.all_models_quota_reset_at = signal.quota_reset_at;
304
- account.reset_source = signal.reset_source ?? "provider";
313
+ accounts[accountId] = account;
314
+ state.providers[provider] = accounts;
315
+ writeQuotaState(hqRoot, state);
316
+ return state;
317
+ });
318
+ }
319
+ /** Clear quota reset marks and persist them with the quota file's atomic writer. */
320
+ export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
321
+ return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
322
+ const cleared = {
323
+ all_models_quota_reset_at: null,
324
+ model_quota_resets: [],
325
+ };
326
+ const state = loadQuotaState(hqRoot);
327
+ const account = state.providers[provider]?.[accountId];
328
+ if (!account)
329
+ return cleared;
330
+ const now = options.now ?? new Date();
331
+ const shouldClear = (reset) => reset !== undefined &&
332
+ (!options.futureOnly || resetStillAhead(reset, now));
333
+ let changed = false;
334
+ const accountWideReset = account.all_models_quota_reset_at;
335
+ if (shouldClear(accountWideReset)) {
336
+ cleared.all_models_quota_reset_at = accountWideReset;
337
+ delete account.all_models_quota_reset_at;
338
+ delete account.reset_source;
339
+ changed = true;
340
+ }
341
+ const modelNames = options.model === undefined
342
+ ? Object.keys(account.models).sort()
343
+ : [options.model];
344
+ for (const model of modelNames) {
345
+ const entry = account.models[model];
346
+ const modelReset = entry?.quota_reset_at;
347
+ if (!entry || !shouldClear(modelReset))
348
+ continue;
349
+ cleared.model_quota_resets.push({
350
+ model,
351
+ quota_reset_at: modelReset,
352
+ });
353
+ delete entry.quota_reset_at;
354
+ if (Object.keys(entry).length === 0)
355
+ delete account.models[model];
356
+ changed = true;
305
357
  }
306
- else if (current.all_models_quota_reset_at) {
307
- account.all_models_quota_reset_at = current.all_models_quota_reset_at;
308
- account.reset_source = current.reset_source ?? "provider";
358
+ if (!changed)
359
+ return cleared;
360
+ if (account.all_models_quota_reset_at === undefined) {
361
+ delete account.reset_source;
309
362
  }
363
+ const accounts = state.providers[provider];
364
+ if (account.all_models_quota_reset_at === undefined &&
365
+ Object.keys(account.models).length === 0) {
366
+ delete accounts[accountId];
367
+ if (Object.keys(accounts).length === 0)
368
+ delete state.providers[provider];
369
+ }
370
+ else {
371
+ accounts[accountId] = account;
372
+ }
373
+ writeQuotaState(hqRoot, state);
374
+ return cleared;
375
+ });
376
+ }
377
+ export function clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now) {
378
+ return clearAccountQuota(hqRoot, provider, accountId, {
379
+ model,
380
+ futureOnly: true,
381
+ now,
382
+ });
383
+ }
384
+ export function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, provider, accountId, model, now) {
385
+ try {
386
+ clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now);
387
+ }
388
+ catch (err) {
389
+ const detail = err instanceof Error ? err.message : String(err);
390
+ process.stderr.write(`hq lanes: quota cleanup after successful turn failed for provider=${provider} account=${accountId} model=${model}: ${detail}\n`);
310
391
  }
311
- accounts[accountId] = account;
312
- state.providers[provider] = accounts;
313
- writeQuotaState(hqRoot, state);
314
- return state;
315
392
  }
316
393
  function shouldReplaceReset(current, incoming) {
317
394
  if (!current)
@@ -27,7 +27,7 @@ import { appendDropboxMessage } from "./dropbox.js";
27
27
  import { DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG, listHostProcesses, runRunawayToolGuard, RUNAWAY_TOOL_GUARD_FLAG, } from "./runaway-tool-guard.js";
28
28
  import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG, LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS, LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT, queueLaneTerminalResume, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
29
29
  import { providerSupportsSessionResume } from "./delivery.js";
30
- import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
30
+ import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, readQuotaSignal, recordAccountQuota, } from "./quota.js";
31
31
  import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
32
32
  import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
33
33
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
@@ -1299,6 +1299,17 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1299
1299
  }
1300
1300
  return next;
1301
1301
  }, deps.dryRun);
1302
+ if (!deps.dryRun &&
1303
+ result.changed &&
1304
+ result.record.state === "done" &&
1305
+ terminalClassification.state === "done" &&
1306
+ terminalEnvelope?.decision === "done" &&
1307
+ !quota &&
1308
+ !providerRejection &&
1309
+ result.record.account &&
1310
+ result.record.model) {
1311
+ clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, result.record.provider, result.record.account, result.record.model, new Date(nowIso));
1312
+ }
1302
1313
  if (result.changed) {
1303
1314
  summary.dead_host.push({
1304
1315
  id: lane.lane_id,
@@ -43,6 +43,8 @@ export declare function sanitizeHostComponent(hostname: string): string;
43
43
  export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> & Partial<Pick<StoreDeps, "generateLaneUlid">>): string;
44
44
  export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
45
45
  export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
46
+ /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
47
+ export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T): T;
46
48
  export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
47
49
  export declare function parseQuestionRecord(raw: unknown, filePath?: string): QuestionRecord;
48
50
  /**
@@ -222,6 +222,10 @@ function withRecordLock(recordPath, deps, fn) {
222
222
  }
223
223
  }
224
224
  }
225
+ /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
226
+ export function withLaneRecordFileLock(recordPath, fn) {
227
+ return withRecordLock(recordPath, defaultStoreDeps(), fn);
228
+ }
225
229
  function readJsonFile(filePath, deps) {
226
230
  let text;
227
231
  try {
@@ -40,7 +40,7 @@ import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
40
40
  import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
41
41
  import { enforceRegistryApprovalGate } from "./approval-gate.js";
42
42
  import { readWorkerRunResult } from "./delivery.js";
43
- import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
43
+ import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
44
44
  import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
45
45
  import { appendDropboxMessageAtPath, dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
46
46
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
@@ -445,6 +445,15 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
445
445
  }
446
446
  return next;
447
447
  });
448
+ if (updated.state === "done" &&
449
+ effectiveClassification.state === "done" &&
450
+ effectiveEnvelope?.decision === "done" &&
451
+ opts.quota == null &&
452
+ opts.providerRejection == null &&
453
+ updated.account &&
454
+ updated.model) {
455
+ clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, updated.provider, updated.account, updated.model, new Date(at));
456
+ }
448
457
  notifySlotFreed({
449
458
  hqRoot,
450
459
  provider: updated.provider,
@@ -307,6 +307,9 @@ export declare function uninstallDaemonService(opts: {
307
307
  unlinkSync?: typeof fs.unlinkSync;
308
308
  existsSync?: typeof fs.existsSync;
309
309
  deactivate?: (platform: DaemonInstallPlatform, dest: string) => void;
310
+ /** Service-manager runner for uninstall deactivation (tests inject; never the real binary). */
311
+ run?: RunCommand;
312
+ uid?: number;
310
313
  /** Windows: schtasks.exe runner (tests inject; never the real binary). */
311
314
  schtasks?: SchtasksRunner;
312
315
  /** Windows: terminate the recorded daemon pid after `schtasks /End` (tests inject). */
@@ -22,7 +22,7 @@ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqR
22
22
  import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
23
23
  import { retireLegacyListen } from "./legacy-listen.js";
24
24
  import { readDaemonState } from "./state.js";
25
- import { activateLaunchd, launchdIsLoaded, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
25
+ import { activateLaunchd, defaultRunCommand, launchdIsLoaded, launchdServiceTarget, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
26
26
  import { activateSystemd, systemdIsActive } from "../../../service-manager/systemd.js";
27
27
  import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
28
28
  /**
@@ -955,7 +955,18 @@ export function uninstallDaemonService(opts) {
955
955
  }
956
956
  if (platform === "darwin") {
957
957
  if (existsSync(paths.plistPath)) {
958
- opts.deactivate?.(platform, paths.plistPath);
958
+ if (opts.deactivate) {
959
+ opts.deactivate(platform, paths.plistPath);
960
+ }
961
+ else {
962
+ const run = opts.run ?? defaultRunCommand;
963
+ const target = launchdServiceTarget(paths.plistPath, opts.uid);
964
+ const bootout = run("launchctl", ["bootout", target]);
965
+ if (bootout.status !== 0 && run("launchctl", ["print", target]).status === 0) {
966
+ const detail = (bootout.stderr || bootout.stdout).trim();
967
+ throw new Error(`launchctl bootout failed for ${target}${detail ? `: ${detail}` : ` (exit ${bootout.status})`}`);
968
+ }
969
+ }
959
970
  unlinkSync(paths.plistPath);
960
971
  }
961
972
  return {
@@ -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