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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Hafez
2
2
 
3
+ [![CI](https://github.com/shrvn-r/hafez/actions/workflows/public.yml/badge.svg?branch=main)](https://github.com/shrvn-r/hafez/actions/workflows/public.yml)
4
+
3
5
  **A knowledge and project vault shared between you and your agents.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. Plain markdown files you own, backed by SQLite full-text search and git.
4
6
 
5
7
  ## Why
@@ -77,6 +79,23 @@ plus `--allow-remote=root`, which npm 12+ requires for a remote URL:
77
79
  npm install -g https://github.com/shrvn-r/hafez/releases/latest/download/hafez.tgz --allow-scripts=better-sqlite3 --allow-remote=root
78
80
  ```
79
81
 
82
+ ### Cloud sessions
83
+
84
+ A Claude Code cloud session (claude.ai/code, the mobile app) starts in a fresh
85
+ container: no `hafez`, no vault, no Block. Keep the vault in a private GitHub
86
+ repo and print the environment's setup script for it:
87
+
88
+ ```bash
89
+ hafez onboard --cloud --vault-repo <owner>/<vault-repo>
90
+ ```
91
+
92
+ Paste the script into the cloud environment's Setup script; the Trusted network
93
+ policy is enough. It installs Hafez, registers the vault when it is attached to
94
+ the session, and writes a Block with one rule more: a session started without
95
+ the vault attached attaches it, clones it and registers it, with one approval
96
+ from you. The script never clones the vault itself, because it runs without
97
+ GitHub credentials. Writes push to the vault's `main` like anywhere else.
98
+
80
99
  ## Updating
81
100
 
82
101
  Tell your agent: **update hafez**. Or paste: "Run `hafez upgrade` and follow
@@ -148,6 +167,9 @@ hafez query --kind knowledge --domain security
148
167
  # Full-text search across the whole vault
149
168
  hafez search "token rotation"
150
169
 
170
+ # See it in context: a live graph of the vault in your browser
171
+ hafez graph auth-refactor
172
+
151
173
  # Record progress
152
174
  hafez update auth-refactor --log "progress: JWT flow implemented"
153
175
  hafez update auth-refactor --complete-action "migration plan"
@@ -161,7 +183,7 @@ Three kinds of documents, all markdown + YAML frontmatter:
161
183
 
162
184
  - **Entities** (`entities/*.md`) — the things you're working on. Types: `capture` (inbox item), `entity` (anything worth tracking), `project` (has outcomes). Statuses: `active | paused | done`. Bodies start from a per-type template (capture: `## Notes`; entity: `## Context`; project: `## Purpose` + `## Goals`) plus a `## Session Log` of dated agent entries; `## Brief` (freeform handoff context) and `## Next Actions` (checkbox list) appear when first written to.
163
185
  - **Knowledge** (`knowledge/*.md`) — durable insights that outlive any one project. Subtypes `insight | plan`; confidence levels `observation → pattern → principle` (promoted manually). Bodies hold `## Synthesis`, `## Evidence`, and `## Sources` — evidence accumulates over time via `--add-evidence`.
164
- - **Sessions** (`sessions/*.md`) — session summaries written via `batch`, kept as history, not indexed.
186
+ - **Sessions** (`sessions/*.md`) — session summaries written at save time, never updated. Readable (`hafez read <slug>`), listed newest first (`hafez query --kind session`), and the look back from an entity (`hafez query --related-to <slug>`); search and query reach them only under `--kind session` or `--kind all`.
165
187
 
166
188
  Frontmatter at a glance:
167
189
 
@@ -175,15 +197,57 @@ Frontmatter at a glance:
175
197
  | `resource` (canonical URI) | optional | — |
176
198
  | `subtype`, `confidence` | — | optional |
177
199
 
178
- Links (`parent`, `related`) are validated — they must point at slugs that exist. `hafez validate` checks vault integrity.
200
+ Links (`parent`, `related`) are validated — they must point at slugs that exist. A `related` link between two entities, or two knowledge notes, is kept on both files. `hafez validate` checks vault integrity.
201
+
202
+ ## Where your judgement matters
203
+
204
+ The agent writes to your vault as work lands, without asking. Behind a few of those writes is a judgement no rule in the tool can make, and the Health pass's proposals wait for your yes. Each choice is below as the question, the test, and what happens once it is made, so you can make it, or check the one the agent made.
205
+
206
+ ### 1. Child or related?
207
+
208
+ **Question:** one piece of work bears on another. Is it part of it, or beside it?
209
+
210
+ **Test:** would it carry on if the other were finished or dropped? If not, it is a **Child**. If so, the two are **Related**.
211
+
212
+ **Then:** a Child carries `parent`, the one mark of childhood, and the parent's `read` lists it. Related between two entities, or two knowledge notes, is one edge the tool keeps on both files. A pair is never both: a `related` naming a parent or a child is rejected, and so is a `--parent` naming a related entity. A parent whose own work is coordination only is a **Hub**.
213
+
214
+ ### 2. Split a Workstream or not?
215
+
216
+ **Question:** an entity's `read` shows a `workstream:` line: its actions share an ALL-CAPS prefix that no Child or related entity carries in its name. Is it a real thread of work, or a habit of phrasing?
217
+
218
+ **Test:** would you track it, brief it and finish it apart from the parent?
219
+
220
+ **Then:** split, and the agent does it with ordinary writes: a Child is created under the parent, the open actions move to it, the log entries stay where they are, and one `decision` entry on the parent records the split. Don't, and nothing is written: the line is a fact, not a request, and it goes once those actions are done.
221
+
222
+ ### 3. Decision or lesson?
223
+
224
+ **Question:** something was settled. Where does it live?
225
+
226
+ **Test:** is it still true with this entity gone, no slug, path or "we chose" needed? If not, it is a **Decision**; if so, a lesson.
227
+
228
+ **Then:** a decision is one `decision` entry on the log of the entity that owns the work (or an entry pointing at the repo's own record). A lesson is a knowledge note, related to where it came from. One fact, one place: a knowledge note that only repeats a decision is the Health pass's "restated decision".
229
+
230
+ ### 4. Retire or delete?
231
+
232
+ **Question:** something in the vault is no longer wanted.
233
+
234
+ **Test:** is it an entity? Then it is retired. A knowledge or session note can be deleted.
235
+
236
+ **Then:** retiring sets `--status done`: the entity stays, readable and linked, and leaves the live views unless a Child of it is not yet done. Deleting removes the note in one commit, together with the back-edges it owns (the notes its own `related` names that name it back); any other entity or knowledge note naming it as `parent` or `related` refuses the delete until that link goes. Every write is a git commit, so either can be undone.
237
+
238
+ ### 5. What `hafez health` lists
239
+
240
+ `hafez health` is the **Health pass**: it reads the vault and lists what placement would change, applying nothing. Run it, or ask the agent to check the vault; a sample of its output is [`health.md`](tests/fixtures/surfaces/health.md).
241
+
242
+ Its six groups take three kinds of answer. **One-way edges** (a `related` kept on one end only) and **unlinked mentions** (a `[[slug]]` with no edge either way) are mechanical: each comes with the `link` line that adds the edge the vault already implies, so one yes can cover the group. **Workstreams** are question 2, one entity at a time. **Leakage** (a parent's, or a related entity's, log naming an entity since that entity was created: was that its work?), **restated decisions** (question 3) and **recurring names** (a name that keeps coming back in one entity's log and actions) are judged one by one: nothing is proposed for them.
179
243
 
180
244
  ## CLI overview
181
245
 
182
246
  | Command | What it does |
183
247
  |---|---|
184
- | `hafez read <slug> [--depth frontmatter\|summary\|full]` | Read an entity or knowledge note |
185
- | `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
186
- | `hafez search <terms> [--kind ...]` | FTS5 full-text search |
248
+ | `hafez read <slug> [--depth frontmatter\|summary\|full] [--archive]` | Read an entity, knowledge or session note; `--archive` an entity's archived log entries |
249
+ | `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|session\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
250
+ | `hafez search <terms> [--kind entity\|knowledge\|session\|all]` | FTS5 full-text search (default: entities and knowledge) |
187
251
  | `hafez create <entity\|knowledge> <name> [flags]` | Create a document |
188
252
  | `hafez capture <name> [--notes]` | Quick inbox capture |
189
253
  | `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
@@ -193,10 +257,13 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
193
257
  | `hafez batch [--dry-run] [--data '<json>' \| --file payload.json]` | JSON ops (stdin, `--data` or `--file`) → atomic multi-op, single git commit, rollback on failure; dry-run runs the same validation as apply and reports derived slugs |
194
258
  | `hafez stats` | Vault summary: counts, stale items, recents |
195
259
  | `hafez changelog --since 7.days.ago` | Git-derived change history |
260
+ | `hafez graph [slug] [--port N] [--no-open]` | Interactive, live graph of the vault in the browser (served on 127.0.0.1); a slug opens it focused on that note; `--json [--layers tag,domain,session]` prints `{nodes, edges}` |
196
261
  | `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
197
262
  | `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
263
+ | `hafez health` | What placement would change: unsplit workstreams, recurring names, leakage, one-way `related` edges and unlinked `[[slug]]` mentions (each with its `hafez link` line), restated decisions; applies nothing |
198
264
  | `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
199
265
  | `hafez onboard --install --mode <always-on\|invoke-only>` / `--remove` | Write (or remove) the two skills and, always-on, the marked Block in the harness's instructions file |
266
+ | `hafez onboard --cloud --vault-repo <owner>/<repo>` | Print the setup script for Claude Code cloud sessions |
200
267
  | `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
201
268
  | `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
202
269
 
package/dist/apply.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type VaultKind } from './vault.js';
1
2
  import type { BatchOperation, BatchResult, CreateEntityFields, DeletableKind, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields, UpdateResult } from './types.js';
2
3
  export interface AppliedOp {
3
4
  filesWritten: string[];
@@ -7,7 +8,7 @@ export interface AppliedOp {
7
8
  indexRemovals: string[];
8
9
  indexUpserts: Array<{
9
10
  slug: string;
10
- kind: 'entity' | 'knowledge';
11
+ kind: VaultKind;
11
12
  }>;
12
13
  /** Set by the update arm: the line each Next Actions query matched. */
13
14
  updateResult?: UpdateResult;
@@ -25,6 +26,18 @@ export interface ApplyContext {
25
26
  export declare function validateKindFields(kind: 'entity' | 'knowledge', fields: UpdateFields, subtype?: KnowledgeSubtype): void;
26
27
  export declare function buildEntityFrontmatter(name: string, ef: CreateEntityFields, existsFn: (slug: string) => boolean): Record<string, any>;
27
28
  export declare function buildKnowledgeFrontmatter(name: string, kf: CreateKnowledgeFields, existsFn: (slug: string) => boolean): Record<string, any>;
29
+ /** The mirror file an edge op touched: what the arm appends to its
30
+ * filesWritten and indexUpserts. */
31
+ export interface EdgeTouch {
32
+ file: string;
33
+ upsert: {
34
+ slug: string;
35
+ kind: VaultKind;
36
+ };
37
+ }
38
+ /** Drop `a` from `b`'s related when the edge is same-kind. Exported for the
39
+ * delete arm (#243) as well. */
40
+ export declare function dropBackEdge(ctx: ApplyContext, a: string, aKind: VaultKind, b: string): EdgeTouch | null;
28
41
  /** The single apply path for every mutation. Writes vault files but never
29
42
  * touches git or SQLite: the Spine commits [...filesWritten, ...filesDeleted]
30
43
  * under commitMessage (single ops) or one composed batch message, then runs
package/dist/apply.js CHANGED
@@ -18,8 +18,8 @@
18
18
  // pure (no filesystem access); the frontmatter builders live here and are
19
19
  // exported so the batch validate phase can run them against a simulated vault.
20
20
  import { writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
21
- import { join, relative, dirname } from 'path';
22
- import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
21
+ import { join, dirname } from 'path';
22
+ import { parseFilePath, serializeFile, slugify, resolveFilePath, vaultRel, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
23
23
  import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
24
24
  import { bodyTemplate, knowledgeBodyTemplate, sessionBodyTemplate } from './templates.js';
25
25
  import { formatSessionLogEntry, stampAction, documentFor, documentForFile } from './document.js';
@@ -369,6 +369,42 @@ function withMechanisedLog(kind, fields) {
369
369
  },
370
370
  };
371
371
  }
372
+ function mirrorEdge(ctx, a, aKind, b, mode) {
373
+ if (a === b)
374
+ return null;
375
+ const bPath = findVaultFile(ctx.vaultPath, b);
376
+ if (!bPath || kindFromPath(bPath) !== aKind)
377
+ return null;
378
+ const parsed = parseFilePath(bPath);
379
+ const fm = parsed.frontmatter;
380
+ const related = fm.related ?? [];
381
+ if (mode === 'write' ? related.includes(a) : !related.includes(a))
382
+ return null;
383
+ ctx.captureOriginal(bPath);
384
+ if (mode === 'write')
385
+ fm.related = [...related, a];
386
+ else {
387
+ fm.related = related.filter(r => r !== a);
388
+ if (fm.related.length === 0)
389
+ delete fm.related;
390
+ }
391
+ const body = documentForFile(parsed).syncRelatedSection(parsed.body, fm);
392
+ writeFileSync(bPath, serializeFile(fm, body));
393
+ return { file: vaultRel(ctx.vaultPath, bPath), upsert: { slug: b, kind: aKind } };
394
+ }
395
+ /** Write `a` into `b`'s related when the edge is same-kind. */
396
+ function writeBackEdge(ctx, a, aKind, b) {
397
+ return mirrorEdge(ctx, a, aKind, b, 'write');
398
+ }
399
+ /** Write `a` into every same-kind target's related. */
400
+ function backEdges(ctx, a, aKind, targets) {
401
+ return targets.flatMap(b => writeBackEdge(ctx, a, aKind, b) ?? []);
402
+ }
403
+ /** Drop `a` from `b`'s related when the edge is same-kind. Exported for the
404
+ * delete arm (#243) as well. */
405
+ export function dropBackEdge(ctx, a, aKind, b) {
406
+ return mirrorEdge(ctx, a, aKind, b, 'drop');
407
+ }
372
408
  // --- The arms ---
373
409
  function applyUpdate(op, ctx) {
374
410
  const { vaultPath } = ctx;
@@ -379,6 +415,9 @@ function applyUpdate(op, ctx) {
379
415
  const doc = documentForFile(parsedFile);
380
416
  let fm = parsedFile.frontmatter;
381
417
  let body = parsedFile.body;
418
+ // The related list before the fields replace it: the diff against the new
419
+ // one is what the edge writer and dropper mirror on the targets.
420
+ const relatedBefore = fm.related ?? [];
382
421
  const applied = applyUpdateFields(doc, fm, body, withMechanisedLog(kind, op.fields));
383
422
  fm = applied.fm;
384
423
  body = applied.body;
@@ -400,19 +439,30 @@ function applyUpdate(op, ctx) {
400
439
  }
401
440
  body = doc.syncRelatedSection(body, fm);
402
441
  writeFileSync(filePath, serializeFile(fm, body));
442
+ const touches = [];
443
+ if (op.fields.related !== undefined) {
444
+ const relatedAfter = fm.related ?? [];
445
+ touches.push(...backEdges(ctx, op.slug, kind, relatedAfter.filter(r => !relatedBefore.includes(r))));
446
+ for (const r of relatedBefore.filter(r => !relatedAfter.includes(r))) {
447
+ const t = dropBackEdge(ctx, op.slug, kind, r);
448
+ if (t)
449
+ touches.push(t);
450
+ }
451
+ }
403
452
  // Only the files THIS update wrote. Keying off the archive file merely
404
453
  // existing swept any external hand-edit sitting in entities/archive/
405
454
  // into an unrelated commit.
406
- const files = [relative(vaultPath, filePath)];
455
+ const files = [vaultRel(vaultPath, filePath)];
407
456
  if (wroteArchive)
408
- files.push(relative(vaultPath, archivePath));
457
+ files.push(vaultRel(vaultPath, archivePath));
458
+ files.push(...touches.map(t => t.file));
409
459
  return {
410
460
  filesWritten: files,
411
461
  filesDeleted: [],
412
462
  result: { op: 'update', slug: op.slug, status: 'ok' },
413
463
  commitMessage: `update: ${op.slug}`,
414
464
  indexRemovals: [],
415
- indexUpserts: [{ slug: op.slug, kind }],
465
+ indexUpserts: [{ slug: op.slug, kind }, ...touches.map(t => t.upsert)],
416
466
  updateResult: applied.updateResult,
417
467
  };
418
468
  }
@@ -432,13 +482,14 @@ function applyCreateEntity(op, ctx) {
432
482
  mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
433
483
  body = documentFor({ kind: 'entity' }).syncRelatedSection(body, fm);
434
484
  writeFileSync(filePath, serializeFile(fm, body));
485
+ const touches = backEdges(ctx, slug, 'entity', fm.related ?? []);
435
486
  return {
436
- filesWritten: [relative(vaultPath, filePath)],
487
+ filesWritten: [vaultRel(vaultPath, filePath), ...touches.map(t => t.file)],
437
488
  filesDeleted: [],
438
489
  result: { op: 'create', slug, status: 'ok', created: true },
439
490
  commitMessage: `create: ${op.name}`,
440
491
  indexRemovals: [],
441
- indexUpserts: [{ slug, kind: 'entity' }],
492
+ indexUpserts: [{ slug, kind: 'entity' }, ...touches.map(t => t.upsert)],
442
493
  };
443
494
  }
444
495
  function applyCreateKnowledge(op, ctx) {
@@ -454,13 +505,14 @@ function applyCreateKnowledge(op, ctx) {
454
505
  mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
455
506
  body = documentFor({ kind: 'knowledge' }).syncRelatedSection(body, fm);
456
507
  writeFileSync(filePath, serializeFile(fm, body));
508
+ const touches = backEdges(ctx, slug, 'knowledge', fm.related ?? []);
457
509
  return {
458
- filesWritten: [relative(vaultPath, filePath)],
510
+ filesWritten: [vaultRel(vaultPath, filePath), ...touches.map(t => t.file)],
459
511
  filesDeleted: [],
460
512
  result: { op: 'create', slug, status: 'ok', created: true },
461
513
  commitMessage: `create: ${op.name}`,
462
514
  indexRemovals: [],
463
- indexUpserts: [{ slug, kind: 'knowledge' }],
515
+ indexUpserts: [{ slug, kind: 'knowledge' }, ...touches.map(t => t.upsert)],
464
516
  };
465
517
  }
466
518
  function applyCreateSession(op, ctx) {
@@ -480,25 +532,23 @@ function applyCreateSession(op, ctx) {
480
532
  appeared(slug);
481
533
  ctx.captureOriginal(filePath);
482
534
  const fm = { name: op.name, created: today() };
483
- const kf = (op.fields ?? {});
535
+ const kf = op.fields ?? {};
484
536
  if (kf['session-date'])
485
537
  fm['session-date'] = kf['session-date'];
486
538
  if (kf.related?.length)
487
539
  fm.related = kf.related;
488
540
  // Session sections come from contracts.SESSION_SECTIONS — the same list
489
541
  // Document splits on, so the written body and the structural set agree.
490
- let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis });
542
+ let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis, decisions: kf.decisions, entities_touched: kf.entities_touched });
491
543
  sessionBody = documentFor({ kind: 'session' }).syncRelatedSection(sessionBody, fm);
492
544
  writeFileSync(filePath, serializeFile(fm, sessionBody));
493
- // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
494
- // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
495
545
  return {
496
- filesWritten: [relative(vaultPath, filePath)],
546
+ filesWritten: [vaultRel(vaultPath, filePath)],
497
547
  filesDeleted: [],
498
548
  result: { op: 'create', slug, status: 'ok', created: true },
499
549
  commitMessage: `create: ${op.name}`,
500
550
  indexRemovals: [],
501
- indexUpserts: [],
551
+ indexUpserts: [{ slug, kind: 'session' }],
502
552
  };
503
553
  }
504
554
  function applyCapture(op, ctx) {
@@ -518,7 +568,7 @@ function applyCapture(op, ctx) {
518
568
  mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
519
569
  writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
520
570
  return {
521
- filesWritten: [relative(vaultPath, filePath)],
571
+ filesWritten: [vaultRel(vaultPath, filePath)],
522
572
  filesDeleted: [],
523
573
  result: { op: 'capture', slug, status: 'ok', created: true },
524
574
  commitMessage: `capture: ${op.name}`,
@@ -533,6 +583,8 @@ function applyLink(op, ctx) {
533
583
  const parsedFile = parseFilePath(filePath);
534
584
  const fm = parsedFile.frontmatter;
535
585
  let body = parsedFile.body;
586
+ const kind = kindFromPath(filePath);
587
+ let touch = null;
536
588
  if (op.relation === 'parent') {
537
589
  fm.parent = op.target;
538
590
  }
@@ -541,16 +593,17 @@ function applyLink(op, ctx) {
541
593
  if (!related.includes(op.target))
542
594
  related.push(op.target);
543
595
  fm.related = related;
596
+ touch = writeBackEdge(ctx, op.slug, kind, op.target);
544
597
  }
545
598
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
546
599
  writeFileSync(filePath, serializeFile(fm, body));
547
600
  return {
548
- filesWritten: [relative(vaultPath, filePath)],
601
+ filesWritten: [vaultRel(vaultPath, filePath), ...(touch ? [touch.file] : [])],
549
602
  filesDeleted: [],
550
603
  result: { op: 'link', slug: op.slug, status: 'ok' },
551
604
  commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
552
605
  indexRemovals: [],
553
- indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
606
+ indexUpserts: [{ slug: op.slug, kind }, ...(touch ? [touch.upsert] : [])],
554
607
  };
555
608
  }
556
609
  function applyUnlink(op, ctx) {
@@ -560,23 +613,30 @@ function applyUnlink(op, ctx) {
560
613
  const parsedFile = parseFilePath(filePath);
561
614
  const fm = parsedFile.frontmatter;
562
615
  let body = parsedFile.body;
616
+ const kind = kindFromPath(filePath);
617
+ let touch = null;
563
618
  if (op.relation === 'parent' && fm.parent === op.target) {
564
619
  delete fm.parent;
565
620
  }
566
- else if (op.relation === 'related' && fm.related) {
567
- fm.related = fm.related.filter((r) => r !== op.target);
568
- if (fm.related.length === 0)
569
- delete fm.related;
621
+ else if (op.relation === 'related') {
622
+ if (fm.related) {
623
+ fm.related = fm.related.filter((r) => r !== op.target);
624
+ if (fm.related.length === 0)
625
+ delete fm.related;
626
+ }
627
+ // The target's end goes too, even when this file never carried the edge
628
+ // (a legacy one-way edge).
629
+ touch = dropBackEdge(ctx, op.slug, kind, op.target);
570
630
  }
571
631
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
572
632
  writeFileSync(filePath, serializeFile(fm, body));
573
633
  return {
574
- filesWritten: [relative(vaultPath, filePath)],
634
+ filesWritten: [vaultRel(vaultPath, filePath), ...(touch ? [touch.file] : [])],
575
635
  filesDeleted: [],
576
636
  result: { op: 'unlink', slug: op.slug, status: 'ok' },
577
637
  commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
578
638
  indexRemovals: [],
579
- indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
639
+ indexUpserts: [{ slug: op.slug, kind }, ...(touch ? [touch.upsert] : [])],
580
640
  };
581
641
  }
582
642
  function applyPromote(op, ctx) {
@@ -631,10 +691,10 @@ function applyPromote(op, ctx) {
631
691
  catch (err) {
632
692
  throw new HafezError('VALIDATION_FAILED', `Failed to promote '${op.slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
633
693
  }
634
- modifiedFiles.push(relative(vaultPath, newPath));
694
+ modifiedFiles.push(vaultRel(vaultPath, newPath));
635
695
  // The deletion must be staged too — `git add` of a removed path stages
636
696
  // the removal; omitting it leaves the vault permanently dirty.
637
- deletedFiles.push(relative(vaultPath, filePath));
697
+ deletedFiles.push(vaultRel(vaultPath, filePath));
638
698
  }
639
699
  else {
640
700
  // Same-kind: capture→entity, capture→project, entity→project
@@ -661,7 +721,7 @@ function applyPromote(op, ctx) {
661
721
  }));
662
722
  newBody = doc.syncRelatedSection(newBody, fm);
663
723
  writeFileSync(filePath, serializeFile(fm, newBody));
664
- modifiedFiles.push(relative(vaultPath, filePath));
724
+ modifiedFiles.push(vaultRel(vaultPath, filePath));
665
725
  }
666
726
  return {
667
727
  filesWritten: modifiedFiles,
@@ -683,15 +743,17 @@ function applyDelete(op, ctx) {
683
743
  vanished(op.slug);
684
744
  // The snapshot is the rollback: a commit-stage failure writes it back.
685
745
  ctx.captureOriginal(filePath);
746
+ // The other end of every same-kind edge the note owns goes with it (#243).
747
+ const own = kind === 'knowledge' ? parseFilePath(filePath).frontmatter.related ?? [] : [];
686
748
  unlinkSync(filePath);
749
+ const touches = own.flatMap(r => dropBackEdge(ctx, op.slug, 'knowledge', r) ?? []);
687
750
  return {
688
- filesWritten: [],
689
- filesDeleted: [relative(vaultPath, filePath)],
751
+ filesWritten: touches.map(t => t.file),
752
+ filesDeleted: [vaultRel(vaultPath, filePath)],
690
753
  result: { op: 'delete', slug: op.slug, status: 'ok' },
691
754
  commitMessage: `delete: ${op.slug} (${kind})`,
692
- // Sessions are not indexed; a knowledge row goes with its file.
693
- indexRemovals: kind === 'knowledge' ? [op.slug] : [],
694
- indexUpserts: [],
755
+ indexRemovals: [op.slug],
756
+ indexUpserts: touches.map(t => t.upsert),
695
757
  deletedKind: kind,
696
758
  };
697
759
  }
@@ -34,12 +34,12 @@ export declare const UpdateFieldsSchema: z.ZodObject<{
34
34
  parent: z.ZodOptional<z.ZodString>;
35
35
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
36
36
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
37
- domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
37
+ domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
38
38
  confidence: z.ZodOptional<z.ZodEnum<{
39
39
  [x: string]: string;
40
40
  }>>;
41
- tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
42
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
41
+ tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
42
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
43
43
  synthesis: z.ZodOptional<z.ZodString>;
44
44
  insight: z.ZodOptional<z.ZodString>;
45
45
  add_evidence: z.ZodOptional<z.ZodString>;
@@ -52,10 +52,10 @@ export declare const CreateEntityFieldsSchema: z.ZodObject<{
52
52
  purpose: z.ZodOptional<z.ZodString>;
53
53
  description: z.ZodOptional<z.ZodString>;
54
54
  resource: z.ZodOptional<z.ZodString>;
55
- domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
55
+ domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
56
56
  parent: z.ZodOptional<z.ZodString>;
57
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
58
- tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
57
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
58
+ tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
59
59
  brief: z.ZodOptional<z.ZodString>;
60
60
  add_action: z.ZodOptional<z.ZodString>;
61
61
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -73,16 +73,18 @@ export declare const CreateKnowledgeFieldsSchema: z.ZodObject<{
73
73
  synthesis: z.ZodOptional<z.ZodString>;
74
74
  insight: z.ZodOptional<z.ZodString>;
75
75
  description: z.ZodOptional<z.ZodString>;
76
- domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
77
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
78
- tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
76
+ domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
77
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
78
+ tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
79
79
  'session-date': z.ZodOptional<z.ZodString>;
80
80
  }, z.core.$strict>;
81
81
  export declare const CreateSessionFieldsSchema: z.ZodObject<{
82
82
  synthesis: z.ZodOptional<z.ZodString>;
83
83
  insight: z.ZodOptional<z.ZodString>;
84
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
84
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
85
85
  'session-date': z.ZodOptional<z.ZodString>;
86
+ decisions: z.ZodOptional<z.ZodArray<z.ZodString>>;
87
+ entities_touched: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
86
88
  }, z.core.$strict>;
87
89
  /** What `digest` may put in a session-create op — typechecked against the
88
90
  * schema so the two can never silently diverge again. */
@@ -119,12 +121,12 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
119
121
  parent: z.ZodOptional<z.ZodString>;
120
122
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
121
123
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
122
- domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
124
+ domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
123
125
  confidence: z.ZodOptional<z.ZodEnum<{
124
126
  [x: string]: string;
125
127
  }>>;
126
- tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
127
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
128
+ tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
129
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
128
130
  synthesis: z.ZodOptional<z.ZodString>;
129
131
  insight: z.ZodOptional<z.ZodString>;
130
132
  add_evidence: z.ZodOptional<z.ZodString>;
@@ -290,12 +292,12 @@ declare const OP_SPEC_ROWS: readonly [{
290
292
  parent: z.ZodOptional<z.ZodString>;
291
293
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
292
294
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
293
- domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
295
+ domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
294
296
  confidence: z.ZodOptional<z.ZodEnum<{
295
297
  [x: string]: string;
296
298
  }>>;
297
- tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
298
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
299
+ tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
300
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
299
301
  synthesis: z.ZodOptional<z.ZodString>;
300
302
  insight: z.ZodOptional<z.ZodString>;
301
303
  add_evidence: z.ZodOptional<z.ZodString>;
@@ -326,10 +328,10 @@ declare const OP_SPEC_ROWS: readonly [{
326
328
  purpose: z.ZodOptional<z.ZodString>;
327
329
  description: z.ZodOptional<z.ZodString>;
328
330
  resource: z.ZodOptional<z.ZodString>;
329
- domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
331
+ domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
330
332
  parent: z.ZodOptional<z.ZodString>;
331
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
332
- tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
333
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
334
+ tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
333
335
  brief: z.ZodOptional<z.ZodString>;
334
336
  add_action: z.ZodOptional<z.ZodString>;
335
337
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -367,9 +369,9 @@ declare const OP_SPEC_ROWS: readonly [{
367
369
  synthesis: z.ZodOptional<z.ZodString>;
368
370
  insight: z.ZodOptional<z.ZodString>;
369
371
  description: z.ZodOptional<z.ZodString>;
370
- domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
371
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
372
- tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
372
+ domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
373
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
374
+ tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
373
375
  'session-date': z.ZodOptional<z.ZodString>;
374
376
  }, z.core.$strict>>;
375
377
  }, z.core.$strict>;
@@ -395,11 +397,13 @@ declare const OP_SPEC_ROWS: readonly [{
395
397
  fields: z.ZodOptional<z.ZodObject<{
396
398
  synthesis: z.ZodOptional<z.ZodString>;
397
399
  insight: z.ZodOptional<z.ZodString>;
398
- related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
400
+ related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
399
401
  'session-date': z.ZodOptional<z.ZodString>;
402
+ decisions: z.ZodOptional<z.ZodArray<z.ZodString>>;
403
+ entities_touched: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
400
404
  }, z.core.$strict>>;
401
405
  }, z.core.$strict>;
402
- readonly description: "Create a new session note linked to an entity. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).";
406
+ readonly description: "Create a new session note linked to an entity. `synthesis` writes Summary, `decisions` one bullet each under Decisions, `entities_touched` one `[[slug]]` bullet each under Entities Touched. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).";
403
407
  readonly exampleInclude: readonly ["synthesis"];
404
408
  readonly check: (op: Extract<BatchOperation, {
405
409
  op: "create";
@@ -445,7 +449,7 @@ declare const OP_SPEC_ROWS: readonly [{
445
449
  related: "related";
446
450
  }>;
447
451
  }, z.core.$strict>;
448
- readonly description: "Add a parent or related link between two entities.";
452
+ readonly description: "Add a parent or related link. A same-kind related link (entity↔entity, knowledge↔knowledge) is written on both files; a related naming the entity's parent or a child is rejected (parent subsumes related).";
449
453
  readonly exampleInclude: readonly [];
450
454
  readonly check: (op: Extract<BatchOperation, {
451
455
  op: "link";
@@ -466,7 +470,7 @@ declare const OP_SPEC_ROWS: readonly [{
466
470
  related: "related";
467
471
  }>;
468
472
  }, z.core.$strict>;
469
- readonly description: "Remove a parent or related link between two entities.";
473
+ readonly description: "Remove a parent or related link. A same-kind related link is dropped from both files, a legacy one-way edge from whichever file carries it.";
470
474
  readonly exampleInclude: readonly [];
471
475
  readonly check: (op: Extract<BatchOperation, {
472
476
  op: "unlink";
@@ -503,7 +507,7 @@ declare const OP_SPEC_ROWS: readonly [{
503
507
  op: z.ZodLiteral<"delete">;
504
508
  slug: z.ZodString;
505
509
  }, z.core.$strict>;
506
- readonly description: "Delete a knowledge or session note. An entity is never deleted — retire it with `hafez update <slug> --status done`. Rejected while another entity or knowledge note links to the note (parent/related): unlink or re-point those first — an earlier op in the same batch counts.";
510
+ readonly description: "Delete a knowledge or session note. An entity is never deleted — retire it with `hafez update <slug> --status done`. The back-edges the note owns (a knowledge note in its own related that names it back) are dropped with it, in the same commit. Rejected while any other entity or knowledge note links to the note (parent/related): unlink or re-point those first — an earlier op in the same batch counts.";
507
511
  readonly exampleInclude: readonly [];
508
512
  readonly addressesSessions: true;
509
513
  readonly check: (op: Extract<BatchOperation, {