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.
- package/README.md +83 -32
- package/dist/apply.d.ts +33 -0
- package/dist/apply.js +693 -0
- package/dist/batch-ops.d.ts +372 -26
- package/dist/batch-ops.js +355 -73
- package/dist/cli/args.d.ts +48 -6
- package/dist/cli/args.js +74 -9
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +28 -3
- package/dist/cli/commands.js +440 -157
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +55 -0
- package/dist/cli/index.js +215 -153
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +51 -0
- package/dist/cli/upgrade.js +351 -0
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +65 -6
- package/dist/contracts.js +148 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +40 -2
- package/dist/db.js +363 -66
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +135 -88
- package/dist/document.js +665 -379
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +50 -20
- package/dist/git.js +165 -48
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +301 -826
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +98 -64
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/schema.js +4 -4
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +78 -22
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +51 -5
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -186
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
- package/skill/SKILL.md +0 -196
package/dist/cli/commands.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// src/cli/commands.ts
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
-
import {
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
53
|
-
const
|
|
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,
|
|
73
|
-
// --kind knowledge: query knowledge only
|
|
74
|
-
//
|
|
75
|
-
if (
|
|
76
|
-
const
|
|
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 (
|
|
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
|
-
|
|
120
|
-
const
|
|
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': '
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
166
|
-
|
|
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': '
|
|
169
|
-
'clear-actions': 'boolean',
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
395
|
-
//
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
442
|
-
|
|
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
|
|
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 =>
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
+
}
|