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/batch-ops.js
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
// The Op Spec (see CONTEXT.md): one table defining every batch operation —
|
|
3
3
|
// shape (Zod), semantic check, description, and example hints — from which
|
|
4
4
|
// validation, error text, and schema introspection all derive. Adding an op
|
|
5
|
-
// means
|
|
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.
|
|
6
10
|
import { z } from 'zod';
|
|
7
|
-
import { ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, getContract, } from './contracts.js';
|
|
11
|
+
import { ALIAS_KEYS, ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, PLAN_STATUSES, getContract, } from './contracts.js';
|
|
8
12
|
import { slugify } from './vault.js';
|
|
9
13
|
import { HafezError } from './types.js';
|
|
10
14
|
// --- Shapes ---
|
|
@@ -13,61 +17,87 @@ import { HafezError } from './types.js';
|
|
|
13
17
|
// Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
|
|
14
18
|
// (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
|
|
15
19
|
// hints and for catching typos like `{"kind": "parent"}` on a link op.
|
|
20
|
+
// Body-section text must have text after trimming. An empty or whitespace-only
|
|
21
|
+
// value (an agent expanding an unset variable) used to sail through the shape —
|
|
22
|
+
// on the action/evidence fields it was dropped by a truthiness guard in apply
|
|
23
|
+
// (exit 0, nothing on disk); on the section-setting fields below it was worse,
|
|
24
|
+
// because apply trusts the validate phase and `setSection(body, 'Synthesis',
|
|
25
|
+
// '')` wipes what was already there. Same false-success class the CLI loop
|
|
26
|
+
// closed for the enum flags, once silent and once destructive.
|
|
27
|
+
//
|
|
28
|
+
// The rule: every non-nullable string field that writes body-section text is
|
|
29
|
+
// nonEmptyText. The nullable ones (update's brief/description/resource) are
|
|
30
|
+
// excluded by construction — null is their documented clear signal, and the
|
|
31
|
+
// CLI maps `--brief ""` to it. Trimming also keeps accidental padding out of
|
|
32
|
+
// stored text.
|
|
33
|
+
const nonEmptyText = z.string().trim().min(1, 'must contain text — an empty value is rejected, never silently dropped');
|
|
34
|
+
// A list field (domain, tags, related) takes a string[] or, like the CLI's
|
|
35
|
+
// list flags (`--domain "a,b"`), one comma string. The split is the one rule
|
|
36
|
+
// for both paths — the CLI's `splitList` calls it — so `"a, b"` is the same
|
|
37
|
+
// two values everywhere (#33).
|
|
38
|
+
export const splitCommaList = (value) => value.split(',').map(s => s.trim()).filter(Boolean);
|
|
39
|
+
const stringList = z.preprocess(v => typeof v === 'string' ? splitCommaList(v) : v, z.array(z.string()));
|
|
16
40
|
export const SessionLogSchema = z.object({
|
|
17
41
|
type: z.enum([...SESSION_LOG_TYPES]),
|
|
18
42
|
summary: z.string(),
|
|
19
|
-
body:
|
|
43
|
+
body: nonEmptyText.optional(),
|
|
20
44
|
agent: z.string(),
|
|
21
45
|
}).strict();
|
|
22
46
|
export const UpdateFieldsSchema = z.object({
|
|
23
47
|
status: z.enum([...ENTITY_STATUSES]).optional(),
|
|
24
|
-
current_state:
|
|
48
|
+
current_state: nonEmptyText.optional(),
|
|
25
49
|
session_log: SessionLogSchema.optional(),
|
|
50
|
+
agent: nonEmptyText.optional(), // entity-only; names the agent on the added session log entry
|
|
26
51
|
brief: z.string().nullable().optional(),
|
|
27
|
-
add_action:
|
|
28
|
-
add_actions: z.array(
|
|
29
|
-
complete_action:
|
|
30
|
-
remove_action:
|
|
52
|
+
add_action: nonEmptyText.optional(),
|
|
53
|
+
add_actions: z.array(nonEmptyText).optional(),
|
|
54
|
+
complete_action: nonEmptyText.optional(),
|
|
55
|
+
remove_action: nonEmptyText.optional(),
|
|
31
56
|
clear_actions: z.boolean().optional(),
|
|
57
|
+
parent: z.string().optional(), // entity-only; the slug must exist, no cycle
|
|
32
58
|
// Knowledge metadata
|
|
33
59
|
description: z.string().nullable().optional(),
|
|
34
60
|
resource: z.string().nullable().optional(),
|
|
35
|
-
domain:
|
|
61
|
+
domain: stringList.optional(),
|
|
36
62
|
confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
|
|
37
|
-
tags:
|
|
38
|
-
related:
|
|
39
|
-
synthesis:
|
|
40
|
-
insight:
|
|
41
|
-
add_evidence:
|
|
42
|
-
add_source:
|
|
63
|
+
tags: stringList.optional(),
|
|
64
|
+
related: stringList.optional(),
|
|
65
|
+
synthesis: nonEmptyText.optional(),
|
|
66
|
+
insight: nonEmptyText.optional(), // alias for synthesis (normalized by normalizeAliases)
|
|
67
|
+
add_evidence: nonEmptyText.optional(),
|
|
68
|
+
add_source: nonEmptyText.optional(),
|
|
43
69
|
}).strict();
|
|
44
70
|
export const CreateEntityFieldsSchema = z.object({
|
|
45
71
|
type: z.enum([...ENTITY_TYPES]),
|
|
46
|
-
purpose:
|
|
72
|
+
purpose: nonEmptyText.optional(),
|
|
47
73
|
description: z.string().optional(),
|
|
48
74
|
resource: z.string().optional(),
|
|
49
|
-
domain:
|
|
75
|
+
domain: stringList.optional(),
|
|
50
76
|
parent: z.string().optional(),
|
|
51
|
-
related:
|
|
52
|
-
tags:
|
|
53
|
-
brief:
|
|
54
|
-
add_action:
|
|
55
|
-
add_actions: z.array(
|
|
77
|
+
related: stringList.optional(),
|
|
78
|
+
tags: stringList.optional(),
|
|
79
|
+
brief: nonEmptyText.optional(),
|
|
80
|
+
add_action: nonEmptyText.optional(),
|
|
81
|
+
add_actions: z.array(nonEmptyText).optional(),
|
|
56
82
|
}).strict();
|
|
57
83
|
export const CreateKnowledgeFieldsSchema = z.object({
|
|
58
84
|
subtype: z.enum([...VALID_SUBTYPES]).optional(),
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
// Plan-only: insights have no lifecycle, so the semantic check rejects
|
|
86
|
+
// status on any other subtype (the shape only pins the allowed values).
|
|
87
|
+
status: z.enum([...PLAN_STATUSES]).optional(),
|
|
88
|
+
confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
|
|
89
|
+
synthesis: nonEmptyText.optional(),
|
|
90
|
+
insight: nonEmptyText.optional(),
|
|
61
91
|
description: z.string().optional(),
|
|
62
|
-
domain:
|
|
63
|
-
related:
|
|
64
|
-
tags:
|
|
92
|
+
domain: stringList.optional(),
|
|
93
|
+
related: stringList.optional(),
|
|
94
|
+
tags: stringList.optional(),
|
|
65
95
|
'session-date': z.string().optional(),
|
|
66
96
|
}).strict();
|
|
67
97
|
export const CreateSessionFieldsSchema = z.object({
|
|
68
|
-
synthesis:
|
|
69
|
-
insight:
|
|
70
|
-
related:
|
|
98
|
+
synthesis: nonEmptyText.optional(),
|
|
99
|
+
insight: nonEmptyText.optional(),
|
|
100
|
+
related: stringList.optional(),
|
|
71
101
|
'session-date': z.string().optional(),
|
|
72
102
|
}).strict();
|
|
73
103
|
const CreateEntityBranch = z.object({
|
|
@@ -88,6 +118,16 @@ const CreateSessionBranch = z.object({
|
|
|
88
118
|
name: z.string(),
|
|
89
119
|
fields: CreateSessionFieldsSchema.optional(),
|
|
90
120
|
}).strict();
|
|
121
|
+
/** Update fields the mechanised session log neither counts nor names:
|
|
122
|
+
* ticking a box is not history worth an entry, and `agent` only names it. */
|
|
123
|
+
const UNLOGGED_FIELDS = new Set(['add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'agent']);
|
|
124
|
+
/** The fields an update's mechanised session log entry names — empty when
|
|
125
|
+
* the update earns none. Shared by the update check (which rejects an
|
|
126
|
+
* `agent` with no entry to name) and the Apply update arm (which writes it). */
|
|
127
|
+
export function loggedFields(fields) {
|
|
128
|
+
return Object.keys(fields)
|
|
129
|
+
.filter(f => fields[f] !== undefined && !UNLOGGED_FIELDS.has(f));
|
|
130
|
+
}
|
|
91
131
|
const UpdateBranch = z.object({
|
|
92
132
|
op: z.literal('update'),
|
|
93
133
|
slug: z.string(),
|
|
@@ -115,6 +155,12 @@ const PromoteBranch = z.object({
|
|
|
115
155
|
slug: z.string(),
|
|
116
156
|
target: z.enum(['entity', 'project', 'knowledge']),
|
|
117
157
|
}).strict();
|
|
158
|
+
// No `kind`: the slug alone says what is being deleted, the same as
|
|
159
|
+
// `hafez delete <slug>`. The check resolves it and rejects an entity.
|
|
160
|
+
const DeleteBranch = z.object({
|
|
161
|
+
op: z.literal('delete'),
|
|
162
|
+
slug: z.string(),
|
|
163
|
+
}).strict();
|
|
118
164
|
// Nested discriminated union: outer on `op`, inner on `kind` for create branch.
|
|
119
165
|
// A flat `discriminatedUnion('op', [...])` cannot work because three branches share
|
|
120
166
|
// op=create — Zod requires unique discriminator values per branch and throws at
|
|
@@ -133,31 +179,184 @@ export const BatchOperationSchema = z.discriminatedUnion('op', [
|
|
|
133
179
|
LinkBranch,
|
|
134
180
|
UnlinkBranch,
|
|
135
181
|
PromoteBranch,
|
|
182
|
+
DeleteBranch,
|
|
136
183
|
]);
|
|
184
|
+
// --- Alias normalization ---
|
|
185
|
+
// The one place a declared alias (contracts.ts ALIAS_KEYS) becomes its canonical
|
|
186
|
+
// field. It runs before validation, so validate and apply both see the canonical
|
|
187
|
+
// op on every entry point — library create/update/batch and CLI batch input
|
|
188
|
+
// alike. Normalizing anywhere else is what made the alias a silent drop: the
|
|
189
|
+
// strict shapes accepted `insight`, nothing rewrote it, and the apply step (which
|
|
190
|
+
// only reads `synthesis`) wrote an empty Synthesis section and reported ok.
|
|
191
|
+
/** ALIAS_KEYS re-keyed by op path prefix: 'update.fields' → [[alias, canonical]]. */
|
|
192
|
+
const ALIASES_BY_PATH = new Map();
|
|
193
|
+
for (const [path, canonical] of Object.entries(ALIAS_KEYS)) {
|
|
194
|
+
const cut = path.lastIndexOf('.');
|
|
195
|
+
const prefix = path.slice(0, cut);
|
|
196
|
+
const list = ALIASES_BY_PATH.get(prefix) ?? [];
|
|
197
|
+
list.push([path.slice(cut + 1), canonical]);
|
|
198
|
+
ALIASES_BY_PATH.set(prefix, list);
|
|
199
|
+
}
|
|
200
|
+
/** Canonicalize an operation's declared aliases.
|
|
201
|
+
*
|
|
202
|
+
* Precedence when both are present: the canonical field wins and the alias is
|
|
203
|
+
* dropped. Never mutates the caller's object — an op carrying no alias is
|
|
204
|
+
* returned as-is. */
|
|
205
|
+
export function normalizeAliases(op) {
|
|
206
|
+
const o = op;
|
|
207
|
+
if (!o || typeof o !== 'object' || !o.fields || typeof o.fields !== 'object')
|
|
208
|
+
return op;
|
|
209
|
+
const prefix = `${String(o.op)}${o.kind === undefined ? '' : `.${String(o.kind)}`}.fields`;
|
|
210
|
+
const aliases = ALIASES_BY_PATH.get(prefix);
|
|
211
|
+
if (!aliases)
|
|
212
|
+
return op;
|
|
213
|
+
let fields;
|
|
214
|
+
for (const [alias, canonical] of aliases) {
|
|
215
|
+
if (o.fields[alias] === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
fields ??= { ...o.fields };
|
|
218
|
+
if (fields[canonical] === undefined)
|
|
219
|
+
fields[canonical] = fields[alias];
|
|
220
|
+
delete fields[alias];
|
|
221
|
+
}
|
|
222
|
+
return fields ? { ...o, fields } : op;
|
|
223
|
+
}
|
|
224
|
+
/** normalizeAliases over a payload — the form the Spine and dry-run use. */
|
|
225
|
+
export function normalizeOperations(operations) {
|
|
226
|
+
return operations.map(op => normalizeAliases(op));
|
|
227
|
+
}
|
|
137
228
|
function pushErr(errors, err) {
|
|
138
229
|
const e = err;
|
|
139
230
|
errors.push(e.details?.length ? `${e.message}: ${e.details.join('; ')}` : e.message);
|
|
140
231
|
}
|
|
141
|
-
|
|
232
|
+
/** The one source of the missing-slug error text — specs and the Spine's
|
|
233
|
+
* shape-failure precedence rule (validateBatchCore) must not drift apart. */
|
|
234
|
+
export function notFoundError(slug) {
|
|
235
|
+
return `'${slug}' not found`;
|
|
236
|
+
}
|
|
237
|
+
/** The one op-error line format. Every surface that localises an error to its
|
|
238
|
+
* op — the validate phase's error list, apply's execution-time failures, and
|
|
239
|
+
* the CLI's dry-run report — renders it through here, so an agent parsing
|
|
240
|
+
* `op[i] (op slug): …` sees one shape whichever surface produced it. */
|
|
241
|
+
export function opErrorLine(index, op, slug, message) {
|
|
242
|
+
return `op[${index}] (${op} ${slug}): ${message}`;
|
|
243
|
+
}
|
|
244
|
+
/** The one "the batch was rejected, nothing landed" header — shared by the
|
|
245
|
+
* library's validate gate and the CLI's shape-parse gate, which reject the
|
|
246
|
+
* same payload at different depths and must say so identically. `batch
|
|
247
|
+
* --dry-run` deliberately words its own header differently: it reports a
|
|
248
|
+
* hypothetical ("would be applied"), not a refusal. */
|
|
249
|
+
export function batchInvalidHeader(invalidCount, total) {
|
|
250
|
+
return `Batch failed validation: ${invalidCount} invalid operation${invalidCount === 1 ? '' : 's'} (0 of ${total} applied)`;
|
|
251
|
+
}
|
|
252
|
+
/** The one source of the duplicate-session disposition text. A duplicate
|
|
253
|
+
* session is not an error — the op is skipped and the batch continues — and
|
|
254
|
+
* dry-run (spec warning) and apply (BatchResult.warning) report it with this
|
|
255
|
+
* same sentence so the two can never drift. */
|
|
256
|
+
export function duplicateSessionWarning(slug) {
|
|
257
|
+
return `session '${slug}' already exists — op skipped (batch continues)`;
|
|
258
|
+
}
|
|
259
|
+
/** The create-style gate, in one place so the invariant can't be re-broken:
|
|
260
|
+
* a taken slug is a SLUG_EXISTS error and must NOT be simulated (simulating
|
|
261
|
+
* over an existing slug shadows the real file's kind for later ops in the
|
|
262
|
+
* batch). The builder runs before the claim — it must see the pre-claim
|
|
263
|
+
* world so a self-referencing parent still fails. */
|
|
264
|
+
function claimNew(ctx, errors, slug, info, build) {
|
|
265
|
+
const taken = ctx.existsAnywhere(slug);
|
|
266
|
+
if (taken)
|
|
267
|
+
errors.push(`Slug '${slug}' already exists`);
|
|
268
|
+
if (build) {
|
|
269
|
+
try {
|
|
270
|
+
build();
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
pushErr(errors, err);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!taken)
|
|
277
|
+
ctx.simulate(slug, info);
|
|
278
|
+
return taken ? 'SLUG_EXISTS' : undefined;
|
|
279
|
+
}
|
|
280
|
+
/** The link fields an update payload sets, or null when it sets none. */
|
|
281
|
+
function linkFieldsOf(fields) {
|
|
282
|
+
const links = {};
|
|
283
|
+
if (fields.parent !== undefined)
|
|
284
|
+
links.parent = fields.parent;
|
|
285
|
+
if (fields.related !== undefined)
|
|
286
|
+
links.related = fields.related;
|
|
287
|
+
return Object.keys(links).length > 0 ? links : null;
|
|
288
|
+
}
|
|
289
|
+
/** The parent chain `slug → parent → … → slug` that giving `slug` this
|
|
290
|
+
* parent closes, or null when the chain ends or loops without `slug`.
|
|
291
|
+
* Shared by the Op Spec checks (against the simulated batch) and
|
|
292
|
+
* `validateVault` (against the files). */
|
|
293
|
+
export function parentCycle(slug, parent, parentOf) {
|
|
294
|
+
const path = [slug];
|
|
295
|
+
const seen = new Set(path);
|
|
296
|
+
for (let p = parent; p !== undefined; p = parentOf(p)) {
|
|
297
|
+
path.push(p);
|
|
298
|
+
if (p === slug)
|
|
299
|
+
return path;
|
|
300
|
+
if (seen.has(p))
|
|
301
|
+
return null;
|
|
302
|
+
seen.add(p);
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
function parentCycleError(slug, parent, ctx) {
|
|
307
|
+
const cycle = parentCycle(slug, parent, s => ctx.linksOf(s).parent);
|
|
308
|
+
return cycle ? `Parent '${parent}' would make a cycle: ${cycle.join(' → ')}` : null;
|
|
309
|
+
}
|
|
310
|
+
// `as const satisfies` rather than a `: OpSpec[]` annotation: it keeps each
|
|
311
|
+
// row's `name` a literal, which is what OpSpecName (and through it the Apply
|
|
312
|
+
// table's key type) is derived from. OP_SPECS below re-exports the rows under
|
|
313
|
+
// the interface, so consumers still read optional fields (`kind`) off them.
|
|
314
|
+
const OP_SPEC_ROWS = [
|
|
142
315
|
{
|
|
143
316
|
name: 'update',
|
|
144
317
|
op: 'update',
|
|
145
318
|
schema: UpdateBranch,
|
|
146
|
-
description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …).',
|
|
319
|
+
description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action` → `complete_action` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity\'s archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.',
|
|
147
320
|
exampleInclude: ['status'],
|
|
148
321
|
check(op, ctx) {
|
|
149
322
|
const errors = [];
|
|
323
|
+
let primaryCode;
|
|
150
324
|
const target = ctx.resolve(op.slug);
|
|
151
|
-
if (!target)
|
|
152
|
-
errors.push(
|
|
325
|
+
if (!target) {
|
|
326
|
+
errors.push(notFoundError(op.slug));
|
|
327
|
+
primaryCode = 'NOT_FOUND';
|
|
328
|
+
}
|
|
153
329
|
else {
|
|
154
330
|
try {
|
|
155
|
-
ctx.validateKindFields(target.kind, op.fields);
|
|
331
|
+
ctx.validateKindFields(target.kind, op.fields, target.subtype);
|
|
156
332
|
}
|
|
157
333
|
catch (err) {
|
|
158
334
|
pushErr(errors, err);
|
|
159
335
|
}
|
|
160
336
|
}
|
|
337
|
+
// Statically contradictory: clear_actions runs first and empties the
|
|
338
|
+
// list, so any query in the same op would fail at apply time with a
|
|
339
|
+
// misleading "No matching action". Reject here, where the message can
|
|
340
|
+
// say why and land beside the batch's other errors. Entity targets
|
|
341
|
+
// only — on a knowledge note validateKindFields has already rejected
|
|
342
|
+
// the fields themselves, and a second error about their order is noise.
|
|
343
|
+
if (target?.kind === 'entity' && op.fields.clear_actions) {
|
|
344
|
+
const queries = ['remove_action', 'complete_action'].filter(f => op.fields[f] !== undefined);
|
|
345
|
+
if (queries.length > 0) {
|
|
346
|
+
errors.push(`clear_actions empties the list before ${queries.join(' and ')} would run — drop the clear or use a separate op`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// `agent` names the entry the Spine adds; with nothing added it would be
|
|
350
|
+
// dropped. Entity targets only — on a knowledge note validateKindFields
|
|
351
|
+
// has already rejected it as entity-only.
|
|
352
|
+
if (target?.kind === 'entity' && op.fields.agent !== undefined) {
|
|
353
|
+
if (op.fields.session_log) {
|
|
354
|
+
errors.push('agent names the session log entry added when session_log is absent — with session_log, set session_log.agent instead');
|
|
355
|
+
}
|
|
356
|
+
else if (loggedFields(op.fields).length === 0) {
|
|
357
|
+
errors.push('agent names the session log entry, and an update of action fields alone writes none — drop agent');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
161
360
|
if (op.fields.session_log) {
|
|
162
361
|
const logErrors = ctx.validateSessionLogEntry(op.fields.session_log);
|
|
163
362
|
if (logErrors.length > 0)
|
|
@@ -169,7 +368,21 @@ export const OP_SPECS = [
|
|
|
169
368
|
errors.push(`Related slug '${r}' does not exist`);
|
|
170
369
|
}
|
|
171
370
|
}
|
|
172
|
-
|
|
371
|
+
if (op.fields.parent !== undefined) {
|
|
372
|
+
if (!ctx.existsAnywhere(op.fields.parent))
|
|
373
|
+
errors.push(`Parent '${op.fields.parent}' does not exist`);
|
|
374
|
+
else if (target?.kind === 'entity') {
|
|
375
|
+
const cycle = parentCycleError(op.slug, op.fields.parent, ctx);
|
|
376
|
+
if (cycle)
|
|
377
|
+
errors.push(cycle);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (target) {
|
|
381
|
+
const links = linkFieldsOf(op.fields);
|
|
382
|
+
if (links)
|
|
383
|
+
ctx.simulateLinks(op.slug, { ...ctx.linksOf(op.slug), ...links });
|
|
384
|
+
}
|
|
385
|
+
return { slug: op.slug, errors, primaryCode };
|
|
173
386
|
},
|
|
174
387
|
},
|
|
175
388
|
{
|
|
@@ -177,21 +390,15 @@ export const OP_SPECS = [
|
|
|
177
390
|
op: 'create',
|
|
178
391
|
kind: 'entity',
|
|
179
392
|
schema: CreateEntityBranch,
|
|
180
|
-
description: 'Create a new entity (capture / entity / project).',
|
|
393
|
+
description: 'Create a new entity (capture / entity / project). A field whose section the chosen type lacks is rejected, never dropped — `purpose` needs type project.',
|
|
181
394
|
exampleInclude: ['type'],
|
|
182
395
|
check(op, ctx) {
|
|
183
396
|
const errors = [];
|
|
184
397
|
const slug = slugify(op.name);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
catch (err) {
|
|
191
|
-
pushErr(errors, err);
|
|
192
|
-
}
|
|
193
|
-
ctx.simulate(slug, { kind: 'entity', type: op.fields.type });
|
|
194
|
-
return { slug, errors, created: errors.length === 0 };
|
|
398
|
+
const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: op.fields.type }, () => ctx.buildEntityFrontmatter(op.name, op.fields));
|
|
399
|
+
if (!primaryCode)
|
|
400
|
+
ctx.simulateLinks(slug, { parent: op.fields.parent, related: op.fields.related });
|
|
401
|
+
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
195
402
|
},
|
|
196
403
|
},
|
|
197
404
|
{
|
|
@@ -199,21 +406,15 @@ export const OP_SPECS = [
|
|
|
199
406
|
op: 'create',
|
|
200
407
|
kind: 'knowledge',
|
|
201
408
|
schema: CreateKnowledgeBranch,
|
|
202
|
-
description: 'Create a new knowledge note (insight / plan subtype).',
|
|
409
|
+
description: 'Create a new knowledge note (insight / plan subtype). `confidence` defaults to observation. `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.',
|
|
203
410
|
exampleInclude: ['subtype', 'synthesis'],
|
|
204
411
|
check(op, ctx) {
|
|
205
412
|
const errors = [];
|
|
206
413
|
const slug = slugify(op.name);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
catch (err) {
|
|
213
|
-
pushErr(errors, err);
|
|
214
|
-
}
|
|
215
|
-
ctx.simulate(slug, { kind: 'knowledge', type: 'knowledge' });
|
|
216
|
-
return { slug, errors, created: errors.length === 0 };
|
|
414
|
+
const primaryCode = claimNew(ctx, errors, slug, { kind: 'knowledge', type: 'knowledge', subtype: op.fields?.subtype }, () => ctx.buildKnowledgeFrontmatter(op.name, (op.fields || {})));
|
|
415
|
+
if (!primaryCode)
|
|
416
|
+
ctx.simulateLinks(slug, { related: op.fields?.related });
|
|
417
|
+
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
217
418
|
},
|
|
218
419
|
},
|
|
219
420
|
{
|
|
@@ -221,12 +422,12 @@ export const OP_SPECS = [
|
|
|
221
422
|
op: 'create',
|
|
222
423
|
kind: 'session',
|
|
223
424
|
schema: CreateSessionBranch,
|
|
224
|
-
description: 'Create a new session note linked to an entity.',
|
|
425
|
+
description: 'Create a new session note linked to an entity. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).',
|
|
225
426
|
exampleInclude: ['synthesis'],
|
|
226
427
|
check(op, ctx) {
|
|
227
428
|
const slug = slugify(op.name);
|
|
228
429
|
if (ctx.existsAnywhere(slug) || ctx.sessionExists(slug)) {
|
|
229
|
-
return { slug, errors: [], warning:
|
|
430
|
+
return { slug, errors: [], warning: duplicateSessionWarning(slug) };
|
|
230
431
|
}
|
|
231
432
|
ctx.simulateSession(slug);
|
|
232
433
|
return { slug, errors: [], created: true };
|
|
@@ -241,10 +442,8 @@ export const OP_SPECS = [
|
|
|
241
442
|
check(op, ctx) {
|
|
242
443
|
const errors = [];
|
|
243
444
|
const slug = slugify(op.name);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
ctx.simulate(slug, { kind: 'entity', type: 'capture' });
|
|
247
|
-
return { slug, errors, created: errors.length === 0 };
|
|
445
|
+
const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: 'capture' });
|
|
446
|
+
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
248
447
|
},
|
|
249
448
|
},
|
|
250
449
|
{
|
|
@@ -255,11 +454,25 @@ export const OP_SPECS = [
|
|
|
255
454
|
exampleInclude: [],
|
|
256
455
|
check(op, ctx) {
|
|
257
456
|
const errors = [];
|
|
258
|
-
|
|
259
|
-
|
|
457
|
+
let primaryCode;
|
|
458
|
+
if (!ctx.resolve(op.slug)) {
|
|
459
|
+
errors.push(notFoundError(op.slug));
|
|
460
|
+
primaryCode = 'NOT_FOUND';
|
|
461
|
+
}
|
|
260
462
|
if (!ctx.existsAnywhere(op.target))
|
|
261
463
|
errors.push(`Target '${op.target}' does not exist`);
|
|
262
|
-
|
|
464
|
+
else if (!primaryCode && op.relation === 'parent') {
|
|
465
|
+
const cycle = parentCycleError(op.slug, op.target, ctx);
|
|
466
|
+
if (cycle)
|
|
467
|
+
errors.push(cycle);
|
|
468
|
+
}
|
|
469
|
+
if (!primaryCode) {
|
|
470
|
+
const links = ctx.linksOf(op.slug);
|
|
471
|
+
ctx.simulateLinks(op.slug, op.relation === 'parent'
|
|
472
|
+
? { ...links, parent: op.target }
|
|
473
|
+
: { ...links, related: [...(links.related ?? []).filter(r => r !== op.target), op.target] });
|
|
474
|
+
}
|
|
475
|
+
return { slug: op.slug, errors, primaryCode };
|
|
263
476
|
},
|
|
264
477
|
},
|
|
265
478
|
{
|
|
@@ -270,9 +483,18 @@ export const OP_SPECS = [
|
|
|
270
483
|
exampleInclude: [],
|
|
271
484
|
check(op, ctx) {
|
|
272
485
|
const errors = [];
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
486
|
+
let primaryCode;
|
|
487
|
+
if (!ctx.resolve(op.slug)) {
|
|
488
|
+
errors.push(notFoundError(op.slug));
|
|
489
|
+
primaryCode = 'NOT_FOUND';
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
const links = ctx.linksOf(op.slug);
|
|
493
|
+
ctx.simulateLinks(op.slug, op.relation === 'parent'
|
|
494
|
+
? { ...links, parent: links.parent === op.target ? undefined : links.parent }
|
|
495
|
+
: { ...links, related: links.related?.filter(r => r !== op.target) });
|
|
496
|
+
}
|
|
497
|
+
return { slug: op.slug, errors, primaryCode };
|
|
276
498
|
},
|
|
277
499
|
},
|
|
278
500
|
{
|
|
@@ -283,9 +505,12 @@ export const OP_SPECS = [
|
|
|
283
505
|
exampleInclude: [],
|
|
284
506
|
check(op, ctx) {
|
|
285
507
|
const errors = [];
|
|
508
|
+
let primaryCode;
|
|
286
509
|
const cur = ctx.resolve(op.slug);
|
|
287
|
-
if (!cur)
|
|
288
|
-
errors.push(
|
|
510
|
+
if (!cur) {
|
|
511
|
+
errors.push(notFoundError(op.slug));
|
|
512
|
+
primaryCode = 'NOT_FOUND';
|
|
513
|
+
}
|
|
289
514
|
else {
|
|
290
515
|
const currentType = cur.kind === 'knowledge' ? 'knowledge' : cur.type;
|
|
291
516
|
try {
|
|
@@ -295,16 +520,56 @@ export const OP_SPECS = [
|
|
|
295
520
|
}
|
|
296
521
|
else {
|
|
297
522
|
ctx.simulate(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
|
|
523
|
+
// A promoted knowledge note keeps related but not parent.
|
|
524
|
+
if (op.target === 'knowledge')
|
|
525
|
+
ctx.simulateLinks(op.slug, { related: ctx.linksOf(op.slug).related });
|
|
298
526
|
}
|
|
299
527
|
}
|
|
300
528
|
catch (err) {
|
|
301
529
|
pushErr(errors, err);
|
|
302
530
|
}
|
|
303
531
|
}
|
|
304
|
-
return { slug: op.slug, errors };
|
|
532
|
+
return { slug: op.slug, errors, primaryCode };
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: 'delete',
|
|
537
|
+
op: 'delete',
|
|
538
|
+
schema: DeleteBranch,
|
|
539
|
+
description: 'Delete a knowledge or session note. An entity is never deleted — retire it with `hafez update <slug> --status done`. Rejected while another entity or knowledge note links to the note (parent/related): unlink or re-point those first — an earlier op in the same batch counts.',
|
|
540
|
+
exampleInclude: [],
|
|
541
|
+
addressesSessions: true,
|
|
542
|
+
check(op, ctx) {
|
|
543
|
+
const errors = [];
|
|
544
|
+
let primaryCode;
|
|
545
|
+
const target = ctx.resolve(op.slug);
|
|
546
|
+
const session = ctx.sessionExists(op.slug);
|
|
547
|
+
if (target?.kind === 'entity') {
|
|
548
|
+
errors.push(`'${op.slug}' is an entity (${target.type}) — an entity is never deleted; retire it with \`hafez update ${op.slug} --status done\``);
|
|
549
|
+
}
|
|
550
|
+
else if (target && session) {
|
|
551
|
+
errors.push(`'${op.slug}' is ambiguous — it names both a knowledge note and a session note, and delete will not guess which`);
|
|
552
|
+
}
|
|
553
|
+
else if (!target && !session) {
|
|
554
|
+
errors.push(notFoundError(op.slug));
|
|
555
|
+
primaryCode = 'NOT_FOUND';
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
// Only knowledge can be linked to: a parent/related slug must name an
|
|
559
|
+
// entity or knowledge note, never a session.
|
|
560
|
+
const referrers = target ? ctx.referrers(op.slug) : [];
|
|
561
|
+
if (referrers.length > 0) {
|
|
562
|
+
errors.push(`'${op.slug}' is still linked from ${referrers.join(', ')} (parent/related) — unlink or re-point those first; an earlier op in the same batch counts`);
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
ctx.simulateDelete(op.slug, target ? 'knowledge' : 'session');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return { slug: op.slug, errors, primaryCode };
|
|
305
569
|
},
|
|
306
570
|
},
|
|
307
571
|
];
|
|
572
|
+
export const OP_SPECS = OP_SPEC_ROWS;
|
|
308
573
|
/** Find the spec for a (shape-valid) operation. */
|
|
309
574
|
export function specFor(op) {
|
|
310
575
|
const spec = OP_SPECS.find(s => s.op === op.op && (s.kind === undefined || s.kind === op.kind));
|
|
@@ -316,6 +581,23 @@ export function specFor(op) {
|
|
|
316
581
|
export function validOpNames() {
|
|
317
582
|
return [...new Set(OP_SPECS.map(s => s.op))];
|
|
318
583
|
}
|
|
584
|
+
/** Ops whose payload addresses an existing file by `slug` — derived from the
|
|
585
|
+
* Zod shapes, so a new slug-bearing op picks up slug-level rules (the
|
|
586
|
+
* NOT_FOUND precedence on shape failure in validateBatchCore) without a
|
|
587
|
+
* hand-kept list. */
|
|
588
|
+
export function slugBearingOpNames() {
|
|
589
|
+
return [...new Set(OP_SPECS
|
|
590
|
+
.filter(s => {
|
|
591
|
+
const shape = s.schema.shape;
|
|
592
|
+
return shape !== undefined && 'slug' in shape;
|
|
593
|
+
})
|
|
594
|
+
.map(s => s.op))];
|
|
595
|
+
}
|
|
596
|
+
/** Slug-bearing ops whose slug may name a session note — a missing entity
|
|
597
|
+
* or knowledge note alone is not NOT_FOUND for them. */
|
|
598
|
+
export function sessionAddressingOpNames() {
|
|
599
|
+
return OP_SPECS.filter(s => s.addressesSessions).map(s => s.op);
|
|
600
|
+
}
|
|
319
601
|
/** Create kinds for error text — derived from the table. */
|
|
320
602
|
export function validCreateKinds() {
|
|
321
603
|
return OP_SPECS.filter(s => s.op === 'create' && s.kind).map(s => s.kind);
|