hafez 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/dist/batch-ops.d.ts +187 -0
- package/dist/batch-ops.js +322 -0
- package/dist/cli/args.d.ts +15 -0
- package/dist/cli/args.js +49 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +1 -109
- package/dist/cli/commands.js +197 -256
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +37 -9
- package/dist/cli/index.js +61 -69
- package/dist/cli/onboard.d.ts +1 -1
- package/dist/cli/onboard.js +7 -1
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli.js +20 -2
- package/dist/contracts.d.ts +6 -0
- package/dist/contracts.js +8 -2
- package/dist/db.js +20 -30
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +108 -0
- package/dist/{sections.js → document.js} +173 -22
- package/dist/export-okf.js +10 -22
- package/dist/git.d.ts +13 -7
- package/dist/git.js +79 -21
- package/dist/index.d.ts +3 -4
- package/dist/index.js +508 -406
- package/dist/knowledge-index.js +9 -7
- package/dist/merge.js +13 -25
- package/dist/query.d.ts +1 -1
- package/dist/query.js +3 -1
- package/dist/types.d.ts +62 -1
- package/dist/vault.d.ts +34 -6
- package/dist/vault.js +71 -7
- package/package.json +4 -4
- package/skill/SKILL.md +7 -1
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/sections.d.ts +0 -54
package/dist/cli/help-agent.js
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// session and then produce valid batch JSON on first try.
|
|
6
6
|
//
|
|
7
7
|
// Hand-written sections: Preamble + Hard rules (prose, free-form).
|
|
8
|
-
// Generated sections (wrapped in <!-- SECTION:name --> markers so
|
|
9
|
-
//
|
|
8
|
+
// Generated sections (wrapped in <!-- SECTION:name --> markers so tests can
|
|
9
|
+
// parse them): op-catalog, critical-enums, batch-examples, digest.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// those come from
|
|
11
|
+
// Generated sections derive from the Op Spec table (batch-ops.ts via
|
|
12
|
+
// schema-introspect.ts), so they cannot drift from the Zod schemas. Do NOT
|
|
13
|
+
// enumerate enum values inside the preamble or hard rules — those come from
|
|
14
|
+
// schema-introspect.ts.
|
|
14
15
|
import { listOps, getEnumValues, getOpExample, getOpSchema } from './schema-introspect.js';
|
|
15
16
|
const PREAMBLE = `# Hafez — Agent Prompt Gate
|
|
16
17
|
|
|
@@ -58,7 +59,17 @@ listed here, it does not exist — do not invent fields.
|
|
|
58
59
|
- When in doubt, run \`hafez schema <op>\` before writing a batch. Cheap,
|
|
59
60
|
deterministic, no side effects.
|
|
60
61
|
- Use \`hafez batch --dry-run\` to validate a payload without touching the
|
|
61
|
-
vault. Same
|
|
62
|
+
vault. Same validation as a real apply, zero filesystem mutation.
|
|
63
|
+
|
|
64
|
+
## Slug contract (create-then-link in one batch)
|
|
65
|
+
|
|
66
|
+
Slugs are deterministic: slug = slugify(name) — lowercase, non-alphanumeric
|
|
67
|
+
runs collapse to single \`-\`, edges trimmed (\`"Auth Refactor!"\` →
|
|
68
|
+
\`auth-refactor\`). A batch executes sequentially, so later ops may reference
|
|
69
|
+
the slugs of earlier creates in the same batch (\`create\` then \`link\`/\`update\`
|
|
70
|
+
works in one payload). Collisions hard-error — a wrong slug prediction can
|
|
71
|
+
never silently hit an existing document. To verify predictions first:
|
|
72
|
+
\`hafez batch --dry-run\` reports the derived slug for every create op.
|
|
62
73
|
`;
|
|
63
74
|
function renderOpCatalog() {
|
|
64
75
|
const lines = ['<!-- SECTION:op-catalog -->'];
|
|
@@ -93,9 +104,10 @@ function renderCriticalEnums() {
|
|
|
93
104
|
}
|
|
94
105
|
function renderBatchExamples() {
|
|
95
106
|
const lines = ['<!-- SECTION:batch-examples -->'];
|
|
96
|
-
lines.push('Batch JSON shape — wrap one or more ops in an array
|
|
107
|
+
lines.push('Batch JSON shape — wrap one or more ops in an array:');
|
|
97
108
|
lines.push('');
|
|
98
|
-
lines.push(' echo \'[...]\' | hafez batch');
|
|
109
|
+
lines.push(' echo \'[...]\' | hafez batch # POSIX shells');
|
|
110
|
+
lines.push(' hafez batch --file payload.json # any platform (PowerShell 5.1 cannot pipe stdin)');
|
|
99
111
|
lines.push('');
|
|
100
112
|
lines.push('Minimal example per op (canonical field names; aliased keys stripped):');
|
|
101
113
|
lines.push('');
|
|
@@ -113,9 +125,11 @@ function renderBatchExamples() {
|
|
|
113
125
|
function renderDigestSection() {
|
|
114
126
|
const lines = ['<!-- SECTION:digest -->'];
|
|
115
127
|
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
|
|
128
|
+
lines.push('NOT a batch op: it reads its own JSON and prints batch JSON.');
|
|
117
129
|
lines.push('');
|
|
118
130
|
lines.push(" echo '<digest-json>' | hafez digest | hafez batch");
|
|
131
|
+
lines.push(' # No stdin pipes (PowerShell 5.1): hafez digest --file summary.json > ops.json,');
|
|
132
|
+
lines.push(' # then hafez batch --file ops.json');
|
|
119
133
|
lines.push('');
|
|
120
134
|
lines.push('Input fields (`hafez schema digest` for the full schema):');
|
|
121
135
|
const schema = getOpSchema('digest');
|
|
@@ -142,6 +156,18 @@ const FOOTER = `## Lookup pointers
|
|
|
142
156
|
* measured as char/3.5. The drift test in tests/cli-help-agent.test.ts
|
|
143
157
|
* enforces the ceiling.
|
|
144
158
|
*/
|
|
159
|
+
const EXIT_CODES_SECTION = `## Exit codes
|
|
160
|
+
|
|
161
|
+
| code | meaning |
|
|
162
|
+
|------|---------|
|
|
163
|
+
| 0 | success |
|
|
164
|
+
| 1 | not found / unknown error |
|
|
165
|
+
| 2 | validation or usage error (bad flags, bad payload, core rejected the write) |
|
|
166
|
+
| 3 | git push failed — the write IS committed locally |
|
|
167
|
+
| 4 | write succeeded but the auto-sync after it failed |
|
|
168
|
+
| 5 | slug already exists |
|
|
169
|
+
| 6 | git commit failed — nothing was written to history |
|
|
170
|
+
| 7 | vault locked by another hafez process — retry when it finishes |`;
|
|
145
171
|
export function renderAgentHelp() {
|
|
146
172
|
return [
|
|
147
173
|
PREAMBLE,
|
|
@@ -153,6 +179,8 @@ export function renderAgentHelp() {
|
|
|
153
179
|
'',
|
|
154
180
|
renderDigestSection(),
|
|
155
181
|
'',
|
|
182
|
+
EXIT_CODES_SECTION,
|
|
183
|
+
'',
|
|
156
184
|
FOOTER,
|
|
157
185
|
].join('\n');
|
|
158
186
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'path';
|
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { createHafez } from '../index.js';
|
|
6
6
|
import { HafezError } from '../types.js';
|
|
7
|
+
import { parseArgs, UsageError } from './args.js';
|
|
7
8
|
import { cmdRead, cmdQuery, cmdSearch, cmdCreate, cmdUpdate, cmdLink, cmdUnlink, cmdPromote, cmdValidate, cmdIndexRebuild, cmdBatch, cmdSync, cmdStats, cmdChangelog, cmdDigest, cmdCapture } from './commands.js';
|
|
8
9
|
import { cmdSchema } from './commands-schema.js';
|
|
9
10
|
import { renderAgentHelp } from './help-agent.js';
|
|
@@ -11,6 +12,7 @@ import { renderOnboard } from './onboard.js';
|
|
|
11
12
|
import { resolveVaultPath, noVaultMessage, cmdInit } from './resolve-vault.js';
|
|
12
13
|
const COMMANDS = {
|
|
13
14
|
read: cmdRead,
|
|
15
|
+
sync: cmdSync,
|
|
14
16
|
query: cmdQuery,
|
|
15
17
|
search: cmdSearch,
|
|
16
18
|
create: cmdCreate,
|
|
@@ -25,7 +27,7 @@ const COMMANDS = {
|
|
|
25
27
|
index: (os, args) => {
|
|
26
28
|
if (args[0] === 'rebuild')
|
|
27
29
|
return cmdIndexRebuild(os);
|
|
28
|
-
throw new
|
|
30
|
+
throw new UsageError('Usage: hafez index rebuild');
|
|
29
31
|
},
|
|
30
32
|
stats: cmdStats,
|
|
31
33
|
changelog: cmdChangelog,
|
|
@@ -76,6 +78,49 @@ Global flags:
|
|
|
76
78
|
|
|
77
79
|
Vault discovery: --vault flag > ~/.config/hafez/vault
|
|
78
80
|
`;
|
|
81
|
+
/** Exit codes, documented in `hafez help --agent`. 0 = success; 4 is the
|
|
82
|
+
* mutate-succeeded-sync-failed warning path; anything unmapped = 1. */
|
|
83
|
+
const EXIT_CODES = {
|
|
84
|
+
NOT_FOUND: 1,
|
|
85
|
+
VALIDATION_FAILED: 2,
|
|
86
|
+
GIT_PUSH_FAILED: 3,
|
|
87
|
+
SLUG_EXISTS: 5,
|
|
88
|
+
GIT_COMMIT_FAILED: 6,
|
|
89
|
+
VAULT_LOCKED: 7,
|
|
90
|
+
};
|
|
91
|
+
/** The one error → exit adapter. Usage errors (bad flags/args) are distinct
|
|
92
|
+
* from vault errors and from unknown crashes. */
|
|
93
|
+
function report(err) {
|
|
94
|
+
if (err instanceof UsageError) {
|
|
95
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
96
|
+
process.exit(2);
|
|
97
|
+
}
|
|
98
|
+
if (err instanceof HafezError) {
|
|
99
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
100
|
+
if (err.details?.length) {
|
|
101
|
+
for (const detail of err.details)
|
|
102
|
+
process.stderr.write(` - ${detail}\n`);
|
|
103
|
+
}
|
|
104
|
+
process.exit(EXIT_CODES[err.code] ?? 1);
|
|
105
|
+
}
|
|
106
|
+
failUnknown(err);
|
|
107
|
+
}
|
|
108
|
+
// Terminal handler for non-Hafez errors. Special-cases unbuilt better-sqlite3
|
|
109
|
+
// bindings: npm 11+ blocks install scripts by default, so `npm install -g
|
|
110
|
+
// hafez` reports success with the native module unbuilt and the first indexed
|
|
111
|
+
// command dies here — guide instead of stack-tracing.
|
|
112
|
+
function failUnknown(err) {
|
|
113
|
+
const e = err;
|
|
114
|
+
if (/better[-_]sqlite3|Could not locate the bindings file/i.test(`${e.message}\n${e.stack ?? ''}`)) {
|
|
115
|
+
process.stderr.write('Error: the SQLite bindings (better-sqlite3) are not built.\n' +
|
|
116
|
+
'npm 11+ blocks install scripts by default, so the install reports success\n' +
|
|
117
|
+
'while skipping the native build. Fix with:\n\n' +
|
|
118
|
+
' npm install -g hafez --allow-scripts=better-sqlite3\n');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
process.stderr.write(`Error: ${e.message}\n`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
79
124
|
export async function main(argv) {
|
|
80
125
|
const args = argv.slice(2);
|
|
81
126
|
// Extract global flags
|
|
@@ -121,16 +166,7 @@ export async function main(argv) {
|
|
|
121
166
|
process.stdout.write(result + '\n');
|
|
122
167
|
}
|
|
123
168
|
catch (err) {
|
|
124
|
-
|
|
125
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
126
|
-
if (err.details?.length) {
|
|
127
|
-
for (const detail of err.details)
|
|
128
|
-
process.stderr.write(` - ${detail}\n`);
|
|
129
|
-
}
|
|
130
|
-
process.exit(2);
|
|
131
|
-
}
|
|
132
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
133
|
-
process.exit(1);
|
|
169
|
+
report(err);
|
|
134
170
|
}
|
|
135
171
|
return;
|
|
136
172
|
}
|
|
@@ -152,61 +188,28 @@ export async function main(argv) {
|
|
|
152
188
|
const vaultPath = resolution;
|
|
153
189
|
// export runs before Hafez instance creation — read-only, no auto-sync
|
|
154
190
|
if (command === 'export') {
|
|
155
|
-
if (!args.includes('--okf')) {
|
|
156
|
-
process.stderr.write('Usage: hafez export --okf [--out <dir>]\n');
|
|
157
|
-
process.exit(1);
|
|
158
|
-
}
|
|
159
|
-
const outIdx = args.indexOf('--out');
|
|
160
|
-
let outDir = resolve(process.cwd(), 'okf-export');
|
|
161
|
-
if (outIdx !== -1) {
|
|
162
|
-
const outValue = args[outIdx + 1];
|
|
163
|
-
if (!outValue) {
|
|
164
|
-
process.stderr.write('Usage: hafez export --okf [--out <dir>]\n');
|
|
165
|
-
process.exit(1);
|
|
166
|
-
}
|
|
167
|
-
outDir = resolve(outValue);
|
|
168
|
-
}
|
|
169
|
-
const { exportOkf } = await import('../export-okf.js');
|
|
170
191
|
try {
|
|
171
|
-
const
|
|
192
|
+
const EXPORT_USAGE = 'Usage: hafez export --okf [--out <dir>]';
|
|
193
|
+
const { flags } = parseArgs(args, { okf: 'boolean', out: 'string' }, EXPORT_USAGE);
|
|
194
|
+
if (flags.okf !== true)
|
|
195
|
+
throw new UsageError(EXPORT_USAGE);
|
|
196
|
+
const outDir = typeof flags.out === 'string' ? resolve(flags.out) : resolve(process.cwd(), 'okf-export');
|
|
197
|
+
const { exportOkf } = await import('../export-okf.js');
|
|
198
|
+
const exportReport = exportOkf(vaultPath, outDir);
|
|
172
199
|
if (json) {
|
|
173
|
-
process.stdout.write(JSON.stringify(
|
|
200
|
+
process.stdout.write(JSON.stringify(exportReport, null, 2) + '\n');
|
|
174
201
|
}
|
|
175
202
|
else {
|
|
176
|
-
const lines = [`Exported ${
|
|
177
|
-
for (const s of
|
|
203
|
+
const lines = [`Exported ${exportReport.entities} entities, ${exportReport.knowledge} knowledge, ${exportReport.sessions} sessions to ${exportReport.outDir}`];
|
|
204
|
+
for (const s of exportReport.skipped)
|
|
178
205
|
lines.push(` skipped ${s.file}: ${s.reason}`);
|
|
179
|
-
if (
|
|
180
|
-
lines.push(` unresolved links: ${
|
|
206
|
+
if (exportReport.unresolvedLinks > 0)
|
|
207
|
+
lines.push(` unresolved links: ${exportReport.unresolvedLinks}`);
|
|
181
208
|
process.stdout.write(lines.join('\n') + '\n');
|
|
182
209
|
}
|
|
183
210
|
}
|
|
184
211
|
catch (err) {
|
|
185
|
-
|
|
186
|
-
process.exit(1);
|
|
187
|
-
}
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
// sync is handled specially — always runs, not gated by MUTATING_COMMANDS
|
|
191
|
-
if (command === 'sync') {
|
|
192
|
-
const os = createHafez({ vaultPath, git: { push: false } });
|
|
193
|
-
try {
|
|
194
|
-
const result = await cmdSync(os, args);
|
|
195
|
-
process.stdout.write(result + '\n');
|
|
196
|
-
}
|
|
197
|
-
catch (err) {
|
|
198
|
-
if (err instanceof HafezError) {
|
|
199
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
200
|
-
if (err.details?.length) {
|
|
201
|
-
for (const detail of err.details) {
|
|
202
|
-
process.stderr.write(` - ${detail}\n`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
|
|
206
|
-
process.exit(exitCodes[err.code] ?? 1);
|
|
207
|
-
}
|
|
208
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
209
|
-
process.exit(1);
|
|
212
|
+
report(err);
|
|
210
213
|
}
|
|
211
214
|
return;
|
|
212
215
|
}
|
|
@@ -233,17 +236,6 @@ export async function main(argv) {
|
|
|
233
236
|
process.stdout.write(result + '\n');
|
|
234
237
|
}
|
|
235
238
|
catch (err) {
|
|
236
|
-
|
|
237
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
238
|
-
if (err.details?.length) {
|
|
239
|
-
for (const detail of err.details) {
|
|
240
|
-
process.stderr.write(` - ${detail}\n`);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
|
|
244
|
-
process.exit(exitCodes[err.code] ?? 1);
|
|
245
|
-
}
|
|
246
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
247
|
-
process.exit(1);
|
|
239
|
+
report(err);
|
|
248
240
|
}
|
|
249
241
|
}
|
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
|
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs
|
|
|
3
3
|
import { join, resolve } from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
|
+
import { kindDir } from '../vault.js';
|
|
6
7
|
export function getConfigDir() {
|
|
7
8
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
8
9
|
return join(xdg || join(homedir(), '.config'), 'hafez');
|
|
@@ -28,7 +29,7 @@ export function saveVaultConfig(vaultPath) {
|
|
|
28
29
|
writeFileSync(join(dir, 'vault'), resolve(vaultPath) + '\n');
|
|
29
30
|
}
|
|
30
31
|
function isVault(dirPath) {
|
|
31
|
-
return existsSync(dirPath) && statSync(dirPath).isDirectory() && existsSync(join(dirPath, '
|
|
32
|
+
return existsSync(dirPath) && statSync(dirPath).isDirectory() && existsSync(join(dirPath, kindDir('entity')));
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* Resolve vault path. Returns string or null.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// types actually used in BatchOperationSchema plus a few forward-looking ones
|
|
11
11
|
// (`default`, `effects`, `union`) so the walker does not explode when the
|
|
12
12
|
// schema is extended.
|
|
13
|
-
import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema, } from '
|
|
13
|
+
import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema, OP_SPECS, } from '../batch-ops.js';
|
|
14
14
|
import { DigestInputSchema } from '../digest.js';
|
|
15
15
|
import { ALIAS_KEYS } from '../contracts.js';
|
|
16
16
|
// --- Walker ---
|
|
@@ -155,16 +155,8 @@ export const DIGEST_EXAMPLE = {
|
|
|
155
155
|
session_date: '2026-01-15',
|
|
156
156
|
agent: 'claude',
|
|
157
157
|
};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
'create-entity': 'Create a new entity (capture / entity / project).',
|
|
161
|
-
'create-knowledge': 'Create a new knowledge note (insight / plan subtype).',
|
|
162
|
-
'create-session': 'Create a new session note linked to an entity.',
|
|
163
|
-
'capture': 'Quick capture — a raw inbox item with no structure required.',
|
|
164
|
-
'link': 'Add a parent or related link between two entities.',
|
|
165
|
-
'unlink': 'Remove a parent or related link between two entities.',
|
|
166
|
-
'promote': 'Promote a capture to entity/project/knowledge, or an entity to project.',
|
|
167
|
-
};
|
|
158
|
+
// Descriptions live in the Op Spec table (src/batch-ops.ts) — one row per op.
|
|
159
|
+
const OP_DESCRIPTIONS = Object.fromEntries(OP_SPECS.map(s => [s.name, s.description]));
|
|
168
160
|
export function listOps() {
|
|
169
161
|
return enumerateBranches().map(({ op, kind }) => {
|
|
170
162
|
const name = kind ? `${op}-${kind}` : op;
|
|
@@ -293,17 +285,9 @@ function shouldStrip(path, _key) {
|
|
|
293
285
|
return Object.prototype.hasOwnProperty.call(ALIAS_KEYS, path);
|
|
294
286
|
}
|
|
295
287
|
function isDefaultIncluded(op, kind, fieldKey) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return true;
|
|
300
|
-
if (op === 'create' && kind === 'session' && fieldKey === 'synthesis')
|
|
301
|
-
return true;
|
|
302
|
-
if (op === 'update' && fieldKey === 'status')
|
|
303
|
-
return true;
|
|
304
|
-
if (op === 'capture' && fieldKey === 'notes')
|
|
305
|
-
return true;
|
|
306
|
-
return false;
|
|
288
|
+
// Example-inclusion hints live in the Op Spec table, one row per op.
|
|
289
|
+
const spec = OP_SPECS.find(s => s.op === op && s.kind === kind);
|
|
290
|
+
return spec ? spec.exampleInclude.includes(fieldKey) : false;
|
|
307
291
|
}
|
|
308
292
|
function sampleValue(field) {
|
|
309
293
|
switch (field.type) {
|
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/contracts.d.ts
CHANGED
|
@@ -24,3 +24,9 @@ export declare const ALIAS_KEYS: {
|
|
|
24
24
|
readonly 'create.session.fields.insight': "synthesis";
|
|
25
25
|
};
|
|
26
26
|
export declare const SUBTYPE_SECTIONS: Record<KnowledgeSubtype, string[]>;
|
|
27
|
+
/** Sections whose content only ever grows — sync merges union their lines
|
|
28
|
+
* instead of letting the newer side replace the older (see merge.ts). */
|
|
29
|
+
export declare const APPEND_ONLY_SECTIONS: ReadonlySet<string>;
|
|
30
|
+
/** Session-file sections. Sessions have no TypeContract (they are not typed
|
|
31
|
+
* entities), but their headings are still structural for Document splitting. */
|
|
32
|
+
export declare const SESSION_SECTIONS: readonly ["Summary", "Entities Touched", "Decisions"];
|
package/dist/contracts.js
CHANGED
|
@@ -63,8 +63,8 @@ export function hasCapability(type, section) {
|
|
|
63
63
|
return contract.defaultSections.includes(section) || contract.optionalSections.includes(section);
|
|
64
64
|
}
|
|
65
65
|
// --- Canonical enum tuples (single source of truth) ---
|
|
66
|
-
// Every consumer (file validators,
|
|
67
|
-
//
|
|
66
|
+
// Every consumer (file validators, the Op Spec table, help renderer) imports
|
|
67
|
+
// these — no other file may declare local copies.
|
|
68
68
|
export const ENTITY_TYPES = ['capture', 'entity', 'project'];
|
|
69
69
|
export const ENTITY_STATUSES = ['active', 'paused', 'done'];
|
|
70
70
|
export const SESSION_LOG_TYPES = ['progress', 'decision', 'blocker', 'research'];
|
|
@@ -85,3 +85,9 @@ export const SUBTYPE_SECTIONS = {
|
|
|
85
85
|
insight: ['Synthesis', 'Evidence', 'Sources'],
|
|
86
86
|
plan: ['Goal', 'Steps', 'Dependencies'],
|
|
87
87
|
};
|
|
88
|
+
/** Sections whose content only ever grows — sync merges union their lines
|
|
89
|
+
* instead of letting the newer side replace the older (see merge.ts). */
|
|
90
|
+
export const APPEND_ONLY_SECTIONS = new Set(['Evidence', 'Sources']);
|
|
91
|
+
/** Session-file sections. Sessions have no TypeContract (they are not typed
|
|
92
|
+
* entities), but their headings are still structural for Document splitting. */
|
|
93
|
+
export const SESSION_SECTIONS = ['Summary', 'Entities Touched', 'Decisions'];
|
package/dist/db.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync, statSync, existsSync, unlinkSync } from 'fs';
|
|
4
4
|
import { join, basename } from 'path';
|
|
5
|
-
import { parseFilePath } from './vault.js';
|
|
5
|
+
import { parseFilePath, scanVaultDir, kindDir, INDEXED_KINDS } from './vault.js';
|
|
6
6
|
import { ensureLocalExclude } from './knowledge-index.js';
|
|
7
|
-
import { getNextActions, getBrief, WIKI_LINK_RE, findSection } from './
|
|
8
|
-
import { parseSessionLog } from './
|
|
7
|
+
import { getNextActions, getBrief, WIKI_LINK_RE, findSection } from './document.js';
|
|
8
|
+
import { parseSessionLog } from './document.js';
|
|
9
9
|
// Bump this when indexing logic changes to force a full rebuild.
|
|
10
10
|
// v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
|
|
11
11
|
// v6: description column on items.
|
|
@@ -86,16 +86,6 @@ const STOPWORDS = new Set([
|
|
|
86
86
|
function hashContent(content) {
|
|
87
87
|
return createHash('sha256').update(content).digest('hex');
|
|
88
88
|
}
|
|
89
|
-
function scanDir(dirPath) {
|
|
90
|
-
try {
|
|
91
|
-
// Skip dotfiles: their slugs are rejected by resolveFilePath, so indexing
|
|
92
|
-
// them would list entities that read/update can never reach.
|
|
93
|
-
return readdirSync(dirPath).filter(f => f.endsWith('.md') && !f.startsWith('.')).map(f => join(dirPath, f));
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return [];
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
89
|
// --- Shared query functions (used by both write and readonly paths) ---
|
|
100
90
|
function sanitizeFtsQuery(raw) {
|
|
101
91
|
const cleaned = raw.replace(/[^\w\s]/g, ' ');
|
|
@@ -322,15 +312,19 @@ function getStatsFn(db) {
|
|
|
322
312
|
WHERE kind = 'entity' AND status = 'active' AND next_action_count = 0
|
|
323
313
|
ORDER BY last_touched DESC
|
|
324
314
|
`).all();
|
|
315
|
+
// Full lists, not top-5: day-granularity timestamps tie massively (a
|
|
316
|
+
// new vault ties on everything), so the caller resolves ties (git commit
|
|
317
|
+
// time, then name) and caps. Name tiebreak here keeps the base order
|
|
318
|
+
// deterministic instead of leaking SQLite row order.
|
|
325
319
|
const recently_touched = db.prepare(`
|
|
326
320
|
SELECT slug, name, last_touched FROM items
|
|
327
321
|
WHERE kind = 'entity'
|
|
328
|
-
ORDER BY last_touched DESC
|
|
322
|
+
ORDER BY last_touched DESC, name COLLATE NOCASE ASC
|
|
329
323
|
`).all();
|
|
330
324
|
const recently_created = db.prepare(`
|
|
331
325
|
SELECT slug, name, created FROM items
|
|
332
326
|
WHERE kind = 'entity'
|
|
333
|
-
ORDER BY created DESC
|
|
327
|
+
ORDER BY created DESC, name COLLATE NOCASE ASC
|
|
334
328
|
`).all();
|
|
335
329
|
const knowledgeCount = db.prepare("SELECT COUNT(*) as cnt FROM items WHERE kind = 'knowledge'").get().cnt;
|
|
336
330
|
return {
|
|
@@ -579,8 +573,8 @@ export function createIndex(vaultPath, opts) {
|
|
|
579
573
|
while ((wikiMatch = linkRe.exec(authoredBody)) !== null) {
|
|
580
574
|
const targetSlug = wikiMatch[1];
|
|
581
575
|
// Check if target file exists on disk (not in index — target may not be indexed yet)
|
|
582
|
-
const targetEntityPath = join(vaultPath, '
|
|
583
|
-
const targetKnowledgePath = join(vaultPath, 'knowledge', `${targetSlug}.md`);
|
|
576
|
+
const targetEntityPath = join(vaultPath, kindDir('entity'), `${targetSlug}.md`);
|
|
577
|
+
const targetKnowledgePath = join(vaultPath, kindDir('knowledge'), `${targetSlug}.md`);
|
|
584
578
|
const targetExists = knownSlugs
|
|
585
579
|
? knownSlugs.has(targetSlug)
|
|
586
580
|
: existsSync(targetEntityPath) || existsSync(targetKnowledgePath);
|
|
@@ -594,16 +588,16 @@ export function createIndex(vaultPath, opts) {
|
|
|
594
588
|
db.exec('DELETE FROM links');
|
|
595
589
|
// Collect all vault slugs for wiki link target validation
|
|
596
590
|
const allVaultSlugs = new Set();
|
|
597
|
-
for (const file of
|
|
591
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
|
|
598
592
|
allVaultSlugs.add(basename(file, '.md'));
|
|
599
593
|
}
|
|
600
|
-
for (const file of
|
|
594
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
|
|
601
595
|
allVaultSlugs.add(basename(file, '.md'));
|
|
602
596
|
}
|
|
603
|
-
for (const file of
|
|
597
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
|
|
604
598
|
indexFile(file, 'entity', allVaultSlugs);
|
|
605
599
|
}
|
|
606
|
-
for (const file of
|
|
600
|
+
for (const file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
|
|
607
601
|
indexFile(file, 'knowledge', allVaultSlugs);
|
|
608
602
|
}
|
|
609
603
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
|
|
@@ -620,12 +614,9 @@ export function createIndex(vaultPath, opts) {
|
|
|
620
614
|
}
|
|
621
615
|
const lastSyncTime = parseInt(lastSync.value, 10);
|
|
622
616
|
let changed = false;
|
|
623
|
-
const dirs =
|
|
624
|
-
{ dir: 'entities', kind: 'entity' },
|
|
625
|
-
{ dir: 'knowledge', kind: 'knowledge' },
|
|
626
|
-
];
|
|
617
|
+
const dirs = INDEXED_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
|
|
627
618
|
for (const { dir, kind } of dirs) {
|
|
628
|
-
for (const file of
|
|
619
|
+
for (const file of scanVaultDir(join(vaultPath, dir))) {
|
|
629
620
|
if (statSync(file).mtimeMs > lastSyncTime) {
|
|
630
621
|
indexFile(file, kind);
|
|
631
622
|
changed = true;
|
|
@@ -636,7 +627,7 @@ export function createIndex(vaultPath, opts) {
|
|
|
636
627
|
const dbSlugs = db.prepare('SELECT slug FROM items').all().map(r => r.slug);
|
|
637
628
|
const fileSlugs = new Set();
|
|
638
629
|
for (const { dir } of dirs) {
|
|
639
|
-
for (const file of
|
|
630
|
+
for (const file of scanVaultDir(join(vaultPath, dir))) {
|
|
640
631
|
fileSlugs.add(basename(file, '.md'));
|
|
641
632
|
}
|
|
642
633
|
}
|
|
@@ -652,8 +643,7 @@ export function createIndex(vaultPath, opts) {
|
|
|
652
643
|
}
|
|
653
644
|
}
|
|
654
645
|
function upsertFromFile(slug, kind) {
|
|
655
|
-
const
|
|
656
|
-
const filePath = join(vaultPath, dir, `${slug}.md`);
|
|
646
|
+
const filePath = join(vaultPath, kindDir(kind), `${slug}.md`);
|
|
657
647
|
if (existsSync(filePath)) {
|
|
658
648
|
// Force re-index by clearing cached hash
|
|
659
649
|
deleteItemStmt.run(slug);
|
package/dist/digest.js
CHANGED
|
@@ -60,6 +60,8 @@ export function digest(input, existingSlugs, now = new Date()) {
|
|
|
60
60
|
// leak into the note body (the schema promises they are omitted).
|
|
61
61
|
const noteBody = buildSessionNoteBody(input, knownTouched);
|
|
62
62
|
const related = knownTouched;
|
|
63
|
+
// Typechecked against the Op Spec's session-create schema so digest output
|
|
64
|
+
// can never silently diverge from what batch validation accepts.
|
|
63
65
|
const noteFields = {
|
|
64
66
|
synthesis: noteBody,
|
|
65
67
|
'session-date': input.session_date,
|