@ghostpaw/questlog 0.10.1 → 0.11.1

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.
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // src/hooks/list_hooks.ts
3
+ function listHooks(db) {
4
+ return db.prepare(`SELECT id, url, headers, tag, events, min_gap, created_at, last_wake,
5
+ (SELECT COUNT(*) FROM hook_assignments a LEFT JOIN hook_deliveries d ON d.id = a.delivery_id WHERE a.target_id = t.id AND (a.delivery_id IS NULL OR d.status = 'pending')) AS pending,
6
+ (SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
7
+ FROM hook_targets t ORDER BY id`).all().map((row) => {
8
+ const url = new URL(String(row.url));
9
+ url.username = "";
10
+ url.password = "";
11
+ url.search = "";
12
+ url.hash = "";
13
+ return {
14
+ id: Number(row.id),
15
+ headers: Object.fromEntries(
16
+ Object.keys(JSON.parse(String(row.headers))).map((name) => [name, "***"])
17
+ ),
18
+ scope: row.tag == null ? "database" : "tag",
19
+ url: url.href,
20
+ tag: row.tag == null ? null : String(row.tag),
21
+ events: JSON.parse(String(row.events)),
22
+ minGap: Number(row.min_gap),
23
+ createdAt: Number(row.created_at),
24
+ lastWake: row.last_wake == null ? null : Number(row.last_wake),
25
+ pending: Number(row.pending),
26
+ failed: Number(row.failed)
27
+ };
28
+ });
29
+ }
30
+
31
+ export { listHooks };
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // src/surfaces/text_output.ts
3
+ var isTime = (key, value) => typeof value === "number" && value > 1e12 && /(At|Wake)$/.test(key);
4
+ function scalar(key, value) {
5
+ if (value == null || value === "") return "-";
6
+ if (isTime(key, value)) return new Date(value).toISOString();
7
+ return String(value).replace(/[\r\n]+/g, " ");
8
+ }
9
+ function lines(value, indent, key = "") {
10
+ if (Array.isArray(value)) {
11
+ if (!value.length) return [`${indent}none`];
12
+ if (value.every((item) => item === null || typeof item !== "object"))
13
+ return [`${indent}${value.map((item) => scalar(key, item)).join(", ")}`];
14
+ return value.flatMap((item, index) => [...index ? [""] : [], ...lines(item, indent, key)]);
15
+ }
16
+ if (value !== null && typeof value === "object") {
17
+ const out = [];
18
+ for (const [name, item] of Object.entries(value)) {
19
+ if (item !== null && typeof item === "object") {
20
+ const nested = lines(item, `${indent} `, name);
21
+ if (Array.isArray(item) && nested.length === 1)
22
+ out.push(`${indent}${name}: ${nested[0]?.trim()}`);
23
+ else out.push(`${indent}${name}:`, ...nested);
24
+ } else out.push(`${indent}${name}: ${scalar(name, item)}`);
25
+ }
26
+ return out.length ? out : [`${indent}-`];
27
+ }
28
+ return [`${indent}${scalar(key, value)}`];
29
+ }
30
+ function textOutput(value) {
31
+ if (value == null) return "-";
32
+ if (Array.isArray(value) && !value.length) return "None";
33
+ return lines(value, "").join("\n");
34
+ }
35
+
36
+ export { textOutput };
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { tickQuestlog, removeHook, listDeliveries, listHooks, listDueRepeatableQuestAnchors, getRepeatableQuestOrThrow, spawnDueRepeatableQuests, deriveQuestState, deriveQuestMarkerId, queryQuestDetailRecords, resolveNow, withTransaction, getQuestOrThrow, parseRRule, mapQuestRewardRow, normalizeTagName } from './chunk-N7V7VUI5.js';
2
+ import { tickQuestlog, removeHook, listDueRepeatableQuestAnchors, getRepeatableQuestOrThrow, spawnDueRepeatableQuests, deriveQuestState, deriveQuestMarkerId, queryQuestDetailRecords, resolveNow, withTransaction, getQuestOrThrow, parseRRule, mapQuestRewardRow, normalizeTagName } from './chunk-FORPYJT4.js';
3
+ import { listDeliveries } from './chunk-D6EGZCT7.js';
4
+ import { listHooks } from './chunk-NBCCA6AX.js';
3
5
  import { registerSchemaActions, minimalInput, requiredForAction } from './chunk-QYSIJ2YN.js';
4
6
 
5
7
  // src/questlines/map_questline_row.ts
