hafez 1.4.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 (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +225 -114
  4. package/dist/batch-ops.d.ts +89 -26
  5. package/dist/batch-ops.js +176 -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 +72 -30
  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 +13 -4
  26. package/dist/cli/upgrade.js +90 -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 +44 -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 +523 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +45 -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,22 @@ 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>;
28
30
  remove_action: z.ZodOptional<z.ZodString>;
29
31
  clear_actions: z.ZodOptional<z.ZodBoolean>;
32
+ parent: z.ZodOptional<z.ZodString>;
30
33
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
31
34
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
32
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
35
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
33
36
  confidence: z.ZodOptional<z.ZodEnum<{
34
37
  [x: string]: string;
35
38
  }>>;
36
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
37
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
39
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
40
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
38
41
  synthesis: z.ZodOptional<z.ZodString>;
39
42
  insight: z.ZodOptional<z.ZodString>;
40
43
  add_evidence: z.ZodOptional<z.ZodString>;
@@ -47,10 +50,10 @@ export declare const CreateEntityFieldsSchema: z.ZodObject<{
47
50
  purpose: z.ZodOptional<z.ZodString>;
48
51
  description: z.ZodOptional<z.ZodString>;
49
52
  resource: z.ZodOptional<z.ZodString>;
50
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
51
54
  parent: z.ZodOptional<z.ZodString>;
52
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
55
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
56
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
54
57
  brief: z.ZodOptional<z.ZodString>;
55
58
  add_action: z.ZodOptional<z.ZodString>;
56
59
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -62,23 +65,30 @@ export declare const CreateKnowledgeFieldsSchema: z.ZodObject<{
62
65
  status: z.ZodOptional<z.ZodEnum<{
63
66
  [x: string]: string;
64
67
  }>>;
68
+ confidence: z.ZodOptional<z.ZodEnum<{
69
+ [x: string]: string;
70
+ }>>;
65
71
  synthesis: z.ZodOptional<z.ZodString>;
66
72
  insight: z.ZodOptional<z.ZodString>;
67
73
  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>>;
74
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
75
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
76
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
71
77
  'session-date': z.ZodOptional<z.ZodString>;
72
78
  }, z.core.$strict>;
73
79
  export declare const CreateSessionFieldsSchema: z.ZodObject<{
74
80
  synthesis: z.ZodOptional<z.ZodString>;
75
81
  insight: z.ZodOptional<z.ZodString>;
76
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
82
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
77
83
  'session-date': z.ZodOptional<z.ZodString>;
78
84
  }, z.core.$strict>;
79
85
  /** What `digest` may put in a session-create op — typechecked against the
80
86
  * schema so the two can never silently diverge again. */
81
87
  export type CreateSessionFieldsInput = z.infer<typeof CreateSessionFieldsSchema>;
88
+ /** The fields an update's mechanised session log entry names — empty when
89
+ * the update earns none. Shared by the update check (which rejects an
90
+ * `agent` with no entry to name) and the Apply update arm (which writes it). */
91
+ export declare function loggedFields(fields: UpdateFields): (keyof UpdateFields)[];
82
92
  export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
83
93
  op: z.ZodLiteral<"update">;
84
94
  slug: z.ZodString;
@@ -95,20 +105,22 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
95
105
  body: z.ZodOptional<z.ZodString>;
96
106
  agent: z.ZodString;
97
107
  }, z.core.$strict>>;
108
+ agent: z.ZodOptional<z.ZodString>;
98
109
  brief: z.ZodOptional<z.ZodNullable<z.ZodString>>;
99
110
  add_action: z.ZodOptional<z.ZodString>;
100
111
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
101
112
  complete_action: z.ZodOptional<z.ZodString>;
102
113
  remove_action: z.ZodOptional<z.ZodString>;
103
114
  clear_actions: z.ZodOptional<z.ZodBoolean>;
115
+ parent: z.ZodOptional<z.ZodString>;
104
116
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
105
117
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
106
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
118
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
107
119
  confidence: z.ZodOptional<z.ZodEnum<{
108
120
  [x: string]: string;
109
121
  }>>;
110
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
122
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
123
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
112
124
  synthesis: z.ZodOptional<z.ZodString>;
113
125
  insight: z.ZodOptional<z.ZodString>;
114
126
  add_evidence: z.ZodOptional<z.ZodString>;
@@ -144,6 +156,9 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
144
156
  project: "project";
145
157
  knowledge: "knowledge";
146
158
  }>;
