hafez 2.2.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 (67) hide show
  1. package/README.md +48 -1
  2. package/dist/apply.d.ts +12 -0
  3. package/dist/apply.js +82 -17
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +80 -15
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +3 -3
  8. package/dist/cli/commands.d.ts +2 -0
  9. package/dist/cli/commands.js +98 -46
  10. package/dist/cli/format.d.ts +4 -1
  11. package/dist/cli/format.js +19 -0
  12. package/dist/cli/index.js +11 -2
  13. package/dist/cli/upgrade.js +1 -1
  14. package/dist/contracts.d.ts +9 -0
  15. package/dist/contracts.js +9 -0
  16. package/dist/dates.d.ts +3 -0
  17. package/dist/dates.js +9 -0
  18. package/dist/db.d.ts +34 -4
  19. package/dist/db.js +163 -21
  20. package/dist/digest.js +14 -7
  21. package/dist/document.d.ts +2 -0
  22. package/dist/document.js +2 -2
  23. package/dist/git.d.ts +13 -2
  24. package/dist/git.js +47 -5
  25. package/dist/graph/activity.d.ts +5 -0
  26. package/dist/graph/activity.js +70 -0
  27. package/dist/graph/client.js +326 -0
  28. package/dist/graph/copy-fonts.d.ts +1 -0
  29. package/dist/graph/copy-fonts.js +13 -0
  30. package/dist/graph/fields.d.ts +8 -0
  31. package/dist/graph/fields.js +12 -0
  32. package/dist/graph/fonts/geist-OFL.txt +93 -0
  33. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  34. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  35. package/dist/graph/fonts/geist.woff2 +0 -0
  36. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  37. package/dist/graph/fonts/outfit.woff2 +0 -0
  38. package/dist/graph/fonts.d.ts +14 -0
  39. package/dist/graph/fonts.js +17 -0
  40. package/dist/graph/live.d.ts +16 -0
  41. package/dist/graph/live.js +99 -0
  42. package/dist/graph/open.d.ts +1 -0
  43. package/dist/graph/open.js +20 -0
  44. package/dist/graph/server.d.ts +17 -0
  45. package/dist/graph/server.js +189 -0
  46. package/dist/health.d.ts +6 -0
  47. package/dist/health.js +310 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +37 -9
  50. package/dist/internal.d.ts +21 -0
  51. package/dist/internal.js +9 -0
  52. package/dist/read-json.d.ts +26 -0
  53. package/dist/read-json.js +53 -0
  54. package/dist/stale.d.ts +2 -2
  55. package/dist/stale.js +4 -9
  56. package/dist/surfaces/items.d.ts +44 -0
  57. package/dist/surfaces/items.js +68 -9
  58. package/dist/surfaces/surfaces.js +3 -1
  59. package/dist/templates.d.ts +2 -0
  60. package/dist/templates.js +6 -1
  61. package/dist/types.d.ts +178 -8
  62. package/dist/vault.d.ts +9 -2
  63. package/dist/vault.js +10 -0
  64. package/dist/workstream.d.ts +17 -0
  65. package/dist/workstream.js +78 -0
  66. package/package.json +9 -3
  67. package/skill/hafez/SKILL.md +12 -6
package/README.md CHANGED
@@ -167,6 +167,9 @@ hafez query --kind knowledge --domain security
167
167
  # Full-text search across the whole vault
168
168
  hafez search "token rotation"
169
169
 
170
+ # See it in context: a live graph of the vault in your browser
171
+ hafez graph auth-refactor
172
+
170
173
  # Record progress
171
174
  hafez update auth-refactor --log "progress: JWT flow implemented"
172
175
  hafez update auth-refactor --complete-action "migration plan"
@@ -194,7 +197,49 @@ Frontmatter at a glance:
194
197
  | `resource` (canonical URI) | optional | — |
195
198
  | `subtype`, `confidence` | — | optional |
