hafez 2.1.0 → 2.3.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.
Files changed (75) hide show
  1. package/README.md +72 -5
  2. package/dist/apply.d.ts +14 -1
  3. package/dist/apply.js +94 -32
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +83 -16
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +27 -12
  8. package/dist/cli/commands.d.ts +3 -1
  9. package/dist/cli/commands.js +183 -73
  10. package/dist/cli/format.d.ts +11 -3
  11. package/dist/cli/format.js +61 -4
  12. package/dist/cli/index.js +40 -19
  13. package/dist/cli/install.d.ts +9 -0
  14. package/dist/cli/install.js +36 -15
  15. package/dist/cli/onboard.js +30 -9
  16. package/dist/cli/upgrade.js +5 -4
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.js +9 -0
  19. package/dist/dates.d.ts +7 -0
  20. package/dist/dates.js +21 -1
  21. package/dist/db.d.ts +50 -10
  22. package/dist/db.js +251 -65
  23. package/dist/digest.js +14 -7
  24. package/dist/document.d.ts +2 -0
  25. package/dist/document.js +2 -2
  26. package/dist/git.d.ts +13 -2
  27. package/dist/git.js +121 -14
  28. package/dist/graph/activity.d.ts +5 -0
  29. package/dist/graph/activity.js +70 -0
  30. package/dist/graph/client.js +326 -0
  31. package/dist/graph/copy-fonts.d.ts +1 -0
  32. package/dist/graph/copy-fonts.js +13 -0
  33. package/dist/graph/fields.d.ts +8 -0
  34. package/dist/graph/fields.js +12 -0
  35. package/dist/graph/fonts/geist-OFL.txt +93 -0
  36. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  37. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  38. package/dist/graph/fonts/geist.woff2 +0 -0
  39. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  40. package/dist/graph/fonts/outfit.woff2 +0 -0
  41. package/dist/graph/fonts.d.ts +14 -0
  42. package/dist/graph/fonts.js +17 -0
  43. package/dist/graph/live.d.ts +16 -0
  44. package/dist/graph/live.js +99 -0
  45. package/dist/graph/open.d.ts +1 -0
  46. package/dist/graph/open.js +20 -0
  47. package/dist/graph/server.d.ts +17 -0
  48. package/dist/graph/server.js +189 -0
  49. package/dist/health.d.ts +6 -0
  50. package/dist/health.js +310 -0
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +66 -18
  53. package/dist/internal.d.ts +21 -0
  54. package/dist/internal.js +9 -0
  55. package/dist/knowledge-index.d.ts +1 -1
  56. package/dist/read-json.d.ts +26 -0
  57. package/dist/read-json.js +53 -0
  58. package/dist/schema.d.ts +2 -0
  59. package/dist/schema.js +11 -0
  60. package/dist/stale.d.ts +2 -2
  61. package/dist/stale.js +4 -9
  62. package/dist/surfaces/items.d.ts +100 -1
  63. package/dist/surfaces/items.js +148 -10
  64. package/dist/surfaces/surfaces.d.ts +8 -5
  65. package/dist/surfaces/surfaces.js +34 -17
  66. package/dist/templates.d.ts +2 -0
  67. package/dist/templates.js +6 -1
  68. package/dist/types.d.ts +239 -25
  69. package/dist/validate.js +44 -56
  70. package/dist/vault.d.ts +29 -9
  71. package/dist/vault.js +39 -8
  72. package/dist/workstream.d.ts +17 -0
  73. package/dist/workstream.js +78 -0
  74. package/package.json +10 -4
  75. package/skill/hafez/SKILL.md +16 -7
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES } from './contracts.js';
1
+ import type { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES, GRAPH_LAYERS, SYNTHETIC_LAYERS, GRAPH_NOTE_RELATIONS, CHANGE_OPS } from './contracts.js';
2
2
  export type EntityType = typeof ENTITY_TYPES[number];
3
3
  export type EntityStatus = typeof ENTITY_STATUSES[number];
4
4
  export type ConfidenceLevel = typeof CONFIDENCE_LEVELS[number];
