hafez 1.1.0 → 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,8 +19,9 @@ 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
@@ -38,13 +41,17 @@ Set up Hafez, an agent-native knowledge vault (github.com/shrvn-r/hafez):
38
41
  From then on, just mention your projects by name in any session — the skill
39
42
  teaches the agent when to read and write the vault, no commands required.
40
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
+
41
48
  ### Manual setup
42
49
 
43
50
  The same thing by hand:
44
51
 
45
52
  ```bash
46
53
  # Install (Node.js 22+ and git required).
47
- # On npm 12+, append: --allow-scripts=better-sqlite3
54
+ # On npm 11+, append: --allow-scripts=better-sqlite3
48
55
  npm install -g hafez
49
56
 
50
57
  # One-time, if you've never used git on this machine:
@@ -133,7 +140,7 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
133
140
  | `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
134
141
  | `hafez update <slug> [flags]` | Status, brief, actions, session log, synthesis, evidence… |
135
142
  | `hafez link / unlink <slug> <target> --relation <parent\|related>` | Manage relationships |
136
- | `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 |
137
144
  | `hafez stats` | Vault summary: counts, stale items, recents |
138
145
  | `hafez changelog --since 7.days.ago` | Git-derived change history |
139
146
  | `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
@@ -181,7 +188,7 @@ for everything on the [AGENTS.md standard](https://agents.md) (Codex, Cursor,
181
188
  Copilot, …). This repo follows the same convention: `AGENTS.md` is canonical
182
189
  and `CLAUDE.md` imports it.
183
190
 
184
- - **`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).
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.
185
192
  - **`hafez help --agent`** — a complete API reference designed to be loaded into an agent's context.
186
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.
187
194
  - **`hafez schema <op> --examples`** — JSON schemas with working examples for every batch operation, so agents can self-correct.
@@ -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;
@@ -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
@@ -76,6 +76,22 @@ Global flags:
76
76
 
77
77
  Vault discovery: --vault flag > ~/.config/hafez/vault
78
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
+ }
79
95
  export async function main(argv) {
80
96
  const args = argv.slice(2);
81
97
  // Extract global flags
@@ -129,8 +145,7 @@ export async function main(argv) {
129
145
  }
130
146
  process.exit(2);
131
147
  }
132
- process.stderr.write(`Error: ${err.message}\n`);
133
- process.exit(1);
148
+ failUnknown(err);
134
149
  }
135
150
  return;
136
151
  }
@@ -182,8 +197,7 @@ export async function main(argv) {
182
197
  }
183
198
  }
184
199
  catch (err) {
185
- process.stderr.write(`Error: ${err.message}\n`);
186
- process.exit(1);
200
+ failUnknown(err);
187
201
  }
188
202
  return;
189
203
  }
@@ -205,8 +219,7 @@ export async function main(argv) {
205
219
  const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
206
220
  process.exit(exitCodes[err.code] ?? 1);
207
221
  }
208
- process.stderr.write(`Error: ${err.message}\n`);
209
- process.exit(1);
222
+ failUnknown(err);
210
223
  }
211
224
  return;
212
225
  }
@@ -243,7 +256,6 @@ export async function main(argv) {
243
256
  const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
244
257
  process.exit(exitCodes[err.code] ?? 1);
245
258
  }
246
- process.stderr.write(`Error: ${err.message}\n`);
247
- process.exit(1);
259
+ failUnknown(err);
248
260
  }
249
261
  }
@@ -1,2 +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`) 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.";
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
2
  export declare function renderOnboard(): string;
@@ -16,7 +16,8 @@ export const INTEGRATION_SNIPPET = `## Hafez — Session Memory
16
16
  - Mid-session: new workstream -> create an entity. Durable insight -> create
17
17
  or update a knowledge note (search first). User mentions a project by
18
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\`) with the
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
20
21
  entities touched, decisions made, and a short narrative.
21
22
  - Knowledge boundary: useful beyond this repo/session -> vault knowledge
22
23
  note. Specific to one repo's code -> that repo's own docs, not the vault.`;
@@ -31,6 +32,11 @@ Prerequisites: \`hafez init\` resolves a vault, and you have read
31
32
  no other pieces are required. If your harness is Claude Code, also install
32
33
  the bundled skill — see the README Setup (github.com/shrvn-r/hafez).
33
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
+
34
40
  ## Step 1 — Seed the vault
35
41
 
36
42
  An empty vault gives the agent nothing to trigger on, so the memory loop
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.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -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
@@ -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.