hafez 1.4.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +249 -114
  4. package/dist/batch-ops.d.ts +95 -26
  5. package/dist/batch-ops.js +178 -18
  6. package/dist/cli/args.d.ts +43 -5
  7. package/dist/cli/args.js +69 -8
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +4 -3
  13. package/dist/cli/commands.js +300 -99
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +18 -4
  17. package/dist/cli/index.js +73 -31
  18. package/dist/cli/install.d.ts +65 -0
  19. package/dist/cli/install.js +333 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +273 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +15 -4
  26. package/dist/cli/upgrade.js +103 -44
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +17 -1
  30. package/dist/contracts.js +46 -0
  31. package/dist/db.d.ts +15 -2
  32. package/dist/db.js +69 -48
  33. package/dist/digest.d.ts +6 -4
  34. package/dist/digest.js +28 -17
  35. package/dist/document.d.ts +41 -3
  36. package/dist/document.js +218 -62
  37. package/dist/export-okf.d.ts +13 -0
  38. package/dist/export-okf.js +11 -7
  39. package/dist/harness.d.ts +58 -0
  40. package/dist/harness.js +50 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +94 -104
  43. package/dist/merge.js +7 -5
  44. package/dist/near-match.d.ts +9 -0
  45. package/dist/near-match.js +34 -0
  46. package/dist/stale.d.ts +41 -0
  47. package/dist/stale.js +43 -0
  48. package/dist/stopwords.d.ts +6 -0
  49. package/dist/stopwords.js +25 -0
  50. package/dist/surfaces/items.d.ts +366 -0
  51. package/dist/surfaces/items.js +527 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +49 -16
  55. package/dist/validate.d.ts +7 -0
  56. package/dist/validate.js +109 -0
  57. package/dist/vault.js +1 -1
  58. package/package.json +4 -3
  59. package/skill/hafez/SKILL.md +68 -0
  60. package/skill/hafez-save/SKILL.md +29 -0
  61. package/dist/cli/help-agent.d.ts +0 -1
  62. package/dist/cli/help-agent.js +0 -210
  63. package/skill/SKILL.md +0 -137
@@ -1,11 +1,18 @@
1
1
  // src/cli/commands.ts
2
2
  import { readFileSync } from 'fs';
3
3
  import { resolve } from 'path';
4
- import { parseArgs, splitList, str, UsageError } from './args.js';
4
+ import { helpIfAsked, parseArgs, splitList, str, MisuseError, RenderedError, UnknownFlagError, UsageError } from './args.js';
5
5
  import { HafezError } from '../types.js';
6
6
  import { BatchOperationSchema, normalizeAliases, opErrorLine, batchInvalidHeader } from '../batch-ops.js';
7
7
  import { formatBatchError } from './batch-errors.js';
8
8
  import { parseDigestInput, digest } from '../digest.js';
9
+ import { detectAgent } from '../harness.js';
10
+ import { getActionItems } from '../document.js';
11
+ import { resemblances } from '../near-match.js';
12
+ import { actionAge, agedActions, staleHandles, stalenessDaysOf } from '../stale.js';
13
+ import { slugify } from '../vault.js';
14
+ import { ITEMS, footerOp } from '../surfaces/items.js';
15
+ import { placeIn } from '../surfaces/surfaces.js';
9
16
  import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
