hafez 1.3.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.
@@ -1,8 +1,9 @@
1
1
  // src/cli/commands.ts
2
2
  import { readFileSync } from 'fs';
3
+ import { resolve } from 'path';
3
4
  import { parseArgs, splitList, str, UsageError } from './args.js';
4
5
  import { HafezError } from '../types.js';
5
- import { BatchOperationSchema } from '../batch-ops.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';
@@ -25,22 +26,75 @@ export async function cmdRead(os, args, opts = {}) {
25
26
  return formatEntityHeader(fm, result.body);
26
27
  return formatKnowledgeHeader(fm, result.body);
27
28
  }
28
- 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]';
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.`;
29
37
  const QUERY_FLAGS = {
30
38
  kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
31
39
  domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
32
40
  since: 'string', before: 'string', 'created-since': 'string', 'created-before': 'string',
33
41
  'sort-by': 'string', 'sort-order': 'string', limit: 'string', offset: 'string',
34
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
+ }
35
84
  export async function cmdQuery(os, args, opts = {}) {
36
85
  const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
37
- const kind = str(flags.kind) ?? 'entity';
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);
38
92
  const filter = (str(flags.filter) ?? 'active');
39
93
  const type = str(flags.type);
40
94
  const parent = str(flags.parent);
41
- const relatedTo = str(flags['related-to']);
42
95
  const domain = str(flags.domain);
43
96
  const confidence = str(flags.confidence);
97
+ const subtype = str(flags.subtype);
44
98
  const since = str(flags.since);
45
99
  const before = str(flags.before);
46
100
  const createdSince = str(flags['created-since']);
@@ -49,11 +103,10 @@ export async function cmdQuery(os, args, opts = {}) {
49
103
  const tags = tagRaw ? tagRaw.split(',').map(t => t.trim()).filter(Boolean) : undefined;
50
104
  const sortBy = str(flags['sort-by']);
51
105
  const sortOrder = str(flags['sort-order']);
52
- const limitRaw = str(flags.limit);
53
- const offsetRaw = str(flags.offset);
54
- const limit = limitRaw != null ? parseInt(limitRaw, 10) : undefined;
55
- const offset = offsetRaw != null ? parseInt(offsetRaw, 10) : undefined;
106
+ const limit = intFlag(str(flags.limit), 'limit');
107
+ const offset = intFlag(str(flags.offset), 'offset');
56
108
  const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
109
+ const timeOpts = { since, before, createdSince, createdBefore };
57
110
  // --related-to: always returns both entities and knowledge
58
111
  if (relatedTo) {
59
112
  const result = await os.related_to(relatedTo);
@@ -69,20 +122,19 @@ export async function cmdQuery(os, args, opts = {}) {
69
122
  sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge, total));
70
123
  return sections.length > 0 ? sections.join('\n\n') : 'No results.';
71
124
  }
72
- const queryOpts = { filter, type, parent, domain, since, before, createdSince, createdBefore, tags, ...sortOpts };
73
- // --kind knowledge: query knowledge only
74
- // Note: time flags only apply to entity queries.
75
- if (kind === 'knowledge') {
76
- const subtype = str(flags.subtype);
77
- 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 };
78
130
  const result = await os.query_knowledge(kOpts);
79
131
  if (opts.json)
80
132
  return jsonOut(result);
81
133
  return formatKnowledgeTable(result.items, result.total);
82
134
  }
83
135
  // --kind all: query both entities and knowledge via unified query
84
- if (kind === 'all') {
85
- const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence });
136
+ if (branch === 'all') {
137
+ const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence, subtype });
86
138
  if (opts.json)
87
139
  return jsonOut(result);
88
140
  const { items, total } = result;
@@ -116,13 +168,38 @@ export async function cmdSearch(os, args, opts = {}) {
116
168
  return jsonOut(results);
117
169
  return formatSearchResults(results);
118
170
  }
119
- const CREATE_USAGE = 'Usage: hafez create <entity|knowledge> <name> [options]';
120
- const CREATE_FLAGS = {
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 = {
121
176
  type: 'string', purpose: 'string', description: 'string', resource: 'string',
122
177
  domain: 'string', parent: 'string', related: 'string', tags: 'string',
123
178
  brief: 'string', 'add-action': 'string',
124
179
  subtype: 'string', synthesis: 'string', insight: 'string',
180
+ // knowledge, plan subtype only — insights have no lifecycle
181
+ status: 'string',
125
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
+ }
126
203
  export async function cmdCreate(os, args, opts = {}) {
127
204
  const { flags, positionals } = parseArgs(args, CREATE_FLAGS, CREATE_USAGE);
128
205
  const kind = positionals[0];
@@ -131,50 +208,84 @@ export async function cmdCreate(os, args, opts = {}) {
131
208
  const name = positionals.slice(1).join(' ');
132
209
  if (!name)
133
210
  throw new UsageError(`Usage: hafez create ${kind} <name> [options]`);
211
+ const fields = createFields(flags);
134
212
  if (kind === 'entity') {
135
- const type = str(flags.type);
136
- if (!type)
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)
137
216
  throw new UsageError('--type is required for entity creation');
138
- const slug = await os.create('entity', name, {
139
- type,
140
- purpose: str(flags.purpose),
141
- description: str(flags.description),
142
- resource: str(flags.resource),
143
- domain: splitList(flags.domain),
144
- parent: str(flags.parent),
145
- related: str(flags.related)?.split(','),
146
- tags: str(flags.tags)?.split(','),
147
- brief: str(flags.brief),
148
- add_action: str(flags['add-action']),
149
- });
217
+ const slug = await os.create('entity', name, fields);
150
218
  return opts.json ? jsonOut({ slug }) : slug;
151
219
  }
152
220
  if (kind === 'knowledge') {
153
- const slug = await os.create('knowledge', name, {
154
- subtype: str(flags.subtype),
155
- synthesis: str(flags.synthesis) ?? str(flags.insight),
156
- description: str(flags.description),
157
- domain: str(flags.domain)?.split(','),
158
- related: str(flags.related)?.split(','),
159
- tags: str(flags.tags)?.split(','),
160
- });
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);
161
226
  return opts.json ? jsonOut({ slug }) : slug;
162
227
  }
163
228
  throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
164
229
  }
165
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"]';
166
- const UPDATE_FLAGS = {
231
+ /** Exported so tests can assert the table covers the update schema. */
232
+ export const UPDATE_FLAGS = {
167
233
  status: 'string', 'current-state': 'string', brief: 'string',
168
234
  'add-action': 'string', 'complete-action': 'string', 'remove-action': 'string',
169
- 'clear-actions': 'boolean', log: 'string', agent: 'string',
235
+ 'clear-actions': 'boolean',
170
236
  description: 'string', resource: 'string', domain: 'string', confidence: 'string',
171
237
  tags: 'string', related: 'string', insight: 'string', synthesis: 'string',
172
238
  'add-evidence': 'string', 'add-source': 'string',
239
+ // Composed, not mapped: --log "type: summary" plus --agent build session_log.
240
+ log: 'string', agent: 'string',
173
241
  // Removed flag, declared so it fails with guidance instead of a generic
174
242
  // unknown-flag error: older installed skills/scripts still pass it, and
175
243
  // "Updated <slug>" with the action discarded is a false success.
176
244
  'next-action': 'string',
177
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) {
270
+ const fields = {};
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;
286
+ }
287
+ return fields;
288
+ }
178
289
  export async function cmdUpdate(os, args, opts = {}) {
179
290
  const { flags, positionals } = parseArgs(args, UPDATE_FLAGS, UPDATE_USAGE);
180
291
  const slug = positionals[0];
@@ -183,71 +294,22 @@ export async function cmdUpdate(os, args, opts = {}) {
183
294
  if (flags['next-action'] !== undefined) {
184
295
  throw new UsageError('--next-action was removed in v1.0.2. Use --add-action (and --clear-actions) instead.');
185
296
  }
186
- const fields = {};
187
- const status = str(flags.status);
188
- if (status)
189
- fields.status = status;
190
- const currentState = str(flags['current-state']);
191
- if (currentState)
192
- fields.current_state = currentState;
193
- // Brief
194
- const brief = str(flags.brief);
195
- if (brief !== undefined) {
196
- fields.brief = brief === '' ? null : brief;
197
- }
198
- // Next Actions
199
- const addAction = str(flags['add-action']);
200
- if (addAction)
201
- fields.add_action = addAction;
202
- const completeAction = str(flags['complete-action']);
203
- if (completeAction)
204
- fields.complete_action = completeAction;
205
- const removeAction = str(flags['remove-action']);
206
- if (removeAction)
207
- fields.remove_action = removeAction;
208
- if (flags['clear-actions'])
209
- fields.clear_actions = true;
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.
210
302
  const log = str(flags.log);
211
- if (log) {
303
+ if (log !== undefined) {
212
304
  const colonIdx = log.indexOf(':');
213
305
  if (colonIdx === -1)
214
306
  throw new UsageError('--log format: "type: summary". Types: progress, decision, blocker, research');
215
- const type = log.slice(0, colonIdx).trim();
216
- const summary = log.slice(colonIdx + 1).trim();
217
- const agent = str(flags.agent) ?? 'claude';
218
- fields.session_log = { type, summary, agent };
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
+ };
219
312
  }
220
- // Shared metadata (entity + knowledge)
221
- const description = str(flags.description);
222
- if (description !== undefined)
223
- fields.description = description;
224
- const resource = str(flags.resource);
225
- if (resource !== undefined)
226
- fields.resource = resource;
227
- const domain = splitList(flags.domain);
228
- if (domain)
229
- fields.domain = domain;
230
- const conf = str(flags.confidence);
231
- if (conf)
232
- fields.confidence = conf;
233
- const tags = splitList(flags.tags);
234
- if (tags)
235
- fields.tags = tags;
236
- const related = splitList(flags.related);
237
- if (related)
238
- fields.related = related;
239
- const insight = str(flags.insight);
240
- if (insight)
241
- fields.synthesis = insight;
242
- const synthesis = str(flags.synthesis);
243
- if (synthesis)
244
- fields.synthesis = synthesis;
245
- const addEvidence = str(flags['add-evidence']);
246
- if (addEvidence)
247
- fields.add_evidence = addEvidence;
248
- const addSource = str(flags['add-source']);
249
- if (addSource)
250
- fields.add_source = addSource;
251
313
  const result = await os.update(slug, fields);
252
314
  if (opts.json)
253
315
  return jsonOut({ slug, ...result });
@@ -391,19 +453,12 @@ export function parseBatchInput(json) {
391
453
  continue;
392
454
  }
393
455
  const op = result.data;
394
- // Alias: insight → synthesis for create knowledge/session and update ops.
395
- // Runs only on successfully-parsed ops, preserving prior semantics.
396
- if (op.fields) {
397
- if (op.fields.insight && !op.fields.synthesis) {
398
- op.fields.synthesis = op.fields.insight;
399
- delete op.fields.insight;
400
- }
401
- }
402
- 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));
403
459
  }
404
460
  if (failureBlocks.length > 0) {
405
- const header = `Batch failed validation: ${failureBlocks.length} invalid operation${failureBlocks.length === 1 ? '' : 's'} (0 of ${parsed.length} applied)`;
406
- throw new HafezError('VALIDATION_FAILED', header, failureBlocks);
461
+ throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(failureBlocks.length, parsed.length), failureBlocks);
407
462
  }
408
463
  return operations;
409
464
  }
@@ -452,7 +507,9 @@ export async function cmdBatch(os, args, opts = {}) {
452
507
  return jsonOut(report);
453
508
  if (!report.valid) {
454
509
  const invalidCount = report.operations.filter(o => o.errors.length > 0).length;
455
- 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.
456
513
  throw new HafezError('VALIDATION_FAILED', `Batch invalid: ${invalidCount} of ${operations.length} operation${operations.length === 1 ? '' : 's'} failed validation (0 would be applied)`, lines);
457
514
  }
458
515
  const out = [`Batch valid: ${operations.length} operations would be applied`];
@@ -475,7 +532,13 @@ export async function cmdBatch(os, args, opts = {}) {
475
532
  let output = `Batch complete: ${operations.length} operations`;
476
533
  if (created.length > 0)
477
534
  output += ` (created: ${created.join(', ')})`;
478
- 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');
479
542
  }
480
543
  const DIGEST_USAGE = 'Usage: hafez digest [--file summary.json] (or pipe JSON to stdin)';
481
544
  const DIGEST_FLAGS = { file: 'string' };
@@ -514,3 +577,22 @@ export async function cmdDigest(os, args, _opts = {}) {
514
577
  const ops = digest(result.data, existingSlugs);
515
578
  return JSON.stringify(ops, null, 2);
516
579
  }
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');
598
+ }
@@ -144,6 +144,29 @@ function renderDigestSection() {
144
144
  lines.push('<!-- /SECTION:digest -->');
145
145
  return lines.join('\n');
146
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
+ `;
147
170
  const FOOTER = `## Lookup pointers
148
171
 
149
172
  - \`hafez schema\` — list all ops
@@ -181,6 +204,7 @@ export function renderAgentHelp() {
181
204
  '',
182
205
  EXIT_CODES_SECTION,
183
206
  '',
207
+ UPGRADE_SECTION,
184
208
  FOOTER,
185
209
  ].join('\n');
186
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 {};