hafez 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +67 -9
  2. package/dist/apply.d.ts +30 -0
  3. package/dist/apply.js +582 -0
  4. package/dist/batch-ops.d.ts +470 -0
  5. package/dist/batch-ops.js +446 -0
  6. package/dist/cli/args.d.ts +19 -0
  7. package/dist/cli/args.js +53 -0
  8. package/dist/cli/batch-errors.js +6 -3
  9. package/dist/cli/commands.d.ts +24 -108
  10. package/dist/cli/commands.js +297 -313
  11. package/dist/cli/help-agent.d.ts +0 -5
  12. package/dist/cli/help-agent.js +44 -5
  13. package/dist/cli/index.d.ts +41 -0
  14. package/dist/cli/index.js +172 -172
  15. package/dist/cli/resolve-vault.js +2 -1
  16. package/dist/cli/schema-introspect.d.ts +1 -1
  17. package/dist/cli/schema-introspect.js +6 -22
  18. package/dist/cli/upgrade.d.ts +42 -0
  19. package/dist/cli/upgrade.js +305 -0
  20. package/dist/cli.js +0 -0
  21. package/dist/contracts.d.ts +55 -6
  22. package/dist/contracts.js +110 -23
  23. package/dist/dates.d.ts +10 -0
  24. package/dist/dates.js +27 -0
  25. package/dist/db.d.ts +27 -2
  26. package/dist/db.js +318 -56
  27. package/dist/digest.js +2 -0
  28. package/dist/document.d.ts +117 -0
  29. package/dist/document.js +578 -0
  30. package/dist/export-okf.js +48 -34
  31. package/dist/git.d.ts +7 -8
  32. package/dist/git.js +198 -63
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +302 -915
  35. package/dist/knowledge-index.d.ts +23 -1
  36. package/dist/knowledge-index.js +71 -13
  37. package/dist/merge.d.ts +1 -1
  38. package/dist/merge.js +106 -86
  39. package/dist/schema.js +4 -4
  40. package/dist/templates.d.ts +6 -0
  41. package/dist/templates.js +27 -27
  42. package/dist/types.d.ts +86 -12
  43. package/dist/vault.d.ts +51 -6
  44. package/dist/vault.js +114 -11
  45. package/package.json +2 -2
  46. package/skill/SKILL.md +51 -110
  47. package/dist/parser.d.ts +0 -18
  48. package/dist/parser.js +0 -68
  49. package/dist/query.d.ts +0 -24
  50. package/dist/query.js +0 -167
  51. package/dist/sections.d.ts +0 -54
  52. package/dist/sections.js +0 -297
@@ -1,6 +1 @@
1
- /**
2
- * Render the full prompt-gate output. Token budget: 1500-3000 tokens when
3
- * measured as char/3.5. The drift test in tests/cli-help-agent.test.ts
4
- * enforces the ceiling.
5
- */
6
1
  export declare function renderAgentHelp(): string;
@@ -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 the drift
9
- // test can parse them): op-catalog, critical-enums, batch-examples, digest.
8
+ // Generated sections (wrapped in <!-- SECTION:name --> markers so tests can
9
+ // parse them): op-catalog, critical-enums, batch-examples, digest.
10
10
  //
11
- // If the generated sections drift from the Zod schema, the drift test fails
12
- // loudly. Do NOT enumerate enum values inside the preamble or hard rules —
13
- // those come from schema-introspect.ts.
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
 
@@ -143,6 +144,29 @@ function renderDigestSection() {
143
144
  lines.push('<!-- /SECTION:digest -->');
144
145
  return lines.join('\n');
145
146
  }