159
+ }, z.core.$strict>, z.ZodObject<{
160
+ op: z.ZodLiteral<"delete">;
161
+ slug: z.ZodString;
147
162
  }, z.core.$strict>], "op">;
148
163
  /** Canonicalize an operation's declared aliases.
149
164
  *
@@ -161,6 +176,12 @@ export interface ResolvedTarget {
161
176
  type: string;
162
177
  subtype?: KnowledgeSubtype;
163
178
  }
179
+ /** The frontmatter links `validate` checks: what makes one file a referrer
180
+ * of another. */
181
+ export interface FileLinks {
182
+ parent?: string;
183
+ related?: string[];
184
+ }
164
185
  /** Vault context handed to each spec's semantic check by validateBatchCore.
165
186
  * Simulation-aware: creates earlier in the batch count as existing. */
166
187
  export interface OpCheckContext {
@@ -169,6 +190,15 @@ export interface OpCheckContext {
169
190
  sessionExists(slug: string): boolean;
170
191
  simulate(slug: string, info: ResolvedTarget): void;
171
192
  simulateSession(slug: string): void;
193
+ /** The parent/related a file carries after the earlier ops in the batch. */
194
+ linksOf(slug: string): FileLinks;
195
+ /** Record the parent/related a file will carry after this op. Every op
196
+ * that changes a link calls it, so `referrers` sees the batch so far. */
197
+ simulateLinks(slug: string, links: FileLinks): void;
198
+ /** Entities and knowledge notes whose parent or related names `slug`,
199
+ * after the earlier ops in the batch. A file never refers to itself. */
200
+ referrers(slug: string): string[];
201
+ simulateDelete(slug: string, kind: DeletableKind): void;
172
202
  /** Kind gating is subtype-aware for knowledge: a plan carries `status`,
173
203
  * an insight does not (see contracts.SUBTYPE_STATUSES). */
174
204
  validateKindFields(kind: 'entity' | 'knowledge', fields: UpdateFields, subtype?: KnowledgeSubtype): void;
@@ -206,6 +236,11 @@ export declare function batchInvalidHeader(invalidCount: number, total: number):
206
236
  * dry-run (spec warning) and apply (BatchResult.warning) report it with this
207
237
  * same sentence so the two can never drift. */
208
238
  export declare function duplicateSessionWarning(slug: string): string;
239
+ /** The parent chain `slug → parent → … → slug` that giving `slug` this
240
+ * parent closes, or null when the chain ends or loops without `slug`.
241
+ * Shared by the Op Spec checks (against the simulated batch) and
242
+ * `validateVault` (against the files). */
243
+ export declare function parentCycle(slug: string, parent: string | undefined, parentOf: (s: string) => string | undefined): string[] | null;
209
244
  export interface OpSpec {
210
245
  /** Stable identifier used by `schema <name>` and the op catalog. */
211
246
  name: string;
@@ -215,6 +250,9 @@ export interface OpSpec {
215
250
  description: string;
216
251
  /** Optional fields force-included in the generated example payload. */
217
252
  exampleInclude: readonly string[];
253
+ /** The op's `slug` may name a session note as well as an entity or
254
+ * knowledge note. */
255
+ addressesSessions?: boolean;
218
256
  check(op: any, ctx: OpCheckContext): OpCheckResult;
219
257
  }
220
258
  declare const OP_SPEC_ROWS: readonly [{
@@ -236,27 +274,29 @@ declare const OP_SPEC_ROWS: readonly [{
236
274
  body: z.ZodOptional<z.ZodString>;
237
275
  agent: z.ZodString;
238
276
  }, z.core.$strict>>;
277
+ agent: z.ZodOptional<z.ZodString>;
239
278
  brief: z.ZodOptional<z.ZodNullable<z.ZodString>>;
240
279
  add_action: z.ZodOptional<z.ZodString>;
241
280
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
242
281
  complete_action: z.ZodOptional<z.ZodString>;
243
282
  remove_action: z.ZodOptional<z.ZodString>;
244
283
  clear_actions: z.ZodOptional<z.ZodBoolean>;
284
+ parent: z.ZodOptional<z.ZodString>;
245
285
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
246
286
  resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
247
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
287
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
248
288
  confidence: z.ZodOptional<z.ZodEnum<{
249
289
  [x: string]: string;
250
290
  }>>;
251
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
252
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
291
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
292
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
253
293
  synthesis: z.ZodOptional<z.ZodString>;
254
294
  insight: z.ZodOptional<z.ZodString>;
255
295
  add_evidence: z.ZodOptional<z.ZodString>;
256
296
  add_source: z.ZodOptional<z.ZodString>;
257
297
  }, z.core.$strict>;
258
298
  }, 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.";
299
+ 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` → `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.";
260
300
  readonly exampleInclude: readonly ["status"];
261
301
  readonly check: (op: Extract<BatchOperation, {
262
302
  op: "update";
@@ -280,10 +320,10 @@ declare const OP_SPEC_ROWS: readonly [{
280
320
  purpose: z.ZodOptional<z.ZodString>;
281
321
  description: z.ZodOptional<z.ZodString>;
282
322
  resource: z.ZodOptional<z.ZodString>;
283
- domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
323
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
284
324
  parent: z.ZodOptional<z.ZodString>;
285
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
286
- tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
325
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
326
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
287
327
  brief: z.ZodOptional<z.ZodString>;
288
328
  add_action: z.ZodOptional<z.ZodString>;
289
329
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -315,16 +355,19 @@ declare const OP_SPEC_ROWS: readonly [{
315
355
  status: z.ZodOptional<z.ZodEnum<{
316
356
  [x: string]: string;
317
357
  }>>;
358
+ confidence: z.ZodOptional<z.ZodEnum<{
359
+ [x: string]: string;
360
+ }>>;
318
361
  synthesis: z.ZodOptional<z.ZodString>;
319
362
  insight: z.ZodOptional<z.ZodString>;
320
363
  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>>;
364
+ domain: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
365
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
366
+ tags: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
324
367
  'session-date': z.ZodOptional<z.ZodString>;
325
368
  }, z.core.$strict>>;
326
369
  }, 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.";
370
+ 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
371
  readonly exampleInclude: readonly ["subtype", "synthesis"];
329
372
  readonly check: (op: Extract<BatchOperation, {
330
373
  op: "create";
@@ -346,7 +389,7 @@ declare const OP_SPEC_ROWS: readonly [{
346
389
  fields: z.ZodOptional<z.ZodObject<{
347
390
  synthesis: z.ZodOptional<z.ZodString>;
348
391
  insight: z.ZodOptional<z.ZodString>;
349
- related: z.ZodOptional<z.ZodArray<z.ZodString>>;
392
+ related: z.ZodOptional<z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<z.ZodString>>>;
350
393
  'session-date': z.ZodOptional<z.ZodString>;
351
394
  }, z.core.$strict>>;
352
395
  }, z.core.$strict>;
@@ -447,6 +490,23 @@ declare const OP_SPEC_ROWS: readonly [{
447
490
  errors: string[];
448
491
  primaryCode: "SLUG_EXISTS" | "NOT_FOUND" | undefined;
449
492
  };
493
+ }, {
494
+ readonly name: "delete";
495
+ readonly op: "delete";
496
+ readonly schema: z.ZodObject<{
497
+ op: z.ZodLiteral<"delete">;
498
+ slug: z.ZodString;
499
+ }, z.core.$strict>;
500
+ 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.";
501
+ readonly exampleInclude: readonly [];
502
+ readonly addressesSessions: true;
503
+ readonly check: (op: Extract<BatchOperation, {
504
+ op: "delete";
505
+ }>, ctx: OpCheckContext) => {
506
+ slug: string;
507
+ errors: string[];
508
+ primaryCode: "SLUG_EXISTS" | "NOT_FOUND" | undefined;
509
+ };
450
510
  }];
451
511
  /** Every op the table defines. The Apply table (src/apply.ts) is keyed by this
452
512
  * union, so a spec row added here without an apply arm fails to build. */
@@ -465,6 +525,9 @@ export declare function validOpNames(): string[];
465
525
  * NOT_FOUND precedence on shape failure in validateBatchCore) without a
466
526
  * hand-kept list. */
467
527
  export declare function slugBearingOpNames(): string[];
528
+ /** Slug-bearing ops whose slug may name a session note — a missing entity
529
+ * or knowledge note alone is not NOT_FOUND for them. */
530
+ export declare function sessionAddressingOpNames(): string[];
468
531
  /** Create kinds for error text — derived from the table. */
469
532
  export declare function validCreateKinds(): string[];
470
533
  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,37 @@ 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(),
48
55
  remove_action: nonEmptyText.optional(),
49
56
  clear_actions: z.boolean().optional(),
57
+ parent: z.string().optional(), // entity-only; the slug must exist, no cycle
50
58
  // Knowledge metadata
51
59
  description: z.string().nullable().optional(),
52
60
  resource: z.string().nullable().optional(),
53
- domain: z.array(z.string()).optional(),
61
+ domain: stringList.optional(),
54
62
  confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
55
- tags: z.array(z.string()).optional(),
56
- related: z.array(z.string()).optional(),
63
+ tags: stringList.optional(),
64
+ related: stringList.optional(),
57
65
  synthesis: nonEmptyText.optional(),
58
66
  insight: nonEmptyText.optional(), // alias for synthesis (normalized by normalizeAliases)
59
67
  add_evidence: nonEmptyText.optional(),
@@ -64,10 +72,10 @@ export const CreateEntityFieldsSchema = z.object({
64
72
  purpose: nonEmptyText.optional(),
65
73
  description: z.string().optional(),
66
74
  resource: z.string().optional(),
67
- domain: z.array(z.string()).optional(),
75
+ domain: stringList.optional(),
68
76
  parent: z.string().optional(),
69
- related: z.array(z.string()).optional(),
70
- tags: z.array(z.string()).optional(),
77
+ related: stringList.optional(),
78
+ tags: stringList.optional(),
71
79
  brief: nonEmptyText.optional(),
72
80
  add_action: nonEmptyText.optional(),
73
81
  add_actions: z.array(nonEmptyText).optional(),
@@ -77,18 +85,19 @@ export const CreateKnowledgeFieldsSchema = z.object({
77
85
  // Plan-only: insights have no lifecycle, so the semantic check rejects
78
86
  // status on any other subtype (the shape only pins the allowed values).
79
87
  status: z.enum([...PLAN_STATUSES]).optional(),
88
+ confidence: z.enum([...CONFIDENCE_LEVELS]).optional(),
80
89
  synthesis: nonEmptyText.optional(),
81
90
  insight: nonEmptyText.optional(),
82
91
  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(),
92
+ domain: stringList.optional(),
93
+ related: stringList.optional(),
94
+ tags: stringList.optional(),
86
95
  'session-date': z.string().optional(),
87
96
  }).strict();
88
97
  export const CreateSessionFieldsSchema = z.object({
89
98
  synthesis: nonEmptyText.optional(),
90
99
  insight: nonEmptyText.optional(),
91
- related: z.array(z.string()).optional(),
100
+ related: stringList.optional(),
92
101
  'session-date': z.string().optional(),
93
102
  }).strict();
94
103
  const CreateEntityBranch = z.object({
@@ -109,6 +118,16 @@ const CreateSessionBranch = z.object({
109
118
  name: z.string(),
110
119
  fields: CreateSessionFieldsSchema.optional(),
111
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
+ }
112
131
  const UpdateBranch = z.object({
113
132
  op: z.literal('update'),
114
133
  slug: z.string(),
@@ -136,6 +155,12 @@ const PromoteBranch = z.object({
136
155
  slug: z.string(),
137
156
  target: z.enum(['entity', 'project', 'knowledge']),
138
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();
139
164
  // Nested discriminated union: outer on `op`, inner on `kind` for create branch.
140
165
  // A flat `discriminatedUnion('op', [...])` cannot work because three branches share
141
166
  // op=create — Zod requires unique discriminator values per branch and throws at
@@ -154,6 +179,7 @@ export const BatchOperationSchema = z.discriminatedUnion('op', [
154
179
  LinkBranch,
155
180
  UnlinkBranch,
156
181
  PromoteBranch,
182
+ DeleteBranch,
157
183
  ]);
158
184
  // --- Alias normalization ---
159
185
  // The one place a declared alias (contracts.ts ALIAS_KEYS) becomes its canonical
@@ -251,6 +277,36 @@ function claimNew(ctx, errors, slug, info, build) {
251
277
  ctx.simulate(slug, info);
252
278
  return taken ? 'SLUG_EXISTS' : undefined;
253
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
+ }
254
310
  // `as const satisfies` rather than a `: OpSpec[]` annotation: it keeps each
255
311
  // row's `name` a literal, which is what OpSpecName (and through it the Apply
256
312
  // table's key type) is derived from. OP_SPECS below re-exports the rows under
@@ -260,7 +316,7 @@ const OP_SPEC_ROWS = [
260
316
  name: 'update',
261
317
  op: 'update',
262
318
  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.',
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.',
264
320
  exampleInclude: ['status'],
265
321
  check(op, ctx) {
266
322
  const errors = [];
@@ -278,6 +334,29 @@ const OP_SPEC_ROWS = [
278
334
  pushErr(errors, err);
279
335
  }
280
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
+ }
281
360
  if (op.fields.session_log) {
282
361
  const logErrors = ctx.validateSessionLogEntry(op.fields.session_log);
283
362
  if (logErrors.length > 0)
@@ -289,6 +368,20 @@ const OP_SPEC_ROWS = [
289
368
  errors.push(`Related slug '${r}' does not exist`);
290
369
  }
291
370
  }
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
+ }
292
385
  return { slug: op.slug, errors, primaryCode };
293
386
  },
294
387
  },
@@ -303,6 +396,8 @@ const OP_SPEC_ROWS = [
303
396
  const errors = [];
304
397
  const slug = slugify(op.name);
305
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 });
306
401
  return { slug, errors, created: errors.length === 0, primaryCode };
307
402
  },
308
403
  },
@@ -311,12 +406,14 @@ const OP_SPEC_ROWS = [
311
406
  op: 'create',
312
407
  kind: 'knowledge',
313
408
  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.',
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.',
315
410
  exampleInclude: ['subtype', 'synthesis'],
316
411
  check(op, ctx) {
317
412
  const errors = [];
318
413
  const slug = slugify(op.name);
319
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 });
320
417
  return { slug, errors, created: errors.length === 0, primaryCode };
321
418
  },
322
419
  },
@@ -364,6 +461,17 @@ const OP_SPEC_ROWS = [
364
461
  }
365
462
  if (!ctx.existsAnywhere(op.target))
366
463
  errors.push(`Target '${op.target}' does not exist`);
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
+ }
367
475
  return { slug: op.slug, errors, primaryCode };
368
476
  },
369
477
  },
@@ -380,6 +488,12 @@ const OP_SPEC_ROWS = [
380
488
  errors.push(notFoundError(op.slug));
381
489
  primaryCode = 'NOT_FOUND';
382
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
+ }
383
497
  return { slug: op.slug, errors, primaryCode };
384
498
  },
385
499
  },
@@ -406,6 +520,9 @@ const OP_SPEC_ROWS = [
406
520
  }
407
521
  else {
408
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 });
409
526
  }
410
527
  }
411
528
  catch (err) {
@@ -415,6 +532,42 @@ const OP_SPEC_ROWS = [
415
532
  return { slug: op.slug, errors, primaryCode };
416
533
  },
417
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 };
569
+ },
570
+ },
418
571
  ];
419
572
  export const OP_SPECS = OP_SPEC_ROWS;
420
573
  /** Find the spec for a (shape-valid) operation. */
@@ -440,6 +593,11 @@ export function slugBearingOpNames() {
440
593
  })
441
594
  .map(s => s.op))];
442
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
+ }
443
601
  /** Create kinds for error text — derived from the table. */
444
602
  export function validCreateKinds() {
445
603
  return OP_SPECS.filter(s => s.op === 'create' && s.kind).map(s => s.kind);