@@ -9,12 +9,22 @@ export type LinkRelation = 'parent' | 'related';
9
9
  export type KnowledgeSubtype = typeof VALID_SUBTYPES[number];
10
10
  /** Plans are the one knowledge subtype with a lifecycle (see PLAN_STATUSES). */
11
11
  export type PlanStatus = typeof PLAN_STATUSES[number];
12
+ /** The kinds of note the Vault holds, one directory each (the Layout,
13
+ * vault.ts, re-exports it). Declared here so this file imports no module
14
+ * with runtime dependencies: the graph page's bundle reads its types. */
15
+ export type VaultKind = 'entity' | 'knowledge' | 'session';
12
16
  /** Sort fields, and the two an entity query cannot honour — reinforcement
13
17
  * lives on knowledge notes only. Asking for one on the entity branch is a
14
18
  * validation error, not a silent fall back to recency, so the entity opts
15
19
  * type declares only what that branch can actually sort by. */
16
- export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
17
- export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count'>;
20
+ export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count' | 'session_date';
21
+ export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count' | 'session_date'>;
22
+ export type KnowledgeSortField = Exclude<SortField, 'session_date'>;
23
+ /** A session note carries only its dates and its name. */
24
+ export type SessionSortField = 'session_date' | 'created' | 'name';
25
+ /** What `search` reaches. Unset is the working set, entity + knowledge;
26
+ * `all` is literal, sessions included. */
27
+ export type SearchKind = 'entity' | 'knowledge' | 'session' | 'all';
18
28
  export interface EntityQueryOpts {
19
29
  filter?: QueryFilter;
20
30
  type?: EntityType;
@@ -39,14 +49,28 @@ export interface KnowledgeQueryOpts {
39
49
  createdSince?: string;
40
50
  createdBefore?: string;
41
51
  tags?: string[];
42
- sort_by?: SortField;
52
+ sort_by?: KnowledgeSortField;
53
+ sort_order?: 'asc' | 'desc';
54
+ limit?: number;
55
+ offset?: number;
56
+ }
57
+ /** Sessions carry no status, domain or tags: the time window (`since`/`before`
58
+ * against `session-date`), sort and paging are all a session query takes. */
59
+ export interface SessionQueryOpts {
60
+ since?: string;
61
+ before?: string;
62
+ createdSince?: string;
63
+ createdBefore?: string;
64
+ sort_by?: SessionSortField;
43
65
  sort_order?: 'asc' | 'desc';
44
66
  limit?: number;
45
67
  offset?: number;
46
68
  }
47
69
  /** `kind: 'all'` (or unset): reaches both sides, so it takes opts from both
48
70
  * and can honour every sort field. Status filters narrow the entity side only
49
- * — knowledge notes carry no status and are never excluded by one. */
71
+ * — knowledge notes carry no status and are never excluded by one. Unset
72
+ * is the working set, entity + knowledge; `all` is literal and reaches the
73
+ * sessions too. */
50
74
  export interface AllQueryOpts extends Omit<EntityQueryOpts, 'sort_by'> {
51
75
  kind?: 'all';
52
76
  confidence?: ConfidenceLevel;
@@ -63,6 +87,8 @@ export type UnifiedQueryOpts = AllQueryOpts | (EntityQueryOpts & {
63
87
  kind: 'entity';
64
88
  }) | (KnowledgeQueryOpts & {
65
89
  kind: 'knowledge';
90
+ }) | (SessionQueryOpts & {
91
+ kind: 'session';
66
92
  });
67
93
  export interface EntityFrontmatter {
68
94
  name: string;
@@ -114,11 +140,8 @@ export type ParsedVaultFile = {
114
140
  frontmatter: SessionFrontmatter;
115
141
  body: string;
116
142
  };
117
- /** What read() returns: the indexed arms of ParsedVaultFile (a session slug is
118
- * never resolvable through read()), so consumers narrow on `kind`. */
119
- export type ParsedFile = Extract<ParsedVaultFile, {
120
- kind: 'entity' | 'knowledge';
121
- }>;
143
+ /** What read() returns: any vault file, so consumers narrow on `kind`. */
144
+ export type ParsedFile = ParsedVaultFile;
122
145
  export interface SessionLogEntry {
123
146
  type: SessionLogType;
124
147
  summary: string;
@@ -189,6 +212,14 @@ export interface CreateKnowledgeFields {
189
212
  tags?: string[];
190
213
  'session-date'?: string;
191
214
  }
215
+ export interface CreateSessionFields {
216
+ synthesis?: string;
217
+ insight?: string;
218
+ related?: string[];
219
+ 'session-date'?: string;
220
+ decisions?: string[];
221
+ entities_touched?: string[];
222
+ }
192
223
  export interface QueryResult {
193
224
  slug: string;
194
225
  name: string;
@@ -221,10 +252,19 @@ export interface KnowledgeQueryResult {
221
252
  last_reinforced: string | null;
222
253
  last_touched: string | null;
223
254
  }
255
+ export interface SessionQueryResult {
256
+ slug: string;
257
+ name: string;
258
+ session_date: string;
259
+ created: string;
260
+ related: string[];
261
+ }
224
262
  export type UnifiedResult = (QueryResult & {
225
263
  kind: 'entity';
226
264
  }) | (KnowledgeQueryResult & {
227
265
  kind: 'knowledge';
266
+ }) | (SessionQueryResult & {
267
+ kind: 'session';
228
268
  });
229
269
  export interface ValidationIssue {
230
270
  slug: string;
@@ -237,10 +277,100 @@ export interface ValidationReport {
237
277
  missing_fields: ValidationIssue[];
238
278
  total_entities: number;
239
279
  total_knowledge: number;
280
+ total_sessions: number;
281
+ }
282
+ /** Check (i): an unsplit prefix family on an entity, from the family module. */
283
+ export interface HealthWorkstream {
284
+ entity: string;
285
+ prefix: string;
286
+ open_actions: number;
287
+ }
288
+ /** Check (ii): a name recurring in `units` units of one entity across `days`. */
289
+ export interface HealthRecurringName {
290
+ entity: string;
291
+ term: string;
292
+ units: number;
293
+ days: string[];
294
+ }
295
+ /** Check (iv): `hits` of the parent's log entries dated on or after the
296
+ * child's `created` (`entries` of them) name the child. */
297
+ export interface HealthLeak {
298
+ parent: string;
299
+ child: string;
300
+ relation: 'parent' | 'related';
301
+ hits: number;
302
+ entries: number;
303
+ }
304
+ /** Check (iii): `from`'s `related` names `to`, a note of the same kind,
305
+ * whose `related` does not name `from`. Check (v): `from`'s body mentions
306
+ * `[[to]]`, a note of the same kind, with no `related` edge either way.
307
+ * Neither lists a parent and its child: parent subsumes related. */
308
+ export interface HealthEdge {
309
+ from: string;
310
+ to: string;
311
+ }
312
+ /** Check (vi): the knowledge note `note`, created on `day` and related to
313
+ * `entity`, shares `share` (0..1) of the summary tokens of a `decision`
314
+ * entry of that day on the entity. */
315
+ export interface HealthRestatedDecision {
316
+ day: string;
317
+ entity: string;
318
+ note: string;
319
+ share: number;
320
+ summary: string;
321
+ }
322
+ export interface HealthReport {
323
+ workstreams: HealthWorkstream[];
324
+ recurring_names: HealthRecurringName[];
325
+ leakage: HealthLeak[];
326
+ one_way_edges: HealthEdge[];
327
+ unlinked_mentions: HealthEdge[];
328
+ restated_decisions: HealthRestatedDecision[];
329
+ }
330
+ export type GraphLayer = typeof GRAPH_LAYERS[number];
331
+ export type SyntheticLayer = typeof SYNTHETIC_LAYERS[number];
332
+ export interface GraphNode {
333
+ /** The slug; `tag:<t>` / `domain:<d>` for a layer node (a slug never holds `:`). */
334
+ id: string;
335
+ kind: VaultKind | SyntheticLayer;
336
+ name: string;
337
+ type?: EntityType;
338
+ status?: EntityStatus;
339
+ subtype?: KnowledgeSubtype;
340
+ confidence?: ConfidenceLevel;
341
+ last_touched?: string;
342
+ description?: string;
343
+ /** An active or paused Entity's open Next Actions. */
344
+ open_actions?: number;
345
+ /** An active Entity untouched past its staleness-days: the index's stale rule. */
346
+ stale?: boolean;
347
+ /** An active or paused Entity's open actions the Stale rule marks, undated ones included. */
348
+ stale_actions?: number;
349
+ /** Every Entity's effective staleness-days: its own, else 14. */
350
+ staleness_days?: number;
351
+ /** An Entity or Knowledge note with no Parent, Related or Wiki-link edge either way. */
352
+ unlinked?: true;
353
+ /** On an Unlinked note: the closest other linked note by name and description,
354
+ * the same kind when one matches; absent when none does. */
355
+ best?: string;
356
+ }
357
+ export interface GraphEdge {
358
+ source: string;
359
+ target: string;
360
+ relation: typeof GRAPH_NOTE_RELATIONS[number] | SyntheticLayer;
361
+ }
362
+ export interface Graph {
363
+ nodes: GraphNode[];
364
+ edges: GraphEdge[];
365
+ }
366
+ /** Any other opt is rejected, never dropped. */
367
+ export interface GraphOpts {
368
+ /** Optional layers added to the default graph; they combine. */
369
+ layers?: GraphLayer[];
240
370
  }
241
371
  export interface SearchResult {
242
372
  slug: string;
243
- kind: 'entity' | 'knowledge';
373
+ kind: 'entity' | 'knowledge' | 'session';
244
374
  name: string;
245
375
  snippet: string;
246
376
  type?: EntityType;
@@ -275,10 +405,52 @@ export interface VaultStats {
275
405
  }
276
406
  export interface ChangelogEntry {
277
407
  slug: string;
278
- kind: 'entity' | 'knowledge';
408
+ kind: 'entity' | 'knowledge' | 'session';
279
409
  operation: 'created' | 'updated' | 'deleted';
280
410
  timestamp: string;
281
411
  commit_message: string;
412
+ /** The commit's hash: what tells the entries of one commit from another's. */
413
+ commit: string;
414
+ }
415
+ /** What a write did to a note, as history tells it. */
416
+ export type ChangeOp = typeof CHANGE_OPS[number];
417
+ /** A file history changed between two points, as the Journal names it:
418
+ * vault-relative and '/'-separated, any file (Layout decides which are notes). */
419
+ export interface FileChange {
420
+ path: string;
421
+ op: ChangeOp;
422
+ }
423
+ /** A note created, updated or deleted since a cursor (`changesSince`). */
424
+ export interface NoteChange {
425
+ slug: string;
426
+ kind: VaultKind;
427
+ op: ChangeOp;
428
+ }
429
+ /** What the graph server's `/events` pushes, one per poll that found
430
+ * changes (src/graph/live.ts); the page reads it too. */
431
+ export interface CommitEvent {
432
+ type: 'commit';
433
+ changes: NoteChange[];
434
+ }
435
+ /** One row of the graph server's `/activity` (#278): a note's run of
436
+ * consecutive writes, newest first. `op`, `kind` and `timestamp` are the
437
+ * newest write's; `name` is the slug once the note is gone; `agent` is the
438
+ * agent of the Entity's newest Session Log entry dated the write's local day,
439
+ * absent where the Vault records none. */
440
+ export interface ActivityRow {
441
+ slug: string;
442
+ kind: VaultKind;
443
+ op: ChangeOp;
444
+ timestamp: string;
445
+ name: string;
446
+ count: number;
447
+ agent?: string;
448
+ }
449
+ export interface ChangesSince {
450
+ /** The history point now: pass it back to get what changed after it.
451
+ * Empty when the Vault has no history yet. */
452
+ cursor: string;
453
+ changes: NoteChange[];
282
454
  }
283
455
  export interface BatchResult {
284
456
  op: string;
@@ -313,7 +485,7 @@ export type BatchOperation = {
313
485
  op: 'create';
314
486
  kind: 'session';
315
487
  name: string;
316
- fields?: CreateKnowledgeFields;
488
+ fields?: CreateSessionFields;
317
489
  } | {
318
490
  op: 'capture';
319
491
  name: string;
@@ -355,7 +527,7 @@ export interface BatchValidationReport {
355
527
  valid: boolean;
356
528
  operations: BatchOpValidation[];
357
529
  }
358
- export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'VAULT_LOCKED';
530
+ export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'GIT_READ_FAILED' | 'VAULT_LOCKED';
359
531
  export declare class HafezError extends Error {
360
532
  code: HafezErrorCode;
361
533
  details?: string[] | undefined;
@@ -366,14 +538,17 @@ export interface GitConfig {
366
538
  }
367
539
  /**
368
540
  * The persistence seam (see CONTEXT.md: Journal) — everything core needs
369
- * from the history layer behind vault mutations. Git satisfies it in
370
- * production (src/git.ts); tests may inject an in-memory adapter.
541
+ * from the history layer: the commit behind every vault mutation, sync, and
542
+ * the history reads (changelog, file times, the changes since a cursor). Git
543
+ * satisfies it in production (src/git.ts); tests may inject an in-memory
544
+ * adapter.
371
545
  */
372
546
  export interface Journal {
373
- /** Persist one logical change: stage written+deleted paths (vault-relative)
374
- * and commit under message. Must clean up its own partial state (e.g.
375
- * staged files) before rethrowing on a commit-stage failure; a push-stage
376
- * failure surfaces as GIT_PUSH_FAILED with the commit kept. */
547
+ /** Persist one logical change: stage written+deleted paths (vault-relative,
548
+ * '/'-separated on every platform) and commit under message. Must clean up
549
+ * its own partial state (e.g. staged files) before rethrowing on a
550
+ * commit-stage failure; a push-stage failure surfaces as GIT_PUSH_FAILED
551
+ * with the commit kept. */
377
552
  commit(written: string[], deleted: string[], message: string): Promise<void>;
378
553
  /** Bidirectional sync with the remote, if any. */
379
554
  sync(): Promise<{
@@ -381,10 +556,23 @@ export interface Journal {
381
556
  pushed: boolean;
382
557
  remote: boolean;
383
558
  }>;
384
- /** History-derived changelog for entities/ and knowledge/ since a date. */
385
- changelog(since: string): Promise<ChangelogEntry[]>;
386
- /** Newest history timestamp per vault file (modified = any change, added = creation). */
559
+ /** History-derived changelog for the indexed dirs since a date ('' = all
560
+ * history), newest first; `limit` reads only the newest that many commits.
561
+ * Empty when the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
562
+ changelog(since: string, limit?: number): Promise<ChangelogEntry[]>;
563
+ /** Newest history timestamp per vault file (modified = any change, added = creation).
564
+ * Empty when the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
387
565
  fileTimes(mode: 'modified' | 'added'): Promise<Map<string, string>>;
566
+ /** The history point now (`cursor`, '' before the first commit) and the
567
+ * indexed-dir files whose content differs between `from` and it, each with
568
+ * its net status across the whole span (a rename is a delete and a
569
+ * create). `from` unset: the cursor only, no files; `from` '': every file
570
+ * in history now is a create. An unknown `from` or any other git failure
571
+ * throws `GIT_READ_FAILED`. */
572
+ changesSince(from?: string): Promise<{
573
+ cursor: string;
574
+ files: FileChange[];
575
+ }>;
388
576
  }
389
577
  export interface HafezConfig {
390
578
  vaultPath: string;
@@ -432,9 +620,35 @@ export interface Hafez {
432
620
  items: KnowledgeQueryResult[];
433
621
  total: number;
434
622
  }>;
623
+ query_sessions(opts?: SessionQueryOpts): Promise<{
624
+ items: SessionQueryResult[];
625
+ total: number;
626
+ }>;
627
+ /** An entity's archived log entries (`entities/archive/<slug>-log.md`), as
628
+ * written; null when it has none. Read from disk, never indexed: the
629
+ * archive is reached from its entity on purpose, never found by search. */
630
+ archive(slug: string): Promise<string | null>;
435
631
  validate(): Promise<ValidationReport>;
436
- search(query: string, kind?: 'entity' | 'knowledge' | 'all'): Promise<SearchResult[]>;
632
+ /** The Health pass: what placement would change, from the files on disk
633
+ * (never the index); applies nothing. */
634
+ health(): Promise<HealthReport>;
635
+ search(query: string, kind?: SearchKind): Promise<SearchResult[]>;
636
+ /** The Vault's structure: Entity and Knowledge nodes, Parent, Related (one
637
+ * per unordered pair) and Wiki-link edges among them; `layers` adds tag,
638
+ * domain and Session nodes with their edges. */
639
+ graph(opts?: GraphOpts): Promise<Graph>;
437
640
  rebuildIndex(): Promise<void>;
438
641
  stats(): Promise<VaultStats>;
439
- changelog(since: string): Promise<ChangelogEntry[]>;
642
+ /** The changes since a date ('' = all history), newest first; `limit`
643
+ * reads only the newest that many commits, a positive whole number
644
+ * (anything else is VALIDATION_FAILED). */
645
+ changelog(since: string, limit?: number): Promise<ChangelogEntry[]>;
646
+ /** The notes created, updated or deleted since `cursor`, from history, and
647
+ * the cursor to pass next time. No cursor: the current cursor and no
648
+ * changes, so a follower starts from now. */
649
+ changesSince(cursor?: string): Promise<ChangesSince>;
650
+ /** Release the SQLite index handle. Idempotent; the next call that needs
651
+ * the index reopens it. Call before deleting the vault: Windows cannot
652
+ * remove a directory holding an open file. */
653
+ close(): void;
440
654
  }
package/dist/validate.js CHANGED
@@ -1,29 +1,42 @@
1
1
  import { join, basename } from 'path';
2
- import { parseFilePath, scanVaultDir, kindDir, vaultFileExists } from './vault.js';
3
- import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
2
+ import { parseFilePath, scanVaultDir, kindDir, findReadableFile } from './vault.js';
3
+ import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionFrontmatter } from './schema.js';
4
4
  import { parentCycle } from './batch-ops.js';
5
+ const FRONTMATTER_CHECKS = {
6
+ entity: validateEntityFrontmatter,
7
+ knowledge: validateKnowledgeFrontmatter,
8
+ session: validateSessionFrontmatter,
9
+ };
5
10
  /** The vault integrity scan. Deliberately independent of the SQLite index:
6
11
  * this is the one operation that does not trust the index, so it reads the
7
12
  * files directly. Re-implementing it as an index query was considered and
8
13
  * rejected — an integrity checker that reads the index is checking the
9
14
  * index's model of the vault, not the vault. */
10
15
  export function validateVault(vaultPath) {
11
- const exists = (slug) => vaultFileExists(vaultPath, slug);
16
+ // Any readable file satisfies a link: an invalid slug definitionally does
17
+ // not exist, and must report as missing rather than crash.
18
+ const exists = (slug) => { try {
19
+ return findReadableFile(vaultPath, slug) !== null;
20
+ }
21
+ catch {
22
+ return false;
23
+ } };
12
24
  const report = {
13
25
  broken_slugs: [],
14
26
  orphaned_knowledge: [],
15
27
  missing_fields: [],
16
28
  total_entities: 0,
17
29
  total_knowledge: 0,
30
+ total_sessions: 0,
18
31
  };
19
- const knowledgeSlugs = new Set();
32
+ const knowledgeRelated = new Map();
20
33
  const referencedKnowledge = new Set();
21
34
  const parents = new Map();
22
- // Scan entities
23
- const entDir = join(vaultPath, kindDir('entity'));
24
- {
25
- const files = scanVaultDir(entDir);
26
- report.total_entities = files.length;
35
+ // One scan per kind: parse, the kind's frontmatter check, then every
36
+ // `related` must exist. What a kind adds on top (parent, the orphan
37
+ // bookkeeping) is in `each`.
38
+ function scan(kind, each) {
39
+ const files = scanVaultDir(join(vaultPath, kindDir(kind)));
27
40
  for (const filePath of files) {
28
41
  const slug = basename(filePath, '.md');
29
42
  let fm;
@@ -34,24 +47,29 @@ export function validateVault(vaultPath) {
34
47
  report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
35
48
  continue;
36
49
  }
37
- const errors = validateEntityFrontmatter(fm);
38
- for (const e of errors)
50
+ for (const e of FRONTMATTER_CHECKS[kind](fm))
39
51
  report.missing_fields.push({ slug, field: '', issue: e });
40
- if (fm.parent) {
41
- parents.set(slug, fm.parent);
42
- if (!exists(fm.parent))
43
- report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
44
- }
45
52
  if (fm.related && Array.isArray(fm.related)) {
46
53
  for (const r of fm.related) {
47
54
  if (!exists(r))
48
55
  report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
49
- // Track referenced knowledge
50
- referencedKnowledge.add(r);
51
56
  }
52
57
  }
58
+ each(slug, fm);
53
59
  }
60
+ return files.length;
54
61
  }
62
+ report.total_entities = scan('entity', (slug, fm) => {
63
+ if (fm.parent) {
64
+ parents.set(slug, fm.parent);
65
+ if (!exists(fm.parent))
66
+ report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
67
+ }
68
+ // Track referenced knowledge
69
+ if (Array.isArray(fm.related))
70
+ for (const r of fm.related)
71
+ referencedKnowledge.add(r);
72
+ });
55
73
  // Parent cycles: reported once each, under the first member found
56
74
  const inCycle = new Set();
57
75
  for (const [slug, parent] of parents) {
@@ -64,46 +82,16 @@ export function validateVault(vaultPath) {
64
82
  inCycle.add(s);
65
83
  report.broken_slugs.push({ slug, field: 'parent', issue: `parent cycle: ${cycle.join(' → ')}` });
66
84
  }
67
- // Scan knowledge
68
- const knDir = join(vaultPath, kindDir('knowledge'));
69
- {
70
- const files = scanVaultDir(knDir);
71
- report.total_knowledge = files.length;
72
- for (const filePath of files) {
73
- const slug = basename(filePath, '.md');
74
- knowledgeSlugs.add(slug);
75
- let fm;
76
- try {
77
- fm = parseFilePath(filePath).frontmatter;
78
- }
79
- catch {
80
- report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
81
- continue;
82
- }
83
- const errors = validateKnowledgeFrontmatter(fm);
84
- for (const e of errors)
85
- report.missing_fields.push({ slug, field: '', issue: e });
86
- if (fm.related && Array.isArray(fm.related)) {
87
- for (const r of fm.related) {
88
- if (!exists(r))
89
- report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
90
- }
91
- }
92
- }
93
- }
85
+ report.total_knowledge = scan('knowledge', (slug, fm) => {
86
+ knowledgeRelated.set(slug, Array.isArray(fm.related) ? fm.related.length : 0);
87
+ });
88
+ // A session with no related is never an orphan — a session that touched
89
+ // nothing is legitimate.
90
+ report.total_sessions = scan('session', () => { });
94
91
  // Find orphaned knowledge: no related, and not referenced by any entity
95
- for (const slug of knowledgeSlugs) {
96
- let fm;
97
- try {
98
- fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
99
- }
100
- catch {
101
- continue; // already reported above
102
- }
103
- const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
104
- if (!hasRelated && !referencedKnowledge.has(slug)) {
92
+ for (const [slug, related] of knowledgeRelated) {
93
+ if (related === 0 && !referencedKnowledge.has(slug))
105
94
  report.orphaned_knowledge.push(slug);
106
- }
107
95
  }
108
96
  return report;
109
97
  }
package/dist/vault.d.ts CHANGED
@@ -1,21 +1,32 @@
1
- import type { ParsedVaultFile } from './types.js';
1
+ import type { ParsedVaultFile, VaultKind } from './types.js';
2
2
  export declare function slugify(name: string): string;
3
- export type VaultKind = 'entity' | 'knowledge' | 'session';
4
- /** Kinds that live in the SQLite read index (sessions are files-and-git only). */
5
- export declare const INDEXED_KINDS: ReadonlyArray<'entity' | 'knowledge'>;
3
+ export type { VaultKind };
4
+ /** Kinds that live in the SQLite read index: every kind. Sessions joined in
5
+ * #35 — before that they were files-and-git only, for no recorded reason. */
6
+ export declare const INDEXED_KINDS: ReadonlyArray<VaultKind>;
7
+ /** The two kinds a mutation can address. A session note is written once and
8
+ * read; `update`, `link`, `promote` never resolve one. */
9
+ export declare const MUTABLE_KINDS: ReadonlyArray<'entity' | 'knowledge'>;
6
10
  /** Vault directory for a kind, relative to the vault root. */
7
11
  export declare function kindDir(kind: VaultKind): string;
8
12
  /** A vault content file: anything a sync/merge must treat semantically. */
9
13
  export declare const VAULT_FILE_RE: RegExp;
10
- /** True for vault-relative paths whose kind lives in the read index. */
11
- export declare function isIndexedRelPath(relPath: string): boolean;
14
+ /** True for vault-relative paths of a mutable kind — what index.md shows. */
15
+ export declare function isMutableRelPath(relPath: string): boolean;
12
16
  /** History pathspecs covering the indexed dirs (changelog, file times). */
13
17
  export declare const INDEXED_DIR_PATHSPECS: ReadonlyArray<string>;
14
18
  /** Classify a vault-relative path into an indexed kind + slug; null outside
15
19
  * the indexed dirs (sessions, non-vault files). Nested paths keep their
16
20
  * historical shape: entities/archive/x-log.md → entity 'archive/x-log'. */
17
21
  export declare function classifyIndexedRelPath(relPath: string): {
18
- kind: 'entity' | 'knowledge';
22
+ kind: VaultKind;
23
+ slug: string;
24
+ } | null;
25
+ /** The note a vault-relative ('/'-separated) path is, or null: the files
26
+ * scanVaultDir lists — a non-dot `.md` directly in a kind dir. Archive logs,
27
+ * scaffold files and anything outside the kind dirs are not notes. */
28
+ export declare function noteAtRelPath(relPath: string): {
29
+ kind: VaultKind;
19
30
  slug: string;
20
31
  } | null;
21
32
  /** All .md files directly in dir — non-recursive (archive/ subdirs are
@@ -25,9 +36,18 @@ export declare function scanVaultDir(dirPath: string): string[];
25
36
  /** Where an entity's overflow session-log entries are archived. */
26
37
  export declare function archiveLogPath(vaultPath: string, slug: string): string;
27
38
  export declare function resolveFilePath(vaultPath: string, slug: string, kind: VaultKind): string;
28
- /** The file a slug names, or null — entities are resolved before knowledge,
29
- * the one resolution order every operation sees. */
39
+ /** The inverse of resolveFilePath / archiveLogPath: a vault file as the
40
+ * Journal names it — vault-relative and '/'-separated on every platform.
41
+ * Everything downstream compares these lists as POSIX paths: stageablePaths
42
+ * (src/git.ts) keeps an absent path only if `git ls-files` echoes it back,
43
+ * always with '/', so a '\' deletion was dropped and never staged; and the
44
+ * index.md gate (isMutableRelPath) never matched a '\' path (#187). */
45
+ export declare function vaultRel(vaultPath: string, filePath: string): string;
46
+ /** The mutable file a slug names, or null — entities are resolved before
47
+ * knowledge, the one resolution order every mutation sees. */
30
48
  export declare function findVaultFile(vaultPath: string, slug: string): string | null;
49
+ /** The file `read` resolves: the mutable kinds first, then a session note. */
50
+ export declare function findReadableFile(vaultPath: string, slug: string): string | null;
31
51
  /** Does this slug name a vault file? An invalid slug definitionally doesn't
32
52
  * exist — reference checks (create parent/related, link targets, validate)
33
53
  * must report "does not exist", not crash. validate() in particular exists to