147
+ const UPGRADE_SECTION = `## Upgrading hafez itself
148
+
149
+ \`hafez upgrade\` — checks whether the installed hafez is outdated and prints
150
+ the steps. It NEVER installs: the steps install code and overwrite the user's
151
+ agent instructions, so show them verbatim, get an explicit yes, and only then
152
+ run them. Re-run to verify. Every check outcome exits 0 — being outdated is a
153
+ report, not a gate — so read the output, not the exit code. A non-zero exit
154
+ means the invocation was wrong (exit 2, \`upgrade\` takes no arguments), never
155
+ that a check failed. Not to be confused with \`hafez update\`, which updates
156
+ an entity.
157
+
158
+ \`--json\` keys: current, latest (null when unverified), comparison
159
+ (behind|equal|ahead|unknown), registryReachable, npmMajor, install
160
+ (registry|dev-checkout), skill (present|absent), skillSource, skillTarget,
161
+ alternativeInstall, steps. \`steps\` is the action list in execution order;
162
+ \`kind: 'command'\` entries are executable, \`kind: 'action'\` entries are
163
+ prose — do not shell out to them. \`alternativeInstall\` is mutually exclusive
164
+ with the install command in \`steps\`; never run both.
165
+
166
+ Checks the npm registry only (override with \`HAFEZ_REGISTRY_URL\`), so a
167
+ just-cut GitHub Release can read as \`equal\` until it is published.
168
+
169
+ `;
146
170
  const FOOTER = `## Lookup pointers
147
171
 
148
172
  - \`hafez schema\` — list all ops
@@ -155,6 +179,18 @@ const FOOTER = `## Lookup pointers
155
179
  * measured as char/3.5. The drift test in tests/cli-help-agent.test.ts
156
180
  * enforces the ceiling.
157
181
  */
