hafez 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,45 +1,56 @@
1
1
  // src/cli/commands.ts
2
- import { z } from 'zod';
3
2
  import { readFileSync } from 'fs';
3
+ import { parseArgs, splitList, str, UsageError } from './args.js';
4
4
  import { HafezError } from '../types.js';
5
- import { ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, } from '../contracts.js';
5
+ import { BatchOperationSchema } from '../batch-ops.js';
6
6
  import { formatBatchError } from './batch-errors.js';
7
7
  import { parseDigestInput, digest } from '../digest.js';
8
8
  import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
9
9
  function jsonOut(data) {
10
10
  return JSON.stringify(data, null, 2);
11
11
  }
12
+ const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full]';
13
+ const READ_FLAGS = { depth: 'string' };
12
14
  export async function cmdRead(os, args, opts = {}) {
13
- const slug = args[0];
15
+ const { flags, positionals } = parseArgs(args, READ_FLAGS, READ_USAGE);
16
+ const slug = positionals[0];
14
17
  if (!slug)
15
- throw new Error('Usage: hafez read <slug> [--depth frontmatter|summary|full]');
16
- const depth = (getFlag(args, '--depth') ?? 'summary');
18
+ throw new UsageError(READ_USAGE);
19
+ const depth = (str(flags.depth) ?? 'summary');
17
20
  const result = await os.read(slug, depth);
18
21
  if (opts.json)
19
22
  return jsonOut(result);
20
23
  const fm = result.frontmatter;
21
- if ('type' in fm)
24
+ if (result.kind === 'entity')
22
25
  return formatEntityHeader(fm, result.body);
23
26
  return formatKnowledgeHeader(fm, result.body);
24
27
  }
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_FLAGS = {
30
+ kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
31
+ domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
32
+ since: 'string', before: 'string', 'created-since': 'string', 'created-before': 'string',
33
+ 'sort-by': 'string', 'sort-order': 'string', limit: 'string', offset: 'string',
34
+ };
25
35
  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');
36
+ const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
37
+ const kind = str(flags.kind) ?? 'entity';
38
+ const filter = (str(flags.filter) ?? 'active');
39
+ const type = str(flags.type);
40
+ const parent = str(flags.parent);
41
+ const relatedTo = str(flags['related-to']);
42
+ const domain = str(flags.domain);
43
+ const confidence = str(flags.confidence);
44
+ const since = str(flags.since);
45
+ const before = str(flags.before);
46
+ const createdSince = str(flags['created-since']);
47
+ const createdBefore = str(flags['created-before']);
48
+ const tagRaw = str(flags.tag);
38
49
  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');
50
+ const sortBy = str(flags['sort-by']);
51
+ const sortOrder = str(flags['sort-order']);
52
+ const limitRaw = str(flags.limit);
53
+ const offsetRaw = str(flags.offset);
43
54
  const limit = limitRaw != null ? parseInt(limitRaw, 10) : undefined;
44
55
  const offset = offsetRaw != null ? parseInt(offsetRaw, 10) : undefined;
45
56
  const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
