@bartolli/kmd 0.7.0 → 0.8.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, 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",
@@ -1737,7 +1750,7 @@ var init_authoring = __esm({
1737
1750
  ].join("\n");
1738
1751
  DEFAULT_SYNC_PROTOCOL = [
1739
1752
  "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."
1753
+ "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
1754
  ].join("\n");
1742
1755
  CANONICAL_STATUS_FLOW = ["draft", "active", "superseded", "archived"];
1743
1756
  }
@@ -2343,12 +2356,15 @@ __export(hook_exports, {
2343
2356
  matchPromptTriggers: () => matchPromptTriggers,
2344
2357
  parsePretoolEvent: () => parsePretoolEvent,
2345
2358
  parsePromptEvent: () => parsePromptEvent,
2359
+ parseStopEvent: () => parseStopEvent,
2346
2360
  renderPosttool: () => renderPosttool,
2347
2361
  renderPretool: () => renderPretool,
2362
+ renderStop: () => renderStop,
2348
2363
  resolveScope: () => resolveScope,
2349
2364
  runHookPosttool: () => runHookPosttool,
2350
2365
  runHookPretool: () => runHookPretool,
2351
2366
  runHookPrompt: () => runHookPrompt,
2367
+ runHookStop: () => runHookStop,
2352
2368
  vaultPathTouched: () => vaultPathTouched
2353
2369
  });
2354
2370
  import { mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync, rmSync as rmSync2, statSync, writeFileSync } from "node:fs";
@@ -2455,7 +2471,11 @@ function matchPromptTriggers(prompt, triggers) {
2455
2471
  hit = trigger.intent.some((pattern) => new RegExp(pattern, "i").test(prompt));
2456
2472
  }
2457
2473
  if (hit) {
2458
- matches.push({ id: trigger.id, text: trigger.text });
2474
+ matches.push({
2475
+ id: trigger.id,
2476
+ text: trigger.text,
2477
+ ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2478
+ });
2459
2479
  }
2460
2480
  }
2461
2481
  } finally {
@@ -2534,7 +2554,8 @@ function matchPretoolTriggers(toolName, toolInput, triggers, cwd) {
2534
2554
  id: trigger.id,
2535
2555
  enforce: trigger.enforce,
2536
2556
  text,
2537
- ...trigger.when !== void 0 && { when: trigger.when }
2557
+ ...trigger.when !== void 0 && { when: trigger.when },
2558
+ ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2538
2559
  });
2539
2560
  }
2540
2561
  return matches;
