@ghostpaw/questlog 0.10.1 → 0.11.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.
@@ -1,7 +1,44 @@
1
1
  #!/usr/bin/env node
2
+ import { listDeliveries } from './chunk-D6EGZCT7.js';
3
+ import { listHooks } from './chunk-NBCCA6AX.js';
2
4
  import { randomBytes, createHmac } from 'node:crypto';
3
5
  import { basename, extname } from 'node:path';
4
6
 
7
+ // src/with_transaction.ts
8
+ var transactionDepth = /* @__PURE__ */ new WeakMap();
9
+ function withTransaction(db, fn) {
10
+ const depth = transactionDepth.get(db) ?? 0;
11
+ const savepoint = `questlog_${depth}`;
12
+ if (depth === 0) {
13
+ db.exec("BEGIN IMMEDIATE");
14
+ } else {
15
+ db.exec(`SAVEPOINT ${savepoint}`);
16
+ }
17
+ transactionDepth.set(db, depth + 1);
18
+ try {
19
+ const result = fn();
20
+ if (depth === 0) {
21
+ db.exec("COMMIT");
22
+ } else {
23
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
24
+ }
25
+ transactionDepth.set(db, depth);
26
+ return result;
27
+ } catch (error) {
28
+ try {
29
+ if (depth === 0) {
30
+ db.exec("ROLLBACK");
31
+ } else {
32
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
33
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
34
+ }
35
+ } catch {
36
+ }
37
+ transactionDepth.set(db, depth);
38
+ throw error;
39
+ }
40
+ }
41
+
5
42
  // src/tags/normalize_tag_name.ts
