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/vault.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import matter from 'gray-matter';
3
3
  import yaml from 'js-yaml';
4
4
  import { readFileSync, readdirSync, existsSync } from 'fs';
5
- import { join } from 'path';
5
+ import { join, relative, sep } from 'path';
6
6
  import { HafezError } from './types.js';
7
7
  // Prevent gray-matter from auto-coercing YAML date strings into JS Date objects
8
8
  const MATTER_OPTIONS = { engines: { yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) } };
@@ -17,17 +17,21 @@ export function slugify(name) {
17
17
  .replace(/-{2,}/g, '-');
18
18
  }
19
19
  const KIND_DIRS = { entity: 'entities', knowledge: 'knowledge', session: 'sessions' };
20
- /** Kinds that live in the SQLite read index (sessions are files-and-git only). */
21
- export const INDEXED_KINDS = ['entity', 'knowledge'];
20
+ /** Kinds that live in the SQLite read index: every kind. Sessions joined in
21
+ * #35 — before that they were files-and-git only, for no recorded reason. */
22
+ export const INDEXED_KINDS = ['entity', 'knowledge', 'session'];
23
+ /** The two kinds a mutation can address. A session note is written once and
24
+ * read; `update`, `link`, `promote` never resolve one. */
25
+ export const MUTABLE_KINDS = ['entity', 'knowledge'];
22
26
  /** Vault directory for a kind, relative to the vault root. */
23
27
  export function kindDir(kind) {
24
28
  return KIND_DIRS[kind];
25
29
  }
26
30
  /** A vault content file: anything a sync/merge must treat semantically. */
27
31
  export const VAULT_FILE_RE = new RegExp(`^(?:${Object.values(KIND_DIRS).join('|')})/.+\\.md$`);
28
- /** True for vault-relative paths whose kind lives in the read index. */
29
- export function isIndexedRelPath(relPath) {
30
- return INDEXED_KINDS.some(k => relPath.startsWith(`${KIND_DIRS[k]}/`));
32
+ /** True for vault-relative paths of a mutable kind — what index.md shows. */
33
+ export function isMutableRelPath(relPath) {
34
+ return MUTABLE_KINDS.some(k => relPath.startsWith(`${KIND_DIRS[k]}/`));
31
35
  }
32
36
  /** History pathspecs covering the indexed dirs (changelog, file times). */
33
37
  export const INDEXED_DIR_PATHSPECS = INDEXED_KINDS.map(k => `${KIND_DIRS[k]}/`);
@@ -43,6 +47,16 @@ export function classifyIndexedRelPath(relPath) {
43
47
  }
44
48
  return null;
45
49
  }
50
+ /** The note a vault-relative ('/'-separated) path is, or null: the files
51
+ * scanVaultDir lists — a non-dot `.md` directly in a kind dir. Archive logs,
52
+ * scaffold files and anything outside the kind dirs are not notes. */
53
+ export function noteAtRelPath(relPath) {
54
+ const match = relPath.match(/^([^/]+)\/([^/]+)\.md$/);
55
+ if (!match || match[2].startsWith('.'))
56
+ return null;
57
+ const kind = INDEXED_KINDS.find(k => KIND_DIRS[k] === match[1]);
58
+ return kind ? { kind, slug: match[2] } : null;
59
+ }
46
60
  /** All .md files directly in dir — non-recursive (archive/ subdirs are
47
61
  * invisible by construction) and dotfile-free: dotfile slugs are rejected by
48
62
  * resolveFilePath, so listing them would surface files no operation can reach. */
@@ -69,8 +83,17 @@ export function resolveFilePath(vaultPath, slug, kind) {
69
83
  }
70
84
  return join(vaultPath, KIND_DIRS[kind], `${slug}.md`);
71
85
  }
