hafez 1.0.2 → 1.2.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.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Hafez
2
2
 
3
- **An agent-native personal knowledge vault.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. It keeps a durable, structured memory for AI agents: plain markdown files you own, backed by SQLite full-text search and git.
3
+ **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
4
 
5
5
  ## Why
6
6
 
7
- AI agents forget everything between sessions. Hafez gives them a place to remember — projects, insights, decisions, session history — as a knowledge graph they can read, query, and write through a CLI.
7
+ Humans have short-term context. Agents have session context. Both can think big only about what's loaded right now — so both need a place that carries what happened and where things stand, ready whenever either wants to remember. Hafez is that place: projects with goals, actions, and progress; decisions; the insights that outlive them — a graph of markdown files both of you read.
8
8
 
9
- The files stay yours. Everything is markdown with YAML frontmatter in a git repo: readable in any editor, Obsidian-compatible, diffable, and synced like code. The SQLite index (`.hafez.db`) is a disposable read cache rebuilt from the files; git is the source of truth and the sync mechanism.
9
+ Writing is the agent's job. As you work, the results organise themselves into the vault — at session end, at the critical points of a project — through a CLI that owns the plumbing: structure, validation, search, index, git. A malformed write fails; links must resolve. No agent decides how to treat your vault, so every agent treats it the same — and its context goes to your content, not your conventions.
10
+
11
+ The files stay yours. Everything is markdown with YAML frontmatter in a git repo: readable — and editable — in any editor, Obsidian-compatible, diffable, and synced like code. The SQLite index (`.hafez.db`) is a disposable read cache rebuilt from the files; git is the source of truth and the sync mechanism. Nothing is trapped, either: `hafez export --okf` writes the vault as a standard [OKF v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle — Hafez's schema is stricter than OKF's, so the export loses nothing.
10
12
 
11
13
  ## Setup
12
14
 
@@ -17,31 +19,39 @@ Claude Code (or any agent with shell access):
17
19
  Set up Hafez, an agent-native knowledge vault (github.com/shrvn-r/hafez):
18
20
 
19
21
  1. Install the CLI (needs Node.js 22+ and git): npm install -g hafez
20
- On npm 12+, append --allow-scripts=better-sqlite3 (npm 12 blocks install
21
- scripts by default; the SQLite bindings need theirs).
22
+ On npm 11+, append --allow-scripts=better-sqlite3 (npm 11 blocks install
23
+ scripts by default; the SQLite bindings need theirs, and the install
24
+ reports success even when they were skipped).
22
25
  2. Check git identity is configured (git config user.name and user.email);
23
26
  if not, ask me for a name and email and set them with git config --global.
24
27
  3. Create my vault — a git repo with entities/ and knowledge/ directories and an
25
28
  initial commit. Suggest ~/vault but ask me where I want it first.
26
29
  4. Register it: hafez init --register <vault-path>
27
- 5. Install the bundled agent skill: copy skill/SKILL.md from the installed
28
- package ("$(npm root -g)/hafez/skill/SKILL.md") to ~/.claude/skills/hafez/SKILL.md
30
+ 5. If you are Claude Code, install the bundled skill: copy skill/SKILL.md
31
+ from the installed package ("$(npm root -g)/hafez/skill/SKILL.md") to
32
+ ~/.claude/skills/hafez/SKILL.md. Any other harness: skip this — step 7
33
+ covers integration.
29
34
  6. Read hafez help --agent, then verify end-to-end: create a test capture,
30
35
  hafez read it back, and show me hafez stats.
31
- 7. Tell me what you set up, and give me three example asks I can use in future
32
- sessions to see the vault working (e.g. "what am I working on?").
36
+ 7. Run hafez onboard and follow what it prints: interview me to seed the
37
+ vault with my current projects, then help me choose how deeply to
38
+ integrate Hafez into my agent setup.
33
39
  ```
34
40
 
35
41
  From then on, just mention your projects by name in any session — the skill
36
42
  teaches the agent when to read and write the vault, no commands required.
37
43
 
44
+ Upgrading from an earlier name of this tool? Remove the old skill directory
45
+ under `~/.claude/skills/` — two installed skills with near-identical
46
+ triggers compete for the same invocations.
47
+
38
48
  ### Manual setup
39
49
 
40
50
  The same thing by hand:
41
51
 
42
52
  ```bash
43
53
  # Install (Node.js 22+ and git required).
44
- # On npm 12+, append: --allow-scripts=better-sqlite3
54
+ # On npm 11+, append: --allow-scripts=better-sqlite3
45
55
  npm install -g hafez
46
56
 
47
57
  # One-time, if you've never used git on this machine:
@@ -54,9 +64,13 @@ cd ~/vault && git init && git commit --allow-empty -m "init vault"
54
64
  hafez init --register ~/vault # remember this vault (one-time)
55
65
  hafez init # show current vault resolution
56
66
 
57
- # Claude Code skill (optional but recommended)
67
+ # Claude Code only (optional): the bundled skill
58
68
  mkdir -p ~/.claude/skills/hafez
59
69
  cp "$(npm root -g)/hafez/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
70
+
71
+ # Then, in your agent — any harness: "run hafez onboard and follow it" —
72
+ # guided first run (seed the vault with your current projects, choose an
73
+ # integration level)
60
74
  ```
61
75
 
62
76
  Prefer installing without the npm registry? The GitHub release tarball is the
@@ -126,11 +140,12 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
126
140
  | `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
127
141
  | `hafez update <slug> [flags]` | Status, brief, actions, session log, synthesis, evidence… |
128
142
  | `hafez link / unlink <slug> <target> --relation <parent\|related>` | Manage relationships |
129
- | `hafez batch [--dry-run]` | JSON ops on stdin → atomic multi-op, single git commit, rollback on failure |
143
+ | `hafez batch [--dry-run] [--file payload.json]` | JSON ops (stdin or `--file`) → atomic multi-op, single git commit, rollback on failure; dry-run runs the same validation as apply and reports derived slugs |
130
144
  | `hafez stats` | Vault summary: counts, stale items, recents |
131
145
  | `hafez changelog --since 7.days.ago` | Git-derived change history |
132
146
  | `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
133
147
  | `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
148
+ | `hafez onboard` | Agent-directed first run: seeding interview + integration setup |
134
149
  | `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
135
150
  | `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
136
151
 
@@ -166,10 +181,16 @@ Writes are serialized through an internal mutex, so a single `Hafez` instance is
166
181
 
167
182
  ## Agent integration
168
183
 
169
- Hafez is built to be operated by agents:
184
+ Hafez is built to be operated by agents — any agent. The CLI is the whole
185
+ interface, so every harness that can run shell commands integrates the same
186
+ way: through its instructions file — CLAUDE.md for Claude Code, AGENTS.md
187
+ for everything on the [AGENTS.md standard](https://agents.md) (Codex, Cursor,
188
+ Copilot, …). This repo follows the same convention: `AGENTS.md` is canonical
189
+ and `CLAUDE.md` imports it.
170
190
 
171
- - **Claude Code skill** — the package bundles a skill (installed in [Setup](#setup)) covering the full session lifecycle: loading context at session start, vault ops mid-session, and a session-end digest.
191
+ - **`hafez onboard`** — an agent-directed first-run guide: a seeding interview for the empty vault, then a choice of integration level (an always-active block for the harness's global instructions file, or invoke-only). Re-running it later is the intended way to seed newly started work — on a set-up vault it's just the interview.
172
192
  - **`hafez help --agent`** — a complete API reference designed to be loaded into an agent's context.
193
+ - **Claude Code skill** — an optional adapter for one harness: the package bundles a skill (installed in [Setup](#setup)) covering the full session lifecycle: loading context at session start, vault ops mid-session, and a session-end digest.
173
194
  - **`hafez schema <op> --examples`** — JSON schemas with working examples for every batch operation, so agents can self-correct.
174
195
  - **`hafez batch`** — apply a whole session's worth of updates atomically from a single JSON payload. Validation errors report every bad op at once with did-you-mean hints.
175
196
  - **Markdown-native output** by default, `--json` everywhere for machine consumption.
@@ -126,4 +126,4 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
126
126
  }, z.core.$strict>], "op">;
127
127
  export declare function parseBatchInput(json: string): BatchOperation[];
128
128
  export declare function cmdBatch(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
129
- export declare function cmdDigest(os: Hafez, _args: string[], _opts?: CommandOpts): Promise<string>;
129
+ export declare function cmdDigest(os: Hafez, args: string[], _opts?: CommandOpts): Promise<string>;
@@ -1,5 +1,6 @@
1
1
  // src/cli/commands.ts
2
2
  import { z } from 'zod';
3
+ import { readFileSync } from 'fs';
3
4
  import { HafezError } from '../types.js';
4
5
  import { ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, } from '../contracts.js';
5
6
  import { formatBatchError } from './batch-errors.js';
@@ -358,6 +359,7 @@ const CreateEntityFieldsSchema = z.object({
358
359
  tags: z.array(z.string()).optional(),
359
360
  brief: z.string().optional(),
360
361
  add_action: z.string().optional(),
362
+ add_actions: z.array(z.string()).optional(),
361
363
  }).strict();
362
364
  const CreateKnowledgeFieldsSchema = z.object({
363
365
  subtype: z.enum([...VALID_SUBTYPES]).optional(),
@@ -489,6 +491,23 @@ export function parseBatchInput(json) {
489
491
  }
490
492
  return operations;
491
493
  }
494
+ // --file payload reader. Exists because Windows PowerShell 5.1 cannot pipe
495
+ // stdin to native executables — the documented pipe idiom fails there, so a
496
+ // file is the portable session-end path. PowerShell's own redirection writes
497
+ // UTF-16LE (and other tools write BOM'd UTF-8), so decode both rather than
498
+ // trading the pipe footgun for an encoding one.
499
+ function readPayloadFile(path) {
500
+ let buf;
501
+ try {
502
+ buf = readFileSync(path);
503
+ }
504
+ catch {
505
+ throw new HafezError('VALIDATION_FAILED', `Cannot read --file: ${path}`);
506
+ }
507
+ if (buf[0] === 0xff && buf[1] === 0xfe)
508
+ return buf.toString('utf16le').replace(/^\uFEFF/, '');
509
+ return buf.toString('utf-8').replace(/^\uFEFF/, '');
510
+ }
492
511
  function readStdin() {
493
512
  return new Promise((resolve, reject) => {
494
513
  let data = '';
@@ -500,15 +519,35 @@ function readStdin() {
500
519
  }
501
520
  export async function cmdBatch(os, args, opts = {}) {
502
521
  const dryRun = args.includes('--dry-run');
503
- const input = await readStdin();
522
+ const file = getFlag(args, '--file');
523
+ const input = file ? readPayloadFile(file) : await readStdin();
504
524
  if (!input.trim()) {
505
- throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin: echo \'[...]\' | hafez batch');
525
+ throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin (echo \'[...]\' | hafez batch) or pass --file payload.json');
506
526
  }
507
527
  const operations = parseBatchInput(input);
508
528
  if (dryRun) {
529
+ // Same validate phase as apply — dry-run can never green-light a payload
530
+ // apply rejects. Reports derived slugs so agents can predict create slugs.
531
+ const report = await os.validateBatch(operations);
509
532
  if (opts.json)
510
- return jsonOut({ valid: true, operations: operations.length });
511
- return `Batch valid: ${operations.length} operations would be applied`;
533
+ return jsonOut(report);
534
+ if (!report.valid) {
535
+ const invalidCount = report.operations.filter(o => o.errors.length > 0).length;
536
+ const lines = report.operations.flatMap(o => o.errors.map(e => `op[${o.index}] (${o.op} ${o.slug}): ${e}`));
537
+ throw new HafezError('VALIDATION_FAILED', `Batch invalid: ${invalidCount} of ${operations.length} operation${operations.length === 1 ? '' : 's'} failed validation (0 would be applied)`, lines);
538
+ }
539
+ const out = [`Batch valid: ${operations.length} operations would be applied`];
540
+ const creates = report.operations.filter(o => o.created);
541
+ if (creates.length > 0) {
542
+ out.push('Derived slugs:');
543
+ for (const c of creates)
544
+ out.push(` op[${c.index}] ${c.op} → ${c.slug}`);
545
+ }
546
+ for (const o of report.operations) {
547
+ if (o.warning)
548
+ out.push(`Warning: op[${o.index}] ${o.warning}`);
549
+ }
550
+ return out.join('\n');
512
551
  }
513
552
  const results = await os.batch(operations);
514
553
  if (opts.json)
@@ -519,11 +558,11 @@ export async function cmdBatch(os, args, opts = {}) {
519
558
  output += ` (created: ${created.join(', ')})`;
520
559
  return output;
521
560
  }
522
- export async function cmdDigest(os, _args, _opts = {}) {
523
- // Read stdin — reuses the existing readStdin() defined in this file
524
- const raw = await readStdin();
561
+ export async function cmdDigest(os, args, _opts = {}) {
562
+ const file = getFlag(args, '--file');
563
+ const raw = file ? readPayloadFile(file) : await readStdin();
525
564
  if (!raw.trim()) {
526
- throw new Error('No input received. Pipe JSON object to stdin: echo \'{"entities_touched":...}\' | hafez digest');
565
+ throw new Error('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --file summary.json');
527
566
  }
528
567
  // Parse JSON
529
568
  let parsed;
@@ -104,6 +104,11 @@ export function formatSearchResults(results) {
104
104
  }
105
105
  export function formatStats(stats) {
106
106
  const lines = [];
107
+ const totalEntities = stats.counts.active + stats.counts.paused + stats.counts.done;
108
+ if (totalEntities === 0 && stats.knowledge_count === 0) {
109
+ lines.push('Vault is empty — run `hafez onboard` for the guided first run (seeding interview + agent integration).');
110
+ lines.push('');
111
+ }
107
112
  lines.push('## Status Counts');
108
113
  lines.push(`Active: ${stats.counts.active} | Paused: ${stats.counts.paused} | Done: ${stats.counts.done}`);
109
114
  lines.push('\n## Type Counts');
@@ -58,7 +58,17 @@ listed here, it does not exist — do not invent fields.
58
58
  - When in doubt, run \`hafez schema <op>\` before writing a batch. Cheap,
59
59
  deterministic, no side effects.
60
60
  - Use \`hafez batch --dry-run\` to validate a payload without touching the
61
- vault. Same errors, zero filesystem mutation.
61
+ vault. Same validation as a real apply, zero filesystem mutation.
62
+
63
+ ## Slug contract (create-then-link in one batch)
64
+
65
+ Slugs are deterministic: slug = slugify(name) — lowercase, non-alphanumeric
66
+ runs collapse to single \`-\`, edges trimmed (\`"Auth Refactor!"\` →
67
+ \`auth-refactor\`). A batch executes sequentially, so later ops may reference
68
+ the slugs of earlier creates in the same batch (\`create\` then \`link\`/\`update\`
69
+ works in one payload). Collisions hard-error — a wrong slug prediction can
70
+ never silently hit an existing document. To verify predictions first:
71
+ \`hafez batch --dry-run\` reports the derived slug for every create op.
62
72
  `;
63
73
  function renderOpCatalog() {
64
74
  const lines = ['<!-- SECTION:op-catalog -->'];
@@ -93,9 +103,10 @@ function renderCriticalEnums() {
93
103
  }
94
104
  function renderBatchExamples() {
95
105
  const lines = ['<!-- SECTION:batch-examples -->'];
96
- lines.push('Batch JSON shape — wrap one or more ops in an array and pipe to stdin:');
106
+ lines.push('Batch JSON shape — wrap one or more ops in an array:');
97
107
  lines.push('');
98
- lines.push(' echo \'[...]\' | hafez batch');
108
+ lines.push(' echo \'[...]\' | hafez batch # POSIX shells');
109
+ lines.push(' hafez batch --file payload.json # any platform (PowerShell 5.1 cannot pipe stdin)');
99
110
  lines.push('');
100
111
  lines.push('Minimal example per op (canonical field names; aliased keys stripped):');
101
112
  lines.push('');
@@ -113,9 +124,11 @@ function renderBatchExamples() {
113
124
  function renderDigestSection() {
114
125
  const lines = ['<!-- SECTION:digest -->'];
115
126
  lines.push('Session digest — turn end-of-session context into a batch payload.');
116
- lines.push('NOT a batch op: it reads its own JSON from stdin and prints batch JSON.');
127
+ lines.push('NOT a batch op: it reads its own JSON and prints batch JSON.');
117
128
  lines.push('');
118
129
  lines.push(" echo '<digest-json>' | hafez digest | hafez batch");
130
+ lines.push(' # No stdin pipes (PowerShell 5.1): hafez digest --file summary.json > ops.json,');
131
+ lines.push(' # then hafez batch --file ops.json');
119
132
  lines.push('');
120
133
  lines.push('Input fields (`hafez schema digest` for the full schema):');
121
134
  const schema = getOpSchema('digest');
package/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@ import { HafezError } from '../types.js';
7
7
  import { cmdRead, cmdQuery, cmdSearch, cmdCreate, cmdUpdate, cmdLink, cmdUnlink, cmdPromote, cmdValidate, cmdIndexRebuild, cmdBatch, cmdSync, cmdStats, cmdChangelog, cmdDigest, cmdCapture } from './commands.js';
8
8
  import { cmdSchema } from './commands-schema.js';
9
9
  import { renderAgentHelp } from './help-agent.js';
10
+ import { renderOnboard } from './onboard.js';
10
11
  import { resolveVaultPath, noVaultMessage, cmdInit } from './resolve-vault.js';
11
12
  const COMMANDS = {
12
13
  read: cmdRead,
@@ -65,6 +66,7 @@ Commands:
65
66
  index rebuild Rebuild the SQLite index
66
67
  init --register <path> Register an existing vault
67
68
  init Show current vault resolution
69
+ onboard Agent-directed first run: seed the vault, pick integration level
68
70
  help --agent Full API reference for agents
69
71
 
70
72
  Global flags:
@@ -74,6 +76,22 @@ Global flags:
74
76
 
75
77
  Vault discovery: --vault flag > ~/.config/hafez/vault
76
78
  `;
79
+ // Terminal handler for non-Hafez errors. Special-cases unbuilt better-sqlite3
80
+ // bindings: npm 11+ blocks install scripts by default, so `npm install -g
81
+ // hafez` reports success with the native module unbuilt and the first indexed
82
+ // command dies here — guide instead of stack-tracing.
83
+ function failUnknown(err) {
84
+ const e = err;
85
+ if (/better[-_]sqlite3|Could not locate the bindings file/i.test(`${e.message}\n${e.stack ?? ''}`)) {
86
+ process.stderr.write('Error: the SQLite bindings (better-sqlite3) are not built.\n' +
87
+ 'npm 11+ blocks install scripts by default, so the install reports success\n' +
88
+ 'while skipping the native build. Fix with:\n\n' +
89
+ ' npm install -g hafez --allow-scripts=better-sqlite3\n');
90
+ process.exit(1);
91
+ }
92
+ process.stderr.write(`Error: ${e.message}\n`);
93
+ process.exit(1);
94
+ }
77
95
  export async function main(argv) {
78
96
  const args = argv.slice(2);
79
97
  // Extract global flags
@@ -107,6 +125,11 @@ export async function main(argv) {
107
125
  process.stdout.write(USAGE);
108
126
  return;
109
127
  }
128
+ // onboard runs before vault resolution — static agent-directed text
129
+ if (command === 'onboard') {
130
+ process.stdout.write(renderOnboard());
131
+ return;
132
+ }
110
133
  // schema runs before vault resolution — pure Zod introspection, no vault needed.
111
134
  if (command === 'schema') {
112
135
  try {
@@ -122,8 +145,7 @@ export async function main(argv) {
122
145
  }
123
146
  process.exit(2);
124
147
  }
125
- process.stderr.write(`Error: ${err.message}\n`);
126
- process.exit(1);
148
+ failUnknown(err);
127
149
  }
128
150
  return;
129
151
  }
@@ -175,8 +197,7 @@ export async function main(argv) {
175
197
  }
176
198
  }
177
199
  catch (err) {
178
- process.stderr.write(`Error: ${err.message}\n`);
179
- process.exit(1);
200
+ failUnknown(err);
180
201
  }
181
202
  return;
182
203
  }
@@ -198,8 +219,7 @@ export async function main(argv) {
198
219
  const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
199
220
  process.exit(exitCodes[err.code] ?? 1);
200
221
  }
201
- process.stderr.write(`Error: ${err.message}\n`);
202
- process.exit(1);
222
+ failUnknown(err);
203
223
  }
204
224
  return;
205
225
  }
@@ -236,7 +256,6 @@ export async function main(argv) {
236
256
  const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
237
257
  process.exit(exitCodes[err.code] ?? 1);
238
258
  }
239
- process.stderr.write(`Error: ${err.message}\n`);
240
- process.exit(1);
259
+ failUnknown(err);
241
260
  }
242
261
  }
@@ -0,0 +1,2 @@
1
+ export declare const INTEGRATION_SNIPPET = "## Hafez \u2014 Session Memory\n\n- Session start: if the first message names a project or topic, load context\n with `hafez read <slug>` (or `hafez search \"<topic>\"`). Don't load\n speculatively.\n- Mid-session: new workstream -> create an entity. Durable insight -> create\n or update a knowledge note (search first). User mentions a project by\n name -> check if it's a Hafez entity. Use `hafez batch` for 2+ mutations.\n- Session end: record a digest (`hafez digest | hafez batch`; on shells\n that can't pipe, e.g. PowerShell 5.1: `--file` on both) with the\n entities touched, decisions made, and a short narrative.\n- Knowledge boundary: useful beyond this repo/session -> vault knowledge\n note. Specific to one repo's code -> that repo's own docs, not the vault.";
2
+ export declare function renderOnboard(): string;
@@ -0,0 +1,94 @@
1
+ // src/cli/onboard.ts
2
+ //
3
+ // `hafez onboard` — agent-directed first-run guide. Same pattern as
4
+ // `hafez help --agent`: plain text printed for the agent operating on the
5
+ // user's behalf. Covers the two things a fresh install leaves open — seeding
6
+ // the empty vault and choosing how deeply the agent integrates.
7
+ //
8
+ // Static by design, and deliberately NOT part of the skill: the skill loads
9
+ // into every session forever, this is read once. Living in the CLI also means
10
+ // it works before the skill is installed.
11
+ export const INTEGRATION_SNIPPET = `## Hafez — Session Memory
12
+
13
+ - Session start: if the first message names a project or topic, load context
14
+ with \`hafez read <slug>\` (or \`hafez search "<topic>"\`). Don't load
15
+ speculatively.
16
+ - Mid-session: new workstream -> create an entity. Durable insight -> create
17
+ or update a knowledge note (search first). User mentions a project by
18
+ name -> check if it's a Hafez entity. Use \`hafez batch\` for 2+ mutations.
19
+ - Session end: record a digest (\`hafez digest | hafez batch\`; on shells
20
+ that can't pipe, e.g. PowerShell 5.1: \`--file\` on both) with the
21
+ entities touched, decisions made, and a short narrative.
22
+ - Knowledge boundary: useful beyond this repo/session -> vault knowledge
23
+ note. Specific to one repo's code -> that repo's own docs, not the vault.`;
24
+ export function renderOnboard() {
25
+ return `# Hafez — First Run
26
+
27
+ You are an agent reading this on behalf of your user. Walk them through the
28
+ steps below conversationally — do not dump this document on them.
29
+
30
+ Prerequisites: \`hafez init\` resolves a vault, and you have read
31
+ \`hafez help --agent\` (do that now if not). The CLI is the whole interface —
32
+ no other pieces are required. If your harness is Claude Code, also install
33
+ the bundled skill — see the README Setup (github.com/shrvn-r/hafez).
34
+
35
+ Re-running onboard is normal and intended: on a vault that is already set
36
+ up and integrated, skip Step 2 and use Step 1 as a seeding interview for
37
+ whatever is new since last time. Seed current, active work — seeding never
38
+ means documenting the past; using the vault as you go stays primary.
39
+
40
+ ## Step 1 — Seed the vault
41
+
42
+ An empty vault gives the agent nothing to trigger on, so the memory loop
43
+ never starts. The goal here is a small set of high-quality entries — not a
44
+ bulk import.
45
+
46
+ 1. Interview the user: "What are you actively working on right now?" Aim for
47
+ 3-7 projects or areas. For each one, draw out:
48
+ - a one-line description (\`description\`)
49
+ - current state / handoff context, 2-4 sentences (\`brief\`)
50
+ - 1-2 concrete next actions (\`add_action\`)
51
+ 2. Offer to mine sources they already have — a repo's README, existing notes,
52
+ a TODO list. Read what they point you at, propose entries, and let them
53
+ approve before writing anything.
54
+ 3. Anything vague, aspirational, or someday-maybe becomes a capture, not a
55
+ project. Captures are the inbox; promotion comes later. When in doubt,
56
+ capture.
57
+ 4. Write everything in ONE \`hafez batch\` call (see
58
+ \`hafez schema create-entity --examples\`), then show the user
59
+ \`hafez stats\`.
60
+
61
+ Quality bar: every project entry should let a future agent with zero context
62
+ pick the work up from the brief alone. If a brief would not do that, ask one
63
+ more question.
64
+
65
+ ## Step 2 — Choose the integration level
66
+
67
+ Ask the user how they want Hafez wired into their sessions. Recommend the
68
+ first option.
69
+
70
+ RECOMMENDED — always active. Add the block below to the user's global agent
71
+ instructions file — Claude Code: \`~/.claude/CLAUDE.md\`; any harness that
72
+ follows the AGENTS.md standard (Codex, Cursor, Copilot, ...): its global
73
+ AGENTS.md, e.g. \`~/.codex/AGENTS.md\` for Codex CLI. This makes memory
74
+ automatic: context loads at session start, the vault is updated as work
75
+ happens, and a digest records each session. Show them the block, get a yes,
76
+ then append it verbatim:
77
+
78
+ ${INTEGRATION_SNIPPET}
79
+
80
+ FALLBACK — invoke only. If the user prefers to stay in control, add nothing.
81
+ Explain how invocation works: the agent touches the vault only when they
82
+ mention Hafez or name a project ("what am I working on?", "read <slug>").
83
+ Nothing is loaded or written unless asked. They can upgrade later
84
+ by re-running \`hafez onboard\`.
85
+
86
+ ## Step 3 — Hand over
87
+
88
+ Finish with three example asks tailored to what was just seeded, e.g.:
89
+
90
+ - "What am I working on?"
91
+ - "Read <their-project-slug> and pick up where we left off."
92
+ - "Capture: <idea> — I'll triage it later."
93
+ `;
94
+ }
package/dist/cli.js CHANGED
@@ -1,3 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { main } from './cli/index.js';
3
- main(process.argv);
2
+ // Dynamic import so a failed native-module load can be caught: npm 11+
3
+ // blocks install scripts by default, so `npm install -g hafez` "succeeds"
4
+ // with the better-sqlite3 bindings unbuilt and every command dies at import
5
+ // time. Catch that one failure and print the remedy instead of a stack trace.
6
+ try {
7
+ const { main } = await import('./cli/index.js');
8
+ await main(process.argv);
9
+ }
10
+ catch (err) {
11
+ const msg = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err);
12
+ if (/better[-_]sqlite3|Could not locate the bindings file|\bbindings\b/i.test(msg)) {
13
+ process.stderr.write('hafez: the SQLite bindings (better-sqlite3) are not built.\n' +
14
+ 'npm 11+ blocks install scripts by default, so the install reports success\n' +
15
+ 'while skipping the native build. Fix with:\n\n' +
16
+ ' npm install -g hafez --allow-scripts=better-sqlite3\n');
17
+ process.exit(1);
18
+ }
19
+ throw err;
20
+ }
21
+ export {};
package/dist/db.js CHANGED
@@ -322,15 +322,19 @@ function getStatsFn(db) {
322
322
  WHERE kind = 'entity' AND status = 'active' AND next_action_count = 0
323
323
  ORDER BY last_touched DESC
324
324
  `).all();
325
+ // Full lists, not top-5: day-granularity timestamps tie massively (a
326
+ // new vault ties on everything), so the caller resolves ties (git commit
327
+ // time, then name) and caps. Name tiebreak here keeps the base order
328
+ // deterministic instead of leaking SQLite row order.
325
329
  const recently_touched = db.prepare(`
326
330
  SELECT slug, name, last_touched FROM items
327
331
  WHERE kind = 'entity'
328
- ORDER BY last_touched DESC LIMIT 5
332
+ ORDER BY last_touched DESC, name COLLATE NOCASE ASC
329
333
  `).all();
330
334
  const recently_created = db.prepare(`
331
335
  SELECT slug, name, created FROM items
332
336
  WHERE kind = 'entity'
333
- ORDER BY created DESC LIMIT 5
337
+ ORDER BY created DESC, name COLLATE NOCASE ASC
334
338
  `).all();
335
339
  const knowledgeCount = db.prepare("SELECT COUNT(*) as cnt FROM items WHERE kind = 'knowledge'").get().cnt;
336
340
  return {
package/dist/git.d.ts CHANGED
@@ -16,4 +16,11 @@ export declare function gitSync(vaultPath: string): Promise<{
16
16
  */
17
17
  export declare function gitUnstage(vaultPath: string, files: string[]): Promise<void>;
18
18
  export declare function gitChangelog(vaultPath: string, since: string): Promise<ChangelogEntry[]>;
19
+ /**
20
+ * Latest git time per file (repo-relative, forward-slash paths). Used to
21
+ * break day-granularity frontmatter ties in stats recents. `modified` maps
22
+ * each file to its most recent commit; `added` to the commit that added it.
23
+ * Files with no history (not yet committed) are absent from the map.
24
+ */
25
+ export declare function gitFileTimes(vaultPath: string, mode: 'modified' | 'added'): Promise<Map<string, string>>;
19
26
  export declare function gitCommitAndPush(vaultPath: string, files: string[], message: string, config?: GitConfig): Promise<void>;
package/dist/git.js CHANGED
@@ -281,10 +281,15 @@ const FILE_STATUS_MAP = {
281
281
  };
282
282
  export async function gitChangelog(vaultPath, since) {
283
283
  const git = simpleGit(vaultPath);
284
+ // git approxidate fills a date-only --since with the *current* time of
285
+ // day, so `--since <today>` means "since right now" and silently returns
286
+ // nothing — the obvious "what happened today" query was the one that
287
+ // failed. Normalise bare dates to local midnight.
288
+ const normalizedSince = /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since}T00:00:00` : since;
284
289
  let output;
285
290
  try {
286
291
  output = await git.raw([
287
- 'log', `--since=${since}`, '--name-status',
292
+ 'log', `--since=${normalizedSince}`, '--name-status',
288
293
  '--pretty=format:%H|%aI|%s', '--', 'entities/', 'knowledge/',
289
294
  ]);
290
295
  }
@@ -311,6 +316,10 @@ export async function gitChangelog(vaultPath, since) {
311
316
  const operation = FILE_STATUS_MAP[statusChar];
312
317
  if (!operation)
313
318
  continue;
319
+ // Only vault documents — scaffold files (.gitkeep) live in the same
320
+ // dirs and would otherwise surface as changelog rows.
321
+ if (!filePath.endsWith('.md'))
322
+ continue;
314
323
  let kind;
315
324
  let slug;
316
325
  if (filePath.startsWith('entities/')) {
@@ -329,6 +338,37 @@ export async function gitChangelog(vaultPath, since) {
329
338
  }
330
339
  return entries;
331
340
  }
341
+ /**
342
+ * Latest git time per file (repo-relative, forward-slash paths). Used to
343
+ * break day-granularity frontmatter ties in stats recents. `modified` maps
344
+ * each file to its most recent commit; `added` to the commit that added it.
345
+ * Files with no history (not yet committed) are absent from the map.
346
+ */
347
+ export async function gitFileTimes(vaultPath, mode) {
348
+ const git = simpleGit(vaultPath);
349
+ let output;
350
+ try {
351
+ const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', 'entities/', 'knowledge/'];
352
+ if (mode === 'added')
353
+ args.splice(1, 0, '--diff-filter=A');
354
+ output = await git.raw(args);
355
+ }
356
+ catch {
357
+ return new Map(); // no git history
358
+ }
359
+ const times = new Map();
360
+ let current = '';
361
+ for (const line of output.split('\n')) {
362
+ if (line.startsWith('\0')) {
363
+ current = line.slice(1);
364
+ continue;
365
+ }
366
+ const file = line.trim();
367
+ if (file && !times.has(file))
368
+ times.set(file, current); // first hit = newest
369
+ }
370
+ return times;
371
+ }
332
372
  export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
333
373
  const git = simpleGit(vaultPath);
334
374
  // If a previous run crashed mid-rebase/merge, the repo is stuck.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
- import { parseFilePath, serializeFile, slugify, resolveFilePath } from './vault.js';
3
- import { gitCommitAndPush, gitSync, gitChangelog, gitUnstage } from './git.js';
2
+ import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath } from './vault.js';
3
+ import { gitCommitAndPush, gitSync, gitChangelog, gitUnstage, gitFileTimes } from './git.js';
4
4
  import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
5
5
  import { bodyTemplate, knowledgeBodyTemplate } from './templates.js';
6
6
  import { formatSessionLogEntry, countSessionLogEntries, extractOldestSessionLogEntry } from './parser.js';
@@ -71,9 +71,6 @@ export function createHafez(config) {
71
71
  }
72
72
  }
73
73
  function today() { return new Date().toISOString().slice(0, 10); }
74
- function kindFromPath(filePath) {
75
- return filePath.includes('/entities/') ? 'entity' : 'knowledge';
76
- }
77
74
  const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
78
75
  const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
79
76
  function validateKindFields(kind, fields) {
@@ -100,20 +97,22 @@ export function createHafez(config) {
100
97
  process.stderr.write(`Warning: failed to regenerate index.md: ${err.message}\n`);
101
98
  }
102
99
  }
103
- // Shared by create() and batch() so optional fields can't silently diverge
104
- // between the two paths (see CLAUDE.md Validation Gotchas).
105
- function buildEntityFrontmatter(name, ef) {
100
+ // Shared by create(), batch(), and the batch validate phase so optional
101
+ // fields can't silently diverge between paths (see CLAUDE.md Validation
102
+ // Gotchas). `existsFn` is pluggable so validateBatchCore can supply a
103
+ // simulation-aware check (same-batch creates count as existing).
104
+ function buildEntityFrontmatter(name, ef, existsFn = exists) {
106
105
  const fm = { name, type: ef.type, status: 'active', created: today(), 'last-touched': today() };
107
106
  if (ef.domain?.length)
108
107
  fm.domain = ef.domain;
109
108
  if (ef.parent) {
110
- if (!exists(ef.parent))
109
+ if (!existsFn(ef.parent))
111
110
  throw new HafezError('VALIDATION_FAILED', `Parent '${ef.parent}' does not exist`);
112
111
  fm.parent = ef.parent;
113
112
  }
114
113
  if (ef.related?.length) {
115
114
  for (const r of ef.related) {
116
- if (!exists(r))
115
+ if (!existsFn(r))
117
116
  throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
118
117
  }
119
118
  fm.related = ef.related;
@@ -129,13 +128,25 @@ export function createHafez(config) {
129
128
  throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
130
129
  return fm;
131
130
  }
132
- function buildKnowledgeFrontmatter(name, kf) {
131
+ // Shared by create() and batch() like buildEntityFrontmatter — body-side
132
+ // optional fields (brief, actions) must behave identically in both paths.
133
+ function buildEntityBody(ef) {
134
+ let body = bodyTemplate(ef.type, { purpose: ef.purpose });
135
+ if (ef.brief)
136
+ body = setBrief(body, ef.brief);
137
+ if (ef.add_action)
138
+ body = addNextAction(body, ef.add_action);
139
+ for (const action of ef.add_actions ?? [])
140
+ body = addNextAction(body, action);
141
+ return body;
142
+ }
143
+ function buildKnowledgeFrontmatter(name, kf, existsFn = exists) {
133
144
  const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
134
145
  if (kf.domain?.length)
135
146
  fm.domain = kf.domain;
136
147
  if (kf.related?.length) {
137
148
  for (const r of kf.related) {
138
- if (!exists(r))
149
+ if (!existsFn(r))
139
150
  throw new HafezError('VALIDATION_FAILED', `Related slug '${r}' does not exist`);
140
151
  }
141
152
  fm.related = kf.related;
@@ -312,11 +323,7 @@ export function createHafez(config) {
312
323
  if (kind === 'entity') {
313
324
  const ef = fields;
314
325
  const fm = buildEntityFrontmatter(name, ef);
315
- let body = bodyTemplate(ef.type, { purpose: ef.purpose });
316
- if (ef.brief)
317
- body = setBrief(body, ef.brief);
318
- if (ef.add_action)
319
- body = addNextAction(body, ef.add_action);
326
+ let body = buildEntityBody(ef);
320
327
  const filePath = entityPath(slug);
321
328
  mkdirSync(join(vaultPath, 'entities'), { recursive: true });
322
329
  body = syncRelatedSection(body, fm);
@@ -456,10 +463,193 @@ export function createHafez(config) {
456
463
  onWrite?.(slug, 'unlink');
457
464
  });
458
465
  }
466
+ // "Recent" with day-granularity dates: primary sort is the frontmatter day,
467
+ // ties break on git commit time (the real recency signal), then name for
468
+ // files with no commit history yet. Without this the top-5 lists leak
469
+ // SQLite row order — a brand-new vault (everything same-day) showed
470
+ // alphabetical-and-missing-newest lists.
471
+ async function rankRecents(rows, dateKey, mode) {
472
+ const day = (r) => r[dateKey];
473
+ if (rows.length <= 1)
474
+ return rows;
475
+ // Everything sharing the 5th row's day (or newer) could land in the top 5
476
+ const cutoffDay = day(rows[Math.min(4, rows.length - 1)]);
477
+ const candidates = rows.filter(r => day(r) >= cutoffDay);
478
+ const hasTies = new Set(candidates.map(day)).size < candidates.length;
479
+ if (!hasTies)
480
+ return rows.slice(0, 5);
481
+ const times = await gitFileTimes(vaultPath, mode);
482
+ const gitTime = (r) => times.get(`entities/${r.slug}.md`) ?? '';
483
+ candidates.sort((a, b) => {
484
+ const dayCmp = day(b).localeCompare(day(a));
485
+ if (dayCmp !== 0)
486
+ return dayCmp;
487
+ const gitCmp = gitTime(b).localeCompare(gitTime(a)); // '' (uncommitted) sorts last
488
+ if (gitCmp !== 0)
489
+ return gitCmp;
490
+ return a.name.localeCompare(b.name);
491
+ });
492
+ return candidates.slice(0, 5);
493
+ }
494
+ // Shared validate phase — dry-run and apply both run this, so dry-run can
495
+ // never pass a payload apply rejects (parity by construction). Simulates
496
+ // creates so same-batch references validate: batch executes sequentially,
497
+ // so op N really does see op 1's creates on disk at apply time.
498
+ function validateBatchCore(operations) {
499
+ // slug → what it would be after earlier ops in this batch
500
+ const simulated = new Map();
501
+ const simulatedSessions = new Set();
502
+ const resolve = (slug) => {
503
+ const sim = simulated.get(slug);
504
+ if (sim)
505
+ return sim;
506
+ const fp = findFile(slug);
507
+ if (!fp)
508
+ return null;
509
+ const kind = kindFromPath(fp);
510
+ if (kind === 'knowledge')
511
+ return { kind, type: 'knowledge' };
512
+ try {
513
+ return { kind, type: parseFilePath(fp).frontmatter.type };
514
+ }
515
+ catch {
516
+ return { kind, type: 'entity' };
517
+ }
518
+ };
519
+ const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
520
+ const report = [];
521
+ for (const [index, op] of operations.entries()) {
522
+ const errors = [];
523
+ let warning;
524
+ let slug;
525
+ let created = false;
526
+ const pushErr = (err) => {
527
+ const e = err;
528
+ errors.push(e.details?.length ? `${e.message}: ${e.details.join('; ')}` : e.message);
529
+ };
530
+ if (op.op === 'update') {
531
+ slug = op.slug;
532
+ const target = resolve(op.slug);
533
+ if (!target)
534
+ errors.push(`'${op.slug}' not found`);
535
+ else {
536
+ try {
537
+ validateKindFields(target.kind, op.fields);
538
+ }
539
+ catch (err) {
540
+ pushErr(err);
541
+ }
542
+ }
543
+ if (op.fields.session_log) {
544
+ const logErrors = validateSessionLogEntry(op.fields.session_log);
545
+ if (logErrors.length > 0)
546
+ errors.push(`Invalid session log entry: ${logErrors.join('; ')}`);
547
+ }
548
+ if (op.fields.related) {
549
+ for (const r of op.fields.related) {
550
+ if (!existsAnywhere(r))
551
+ errors.push(`Related slug '${r}' does not exist`);
552
+ }
553
+ }
554
+ }
555
+ else if (op.op === 'create' && op.kind === 'session') {
556
+ slug = slugify(op.name);
557
+ if (exists(slug) || existsSync(join(vaultPath, 'sessions', `${slug}.md`)) || simulatedSessions.has(slug)) {
558
+ warning = `session '${slug}' already exists — op will be skipped (batch continues)`;
559
+ }
560
+ else {
561
+ simulatedSessions.add(slug);
562
+ created = true;
563
+ }
564
+ }
565
+ else if (op.op === 'create') {
566
+ slug = slugify(op.name);
567
+ if (existsAnywhere(slug))
568
+ errors.push(`Slug '${slug}' already exists`);
569
+ if (op.kind === 'entity') {
570
+ try {
571
+ buildEntityFrontmatter(op.name, op.fields, existsAnywhere);
572
+ }
573
+ catch (err) {
574
+ pushErr(err);
575
+ }
576
+ simulated.set(slug, { kind: 'entity', type: op.fields.type });
577
+ }
578
+ else {
579
+ try {
580
+ buildKnowledgeFrontmatter(op.name, (op.fields || {}), existsAnywhere);
581
+ }
582
+ catch (err) {
583
+ pushErr(err);
584
+ }
585
+ simulated.set(slug, { kind: 'knowledge', type: 'knowledge' });
586
+ }
587
+ created = errors.length === 0;
588
+ }
589
+ else if (op.op === 'capture') {
590
+ slug = slugify(op.name);
591
+ if (existsAnywhere(slug))
592
+ errors.push(`Slug '${slug}' already exists`);
593
+ simulated.set(slug, { kind: 'entity', type: 'capture' });
594
+ created = errors.length === 0;
595
+ }
596
+ else if (op.op === 'link') {
597
+ slug = op.slug;
598
+ if (!resolve(op.slug))
599
+ errors.push(`'${op.slug}' not found`);
600
+ if (!existsAnywhere(op.target))
601
+ errors.push(`Target '${op.target}' does not exist`);
602
+ }
603
+ else if (op.op === 'unlink') {
604
+ slug = op.slug;
605
+ if (!resolve(op.slug))
606
+ errors.push(`'${op.slug}' not found`);
607
+ }
608
+ else {
609
+ // promote
610
+ slug = op.slug;
611
+ const cur = resolve(op.slug);
612
+ if (!cur)
613
+ errors.push(`'${op.slug}' not found`);
614
+ else {
615
+ const currentType = cur.kind === 'knowledge' ? 'knowledge' : cur.type;
616
+ try {
617
+ const contract = getContract(currentType);
618
+ if (!contract.canPromoteTo.includes(op.target)) {
619
+ errors.push(`Cannot promote ${currentType} to ${op.target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
620
+ }
621
+ else {
622
+ simulated.set(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
623
+ }
624
+ }
625
+ catch (err) {
626
+ pushErr(err);
627
+ }
628
+ }
629
+ }
630
+ const entry = { index, op: op.op, slug, errors };
631
+ if (created)
632
+ entry.created = true;
633
+ if (warning)
634
+ entry.warning = warning;
635
+ report.push(entry);
636
+ }
637
+ return { valid: report.every(o => o.errors.length === 0), operations: report };
638
+ }
639
+ function formatOpErrors(report) {
640
+ return report.operations.flatMap(o => o.errors.map(e => `op[${o.index}] (${o.op} ${o.slug}): ${e}`));
641
+ }
459
642
  async function batch(operations) {
460
643
  if (readOnly)
461
644
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
462
645
  return withLock(async () => {
646
+ // Validate the whole payload before touching anything — all errors
647
+ // surface at once, localised to their op.
648
+ const validation = validateBatchCore(operations);
649
+ if (!validation.valid) {
650
+ const invalidCount = validation.operations.filter(o => o.errors.length > 0).length;
651
+ throw new HafezError('VALIDATION_FAILED', `Batch failed validation: ${invalidCount} invalid operation${invalidCount === 1 ? '' : 's'} (0 of ${operations.length} applied)`, formatOpErrors(validation));
652
+ }
463
653
  // Capture originals for rollback
464
654
  const originals = new Map();
465
655
  function captureOriginal(filePath) {
@@ -470,8 +660,10 @@ export function createHafez(config) {
470
660
  const modifiedFiles = [];
471
661
  const affectedSlugs = [];
472
662
  const results = [];
663
+ let currentOp = -1;
473
664
  try {
474
665
  for (const op of operations) {
666
+ currentOp++;
475
667
  if (op.op === 'update') {
476
668
  const filePath = findFile(op.slug);
477
669
  if (!filePath)
@@ -530,11 +722,7 @@ export function createHafez(config) {
530
722
  if (op.kind === 'entity') {
531
723
  const ef = op.fields;
532
724
  const fm = buildEntityFrontmatter(op.name, ef);
533
- let entBody = bodyTemplate(ef.type, { purpose: ef.purpose });
534
- if (ef.brief)
535
- entBody = setBrief(entBody, ef.brief);
536
- if (ef.add_action)
537
- entBody = addNextAction(entBody, ef.add_action);
725
+ let entBody = buildEntityBody(ef);
538
726
  const filePath = entityPath(slug);
539
727
  captureOriginal(filePath);
540
728
  mkdirSync(join(vaultPath, 'entities'), { recursive: true });
@@ -643,6 +831,14 @@ export function createHafez(config) {
643
831
  writeFileSync(path, content);
644
832
  }
645
833
  }
834
+ // Localise: which op failed, by index and slug/name. The validate
835
+ // phase above catches semantic errors pre-execution; this covers
836
+ // residual execution-time failures (races, disk errors).
837
+ if (err instanceof HafezError && currentOp >= 0) {
838
+ const op = operations[currentOp];
839
+ const label = 'slug' in op ? op.slug : slugify(op.name);
840
+ throw new HafezError(err.code, `op[${currentOp}] (${op.op} ${label}): ${err.message}`, err.details);
841
+ }
646
842
  throw err;
647
843
  }
648
844
  // Single git commit for all batch operations
@@ -918,6 +1114,7 @@ export function createHafez(config) {
918
1114
  link,
919
1115
  unlink,
920
1116
  batch,
1117
+ validateBatch: async (operations) => validateBatchCore(operations),
921
1118
  sync: async () => {
922
1119
  if (readOnly)
923
1120
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
@@ -959,7 +1156,10 @@ export function createHafez(config) {
959
1156
  if (!idx)
960
1157
  return emptyStats;
961
1158
  idx.syncIfStale();
962
- return idx.getStats();
1159
+ const s = idx.getStats();
1160
+ s.recently_touched = await rankRecents(s.recently_touched, 'last_touched', 'modified');
1161
+ s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
1162
+ return s;
963
1163
  },
964
1164
  changelog: (since) => gitChangelog(vaultPath, since),
965
1165
  };
package/dist/types.d.ts CHANGED
@@ -112,6 +112,7 @@ export interface CreateEntityFields {
112
112
  tags?: string[];
113
113
  brief?: string;
114
114
  add_action?: string;
115
+ add_actions?: string[];
115
116
  }
116
117
  export interface CreateKnowledgeFields {
117
118
  subtype?: KnowledgeSubtype;
@@ -259,6 +260,18 @@ export type BatchOperation = {
259
260
  slug: string;
260
261
  target: 'entity' | 'project' | 'knowledge';
261
262
  };
263
+ export interface BatchOpValidation {
264
+ index: number;
265
+ op: string;
266
+ slug: string;
267
+ created?: boolean;
268
+ errors: string[];
269
+ warning?: string;
270
+ }
271
+ export interface BatchValidationReport {
272
+ valid: boolean;
273
+ operations: BatchOpValidation[];
274
+ }
262
275
  export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'VAULT_LOCKED';
263
276
  export declare class HafezError extends Error {
264
277
  code: HafezErrorCode;
@@ -294,6 +307,7 @@ export interface Hafez {
294
307
  unlink(slug: string, target: string, relation: LinkRelation): Promise<void>;
295
308
  promote(slug: string, target: 'entity' | 'project' | 'knowledge'): Promise<string>;
296
309
  batch(operations: BatchOperation[]): Promise<BatchResult[]>;
310
+ validateBatch(operations: BatchOperation[]): Promise<BatchValidationReport>;
297
311
  sync(): Promise<{
298
312
  pulled: boolean;
299
313
  pushed: boolean;
package/dist/vault.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export declare function slugify(name: string): string;
2
2
  export declare function resolveFilePath(vaultPath: string, slug: string, kind: 'entity' | 'knowledge'): string;
3
+ export declare function kindFromPath(filePath: string): 'entity' | 'knowledge';
3
4
  export declare function slugExists(vaultPath: string, slug: string): boolean;
4
5
  export declare function parseFilePath(path: string): {
5
6
  frontmatter: Record<string, any>;
package/dist/vault.js CHANGED
@@ -28,6 +28,13 @@ export function resolveFilePath(vaultPath, slug, kind) {
28
28
  const dir = kind === 'entity' ? 'entities' : 'knowledge';
29
29
  return join(vaultPath, dir, `${slug}.md`);
30
30
  }
31
+ // Separator-agnostic: OS paths use '/' or '\' depending on platform, and a
32
+ // POSIX-only check silently classifies every entity as knowledge on Windows
33
+ // (all updates then fail on kind rules). Exported pure so win32 paths are
34
+ // unit-testable from any platform.
35
+ export function kindFromPath(filePath) {
36
+ return /[/\\]entities[/\\]/.test(filePath) ? 'entity' : 'knowledge';
37
+ }
31
38
  export function slugExists(vaultPath, slug) {
32
39
  return existsSync(resolveFilePath(vaultPath, slug, 'entity'))
33
40
  || existsSync(resolveFilePath(vaultPath, slug, 'knowledge'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hafez",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "build": "rm -rf dist && tsc",
21
- "install:local": "mkdir -p ~/.local/bin && ln -sf \"$(pwd)/dist/cli.js\" ~/.local/bin/hafez && rm -f ~/.claude/skills/hafez/SKILL.md && mkdir -p ~/.claude/skills/hafez && cp \"$(pwd)/skill/SKILL.md\" ~/.claude/skills/hafez/SKILL.md",
21
+ "install:local": "chmod +x \"$(pwd)/dist/cli.js\" && mkdir -p ~/.local/bin && ln -sf \"$(pwd)/dist/cli.js\" ~/.local/bin/hafez && rm -f ~/.claude/skills/hafez/SKILL.md && mkdir -p ~/.claude/skills/hafez && cp \"$(pwd)/skill/SKILL.md\" ~/.claude/skills/hafez/SKILL.md",
22
22
  "pretest": "npm run build",
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
@@ -34,15 +34,15 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/better-sqlite3": "^7.6.13",
37
- "@types/proper-lockfile": "^4.1.4",
38
37
  "@types/js-yaml": "^4.0.9",
39
38
  "@types/node": "^25.4.0",
39
+ "@types/proper-lockfile": "^4.1.4",
40
40
  "tsx": "^4.21.0",
41
41
  "typescript": "^5.9.3",
42
42
  "vitest": "^4.0.18"
43
43
  },
44
44
  "license": "MIT",
45
- "description": "Agent-native personal knowledge vault: markdown + YAML frontmatter, SQLite FTS5 search, git-backed",
45
+ "description": "A knowledge and project vault shared between you and your agents — plain markdown + git, schema-enforced writes, FTS5 search, operated by agents through a CLI",
46
46
  "keywords": [
47
47
  "knowledge-base",
48
48
  "knowledge-vault",
package/skill/SKILL.md CHANGED
@@ -14,7 +14,7 @@ description: >
14
14
 
15
15
  All operations use the `hafez` CLI. No file manipulation — the CLI handles reads, writes, validation, and git.
16
16
 
17
- If `hafez` is not installed, or `hafez init` reports no vault, stop and follow the setup in the project README (github.com/shrvn-r/hafez) before using this skill.
17
+ If `hafez` is not installed, or `hafez init` reports no vault, stop and follow the setup in the project README (github.com/shrvn-r/hafez) before using this skill. If the vault exists but is empty, run `hafez onboard` and follow what it prints.
18
18
 
19
19
  ## CLI Reference
20
20
 
@@ -41,7 +41,7 @@ Quick reference for common operations:
41
41
  | Add evidence to knowledge | `hafez update <slug> --add-evidence "text"` |
42
42
  | Add source to knowledge | `hafez update <slug> --add-source "url"` |
43
43
  | Export OKF bundle | `hafez export --okf [--out <dir>]` (re-export overwrites, never deletes) |
44
- | Digest session → batch | `echo '{...}' \| hafez digest \| hafez batch` |
44
+ | Digest session → batch | `echo '{...}' \| hafez digest \| hafez batch` (no pipes? `--file` on both) |
45
45
 
46
46
  Note: `--insight` is an alias for `--synthesis`.
47
47
 
@@ -143,6 +143,10 @@ echo '[
143
143
 
144
144
  Batch output includes created slugs: `Batch complete: 3 operations (created: new-thing, insight)`
145
145
 
146
+ On shells that can't pipe stdin to native executables (Windows PowerShell 5.1), write the payload to a file and use `hafez batch --file payload.json` — same JSON, same behavior. (`cmd /c "hafez batch < payload.json"` also works.)
147
+
148
+ **Slug contract.** Slugs derive deterministically from names: lowercase, non-alphanumeric runs become single `-` (`"Auth Refactor!"` → `auth-refactor`). Batch executes sequentially, so later ops can reference the slugs of earlier creates in the same payload — create-then-link works in one batch. Collisions hard-error, so a wrong prediction can't touch the wrong document; `hafez batch --dry-run` reports the derived slug for every create op if you want to verify first.
149
+
146
150
  For a single operation, individual commands are fine — they auto-sync.
147
151
 
148
152
  ## Session End — Digest
@@ -163,6 +167,8 @@ echo '{
163
167
  }' | hafez digest | hafez batch
164
168
  ```
165
169
 
170
+ On shells that can't pipe (PowerShell 5.1), use files instead — digest stays inspectable in the middle: `hafez digest --file summary.json > ops.json`, then `hafez batch --file ops.json`.
171
+
166
172
  Digest adds a session log entry to each touched entity and creates a session note linking them all. Authoritative field docs: `hafez schema digest`.
167
173
 
168
174
  Digest itself is pure — it only prints batch JSON; `hafez batch` is what writes. If you are unsure the slugs exist, run `hafez digest` alone first: unknown slugs warn on stderr (they are omitted from the output). Fix `entities_touched`, regenerate, and pipe to batch **once** — re-running the full pipe after a successful batch creates a duplicate session note.