@bartolli/kmd 0.6.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
@@ -116,7 +116,7 @@ CREATE TABLE IF NOT EXISTS meta (
116
116
  }
117
117
  });
118
118
 
119
- // ../cli/src/config.ts
119
+ // ../db/src/vault-config.ts
120
120
  import { readFile } from "node:fs/promises";
121
121
  import { join as join2 } from "node:path";
122
122
  import { parse } from "yaml";
@@ -131,6 +131,9 @@ function isValidRegex(pattern) {
131
131
  function kindName(entry) {
132
132
  return typeof entry === "string" ? entry : entry.name;
133
133
  }
134
+ function configJsonSchema() {
135
+ return z.toJSONSchema(VaultConfigSchema, { target: "draft-7" });
136
+ }
134
137
  async function loadVaultConfig(vaultRoot2) {
135
138
  const path = join2(vaultRoot2, "vault.yaml");
136
139
  let raw;
@@ -147,43 +150,54 @@ ${issues}`);
147
150
  }
148
151
  return parsed.data;
149
152
  }
150
- var ScopeSchema, KindEntrySchema, WhenSchema, TriggerSchema, TriggersSchema, VaultConfigSchema, BUILT_IN_KINDS;
151
- var init_config = __esm({
152
- "../cli/src/config.ts"() {
153
+ var ScopeSchema, KindEntrySchema, WhenSchema, DedupSchema, TriggerSchema, TriggersSchema, VaultConfigSchema, BUILT_IN_KINDS;
154
+ var init_vault_config = __esm({
155
+ "../db/src/vault-config.ts"() {
153
156
  "use strict";
154
- ScopeSchema = z.object({
155
- repo: z.string().optional(),
156
- methodology: z.string().optional(),
157
- status: z.string()
157
+ ScopeSchema = z.strictObject({
158
+ repo: z.string().optional().describe(
159
+ "Consumer repo path (~ expands). Load-bearing for kmd hook: the active scope resolves by matching the session cwd against it."
160
+ ),
161
+ methodology: z.string().optional().describe("Must appear in the methodologies list."),
162
+ status: z.string().describe("Free string; keep within statuses by convention.")
158
163
  });
159
164
  KindEntrySchema = z.union([
160
165
  z.string(),
161
- z.object({
166
+ z.strictObject({
162
167
  name: z.string(),
163
- signal: z.string(),
164
- where: z.string()
168
+ signal: z.string().describe("When to pick this kind."),
169
+ where: z.string().describe("Path pattern pages of this kind follow.")
165
170
  })
166
171
  ]);
167
172
  WhenSchema = z.union([
168
173
  z.string(),
169
- z.object({
174
+ z.strictObject({
170
175
  name: z.enum(["newer-than"]),
171
176
  fresh: z.array(z.string().min(1)).min(1),
172
177
  than: z.array(z.string().min(1)).min(1)
173
178
  })
174
179
  ]);
175
- TriggerSchema = z.object({
176
- id: z.string().min(1),
180
+ DedupSchema = z.union([
181
+ z.enum(["session", "never"]),
182
+ z.strictObject({ minutes: z.number().int().positive() })
183
+ ]);
184
+ TriggerSchema = z.strictObject({
185
+ id: z.string().min(1).describe("Unique per scope list; duplicates keep the first occurrence."),
177
186
  on: z.enum(["prompt", "pretool"]),
178
- enforce: z.enum(["inject", "warn", "block"]),
179
- keywords: z.array(z.string().min(1)).optional(),
180
- intent: z.array(z.string()).optional(),
181
- tool: z.string().optional(),
182
- args_match: z.string().optional(),
183
- files: z.array(z.string().min(1)).optional(),
184
- when: WhenSchema.optional(),
185
- text: z.string().optional(),
186
- reason: z.string().optional()
187
+ enforce: z.enum(["inject", "warn", "block"]).describe("inject: context line \xB7 warn: stderr \xB7 block: deny with reason."),
188
+ keywords: z.array(z.string().min(1)).optional().describe("Word-boundary, porter-stemmed match. Prompt triggers need keywords or intent."),
189
+ intent: z.array(z.string()).optional().describe("Case-insensitive regexes over the raw prompt \u2014 the stemming escape hatch."),
190
+ tool: z.string().optional().describe("Exact tool name; pretool matchers AND-compose."),
191
+ args_match: z.string().optional().describe("Regex over the serialized tool input."),
192
+ files: z.array(z.string().min(1)).optional().describe("Globs against the paths the tool touches; pretool triggers only."),
193
+ when: WhenSchema.optional().describe(
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."
195
+ ),
196
+ text: z.string().optional().describe("Required for inject and warn \u2014 the line emitted."),
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
+ )
187
201
  }).superRefine((trigger, ctx) => {
188
202
  if (trigger.on === "prompt" && !trigger.keywords?.length && !trigger.intent?.length) {
189
203
  ctx.addIssue({
@@ -203,6 +217,12 @@ var init_config = __esm({
203
217
  message: `trigger "${trigger.id}": files applies to pretool triggers only`
204
218
  });
205
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
+ }
206
226
  if (trigger.enforce === "block" ? trigger.reason === void 0 : trigger.text === void 0) {
207
227
  ctx.addIssue({
208
228
  code: "custom",
@@ -221,22 +241,28 @@ var init_config = __esm({
221
241
  }
222
242
  });
223
243
  TriggersSchema = z.record(z.string(), z.array(TriggerSchema));
224
- VaultConfigSchema = z.object({
225
- scopes: z.record(z.string(), ScopeSchema),
226
- kinds: z.array(KindEntrySchema),
227
- statuses: z.array(z.string()),
228
- methodologies: z.array(z.string()),
229
- tags: z.object({
230
- canonical: z.array(z.string()),
231
- aliases: z.record(z.string(), z.string())
244
+ VaultConfigSchema = z.strictObject({
245
+ scopes: z.record(z.string(), ScopeSchema).describe("Scope name \u2192 entry; key = directory name under projects/."),
246
+ kinds: z.array(KindEntrySchema).describe(
247
+ "Page kind vocabulary; validate-enforced. Object form adds a kind-selector row to wiki://authoring."
248
+ ),
249
+ statuses: z.array(z.string()).describe("Page status vocabulary; validate-enforced."),
250
+ methodologies: z.array(z.string()).describe("Methodology vocabulary for pages and scope entries."),
251
+ tags: z.strictObject({
252
+ canonical: z.array(z.string()).describe("Approved tags."),
253
+ aliases: z.record(z.string(), z.string()).describe("Alias \u2192 canonical; validate warns on alias use.")
232
254
  }),
233
- authoring_rules: z.string().optional(),
234
- authoring_rules_extra: z.string().optional(),
235
- sync_protocol: z.string().optional(),
236
- sync_protocol_extra: z.string().optional(),
237
- triggers: TriggersSchema.optional(),
238
- triggers_extra: TriggersSchema.optional()
239
- }).superRefine((config, ctx) => {
255
+ authoring_rules: z.string().optional().describe("Replaces the served \xA7 Authoring rules entirely \u2014 escape hatch."),
256
+ authoring_rules_extra: z.string().optional().describe("Appended after the served \xA7 Authoring rules."),
257
+ sync_protocol: z.string().optional().describe("Replaces the served \xA7 Resync protocol entirely \u2014 escape hatch."),
258
+ sync_protocol_extra: z.string().optional().describe("Appended after the served \xA7 Resync protocol."),
259
+ triggers: TriggersSchema.optional().describe(
260
+ 'Full-replace of the trigger base per scope \u2014 escape hatch. "_all" is reserved for triggers_extra.'
261
+ ),
262
+ triggers_extra: TriggersSchema.optional().describe(
263
+ 'Appended per scope after the engine defaults; the reserved "_all" key fires in every session.'
264
+ )
265
+ }).describe("kmd vault.yaml \u2014 controlled vocabulary and gate triggers.").superRefine((config, ctx) => {
240
266
  for (const [name, scope] of Object.entries(config.scopes)) {
241
267
  if (scope.methodology !== void 0 && !config.methodologies.includes(scope.methodology)) {
242
268
  ctx.addIssue({
@@ -310,11 +336,287 @@ var init_frontmatter = __esm({
310
336
  }
311
337
  });
312
338
 
339
+ // ../cli/src/init-templates.ts
340
+ var VAULT_TEMPLATES;
341
+ var init_init_templates = __esm({
342
+ "../cli/src/init-templates.ts"() {
343
+ "use strict";
344
+ VAULT_TEMPLATES = {
345
+ "note.md": '---\ntitle: {{title}}\ntags: []\ncreated: "{{date}}"\nupdated: {{date}}\n---\n\n# {{title}}\n',
346
+ "project-adr.md": '---\ntitle: {{title}}\nkind: adr\nscope:\nstatus: active\nsummary:\ntags: []\nsupersedes:\nsuperseded_by:\nsources: []\ncreated: "{{date}}"\nupdated: {{date}}\n---\n\n# {{title}}\n\n## Status\n\n> [!info] Active\n\n## Context\n\nThe forces in tension \u2014 what made this a decision rather than an\nobvious step. Name the constraints; skip the history recap.\n\n## Decision\n\nWhat was decided, precisely. Predicates only \u2014 falsifiable, not\naspirational.\n\n## Rationale\n\nWhy this side won: which alternatives were live, and what killed each.\nAn ADR with no rejected alternative is a spec in disguise.\n\n## Consequences\n\nWhat this commits the project to going forward \u2014 including the costs\naccepted, not just the benefits claimed.\n\n## Links\n\n-\n',
347
+ "project-index.md": `---
348
+ title: {{title}}
349
+ kind: project
350
+ methodology: sdd
351
+ phase: 1
352
+ repo:
353
+ scope:
354
+ status: active
355
+ tags: []
356
+ summary:
357
+ created: "{{date}}"
358
+ updated: {{date}}
359
+ ---
360
+
361
+ # {{title}}
362
+
363
+ ## Summary
364
+
365
+ One paragraph. What this project is and what it delivers.
366
+
367
+ ## Current Phase
368
+
369
+ What's in flight right now. 1\u20133 sentences.
370
+
371
+ ## Links
372
+
373
+ - [[projects/{{title}}/primer]]
374
+
375
+ ## Sources
376
+
377
+ -
378
+ `,
379
+ "project-ops.md": `---
380
+ title: {{title}}
381
+ kind: ops
382
+ scope:
383
+ status: active
384
+ summary:
385
+ tags: []
386
+ sources: []
387
+ created: "{{date}}"
388
+ updated: {{date}}
389
+ ---
390
+
391
+ # {{title}}
392
+
393
+ ## Summary
394
+
395
+ What this runbook operates and when it runs.
396
+
397
+ ## Context
398
+
399
+ What triggers this flow; what breaks when it's skipped.
400
+
401
+ ## Details
402
+
403
+ Numbered steps executable without asking: exact commands, expected
404
+ output, failure modes and their recovery. Predicates only \u2014 if a step
405
+ needs judgment, say whose.
406
+
407
+ ## Links
408
+
409
+ -
410
+
411
+ ## Sources
412
+
413
+ -
414
+ `,
415
+ "project-plan.md": `---
416
+ title: {{title}}
417
+ kind: plan
418
+ scope:
419
+ status: active
420
+ summary:
421
+ tags: []
422
+ created: "{{date}}"
423
+ updated: {{date}}
424
+ ---
425
+
426
+ # {{title}}
427
+
428
+ ## Goal
429
+
430
+ One sentence: what this phase delivers. If it needs two, it's two
431
+ plans.
432
+
433
+ ## Scope
434
+
435
+ What's in and \u2014 more important \u2014 what's out. Out-of-scope lines stop
436
+ scope creep better than in-scope lines define it.
437
+
438
+ ## Milestones
439
+
440
+ Checkable outcomes, not activities. Tick here; don't cascade ticks to
441
+ index.md unless phase or status changed.
442
+
443
+ 1.
444
+ 2.
445
+
446
+ ## Dependencies
447
+
448
+ -
449
+
450
+ ## Status Log
451
+
452
+ Dated one-liners, newest first. This is the only history surface \u2014
453
+ primer and index stay clean.
454
+
455
+ - {{date}}: Phase started.
456
+ `,
457
+ "project-primer.md": "---\ncreated: \"{{date}}\"\nupdated: {{date}}\n---\n\n# Primer\n\n## Current Focus\n\nWhat we're working on right now. 1\u20133 sentences. Not a history log \u2014\nprogress lives in plan checkboxes; history lives in git.\n\n## Open Questions\n\nCurrently-open questions only. Strip on resolution; reflect the\nresolution in the relevant ADR or spec, not here.\n\n-\n\n## Blocked On\n\nNothing currently. List only items genuinely blocking forward\nprogress.\n\n## Load-bearing invariants (optional)\n\nFacts not derivable from code or commits \u2014 pipeline orderings, sync\nconstraints, file-location rationales. The kind of fact a fresh-context\nagent would otherwise re-derive incorrectly. Durable across sessions.\n\n## Read Order (optional)\n\n\u22645 entries. Wiki/doc pointers \u2014 what to read to orient. Each entry\none line: `[[wikilink]] \u2014 why`.\n\n1.\n\n## Working set (optional)\n\n\u226410 entries. File pointers (code, fixtures, configs) for the\nimmediate Next Steps, with one-line context. Lets a fresh-context\nagent skip the discovery loop. Task-scoped \u2014 refresh when focus\nshifts. Most valuable when transitioning phases or starting a new\nfeature; can be skipped mid-slice when the immediate context already\ncarries it.\n\n- `path/to/file` \u2014 why it's relevant for the next steps.\n",
458
+ "project-spec.md": `---
459
+ title: {{title}}
460
+ kind: spec
461
+ scope:
462
+ status: active
463
+ summary:
464
+ tags: []
465
+ sources: []
466
+ created: "{{date}}"
467
+ updated: {{date}}
468
+ ---
469
+
470
+ # {{title}}
471
+
472
+ ## Summary
473
+
474
+ One paragraph, in predicates \u2014 the system's current shape, not its
475
+ history. If you're recording a *choice* between alternatives, stop:
476
+ that's an ADR.
477
+
478
+ ## Context
479
+
480
+ The question this spec answers and who asks it. One paragraph, no
481
+ narrative arc.
482
+
483
+ ## Details
484
+
485
+ The actual contract: inputs, outputs, invariants, failure modes. Use
486
+ subsections per concern. Present tense \u2014 the spec describes what IS
487
+ and must match current code at every commit.
488
+
489
+ ## Links
490
+
491
+ -
492
+
493
+ ## Sources
494
+
495
+ -
496
+ `,
497
+ "project-story.md": '---\ntitle: {{title}}\nkind: story\nscope:\nparent:\nstatus: active\ntriage_state: needs-triage\ncategory: enhancement\nblocked_by: []\ntags: []\nsources: []\ncreated: "{{date}}"\nupdated: {{date}}\n---\n\n# {{title}}\n\n## User Story\n\nAs a {{actor}}, I want {{capability}}, so that {{benefit}}.\n\n## Scenarios\n\nScenarios are the test specification \u2014 each becomes a failing test\nbefore implementation. Write observable outcomes, not implementation\nsteps.\n\n**Scenario: {{scenario name}}**\n- Given {{precondition}}\n- When {{action}}\n- Then {{expected outcome}}\n\n**Scenario: {{edge case name}}**\n- Given {{precondition}}\n- When {{action}}\n- Then {{expected outcome}}\n\n## Slices\n\nVertical tracer bullets: each slice ships signature + implementation +\ntests + wiring, independently committable. `AFK` = agent works alone;\n`HITL` = human in the loop.\n\n- [ ] **Slice 1** \u2014 {{description}} \xB7 `AFK` \xB7 [[spec-{{topic}}]]\n- [ ] **Slice 2** \u2014 {{description}} \xB7 `HITL` \xB7 [[adr-{{decision}}]]\n\n## References\n\n- [[spec-{{related-spec}}]]\n- [[adr-{{related-adr}}]]\n',
498
+ "research-article.md": '---\ntitle: {{title}}\nkind: article\ntopic:\nstatus: active\nsummary:\ntags: []\nsources: []\ncreated: "{{date}}"\nupdated: {{date}}\n---\n\n# {{title}}\n\n## Summary\n\nOne paragraph. What this page covers and why it matters.\n\n## Context\n\nWhy this page exists. The problem or question it addresses.\n\n## Details\n\nThe actual content. Use subsections as needed.\n\n## Links\n\n-\n\n## Sources\n\n-\n',
499
+ "research-index.md": '---\ntitle: {{title}}\nkind: topic\nstatus: active\nsummary:\nconfidence: medium\nsource_count: 0\ntags: []\ncreated: "{{date}}"\nupdated: {{date}}\n---\n\n# {{title}}\n\n## Summary\n\nOne paragraph. What this research topic covers.\n\n## Confidence\n\nWhy the confidence level is what it is. What would raise or lower it.\n\n## Pages\n\n-\n\n## Sources\n\n-\n',
500
+ "research-src.md": '---\ntitle: {{title}}\nkind: src\ntopic:\nstatus: active\nsummary:\ntags: []\nsource_url:\ncreated: "{{date}}"\nupdated: {{date}}\n---\n\n# {{title}}\n\n## Summary\n\nWhat this source is. Author, origin, year.\n\n## Key Points\n\n-\n\n## Quotes\n\n>\n\n## Relevance\n\nWhy this source matters to the topic.\n\n## Citation\n\n`{author, year, title, url}`\n'
501
+ };
502
+ }
503
+ });
504
+
505
+ // ../cli/src/init.ts
506
+ import { mkdir, readdir, readFile as readFile2, writeFile } from "node:fs/promises";
507
+ import { join as join3, resolve as resolve2 } from "node:path";
508
+ import { stringify } from "yaml";
509
+ async function refreshSchemaFile(root) {
510
+ const path = join3(root, SCHEMA_FILE);
511
+ const next = `${JSON.stringify(configJsonSchema(), null, 2)}
512
+ `;
513
+ try {
514
+ if (await readFile2(path, "utf8") === next) return false;
515
+ } catch {
516
+ }
517
+ await writeFile(path, next);
518
+ return true;
519
+ }
520
+ async function scaffoldVault(dir) {
521
+ const root = resolve2(dir);
522
+ let entries = [];
523
+ try {
524
+ entries = await readdir(root);
525
+ } catch (err) {
526
+ if (err.code !== "ENOENT") throw err;
527
+ }
528
+ if (entries.includes("vault.yaml")) {
529
+ throw new Error(`already a vault: ${root} (vault.yaml exists)`);
530
+ }
531
+ if (entries.length > 0) {
532
+ throw new Error(
533
+ `target is not empty: ${root}
534
+ found: ${entries.join(", ")}
535
+ delete it or pick another directory`
536
+ );
537
+ }
538
+ for (const domain of DOMAIN_DIRS) {
539
+ await mkdir(join3(root, domain), { recursive: true });
540
+ }
541
+ await mkdir(join3(root, "templates"), { recursive: true });
542
+ for (const [file, content] of Object.entries(VAULT_TEMPLATES)) {
543
+ await writeFile(join3(root, "templates", file), content);
544
+ }
545
+ await refreshSchemaFile(root);
546
+ await writeFile(join3(root, "vault.yaml"), SCHEMA_MODELINE + stringify(STARTER_CONFIG));
547
+ return root;
548
+ }
549
+ async function promptYesNo(question, input = process.stdin, output = process.stderr) {
550
+ const { createInterface } = await import("node:readline/promises");
551
+ const rl = createInterface({ input, output });
552
+ try {
553
+ const answer = await rl.question(question);
554
+ return /^y(es)?$/i.test(answer.trim());
555
+ } finally {
556
+ rl.close();
557
+ }
558
+ }
559
+ async function runInit(dir, yes = false) {
560
+ let target = dir;
561
+ if (!target) {
562
+ if (yes) {
563
+ target = ".";
564
+ } else if (process.stdin.isTTY) {
565
+ const ok = await promptYesNo(`initialize a vault in ${resolve2(".")}? [y/N] `);
566
+ if (!ok) {
567
+ console.error("init: aborted");
568
+ process.exit(1);
569
+ }
570
+ target = ".";
571
+ } else {
572
+ console.error("usage: kmd init <dir> (or --yes to scaffold the current directory)");
573
+ process.exit(2);
574
+ }
575
+ }
576
+ let root;
577
+ try {
578
+ root = await scaffoldVault(target);
579
+ } catch (err) {
580
+ console.error(`init: ${err instanceof Error ? err.message : err}`);
581
+ process.exit(1);
582
+ }
583
+ const templateCount = Object.keys(VAULT_TEMPLATES).length;
584
+ console.log(`initialized empty vault at ${root}
585
+
586
+ vault.yaml starter vocabulary \u2014 add your first scope under scopes:
587
+ vault.schema.json IDE validation via the yaml-language-server modeline
588
+ templates/ ${templateCount} built-in templates (served at wiki://template/...)
589
+ projects/ research/ notes/
590
+
591
+ next steps:
592
+ export WIKI_VAULT=${root}
593
+ kmd mcp ${root} # stdio MCP server (prime, search)`);
594
+ }
595
+ var SCHEMA_FILE, SCHEMA_MODELINE, STARTER_CONFIG, DOMAIN_DIRS;
596
+ var init_init = __esm({
597
+ "../cli/src/init.ts"() {
598
+ "use strict";
599
+ init_vault_config();
600
+ init_init_templates();
601
+ SCHEMA_FILE = "vault.schema.json";
602
+ SCHEMA_MODELINE = `# yaml-language-server: $schema=./${SCHEMA_FILE}
603
+ `;
604
+ STARTER_CONFIG = {
605
+ scopes: {},
606
+ kinds: ["project", "spec", "adr", "plan", "story", "ops", "topic", "article", "src", "note"],
607
+ statuses: ["draft", "active", "superseded", "archived"],
608
+ methodologies: ["sdd", "tdd", "hybrid"],
609
+ tags: { canonical: [], aliases: {} }
610
+ };
611
+ DOMAIN_DIRS = ["projects", "research", "notes"];
612
+ }
613
+ });
614
+
313
615
  // ../cli/src/sync.ts
314
616
  import { createHash as createHash2 } from "node:crypto";
315
617
  import { mkdirSync } from "node:fs";
316
- import { readdir, readFile as readFile2 } from "node:fs/promises";
317
- import { dirname, join as join3, relative, sep } from "node:path";
618
+ import { readdir as readdir2, readFile as readFile3 } from "node:fs/promises";
619
+ import { dirname, join as join4, relative, sep } from "node:path";
318
620
  import { z as z2 } from "zod";
319
621
  function loadEnv() {
320
622
  const parsed = EnvSchema.safeParse({
@@ -330,18 +632,18 @@ function loadEnv() {
330
632
  async function walkMarkdown(root, domain) {
331
633
  const out = [];
332
634
  async function recurse(dir) {
333
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
635
+ const entries = await readdir2(dir, { withFileTypes: true }).catch(() => null);
334
636
  if (!entries) return;
335
637
  for (const entry of entries) {
336
638
  if (entry.name.startsWith(".")) continue;
337
639
  if (entry.isDirectory()) {
338
- await recurse(join3(dir, entry.name));
640
+ await recurse(join4(dir, entry.name));
339
641
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
340
- out.push(join3(dir, entry.name));
642
+ out.push(join4(dir, entry.name));
341
643
  }
342
644
  }
343
645
  }
344
- await recurse(join3(root, domain));
646
+ await recurse(join4(root, domain));
345
647
  return out;
346
648
  }
347
649
  function toRelativePath(root, absolute) {
@@ -480,7 +782,7 @@ async function syncVault(vaultRoot2) {
480
782
  let skipped = 0;
481
783
  for (const file of files) {
482
784
  const path = toRelativePath(vaultRoot2, file);
483
- const raw = await readFile2(file, "utf8");
785
+ const raw = await readFile3(file, "utf8");
484
786
  const parsed = parseFrontmatter(raw);
485
787
  const fields = buildPageFields(path, raw, parsed, scopes);
486
788
  if (!fields) {
@@ -507,6 +809,9 @@ async function syncVault(vaultRoot2) {
507
809
  db.exec("INSERT INTO pages_fts(pages_fts) VALUES('rebuild')");
508
810
  setMeta(db, "vault_root", canonicalVaultRoot(vaultRoot2));
509
811
  setMeta(db, "last_synced", (/* @__PURE__ */ new Date()).toISOString());
812
+ if (await refreshSchemaFile(vaultRoot2)) {
813
+ console.error("sync: vault.schema.json refreshed to the running engine");
814
+ }
510
815
  return {
511
816
  changed,
512
817
  unchanged,
@@ -535,8 +840,9 @@ var init_sync = __esm({
535
840
  "../cli/src/sync.ts"() {
536
841
  "use strict";
537
842
  init_database();
538
- init_config();
843
+ init_vault_config();
539
844
  init_frontmatter();
845
+ init_init();
540
846
  EnvSchema = z2.object({
541
847
  WIKI_VAULT: z2.string().min(1)
542
848
  });
@@ -556,8 +862,8 @@ var init_sync = __esm({
556
862
  });
557
863
 
558
864
  // ../cli/src/validate.ts
559
- import { readFile as readFile3, stat } from "node:fs/promises";
560
- import { join as join4 } from "node:path";
865
+ import { readFile as readFile4, stat } from "node:fs/promises";
866
+ import { join as join5 } from "node:path";
561
867
  function hasIndexableTitle(data) {
562
868
  return typeof data.title === "string" && data.title.trim() !== "";
563
869
  }
@@ -915,7 +1221,7 @@ async function validateVault(root) {
915
1221
  const pages = [];
916
1222
  const linkPages = [];
917
1223
  for (const { relPath, abs } of files) {
918
- const raw = await readFile3(abs, "utf8");
1224
+ const raw = await readFile4(abs, "utf8");
919
1225
  findings.push(...validatePage(relPath, raw, cfg, refIndex));
920
1226
  try {
921
1227
  const parsed = parseFrontmatter(raw);
@@ -929,7 +1235,7 @@ async function validateVault(root) {
929
1235
  for (const name of customKindNames(cfg)) {
930
1236
  const file = `templates/${name}.md`;
931
1237
  try {
932
- await stat(join4(root, file));
1238
+ await stat(join5(root, file));
933
1239
  } catch {
934
1240
  findings.push({
935
1241
  path: file,
@@ -945,7 +1251,7 @@ var REQUIRED_FIELDS, TAG_OPTIONAL_KINDS, FOLDER_PATTERNS, UNIVERSAL_FLOOR;
945
1251
  var init_validate = __esm({
946
1252
  "../cli/src/validate.ts"() {
947
1253
  "use strict";
948
- init_config();
1254
+ init_vault_config();
949
1255
  init_frontmatter();
950
1256
  init_sync();
951
1257
  REQUIRED_FIELDS = {
@@ -1003,12 +1309,13 @@ __export(cli_exports, {
1003
1309
  resolveCli: () => resolveCli,
1004
1310
  runConfig: () => runConfig,
1005
1311
  runDbReset: () => runDbReset,
1312
+ runInit: () => runInit,
1006
1313
  runSyncCommand: () => runSyncCommand,
1007
1314
  runValidate: () => runValidate,
1008
1315
  vaultRoot: () => vaultRoot
1009
1316
  });
1010
1317
  import { existsSync, readdirSync, rmSync } from "node:fs";
1011
- import { dirname as dirname2, join as join5 } from "node:path";
1318
+ import { dirname as dirname2, join as join6 } from "node:path";
1012
1319
  import { parseArgs } from "node:util";
1013
1320
  function resolveCli(argv) {
1014
1321
  const { positionals: positionals2 } = parseArgs({ args: argv, allowPositionals: true, strict: false });
@@ -1078,7 +1385,7 @@ function knownVaults() {
1078
1385
  const known = [];
1079
1386
  for (const entry of readdirSync(root, { withFileTypes: true })) {
1080
1387
  if (!entry.isDirectory()) continue;
1081
- const index = join5(root, entry.name, "index.db");
1388
+ const index = join6(root, entry.name, "index.db");
1082
1389
  if (!existsSync(index)) continue;
1083
1390
  const db = openDatabase(index);
1084
1391
  try {
@@ -1141,6 +1448,7 @@ var init_cli = __esm({
1141
1448
  init_database();
1142
1449
  init_sync();
1143
1450
  init_validate();
1451
+ init_init();
1144
1452
  }
1145
1453
  });
1146
1454
 
@@ -1161,7 +1469,7 @@ ${issues}`);
1161
1469
  };
1162
1470
  }
1163
1471
  var EnvSchema2;
1164
- var init_config2 = __esm({
1472
+ var init_config = __esm({
1165
1473
  "../mcp/src/config.ts"() {
1166
1474
  "use strict";
1167
1475
  EnvSchema2 = z3.object({
@@ -1193,7 +1501,7 @@ var init_db = __esm({
1193
1501
  // ../mcp/src/lib/diag.ts
1194
1502
  import { appendFileSync, mkdirSync as mkdirSync3 } from "node:fs";
1195
1503
  import { homedir as homedir2 } from "node:os";
1196
- import { join as join6 } from "node:path";
1504
+ import { join as join7 } from "node:path";
1197
1505
  function diag(msg, data) {
1198
1506
  try {
1199
1507
  const line = data ? `${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} ${msg} ${JSON.stringify(data)}
@@ -1207,8 +1515,8 @@ var DIAG_DIR, DIAG_LOG_PATH;
1207
1515
  var init_diag = __esm({
1208
1516
  "../mcp/src/lib/diag.ts"() {
1209
1517
  "use strict";
1210
- DIAG_DIR = join6(homedir2(), ".local", "state", "wiki-mcp");
1211
- DIAG_LOG_PATH = join6(DIAG_DIR, "server.log");
1518
+ DIAG_DIR = join7(homedir2(), ".local", "state", "wiki-mcp");
1519
+ DIAG_LOG_PATH = join7(DIAG_DIR, "server.log");
1212
1520
  try {
1213
1521
  mkdirSync3(DIAG_DIR, { recursive: true });
1214
1522
  } catch {
@@ -1235,176 +1543,6 @@ var init_logger = __esm({
1235
1543
  }
1236
1544
  });
1237
1545
 
1238
- // ../mcp/src/vault-config.ts
1239
- import { readFile as readFile4 } from "node:fs/promises";
1240
- import { join as join7 } from "node:path";
1241
- import { parse as parse2 } from "yaml";
1242
- import { z as z4 } from "zod";
1243
- function isValidRegex2(pattern) {
1244
- try {
1245
- return Boolean(new RegExp(pattern));
1246
- } catch {
1247
- return false;
1248
- }
1249
- }
1250
- function kindName2(entry) {
1251
- return typeof entry === "string" ? entry : entry.name;
1252
- }
1253
- async function loadVaultConfig2(vaultRoot2) {
1254
- const path = join7(vaultRoot2, "vault.yaml");
1255
- let raw;
1256
- try {
1257
- raw = await readFile4(path, "utf8");
1258
- } catch (err) {
1259
- throw new Error(`vault.yaml not found at ${path}`, { cause: err });
1260
- }
1261
- const parsed = VaultConfigSchema2.safeParse(parse2(raw));
1262
- if (!parsed.success) {
1263
- const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
1264
- throw new Error(`Invalid vault.yaml at ${path}:
1265
- ${issues}`);
1266
- }
1267
- return parsed.data;
1268
- }
1269
- var ScopeSchema2, KindEntrySchema2, WhenSchema2, TriggerSchema2, TriggersSchema2, VaultConfigSchema2, BUILT_IN_KINDS2;
1270
- var init_vault_config = __esm({
1271
- "../mcp/src/vault-config.ts"() {
1272
- "use strict";
1273
- ScopeSchema2 = z4.object({
1274
- repo: z4.string().optional(),
1275
- methodology: z4.string().optional(),
1276
- status: z4.string()
1277
- });
1278
- KindEntrySchema2 = z4.union([
1279
- z4.string(),
1280
- z4.object({
1281
- name: z4.string(),
1282
- signal: z4.string(),
1283
- where: z4.string()
1284
- })
1285
- ]);
1286
- WhenSchema2 = z4.union([
1287
- z4.string(),
1288
- z4.object({
1289
- name: z4.enum(["newer-than"]),
1290
- fresh: z4.array(z4.string().min(1)).min(1),
1291
- than: z4.array(z4.string().min(1)).min(1)
1292
- })
1293
- ]);
1294
- TriggerSchema2 = z4.object({
1295
- id: z4.string().min(1),
1296
- on: z4.enum(["prompt", "pretool"]),
1297
- enforce: z4.enum(["inject", "warn", "block"]),
1298
- keywords: z4.array(z4.string().min(1)).optional(),
1299
- intent: z4.array(z4.string()).optional(),
1300
- tool: z4.string().optional(),
1301
- args_match: z4.string().optional(),
1302
- files: z4.array(z4.string().min(1)).optional(),
1303
- when: WhenSchema2.optional(),
1304
- text: z4.string().optional(),
1305
- reason: z4.string().optional()
1306
- }).superRefine((trigger, ctx) => {
1307
- if (trigger.on === "prompt" && !trigger.keywords?.length && !trigger.intent?.length) {
1308
- ctx.addIssue({
1309
- code: "custom",
1310
- message: `prompt trigger "${trigger.id}" needs keywords or intent`
1311
- });
1312
- }
1313
- if (trigger.on === "pretool" && trigger.tool === void 0 && trigger.args_match === void 0 && !trigger.files?.length) {
1314
- ctx.addIssue({
1315
- code: "custom",
1316
- message: `pretool trigger "${trigger.id}" needs a tool, args_match, or files matcher`
1317
- });
1318
- }
1319
- if (trigger.on === "prompt" && trigger.files !== void 0) {
1320
- ctx.addIssue({
1321
- code: "custom",
1322
- message: `trigger "${trigger.id}": files applies to pretool triggers only`
1323
- });
1324
- }
1325
- if (trigger.enforce === "block" ? trigger.reason === void 0 : trigger.text === void 0) {
1326
- ctx.addIssue({
1327
- code: "custom",
1328
- message: trigger.enforce === "block" ? `block trigger "${trigger.id}" needs a reason` : `${trigger.enforce} trigger "${trigger.id}" needs a text`
1329
- });
1330
- }
1331
- const patterns = [...trigger.intent ?? []];
1332
- if (trigger.args_match !== void 0) patterns.push(trigger.args_match);
1333
- for (const pattern of patterns) {
1334
- if (!isValidRegex2(pattern)) {
1335
- ctx.addIssue({
1336
- code: "custom",
1337
- message: `trigger "${trigger.id}" has an invalid regex: ${pattern}`
1338
- });
1339
- }
1340
- }
1341
- });
1342
- TriggersSchema2 = z4.record(z4.string(), z4.array(TriggerSchema2));
1343
- VaultConfigSchema2 = z4.object({
1344
- scopes: z4.record(z4.string(), ScopeSchema2),
1345
- kinds: z4.array(KindEntrySchema2),
1346
- statuses: z4.array(z4.string()),
1347
- methodologies: z4.array(z4.string()),
1348
- tags: z4.object({
1349
- canonical: z4.array(z4.string()),
1350
- aliases: z4.record(z4.string(), z4.string())
1351
- }),
1352
- authoring_rules: z4.string().optional(),
1353
- authoring_rules_extra: z4.string().optional(),
1354
- sync_protocol: z4.string().optional(),
1355
- sync_protocol_extra: z4.string().optional(),
1356
- triggers: TriggersSchema2.optional(),
1357
- triggers_extra: TriggersSchema2.optional()
1358
- }).superRefine((config, ctx) => {
1359
- for (const [name, scope] of Object.entries(config.scopes)) {
1360
- if (scope.methodology !== void 0 && !config.methodologies.includes(scope.methodology)) {
1361
- ctx.addIssue({
1362
- code: "custom",
1363
- path: ["scopes", name, "methodology"],
1364
- message: `"${scope.methodology}" is not in the methodologies list`
1365
- });
1366
- }
1367
- }
1368
- if (config.triggers?._all !== void 0) {
1369
- ctx.addIssue({
1370
- code: "custom",
1371
- path: ["triggers", "_all"],
1372
- message: '"_all" is reserved for triggers_extra'
1373
- });
1374
- }
1375
- for (const field of ["triggers", "triggers_extra"]) {
1376
- for (const [scope, list] of Object.entries(config[field] ?? {})) {
1377
- const seen = /* @__PURE__ */ new Set();
1378
- for (const trigger of list) {
1379
- if (seen.has(trigger.id)) {
1380
- ctx.addIssue({
1381
- code: "custom",
1382
- path: [field, scope],
1383
- message: `duplicate trigger id "${trigger.id}"`
1384
- });
1385
- }
1386
- seen.add(trigger.id);
1387
- }
1388
- }
1389
- }
1390
- });
1391
- BUILT_IN_KINDS2 = /* @__PURE__ */ new Set([
1392
- "project",
1393
- "spec",
1394
- "adr",
1395
- "plan",
1396
- "story",
1397
- "ops",
1398
- "topic",
1399
- "article",
1400
- "src",
1401
- "note",
1402
- "artifact",
1403
- "prompt"
1404
- ]);
1405
- }
1406
- });
1407
-
1408
1546
  // ../mcp/src/resources/authoring.ts
1409
1547
  function buildAuthoringRules(config) {
1410
1548
  const parts = [(config.authoring_rules ?? DEFAULT_AUTHORING_RULES).trim()];
@@ -1419,13 +1557,13 @@ function buildSyncProtocol(config) {
1419
1557
  function buildKindSelector(kinds) {
1420
1558
  const lines = ["## Kind selector", "", "| Signal | Kind | Where |", "|---|---|---|"];
1421
1559
  for (const entry of kinds) {
1422
- const name = kindName2(entry);
1560
+ const name = kindName(entry);
1423
1561
  const pedagogy = typeof entry === "string" ? KIND_PEDAGOGY.get(entry) : entry;
1424
1562
  const signal = pedagogy?.signal ?? "\u2014";
1425
1563
  const where = pedagogy?.where ?? "\u2014";
1426
1564
  lines.push(`| ${signal} | **${name}** | ${where} |`);
1427
1565
  }
1428
- const names = kinds.map(kindName2);
1566
+ const names = kinds.map(kindName);
1429
1567
  const hasNote = names.includes("note");
1430
1568
  const hasAdrAndSpec = names.includes("adr") && names.includes("spec");
1431
1569
  if (hasNote || hasAdrAndSpec) {
@@ -1448,7 +1586,7 @@ function buildVocabulary(config) {
1448
1586
  const lines = [
1449
1587
  "## Controlled vocabulary",
1450
1588
  "",
1451
- `**Kinds:** ${config.kinds.map(kindName2).join(", ")}`,
1589
+ `**Kinds:** ${config.kinds.map(kindName).join(", ")}`,
1452
1590
  buildStatusLine(config.statuses),
1453
1591
  `**Methodologies:** ${config.methodologies.join(", ")}`,
1454
1592
  `**Canonical tags:** ${config.tags.canonical.join(", ")}`
@@ -1612,7 +1750,7 @@ var init_authoring = __esm({
1612
1750
  ].join("\n");
1613
1751
  DEFAULT_SYNC_PROTOCOL = [
1614
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.",
1615
- "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`."
1616
1754
  ].join("\n");
1617
1755
  CANONICAL_STATUS_FLOW = ["draft", "active", "superseded", "archived"];
1618
1756
  }
@@ -1624,7 +1762,7 @@ import { join as join8 } from "node:path";
1624
1762
  function customTemplates(config) {
1625
1763
  const specs = [];
1626
1764
  for (const entry of config.kinds) {
1627
- if (typeof entry === "string" || BUILT_IN_KINDS2.has(entry.name)) continue;
1765
+ if (typeof entry === "string" || BUILT_IN_KINDS.has(entry.name)) continue;
1628
1766
  specs.push({
1629
1767
  uri: `wiki://template/${entry.name}`,
1630
1768
  name: entry.name.charAt(0).toUpperCase() + entry.name.slice(1),
@@ -1825,7 +1963,7 @@ var init_toolResponse = __esm({
1825
1963
  });
1826
1964
 
1827
1965
  // ../mcp/src/tools/search.ts
1828
- import { z as z5 } from "zod";
1966
+ import { z as z4 } from "zod";
1829
1967
  function search(deps, input) {
1830
1968
  const ftsQuery = sanitizeFtsQuery(input.query);
1831
1969
  if (!ftsQuery) return { results: [] };
@@ -1872,15 +2010,15 @@ var init_search = __esm({
1872
2010
  "use strict";
1873
2011
  init_fts();
1874
2012
  init_toolResponse();
1875
- SearchInputSchema = z5.object({
1876
- query: z5.string().min(1).describe(
2013
+ SearchInputSchema = z4.object({
2014
+ query: z4.string().min(1).describe(
1877
2015
  "Natural-language search query. Matched against title, summary, and body via SQLite FTS5."
1878
2016
  ),
1879
- scope: z5.string().optional().describe('Optional: restrict to a project scope (e.g. "ontology", "sotto").'),
1880
- kind: z5.string().optional().describe(
2017
+ scope: z4.string().optional().describe('Optional: restrict to a project scope (e.g. "ontology", "sotto").'),
2018
+ kind: z4.string().optional().describe(
1881
2019
  "Optional: restrict to a kind. Project kinds: spec, adr, plan, ops. Research kinds: article, src. Misc: note."
1882
2020
  ),
1883
- limit: z5.number().int().min(1).max(50).default(5).describe("Maximum number of ranked results to return. Default 5.")
2021
+ limit: z4.number().int().min(1).max(50).default(5).describe("Maximum number of ranked results to return. Default 5.")
1884
2022
  });
1885
2023
  FTS_RANK = "bm25(pages_fts, 10.0, 5.0, 1.0)";
1886
2024
  }
@@ -1889,7 +2027,7 @@ var init_search = __esm({
1889
2027
  // ../mcp/src/tools/prime.ts
1890
2028
  import { readFile as readFile6 } from "node:fs/promises";
1891
2029
  import { basename as basename3, join as join9 } from "node:path";
1892
- import { z as z6 } from "zod";
2030
+ import { z as z5 } from "zod";
1893
2031
  function pathSlug(p) {
1894
2032
  return basename3(p).replace(/\.md$/, "");
1895
2033
  }
@@ -2032,7 +2170,7 @@ function renderMarkdown(d, config, task) {
2032
2170
  lines.push(countEntries.map(([k, n]) => `${k}: ${n}`).join(" | "));
2033
2171
  }
2034
2172
  lines.push("", "## Vocabulary");
2035
- lines.push(`kinds: ${config.kinds.map(kindName2).join(", ")}`);
2173
+ lines.push(`kinds: ${config.kinds.map(kindName).join(", ")}`);
2036
2174
  lines.push(`statuses: ${config.statuses.join(", ")}`);
2037
2175
  lines.push(`tags: ${config.tags.canonical.join(", ")}`);
2038
2176
  if (d.top_tags.length > 0) {
@@ -2093,14 +2231,14 @@ var init_prime = __esm({
2093
2231
  "../mcp/src/tools/prime.ts"() {
2094
2232
  "use strict";
2095
2233
  init_database();
2234
+ init_vault_config();
2096
2235
  init_frontmatter2();
2097
2236
  init_fts();
2098
2237
  init_toolResponse();
2099
- init_vault_config();
2100
2238
  init_search();
2101
- PrimeInputSchema = z6.object({
2102
- scope: z6.string().min(1).describe("Project scope to prime (matches projects/{scope}/ in the vault)."),
2103
- task: z6.string().optional().describe("Optional task description; surfaces top-3 tsvector-ranked relevant pages.")
2239
+ PrimeInputSchema = z5.object({
2240
+ scope: z5.string().min(1).describe("Project scope to prime (matches projects/{scope}/ in the vault)."),
2241
+ task: z5.string().optional().describe("Optional task description; surfaces top-3 tsvector-ranked relevant pages.")
2104
2242
  });
2105
2243
  }
2106
2244
  });
@@ -2152,7 +2290,7 @@ async function startMcpServer() {
2152
2290
  diag("main entered");
2153
2291
  const config = loadConfig();
2154
2292
  diag("config loaded", { vault: config.wikiVault, level: config.logLevel });
2155
- const vaultConfig = await loadVaultConfig2(config.wikiVault);
2293
+ const vaultConfig = await loadVaultConfig(config.wikiVault);
2156
2294
  diag("vault config loaded", {
2157
2295
  scopes: Object.keys(vaultConfig.scopes).length,
2158
2296
  kinds: vaultConfig.kinds.length,
@@ -2195,12 +2333,12 @@ async function startMcpServer() {
2195
2333
  var init_start = __esm({
2196
2334
  "../mcp/src/start.ts"() {
2197
2335
  "use strict";
2198
- init_config2();
2336
+ init_vault_config();
2337
+ init_config();
2199
2338
  init_db();
2200
2339
  init_diag();
2201
2340
  init_logger();
2202
2341
  init_server();
2203
- init_vault_config();
2204
2342
  }
2205
2343
  });
2206
2344
 
@@ -2218,21 +2356,24 @@ __export(hook_exports, {
2218
2356
  matchPromptTriggers: () => matchPromptTriggers,
2219
2357
  parsePretoolEvent: () => parsePretoolEvent,
2220
2358
  parsePromptEvent: () => parsePromptEvent,
2359
+ parseStopEvent: () => parseStopEvent,
2221
2360
  renderPosttool: () => renderPosttool,
2222
2361
  renderPretool: () => renderPretool,
2362
+ renderStop: () => renderStop,
2223
2363
  resolveScope: () => resolveScope,
2224
2364
  runHookPosttool: () => runHookPosttool,
2225
2365
  runHookPretool: () => runHookPretool,
2226
2366
  runHookPrompt: () => runHookPrompt,
2367
+ runHookStop: () => runHookStop,
2227
2368
  vaultPathTouched: () => vaultPathTouched
2228
2369
  });
2229
2370
  import { mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync, rmSync as rmSync2, statSync, writeFileSync } from "node:fs";
2230
2371
  import { homedir as homedir3 } from "node:os";
2231
- import { join as join10, resolve as resolve2, sep as sep2 } from "node:path";
2372
+ import { join as join10, resolve as resolve3, sep as sep2 } from "node:path";
2232
2373
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
2233
2374
  import { parseArgs as parseArgs2 } from "node:util";
2234
2375
  import { parse as parseYaml3 } from "yaml";
2235
- import { z as z7 } from "zod";
2376
+ import { z as z6 } from "zod";
2236
2377
  function eventFields(raw) {
2237
2378
  let data;
2238
2379
  try {
@@ -2258,7 +2399,7 @@ function kiroIdePromptEvent(now = Date.now()) {
2258
2399
  }
2259
2400
  function loadTriggerFile(path) {
2260
2401
  try {
2261
- const result = z7.array(TriggerSchema).safeParse(parseYaml3(readFileSync(path, "utf8")));
2402
+ const result = z6.array(TriggerSchema).safeParse(parseYaml3(readFileSync(path, "utf8")));
2262
2403
  return result.success ? result.data : null;
2263
2404
  } catch {
2264
2405
  return null;
@@ -2330,7 +2471,11 @@ function matchPromptTriggers(prompt, triggers) {
2330
2471
  hit = trigger.intent.some((pattern) => new RegExp(pattern, "i").test(prompt));
2331
2472
  }
2332
2473
  if (hit) {
2333
- 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
+ });
2334
2479
  }
2335
2480
  }
2336
2481
  } finally {
@@ -2409,7 +2554,8 @@ function matchPretoolTriggers(toolName, toolInput, triggers, cwd) {
2409
2554
  id: trigger.id,
2410
2555
  enforce: trigger.enforce,
2411
2556
  text,
2412
- ...trigger.when !== void 0 && { when: trigger.when }
2557
+ ...trigger.when !== void 0 && { when: trigger.when },
2558
+ ...trigger.dedup !== void 0 && { dedup: trigger.dedup }
2413
2559
  });
2414
2560
  }
2415
2561
  return matches;
@@ -2506,10 +2652,10 @@ function patchPaths(toolInput) {
2506
2652
  return paths;
2507
2653
  }
2508
2654
  function vaultPathTouched(toolInput, vaultRoot2, cwd) {
2509
- const root = resolve2(vaultRoot2);
2655
+ const root = resolve3(vaultRoot2);
2510
2656
  const candidates = [...pathCandidates(toolInput, cwd), ...patchPaths(toolInput)];
2511
2657
  return candidates.some((candidate) => {
2512
- const absolute = resolve2(cwd ?? ".", candidate);
2658
+ const absolute = resolve3(cwd ?? ".", candidate);
2513
2659
  return absolute === root || absolute.startsWith(`${root}/`);
2514
2660
  });
2515
2661
  }
@@ -2532,6 +2678,27 @@ ${lines.join("\n")}`
2532
2678
  }
2533
2679
  return JSON.stringify({ findings, synced });
2534
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
+ }
2535
2702
  function dedupePretoolMatches(stateDir, sessionId, matches) {
2536
2703
  const blocks = matches.filter((match) => match.enforce === "block");
2537
2704
  const rest = matches.filter((match) => match.enforce !== "block");
@@ -2541,15 +2708,26 @@ function dedupePretoolMatches(stateDir, sessionId, matches) {
2541
2708
  function hookStateDir() {
2542
2709
  return join10(kmdHome(), "state", "hook");
2543
2710
  }
2544
- function dedupeMatches(stateDir, sessionId, matches) {
2711
+ function dedupeMatches(stateDir, sessionId, matches, now = Date.now()) {
2545
2712
  if (matches.length === 0) return [];
2546
2713
  const file = join10(stateDir, `${sessionId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
2547
2714
  const fired = readFired(file);
2548
- const fresh = matches.filter((match) => !fired.has(match.id));
2549
- 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) {
2550
2728
  mkdirSync4(stateDir, { recursive: true });
2551
- for (const match of fresh) {
2552
- fired.add(match.id);
2729
+ for (const key of record) {
2730
+ fired.add(key);
2553
2731
  }
2554
2732
  writeFileSync(file, JSON.stringify([...fired]));
2555
2733
  pruneStale(stateDir, file);
@@ -2723,6 +2901,28 @@ async function runHookPosttool() {
2723
2901
  diag2(err instanceof Error ? err.message : String(err));
2724
2902
  }
2725
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
+ }
2726
2926
  async function readStdin() {
2727
2927
  process.stdin.setEncoding("utf8");
2728
2928
  let input = "";
@@ -2739,7 +2939,7 @@ var init_hook = __esm({
2739
2939
  "../cli/src/hook.ts"() {
2740
2940
  "use strict";
2741
2941
  init_database();
2742
- init_config();
2942
+ init_vault_config();
2743
2943
  init_frontmatter();
2744
2944
  init_sync();
2745
2945
  init_validate();
@@ -2762,14 +2962,16 @@ process.on("warning", (warning) => {
2762
2962
  var USAGE = `usage: kmd <command> [options]
2763
2963
 
2764
2964
  commands:
2965
+ init [<dir>] [-y] scaffold a fresh vault (no dir: current directory \u2014 TTY prompt, or -y)
2765
2966
  sync vault \u2192 index sync (runs validate first)
2766
2967
  validate [<path>] deterministic vault checker (default: $WIKI_VAULT)
2767
2968
  mcp [<vault-root>] start the stdio MCP server (default: $WIKI_VAULT)
2768
2969
  config [<vault-root>] print vault + index resolution; with no vault, list known vaults
2769
2970
  db reset [<vault-root>] delete the vault's index (default: $WIKI_VAULT)
2770
- 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>]
2771
2972
  harness gate engine: JSON event on stdin, decision/context on stdout;
2772
- 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
2773
2975
 
2774
2976
  options:
2775
2977
  --version print version
@@ -2780,7 +2982,8 @@ var { positionals, values } = parseArgs3({
2780
2982
  strict: false,
2781
2983
  options: {
2782
2984
  version: { type: "boolean", short: "v" },
2783
- help: { type: "boolean", short: "h" }
2985
+ help: { type: "boolean", short: "h" },
2986
+ yes: { type: "boolean", short: "y" }
2784
2987
  }
2785
2988
  });
2786
2989
  var command = values.version ? "--version" : values.help ? "--help" : positionals[0];
@@ -2792,6 +2995,11 @@ function applyVaultRoot(positionalIndex) {
2792
2995
  }
2793
2996
  async function run() {
2794
2997
  switch (command) {
2998
+ case "init": {
2999
+ const { runInit: runInit2 } = await Promise.resolve().then(() => (init_cli(), cli_exports));
3000
+ await runInit2(positionals[1], Boolean(values.yes));
3001
+ break;
3002
+ }
2795
3003
  case "sync": {
2796
3004
  const { runSyncCommand: runSyncCommand2 } = await Promise.resolve().then(() => (init_cli(), cli_exports));
2797
3005
  await runSyncCommand2();
@@ -2838,9 +3046,14 @@ async function run() {
2838
3046
  } else if (sub === "posttool") {
2839
3047
  const { runHookPosttool: runHookPosttool2 } = await Promise.resolve().then(() => (init_hook(), hook_exports));
2840
3048
  await runHookPosttool2();
3049
+ } else if (sub === "stop") {
3050
+ const { runHookStop: runHookStop2 } = await Promise.resolve().then(() => (init_hook(), hook_exports));
3051
+ await runHookStop2();
3052
+ } else if (sub) {
3053
+ console.error(`kmd hook: unknown event: ${sub}`);
2841
3054
  } else {
2842
3055
  console.error(
2843
- sub ? `unknown hook event: ${sub}` : "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>]"
2844
3057
  );
2845
3058
  process.exit(2);
2846
3059
  }
@@ -2864,6 +3077,11 @@ async function run() {
2864
3077
  break;
2865
3078
  }
2866
3079
  default: {
3080
+ const tail = positionals[1];
3081
+ if (tail === "prompt" || tail === "pretool" || tail === "posttool") {
3082
+ console.error(`kmd: unknown command: ${command}`);
3083
+ break;
3084
+ }
2867
3085
  console.error(`unknown command: ${command}
2868
3086
 
2869
3087
  ${USAGE}`);