hafez 1.3.0 → 2.0.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 (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  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 +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -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 +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. package/skill/SKILL.md +0 -196
@@ -1,10 +1,18 @@
1
1
  // src/cli/commands.ts
2
2
  import { readFileSync } from 'fs';
3
- import { parseArgs, splitList, str, UsageError } from './args.js';
3
+ import { resolve } from 'path';
4
+ import { helpIfAsked, parseArgs, splitList, str, MisuseError, RenderedError, UnknownFlagError, UsageError } from './args.js';
4
5
  import { HafezError } from '../types.js';
5
- import { BatchOperationSchema } from '../batch-ops.js';
6
+ import { BatchOperationSchema, normalizeAliases, opErrorLine, batchInvalidHeader } from '../batch-ops.js';
6
7
  import { formatBatchError } from './batch-errors.js';
7
8
  import { parseDigestInput, digest } from '../digest.js';
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';
8
16
  import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
9
17
  function jsonOut(data) {
10
18
  return JSON.stringify(data, null, 2);
@@ -17,30 +25,134 @@ export async function cmdRead(os, args, opts = {}) {
17
25
  if (!slug)
18
26
  throw new UsageError(READ_USAGE);
19
27
  const depth = (str(flags.depth) ?? 'summary');
20
- const result = await os.read(slug, depth);
21
- if (opts.json)
22
- 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
+ }
23
44
  const fm = result.frontmatter;
24
- if (result.kind === 'entity')
25
- return formatEntityHeader(fm, result.body);
26
- 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 });
27
63
  }
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]';
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 } }) };
86
+ }
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]
88
+
89
+ --filter active|paused|done matches entity status; knowledge notes carry no
90
+ status, so under --kind all they are always included alongside the matching
91
+ entities. --filter stale|capture are entity-only views and return no
92
+ knowledge. --filter/--type/--parent are rejected with --kind knowledge, and
93
+ --confidence/--subtype are rejected with --kind entity, rather than being
94
+ silently dropped. --related-to takes no other query flags.`;
29
95
  const QUERY_FLAGS = {
30
96
  kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
31
97
  domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
32
98
  since: 'string', before: 'string', 'created-since': 'string', 'created-before': 'string',
33
99
  'sort-by': 'string', 'sort-order': 'string', limit: 'string', offset: 'string',
34
100
  };
101
+ /** Every flag the query parser accepts, for the branch-support matrix. */
102
+ export const QUERY_FLAG_NAMES = Object.keys(QUERY_FLAGS);
103
+ /** Which flags each kind branch actually applies. A branch that cannot honour
104
+ * a flag rejects it (below) instead of dropping it: `--kind knowledge --filter
105
+ * stale` used to return every knowledge note with exit 0, and `--confidence`
106
+ * never reached an entity query at all. */
107
+ export const QUERY_FLAG_SUPPORT = {
108
+ entity: ['kind', 'filter', 'type', 'parent', 'domain', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
109
+ knowledge: ['kind', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
110
+ all: ['kind', 'filter', 'type', 'parent', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
111
+ 'related-to': ['related-to'],
112
+ };
113
+ const FLAG_SCOPE_REASON = {
114
+ filter: 'it selects entity status (or the entity-only stale/capture views); knowledge notes carry no status',
115
+ type: 'entity types only',
116
+ parent: 'entity hierarchy only',
117
+ confidence: 'knowledge notes only',
118
+ subtype: 'knowledge notes only',
119
+ };
120
+ const QUERY_KINDS = ['entity', 'knowledge', 'all'];
121
+ function rejectUnsupportedFlags(branch, flags) {
122
+ const supported = new Set(QUERY_FLAG_SUPPORT[branch]);
123
+ for (const [name, value] of Object.entries(flags)) {
124
+ if (value === undefined || supported.has(name))
125
+ continue;
126
+ const where = branch === 'related-to'
127
+ ? 'with --related-to (it returns every entity and knowledge note linked to the slug)'
128
+ : `with --kind ${branch}`;
129
+ const reason = FLAG_SCOPE_REASON[name] ? ` — ${FLAG_SCOPE_REASON[name]}` : '';
130
+ throw new UsageError(`--${name} is not supported ${where}${reason}\n${QUERY_USAGE}`);
131
+ }
132
+ }
133
+ /** Paging flags reach SQL as text — parse them strictly here so `--limit abc`
134
+ * is a usage error rather than a prepare-time SqliteError. */
135
+ function intFlag(raw, name) {
136
+ if (raw === undefined)
137
+ return undefined;
138
+ if (!/^\d+$/.test(raw))
139
+ throw new UsageError(`--${name} must be a non-negative integer (got "${raw}")\n${QUERY_USAGE}`);
140
+ return Number(raw);
141
+ }
35
142
  export async function cmdQuery(os, args, opts = {}) {
36
143
  const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
37
- const kind = str(flags.kind) ?? 'entity';
144
+ const relatedTo = str(flags['related-to']);
145
+ const branch = relatedTo !== undefined ? 'related-to' : (str(flags.kind) ?? 'entity');
146
+ if (branch !== 'related-to' && !QUERY_KINDS.includes(branch)) {
147
+ throw new UsageError(`--kind must be one of ${QUERY_KINDS.join(', ')} (got "${branch}")\n${QUERY_USAGE}`);
148
+ }
149
+ rejectUnsupportedFlags(branch, flags);
38
150
  const filter = (str(flags.filter) ?? 'active');
39
151
  const type = str(flags.type);
40
152
  const parent = str(flags.parent);
41
- const relatedTo = str(flags['related-to']);
42
153
  const domain = str(flags.domain);
43
154
  const confidence = str(flags.confidence);
155
+ const subtype = str(flags.subtype);
44
156
  const since = str(flags.since);
45
157
  const before = str(flags.before);
46
158
  const createdSince = str(flags['created-since']);
@@ -49,11 +161,10 @@ export async function cmdQuery(os, args, opts = {}) {
49
161
  const tags = tagRaw ? tagRaw.split(',').map(t => t.trim()).filter(Boolean) : undefined;
50
162
  const sortBy = str(flags['sort-by']);
51
163
  const sortOrder = str(flags['sort-order']);
52
- const limitRaw = str(flags.limit);
53
- const offsetRaw = str(flags.offset);
54
- const limit = limitRaw != null ? parseInt(limitRaw, 10) : undefined;
55
- const offset = offsetRaw != null ? parseInt(offsetRaw, 10) : undefined;
164
+ const limit = intFlag(str(flags.limit), 'limit');
165
+ const offset = intFlag(str(flags.offset), 'offset');
56
166
  const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
167
+ const timeOpts = { since, before, createdSince, createdBefore };
57
168
  // --related-to: always returns both entities and knowledge
58
169
  if (relatedTo) {
59
170
  const result = await os.related_to(relatedTo);
@@ -69,20 +180,19 @@ export async function cmdQuery(os, args, opts = {}) {
69
180
  sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge, total));
70
181
  return sections.length > 0 ? sections.join('\n\n') : 'No results.';
71
182
  }
72
- const queryOpts = { filter, type, parent, domain, since, before, createdSince, createdBefore, tags, ...sortOpts };
73
- // --kind knowledge: query knowledge only
74
- // Note: time flags only apply to entity queries.
75
- if (kind === 'knowledge') {
76
- const subtype = str(flags.subtype);
77
- const kOpts = { domain: domain ?? undefined, confidence, subtype, tags, ...sortOpts };
183
+ const queryOpts = { filter, type, parent, domain, ...timeOpts, tags, ...sortOpts };
184
+ // --kind knowledge: query knowledge only. The time window applies here too —
185
+ // knowledge rows carry created (and last-touched for the plan subtype).
186
+ if (branch === 'knowledge') {
187
+ const kOpts = { domain: domain ?? undefined, confidence, subtype, ...timeOpts, tags, ...sortOpts };
78
188
  const result = await os.query_knowledge(kOpts);
79
189
  if (opts.json)
80
190
  return jsonOut(result);
81
191
  return formatKnowledgeTable(result.items, result.total);
82
192
  }
83
193
  // --kind all: query both entities and knowledge via unified query
84
- if (kind === 'all') {
85
- const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence });
194
+ if (branch === 'all') {
195
+ const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence, subtype });
86
196
  if (opts.json)
87
197
  return jsonOut(result);
88
198
  const { items, total } = result;
@@ -116,13 +226,84 @@ export async function cmdSearch(os, args, opts = {}) {
116
226
  return jsonOut(results);
117
227
  return formatSearchResults(results);
118
228
  }
119
- const CREATE_USAGE = 'Usage: hafez create <entity|knowledge> <name> [options]';
120
- const CREATE_FLAGS = {
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
+ }
262
+ // --status is knowledge/plan only, and --purpose needs --type project: a field
263
+ // whose section the chosen template lacks is rejected, never silently dropped.
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)] …';
265
+ /** Exported so tests can assert the flag × kind matrix is exhaustive. */
266
+ export const CREATE_FLAGS = {
121
267
  type: 'string', purpose: 'string', description: 'string', resource: 'string',
122
268
  domain: 'string', parent: 'string', related: 'string', tags: 'string',
123
- brief: 'string', 'add-action': 'string',
124
- subtype: 'string', synthesis: 'string', insight: 'string',
269
+ brief: 'string', 'add-action': 'repeat',
270
+ subtype: 'string', synthesis: 'string', insight: 'string', confidence: 'string',
271
+ // knowledge, plan subtype only — insights have no lifecycle
272
+ status: 'string',
273
+ // knowledge only: write despite a Near-match. Not a field of any shape.
274
+ new: 'boolean',
125
275
  };
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
+ });
306
+ }
126
307
  export async function cmdCreate(os, args, opts = {}) {
127
308
  const { flags, positionals } = parseArgs(args, CREATE_FLAGS, CREATE_USAGE);
128
309
  const kind = positionals[0];
@@ -131,51 +312,103 @@ export async function cmdCreate(os, args, opts = {}) {
131
312
  const name = positionals.slice(1).join(' ');
132
313
  if (!name)
133
314
  throw new UsageError(`Usage: hafez create ${kind} <name> [options]`);
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']) });
134
322
  if (kind === 'entity') {
135
- const type = str(flags.type);
136
- if (!type)
323
+ // The only per-kind rule the CLI still owns: --type is required, and a
324
+ // missing one deserves better than a shape error naming a JSON field.
325
+ if (fields.type === undefined)
137
326
  throw new UsageError('--type is required for entity creation');
138
- const slug = await os.create('entity', name, {
139
- type,
140
- purpose: str(flags.purpose),
141
- description: str(flags.description),
142
- resource: str(flags.resource),
143
- domain: splitList(flags.domain),
144
- parent: str(flags.parent),
145
- related: str(flags.related)?.split(','),
146
- tags: str(flags.tags)?.split(','),
147
- brief: str(flags.brief),
148
- add_action: str(flags['add-action']),
149
- });
327
+ const slug = await os.create('entity', name, fields);
150
328
  return opts.json ? jsonOut({ slug }) : slug;
151
329
  }
152
330
  if (kind === 'knowledge') {
153
- const slug = await os.create('knowledge', name, {
154
- subtype: str(flags.subtype),
155
- synthesis: str(flags.synthesis) ?? str(flags.insight),
156
- description: str(flags.description),
157
- domain: str(flags.domain)?.split(','),
158
- related: str(flags.related)?.split(','),
159
- tags: str(flags.tags)?.split(','),
160
- });
161
- return opts.json ? jsonOut({ slug }) : slug;
331
+ // --insight passes through untouched: the Op Spec normalizes the alias
332
+ // (and drops it when --synthesis is also given). On entity it stays
333
+ // `insight` and the kind gate rejects it. --status flows through the
334
+ // same way; the subtype-aware gate accepts it on plans only.
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 })}`;
162
356
  }
