hafez 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -9
- package/dist/apply.d.ts +30 -0
- package/dist/apply.js +582 -0
- package/dist/batch-ops.d.ts +470 -0
- package/dist/batch-ops.js +446 -0
- package/dist/cli/args.d.ts +19 -0
- package/dist/cli/args.js +53 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +24 -108
- package/dist/cli/commands.js +297 -313
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +44 -5
- package/dist/cli/index.d.ts +41 -0
- package/dist/cli/index.js +172 -172
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli/upgrade.d.ts +42 -0
- package/dist/cli/upgrade.js +305 -0
- package/dist/cli.js +0 -0
- package/dist/contracts.d.ts +55 -6
- package/dist/contracts.js +110 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +27 -2
- package/dist/db.js +318 -56
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +117 -0
- package/dist/document.js +578 -0
- package/dist/export-okf.js +48 -34
- package/dist/git.d.ts +7 -8
- package/dist/git.js +198 -63
- package/dist/index.d.ts +3 -4
- package/dist/index.js +302 -915
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +71 -13
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +106 -86
- package/dist/schema.js +4 -4
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +86 -12
- package/dist/vault.d.ts +51 -6
- package/dist/vault.js +114 -11
- package/package.json +2 -2
- package/skill/SKILL.md +51 -110
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -167
- package/dist/sections.d.ts +0 -54
- package/dist/sections.js +0 -297
package/dist/cli/commands.js
CHANGED
|
@@ -1,56 +1,120 @@
|
|
|
1
1
|
// src/cli/commands.ts
|
|
2
|
-
import { z } from 'zod';
|
|
3
2
|
import { readFileSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { parseArgs, splitList, str, UsageError } from './args.js';
|
|
4
5
|
import { HafezError } from '../types.js';
|
|
5
|
-
import {
|
|
6
|
+
import { BatchOperationSchema, normalizeAliases, opErrorLine, batchInvalidHeader } from '../batch-ops.js';
|
|
6
7
|
import { formatBatchError } from './batch-errors.js';
|
|
7
8
|
import { parseDigestInput, digest } from '../digest.js';
|
|
8
9
|
import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
|
|
9
10
|
function jsonOut(data) {
|
|
10
11
|
return JSON.stringify(data, null, 2);
|
|
11
12
|
}
|
|
13
|
+
const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full]';
|
|
14
|
+
const READ_FLAGS = { depth: 'string' };
|
|
12
15
|
export async function cmdRead(os, args, opts = {}) {
|
|
13
|
-
const
|
|
16
|
+
const { flags, positionals } = parseArgs(args, READ_FLAGS, READ_USAGE);
|
|
17
|
+
const slug = positionals[0];
|
|
14
18
|
if (!slug)
|
|
15
|
-
throw new
|
|
16
|
-
const depth = (
|
|
19
|
+
throw new UsageError(READ_USAGE);
|
|
20
|
+
const depth = (str(flags.depth) ?? 'summary');
|
|
17
21
|
const result = await os.read(slug, depth);
|
|
18
22
|
if (opts.json)
|
|
19
23
|
return jsonOut(result);
|
|
20
24
|
const fm = result.frontmatter;
|
|
21
|
-
if ('
|
|
25
|
+
if (result.kind === 'entity')
|
|
22
26
|
return formatEntityHeader(fm, result.body);
|
|
23
27
|
return formatKnowledgeHeader(fm, result.body);
|
|
24
28
|
}
|
|
29
|
+
const QUERY_USAGE = `Usage: hafez query [--kind entity|knowledge|all] [--filter ...] [--type t] [--parent slug] [--related-to slug] [--domain d] [--confidence c] [--subtype s] [--tag t] [--since d] [--before d] [--created-since d] [--created-before d] [--sort-by f] [--sort-order asc|desc] [--limit n] [--offset n]
|
|
30
|
+
|
|
31
|
+
--filter active|paused|done matches entity status; knowledge notes carry no
|
|
32
|
+
status, so under --kind all they are always included alongside the matching
|
|
33
|
+
entities. --filter stale|capture are entity-only views and return no
|
|
34
|
+
knowledge. --filter/--type/--parent are rejected with --kind knowledge, and
|
|
35
|
+
--confidence/--subtype are rejected with --kind entity, rather than being
|
|
36
|
+
silently dropped. --related-to takes no other query flags.`;
|
|
37
|
+
const QUERY_FLAGS = {
|
|
38
|
+
kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
|
|
39
|
+
domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
|
|
40
|
+
since: 'string', before: 'string', 'created-since': 'string', 'created-before': 'string',
|
|
41
|
+
'sort-by': 'string', 'sort-order': 'string', limit: 'string', offset: 'string',
|
|
42
|
+
};
|
|
43
|
+
/** Every flag the query parser accepts, for the branch-support matrix. */
|
|
44
|
+
export const QUERY_FLAG_NAMES = Object.keys(QUERY_FLAGS);
|
|
45
|
+
/** Which flags each kind branch actually applies. A branch that cannot honour
|
|
46
|
+
* a flag rejects it (below) instead of dropping it: `--kind knowledge --filter
|
|
47
|
+
* stale` used to return every knowledge note with exit 0, and `--confidence`
|
|
48
|
+
* never reached an entity query at all. */
|
|
49
|
+
export const QUERY_FLAG_SUPPORT = {
|
|
50
|
+
entity: ['kind', 'filter', 'type', 'parent', 'domain', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
|
|
51
|
+
knowledge: ['kind', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
|
|
52
|
+
all: ['kind', 'filter', 'type', 'parent', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
|
|
53
|
+
'related-to': ['related-to'],
|
|
54
|
+
};
|
|
55
|
+
const FLAG_SCOPE_REASON = {
|
|
56
|
+
filter: 'it selects entity status (or the entity-only stale/capture views); knowledge notes carry no status',
|
|
57
|
+
type: 'entity types only',
|
|
58
|
+
parent: 'entity hierarchy only',
|
|
59
|
+
confidence: 'knowledge notes only',
|
|
60
|
+
subtype: 'knowledge notes only',
|
|
61
|
+
};
|
|
62
|
+
const QUERY_KINDS = ['entity', 'knowledge', 'all'];
|
|
63
|
+
function rejectUnsupportedFlags(branch, flags) {
|
|
64
|
+
const supported = new Set(QUERY_FLAG_SUPPORT[branch]);
|
|
65
|
+
for (const [name, value] of Object.entries(flags)) {
|
|
66
|
+
if (value === undefined || supported.has(name))
|
|
67
|
+
continue;
|
|
68
|
+
const where = branch === 'related-to'
|
|
69
|
+
? 'with --related-to (it returns every entity and knowledge note linked to the slug)'
|
|
70
|
+
: `with --kind ${branch}`;
|
|
71
|
+
const reason = FLAG_SCOPE_REASON[name] ? ` — ${FLAG_SCOPE_REASON[name]}` : '';
|
|
72
|
+
throw new UsageError(`--${name} is not supported ${where}${reason}\n${QUERY_USAGE}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Paging flags reach SQL as text — parse them strictly here so `--limit abc`
|
|
76
|
+
* is a usage error rather than a prepare-time SqliteError. */
|
|
77
|
+
function intFlag(raw, name) {
|
|
78
|
+
if (raw === undefined)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (!/^\d+$/.test(raw))
|
|
81
|
+
throw new UsageError(`--${name} must be a non-negative integer (got "${raw}")\n${QUERY_USAGE}`);
|
|
82
|
+
return Number(raw);
|
|
83
|
+
}
|
|
25
84
|
export async function cmdQuery(os, args, opts = {}) {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
85
|
+
const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
|
|
86
|
+
const relatedTo = str(flags['related-to']);
|
|
87
|
+
const branch = relatedTo !== undefined ? 'related-to' : (str(flags.kind) ?? 'entity');
|
|
88
|
+
if (branch !== 'related-to' && !QUERY_KINDS.includes(branch)) {
|
|
89
|
+
throw new UsageError(`--kind must be one of ${QUERY_KINDS.join(', ')} (got "${branch}")\n${QUERY_USAGE}`);
|
|
90
|
+
}
|
|
91
|
+
rejectUnsupportedFlags(branch, flags);
|
|
92
|
+
const filter = (str(flags.filter) ?? 'active');
|
|
93
|
+
const type = str(flags.type);
|
|
94
|
+
const parent = str(flags.parent);
|
|
95
|
+
const domain = str(flags.domain);
|
|
96
|
+
const confidence = str(flags.confidence);
|
|
97
|
+
const subtype = str(flags.subtype);
|
|
98
|
+
const since = str(flags.since);
|
|
99
|
+
const before = str(flags.before);
|
|
100
|
+
const createdSince = str(flags['created-since']);
|
|
101
|
+
const createdBefore = str(flags['created-before']);
|
|
102
|
+
const tagRaw = str(flags.tag);
|
|
38
103
|
const tags = tagRaw ? tagRaw.split(',').map(t => t.trim()).filter(Boolean) : undefined;
|
|
39
|
-
const sortBy =
|
|
40
|
-
const sortOrder =
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const limit = limitRaw != null ? parseInt(limitRaw, 10) : undefined;
|
|
44
|
-
const offset = offsetRaw != null ? parseInt(offsetRaw, 10) : undefined;
|
|
104
|
+
const sortBy = str(flags['sort-by']);
|
|
105
|
+
const sortOrder = str(flags['sort-order']);
|
|
106
|
+
const limit = intFlag(str(flags.limit), 'limit');
|
|
107
|
+
const offset = intFlag(str(flags.offset), 'offset');
|
|
45
108
|
const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
|
|
109
|
+
const timeOpts = { since, before, createdSince, createdBefore };
|
|
46
110
|
// --related-to: always returns both entities and knowledge
|
|
47
111
|
if (relatedTo) {
|
|
48
112
|
const result = await os.related_to(relatedTo);
|
|
49
113
|
if (opts.json)
|
|
50
114
|
return jsonOut(result);
|
|
51
115
|
const { items: results, total } = result;
|
|
52
|
-
const entities = results.filter(
|
|
53
|
-
const knowledge = results.filter(
|
|
116
|
+
const entities = results.filter(r => r.kind === 'entity');
|
|
117
|
+
const knowledge = results.filter(r => r.kind === 'knowledge');
|
|
54
118
|
const sections = [];
|
|
55
119
|
if (entities.length > 0)
|
|
56
120
|
sections.push('## Entities\n\n' + formatQueryTable(entities, total));
|
|
@@ -58,20 +122,19 @@ export async function cmdQuery(os, args, opts = {}) {
|
|
|
58
122
|
sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge, total));
|
|
59
123
|
return sections.length > 0 ? sections.join('\n\n') : 'No results.';
|
|
60
124
|
}
|
|
61
|
-
const queryOpts = { filter, type, parent, domain,
|
|
62
|
-
// --kind knowledge: query knowledge only
|
|
63
|
-
//
|
|
64
|
-
if (
|
|
65
|
-
const
|
|
66
|
-
const kOpts = { domain: domain ?? undefined, confidence, subtype, tags, ...sortOpts };
|
|
125
|
+
const queryOpts = { filter, type, parent, domain, ...timeOpts, tags, ...sortOpts };
|
|
126
|
+
// --kind knowledge: query knowledge only. The time window applies here too —
|
|
127
|
+
// knowledge rows carry created (and last-touched for the plan subtype).
|
|
128
|
+
if (branch === 'knowledge') {
|
|
129
|
+
const kOpts = { domain: domain ?? undefined, confidence, subtype, ...timeOpts, tags, ...sortOpts };
|
|
67
130
|
const result = await os.query_knowledge(kOpts);
|
|
68
131
|
if (opts.json)
|
|
69
132
|
return jsonOut(result);
|
|
70
133
|
return formatKnowledgeTable(result.items, result.total);
|
|
71
134
|
}
|
|
72
135
|
// --kind all: query both entities and knowledge via unified query
|
|
73
|
-
if (
|
|
74
|
-
const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence });
|
|
136
|
+
if (branch === 'all') {
|
|
137
|
+
const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence, subtype });
|
|
75
138
|
if (opts.json)
|
|
76
139
|
return jsonOut(result);
|
|
77
140
|
const { items, total } = result;
|
|
@@ -92,129 +155,161 @@ export async function cmdQuery(os, args, opts = {}) {
|
|
|
92
155
|
return jsonOut(result);
|
|
93
156
|
return formatQueryTable(result.items, result.total);
|
|
94
157
|
}
|
|
158
|
+
const SEARCH_USAGE = 'Usage: hafez search <terms> [--kind entity|knowledge|all]';
|
|
159
|
+
const SEARCH_FLAGS = { kind: 'string' };
|
|
95
160
|
export async function cmdSearch(os, args, opts = {}) {
|
|
96
|
-
const
|
|
161
|
+
const { flags, positionals } = parseArgs(args, SEARCH_FLAGS, SEARCH_USAGE);
|
|
162
|
+
const terms = positionals.join(' ');
|
|
97
163
|
if (!terms)
|
|
98
|
-
throw new
|
|
99
|
-
const kind = (
|
|
164
|
+
throw new UsageError(SEARCH_USAGE);
|
|
165
|
+
const kind = (str(flags.kind) ?? 'all');
|
|
100
166
|
const results = await os.search(terms, kind);
|
|
101
167
|
if (opts.json)
|
|
102
168
|
return jsonOut(results);
|
|
103
169
|
return formatSearchResults(results);
|
|
104
170
|
}
|
|
171
|
+
// --status is knowledge/plan only, and --purpose needs --type project: a field
|
|
172
|
+
// whose section the chosen template lacks is rejected, never silently dropped.
|
|
173
|
+
const CREATE_USAGE = 'Usage: hafez create <entity|knowledge> <name> [options]\n entity: --type capture|entity|project [--purpose text (project only)] [--brief text] [--add-action text] …\n knowledge: [--subtype insight|plan] [--status active|done (plan only)] [--synthesis text (insight only)] …';
|
|
174
|
+
/** Exported so tests can assert the flag × kind matrix is exhaustive. */
|
|
175
|
+
export const CREATE_FLAGS = {
|
|
176
|
+
type: 'string', purpose: 'string', description: 'string', resource: 'string',
|
|
177
|
+
domain: 'string', parent: 'string', related: 'string', tags: 'string',
|
|
178
|
+
brief: 'string', 'add-action': 'string',
|
|
179
|
+
subtype: 'string', synthesis: 'string', insight: 'string',
|
|
180
|
+
// knowledge, plan subtype only — insights have no lifecycle
|
|
181
|
+
status: 'string',
|
|
182
|
+
};
|
|
183
|
+
/** Flags whose value is a comma list. Everything else is a plain string. */
|
|
184
|
+
const CREATE_LIST_FLAGS = new Set(['domain', 'related', 'tags']);
|
|
185
|
+
/** Every provided create flag → its field name (dashes become underscores),
|
|
186
|
+
* kind-agnostic on purpose. Which fields a kind accepts is the Op Spec's
|
|
187
|
+
* call, not the CLI's: the per-kind hand-filter this replaces discarded
|
|
188
|
+
* whatever its branch didn't name (--brief on knowledge, --subtype on
|
|
189
|
+
* entity) and still exited 0 with the value nowhere on disk. Only provided
|
|
190
|
+
* flags are included — a present-but-undefined key would trip the same
|
|
191
|
+
* strict shape that does the rejecting. */
|
|
192
|
+
function createFields(flags) {
|
|
193
|
+
const fields = {};
|
|
194
|
+
for (const flag of Object.keys(CREATE_FLAGS)) {
|
|
195
|
+
if (flags[flag] === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
fields[flag.replace(/-/g, '_')] = CREATE_LIST_FLAGS.has(flag)
|
|
198
|
+
? splitList(flags[flag])
|
|
199
|
+
: str(flags[flag]);
|
|
200
|
+
}
|
|
201
|
+
return fields;
|
|
202
|
+
}
|
|
105
203
|
export async function cmdCreate(os, args, opts = {}) {
|
|
106
|
-
const
|
|
204
|
+
const { flags, positionals } = parseArgs(args, CREATE_FLAGS, CREATE_USAGE);
|
|
205
|
+
const kind = positionals[0];
|
|
107
206
|
if (!kind)
|
|
108
|
-
throw new
|
|
109
|
-
const name =
|
|
207
|
+
throw new UsageError(CREATE_USAGE);
|
|
208
|
+
const name = positionals.slice(1).join(' ');
|
|
110
209
|
if (!name)
|
|
111
|
-
throw new
|
|
210
|
+
throw new UsageError(`Usage: hafez create ${kind} <name> [options]`);
|
|
211
|
+
const fields = createFields(flags);
|
|
112
212
|
if (kind === 'entity') {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
purpose: getFlag(args, '--purpose'),
|
|
119
|
-
description: getFlag(args, '--description'),
|
|
120
|
-
resource: getFlag(args, '--resource'),
|
|
121
|
-
domain: getFlag(args, '--domain')?.split(',').map(d => d.trim()),
|
|
122
|
-
parent: getFlag(args, '--parent'),
|
|
123
|
-
related: getFlag(args, '--related')?.split(','),
|
|
124
|
-
tags: getFlag(args, '--tags')?.split(','),
|
|
125
|
-
brief: getFlag(args, '--brief'),
|
|
126
|
-
add_action: getFlag(args, '--add-action'),
|
|
127
|
-
});
|
|
213
|
+
// The only per-kind rule the CLI still owns: --type is required, and a
|
|
214
|
+
// missing one deserves better than a shape error naming a JSON field.
|
|
215
|
+
if (fields.type === undefined)
|
|
216
|
+
throw new UsageError('--type is required for entity creation');
|
|
217
|
+
const slug = await os.create('entity', name, fields);
|
|
128
218
|
return opts.json ? jsonOut({ slug }) : slug;
|
|
129
219
|
}
|
|
130
220
|
if (kind === 'knowledge') {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
related: getFlag(args, '--related')?.split(','),
|
|
137
|
-
tags: getFlag(args, '--tags')?.split(','),
|
|
138
|
-
});
|
|
221
|
+
// --insight passes through untouched: the Op Spec normalizes the alias
|
|
222
|
+
// (and drops it when --synthesis is also given). On entity it stays
|
|
223
|
+
// `insight` and the kind gate rejects it. --status flows through the
|
|
224
|
+
// same way; the subtype-aware gate accepts it on plans only.
|
|
225
|
+
const slug = await os.create('knowledge', name, fields);
|
|
139
226
|
return opts.json ? jsonOut({ slug }) : slug;
|
|
140
227
|
}
|
|
141
|
-
throw new
|
|
228
|
+
throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
|
|
142
229
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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"]';
|
|
231
|
+
/** Exported so tests can assert the table covers the update schema. */
|
|
232
|
+
export const UPDATE_FLAGS = {
|
|
233
|
+
status: 'string', 'current-state': 'string', brief: 'string',
|
|
234
|
+
'add-action': 'string', 'complete-action': 'string', 'remove-action': 'string',
|
|
235
|
+
'clear-actions': 'boolean',
|
|
236
|
+
description: 'string', resource: 'string', domain: 'string', confidence: 'string',
|
|
237
|
+
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',
|
|
241
|
+
// Removed flag, declared so it fails with guidance instead of a generic
|
|
242
|
+
// unknown-flag error: older installed skills/scripts still pass it, and
|
|
243
|
+
// "Updated <slug>" with the action discarded is a false success.
|
|
244
|
+
'next-action': 'string',
|
|
245
|
+
};
|
|
246
|
+
/** Flags whose value is a comma list. Everything else is a plain string. */
|
|
247
|
+
const UPDATE_LIST_FLAGS = new Set(['domain', 'tags', 'related']);
|
|
248
|
+
/** Flags where an empty string is the clear signal — exactly the nullable
|
|
249
|
+
* fields of the update shape. Every other flag passes its value through
|
|
250
|
+
* untouched, so `--status ""` reaches the enum and is rejected by name
|
|
251
|
+
* instead of silently no-opping (which is how it read before: the guard
|
|
252
|
+
* each flag got was an accident of which one was typed). */
|
|
253
|
+
const UPDATE_CLEARABLE_FLAGS = new Set(['brief', 'description', 'resource']);
|
|
254
|
+
/** Flags the generic loop skips because they are not a field of the update
|
|
255
|
+
* shape — each is hand-handled in cmdUpdate. */
|
|
256
|
+
export const UPDATE_SPECIAL_FLAGS = new Set(['log', 'agent', 'next-action']);
|
|
257
|
+
/** Update-shape fields with no flag of their own, and why. The exhaustiveness
|
|
258
|
+
* test consults this, so a gap here is a stated decision rather than the
|
|
259
|
+
* silent hole `add_actions` was. */
|
|
260
|
+
export const UPDATE_FIELDS_WITHOUT_FLAGS = {
|
|
261
|
+
session_log: 'composed by --log "type: summary" (+ --agent), never passed raw',
|
|
262
|
+
add_actions: 'array-valued: comma-splitting would mangle action text (actions carry commas), and the parser takes no repeated flags — batch payloads are the interface for multi-action writes',
|
|
263
|
+
};
|
|
264
|
+
/** Every provided update flag → its field name (dashes become underscores).
|
|
265
|
+
* Which fields the target accepts is the Op Spec's call, not the CLI's — the
|
|
266
|
+
* strict shape does all accepting and rejecting, exactly as on create. Only
|
|
267
|
+
* provided flags are included: a present-but-undefined key would trip that
|
|
268
|
+
* same strict shape. */
|
|
269
|
+
function updateFields(flags) {
|
|
147
270
|
const fields = {};
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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;
|
|
156
286
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
287
|
+
return fields;
|
|
288
|
+
}
|
|
289
|
+
export async function cmdUpdate(os, args, opts = {}) {
|
|
290
|
+
const { flags, positionals } = parseArgs(args, UPDATE_FLAGS, UPDATE_USAGE);
|
|
291
|
+
const slug = positionals[0];
|
|
292
|
+
if (!slug)
|
|
293
|
+
throw new UsageError(UPDATE_USAGE);
|
|
294
|
+
if (flags['next-action'] !== undefined) {
|
|
295
|
+
throw new UsageError('--next-action was removed in v1.0.2. Use --add-action (and --clear-actions) instead.');
|
|
164
296
|
}
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const removeAction = getFlag(args, '--remove-action');
|
|
173
|
-
if (removeAction)
|
|
174
|
-
fields.remove_action = removeAction;
|
|
175
|
-
if (args.includes('--clear-actions'))
|
|
176
|
-
fields.clear_actions = true;
|
|
177
|
-
const log = getFlag(args, '--log');
|
|
178
|
-
if (log) {
|
|
297
|
+
// --insight is the declared alias; the Op Spec normalizes it (and drops it
|
|
298
|
+
// when --synthesis is also given).
|
|
299
|
+
const fields = updateFields(flags);
|
|
300
|
+
// The one genuinely CLI-owned convenience: session_log is a nested object,
|
|
301
|
+
// so the flag pair composes it rather than naming a field.
|
|
302
|
+
const log = str(flags.log);
|
|
303
|
+
if (log !== undefined) {
|
|
179
304
|
const colonIdx = log.indexOf(':');
|
|
180
305
|
if (colonIdx === -1)
|
|
181
|
-
throw new
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
306
|
+
throw new UsageError('--log format: "type: summary". Types: progress, decision, blocker, research');
|
|
307
|
+
fields.session_log = {
|
|
308
|
+
type: log.slice(0, colonIdx).trim(),
|
|
309
|
+
summary: log.slice(colonIdx + 1).trim(),
|
|
310
|
+
agent: str(flags.agent) ?? 'claude',
|
|
311
|
+
};
|
|
186
312
|
}
|
|
187
|
-
// Shared metadata (entity + knowledge)
|
|
188
|
-
const description = getFlag(args, '--description');
|
|
189
|
-
if (description !== undefined)
|
|
190
|
-
fields.description = description;
|
|
191
|
-
const resource = getFlag(args, '--resource');
|
|
192
|
-
if (resource !== undefined)
|
|
193
|
-
fields.resource = resource;
|
|
194
|
-
const domainRaw = getFlag(args, '--domain');
|
|
195
|
-
if (domainRaw)
|
|
196
|
-
fields.domain = domainRaw.split(',').map((d) => d.trim());
|
|
197
|
-
const conf = getFlag(args, '--confidence');
|
|
198
|
-
if (conf)
|
|
199
|
-
fields.confidence = conf;
|
|
200
|
-
const tagsRaw = getFlag(args, '--tags');
|
|
201
|
-
if (tagsRaw)
|
|
202
|
-
fields.tags = tagsRaw.split(',').map((t) => t.trim());
|
|
203
|
-
const relatedRaw = getFlag(args, '--related');
|
|
204
|
-
if (relatedRaw)
|
|
205
|
-
fields.related = relatedRaw.split(',').map((r) => r.trim());
|
|
206
|
-
const insight = getFlag(args, '--insight');
|
|
207
|
-
if (insight)
|
|
208
|
-
fields.synthesis = insight;
|
|
209
|
-
const synthesis = getFlag(args, '--synthesis');
|
|
210
|
-
if (synthesis)
|
|
211
|
-
fields.synthesis = synthesis;
|
|
212
|
-
const addEvidence = getFlag(args, '--add-evidence');
|
|
213
|
-
if (addEvidence)
|
|
214
|
-
fields.add_evidence = addEvidence;
|
|
215
|
-
const addSource = getFlag(args, '--add-source');
|
|
216
|
-
if (addSource)
|
|
217
|
-
fields.add_source = addSource;
|
|
218
313
|
const result = await os.update(slug, fields);
|
|
219
314
|
if (opts.json)
|
|
220
315
|
return jsonOut({ slug, ...result });
|
|
@@ -224,23 +319,17 @@ export async function cmdUpdate(os, args, opts = {}) {
|
|
|
224
319
|
}
|
|
225
320
|
return lines.join('\n');
|
|
226
321
|
}
|
|
322
|
+
const LINK_FLAGS = { relation: 'string' };
|
|
227
323
|
function parseLinkArgs(args, verb) {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const positionals = args.filter((a, i) => {
|
|
232
|
-
if (a === '--relation')
|
|
233
|
-
return false;
|
|
234
|
-
if (i > 0 && args[i - 1] === '--relation')
|
|
235
|
-
return false;
|
|
236
|
-
return !a.startsWith('--');
|
|
237
|
-
});
|
|
324
|
+
const usage = `Usage: hafez ${verb} <slug> <target> --relation <parent|related>`;
|
|
325
|
+
const { flags, positionals } = parseArgs(args, LINK_FLAGS, usage);
|
|
326
|
+
const relation = str(flags.relation);
|
|
238
327
|
const [slug, target] = positionals;
|
|
239
328
|
if (!slug || !target || !relation) {
|
|
240
|
-
throw new
|
|
329
|
+
throw new UsageError(usage);
|
|
241
330
|
}
|
|
242
331
|
if (!['parent', 'related'].includes(relation)) {
|
|
243
|
-
throw new
|
|
332
|
+
throw new UsageError(`Invalid relation: ${relation}. Must be parent or related.`);
|
|
244
333
|
}
|
|
245
334
|
return { slug, target, relation };
|
|
246
335
|
}
|
|
@@ -255,22 +344,24 @@ export async function cmdUnlink(os, args, opts = {}) {
|
|
|
255
344
|
return opts.json ? jsonOut({ slug, target, relation }) : `Unlinked ${slug} — ${target} (${relation})`;
|
|
256
345
|
}
|
|
257
346
|
/** Top-level capture command — aligns the CLI verb with the batch op name. */
|
|
347
|
+
const CAPTURE_USAGE = 'Usage: hafez capture <name> [--notes "text"]';
|
|
348
|
+
const CAPTURE_FLAGS = { notes: 'string' };
|
|
258
349
|
export async function cmdCapture(os, args, opts = {}) {
|
|
259
|
-
const
|
|
350
|
+
const { flags, positionals } = parseArgs(args, CAPTURE_FLAGS, CAPTURE_USAGE);
|
|
351
|
+
const name = positionals.join(' ');
|
|
260
352
|
if (!name)
|
|
261
|
-
throw new
|
|
262
|
-
const notes =
|
|
353
|
+
throw new UsageError(CAPTURE_USAGE);
|
|
354
|
+
const notes = str(flags.notes);
|
|
263
355
|
const slug = await os.capture(name, notes);
|
|
264
356
|
return opts.json ? jsonOut({ slug }) : slug;
|
|
265
357
|
}
|
|
358
|
+
const PROMOTE_USAGE = 'Usage: hafez promote <slug> <entity|project|knowledge>';
|
|
266
359
|
export async function cmdPromote(os, args, opts = {}) {
|
|
267
|
-
const
|
|
268
|
-
const target =
|
|
360
|
+
const { positionals } = parseArgs(args, {}, PROMOTE_USAGE);
|
|
361
|
+
const [slug, target] = positionals;
|
|
269
362
|
if (!slug || !target)
|
|
270
|
-
throw new
|
|
271
|
-
|
|
272
|
-
throw new Error(`Invalid target: ${target}. Must be entity, project, or knowledge.`);
|
|
273
|
-
}
|
|
363
|
+
throw new UsageError(PROMOTE_USAGE);
|
|
364
|
+
// Target validity is core's rule (contract canPromoteTo) — no CLI re-check.
|
|
274
365
|
await os.promote(slug, target);
|
|
275
366
|
if (opts.json)
|
|
276
367
|
return jsonOut({ slug, target });
|
|
@@ -304,10 +395,13 @@ export async function cmdStats(os, _args, opts = {}) {
|
|
|
304
395
|
return jsonOut(stats);
|
|
305
396
|
return formatStats(stats);
|
|
306
397
|
}
|
|
398
|
+
const CHANGELOG_USAGE = 'Usage: hafez changelog --since <date|relative>\nExamples: --since 2026-03-20, --since 7.days.ago, --since 1.week.ago';
|
|
399
|
+
const CHANGELOG_FLAGS = { since: 'string' };
|
|
307
400
|
export async function cmdChangelog(os, args, opts = {}) {
|
|
308
|
-
const
|
|
401
|
+
const { flags } = parseArgs(args, CHANGELOG_FLAGS, CHANGELOG_USAGE);
|
|
402
|
+
const since = str(flags.since);
|
|
309
403
|
if (!since)
|
|
310
|
-
throw new
|
|
404
|
+
throw new UsageError(CHANGELOG_USAGE);
|
|
311
405
|
const entries = await os.changelog(since);
|
|
312
406
|
if (opts.json)
|
|
313
407
|
return jsonOut(entries);
|
|
@@ -315,127 +409,11 @@ export async function cmdChangelog(os, args, opts = {}) {
|
|
|
315
409
|
}
|
|
316
410
|
// --- Batch ---
|
|
317
411
|
// All enums are built from contracts.ts tuples so help-renderer, file validators,
|
|
318
|
-
// and CLI-input validator share a single source of truth
|
|
319
|
-
//
|
|
412
|
+
// and CLI-input validator share a single source of truth — drift is structurally
|
|
413
|
+
// impossible (the Op Spec table in batch-ops.ts is the only consumer).
|
|
320
414
|
// Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
|
|
321
415
|
// (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
|
|
322
416
|
// hints and for catching typos like `{"kind": "parent"}` on a link op.
|
|
323
|
-
export const SessionLogSchema = z.object({
|
|
324
|
-
type: z.enum([...SESSION_LOG_TYPES]),
|
|
325
|
-
summary: z.string(),
|
|
326
|
-
body: z.string().optional(),
|
|
327
|
-
agent: z.string(),
|
|
328
|
-
}).strict();
|
|
329
|
-
export const UpdateFieldsSchema = z.object({
|
|
330
|
-
status: z.enum([...ENTITY_STATUSES]).optional(),
|
|
331
|
-
current_state: z.string().optional(),
|
|
332
|
-
session_log: SessionLogSchema.optional(),
|
|
333
|
-
brief: z.string().nullable().optional(),
|
|
334
|
-
add_action: z.string().optional(),
|
|
335
|
-
add_actions: z.array(z.string()).optional(),
|
|
336
|
-
complete_action: z.string().optional(),
|
|
337
|
-
remove_action: z.string().optional(),
|
|
338
|
-
clear_actions: z.boolean().optional(),
|
|
339
|
-
// Knowledge metadata
|
|
340
|
-
description: z.string().nullable().optional(),
|
|
341
|
-
resource: z.string().nullable().optional(),
|
|
342
|
-
domain: z.array(z.string()).optional(),
|
|
343
|
-
confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
|
|
344
|
-
tags: z.array(z.string()).optional(),
|
|
345
|
-
related: z.array(z.string()).optional(),
|
|
346
|
-
synthesis: z.string().optional(),
|
|
347
|
-
insight: z.string().optional(), // alias for synthesis (normalized in parseBatchInput)
|
|
348
|
-
add_evidence: z.string().optional(),
|
|
349
|
-
add_source: z.string().optional(),
|
|
350
|
-
}).strict();
|
|
351
|
-
const CreateEntityFieldsSchema = z.object({
|
|
352
|
-
type: z.enum([...ENTITY_TYPES]),
|
|
353
|
-
purpose: z.string().optional(),
|
|
354
|
-
description: z.string().optional(),
|
|
355
|
-
resource: z.string().optional(),
|
|
356
|
-
domain: z.array(z.string()).optional(),
|
|
357
|
-
parent: z.string().optional(),
|
|
358
|
-
related: z.array(z.string()).optional(),
|
|
359
|
-
tags: z.array(z.string()).optional(),
|
|
360
|
-
brief: z.string().optional(),
|
|
361
|
-
add_action: z.string().optional(),
|
|
362
|
-
add_actions: z.array(z.string()).optional(),
|
|
363
|
-
}).strict();
|
|
364
|
-
const CreateKnowledgeFieldsSchema = z.object({
|
|
365
|
-
subtype: z.enum([...VALID_SUBTYPES]).optional(),
|
|
366
|
-
synthesis: z.string().optional(),
|
|
367
|
-
insight: z.string().optional(),
|
|
368
|
-
description: z.string().optional(),
|
|
369
|
-
domain: z.array(z.string()).optional(),
|
|
370
|
-
related: z.array(z.string()).optional(),
|
|
371
|
-
tags: z.array(z.string()).optional(),
|
|
372
|
-
'session-date': z.string().optional(),
|
|
373
|
-
}).strict();
|
|
374
|
-
const CreateSessionFieldsSchema = z.object({
|
|
375
|
-
synthesis: z.string().optional(),
|
|
376
|
-
insight: z.string().optional(),
|
|
377
|
-
related: z.array(z.string()).optional(),
|
|
378
|
-
'session-date': z.string().optional(),
|
|
379
|
-
}).strict();
|
|
380
|
-
const CreateEntityBranch = z.object({
|
|
381
|
-
op: z.literal('create'),
|
|
382
|
-
kind: z.literal('entity'),
|
|
383
|
-
name: z.string(),
|
|
384
|
-
fields: CreateEntityFieldsSchema,
|
|
385
|
-
}).strict();
|
|
386
|
-
const CreateKnowledgeBranch = z.object({
|
|
387
|
-
op: z.literal('create'),
|
|
388
|
-
kind: z.literal('knowledge'),
|
|
389
|
-
name: z.string(),
|
|
390
|
-
fields: CreateKnowledgeFieldsSchema.optional(),
|
|
391
|
-
}).strict();
|
|
392
|
-
const CreateSessionBranch = z.object({
|
|
393
|
-
op: z.literal('create'),
|
|
394
|
-
kind: z.literal('session'),
|
|
395
|
-
name: z.string(),
|
|
396
|
-
fields: CreateSessionFieldsSchema.optional(),
|
|
397
|
-
}).strict();
|
|
398
|
-
// Nested discriminated union: outer on `op`, inner on `kind` for create branch.
|
|
399
|
-
// A flat `discriminatedUnion('op', [...])` cannot work because three branches share
|
|
400
|
-
// op=create — Zod requires unique discriminator values per branch and throws at
|
|
401
|
-
// schema construction time if violated.
|
|
402
|
-
const CreateBranch = z.discriminatedUnion('kind', [
|
|
403
|
-
CreateEntityBranch,
|
|
404
|
-
CreateKnowledgeBranch,
|
|
405
|
-
CreateSessionBranch,
|
|
406
|
-
]);
|
|
407
|
-
export const BatchOperationSchema = z.discriminatedUnion('op', [
|
|
408
|
-
z.object({
|
|
409
|
-
op: z.literal('update'),
|
|
410
|
-
slug: z.string(),
|
|
411
|
-
fields: UpdateFieldsSchema,
|
|
412
|
-
}).strict(),
|
|
413
|
-
// create is a nested discriminatedUnion on `kind` — see CreateBranch above.
|
|
414
|
-
// discriminatedUnion accepts a ZodDiscriminatedUnion as a branch via option-flattening.
|
|
415
|
-
CreateBranch,
|
|
416
|
-
z.object({
|
|
417
|
-
op: z.literal('capture'),
|
|
418
|
-
name: z.string(),
|
|
419
|
-
notes: z.string().optional(),
|
|
420
|
-
}).strict(),
|
|
421
|
-
z.object({
|
|
422
|
-
op: z.literal('link'),
|
|
423
|
-
slug: z.string(),
|
|
424
|
-
target: z.string(),
|
|
425
|
-
relation: z.enum(['parent', 'related']),
|
|
426
|
-
}).strict(),
|
|
427
|
-
z.object({
|
|
428
|
-
op: z.literal('unlink'),
|
|
429
|
-
slug: z.string(),
|
|
430
|
-
target: z.string(),
|
|
431
|
-
relation: z.enum(['parent', 'related']),
|
|
432
|
-
}).strict(),
|
|
433
|
-
z.object({
|
|
434
|
-
op: z.literal('promote'),
|
|
435
|
-
slug: z.string(),
|
|
436
|
-
target: z.enum(['entity', 'project', 'knowledge']),
|
|
437
|
-
}).strict(),
|
|
438
|
-
]);
|
|
439
417
|
export function parseBatchInput(json) {
|
|
440
418
|
let parsed;
|
|
441
419
|
try {
|
|
@@ -475,19 +453,12 @@ export function parseBatchInput(json) {
|
|
|
475
453
|
continue;
|
|
476
454
|
}
|
|
477
455
|
const op = result.data;
|
|
478
|
-
// Alias
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
if (op.fields.insight && !op.fields.synthesis) {
|
|
482
|
-
op.fields.synthesis = op.fields.insight;
|
|
483
|
-
delete op.fields.insight;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
operations.push(op);
|
|
456
|
+
// Alias normalization is the Op Spec's (normalizeAliases) — the CLI just
|
|
457
|
+
// hands back canonical ops so `batch --dry-run` output names real fields.
|
|
458
|
+
operations.push(normalizeAliases(op));
|
|
487
459
|
}
|
|
488
460
|
if (failureBlocks.length > 0) {
|
|
489
|
-
|
|
490
|
-
throw new HafezError('VALIDATION_FAILED', header, failureBlocks);
|
|
461
|
+
throw new HafezError('VALIDATION_FAILED', batchInvalidHeader(failureBlocks.length, parsed.length), failureBlocks);
|
|
491
462
|
}
|
|
492
463
|
return operations;
|
|
493
464
|
}
|
|
@@ -517,9 +488,12 @@ function readStdin() {
|
|
|
517
488
|
process.stdin.on('error', reject);
|
|
518
489
|
});
|
|
519
490
|
}
|
|
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' };
|
|
520
493
|
export async function cmdBatch(os, args, opts = {}) {
|
|
521
|
-
const
|
|
522
|
-
const
|
|
494
|
+
const { flags } = parseArgs(args, BATCH_FLAGS, BATCH_USAGE);
|
|
495
|
+
const dryRun = flags['dry-run'] === true;
|
|
496
|
+
const file = str(flags.file);
|
|
523
497
|
const input = file ? readPayloadFile(file) : await readStdin();
|
|
524
498
|
if (!input.trim()) {
|
|
525
499
|
throw new HafezError('VALIDATION_FAILED', 'No input received. Pipe JSON array to stdin (echo \'[...]\' | hafez batch) or pass --file payload.json');
|
|
@@ -533,7 +507,9 @@ export async function cmdBatch(os, args, opts = {}) {
|
|
|
533
507
|
return jsonOut(report);
|
|
534
508
|
if (!report.valid) {
|
|
535
509
|
const invalidCount = report.operations.filter(o => o.errors.length > 0).length;
|
|
536
|
-
const lines = report.operations.flatMap(o => o.errors.map(e =>
|
|
510
|
+
const lines = report.operations.flatMap(o => o.errors.map(e => opErrorLine(o.index, o.op, o.slug, e)));
|
|
511
|
+
// Dry-run's own header: it reports a hypothetical, so it says "would be
|
|
512
|
+
// applied" rather than the refusal wording batchInvalidHeader carries.
|
|
537
513
|
throw new HafezError('VALIDATION_FAILED', `Batch invalid: ${invalidCount} of ${operations.length} operation${operations.length === 1 ? '' : 's'} failed validation (0 would be applied)`, lines);
|
|
538
514
|
}
|
|
539
515
|
const out = [`Batch valid: ${operations.length} operations would be applied`];
|
|
@@ -556,13 +532,22 @@ export async function cmdBatch(os, args, opts = {}) {
|
|
|
556
532
|
let output = `Batch complete: ${operations.length} operations`;
|
|
557
533
|
if (created.length > 0)
|
|
558
534
|
output += ` (created: ${created.join(', ')})`;
|
|
559
|
-
|
|
535
|
+
// Skipped ops carry the same warning line dry-run printed for them.
|
|
536
|
+
const lines = [output];
|
|
537
|
+
results.forEach((r, i) => {
|
|
538
|
+
if (r.status === 'skipped' && r.warning)
|
|
539
|
+
lines.push(`Warning: op[${i}] ${r.warning}`);
|
|
540
|
+
});
|
|
541
|
+
return lines.join('\n');
|
|
560
542
|
}
|
|
543
|
+
const DIGEST_USAGE = 'Usage: hafez digest [--file summary.json] (or pipe JSON to stdin)';
|
|
544
|
+
const DIGEST_FLAGS = { file: 'string' };
|
|
561
545
|
export async function cmdDigest(os, args, _opts = {}) {
|
|
562
|
-
const
|
|
546
|
+
const { flags } = parseArgs(args, DIGEST_FLAGS, DIGEST_USAGE);
|
|
547
|
+
const file = str(flags.file);
|
|
563
548
|
const raw = file ? readPayloadFile(file) : await readStdin();
|
|
564
549
|
if (!raw.trim()) {
|
|
565
|
-
throw new
|
|
550
|
+
throw new UsageError('No input received. Pipe JSON object to stdin (echo \'{"entities_touched":...}\' | hafez digest) or pass --file summary.json');
|
|
566
551
|
}
|
|
567
552
|
// Parse JSON
|
|
568
553
|
let parsed;
|
|
@@ -570,12 +555,12 @@ export async function cmdDigest(os, args, _opts = {}) {
|
|
|
570
555
|
parsed = JSON.parse(raw);
|
|
571
556
|
}
|
|
572
557
|
catch {
|
|
573
|
-
throw new
|
|
558
|
+
throw new HafezError('VALIDATION_FAILED', 'Invalid JSON on stdin: ' + (raw.slice(0, 80)));
|
|
574
559
|
}
|
|
575
560
|
const result = parseDigestInput(parsed);
|
|
576
561
|
if (!result.success) {
|
|
577
562
|
const fields = result.error.issues.map(i => i.path.join('.')).join(', ');
|
|
578
|
-
throw new
|
|
563
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid digest input — missing or invalid fields: ${fields}`);
|
|
579
564
|
}
|
|
580
565
|
// Warn about unknown fields
|
|
581
566
|
if (result.unknownFields.length > 0) {
|
|
@@ -592,23 +577,22 @@ export async function cmdDigest(os, args, _opts = {}) {
|
|
|
592
577
|
const ops = digest(result.data, existingSlugs);
|
|
593
578
|
return JSON.stringify(ops, null, 2);
|
|
594
579
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
return result;
|
|
580
|
+
export async function cmdExport(_os, args, opts = {}) {
|
|
581
|
+
const EXPORT_USAGE = 'Usage: hafez export --okf [--out <dir>]';
|
|
582
|
+
const { flags } = parseArgs(args, { okf: 'boolean', out: 'string' }, EXPORT_USAGE);
|
|
583
|
+
if (flags.okf !== true)
|
|
584
|
+
throw new UsageError(EXPORT_USAGE);
|
|
585
|
+
const outDir = typeof flags.out === 'string' ? resolve(flags.out) : resolve(process.cwd(), 'okf-export');
|
|
586
|
+
if (!opts.vaultPath)
|
|
587
|
+
throw new HafezError('VALIDATION_FAILED', 'export requires a resolved vault path (CommandOpts.vaultPath)');
|
|
588
|
+
const { exportOkf } = await import('../export-okf.js');
|
|
589
|
+
const exportReport = exportOkf(opts.vaultPath, outDir);
|
|
590
|
+
if (opts.json)
|
|
591
|
+
return JSON.stringify(exportReport, null, 2);
|
|
592
|
+
const lines = [`Exported ${exportReport.entities} entities, ${exportReport.knowledge} knowledge, ${exportReport.sessions} sessions to ${exportReport.outDir}`];
|
|
593
|
+
for (const s of exportReport.skipped)
|
|
594
|
+
lines.push(` skipped ${s.file}: ${s.reason}`);
|
|
595
|
+
if (exportReport.unresolvedLinks > 0)
|
|
596
|
+
lines.push(` unresolved links: ${exportReport.unresolvedLinks}`);
|
|
597
|
+
return lines.join('\n');
|
|
614
598
|
}
|