hafez 1.1.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,44 +1,56 @@
1
1
  // src/cli/commands.ts
2
- import { z } from 'zod';
2
+ import { readFileSync } from 'fs';
3
+ import { parseArgs, splitList, str, UsageError } from './args.js';
3
4
  import { HafezError } from '../types.js';
4
- import { ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, } from '../contracts.js';
5
+ import { BatchOperationSchema } from '../batch-ops.js';
5
6
  import { formatBatchError } from './batch-errors.js';
6
7
  import { parseDigestInput, digest } from '../digest.js';
7
8
  import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
8
9
  function jsonOut(data) {
9
10
  return JSON.stringify(data, null, 2);
10
11
  }
12
+ const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full]';
13
+ const READ_FLAGS = { depth: 'string' };
11
14
  export async function cmdRead(os, args, opts = {}) {
12
- const slug = args[0];
15
+ const { flags, positionals } = parseArgs(args, READ_FLAGS, READ_USAGE);
16
+ const slug = positionals[0];
13
17
  if (!slug)
14
- throw new Error('Usage: hafez read <slug> [--depth frontmatter|summary|full]');
15
- const depth = (getFlag(args, '--depth') ?? 'summary');
18
+ throw new UsageError(READ_USAGE);
19
+ const depth = (str(flags.depth) ?? 'summary');
16
20
  const result = await os.read(slug, depth);
17
21
  if (opts.json)
18
22
  return jsonOut(result);
19
23
  const fm = result.frontmatter;
20
- if ('type' in fm)
24
+ if (result.kind === 'entity')
21
25
  return formatEntityHeader(fm, result.body);
22
26
  return formatKnowledgeHeader(fm, result.body);
23
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
+ };
24
35
  export async function cmdQuery(os, args, opts = {}) {
25
- const kind = getFlag(args, '--kind') ?? 'entity';
26
- const filter = (getFlag(args, '--filter') ?? 'active');
27
- const type = getFlag(args, '--type');
28
- const parent = getFlag(args, '--parent');
29
- const relatedTo = getFlag(args, '--related-to');
30
- const domain = getFlag(args, '--domain');
31
- const confidence = getFlag(args, '--confidence');
32
- const since = getFlag(args, '--since');
33
- const before = getFlag(args, '--before');
34
- const createdSince = getFlag(args, '--created-since');
35
- const createdBefore = getFlag(args, '--created-before');
36
- 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);
37
49
  const tags = tagRaw ? tagRaw.split(',').map(t => t.trim()).filter(Boolean) : undefined;
38
- const sortBy = getFlag(args, '--sort-by');
39
- const sortOrder = getFlag(args, '--sort-order');
40
- const limitRaw = getFlag(args, '--limit');
41
- 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);
42
54
  const limit = limitRaw != null ? parseInt(limitRaw, 10) : undefined;
43
55
  const offset = offsetRaw != null ? parseInt(offsetRaw, 10) : undefined;
44
56
  const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
