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/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'),
@@ -264,7 +267,9 @@ export function duplicateSessionWarning(slug) {
264
267
  * batch). The builder runs before the claim — it must see the pre-claim
265
268
  * world so a self-referencing parent still fails. */
266
269
  function claimNew(ctx, errors, slug, info, build) {
267
- const taken = ctx.existsAnywhere(slug);
270
+ // A session note holds its slug too: every kind shares one index row per
271
+ // slug, so a create must not take one a session already has.
272
+ const taken = ctx.existsAnywhere(slug) || ctx.sessionExists(slug);
268
273
  if (taken)
269
274
  errors.push(`Slug '${slug}' already exists`);
270
275
  if (build) {
@@ -309,6 +314,48 @@ function parentCycleError(slug, parent, ctx) {
309
314
  const cycle = parentCycle(slug, parent, s => ctx.linksOf(s).parent);
310
315
  return cycle ? `Parent '${parent}' would make a cycle: ${cycle.join(' → ')}` : null;
311
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
+ }
312
359
  // `as const satisfies` rather than a `: OpSpec[]` annotation: it keeps each
313
360
  // row's `name` a literal, which is what OpSpecName (and through it the Apply
314
361
  // table's key type) is derived from. OP_SPECS below re-exports the rows under
@@ -381,8 +428,12 @@ const OP_SPEC_ROWS = [
381
428
  }
382
429
  if (target) {
383
430
  const links = linkFieldsOf(op.fields);
384
- if (links)
385
- 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
+ }
386
437
  }
387
438
  return { slug: op.slug, errors, primaryCode };
388
439
  },
@@ -398,8 +449,11 @@ const OP_SPEC_ROWS = [
398
449
  const errors = [];
399
450
  const slug = slugify(op.name);
400
451
  const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: op.fields.type }, () => ctx.buildEntityFrontmatter(op.name, op.fields));
401
- if (!primaryCode)
402
- 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
+ }
403
457
  return { slug, errors, created: errors.length === 0, primaryCode };
404
458
  },
405
459
  },
@@ -415,7 +469,7 @@ const OP_SPEC_ROWS = [
415
469
  const slug = slugify(op.name);
416
470
  const primaryCode = claimNew(ctx, errors, slug, { kind: 'knowledge', type: 'knowledge', subtype: op.fields?.subtype }, () => ctx.buildKnowledgeFrontmatter(op.name, (op.fields || {})));
417
471
  if (!primaryCode)
418
- ctx.simulateLinks(slug, { related: op.fields?.related });
472
+ simulateLinkChange(slug, {}, { related: op.fields?.related }, ctx);
419
473
  return { slug, errors, created: errors.length === 0, primaryCode };
420
474
  },
421
475
  },
@@ -424,7 +478,7 @@ const OP_SPEC_ROWS = [
424
478
  op: 'create',
425
479
  kind: 'session',
426
480
  schema: CreateSessionBranch,
427
- 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).',
428
482
  exampleInclude: ['synthesis'],