72
- /** The file a slug names, or null — entities are resolved before knowledge,
73
- * the one resolution order every operation sees. */
86
+ /** The inverse of resolveFilePath / archiveLogPath: a vault file as the
87
+ * Journal names it — vault-relative and '/'-separated on every platform.
88
+ * Everything downstream compares these lists as POSIX paths: stageablePaths
89
+ * (src/git.ts) keeps an absent path only if `git ls-files` echoes it back,
90
+ * always with '/', so a '\' deletion was dropped and never staged; and the
91
+ * index.md gate (isMutableRelPath) never matched a '\' path (#187). */
92
+ export function vaultRel(vaultPath, filePath) {
93
+ return relative(vaultPath, filePath).split(sep).join('/');
94
+ }
95
+ /** The mutable file a slug names, or null — entities are resolved before
96
+ * knowledge, the one resolution order every mutation sees. */
74
97
  export function findVaultFile(vaultPath, slug) {
75
98
  const entity = resolveFilePath(vaultPath, slug, 'entity');
76
99
  if (existsSync(entity))
@@ -80,6 +103,14 @@ export function findVaultFile(vaultPath, slug) {
80
103
  return knowledge;
81
104
  return null;
82
105
  }
106
+ /** The file `read` resolves: the mutable kinds first, then a session note. */
107
+ export function findReadableFile(vaultPath, slug) {
108
+ const mutable = findVaultFile(vaultPath, slug);
109
+ if (mutable)
110
+ return mutable;
111
+ const session = resolveFilePath(vaultPath, slug, 'session');
112
+ return existsSync(session) ? session : null;
113
+ }
83
114
  /** Does this slug name a vault file? An invalid slug definitionally doesn't
84
115
  * exist — reference checks (create parent/related, link targets, validate)
85
116
  * must report "does not exist", not crash. validate() in particular exists to
@@ -0,0 +1,17 @@
1
+ /** The family lines fire at: Number A's threshold. */
2
+ export declare const FAMILY_MIN_LINES = 3;
3
+ /** The family a Next Actions line opens with, or null. */
4
+ export declare function familyOf(text: string): string | null;
5
+ /** One firing family and its open-line count, as `read --json` gives it. */
6
+ export interface Workstream {
7
+ prefix: string;
8
+ open_actions: number;
9
+ }
10
+ /** The `[a-z0-9]+` word tokens of a slug and name, lowercased. */
11
+ export declare const wordTokens: (text: string) => Set<string>;
12
+ /** The families an entity body carries unsplit, in first-appearance order,
13
+ * against the entities whose `parent` or `related` names it (`naming`). */
14
+ export declare function workstreams(body: string, naming: Array<{
15
+ slug: string;
16
+ name: string;
17
+ }>): Workstream[];
@@ -0,0 +1,78 @@
1
+ // src/workstream.ts
2
+ // Workstream (see CONTEXT.md): a prefix family on an entity's Next Actions
3
+ // with no Child or Related entity carrying it, Number A of the
4
+ // workstream-signal research (#230, #240). The reference definition is
5
+ // tools/research/ws_caps_prefix.py; the Health pass shares this module.
6
+ //
7
+ // A line's family is its leading run of ALL-CAPS tokens (two or more
8
+ // characters; digits, hyphen, apostrophe allowed; a `WS4`/`WS-A` code is one
9
+ // such token), minus a trailing step id (`A3`, `B1`); a lone step id is not
10
+ // a family; a trailing possessive `'S` is stripped from a token. A family
11
+ // fires at three or more lines, open or ticked; it stops once an entity
12
+ // whose `parent` or `related` names this one carries a family token among
13
+ // the word tokens of its slug or name. A code family (`WS4`, `WS-A`) stops
14
+ // on its whole code only — a segment of the slug (`ws-a-dynamics`) or a
15
+ // word of the name (`WS-A dynamics`) — never on `ws` or `a` alone. The
16
+ // count reported is the family's OPEN lines — what is left to split — so a
17
+ // family with none does not render at all.
18
+ import { getActionItems } from './document.js';
19
+ const TOKEN = "[A-Z][A-Z0-9'’\\-]+";
20
+ const PREFIX_RE = new RegExp(`^((?:${TOKEN})(?: ${TOKEN})*)(?=[,:;]|\\s[—–-]\\s|\\s|$)`);
21
+ const STEP_RE = /^[A-Z]\d+$/;
22
+ const POSSESSIVE_RE = /['’]S$/;
23
+ const CODE_RE = /^WS(\d+|-[A-Z])$/;
24
+ /** The family lines fire at: Number A's threshold. */
25
+ export const FAMILY_MIN_LINES = 3;
26
+ /** The family a Next Actions line opens with, or null. */
27
+ export function familyOf(text) {
28
+ const m = PREFIX_RE.exec(text);
29
+ if (!m)
30
+ return null;
31
+ const toks = m[1].split(' ').map(t => t.replace(POSSESSIVE_RE, ''));
32
+ while (toks.length > 1 && STEP_RE.test(toks[toks.length - 1]))
33
+ toks.pop();
34
+ const fam = toks.join(' ');
35
+ if (STEP_RE.test(fam))
36
+ return null;
37
+ return fam.length >= 2 ? fam : null;
38
+ }
39
+ /** The `[a-z0-9]+` word tokens of a slug and name, lowercased. */
40
+ export const wordTokens = (text) => new Set(text.toLowerCase().match(/[a-z0-9]+/g) ?? []);
41
+ /** Whether the entity carries the whole lowercased code: as a segment of
42
+ * its slug, or as a word of its name (hyphen kept). */
43
+ function carriesCode(e, code) {
44
+ return `-${e.slug.toLowerCase()}-`.includes(`-${code}-`)
45
+ || (e.name.toLowerCase().match(/[a-z0-9]+(?:-[a-z0-9]+)*/g) ?? []).some(w => w === code);
46
+ }
47
+ /** The families an entity body carries unsplit, in first-appearance order,
48
+ * against the entities whose `parent` or `related` names it (`naming`). */
49
+ export function workstreams(body, naming) {
50
+ const families = new Map();
51
+ for (const item of getActionItems(body)) {
52
+ const fam = familyOf(item.text);
53
+ if (fam === null)
54
+ continue;
55
+ const f = families.get(fam) ?? { lines: 0, open: 0 };
56
+ f.lines++;
57
+ if ('handle' in item)
58
+ f.open++;
59
+ families.set(fam, f);
60
+ }
61
+ const named = naming.map(e => wordTokens(`${e.slug} ${e.name}`));
62
+ const out = [];
63
+ for (const [prefix, f] of families) {
64
+ if (f.lines < FAMILY_MIN_LINES || f.open === 0)
65
+ continue;
66
+ if (CODE_RE.test(prefix)) {
67
+ if (naming.some(e => carriesCode(e, prefix.toLowerCase())))
68
+ continue;
69
+ }
70
+ else {
71
+ const tokens = Array.from(wordTokens(prefix));
72
+ if (named.some(set => tokens.some(t => set.has(t))))
73
+ continue;
74
+ }
75
+ out.push({ prefix, open_actions: f.open });
76
+ }
77
+ return out;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hafez",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -17,9 +17,9 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "scripts": {
20
- "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
20
+ "build": "rm -rf dist && tsc && esbuild src/graph/client/main.ts --bundle --minify --format=iife --target=es2020 --log-level=warning --outfile=dist/graph/client.js && node dist/graph/copy-fonts.js && chmod +x dist/cli.js",
21
21
  "install:local": "chmod +x \"$(pwd)/dist/cli.js\" && mkdir -p ~/.local/bin && ln -sf \"$(pwd)/dist/cli.js\" ~/.local/bin/hafez && for d in ~/.claude/skills ~/.agents/skills; do for s in hafez hafez-save; do mkdir -p \"$d/$s\" && cp \"$(pwd)/skill/$s/SKILL.md\" \"$d/$s/SKILL.md\"; done; done",
22
- "typecheck": "tsc -p tsconfig.test.json",
22
+ "typecheck": "tsc -p tsconfig.test.json && tsc -p src/graph/client/tsconfig.json",
23
23
  "pretest": "npm run build && npm run typecheck",
24
24
  "test": "vitest run",
25
25
  "test:watch": "vitest",
@@ -30,14 +30,20 @@
30
30
  "gray-matter": "^4.0.3",
31
31
  "js-yaml": "^4.1.1",
32
32
  "proper-lockfile": "^4.1.2",
33
- "simple-git": "^3.33.0",
33
+ "simple-git": "^4.0.2",
34
34
  "zod": "^4.3.6"
35
35
  },
36
36
  "devDependencies": {
37
+ "@fontsource-variable/geist": "^5.3.0",
38
+ "@fontsource-variable/geist-mono": "^5.3.0",
39
+ "@fontsource-variable/outfit": "^5.3.0",
37
40
  "@types/better-sqlite3": "^7.6.13",
38
41
  "@types/js-yaml": "^4.0.9",
39
42
  "@types/node": "^25.4.0",
40
43
  "@types/proper-lockfile": "^4.1.4",
44
+ "esbuild": "^0.28.2",
45
+ "force-graph": "^1.52.0",
46
+ "markdown-it": "^15.0.3",
41
47
  "tsx": "^4.21.0",
42
48
  "typescript": "^5.9.3",
43
49
  "vitest": "^4.0.18"
@@ -3,10 +3,11 @@ name: hafez
3
3
  description: >
4
4
  Hafez vault: the less common operations and the tool itself. Use when the
5
5
  user asks what is in the vault ("what am I working on?", "what's stale?"),
6
- wants to promote, link, unlink, delete, change a status or validate, or
7
- wants to update or reconfigure Hafez ("upgrade hafez", "set up hafez").
8
- Also the reference to read when no Hafez block is in your instructions.
9
- hash: 01a30ae1e209
6
+ wants to promote, link, unlink, delete, change a status or validate, asks
7
+ to check the vault or clean it up, or wants to update or reconfigure Hafez
8
+ ("upgrade hafez", "set up hafez"). Also the reference to read when no
9
+ Hafez block is in your instructions.
10
+ hash: c52f079e667f
10
11
  ---
11
12
 
12
13
  # Hafez
@@ -22,7 +23,7 @@ Open actions on an entity you read are yours: tick what landed, drop what is dea
22
23
  ## Create
23
24
 
24
25
  ```
25
- hafez create entity "<name>" --type <project|entity> --description "<one sentence>" --brief "<where things stand>" --add-action "<next step>"
26
+ hafez create entity "<name>" --type <project|entity> --parent <slug you read, or its parent> --description "<one sentence>" --brief "<where things stand>" --add-action "<next step>"
26
27
  hafez create knowledge "<name>" --synthesis "<the insight>" --confidence <observation|pattern|principle> --related <slug>
27
28
  ```
28
29
 
@@ -32,10 +33,13 @@ A plan is `--subtype plan` without `--synthesis`. Cannot tell entity from knowle
32
33
 
33
34
  ```
34
35
  hafez query --filter <active|paused|done|stale|capture>
35
- hafez search "<terms>" --kind <entity|knowledge>
36
+ hafez search "<terms>" --kind <entity|knowledge|session|all>
37
+ hafez query --kind session --related-to <slug>
36
38
  hafez stats
37
39
  ```
38
40
 
41
+ `search` and `query` reach entities and knowledge notes; `--kind all` adds the session notes, `--kind session` lists them alone, newest first. `hafez read <slug> --archive` prints an entity's archived log entries.
42
+
39
43
  ## Less common operations
40
44
 
41
45
  ```
@@ -45,9 +49,14 @@ hafez unlink <slug> <target> --relation <parent|related>
45
49
  hafez update <slug> --status <active|paused|done>
46
50
  hafez delete <slug>
47
51
  hafez validate
52
+ hafez health
48
53
  ```
49
54
 
50
- A plan's status is `active|done`. `delete` removes a knowledge or session note only; an entity is retired with `--status done`.
55
+ A plan's status is `active|done`. `delete` removes a knowledge or session note only; an entity is retired with `--status done`. `health` lists what placement would change and applies nothing.
56
+
57
+ A vault check ("check the vault", "clean up the vault", "what has drifted"): run `hafez health --json` and bring back proposals grouped by kind, never the rows. In a session that read an entity, lead with that entity's rows; the rest of the vault as counts.
58
+ One line per kind, with the count: the Workstreams to split (a `create entity … --parent` line each, named as the actions name it); the one-way edges and the unlinked mentions as one `hafez batch` of `link` ops, one commit; the restated decisions and the leakage pairs as a short list for the user to judge, nothing proposed.
59
+ Apply nothing until the user confirms a kind, not even a `link` line that looks safe; then one `batch` per kind.
51
60
 
52
61
  Several writes that must land together go in one batch: a bare array, no wrapper. A slug is the name lowercased, apostrophes dropped, each other run of characters that are not letters or digits turned into one `-` and none left at either end, so a later op can name what an earlier one creates.
53
62