@bartolli/kmd 0.8.0 → 0.10.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/dist/kmd.mjs CHANGED
@@ -150,7 +150,7 @@ ${issues}`);
150
150
  }
151
151
  return parsed.data;
152
152
  }
153
- var ScopeSchema, KindEntrySchema, WhenSchema, DedupSchema, TriggerSchema, TriggersSchema, VaultConfigSchema, BUILT_IN_KINDS;
153
+ var ScopeSchema, KindEntrySchema, WhenSchema, DedupSchema, TriggerSchema, TriggersSchema, BuiltinHooksSchema, VaultConfigSchema, BUILT_IN_KINDS;
154
154
  var init_vault_config = __esm({
155
155
  "../db/src/vault-config.ts"() {
156
156
  "use strict";
@@ -241,6 +241,15 @@ var init_vault_config = __esm({
241
241
  }
242
242
  });
243
243
  TriggersSchema = z.record(z.string(), z.array(TriggerSchema));
244
+ BuiltinHooksSchema = z.strictObject({
245
+ resync: z.strictObject({
246
+ reason: z.string().min(1).optional().describe("Validate-errors preamble; the engine appends the error lines."),
247
+ text: z.string().min(1).optional().describe("Sync-failed note.")
248
+ }).optional(),
249
+ "handoff-gate": z.strictObject({
250
+ reason: z.string().min(1).optional().describe("Stop-block preamble; the engine appends the error lines.")
251
+ }).optional()
252
+ });
244
253
  VaultConfigSchema = z.strictObject({
245
254
  scopes: z.record(z.string(), ScopeSchema).describe("Scope name \u2192 entry; key = directory name under projects/."),
246
255
  kinds: z.array(KindEntrySchema).describe(
@@ -259,6 +268,9 @@ var init_vault_config = __esm({
259
268
  triggers: TriggersSchema.optional().describe(
260
269
  'Full-replace of the trigger base per scope \u2014 escape hatch. "_all" is reserved for triggers_extra.'
261
270
  ),
271
+ builtin_hooks: BuiltinHooksSchema.optional().describe(
272
+ "Message overrides for the fixed-function hooks (resync, handoff-gate) by public id."
273
+ ),
262
274
  triggers_extra: TriggersSchema.optional().describe(
263
275
  'Appended per scope after the engine defaults; the reserved "_all" key fires in every session.'
264
276
  )
@@ -797,15 +809,11 @@ async function syncVault(vaultRoot2) {
797
809
  }
798
810
  indexedPaths.push(path);
799
811
  }
800
- let pagesDeleted = 0;
801
- let linksDeleted = 0;
802
- if (indexedPaths.length > 0) {
803
- const placeholders = indexedPaths.map(() => "?").join(", ");
804
- const pageResult = db.prepare(`DELETE FROM pages WHERE path NOT IN (${placeholders})`).run(...indexedPaths);
805
- pagesDeleted = Number(pageResult.changes);
806
- const linkResult = db.prepare("DELETE FROM links WHERE source_path NOT IN (SELECT path FROM pages)").run();
807
- linksDeleted = Number(linkResult.changes);
808
- }
812
+ const placeholders = indexedPaths.map(() => "?").join(", ");
813
+ const pageResult = indexedPaths.length > 0 ? db.prepare(`DELETE FROM pages WHERE path NOT IN (${placeholders})`).run(...indexedPaths) : db.prepare("DELETE FROM pages").run();
814
+ const pagesDeleted = Number(pageResult.changes);
815
+ const linkResult = db.prepare("DELETE FROM links WHERE source_path NOT IN (SELECT path FROM pages)").run();
816
+ const linksDeleted = Number(linkResult.changes);
809
817
  db.exec("INSERT INTO pages_fts(pages_fts) VALUES('rebuild')");
810
818
  setMeta(db, "vault_root", canonicalVaultRoot(vaultRoot2));
811
819
  setMeta(db, "last_synced", (/* @__PURE__ */ new Date()).toISOString());
@@ -829,7 +837,7 @@ async function runSync() {
829
837
  console.log(`sync: ${env.WIKI_VAULT} \u2192 ${resolveIndexPath(env.WIKI_VAULT)}`);
830
838
  const stats = await syncVault(env.WIKI_VAULT);
831
839
  if (stats.noPages) {
832
- console.warn("no indexable pages found; skipping orphan deletion (safety)");
840
+ console.warn("no indexable pages found; index swept empty");
833
841
  }
834
842
  console.log(
835
843
  `done: ${stats.changed} changed, ${stats.unchanged} unchanged, ${stats.skipped} skipped, ${stats.pagesDeleted} pages deleted, ${stats.linksDeleted} link orphans cleared`
@@ -2349,6 +2357,8 @@ __export(hook_exports, {
2349
2357
  dedupePretoolMatches: () => dedupePretoolMatches,
2350
2358
  effectiveTriggers: () => effectiveTriggers,
2351
2359
  evaluateMatches: () => evaluateMatches,
2360
+ explainPretool: () => explainPretool,
2361
+ explainPrompt: () => explainPrompt,
2352
2362
  hookStateDir: () => hookStateDir,
2353
2363
  kiroIdePromptEvent: () => kiroIdePromptEvent,
2354
2364
  loadTriggerFile: () => loadTriggerFile,
@@ -2359,6 +2369,7 @@ __export(hook_exports, {
2359
2369
  parseStopEvent: () => parseStopEvent,
2360
2370
  renderPosttool: () => renderPosttool,
2361
2371
  renderPretool: () => renderPretool,
2372
+ renderPrompt: () => renderPrompt,
2362
2373
  renderStop: () => renderStop,
2363
2374
  resolveScope: () => resolveScope,
2364
2375
  runHookPosttool: () => runHookPosttool,
@@ -2452,37 +2463,50 @@ function openPromptIndex(prompt) {
2452
2463
  db.prepare("INSERT INTO prompt_doc (text) VALUES (?)").run(prompt);
2453
2464
  return db;
2454
2465
  }
2466
+ function promptProbe(trigger, prompt, getDb) {
2467
+ let keywords = "unset";
2468
+ if (trigger.keywords !== void 0 && trigger.keywords.length > 0) {
2469
+ const row = getDb().prepare("SELECT count(*) AS n FROM prompt_doc WHERE prompt_doc MATCH ?").get(keywordQuery(trigger.keywords));
2470
+ keywords = row.n > 0 ? "hit" : "miss";
2471
+ }
2472
+ let intent = "unset";
2473
+ if (trigger.intent !== void 0) {
2474
+ intent = trigger.intent.some((pattern) => new RegExp(pattern, "i").test(prompt)) ? "hit" : "miss";
2475
+ }
2476
+ return { keywords, intent };
2477
+ }
2478
+ function promptHit(probe) {
2479
+ return probe.keywords === "hit" || probe.intent === "hit";
2480
+ }
2481
+ function isInjectTrigger(trigger) {
2482
+ return trigger.on === "prompt" && trigger.enforce === "inject" && trigger.text !== void 0;
2483
+ }
2455
2484
  function matchPromptTriggers(prompt, triggers) {
2456
- const candidates = triggers.filter(
2457
- (trigger) => trigger.on === "prompt" && trigger.enforce === "inject" && trigger.text !== void 0
2458
- );
2485
+ const candidates = triggers.filter(isInjectTrigger);
2459
2486
  if (candidates.length === 0) return [];
2460
2487
  const matches = [];
2461
- let db = null;
2488
+ const state = { db: null };
2489
+ const getDb = () => state.db ??= openPromptIndex(prompt);
2462
2490
  try {
2463
2491
  for (const trigger of candidates) {
2464
- let hit = false;
2465
- if (trigger.keywords !== void 0 && trigger.keywords.length > 0) {
2466
- db ??= openPromptIndex(prompt);
2467
- const row = db.prepare("SELECT count(*) AS n FROM prompt_doc WHERE prompt_doc MATCH ?").get(keywordQuery(trigger.keywords));
2468
- hit = row.n > 0;
2469
- }
2470
- if (!hit && trigger.intent !== void 0) {
2471
- hit = trigger.intent.some((pattern) => new RegExp(pattern, "i").test(prompt));
2472
- }
2473
- if (hit) {
2474
- matches.push({
2475
- id: trigger.id,
2476
- text: trigger.text,
2477
- ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2478
- });
2479
- }
2492
+ if (!promptHit(promptProbe(trigger, prompt, getDb))) continue;
2493
+ matches.push({
2494
+ id: trigger.id,
2495
+ text: trigger.text,
2496
+ ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2497
+ });
2480
2498
  }
2481
2499
  } finally {
2482
- db?.close();
2500
+ state.db?.close();
2483
2501
  }
2484
2502
  return matches;
2485
2503
  }
2504
+ function uniqueTexts(texts) {
2505
+ return [...new Set(texts)];
2506
+ }
2507
+ function renderPrompt(matches) {
2508
+ return uniqueTexts(matches.map((match) => match.text));
2509
+ }
2486
2510
  function parsePretoolEvent(raw) {
2487
2511
  const fields = eventFields(raw);
2488
2512
  if (fields === null) return null;
@@ -2531,32 +2555,41 @@ function pathCandidates(toolInput, cwd) {
2531
2555
  }
2532
2556
  return candidates;
2533
2557
  }
2558
+ function pretoolStage(trigger, toolName, toolInput, cwd) {
2559
+ if (trigger.tool !== void 0 && trigger.tool !== toolName) return "tool";
2560
+ if (trigger.args_match !== void 0) {
2561
+ const serialized = JSON.stringify(toolInput ?? {});
2562
+ if (!new RegExp(trigger.args_match).test(serialized)) return "args";
2563
+ }
2564
+ if (trigger.files !== void 0 && trigger.files.length > 0) {
2565
+ const candidates = [...pathCandidates(toolInput, cwd), ...patchPaths(toolInput)];
2566
+ const hit = trigger.files.some((glob) => {
2567
+ const regex = globToRegExp(glob);
2568
+ return candidates.some((candidate) => regex.test(candidate));
2569
+ });
2570
+ if (!hit) return "files";
2571
+ }
2572
+ if (pretoolText(trigger) === void 0) return "payload";
2573
+ return "hit";
2574
+ }
2575
+ function pretoolText(trigger) {
2576
+ return trigger.enforce === "block" ? trigger.reason : trigger.text;
2577
+ }
2578
+ function pretoolMatch(trigger) {
2579
+ return {
2580
+ id: trigger.id,
2581
+ enforce: trigger.enforce,
2582
+ text: pretoolText(trigger),
2583
+ ...trigger.when !== void 0 && { when: trigger.when },
2584
+ ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2585
+ };
2586
+ }
2534
2587
  function matchPretoolTriggers(toolName, toolInput, triggers, cwd) {
2535
2588
  const matches = [];
2536
2589
  for (const trigger of triggers) {
2537
2590
  if (trigger.on !== "pretool") continue;
2538
- if (trigger.tool !== void 0 && trigger.tool !== toolName) continue;
2539
- if (trigger.args_match !== void 0) {
2540
- const serialized = JSON.stringify(toolInput ?? {});
2541
- if (!new RegExp(trigger.args_match).test(serialized)) continue;
2542
- }
2543
- if (trigger.files !== void 0 && trigger.files.length > 0) {
2544
- const candidates = pathCandidates(toolInput, cwd);
2545
- const hit = trigger.files.some((glob) => {
2546
- const regex = globToRegExp(glob);
2547
- return candidates.some((candidate) => regex.test(candidate));
2548
- });
2549
- if (!hit) continue;
2550
- }
2551
- const text = trigger.enforce === "block" ? trigger.reason : trigger.text;
2552
- if (text === void 0) continue;
2553
- matches.push({
2554
- id: trigger.id,
2555
- enforce: trigger.enforce,
2556
- text,
2557
- ...trigger.when !== void 0 && { when: trigger.when },
2558
- ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2559
- });
2591
+ if (pretoolStage(trigger, toolName, toolInput, cwd) !== "hit") continue;
2592
+ matches.push(pretoolMatch(trigger));
2560
2593
  }
2561
2594
  return matches;
2562
2595
  }
@@ -2574,18 +2607,23 @@ function evaluateMatches(matches, vaultRoot2) {
2574
2607
  }
2575
2608
  return { fired, skipped };
2576
2609
  }
2577
- function evaluateWhen(when, vaultRoot2) {
2578
- if (typeof when === "string") return null;
2610
+ function evaluateWhenVerdict(when, vaultRoot2) {
2611
+ if (typeof when === "string") return "unknown";
2579
2612
  try {
2580
2613
  const than = newestUpdated(vaultRoot2, when.than);
2581
- if (than === null) return true;
2614
+ if (than === null) return "vacuous";
2582
2615
  const fresh = newestUpdated(vaultRoot2, when.fresh);
2583
- if (fresh === null) return false;
2584
- return fresh >= than;
2616
+ if (fresh === null) return "unmet";
2617
+ return fresh >= than ? "satisfied" : "unmet";
2585
2618
  } catch {
2586
- return null;
2619
+ return "unknown";
2587
2620
  }
2588
2621
  }
2622
+ function evaluateWhen(when, vaultRoot2) {
2623
+ const verdict = evaluateWhenVerdict(when, vaultRoot2);
2624
+ if (verdict === "unknown") return null;
2625
+ return verdict !== "unmet";
2626
+ }
2589
2627
  function newestUpdated(vaultRoot2, globs) {
2590
2628
  const regexes = globs.map(globToRegExp);
2591
2629
  let newest = null;
@@ -2612,26 +2650,27 @@ function readUpdated(path) {
2612
2650
  return null;
2613
2651
  }
2614
2652
  function renderPretool(matches, format) {
2615
- const block = matches.find((match) => match.enforce === "block");
2616
- const context = matches.filter((match) => match.enforce === "inject").map((match) => match.text);
2617
- const warnings = matches.filter((match) => match.enforce === "warn").map((match) => match.text);
2653
+ const byClass = (enforce) => uniqueTexts(matches.filter((match) => match.enforce === enforce).map((match) => match.text));
2654
+ const reasons = byClass("block");
2655
+ const context = byClass("inject");
2656
+ const warnings = byClass("warn");
2618
2657
  if (format === "claude") {
2619
2658
  const hookSpecificOutput = { hookEventName: "PreToolUse" };
2620
- if (block !== void 0) {
2659
+ if (reasons.length > 0) {
2621
2660
  hookSpecificOutput.permissionDecision = "deny";
2622
- hookSpecificOutput.permissionDecisionReason = block.text;
2661
+ hookSpecificOutput.permissionDecisionReason = reasons.join("\n");
2623
2662
  }
2624
2663
  if (context.length > 0) {
2625
2664
  hookSpecificOutput.additionalContext = context.join("\n");
2626
2665
  }
2627
- const decided = block !== void 0 || context.length > 0;
2666
+ const decided = reasons.length > 0 || context.length > 0;
2628
2667
  return { stdout: decided ? JSON.stringify({ hookSpecificOutput }) : null, stderr: warnings };
2629
2668
  }
2630
2669
  if (matches.length === 0) return { stdout: null, stderr: [] };
2631
2670
  return {
2632
2671
  stdout: JSON.stringify({
2633
- decision: block !== void 0 ? "deny" : "none",
2634
- ...block !== void 0 && { reason: block.text },
2672
+ decision: reasons.length > 0 ? "deny" : "none",
2673
+ ...reasons.length > 0 && { reason: reasons.join("\n") },
2635
2674
  context,
2636
2675
  warnings
2637
2676
  }),
@@ -2651,28 +2690,46 @@ function patchPaths(toolInput) {
2651
2690
  }
2652
2691
  return paths;
2653
2692
  }
2693
+ function commandPaths(toolInput) {
2694
+ if (typeof toolInput !== "object" || toolInput === null) return [];
2695
+ const command2 = toolInput.command;
2696
+ if (typeof command2 !== "string") return [];
2697
+ const paths = [];
2698
+ for (const match of command2.matchAll(COMMAND_TOKEN_RE)) {
2699
+ const token = match[1] ?? match[2] ?? match[3];
2700
+ for (const piece of token.split(/[;|&<>()]+/)) {
2701
+ if (piece === "") continue;
2702
+ if (piece === "." || piece === ".." || PATHISH_RE.test(piece)) paths.push(piece);
2703
+ }
2704
+ }
2705
+ return paths;
2706
+ }
2654
2707
  function vaultPathTouched(toolInput, vaultRoot2, cwd) {
2655
2708
  const root = resolve3(vaultRoot2);
2656
- const candidates = [...pathCandidates(toolInput, cwd), ...patchPaths(toolInput)];
2709
+ const candidates = [
2710
+ ...pathCandidates(toolInput, cwd),
2711
+ ...patchPaths(toolInput),
2712
+ ...commandPaths(toolInput)
2713
+ ];
2657
2714
  return candidates.some((candidate) => {
2658
2715
  const absolute = resolve3(cwd ?? ".", candidate);
2659
2716
  return absolute === root || absolute.startsWith(`${root}/`);
2660
2717
  });
2661
2718
  }
2662
- function renderPosttool(findings, synced, format) {
2719
+ function renderPosttool(findings, synced, format, messages = {}) {
2663
2720
  if (findings.length === 0 && synced) return null;
2664
2721
  const lines = findings.map((f) => `${f.severity}: ${f.path} [${f.rule}] ${f.message}`);
2665
2722
  if (format === "claude") {
2666
2723
  if (hasErrors(findings)) {
2667
2724
  return JSON.stringify({
2668
2725
  decision: "block",
2669
- reason: `kmd validate failed \u2014 fix before the index syncs:
2726
+ reason: `${messages.reason ?? RESYNC_REASON}:
2670
2727
  ${lines.join("\n")}`
