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 +15 -8
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +47 -8
- package/dist/cli/help-agent.js +17 -4
- package/dist/cli/index.js +20 -8
- package/dist/cli/onboard.d.ts +1 -1
- package/dist/cli/onboard.js +7 -1
- package/dist/cli.js +20 -2
- package/dist/db.js +6 -2
- package/dist/git.d.ts +7 -0
- package/dist/git.js +41 -1
- package/dist/index.js +223 -23
- package/dist/types.d.ts +14 -0
- package/dist/vault.d.ts +1 -0
- package/dist/vault.js +7 -0
- package/package.json +3 -3
- package/skill/SKILL.md +7 -1
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Hafez
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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,
|
|
129
|
+
export declare function cmdDigest(os: Hafez, args: string[], _opts?: CommandOpts): Promise<string>;
|
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
511
|
-
|
|
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,
|
|
523
|
-
|
|
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
|
|
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;
|
package/dist/cli/help-agent.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
process.exit(1);
|
|
259
|
+
failUnknown(err);
|
|
248
260
|
}
|
|
249
261
|
}
|
package/dist/cli/onboard.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/onboard.js
CHANGED
|
@@ -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
|
|
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
|
|
3
|
-
|
|
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
|
|
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
|
|
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=${
|
|
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()
|
|
104
|
-
//
|
|
105
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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.
|