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,56 +1,120 @@
1
1
  // src/cli/commands.ts
2
- import { z } from 'zod';
3
2
  import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { parseArgs, splitList, str, UsageError } from './args.js';
4
5
  import { HafezError } from '../types.js';
5
- import { ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, } from '../contracts.js';
6
+ import { BatchOperationSchema, normalizeAliases, opErrorLine, batchInvalidHeader } from '../batch-ops.js';
6
7
  import { formatBatchError } from './batch-errors.js';
7
8
  import { parseDigestInput, digest } from '../digest.js';
8
9
  import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
9
10
  function jsonOut(data) {
10
11
  return JSON.stringify(data, null, 2);
11
12
  }
13
+ const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full]';
14
+ const READ_FLAGS = { depth: 'string' };
12
15
  export async function cmdRead(os, args, opts = {}) {
13
- const slug = args[0];
16
+ const { flags, positionals } = parseArgs(args, READ_FLAGS, READ_USAGE);
17
+ const slug = positionals[0];
14
18
  if (!slug)
15
- throw new Error('Usage: hafez read <slug> [--depth frontmatter|summary|full]');
16
- const depth = (getFlag(args, '--depth') ?? 'summary');
19
+ throw new UsageError(READ_USAGE);
20
+ const depth = (str(flags.depth) ?? 'summary');
17
21
  const result = await os.read(slug, depth);
18
22
  if (opts.json)
19
23
  return jsonOut(result);
20
24
  const fm = result.frontmatter;
21
- if ('type' in fm)
25
+ if (result.kind === 'entity')
22
26
  return formatEntityHeader(fm, result.body);
23
27
  return formatKnowledgeHeader(fm, result.body);
24
28
  }
