@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.
- package/README.md +34 -5
- package/SKILL.md +69 -38
- package/dist/chunk-7KTRNXTU.js +21 -0
- package/dist/{chunk-GVD6A3RR.js → chunk-B5KVA6XE.js} +15 -9
- package/dist/chunk-D6EGZCT7.js +23 -0
- package/dist/{chunk-N7V7VUI5.js → chunk-FORPYJT4.js} +54 -87
- package/dist/{chunk-D2W5MYNO.js → chunk-I2AMIXGP.js} +1 -1
- package/dist/{chunk-ZHTX64KD.js → chunk-IA2GVCZS.js} +11 -2
- package/dist/chunk-NBCCA6AX.js +31 -0
- package/dist/chunk-QK3H2AL5.js +36 -0
- package/dist/{chunk-55JAQORX.js → chunk-R56MXZ2K.js} +3 -1
- package/dist/cli.js +1 -1
- package/dist/{cli_main-7AIEZV75.js → cli_main-YW3E2NJM.js} +85 -23
- package/dist/index.cjs +23 -6
- package/dist/index.js +23 -6
- package/dist/list_deliveries-WJEJUZIQ.js +2 -0
- package/dist/list_hooks-C5CAA2C7.js +2 -0
- package/dist/mcp-server.js +22 -5
- package/dist/mcp.cjs +21 -4
- package/dist/mcp.js +22 -5
- package/dist/open_questlog_db-6NS6BVBC.js +2 -0
- package/dist/{questlog_skill_spec-HEW2B6EB.js → questlog_skill_spec-S5OYGF5X.js} +15 -9
- package/dist/run_hook_command-OGLBKXAV.js +155 -0
- package/dist/{serve_questlog_mcp-W3R63HW4.js → serve_questlog_mcp-YRCT3GA3.js} +5 -3
- package/dist/{skill_markdown-HXYQ3K6U.js → skill_markdown-EGKYZ3W2.js} +4 -20
- package/dist/text_output-X7F3K23Q.js +2 -0
- package/package.json +1 -1
- package/skills/questlog/SKILL.md +69 -38
- package/skills/questlog/references/webhooks.md +3 -0
- package/dist/open_questlog_db-LAEH7UDM.js +0 -2
- package/dist/run_hook_command-C42BQC36.js +0 -95
|
@@ -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,
|
|
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
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import { listDeliveries
|
|
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
|
|
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})
|
|
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)
|
|
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
|
-
{
|
|
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)
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
345
|
-
const { questlogSkillSpec } = await import('./questlog_skill_spec-
|
|
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
|
|
459
|
-
|
|
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
|
-
${
|
|
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))
|
|
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(
|
|
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.
|
|
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))
|
|
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(
|
|
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.
|
|
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
|
package/dist/mcp-server.js
CHANGED
|
@@ -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:")
|
|
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.
|
|
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}`
|