hafez 1.4.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +249 -114
  4. package/dist/batch-ops.d.ts +95 -26
  5. package/dist/batch-ops.js +178 -18
  6. package/dist/cli/args.d.ts +43 -5
  7. package/dist/cli/args.js +69 -8
  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 +4 -3
  13. package/dist/cli/commands.js +300 -99
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +18 -4
  17. package/dist/cli/index.js +73 -31
  18. package/dist/cli/install.d.ts +65 -0
  19. package/dist/cli/install.js +333 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +273 -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 +15 -4
  26. package/dist/cli/upgrade.js +103 -44
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +17 -1
  30. package/dist/contracts.js +46 -0
  31. package/dist/db.d.ts +15 -2
  32. package/dist/db.js +69 -48
  33. package/dist/digest.d.ts +6 -4
  34. package/dist/digest.js +28 -17
  35. package/dist/document.d.ts +41 -3
  36. package/dist/document.js +218 -62
  37. package/dist/export-okf.d.ts +13 -0
  38. package/dist/export-okf.js +11 -7
  39. package/dist/harness.d.ts +58 -0
  40. package/dist/harness.js +50 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +94 -104
  43. package/dist/merge.js +7 -5
  44. package/dist/near-match.d.ts +9 -0
  45. package/dist/near-match.js +34 -0
  46. package/dist/stale.d.ts +41 -0
  47. package/dist/stale.js +43 -0
  48. package/dist/stopwords.d.ts +6 -0
  49. package/dist/stopwords.js +25 -0
  50. package/dist/surfaces/items.d.ts +366 -0
  51. package/dist/surfaces/items.js +527 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +49 -16
  55. package/dist/validate.d.ts +7 -0
  56. package/dist/validate.js +109 -0
  57. package/dist/vault.js +1 -1
  58. package/package.json +4 -3
  59. package/skill/hafez/SKILL.md +68 -0
  60. package/skill/hafez-save/SKILL.md +29 -0
  61. package/dist/cli/help-agent.d.ts +0 -1
  62. package/dist/cli/help-agent.js +0 -210
  63. package/skill/SKILL.md +0 -137
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import type { BatchOperation, CreateEntityFields, CreateKnowledgeFields, KnowledgeSubtype, SessionLogEntry, UpdateFields } from './types.js';
2
+ import type { BatchOperation, CreateEntityFields, CreateKnowledgeFields, DeletableKind, KnowledgeSubtype, SessionLogEntry, UpdateFields } from './types.js';
3
+ export declare const splitCommaList: (value: string) => string[];
3
4
  export declare const SessionLogSchema: z.ZodObject<{
4
5
  type: z.ZodEnum<{
5
6
  [x: string]: string;
@@ -21,20 +22,24 @@ export declare const UpdateFieldsSchema: z.ZodObject<{
21
22
  body: z.ZodOptional<z.ZodString>;
22
23
  agent: z.ZodString;
23
24
  }, z.core.$strict>>;
25
+ agent: z.ZodOptional<z.ZodString>;
24
26
  brief: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
27
  add_action: z.ZodOptional<z.ZodString>;
26
28
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
27
29
  complete_action: z.ZodOptional<z.ZodString>;
30
+ complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
31
  remove_action: z.ZodOptional<z.ZodString>;
32
+ remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
29
33
  clear_actions: z.ZodOptional<z.ZodBoolean>;
34
+ parent: z.ZodOptional<z.ZodString>;
30
35
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
31
36
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
32
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
37
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
33
38
  confidence: z.ZodOptional<z.ZodEnum<{
34
39
  [x: string]: string;
35
40
  }>>;
36
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
37
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
41
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
42
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
38
43
  synthesis: z.ZodOptional<z.ZodString>;
39
44
  insight: z.ZodOptional<z.ZodString>;
40
45
  add_evidence: z.ZodOptional<z.ZodString>;
@@ -47,10 +52,10 @@ export declare const CreateEntityFieldsSchema: z.ZodObject<{
47
52
  purpose: z.ZodOptional<z.ZodString>;
48
53
  description: z.ZodOptional<z.ZodString>;
49
54
  resource: z.ZodOptional<z.ZodString>;
50
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
55
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
51
56
  parent: z.ZodOptional<z.ZodString>;
52
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
57
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
58
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
54
59
  brief: z.ZodOptional<z.ZodString>;
55
60
  add_action: z.ZodOptional<z.ZodString>;
56
61
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -62,23 +67,30 @@ export declare const CreateKnowledgeFieldsSchema: z.ZodObject<{
62
67
  status: z.ZodOptional<z.ZodEnum<{
63
68
  [x: string]: string;
64
69
  }>>;
70
+ confidence: z.ZodOptional<z.ZodEnum<{
71
+ [x: string]: string;
72
+ }>>;
65
73
  synthesis: z.ZodOptional<z.ZodString>;
66
74
  insight: z.ZodOptional<z.ZodString>;
67
75
  description: z.ZodOptional<z.ZodString>;
68
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
69
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
70
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
76
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
77
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
78
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
71
79
  'session-date': z.ZodOptional<z.ZodString>;
72
80
  }, z.core.$strict>;
73
81
  export declare const CreateSessionFieldsSchema: z.ZodObject<{
74
82
  synthesis: z.ZodOptional<z.ZodString>;
75
83
  insight: z.ZodOptional<z.ZodString>;
76
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
84
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
77
85
  'session-date': z.ZodOptional<z.ZodString>;
78
86
  }, z.core.$strict>;
79
87
  /** What `digest` may put in a session-create op — typechecked against the
80
88
  * schema so the two can never silently diverge again. */
81
89
  export type CreateSessionFieldsInput = z.infer<typeof CreateSessionFieldsSchema>;
90
+ /** The fields an update's mechanised session log entry names — empty when
91
+ * the update earns none. Shared by the update check (which rejects an
92
+ * `agent` with no entry to name) and the Apply update arm (which writes it). */
93
+ export declare function loggedFields(fields: UpdateFields): (keyof UpdateFields)[];
82
94
  export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
83
95
  op: z.ZodLiteral<"update">;
84
96
  slug: z.ZodString;
@@ -95,20 +107,24 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
95
107
  body: z.ZodOptional<z.ZodString>;
96
108
  agent: z.ZodString;
97
109
  }, z.core.$strict>>;
110
+ agent: z.ZodOptional<z.ZodString>;
98
111
  brief: z.ZodOptional<z.ZodNullable<z.ZodString>>;
99
112
  add_action: z.ZodOptional<z.ZodString>;
100
113
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
101
114
  complete_action: z.ZodOptional<z.ZodString>;
115
+ complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
102
116
  remove_action: z.ZodOptional<z.ZodString>;
117
+ remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
103
118
  clear_actions: z.ZodOptional<z.ZodBoolean>;
119
+ parent: z.ZodOptional<z.ZodString>;
104
120
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
105
121
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
106
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
122
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
107
123
  confidence: z.ZodOptional<z.ZodEnum<{
108
124
  [x: string]: string;
109
125
  }>>;
110
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
126
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
127
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
112
128
  synthesis: z.ZodOptional<z.ZodString>;
113
129
  insight: z.ZodOptional<z.ZodString>;
114
130
  add_evidence: z.ZodOptional<z.ZodString>;
@@ -144,6 +160,9 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
144
160
  project: "project";
145
161
  knowledge: "knowledge";
146
162
  }>;
163
+ }, z.core.$strict>, z.ZodObject<{
164
+ op: z.ZodLiteral<"delete">;
165
+ slug: z.ZodString;
147
166
  }, z.core.$strict>], "op">;
