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
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// src/batch-ops.ts
|
|
2
|
+
// The Op Spec (see CONTEXT.md): one table defining every batch operation —
|
|
3
|
+
// shape (Zod), semantic check, description, and example hints — from which
|
|
4
|
+
// validation, error text, and schema introspection all derive. Adding an op
|
|
5
|
+
// means one entry here plus one arm in the Apply table (src/apply.ts), which
|
|
6
|
+
// the compiler enforces: the arm table is keyed by this table's names.
|
|
7
|
+
//
|
|
8
|
+
// Every semantic gate lives here and runs exactly once, in the validate phase.
|
|
9
|
+
// Apply assumes the payload it receives passed these checks.
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { ALIAS_KEYS, ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, PLAN_STATUSES, getContract, } from './contracts.js';
|
|
12
|
+
import { slugify } from './vault.js';
|
|
13
|
+
import { HafezError } from './types.js';
|
|
14
|
+
// --- Shapes ---
|
|
15
|
+
// All enums are built from contracts.ts tuples so help-renderer, file validators,
|
|
16
|
+
// and CLI-input validator share a single source of truth.
|
|
17
|
+
// Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
|
|
18
|
+
// (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
|
|
19
|
+
// hints and for catching typos like `{"kind": "parent"}` on a link op.
|
|
20
|
+
export const SessionLogSchema = z.object({
|
|
21
|
+
type: z.enum([...SESSION_LOG_TYPES]),
|
|
22
|
+
summary: z.string(),
|
|
23
|
+
body: z.string().optional(),
|
|
24
|
+
agent: z.string(),
|
|
25
|
+
}).strict();
|
|
26
|
+
// Body-section text must have text after trimming. An empty or whitespace-only
|
|
27
|
+
// value (an agent expanding an unset variable) used to sail through the shape —
|
|
28
|
+
// on the action/evidence fields it was dropped by a truthiness guard in apply
|
|
29
|
+
// (exit 0, nothing on disk); on the section-setting fields below it was worse,
|
|
30
|
+
// because apply trusts the validate phase and `setSection(body, 'Synthesis',
|
|
31
|
+
// '')` wipes what was already there. Same false-success class the CLI loop
|
|
32
|
+
// closed for the enum flags, once silent and once destructive.
|
|
33
|
+
//
|
|
34
|
+
// The rule: every non-nullable string field that writes body-section text is
|
|
35
|
+
// nonEmptyText. The nullable ones (update's brief/description/resource) are
|
|
36
|
+
// excluded by construction — null is their documented clear signal, and the
|
|
37
|
+
// CLI maps `--brief ""` to it. Trimming also keeps accidental padding out of
|
|
38
|
+
// stored text.
|
|
39
|
+
const nonEmptyText = z.string().trim().min(1, 'must contain text — an empty value is rejected, never silently dropped');
|
|
40
|
+
export const UpdateFieldsSchema = z.object({
|
|
41
|
+
status: z.enum([...ENTITY_STATUSES]).optional(),
|
|
42
|
+
current_state: nonEmptyText.optional(),
|
|
43
|
+
session_log: SessionLogSchema.optional(),
|
|
44
|
+
brief: z.string().nullable().optional(),
|
|
45
|
+
add_action: nonEmptyText.optional(),
|
|
46
|
+
add_actions: z.array(nonEmptyText).optional(),
|
|
47
|
+
complete_action: nonEmptyText.optional(),
|
|
48
|
+
remove_action: nonEmptyText.optional(),
|
|
49
|
+
clear_actions: z.boolean().optional(),
|
|
50
|
+
// Knowledge metadata
|
|
51
|
+
description: z.string().nullable().optional(),
|
|
52
|
+
resource: z.string().nullable().optional(),
|
|
53
|
+
domain: z.array(z.string()).optional(),
|
|
54
|
+
confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
|
|
55
|
+
tags: z.array(z.string()).optional(),
|
|
56
|
+
related: z.array(z.string()).optional(),
|
|
57
|
+
synthesis: nonEmptyText.optional(),
|
|
58
|
+
insight: nonEmptyText.optional(), // alias for synthesis (normalized by normalizeAliases)
|
|
59
|
+
add_evidence: nonEmptyText.optional(),
|
|
60
|
+
add_source: nonEmptyText.optional(),
|
|
61
|
+
}).strict();
|
|
62
|
+
export const CreateEntityFieldsSchema = z.object({
|
|
63
|
+
type: z.enum([...ENTITY_TYPES]),
|
|
64
|
+
purpose: nonEmptyText.optional(),
|
|
65
|
+
description: z.string().optional(),
|
|
66
|
+
resource: z.string().optional(),
|
|
67
|
+
domain: z.array(z.string()).optional(),
|
|
68
|
+
parent: z.string().optional(),
|
|
69
|
+
related: z.array(z.string()).optional(),
|
|
70
|
+
tags: z.array(z.string()).optional(),
|
|
71
|
+
brief: nonEmptyText.optional(),
|
|
72
|
+
add_action: nonEmptyText.optional(),
|
|
73
|
+
add_actions: z.array(nonEmptyText).optional(),
|
|
74
|
+
}).strict();
|
|
75
|
+
export const CreateKnowledgeFieldsSchema = z.object({
|
|
76
|
+
subtype: z.enum([...VALID_SUBTYPES]).optional(),
|
|
77
|
+
// Plan-only: insights have no lifecycle, so the semantic check rejects
|
|
78
|
+
// status on any other subtype (the shape only pins the allowed values).
|
|
79
|
+
status: z.enum([...PLAN_STATUSES]).optional(),
|
|
80
|
+
synthesis: nonEmptyText.optional(),
|
|
81
|
+
insight: nonEmptyText.optional(),
|
|
82
|
+
description: z.string().optional(),
|
|
83
|
+
domain: z.array(z.string()).optional(),
|
|
84
|
+
related: z.array(z.string()).optional(),
|
|
85
|
+
tags: z.array(z.string()).optional(),
|
|
86
|
+
'session-date': z.string().optional(),
|
|
87
|
+
}).strict();
|
|
88
|
+
export const CreateSessionFieldsSchema = z.object({
|
|
89
|
+
synthesis: nonEmptyText.optional(),
|
|
90
|
+
insight: nonEmptyText.optional(),
|
|
91
|
+
related: z.array(z.string()).optional(),
|
|
92
|
+
'session-date': z.string().optional(),
|
|
93
|
+
}).strict();
|
|
94
|
+
const CreateEntityBranch = z.object({
|
|
95
|
+
op: z.literal('create'),
|
|
96
|
+
kind: z.literal('entity'),
|
|
97
|
+
name: z.string(),
|
|
98
|
+
fields: CreateEntityFieldsSchema,
|
|
99
|
+
}).strict();
|
|
100
|
+
const CreateKnowledgeBranch = z.object({
|
|
101
|
+
op: z.literal('create'),
|
|
102
|
+
kind: z.literal('knowledge'),
|
|
103
|
+
name: z.string(),
|
|
104
|
+
fields: CreateKnowledgeFieldsSchema.optional(),
|
|
105
|
+
}).strict();
|
|
106
|
+
const CreateSessionBranch = z.object({
|
|
107
|
+
op: z.literal('create'),
|
|
108
|
+
kind: z.literal('session'),
|
|
109
|
+
name: z.string(),
|
|
110
|
+
fields: CreateSessionFieldsSchema.optional(),
|
|
111
|
+
}).strict();
|
|
112
|
+
const UpdateBranch = z.object({
|
|
113
|
+
op: z.literal('update'),
|
|
114
|
+
slug: z.string(),
|
|
115
|
+
fields: UpdateFieldsSchema,
|
|
116
|
+
}).strict();
|
|
117
|
+
const CaptureBranch = z.object({
|
|
118
|
+
op: z.literal('capture'),
|
|
119
|
+
name: z.string(),
|
|
120
|
+
notes: z.string().optional(),
|
|
121
|
+
}).strict();
|
|
122
|
+
const LinkBranch = z.object({
|
|
123
|
+
op: z.literal('link'),
|
|
124
|
+
slug: z.string(),
|
|
125
|
+
target: z.string(),
|
|
126
|
+
relation: z.enum(['parent', 'related']),
|
|
127
|
+
}).strict();
|
|
128
|
+
const UnlinkBranch = z.object({
|
|
129
|
+
op: z.literal('unlink'),
|
|
130
|
+
slug: z.string(),
|
|
131
|
+
target: z.string(),
|
|
132
|
+
relation: z.enum(['parent', 'related']),
|
|
133
|
+
}).strict();
|
|
134
|
+
const PromoteBranch = z.object({
|
|
135
|
+
op: z.literal('promote'),
|
|
136
|
+
slug: z.string(),
|
|
137
|
+
target: z.enum(['entity', 'project', 'knowledge']),
|
|
138
|
+
}).strict();
|
|
139
|
+
// Nested discriminated union: outer on `op`, inner on `kind` for create branch.
|
|
140
|
+
// A flat `discriminatedUnion('op', [...])` cannot work because three branches share
|
|
141
|
+
// op=create — Zod requires unique discriminator values per branch and throws at
|
|
142
|
+
// schema construction time if violated.
|
|
143
|
+
const CreateBranch = z.discriminatedUnion('kind', [
|
|
144
|
+
CreateEntityBranch,
|
|
145
|
+
CreateKnowledgeBranch,
|
|
146
|
+
CreateSessionBranch,
|
|
147
|
+
]);
|
|
148
|
+
export const BatchOperationSchema = z.discriminatedUnion('op', [
|
|
149
|
+
UpdateBranch,
|
|
150
|
+
// create is a nested discriminatedUnion on `kind` — see CreateBranch above.
|
|
151
|
+
// discriminatedUnion accepts a ZodDiscriminatedUnion as a branch via option-flattening.
|
|
152
|
+
CreateBranch,
|
|
153
|
+
CaptureBranch,
|
|
154
|
+
LinkBranch,
|
|
155
|
+
UnlinkBranch,
|
|
156
|
+
PromoteBranch,
|
|
157
|
+
]);
|
|
158
|
+
// --- Alias normalization ---
|
|
159
|
+
// The one place a declared alias (contracts.ts ALIAS_KEYS) becomes its canonical
|
|
160
|
+
// field. It runs before validation, so validate and apply both see the canonical
|
|
161
|
+
// op on every entry point — library create/update/batch and CLI batch input
|
|
162
|
+
// alike. Normalizing anywhere else is what made the alias a silent drop: the
|
|
163
|
+
// strict shapes accepted `insight`, nothing rewrote it, and the apply step (which
|
|
164
|
+
// only reads `synthesis`) wrote an empty Synthesis section and reported ok.
|
|
165
|
+
/** ALIAS_KEYS re-keyed by op path prefix: 'update.fields' → [[alias, canonical]]. */
|
|
166
|
+
const ALIASES_BY_PATH = new Map();
|
|
167
|
+
for (const [path, canonical] of Object.entries(ALIAS_KEYS)) {
|
|
168
|
+
const cut = path.lastIndexOf('.');
|
|
169
|
+
const prefix = path.slice(0, cut);
|
|
170
|
+
const list = ALIASES_BY_PATH.get(prefix) ?? [];
|
|
171
|
+
list.push([path.slice(cut + 1), canonical]);
|
|
172
|
+
ALIASES_BY_PATH.set(prefix, list);
|
|
173
|
+
}
|
|
174
|
+
/** Canonicalize an operation's declared aliases.
|
|
175
|
+
*
|
|
176
|
+
* Precedence when both are present: the canonical field wins and the alias is
|
|
177
|
+
* dropped. Never mutates the caller's object — an op carrying no alias is
|
|
178
|
+
* returned as-is. */
|
|
179
|
+
export function normalizeAliases(op) {
|
|
180
|
+
const o = op;
|
|
181
|
+
if (!o || typeof o !== 'object' || !o.fields || typeof o.fields !== 'object')
|
|
182
|
+
return op;
|
|
183
|
+
const prefix = `${String(o.op)}${o.kind === undefined ? '' : `.${String(o.kind)}`}.fields`;
|
|
184
|
+
const aliases = ALIASES_BY_PATH.get(prefix);
|
|
185
|
+
if (!aliases)
|
|
186
|
+
return op;
|
|
187
|
+
let fields;
|
|
188
|
+
for (const [alias, canonical] of aliases) {
|
|
189
|
+
if (o.fields[alias] === undefined)
|
|
190
|
+
continue;
|
|
191
|
+
fields ??= { ...o.fields };
|
|
192
|
+
if (fields[canonical] === undefined)
|
|
193
|
+
fields[canonical] = fields[alias];
|
|
194
|
+
delete fields[alias];
|
|
195
|
+
}
|
|
196
|
+
return fields ? { ...o, fields } : op;
|
|
197
|
+
}
|
|
198
|
+
/** normalizeAliases over a payload — the form the Spine and dry-run use. */
|
|
199
|
+
export function normalizeOperations(operations) {
|
|
200
|
+
return operations.map(op => normalizeAliases(op));
|
|
201
|
+
}
|
|
202
|
+
function pushErr(errors, err) {
|
|
203
|
+
const e = err;
|
|
204
|
+
errors.push(e.details?.length ? `${e.message}: ${e.details.join('; ')}` : e.message);
|
|
205
|
+
}
|
|
206
|
+
/** The one source of the missing-slug error text — specs and the Spine's
|
|
207
|
+
* shape-failure precedence rule (validateBatchCore) must not drift apart. */
|
|
208
|
+
export function notFoundError(slug) {
|
|
209
|
+
return `'${slug}' not found`;
|
|
210
|
+
}
|
|
211
|
+
/** The one op-error line format. Every surface that localises an error to its
|
|
212
|
+
* op — the validate phase's error list, apply's execution-time failures, and
|
|
213
|
+
* the CLI's dry-run report — renders it through here, so an agent parsing
|
|
214
|
+
* `op[i] (op slug): …` sees one shape whichever surface produced it. */
|
|
215
|
+
export function opErrorLine(index, op, slug, message) {
|
|
216
|
+
return `op[${index}] (${op} ${slug}): ${message}`;
|
|
217
|
+
}
|
|
218
|
+
/** The one "the batch was rejected, nothing landed" header — shared by the
|
|
219
|
+
* library's validate gate and the CLI's shape-parse gate, which reject the
|
|
220
|
+
* same payload at different depths and must say so identically. `batch
|
|
221
|
+
* --dry-run` deliberately words its own header differently: it reports a
|
|
222
|
+
* hypothetical ("would be applied"), not a refusal. */
|
|
223
|
+
export function batchInvalidHeader(invalidCount, total) {
|
|
224
|
+
return `Batch failed validation: ${invalidCount} invalid operation${invalidCount === 1 ? '' : 's'} (0 of ${total} applied)`;
|
|
225
|
+
}
|
|
226
|
+
/** The one source of the duplicate-session disposition text. A duplicate
|
|
227
|
+
* session is not an error — the op is skipped and the batch continues — and
|
|
228
|
+
* dry-run (spec warning) and apply (BatchResult.warning) report it with this
|
|
229
|
+
* same sentence so the two can never drift. */
|
|
230
|
+
export function duplicateSessionWarning(slug) {
|
|
231
|
+
return `session '${slug}' already exists — op skipped (batch continues)`;
|
|
232
|
+
}
|
|
233
|
+
/** The create-style gate, in one place so the invariant can't be re-broken:
|
|
234
|
+
* a taken slug is a SLUG_EXISTS error and must NOT be simulated (simulating
|
|
235
|
+
* over an existing slug shadows the real file's kind for later ops in the
|
|
236
|
+
* batch). The builder runs before the claim — it must see the pre-claim
|
|
237
|
+
* world so a self-referencing parent still fails. */
|
|
238
|
+
function claimNew(ctx, errors, slug, info, build) {
|
|
239
|
+
const taken = ctx.existsAnywhere(slug);
|
|
240
|
+
if (taken)
|
|
241
|
+
errors.push(`Slug '${slug}' already exists`);
|
|
242
|
+
if (build) {
|
|
243
|
+
try {
|
|
244
|
+
build();
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
pushErr(errors, err);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!taken)
|
|
251
|
+
ctx.simulate(slug, info);
|
|
252
|
+
return taken ? 'SLUG_EXISTS' : undefined;
|
|
253
|
+
}
|
|
254
|
+
// `as const satisfies` rather than a `: OpSpec[]` annotation: it keeps each
|
|
255
|
+
// row's `name` a literal, which is what OpSpecName (and through it the Apply
|
|
256
|
+
// table's key type) is derived from. OP_SPECS below re-exports the rows under
|
|
257
|
+
// the interface, so consumers still read optional fields (`kind`) off them.
|
|
258
|
+
const OP_SPEC_ROWS = [
|
|
259
|
+
{
|
|
260
|
+
name: 'update',
|
|
261
|
+
op: 'update',
|
|
262
|
+
schema: UpdateBranch,
|
|
263
|
+
description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.',
|
|
264
|
+
exampleInclude: ['status'],
|
|
265
|
+
check(op, ctx) {
|
|
266
|
+
const errors = [];
|
|
267
|
+
let primaryCode;
|
|
268
|
+
const target = ctx.resolve(op.slug);
|
|
269
|
+
if (!target) {
|
|
270
|
+
errors.push(notFoundError(op.slug));
|
|
271
|
+
primaryCode = 'NOT_FOUND';
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
try {
|
|
275
|
+
ctx.validateKindFields(target.kind, op.fields, target.subtype);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
pushErr(errors, err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (op.fields.session_log) {
|
|
282
|
+
const logErrors = ctx.validateSessionLogEntry(op.fields.session_log);
|
|
283
|
+
if (logErrors.length > 0)
|
|
284
|
+
errors.push(`Invalid session log entry: ${logErrors.join('; ')}`);
|
|
285
|
+
}
|
|
286
|
+
if (op.fields.related) {
|
|
287
|
+
for (const r of op.fields.related) {
|
|
288
|
+
if (!ctx.existsAnywhere(r))
|
|
289
|
+
errors.push(`Related slug '${r}' does not exist`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { slug: op.slug, errors, primaryCode };
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: 'create-entity',
|
|
297
|
+
op: 'create',
|
|
298
|
+
kind: 'entity',
|
|
299
|
+
schema: CreateEntityBranch,
|
|
300
|
+
description: 'Create a new entity (capture / entity / project). A field whose section the chosen type lacks is rejected, never dropped — `purpose` needs type project.',
|
|
301
|
+
exampleInclude: ['type'],
|
|
302
|
+
check(op, ctx) {
|
|
303
|
+
const errors = [];
|
|
304
|
+
const slug = slugify(op.name);
|
|
305
|
+
const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: op.fields.type }, () => ctx.buildEntityFrontmatter(op.name, op.fields));
|
|
306
|
+
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: 'create-knowledge',
|
|
311
|
+
op: 'create',
|
|
312
|
+
kind: 'knowledge',
|
|
313
|
+
schema: CreateKnowledgeBranch,
|
|
314
|
+
description: 'Create a new knowledge note (insight / plan subtype). `insight` is an alias for `synthesis` (ignored when `synthesis` is also given). `status` (active|done) is plan-only; `synthesis` is insight-only — plan bodies are Goal/Steps/Dependencies, and a field whose section the subtype lacks is rejected, never dropped.',
|
|
315
|
+
exampleInclude: ['subtype', 'synthesis'],
|
|
316
|
+
check(op, ctx) {
|
|
317
|
+
const errors = [];
|
|
318
|
+
const slug = slugify(op.name);
|
|
319
|
+
const primaryCode = claimNew(ctx, errors, slug, { kind: 'knowledge', type: 'knowledge', subtype: op.fields?.subtype }, () => ctx.buildKnowledgeFrontmatter(op.name, (op.fields || {})));
|
|
320
|
+
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: 'create-session',
|
|
325
|
+
op: 'create',
|
|
326
|
+
kind: 'session',
|
|
327
|
+
schema: CreateSessionBranch,
|
|
328
|
+
description: 'Create a new session note linked to an entity. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).',
|
|
329
|
+
exampleInclude: ['synthesis'],
|
|
330
|
+
check(op, ctx) {
|
|
331
|
+
const slug = slugify(op.name);
|
|
332
|
+
if (ctx.existsAnywhere(slug) || ctx.sessionExists(slug)) {
|
|
333
|
+
return { slug, errors: [], warning: duplicateSessionWarning(slug) };
|
|
334
|
+
}
|
|
335
|
+
ctx.simulateSession(slug);
|
|
336
|
+
return { slug, errors: [], created: true };
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'capture',
|
|
341
|
+
op: 'capture',
|
|
342
|
+
schema: CaptureBranch,
|
|
343
|
+
description: 'Quick capture — a raw inbox item with no structure required.',
|
|
344
|
+
exampleInclude: ['notes'],
|
|
345
|
+
check(op, ctx) {
|
|
346
|
+
const errors = [];
|
|
347
|
+
const slug = slugify(op.name);
|
|
348
|
+
const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: 'capture' });
|
|
349
|
+
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'link',
|
|
354
|
+
op: 'link',
|
|
355
|
+
schema: LinkBranch,
|
|
356
|
+
description: 'Add a parent or related link between two entities.',
|
|
357
|
+
exampleInclude: [],
|
|
358
|
+
check(op, ctx) {
|
|
359
|
+
const errors = [];
|
|
360
|
+
let primaryCode;
|
|
361
|
+
if (!ctx.resolve(op.slug)) {
|
|
362
|
+
errors.push(notFoundError(op.slug));
|
|
363
|
+
primaryCode = 'NOT_FOUND';
|
|
364
|
+
}
|
|
365
|
+
if (!ctx.existsAnywhere(op.target))
|
|
366
|
+
errors.push(`Target '${op.target}' does not exist`);
|
|
367
|
+
return { slug: op.slug, errors, primaryCode };
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: 'unlink',
|
|
372
|
+
op: 'unlink',
|
|
373
|
+
schema: UnlinkBranch,
|
|
374
|
+
description: 'Remove a parent or related link between two entities.',
|
|
375
|
+
exampleInclude: [],
|
|
376
|
+
check(op, ctx) {
|
|
377
|
+
const errors = [];
|
|
378
|
+
let primaryCode;
|
|
379
|
+
if (!ctx.resolve(op.slug)) {
|
|
380
|
+
errors.push(notFoundError(op.slug));
|
|
381
|
+
primaryCode = 'NOT_FOUND';
|
|
382
|
+
}
|
|
383
|
+
return { slug: op.slug, errors, primaryCode };
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: 'promote',
|
|
388
|
+
op: 'promote',
|
|
389
|
+
schema: PromoteBranch,
|
|
390
|
+
description: 'Promote a capture to entity/project/knowledge, or an entity to project.',
|
|
391
|
+
exampleInclude: [],
|
|
392
|
+
check(op, ctx) {
|
|
393
|
+
const errors = [];
|
|
394
|
+
let primaryCode;
|
|
395
|
+
const cur = ctx.resolve(op.slug);
|
|
396
|
+
if (!cur) {
|
|
397
|
+
errors.push(notFoundError(op.slug));
|
|
398
|
+
primaryCode = 'NOT_FOUND';
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const currentType = cur.kind === 'knowledge' ? 'knowledge' : cur.type;
|
|
402
|
+
try {
|
|
403
|
+
const contract = getContract(currentType);
|
|
404
|
+
if (!contract.canPromoteTo.includes(op.target)) {
|
|
405
|
+
errors.push(`Cannot promote ${currentType} to ${op.target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
ctx.simulate(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
pushErr(errors, err);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { slug: op.slug, errors, primaryCode };
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
];
|
|
419
|
+
export const OP_SPECS = OP_SPEC_ROWS;
|
|
420
|
+
/** Find the spec for a (shape-valid) operation. */
|
|
421
|
+
export function specFor(op) {
|
|
422
|
+
const spec = OP_SPECS.find(s => s.op === op.op && (s.kind === undefined || s.kind === op.kind));
|
|
423
|
+
if (!spec)
|
|
424
|
+
throw new HafezError('VALIDATION_FAILED', `Unknown batch op: ${op.op}`);
|
|
425
|
+
return spec;
|
|
426
|
+
}
|
|
427
|
+
/** Op names for error text ("expected one of […]") — derived, never hardcoded. */
|
|
428
|
+
export function validOpNames() {
|
|
429
|
+
return [...new Set(OP_SPECS.map(s => s.op))];
|
|
430
|
+
}
|
|
431
|
+
/** Ops whose payload addresses an existing file by `slug` — derived from the
|
|
432
|
+
* Zod shapes, so a new slug-bearing op picks up slug-level rules (the
|
|
433
|
+
* NOT_FOUND precedence on shape failure in validateBatchCore) without a
|
|
434
|
+
* hand-kept list. */
|
|
435
|
+
export function slugBearingOpNames() {
|
|
436
|
+
return [...new Set(OP_SPECS
|
|
437
|
+
.filter(s => {
|
|
438
|
+
const shape = s.schema.shape;
|
|
439
|
+
return shape !== undefined && 'slug' in shape;
|
|
440
|
+
})
|
|
441
|
+
.map(s => s.op))];
|
|
442
|
+
}
|
|
443
|
+
/** Create kinds for error text — derived from the table. */
|
|
444
|
+
export function validCreateKinds() {
|
|
445
|
+
return OP_SPECS.filter(s => s.op === 'create' && s.kind).map(s => s.kind);
|
|
446
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Bad invocation (unknown flag, missing value, missing positional).
|
|
2
|
+
* Distinct from crashes: the error adapter maps it to exit 2. */
|
|
3
|
+
export declare class UsageError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export type FlagType = 'string' | 'boolean';
|
|
6
|
+
/** Flag names WITHOUT the leading dashes, e.g. { 'add-action': 'string' }. */
|
|
7
|
+
export type FlagSpec = Record<string, FlagType>;
|
|
8
|
+
export interface ParsedArgs {
|
|
9
|
+
flags: Record<string, string | boolean | undefined>;
|
|
10
|
+
positionals: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function parseArgs(args: string[], spec: FlagSpec, usage: string): ParsedArgs;
|
|
13
|
+
/** The one comma-list parser — every list flag on every command goes through
|
|
14
|
+
* it. Trims each segment and drops the empty ones, so `"ai, ml"` is two
|
|
15
|
+
* domains (not `ai` and ` ml`, which no query matches) and `"eng,"` is one
|
|
16
|
+
* (not one plus an empty string persisted to frontmatter and the index).
|
|
17
|
+
* undefined stays undefined; an all-empty list is an empty array (a clear). */
|
|
18
|
+
export declare function splitList(value: string | boolean | undefined): string[] | undefined;
|
|
19
|
+
export declare function str(value: string | boolean | undefined): string | undefined;
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/cli/args.ts
|
|
2
|
+
// The one flag parser behind every CLI command. Each command declares its
|
|
3
|
+
// flags; parseArgs enforces the declaration — unknown flags and missing
|
|
4
|
+
// values are UsageErrors instead of silently ignored tokens (the getFlag era
|
|
5
|
+
// needed a documented workaround because values stayed in the positional
|
|
6
|
+
// stream).
|
|
7
|
+
/** Bad invocation (unknown flag, missing value, missing positional).
|
|
8
|
+
* Distinct from crashes: the error adapter maps it to exit 2. */
|
|
9
|
+
export class UsageError extends Error {
|
|
10
|
+
}
|
|
11
|
+
export function parseArgs(args, spec, usage) {
|
|
12
|
+
const flags = {};
|
|
13
|
+
const positionals = [];
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const token = args[i];
|
|
16
|
+
if (token.startsWith('--')) {
|
|
17
|
+
const name = token.slice(2);
|
|
18
|
+
const type = spec[name];
|
|
19
|
+
if (!type)
|
|
20
|
+
throw new UsageError(`Unknown flag --${name}\n${usage}`);
|
|
21
|
+
if (type === 'boolean') {
|
|
22
|
+
flags[name] = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const value = args[i + 1];
|
|
26
|
+
// A declared flag in value position means the value was omitted
|
|
27
|
+
// (`--brief --clear-actions`) — consuming it would both corrupt the
|
|
28
|
+
// string and silently drop the swallowed flag.
|
|
29
|
+
if (value === undefined || (value.startsWith('--') && value.slice(2) in spec)) {
|
|
30
|
+
throw new UsageError(`--${name} requires a value\n${usage}`);
|
|
31
|
+
}
|
|
32
|
+
flags[name] = value;
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
positionals.push(token);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { flags, positionals };
|
|
40
|
+
}
|
|
41
|
+
/** The one comma-list parser — every list flag on every command goes through
|
|
42
|
+
* it. Trims each segment and drops the empty ones, so `"ai, ml"` is two
|
|
43
|
+
* domains (not `ai` and ` ml`, which no query matches) and `"eng,"` is one
|
|
44
|
+
* (not one plus an empty string persisted to frontmatter and the index).
|
|
45
|
+
* undefined stays undefined; an all-empty list is an empty array (a clear). */
|
|
46
|
+
export function splitList(value) {
|
|
47
|
+
if (typeof value !== 'string')
|
|
48
|
+
return undefined;
|
|
49
|
+
return value.split(',').map(s => s.trim()).filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
export function str(value) {
|
|
52
|
+
return typeof value === 'string' ? value : undefined;
|
|
53
|
+
}
|
package/dist/cli/batch-errors.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// without those, the `unrecognized_keys` and `invalid_union_discriminator`
|
|
10
10
|
// paths are unreachable.
|
|
11
11
|
import { getOpSchema, listOps } from './schema-introspect.js';
|
|
12
|
+
import { validOpNames, validCreateKinds } from '../batch-ops.js';
|
|
12
13
|
// --- did-you-mean (cheap Levenshtein) ---
|
|
13
14
|
export function levenshtein(a, b) {
|
|
14
15
|
if (a === b)
|
|
@@ -91,7 +92,8 @@ export function opNameOf(raw) {
|
|
|
91
92
|
return null;
|
|
92
93
|
if (op === 'create') {
|
|
93
94
|
const kind = raw.kind;
|
|
94
|
-
|
|
95
|
+
const names = new Set(listOps().map(o => o.name));
|
|
96
|
+
if (typeof kind === 'string' && names.has(`create-${kind}`))
|
|
95
97
|
return `create-${kind}`;
|
|
96
98
|
return 'create';
|
|
97
99
|
}
|
|
@@ -126,7 +128,8 @@ function toStr(v) {
|
|
|
126
128
|
return String(v);
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
|
-
|
|
131
|
+
// Derived from the Op Spec table — never hardcoded here.
|
|
132
|
+
const VALID_OPS = validOpNames();
|
|
130
133
|
/**
|
|
131
134
|
* Format a single Zod issue into a readable block. Caller supplies the op
|
|
132
135
|
* index and the raw op JSON so the block can reference the right `hafez
|
|
@@ -160,7 +163,7 @@ export function formatIssue(issue, opIndex, raw) {
|
|
|
160
163
|
return [
|
|
161
164
|
header,
|
|
162
165
|
` field: kind`,
|
|
163
|
-
` issue: unknown kind ${got} (expected one of [
|
|
166
|
+
` issue: unknown kind ${got} (expected one of [${validCreateKinds().join(', ')}])`,
|
|
164
167
|
` ${tip}`,
|
|
165
168
|
].join('\n');
|
|
166
169
|
}
|