@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.
- package/README.md +31 -5
- package/SKILL.md +69 -38
- package/dist/chunk-7KTRNXTU.js +21 -0
- package/dist/chunk-D6EGZCT7.js +23 -0
- package/dist/{chunk-D2W5MYNO.js → chunk-I2AMIXGP.js} +1 -1
- package/dist/{chunk-ZHTX64KD.js → chunk-IA2GVCZS.js} +11 -2
- package/dist/{chunk-55JAQORX.js → chunk-KOWIVBG6.js} +3 -1
- package/dist/chunk-NBCCA6AX.js +31 -0
- package/dist/chunk-QK3H2AL5.js +36 -0
- package/dist/{chunk-N7V7VUI5.js → chunk-RBGQK5WP.js} +54 -87
- package/dist/cli.js +1 -1
- package/dist/{cli_main-7AIEZV75.js → cli_main-TEG3QTDP.js} +78 -21
- 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-ILTCTRYS.js} +15 -9
- package/dist/run_hook_command-BOENYNDT.js +155 -0
- package/dist/{serve_questlog_mcp-W3R63HW4.js → serve_questlog_mcp-X524X34F.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
|
@@ -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))
|
|
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(
|
|
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.
|
|
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,
|
|
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
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import { listDeliveries
|
|
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
|
|
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)));
|
|
@@ -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)
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
345
|
-
const { questlogSkillSpec } = await import('./questlog_skill_spec-
|
|
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
|
|
459
|
-
|
|
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
|
-
${
|
|
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))
|
|
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.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))
|
|
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.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
|