429
483
  check(op, ctx) {
430
484
  const slug = slugify(op.name);
@@ -452,7 +506,7 @@ const OP_SPEC_ROWS = [
452
506
  name: 'link',
453
507
  op: 'link',
454
508
  schema: LinkBranch,
455
- 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).',
456
510
  exampleInclude: [],
457
511
  check(op, ctx) {
458
512
  const errors = [];
@@ -470,9 +524,11 @@ const OP_SPEC_ROWS = [
470
524
  }
471
525
  if (!primaryCode) {
472
526
  const links = ctx.linksOf(op.slug);
473
- ctx.simulateLinks(op.slug, op.relation === 'parent'
527
+ const after = op.relation === 'parent'
474
528
  ? { ...links, parent: op.target }
475
- : { ...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);
476
532
  }
477
533
  return { slug: op.slug, errors, primaryCode };
478
534
  },
@@ -481,7 +537,7 @@ const OP_SPEC_ROWS = [
481
537
  name: 'unlink',
482
538
  op: 'unlink',
483
539
  schema: UnlinkBranch,
484
- 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.',
485
541
  exampleInclude: [],
486
542
  check(op, ctx) {
487
543
  const errors = [];
@@ -492,9 +548,13 @@ const OP_SPEC_ROWS = [
492
548
  }
493
549
  else {
494
550
  const links = ctx.linksOf(op.slug);
495
- ctx.simulateLinks(op.slug, op.relation === 'parent'
551
+ simulateLinkChange(op.slug, links, op.relation === 'parent'
496
552
  ? { ...links, parent: links.parent === op.target ? undefined : links.parent }
497
- : { ...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);
498
558
  }
499
559
  return { slug: op.slug, errors, primaryCode };
500
560
  },
@@ -538,7 +598,7 @@ const OP_SPEC_ROWS = [
538
598
  name: 'delete',
539
599
  op: 'delete',
540
600
  schema: DeleteBranch,
541
- 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.',
542
602
  exampleInclude: [],
543
603
  addressesSessions: true,
544
604
  check(op, ctx) {
@@ -558,12 +618,19 @@ const OP_SPEC_ROWS = [
558
618
  }
559
619
  else {
560
620
  // Only knowledge can be linked to: a parent/related slug must name an
561
- // entity or knowledge note, never a session.
562
- 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
+ : [];
563
628
  if (referrers.length > 0) {
564
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`);
565
630
  }
566
631
  else {
632
+ if (target)
633
+ simulateBackEdges(op.slug, own, [], ctx);
567
634
  ctx.simulateDelete(op.slug, target ? 'knowledge' : 'session');
568
635
  }
569
636
  }
@@ -7,3 +7,9 @@ export interface BlockHookCtx extends ConfigHome {
7
7
  }
8
8
  /** The render, or null for any failure: the caller prints nothing. */
9
9
  export declare function blockHook(ctx: BlockHookCtx): Promise<string | null>;
10
+ /** The working directory, its git root, and in a linked worktree the main
11
+ * repository's root. */
12
+ export declare function foldersOf(cwd: string): string[];
13
+ /** Every remote's URL, normalised. */
14
+ export declare function remoteUrls(cwd: string): string[];
15
+ export declare function resourceMatches(resource: string, folders: string[], remotes: string[]): boolean;
@@ -26,11 +26,16 @@ export async function blockHook(ctx) {
26
26
  // leave an index file in it.
27
27
  if (typeof vaultPath !== 'string' || !isVault(vaultPath))
28
28
  return null;
29
- const cwd = realpathSync(ctx.cwd ?? process.cwd());
29
+ const dir = ctx.cwd ?? process.cwd();
30
+ // A deleted working directory: no folder to match.
31
+ if (!existsSync(dir))
32
+ return null;
33
+ const cwd = canonical(dir);
30
34
  const folders = foldersOf(cwd);
31
35
  const remotes = remoteUrls(cwd);
32
36
  const { createHafez } = await import('../index.js');
33
- const { items } = await createHafez({ vaultPath, git: { push: false } }).query({ filter: 'all' });
37
+ const os = createHafez({ vaultPath, git: { push: false } });
38
+ const { items } = await os.query({ filter: 'all' }).finally(() => os.close());
34
39
  const matches = items
35
40
  .filter(e => folders.some(f => basename(f) === e.slug) || (e.resource !== null && resourceMatches(e.resource, folders, remotes)))
36
41
  // Active first: the status tuple is ordered active, paused, done.
@@ -53,21 +58,33 @@ function git(cwd, ...args) {
53
58
  }
54
59
  /** The working directory, its git root, and in a linked worktree the main
55
60
  * repository's root. */
56
- function foldersOf(cwd) {
61
+ export function foldersOf(cwd) {
57
62
  const folders = [cwd];
58
63
  const out = git(cwd, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir');
59
64
  const [top, common] = out?.split('\n') ?? [];
60
65
  // Before git 2.31 `--path-format` is echoed back, not obeyed: no git root.
61
66
  if (top && common && isAbsolute(top) && isAbsolute(common)) {
62
- folders.push(top);
67
+ const root = canonical(top);
68
+ folders.push(root);
63
69
  // A linked worktree shares the main checkout's `.git` as its common dir.
64
- if (basename(common) === '.git' && dirname(common) !== top)
65
- folders.push(dirname(common));
70
+ // The shape is read off git's own path: a symlinked `.git` resolves to a
71
+ // directory with some other name.
72
+ const main = canonical(dirname(common));
73
+ if (basename(common) === '.git' && main !== root)
74
+ folders.push(main);
66
75
  }
67
76
  return [...new Set(folders)];
68
77
  }
78
+ /** One spelling per folder, so paths from git, the working directory and a
79
+ * `resource` compare equal: git prints `C:/…` on Windows, realpath `C:\…`.
80
+ * The native realpath, since only it returns the filesystem's own letter case
81
+ * (`c:\users\…` is `C:\Users\…`) and long names for 8.3 ones. */
82
+ function canonical(path) {
83
+ const abs = resolve(path);
84
+ return existsSync(abs) ? realpathSync.native(abs) : abs;
85
+ }
69
86
  /** Every remote's URL, normalised. */
70
- function remoteUrls(cwd) {
87
+ export function remoteUrls(cwd) {
71
88
  const out = git(cwd, 'config', '--get-regexp', '^remote\\..*\\.url$');
72
89
  return out ? out.split('\n').map(line => normaliseUrl(line.slice(line.indexOf(' ') + 1))) : [];
73
90
  }
@@ -81,10 +98,8 @@ function normaliseUrl(url) {
81
98
  .replace(/\/+$/, '')
82
99
  .replace(/\.git$/, '');
83
100
  }
84
- function resourceMatches(resource, folders, remotes) {
85
- if (isAbsolute(resource)) {
86
- const path = resolve(resource);
87
- return folders.includes(existsSync(path) ? realpathSync(path) : path);
88
- }
101
+ export function resourceMatches(resource, folders, remotes) {
102
+ if (isAbsolute(resource))
103
+ return folders.includes(canonical(resource));
89
104
  return remotes.includes(normaliseUrl(resource));
90
105
  }
@@ -9,7 +9,7 @@ export interface CommandOpts {
9
9
  export declare function cmdRead(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
10
10
  /** Every flag the query parser accepts, for the branch-support matrix. */
11
11
  export declare const QUERY_FLAG_NAMES: string[];
12
- export type QueryBranch = 'entity' | 'knowledge' | 'all' | 'related-to';
12
+ export type QueryBranch = 'entity' | 'knowledge' | 'session' | 'all' | 'related-to';
13
13
  /** Which flags each kind branch actually applies. A branch that cannot honour
14
14
  * a flag rejects it (below) instead of dropping it: `--kind knowledge --filter
15
15
  * stale` used to return every knowledge note with exit 0, and `--confidence`
@@ -36,6 +36,8 @@ export declare function cmdCapture(os: Hafez, args: string[], opts?: CommandOpts
36
36
  export declare function cmdPromote(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
37
37
  export declare function cmdDelete(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
38
38
  export declare function cmdValidate(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
39
+ export declare function cmdHealth(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
40
+ export declare function cmdGraph(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
39
41
  export declare function cmdIndexRebuild(os: Hafez): Promise<string>;
40
42
  export declare function cmdSync(os: Hafez, args: string[]): Promise<string>;
41
43
  export declare function cmdStats(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;