182
+ const EXIT_CODES_SECTION = `## Exit codes
183
+
184
+ | code | meaning |
185
+ |------|---------|
186
+ | 0 | success |
187
+ | 1 | not found / unknown error |
188
+ | 2 | validation or usage error (bad flags, bad payload, core rejected the write) |
189
+ | 3 | git push failed — the write IS committed locally |
190
+ | 4 | write succeeded but the auto-sync after it failed |
191
+ | 5 | slug already exists |
192
+ | 6 | git commit failed — nothing was written to history |
193
+ | 7 | vault locked by another hafez process — retry when it finishes |`;
158
194
  export function renderAgentHelp() {
159
195
  return [
160
196
  PREAMBLE,
@@ -166,6 +202,9 @@ export function renderAgentHelp() {
166
202
  '',
167
203
  renderDigestSection(),
168
204
  '',
205
+ EXIT_CODES_SECTION,
206
+ '',
207
+ UPGRADE_SECTION,
169
208
  FOOTER,
170
209
  ].join('\n');
171
210
  }
@@ -1 +1,42 @@
1
+ import type { CommandOpts } from './commands.js';
2
+ type PreVaultRun = (args: string[], ctx: {
3
+ json: boolean;
4
+ vaultFlag?: string;
5
+ }) => Promise<string | null> | string | null;
6
+ type VaultRun = (os: any, args: string[], opts: CommandOpts) => Promise<string>;
7
+ export type CommandSpec = {
8
+ phase: 'pre-vault';
9
+ run: PreVaultRun;
10
+ } | {
11
+ phase: 'vault';
12
+ mutating?: boolean;
13
+ run: VaultRun;
14
+ };
15
+ export declare const COMMANDS: Record<string, CommandSpec>;
16
+ /** Exported so a guard test can assert every registered command is documented
17
+ * here — a registered-but-undocumented command is otherwise invisible. */
18
+ export declare const USAGE = "Usage: hafez <command> [args]\n\nCommands:\n read <slug> Read entity or knowledge note\n query [--filter ...] ... Filter entities/knowledge\n --kind all Both kinds; --filter matches entity status only,\n so knowledge notes (which have none) always show\n search <terms> Full-text search\n create <kind> <name> Create entity or knowledge note\n capture <name> [--notes] Quick inbox capture\n promote <slug> <target> Promote capture\u2192entity/project/knowledge, entity\u2192project\n update <slug> [--status] Update an entity or knowledge note\n --brief \"text\" Set/replace Brief (use --brief \"\" to clear)\n --description \"text\" Set one-line description (use --description \"\" to clear)\n --resource \"uri\" Set canonical resource URI, entities only (use --resource \"\" to clear)\n --add-action \"text\" Add a next action\n --complete-action \"text\" Complete matching action\n --remove-action \"text\" Remove matching action\n --clear-actions Remove all next actions\n --synthesis \"text\" Set/replace Synthesis section (--insight is an alias)\n --add-evidence \"text\" Append to Evidence section\n --add-source \"text\" Append to Sources section\n batch Apply multiple operations atomically (JSON on stdin)\n digest Apply digest input (JSON stdin) \u2192 batch payload (JSON stdout)\n export --okf [--out <dir>] Export vault as an OKF v0.1 bundle (read-only)\n sync Pull remote changes and push local commits\n link <slug> <target> --relation <rel> Add a link\n unlink <slug> <target> --relation <rel> Remove a link\n stats Vault summary: counts, stale items, recents\n changelog --since <when> Git-derived change history (e.g. --since 7.days.ago)\n schema [op] [--examples] Machine-readable JSON schema for batch operations\n validate Check vault integrity\n index rebuild Rebuild the SQLite index\n init --register <path> Register an existing vault\n init Show current vault resolution\n onboard Agent-directed first run: seed the vault, pick integration level\n upgrade Check whether hafez is outdated and print the update steps\n help --agent Full API reference for agents\n\nGlobal flags:\n --vault <path> Vault path (overrides config)\n --json JSON output (structured, machine-readable)\n --version Print the hafez version\n\nVault discovery: --vault flag > ~/.config/hafez/vault\n";
19
+ /** Exit codes, documented in `hafez help --agent`. 0 = success; 4 is the
20
+ * mutate-succeeded-sync-failed warning path; anything unmapped = 1. */
21
+ export declare const EXIT_CODES: Record<string, number>;
22
+ /** The pure half of the error → exit adapter: which code an error maps to.
23
+ * Exported so the exit-code contract is testable without spawning the CLI. */
24
+ export declare function exitCodeFor(err: unknown): number;
25
+ /** Does this error mean the native module failed to load/build?
26
+ *
27
+ * Matched on the MESSAGE only, and only on the messages a load/ABI failure
28
+ * actually produces. The old test also scanned the stack for "better-sqlite3",
29
+ * which every SqliteError's stack contains — so a plain query error ("near
30
+ * 'abc': syntax error") printed reinstall advice instead of itself. */
31
+ export declare function isBindingsFailure(err: unknown): boolean;
32
+ /** Extract the global flags (--vault <path>, --json) from anywhere in argv.
33
+ * Exported for the in-process dispatch tests. A flag sitting where the vault
34
+ * path should be means the value is missing — never consume it (the same
35
+ * value-position rule parseArgs applies to declared command flags). */
36
+ export declare function extractGlobalFlags(args: string[]): {
37
+ rest: string[];
38
+ vaultFlag?: string;
39
+ json: boolean;
40
+ };
1
41
  export declare function main(argv: string[]): Promise<void>;
42
+ export {};
package/dist/cli/index.js CHANGED
@@ -1,43 +1,67 @@
1
1
  // src/cli/index.ts
2
+ //
3
+ // Import discipline: this module is loaded for EVERY invocation, so it may
4
+ // only import what every invocation needs — node builtins, the flag parser,
5
+ // and the error type. Everything else (the core factory and its native SQLite
6
+ // binding, zod-backed schema introspection, the markdown formatters, even
7
+ // vault-path resolution) loads on demand from the command that needs it, so
8
+ // `hafez --version` / `help` / `init` no longer pay for the whole program.
2
9
  import { readFileSync } from 'fs';
3
- import { dirname, join, resolve } from 'path';
10
+ import { dirname, join } from 'path';
4
11
  import { fileURLToPath } from 'url';
5
- import { createHafez } from '../index.js';
6
12
  import { HafezError } from '../types.js';
7
- import { cmdRead, cmdQuery, cmdSearch, cmdCreate, cmdUpdate, cmdLink, cmdUnlink, cmdPromote, cmdValidate, cmdIndexRebuild, cmdBatch, cmdSync, cmdStats, cmdChangelog, cmdDigest, cmdCapture } from './commands.js';
8
- import { cmdSchema } from './commands-schema.js';
9
- import { renderAgentHelp } from './help-agent.js';
10
- import { renderOnboard } from './onboard.js';
11
- import { resolveVaultPath, noVaultMessage, cmdInit } from './resolve-vault.js';
12
- const COMMANDS = {
13
- read: cmdRead,
14
- query: cmdQuery,
15
- search: cmdSearch,
16
- create: cmdCreate,
17
- capture: cmdCapture,
18
- update: cmdUpdate,
19
- link: cmdLink,
20
- unlink: cmdUnlink,
21
- promote: cmdPromote,
22
- batch: cmdBatch,
23
- schema: cmdSchema,
24
- validate: cmdValidate,
25
- index: (os, args) => {
26
- if (args[0] === 'rebuild')
27
- return cmdIndexRebuild(os);
28
- throw new Error('Usage: hafez index rebuild');
13
+ import { UsageError } from './args.js';
14
+ /** Bind a vault command to its handler without importing the handler module
15
+ * until the command actually runs. */
16
+ function lazy(pick) {
17
+ return async (os, args, opts) => pick(await import('./commands.js'))(os, args, opts);
18
+ }
19
+ export const COMMANDS = {
20
+ // --- pre-vault ---
21
+ help: { phase: 'pre-vault', run: async (args) => (args[0] === '--agent' ? (await import('./help-agent.js')).renderAgentHelp() : USAGE) },
22
+ onboard: { phase: 'pre-vault', run: async () => (await import('./onboard.js')).renderOnboard() },
23
+ schema: { phase: 'pre-vault', run: async (args) => (await import('./commands-schema.js')).cmdSchema(null, args) },
24
+ version: { phase: 'pre-vault', run: () => versionLine() },
25
+ init: { phase: 'pre-vault', run: async (args, ctx) => { (await import('./resolve-vault.js')).cmdInit(args, ctx.vaultFlag); return null; } },
26
+ // Binds (args, ctx) explicitly: cmdUpgrade's third parameter is its
27
+ // dependency seam, and PreVaultRun's signature keeps dispatch from ever
28
+ // passing a third argument into it.
29
+ upgrade: { phase: 'pre-vault', run: async (args, ctx) => (await import('./upgrade.js')).cmdUpgrade(args, ctx) },
30
+ // --- vault ---
31
+ read: { phase: 'vault', run: lazy(m => m.cmdRead) },
32
+ sync: { phase: 'vault', run: lazy(m => m.cmdSync) },
33
+ query: { phase: 'vault', run: lazy(m => m.cmdQuery) },
34
+ search: { phase: 'vault', run: lazy(m => m.cmdSearch) },
35
+ create: { phase: 'vault', mutating: true, run: lazy(m => m.cmdCreate) },
36
+ capture: { phase: 'vault', mutating: true, run: lazy(m => m.cmdCapture) },
37
+ update: { phase: 'vault', mutating: true, run: lazy(m => m.cmdUpdate) },
38
+ link: { phase: 'vault', mutating: true, run: lazy(m => m.cmdLink) },
39
+ unlink: { phase: 'vault', mutating: true, run: lazy(m => m.cmdUnlink) },
40
+ promote: { phase: 'vault', mutating: true, run: lazy(m => m.cmdPromote) },
41
+ batch: { phase: 'vault', mutating: true, run: lazy(m => m.cmdBatch) },
42
+ validate: { phase: 'vault', run: lazy(m => m.cmdValidate) },
43
+ index: {
44
+ phase: 'vault',
45
+ run: async (os, args) => {
46
+ if (args[0] === 'rebuild')
47
+ return (await import('./commands.js')).cmdIndexRebuild(os);
48
+ throw new UsageError('Usage: hafez index rebuild');
49
+ },
29
50
  },
30
- stats: cmdStats,
31
- changelog: cmdChangelog,
32
- digest: cmdDigest,
51
+ stats: { phase: 'vault', run: lazy(m => m.cmdStats) },
52
+ changelog: { phase: 'vault', run: lazy(m => m.cmdChangelog) },
53
+ digest: { phase: 'vault', run: lazy(m => m.cmdDigest) },
54
+ export: { phase: 'vault', run: lazy(m => m.cmdExport) },
33
55
  };
34
- // Commands that modify vault files — auto-sync after these
35
- const MUTATING_COMMANDS = new Set(['update', 'create', 'capture', 'link', 'unlink', 'batch', 'promote']);
36
- const USAGE = `Usage: hafez <command> [args]
56
+ /** Exported so a guard test can assert every registered command is documented
57
+ * here — a registered-but-undocumented command is otherwise invisible. */
58
+ export const USAGE = `Usage: hafez <command> [args]
37
59
 
38
60
  Commands:
39
61
  read <slug> Read entity or knowledge note
40
62
  query [--filter ...] ... Filter entities/knowledge
63
+ --kind all Both kinds; --filter matches entity status only,
64
+ so knowledge notes (which have none) always show
41
65
  search <terms> Full-text search
42
66
  create <kind> <name> Create entity or knowledge note
43
67
  capture <name> [--notes] Quick inbox capture
@@ -67,6 +91,7 @@ Commands:
67
91
  init --register <path> Register an existing vault
68
92
  init Show current vault resolution
69
93
  onboard Agent-directed first run: seed the vault, pick integration level
94
+ upgrade Check whether hafez is outdated and print the update steps
70
95
  help --agent Full API reference for agents
71
96
 
72
97
  Global flags:
@@ -76,13 +101,61 @@ Global flags:
76
101
 
77
102
  Vault discovery: --vault flag > ~/.config/hafez/vault
78
103
  `;
104
+ /** Exit codes, documented in `hafez help --agent`. 0 = success; 4 is the
105
+ * mutate-succeeded-sync-failed warning path; anything unmapped = 1. */
106
+ export const EXIT_CODES = {
107
+ NOT_FOUND: 1,
108
+ VALIDATION_FAILED: 2,
109
+ GIT_PUSH_FAILED: 3,
110
+ SLUG_EXISTS: 5,
111
+ GIT_COMMIT_FAILED: 6,
112
+ VAULT_LOCKED: 7,
113
+ };
114
+ /** The pure half of the error → exit adapter: which code an error maps to.
115
+ * Exported so the exit-code contract is testable without spawning the CLI. */
116
+ export function exitCodeFor(err) {
117
+ if (err instanceof UsageError)
118
+ return 2;
119
+ if (err instanceof HafezError)
120
+ return EXIT_CODES[err.code] ?? 1;
121
+ return 1;
122
+ }
123
+ /** The one error → exit adapter. Usage errors (bad flags/args) are distinct
124
+ * from vault errors and from unknown crashes. */
125
+ function report(err) {
126
+ if (err instanceof UsageError) {
127
+ process.stderr.write(`Error: ${err.message}\n`);
128
+ process.exit(exitCodeFor(err));
129
+ }
130
+ if (err instanceof HafezError) {
131
+ process.stderr.write(`Error: ${err.message}\n`);
132
+ if (err.details?.length) {
133
+ for (const detail of err.details)
134
+ process.stderr.write(` - ${detail}\n`);
135
+ }
136
+ process.exit(exitCodeFor(err));
137
+ }
138
+ failUnknown(err);
139
+ }
140
+ /** Does this error mean the native module failed to load/build?
141
+ *
142
+ * Matched on the MESSAGE only, and only on the messages a load/ABI failure
143
+ * actually produces. The old test also scanned the stack for "better-sqlite3",
144
+ * which every SqliteError's stack contains — so a plain query error ("near
145
+ * 'abc': syntax error") printed reinstall advice instead of itself. */
146
+ export function isBindingsFailure(err) {
147
+ const message = err?.message;
148
+ if (typeof message !== 'string')
149
+ return false;
150
+ return /Could not locate the bindings file|better_sqlite3\.node|was compiled against a different Node\.js version|NODE_MODULE_VERSION|invalid ELF header/i.test(message);
151
+ }
79
152
  // Terminal handler for non-Hafez errors. Special-cases unbuilt better-sqlite3
80
153
  // bindings: npm 11+ blocks install scripts by default, so `npm install -g
81
154
  // hafez` reports success with the native module unbuilt and the first indexed
82
155
  // command dies here — guide instead of stack-tracing.
83
156
  function failUnknown(err) {
84
157
  const e = err;
85
- if (/better[-_]sqlite3|Could not locate the bindings file/i.test(`${e.message}\n${e.stack ?? ''}`)) {
158
+ if (isBindingsFailure(e)) {
86
159
  process.stderr.write('Error: the SQLite bindings (better-sqlite3) are not built.\n' +
87
160
  'npm 11+ blocks install scripts by default, so the install reports success\n' +
88
161
  'while skipping the native build. Fix with:\n\n' +
@@ -92,147 +165,84 @@ function failUnknown(err) {
92
165
  process.stderr.write(`Error: ${e.message}\n`);
93
166
  process.exit(1);
94
167
  }
95
- export async function main(argv) {
96
- const args = argv.slice(2);
97
- // Extract global flags
98
- const vaultIdx = args.indexOf('--vault');
99
- let flagValue;
100
- if (vaultIdx !== -1) {
101
- flagValue = args[vaultIdx + 1];
102
- args.splice(vaultIdx, 2);
103
- }
104
- const jsonIdx = args.indexOf('--json');
105
- const json = jsonIdx !== -1;
106
- if (json)
107
- args.splice(jsonIdx, 1);
108
- const command = args.shift();
109
- if (!command || command === '--help' || command === '-h') {
110
- process.stdout.write(USAGE);
111
- return;
112
- }
113
- if (command === '--version' || command === '-v' || command === 'version') {
114
- const here = dirname(fileURLToPath(import.meta.url));
115
- const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
116
- process.stdout.write(`hafez ${pkg.version}\n`);
117
- return;
118
- }
119
- // help runs before vault resolution — works without a registered vault
120
- if (command === 'help') {
121
- if (args[0] === '--agent') {
122
- process.stdout.write(renderAgentHelp());
123
- return;
168
+ function versionLine() {
169
+ const here = dirname(fileURLToPath(import.meta.url));
170
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
171
+ return `hafez ${pkg.version}`;
172
+ }
173
+ /** Extract the global flags (--vault <path>, --json) from anywhere in argv.
174
+ * Exported for the in-process dispatch tests. A flag sitting where the vault
175
+ * path should be means the value is missing — never consume it (the same
176
+ * value-position rule parseArgs applies to declared command flags). */
177
+ export function extractGlobalFlags(args) {
178
+ const rest = [];
179
+ let vaultFlag;
180
+ let json = false;
181
+ for (let i = 0; i < args.length; i++) {
182
+ const a = args[i];
183
+ if (a === '--vault') {
184
+ const v = args[i + 1];
185
+ if (v === undefined || v.startsWith('--'))
186
+ throw new UsageError('--vault requires a path value');
187
+ // The old one-shot extraction left a second --vault in the command args
188
+ // where the declared-flag parser rejected it — keep failing loudly
189
+ // rather than silently letting the last one win (a mutating command
190
+ // would write to a different vault than the first flag named).
191
+ if (vaultFlag !== undefined)
192
+ throw new UsageError('--vault specified more than once');
193
+ vaultFlag = v;
194
+ i++;
124
195
  }
125
- process.stdout.write(USAGE);
126
- return;
127
- }
128
- // onboard runs before vault resolution — static agent-directed text
129
- if (command === 'onboard') {
130
- process.stdout.write(renderOnboard());
131
- return;
132
- }
133
- // schema runs before vault resolution — pure Zod introspection, no vault needed.
134
- if (command === 'schema') {
135
- try {
136
- const result = await cmdSchema(null, args);
137
- process.stdout.write(result + '\n');
196
+ else if (a === '--json') {
197
+ json = true;
138
198
  }
139
- catch (err) {
140
- if (err instanceof HafezError) {
141
- process.stderr.write(`Error: ${err.message}\n`);
142
- if (err.details?.length) {
143
- for (const detail of err.details)
144
- process.stderr.write(` - ${detail}\n`);
145
- }
146
- process.exit(2);
147
- }
148
- failUnknown(err);
199
+ else {
200
+ rest.push(a);
149
201
  }
150
- return;
151
- }
152
- // init runs before vault resolution
153
- if (command === 'init') {
154
- cmdInit(args, flagValue);
155
- return;
156
- }
157
- // Resolve vault
158
- const resolution = resolveVaultPath(flagValue);
159
- if (resolution === null) {
160
- process.stderr.write(noVaultMessage());
161
- process.exit(1);
162
- }
163
- if (typeof resolution === 'object' && 'error' in resolution) {
164
- process.stderr.write(`Error: ${resolution.error}\n`);
165
- process.exit(1);
166
202
  }
167
- const vaultPath = resolution;
168
- // export runs before Hafez instance creation — read-only, no auto-sync
169
- if (command === 'export') {
170
- if (!args.includes('--okf')) {
171
- process.stderr.write('Usage: hafez export --okf [--out <dir>]\n');
203
+ return { rest, vaultFlag, json };
204
+ }
205
+ export async function main(argv) {
206
+ try {
207
+ const { rest: args, vaultFlag, json } = extractGlobalFlags(argv.slice(2));
208
+ const command = args.shift();
209
+ if (!command || command === '--help' || command === '-h') {
210
+ process.stdout.write(USAGE);
211
+ return;
212
+ }
213
+ const name = command === '--version' || command === '-v' ? 'version' : command;
214
+ const spec = COMMANDS[name];
215
+ if (!spec) {
216
+ process.stderr.write(`Unknown command: ${command}\n${USAGE}`);
172
217
  process.exit(1);
173
218
  }
174
- const outIdx = args.indexOf('--out');
175
- let outDir = resolve(process.cwd(), 'okf-export');
176
- if (outIdx !== -1) {
177
- const outValue = args[outIdx + 1];
178
- if (!outValue) {
179
- process.stderr.write('Usage: hafez export --okf [--out <dir>]\n');
180
- process.exit(1);
181
- }
182
- outDir = resolve(outValue);
219
+ if (spec.phase === 'pre-vault') {
220
+ const out = await spec.run(args, { json, vaultFlag });
221
+ if (out !== null)
222
+ process.stdout.write(out.endsWith('\n') ? out : out + '\n');
223
+ return;
183
224
  }
184
- const { exportOkf } = await import('../export-okf.js');
185
- try {
186
- const report = exportOkf(vaultPath, outDir);
187
- if (json) {
188
- process.stdout.write(JSON.stringify(report, null, 2) + '\n');
189
- }
190
- else {
191
- const lines = [`Exported ${report.entities} entities, ${report.knowledge} knowledge, ${report.sessions} sessions to ${report.outDir}`];
192
- for (const s of report.skipped)
193
- lines.push(` skipped ${s.file}: ${s.reason}`);
194
- if (report.unresolvedLinks > 0)
195
- lines.push(` unresolved links: ${report.unresolvedLinks}`);
196
- process.stdout.write(lines.join('\n') + '\n');
197
- }
225
+ // Resolve vault (loaded here, not at module top: the pre-vault commands
226
+ // above return without ever needing the vault machinery)
227
+ const { resolveVaultPath, noVaultMessage } = await import('./resolve-vault.js');
228
+ const resolution = resolveVaultPath(vaultFlag);
229
+ if (resolution === null) {
230
+ process.stderr.write(noVaultMessage());
231
+ process.exit(1);
198
232
  }
199
- catch (err) {
200
- failUnknown(err);
233
+ if (typeof resolution === 'object' && 'error' in resolution) {
234
+ process.stderr.write(`Error: ${resolution.error}\n`);
235
+ process.exit(1);
201
236
  }
202
- return;
203
- }
204
- // sync is handled specially — always runs, not gated by MUTATING_COMMANDS
205
- if (command === 'sync') {
237
+ const vaultPath = resolution;
238
+ // First touch of the core — and so of the native SQLite binding. A
239
+ // failure to load it lands in the catch below and is recognised by
240
+ // isBindingsFailure, exactly as it was when this import was static.
241
+ const { createHafez } = await import('../index.js');
206
242
  const os = createHafez({ vaultPath, git: { push: false } });
207
- try {
208
- const result = await cmdSync(os, args);
209
- process.stdout.write(result + '\n');
210
- }
211
- catch (err) {
212
- if (err instanceof HafezError) {
213
- process.stderr.write(`Error: ${err.message}\n`);
214
- if (err.details?.length) {
215
- for (const detail of err.details) {
216
- process.stderr.write(` - ${detail}\n`);
217
- }
218
- }
219
- const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
220
- process.exit(exitCodes[err.code] ?? 1);
221
- }
222
- failUnknown(err);
223
- }
224
- return;
225
- }
226
- const handler = COMMANDS[command];
227
- if (!handler) {
228
- process.stderr.write(`Unknown command: ${command}\n${USAGE}`);
229
- process.exit(1);
230
- }
231
- const os = createHafez({ vaultPath, git: { push: false } });
232
- try {
233
- const result = await handler(os, args, { json });
243
+ const result = await spec.run(os, args, { json, vaultPath });
234
244
  // Auto-sync after mutating commands
235
- if (MUTATING_COMMANDS.has(command)) {
245
+ if (spec.mutating) {
236
246
  try {
237
247
  await os.sync();
238
248
  }
@@ -246,16 +256,6 @@ export async function main(argv) {
246
256
  process.stdout.write(result + '\n');
247
257
  }
248
258
  catch (err) {
249
- if (err instanceof HafezError) {
250
- process.stderr.write(`Error: ${err.message}\n`);
251
- if (err.details?.length) {
252
- for (const detail of err.details) {
253
- process.stderr.write(` - ${detail}\n`);
254
- }
255
- }
256
- const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
257
- process.exit(exitCodes[err.code] ?? 1);
258
- }
259
- failUnknown(err);
259
+ report(err);
260
260
  }
261
261
  }
@@ -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, 'entities'));
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.
@@ -1,4 +1,4 @@
1
- import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema } from './commands.js';
1
+ import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema } from '../batch-ops.js';
2
2
  export interface FieldSchema {
3
3
  type: string;
4
4
  optional?: boolean;
@@ -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 './commands.js';
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
- const OP_DESCRIPTIONS = {
159
- 'update': 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …).',
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
- if (op === 'create' && kind === 'entity' && fieldKey === 'type')
297
- return true;
298
- if (op === 'create' && kind === 'knowledge' && (fieldKey === 'subtype' || fieldKey === 'synthesis'))
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) {