196
199
 
197
- 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.
198
243
 
199
244
  ## CLI overview
200
245
 
@@ -212,8 +257,10 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
212
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 |
213
258
  | `hafez stats` | Vault summary: counts, stale items, recents |
214
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}` |
215
261
  | `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
216
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 |
217
264
  | `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
218
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 |
219
266
  | `hafez onboard --cloud --vault-repo <owner>/<repo>` | Print the setup script for Claude Code cloud sessions |
package/dist/apply.d.ts CHANGED
@@ -26,6 +26,18 @@ export interface ApplyContext {
26
26
  export declare function validateKindFields(kind: 'entity' | 'knowledge', fields: UpdateFields, subtype?: KnowledgeSubtype): void;
27
27
  export declare function buildEntityFrontmatter(name: string, ef: CreateEntityFields, existsFn: (slug: string) => boolean): Record<string, any>;
28
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;
29
41
  /** The single apply path for every mutation. Writes vault files but never
30
42
  * touches git or SQLite: the Spine commits [...filesWritten, ...filesDeleted]
31
43
  * under commitMessage (single ops) or one composed batch message, then runs
package/dist/apply.js CHANGED
@@ -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
455
  const files = [vaultRel(vaultPath, filePath)];
407
456
  if (wroteArchive)
408
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: [vaultRel(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: [vaultRel(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,14 +532,14 @@ 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
545
  return {
@@ -531,6 +583,8 @@ function applyLink(op, ctx) {
531
583
  const parsedFile = parseFilePath(filePath);
532
584
  const fm = parsedFile.frontmatter;
533
585
  let body = parsedFile.body;
586
+ const kind = kindFromPath(filePath);
587
+ let touch = null;
534
588
  if (op.relation === 'parent') {
535
589
  fm.parent = op.target;
536
590
  }
@@ -539,16 +593,17 @@ function applyLink(op, ctx) {
539
593
  if (!related.includes(op.target))
540
594
  related.push(op.target);
541
595
  fm.related = related;
596
+ touch = writeBackEdge(ctx, op.slug, kind, op.target);
542
597
  }
543
598
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
544
599
  writeFileSync(filePath, serializeFile(fm, body));
545
600
  return {
546
- filesWritten: [vaultRel(vaultPath, filePath)],
601
+ filesWritten: [vaultRel(vaultPath, filePath), ...(touch ? [touch.file] : [])],
547
602
  filesDeleted: [],
548
603
  result: { op: 'link', slug: op.slug, status: 'ok' },
549
604
  commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
550
605
  indexRemovals: [],
551
- indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
606
+ indexUpserts: [{ slug: op.slug, kind }, ...(touch ? [touch.upsert] : [])],
552
607
  };
553
608
  }
554
609
  function applyUnlink(op, ctx) {
@@ -558,23 +613,30 @@ function applyUnlink(op, ctx) {
558
613
  const parsedFile = parseFilePath(filePath);
559
614
  const fm = parsedFile.frontmatter;
560
615
  let body = parsedFile.body;
616
+ const kind = kindFromPath(filePath);
617
+ let touch = null;
561
618
  if (op.relation === 'parent' && fm.parent === op.target) {
562
619
  delete fm.parent;
563
620
  }
564
- else if (op.relation === 'related' && fm.related) {
565
- fm.related = fm.related.filter((r) => r !== op.target);
566
- if (fm.related.length === 0)
567
- 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);
568
630
  }
569
631
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
570
632
  writeFileSync(filePath, serializeFile(fm, body));
571
633
  return {
572
- filesWritten: [vaultRel(vaultPath, filePath)],
634
+ filesWritten: [vaultRel(vaultPath, filePath), ...(touch ? [touch.file] : [])],
573
635
  filesDeleted: [],
574
636
  result: { op: 'unlink', slug: op.slug, status: 'ok' },
575
637
  commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
576
638
  indexRemovals: [],
577
- indexUpserts: [{ slug: op.slug, kind: kindFromPath(filePath) }],
639
+ indexUpserts: [{ slug: op.slug, kind }, ...(touch ? [touch.upsert] : [])],
578
640
  };
579
641
  }
580
642
  function applyPromote(op, ctx) {
@@ -681,14 +743,17 @@ function applyDelete(op, ctx) {
681
743
  vanished(op.slug);
682
744
  // The snapshot is the rollback: a commit-stage failure writes it back.
683
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 ?? [] : [];
684
748
  unlinkSync(filePath);
749
+ const touches = own.flatMap(r => dropBackEdge(ctx, op.slug, 'knowledge', r) ?? []);
685
750
  return {
686
- filesWritten: [],
751
+ filesWritten: touches.map(t => t.file),
687
752
  filesDeleted: [vaultRel(vaultPath, filePath)],
688
753
  result: { op: 'delete', slug: op.slug, status: 'ok' },
689
754
  commitMessage: `delete: ${op.slug} (${kind})`,
690
755
  indexRemovals: [op.slug],
691
- indexUpserts: [],
756
+ indexUpserts: touches.map(t => t.upsert),
692
757
  deletedKind: kind,
693
758
  };
694
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, {
package/dist/batch-ops.js CHANGED
@@ -101,6 +101,9 @@ export const CreateSessionFieldsSchema = z.object({
101
101
  insight: nonEmptyText.optional(),
102
102
  related: stringList.optional(),
103
103
  'session-date': z.string().optional(),
104
+ // Decisions are prose: a list only, never a comma string split apart.
105
+ decisions: z.array(nonEmptyText).optional(),
106
+ entities_touched: stringList.optional(),
104
107
  }).strict();
105
108
  const CreateEntityBranch = z.object({
106
109
  op: z.literal('create'),
@@ -311,6 +314,48 @@ function parentCycleError(slug, parent, ctx) {
311
314
  const cycle = parentCycle(slug, parent, s => ctx.linksOf(s).parent);
312
315
  return cycle ? `Parent '${parent}' would make a cycle: ${cycle.join(' → ')}` : null;
313
316
  }
317
+ /** An entity and its own parent or child are never `related` (ADR 0002):
318
+ * `parent` subsumes it. The errors for the links `slug` would carry after
319
+ * the op — the parent named in its related, a related whose parent is
320
+ * `slug`, or a parent whose related names `slug` (a legacy one-way edge) —
321
+ * against the batch so far. */
322
+ function parentSubsumesErrors(slug, links, ctx) {
323
+ const errors = [];
324
+ for (const r of links.related ?? []) {
325
+ if (r === links.parent)
326
+ errors.push(`'${r}' is the parent of '${slug}' — parent subsumes related`);
327
+ else if (ctx.existsAnywhere(r) && ctx.linksOf(r).parent === slug)
328
+ errors.push(`'${r}' is a child of '${slug}' — parent subsumes related`);
329
+ }
330
+ const p = links.parent;
331
+ if (p && ctx.existsAnywhere(p) && ctx.linksOf(p).related?.includes(slug))
332
+ errors.push(`'${p}' names '${slug}' in its related — parent subsumes related`);
333
+ return errors;
334
+ }
335
+ /** Record what the edge writer and dropper will do to the targets: a
336
+ * same-kind `related` edge has two ends, so after `slug`'s related changes
337
+ * from `before` to `after`, every added same-kind target carries `slug` and
338
+ * every dropped one loses it. Cross-kind and unknown targets are left as
339
+ * they are, like the files. */
340
+ function simulateBackEdges(slug, before, after, ctx) {
341
+ const kind = ctx.resolve(slug)?.kind;
342
+ const was = before ?? [];
343
+ const now = after ?? [];
344
+ for (const r of [...now.filter(r => !was.includes(r)), ...was.filter(r => !now.includes(r))]) {
345
+ if (r === slug || !kind || !ctx.existsAnywhere(r) || ctx.resolve(r)?.kind !== kind)
346
+ continue;
347
+ const links = ctx.linksOf(r);
348
+ const rest = (links.related ?? []).filter(x => x !== slug);
349
+ const related = now.includes(r) ? [...rest, slug] : rest;
350
+ ctx.simulateLinks(r, { ...links, related: related.length > 0 ? related : undefined });
351
+ }
352
+ }
353
+ /** The link fields after an op, simulated for `slug` and mirrored on the
354
+ * targets its related gained or lost. */
355
+ function simulateLinkChange(slug, before, after, ctx) {
356
+ ctx.simulateLinks(slug, after);
357
+ simulateBackEdges(slug, before.related, after.related, ctx);
358
+ }
314
359
  // `as const satisfies` rather than a `: OpSpec[]` annotation: it keeps each
315
360
  // row's `name` a literal, which is what OpSpecName (and through it the Apply
316
361
  // table's key type) is derived from. OP_SPECS below re-exports the rows under
@@ -383,8 +428,12 @@ const OP_SPEC_ROWS = [
383
428
  }
384
429
  if (target) {
385
430
  const links = linkFieldsOf(op.fields);
386
- if (links)
387
- ctx.simulateLinks(op.slug, { ...ctx.linksOf(op.slug), ...links });
431
+ if (links) {
432
+ const before = ctx.linksOf(op.slug);
433
+ const after = { ...before, ...links };
434
+ errors.push(...parentSubsumesErrors(op.slug, after, ctx));
435
+ simulateLinkChange(op.slug, before, after, ctx);
436
+ }
388
437
  }
389
438
  return { slug: op.slug, errors, primaryCode };
390
439
  },
@@ -400,8 +449,11 @@ const OP_SPEC_ROWS = [
400
449
  const errors = [];
401
450
  const slug = slugify(op.name);
402
451
  const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: op.fields.type }, () => ctx.buildEntityFrontmatter(op.name, op.fields));
403
- if (!primaryCode)
404
- ctx.simulateLinks(slug, { parent: op.fields.parent, related: op.fields.related });
452
+ if (!primaryCode) {
453
+ const links = { parent: op.fields.parent, related: op.fields.related };
454
+ errors.push(...parentSubsumesErrors(slug, links, ctx));
455
+ simulateLinkChange(slug, {}, links, ctx);
456
+ }
405
457
  return { slug, errors, created: errors.length === 0, primaryCode };
406
458
  },
407
459
  },
@@ -417,7 +469,7 @@ const OP_SPEC_ROWS = [
417
469
  const slug = slugify(op.name);
418
470
  const primaryCode = claimNew(ctx, errors, slug, { kind: 'knowledge', type: 'knowledge', subtype: op.fields?.subtype }, () => ctx.buildKnowledgeFrontmatter(op.name, (op.fields || {})));
419
471
  if (!primaryCode)
420
- ctx.simulateLinks(slug, { related: op.fields?.related });
472
+ simulateLinkChange(slug, {}, { related: op.fields?.related }, ctx);
421
473
  return { slug, errors, created: errors.length === 0, primaryCode };
422
474
  },
423
475
  },
@@ -426,7 +478,7 @@ const OP_SPEC_ROWS = [
426
478
  op: 'create',
427
479
  kind: 'session',
428
480
  schema: CreateSessionBranch,
429
- description: 'Create a new session note linked to an entity. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).',
481
+ 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).',
430
482
  exampleInclude: ['synthesis'],
431
483
  check(op, ctx) {
432
484
  const slug = slugify(op.name);
@@ -454,7 +506,7 @@ const OP_SPEC_ROWS = [
454
506
  name: 'link',
455
507
  op: 'link',
456
508
  schema: LinkBranch,
457
- description: 'Add a parent or related link between two entities.',
509
+ 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).',
458
510
  exampleInclude: [],
459
511
  check(op, ctx) {
460
512
  const errors = [];
@@ -472,9 +524,11 @@ const OP_SPEC_ROWS = [
472
524
  }
473
525
  if (!primaryCode) {
474
526
  const links = ctx.linksOf(op.slug);
475
- ctx.simulateLinks(op.slug, op.relation === 'parent'
527
+ const after = op.relation === 'parent'
476
528
  ? { ...links, parent: op.target }
477
- : { ...links, related: [...(links.related ?? []).filter(r => r !== op.target), op.target] });
529
+ : { ...links, related: [...(links.related ?? []).filter(r => r !== op.target), op.target] };
530
+ errors.push(...parentSubsumesErrors(op.slug, after, ctx));
531
+ simulateLinkChange(op.slug, links, after, ctx);
478
532
  }
479
533
  return { slug: op.slug, errors, primaryCode };
480
534
  },
@@ -483,7 +537,7 @@ const OP_SPEC_ROWS = [
483
537
  name: 'unlink',
484
538
  op: 'unlink',
485
539
  schema: UnlinkBranch,
486
- description: 'Remove a parent or related link between two entities.',
540
+ 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.',
487
541
  exampleInclude: [],
488
542
  check(op, ctx) {
489
543
  const errors = [];
@@ -494,9 +548,13 @@ const OP_SPEC_ROWS = [
494
548
  }
495
549
  else {
496
550
  const links = ctx.linksOf(op.slug);
497
- ctx.simulateLinks(op.slug, op.relation === 'parent'
551
+ simulateLinkChange(op.slug, links, op.relation === 'parent'
498
552
  ? { ...links, parent: links.parent === op.target ? undefined : links.parent }
499
- : { ...links, related: links.related?.filter(r => r !== op.target) });
553
+ : { ...links, related: links.related?.filter(r => r !== op.target) }, ctx);
554
+ // A legacy one-way edge may live on the target alone: the dropper
555
+ // takes it there too.
556
+ if (op.relation === 'related' && !links.related?.includes(op.target))
557
+ simulateBackEdges(op.slug, [op.target], [], ctx);
500
558
  }
501
559
  return { slug: op.slug, errors, primaryCode };
502
560
  },
@@ -540,7 +598,7 @@ const OP_SPEC_ROWS = [
540
598
  name: 'delete',
541
599
  op: 'delete',
542
600
  schema: DeleteBranch,
543
- 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.',
601
+ 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.',
544
602
  exampleInclude: [],
545
603
  addressesSessions: true,
546
604
  check(op, ctx) {
@@ -560,12 +618,19 @@ const OP_SPEC_ROWS = [
560
618
  }
561
619
  else {
562
620
  // Only knowledge can be linked to: a parent/related slug must name an
563
- // entity or knowledge note, never a session.
564
- const referrers = target ? ctx.referrers(op.slug) : [];
621
+ // entity or knowledge note, never a session. A same-kind back-edge
622
+ // the note owns — a note in its own related that names it back — is
623
+ // the other end of its edge, dropped with it (#243), not a blocker.
624
+ const own = target ? ctx.linksOf(op.slug).related ?? [] : [];
625
+ const referrers = target
626
+ ? ctx.referrers(op.slug).filter(r => !(own.includes(r) && ctx.resolve(r)?.kind === target.kind))
627
+ : [];
565
628
  if (referrers.length > 0) {
566
629
  errors.push(`'${op.slug}' is still linked from ${referrers.join(', ')} (parent/related) — unlink or re-point those first; an earlier op in the same batch counts`);
567
630
  }
568
631
  else {
632
+ if (target)
633
+ simulateBackEdges(op.slug, own, [], ctx);
569
634
  ctx.simulateDelete(op.slug, target ? 'knowledge' : 'session');
570
635
  }
571
636
  }