29
+ const QUERY_USAGE = `Usage: hafez query [--kind entity|knowledge|all] [--filter ...] [--type t] [--parent slug] [--related-to slug] [--domain d] [--confidence c] [--subtype s] [--tag t] [--since d] [--before d] [--created-since d] [--created-before d] [--sort-by f] [--sort-order asc|desc] [--limit n] [--offset n]
30
+
31
+ --filter active|paused|done matches entity status; knowledge notes carry no
32
+ status, so under --kind all they are always included alongside the matching
33
+ entities. --filter stale|capture are entity-only views and return no
34
+ knowledge. --filter/--type/--parent are rejected with --kind knowledge, and
35
+ --confidence/--subtype are rejected with --kind entity, rather than being
36
+ silently dropped. --related-to takes no other query flags.`;
37
+ const QUERY_FLAGS = {
38
+ kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
39
+ domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
40
+ since: 'string', before: 'string', 'created-since': 'string', 'created-before': 'string',
41
+ 'sort-by': 'string', 'sort-order': 'string', limit: 'string', offset: 'string',
42
+ };
43
+ /** Every flag the query parser accepts, for the branch-support matrix. */
44
+ export const QUERY_FLAG_NAMES = Object.keys(QUERY_FLAGS);
45
+ /** Which flags each kind branch actually applies. A branch that cannot honour
46
+ * a flag rejects it (below) instead of dropping it: `--kind knowledge --filter
47
+ * stale` used to return every knowledge note with exit 0, and `--confidence`
48
+ * never reached an entity query at all. */
49
+ export const QUERY_FLAG_SUPPORT = {
50
+ entity: ['kind', 'filter', 'type', 'parent', 'domain', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
51
+ knowledge: ['kind', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
52
+ all: ['kind', 'filter', 'type', 'parent', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
53
+ 'related-to': ['related-to'],
54
+ };
55
+ const FLAG_SCOPE_REASON = {
56
+ filter: 'it selects entity status (or the entity-only stale/capture views); knowledge notes carry no status',
57
+ type: 'entity types only',
58
+ parent: 'entity hierarchy only',
59
+ confidence: 'knowledge notes only',
60
+ subtype: 'knowledge notes only',
61
+ };
62
+ const QUERY_KINDS = ['entity', 'knowledge', 'all'];
63
+ function rejectUnsupportedFlags(branch, flags) {
64
+ const supported = new Set(QUERY_FLAG_SUPPORT[branch]);
65
+ for (const [name, value] of Object.entries(flags)) {
66
+ if (value === undefined || supported.has(name))
67
+ continue;
68
+ const where = branch === 'related-to'
69
+ ? 'with --related-to (it returns every entity and knowledge note linked to the slug)'
70
+ : `with --kind ${branch}`;
71
+ const reason = FLAG_SCOPE_REASON[name] ? ` — ${FLAG_SCOPE_REASON[name]}` : '';
72
+ throw new UsageError(`--${name} is not supported ${where}${reason}\n${QUERY_USAGE}`);
73
+ }
74
+ }
75
+ /** Paging flags reach SQL as text — parse them strictly here so `--limit abc`
76
+ * is a usage error rather than a prepare-time SqliteError. */
77
+ function intFlag(raw, name) {
78
+ if (raw === undefined)
79
+ return undefined;
80
+ if (!/^\d+$/.test(raw))
81
+ throw new UsageError(`--${name} must be a non-negative integer (got "${raw}")\n${QUERY_USAGE}`);
82
+ return Number(raw);
83
+ }
25
84
  export async function cmdQuery(os, args, opts = {}) {
26
- const kind = getFlag(args, '--kind') ?? 'entity';
27
- const filter = (getFlag(args, '--filter') ?? 'active');
28
- const type = getFlag(args, '--type');
29
- const parent = getFlag(args, '--parent');
30
- const relatedTo = getFlag(args, '--related-to');
31
- const domain = getFlag(args, '--domain');
32
- const confidence = getFlag(args, '--confidence');
33
- const since = getFlag(args, '--since');
34
- const before = getFlag(args, '--before');
35
- const createdSince = getFlag(args, '--created-since');
36
- const createdBefore = getFlag(args, '--created-before');
37
- const tagRaw = getFlag(args, '--tag');
85
+ const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
86
+ const relatedTo = str(flags['related-to']);
87
+ const branch = relatedTo !== undefined ? 'related-to' : (str(flags.kind) ?? 'entity');
88
+ if (branch !== 'related-to' && !QUERY_KINDS.includes(branch)) {
89
+ throw new UsageError(`--kind must be one of ${QUERY_KINDS.join(', ')} (got "${branch}")\n${QUERY_USAGE}`);
90
+ }
91
+ rejectUnsupportedFlags(branch, flags);
92
+ const filter = (str(flags.filter) ?? 'active');
93
+ const type = str(flags.type);
94
+ const parent = str(flags.parent);
95
+ const domain = str(flags.domain);
96
+ const confidence = str(flags.confidence);
97
+ const subtype = str(flags.subtype);
98
+ const since = str(flags.since);
99
+ const before = str(flags.before);
100
+ const createdSince = str(flags['created-since']);
101
+ const createdBefore = str(flags['created-before']);
102
+ const tagRaw = str(flags.tag);
38
103
  const tags = tagRaw ? tagRaw.split(',').map(t => t.trim()).filter(Boolean) : undefined;
39
- const sortBy = getFlag(args, '--sort-by');
40
- const sortOrder = getFlag(args, '--sort-order');
41
- const limitRaw = getFlag(args, '--limit');
42
- const offsetRaw = getFlag(args, '--offset');
43
- const limit = limitRaw != null ? parseInt(limitRaw, 10) : undefined;
44
- const offset = offsetRaw != null ? parseInt(offsetRaw, 10) : undefined;
104
+ const sortBy = str(flags['sort-by']);
105
+ const sortOrder = str(flags['sort-order']);
106
+ const limit = intFlag(str(flags.limit), 'limit');
107
+ const offset = intFlag(str(flags.offset), 'offset');
45
108
  const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
109
+ const timeOpts = { since, before, createdSince, createdBefore };
46
110
  // --related-to: always returns both entities and knowledge
47
111
  if (relatedTo) {
48
112
  const result = await os.related_to(relatedTo);
49
113
  if (opts.json)
50
114
  return jsonOut(result);
51
115
  const { items: results, total } = result;
52
- const entities = results.filter((r) => 'type' in r);
53
- const knowledge = results.filter((r) => 'confidence' in r);
116
+ const entities = results.filter(r => r.kind === 'entity');
117
+ const knowledge = results.filter(r => r.kind === 'knowledge');
54
118
  const sections = [];
55
119
  if (entities.length > 0)
56
120
  sections.push('## Entities\n\n' + formatQueryTable(entities, total));
@@ -58,20 +122,19 @@ export async function cmdQuery(os, args, opts = {}) {
58
122
  sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge, total));
59
123
  return sections.length > 0 ? sections.join('\n\n') : 'No results.';
60
124
  }
61
- const queryOpts = { filter, type, parent, domain, since, before, createdSince, createdBefore, tags, ...sortOpts };
62
- // --kind knowledge: query knowledge only
63
- // Note: time flags only apply to entity queries.
64
- if (kind === 'knowledge') {
65
- const subtype = getFlag(args, '--subtype');
66
- const kOpts = { domain: domain ?? undefined, confidence, subtype, tags, ...sortOpts };
125
+ const queryOpts = { filter, type, parent, domain, ...timeOpts, tags, ...sortOpts };
126
+ // --kind knowledge: query knowledge only. The time window applies here too —
127
+ // knowledge rows carry created (and last-touched for the plan subtype).
128
+ if (branch === 'knowledge') {
129
+ const kOpts = { domain: domain ?? undefined, confidence, subtype, ...timeOpts, tags, ...sortOpts };
67
130
  const result = await os.query_knowledge(kOpts);
68
131
  if (opts.json)
69
132
  return jsonOut(result);
70
133
  return formatKnowledgeTable(result.items, result.total);
71
134
  }
72
135
  // --kind all: query both entities and knowledge via unified query
73
- if (kind === 'all') {
74
- const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence });
136
+ if (branch === 'all') {
137
+ const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence, subtype });
75
138
  if (opts.json)
76
139
  return jsonOut(result);
77
140
  const { items, total } = result;
@@ -92,129 +155,161 @@ export async function cmdQuery(os, args, opts = {}) {
92
155
  return jsonOut(result);
93
156
  return formatQueryTable(result.items, result.total);
94
157
  }
158
+ const SEARCH_USAGE = 'Usage: hafez search <terms> [--kind entity|knowledge|all]';
159
+ const SEARCH_FLAGS = { kind: 'string' };
95
160
  export async function cmdSearch(os, args, opts = {}) {
96
- const terms = positionalArgs(args).join(' ');
161
+ const { flags, positionals } = parseArgs(args, SEARCH_FLAGS, SEARCH_USAGE);
162
+ const terms = positionals.join(' ');
97
163
  if (!terms)
98
- throw new Error('Usage: hafez search <terms> [--kind entity|knowledge|all]');
99
- const kind = (getFlag(args, '--kind') ?? 'all');
164
+ throw new UsageError(SEARCH_USAGE);
165
+ const kind = (str(flags.kind) ?? 'all');
100
166
  const results = await os.search(terms, kind);
101
167
  if (opts.json)
102
168
  return jsonOut(results);
103
169
  return formatSearchResults(results);
104
170
  }
171
+ // --status is knowledge/plan only, and --purpose needs --type project: a field
172
+ // whose section the chosen template lacks is rejected, never silently dropped.
173
+ const CREATE_USAGE = 'Usage: hafez create <entity|knowledge> <name> [options]\n entity: --type capture|entity|project [--purpose text (project only)] [--brief text] [--add-action text] …\n knowledge: [--subtype insight|plan] [--status active|done (plan only)] [--synthesis text (insight only)] …';
174
+ /** Exported so tests can assert the flag × kind matrix is exhaustive. */
175
+ export const CREATE_FLAGS = {
176
+ type: 'string', purpose: 'string', description: 'string', resource: 'string',
177
+ domain: 'string', parent: 'string', related: 'string', tags: 'string',
178
+ brief: 'string', 'add-action': 'string',
179
+ subtype: 'string', synthesis: 'string', insight: 'string',
180
+ // knowledge, plan subtype only — insights have no lifecycle
181
+ status: 'string',
182
+ };
183
+ /** Flags whose value is a comma list. Everything else is a plain string. */
184
+ const CREATE_LIST_FLAGS = new Set(['domain', 'related', 'tags']);
185
+ /** Every provided create flag → its field name (dashes become underscores),
186
+ * kind-agnostic on purpose. Which fields a kind accepts is the Op Spec's
187
+ * call, not the CLI's: the per-kind hand-filter this replaces discarded
188
+ * whatever its branch didn't name (--brief on knowledge, --subtype on
189
+ * entity) and still exited 0 with the value nowhere on disk. Only provided
190
+ * flags are included — a present-but-undefined key would trip the same
191
+ * strict shape that does the rejecting. */
192
+ function createFields(flags) {
193
+ const fields = {};
194
+ for (const flag of Object.keys(CREATE_FLAGS)) {
195
+ if (flags[flag] === undefined)
196
+ continue;
197
+ fields[flag.replace(/-/g, '_')] = CREATE_LIST_FLAGS.has(flag)
198
+ ? splitList(flags[flag])
199
+ : str(flags[flag]);
200
+ }
201
+ return fields;
202
+ }
105
203
  export async function cmdCreate(os, args, opts = {}) {
106
- const kind = args[0];
204
+ const { flags, positionals } = parseArgs(args, CREATE_FLAGS, CREATE_USAGE);
205
+ const kind = positionals[0];
107
206
  if (!kind)
108
- throw new Error('Usage: hafez create <entity|knowledge> <name> [options]');
109
- const name = positionalArgs(args.slice(1)).join(' ');
207
+ throw new UsageError(CREATE_USAGE);
208
+ const name = positionals.slice(1).join(' ');
110
209
  if (!name)
111
- throw new Error(`Usage: hafez create ${kind} <name> [options]`);
210
+ throw new UsageError(`Usage: hafez create ${kind} <name> [options]`);
211
+ const fields = createFields(flags);
112
212
  if (kind === 'entity') {
113
- const type = getFlag(args, '--type');
114
- if (!type)
115
- throw new Error('--type is required for entity creation');
116
- const slug = await os.create('entity', name, {
117
- type,
118
- purpose: getFlag(args, '--purpose'),
119
- description: getFlag(args, '--description'),
120
- resource: getFlag(args, '--resource'),
121
- domain: getFlag(args, '--domain')?.split(',').map(d => d.trim()),
122
- parent: getFlag(args, '--parent'),
123
- related: getFlag(args, '--related')?.split(','),
124
- tags: getFlag(args, '--tags')?.split(','),
125
- brief: getFlag(args, '--brief'),
126
- add_action: getFlag(args, '--add-action'),
127
- });
213
+ // The only per-kind rule the CLI still owns: --type is required, and a
214
+ // missing one deserves better than a shape error naming a JSON field.
215
+ if (fields.type === undefined)
216
+ throw new UsageError('--type is required for entity creation');
217
+ const slug = await os.create('entity', name, fields);
128
218
  return opts.json ? jsonOut({ slug }) : slug;
129
219
  }
130
220
  if (kind === 'knowledge') {
131
- const slug = await os.create('knowledge', name, {
132
- subtype: getFlag(args, '--subtype'),
133
- synthesis: getFlag(args, '--synthesis') ?? getFlag(args, '--insight'),
134
- description: getFlag(args, '--description'),
135
- domain: getFlag(args, '--domain')?.split(','),
136
- related: getFlag(args, '--related')?.split(','),
137
- tags: getFlag(args, '--tags')?.split(','),
138
- });
221
+ // --insight passes through untouched: the Op Spec normalizes the alias
222
+ // (and drops it when --synthesis is also given). On entity it stays
223
+ // `insight` and the kind gate rejects it. --status flows through the
224
+ // same way; the subtype-aware gate accepts it on plans only.
225
+ const slug = await os.create('knowledge', name, fields);
139
226
  return opts.json ? jsonOut({ slug }) : slug;
140
227
  }
141
- throw new Error(`Unknown kind: ${kind}. Use entity or knowledge.`);
228
+ throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
142
229
  }
143
- export async function cmdUpdate(os, args, opts = {}) {
144
- const slug = args[0];
145
- if (!slug)
146
- throw new Error('Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text] [--complete-action text] [--remove-action text] [--clear-actions] [--status s] [--log "type: summary"]');
230
+ const UPDATE_USAGE = 'Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text] [--complete-action text] [--remove-action text] [--clear-actions] [--status s] [--log "type: summary"]';
231
+ /** Exported so tests can assert the table covers the update schema. */
232
+ export const UPDATE_FLAGS = {
233
+ status: 'string', 'current-state': 'string', brief: 'string',
234
+ 'add-action': 'string', 'complete-action': 'string', 'remove-action': 'string',
235
+ 'clear-actions': 'boolean',
236
+ description: 'string', resource: 'string', domain: 'string', confidence: 'string',
237
+ tags: 'string', related: 'string', insight: 'string', synthesis: 'string',
238
+ 'add-evidence': 'string', 'add-source': 'string',
239
+ // Composed, not mapped: --log "type: summary" plus --agent build session_log.
240
+ log: 'string', agent: 'string',
241
+ // Removed flag, declared so it fails with guidance instead of a generic
242
+ // unknown-flag error: older installed skills/scripts still pass it, and
243
+ // "Updated <slug>" with the action discarded is a false success.
244
+ 'next-action': 'string',
245
+ };
246
+ /** Flags whose value is a comma list. Everything else is a plain string. */
247
+ const UPDATE_LIST_FLAGS = new Set(['domain', 'tags', 'related']);
248
+ /** Flags where an empty string is the clear signal — exactly the nullable
249
+ * fields of the update shape. Every other flag passes its value through
250
+ * untouched, so `--status ""` reaches the enum and is rejected by name
251
+ * instead of silently no-opping (which is how it read before: the guard
252
+ * each flag got was an accident of which one was typed). */
253
+ const UPDATE_CLEARABLE_FLAGS = new Set(['brief', 'description', 'resource']);
254
+ /** Flags the generic loop skips because they are not a field of the update
255
+ * shape — each is hand-handled in cmdUpdate. */
256
+ export const UPDATE_SPECIAL_FLAGS = new Set(['log', 'agent', 'next-action']);
257
+ /** Update-shape fields with no flag of their own, and why. The exhaustiveness
258
+ * test consults this, so a gap here is a stated decision rather than the
259
+ * silent hole `add_actions` was. */
260
+ export const UPDATE_FIELDS_WITHOUT_FLAGS = {
261
+ session_log: 'composed by --log "type: summary" (+ --agent), never passed raw',
262
+ add_actions: 'array-valued: comma-splitting would mangle action text (actions carry commas), and the parser takes no repeated flags — batch payloads are the interface for multi-action writes',
263
+ };
264
+ /** Every provided update flag → its field name (dashes become underscores).
265
+ * Which fields the target accepts is the Op Spec's call, not the CLI's — the
266
+ * strict shape does all accepting and rejecting, exactly as on create. Only
267
+ * provided flags are included: a present-but-undefined key would trip that
268
+ * same strict shape. */
269
+ function updateFields(flags) {
147
270
  const fields = {};
148
- const status = getFlag(args, '--status');
149
- if (status)
150
- fields.status = status;
151
- // Removed flag must fail loudly, not silently drop the value: older
152
- // installed skills/scripts still pass it, and "Updated <slug>" with the
153
- // action discarded is a false success.
154
- if (args.includes('--next-action')) {
155
- throw new Error('--next-action was removed in v1.0.2. Use --add-action (and --clear-actions) instead.');
271
+ for (const [flag, type] of Object.entries(UPDATE_FLAGS)) {
272
+ const raw = flags[flag];
273
+ if (raw === undefined || UPDATE_SPECIAL_FLAGS.has(flag))
274
+ continue;
275
+ const field = flag.replace(/-/g, '_');
276
+ if (type === 'boolean') {
277
+ fields[field] = true;
278
+ continue;
279
+ }
280
+ if (UPDATE_LIST_FLAGS.has(flag)) {
281
+ fields[field] = splitList(raw);
282
+ continue;
283
+ }
284
+ const value = str(raw);
285
+ fields[field] = value === '' && UPDATE_CLEARABLE_FLAGS.has(flag) ? null : value;
156
286
  }
157
- const currentState = getFlag(args, '--current-state');
158
- if (currentState)
159
- fields.current_state = currentState;
160
- // Brief
161
- const brief = getFlag(args, '--brief');
162
- if (brief !== undefined) {
163
- fields.brief = brief === '' ? null : brief;
287
+ return fields;
288
+ }
289
+ export async function cmdUpdate(os, args, opts = {}) {
290
+ const { flags, positionals } = parseArgs(args, UPDATE_FLAGS, UPDATE_USAGE);
291
+ const slug = positionals[0];
292
+ if (!slug)
293
+ throw new UsageError(UPDATE_USAGE);
294
+ if (flags['next-action'] !== undefined) {
295
+ throw new UsageError('--next-action was removed in v1.0.2. Use --add-action (and --clear-actions) instead.');
164
296
  }
165
- // Next Actions
166
- const addAction = getFlag(args, '--add-action');
167
- if (addAction)
168
- fields.add_action = addAction;
169
- const completeAction = getFlag(args, '--complete-action');
170
- if (completeAction)
171
- fields.complete_action = completeAction;
172
- const removeAction = getFlag(args, '--remove-action');
173
- if (removeAction)
174
- fields.remove_action = removeAction;
175
- if (args.includes('--clear-actions'))
176
- fields.clear_actions = true;
177
- const log = getFlag(args, '--log');
178
- if (log) {
297
+ // --insight is the declared alias; the Op Spec normalizes it (and drops it
298
+ // when --synthesis is also given).
299
+ const fields = updateFields(flags);
300
+ // The one genuinely CLI-owned convenience: session_log is a nested object,
301
+ // so the flag pair composes it rather than naming a field.
302
+ const log = str(flags.log);
303
+ if (log !== undefined) {
179
304
  const colonIdx = log.indexOf(':');
180
305
  if (colonIdx === -1)
181
- throw new Error('--log format: "type: summary". Types: progress, decision, blocker, research');
182
- const type = log.slice(0, colonIdx).trim();
183
- const summary = log.slice(colonIdx + 1).trim();
184
- const agent = getFlag(args, '--agent') ?? 'claude';
185
- fields.session_log = { type, summary, agent };
306
+ throw new UsageError('--log format: "type: summary". Types: progress, decision, blocker, research');
307
+ fields.session_log = {
308
+ type: log.slice(0, colonIdx).trim(),
309
+ summary: log.slice(colonIdx + 1).trim(),
310
+ agent: str(flags.agent) ?? 'claude',
311
+ };
186
312
  }
187
- // Shared metadata (entity + knowledge)
188
- const description = getFlag(args, '--description');
189
- if (description !== undefined)
190
- fields.description = description;
191
- const resource = getFlag(args, '--resource');
192
- if (resource !== undefined)
193
- fields.resource = resource;
194
- const domainRaw = getFlag(args, '--domain');
195
- if (domainRaw)
196
- fields.domain = domainRaw.split(',').map((d) => d.trim());
197
- const conf = getFlag(args, '--confidence');
198
- if (conf)
199
- fields.confidence = conf;
200
- const tagsRaw = getFlag(args, '--tags');
201
- if (tagsRaw)
202
- fields.tags = tagsRaw.split(',').map((t) => t.trim());
203
- const relatedRaw = getFlag(args, '--related');
204
- if (relatedRaw)
205
- fields.related = relatedRaw.split(',').map((r) => r.trim());
206
- const insight = getFlag(args, '--insight');
207
- if (insight)
208
- fields.synthesis = insight;
209
- const synthesis = getFlag(args, '--synthesis');
210
- if (synthesis)
211
- fields.synthesis = synthesis;
212
- const addEvidence = getFlag(args, '--add-evidence');
213
- if (addEvidence)
214
- fields.add_evidence = addEvidence;
215
- const addSource = getFlag(args, '--add-source');
216
- if (addSource)
217
- fields.add_source = addSource;
218
313
  const result = await os.update(slug, fields);
219
314
  if (opts.json)
220
315
  return jsonOut({ slug, ...result });
@@ -224,23 +319,17 @@ export async function cmdUpdate(os, args, opts = {}) {
224
319
  }
225
320
  return lines.join('\n');
226
321
  }
322
+ const LINK_FLAGS = { relation: 'string' };
227
323
  function parseLinkArgs(args, verb) {
228
- const relation = getFlag(args, '--relation');
229
- // After getFlag the relation value is still at its original positional slot
230
- // (getFlag does NOT splice), so strip it manually before reading positionals.
231
- const positionals = args.filter((a, i) => {
232
- if (a === '--relation')
233
- return false;
234
- if (i > 0 && args[i - 1] === '--relation')
235
- return false;
236
- return !a.startsWith('--');
237
- });
324
+ const usage = `Usage: hafez ${verb} <slug> <target> --relation <parent|related>`;
325
+ const { flags, positionals } = parseArgs(args, LINK_FLAGS, usage);
326
+ const relation = str(flags.relation);
238
327
  const [slug, target] = positionals;
239
328
  if (!slug || !target || !relation) {
240
- throw new Error(`Usage: hafez ${verb} <slug> <target> --relation <parent|related>`);
329
+ throw new UsageError(usage);
241
330
  }
242
331
  if (!['parent', 'related'].includes(relation)) {
243
- throw new Error(`Invalid relation: ${relation}. Must be parent or related.`);
332
+ throw new UsageError(`Invalid relation: ${relation}. Must be parent or related.`);
244
333
  }
245
334
  return { slug, target, relation };
246
335
  }
@@ -255,22 +344,24 @@ export async function cmdUnlink(os, args, opts = {}) {
255
344
  return opts.json ? jsonOut({ slug, target, relation }) : `Unlinked ${slug} — ${target} (${relation})`;
256
345
  }
257
346
  /** Top-level capture command — aligns the CLI verb with the batch op name. */
347
+ const CAPTURE_USAGE = 'Usage: hafez capture <name> [--notes "text"]';
348
+ const CAPTURE_FLAGS = { notes: 'string' };
258
349
  export async function cmdCapture(os, args, opts = {}) {
259
- const name = positionalArgs(args).join(' ');
350
+ const { flags, positionals } = parseArgs(args, CAPTURE_FLAGS, CAPTURE_USAGE);
351
+ const name = positionals.join(' ');
260
352
  if (!name)
261
- throw new Error('Usage: hafez capture <name> [--notes "text"]');
262
- const notes = getFlag(args, '--notes');
353
+ throw new UsageError(CAPTURE_USAGE);
354
+ const notes = str(flags.notes);
263
355
  const slug = await os.capture(name, notes);
264
356
  return opts.json ? jsonOut({ slug }) : slug;
265
357
  }
358
+ const PROMOTE_USAGE = 'Usage: hafez promote <slug> <entity|project|knowledge>';
266
359
  export async function cmdPromote(os, args, opts = {}) {
267
- const slug = args[0];
268
- const target = args[1];
360
+ const { positionals } = parseArgs(args, {}, PROMOTE_USAGE);
361
+ const [slug, target] = positionals;
269
362
  if (!slug || !target)
270
- throw new Error('Usage: hafez promote <slug> <entity|project|knowledge>');
271
- if (!['entity', 'project', 'knowledge'].includes(target)) {
272
- throw new Error(`Invalid target: ${target}. Must be entity, project, or knowledge.`);
273
- }
363
+ throw new UsageError(PROMOTE_USAGE);
364
+ // Target validity is core's rule (contract canPromoteTo) — no CLI re-check.
274
365
  await os.promote(slug, target);
275
366
  if (opts.json)
276
367
  return jsonOut({ slug, target });
@@ -304,10 +395,13 @@ export async function cmdStats(os, _args, opts = {}) {
304
395
  return jsonOut(stats);
305
396
  return formatStats(stats);
306
397
  }
398
+ const CHANGELOG_USAGE = 'Usage: hafez changelog --since <date|relative>\nExamples: --since 2026-03-20, --since 7.days.ago, --since 1.week.ago';
399
+ const CHANGELOG_FLAGS = { since: 'string' };
307
400
  export async function cmdChangelog(os, args, opts = {}) {
308
- const since = getFlag(args, '--since');
401
+ const { flags } = parseArgs(args, CHANGELOG_FLAGS, CHANGELOG_USAGE);
402
+ const since = str(flags.since);
309
403
  if (!since)
310
- throw new Error('Usage: hafez changelog --since <date|relative>\nExamples: --since 2026-03-20, --since 7.days.ago, --since 1.week.ago');
404
+ throw new UsageError(CHANGELOG_USAGE);
311
405
  const entries = await os.changelog(since);
312
406
  if (opts.json)
313
407
  return jsonOut(entries);
@@ -315,127 +409,11 @@ export async function cmdChangelog(os, args, opts = {}) {
315
409
  }
316
410
  // --- Batch ---
317
411
  // All enums are built from contracts.ts tuples so help-renderer, file validators,
318
- // and CLI-input validator share a single source of truth. cli-schema-parity.test.ts
319
- // enforces this on every build.
412
+ // and CLI-input validator share a single source of truth — drift is structurally
413
+ // impossible (the Op Spec table in batch-ops.ts is the only consumer).
320
414
  // Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
321
415
  // (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
322
416
  // hints and for catching typos like `{"kind": "parent"}` on a link op.
323
- export const SessionLogSchema = z.object({
324
- type: z.enum([...SESSION_LOG_TYPES]),
325
- summary: z.string(),
326
- body: z.string().optional(),
327
- agent: z.string(),
328
- }).strict();
329
- export const UpdateFieldsSchema = z.object({
330
- status: z.enum([...ENTITY_STATUSES]).optional(),
331
- current_state: z.string().optional(),
332
- session_log: SessionLogSchema.optional(),
333
- brief: z.string().nullable().optional(),
334
- add_action: z.string().optional(),
335
- add_actions: z.array(z.string()).optional(),
336
- complete_action: z.string().optional(),
337
- remove_action: z.string().optional(),
338
- clear_actions: z.boolean().optional(),
339
- // Knowledge metadata
340
- description: z.string().nullable().optional(),
341
- resource: z.string().nullable().optional(),
342
- domain: z.array(z.string()).optional(),
343
- confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
344
- tags: z.array(z.string()).optional(),
345
- related: z.array(z.string()).optional(),
346
- synthesis: z.string().optional(),
347
- insight: z.string().optional(), // alias for synthesis (normalized in parseBatchInput)
348
- add_evidence: z.string().optional(),
349
- add_source: z.string().optional(),
350
- }).strict();
351
- const CreateEntityFieldsSchema = z.object({
352
- type: z.enum([...ENTITY_TYPES]),
353
- purpose: z.string().optional(),
354
- description: z.string().optional(),
355
- resource: z.string().optional(),
356
- domain: z.array(z.string()).optional(),
357
- parent: z.string().optional(),
358
- related: z.array(z.string()).optional(),
359
- tags: z.array(z.string()).optional(),
360
- brief: z.string().optional(),
361
- add_action: z.string().optional(),
362
- add_actions: z.array(z.string()).optional(),
363
- }).strict();
364
- const CreateKnowledgeFieldsSchema = z.object({
365
- subtype: z.enum([...VALID_SUBTYPES]).optional(),
366
- synthesis: z.string().optional(),
367
- insight: z.string().optional(),
368
- description: z.string().optional(),
369
- domain: z.array(z.string()).optional(),
370
- related: z.array(z.string()).optional(),
371
- tags: z.array(z.string()).optional(),
372
- 'session-date': z.string().optional(),
373
- }).strict();
374
- const CreateSessionFieldsSchema = z.object({
375
- synthesis: z.string().optional(),
376
- insight: z.string().optional(),
377
- related: z.array(z.string()).optional(),
378
- 'session-date': z.string().optional(),
379
- }).strict();
380
- const CreateEntityBranch = z.object({
381
- op: z.literal('create'),
382
- kind: z.literal('entity'),
383
- name: z.string(),
384
- fields: CreateEntityFieldsSchema,
385
- }).strict();
386
- const CreateKnowledgeBranch = z.object({
387
- op: z.literal('create'),
388
- kind: z.literal('knowledge'),
389
- name: z.string(),
390
- fields: CreateKnowledgeFieldsSchema.optional(),
391
- }).strict();
392
- const CreateSessionBranch = z.object({
393
- op: z.literal('create'),
394
- kind: z.literal('session'),
395
- name: z.string(),
396
- fields: CreateSessionFieldsSchema.optional(),
397
- }).strict();
398
- // Nested discriminated union: outer on `op`, inner on `kind` for create branch.
399
- // A flat `discriminatedUnion('op', [...])` cannot work because three branches share
400
- // op=create — Zod requires unique discriminator values per branch and throws at
401
- // schema construction time if violated.
402
- const CreateBranch = z.discriminatedUnion('kind', [
403
- CreateEntityBranch,
404
- CreateKnowledgeBranch,
405
- CreateSessionBranch,
406
- ]);
407
- export const BatchOperationSchema = z.discriminatedUnion('op', [
408
- z.object({
409
- op: z.literal('update'),
410
- slug: z.string(),
411
- fields: UpdateFieldsSchema,
412
- }).strict(),
413
- // create is a nested discriminatedUnion on `kind` — see CreateBranch above.
414
- // discriminatedUnion accepts a ZodDiscriminatedUnion as a branch via option-flattening.
415
- CreateBranch,
416
- z.object({
417
- op: z.literal('capture'),
418
- name: z.string(),
419
- notes: z.string().optional(),
420
- }).strict(),
421
- z.object({
422
- op: z.literal('link'),
423
- slug: z.string(),
424
- target: z.string(),
425
- relation: z.enum(['parent', 'related']),
426
- }).strict(),
427
- z.object({
428
- op: z.literal('unlink'),
429
- slug: z.string(),
430
- target: z.string(),
431
- relation: z.enum(['parent', 'related']),
432
- }).strict(),
433
- z.object({
434
- op: z.literal('promote'),
435
- slug: z.string(),
436
- target: z.enum(['entity', 'project', 'knowledge']),
437
- }).strict(),
438
- ]);
439
417
  export function parseBatchInput(json) {
440
418
  let parsed;
441
419
  try {
@@ -475,19 +453,12 @@ export function parseBatchInput(json) {
475
453
  continue;
476
454
  }
477
455
  const op = result.data;
478
- // Alias: insight → synthesis for create knowledge/session and update ops.
479
- // Runs only on successfully-parsed ops, preserving prior semantics.
480
- if (op.fields) {
481
- if (op.fields.insight && !op.fields.synthesis) {
482
- op.fields.synthesis = op.fields.insight;
483
- delete op.fields.insight;
484
- }
485
- }
486
- operations.push(op);
456
+ // Alias normalization is the Op Spec's (normalizeAliases) — the CLI just
457
+ // hands back canonical ops so `batch --dry-run` output names real fields.
458
+ operations.push(normalizeAliases(op));
487
459
  }
488
460
  if (failureBlocks.length > 0) {
489
- const header = `Batch contains ${failureBlocks.length} invalid operation${failureBlocks.length === 1 ? '' : 's'} (0 of ${parsed.length} applied, batch aborted):`;
490
- throw new HafezError('VALIDATION_FAILED', header, failureBlocks);
461
+ throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(failureBlocks.length, parsed.length), failureBlocks);
491
462
  }
492
463
  return operations;
493
464
  }
@@ -517,9 +488,12 @@ function readStdin() {
517
488
  process.stdin.on('error', reject);
518
489
  });
519
490
  }
491
+ const BATCH_USAGE = "Usage: hafez batch [--dry-run] [--file payload.json] (or pipe JSON to stdin)";
492
+ const BATCH_FLAGS = { 'dry-run': 'boolean', file: 'string' };
520
493
  export async function cmdBatch(os, args, opts = {}) {
521
- const dryRun = args.includes('--dry-run');
522
- const file = getFlag(args, '--file');
494
+ const { flags } = parseArgs(args, BATCH_FLAGS, BATCH_USAGE);
495
+ const dryRun = flags['dry-run'] === true;
496
+ const file = str(flags.file);
523
497
  const input = file ? readPayloadFile(file) : await readStdin();
524
498
  if (!input.trim()) {
525
499
  throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin (echo \'[...]\' | hafez batch) or pass --file payload.json');
@@ -533,7 +507,9 @@ export async function cmdBatch(os, args, opts = {}) {
533
507
  return jsonOut(report);
534
508
  if (!report.valid) {
535
509
  const invalidCount = report.operations.filter(o => o.errors.length > 0).length;
536
- const lines = report.operations.flatMap(o => o.errors.map(e => `op[${o.index}] (${o.op} ${o.slug}): ${e}`));
510
+ const lines = report.operations.flatMap(o => o.errors.map(e => opErrorLine(o.index, o.op, o.slug, e)));
511
+ // Dry-run's own header: it reports a hypothetical, so it says "would be
512
+ // applied" rather than the refusal wording batchInvalidHeader carries.
537
513
  throw new HafezError('VALIDATION_FAILED', `Batch invalid: ${invalidCount} of ${operations.length} operation${operations.length === 1 ? '' : 's'} failed validation (0 would be applied)`, lines);
538
514
  }
539
515
  const out = [`Batch valid: ${operations.length} operations would be applied`];
@@ -556,13 +532,22 @@ export async function cmdBatch(os, args, opts = {}) {
556
532
  let output = `Batch complete: ${operations.length} operations`;
557
533
  if (created.length > 0)
558
534
  output += ` (created: ${created.join(', ')})`;
559
- return output;
535
+ // Skipped ops carry the same warning line dry-run printed for them.
536
+ const lines = [output];
537
+ results.forEach((r, i) => {
538
+ if (r.status === 'skipped' && r.warning)
539
+ lines.push(`Warning: op[${i}] ${r.warning}`);
540
+ });
541
+ return lines.join('\n');
560
542
  }
543
+ const DIGEST_USAGE = 'Usage: hafez digest [--file summary.json] (or pipe JSON to stdin)';
544
+ const DIGEST_FLAGS = { file: 'string' };
561
545
  export async function cmdDigest(os, args, _opts = {}) {
562
- const file = getFlag(args, '--file');
546
+ const { flags } = parseArgs(args, DIGEST_FLAGS, DIGEST_USAGE);
547
+ const file = str(flags.file);
563
548
  const raw = file ? readPayloadFile(file) : await readStdin();
564
549
  if (!raw.trim()) {
565
- throw new Error('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --file summary.json');
550
+ throw new UsageError('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --file summary.json');
566
551
  }
567
552
  // Parse JSON
568
553
  let parsed;
@@ -570,12 +555,12 @@ export async function cmdDigest(os, args, _opts = {}) {
570
555
  parsed = JSON.parse(raw);
571
556
  }
572
557
  catch {
573
- throw new Error('Invalid JSON on stdin: ' + (raw.slice(0, 80)));
558
+ throw new HafezError('VALIDATION_FAILED', 'Invalid JSON on stdin: ' + (raw.slice(0, 80)));
574
559
  }
575
560
  const result = parseDigestInput(parsed);
576
561
  if (!result.success) {
577
562
  const fields = result.error.issues.map(i => i.path.join('.')).join(', ');
578
- throw new Error(`Invalid digest input — missing or invalid fields: ${fields}`);
563
+ throw new HafezError('VALIDATION_FAILED', `Invalid digest input — missing or invalid fields: ${fields}`);
579
564
  }
580
565
  // Warn about unknown fields
581
566
  if (result.unknownFields.length > 0) {
@@ -592,23 +577,22 @@ export async function cmdDigest(os, args, _opts = {}) {
592
577
  const ops = digest(result.data, existingSlugs);
593
578
  return JSON.stringify(ops, null, 2);
594
579
  }
595
- // --- Arg helpers ---
596
- function getFlag(args, flag) {
597
- const idx = args.indexOf(flag);
598
- if (idx === -1)
599
- return undefined;
600
- return args[idx + 1];
601
- }
602
- /** Extract positional args by skipping --flag value pairs */
603
- function positionalArgs(args) {
604
- const result = [];
605
- for (let i = 0; i < args.length; i++) {
606
- if (args[i].startsWith('--')) {
607
- i++; // skip the flag's value too
608
- }
609
- else {
610
- result.push(args[i]);
611
- }
612
- }
613
- return result;
580
+ export async function cmdExport(_os, args, opts = {}) {
581
+ const EXPORT_USAGE = 'Usage: hafez export --okf [--out <dir>]';
582
+ const { flags } = parseArgs(args, { okf: 'boolean', out: 'string' }, EXPORT_USAGE);
583
+ if (flags.okf !== true)
584
+ throw new UsageError(EXPORT_USAGE);
585
+ const outDir = typeof flags.out === 'string' ? resolve(flags.out) : resolve(process.cwd(), 'okf-export');
586
+ if (!opts.vaultPath)
587
+ throw new HafezError('VALIDATION_FAILED', 'export requires a resolved vault path (CommandOpts.vaultPath)');
588
+ const { exportOkf } = await import('../export-okf.js');
589
+ const exportReport = exportOkf(opts.vaultPath, outDir);
590
+ if (opts.json)
591
+ return JSON.stringify(exportReport, null, 2);
592
+ const lines = [`Exported ${exportReport.entities} entities, ${exportReport.knowledge} knowledge, ${exportReport.sessions} sessions to ${exportReport.outDir}`];
593
+ for (const s of exportReport.skipped)
594
+ lines.push(` skipped ${s.file}: ${s.reason}`);
595
+ if (exportReport.unresolvedLinks > 0)
596
+ lines.push(` unresolved links: ${exportReport.unresolvedLinks}`);
597
+ return lines.join('\n');
614
598
  }