@@ -48,8 +60,8 @@ export async function cmdQuery(os, args, opts = {}) {
48
60
  if (opts.json)
49
61
  return jsonOut(result);
50
62
  const { items: results, total } = result;
51
- const entities = results.filter((r) => 'type' in r);
52
- 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');
53
65
  const sections = [];
54
66
  if (entities.length > 0)
55
67
  sections.push('## Entities\n\n' + formatQueryTable(entities, total));
@@ -61,7 +73,7 @@ export async function cmdQuery(os, args, opts = {}) {
61
73
  // --kind knowledge: query knowledge only
62
74
  // Note: time flags only apply to entity queries.
63
75
  if (kind === 'knowledge') {
64
- const subtype = getFlag(args, '--subtype');
76
+ const subtype = str(flags.subtype);
65
77
  const kOpts = { domain: domain ?? undefined, confidence, subtype, tags, ...sortOpts };
66
78
  const result = await os.query_knowledge(kOpts);
67
79
  if (opts.json)
@@ -91,127 +103,149 @@ export async function cmdQuery(os, args, opts = {}) {
91
103
  return jsonOut(result);
92
104
  return formatQueryTable(result.items, result.total);
93
105
  }
106
+ const SEARCH_USAGE = 'Usage: hafez search <terms> [--kind entity|knowledge|all]';
107
+ const SEARCH_FLAGS = { kind: 'string' };
94
108
  export async function cmdSearch(os, args, opts = {}) {
95
- const terms = positionalArgs(args).join(' ');
109
+ const { flags, positionals } = parseArgs(args, SEARCH_FLAGS, SEARCH_USAGE);
110
+ const terms = positionals.join(' ');
96
111
  if (!terms)
97
- throw new Error('Usage: hafez search <terms> [--kind entity|knowledge|all]');
98
- const kind = (getFlag(args, '--kind') ?? 'all');
112
+ throw new UsageError(SEARCH_USAGE);
113
+ const kind = (str(flags.kind) ?? 'all');
99
114
  const results = await os.search(terms, kind);
100
115
  if (opts.json)
101
116
  return jsonOut(results);
102
117
  return formatSearchResults(results);
103
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
+ };
104
126
  export async function cmdCreate(os, args, opts = {}) {
105
- const kind = args[0];
127
+ const { flags, positionals } = parseArgs(args, CREATE_FLAGS, CREATE_USAGE);
128
+ const kind = positionals[0];
106
129
  if (!kind)
107
- throw new Error('Usage: hafez create <entity|knowledge> <name> [options]');
108
- const name = positionalArgs(args.slice(1)).join(' ');
130
+ throw new UsageError(CREATE_USAGE);
131
+ const name = positionals.slice(1).join(' ');
109
132
  if (!name)
110
- throw new Error(`Usage: hafez create ${kind} <name> [options]`);
133
+ throw new UsageError(`Usage: hafez create ${kind} <name> [options]`);
111
134
  if (kind === 'entity') {
112
- const type = getFlag(args, '--type');
135
+ const type = str(flags.type);
113
136
  if (!type)
114
- throw new Error('--type is required for entity creation');
137
+ throw new UsageError('--type is required for entity creation');
115
138
  const slug = await os.create('entity', name, {
116
139
  type,
117
- purpose: getFlag(args, '--purpose'),
118
- description: getFlag(args, '--description'),
119
- resource: getFlag(args, '--resource'),
120
- domain: getFlag(args, '--domain')?.split(',').map(d => d.trim()),
121
- parent: getFlag(args, '--parent'),
122
- related: getFlag(args, '--related')?.split(','),
123
- tags: getFlag(args, '--tags')?.split(','),
124
- brief: getFlag(args, '--brief'),
125
- 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']),
126
149
  });
127
150
  return opts.json ? jsonOut({ slug }) : slug;
128
151
  }
129
152
  if (kind === 'knowledge') {
130
153
  const slug = await os.create('knowledge', name, {
131
- subtype: getFlag(args, '--subtype'),
132
- synthesis: getFlag(args, '--synthesis') ?? getFlag(args, '--insight'),
133
- description: getFlag(args, '--description'),
134
- domain: getFlag(args, '--domain')?.split(','),
135
- related: getFlag(args, '--related')?.split(','),
136
- 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(','),
137
160
  });
138
161
  return opts.json ? jsonOut({ slug }) : slug;
139
162
  }
140
- throw new Error(`Unknown kind: ${kind}. Use entity or knowledge.`);
163
+ throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
141
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
+ };
142
178
  export async function cmdUpdate(os, args, opts = {}) {
143
- const slug = args[0];
179
+ const { flags, positionals } = parseArgs(args, UPDATE_FLAGS, UPDATE_USAGE);
180
+ const slug = positionals[0];
144
181
  if (!slug)
145
- 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
+ }
146
186
  const fields = {};
147
- const status = getFlag(args, '--status');
187
+ const status = str(flags.status);
148
188
  if (status)
149
189
  fields.status = status;
150
- // Removed flag must fail loudly, not silently drop the value: older
151
- // installed skills/scripts still pass it, and "Updated <slug>" with the
152
- // action discarded is a false success.
153
- if (args.includes('--next-action')) {
154
- throw new Error('--next-action was removed in v1.0.2. Use --add-action (and --clear-actions) instead.');
155
- }
156
- const currentState = getFlag(args, '--current-state');
190
+ const currentState = str(flags['current-state']);
157
191
  if (currentState)
158
192
  fields.current_state = currentState;
159
193
  // Brief
160
- const brief = getFlag(args, '--brief');
194
+ const brief = str(flags.brief);
161
195
  if (brief !== undefined) {
162
196
  fields.brief = brief === '' ? null : brief;
163
197
  }
164
198
  // Next Actions
165
- const addAction = getFlag(args, '--add-action');
199
+ const addAction = str(flags['add-action']);
166
200
  if (addAction)
167
201
  fields.add_action = addAction;
168
- const completeAction = getFlag(args, '--complete-action');
202
+ const completeAction = str(flags['complete-action']);
169
203
  if (completeAction)
170
204
  fields.complete_action = completeAction;
171
- const removeAction = getFlag(args, '--remove-action');
205
+ const removeAction = str(flags['remove-action']);
172
206
  if (removeAction)
173
207
  fields.remove_action = removeAction;
174
- if (args.includes('--clear-actions'))
208
+ if (flags['clear-actions'])
175
209
  fields.clear_actions = true;
176
- const log = getFlag(args, '--log');
210
+ const log = str(flags.log);
177
211
  if (log) {
178
212
  const colonIdx = log.indexOf(':');
179
213
  if (colonIdx === -1)
180
- 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');
181
215
  const type = log.slice(0, colonIdx).trim();
182
216
  const summary = log.slice(colonIdx + 1).trim();
183
- const agent = getFlag(args, '--agent') ?? 'claude';
217
+ const agent = str(flags.agent) ?? 'claude';
184
218
  fields.session_log = { type, summary, agent };
185
219
  }
186
220
  // Shared metadata (entity + knowledge)
187
- const description = getFlag(args, '--description');
221
+ const description = str(flags.description);
188
222
  if (description !== undefined)
189
223
  fields.description = description;
190
- const resource = getFlag(args, '--resource');
224
+ const resource = str(flags.resource);
191
225
  if (resource !== undefined)
192
226
  fields.resource = resource;
193
- const domainRaw = getFlag(args, '--domain');
194
- if (domainRaw)
195
- fields.domain = domainRaw.split(',').map((d) => d.trim());
196
- const conf = getFlag(args, '--confidence');
227
+ const domain = splitList(flags.domain);
228
+ if (domain)
229
+ fields.domain = domain;
230
+ const conf = str(flags.confidence);
197
231
  if (conf)
198
232
  fields.confidence = conf;
199
- const tagsRaw = getFlag(args, '--tags');
200
- if (tagsRaw)
201
- fields.tags = tagsRaw.split(',').map((t) => t.trim());
202
- const relatedRaw = getFlag(args, '--related');
203
- if (relatedRaw)
204
- fields.related = relatedRaw.split(',').map((r) => r.trim());
205
- 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);
206
240
  if (insight)
207
241
  fields.synthesis = insight;
208
- const synthesis = getFlag(args, '--synthesis');
242
+ const synthesis = str(flags.synthesis);
209
243
  if (synthesis)
210
244
  fields.synthesis = synthesis;
211
- const addEvidence = getFlag(args, '--add-evidence');
245
+ const addEvidence = str(flags['add-evidence']);
212
246
  if (addEvidence)
213
247
  fields.add_evidence = addEvidence;
214
- const addSource = getFlag(args, '--add-source');
248
+ const addSource = str(flags['add-source']);
215
249
  if (addSource)
216
250
  fields.add_source = addSource;
217
251
  const result = await os.update(slug, fields);
@@ -223,23 +257,17 @@ export async function cmdUpdate(os, args, opts = {}) {
223
257
  }
224
258
  return lines.join('\n');
225
259
  }
260
+ const LINK_FLAGS = { relation: 'string' };
226
261
  function parseLinkArgs(args, verb) {
227
- const relation = getFlag(args, '--relation');
228
- // After getFlag the relation value is still at its original positional slot
229
- // (getFlag does NOT splice), so strip it manually before reading positionals.
230
- const positionals = args.filter((a, i) => {
231
- if (a === '--relation')
232
- return false;
233
- if (i > 0 && args[i - 1] === '--relation')
234
- return false;
235
- return !a.startsWith('--');
236
- });
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);
237
265
  const [slug, target] = positionals;
238
266
  if (!slug || !target || !relation) {
239
- throw new Error(`Usage: hafez ${verb} <slug> <target> --relation <parent|related>`);
267
+ throw new UsageError(usage);
240
268
  }
241
269
  if (!['parent', 'related'].includes(relation)) {
242
- throw new Error(`Invalid relation: ${relation}. Must be parent or related.`);
270
+ throw new UsageError(`Invalid relation: ${relation}. Must be parent or related.`);
243
271
  }
244
272
  return { slug, target, relation };
245
273
  }
