@indigoai-us/hq-cli 5.345.143 → 5.345.145

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/dist/command-catalog.generated.d.ts +286 -6
  3. package/dist/command-catalog.generated.js +351 -6
  4. package/dist/command-registration-plan.d.ts +6 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/agent-enroll.d.ts +1 -0
  7. package/dist/commands/agent-enroll.js +7 -0
  8. package/dist/commands/agents.d.ts +25 -3
  9. package/dist/commands/agents.js +57 -4
  10. package/dist/commands/api-keys.js +66 -8
  11. package/dist/commands/billing-sponsor.d.ts +4 -0
  12. package/dist/commands/billing-sponsor.js +48 -5
  13. package/dist/commands/billing-usage.d.ts +59 -0
  14. package/dist/commands/billing-usage.js +162 -0
  15. package/dist/commands/billing.d.ts +9 -0
  16. package/dist/commands/billing.js +29 -2
  17. package/dist/commands/core-checkpoint-native.d.ts +30 -0
  18. package/dist/commands/core-checkpoint-native.js +167 -10
  19. package/dist/commands/core-job-notify-native.js +53 -3
  20. package/dist/commands/core-jobs-native.d.ts +8 -0
  21. package/dist/commands/core-jobs-native.js +34 -2
  22. package/dist/commands/core-policy.js +76 -13
  23. package/dist/commands/library-add.d.ts +120 -0
  24. package/dist/commands/library-add.js +434 -0
  25. package/dist/commands/library-caption.d.ts +112 -0
  26. package/dist/commands/library-caption.js +393 -0
  27. package/dist/commands/library-link.d.ts +32 -0
  28. package/dist/commands/library-link.js +95 -0
  29. package/dist/commands/library.d.ts +72 -0
  30. package/dist/commands/library.js +527 -0
  31. package/dist/commands/meetings.d.ts +8 -0
  32. package/dist/commands/meetings.js +25 -2
  33. package/dist/commands/members.d.ts +20 -0
  34. package/dist/commands/members.js +51 -2
  35. package/dist/commands/storage-offload.d.ts +16 -2
  36. package/dist/commands/storage-offload.js +55 -4
  37. package/dist/commands/storage.d.ts +8 -1
  38. package/dist/commands/storage.js +6 -2
  39. package/dist/commands/whoami.js +9 -0
  40. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2410 -2063
  41. package/dist/hq-cloud-sync-runner/package.json +1 -1
  42. package/dist/lib/billing/plan-lock.d.ts +15 -1
  43. package/dist/lib/billing/plan-lock.js +45 -5
  44. package/dist/lib/billing/usage-card.d.ts +82 -0
  45. package/dist/lib/billing/usage-card.js +198 -0
  46. package/dist/lib/billing/usage-cost.d.ts +214 -0
  47. package/dist/lib/billing/usage-cost.js +440 -0
  48. package/dist/lib/billing/usage-only.d.ts +87 -0
  49. package/dist/lib/billing/usage-only.js +262 -0
  50. package/dist/lib/billing/usage-rate-card.d.ts +51 -0
  51. package/dist/lib/billing/usage-rate-card.js +62 -0
  52. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  53. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  54. package/dist/lib/plan-limit-nag.d.ts +4 -6
  55. package/dist/lib/plan-limit-nag.js +19 -1
  56. package/dist/lib/provider-launcher.d.ts +22 -0
  57. package/dist/lib/provider-launcher.js +65 -0
  58. package/dist/lib/search-index/index.d.ts +2 -0
  59. package/dist/lib/search-index/index.js +34 -0
  60. package/dist/lib/search-index/max-doc-bytes.d.ts +2 -1
  61. package/dist/lib/search-index/max-doc-bytes.js +2 -1
  62. package/dist/main.d.ts +9 -0
  63. package/dist/main.js +20 -3
  64. package/dist/utils/billing-gate.d.ts +19 -4
  65. package/dist/utils/billing-gate.js +25 -4
  66. package/dist/utils/plan-gate-error.d.ts +10 -0
  67. package/dist/utils/plan-gate-error.js +54 -0
  68. package/dist/utils/vault-api.js +4 -1
  69. package/package.json +2 -2
@@ -92,6 +92,14 @@ const SIBLING_LOCK_TTL_MS = 60 * 60 * 1000;
92
92
  export const SIBLING_MAX_RUNTIME_MS = 30 * 60 * 1000;
93
93
  /** Newest-wins cap so a wedged sibling cannot grow an unbounded queue. */
94
94
  const MAX_PENDING_PAYLOADS = 50;
95
+ /**
96
+ * Queued payloads one sibling run may process, across its launch claim and its
97
+ * own queue drain. Each one runs the full upgrade flow, so a 50-deep queue in
98
+ * one run outlasts SIBLING_MAX_RUNTIME_MS and the grok turn cap; the run is
99
+ * then killed with work half done. The launcher re-queues the overflow for the
100
+ * next sibling instead.
101
+ */
102
+ export const MAX_QUEUED_PAYLOADS_PER_RUN = 5;
95
103
  class CheckpointUsageError extends Error {
96
104
  }