2671
2728
  });
2672
2729
  }
2673
2730
  const hookSpecificOutput = { hookEventName: "PostToolUse" };
2674
2731
  const notes = [...lines];
2675
- if (!synced) notes.push("kmd sync failed \u2014 index not updated; see hook stderr");
2732
+ if (!synced) notes.push(messages.text ?? RESYNC_TEXT);
2676
2733
  hookSpecificOutput.additionalContext = notes.join("\n");
2677
2734
  return JSON.stringify({ hookSpecificOutput });
2678
2735
  }
@@ -2689,60 +2746,151 @@ function parseStopEvent(raw) {
2689
2746
  ...typeof stop_hook_active === "boolean" && { stop_hook_active }
2690
2747
  };
2691
2748
  }
2692
- function renderStop(findings) {
2749
+ function renderStop(findings, reason) {
2693
2750
  const errors = findings.filter((finding) => finding.severity === "error");
2694
2751
  if (errors.length === 0) return null;
2695
2752
  const lines = errors.map((f) => `${f.severity}: ${f.path} [${f.rule}] ${f.message}`);
2696
2753
  return JSON.stringify({
2697
2754
  decision: "block",
2698
- reason: `kmd validate: ${errors.length} error(s) outstanding \u2014 the index sync is held. Fix them, let the posttool hook sync, then finish:
2755
+ reason: `${reason ?? HANDOFF_GATE_REASON}:
2699
2756
  ${lines.join("\n")}`
2700
2757
  });
2701
2758
  }
2702
- function dedupePretoolMatches(stateDir, sessionId, matches) {
2759
+ function dedupePretoolMatches(stateDir, sessionId, matches, persist = true) {
2703
2760
  const blocks = matches.filter((match) => match.enforce === "block");
2704
2761
  const rest = matches.filter((match) => match.enforce !== "block");
2705
- const fresh = dedupeMatches(stateDir, sessionId, rest);
2762
+ const fresh = dedupeMatches(stateDir, sessionId, rest, Date.now(), persist);
2706
2763
  return matches.filter((match) => blocks.includes(match) || fresh.includes(match));
2707
2764
  }
2708
2765
  function hookStateDir() {
2709
2766
  return join10(kmdHome(), "state", "hook");
2710
2767
  }
2711
- function dedupeMatches(stateDir, sessionId, matches, now = Date.now()) {
2768
+ function explainPrompt(options) {
2769
+ const now = options.now ?? Date.now();
2770
+ const fired = readFired(join10(options.stateDir, safeName(options.sessionId)));
2771
+ const entries = [];
2772
+ const rendered = [];
2773
+ const state = { db: null };
2774
+ const getDb = () => state.db ??= openPromptIndex(options.prompt);
2775
+ try {
2776
+ for (const trigger of options.triggers) {
2777
+ if (!isInjectTrigger(trigger)) {
2778
+ entries.push({ id: trigger.id, considered: false });
2779
+ continue;
2780
+ }
2781
+ const probe = promptProbe(trigger, options.prompt, getDb);
2782
+ const matched = promptHit(probe);
2783
+ const entry = {
2784
+ id: trigger.id,
2785
+ considered: true,
2786
+ keywords: probe.keywords,
2787
+ intent: probe.intent,
2788
+ matched
2789
+ };
2790
+ entries.push(entry);
2791
+ if (!matched) {
2792
+ entry.fired = false;
2793
+ continue;
2794
+ }
2795
+ const key = dedupKey(trigger, now);
2796
+ entry.dedup = key === null ? "never" : fired.has(key) ? "suppressed" : "fresh";
2797
+ entry.fired = entry.dedup !== "suppressed";
2798
+ if (entry.fired) rendered.push({ id: trigger.id, text: trigger.text });
2799
+ }
2800
+ } finally {
2801
+ state.db?.close();
2802
+ }
2803
+ return { triggers: entries, output: renderPrompt(rendered) };
2804
+ }
2805
+ function explainPretool(options) {
2806
+ const now = options.now ?? Date.now();
2807
+ const fired = readFired(join10(options.stateDir, safeName(options.sessionId)));
2808
+ const entries = [];
2809
+ const rendered = [];
2810
+ for (const trigger of options.triggers) {
2811
+ if (trigger.on !== "pretool") {
2812
+ entries.push({ id: trigger.id, considered: false });
2813
+ continue;
2814
+ }
2815
+ const stage = pretoolStage(trigger, options.toolName, options.toolInput, options.cwd);
2816
+ const entry = {
2817
+ id: trigger.id,
2818
+ enforce: trigger.enforce,
2819
+ considered: true,
2820
+ matcher: stage === "hit" ? "hit" : `${stage}-miss`
2821
+ };
2822
+ entries.push(entry);
2823
+ if (stage !== "hit") {
2824
+ entry.fired = false;
2825
+ continue;
2826
+ }
2827
+ if (trigger.when !== void 0) {
2828
+ entry.when = evaluateWhenVerdict(trigger.when, options.vaultRoot);
2829
+ if (entry.when !== "unmet") {
2830
+ entry.fired = false;
2831
+ continue;
2832
+ }
2833
+ }
2834
+ const match = pretoolMatch(trigger);
2835
+ if (match.enforce === "block") {
2836
+ entry.dedup = "exempt";
2837
+ } else {
2838
+ const key = dedupKey(match, now);
2839
+ entry.dedup = key === null ? "never" : fired.has(key) ? "suppressed" : "fresh";
2840
+ }
2841
+ entry.fired = entry.dedup !== "suppressed";
2842
+ if (entry.fired) rendered.push(match);
2843
+ }
2844
+ return { triggers: entries, outcome: renderPretool(rendered, options.format ?? "neutral") };
2845
+ }
2846
+ function dedupeMatches(stateDir, sessionId, matches, now = Date.now(), persist = true) {
2712
2847
  if (matches.length === 0) return [];
2713
- const file = join10(stateDir, `${sessionId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
2714
- const fired = readFired(file);
2848
+ const dir = join10(stateDir, safeName(sessionId));
2849
+ const fired = readFired(dir);
2715
2850
  const fresh = [];
2716
2851
  const record = [];
2717
2852
  for (const match of matches) {
2718
- if (match.dedup === "never") {
2853
+ const key = dedupKey(match, now);
2854
+ if (key === null) {
2719
2855
  fresh.push(match);
2720
2856
  continue;
2721
2857
  }
2722
- const key = typeof match.dedup === "object" ? `${match.id}@${Math.floor(now / (match.dedup.minutes * 6e4))}` : match.id;
2723
2858
  if (fired.has(key)) continue;
2724
2859
  fresh.push(match);
2725
2860
  record.push(key);
2726
2861
  }
2727
- if (record.length > 0) {
2728
- mkdirSync4(stateDir, { recursive: true });
2729
- for (const key of record) {
2730
- fired.add(key);
2862
+ if (persist && record.length > 0) {
2863
+ try {
2864
+ mkdirSync4(dir, { recursive: true });
2865
+ for (const key of record) {
2866
+ try {
2867
+ writeFileSync(join10(dir, key), "", { flag: "wx" });
2868
+ } catch (err) {
2869
+ if (err.code !== "EEXIST") throw err;
2870
+ }
2871
+ }
2872
+ pruneStale(stateDir, dir);
2873
+ } catch (err) {
2874
+ diag2(`dedup state not persisted: ${err instanceof Error ? err.message : String(err)}`);
2731
2875
  }
2732
- writeFileSync(file, JSON.stringify([...fired]));
2733
- pruneStale(stateDir, file);
2734
2876
  }
2735
2877
  return fresh;
2736
2878
  }
2737
- function readFired(file) {
2879
+ function safeName(part) {
2880
+ return part.replace(/[^A-Za-z0-9._@-]/g, "_");
2881
+ }
2882
+ function dedupKey(match, now) {
2883
+ if (match.dedup === "never") return null;
2884
+ return safeName(
2885
+ typeof match.dedup === "object" ? `${match.id}@${Math.floor(now / (match.dedup.minutes * 6e4))}` : match.id
2886
+ );
2887
+ }
2888
+ function readFired(dir) {
2738
2889
  try {
2739
- const data = JSON.parse(readFileSync(file, "utf8"));
2740
- if (Array.isArray(data)) {
2741
- return new Set(data.filter((entry) => typeof entry === "string"));
2742
- }
2890
+ return new Set(readdirSync2(dir));
2743
2891
  } catch {
2892
+ return /* @__PURE__ */ new Set();
2744
2893
  }
2745
- return /* @__PURE__ */ new Set();
2746
2894
  }
2747
2895
  function pruneStale(stateDir, keep) {
2748
2896
  try {
@@ -2750,7 +2898,7 @@ function pruneStale(stateDir, keep) {
2750
2898
  for (const entry of readdirSync2(stateDir)) {
2751
2899
  const path = join10(stateDir, entry);
2752
2900
  if (path !== keep && statSync(path).mtimeMs < cutoff) {
2753
- rmSync2(path, { force: true });
2901
+ rmSync2(path, { recursive: true, force: true });
2754
2902
  }
2755
2903
  }
2756
2904
  } catch {
@@ -2764,7 +2912,9 @@ function hookInvocation() {
2764
2912
  options: {
2765
2913
  scope: { type: "string" },
2766
2914
  harness: { type: "string" },
2767
- triggers: { type: "string" }
2915
+ triggers: { type: "string" },
2916
+ "dry-run": { type: "boolean" },
2917
+ explain: { type: "boolean" }
2768
2918
  }
2769
2919
  });
2770
2920
  const vaultRoot2 = positionals2[2] ?? process.env.WIKI_VAULT;
@@ -2776,7 +2926,9 @@ function hookInvocation() {
2776
2926
  vaultRoot: vaultRoot2,
2777
2927
  scope: typeof values2.scope === "string" ? values2.scope : process.env.WIKI_SCOPE,
2778
2928
  harness: values2.harness,
2779
- triggersFile: values2.triggers
2929
+ triggersFile: values2.triggers,
2930
+ dryRun: values2["dry-run"] === true || values2.explain === true,
2931
+ explain: values2.explain === true
2780
2932
  };
2781
2933
  }
2782
2934
  function resolveFileTriggers(invocation) {
@@ -2814,9 +2966,26 @@ async function runHookPrompt() {
2814
2966
  for (const id of duplicates) {
2815
2967
  diag2(`duplicate trigger id "${id}" \u2014 later occurrence ignored`);
2816
2968
  }
2969
+ if (invocation.explain) {
2970
+ const trace = explainPrompt({
2971
+ prompt: event.prompt,
2972
+ triggers,
2973
+ stateDir: hookStateDir(),
2974
+ sessionId: event.session_id
2975
+ });
2976
+ console.log(JSON.stringify({ event: "prompt", scope: scope ?? null, duplicates, ...trace }));
2977
+ return;
2978
+ }
2817
2979
  const matches = matchPromptTriggers(event.prompt, triggers);
2818
- for (const match of dedupeMatches(hookStateDir(), event.session_id, matches)) {
2819
- console.log(match.text);
2980
+ const fresh = dedupeMatches(
2981
+ hookStateDir(),
2982
+ event.session_id,
2983
+ matches,
2984
+ Date.now(),
2985
+ !invocation.dryRun
2986
+ );
2987
+ for (const line of renderPrompt(fresh)) {
2988
+ console.log(line);
2820
2989
  }
2821
2990
  } catch (err) {
2822
2991
  diag2(err instanceof Error ? err.message : String(err));
@@ -2848,13 +3017,27 @@ async function runHookPretool() {
2848
3017
  for (const id of duplicates) {
2849
3018
  diag2(`duplicate trigger id "${id}" \u2014 later occurrence ignored`);
2850
3019
  }
3020
+ if (invocation.explain) {
3021
+ const trace = explainPretool({
3022
+ toolName: event.tool_name,
3023
+ toolInput: event.tool_input,
3024
+ triggers,
3025
+ vaultRoot: vaultRoot2,
3026
+ stateDir: hookStateDir(),
3027
+ sessionId: event.session_id,
3028
+ ...event.cwd !== void 0 && { cwd: event.cwd },
3029
+ format
3030
+ });
3031
+ console.log(JSON.stringify({ event: "pretool", scope: scope ?? null, duplicates, ...trace }));
3032
+ return;
3033
+ }
2851
3034
  const matches = matchPretoolTriggers(event.tool_name, event.tool_input, triggers, event.cwd);
2852
3035
  const { fired, skipped } = evaluateMatches(matches, vaultRoot2);
2853
3036
  for (const id of skipped) {
2854
3037
  diag2(`trigger "${id}": unknown or unevaluable predicate \u2014 skipped`);
2855
3038
  }
2856
3039
  const rendered = renderPretool(
2857
- dedupePretoolMatches(hookStateDir(), event.session_id, fired),
3040
+ dedupePretoolMatches(hookStateDir(), event.session_id, fired, !invocation.dryRun),
2858
3041
  format
2859
3042
  );
2860
3043
  for (const line of rendered.stderr) {
@@ -2871,6 +3054,10 @@ async function runHookPosttool() {
2871
3054
  try {
2872
3055
  const invocation = hookInvocation();
2873
3056
  if (invocation === null) return;
3057
+ if (invocation.dryRun) {
3058
+ diag2("--dry-run/--explain support prompt and pretool events only");
3059
+ return;
3060
+ }
2874
3061
  let format = "neutral";
2875
3062
  if (invocation.harness === "claude") {
2876
3063
  format = "claude";
@@ -2883,6 +3070,7 @@ async function runHookPosttool() {
2883
3070
  return;
2884
3071
  }
2885
3072
  if (!vaultPathTouched(event.tool_input, invocation.vaultRoot, event.cwd)) return;
3073
+ const config = await loadVaultConfig(invocation.vaultRoot);
2886
3074
  const findings = await validateVault(invocation.vaultRoot);
2887
3075
  let synced = false;
2888
3076
  if (!hasErrors(findings)) {
@@ -2893,7 +3081,7 @@ async function runHookPosttool() {
2893
3081
  diag2(`sync failed: ${err instanceof Error ? err.message : String(err)}`);
2894
3082
  }
2895
3083
  }
2896
- const rendered = renderPosttool(findings, synced, format);
3084
+ const rendered = renderPosttool(findings, synced, format, config.builtin_hooks?.resync ?? {});
2897
3085
  if (rendered !== null) {
2898
3086
  console.log(rendered);
2899
3087
  }
@@ -2905,6 +3093,10 @@ async function runHookStop() {
2905
3093
  try {
2906
3094
  const invocation = hookInvocation();
2907
3095
  if (invocation === null) return;
3096
+ if (invocation.dryRun) {
3097
+ diag2("--dry-run/--explain support prompt and pretool events only");
3098
+ return;
3099
+ }
2908
3100
  const event = parseStopEvent(await readStdin());
2909
3101
  if (event === null) {
2910
3102
  diag2("stdin is not a stop event ({session_id})");
@@ -2914,9 +3106,12 @@ async function runHookStop() {
2914
3106
  const config = await loadVaultConfig(invocation.vaultRoot);
2915
3107
  const scope = invocation.scope ?? resolveScope(config, event.cwd);
2916
3108
  if (scope === void 0) return;
2917
- const rendered = renderStop(await validateVault(invocation.vaultRoot));
3109
+ const rendered = renderStop(
3110
+ await validateVault(invocation.vaultRoot),
3111
+ config.builtin_hooks?.["handoff-gate"]?.reason
3112
+ );
2918
3113
  if (rendered === null) return;
2919
- const fired = dedupeMatches(hookStateDir(), event.session_id, [{ id: "stop-validate-gate" }]);
3114
+ const fired = dedupeMatches(hookStateDir(), event.session_id, [{ id: "handoff-gate" }]);
2920
3115
  if (fired.length === 0) return;
2921
3116
  console.log(rendered);
2922
3117
  } catch (err) {
@@ -2934,7 +3129,7 @@ async function readStdin() {
2934
3129
  function diag2(message) {
2935
3130
  console.error(`kmd hook: ${message}`);
2936
3131
  }
2937
- var DEFAULT_TRIGGERS, SESSION_STATE_MAX_AGE_MS, KIRO_IDE_BUCKET_MS, ALL_SCOPES_KEY, PATCH_FILE_RE;
3132
+ var DEFAULT_TRIGGERS, SESSION_STATE_MAX_AGE_MS, KIRO_IDE_BUCKET_MS, ALL_SCOPES_KEY, PATCH_FILE_RE, COMMAND_TOKEN_RE, PATHISH_RE, RESYNC_REASON, RESYNC_TEXT, HANDOFF_GATE_REASON;
2938
3133
  var init_hook = __esm({
2939
3134
  "../cli/src/hook.ts"() {
2940
3135
  "use strict";
@@ -2948,6 +3143,11 @@ var init_hook = __esm({
2948
3143
  KIRO_IDE_BUCKET_MS = 30 * 60 * 1e3;
2949
3144
  ALL_SCOPES_KEY = "_all";
2950
3145
  PATCH_FILE_RE = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
3146
+ COMMAND_TOKEN_RE = /"([^"]*)"|'([^']*)'|(\S+)/g;
3147
+ PATHISH_RE = /[/*$]|\.(?:md|base|canvas|ya?ml)$/i;
3148
+ RESYNC_REASON = "Edit landed; the index sync is held until these validate errors are fixed";
3149
+ RESYNC_TEXT = "kmd sync failed \u2014 index not updated; see hook stderr";
3150
+ HANDOFF_GATE_REASON = "Validate errors are outstanding and the index sync is held \u2014 fix them, let the resync run, then finish";
2951
3151
  }
2952
3152
  });
2953
3153