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