@@ -254,22 +282,24 @@ export async function cmdUnlink(os, args, opts = {}) {
254
282
  return opts.json ? jsonOut({ slug, target, relation }) : `Unlinked ${slug} — ${target} (${relation})`;
255
283
  }
256
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' };
257
287
  export async function cmdCapture(os, args, opts = {}) {
258
- const name = positionalArgs(args).join(' ');
288
+ const { flags, positionals } = parseArgs(args, CAPTURE_FLAGS, CAPTURE_USAGE);
289
+ const name = positionals.join(' ');
259
290
  if (!name)
260
- throw new Error('Usage: hafez capture <name> [--notes "text"]');
261
- const notes = getFlag(args, '--notes');
291
+ throw new UsageError(CAPTURE_USAGE);
292
+ const notes = str(flags.notes);
262
293
  const slug = await os.capture(name, notes);
263
294
  return opts.json ? jsonOut({ slug }) : slug;
264
295
  }
296
+ const PROMOTE_USAGE = 'Usage: hafez promote <slug> <entity|project|knowledge>';
265
297
  export async function cmdPromote(os, args, opts = {}) {
266
- const slug = args[0];
267
- const target = args[1];
298
+ const { positionals } = parseArgs(args, {}, PROMOTE_USAGE);
299
+ const [slug, target] = positionals;
268
300
  if (!slug || !target)
269
- throw new Error('Usage: hafez promote <slug> <entity|project|knowledge>');
270
- if (!['entity', 'project', 'knowledge'].includes(target)) {
271
- throw new Error(`Invalid target: ${target}. Must be entity, project, or knowledge.`);
272
- }
301
+ throw new UsageError(PROMOTE_USAGE);
302
+ // Target validity is core's rule (contract canPromoteTo) — no CLI re-check.
273
303
  await os.promote(slug, target);
274
304
  if (opts.json)
275
305
  return jsonOut({ slug, target });
@@ -303,10 +333,13 @@ export async function cmdStats(os, _args, opts = {}) {
303
333
  return jsonOut(stats);
304
334
  return formatStats(stats);
305
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' };
306
338
  export async function cmdChangelog(os, args, opts = {}) {
307
- const since = getFlag(args, '--since');
339
+ const { flags } = parseArgs(args, CHANGELOG_FLAGS, CHANGELOG_USAGE);
340
+ const since = str(flags.since);
308
341
  if (!since)
309
- 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);
310
343
  const entries = await os.changelog(since);
311
344
  if (opts.json)
312
345
  return jsonOut(entries);
@@ -314,126 +347,11 @@ export async function cmdChangelog(os, args, opts = {}) {
314
347
  }
315
348
  // --- Batch ---
316
349
  // All enums are built from contracts.ts tuples so help-renderer, file validators,
317
- // and CLI-input validator share a single source of truth. cli-schema-parity.test.ts
318
- // 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).
319
352
  // Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
320
353
  // (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
321
354
  // hints and for catching typos like `{"kind": "parent"}` on a link op.
322
- export const SessionLogSchema = z.object({
323
- type: z.enum([...SESSION_LOG_TYPES]),
324
- summary: z.string(),
325
- body: z.string().optional(),
326
- agent: z.string(),
327
- }).strict();
328
- export const UpdateFieldsSchema = z.object({
329
- status: z.enum([...ENTITY_STATUSES]).optional(),
330
- current_state: z.string().optional(),
331
- session_log: SessionLogSchema.optional(),
332
- brief: z.string().nullable().optional(),
333
- add_action: z.string().optional(),
334
- add_actions: z.array(z.string()).optional(),
335
- complete_action: z.string().optional(),
336
- remove_action: z.string().optional(),
337
- clear_actions: z.boolean().optional(),
338
- // Knowledge metadata
339
- description: z.string().nullable().optional(),
340
- resource: z.string().nullable().optional(),
341
- domain: z.array(z.string()).optional(),
342
- confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
343
- tags: z.array(z.string()).optional(),
344
- related: z.array(z.string()).optional(),
345
- synthesis: z.string().optional(),
346
- insight: z.string().optional(), // alias for synthesis (normalized in parseBatchInput)
347
- add_evidence: z.string().optional(),
348
- add_source: z.string().optional(),
349
- }).strict();
350
- const CreateEntityFieldsSchema = z.object({
351
- type: z.enum([...ENTITY_TYPES]),
352
- purpose: z.string().optional(),
353
- description: z.string().optional(),
354
- resource: z.string().optional(),
355
- domain: z.array(z.string()).optional(),
356
- parent: z.string().optional(),
357
- related: z.array(z.string()).optional(),
358
- tags: z.array(z.string()).optional(),
359
- brief: z.string().optional(),
360
- add_action: z.string().optional(),
361
- }).strict();
362
- const CreateKnowledgeFieldsSchema = z.object({
363
- subtype: z.enum([...VALID_SUBTYPES]).optional(),
364
- synthesis: z.string().optional(),
365
- insight: z.string().optional(),
366
- description: z.string().optional(),
367
- domain: z.array(z.string()).optional(),
368
- related: z.array(z.string()).optional(),
369
- tags: z.array(z.string()).optional(),
370
- 'session-date': z.string().optional(),
371
- }).strict();
372
- const CreateSessionFieldsSchema = z.object({
373
- synthesis: z.string().optional(),
374
- insight: z.string().optional(),
375
- related: z.array(z.string()).optional(),
376
- 'session-date': z.string().optional(),
377
- }).strict();
378
- const CreateEntityBranch = z.object({
379
- op: z.literal('create'),
380
- kind: z.literal('entity'),
381
- name: z.string(),
382
- fields: CreateEntityFieldsSchema,
383
- }).strict();
384
- const CreateKnowledgeBranch = z.object({
385
- op: z.literal('create'),
386
- kind: z.literal('knowledge'),
387
- name: z.string(),
388
- fields: CreateKnowledgeFieldsSchema.optional(),
389
- }).strict();
390
- const CreateSessionBranch = z.object({
391
- op: z.literal('create'),
392
- kind: z.literal('session'),
393
- name: z.string(),
394
- fields: CreateSessionFieldsSchema.optional(),
395
- }).strict();
396
- // Nested discriminated union: outer on `op`, inner on `kind` for create branch.
397
- // A flat `discriminatedUnion('op', [...])` cannot work because three branches share
398
- // op=create — Zod requires unique discriminator values per branch and throws at
399
- // schema construction time if violated.
400
- const CreateBranch = z.discriminatedUnion('kind', [
401
- CreateEntityBranch,
402
- CreateKnowledgeBranch,
403
- CreateSessionBranch,
404
- ]);
405
- export const BatchOperationSchema = z.discriminatedUnion('op', [
406
- z.object({
407
- op: z.literal('update'),
408
- slug: z.string(),
409
- fields: UpdateFieldsSchema,
410
- }).strict(),
411
- // create is a nested discriminatedUnion on `kind` — see CreateBranch above.
412
- // discriminatedUnion accepts a ZodDiscriminatedUnion as a branch via option-flattening.
413
- CreateBranch,
414
- z.object({
415
- op: z.literal('capture'),
416
- name: z.string(),
417
- notes: z.string().optional(),
418
- }).strict(),
419
- z.object({
420
- op: z.literal('link'),
421
- slug: z.string(),
422
- target: z.string(),
423
- relation: z.enum(['parent', 'related']),
424
- }).strict(),
425
- z.object({
426
- op: z.literal('unlink'),
427
- slug: z.string(),
428
- target: z.string(),
429
- relation: z.enum(['parent', 'related']),
430
- }).strict(),
431
- z.object({
432
- op: z.literal('promote'),
433
- slug: z.string(),
434
- target: z.enum(['entity', 'project', 'knowledge']),
435
- }).strict(),
436
- ]);
437
355
  export function parseBatchInput(json) {
438
356
  let parsed;
439
357
  try {
@@ -484,11 +402,28 @@ export function parseBatchInput(json) {
484
402
  operations.push(op);
485
403
  }
486
404
  if (failureBlocks.length > 0) {
487
- 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)`;
488
406
  throw new HafezError('VALIDATION_FAILED', header, failureBlocks);
489
407
  }
490
408
  return operations;
491
409
  }
410
+ // --file payload reader. Exists because Windows PowerShell 5.1 cannot pipe
411
+ // stdin to native executables — the documented pipe idiom fails there, so a
412
+ // file is the portable session-end path. PowerShell's own redirection writes
413
+ // UTF-16LE (and other tools write BOM'd UTF-8), so decode both rather than
414
+ // trading the pipe footgun for an encoding one.
415
+ function readPayloadFile(path) {
416
+ let buf;
417
+ try {
418
+ buf = readFileSync(path);
419
+ }
420
+ catch {
421
+ throw new HafezError('VALIDATION_FAILED', `Cannot read --file: ${path}`);
422
+ }
423
+ if (buf[0] === 0xff && buf[1] === 0xfe)
424
+ return buf.toString('utf16le').replace(/^\uFEFF/, '');
425
+ return buf.toString('utf-8').replace(/^\uFEFF/, '');
426
+ }
492
427
  function readStdin() {
493
428
  return new Promise((resolve, reject) => {
494
429
  let data = '';
@@ -498,17 +433,40 @@ function readStdin() {
498
433
  process.stdin.on('error', reject);
499
434
  });
500
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' };
501
438
  export async function cmdBatch(os, args, opts = {}) {
502
- const dryRun = args.includes('--dry-run');
503
- const input = await readStdin();
439
+ const { flags } = parseArgs(args, BATCH_FLAGS, BATCH_USAGE);
440
+ const dryRun = flags['dry-run'] === true;
441
+ const file = str(flags.file);
442
+ const input = file ? readPayloadFile(file) : await readStdin();
504
443
  if (!input.trim()) {
505
- throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin: echo \'[...]\' | hafez batch');
444
+ throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin (echo \'[...]\' | hafez batch) or pass --file payload.json');
506
445
  }
507
446
  const operations = parseBatchInput(input);
508
447
  if (dryRun) {
448
+ // Same validate phase as apply — dry-run can never green-light a payload
449
+ // apply rejects. Reports derived slugs so agents can predict create slugs.
450
+ const report = await os.validateBatch(operations);
509
451
  if (opts.json)
510
- return jsonOut({ valid: true, operations: operations.length });
511
- return `Batch valid: ${operations.length} operations would be applied`;
452
+ return jsonOut(report);
453
+ if (!report.valid) {
454
+ 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}`));
456
+ throw new HafezError('VALIDATION_FAILED', `Batch invalid: ${invalidCount} of ${operations.length} operation${operations.length === 1 ? '' : 's'} failed validation (0 would be applied)`, lines);
457
+ }
458
+ const out = [`Batch valid: ${operations.length} operations would be applied`];
459
+ const creates = report.operations.filter(o => o.created);
460
+ if (creates.length > 0) {
461
+ out.push('Derived slugs:');
462
+ for (const c of creates)
463
+ out.push(` op[${c.index}] ${c.op} → ${c.slug}`);
464
+ }
465
+ for (const o of report.operations) {
466
+ if (o.warning)
467
+ out.push(`Warning: op[${o.index}] ${o.warning}`);
468
+ }
469
+ return out.join('\n');
512
470
  }