10
17
  function jsonOut(data) {
11
18
  return JSON.stringify(data, null, 2);
@@ -18,13 +25,64 @@ export async function cmdRead(os, args, opts = {}) {
18
25
  if (!slug)
19
26
  throw new UsageError(READ_USAGE);
20
27
  const depth = (str(flags.depth) ?? 'summary');
21
- const result = await os.read(slug, depth);
22
- if (opts.json)
23
- return jsonOut(result);
28
+ const result = await os.read(slug, depth).catch(err => {
29
+ if (!(err instanceof HafezError) || err.code !== 'NOT_FOUND')
30
+ throw err;
31
+ const closest = err.details ?? [];
32
+ throw opts.json
33
+ ? new RenderedError('NOT_FOUND', jsonOut({ slug, closest }), 'stdout')
34
+ : new RenderedError('NOT_FOUND', ITEMS.slugMissRecovery.render({ slug, closest }), 'stderr');
35
+ });
36
+ const footer = await footerFor(os, slug, result, depth);
37
+ if (opts.json) {
38
+ const writes = footerOp(footer);
39
+ // A frontmatter-depth read carries no body, so it has no actions to list.
40
+ if (result.kind !== 'entity' || depth === 'frontmatter')
41
+ return jsonOut({ ...result, writes });
42
+ return jsonOut({ ...result, actions: agedActions(result.body, result.frontmatter), writes });
43
+ }
24
44
  const fm = result.frontmatter;
25
- if (result.kind === 'entity')
26
- return formatEntityHeader(fm, result.body);
27
- return formatKnowledgeHeader(fm, result.body);
45
+ const days = stalenessDaysOf(fm);
46
+ const staleMark = (added) => ITEMS.staleMarker.render(actionAge(added, days));
47
+ const text = result.kind === 'entity'
48
+ ? formatEntityHeader(fm, ITEMS.handles.render(result.body, staleMark), await parentLine(os, fm.parent))
49
+ : formatKnowledgeHeader(fm, result.body);
50
+ // Trailing newlines only: the Footer brings its own blank line.
51
+ return `${text.replace(/\n+$/, '')}\n${ITEMS.writeLine.render(footer)}`;
52
+ }
53
+ /** The `parent:` line's value: the slug with the parent's description. The
54
+ * one thing `read` takes from another file; a parent that cannot be read
55
+ * (a broken slug) is named by its slug alone. */
56
+ async function parentLine(os, parent) {
57
+ if (!parent)
58
+ return undefined;
59
+ const description = await os.read(parent, 'frontmatter')
60
+ .then(file => file.frontmatter.description ?? null)
61
+ .catch(() => null);
62
+ return ITEMS.parentDescription.render({ slug: parent, description });
63
+ }
64
+ /** The Handles of the Stale lines an entity holds after a write; none for a
65
+ * knowledge note. Best-effort: the write is committed, so a failed read here
66
+ * drops the reminder rather than failing the update. */
67
+ async function staleAfterWrite(os, slug) {
68
+ try {
69
+ const file = await os.read(slug, 'full');
70
+ return file.kind === 'entity' ? staleHandles(file.body, file.frontmatter) : [];
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ }
76
+ /** The Footer for a file `read` returned at `depth`. The Footer is the same
77
+ * at every depth, so its Handle comes from the whole file, not from the body
78
+ * this depth shows. */
79
+ async function footerFor(os, slug, result, depth) {
80
+ return { slug, agent: detectAgent(), ...(result.kind === 'entity'
81
+ ? {
82
+ target: { kind: 'entity', type: result.frontmatter.type },
83
+ handle: getActionItems(depth === 'full' ? result.body : (await os.read(slug, 'full')).body).find(a => a.handle)?.handle,
84
+ }
85
+ : { target: { kind: 'knowledge', subtype: result.frontmatter.subtype } }) };
28
86
  }
29
87
  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
88
 
@@ -168,37 +226,83 @@ export async function cmdSearch(os, args, opts = {}) {
168
226
  return jsonOut(results);
169
227
  return formatSearchResults(results);
170
228
  }
229
+ /** Flags whose value is a comma list, on create and update alike. */
230
+ const LIST_FLAGS = new Set(['domain', 'related', 'tags']);
231
+ /** The Write Line grammar, one mapper for create and update: every provided
232
+ * flag → its field, the flag name with dashes as underscores. A comma-list
233
+ * flag is split; a `clearable` flag maps `""` to null (its clear signal); a
234
+ * repeatable flag given once is its own field (`--add-action` → `add_action`)
235
+ * and given more than once is the array form of that field (`add_actions`),
236
+ * so each repeat appends. Which fields a target accepts is the Op Spec's
237
+ * call, not the CLI's — the strict shape does all accepting and rejecting.
238
+ * Only provided flags are included: a present-but-undefined key would trip
239
+ * that same strict shape. */
240
+ function flagFields(spec, flags, opts = {}) {
241
+ const fields = {};
242
+ for (const flag of Object.keys(spec)) {
243
+ const raw = flags[flag];
244
+ if (raw === undefined || opts.skip?.has(flag))
245
+ continue;
246
+ const field = flag.replace(/-/g, '_');
247
+ if (Array.isArray(raw)) {
248
+ if (raw.length === 1)
249
+ fields[field] = raw[0];
250
+ else
251
+ fields[`${field}s`] = raw;
252
+ }
253
+ else if (typeof raw === 'boolean')
254
+ fields[field] = raw;
255
+ else if (LIST_FLAGS.has(flag))
256
+ fields[field] = splitList(raw);
257
+ else
258
+ fields[field] = raw === '' && opts.clearable?.has(flag) ? null : raw;
259
+ }
260
+ return fields;
261
+ }
171
262
  // --status is knowledge/plan only, and --purpose needs --type project: a field
172
263
  // 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)] …';
264
+ 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 (repeats)] …\n knowledge: [--subtype <insight|plan>] [--status <active|done> (plan only)] [--synthesis text (insight only)] [--confidence <observation|pattern|principle>] [--new (write despite a near-match)] …';
174
265
  /** Exported so tests can assert the flag × kind matrix is exhaustive. */
175
266
  export const CREATE_FLAGS = {
176
267
  type: 'string', purpose: 'string', description: 'string', resource: 'string',
177
268
  domain: 'string', parent: 'string', related: 'string', tags: 'string',
178
- brief: 'string', 'add-action': 'string',
179
- subtype: 'string', synthesis: 'string', insight: 'string',
269
+ brief: 'string', 'add-action': 'repeat',
270
+ subtype: 'string', synthesis: 'string', insight: 'string', confidence: 'string',
180
271
  // knowledge, plan subtype only — insights have no lifecycle
181
272
  status: 'string',
273
+ // knowledge only: write despite a Near-match. Not a field of any shape.
274
+ new: 'boolean',
182
275
  };
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;
276
+ /** Every knowledge note, with its subtype, for the Near-match check. */
277
+ async function knowledgeNotes(os) {
278
+ const plans = new Set((await os.query_knowledge({ subtype: 'plan' })).items.map(n => n.slug));
279
+ return (await os.query_knowledge()).items.map(n => ({
280
+ slug: n.slug, name: n.name, description: n.description, subtype: plans.has(n.slug) ? 'plan' : 'insight',
281
+ }));
282
+ }
283
+ /** The parsed flags as `[flag, value?]` pairs, in the order first given,
284
+ * for echoing a create line back. */
285
+ function echoFlags(flags) {
286
+ return Object.entries(flags).flatMap(([flag, value]) => value === true ? [[flag]]
287
+ : Array.isArray(value) ? value.map(v => [flag, v])
288
+ : typeof value === 'string' ? [[flag, value]] : []);
289
+ }
290
+ /** `batch`'s Near-match warnings (#60): a save never fails on one, so they
291
+ * only warn. Each create is judged against the vault plus the batch's
292
+ * earlier creates. */
293
+ async function nearMatchWarnings(os, operations) {
294
+ if (!operations.some(op => op.op === 'create' && op.kind === 'knowledge'))
295
+ return [];
296
+ const seen = await knowledgeNotes(os);
297
+ return operations.flatMap((op, index) => {
298
+ if (op.op !== 'create' || op.kind !== 'knowledge')
299
+ return [];
300
+ const { near } = resemblances(op.name, seen);
301
+ seen.push({ slug: slugify(op.name), name: op.name, subtype: op.fields?.subtype ?? 'insight' });
302
+ return near.length > 0
303
+ ? [ITEMS.nearMatch.render({ form: 'batchWarning', index, name: op.name, matches: near.map(n => n.slug) })]
304
+ : [];
305
+ });
202
306
  }
203
307
  export async function cmdCreate(os, args, opts = {}) {
204
308
  const { flags, positionals } = parseArgs(args, CREATE_FLAGS, CREATE_USAGE);
@@ -208,7 +312,13 @@ export async function cmdCreate(os, args, opts = {}) {
208
312
  const name = positionals.slice(1).join(' ');
209
313
  if (!name)
210
314
  throw new UsageError(`Usage: hafez create ${kind} <name> [options]`);
211
- const fields = createFields(flags);
315
+ if (flags.new !== undefined && kind !== 'knowledge') {
316
+ throw new UsageError('--new is for create knowledge only: it writes a note despite a near-match');
317
+ }
318
+ // Kind-agnostic on purpose: the per-kind hand-filter this replaced
319
+ // discarded whatever its branch didn't name (--brief on knowledge,
320
+ // --subtype on entity) and still exited 0 with the value nowhere on disk.
321
+ const fields = flagFields(CREATE_FLAGS, flags, { skip: new Set(['new']) });
212
322
  if (kind === 'entity') {
213
323
  // The only per-kind rule the CLI still owns: --type is required, and a
214
324
  // missing one deserves better than a shape error naming a JSON field.
@@ -222,29 +332,47 @@ export async function cmdCreate(os, args, opts = {}) {
222
332
  // (and drops it when --synthesis is also given). On entity it stays
223
333
  // `insight` and the kind gate rejects it. --status flows through the
224
334
  // same way; the subtype-aware gate accepts it on plans only.
225
- const slug = await os.create('knowledge', name, fields);
226
- return opts.json ? jsonOut({ slug }) : slug;
335
+ const knowledgeFields = fields;
336
+ const { near, weak } = resemblances(name, await knowledgeNotes(os));
337
+ // Near-match (#60): refuse unless --new, handing back the update line.
338
+ // Only a create that would otherwise be written: an invalid one, or an
339
+ // exact slug collision, gets its own error rather than a --new line that
340
+ // would fail the same way.
341
+ if (near.length > 0 && flags.new === undefined
342
+ && (await os.validateBatch([{ op: 'create', kind: 'knowledge', name, fields: knowledgeFields }])).valid) {
343
+ throw new HafezError('SLUG_EXISTS', ITEMS.nearMatch.render({
344
+ form: 'refusal', name, flags: echoFlags(flags), matches: near,
345
+ evidence: str(flags.synthesis) ?? str(flags.insight),
346
+ }));
347
+ }
348
+ const slug = await os.create('knowledge', name, knowledgeFields);
349
+ if (opts.json)
350
+ return jsonOut({ slug });
351
+ const related = knowledgeFields.related ?? [];
352
+ const resembles = [...near, ...weak].map(n => n.slug).filter(r => !related.includes(r)).slice(0, 3);
353
+ if (resembles.length === 0)
354
+ return slug;
355
+ return `${slug}\n${ITEMS.nearMatch.render({ form: 'relate', slug, related, resembles })}`;
227
356
  }
228
357
  throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
229
358
  }
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"]';
359
+ const UPDATE_USAGE = 'Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text (repeats)] [--complete-action handle-or-text (repeats)] [--remove-action handle-or-text (repeats)] [--clear-actions] [--status <active|paused|done>] [--parent slug] [--log "<progress|decision|blocker|research>: summary" [--body text]]';
231
360
  /** Exported so tests can assert the table covers the update schema. */
232
361
  export const UPDATE_FLAGS = {
233
362
  status: 'string', 'current-state': 'string', brief: 'string',
234
- 'add-action': 'string', 'complete-action': 'string', 'remove-action': 'string',
235
- 'clear-actions': 'boolean',
363
+ 'add-action': 'repeat', 'complete-action': 'repeat', 'remove-action': 'repeat',
364
+ 'clear-actions': 'boolean', parent: 'string',
236
365
  description: 'string', resource: 'string', domain: 'string', confidence: 'string',
237
366
  tags: 'string', related: 'string', insight: 'string', synthesis: 'string',
238
- 'add-evidence': 'string', 'add-source': 'string',
239
- // Composed, not mapped: --log "type: summary" plus --agent build session_log.
240
- log: 'string', agent: 'string',
367
+ 'add-evidence': 'string', 'add-source': 'string', agent: 'string',
368
+ // Composed, not mapped: --log "type: summary" plus --body (and --agent, when
369
+ // given) build session_log.
370
+ log: 'string', body: 'string',
241
371
  // Removed flag, declared so it fails with guidance instead of a generic
242
372
  // unknown-flag error: older installed skills/scripts still pass it, and
243
373
  // "Updated <slug>" with the action discarded is a false success.
244
374
  'next-action': 'string',
245
375
  };
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
376
  /** Flags where an empty string is the clear signal — exactly the nullable
249
377
  * fields of the update shape. Every other flag passes its value through
250
378
  * untouched, so `--status ""` reaches the enum and is rejected by name
@@ -253,40 +381,34 @@ const UPDATE_LIST_FLAGS = new Set(['domain', 'tags', 'related']);
253
381
  const UPDATE_CLEARABLE_FLAGS = new Set(['brief', 'description', 'resource']);
254
382
  /** Flags the generic loop skips because they are not a field of the update
255
383
  * shape — each is hand-handled in cmdUpdate. */
256
- export const UPDATE_SPECIAL_FLAGS = new Set(['log', 'agent', 'next-action']);
384
+ export const UPDATE_SPECIAL_FLAGS = new Set(['log', 'body', 'next-action']);
257
385
  /** Update-shape fields with no flag of their own, and why. The exhaustiveness
258
386
  * test consults this, so a gap here is a stated decision rather than the
259
387
  * silent hole `add_actions` was. */
260
388
  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',
389
+ session_log: 'composed by --log "type: summary" (+ --body, --agent), never passed raw',
263
390
  };
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;
391
+ export async function cmdUpdate(os, args, opts = {}) {
392
+ // Every Write Line names the slug first, so a misuse that stops the parse
393
+ // part-way is still reported against it.
394
+ let slug = args[0]?.startsWith('--') ? undefined : args[0];
395
+ let fields;
396
+ let result;
397
+ try {
398
+ ({ slug, fields } = parseUpdate(args));
399
+ result = await os.update(slug, fields);
286
400
  }
287
- return fields;
401
+ catch (err) {
402
+ throw await updateFailure(os, slug, err, opts);
403
+ }
404
+ if (opts.json)
405
+ return jsonOut({ slug, ...result });
406
+ const summary = ITEMS.updateSummary.render({ slug, fields, result });
407
+ const stale = await staleAfterWrite(os, slug);
408
+ return stale.length > 0 ? `${summary}\n${ITEMS.staleReminder.render({ handles: stale })}` : summary;
288
409
  }
289
- export async function cmdUpdate(os, args, opts = {}) {
410
+ /** The update's argv as its slug and fields: the Write Line grammar. */
411
+ function parseUpdate(args) {
290
412
  const { flags, positionals } = parseArgs(args, UPDATE_FLAGS, UPDATE_USAGE);
291
413
  const slug = positionals[0];
292
414
  if (!slug)
@@ -296,10 +418,15 @@ export async function cmdUpdate(os, args, opts = {}) {
296
418
  }
297
419
  // --insight is the declared alias; the Op Spec normalizes it (and drops it
298
420
  // when --synthesis is also given).
299
- const fields = updateFields(flags);
421
+ const fields = flagFields(UPDATE_FLAGS, flags, { skip: UPDATE_SPECIAL_FLAGS, clearable: UPDATE_CLEARABLE_FLAGS });
300
422
  // The one genuinely CLI-owned convenience: session_log is a nested object,
301
- // so the flag pair composes it rather than naming a field.
423
+ // so the flag pair composes it rather than naming a field. Without --log,
424
+ // the Spine adds the entry itself and --agent rides along as `agent`.
302
425
  const log = str(flags.log);
426
+ const body = str(flags.body);
427
+ if (body !== undefined && log === undefined) {
428
+ throw new UsageError('--body is the detail of a session log entry and needs --log "type: summary"');
429
+ }
303
430
  if (log !== undefined) {
304
431
  const colonIdx = log.indexOf(':');
305
432
  if (colonIdx === -1)
@@ -307,17 +434,48 @@ export async function cmdUpdate(os, args, opts = {}) {
307
434
  fields.session_log = {
308
435
  type: log.slice(0, colonIdx).trim(),
309
436
  summary: log.slice(colonIdx + 1).trim(),
310
- agent: str(flags.agent) ?? 'claude',
437
+ ...(body !== undefined && { body }),
438
+ agent: fields.agent ?? detectAgent(),
311
439
  };
440
+ delete fields.agent;
312
441
  }
313
- const result = await os.update(slug, fields);
314
- if (opts.json)
315
- return jsonOut({ slug, ...result });
316
- const lines = [`Updated ${slug}`];
317
- if (result.matched_action) {
318
- lines.push(`matched action: ${result.matched_action}`);
442
+ // An update that names nothing to change would print a success line that
443
+ // names nothing either.
444
+ if (Object.keys(fields).length === 0)
445
+ throw new UsageError('update needs at least one flag');
446
+ return { slug, fields: fields };
447
+ }
448
+ /** What a failed update reports (Agent Contract: a misuse returns the
449
+ * corrected shape). A misuse — a usage error or a write the Op Spec rejected —
450
+ * says nothing was written, then gives the Footer `read` shows for the slug;
451
+ * a slug that names no file gets the `read` miss instead. Anything else, and
452
+ * any failure with no slug to report against, passes through unchanged. */
453
+ async function updateFailure(os, slug, err, opts) {
454
+ const misuse = err instanceof UsageError
455
+ ? { error: err instanceof UnknownFlagError ? `update has no flag --${err.flag}` : err.reason }
456
+ : err instanceof HafezError && err.code === 'VALIDATION_FAILED' ? { error: err.message, details: err.details }
457
+ : undefined;
458
+ const missing = err instanceof HafezError && err.code === 'NOT_FOUND';
459
+ // Under --json only the miss has a machine form (read's); a misuse's
460
+ // Footer is prose, so the error passes through as it always has.
461
+ if (slug === undefined || (!misuse && !missing) || (opts.json && misuse))
462
+ return err;
463
+ let retry;
464
+ try {
465
+ retry = { footer: await footerFor(os, slug, await os.read(slug, 'full'), 'full') };
319
466
  }
320
- return lines.join('\n');
467
+ catch (readErr) {
468
+ if (!(readErr instanceof HafezError) || readErr.code !== 'NOT_FOUND')
469
+ return err;
470
+ retry = { miss: { slug, closest: readErr.details ?? [] } };
471
+ }
472
+ if (misuse)
473
+ return new RenderedError('VALIDATION_FAILED', ITEMS.updateMisuse.render({ ...misuse, ...retry }), 'stderr');
474
+ if (!('miss' in retry))
475
+ return err;
476
+ return opts.json
477
+ ? new RenderedError('NOT_FOUND', jsonOut(retry.miss), 'stdout')
478
+ : new RenderedError('NOT_FOUND', ITEMS.slugMissRecovery.render(retry.miss), 'stderr');
321
479
  }
322
480
  const LINK_FLAGS = { relation: 'string' };
323
481
  function parseLinkArgs(args, verb) {
@@ -367,7 +525,18 @@ export async function cmdPromote(os, args, opts = {}) {
367
525
  return jsonOut({ slug, target });
368
526
  return `Promoted ${slug} → ${target}`;
369
527
  }
370
- export async function cmdValidate(os, _args, opts = {}) {
528
+ const DELETE_USAGE = 'Usage: hafez delete <slug> (knowledge and session notes only — retire an entity with `hafez update <slug> --status done`)';
529
+ export async function cmdDelete(os, args, opts = {}) {
530
+ const { positionals } = parseArgs(args, {}, DELETE_USAGE);
531
+ if (positionals.length !== 1)
532
+ throw new UsageError(DELETE_USAGE);
533
+ const [slug] = positionals;
534
+ // What may be deleted (not an entity, nothing linking to it) is core's rule.
535
+ const kind = await os.delete(slug);
536
+ return opts.json ? jsonOut({ slug, kind }) : `Deleted ${slug} (${kind})`;
537
+ }
538
+ export async function cmdValidate(os, args, opts = {}) {
539
+ helpIfAsked(args, 'Usage: hafez validate');
371
540
  const report = await os.validate();
372
541
  if (opts.json)
373
542
  return jsonOut(report);
@@ -377,7 +546,8 @@ export async function cmdIndexRebuild(os) {
377
546
  await os.rebuildIndex();
378
547
  return 'Index rebuilt.';
379
548
  }
380
- export async function cmdSync(os, _args) {
549
+ export async function cmdSync(os, args) {
550
+ helpIfAsked(args, 'Usage: hafez sync (pull remote changes, push local commits)');
381
551
  const result = await os.sync();
382
552
  if (!result.remote)
383
553
  return 'No remote configured — vault is local-only. Every write is already committed to git.';
@@ -389,7 +559,8 @@ export async function cmdSync(os, _args) {
389
559
  return 'Synced: pushed local commits.';
390
560
  return 'Already up to date.';
391
561
  }
392
- export async function cmdStats(os, _args, opts = {}) {
562
+ export async function cmdStats(os, args, opts = {}) {
563
+ helpIfAsked(args, 'Usage: hafez stats');
393
564
  const stats = await os.stats();
394
565
  if (opts.json)
395
566
  return jsonOut(stats);
@@ -479,6 +650,19 @@ function readPayloadFile(path) {
479
650
  return buf.toString('utf16le').replace(/^\uFEFF/, '');
480
651
  return buf.toString('utf-8').replace(/^\uFEFF/, '');
481
652
  }
653
+ // The payload as one argument. Exists because Codex runs a command outside its
654
+ // sandbox (where the vault's .git is writable) only when every stage of a pipe
655
+ // matches an allow rule: `hafez digest --data '…' | hafez batch` does, an `echo`
656
+ // stage does not.
657
+ function readPayload(flags, usage) {
658
+ const data = str(flags.data);
659
+ const file = str(flags.file);
660
+ if (data !== undefined && file !== undefined)
661
+ throw new UsageError(`--data and --file are two ways to pass one payload; give one.\n${usage}`);
662
+ if (data !== undefined)
663
+ return data;
664
+ return file ? readPayloadFile(file) : readStdin();
665
+ }
482
666
  function readStdin() {
483
667
  return new Promise((resolve, reject) => {
484
668
  let data = '';
@@ -488,17 +672,32 @@ function readStdin() {
488
672
  process.stdin.on('error', reject);
489
673
  });
490
674
  }
491
- const BATCH_USAGE = "Usage: hafez batch [--dry-run] [--file payload.json] (or pipe JSON to stdin)";
492
- const BATCH_FLAGS = { 'dry-run': 'boolean', file: 'string' };
675
+ const BATCH_USAGE = "Usage: hafez batch [--dry-run] [--data '<payload>' | --file payload.json] (or pipe JSON to stdin)";
676
+ const BATCH_FLAGS = { 'dry-run': 'boolean', data: 'string', file: 'string' };
493
677
  export async function cmdBatch(os, args, opts = {}) {
494
678
  const { flags } = parseArgs(args, BATCH_FLAGS, BATCH_USAGE);
495
679
  const dryRun = flags['dry-run'] === true;
496
- const file = str(flags.file);
497
- const input = file ? readPayloadFile(file) : await readStdin();
680
+ const input = await readPayload(flags, BATCH_USAGE);
681
+ // A payload that is not a well-formed batch is a misuse: the error carries
682
+ // the batch shape so the retry is right. Semantic failures (a missing slug)
683
+ // are not shape problems and say only what failed.
684
+ // No input is not one: in a pipe it means the stage before failed and said
685
+ // why, and a batch shape there would invite hand-writing the ops.
498
686
  if (!input.trim()) {
499
- throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin (echo \'[...]\' | hafez batch) or pass --file payload.json');
687
+ throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin (echo \'[...]\' | hafez batch) or pass --data \'[...]\' or --file payload.json');
688
+ }
689
+ let operations;
690
+ try {
691
+ operations = parseBatchInput(input);
500
692
  }
501
- const operations = parseBatchInput(input);
693
+ catch (err) {
694
+ throw err instanceof HafezError ? new MisuseError(err, placeIn('batchMisuse', 'batchShape')) : err;
695
+ }
696
+ const nearMatches = await nearMatchWarnings(os, operations);
697
+ // Under --json stdout is the report alone; the warnings go to stderr.
698
+ if (opts.json)
699
+ for (const w of nearMatches)
700
+ process.stderr.write(`${w}\n`);
502
701
  if (dryRun) {
503
702
  // Same validate phase as apply — dry-run can never green-light a payload
504
703
  // apply rejects. Reports derived slugs so agents can predict create slugs.
@@ -523,7 +722,7 @@ export async function cmdBatch(os, args, opts = {}) {
523
722
  if (o.warning)
524
723
  out.push(`Warning: op[${o.index}] ${o.warning}`);
525
724
  }
526
- return out.join('\n');
725
+ return [...out, ...nearMatches].join('\n');
527
726
  }
528
727
  const results = await os.batch(operations);
529
728
  if (opts.json)
@@ -538,16 +737,15 @@ export async function cmdBatch(os, args, opts = {}) {
538
737
  if (r.status === 'skipped' && r.warning)
539
738
  lines.push(`Warning: op[${i}] ${r.warning}`);
540
739
  });
541
- return lines.join('\n');
740
+ return [...lines, ...nearMatches].join('\n');
542
741
  }
543
- const DIGEST_USAGE = 'Usage: hafez digest [--file summary.json] (or pipe JSON to stdin)';
544
- const DIGEST_FLAGS = { file: 'string' };
742
+ const DIGEST_USAGE = "Usage: hafez digest [--data '<summary>' | --file summary.json] (or pipe JSON to stdin)";
743
+ const DIGEST_FLAGS = { data: 'string', file: 'string' };
545
744
  export async function cmdDigest(os, args, _opts = {}) {
546
745
  const { flags } = parseArgs(args, DIGEST_FLAGS, DIGEST_USAGE);
547
- const file = str(flags.file);
548
- const raw = file ? readPayloadFile(file) : await readStdin();
746
+ const raw = await readPayload(flags, DIGEST_USAGE);
549
747
  if (!raw.trim()) {
550
- throw new UsageError('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --file summary.json');
748
+ throw new UsageError('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --data \'<summary>\' or --file summary.json');
551
749
  }
552
750
  // Parse JSON
553
751
  let parsed;
@@ -568,13 +766,16 @@ export async function cmdDigest(os, args, _opts = {}) {
568
766
  }
569
767
  // Resolve existing entity slugs — digest only generates update ops for entities
570
768
  const { items: entityItems } = await os.query({ filter: 'all', limit: 9999 });
571
- const existingSlugs = new Set(entityItems.map(i => i.slug));
572
- // Warn about unknown entity slugs
573
- const unknownSlugs = result.data.entities_touched.filter(s => !existingSlugs.has(s));
574
- for (const slug of unknownSlugs) {
575
- process.stderr.write(`Warning: entity slug '${slug}' not found in vault — skipping session log update\n`);
769
+ const { items: knowledgeItems } = await os.query_knowledge({ limit: 9999 });
770
+ const ops = digest(result.data, new Set(entityItems.map(i => i.slug)), new Set(knowledgeItems.map(i => i.slug)));
771
+ // A session already saved: emit the session note alone, which batch skips
772
+ // as a duplicate, so a second save writes no second set of log entries.
773
+ const note = ops.find(o => o.op === 'create');
774
+ const [check] = (await os.validateBatch([note])).operations;
775
+ if (check.warning) {
776
+ process.stderr.write(`Session already saved as '${check.slug}' — skipped, nothing new to write\n`);
777
+ return JSON.stringify([note], null, 2);
576
778
  }
577
- const ops = digest(result.data, existingSlugs);
578
779
  return JSON.stringify(ops, null, 2);
579
780
  }
580
781
  export async function cmdExport(_os, args, opts = {}) {
@@ -1,8 +1,12 @@
1
1
  import type { QueryResult, KnowledgeQueryResult, ValidationReport, SearchResult, VaultStats, ChangelogEntry } from '../types.js';
2
- export declare function formatEntityHeader(fm: Record<string, any>, body: string): string;
2
+ /** `parent` is the rendered value of the `parent:` line (the slug with the
3
+ * parent's description); absent, the slug alone. */
4
+ export declare function formatEntityHeader(fm: Record<string, any>, body: string, parent?: string | undefined): string;
3
5
  export declare function formatKnowledgeHeader(fm: Record<string, any>, body: string): string;
4
- export declare function formatQueryTable(results: QueryResult[], total?: number): string;
5
- export declare function formatKnowledgeTable(results: KnowledgeQueryResult[], total?: number): string;
6
+ export type QueryTableRow = Pick<QueryResult, 'slug' | 'name' | 'type' | 'status' | 'next_action' | 'next_action_count'>;
7
+ export declare function formatQueryTable(results: QueryTableRow[], total?: number): string;
8
+ export type KnowledgeTableRow = Pick<KnowledgeQueryResult, 'slug' | 'name' | 'confidence' | 'domain' | 'reinforcement_count'>;
9
+ export declare function formatKnowledgeTable(results: KnowledgeTableRow[], total?: number): string;
6
10
  export declare function formatSearchResults(results: SearchResult[]): string;
7
11
  export declare function formatStats(stats: VaultStats): string;
8
12
  export declare function formatChangelog(entries: ChangelogEntry[]): string;