@bartolli/kmd 0.7.0 → 0.9.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, 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";
@@ -177,6 +177,10 @@ var init_vault_config = __esm({
177
177
  than: z.array(z.string().min(1)).min(1)
178
178
  })
179
179
  ]);
180
+ DedupSchema = z.union([
181
+ z.enum(["session", "never"]),
182
+ z.strictObject({ minutes: z.number().int().positive() })
183
+ ]);
180
184
  TriggerSchema = z.strictObject({
181
185
  id: z.string().min(1).describe("Unique per scope list; duplicates keep the first occurrence."),
182
186
  on: z.enum(["prompt", "pretool"]),
@@ -190,7 +194,10 @@ var init_vault_config = __esm({
190
194
  "Precondition \u2014 the gate fires only when it is UNMET. newer-than: the newest page matching fresh must carry frontmatter updated at or after the newest matching than."
191
195
  ),
192
196
  text: z.string().optional().describe("Required for inject and warn \u2014 the line emitted."),
193
- reason: z.string().optional().describe("Required for block \u2014 the denial the agent reads.")
197
+ reason: z.string().optional().describe("Required for block \u2014 the denial the agent reads."),
198
+ dedup: DedupSchema.optional().describe(
199
+ "Re-fire policy: session (default, once per session), never, or {minutes: N} for at most once per bucket. Rejected on block triggers \u2014 blocks are dedup-exempt."
200
+ )
194
201
  }).superRefine((trigger, ctx) => {
195
202
  if (trigger.on === "prompt" && !trigger.keywords?.length && !trigger.intent?.length) {
196
203
  ctx.addIssue({
@@ -210,6 +217,12 @@ var init_vault_config = __esm({
210
217
  message: `trigger "${trigger.id}": files applies to pretool triggers only`
211
218
  });
212
219
  }
220
+ if (trigger.enforce === "block" && trigger.dedup !== void 0) {
221
+ ctx.addIssue({
222
+ code: "custom",
223
+ message: `block trigger "${trigger.id}" may not set dedup \u2014 blocks fire on every matching event`
224
+ });
225
+ }
213
226
  if (trigger.enforce === "block" ? trigger.reason === void 0 : trigger.text === void 0) {
214
227
  ctx.addIssue({
215
228
  code: "custom",
@@ -228,6 +241,15 @@ var init_vault_config = __esm({
228
241
  }
229
242
  });
230
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
+ });
231
253
  VaultConfigSchema = z.strictObject({
232
254
  scopes: z.record(z.string(), ScopeSchema).describe("Scope name \u2192 entry; key = directory name under projects/."),
233
255
  kinds: z.array(KindEntrySchema).describe(
@@ -246,6 +268,9 @@ var init_vault_config = __esm({
246
268
  triggers: TriggersSchema.optional().describe(
247
269
  'Full-replace of the trigger base per scope \u2014 escape hatch. "_all" is reserved for triggers_extra.'
248
270
  ),
271
+ builtin_hooks: BuiltinHooksSchema.optional().describe(
272
+ "Message overrides for the fixed-function hooks (resync, handoff-gate) by public id."
273
+ ),
249
274
  triggers_extra: TriggersSchema.optional().describe(
250
275
  'Appended per scope after the engine defaults; the reserved "_all" key fires in every session.'
251
276
  )
@@ -1737,7 +1762,7 @@ var init_authoring = __esm({
1737
1762
  ].join("\n");
1738
1763
  DEFAULT_SYNC_PROTOCOL = [
1739
1764
  "Edit the smallest set of files that reflects the change. A milestone tick is plan-only; don't cascade to index.md unless phase or status changed. Controlled-vocabulary edits (`vault.yaml`) need explicit user approval.",
1740
- "After editing wiki pages, run `kmd validate` and fix findings before `kmd sync` \u2014 it checks frontmatter shape, vocabulary membership, and link integrity."
1765
+ "Harnesses with the posttool hook validate and sync automatically on every vault write. Check `kmd config`: if the `synced` line did not advance past your edits, the hook is not wired \u2014 run `kmd validate`, fix findings, then `kmd sync`."
1741
1766
  ].join("\n");
1742
1767
  CANONICAL_STATUS_FLOW = ["draft", "active", "superseded", "archived"];
1743
1768
  }
@@ -2343,12 +2368,15 @@ __export(hook_exports, {
2343
2368
  matchPromptTriggers: () => matchPromptTriggers,
2344
2369
  parsePretoolEvent: () => parsePretoolEvent,
2345
2370
  parsePromptEvent: () => parsePromptEvent,
2371
+ parseStopEvent: () => parseStopEvent,
2346
2372
  renderPosttool: () => renderPosttool,
2347
2373
  renderPretool: () => renderPretool,
2374
+ renderStop: () => renderStop,
2348
2375
  resolveScope: () => resolveScope,
2349
2376
  runHookPosttool: () => runHookPosttool,
2350
2377
  runHookPretool: () => runHookPretool,
2351
2378
  runHookPrompt: () => runHookPrompt,
2379
+ runHookStop: () => runHookStop,
2352
2380
  vaultPathTouched: () => vaultPathTouched
2353
2381
  });
2354
2382
  import { mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync, rmSync as rmSync2, statSync, writeFileSync } from "node:fs";
@@ -2455,7 +2483,11 @@ function matchPromptTriggers(prompt, triggers) {
2455
2483
  hit = trigger.intent.some((pattern) => new RegExp(pattern, "i").test(prompt));
2456
2484
  }
2457
2485
  if (hit) {
2458
- matches.push({ id: trigger.id, text: trigger.text });
2486
+ matches.push({
2487
+ id: trigger.id,
2488
+ text: trigger.text,
2489
+ ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2490
+ });
2459
2491
  }
2460
2492
  }
2461
2493
  } finally {
@@ -2521,7 +2553,7 @@ function matchPretoolTriggers(toolName, toolInput, triggers, cwd) {
2521
2553
  if (!new RegExp(trigger.args_match).test(serialized)) continue;
2522
2554
  }
2523
2555
  if (trigger.files !== void 0 && trigger.files.length > 0) {
2524
- const candidates = pathCandidates(toolInput, cwd);
2556
+ const candidates = [...pathCandidates(toolInput, cwd), ...patchPaths(toolInput)];
2525
2557
  const hit = trigger.files.some((glob) => {
2526
2558
  const regex = globToRegExp(glob);
2527
2559
  return candidates.some((candidate) => regex.test(candidate));
@@ -2534,7 +2566,8 @@ function matchPretoolTriggers(toolName, toolInput, triggers, cwd) {
2534
2566
  id: trigger.id,
2535
2567
  enforce: trigger.enforce,
2536
2568
  text,
2537
- ...trigger.when !== void 0 && { when: trigger.when }
2569
+ ...trigger.when !== void 0 && { when: trigger.when },
2570
+ ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2538
2571
  });
2539
2572
  }
2540
2573
  return matches;
@@ -2638,25 +2671,46 @@ function vaultPathTouched(toolInput, vaultRoot2, cwd) {
2638
2671
  return absolute === root || absolute.startsWith(`${root}/`);
2639
2672
  });
2640
2673
  }
2641
- function renderPosttool(findings, synced, format) {
2674
+ function renderPosttool(findings, synced, format, messages = {}) {
2642
2675
  if (findings.length === 0 && synced) return null;
2643
2676
  const lines = findings.map((f) => `${f.severity}: ${f.path} [${f.rule}] ${f.message}`);
2644
2677
  if (format === "claude") {
2645
2678
  if (hasErrors(findings)) {
2646
2679
  return JSON.stringify({
2647
2680
  decision: "block",
2648
- reason: `kmd validate failed \u2014 fix before the index syncs:
2681
+ reason: `${messages.reason ?? RESYNC_REASON}:
2649
2682
  ${lines.join("\n")}`
2650
2683
  });
2651
2684
  }
2652
2685
  const hookSpecificOutput = { hookEventName: "PostToolUse" };
2653
2686
  const notes = [...lines];
2654
- if (!synced) notes.push("kmd sync failed \u2014 index not updated; see hook stderr");
2687
+ if (!synced) notes.push(messages.text ?? RESYNC_TEXT);
2655
2688
  hookSpecificOutput.additionalContext = notes.join("\n");
2656
2689
  return JSON.stringify({ hookSpecificOutput });
2657
2690
  }
2658
2691
  return JSON.stringify({ findings, synced });
2659
2692
  }
2693
+ function parseStopEvent(raw) {
2694
+ const fields = eventFields(raw);
2695
+ if (fields === null) return null;
2696
+ const { session_id, cwd, stop_hook_active } = fields;
2697
+ if (typeof session_id !== "string") return null;
2698
+ return {
2699
+ session_id,
2700
+ ...typeof cwd === "string" && { cwd },
2701
+ ...typeof stop_hook_active === "boolean" && { stop_hook_active }
2702
+ };
2703
+ }
2704
+ function renderStop(findings, reason) {
2705
+ const errors = findings.filter((finding) => finding.severity === "error");
2706
+ if (errors.length === 0) return null;
2707
+ const lines = errors.map((f) => `${f.severity}: ${f.path} [${f.rule}] ${f.message}`);
2708
+ return JSON.stringify({
2709
+ decision: "block",
2710
+ reason: `${reason ?? HANDOFF_GATE_REASON}:
2711
+ ${lines.join("\n")}`
2712
+ });
2713
+ }
2660
2714
  function dedupePretoolMatches(stateDir, sessionId, matches) {
2661
2715
  const blocks = matches.filter((match) => match.enforce === "block");
2662
2716
  const rest = matches.filter((match) => match.enforce !== "block");
@@ -2666,15 +2720,26 @@ function dedupePretoolMatches(stateDir, sessionId, matches) {
2666
2720
  function hookStateDir() {
2667
2721
  return join10(kmdHome(), "state", "hook");
2668
2722
  }
2669
- function dedupeMatches(stateDir, sessionId, matches) {
2723
+ function dedupeMatches(stateDir, sessionId, matches, now = Date.now()) {
2670
2724
  if (matches.length === 0) return [];
2671
2725
  const file = join10(stateDir, `${sessionId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
2672
2726
  const fired = readFired(file);
2673
- const fresh = matches.filter((match) => !fired.has(match.id));
2674
- if (fresh.length > 0) {
2727
+ const fresh = [];
2728
+ const record = [];
2729
+ for (const match of matches) {
2730
+ if (match.dedup === "never") {
2731
+ fresh.push(match);
2732
+ continue;
2733
+ }
2734
+ const key = typeof match.dedup === "object" ? `${match.id}@${Math.floor(now / (match.dedup.minutes * 6e4))}` : match.id;
2735
+ if (fired.has(key)) continue;
2736
+ fresh.push(match);
2737
+ record.push(key);
2738
+ }
2739
+ if (record.length > 0) {
2675
2740
  mkdirSync4(stateDir, { recursive: true });
2676
- for (const match of fresh) {
2677
- fired.add(match.id);
2741
+ for (const key of record) {
2742
+ fired.add(key);
2678
2743
  }
2679
2744
  writeFileSync(file, JSON.stringify([...fired]));
2680
2745
  pruneStale(stateDir, file);
@@ -2830,6 +2895,7 @@ async function runHookPosttool() {
2830
2895
  return;
2831
2896
  }
2832
2897
  if (!vaultPathTouched(event.tool_input, invocation.vaultRoot, event.cwd)) return;
2898
+ const config = await loadVaultConfig(invocation.vaultRoot);
2833
2899
  const findings = await validateVault(invocation.vaultRoot);
2834
2900
  let synced = false;
2835
2901
  if (!hasErrors(findings)) {
@@ -2840,7 +2906,7 @@ async function runHookPosttool() {
2840
2906
  diag2(`sync failed: ${err instanceof Error ? err.message : String(err)}`);
2841
2907
  }
2842
2908
  }
2843
- const rendered = renderPosttool(findings, synced, format);
2909
+ const rendered = renderPosttool(findings, synced, format, config.builtin_hooks?.resync ?? {});
2844
2910
  if (rendered !== null) {
2845
2911
  console.log(rendered);
2846
2912
  }
@@ -2848,6 +2914,31 @@ async function runHookPosttool() {
2848
2914
  diag2(err instanceof Error ? err.message : String(err));
2849
2915
  }
2850
2916
  }
2917
+ async function runHookStop() {
2918
+ try {
2919
+ const invocation = hookInvocation();
2920
+ if (invocation === null) return;
2921
+ const event = parseStopEvent(await readStdin());
2922
+ if (event === null) {
2923
+ diag2("stdin is not a stop event ({session_id})");
2924
+ return;
2925
+ }
2926
+ if (event.stop_hook_active === true) return;
2927
+ const config = await loadVaultConfig(invocation.vaultRoot);
2928
+ const scope = invocation.scope ?? resolveScope(config, event.cwd);
2929
+ if (scope === void 0) return;
2930
+ const rendered = renderStop(
2931
+ await validateVault(invocation.vaultRoot),
2932
+ config.builtin_hooks?.["handoff-gate"]?.reason
2933
+ );
2934
+ if (rendered === null) return;
2935
+ const fired = dedupeMatches(hookStateDir(), event.session_id, [{ id: "handoff-gate" }]);
2936
+ if (fired.length === 0) return;
2937
+ console.log(rendered);
2938
+ } catch (err) {
2939
+ diag2(err instanceof Error ? err.message : String(err));
2940
+ }
2941
+ }
2851
2942
  async function readStdin() {
2852
2943
  process.stdin.setEncoding("utf8");
2853
2944
  let input = "";
@@ -2859,7 +2950,7 @@ async function readStdin() {
2859
2950
  function diag2(message) {
2860
2951
  console.error(`kmd hook: ${message}`);
2861
2952
  }
2862
- var DEFAULT_TRIGGERS, SESSION_STATE_MAX_AGE_MS, KIRO_IDE_BUCKET_MS, ALL_SCOPES_KEY, PATCH_FILE_RE;
2953
+ var DEFAULT_TRIGGERS, SESSION_STATE_MAX_AGE_MS, KIRO_IDE_BUCKET_MS, ALL_SCOPES_KEY, PATCH_FILE_RE, RESYNC_REASON, RESYNC_TEXT, HANDOFF_GATE_REASON;
2863
2954
  var init_hook = __esm({
2864
2955
  "../cli/src/hook.ts"() {
2865
2956
  "use strict";
@@ -2873,6 +2964,9 @@ var init_hook = __esm({
2873
2964
  KIRO_IDE_BUCKET_MS = 30 * 60 * 1e3;
2874
2965
  ALL_SCOPES_KEY = "_all";
2875
2966
  PATCH_FILE_RE = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
2967
+ RESYNC_REASON = "Edit landed; the index sync is held until these validate errors are fixed";
2968
+ RESYNC_TEXT = "kmd sync failed \u2014 index not updated; see hook stderr";
2969
+ HANDOFF_GATE_REASON = "Validate errors are outstanding and the index sync is held \u2014 fix them, let the resync run, then finish";
2876
2970
  }
2877
2971
  });
2878
2972
 
@@ -2893,9 +2987,10 @@ commands:
2893
2987
  mcp [<vault-root>] start the stdio MCP server (default: $WIKI_VAULT)
2894
2988
  config [<vault-root>] print vault + index resolution; with no vault, list known vaults
2895
2989
  db reset [<vault-root>] delete the vault's index (default: $WIKI_VAULT)
2896
- hook <prompt|pretool|posttool> [<vault-root>] [--scope <s>] [--harness <claude|kiro-ide>] [--triggers <file>]
2990
+ hook <prompt|pretool|posttool|stop> [<vault-root>] [--scope <s>] [--harness <claude|kiro-ide>] [--triggers <file>]
2897
2991
  harness gate engine: JSON event on stdin, decision/context on stdout;
2898
- posttool auto-runs validate + sync after a vault write
2992
+ posttool auto-runs validate + sync after a vault write;
2993
+ stop blocks the handoff once while validate errors hold the sync
2899
2994
 
2900
2995
  options:
2901
2996
  --version print version
@@ -2970,11 +3065,14 @@ async function run() {
2970
3065
  } else if (sub === "posttool") {
2971
3066
  const { runHookPosttool: runHookPosttool2 } = await Promise.resolve().then(() => (init_hook(), hook_exports));
2972
3067
  await runHookPosttool2();
3068
+ } else if (sub === "stop") {
3069
+ const { runHookStop: runHookStop2 } = await Promise.resolve().then(() => (init_hook(), hook_exports));
3070
+ await runHookStop2();
2973
3071
  } else if (sub) {
2974
3072
  console.error(`kmd hook: unknown event: ${sub}`);
2975
3073
  } else {
2976
3074
  console.error(
2977
- "usage: kmd hook <prompt|pretool|posttool> [<vault-root>] [--scope <scope>] [--harness <claude|kiro-ide>]"
3075
+ "usage: kmd hook <prompt|pretool|posttool|stop> [<vault-root>] [--scope <scope>] [--harness <claude|kiro-ide>]"
2978
3076
  );
2979
3077
  process.exit(2);
2980
3078
  }