6
43
  function normalizeTagName(name) {
7
44
  return name.trim().toLowerCase().replace(/\s+/g, " ");
@@ -24,13 +61,20 @@ function addHook(db, input) {
24
61
  } catch {
25
62
  throw new Error("Expected an HTTP or HTTPS URL");
26
63
  }
27
- if (!["http:", "https:"].includes(url.protocol)) throw new Error("Expected an HTTP or HTTPS URL");
64
+ if (!["http:", "https:"].includes(url.protocol))
65
+ throw new Error(`unsupported scheme ${url.protocol}; use http or https`);
66
+ if (url.username || url.password)
67
+ throw new Error(
68
+ "URL userinfo is not supported; use a custom header (--header or --header-stdin) or a query key instead"
69
+ );
28
70
  const minGap = input.minGap ?? 60;
29
71
  if (!Number.isSafeInteger(minGap) || minGap < 0)
30
72
  throw new Error("Min gap must be a nonnegative integer");
31
73
  const events = input.events ?? [...hookEventTypes];
32
74
  if (!events.length || events.some((e) => !hookEventTypes.includes(e)))
33
- throw new Error("Unknown event filter");
75
+ throw new Error(
76
+ `unknown event ${events.find((e) => !hookEventTypes.includes(e)) ?? "(empty)"}; valid: ${hookEventTypes.join(", ")}`
77
+ );
34
78
  if (input.tag !== void 0 && !normalizeTagName(input.tag))
35
79
  throw new Error("Expected a tag name");
36
80
  const secret = input.secret ?? randomBytes(32).toString("hex");
@@ -62,67 +106,6 @@ function hookLabel(db, name) {
62
106
  return path ? basename(path, extname(path)) : "questlog";
63
107
  }
64
108
 
65
- // src/hooks/list_hooks.ts
66
- function listHooks(db) {
67
- return db.prepare(`SELECT id, url, tag, events, min_gap, created_at, last_wake,
68
- (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,
69
- (SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
70
- FROM hook_targets t ORDER BY id`).all().map((row) => {
71
- const url = new URL(String(row.url));
72
- url.username = "";
73
- url.password = "";
74
- url.search = "";
75
- url.hash = "";
76
- return {
77
- id: Number(row.id),
78
- scope: row.tag == null ? "database" : "tag",
79
- url: url.href,
80
- tag: row.tag == null ? null : String(row.tag),
81
- events: JSON.parse(String(row.events)),
82
- minGap: Number(row.min_gap),
83
- createdAt: Number(row.created_at),
84
- lastWake: row.last_wake == null ? null : Number(row.last_wake),
85
- pending: Number(row.pending),
86
- failed: Number(row.failed)
87
- };
88
- });
89
- }
90
-
91
- // src/with_transaction.ts
92
- var transactionDepth = /* @__PURE__ */ new WeakMap();
93
- function withTransaction(db, fn) {
94
- const depth = transactionDepth.get(db) ?? 0;
95
- const savepoint = `questlog_${depth}`;
96
- if (depth === 0) {
97
- db.exec("BEGIN IMMEDIATE");
98
- } else {
99
- db.exec(`SAVEPOINT ${savepoint}`);
100
- }
101
- transactionDepth.set(db, depth + 1);
102
- try {
103
- const result = fn();
104
- if (depth === 0) {
105
- db.exec("COMMIT");
106
- } else {
107
- db.exec(`RELEASE SAVEPOINT ${savepoint}`);
108
- }
109
- transactionDepth.set(db, depth);
110
- return result;
111
- } catch (error) {
112
- try {
113
- if (depth === 0) {
114
- db.exec("ROLLBACK");
115
- } else {
116
- db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
117
- db.exec(`RELEASE SAVEPOINT ${savepoint}`);
118
- }
119
- } catch {
120
- }
121
- transactionDepth.set(db, depth);
122
- throw error;
123
- }
124
- }
125
-
126
109
  // src/hooks/remove_hook.ts
127
110
  function removeHook(db, id) {
128
111
  withTransaction(db, () => {
@@ -133,7 +116,7 @@ function removeHook(db, id) {
133
116
  }
134
117
 
135
118
  // src/version.ts
136
- var questlogVersion = "0.10.1";
119
+ var questlogVersion = "0.11.0";
137
120
 
138
121
  // src/hooks/send_hook_delivery.ts
139
122
  async function sendHookDelivery(db, delivery, target, options) {
@@ -154,8 +137,13 @@ async function sendHookDelivery(db, delivery, target, options) {
154
137
  method: "POST",
155
138
  redirect: "error",
156
139
  headers: {
157
- "content-type": "application/json",
158
140
  "user-agent": `questlog/${questlogVersion}`,
141
+ ...JSON.parse(
142
+ String(
143
+ db.prepare("SELECT headers FROM hook_targets WHERE id = ?").get(target.id)?.headers ?? "{}"
144
+ )
145
+ ),
146
+ "content-type": "application/json",
159
147
  "x-questlog-delivery": delivery.id,
160
148
  "x-questlog-timestamp": timestamp,
161
149
  "x-questlog-signature": `sha256=${signature}`
@@ -815,27 +803,6 @@ function detectHookEvents(db, now) {
815
803
  );
816
804
  }
817
805
 
818
- // src/hooks/list_deliveries.ts
819
- function listDeliveries(db, options = {}) {
820
- return db.prepare(
821
- `SELECT * FROM hook_deliveries ${options.includeDelivered ? "" : "WHERE status <> 'delivered'"} ORDER BY target_id, next_attempt_at, id`
822
- ).all().map(
823
- (row) => ({
824
- id: String(row.id),
825
- targetId: Number(row.target_id),
826
- events: JSON.parse(String(row.events)),
827
- status: row.status,
828
- attempts: Number(row.attempts),
829
- lastAttemptAt: row.last_attempt_at == null ? null : Number(row.last_attempt_at),
830
- httpStatus: row.http_status == null ? null : Number(row.http_status),
831
- errorClass: row.error_class == null ? null : String(row.error_class),
832
- nextAttemptAt: Number(row.next_attempt_at)
833
- })
834
- ).filter(
835
- (d) => options.questId === void 0 || d.events.some((e) => e.questId === options.questId)
836
- );
837
- }
838
-
839
806
  // src/hooks/tick_questlog.ts
840
807
  async function tickQuestlog(db, options = {}) {
841
808
  const now = options.now ?? options.clock?.() ?? Date.now();
@@ -979,4 +946,4 @@ async function watchQuestlog(db, options) {
979
946
  }
980
947
  }
981
948
 
982
- export { addHook, deriveQuestMarkerId, deriveQuestState, getQuestOrThrow, getRepeatableQuestOrThrow, hookLabel, listDeliveries, listDueRepeatableQuestAnchors, listHooks, mapQuestRewardRow, normalizeTagName, parseRRule, queryQuestDetailRecords, questlogVersion, removeHook, resolveNow, spawnDueRepeatableQuests, testHook, tickQuestlog, watchQuestlog, withTransaction };
949
+ export { addHook, deriveQuestMarkerId, deriveQuestState, getQuestOrThrow, getRepeatableQuestOrThrow, hookLabel, listDueRepeatableQuestAnchors, mapQuestRewardRow, normalizeTagName, parseRRule, queryQuestDetailRecords, questlogVersion, removeHook, resolveNow, spawnDueRepeatableQuests, testHook, tickQuestlog, watchQuestlog, withTransaction };
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-TEG3QTDP.js');
17
17
  }
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { questlogSkills } from './chunk-I2AMIXGP.js';
2
3
  import { CliUsageError, renderCliHelp, parseCliArgs } from './chunk-GVD6A3RR.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-KOWIVBG6.js';
6
+ import { questlogVersion } from './chunk-RBGQK5WP.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)));
@@ -221,7 +225,10 @@ ${values.dryRun ? `${JSON.stringify({ events: d.events, batches: d.batches }, nu
221
225
  "give the title once: either as the first argument or with --title"
222
226
  );
223
227
  const title = v.positionalTitle ?? v.title;
224
- if (title === void 0) throw new CliUsageError("Missing required argument: title");
228
+ if (title === void 0)
229
+ throw new CliUsageError(
230
+ 'Missing required argument: title. Example: questlog add "Write the report" --due tomorrow'
231
+ );
225
232
  const quest = {
226
233
  title,
227
234
  objective: v.objective ?? title
@@ -315,7 +322,7 @@ var questlogCliOptions = {
315
322
  describeDb: (options) => `Database: ${selectedDbPath(options)}
316
323
  Use --db <path> or GHOSTPAW_QUESTLOG_DB to override the default ~/.ghostpaw/questlog.db.`,
317
324
  openContext: async (options) => {
318
- const { openQuestlogDb } = await import('./open_questlog_db-LAEH7UDM.js');
325
+ const { openQuestlogDb } = await import('./open_questlog_db-6NS6BVBC.js');
319
326
  const path = selectedDbPath(options);
320
327
  const created = path !== ":memory:" && !existsSync(path);
321
328
  const db = await openQuestlogDb(path);
@@ -327,22 +334,56 @@ Use --db <path> or GHOSTPAW_QUESTLOG_DB to override the default ~/.ghostpaw/ques
327
334
  commands: {
328
335
  names: ["hook", "watch"],
329
336
  run: async (command, args, options, io) => {
337
+ if (command === "show" && args[0]?.startsWith("d_")) {
338
+ const { listDeliveries: listDeliveries2 } = await import('./list_deliveries-WJEJUZIQ.js');
339
+ const { listHooks } = await import('./list_hooks-C5CAA2C7.js');
340
+ const { textOutput } = await import('./text_output-X7F3K23Q.js');
341
+ if (args.includes("--help") || args.includes("-h")) {
342
+ io.stdout.write("questlog show <delivery-id> [--json]\n");
343
+ return 0;
344
+ }
345
+ const { openQuestlogDb } = await import('./open_questlog_db-6NS6BVBC.js');
346
+ const db = await openQuestlogDb(selectedDbPath(options));
347
+ try {
348
+ const delivery = listDeliveries2(db, { includeDelivered: true }).find(
349
+ (d) => d.id === args[0]
350
+ );
351
+ if (!delivery) {
352
+ io.stderr.write(`Delivery ${args[0]} not found
353
+ `);
354
+ return 1;
355
+ }
356
+ const target = listHooks(db).find((h) => h.id === delivery.targetId);
357
+ const result = {
358
+ ...delivery,
359
+ target: target ?? null,
360
+ eventIds: delivery.events.map((e) => e.id)
361
+ };
362
+ io.stdout.write(
363
+ `${args.includes("--json") ? JSON.stringify(result, null, 2) : textOutput(result)}
364
+ `
365
+ );
366
+ return 0;
367
+ } finally {
368
+ db.close();
369
+ }
370
+ }
330
371
  if (command !== "hook" && command !== "watch") return void 0;
331
- const { runHookCommand } = await import('./run_hook_command-C42BQC36.js');
372
+ const { runHookCommand } = await import('./run_hook_command-BOENYNDT.js');
332
373
  return runHookCommand(command, args, io, async () => {
333
- const { openQuestlogDb } = await import('./open_questlog_db-LAEH7UDM.js');
374
+ const { openQuestlogDb } = await import('./open_questlog_db-6NS6BVBC.js');
334
375
  return openQuestlogDb(selectedDbPath(options));
335
376
  });
336
377
  },
337
378
  plain: questlogPlainCommands(),
338
379
  guides: questlogSkills,
339
380
  mcp: async (options) => {
340
- const { serveQuestlogMcp } = await import('./serve_questlog_mcp-W3R63HW4.js');
381
+ const { serveQuestlogMcp } = await import('./serve_questlog_mcp-X524X34F.js');
341
382
  await serveQuestlogMcp(options.db === void 0 ? {} : { dbPath: options.db });
342
383
  },
343
384
  skill: async () => {
344
- const { renderSkillMarkdown } = await import('./skill_markdown-HXYQ3K6U.js');
345
- const { questlogSkillSpec } = await import('./questlog_skill_spec-HEW2B6EB.js');
385
+ const { renderSkillMarkdown } = await import('./skill_markdown-EGKYZ3W2.js');
386
+ const { questlogSkillSpec } = await import('./questlog_skill_spec-ILTCTRYS.js');
346
387
  return renderSkillMarkdown(questlogSkillSpec);
347
388
  }
348
389
  }
@@ -437,6 +478,13 @@ Version formats: --version --short, --version --json
437
478
  ${options.describeDb ? `${options.describeDb(ctxOptions)}
438
479
  ` : ""}
439
480
  ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")}
481
+ `
482
+ );
483
+ return 0;
484
+ }
485
+ if (["tools", "call", "describe"].includes(command) && args.length === 1 && ["--help", "-h"].includes(args[0] ?? "")) {
486
+ stdout.write(
487
+ `Usage: ${options.bin} ${command}${command === "tools" ? " [--json]" : " <tool> [--help]"}
440
488
  `
441
489
  );
442
490
  return 0;
@@ -455,16 +503,21 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
455
503
  }
456
504
  if (command === "skill") {
457
505
  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];
506
+ if (args.length > 1 && !args.some((arg) => ["--help", "-h"].includes(arg)))
507
+ return fail(`Usage: ${options.bin} skill [--short | --list | <guide> | --help]`);
508
+ const guide = args.some((arg) => ["--help", "-h"].includes(arg)) ? "--help" : args[0];
460
509
  if (guide === "--help" || guide === "-h") {
461
510
  stdout.write(
462
511
  `Usage:
463
512
  ${options.bin} skill
464
513
  ${options.bin} skill --list
514
+ ${options.bin} skill --short
465
515
  ${options.bin} skill <guide>
466
516
  `
467
517
  );
518
+ } else if (guide === "--short") {
519
+ const full = await options.commands.skill();
520
+ stdout.write(full.split("## Appendix:")[0]?.replace(/^---[\s\S]*?---\n+/, "") ?? full);
468
521
  } else if (guide === "--list") {
469
522
  stdout.write(
470
523
  `${(options.commands.guides ?? []).map((entry) => `${entry.name} ${entry.description.replace(/\s+/g, " ")}`).join("\n")}
@@ -477,6 +530,11 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
477
530
  } else stdout.write(await options.commands.skill());
478
531
  return 0;
479
532
  }
533
+ if (command === "mcp" && (args.includes("--help") || args.includes("-h"))) {
534
+ stdout.write(`${options.bin} mcp [--db path]
535
+ `);
536
+ return 0;
537
+ }
480
538
  if (command === "mcp") {
481
539
  if (!options.commands?.mcp) return fail("mcp command unavailable");
482
540
  await options.commands.mcp(ctxOptions);
@@ -528,26 +586,25 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
528
586
  ]);
529
587
  return fail(`Unknown command: ${command}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`);
530
588
  }
531
- if (command === "describe") {
589
+ if (command === "describe" || args.includes("--help") || args.includes("-h")) {
532
590
  stdout.write(
533
591
  `${tool.name}
592
+ Usage: ${options.bin} ${tool.name} --parameter value [--json]
534
593
  ${tool.description}
535
594
  Use when: ${tool.whenToUse ?? ""}
536
595
  Not for: ${tool.whenNotToUse ?? ""}
537
596
  Read only: ${tool.readOnly ?? false}
538
597
 
539
598
  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")}
599
+ ${schemaRows(tool.inputSchema).join("\n")}
545
600
 
546
601
  ${JSON.stringify(tool.inputSchema, null, 2)}
547
602
  `
548
603
  );
549
604
  return 0;
550
605
  }
606
+ const json = args.includes("--json");
607
+ if (!plain && json) args.splice(args.indexOf("--json"), 1);
551
608
  let input = {};
552
609
  try {
553
610
  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.0";
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.0";
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';