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.
Files changed (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. 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 adding one entry here.
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: z.string().optional(),
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: z.string().optional(),
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: z.string().optional(),
28
- add_actions: z.array(z.string()).optional(),
29
- complete_action: z.string().optional(),
30
- remove_action: z.string().optional(),
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: z.array(z.string()).optional(),
61
+ domain: stringList.optional(),
36
62
  confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
37
- tags: z.array(z.string()).optional(),
38
- related: z.array(z.string()).optional(),
39
- synthesis: z.string().optional(),
40
- insight: z.string().optional(), // alias for synthesis (normalized in parseBatchInput)
41
- add_evidence: z.string().optional(),
42
- add_source: z.string().optional(),
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: z.string().optional(),
72
+ purpose: nonEmptyText.optional(),
47
73
  description: z.string().optional(),
48
74
  resource: z.string().optional(),
49
- domain: z.array(z.string()).optional(),
75
+ domain: stringList.optional(),
50
76
  parent: z.string().optional(),
51
- related: z.array(z.string()).optional(),
52
- tags: z.array(z.string()).optional(),
53
- brief: z.string().optional(),
54
- add_action: z.string().optional(),
55
- add_actions: z.array(z.string()).optional(),
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
- synthesis: z.string().optional(),
60
- insight: z.string().optional(),
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: z.array(z.string()).optional(),
63
- related: z.array(z.string()).optional(),
64
- tags: z.array(z.string()).optional(),
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: z.string().optional(),
69
- insight: z.string().optional(),
70
- related: z.array(z.string()).optional(),
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
- export const OP_SPECS = [
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(`'${op.slug}' not found`);
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
- return { slug: op.slug, errors };
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
- if (ctx.existsAnywhere(slug))
186
- errors.push(`Slug '${slug}' already exists`);
187
- try {
188
- ctx.buildEntityFrontmatter(op.name, op.fields);
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
- if (ctx.existsAnywhere(slug))
208
- errors.push(`Slug '${slug}' already exists`);
209
- try {
210
- ctx.buildKnowledgeFrontmatter(op.name, (op.fields || {}));
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: `session '${slug}' already exists — op will be skipped (batch continues)` };
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
- if (ctx.existsAnywhere(slug))
245
- errors.push(`Slug '${slug}' already exists`);
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
- if (!ctx.resolve(op.slug))
259
- errors.push(`'${op.slug}' not found`);
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
- return { slug: op.slug, errors };
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
- if (!ctx.resolve(op.slug))
274
- errors.push(`'${op.slug}' not found`);
275
- return { slug: op.slug, errors };
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(`'${op.slug}' not found`);
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);