package/dist/cli.js CHANGED
@@ -13,5 +13,5 @@ function checkNodeVersion(version, stderr) {
13
13
  if (!checkNodeVersion(process.versions.node, process.stderr)) {
14
14
  process.exitCode = 2;
15
15
  } else {
16
- await import('./cli_main-7AIEZV75.js');
16
+ await import('./cli_main-YW3E2NJM.js');
17
17
  }
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { CliUsageError, renderCliHelp, parseCliArgs } from './chunk-GVD6A3RR.js';
2
+ import { questlogSkills } from './chunk-I2AMIXGP.js';
3
+ import { CliUsageError, renderCliHelp, parseCliArgs } from './chunk-B5KVA6XE.js';
3
4
  import { isErrorResult, resolveQuestlogDbPath } from './chunk-IL2QRKZL.js';
4
- import { questlogSkills } from './chunk-D2W5MYNO.js';
5
- import { reviewQuestlogTool, validateInput, getQuestDetail, questlogSurfaceTools } from './chunk-55JAQORX.js';
6
- import { listDeliveries, questlogVersion } from './chunk-N7V7VUI5.js';
5
+ import { reviewQuestlogTool, validateInput, getQuestDetail, questlogSurfaceTools } from './chunk-R56MXZ2K.js';
6
+ import { questlogVersion } from './chunk-FORPYJT4.js';
7
+ import { listDeliveries } from './chunk-D6EGZCT7.js';
8
+ import './chunk-NBCCA6AX.js';
9
+ import { schemaRows } from './chunk-7KTRNXTU.js';
7
10
  import { minimalInput } from './chunk-QYSIJ2YN.js';
8
11
  import { isSea } from 'node:sea';
9
12
  import { existsSync } from 'node:fs';
@@ -36,7 +39,7 @@ function cliVersion(version, {
36
39
  // src/surface/plain_dates.ts
37
40
  function parsePlainDue(raw, now = Date.now()) {
38
41
  const invalid = () => new CliUsageError(
39
- `Invalid --due "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, +3d or an ISO date-time.`
42
+ `Invalid --due "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, +Nd (for example +3d), integer epoch milliseconds, or YYYY-MM-DDTHH:mm[:ss[.fraction]][Z|\xB1HH:mm] (no zone means local time).`
40
43
  );
41
44
  if (/^-?\d+$/.test(raw)) {
42
45
  const value = Number(raw);
@@ -62,7 +65,7 @@ function parsePlainDue(raw, now = Date.now()) {
62
65
  );
63
66
  date.setHours(23, 59, 59, 999);
64
67
  } else {
65
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(raw))
68
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$/.test(raw))
66
69
  throw invalid();
67
70
  parsePlainDue(raw.slice(0, 10), now);
68
71
  date = new Date(raw);
@@ -115,7 +118,8 @@ Missing: ${r.clarification.missing.map((name) => `--${name}`).join(", ")}
115
118
  ` : `${r.summary}
116
119
  ${r.error.message}
117
120
  `;
118
- if (r.data.deliveries && !r.data.item) return `${JSON.stringify(r.data.deliveries, null, 2)}
121
+ if (r.data.deliveries && !r.data.item)
122
+ return `${r.data.deliveries.map((d) => `${d.id} target ${d.targetId} ${d.status} attempts ${d.attempts} HTTP ${d.httpStatus ?? "-"} next ${new Date(d.nextAttemptAt).toISOString()} events ${d.events.map((e) => e.id).join(", ")}`).join("\n") || "No deliveries"}
119
123
  `;
120
124
  if (r.data.items) {
121
125
  const items = values.limit === void 0 ? r.data.items : r.data.items.slice(0, Math.max(0, Number(values.limit)));
@@ -205,7 +209,12 @@ ${values.dryRun ? `${JSON.stringify({ events: d.events, batches: d.batches }, nu
205
209
  flags: [
206
210
  { name: "title", type: "string" },
207
211
  { name: "tag", type: "array" },
208
- { name: "objective", type: "string" },
212
+ {
213
+ name: "objective",
214
+ type: "string",
215
+ file: true,
216
+ description: "Quest objective text; defaults to the title."
217
+ },
209
218
  {
210
219
  name: "due",
211
220
  type: "string",
@@ -221,7 +230,10 @@ ${values.dryRun ? `${JSON.stringify({ events: d.events, batches: d.batches }, nu
221
230
  "give the title once: either as the first argument or with --title"
222
231
  );
223
232
  const title = v.positionalTitle ?? v.title;
224
- if (title === void 0) throw new CliUsageError("Missing required argument: title");
233
+ if (title === void 0)
234
+ throw new CliUsageError(
235
+ 'Missing required argument: title. Example: questlog add "Write the report" --due tomorrow'
236
+ );
225
237
  const quest = {
226
238
  title,
227
239
  objective: v.objective ?? title
@@ -315,7 +327,7 @@ var questlogCliOptions = {
315
327
  describeDb: (options) => `Database: ${selectedDbPath(options)}
316
328
  Use --db <path> or GHOSTPAW_QUESTLOG_DB to override the default ~/.ghostpaw/questlog.db.`,
317
329
  openContext: async (options) => {
318
- const { openQuestlogDb } = await import('./open_questlog_db-LAEH7UDM.js');
330
+ const { openQuestlogDb } = await import('./open_questlog_db-6NS6BVBC.js');
319
331
  const path = selectedDbPath(options);
320
332
  const created = path !== ":memory:" && !existsSync(path);
321
333
  const db = await openQuestlogDb(path);
@@ -327,22 +339,56 @@ Use --db <path> or GHOSTPAW_QUESTLOG_DB to override the default ~/.ghostpaw/ques
327
339
  commands: {
328
340
  names: ["hook", "watch"],
329
341
  run: async (command, args, options, io) => {
342
+ if (command === "show" && args[0]?.startsWith("d_")) {
343
+ const { listDeliveries: listDeliveries2 } = await import('./list_deliveries-WJEJUZIQ.js');
344
+ const { listHooks } = await import('./list_hooks-C5CAA2C7.js');
345
+ const { textOutput } = await import('./text_output-X7F3K23Q.js');
346
+ if (args.includes("--help") || args.includes("-h")) {
347
+ io.stdout.write("questlog show <delivery-id> [--json]\n");
348
+ return 0;
349
+ }
350
+ const { openQuestlogDb } = await import('./open_questlog_db-6NS6BVBC.js');
351
+ const db = await openQuestlogDb(selectedDbPath(options));
352
+ try {
353
+ const delivery = listDeliveries2(db, { includeDelivered: true }).find(
354
+ (d) => d.id === args[0]
355
+ );
356
+ if (!delivery) {
357
+ io.stderr.write(`Delivery ${args[0]} not found
358
+ `);
359
+ return 1;
360
+ }
361
+ const target = listHooks(db).find((h) => h.id === delivery.targetId);
362
+ const result = {
363
+ ...delivery,
364
+ target: target ?? null,
365
+ eventIds: delivery.events.map((e) => e.id)
366
+ };
367
+ io.stdout.write(
368
+ `${args.includes("--json") ? JSON.stringify(result, null, 2) : textOutput(result)}
369
+ `
370
+ );
371
+ return 0;
372
+ } finally {
373
+ db.close();
374
+ }
375
+ }
330
376
  if (command !== "hook" && command !== "watch") return void 0;
331
- const { runHookCommand } = await import('./run_hook_command-C42BQC36.js');
377
+ const { runHookCommand } = await import('./run_hook_command-OGLBKXAV.js');
332
378
  return runHookCommand(command, args, io, async () => {
333
- const { openQuestlogDb } = await import('./open_questlog_db-LAEH7UDM.js');
379
+ const { openQuestlogDb } = await import('./open_questlog_db-6NS6BVBC.js');
334
380
  return openQuestlogDb(selectedDbPath(options));
335
381
  });
336
382
  },
337
383
  plain: questlogPlainCommands(),
338
384
  guides: questlogSkills,
339
385
  mcp: async (options) => {
340
- const { serveQuestlogMcp } = await import('./serve_questlog_mcp-W3R63HW4.js');
386
+ const { serveQuestlogMcp } = await import('./serve_questlog_mcp-YRCT3GA3.js');
341
387
  await serveQuestlogMcp(options.db === void 0 ? {} : { dbPath: options.db });
342
388
  },
343
389
  skill: async () => {
344
- const { renderSkillMarkdown } = await import('./skill_markdown-HXYQ3K6U.js');
345
- const { questlogSkillSpec } = await import('./questlog_skill_spec-HEW2B6EB.js');
390
+ const { renderSkillMarkdown } = await import('./skill_markdown-EGKYZ3W2.js');
391
+ const { questlogSkillSpec } = await import('./questlog_skill_spec-S5OYGF5X.js');
346
392
  return renderSkillMarkdown(questlogSkillSpec);
347
393
  }
348
394
  }
@@ -437,6 +483,13 @@ Version formats: --version --short, --version --json
437
483
  ${options.describeDb ? `${options.describeDb(ctxOptions)}
438
484
  ` : ""}
439
485
  ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")}
486
+ `
487
+ );
488
+ return 0;
489
+ }
490
+ if (["tools", "call", "describe"].includes(command) && args.length === 1 && ["--help", "-h"].includes(args[0] ?? "")) {
491
+ stdout.write(
492
+ `Usage: ${options.bin} ${command}${command === "tools" ? " [--json]" : " <tool> [--help]"}
440
493
  `
441
494
  );
442
495
  return 0;
@@ -455,16 +508,21 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
455
508
  }
456
509
  if (command === "skill") {
457
510
  if (!options.commands?.skill) return fail("skill command unavailable");
458
- if (args.length > 1) return fail(`Usage: ${options.bin} skill [--list | <guide> | --help]`);
459
- const guide = args[0];
511
+ if (args.length > 1 && !args.some((arg) => ["--help", "-h"].includes(arg)))
512
+ return fail(`Usage: ${options.bin} skill [--short | --list | <guide> | --help]`);
513
+ const guide = args.some((arg) => ["--help", "-h"].includes(arg)) ? "--help" : args[0];
460
514
  if (guide === "--help" || guide === "-h") {
461
515
  stdout.write(
462
516
  `Usage:
463
517
  ${options.bin} skill
464
518
  ${options.bin} skill --list
519
+ ${options.bin} skill --short
465
520
  ${options.bin} skill <guide>
466
521
  `
467
522
  );
523
+ } else if (guide === "--short") {
524
+ const full = await options.commands.skill();
525
+ stdout.write(full.split("## Appendix:")[0]?.replace(/^---[\s\S]*?---\n+/, "") ?? full);
468
526
  } else if (guide === "--list") {
469
527
  stdout.write(
470
528
  `${(options.commands.guides ?? []).map((entry) => `${entry.name} ${entry.description.replace(/\s+/g, " ")}`).join("\n")}
@@ -477,6 +535,11 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
477
535
  } else stdout.write(await options.commands.skill());
478
536
  return 0;
479
537
  }
538
+ if (command === "mcp" && (args.includes("--help") || args.includes("-h"))) {
539
+ stdout.write(`${options.bin} mcp [--db path]
540
+ `);
541
+ return 0;
542
+ }
480
543
  if (command === "mcp") {
481
544
  if (!options.commands?.mcp) return fail("mcp command unavailable");
482
545
  await options.commands.mcp(ctxOptions);
@@ -528,26 +591,25 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
528
591
  ]);
529
592
  return fail(`Unknown command: ${command}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`);
530
593
  }
531
- if (command === "describe") {
594
+ if (command === "describe" || args.includes("--help") || args.includes("-h")) {
532
595
  stdout.write(
533
596
  `${tool.name}
597
+ Usage: ${options.bin} ${tool.name} --parameter value [--json]
534
598
  ${tool.description}
535
599
  Use when: ${tool.whenToUse ?? ""}
536
600
  Not for: ${tool.whenNotToUse ?? ""}
537
601
  Read only: ${tool.readOnly ?? false}
538
602
 
539
603
  Parameter | Type | Required | Description
540
- ${Object.entries(
541
- tool.inputSchema.properties ?? {}
542
- ).map(
543
- ([key, schema]) => `${key} | ${schema.type ?? "oneOf"} | ${tool.inputSchema.required?.includes(key) ?? false} | ${schema.description ?? ""}`
544
- ).join("\n")}
604
+ ${schemaRows(tool.inputSchema).join("\n")}
545
605
 
546
606
  ${JSON.stringify(tool.inputSchema, null, 2)}
547
607
  `
548
608
  );
549
609
  return 0;
550
610
  }
611
+ const json = args.includes("--json");
612
+ if (!plain && json) args.splice(args.indexOf("--json"), 1);
551
613
  let input = {};
552
614
  try {
553
615
  const readStdin = async () => {
package/dist/index.cjs CHANGED
@@ -45,13 +45,20 @@ function addHook(db, input) {
45
45
  } catch {
46
46
  throw new Error("Expected an HTTP or HTTPS URL");
47
47
  }
48
- if (!["http:", "https:"].includes(url.protocol)) throw new Error("Expected an HTTP or HTTPS URL");
48
+ if (!["http:", "https:"].includes(url.protocol))
49
+ throw new Error(`unsupported scheme ${url.protocol}; use http or https`);
50
+ if (url.username || url.password)
51
+ throw new Error(
52
+ "URL userinfo is not supported; use a custom header (--header or --header-stdin) or a query key instead"
53
+ );
49
54
  const minGap = input.minGap ?? 60;
50
55
  if (!Number.isSafeInteger(minGap) || minGap < 0)
51
56
  throw new Error("Min gap must be a nonnegative integer");
52
57
  const events = input.events ?? [...hookEventTypes];
53
58
  if (!events.length || events.some((e) => !hookEventTypes.includes(e)))
54
- throw new Error("Unknown event filter");
59
+ throw new Error(
60
+ `unknown event ${events.find((e) => !hookEventTypes.includes(e)) ?? "(empty)"}; valid: ${hookEventTypes.join(", ")}`
61
+ );
55
62
  if (input.tag !== void 0 && !normalizeTagName(input.tag))
56
63
  throw new Error("Expected a tag name");
57
64
  const secret = input.secret ?? node_crypto.randomBytes(32).toString("hex");
@@ -106,7 +113,7 @@ function listDeliveries(db, options = {}) {
106
113
 
107
114
  // src/hooks/list_hooks.ts
108
115
  function listHooks(db) {
109
- return db.prepare(`SELECT id, url, tag, events, min_gap, created_at, last_wake,
116
+ return db.prepare(`SELECT id, url, headers, tag, events, min_gap, created_at, last_wake,
110
117
  (SELECT COUNT(*) FROM hook_assignments a LEFT JOIN hook_deliveries d ON d.id = a.delivery_id WHERE a.target_id = t.id AND (a.delivery_id IS NULL OR d.status = 'pending')) AS pending,
111
118
  (SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
112
119
  FROM hook_targets t ORDER BY id`).all().map((row) => {
@@ -117,6 +124,9 @@ function listHooks(db) {
117
124
  url.hash = "";
118
125
  return {
119
126
  id: Number(row.id),
127
+ headers: Object.fromEntries(
128
+ Object.keys(JSON.parse(String(row.headers))).map((name) => [name, "***"])
129
+ ),
120
130
  scope: row.tag == null ? "database" : "tag",
121
131
  url: url.href,
122
132
  tag: row.tag == null ? null : String(row.tag),
@@ -175,7 +185,7 @@ function removeHook(db, id) {
175
185
  }
176
186
 
177
187
  // src/version.ts
178
- var questlogVersion = "0.10.1";
188
+ var questlogVersion = "0.11.1";
179
189
 
180
190
  // src/hooks/send_hook_delivery.ts
181
191
  async function sendHookDelivery(db, delivery, target, options) {
@@ -196,8 +206,13 @@ async function sendHookDelivery(db, delivery, target, options) {
196
206
  method: "POST",
197
207
  redirect: "error",
198
208
  headers: {
199
- "content-type": "application/json",
200
209
  "user-agent": `questlog/${questlogVersion}`,
210
+ ...JSON.parse(
211
+ String(
212
+ db.prepare("SELECT headers FROM hook_targets WHERE id = ?").get(target.id)?.headers ?? "{}"
213
+ )
214
+ ),
215
+ "content-type": "application/json",
201
216
  "x-questlog-delivery": delivery.id,
202
217
  "x-questlog-timestamp": timestamp,
203
218
  "x-questlog-signature": `sha256=${signature}`
@@ -1021,6 +1036,8 @@ function initHookTables(db) {
1021
1036
  CREATE INDEX IF NOT EXISTS idx_hook_deliveries_target ON hook_deliveries(target_id, status, next_attempt_at);
1022
1037
  CREATE TABLE IF NOT EXISTS hook_settings (id INTEGER PRIMARY KEY CHECK(id = 1), label TEXT NOT NULL);
1023
1038
  `);
1039
+ if (!db.prepare("PRAGMA table_info(hook_targets)").all().some((row) => row.name === "headers"))
1040
+ db.exec("ALTER TABLE hook_targets ADD COLUMN headers TEXT NOT NULL DEFAULT '{}'");
1024
1041
  }
1025
1042
 
1026
1043
  // src/questlines/init_questline_tables.ts
@@ -3300,7 +3317,7 @@ Failure handling:
3300
3317
  var webhooksSkill = defineQuestlogSkill({
3301
3318
  name: "webhooks",
3302
3319
  description: "Configure signed outbound event webhooks and verify deliveries.",
3303
- content: '# Webhooks\n\nUse `questlog` to configure opt-in outbound automation. No server or scheduler is added.\nNo targets means no network calls. `manage_hooks` supports list, remove, tick with\n`dry_run`, and deliveries; add/test are CLI-only. `review_questlog` view `deliveries`\nand `questlog show <id>` expose pending/failed deliveries.\n\n```sh\nquestlog hook add https://example.org/wake --min-gap 60\nquestlog hook add https://example.org/team --tag work --events quest.due,quest.overdue --secret-stdin\nquestlog hook label work\nquestlog hook list\nquestlog tick --dry-run\nquestlog tick\nquestlog watch --interval 60\nquestlog hook test 1\nquestlog review deliveries\nquestlog show 12\nquestlog hook remove 1\n```\n\n`hook add` prints its random 32-byte signing secret once, also as `secret` in `--json`.\nSave it securely. `--secret-stdin` supplies one via stdin; there is no secret flag.\nList output redacts userinfo/query strings. Removal deletes the target and deliveries.\nDefault scope is the database; `--tag` matches quest tags, never rumors.\n`--events` defaults to all types. The DB label defaults to the filename without its\npath/extension (`questlog` in memory); set it to a name suitable for disclosure.\n\n```cron\n* * * * * questlog tick\n```\n\nCron\'s PATH may need `$HOME/.local/bin/questlog tick --db /path/to/quests.db`.\nWatch defaults to 60 seconds (minimum 5); SIGINT/SIGTERM finishes the current tick.\nTick exits 0 even when deliveries need retries. Dry-run records nothing and sends nothing.\n\nEvents are detected from the model at tick time:\n\n- `quest.due`: an unresolved, undeleted quest reaches its effective due time (own `due_at`, otherwise its undeleted questline\'s due time).\n- `quest.overdue`: that effective deadline is strictly earlier than now and the quest remains unresolved.\n- `quest.unblocked`: an unresolved quest is available, all active prerequisites have succeeded or been deleted, and at least one prerequisite succeeded after its unlock was installed. Occurrence time is the latest active prerequisite success or not-before time, whichever is later.\n- `repeatable.spawned`: a concrete repeatable instance was created; occurrence time is its creation time. Tick first materializes due anchors through the existing spawn operation.\n- `rumor.captured`: an undeleted rumor was created; occurrence time is its creation time, even if subsequently settled or dismissed.\n\nPersistent event keys contain type, entity ids, and occurrence time. Restarts and repeated\nticks do not repeat occurrences. Removed transitions between ticks cannot be reconstructed.\nOnly occurrences at/after target creation are assigned; tags are evaluated at assignment.\nTick materializes due repeatables using the existing spawn path, including during preview\nwith rollback. Batches contain only ids, event types, timestamps, and the DB label:\n\n```json\n{"type":"questlog.events","db":"work","deliveryId":"d_\u2026","sentAt":"2026-10-28T10:00:00.000Z","events":[{"id":"e_\u2026","type":"quest.due","questId":12,"occurredAt":"2026-10-28T09:59:00.000Z"}]}\n```\n\nHeaders are `content-type: application/json`, `user-agent: questlog/<version>`,\n`x-questlog-delivery: <deliveryId>`, `x-questlog-timestamp: <unix seconds>`, and\n`x-questlog-signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.\nNo titles, objectives, details, or tag text are sent. `hook test` sends a logged `hook.test`\nevent immediately, ignoring the min gap. Add and test are CLI-only in the tool facade;\n`manage_hooks` supports list, remove, tick (with `dry_run`), and deliveries.\n\n2xx delivers. Timeout is 5 seconds. Failures retry after 30 s, 1 m, 2 m, 4 m, doubling\nto 1 h plus <1 s jitter, subject to the target\'s min gap. Eight failures mark failed.\nRetries keep the delivery id/events; new events queue behind them. Keep the last 500\ndelivered log entries per target and all pending/failed entries. Transactional claims\nlease work for two minutes. A crash after receipt may retry: dedupe at the receiver.\n\n### Verifying a delivery\n\nCompute HMAC-SHA256 over `timestamp + "." + rawBody` with the secret and compare in constant\ntime. Reject timestamps older than 5 minutes (and timestamps too far in the future), then\ndedupe on `x-questlog-delivery`. Use durable deduplication storage in a production receiver.\nThis 15-line Node example uses an in-memory set:\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\nimport { createServer } from \'node:http\';\nconst secret = process.env.WEBHOOK_SECRET, seen = new Set();\ncreateServer(async (req, res) => {\n const chunks = []; for await (const chunk of req) chunks.push(chunk);\n const rawBody = Buffer.concat(chunks), h = req.headers;\n const timestamp = h[\'x-questlog-timestamp\'], id = h[\'x-questlog-delivery\'];\n const age = Date.now() / 1000 - Number(timestamp);\n const expected = createHmac(\'sha256\', secret).update(timestamp + \'.\').update(rawBody).digest();\n const signature = String(h[\'x-questlog-signature\'] || \'\');\n const actual = Buffer.from(signature.slice(7), \'hex\');\n if (!id || !/^\\d+$/.test(timestamp) || age > 300 || age < -300 || !/^sha256=[a-f0-9]{64}$/.test(signature) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) { res.writeHead(401).end(); return; }\n if (!seen.has(id)) { seen.add(id); console.log(JSON.parse(rawBody)); }\n res.writeHead(204).end();\n}).listen(8080, \'127.0.0.1\');\n```\n'
3320
+ content: '# Webhooks\n\nUse `questlog` to configure opt-in outbound automation. No server or scheduler is added.\nNo targets means no network calls. `manage_hooks` supports list, remove, tick with\n`dry_run`, and deliveries; add/test are CLI-only. `review_questlog` view `deliveries`\nand `questlog show <id>` expose pending/failed deliveries.\n\n```sh\nquestlog hook add https://example.org/wake --min-gap 60\nquestlog hook add https://example.org/team --tag work --events quest.due,quest.overdue --secret-stdin\nquestlog hook label work\nquestlog hook list\nquestlog tick --dry-run\nquestlog tick\nquestlog watch --interval 60\nquestlog hook test 1\nquestlog review deliveries\nquestlog show 12\nquestlog hook remove 1\n```\n\n`hook add` prints its random 32-byte signing secret once, also as `secret` in `--json`.\nSave it securely. `--secret-stdin` supplies one via stdin; there is no secret flag.\nSecrets and custom header values are stored in the DB. New POSIX DB/WAL/SHM files use 0600 and created directories use 0700; existing DB modes are preserved. Windows uses existing access controls.\nUse repeatable `--header "Name: value"` or `--header-stdin` (one line per header, avoiding shell history). Headers are sent on every delivery and test; output shows names with `***` values. HTTP token names are required; host, content-length, content-type, and x-questlog-* are reserved. Change headers by removing and adding the hook again. Use --header-stdin and --secret-stdin separately. URL userinfo is rejected; use a custom header or query key.\n`questlog show d_\u2026` displays delivery status, attempts, HTTP status, next attempt, redacted target URL and event ids. Text is the default; --json prints JSON. Missing hook remove/test targets exit 3; missing quests/deliveries exit 1. hook label names the database.\nList output redacts userinfo/query strings. Removal deletes the target and deliveries.\nDefault scope is the database; `--tag` matches quest tags, never rumors.\n`--events` defaults to all types. The DB label defaults to the filename without its\npath/extension (`questlog` in memory); set it to a name suitable for disclosure.\n\n```cron\n* * * * * questlog tick\n```\n\nCron\'s PATH may need `$HOME/.local/bin/questlog tick --db /path/to/quests.db`.\nWatch defaults to 60 seconds (minimum 5); SIGINT/SIGTERM finishes the current tick.\nTick exits 0 even when deliveries need retries. Dry-run records nothing and sends nothing.\n\nEvents are detected from the model at tick time:\n\n- `quest.due`: an unresolved, undeleted quest reaches its effective due time (own `due_at`, otherwise its undeleted questline\'s due time).\n- `quest.overdue`: that effective deadline is strictly earlier than now and the quest remains unresolved.\n- `quest.unblocked`: an unresolved quest is available, all active prerequisites have succeeded or been deleted, and at least one prerequisite succeeded after its unlock was installed. Occurrence time is the latest active prerequisite success or not-before time, whichever is later.\n- `repeatable.spawned`: a concrete repeatable instance was created; occurrence time is its creation time. Tick first materializes due anchors through the existing spawn operation.\n- `rumor.captured`: an undeleted rumor was created; occurrence time is its creation time, even if subsequently settled or dismissed.\n\nPersistent event keys contain type, entity ids, and occurrence time. Restarts and repeated\nticks do not repeat occurrences. Removed transitions between ticks cannot be reconstructed.\nOnly occurrences at/after target creation are assigned; tags are evaluated at assignment.\nTick materializes due repeatables using the existing spawn path, including during preview\nwith rollback. Batches contain only ids, event types, timestamps, and the DB label:\n\n```json\n{"type":"questlog.events","db":"work","deliveryId":"d_\u2026","sentAt":"2026-10-28T10:00:00.000Z","events":[{"id":"e_\u2026","type":"quest.due","questId":12,"occurredAt":"2026-10-28T09:59:00.000Z"}]}\n```\n\nHeaders are `content-type: application/json`, `user-agent: questlog/<version>`,\n`x-questlog-delivery: <deliveryId>`, `x-questlog-timestamp: <unix seconds>`, and\n`x-questlog-signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.\nNo titles, objectives, details, or tag text are sent. `hook test` sends a logged `hook.test`\nevent immediately, ignoring the min gap. Add and test are CLI-only in the tool facade;\n`manage_hooks` supports list, remove, tick (with `dry_run`), and deliveries.\n\n2xx delivers. Timeout is 5 seconds. Failures retry after 30 s, 1 m, 2 m, 4 m, doubling\nto 1 h plus <1 s jitter, subject to the target\'s min gap. Eight failures mark failed.\nRetries keep the delivery id/events; new events queue behind them. Keep the last 500\ndelivered log entries per target and all pending/failed entries. Transactional claims\nlease work for two minutes. A crash after receipt may retry: dedupe at the receiver.\n\n### Verifying a delivery\n\nCompute HMAC-SHA256 over `timestamp + "." + rawBody` with the secret and compare in constant\ntime. Reject timestamps older than 5 minutes (and timestamps too far in the future), then\ndedupe on `x-questlog-delivery`. Use durable deduplication storage in a production receiver.\nThis 15-line Node example uses an in-memory set:\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\nimport { createServer } from \'node:http\';\nconst secret = process.env.WEBHOOK_SECRET, seen = new Set();\ncreateServer(async (req, res) => {\n const chunks = []; for await (const chunk of req) chunks.push(chunk);\n const rawBody = Buffer.concat(chunks), h = req.headers;\n const timestamp = h[\'x-questlog-timestamp\'], id = h[\'x-questlog-delivery\'];\n const age = Date.now() / 1000 - Number(timestamp);\n const expected = createHmac(\'sha256\', secret).update(timestamp + \'.\').update(rawBody).digest();\n const signature = String(h[\'x-questlog-signature\'] || \'\');\n const actual = Buffer.from(signature.slice(7), \'hex\');\n if (!id || !/^\\d+$/.test(timestamp) || age > 300 || age < -300 || !/^sha256=[a-f0-9]{64}$/.test(signature) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) { res.writeHead(401).end(); return; }\n if (!seen.has(id)) { seen.add(id); console.log(JSON.parse(rawBody)); }\n res.writeHead(204).end();\n}).listen(8080, \'127.0.0.1\');\n```\n'
3304
3321
  });
3305
3322
 
3306
3323
  // src/skills/skill_registry.ts
package/dist/index.js CHANGED
@@ -43,13 +43,20 @@ function addHook(db, input) {
43
43
  } catch {
44
44
  throw new Error("Expected an HTTP or HTTPS URL");
45
45
  }
46
- if (!["http:", "https:"].includes(url.protocol)) throw new Error("Expected an HTTP or HTTPS URL");
46
+ if (!["http:", "https:"].includes(url.protocol))
47
+ throw new Error(`unsupported scheme ${url.protocol}; use http or https`);
48
+ if (url.username || url.password)
49
+ throw new Error(
50
+ "URL userinfo is not supported; use a custom header (--header or --header-stdin) or a query key instead"
51
+ );
47
52
  const minGap = input.minGap ?? 60;
48
53
  if (!Number.isSafeInteger(minGap) || minGap < 0)
49
54
  throw new Error("Min gap must be a nonnegative integer");
50
55
  const events = input.events ?? [...hookEventTypes];
51
56
  if (!events.length || events.some((e) => !hookEventTypes.includes(e)))
52
- throw new Error("Unknown event filter");
57
+ throw new Error(
58
+ `unknown event ${events.find((e) => !hookEventTypes.includes(e)) ?? "(empty)"}; valid: ${hookEventTypes.join(", ")}`
59
+ );
53
60
  if (input.tag !== void 0 && !normalizeTagName(input.tag))
54
61
  throw new Error("Expected a tag name");
55
62
  const secret = input.secret ?? randomBytes(32).toString("hex");
@@ -104,7 +111,7 @@ function listDeliveries(db, options = {}) {
104
111
 
105
112
  // src/hooks/list_hooks.ts
106
113
  function listHooks(db) {
107
- return db.prepare(`SELECT id, url, tag, events, min_gap, created_at, last_wake,
114
+ return db.prepare(`SELECT id, url, headers, tag, events, min_gap, created_at, last_wake,
108
115
  (SELECT COUNT(*) FROM hook_assignments a LEFT JOIN hook_deliveries d ON d.id = a.delivery_id WHERE a.target_id = t.id AND (a.delivery_id IS NULL OR d.status = 'pending')) AS pending,
109
116
  (SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
110
117
  FROM hook_targets t ORDER BY id`).all().map((row) => {
@@ -115,6 +122,9 @@ function listHooks(db) {
115
122
  url.hash = "";
116
123
  return {
117
124
  id: Number(row.id),
125
+ headers: Object.fromEntries(
126
+ Object.keys(JSON.parse(String(row.headers))).map((name) => [name, "***"])
127
+ ),
118
128
  scope: row.tag == null ? "database" : "tag",
119
129
  url: url.href,
120
130
  tag: row.tag == null ? null : String(row.tag),
@@ -173,7 +183,7 @@ function removeHook(db, id) {
173
183
  }
174
184
 
175
185
  // src/version.ts
176
- var questlogVersion = "0.10.1";
186
+ var questlogVersion = "0.11.1";
177
187
 
178
188
  // src/hooks/send_hook_delivery.ts
179
189
  async function sendHookDelivery(db, delivery, target, options) {
@@ -194,8 +204,13 @@ async function sendHookDelivery(db, delivery, target, options) {
194
204
  method: "POST",
195
205
  redirect: "error",
196
206
  headers: {
197
- "content-type": "application/json",
198
207
  "user-agent": `questlog/${questlogVersion}`,
208
+ ...JSON.parse(
209
+ String(
210
+ db.prepare("SELECT headers FROM hook_targets WHERE id = ?").get(target.id)?.headers ?? "{}"
211
+ )
212
+ ),
213
+ "content-type": "application/json",
199
214
  "x-questlog-delivery": delivery.id,
200
215
  "x-questlog-timestamp": timestamp,
201
216
  "x-questlog-signature": `sha256=${signature}`
@@ -1019,6 +1034,8 @@ function initHookTables(db) {
1019
1034
  CREATE INDEX IF NOT EXISTS idx_hook_deliveries_target ON hook_deliveries(target_id, status, next_attempt_at);
1020
1035
  CREATE TABLE IF NOT EXISTS hook_settings (id INTEGER PRIMARY KEY CHECK(id = 1), label TEXT NOT NULL);
1021
1036
  `);
1037
+ if (!db.prepare("PRAGMA table_info(hook_targets)").all().some((row) => row.name === "headers"))
1038
+ db.exec("ALTER TABLE hook_targets ADD COLUMN headers TEXT NOT NULL DEFAULT '{}'");
1022
1039
  }
1023
1040
 
1024
1041
  // src/questlines/init_questline_tables.ts
@@ -3298,7 +3315,7 @@ Failure handling:
3298
3315
  var webhooksSkill = defineQuestlogSkill({
3299
3316
  name: "webhooks",
3300
3317
  description: "Configure signed outbound event webhooks and verify deliveries.",
3301
- content: '# Webhooks\n\nUse `questlog` to configure opt-in outbound automation. No server or scheduler is added.\nNo targets means no network calls. `manage_hooks` supports list, remove, tick with\n`dry_run`, and deliveries; add/test are CLI-only. `review_questlog` view `deliveries`\nand `questlog show <id>` expose pending/failed deliveries.\n\n```sh\nquestlog hook add https://example.org/wake --min-gap 60\nquestlog hook add https://example.org/team --tag work --events quest.due,quest.overdue --secret-stdin\nquestlog hook label work\nquestlog hook list\nquestlog tick --dry-run\nquestlog tick\nquestlog watch --interval 60\nquestlog hook test 1\nquestlog review deliveries\nquestlog show 12\nquestlog hook remove 1\n```\n\n`hook add` prints its random 32-byte signing secret once, also as `secret` in `--json`.\nSave it securely. `--secret-stdin` supplies one via stdin; there is no secret flag.\nList output redacts userinfo/query strings. Removal deletes the target and deliveries.\nDefault scope is the database; `--tag` matches quest tags, never rumors.\n`--events` defaults to all types. The DB label defaults to the filename without its\npath/extension (`questlog` in memory); set it to a name suitable for disclosure.\n\n```cron\n* * * * * questlog tick\n```\n\nCron\'s PATH may need `$HOME/.local/bin/questlog tick --db /path/to/quests.db`.\nWatch defaults to 60 seconds (minimum 5); SIGINT/SIGTERM finishes the current tick.\nTick exits 0 even when deliveries need retries. Dry-run records nothing and sends nothing.\n\nEvents are detected from the model at tick time:\n\n- `quest.due`: an unresolved, undeleted quest reaches its effective due time (own `due_at`, otherwise its undeleted questline\'s due time).\n- `quest.overdue`: that effective deadline is strictly earlier than now and the quest remains unresolved.\n- `quest.unblocked`: an unresolved quest is available, all active prerequisites have succeeded or been deleted, and at least one prerequisite succeeded after its unlock was installed. Occurrence time is the latest active prerequisite success or not-before time, whichever is later.\n- `repeatable.spawned`: a concrete repeatable instance was created; occurrence time is its creation time. Tick first materializes due anchors through the existing spawn operation.\n- `rumor.captured`: an undeleted rumor was created; occurrence time is its creation time, even if subsequently settled or dismissed.\n\nPersistent event keys contain type, entity ids, and occurrence time. Restarts and repeated\nticks do not repeat occurrences. Removed transitions between ticks cannot be reconstructed.\nOnly occurrences at/after target creation are assigned; tags are evaluated at assignment.\nTick materializes due repeatables using the existing spawn path, including during preview\nwith rollback. Batches contain only ids, event types, timestamps, and the DB label:\n\n```json\n{"type":"questlog.events","db":"work","deliveryId":"d_\u2026","sentAt":"2026-10-28T10:00:00.000Z","events":[{"id":"e_\u2026","type":"quest.due","questId":12,"occurredAt":"2026-10-28T09:59:00.000Z"}]}\n```\n\nHeaders are `content-type: application/json`, `user-agent: questlog/<version>`,\n`x-questlog-delivery: <deliveryId>`, `x-questlog-timestamp: <unix seconds>`, and\n`x-questlog-signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.\nNo titles, objectives, details, or tag text are sent. `hook test` sends a logged `hook.test`\nevent immediately, ignoring the min gap. Add and test are CLI-only in the tool facade;\n`manage_hooks` supports list, remove, tick (with `dry_run`), and deliveries.\n\n2xx delivers. Timeout is 5 seconds. Failures retry after 30 s, 1 m, 2 m, 4 m, doubling\nto 1 h plus <1 s jitter, subject to the target\'s min gap. Eight failures mark failed.\nRetries keep the delivery id/events; new events queue behind them. Keep the last 500\ndelivered log entries per target and all pending/failed entries. Transactional claims\nlease work for two minutes. A crash after receipt may retry: dedupe at the receiver.\n\n### Verifying a delivery\n\nCompute HMAC-SHA256 over `timestamp + "." + rawBody` with the secret and compare in constant\ntime. Reject timestamps older than 5 minutes (and timestamps too far in the future), then\ndedupe on `x-questlog-delivery`. Use durable deduplication storage in a production receiver.\nThis 15-line Node example uses an in-memory set:\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\nimport { createServer } from \'node:http\';\nconst secret = process.env.WEBHOOK_SECRET, seen = new Set();\ncreateServer(async (req, res) => {\n const chunks = []; for await (const chunk of req) chunks.push(chunk);\n const rawBody = Buffer.concat(chunks), h = req.headers;\n const timestamp = h[\'x-questlog-timestamp\'], id = h[\'x-questlog-delivery\'];\n const age = Date.now() / 1000 - Number(timestamp);\n const expected = createHmac(\'sha256\', secret).update(timestamp + \'.\').update(rawBody).digest();\n const signature = String(h[\'x-questlog-signature\'] || \'\');\n const actual = Buffer.from(signature.slice(7), \'hex\');\n if (!id || !/^\\d+$/.test(timestamp) || age > 300 || age < -300 || !/^sha256=[a-f0-9]{64}$/.test(signature) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) { res.writeHead(401).end(); return; }\n if (!seen.has(id)) { seen.add(id); console.log(JSON.parse(rawBody)); }\n res.writeHead(204).end();\n}).listen(8080, \'127.0.0.1\');\n```\n'
3318
+ content: '# Webhooks\n\nUse `questlog` to configure opt-in outbound automation. No server or scheduler is added.\nNo targets means no network calls. `manage_hooks` supports list, remove, tick with\n`dry_run`, and deliveries; add/test are CLI-only. `review_questlog` view `deliveries`\nand `questlog show <id>` expose pending/failed deliveries.\n\n```sh\nquestlog hook add https://example.org/wake --min-gap 60\nquestlog hook add https://example.org/team --tag work --events quest.due,quest.overdue --secret-stdin\nquestlog hook label work\nquestlog hook list\nquestlog tick --dry-run\nquestlog tick\nquestlog watch --interval 60\nquestlog hook test 1\nquestlog review deliveries\nquestlog show 12\nquestlog hook remove 1\n```\n\n`hook add` prints its random 32-byte signing secret once, also as `secret` in `--json`.\nSave it securely. `--secret-stdin` supplies one via stdin; there is no secret flag.\nSecrets and custom header values are stored in the DB. New POSIX DB/WAL/SHM files use 0600 and created directories use 0700; existing DB modes are preserved. Windows uses existing access controls.\nUse repeatable `--header "Name: value"` or `--header-stdin` (one line per header, avoiding shell history). Headers are sent on every delivery and test; output shows names with `***` values. HTTP token names are required; host, content-length, content-type, and x-questlog-* are reserved. Change headers by removing and adding the hook again. Use --header-stdin and --secret-stdin separately. URL userinfo is rejected; use a custom header or query key.\n`questlog show d_\u2026` displays delivery status, attempts, HTTP status, next attempt, redacted target URL and event ids. Text is the default; --json prints JSON. Missing hook remove/test targets exit 3; missing quests/deliveries exit 1. hook label names the database.\nList output redacts userinfo/query strings. Removal deletes the target and deliveries.\nDefault scope is the database; `--tag` matches quest tags, never rumors.\n`--events` defaults to all types. The DB label defaults to the filename without its\npath/extension (`questlog` in memory); set it to a name suitable for disclosure.\n\n```cron\n* * * * * questlog tick\n```\n\nCron\'s PATH may need `$HOME/.local/bin/questlog tick --db /path/to/quests.db`.\nWatch defaults to 60 seconds (minimum 5); SIGINT/SIGTERM finishes the current tick.\nTick exits 0 even when deliveries need retries. Dry-run records nothing and sends nothing.\n\nEvents are detected from the model at tick time:\n\n- `quest.due`: an unresolved, undeleted quest reaches its effective due time (own `due_at`, otherwise its undeleted questline\'s due time).\n- `quest.overdue`: that effective deadline is strictly earlier than now and the quest remains unresolved.\n- `quest.unblocked`: an unresolved quest is available, all active prerequisites have succeeded or been deleted, and at least one prerequisite succeeded after its unlock was installed. Occurrence time is the latest active prerequisite success or not-before time, whichever is later.\n- `repeatable.spawned`: a concrete repeatable instance was created; occurrence time is its creation time. Tick first materializes due anchors through the existing spawn operation.\n- `rumor.captured`: an undeleted rumor was created; occurrence time is its creation time, even if subsequently settled or dismissed.\n\nPersistent event keys contain type, entity ids, and occurrence time. Restarts and repeated\nticks do not repeat occurrences. Removed transitions between ticks cannot be reconstructed.\nOnly occurrences at/after target creation are assigned; tags are evaluated at assignment.\nTick materializes due repeatables using the existing spawn path, including during preview\nwith rollback. Batches contain only ids, event types, timestamps, and the DB label:\n\n```json\n{"type":"questlog.events","db":"work","deliveryId":"d_\u2026","sentAt":"2026-10-28T10:00:00.000Z","events":[{"id":"e_\u2026","type":"quest.due","questId":12,"occurredAt":"2026-10-28T09:59:00.000Z"}]}\n```\n\nHeaders are `content-type: application/json`, `user-agent: questlog/<version>`,\n`x-questlog-delivery: <deliveryId>`, `x-questlog-timestamp: <unix seconds>`, and\n`x-questlog-signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.\nNo titles, objectives, details, or tag text are sent. `hook test` sends a logged `hook.test`\nevent immediately, ignoring the min gap. Add and test are CLI-only in the tool facade;\n`manage_hooks` supports list, remove, tick (with `dry_run`), and deliveries.\n\n2xx delivers. Timeout is 5 seconds. Failures retry after 30 s, 1 m, 2 m, 4 m, doubling\nto 1 h plus <1 s jitter, subject to the target\'s min gap. Eight failures mark failed.\nRetries keep the delivery id/events; new events queue behind them. Keep the last 500\ndelivered log entries per target and all pending/failed entries. Transactional claims\nlease work for two minutes. A crash after receipt may retry: dedupe at the receiver.\n\n### Verifying a delivery\n\nCompute HMAC-SHA256 over `timestamp + "." + rawBody` with the secret and compare in constant\ntime. Reject timestamps older than 5 minutes (and timestamps too far in the future), then\ndedupe on `x-questlog-delivery`. Use durable deduplication storage in a production receiver.\nThis 15-line Node example uses an in-memory set:\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\nimport { createServer } from \'node:http\';\nconst secret = process.env.WEBHOOK_SECRET, seen = new Set();\ncreateServer(async (req, res) => {\n const chunks = []; for await (const chunk of req) chunks.push(chunk);\n const rawBody = Buffer.concat(chunks), h = req.headers;\n const timestamp = h[\'x-questlog-timestamp\'], id = h[\'x-questlog-delivery\'];\n const age = Date.now() / 1000 - Number(timestamp);\n const expected = createHmac(\'sha256\', secret).update(timestamp + \'.\').update(rawBody).digest();\n const signature = String(h[\'x-questlog-signature\'] || \'\');\n const actual = Buffer.from(signature.slice(7), \'hex\');\n if (!id || !/^\\d+$/.test(timestamp) || age > 300 || age < -300 || !/^sha256=[a-f0-9]{64}$/.test(signature) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) { res.writeHead(401).end(); return; }\n if (!seen.has(id)) { seen.add(id); console.log(JSON.parse(rawBody)); }\n res.writeHead(204).end();\n}).listen(8080, \'127.0.0.1\');\n```\n'
3302
3319
  });
3303
3320
 
3304
3321
  // src/skills/skill_registry.ts
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export { listDeliveries } from './chunk-D6EGZCT7.js';
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export { listHooks } from './chunk-NBCCA6AX.js';
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync } from 'node:fs';
2
+ import { mkdirSync, closeSync, openSync } from 'node:fs';
3
3
  import { basename, extname, dirname, join } from 'node:path';
4
4
  import { randomBytes, createHmac } from 'node:crypto';
5
5
  import { homedir } from 'node:os';
@@ -345,6 +345,8 @@ function initHookTables(db) {
345
345
  CREATE INDEX IF NOT EXISTS idx_hook_deliveries_target ON hook_deliveries(target_id, status, next_attempt_at);
346
346
  CREATE TABLE IF NOT EXISTS hook_settings (id INTEGER PRIMARY KEY CHECK(id = 1), label TEXT NOT NULL);
347
347
  `);
348
+ if (!db.prepare("PRAGMA table_info(hook_targets)").all().some((row) => row.name === "headers"))
349
+ db.exec("ALTER TABLE hook_targets ADD COLUMN headers TEXT NOT NULL DEFAULT '{}'");
348
350
  }
349
351
  var init_init_hook_tables = __esm({
350
352
  "src/hooks/init_hook_tables.ts"() {
@@ -766,7 +768,14 @@ async function openQuestlogDb(path) {
766
768
  } finally {
767
769
  process.emitWarning = original;
768
770
  }
769
- if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
771
+ if (path !== ":memory:") {
772
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
773
+ try {
774
+ closeSync(openSync(path, "wx", 384));
775
+ } catch (error) {
776
+ if (error.code !== "EEXIST") throw error;
777
+ }
778
+ }
770
779
  const db = new sqlite.DatabaseSync(path);
771
780
  try {
772
781
  db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
@@ -788,7 +797,7 @@ var init_open_questlog_db = __esm({
788
797
  var questlogVersion;
789
798
  var init_version = __esm({
790
799
  "src/version.ts"() {
791
- questlogVersion = "0.10.1";
800
+ questlogVersion = "0.11.1";
792
801
  }
793
802
  });
794
803
 
@@ -2137,7 +2146,7 @@ var init_hook_label = __esm({
2137
2146
 
2138
2147
  // src/hooks/list_hooks.ts
2139
2148
  function listHooks(db) {
2140
- return db.prepare(`SELECT id, url, tag, events, min_gap, created_at, last_wake,
2149
+ return db.prepare(`SELECT id, url, headers, tag, events, min_gap, created_at, last_wake,
2141
2150
  (SELECT COUNT(*) FROM hook_assignments a LEFT JOIN hook_deliveries d ON d.id = a.delivery_id WHERE a.target_id = t.id AND (a.delivery_id IS NULL OR d.status = 'pending')) AS pending,
2142
2151
  (SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
2143
2152
  FROM hook_targets t ORDER BY id`).all().map((row) => {
@@ -2148,6 +2157,9 @@ function listHooks(db) {
2148
2157
  url.hash = "";
2149
2158
  return {
2150
2159
  id: Number(row.id),
2160
+ headers: Object.fromEntries(
2161
+ Object.keys(JSON.parse(String(row.headers))).map((name) => [name, "***"])
2162
+ ),
2151
2163
  scope: row.tag == null ? "database" : "tag",
2152
2164
  url: url.href,
2153
2165
  tag: row.tag == null ? null : String(row.tag),
@@ -2236,8 +2248,13 @@ async function sendHookDelivery(db, delivery, target, options) {
2236
2248
  method: "POST",
2237
2249
  redirect: "error",
2238
2250
  headers: {
2239
- "content-type": "application/json",
2240
2251
  "user-agent": `questlog/${questlogVersion}`,
2252
+ ...JSON.parse(
2253
+ String(
2254
+ db.prepare("SELECT headers FROM hook_targets WHERE id = ?").get(target.id)?.headers ?? "{}"
2255
+ )
2256
+ ),
2257
+ "content-type": "application/json",
2241
2258
  "x-questlog-delivery": delivery.id,
2242
2259
  "x-questlog-timestamp": timestamp,
2243
2260
  "x-questlog-signature": `sha256=${signature}`