513
471
  const results = await os.batch(operations);
514
472
  if (opts.json)
@@ -519,11 +477,14 @@ export async function cmdBatch(os, args, opts = {}) {
519
477
  output += ` (created: ${created.join(', ')})`;
520
478
  return output;
521
479
  }
522
- export async function cmdDigest(os, _args, _opts = {}) {
523
- // Read stdin — reuses the existing readStdin() defined in this file
524
- const raw = await readStdin();
480
+ const DIGEST_USAGE = 'Usage: hafez digest [--file summary.json] (or pipe JSON to stdin)';
481
+ const DIGEST_FLAGS = { file: 'string' };
482
+ export async function cmdDigest(os, args, _opts = {}) {
483
+ const { flags } = parseArgs(args, DIGEST_FLAGS, DIGEST_USAGE);
484
+ const file = str(flags.file);
485
+ const raw = file ? readPayloadFile(file) : await readStdin();
525
486
  if (!raw.trim()) {
526
- throw new Error('No input received. Pipe JSON object to stdin: echo \'{"entities_touched":...}\' | hafez digest');
487
+ throw new UsageError('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --file summary.json');
527
488
  }
528
489
  // Parse JSON
529
490
  let parsed;
@@ -531,12 +492,12 @@ export async function cmdDigest(os, _args, _opts = {}) {
531
492
  parsed = JSON.parse(raw);
532
493
  }
533
494
  catch {
534
- 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)));
535
496
  }
536
497
  const result = parseDigestInput(parsed);
537
498
  if (!result.success) {
538
499
  const fields = result.error.issues.map(i => i.path.join('.')).join(', ');
539
- 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}`);
540
501
  }
541
502
  // Warn about unknown fields
542
503
  if (result.unknownFields.length > 0) {
@@ -553,23 +514,3 @@ export async function cmdDigest(os, _args, _opts = {}) {
553
514
  const ops = digest(result.data, existingSlugs);
554
515
  return JSON.stringify(ops, null, 2);
555
516
  }
556
- // --- Arg helpers ---
557
- function getFlag(args, flag) {
558
- const idx = args.indexOf(flag);
559
- if (idx === -1)
560
- return undefined;
561
- return args[idx + 1];
562
- }
563
- /** Extract positional args by skipping --flag value pairs */
564
- function positionalArgs(args) {
565
- const result = [];
566
- for (let i = 0; i < args.length; i++) {
567
- if (args[i].startsWith('--')) {
568
- i++; // skip the flag's value too
569
- }
570
- else {
571
- result.push(args[i]);
572
- }
573
- }
574
- return result;
575
- }
@@ -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;