163
357
  throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
164
358
  }
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 = {
359
+ const UPDATE_USAGE = 'Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text (repeats)] [--complete-action handle-or-text] [--remove-action handle-or-text] [--clear-actions] [--status <active|paused|done>] [--parent slug] [--log "<progress|decision|blocker|research>: summary" [--body text]]';
360
+ /** Exported so tests can assert the table covers the update schema. */
361
+ export const UPDATE_FLAGS = {
167
362
  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',
363
+ 'add-action': 'repeat', 'complete-action': 'string', 'remove-action': 'string',
364
+ 'clear-actions': 'boolean', parent: 'string',
170
365
  description: 'string', resource: 'string', domain: 'string', confidence: 'string',
171
366
  tags: 'string', related: 'string', insight: 'string', synthesis: 'string',
172
- 'add-evidence': 'string', 'add-source': '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',
173
371
  // Removed flag, declared so it fails with guidance instead of a generic
174
372
  // unknown-flag error: older installed skills/scripts still pass it, and
175
373
  // "Updated <slug>" with the action discarded is a false success.
176
374
  'next-action': 'string',
177
375
  };
376
+ /** Flags where an empty string is the clear signal — exactly the nullable
377
+ * fields of the update shape. Every other flag passes its value through
378
+ * untouched, so `--status ""` reaches the enum and is rejected by name
379
+ * instead of silently no-opping (which is how it read before: the guard
380
+ * each flag got was an accident of which one was typed). */
381
+ const UPDATE_CLEARABLE_FLAGS = new Set(['brief', 'description', 'resource']);
382
+ /** Flags the generic loop skips because they are not a field of the update
383
+ * shape — each is hand-handled in cmdUpdate. */
384
+ export const UPDATE_SPECIAL_FLAGS = new Set(['log', 'body', 'next-action']);
385
+ /** Update-shape fields with no flag of their own, and why. The exhaustiveness
386
+ * test consults this, so a gap here is a stated decision rather than the
387
+ * silent hole `add_actions` was. */
388
+ export const UPDATE_FIELDS_WITHOUT_FLAGS = {
389
+ session_log: 'composed by --log "type: summary" (+ --body, --agent), never passed raw',
390
+ };
178
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);
400
+ }
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;
409
+ }
410
+ /** The update's argv as its slug and fields: the Write Line grammar. */
411
+ function parseUpdate(args) {
179
412
  const { flags, positionals } = parseArgs(args, UPDATE_FLAGS, UPDATE_USAGE);
180
413
  const slug = positionals[0];
181
414
  if (!slug)
@@ -183,79 +416,66 @@ export async function cmdUpdate(os, args, opts = {}) {
183
416
  if (flags['next-action'] !== undefined) {
184
417
  throw new UsageError('--next-action was removed in v1.0.2. Use --add-action (and --clear-actions) instead.');
185
418
  }
186
- const fields = {};
187
- const status = str(flags.status);
188
- if (status)
189
- fields.status = status;
190
- const currentState = str(flags['current-state']);
191
- if (currentState)
192
- fields.current_state = currentState;
193
- // Brief
194
- const brief = str(flags.brief);
195
- if (brief !== undefined) {
196
- fields.brief = brief === '' ? null : brief;
197
- }
198
- // Next Actions
199
- const addAction = str(flags['add-action']);
200
- if (addAction)
201
- fields.add_action = addAction;
202
- const completeAction = str(flags['complete-action']);
203
- if (completeAction)
204
- fields.complete_action = completeAction;
205
- const removeAction = str(flags['remove-action']);
206
- if (removeAction)
207
- fields.remove_action = removeAction;
208
- if (flags['clear-actions'])
209
- fields.clear_actions = true;
419
+ // --insight is the declared alias; the Op Spec normalizes it (and drops it
420
+ // when --synthesis is also given).
421
+ const fields = flagFields(UPDATE_FLAGS, flags, { skip: UPDATE_SPECIAL_FLAGS, clearable: UPDATE_CLEARABLE_FLAGS });
422
+ // The one genuinely CLI-owned convenience: session_log is a nested object,
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`.
210
425
  const log = str(flags.log);
211
- if (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
+ }
430
+ if (log !== undefined) {
212
431
  const colonIdx = log.indexOf(':');
213
432
  if (colonIdx === -1)
214
433
  throw new UsageError('--log format: "type: summary". Types: progress, decision, blocker, research');
215
- const type = log.slice(0, colonIdx).trim();
216
- const summary = log.slice(colonIdx + 1).trim();
217
- const agent = str(flags.agent) ?? 'claude';
218
- fields.session_log = { type, summary, agent };
219
- }
220
- // Shared metadata (entity + knowledge)
221
- const description = str(flags.description);
222
- if (description !== undefined)
223
- fields.description = description;
224
- const resource = str(flags.resource);
225
- if (resource !== undefined)
226
- fields.resource = resource;
227
- const domain = splitList(flags.domain);
228
- if (domain)
229
- fields.domain = domain;
230
- const conf = str(flags.confidence);
231
- if (conf)
232
- fields.confidence = conf;
233
- const tags = splitList(flags.tags);
234
- if (tags)
235
- fields.tags = tags;
236
- const related = splitList(flags.related);
237
- if (related)
238
- fields.related = related;
239
- const insight = str(flags.insight);
240
- if (insight)
241
- fields.synthesis = insight;
242
- const synthesis = str(flags.synthesis);
243
- if (synthesis)
244
- fields.synthesis = synthesis;
245
- const addEvidence = str(flags['add-evidence']);
246
- if (addEvidence)
247
- fields.add_evidence = addEvidence;
248
- const addSource = str(flags['add-source']);
249
- if (addSource)
250
- fields.add_source = addSource;
251
- const result = await os.update(slug, fields);
252
- if (opts.json)
253
- return jsonOut({ slug, ...result });
254
- const lines = [`Updated ${slug}`];
255
- if (result.matched_action) {
256
- lines.push(`matched action: ${result.matched_action}`);
434
+ fields.session_log = {
435
+ type: log.slice(0, colonIdx).trim(),
436
+ summary: log.slice(colonIdx + 1).trim(),
437
+ ...(body !== undefined && { body }),
438
+ agent: fields.agent ?? detectAgent(),
439
+ };
440
+ delete fields.agent;
257
441
  }
258
- return lines.join('\n');
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') };
466
+ }
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');
259
479
  }
260
480
  const LINK_FLAGS = { relation: 'string' };
261
481
  function parseLinkArgs(args, verb) {
@@ -305,7 +525,18 @@ export async function cmdPromote(os, args, opts = {}) {
305
525
  return jsonOut({ slug, target });
306
526
  return `Promoted ${slug} → ${target}`;
307
527
  }
308
- 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');
309
540
  const report = await os.validate();
310
541
  if (opts.json)
311
542
  return jsonOut(report);
@@ -315,7 +546,8 @@ export async function cmdIndexRebuild(os) {
315
546
  await os.rebuildIndex();
316
547
  return 'Index rebuilt.';
317
548
  }
318
- 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)');
319
551
  const result = await os.sync();
320
552
  if (!result.remote)
321
553
  return 'No remote configured — vault is local-only. Every write is already committed to git.';
@@ -327,7 +559,8 @@ export async function cmdSync(os, _args) {
327
559
  return 'Synced: pushed local commits.';
328
560
  return 'Already up to date.';
329
561
  }
330
- export async function cmdStats(os, _args, opts = {}) {
562
+ export async function cmdStats(os, args, opts = {}) {
563
+ helpIfAsked(args, 'Usage: hafez stats');
331
564
  const stats = await os.stats();
332
565
  if (opts.json)
333
566
  return jsonOut(stats);
@@ -391,19 +624,12 @@ export function parseBatchInput(json) {
391
624
  continue;
392
625
  }
393
626
  const op = result.data;
394
- // Alias: insight → synthesis for create knowledge/session and update ops.
395
- // Runs only on successfully-parsed ops, preserving prior semantics.
396
- if (op.fields) {
397
- if (op.fields.insight && !op.fields.synthesis) {
398
- op.fields.synthesis = op.fields.insight;
399
- delete op.fields.insight;
400
- }
401
- }
402
- operations.push(op);
627
+ // Alias normalization is the Op Spec's (normalizeAliases) — the CLI just
628
+ // hands back canonical ops so `batch --dry-run` output names real fields.
629
+ operations.push(normalizeAliases(op));
403
630
  }
404
631
  if (failureBlocks.length > 0) {
405
- const header = `Batch failed validation: ${failureBlocks.length} invalid operation${failureBlocks.length === 1 ? '' : 's'} (0 of ${parsed.length} applied)`;
406
- throw new HafezError('VALIDATION_FAILED', header, failureBlocks);
632
+ throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(failureBlocks.length, parsed.length), failureBlocks);
407
633
  }
408
634
  return operations;
409
635
  }
@@ -424,6 +650,19 @@ function readPayloadFile(path) {
424
650
  return buf.toString('utf16le').replace(/^\uFEFF/, '');
425
651
  return buf.toString('utf-8').replace(/^\uFEFF/, '');
426
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
+ }
427
666
  function readStdin() {
428
667
  return new Promise((resolve, reject) => {
429
668
  let data = '';
@@ -433,17 +672,32 @@ function readStdin() {
433
672
  process.stdin.on('error', reject);
434
673
  });
435
674
  }
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' };
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' };
438
677
  export async function cmdBatch(os, args, opts = {}) {
439
678
  const { flags } = parseArgs(args, BATCH_FLAGS, BATCH_USAGE);
440
679
  const dryRun = flags['dry-run'] === true;
441
- const file = str(flags.file);
442
- 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.
443
686
  if (!input.trim()) {
444
- 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);
692
+ }
693
+ catch (err) {
694
+ throw err instanceof HafezError ? new MisuseError(err, placeIn('batchMisuse', 'batchShape')) : err;
445
695
  }
446
- const operations = parseBatchInput(input);
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`);
447
701
  if (dryRun) {
448
702
  // Same validate phase as apply — dry-run can never green-light a payload
449
703
  // apply rejects. Reports derived slugs so agents can predict create slugs.
@@ -452,7 +706,9 @@ export async function cmdBatch(os, args, opts = {}) {
452
706
  return jsonOut(report);
453
707
  if (!report.valid) {
454
708
  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}`));
709
+ const lines = report.operations.flatMap(o => o.errors.map(e => opErrorLine(o.index, o.op, o.slug, e)));
710
+ // Dry-run's own header: it reports a hypothetical, so it says "would be
711
+ // applied" rather than the refusal wording batchInvalidHeader carries.
456
712
  throw new HafezError('VALIDATION_FAILED', `Batch invalid: ${invalidCount} of ${operations.length} operation${operations.length === 1 ? '' : 's'} failed validation (0 would be applied)`, lines);
457
713
  }
458
714
  const out = [`Batch valid: ${operations.length} operations would be applied`];
@@ -466,7 +722,7 @@ export async function cmdBatch(os, args, opts = {}) {
466
722
  if (o.warning)
467
723
  out.push(`Warning: op[${o.index}] ${o.warning}`);
468
724
  }
469
- return out.join('\n');
725
+ return [...out, ...nearMatches].join('\n');
470
726
  }
471
727
  const results = await os.batch(operations);
472
728
  if (opts.json)
@@ -475,16 +731,21 @@ export async function cmdBatch(os, args, opts = {}) {
475
731
  let output = `Batch complete: ${operations.length} operations`;
476
732
  if (created.length > 0)
477
733
  output += ` (created: ${created.join(', ')})`;
478
- return output;
734
+ // Skipped ops carry the same warning line dry-run printed for them.
735
+ const lines = [output];
736
+ results.forEach((r, i) => {
737
+ if (r.status === 'skipped' && r.warning)
738
+ lines.push(`Warning: op[${i}] ${r.warning}`);
739
+ });
740
+ return [...lines, ...nearMatches].join('\n');
479
741
  }
480
- const DIGEST_USAGE = 'Usage: hafez digest [--file summary.json] (or pipe JSON to stdin)';
481
- 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' };
482
744
  export async function cmdDigest(os, args, _opts = {}) {
483
745
  const { flags } = parseArgs(args, DIGEST_FLAGS, DIGEST_USAGE);
484
- const file = str(flags.file);
485
- const raw = file ? readPayloadFile(file) : await readStdin();
746
+ const raw = await readPayload(flags, DIGEST_USAGE);
486
747
  if (!raw.trim()) {
487
- 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');
488
749
  }
489
750
  // Parse JSON
490
751
  let parsed;
@@ -505,12 +766,34 @@ export async function cmdDigest(os, args, _opts = {}) {
505
766
  }
506
767
  // Resolve existing entity slugs — digest only generates update ops for entities
507
768
  const { items: entityItems } = await os.query({ filter: 'all', limit: 9999 });
508
- const existingSlugs = new Set(entityItems.map(i => i.slug));
509
- // Warn about unknown entity slugs
510
- const unknownSlugs = result.data.entities_touched.filter(s => !existingSlugs.has(s));
511
- for (const slug of unknownSlugs) {
512
- 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);
513
778
  }
514
- const ops = digest(result.data, existingSlugs);
515
779
  return JSON.stringify(ops, null, 2);
516
780
  }
781
+ export async function cmdExport(_os, args, opts = {}) {
782
+ const EXPORT_USAGE = 'Usage: hafez export --okf [--out <dir>]';
783
+ const { flags } = parseArgs(args, { okf: 'boolean', out: 'string' }, EXPORT_USAGE);
784
+ if (flags.okf !== true)
785
+ throw new UsageError(EXPORT_USAGE);
786
+ const outDir = typeof flags.out === 'string' ? resolve(flags.out) : resolve(process.cwd(), 'okf-export');
787
+ if (!opts.vaultPath)
788
+ throw new HafezError('VALIDATION_FAILED', 'export requires a resolved vault path (CommandOpts.vaultPath)');
789
+ const { exportOkf } = await import('../export-okf.js');
790
+ const exportReport = exportOkf(opts.vaultPath, outDir);
791
+ if (opts.json)
792
+ return JSON.stringify(exportReport, null, 2);
793
+ const lines = [`Exported ${exportReport.entities} entities, ${exportReport.knowledge} knowledge, ${exportReport.sessions} sessions to ${exportReport.outDir}`];
794
+ for (const s of exportReport.skipped)
795
+ lines.push(` skipped ${s.file}: ${s.reason}`);
796
+ if (exportReport.unresolvedLinks > 0)
797
+ lines.push(` unresolved links: ${exportReport.unresolvedLinks}`);
798
+ return lines.join('\n');
799
+ }