@@ -49,8 +60,8 @@ export async function cmdQuery(os, args, opts = {}) {
49
60
  if (opts.json)
50
61
  return jsonOut(result);
51
62
  const { items: results, total } = result;
52
- const entities = results.filter((r) => 'type' in r);
53
- const knowledge = results.filter((r) => 'confidence' in r);
63
+ const entities = results.filter(r => r.kind === 'entity');
64
+ const knowledge = results.filter(r => r.kind === 'knowledge');
54
65
  const sections = [];
55
66
  if (entities.length > 0)
56
67
  sections.push('## Entities\n\n' + formatQueryTable(entities, total));
@@ -62,7 +73,7 @@ export async function cmdQuery(os, args, opts = {}) {
62
73
  // --kind knowledge: query knowledge only
63
74
  // Note: time flags only apply to entity queries.
64
75
  if (kind === 'knowledge') {
65
- const subtype = getFlag(args, '--subtype');
76
+ const subtype = str(flags.subtype);
66
77
  const kOpts = { domain: domain ?? undefined, confidence, subtype, tags, ...sortOpts };
67
78
  const result = await os.query_knowledge(kOpts);
68
79
  if (opts.json)
@@ -92,127 +103,149 @@ export async function cmdQuery(os, args, opts = {}) {
92
103
  return jsonOut(result);
93
104
  return formatQueryTable(result.items, result.total);
94
105
  }
106
+ const SEARCH_USAGE = 'Usage: hafez search <terms> [--kind entity|knowledge|all]';
107
+ const SEARCH_FLAGS = { kind: 'string' };
95
108
  export async function cmdSearch(os, args, opts = {}) {
96
- const terms = positionalArgs(args).join(' ');
109
+ const { flags, positionals } = parseArgs(args, SEARCH_FLAGS, SEARCH_USAGE);
110
+ const terms = positionals.join(' ');
97
111
  if (!terms)
98
- throw new Error('Usage: hafez search <terms> [--kind entity|knowledge|all]');
99
- const kind = (getFlag(args, '--kind') ?? 'all');
112
+ throw new UsageError(SEARCH_USAGE);
113
+ const kind = (str(flags.kind) ?? 'all');
100
114
  const results = await os.search(terms, kind);
101
115
  if (opts.json)
102
116
  return jsonOut(results);
103
117
  return formatSearchResults(results);
104
118
  }
119
+ const CREATE_USAGE = 'Usage: hafez create <entity|knowledge> <name> [options]';
120
+ const CREATE_FLAGS = {
121
+ type: 'string', purpose: 'string', description: 'string', resource: 'string',
122
+ domain: 'string', parent: 'string', related: 'string', tags: 'string',
123
+ brief: 'string', 'add-action': 'string',
124
+ subtype: 'string', synthesis: 'string', insight: 'string',
125
+ };
105
126
  export async function cmdCreate(os, args, opts = {}) {
106
- const kind = args[0];
127
+ const { flags, positionals } = parseArgs(args, CREATE_FLAGS, CREATE_USAGE);
128
+ const kind = positionals[0];
107
129
  if (!kind)
108
- throw new Error('Usage: hafez create <entity|knowledge> <name> [options]');
109
- const name = positionalArgs(args.slice(1)).join(' ');
130
+ throw new UsageError(CREATE_USAGE);
131
+ const name = positionals.slice(1).join(' ');
110
132
  if (!name)
111
- throw new Error(`Usage: hafez create ${kind} <name> [options]`);
133
+ throw new UsageError(`Usage: hafez create ${kind} <name> [options]`);
112
134
  if (kind === 'entity') {
113
- const type = getFlag(args, '--type');
135
+ const type = str(flags.type);
114
136
  if (!type)
115
- throw new Error('--type is required for entity creation');
137
+ throw new UsageError('--type is required for entity creation');
116
138
  const slug = await os.create('entity', name, {
117
139
  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'),
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']),
127
149
  });
128
150
  return opts.json ? jsonOut({ slug }) : slug;
129
151
  }
130
152
  if (kind === 'knowledge') {
131
153
  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(','),
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(','),
138
160
  });
139
161
  return opts.json ? jsonOut({ slug }) : slug;
140
162
  }
141
- throw new Error(`Unknown kind: ${kind}. Use entity or knowledge.`);
163
+ throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
142
164
  }
165
+ 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 = {
167
+ status: 'string', 'current-state': 'string', brief: 'string',
168
+ 'add-action': 'string', 'complete-action': 'string', 'remove-action': 'string',
169
+ 'clear-actions': 'boolean', log: 'string', agent: 'string',
170
+ description: 'string', resource: 'string', domain: 'string', confidence: 'string',
171
+ tags: 'string', related: 'string', insight: 'string', synthesis: 'string',
172
+ 'add-evidence': 'string', 'add-source': 'string',
173
+ // Removed flag, declared so it fails with guidance instead of a generic
174
+ // unknown-flag error: older installed skills/scripts still pass it, and
175
+ // "Updated <slug>" with the action discarded is a false success.
176
+ 'next-action': 'string',
177
+ };
143
178
  export async function cmdUpdate(os, args, opts = {}) {
144
- const slug = args[0];
179
+ const { flags, positionals } = parseArgs(args, UPDATE_FLAGS, UPDATE_USAGE);
180
+ const slug = positionals[0];
145
181
  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"]');
182
+ throw new UsageError(UPDATE_USAGE);
183
+ if (flags['next-action'] !== undefined) {
184
+ throw new UsageError('--next-action was removed in v1.0.2. Use --add-action (and --clear-actions) instead.');
185
+ }
147
186
  const fields = {};
148
- const status = getFlag(args, '--status');
187
+ const status = str(flags.status);
149
188
  if (status)
150
189
  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.');
156
- }
157
- const currentState = getFlag(args, '--current-state');
190
+ const currentState = str(flags['current-state']);
158
191
  if (currentState)
159
192
  fields.current_state = currentState;
160
193
  // Brief
161
- const brief = getFlag(args, '--brief');
194
+ const brief = str(flags.brief);
162
195
  if (brief !== undefined) {
163
196
  fields.brief = brief === '' ? null : brief;
164
197
  }
165
198
  // Next Actions
166
- const addAction = getFlag(args, '--add-action');
199
+ const addAction = str(flags['add-action']);
167
200
  if (addAction)
168
201
  fields.add_action = addAction;
169
- const completeAction = getFlag(args, '--complete-action');
202
+ const completeAction = str(flags['complete-action']);
170
203
  if (completeAction)
171
204
  fields.complete_action = completeAction;
172
- const removeAction = getFlag(args, '--remove-action');
205
+ const removeAction = str(flags['remove-action']);
173
206
  if (removeAction)
174
207
  fields.remove_action = removeAction;
175
- if (args.includes('--clear-actions'))
208
+ if (flags['clear-actions'])
176
209
  fields.clear_actions = true;
177
- const log = getFlag(args, '--log');
210
+ const log = str(flags.log);
178
211
  if (log) {
179
212
  const colonIdx = log.indexOf(':');
180
213
  if (colonIdx === -1)
181
- throw new Error('--log format: "type: summary". Types: progress, decision, blocker, research');
214
+ throw new UsageError('--log format: "type: summary". Types: progress, decision, blocker, research');
182
215
  const type = log.slice(0, colonIdx).trim();
183
216
  const summary = log.slice(colonIdx + 1).trim();
184
- const agent = getFlag(args, '--agent') ?? 'claude';
217
+ const agent = str(flags.agent) ?? 'claude';
185
218
  fields.session_log = { type, summary, agent };
186
219
  }
187
220
  // Shared metadata (entity + knowledge)
188
- const description = getFlag(args, '--description');
221
+ const description = str(flags.description);
189
222
  if (description !== undefined)
190
223
  fields.description = description;
191
- const resource = getFlag(args, '--resource');
224
+ const resource = str(flags.resource);
192
225
  if (resource !== undefined)
193
226
  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');
227
+ const domain = splitList(flags.domain);
228
+ if (domain)
229
+ fields.domain = domain;
230
+ const conf = str(flags.confidence);
198
231
  if (conf)
199
232
  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');
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);
207
240
  if (insight)