97
105
  /**
@@ -202,16 +210,37 @@ report instead of guessing.
202
210
  skip silently on any failure. Hook or automation improvements go ONLY under
203
211
  personal/hooks/ as proposals.
204
212
  7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.
205
- 8. Write <runDir>/report.md — full prose: what you read, what you changed
206
- (paths), and what you skipped and why. List every policy or knowledge file
207
- you amended, superseded, merged or deleted with the reason, so a human can
213
+ 8. Write <runDir>/report.md NOW, before you touch any queued payload — full
214
+ prose: what you read, what you changed (paths), and what you skipped and
215
+ why. List every policy or knowledge file you
216
+ amended, superseded, merged or deleted with the reason, so a human can
208
217
  audit and reverse it; a deletion you do not name in the report is a defect.
209
- Then drain the queue: while
210
- workspace/checkpoints/sibling/pending.jsonl exists and is non-empty, claim
218
+ The run can be stopped at its time or turn cap at any point after this, so
219
+ this run's own report must already exist.
220
+ 9. QUEUED PAYLOADS CLAIMED AT LAUNCH: the payload's pending_payloads array
221
+ holds checkpoints that were queued while an earlier sibling was busy; the
222
+ same records are in <runDir>/pending-claimed.jsonl. The launcher hands one
223
+ run at most <maxQueued> of them and has already put any others back in
224
+ workspace/checkpoints/sibling/pending.jsonl. For EACH record in
225
+ pending_payloads, in order, run steps 1-5 with that record as the payload:
226
+ upgrade the thread file named in its thread_path IN PLACE and drop -auto-
227
+ from its filename. If that -auto- file no longer exists, the record was
228
+ already processed; note it and move on. For a queued record, skip the
229
+ journal close in step 4 (it belongs to the parent session) and skip step 5b
230
+ (ontology); in steps 3 and 7, <company> means that record's own company, not
231
+ this run's. After EACH record, append one line to <runDir>/report.md naming
232
+ its thread and what you did, so a run stopped mid-queue shows how far it got.
233
+ If the array is empty, skip this step.
234
+ 10. Drain the queue, still within the same limit of <maxQueued> queued records
235
+ per run counted across step 9 and this step: while
236
+ workspace/checkpoints/sibling/pending.jsonl exists and is non-empty and you
237
+ have processed fewer than <maxQueued> queued records, claim
211
238
  it atomically by renaming it aside —
212
239
  mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl
213
- (N counting up from 2) — and process the claimed payloads with this same
214
- flow. Repeat until a claim finds nothing left, then update the report.
240
+ (N counting up from 2) — and process its records as in step 9. When you
241
+ reach the limit, append every record of that claimed file you did not
242
+ process back to workspace/checkpoints/sibling/pending.jsonl (append with >>,
243
+ never overwrite) so the next sibling takes it, and record that in the report.
215
244
  NEVER read the queue and truncate it in place: a payload appended between
216
245
  your read and the truncate is lost, and its checkpoint is never enriched.
217
246
  `;
@@ -241,7 +270,8 @@ export function renderSiblingPrompt(runDir, payloadPath, options = {}) {
241
270
  .replaceAll("<ontologyStep>", () => ontologyStep)
242
271
  .replaceAll("<runDir>", () => runDir)
243
272
  .replaceAll("<payloadPath>", () => payloadPath)
244
- .replaceAll("<hqRoot>", () => options.hqRoot ?? "");
273
+ .replaceAll("<hqRoot>", () => options.hqRoot ?? "")
274
+ .replaceAll("<maxQueued>", () => String(MAX_QUEUED_PAYLOADS_PER_RUN));
245
275
  }
246
276
  function collect(value, previous) {
247
277
  return [...previous, value];
@@ -1137,7 +1167,8 @@ function appendPending(pendingPath, payload) {
1137
1167
  * describes is never enriched. Renaming is atomic, so this run receives exactly
1138
1168
  * the file that existed at the moment of the claim while concurrent callers go
1139
1169
  * on appending to a fresh queue. The claimed file is left in the run directory
1140
- * as an audit trail and as a recovery point if this sibling dies.
1170
+ * as an audit trail and as a recovery point if this sibling dies: the next
1171
+ * launch re-queues its unprocessed records through recoverOrphanedClaims.
1141
1172
  */