@@ -2657,6 +2678,27 @@ ${lines.join("\n")}`
2657
2678
  }
2658
2679
  return JSON.stringify({ findings, synced });
2659
2680
  }
2681
+ function parseStopEvent(raw) {
2682
+ const fields = eventFields(raw);
2683
+ if (fields === null) return null;
2684
+ const { session_id, cwd, stop_hook_active } = fields;
2685
+ if (typeof session_id !== "string") return null;
2686
+ return {
2687
+ session_id,
2688
+ ...typeof cwd === "string" && { cwd },
2689
+ ...typeof stop_hook_active === "boolean" && { stop_hook_active }
2690
+ };
2691
+ }
2692
+ function renderStop(findings) {
2693
+ const errors = findings.filter((finding) => finding.severity === "error");
2694
+ if (errors.length === 0) return null;
2695
+ const lines = errors.map((f) => `${f.severity}: ${f.path} [${f.rule}] ${f.message}`);
2696
+ return JSON.stringify({
2697
+ 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:
2699
+ ${lines.join("\n")}`
2700
+ });
2701
+ }
2660
2702
  function dedupePretoolMatches(stateDir, sessionId, matches) {
2661
2703
  const blocks = matches.filter((match) => match.enforce === "block");
2662
2704
  const rest = matches.filter((match) => match.enforce !== "block");
@@ -2666,15 +2708,26 @@ function dedupePretoolMatches(stateDir, sessionId, matches) {
2666
2708
  function hookStateDir() {
2667
2709
  return join10(kmdHome(), "state", "hook");
2668
2710
  }
2669
- function dedupeMatches(stateDir, sessionId, matches) {
2711
+ function dedupeMatches(stateDir, sessionId, matches, now = Date.now()) {
2670
2712
  if (matches.length === 0) return [];
2671
2713
  const file = join10(stateDir, `${sessionId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
2672
2714
  const fired = readFired(file);
2673
- const fresh = matches.filter((match) => !fired.has(match.id));
2674
- if (fresh.length > 0) {
2715
+ const fresh = [];
2716
+ const record = [];
2717
+ for (const match of matches) {
2718
+ if (match.dedup === "never") {
2719
+ fresh.push(match);
2720
+ continue;
2721
+ }
2722
+ const key = typeof match.dedup === "object" ? `${match.id}@${Math.floor(now / (match.dedup.minutes * 6e4))}` : match.id;
2723
+ if (fired.has(key)) continue;
2724
+ fresh.push(match);
2725
+ record.push(key);
2726
+ }
2727
+ if (record.length > 0) {
2675
2728
  mkdirSync4(stateDir, { recursive: true });
2676
- for (const match of fresh) {
2677
- fired.add(match.id);
2729
+ for (const key of record) {
2730
+ fired.add(key);
2678
2731
  }
2679
2732
  writeFileSync(file, JSON.stringify([...fired]));
2680
2733
  pruneStale(stateDir, file);
@@ -2848,6 +2901,28 @@ async function runHookPosttool() {
2848
2901
  diag2(err instanceof Error ? err.message : String(err));
2849
2902
  }
2850
2903
  }
2904
+ async function runHookStop() {
2905
+ try {
2906
+ const invocation = hookInvocation();
2907
+ if (invocation === null) return;
2908
+ const event = parseStopEvent(await readStdin());
2909
+ if (event === null) {
2910
+ diag2("stdin is not a stop event ({session_id})");
2911
+ return;
2912
+ }
2913
+ if (event.stop_hook_active === true) return;
2914
+ const config = await loadVaultConfig(invocation.vaultRoot);
2915
+ const scope = invocation.scope ?? resolveScope(config, event.cwd);
2916
+ if (scope === void 0) return;
2917
+ const rendered = renderStop(await validateVault(invocation.vaultRoot));
2918
+ if (rendered === null) return;
2919
+ const fired = dedupeMatches(hookStateDir(), event.session_id, [{ id: "stop-validate-gate" }]);
2920
+ if (fired.length === 0) return;
2921
+ console.log(rendered);
2922
+ } catch (err) {
2923
+ diag2(err instanceof Error ? err.message : String(err));
2924
+ }
2925
+ }
2851
2926
  async function readStdin() {
2852
2927
  process.stdin.setEncoding("utf8");
2853
2928
  let input = "";
@@ -2893,9 +2968,10 @@ commands:
2893
2968
  mcp [<vault-root>] start the stdio MCP server (default: $WIKI_VAULT)
2894
2969
  config [<vault-root>] print vault + index resolution; with no vault, list known vaults
2895
2970
  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>]
2971
+ hook <prompt|pretool|posttool|stop> [<vault-root>] [--scope <s>] [--harness <claude|kiro-ide>] [--triggers <file>]
2897
2972
  harness gate engine: JSON event on stdin, decision/context on stdout;
2898
- posttool auto-runs validate + sync after a vault write
2973
+ posttool auto-runs validate + sync after a vault write;
2974
+ stop blocks the handoff once while validate errors hold the sync
2899
2975
 
2900
2976
  options:
2901
2977
  --version print version
@@ -2970,11 +3046,14 @@ async function run() {
2970
3046
  } else if (sub === "posttool") {
2971
3047
  const { runHookPosttool: runHookPosttool2 } = await Promise.resolve().then(() => (init_hook(), hook_exports));
2972
3048
  await runHookPosttool2();
3049
+ } else if (sub === "stop") {
3050
+ const { runHookStop: runHookStop2 } = await Promise.resolve().then(() => (init_hook(), hook_exports));
3051
+ await runHookStop2();
2973
3052
  } else if (sub) {
2974
3053
  console.error(`kmd hook: unknown event: ${sub}`);
2975
3054
  } else {
2976
3055
  console.error(
2977
- "usage: kmd hook <prompt|pretool|posttool> [<vault-root>] [--scope <scope>] [--harness <claude|kiro-ide>]"
3056
+ "usage: kmd hook <prompt|pretool|posttool|stop> [<vault-root>] [--scope <scope>] [--harness <claude|kiro-ide>]"
2978
3057
  );
2979
3058
  process.exit(2);
2980
3059
  }