208
241
  fields.synthesis = insight;
209
- const synthesis = getFlag(args, '--synthesis');
242
+ const synthesis = str(flags.synthesis);
210
243
  if (synthesis)
211
244
  fields.synthesis = synthesis;
212
- const addEvidence = getFlag(args, '--add-evidence');
245
+ const addEvidence = str(flags['add-evidence']);
213
246
  if (addEvidence)
214
247
  fields.add_evidence = addEvidence;
215
- const addSource = getFlag(args, '--add-source');
248
+ const addSource = str(flags['add-source']);
216
249
  if (addSource)
217
250
  fields.add_source = addSource;
218
251
  const result = await os.update(slug, fields);
@@ -224,23 +257,17 @@ export async function cmdUpdate(os, args, opts = {}) {
224
257
  }
225
258
  return lines.join('\n');
226
259
  }
260
+ const LINK_FLAGS = { relation: 'string' };
227
261
  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
- });
262
+ const usage = `Usage: hafez ${verb} <slug> <target> --relation <parent|related>`;
263
+ const { flags, positionals } = parseArgs(args, LINK_FLAGS, usage);
264
+ const relation = str(flags.relation);
238
265
  const [slug, target] = positionals;
239
266
  if (!slug || !target || !relation) {
240
- throw new Error(`Usage: hafez ${verb} <slug> <target> --relation <parent|related>`);
267
+ throw new UsageError(usage);
241
268
  }
242
269
  if (!['parent', 'related'].includes(relation)) {
243
- throw new Error(`Invalid relation: ${relation}. Must be parent or related.`);
270
+ throw new UsageError(`Invalid relation: ${relation}. Must be parent or related.`);
244
271
  }
245
272
  return { slug, target, relation };
246
273
  }
@@ -255,22 +282,24 @@ export async function cmdUnlink(os, args, opts = {}) {
255
282
  return opts.json ? jsonOut({ slug, target, relation }) : `Unlinked ${slug} — ${target} (${relation})`;
256
283
  }
257
284
  /** Top-level capture command — aligns the CLI verb with the batch op name. */