148
167
  /** Canonicalize an operation's declared aliases.
149
168
  *
@@ -161,6 +180,12 @@ export interface ResolvedTarget {
161
180
  type: string;
162
181
  subtype?: KnowledgeSubtype;
163
182
  }
183
+ /** The frontmatter links `validate` checks: what makes one file a referrer
184
+ * of another. */
185
+ export interface FileLinks {
186
+ parent?: string;
187
+ related?: string[];
188
+ }
164
189
  /** Vault context handed to each spec's semantic check by validateBatchCore.
165
190
  * Simulation-aware: creates earlier in the batch count as existing. */
166
191
  export interface OpCheckContext {
@@ -169,6 +194,15 @@ export interface OpCheckContext {
169
194
  sessionExists(slug: string): boolean;
170
195
  simulate(slug: string, info: ResolvedTarget): void;
171
196
  simulateSession(slug: string): void;
197
+ /** The parent/related a file carries after the earlier ops in the batch. */
198
+ linksOf(slug: string): FileLinks;
199
+ /** Record the parent/related a file will carry after this op. Every op
200
+ * that changes a link calls it, so `referrers` sees the batch so far. */
201
+ simulateLinks(slug: string, links: FileLinks): void;
202
+ /** Entities and knowledge notes whose parent or related names `slug`,
203
+ * after the earlier ops in the batch. A file never refers to itself. */
204
+ referrers(slug: string): string[];
205
+ simulateDelete(slug: string, kind: DeletableKind): void;
172
206
  /** Kind gating is subtype-aware for knowledge: a plan carries `status`,
173
207
  * an insight does not (see contracts.SUBTYPE_STATUSES). */
174
208
  validateKindFields(kind: 'entity' | 'knowledge', fields: UpdateFields, subtype?: KnowledgeSubtype): void;
@@ -206,6 +240,11 @@ export declare function batchInvalidHeader(invalidCount: number, total: number):
206
240
  * dry-run (spec warning) and apply (BatchResult.warning) report it with this
207
241
  * same sentence so the two can never drift. */
208
242
  export declare function duplicateSessionWarning(slug: string): string;
243
+ /** The parent chain `slug → parent → … → slug` that giving `slug` this
244
+ * parent closes, or null when the chain ends or loops without `slug`.
245
+ * Shared by the Op Spec checks (against the simulated batch) and
246
+ * `validateVault` (against the files). */
247
+ export declare function parentCycle(slug: string, parent: string | undefined, parentOf: (s: string) => string | undefined): string[] | null;
209
248
  export interface OpSpec {
210
249
  /** Stable identifier used by `schema <name>` and the op catalog. */
211
250
  name: string;
@@ -215,6 +254,9 @@ export interface OpSpec {
215
254
  description: string;
216
255
  /** Optional fields force-included in the generated example payload. */
217
256
  exampleInclude: readonly string[];
257
+ /** The op's `slug` may name a session note as well as an entity or
258
+ * knowledge note. */
259
+ addressesSessions?: boolean;
218
260
  check(op: any, ctx: OpCheckContext): OpCheckResult;
219
261
  }
220
262
  declare const OP_SPEC_ROWS: readonly [{
@@ -236,27 +278,31 @@ declare const OP_SPEC_ROWS: readonly [{
236
278
  body: z.ZodOptional<z.ZodString>;
237
279
  agent: z.ZodString;
238
280
  }, z.core.$strict>>;
281
+ agent: z.ZodOptional<z.ZodString>;
239
282
  brief: z.ZodOptional<z.ZodNullable<z.ZodString>>;
240
283
  add_action: z.ZodOptional<z.ZodString>;
241
284
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
242
285
  complete_action: z.ZodOptional<z.ZodString>;
286
+ complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
243
287
  remove_action: z.ZodOptional<z.ZodString>;
288
+ remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
244
289
  clear_actions: z.ZodOptional<z.ZodBoolean>;
290
+ parent: z.ZodOptional<z.ZodString>;
245
291
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
246
292
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
247
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
293
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
248
294
  confidence: z.ZodOptional<z.ZodEnum<{
249
295
  [x: string]: string;
250
296
  }>>;
251
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
252
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
297
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
298
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
253
299
  synthesis: z.ZodOptional<z.ZodString>;
254
300
  insight: z.ZodOptional<z.ZodString>;
255
301
  add_evidence: z.ZodOptional<z.ZodString>;
256
302
  add_source: z.ZodOptional<z.ZodString>;
257
303
  }, z.core.$strict>;
258
304
  }, z.core.$strict>;
259
- readonly 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.";
305
+ readonly 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(s)` → `complete_action(s)` → `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.";
260
306
  readonly exampleInclude: readonly ["status"];
261
307
  readonly check: (op: Extract<BatchOperation, {
262
308
  op: "update";
@@ -280,10 +326,10 @@ declare const OP_SPEC_ROWS: readonly [{
280
326
  purpose: z.ZodOptional<z.ZodString>;
281
327
  description: z.ZodOptional<z.ZodString>;
282
328
  resource: z.ZodOptional<z.ZodString>;
283
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
329
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
284
330
  parent: z.ZodOptional<z.ZodString>;
285
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
286
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
331
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
332
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
287
333
  brief: z.ZodOptional<z.ZodString>;
288
334
  add_action: z.ZodOptional<z.ZodString>;
289
335
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -315,16 +361,19 @@ declare const OP_SPEC_ROWS: readonly [{
315
361
  status: z.ZodOptional<z.ZodEnum<{
316
362
  [x: string]: string;
317
363
  }>>;
364
+ confidence: z.ZodOptional<z.ZodEnum<{
365
+ [x: string]: string;
366
+ }>>;
318
367
  synthesis: z.ZodOptional<z.ZodString>;
319
368
  insight: z.ZodOptional<z.ZodString>;
320
369
  description: z.ZodOptional<z.ZodString>;
321
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
322
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
323
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
370
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
371
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
372
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
324
373
  'session-date': z.ZodOptional<z.ZodString>;
325
374
  }, z.core.$strict>>;
326
375
  }, z.core.$strict>;
327
- readonly 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.";
376
+ readonly 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.";
328
377
  readonly exampleInclude: readonly ["subtype", "synthesis"];
329
378
  readonly check: (op: Extract<BatchOperation, {
330
379
  op: "create";
@@ -346,7 +395,7 @@ declare const OP_SPEC_ROWS: readonly [{
346
395
  fields: z.ZodOptional<z.ZodObject<{
347
396
  synthesis: z.ZodOptional<z.ZodString>;
348
397
  insight: z.ZodOptional<z.ZodString>;
349
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
398
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
350
399
  'session-date': z.ZodOptional<z.ZodString>;
351
400
  }, z.core.$strict>>;
352
401
  }, z.core.$strict>;
@@ -447,6 +496,23 @@ declare const OP_SPEC_ROWS: readonly [{
447
496
  errors: string[];
448
497
  primaryCode: "SLUG_EXISTS" | "NOT_FOUND" | undefined;
449
498
  };
499
+ }, {
500
+ readonly name: "delete";
501
+ readonly op: "delete";
502
+ readonly schema: z.ZodObject<{
503
+ op: z.ZodLiteral<"delete">;
504
+ slug: z.ZodString;
505
+ }, z.core.$strict>;
506
+ readonly 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.";
507
+ readonly exampleInclude: readonly [];
508
+ readonly addressesSessions: true;
509
+ readonly check: (op: Extract<BatchOperation, {
510
+ op: "delete";
511
+ }>, ctx: OpCheckContext) => {
512
+ slug: string;
513
+ errors: string[];
514
+ primaryCode: "SLUG_EXISTS" | "NOT_FOUND" | undefined;
515
+ };
450
516
  }];
451
517
  /** Every op the table defines. The Apply table (src/apply.ts) is keyed by this
452
518
  * union, so a spec row added here without an apply arm fails to build. */
@@ -465,6 +531,9 @@ export declare function validOpNames(): string[];
465
531
  * NOT_FOUND precedence on shape failure in validateBatchCore) without a
466
532
  * hand-kept list. */
467
533
  export declare function slugBearingOpNames(): string[];
534
+ /** Slug-bearing ops whose slug may name a session note — a missing entity
535
+ * or knowledge note alone is not NOT_FOUND for them. */
536
+ export declare function sessionAddressingOpNames(): string[];
468
537
  /** Create kinds for error text — derived from the table. */
469
538
  export declare function validCreateKinds(): string[];
470
539
  export {};
package/dist/batch-ops.js CHANGED
@@ -17,12 +17,6 @@ import { HafezError } from './types.js';
17
17
  // Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
18
18
  // (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
19
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
20
  // Body-section text must have text after trimming. An empty or whitespace-only
27
21
  // value (an agent expanding an unset variable) used to sail through the shape —
28
22
  // on the action/evidence fields it was dropped by a truthiness guard in apply
@@ -37,23 +31,39 @@ export const SessionLogSchema = z.object({
37
31
  // CLI maps `--brief ""` to it. Trimming also keeps accidental padding out of
38
32
  // stored text.
39
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()));
40
+ export const SessionLogSchema = z.object({
41
+ type: z.enum([...SESSION_LOG_TYPES]),
42
+ summary: z.string(),
43
+ body: nonEmptyText.optional(),
44
+ agent: z.string(),
45
+ }).strict();
40
46
  export const UpdateFieldsSchema = z.object({
41
47
  status: z.enum([...ENTITY_STATUSES]).optional(),
42
48
  current_state: nonEmptyText.optional(),
43
49
  session_log: SessionLogSchema.optional(),
50
+ agent: nonEmptyText.optional(), // entity-only; names the agent on the added session log entry
44
51
  brief: z.string().nullable().optional(),
45
52
  add_action: nonEmptyText.optional(),
46
53
  add_actions: z.array(nonEmptyText).optional(),
47
54
  complete_action: nonEmptyText.optional(),
55
+ complete_actions: z.array(nonEmptyText).min(1).optional(),
48
56
  remove_action: nonEmptyText.optional(),
57
+ remove_actions: z.array(nonEmptyText).min(1).optional(),
49
58
  clear_actions: z.boolean().optional(),
59
+ parent: z.string().optional(), // entity-only; the slug must exist, no cycle
50
60
  // Knowledge metadata
51
61
  description: z.string().nullable().optional(),
52
62
  resource: z.string().nullable().optional(),
53
- domain: z.array(z.string()).optional(),
63
+ domain: stringList.optional(),
54
64
  confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
55
- tags: z.array(z.string()).optional(),
56
- related: z.array(z.string()).optional(),
65
+ tags: stringList.optional(),
66
+ related: stringList.optional(),
57
67
  synthesis: nonEmptyText.optional(),
58
68
  insight: nonEmptyText.optional(), // alias for synthesis (normalized by normalizeAliases)
59
69
  add_evidence: nonEmptyText.optional(),
@@ -64,10 +74,10 @@ export const CreateEntityFieldsSchema = z.object({
64
74
  purpose: nonEmptyText.optional(),
65
75
  description: z.string().optional(),
66
76
  resource: z.string().optional(),
67
- domain: z.array(z.string()).optional(),
77
+ domain: stringList.optional(),
68
78
  parent: z.string().optional(),
69
- related: z.array(z.string()).optional(),
70
- tags: z.array(z.string()).optional(),
79
+ related: stringList.optional(),
80
+ tags: stringList.optional(),
71
81
  brief: nonEmptyText.optional(),
72
82
  add_action: nonEmptyText.optional(),
73
83
  add_actions: z.array(nonEmptyText).optional(),
@@ -77,18 +87,19 @@ export const CreateKnowledgeFieldsSchema = z.object({
77
87
  // Plan-only: insights have no lifecycle, so the semantic check rejects
78
88
  // status on any other subtype (the shape only pins the allowed values).
79
89
  status: z.enum([...PLAN_STATUSES]).optional(),
90
+ confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
80
91
  synthesis: nonEmptyText.optional(),
81
92
  insight: nonEmptyText.optional(),
82
93
  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(),
94
+ domain: stringList.optional(),
95
+ related: stringList.optional(),
96
+ tags: stringList.optional(),
86
97
  'session-date': z.string().optional(),
87
98
  }).strict();
88
99
  export const CreateSessionFieldsSchema = z.object({
89
100
  synthesis: nonEmptyText.optional(),
90
101
  insight: nonEmptyText.optional(),
91
- related: z.array(z.string()).optional(),
102
+ related: stringList.optional(),
92
103
  'session-date': z.string().optional(),
93
104
  }).strict();
94
105
  const CreateEntityBranch = z.object({
@@ -109,6 +120,16 @@ const CreateSessionBranch = z.object({
109
120
  name: z.string(),
110
121
  fields: CreateSessionFieldsSchema.optional(),
111
122
  }).strict();
123
+ /** Update fields the mechanised session log neither counts nor names:
124
+ * ticking a box is not history worth an entry, and `agent` only names it. */
125
+ const UNLOGGED_FIELDS = new Set(['add_action', 'add_actions', 'complete_action', 'complete_actions', 'remove_action', 'remove_actions', 'clear_actions', 'agent']);
126
+ /** The fields an update's mechanised session log entry names — empty when
127
+ * the update earns none. Shared by the update check (which rejects an
128
+ * `agent` with no entry to name) and the Apply update arm (which writes it). */
129
+ export function loggedFields(fields) {
130
+ return Object.keys(fields)
131
+ .filter(f => fields[f] !== undefined && !UNLOGGED_FIELDS.has(f));
132
+ }
112
133
  const UpdateBranch = z.object({
113
134
  op: z.literal('update'),
114
135
  slug: z.string(),
@@ -136,6 +157,12 @@ const PromoteBranch = z.object({
136
157
  slug: z.string(),
137
158
  target: z.enum(['entity', 'project', 'knowledge']),
138
159
  }).strict();
160
+ // No `kind`: the slug alone says what is being deleted, the same as
161
+ // `hafez delete <slug>`. The check resolves it and rejects an entity.
162
+ const DeleteBranch = z.object({
163
+ op: z.literal('delete'),
164
+ slug: z.string(),
165
+ }).strict();
139
166
  // Nested discriminated union: outer on `op`, inner on `kind` for create branch.
140
167
  // A flat `discriminatedUnion('op', [...])` cannot work because three branches share
141
168
  // op=create — Zod requires unique discriminator values per branch and throws at
@@ -154,6 +181,7 @@ export const BatchOperationSchema = z.discriminatedUnion('op', [
154
181
  LinkBranch,
155
182
  UnlinkBranch,
156
183
  PromoteBranch,
184
+ DeleteBranch,
157
185
  ]);
158
186
  // --- Alias normalization ---
159
187
  // The one place a declared alias (contracts.ts ALIAS_KEYS) becomes its canonical
@@ -251,6 +279,36 @@ function claimNew(ctx, errors, slug, info, build) {
251
279
  ctx.simulate(slug, info);
252
280
  return taken ? 'SLUG_EXISTS' : undefined;
253
281
  }
282
+ /** The link fields an update payload sets, or null when it sets none. */
283
+ function linkFieldsOf(fields) {
284
+ const links = {};
285
+ if (fields.parent !== undefined)
286
+ links.parent = fields.parent;
287
+ if (fields.related !== undefined)
288
+ links.related = fields.related;
289
+ return Object.keys(links).length > 0 ? links : null;
290
+ }
291
+ /** The parent chain `slug → parent → … → slug` that giving `slug` this
292
+ * parent closes, or null when the chain ends or loops without `slug`.
293
+ * Shared by the Op Spec checks (against the simulated batch) and
294
+ * `validateVault` (against the files). */
295
+ export function parentCycle(slug, parent, parentOf) {
296
+ const path = [slug];
297
+ const seen = new Set(path);
298
+ for (let p = parent; p !== undefined; p = parentOf(p)) {
299
+ path.push(p);
300
+ if (p === slug)
301
+ return path;
302
+ if (seen.has(p))
303
+ return null;
304
+ seen.add(p);
305
+ }
306
+ return null;
307
+ }
308
+ function parentCycleError(slug, parent, ctx) {
309
+ const cycle = parentCycle(slug, parent, s => ctx.linksOf(s).parent);
310
+ return cycle ? `Parent '${parent}' would make a cycle: ${cycle.join(' → ')}` : null;
311
+ }
254
312
  // `as const satisfies` rather than a `: OpSpec[]` annotation: it keeps each
255
313
  // row's `name` a literal, which is what OpSpecName (and through it the Apply
256
314
  // table's key type) is derived from. OP_SPECS below re-exports the rows under
@@ -260,7 +318,7 @@ const OP_SPEC_ROWS = [
260
318
  name: 'update',
261
319
  op: 'update',
262
320
  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.',
321
+ 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(s)` → `complete_action(s)` → `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.',
264
322
  exampleInclude: ['status'],
265
323
  check(op, ctx) {
266
324
  const errors = [];
@@ -278,6 +336,29 @@ const OP_SPEC_ROWS = [
278
336
  pushErr(errors, err);
279
337
  }
280
338
  }
339
+ // Statically contradictory: clear_actions runs first and empties the
340
+ // list, so any query in the same op would fail at apply time with a
341
+ // misleading "No matching action". Reject here, where the message can
342
+ // say why and land beside the batch's other errors. Entity targets
343
+ // only — on a knowledge note validateKindFields has already rejected
344
+ // the fields themselves, and a second error about their order is noise.
345
+ if (target?.kind === 'entity' && op.fields.clear_actions) {
346
+ const queries = ['remove_action', 'remove_actions', 'complete_action', 'complete_actions'].filter(f => op.fields[f] !== undefined);
347
+ if (queries.length > 0) {
348
+ errors.push(`clear_actions empties the list before ${queries.slice(0, -1).join(', ')}${queries.length > 1 ? ' and ' : ''}${queries.at(-1)} would run — drop the clear or use a separate op`);
349
+ }
350
+ }
351
+ // `agent` names the entry the Spine adds; with nothing added it would be
352
+ // dropped. Entity targets only — on a knowledge note validateKindFields
353
+ // has already rejected it as entity-only.
354
+ if (target?.kind === 'entity' && op.fields.agent !== undefined) {
355
+ if (op.fields.session_log) {
356
+ errors.push('agent names the session log entry added when session_log is absent — with session_log, set session_log.agent instead');
357
+ }
358
+ else if (loggedFields(op.fields).length === 0) {
359
+ errors.push('agent names the session log entry, and an update of action fields alone writes none — drop agent');
360
+ }
361
+ }
281
362
  if (op.fields.session_log) {
282
363
  const logErrors = ctx.validateSessionLogEntry(op.fields.session_log);
283
364
  if (logErrors.length > 0)
@@ -289,6 +370,20 @@ const OP_SPEC_ROWS = [
289
370
  errors.push(`Related slug '${r}' does not exist`);
290
371
  }
291
372
  }
373
+ if (op.fields.parent !== undefined) {
374
+ if (!ctx.existsAnywhere(op.fields.parent))
375
+ errors.push(`Parent '${op.fields.parent}' does not exist`);
376
+ else if (target?.kind === 'entity') {
377
+ const cycle = parentCycleError(op.slug, op.fields.parent, ctx);
378
+ if (cycle)
379
+ errors.push(cycle);
380
+ }
381
+ }
382
+ if (target) {
383
+ const links = linkFieldsOf(op.fields);
384
+ if (links)
385
+ ctx.simulateLinks(op.slug, { ...ctx.linksOf(op.slug), ...links });
386
+ }
292
387
  return { slug: op.slug, errors, primaryCode };
293
388
  },
294
389
  },
@@ -303,6 +398,8 @@ const OP_SPEC_ROWS = [
303
398
  const errors = [];
304
399
  const slug = slugify(op.name);
305
400
  const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: op.fields.type }, () => ctx.buildEntityFrontmatter(op.name, op.fields));
401
+ if (!primaryCode)
402
+ ctx.simulateLinks(slug, { parent: op.fields.parent, related: op.fields.related });
306
403
  return { slug, errors, created: errors.length === 0, primaryCode };
307
404
  },
308
405
  },
@@ -311,12 +408,14 @@ const OP_SPEC_ROWS = [
311
408
  op: 'create',
312
409
  kind: 'knowledge',
313
410
  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.',
411
+ 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.',
315
412
  exampleInclude: ['subtype', 'synthesis'],
316
413
  check(op, ctx) {
317
414
  const errors = [];
318
415
  const slug = slugify(op.name);
319
416
  const primaryCode = claimNew(ctx, errors, slug, { kind: 'knowledge', type: 'knowledge', subtype: op.fields?.subtype }, () => ctx.buildKnowledgeFrontmatter(op.name, (op.fields || {})));
417
+ if (!primaryCode)
418
+ ctx.simulateLinks(slug, { related: op.fields?.related });
320
419
  return { slug, errors, created: errors.length === 0, primaryCode };
321
420
  },
322
421
  },
@@ -364,6 +463,17 @@ const OP_SPEC_ROWS = [
364
463
  }
365
464
  if (!ctx.existsAnywhere(op.target))
366
465
  errors.push(`Target '${op.target}' does not exist`);
466
+ else if (!primaryCode && op.relation === 'parent') {
467
+ const cycle = parentCycleError(op.slug, op.target, ctx);
468
+ if (cycle)
469
+ errors.push(cycle);
470
+ }
471
+ if (!primaryCode) {
472
+ const links = ctx.linksOf(op.slug);
473
+ ctx.simulateLinks(op.slug, op.relation === 'parent'
474
+ ? { ...links, parent: op.target }
475
+ : { ...links, related: [...(links.related ?? []).filter(r => r !== op.target), op.target] });
476
+ }
367
477
  return { slug: op.slug, errors, primaryCode };
368
478
  },
369
479
  },
@@ -380,6 +490,12 @@ const OP_SPEC_ROWS = [
380
490
  errors.push(notFoundError(op.slug));
381
491
  primaryCode = 'NOT_FOUND';
382
492
  }
493
+ else {
494
+ const links = ctx.linksOf(op.slug);
495
+ ctx.simulateLinks(op.slug, op.relation === 'parent'
496
+ ? { ...links, parent: links.parent === op.target ? undefined : links.parent }
497
+ : { ...links, related: links.related?.filter(r => r !== op.target) });
498
+ }
383
499
  return { slug: op.slug, errors, primaryCode };
384
500
  },
385
501
  },
@@ -406,6 +522,9 @@ const OP_SPEC_ROWS = [
406
522
  }
407
523
  else {
408
524
  ctx.simulate(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
525
+ // A promoted knowledge note keeps related but not parent.
526
+ if (op.target === 'knowledge')
527
+ ctx.simulateLinks(op.slug, { related: ctx.linksOf(op.slug).related });
409
528
  }
410
529
  }
411
530
  catch (err) {
@@ -415,6 +534,42 @@ const OP_SPEC_ROWS = [
415
534
  return { slug: op.slug, errors, primaryCode };
416
535
  },
417
536
  },
537
+ {
538
+ name: 'delete',
539
+ op: 'delete',
540
+ schema: DeleteBranch,
541
+ 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.',
542
+ exampleInclude: [],
543
+ addressesSessions: true,
544
+ check(op, ctx) {
545
+ const errors = [];
546
+ let primaryCode;
547
+ const target = ctx.resolve(op.slug);
548
+ const session = ctx.sessionExists(op.slug);
549
+ if (target?.kind === 'entity') {
550
+ errors.push(`'${op.slug}' is an entity (${target.type}) — an entity is never deleted; retire it with \`hafez update ${op.slug} --status done\``);
551
+ }
552
+ else if (target && session) {
553
+ errors.push(`'${op.slug}' is ambiguous — it names both a knowledge note and a session note, and delete will not guess which`);
554
+ }
555
+ else if (!target && !session) {
556
+ errors.push(notFoundError(op.slug));
557
+ primaryCode = 'NOT_FOUND';
558
+ }
559
+ else {
560
+ // Only knowledge can be linked to: a parent/related slug must name an
561
+ // entity or knowledge note, never a session.
562
+ const referrers = target ? ctx.referrers(op.slug) : [];
563
+ if (referrers.length > 0) {
564
+ 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`);
565
+ }
566
+ else {
567
+ ctx.simulateDelete(op.slug, target ? 'knowledge' : 'session');
568
+ }
569
+ }
570
+ return { slug: op.slug, errors, primaryCode };
571
+ },
572
+ },
418
573
  ];
419
574
  export const OP_SPECS = OP_SPEC_ROWS;
420
575
  /** Find the spec for a (shape-valid) operation. */
@@ -440,6 +595,11 @@ export function slugBearingOpNames() {
440
595
  })
441
596
  .map(s => s.op))];
442
597
  }
598
+ /** Slug-bearing ops whose slug may name a session note — a missing entity
599
+ * or knowledge note alone is not NOT_FOUND for them. */
600
+ export function sessionAddressingOpNames() {
601
+ return OP_SPECS.filter(s => s.addressesSessions).map(s => s.op);
602
+ }
443
603
  /** Create kinds for error text — derived from the table. */
444
604
  export function validCreateKinds() {
445
605
  return OP_SPECS.filter(s => s.op === 'create' && s.kind).map(s => s.kind);