hafez 1.3.0 → 2.0.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 (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. package/skill/SKILL.md +0 -196
@@ -1,8 +1,12 @@
1
1
  import type { QueryResult, KnowledgeQueryResult, ValidationReport, SearchResult, VaultStats, ChangelogEntry } from '../types.js';
2
- export declare function formatEntityHeader(fm: Record<string, any>, body: string): string;
2
+ /** `parent` is the rendered value of the `parent:` line (the slug with the
3
+ * parent's description); absent, the slug alone. */
4
+ export declare function formatEntityHeader(fm: Record<string, any>, body: string, parent?: string | undefined): string;
3
5
  export declare function formatKnowledgeHeader(fm: Record<string, any>, body: string): string;
4
- export declare function formatQueryTable(results: QueryResult[], total?: number): string;
5
- export declare function formatKnowledgeTable(results: KnowledgeQueryResult[], total?: number): string;
6
+ export type QueryTableRow = Pick<QueryResult, 'slug' | 'name' | 'type' | 'status' | 'next_action' | 'next_action_count'>;
7
+ export declare function formatQueryTable(results: QueryTableRow[], total?: number): string;
8
+ export type KnowledgeTableRow = Pick<KnowledgeQueryResult, 'slug' | 'name' | 'confidence' | 'domain' | 'reinforcement_count'>;
9
+ export declare function formatKnowledgeTable(results: KnowledgeTableRow[], total?: number): string;
6
10
  export declare function formatSearchResults(results: SearchResult[]): string;
7
11
  export declare function formatStats(stats: VaultStats): string;
8
12
  export declare function formatChangelog(entries: ChangelogEntry[]): string;
@@ -1,4 +1,6 @@
1
- export function formatEntityHeader(fm, body) {
1
+ /** `parent` is the rendered value of the `parent:` line (the slug with the
2
+ * parent's description); absent, the slug alone. */
3
+ export function formatEntityHeader(fm, body, parent = fm.parent) {
2
4
  const lines = [`# ${fm.name}`];
3
5
  if (fm.description)
4
6
  lines.push(`> ${fm.description}`);
@@ -11,9 +13,10 @@ export function formatEntityHeader(fm, body) {
11
13
  meta.push(`domain: ${fm.domain.join(', ')}`);
12
14
  if (meta.length)
13
15
  lines.push(meta.join(' | '));
16
+ // The parent line stands alone: with its description it is a sentence.
17
+ if (parent)
18
+ lines.push(`parent: ${parent}`);
14
19
  const meta2 = [];
15
- if (fm.parent)
16
- meta2.push(`parent: ${fm.parent}`);
17
20
  if (fm['last-touched'])
18
21
  meta2.push(`last-touched: ${fm['last-touched']}`);
19
22
  if (fm.created)
@@ -34,18 +37,33 @@ export function formatKnowledgeHeader(fm, body) {
34
37
  const lines = [`# ${fm.name}`];
35
38
  if (fm.description)
36
39
  lines.push(`> ${fm.description}`);
40
+ // A plan has a lifecycle and no reinforcement: its subtype, status and
41
+ // dates; an insight its confidence and reinforcement.
42
+ const plan = fm.subtype === 'plan';
37
43
  const meta = [];
38
- if (fm.confidence)
44
+ if (plan)
45
+ meta.push('subtype: plan');
46
+ if (plan && fm.status)
47
+ meta.push(`status: ${fm.status}`);
48
+ if (!plan && fm.confidence)
39
49
  meta.push(`confidence: ${fm.confidence}`);
40
50
  if (fm.domain?.length)
41
51
  meta.push(`domain: ${Array.isArray(fm.domain) ? fm.domain.join(', ') : fm.domain}`);
42
52
  if (meta.length)
43
53
  lines.push(meta.join(' | '));
44
54
  const meta2 = [];
45
- if (fm['reinforcement-count'] != null)
46
- meta2.push(`reinforcement-count: ${fm['reinforcement-count']}`);
47
- if (fm['last-reinforced'])
48
- meta2.push(`last-reinforced: ${fm['last-reinforced']}`);
55
+ if (plan) {
56
+ if (fm.created)
57
+ meta2.push(`created: ${fm.created}`);
58
+ if (fm['last-touched'])
59
+ meta2.push(`last-touched: ${fm['last-touched']}`);
60
+ }
61
+ else {
62
+ if (fm['reinforcement-count'] != null)
63
+ meta2.push(`reinforcement-count: ${fm['reinforcement-count']}`);
64
+ if (fm['last-reinforced'])
65
+ meta2.push(`last-reinforced: ${fm['last-reinforced']}`);
66
+ }
49
67
  if (meta2.length)
50
68
  lines.push(meta2.join(' | '));
51
69
  if (fm.related?.length)
@@ -143,7 +161,7 @@ export function formatChangelog(entries) {
143
161
  }
144
162
  export function formatValidation(report) {
145
163
  const issues = report.broken_slugs.length + report.orphaned_knowledge.length +
146
- report.oversized_related.length + report.missing_fields.length;
164
+ report.missing_fields.length;
147
165
  if (issues === 0) {
148
166
  return `Vault OK — ${report.total_entities} entities, ${report.total_knowledge} knowledge, 0 issues`;
149
167
  }
@@ -157,7 +175,6 @@ export function formatValidation(report) {
157
175
  };
158
176
  addSection('Broken Slugs', report.broken_slugs.map(i => `${i.slug}: ${i.issue}`));
159
177
  addSection('Orphaned Knowledge', report.orphaned_knowledge.map(s => `${s} (no related links, not referenced by any entity)`));
160
- addSection('Oversized Related', report.oversized_related.map(i => `${i.slug}: ${i.issue}`));
161
178
  addSection('Missing Fields', report.missing_fields.map(i => `${i.slug}: ${i.issue}`));
162
179
  return sections.join('\n');
163
180
  }
@@ -1 +1,56 @@
1
+ import { HafezError, type HafezErrorCode } from '../types.js';
2
+ import { UsageError } from './args.js';
3
+ import type { CommandOpts } from './commands.js';
4
+ type PreVaultRun = (args: string[], ctx: {
5
+ json: boolean;
6
+ vaultFlag?: string;
7
+ }) => Promise<string | null> | string | null;
8
+ type VaultRun = (os: any, args: string[], opts: CommandOpts) => Promise<string>;
9
+ export type CommandSpec = {
10
+ phase: 'pre-vault';
11
+ run: PreVaultRun;
12
+ } | {
13
+ phase: 'vault';
14
+ mutating?: boolean;
15
+ run: VaultRun;
16
+ };
17
+ export declare const COMMANDS: Record<string, CommandSpec>;
18
+ /** The one exit-code table. The error → exit adapter maps HafezError codes
19
+ * through `errorCodes`; `hafez --help` prints every row. 4 is the
20
+ * mutate-succeeded-sync-failed warning path; anything unmapped = 1. */
21
+ export declare const EXIT_TABLE: ReadonlyArray<{
22
+ exit: number;
23
+ meaning: string;
24
+ errorCodes?: readonly HafezErrorCode[];
25
+ }>;
26
+ export declare const EXIT_CODES: Record<string, number>;
27
+ /** Exported so a guard test can assert every registered command is documented
28
+ * here — a registered-but-undocumented command is otherwise invisible. */
29
+ export declare const USAGE: string;
30
+ /** The pure half of the error → exit adapter: which code an error maps to.
31
+ * Exported so the exit-code contract is testable without spawning the CLI. */
32
+ export declare function exitCodeFor(err: unknown): number;
33
+ /** The text half of the error adapter: what it prints for a usage or vault
34
+ * error. A misuse ends with the shape to retry from. */
35
+ export declare function errorText(err: UsageError | HafezError): string;
36
+ /** Does this error mean the native module failed to load/build?
37
+ *
38
+ * Matched on the MESSAGE only, and only on the messages a load/ABI failure
39
+ * actually produces. The old test also scanned the stack for "better-sqlite3",
40
+ * which every SqliteError's stack contains — so a plain query error ("near
41
+ * 'abc': syntax error") printed reinstall advice instead of itself. */
42
+ export declare function isBindingsFailure(err: unknown): boolean;
43
+ /** Extract the global flags (--vault <path>, --json) from anywhere in argv.
44
+ * Exported for the in-process dispatch tests. A flag sitting where the vault
45
+ * path should be means the value is missing — never consume it (the same
46
+ * value-position rule parseArgs applies to declared command flags). */
47
+ export declare function extractGlobalFlags(args: string[]): {
48
+ rest: string[];
49
+ vaultFlag?: string;
50
+ json: boolean;
51
+ };
52
+ /** Is this the SessionStart hook's form, `onboard --block`? Read from the raw
53
+ * args, the token after `--vault` skipped, so it holds when parsing them fails. */
54
+ export declare function isBlockHook(args: string[]): boolean;
1
55
  export declare function main(argv: string[]): Promise<void>;
56
+ export {};
package/dist/cli/index.js CHANGED
@@ -1,62 +1,102 @@
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 { parseArgs, UsageError } from './args.js';
8
- import { cmdRead, cmdQuery, cmdSearch, cmdCreate, cmdUpdate, cmdLink, cmdUnlink, cmdPromote, cmdValidate, cmdIndexRebuild, cmdBatch, cmdSync, cmdStats, cmdChangelog, cmdDigest, cmdCapture } from './commands.js';
9
- import { cmdSchema } from './commands-schema.js';
10
- import { renderAgentHelp } from './help-agent.js';
11
- import { renderOnboard } from './onboard.js';
12
- import { resolveVaultPath, noVaultMessage, cmdInit } from './resolve-vault.js';
13
- const COMMANDS = {
14
- read: cmdRead,
15
- sync: cmdSync,
16
- query: cmdQuery,
17
- search: cmdSearch,
18
- create: cmdCreate,
19
- capture: cmdCapture,
20
- update: cmdUpdate,
21
- link: cmdLink,
22
- unlink: cmdUnlink,
23
- promote: cmdPromote,
24
- batch: cmdBatch,
25
- schema: cmdSchema,
26
- validate: cmdValidate,
27
- index: (os, args) => {
28
- if (args[0] === 'rebuild')
29
- return cmdIndexRebuild(os);
30
- throw new UsageError('Usage: hafez index rebuild');
13
+ import { HelpRequest, MisuseError, RenderedError, UsageError, helpIfAsked } 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('../surfaces/surfaces.js')).renderAgentHelp() : USAGE) },
22
+ onboard: { phase: 'pre-vault', run: async (args, ctx) => (await import('./onboard.js')).cmdOnboard(args, { vaultFlag: ctx.vaultFlag }) },
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
+ delete: { phase: 'vault', mutating: true, run: lazy(m => m.cmdDelete) },
42
+ batch: { phase: 'vault', mutating: true, run: lazy(m => m.cmdBatch) },
43
+ validate: { phase: 'vault', run: lazy(m => m.cmdValidate) },
44
+ index: {
45
+ phase: 'vault',
46
+ run: async (os, args) => {
47
+ const usage = 'Usage: hafez index rebuild';
48
+ helpIfAsked(args, usage);
49
+ if (args[0] === 'rebuild')
50
+ return (await import('./commands.js')).cmdIndexRebuild(os);
51
+ throw new UsageError(usage);
52
+ },
31
53
  },
32
- stats: cmdStats,
33
- changelog: cmdChangelog,
34
- digest: cmdDigest,
54
+ stats: { phase: 'vault', run: lazy(m => m.cmdStats) },
55
+ changelog: { phase: 'vault', run: lazy(m => m.cmdChangelog) },
56
+ digest: { phase: 'vault', run: lazy(m => m.cmdDigest) },
57
+ export: { phase: 'vault', run: lazy(m => m.cmdExport) },
35
58
  };
36
- // Commands that modify vault files — auto-sync after these
37
- const MUTATING_COMMANDS = new Set(['update', 'create', 'capture', 'link', 'unlink', 'batch', 'promote']);
38
- const USAGE = `Usage: hafez <command> [args]
59
+ /** The one exit-code table. The error → exit adapter maps HafezError codes
60
+ * through `errorCodes`; `hafez --help` prints every row. 4 is the
61
+ * mutate-succeeded-sync-failed warning path; anything unmapped = 1. */
62
+ export const EXIT_TABLE = [
63
+ { exit: 0, meaning: 'success' },
64
+ { exit: 1, meaning: 'not found (slug, command or vault) / unknown error', errorCodes: ['NOT_FOUND'] },
65
+ { exit: 2, meaning: 'validation or usage error (bad flags, bad payload, core rejected the write)', errorCodes: ['VALIDATION_FAILED'] },
66
+ { exit: 3, meaning: 'git push failed — the write IS committed locally', errorCodes: ['GIT_PUSH_FAILED'] },
67
+ { exit: 4, meaning: 'write succeeded but the auto-sync after it failed' },
68
+ { exit: 5, meaning: 'slug already exists', errorCodes: ['SLUG_EXISTS'] },
69
+ { exit: 6, meaning: 'git commit failed — nothing was written to history', errorCodes: ['GIT_COMMIT_FAILED'] },
70
+ { exit: 7, meaning: 'vault locked by another hafez process — retry when it finishes', errorCodes: ['VAULT_LOCKED'] },
71
+ ];
72
+ export const EXIT_CODES = Object.fromEntries(EXIT_TABLE.flatMap(row => (row.errorCodes ?? []).map(code => [code, row.exit])));
73
+ /** Exported so a guard test can assert every registered command is documented
74
+ * here — a registered-but-undocumented command is otherwise invisible. */
75
+ export const USAGE = `Usage: hafez <command> [args]
39
76
 
40
77
  Commands:
41
78
  read <slug> Read entity or knowledge note
42
79
  query [--filter ...] ... Filter entities/knowledge
80
+ --kind all Both kinds; --filter matches entity status only,
81
+ so knowledge notes (which have none) always show
43
82
  search <terms> Full-text search
44
83
  create <kind> <name> Create entity or knowledge note
45
84
  capture <name> [--notes] Quick inbox capture
46
85
  promote <slug> <target> Promote capture→entity/project/knowledge, entity→project
86
+ delete <slug> Delete a knowledge or session note (an entity: update --status done)
47
87
  update <slug> [--status] Update an entity or knowledge note
48
88
  --brief "text" Set/replace Brief (use --brief "" to clear)
49
89
  --description "text" Set one-line description (use --description "" to clear)
50
90
  --resource "uri" Set canonical resource URI, entities only (use --resource "" to clear)
51
91
  --add-action "text" Add a next action
52
- --complete-action "text" Complete matching action
53
- --remove-action "text" Remove matching action
92
+ --complete-action handle-or-text Complete the action with that Handle (as read shows it), else the matching one
93
+ --remove-action handle-or-text Remove the action with that Handle, else the matching one
54
94
  --clear-actions Remove all next actions
55
95
  --synthesis "text" Set/replace Synthesis section (--insight is an alias)
56
96
  --add-evidence "text" Append to Evidence section
57
97
  --add-source "text" Append to Sources section
58
- batch Apply multiple operations atomically (JSON on stdin)
59
- digest Apply digest input (JSON stdin) → batch payload (JSON stdout)
98
+ batch Apply multiple operations atomically (JSON on stdin, --data or --file)
99
+ digest Apply digest input (JSON on stdin, --data or --file) → batch payload (JSON stdout)
60
100
  export --okf [--out <dir>] Export vault as an OKF v0.1 bundle (read-only)
61
101
  sync Pull remote changes and push local commits
62
102
  link <slug> <target> --relation <rel> Add a link
@@ -68,8 +108,14 @@ Commands:
68
108
  index rebuild Rebuild the SQLite index
69
109
  init --register <path> Register an existing vault
70
110
  init Show current vault resolution
71
- onboard Agent-directed first run: seed the vault, pick integration level
72
- help --agent Full API reference for agents
111
+ onboard Setup status, then the script your agent follows to set Hafez up
112
+ --install --mode <always-on|invoke-only> [--harness <h>] [--confirm]
113
+ Write both skills and, always-on, the Block
114
+ --remove [--confirm] Remove the skills and the Block region
115
+ --block [--harness <h>] Print the Block and the entities matching this folder
116
+ (the SessionStart hook; prints nothing on any failure)
117
+ upgrade Check whether hafez is outdated and print the update steps
118
+ help --agent The hafez skill body: the reference when the skill is not installed
73
119
 
74
120
  Global flags:
75
121
  --vault <path> Vault path (overrides config)
@@ -77,41 +123,68 @@ Global flags:
77
123
  --version Print the hafez version
78
124
 
79
125
  Vault discovery: --vault flag > ~/.config/hafez/vault
126
+
127
+ Exit codes:
128
+ ${EXIT_TABLE.map(row => ` ${row.exit} ${row.meaning}`).join('\n')}
80
129
  `;
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
- };
130
+ /** The pure half of the error → exit adapter: which code an error maps to.
131
+ * Exported so the exit-code contract is testable without spawning the CLI. */
132
+ export function exitCodeFor(err) {
133
+ if (err instanceof HelpRequest)
134
+ return 0;
135
+ if (err instanceof UsageError)
136
+ return 2;
137
+ if (err instanceof HafezError)
138
+ return EXIT_CODES[err.code] ?? 1;
139
+ return 1;
140
+ }
141
+ /** The text half of the error adapter: what it prints for a usage or vault
142
+ * error. A misuse ends with the shape to retry from. */
143
+ export function errorText(err) {
144
+ const lines = [`Error: ${err.message}`];
145
+ if (err instanceof HafezError)
146
+ for (const detail of err.details ?? [])
147
+ lines.push(` - ${detail}`);
148
+ if (err instanceof MisuseError)
149
+ lines.push('', err.shape);
150
+ return lines.join('\n') + '\n';
151
+ }
91
152
  /** The one error → exit adapter. Usage errors (bad flags/args) are distinct
92
153
  * from vault errors and from unknown crashes. */
93
154
  function report(err) {
94
- if (err instanceof UsageError) {
95
- process.stderr.write(`Error: ${err.message}\n`);
96
- process.exit(2);
155
+ if (err instanceof HelpRequest) {
156
+ process.stdout.write(err.usage + '\n');
157
+ process.exit(exitCodeFor(err));
97
158
  }
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);
159
+ if (err instanceof RenderedError) {
160
+ process[err.stream].write(err.output + '\n');
161
+ process.exit(exitCodeFor(err));
162
+ }
163
+ if (err instanceof UsageError || err instanceof HafezError) {
164
+ process.stderr.write(errorText(err));
165
+ process.exit(exitCodeFor(err));
105
166
  }
106
167
  failUnknown(err);
107
168
  }
169
+ /** Does this error mean the native module failed to load/build?
170
+ *
171
+ * Matched on the MESSAGE only, and only on the messages a load/ABI failure
172
+ * actually produces. The old test also scanned the stack for "better-sqlite3",
173
+ * which every SqliteError's stack contains — so a plain query error ("near
174
+ * 'abc': syntax error") printed reinstall advice instead of itself. */
175
+ export function isBindingsFailure(err) {
176
+ const message = err?.message;
177
+ if (typeof message !== 'string')
178
+ return false;
179
+ 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);
180
+ }
108
181
  // Terminal handler for non-Hafez errors. Special-cases unbuilt better-sqlite3
109
182
  // bindings: npm 11+ blocks install scripts by default, so `npm install -g
110
183
  // hafez` reports success with the native module unbuilt and the first indexed
111
184
  // command dies here — guide instead of stack-tracing.
112
185
  function failUnknown(err) {
113
186
  const e = err;
114
- if (/better[-_]sqlite3|Could not locate the bindings file/i.test(`${e.message}\n${e.stack ?? ''}`)) {
187
+ if (isBindingsFailure(e)) {
115
188
  process.stderr.write('Error: the SQLite bindings (better-sqlite3) are not built.\n' +
116
189
  'npm 11+ blocks install scripts by default, so the install reports success\n' +
117
190
  'while skipping the native build. Fix with:\n\n' +
@@ -121,108 +194,95 @@ function failUnknown(err) {
121
194
  process.stderr.write(`Error: ${e.message}\n`);
122
195
  process.exit(1);
123
196
  }
124
- export async function main(argv) {
125
- const args = argv.slice(2);
126
- // Extract global flags
127
- const vaultIdx = args.indexOf('--vault');
128
- let flagValue;
129
- if (vaultIdx !== -1) {
130
- flagValue = args[vaultIdx + 1];
131
- args.splice(vaultIdx, 2);
132
- }
133
- const jsonIdx = args.indexOf('--json');
134
- const json = jsonIdx !== -1;
135
- if (json)
136
- args.splice(jsonIdx, 1);
137
- const command = args.shift();
138
- if (!command || command === '--help' || command === '-h') {
139
- process.stdout.write(USAGE);
140
- return;
141
- }
142
- if (command === '--version' || command === '-v' || command === 'version') {
143
- const here = dirname(fileURLToPath(import.meta.url));
144
- const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
145
- process.stdout.write(`hafez ${pkg.version}\n`);
146
- return;
197
+ function versionLine() {
198
+ const here = dirname(fileURLToPath(import.meta.url));
199
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
200
+ return `hafez ${pkg.version}`;
201
+ }
202
+ /** Extract the global flags (--vault <path>, --json) from anywhere in argv.
203
+ * Exported for the in-process dispatch tests. A flag sitting where the vault
204
+ * path should be means the value is missing — never consume it (the same
205
+ * value-position rule parseArgs applies to declared command flags). */
206
+ export function extractGlobalFlags(args) {
207
+ const rest = [];
208
+ let vaultFlag;
209
+ let json = false;
210
+ for (let i = 0; i < args.length; i++) {
211
+ const a = args[i];
212
+ if (a === '--vault') {
213
+ const v = args[i + 1];
214
+ if (v === undefined || v.startsWith('--'))
215
+ throw new UsageError('--vault requires a path value');
216
+ // The old one-shot extraction left a second --vault in the command args
217
+ // where the declared-flag parser rejected it — keep failing loudly
218
+ // rather than silently letting the last one win (a mutating command
219
+ // would write to a different vault than the first flag named).
220
+ if (vaultFlag !== undefined)
221
+ throw new UsageError('--vault specified more than once');
222
+ vaultFlag = v;
223
+ i++;
224
+ }
225
+ else if (a === '--json') {
226
+ json = true;
227
+ }
228
+ else {
229
+ rest.push(a);
230
+ }
147
231
  }
148
- // help runs before vault resolution — works without a registered vault
149
- if (command === 'help') {
150
- if (args[0] === '--agent') {
151
- process.stdout.write(renderAgentHelp());
232
+ return { rest, vaultFlag, json };
233
+ }
234
+ /** Is this the SessionStart hook's form, `onboard --block`? Read from the raw
235
+ * args, the token after `--vault` skipped, so it holds when parsing them fails. */
236
+ export function isBlockHook(args) {
237
+ const command = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--vault');
238
+ return command === 'onboard' && args.includes('--block');
239
+ }
240
+ export async function main(argv) {
241
+ // The hook must never block a session: under `onboard --block` any failure
242
+ // at all, a bad flag included, prints nothing and exits 0 (#132).
243
+ const hook = isBlockHook(argv.slice(2));
244
+ try {
245
+ const { rest: args, vaultFlag, json } = extractGlobalFlags(argv.slice(2));
246
+ const command = args.shift();
247
+ if (!command || command === '--help' || command === '-h') {
248
+ process.stdout.write(USAGE);
152
249
  return;
153
250
  }
154
- process.stdout.write(USAGE);
155
- return;
156
- }
157
- // onboard runs before vault resolution — static agent-directed text
158
- if (command === 'onboard') {
159
- process.stdout.write(renderOnboard());
160
- return;
161
- }
162
- // schema runs before vault resolution — pure Zod introspection, no vault needed.
163
- if (command === 'schema') {
164
- try {
165
- const result = await cmdSchema(null, args);
166
- process.stdout.write(result + '\n');
251
+ const name = command === '--version' || command === '-v' ? 'version' : command;
252
+ const spec = Object.hasOwn(COMMANDS, name) ? COMMANDS[name] : undefined;
253
+ if (!spec) {
254
+ // `list` is the command agents guess for listing the vault (#30).
255
+ const hint = command === 'list' ? '. To list the vault: hafez query --filter active' : '';
256
+ process.stderr.write(`Unknown command: ${command}${hint}\n${USAGE}`);
257
+ process.exit(1);
167
258
  }
168
- catch (err) {
169
- report(err);
259
+ if (spec.phase === 'pre-vault') {
260
+ const out = await spec.run(args, { json, vaultFlag });
261
+ if (out !== null)
262
+ process.stdout.write(out.endsWith('\n') ? out : out + '\n');
263
+ return;
170
264
  }
171
- return;
172
- }
173
- // init runs before vault resolution
174
- if (command === 'init') {
175
- cmdInit(args, flagValue);
176
- return;
177
- }
178
- // Resolve vault
179
- const resolution = resolveVaultPath(flagValue);
180
- if (resolution === null) {
181
- process.stderr.write(noVaultMessage());
182
- process.exit(1);
183
- }
184
- if (typeof resolution === 'object' && 'error' in resolution) {
185
- process.stderr.write(`Error: ${resolution.error}\n`);
186
- process.exit(1);
187
- }
188
- const vaultPath = resolution;
189
- // export runs before Hafez instance creation — read-only, no auto-sync
190
- if (command === 'export') {
191
- try {
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);
199
- if (json) {
200
- process.stdout.write(JSON.stringify(exportReport, null, 2) + '\n');
201
- }
202
- else {
203
- const lines = [`Exported ${exportReport.entities} entities, ${exportReport.knowledge} knowledge, ${exportReport.sessions} sessions to ${exportReport.outDir}`];
204
- for (const s of exportReport.skipped)
205
- lines.push(` skipped ${s.file}: ${s.reason}`);
206
- if (exportReport.unresolvedLinks > 0)
207
- lines.push(` unresolved links: ${exportReport.unresolvedLinks}`);
208
- process.stdout.write(lines.join('\n') + '\n');
209
- }
265
+ // Resolve vault (loaded here, not at module top: the pre-vault commands
266
+ // above return without ever needing the vault machinery)
267
+ const { resolveVaultPath, noVaultMessage } = await import('./resolve-vault.js');
268
+ const resolution = resolveVaultPath(vaultFlag);
269
+ if (resolution === null) {
270
+ process.stderr.write(noVaultMessage());
271
+ process.exit(1);
210
272
  }
211
- catch (err) {
212
- report(err);
273
+ if (typeof resolution === 'object' && 'error' in resolution) {
274
+ process.stderr.write(`Error: ${resolution.error}\n`);
275
+ process.exit(1);
213
276
  }
214
- return;
215
- }
216
- const handler = COMMANDS[command];
217
- if (!handler) {
218
- process.stderr.write(`Unknown command: ${command}\n${USAGE}`);
219
- process.exit(1);
220
- }
221
- const os = createHafez({ vaultPath, git: { push: false } });
222
- try {
223
- const result = await handler(os, args, { json });
277
+ const vaultPath = resolution;
278
+ // First touch of the core — and so of the native SQLite binding. A
279
+ // failure to load it lands in the catch below and is recognised by
280
+ // isBindingsFailure, exactly as it was when this import was static.
281
+ const { createHafez } = await import('../index.js');
282
+ const os = createHafez({ vaultPath, git: { push: false } });
283
+ const result = await spec.run(os, args, { json, vaultPath });
224
284
  // Auto-sync after mutating commands
225
- if (MUTATING_COMMANDS.has(command)) {
285
+ if (spec.mutating) {
226
286
  try {
227
287
  await os.sync();
228
288
  }
@@ -236,6 +296,8 @@ export async function main(argv) {
236
296
  process.stdout.write(result + '\n');
237
297
  }
238
298
  catch (err) {
299
+ if (hook)
300
+ return;
239
301
  report(err);
240
302
  }
241
303
  }