285
+ const CAPTURE_USAGE = 'Usage: hafez capture <name> [--notes "text"]';
286
+ const CAPTURE_FLAGS = { notes: 'string' };
258
287
  export async function cmdCapture(os, args, opts = {}) {
259
- const name = positionalArgs(args).join(' ');
288
+ const { flags, positionals } = parseArgs(args, CAPTURE_FLAGS, CAPTURE_USAGE);
289
+ const name = positionals.join(' ');
260
290
  if (!name)
261
- throw new Error('Usage: hafez capture <name> [--notes "text"]');
262
- const notes = getFlag(args, '--notes');
291
+ throw new UsageError(CAPTURE_USAGE);
292
+ const notes = str(flags.notes);
263
293
  const slug = await os.capture(name, notes);
264
294
  return opts.json ? jsonOut({ slug }) : slug;
265
295
  }
296
+ const PROMOTE_USAGE = 'Usage: hafez promote <slug> <entity|project|knowledge>';
266
297
  export async function cmdPromote(os, args, opts = {}) {
267
- const slug = args[0];
268
- const target = args[1];
298
+ const { positionals } = parseArgs(args, {}, PROMOTE_USAGE);
299
+ const [slug, target] = positionals;
269
300
  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
- }
301
+ throw new UsageError(PROMOTE_USAGE);
302
+ // Target validity is core's rule (contract canPromoteTo) — no CLI re-check.
274
303
  await os.promote(slug, target);
275
304
  if (opts.json)
276
305
  return jsonOut({ slug, target });
@@ -304,10 +333,13 @@ export async function cmdStats(os, _args, opts = {}) {
304
333
  return jsonOut(stats);
305
334
  return formatStats(stats);
306
335
  }