1142
1173
  function claimPending(pendingPath, claimPath) {
1143
1174
  try {
@@ -1162,6 +1193,124 @@ function claimPending(pendingPath, claimPath) {
1162
1193
  }
1163
1194
  return entries;
1164
1195
  }
1196
+ /**
1197
+ * Keep the oldest {@link MAX_QUEUED_PAYLOADS_PER_RUN} claimed payloads for this
1198
+ * run and append the rest back to the queue, so a deep queue is worked off
1199
+ * across several runs instead of being lost when one run hits its cap. The
1200
+ * claim file is rewritten to hold only what this run owns.
1201
+ */
1202
+ export function limitClaimedPayloads(entries, claimPath, pendingPath, limit = MAX_QUEUED_PAYLOADS_PER_RUN) {
1203
+ if (entries.length <= limit)
1204
+ return entries;
1205
+ const kept = entries.slice(0, limit);
1206
+ const overflow = entries.slice(limit);
1207
+ // Re-queue first: if the rewrite below fails, every record is still on disk.
1208
+ fs.appendFileSync(pendingPath, overflow.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
1209
+ fs.writeFileSync(claimPath, kept.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
1210
+ return kept;
1211
+ }
1212
+ /** Times one queued record may be recovered from a finished run before it is dropped. */
1213
+ export const MAX_CLAIM_RECOVERIES = 3;
1214
+ /**
1215
+ * Put back the queued records a finished sibling claimed but never processed.
1216
+ *
1217
+ * A sibling stopped at its wall-clock or turn cap leaves its claim files
1218
+ * (pending-claimed*.jsonl) in its run directory. A record whose thread file
1219
+ * still exists at its thread_path was not upgraded (the sibling renames the
1220
+ * -auto- file when it is done), so it is appended back to pending.jsonl for
1221
+ * this launch to claim. Only runs whose process is gone are read, records
1222
+ * already queued are not duplicated, and each record is recovered at most
1223
+ * {@link MAX_CLAIM_RECOVERIES} times so one the sibling keeps skipping cannot
1224
+ * cycle forever. Each claim file read is renamed to *.recovered.
1225
+ */
1226
+ export function recoverOrphanedClaims(siblingRoot, pendingPath, liveRoot) {
1227
+ let runDirs;
1228
+ try {
1229
+ runDirs = fs.readdirSync(siblingRoot).filter((entry) => entry !== "pending.jsonl");
1230
+ }
1231
+ catch {
1232
+ return 0;
1233
+ }
1234
+ const queuedThreads = new Set();
1235
+ try {
1236
+ for (const line of fs.readFileSync(pendingPath, "utf8").split("\n")) {
1237
+ if (!line.trim())
1238
+ continue;
1239
+ try {
1240
+ const threadPath = JSON.parse(line).thread_path;
1241
+ if (typeof threadPath === "string")
1242
+ queuedThreads.add(threadPath);
1243
+ }
1244
+ catch {
1245
+ // A torn line names no thread.
1246
+ }
1247
+ }
1248
+ }
1249
+ catch {
1250
+ // No queue yet.
1251
+ }
1252
+ const recovered = [];
1253
+ let count = 0;
1254
+ for (const runDir of runDirs.sort()) {
1255
+ const absolute = path.join(siblingRoot, runDir);
1256
+ let claimFiles;
1257
+ try {
1258
+ claimFiles = fs.readdirSync(absolute).filter((entry) => /^pending-claimed(?:-\d+)?\.jsonl$/u.test(entry));
1259
+ }
1260
+ catch {
1261
+ continue;
1262
+ }
1263
+ if (claimFiles.length === 0)
1264
+ continue;
1265
+ let pid = Number.NaN;
1266
+ try {
1267
+ pid = Number.parseInt(fs.readFileSync(path.join(absolute, "sibling.pid"), "utf8").trim(), 10);
1268
+ }
1269
+ catch {
1270
+ // A run that never recorded a PID cannot still be running.
1271
+ }
1272
+ if (Number.isSafeInteger(pid) && pid > 0 && processAlive(pid))
1273
+ continue;
1274
+ for (const claimFile of claimFiles.sort()) {
1275
+ const claimPath = path.join(absolute, claimFile);
1276
+ let content;
1277
+ try {
1278
+ content = fs.readFileSync(claimPath, "utf8");
1279
+ }
1280
+ catch {
1281
+ continue;
1282
+ }
1283
+ for (const line of content.split("\n")) {
1284
+ if (!line.trim())
1285
+ continue;
1286
+ let entry;
1287
+ try {
1288
+ entry = JSON.parse(line);
1289
+ }
1290
+ catch {
1291
+ continue;
1292
+ }
1293
+ const threadPath = entry.thread_path;
1294
+ if (typeof threadPath !== "string" || !threadPath || queuedThreads.has(threadPath))
1295
+ continue;
1296
+ if (!fs.existsSync(path.resolve(liveRoot, threadPath)))
1297
+ continue;
1298
+ const attempts = typeof entry.claim_recoveries === "number" ? entry.claim_recoveries : 0;
1299
+ if (attempts >= MAX_CLAIM_RECOVERIES)
1300
+ continue;
1301
+ queuedThreads.add(threadPath);
1302
+ recovered.push(`${JSON.stringify({ ...entry, claim_recoveries: attempts + 1 })}\n`);
1303
+ count += 1;
1304
+ }
1305
+ // Re-queue before retiring the claim file, so a failure leaves the
1306
+ // records on disk; a repeat read is deduplicated by thread_path above.
1307
+ if (recovered.length > 0)
1308
+ fs.appendFileSync(pendingPath, recovered.splice(0).join(""));
1309
+ fs.renameSync(claimPath, `${claimPath}.recovered`);
1310
+ }
1311
+ }
1312
+ return count;
1313
+ }
1165
1314
  async function startSibling(liveRoot, input, threadPath, backend) {
1166
1315
  const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
1167
1316
  const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
@@ -1183,9 +1332,17 @@ async function startSibling(liveRoot, input, threadPath, backend) {
1183
1332
  printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
1184
1333
  return;
1185
1334
  }
1335
+ // Before this run's directory exists, so only earlier runs are read.
1336
+ try {
1337
+ recoverOrphanedClaims(siblingRoot, pendingPath, liveRoot);
1338
+ }
1339
+ catch {
1340
+ // Recovery is best effort; the claim files stay on disk for the next launch.
1341
+ }
1186
1342
  const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
1187
1343
  fs.mkdirSync(runDir, { recursive: true });
1188
- payload.pending_payloads = claimPending(pendingPath, path.join(runDir, "pending-claimed.jsonl"));
1344
+ const claimPath = path.join(runDir, "pending-claimed.jsonl");
1345
+ payload.pending_payloads = limitClaimedPayloads(claimPending(pendingPath, claimPath), claimPath, pendingPath);
1189
1346
  if (!payload.company) {
1190
1347
  let queued;
1191
1348
  try {
@@ -195,6 +195,40 @@ function emailFromWhoami(output) {
195
195
  }
196
196
  return "";
197
197
  }
198
+ const EMAIL_RECIPIENT = /^[^\s@]+@[^\s@]+$/;
199
+ const COGNITO_SUB = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
200
+ /**
201
+ * Outpost jobs may record `owner` as the owner's Cognito user id (the box's
202
+ * OUTPOST_USER_ID; the reconciler matches company jobs on it). `hq dm` cannot
203
+ * address a Cognito id: any token that is not an email or prs_/agt_ uid is
204
+ * treated as a channel name and bounces with "No channel named …". Map the id
205
+ * to the signed-in identity's email when `hq whoami --json` reports the same
206
+ * subject, directly or as the human an Outpost session acts for.
207
+ */
208
+ function resolveCognitoOwner(owner, deps) {
209
+ if (!deps.commandExists("hq"))
210
+ return "";
211
+ let parsed;
212
+ try {
213
+ const raw = deps.run("hq", ["whoami", "--json"], { cwd: deps.cwd, env: deps.env, captureStdout: true }).stdout;
214
+ const value = JSON.parse(raw);
215
+ if (!value || typeof value !== "object" || Array.isArray(value))
216
+ return "";
217
+ parsed = value;
218
+ }
219
+ catch {
220
+ return "";
221
+ }
222
+ const target = owner.toLowerCase();
223
+ for (const [subKey, emailKey] of [["sub", "email"], ["delegatedSub", "delegatedEmail"]]) {
224
+ const sub = parsed[subKey];
225
+ const email = parsed[emailKey];
226
+ if (typeof sub === "string" && sub.toLowerCase() === target
227
+ && typeof email === "string" && EMAIL_RECIPIENT.test(email))
228
+ return email;
229
+ }
230
+ return "";
231
+ }
198
232
  function normalizedEpoch(value) {
199
233
  if (!/^-?\d+$/.test(value))
200
234
  throw new Error(`invalid HQ_JOB_NOTIFY_NOW_EPOCH: ${value}`);
@@ -260,6 +294,17 @@ export function runJobNotify(argv, options = {}) {
260
294
  log("no owner recipient — cannot dm (logged only); never fails the job");
261
295
  deps.appendFile(deliveryLog, '{"delivered":false,"reason":"missing_owner"}\n');
262
296
  }
297
+ let recipient = owner;
298
+ const cognitoOwner = Boolean(owner) && COGNITO_SUB.test(owner);
299
+ if (cognitoOwner) {
300
+ recipient = resolveCognitoOwner(owner, deps);
301
+ if (recipient)
302
+ log(`owner ${owner} is a Cognito user id — delivering to ${recipient}`);
303
+ else {
304
+ log(`owner ${owner} is a Cognito user id that hq dm cannot address and hq whoami does not report it — set the job owner to an email or prs_ uid (logged only); never fails the job`);
305
+ deps.appendFile(deliveryLog, '{"delivered":false,"reason":"unresolved_owner_id"}\n');
306
+ }
307
+ }
263
308
  let previous = {};
264
309
  if (deps.exists(stateFile))
265
310
  previous = JSON.parse(deps.readFile(stateFile));
@@ -306,18 +351,22 @@ export function runJobNotify(argv, options = {}) {
306
351
  log("skip dm send — missing owner");
307
352
  deliverRc = 1;
308
353
  }
354
+ else if (!recipient) {
355
+ log("skip dm send — owner id has no dm recipient");
356
+ deliverRc = 1;
357
+ }
309
358
  else if (!dmBin) {
310
359
  log("skip dm send — hq CLI not found");
311
360
  deliverRc = 1;
312
361
  }
313
362
  else {
314
363
  const args = dmBin === "hq"
315
- ? ["dm", owner, headline, "--details", details]
316
- : [owner, headline, details];
364
+ ? ["dm", recipient, headline, "--details", details]
365
+ : [recipient, headline, details];
317
366
  deliverRc = deps.run(dmBin, args, { cwd: deps.cwd, env: deps.env, captureStdout: false, logPath: deliveryLog }).code;
318
367
  if (deliverRc === 0) {
319
368
  delivered = 1;
320
- log(`dm delivered to ${owner} kind=${kind} job=${parsed.jobId}`);
369
+ log(`dm delivered to ${recipient} kind=${kind} job=${parsed.jobId}`);
321
370
  }
322
371
  else
323
372
  log(`dm delivery failed rc=${deliverRc} job=${parsed.jobId} (logged; not failing run)`);
@@ -344,6 +393,7 @@ export function runJobNotify(argv, options = {}) {
344
393
  delivered: delivered === 1,
345
394
  kind,
346
395
  owner,
396
+ ...(cognitoOwner ? { recipient: recipient || null } : {}),
347
397
  channel_unimplemented: channelUnimplemented === 1,
348
398
  deliver_rc: deliverRc,
349
399
  }, "compact"));
@@ -15,6 +15,14 @@ export type ReconcileOptions = {
15
15
  fetcher?: typeof fetch;
16
16
  now?: () => Date;
17
17
  };
18
+ /**
19
+ * A system unit with User=ec2-user (outpost-jobs-reconcile.service) or a cron
20
+ * job has no login session, so XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS are
21
+ * unset and `systemctl --user` fails with "Failed to connect to bus: No medium
22
+ * found" (feedback_6ce027d7). When the lingering user manager's runtime dir
23
+ * exists, point systemctl at it. Caller-provided values are kept.
24
+ */
25
+ export declare function withUserBusEnvironment(env: NodeJS.ProcessEnv, uid: number | undefined, runtimeBase?: string, exists?: (file: string) => boolean): NodeJS.ProcessEnv;
18
26
  export declare function runReconcile(argv: string[], rootArg?: string, options?: ReconcileOptions): Promise<number>;
19
27
  export declare function registerCoreJobsNativeCommand(core: Command): void;
20
28
  export declare function rawArgumentsForCommand(command: Command, fallback: string[], raw?: string[]): string[];
@@ -379,9 +379,36 @@ Env / overrides (tests):
379
379
 
380
380
  Exit: 0 success; 1 partial failures; 2 usage/config.
381
381
  `;
382
- function commandResult(command, args) {
382
+ /**
383
+ * A system unit with User=ec2-user (outpost-jobs-reconcile.service) or a cron
384
+ * job has no login session, so XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS are
385
+ * unset and `systemctl --user` fails with "Failed to connect to bus: No medium
386
+ * found" (feedback_6ce027d7). When the lingering user manager's runtime dir
387
+ * exists, point systemctl at it. Caller-provided values are kept.
388
+ */
389
+ export function withUserBusEnvironment(env, uid, runtimeBase = "/run/user", exists = fs.existsSync) {
390
+ if (uid === undefined)
391
+ return env;
392
+ const next = { ...env };
393
+ if (!next.XDG_RUNTIME_DIR) {
394
+ const directory = path.posix.join(runtimeBase, String(uid));
395
+ if (!exists(directory))
396
+ return next;
397
+ next.XDG_RUNTIME_DIR = directory;
398
+ }
399
+ if (!next.DBUS_SESSION_BUS_ADDRESS && exists(path.posix.join(next.XDG_RUNTIME_DIR, "bus"))) {
400
+ next.DBUS_SESSION_BUS_ADDRESS = `unix:path=${next.XDG_RUNTIME_DIR}/bus`;
401
+ }
402
+ return next;
403
+ }
404
+ function schedulerCommandResult(command, args) {
405
+ const env = withUserBusEnvironment(process.env, typeof process.getuid === "function" ? process.getuid() : undefined, process.env.HQ_JOB_USER_RUNTIME_BASE || "/run/user");
406
+ return commandResult(command, args, env);
407
+ }
408
+ function commandResult(command, args, env = process.env) {
383
409
  const result = spawnSync(command, args, {
384
410
  encoding: "utf8",
411
+ env,
385
412
  stdio: ["ignore", "pipe", "pipe"],
386
413
  });
387
414
  return {
@@ -486,6 +513,11 @@ function resolveBoxOwners(options) {
486
513
  email = shellText(record.email ?? user?.email);
487
514
  add(email);
488
515
  add(shellText(record.personUid ?? record.person_uid ?? record.uid));
516
+ // The person (or the human an Outpost session acts for) by Cognito
517
+ // id and email, so a job owner written either way matches the box.
518
+ add(shellText(record.sub));
519
+ add(shellText(record.delegatedEmail));
520
+ add(shellText(record.delegatedSub));
489
521
  }
490
522
  if (!email) {
491
523
  const humanEmail = /.*Email:\s*(.*)/i.exec(who)?.[1]
@@ -757,7 +789,7 @@ function systemctlResult(args, dryRun, command, options) {
757
789
  return { status: 127, stdout: "", stderr: "scheduler stub was requested but no injected runner was supplied" };
758
790
  }
759
791
  try {
760
- const runner = options.schedulerRunner ?? commandResult;
792
+ const runner = options.schedulerRunner ?? schedulerCommandResult;
761
793
  return runner(command, ["--user", ...args]);
762
794
  }
763
795
  catch (error) {
@@ -4,6 +4,7 @@
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import { policyValidatorDiagnostic } from '../lib/policy-validator.js';
7
+ import { evaluateTrigger } from '../utils/core-eval-trigger.js';
7
8
  import { packageRoot, resolveLiveRoot } from '../utils/hq-roots.js';
8
9
  import { describeCoreCommand } from './core-command-catalog.js';
9
10
  import { runPolicyFrontmatter as runPolicyFrontmatterImpl } from './core-policy-frontmatter.js';
@@ -165,12 +166,46 @@ function frontmatterScalar(file, key) {
165
166
  value = value.replace(/^["']|["']$/g, '');
166
167
  if (key === 'source' || key === 'created')
167
168
  value = value.replaceAll('"', '');
168
- if (key === 'created')
169
+ if (key === 'updated')
170
+ value = value.replace(/^["']|["']$/g, '');
171
+ if (key === 'created' || key === 'updated')
169
172
  return value.slice(0, 10);
170
173
  return value;
171
174
  }
172
175
  return '';
173
176
  }
177
+ /**
178
+ * The `when:` expression as inject-policy-on-trigger.sh reads it: the last
179
+ * `when:` line in the frontmatter, trailing ` # comment` removed, surrounding
180
+ * quotes stripped. Returns the expression when the canonical trigger evaluator
181
+ * (the one behind eval-trigger.sh) rejects it as malformed, so the policy can
182
+ * never match an event; undefined when it parses or there is no `when:`.
183
+ */
184
+ function malformedTrigger(text) {
185
+ const lines = text.split('\n');
186
+ if (!(lines[0] ?? '').startsWith('---'))
187
+ return undefined;
188
+ let depth = 0;
189
+ let when;
190
+ for (const rawLine of lines) {
191
+ const line = rawLine.replace(/\r$/, '');
192
+ if (/^---[ \t]*$/.test(line)) {
193
+ depth += 1;
194
+ if (depth === 2)
195
+ break;
196
+ continue;
197
+ }
198
+ if (depth !== 1 || !line.startsWith('when:'))
199
+ continue;
200
+ when = line
201
+ .replace(/^when:[ \t]*/, '')
202
+ .replace(/[ \t]+#.*$/, '')
203
+ .replace(/^["']|["']$/g, '');
204
+ }
205
+ if (when === undefined || when.trim() === '')
206
+ return undefined;
207
+ return evaluateTrigger(when, '') === 2 ? when : undefined;
208
+ }
174
209
  function ruleBody(file) {
175
210
  const text = fileText(file) ?? '';
176
211
  const lines = text.split('\n');
@@ -438,8 +473,18 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
438
473
  const enforcement = frontmatterScalar(file, 'enforcement') || 'unset';
439
474
  const source = frontmatterScalar(file, 'source');
440
475
  const version = frontmatterScalar(file, 'version');
441
- const createdEpoch = epochFromDate(frontmatterScalar(file, 'created')) || statMtimeSeconds(file) || 0;
476
+ const mtimeEpoch = statMtimeSeconds(file);
477
+ const createdEpoch = epochFromDate(frontmatterScalar(file, 'created')) || mtimeEpoch || 0;
442
478
  const ageDays = Math.trunc((now - createdEpoch) / 86400);
479
+ // Usage history counts only from the last change to the policy (and so
480
+ // to its trigger): `updated:` frontmatter or the file mtime, whichever is
481
+ // newer. A trigger that was malformed and was just repaired is not judged
482
+ // on the period when it could not fire. An mtime later than the report
483
+ // clock (pinned clock or clock skew) is not a usable change date.
484
+ const changedEpoch = mtimeEpoch !== undefined && mtimeEpoch <= now ? mtimeEpoch : 0;
485
+ const clockEpoch = Math.max(createdEpoch, epochFromDate(frontmatterScalar(file, 'updated')), changedEpoch);
486
+ const clockDays = Math.trunc((now - clockEpoch) / 86400);
487
+ const brokenTrigger = malformedTrigger(text);
443
488
  const fired = usage.counts.get(id) ?? 0;
444
489
  const lastEpoch = usage.newest.get(id);
445
490
  const lastFired = lastEpoch === undefined ? null : new Date(lastEpoch * 1000).toISOString().slice(0, 10);
@@ -448,24 +493,30 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
448
493
  const refs = refSummary(file, root);
449
494
  const singleIncident = ['user-correction', 'session-learning', 'back-pressure-failure'].includes(source) && (version || '1') === '1';
450
495
  const classes = [];
451
- if (fired === 0) {
452
- const comparison = bashTestGreaterOrEqual(ageDays, parsed.minAge, formerScriptPath(POLICY_AGE_REPORT_SCRIPT), 173, platform);
496
+ if (brokenTrigger !== undefined) {
497
+ // A policy whose `when:` cannot parse never had a chance to fire, so
498
+ // its usage history says nothing about whether the rule is needed. It
499
+ // is reported as broken and never offered for retirement.
500
+ classes.push('malformed-trigger');
501
+ }
502
+ else if (fired === 0) {
503
+ const comparison = bashTestGreaterOrEqual(clockDays, parsed.minAge, formerScriptPath(POLICY_AGE_REPORT_SCRIPT), 173, platform);
453
504
  stderr += comparison.stderr;
454
505
  if (comparison.value && usage.anyFiles)
455
506
  classes.push('never-fired');
456
507
  }
457
- if (fired > 0) {
458
- const comparison = bashTestGreaterOrEqual(lastAge, parsed.dormantDays, formerScriptPath(POLICY_AGE_REPORT_SCRIPT), 174, platform);
508
+ if (brokenTrigger === undefined && fired > 0) {
509
+ const comparison = bashTestGreaterOrEqual(Math.min(lastAge, clockDays), parsed.dormantDays, formerScriptPath(POLICY_AGE_REPORT_SCRIPT), 174, platform);
459
510
  stderr += comparison.stderr;
460
511
  if (comparison.value)
461
512
  classes.push('dormant');
462
513
  }
463
- if (refs.stale.length > 0)
514
+ if (brokenTrigger === undefined && refs.stale.length > 0)
464
515
  classes.push('stale-refs');
465
- if (refs.renamed.length > 0)
516
+ if (brokenTrigger === undefined && refs.renamed.length > 0)
466
517
  classes.push('renamed-refs');
467
- if (singleIncident) {
468
- const comparison = bashTestGreaterOrEqual(ageDays, parsed.incidentDays, formerScriptPath(POLICY_AGE_REPORT_SCRIPT), 178, platform);
518
+ if (brokenTrigger === undefined && singleIncident) {
519
+ const comparison = bashTestGreaterOrEqual(clockDays, parsed.incidentDays, formerScriptPath(POLICY_AGE_REPORT_SCRIPT), 178, platform);
469
520
  stderr += comparison.stderr;
470
521
  if (comparison.value && fired <= 1)
471
522
  classes.push('single-incident-old');
@@ -484,6 +535,7 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
484
535
  retrieved,
485
536
  refs_judged: refs.judged,
486
537
  renamed_refs: refs.renamed,
538
+ ...(brokenTrigger === undefined ? {} : { malformed_trigger: brokenTrigger }),
487
539
  });
488
540
  }
489
541
  }
@@ -491,10 +543,14 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
491
543
  const selected = parsed.candidates ? rows.filter((row) => row.candidate_classes.length > 0) : rows;
492
544
  return policyResult(0, `${JSON.stringify(selected)}\n`, stderr);
493
545
  }
494
- const never = rows.filter((row) => row.fired === 0).length;
495
- const staleTotal = rows.filter((row) => row.stale_refs.length > 0).length;
496
- const candidateCount = rows.filter((row) => row.candidate_classes.length > 0).length;
546
+ const malformed = rows.filter((row) => row.malformed_trigger !== undefined);
547
+ const never = rows.filter((row) => row.fired === 0 && row.malformed_trigger === undefined).length;
548
+ const staleTotal = rows.filter((row) => row.stale_refs.length > 0 && row.malformed_trigger === undefined).length;
549
+ const candidateCount = rows.filter((row) => row.candidate_classes.length > 0 && row.malformed_trigger === undefined).length;
497
550
  let stdout = `policy-age-report: ${rows.length} policies, ${never} never fired, ${staleTotal} with stale references, ${candidateCount} retirement candidates (min-age ${parsed.minAge}d, dormant ${parsed.dormantDays}d, incident ${parsed.incidentDays}d)\n`;
551
+ if (malformed.length > 0) {
552
+ stdout += `policy-age-report: ${malformed.length} with a malformed when: trigger that cannot fire (fix the trigger; not retirement candidates): ${malformed.map((row) => row.id).sort().join(', ')}\n`;
553
+ }
498
554
  if (!parsed.candidates)
499
555
  stdout += ' (add --candidates to list them, --json for machine-readable rows)\n';
500
556
  else {
@@ -805,6 +861,13 @@ function autoRetire(options, root, cwd, env, now) {
805
861
  continue;
806
862
  }
807
863
  let why = '';
864
+ if (classes.split(',').includes('malformed-trigger')) {
865
+ const expression = candidate.malformed_trigger ?? '';
866
+ reportText += `- needs-fix (not retired) **${candidate.id}** (${candidate.path}): malformed when: trigger, so it can never fire: ${expression}\n`;
867
+ stderr += `policy-retire: ${candidate.id} (${candidate.path}) has a malformed when: trigger and was not retired; fix it: ${expression}\n`;
868
+ needfix += 1;
869
+ continue;
870
+ }
808
871
  if (classes.split(',').includes('renamed-refs')) {
809
872
  reportText += `- needs-fix (not retired) **${candidate.id}** (${candidate.path}): referenced file was renamed: ${candidate.renamed_refs.join(', ')}\n`;
810
873
  needfix += 1;
@@ -0,0 +1,120 @@
1
+ import { Readable } from "node:stream";
2
+ import { type CloudAssetMediaKind } from "@indigoai-us/hq-cloud";
3
+ import type { LibraryCaptionNote, LibraryCaptionRequest } from "./library-caption.js";
4
+ export interface LibraryCompany {
5
+ uid: string;
6
+ slug: string;
7
+ }
8
+ export interface LibrarySource {
9
+ kind: "file" | "url";
10
+ /** The argument as given. */
11
+ input: string;
12
+ /** File name the placeholder is written under. */
13
+ name: string;
14
+ }
15
+ export interface OpenedSource {
16
+ stream: Readable;
17
+ contentType?: string;
18
+ }
19
+ export interface PutResponse {
20
+ status: number;
21
+ headers: Record<string, string | undefined>;
22
+ }
23
+ export interface LibraryAddDeps {
24
+ hqRoot: string;
25
+ now: () => Date;
26
+ /** Resolve the slug to an active company membership. */
27
+ resolveCompany: (slug: string) => Promise<LibraryCompany>;
28
+ /** POST /v1/files/presign. */
29
+ presign: (body: Record<string, unknown>) => Promise<Response>;
30
+ /** Open a fresh byte stream of a source. Called once to hash and once to upload. */
31
+ openSource: (source: LibrarySource) => Promise<OpenedSource>;
32
+ /** PUT `body` (exactly `size` bytes) to a presigned URL with `headers`. */
33
+ put: (url: string, headers: Record<string, string>, body: Readable, size: number) => Promise<PutResponse>;
34
+ writePlaceholder: (file: string, text: string) => Promise<void>;
35
+ /**
36
+ * Caption one written image or video placeholder (US-006). Must not throw:
37
+ * a caption failure never fails the upload. Absent = no captioning.
38
+ */
39
+ caption?: (request: LibraryCaptionRequest) => Promise<LibraryCaptionNote>;
40
+ }
41
+ export interface LibraryAddOptions {
42
+ company?: string;
43
+ to?: string;
44
+ tags: string[];
45
+ title?: string;
46
+ note?: string;
47
+ }
48
+ export interface LibraryAddItem {
49
+ source: string;
50
+ ok: boolean;
51
+ /** HQ-relative placeholder path (set when written). */
52
+ placeholderPath?: string;
53
+ originalPath?: string;
54
+ key?: string;
55
+ size?: number;
56
+ sha256?: string;
57
+ /** False when the vault already held the bytes. */
58
+ uploaded?: boolean;
59
+ error?: string;
60
+ /** AI caption outcome, set when captioning ran for this item. */
61
+ caption?: LibraryCaptionNote;
62
+ }
63
+ export interface LibraryAddResult {
64
+ company: string;
65
+ items: LibraryAddItem[];
66
+ }
67
+ export declare function contentTypeFor(name: string, served?: string): string;
68
+ export declare function mediaKindFor(contentType: string): CloudAssetMediaKind;
69
+ /** A file name safe to place in one directory: no separators, no control chars. */
70
+ export declare function safeAssetName(raw: string): string;
71
+ /** Classify an argument: an http(s) URL, otherwise a local file. */
72
+ export declare function parseSource(input: string): LibrarySource;
73
+ /**
74
+ * The library directory, HQ-relative. `--to` resolves against the HQ root and
75
+ * must be inside `companies/<co>/`, so `hq storage fetch` can find the
76
+ * company vault from the placeholder path. The default is
77
+ * `companies/<co>/library`.
78
+ */
79
+ export declare function resolveLibraryDir(hqRoot: string, options: Pick<LibraryAddOptions, "company" | "to">): {
80
+ slug: string;
81
+ dirRel: string;
82
+ dirAbs: string;
83
+ };
84
+ export declare function offloadTimestamp(now: Date): string;
85
+ /**
86
+ * Testable core. Every source is hashed first, then one presign request
87
+ * covers all of them (hq-pro rate-limits presign per user). A 402 for the
88
+ * request refuses every source and writes nothing; a per-key refusal fails
89
+ * only that source.
90
+ */
91
+ export declare function runLibraryAdd(inputs: readonly string[], options: LibraryAddOptions, deps: LibraryAddDeps): Promise<LibraryAddResult>;
92
+ export interface LibraryTagResult {
93
+ placeholderPath: string;
94
+ tags: string[];
95
+ changed: boolean;
96
+ }
97
+ /**
98
+ * Add and remove tags on a library placeholder. The placeholder is parsed and
99
+ * re-rendered by hq-cloud's placeholder writer, then replaced atomically
100
+ * (temp file + rename), so a sync watcher never reads a half-written file.
101
+ */
102
+ export declare function runLibraryTag(hqRoot: string, target: string, edits: {
103
+ add: string[];
104
+ remove: string[];
105
+ }, now?: () => Date): Promise<LibraryTagResult>;
106
+ /** Replace a placeholder atomically (temp file + rename). */
107
+ export declare function replacePlaceholderText(file: string, text: string): Promise<void>;
108
+ /** Open a local file or GET a URL as a Node stream. */
109
+ export declare function openLibrarySource(source: LibrarySource): Promise<OpenedSource>;
110
+ /**
111
+ * Stream a PUT with an explicit Content-Length: S3 presigned PUTs refuse
112
+ * chunked transfer encoding, which fetch would use for a stream body.
113
+ */
114
+ export declare function streamPut(url: string, headers: Record<string, string>, body: Readable, size: number): Promise<PutResponse>;
115
+ export declare const defaultLibraryIo: {
116
+ openSource: typeof openLibrarySource;
117
+ put: typeof streamPut;
118
+ writePlaceholder: (file: string, text: string) => Promise<void>;
119
+ };
120
+ //# sourceMappingURL=library-add.d.ts.map