336
+ const CHANGELOG_USAGE = 'Usage: hafez changelog --since <date|relative>\nExamples: --since 2026-03-20, --since 7.days.ago, --since 1.week.ago';
337
+ const CHANGELOG_FLAGS = { since: 'string' };
307
338
  export async function cmdChangelog(os, args, opts = {}) {
308
- const since = getFlag(args, '--since');
339
+ const { flags } = parseArgs(args, CHANGELOG_FLAGS, CHANGELOG_USAGE);
340
+ const since = str(flags.since);
309
341
  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');
342
+ throw new UsageError(CHANGELOG_USAGE);
311
343
  const entries = await os.changelog(since);
312
344
  if (opts.json)
313
345
  return jsonOut(entries);
@@ -315,127 +347,11 @@ export async function cmdChangelog(os, args, opts = {}) {
315
347
  }
316
348
  // --- Batch ---
317
349
  // 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.
350
+ // and CLI-input validator share a single source of truth — drift is structurally
351
+ // impossible (the Op Spec table in batch-ops.ts is the only consumer).
320
352
  // Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
321
353
  // (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
322
354
  // 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
355
  export function parseBatchInput(json) {
440
356
  let parsed;
441
357
  try {
@@ -486,7 +402,7 @@ export function parseBatchInput(json) {
486
402
  operations.push(op);
487
403
  }
488
404
  if (failureBlocks.length > 0) {
489
- const header = `Batch contains ${failureBlocks.length} invalid operation${failureBlocks.length === 1 ? '' : 's'} (0 of ${parsed.length} applied, batch aborted):`;
405
+ const header = `Batch failed validation: ${failureBlocks.length} invalid operation${failureBlocks.length === 1 ? '' : 's'} (0 of ${parsed.length} applied)`;
490
406
  throw new HafezError('VALIDATION_FAILED', header, failureBlocks);
491
407
  }
492
408
  return operations;
@@ -517,9 +433,12 @@ function readStdin() {
517
433
  process.stdin.on('error', reject);
518
434
  });
519
435
  }
436
+ const BATCH_USAGE = "Usage: hafez batch [--dry-run] [--file payload.json] (or pipe JSON to stdin)";
437
+ const BATCH_FLAGS = { 'dry-run': 'boolean', file: 'string' };
520
438
  export async function cmdBatch(os, args, opts = {}) {
521
- const dryRun = args.includes('--dry-run');
522
- const file = getFlag(args, '--file');
439
+ const { flags } = parseArgs(args, BATCH_FLAGS, BATCH_USAGE);
440
+ const dryRun = flags['dry-run'] === true;
441
+ const file = str(flags.file);
523
442
  const input = file ? readPayloadFile(file) : await readStdin();
524
443
  if (!input.trim()) {
525
444
  throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin (echo \'[...]\' | hafez batch) or pass --file payload.json');
@@ -558,11 +477,14 @@ export async function cmdBatch(os, args, opts = {}) {
558
477
  output += ` (created: ${created.join(', ')})`;
559
478
  return output;
560
479
  }
480
+ const DIGEST_USAGE = 'Usage: hafez digest [--file summary.json] (or pipe JSON to stdin)';
481
+ const DIGEST_FLAGS = { file: 'string' };
561
482
  export async function cmdDigest(os, args, _opts = {}) {
562
- const file = getFlag(args, '--file');
483
+ const { flags } = parseArgs(args, DIGEST_FLAGS, DIGEST_USAGE);
484
+ const file = str(flags.file);
563
485
  const raw = file ? readPayloadFile(file) : await readStdin();
564
486
  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');
487
+ throw new UsageError('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --file summary.json');
566
488
  }
567
489
  // Parse JSON
568
490
  let parsed;
@@ -570,12 +492,12 @@ export async function cmdDigest(os, args, _opts = {}) {
570
492
  parsed = JSON.parse(raw);
571
493
  }
572
494
  catch {
573
- throw new Error('Invalid JSON on stdin: ' + (raw.slice(0, 80)));
495
+ throw new HafezError('VALIDATION_FAILED', 'Invalid JSON on stdin: ' + (raw.slice(0, 80)));
574
496
  }
575
497
  const result = parseDigestInput(parsed);
576
498
  if (!result.success) {
577
499
  const fields = result.error.issues.map(i => i.path.join('.')).join(', ');
578
- throw new Error(`Invalid digest input — missing or invalid fields: ${fields}`);
500
+ throw new HafezError('VALIDATION_FAILED', `Invalid digest input — missing or invalid fields: ${fields}`);
579
501
  }
580
502
  // Warn about unknown fields
581
503
  if (result.unknownFields.length > 0) {
@@ -592,23 +514,3 @@ export async function cmdDigest(os, args, _opts = {}) {
592
514
  const ops = digest(result.data, existingSlugs);
593
515
  return JSON.stringify(ops, null, 2);
594
516
  }
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;
614
- }
@@ -1,6 +1 @@
1
- /**
2
- * Render the full prompt-gate output. Token budget: 1500-3000 tokens when
3
- * measured as char/3.5. The drift test in tests/cli-help-agent.test.ts
4
- * enforces the ceiling.
5
- */
6
1
  export declare function renderAgentHelp(): string;
@@ -5,12 +5,13 @@
5
5
  // session and then produce valid batch JSON on first try.
6
6
  //
7
7
  // Hand-written sections: Preamble + Hard rules (prose, free-form).
8
- // Generated sections (wrapped in <!-- SECTION:name --> markers so the drift
9
- // test can parse them): op-catalog, critical-enums, batch-examples, digest.
8
+ // Generated sections (wrapped in <!-- SECTION:name --> markers so tests can
9
+ // parse them): op-catalog, critical-enums, batch-examples, digest.
10
10
  //
11
- // If the generated sections drift from the Zod schema, the drift test fails
12
- // loudly. Do NOT enumerate enum values inside the preamble or hard rules —
13
- // those come from schema-introspect.ts.
11
+ // Generated sections derive from the Op Spec table (batch-ops.ts via
12
+ // schema-introspect.ts), so they cannot drift from the Zod schemas. Do NOT
13
+ // enumerate enum values inside the preamble or hard rules — those come from
14
+ // schema-introspect.ts.
14
15
  import { listOps, getEnumValues, getOpExample, getOpSchema } from './schema-introspect.js';
15
16
  const PREAMBLE = `# Hafez — Agent Prompt Gate
16
17
 
@@ -155,6 +156,18 @@ const FOOTER = `## Lookup pointers
155
156
  * measured as char/3.5. The drift test in tests/cli-help-agent.test.ts
156
157
  * enforces the ceiling.
157
158
  */
159
+ const EXIT_CODES_SECTION = `## Exit codes
160
+
161
+ | code | meaning |
162
+ |------|---------|
163
+ | 0 | success |
164
+ | 1 | not found / unknown error |
165
+ | 2 | validation or usage error (bad flags, bad payload, core rejected the write) |
166
+ | 3 | git push failed — the write IS committed locally |
167
+ | 4 | write succeeded but the auto-sync after it failed |
168
+ | 5 | slug already exists |
169
+ | 6 | git commit failed — nothing was written to history |
170
+ | 7 | vault locked by another hafez process — retry when it finishes |`;
158
171
  export function renderAgentHelp() {
159
172
  return [
160
173
  PREAMBLE,
@@ -166,6 +179,8 @@ export function renderAgentHelp() {
166
179
  '',
167
180
  renderDigestSection(),
168
181
  '',
182
+ EXIT_CODES_SECTION,
183
+ '',
169
184
  FOOTER,
170
185
  ].join('\n');
171
186
  }