hafez 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Hafez
2
2
 
3
- **An agent-native personal knowledge vault.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. It keeps a durable, structured memory for AI agents: plain markdown files you own, backed by SQLite full-text search and git.
3
+ **A knowledge and project vault shared between you and your agents.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. Plain markdown files you own, backed by SQLite full-text search and git.
4
4
 
5
5
  ## Why
6
6
 
7
- AI agents forget everything between sessions. Hafez gives them a place to remember — projects, insights, decisions, session history — as a knowledge graph they can read, query, and write through a CLI.
7
+ Humans have short-term context. Agents have session context. Both can think big only about what's loaded right now — so both need a place that carries what happened and where things stand, ready whenever either wants to remember. Hafez is that place: projects with goals, actions, and progress; decisions; the insights that outlive them — a graph of markdown files both of you read.
8
8
 
9
- The files stay yours. Everything is markdown with YAML frontmatter in a git repo: readable in any editor, Obsidian-compatible, diffable, and synced like code. The SQLite index (`.hafez.db`) is a disposable read cache rebuilt from the files; git is the source of truth and the sync mechanism.
9
+ Writing is the agent's job. As you work, the results organise themselves into the vault — at session end, at the critical points of a project — through a CLI that owns the plumbing: structure, validation, search, index, git. A malformed write fails; links must resolve. No agent decides how to treat your vault, so every agent treats it the same — and its context goes to your content, not your conventions.
10
+
11
+ The files stay yours. Everything is markdown with YAML frontmatter in a git repo: readable — and editable — in any editor, Obsidian-compatible, diffable, and synced like code. The SQLite index (`.hafez.db`) is a disposable read cache rebuilt from the files; git is the source of truth and the sync mechanism. Nothing is trapped, either: `hafez export --okf` writes the vault as a standard [OKF v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle — Hafez's schema is stricter than OKF's, so the export loses nothing.
10
12
 
11
13
  ## Setup
12
14
 
@@ -17,8 +19,9 @@ Claude Code (or any agent with shell access):
17
19
  Set up Hafez, an agent-native knowledge vault (github.com/shrvn-r/hafez):
18
20
 
19
21
  1. Install the CLI (needs Node.js 22+ and git): npm install -g hafez
20
- On npm 12+, append --allow-scripts=better-sqlite3 (npm 12 blocks install
21
- scripts by default; the SQLite bindings need theirs).
22
+ On npm 11+, append --allow-scripts=better-sqlite3 (npm 11 blocks install
23
+ scripts by default; the SQLite bindings need theirs, and the install
24
+ reports success even when they were skipped).
22
25
  2. Check git identity is configured (git config user.name and user.email);
23
26
  if not, ask me for a name and email and set them with git config --global.
24
27
  3. Create my vault — a git repo with entities/ and knowledge/ directories and an
@@ -38,13 +41,17 @@ Set up Hafez, an agent-native knowledge vault (github.com/shrvn-r/hafez):
38
41
  From then on, just mention your projects by name in any session — the skill
39
42
  teaches the agent when to read and write the vault, no commands required.
40
43
 
44
+ Upgrading from an earlier name of this tool? Remove the old skill directory
45
+ under `~/.claude/skills/` — two installed skills with near-identical
46
+ triggers compete for the same invocations.
47
+
41
48
  ### Manual setup
42
49
 
43
50
  The same thing by hand:
44
51
 
45
52
  ```bash
46
53
  # Install (Node.js 22+ and git required).
47
- # On npm 12+, append: --allow-scripts=better-sqlite3
54
+ # On npm 11+, append: --allow-scripts=better-sqlite3
48
55
  npm install -g hafez
49
56
 
50
57
  # One-time, if you've never used git on this machine:
@@ -133,7 +140,7 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
133
140
  | `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
134
141
  | `hafez update <slug> [flags]` | Status, brief, actions, session log, synthesis, evidence… |
135
142
  | `hafez link / unlink <slug> <target> --relation <parent\|related>` | Manage relationships |
136
- | `hafez batch [--dry-run]` | JSON ops on stdin → atomic multi-op, single git commit, rollback on failure |
143
+ | `hafez batch [--dry-run] [--file payload.json]` | JSON ops (stdin or `--file`) → atomic multi-op, single git commit, rollback on failure; dry-run runs the same validation as apply and reports derived slugs |
137
144
  | `hafez stats` | Vault summary: counts, stale items, recents |
138
145
  | `hafez changelog --since 7.days.ago` | Git-derived change history |
139
146
  | `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
@@ -181,7 +188,7 @@ for everything on the [AGENTS.md standard](https://agents.md) (Codex, Cursor,
181
188
  Copilot, …). This repo follows the same convention: `AGENTS.md` is canonical
182
189
  and `CLAUDE.md` imports it.
183
190
 
184
- - **`hafez onboard`** — an agent-directed first-run guide: a seeding interview for the empty vault, then a choice of integration level (an always-active block for the harness's global instructions file, or invoke-only).
191
+ - **`hafez onboard`** — an agent-directed first-run guide: a seeding interview for the empty vault, then a choice of integration level (an always-active block for the harness's global instructions file, or invoke-only). Re-running it later is the intended way to seed newly started work — on a set-up vault it's just the interview.
185
192
  - **`hafez help --agent`** — a complete API reference designed to be loaded into an agent's context.
186
193
  - **Claude Code skill** — an optional adapter for one harness: the package bundles a skill (installed in [Setup](#setup)) covering the full session lifecycle: loading context at session start, vault ops mid-session, and a session-end digest.
187
194
  - **`hafez schema <op> --examples`** — JSON schemas with working examples for every batch operation, so agents can self-correct.
@@ -0,0 +1,187 @@
1
+ import { z } from 'zod';
2
+ import type { BatchOperation, CreateEntityFields, CreateKnowledgeFields, SessionLogEntry, UpdateFields } from './types.js';
3
+ export declare const SessionLogSchema: z.ZodObject<{
4
+ type: z.ZodEnum<{
5
+ [x: string]: string;
6
+ }>;
7
+ summary: z.ZodString;
8
+ body: z.ZodOptional<z.ZodString>;
9
+ agent: z.ZodString;
10
+ }, z.core.$strict>;
11
+ export declare const UpdateFieldsSchema: z.ZodObject<{
12
+ status: z.ZodOptional<z.ZodEnum<{
13
+ [x: string]: string;
14
+ }>>;
15
+ current_state: z.ZodOptional<z.ZodString>;
16
+ session_log: z.ZodOptional<z.ZodObject<{
17
+ type: z.ZodEnum<{
18
+ [x: string]: string;
19
+ }>;
20
+ summary: z.ZodString;
21
+ body: z.ZodOptional<z.ZodString>;
22
+ agent: z.ZodString;
23
+ }, z.core.$strict>>;
24
+ brief: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
+ add_action: z.ZodOptional<z.ZodString>;
26
+ add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
27
+ complete_action: z.ZodOptional<z.ZodString>;
28
+ remove_action: z.ZodOptional<z.ZodString>;
29
+ clear_actions: z.ZodOptional<z.ZodBoolean>;
30
+ description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
31
+ resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
32
+ domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
33
+ confidence: z.ZodOptional<z.ZodEnum<{
34
+ [x: string]: string;
35
+ }>>;
36
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
37
+ related: z.ZodOptional<z.ZodArray<z.ZodString>>;
38
+ synthesis: z.ZodOptional<z.ZodString>;
39
+ insight: z.ZodOptional<z.ZodString>;
40
+ add_evidence: z.ZodOptional<z.ZodString>;
41
+ add_source: z.ZodOptional<z.ZodString>;
42
+ }, z.core.$strict>;
43
+ export declare const CreateEntityFieldsSchema: z.ZodObject<{
44
+ type: z.ZodEnum<{
45
+ [x: string]: string;
46
+ }>;
47
+ purpose: z.ZodOptional<z.ZodString>;
48
+ description: z.ZodOptional<z.ZodString>;
49
+ resource: z.ZodOptional<z.ZodString>;
50
+ domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
51
+ parent: z.ZodOptional<z.ZodString>;
52
+ related: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
54
+ brief: z.ZodOptional<z.ZodString>;
55
+ add_action: z.ZodOptional<z.ZodString>;
56
+ add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
57
+ }, z.core.$strict>;
58
+ export declare const CreateKnowledgeFieldsSchema: z.ZodObject<{
59
+ subtype: z.ZodOptional<z.ZodEnum<{
60
+ [x: string]: string;
61
+ }>>;
62
+ synthesis: z.ZodOptional<z.ZodString>;
63
+ insight: z.ZodOptional<z.ZodString>;
64
+ description: z.ZodOptional<z.ZodString>;
65
+ domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
66
+ related: z.ZodOptional<z.ZodArray<z.ZodString>>;
67
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
68
+ 'session-date': z.ZodOptional<z.ZodString>;
69
+ }, z.core.$strict>;
70
+ export declare const CreateSessionFieldsSchema: z.ZodObject<{
71
+ synthesis: z.ZodOptional<z.ZodString>;
72
+ insight: z.ZodOptional<z.ZodString>;
73
+ related: z.ZodOptional<z.ZodArray<z.ZodString>>;
74
+ 'session-date': z.ZodOptional<z.ZodString>;
75
+ }, z.core.$strict>;
76
+ /** What `digest` may put in a session-create op — typechecked against the
77
+ * schema so the two can never silently diverge again. */
78
+ export type CreateSessionFieldsInput = z.infer<typeof CreateSessionFieldsSchema>;
79
+ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
80
+ op: z.ZodLiteral<"update">;
81
+ slug: z.ZodString;
82
+ fields: z.ZodObject<{
83
+ status: z.ZodOptional<z.ZodEnum<{
84
+ [x: string]: string;
85
+ }>>;
86
+ current_state: z.ZodOptional<z.ZodString>;
87
+ session_log: z.ZodOptional<z.ZodObject<{
88
+ type: z.ZodEnum<{
89
+ [x: string]: string;
90
+ }>;
91
+ summary: z.ZodString;
92
+ body: z.ZodOptional<z.ZodString>;
93
+ agent: z.ZodString;
94
+ }, z.core.$strict>>;
95
+ brief: z.ZodOptional<z.ZodNullable<z.ZodString>>;
96
+ add_action: z.ZodOptional<z.ZodString>;
97
+ add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
98
+ complete_action: z.ZodOptional<z.ZodString>;
99
+ remove_action: z.ZodOptional<z.ZodString>;
100
+ clear_actions: z.ZodOptional<z.ZodBoolean>;
101
+ description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
102
+ resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
103
+ domain: z.ZodOptional<z.ZodArray<z.ZodString>>;
104
+ confidence: z.ZodOptional<z.ZodEnum<{
105
+ [x: string]: string;
106
+ }>>;
107
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
108
+ related: z.ZodOptional<z.ZodArray<z.ZodString>>;
109
+ synthesis: z.ZodOptional<z.ZodString>;
110
+ insight: z.ZodOptional<z.ZodString>;
111
+ add_evidence: z.ZodOptional<z.ZodString>;
112
+ add_source: z.ZodOptional<z.ZodString>;
113
+ }, z.core.$strict>;
114
+ }, z.core.$strict>, z.ZodObject<{
115
+ op: z.ZodLiteral<"create">;
116
+ }, z.core.$strip>, z.ZodObject<{
117
+ op: z.ZodLiteral<"capture">;
118
+ name: z.ZodString;
119
+ notes: z.ZodOptional<z.ZodString>;
120
+ }, z.core.$strict>, z.ZodObject<{
121
+ op: z.ZodLiteral<"link">;
122
+ slug: z.ZodString;
123
+ target: z.ZodString;
124
+ relation: z.ZodEnum<{
125
+ parent: "parent";
126
+ related: "related";
127
+ }>;
128
+ }, z.core.$strict>, z.ZodObject<{
129
+ op: z.ZodLiteral<"unlink">;
130
+ slug: z.ZodString;
131
+ target: z.ZodString;
132
+ relation: z.ZodEnum<{
133
+ parent: "parent";
134
+ related: "related";
135
+ }>;
136
+ }, z.core.$strict>, z.ZodObject<{
137
+ op: z.ZodLiteral<"promote">;
138
+ slug: z.ZodString;
139
+ target: z.ZodEnum<{
140
+ entity: "entity";
141
+ project: "project";
142
+ knowledge: "knowledge";
143
+ }>;
144
+ }, z.core.$strict>], "op">;
145
+ /** Vault context handed to each spec's semantic check by validateBatchCore.
146
+ * Simulation-aware: creates earlier in the batch count as existing. */
147
+ export interface OpCheckContext {
148
+ resolve(slug: string): {
149
+ kind: 'entity' | 'knowledge';
150
+ type: string;
151
+ } | null;
152
+ existsAnywhere(slug: string): boolean;
153
+ sessionExists(slug: string): boolean;
154
+ simulate(slug: string, info: {
155
+ kind: 'entity' | 'knowledge';
156
+ type: string;
157
+ }): void;
158
+ simulateSession(slug: string): void;
159
+ validateKindFields(kind: 'entity' | 'knowledge', fields: UpdateFields): void;
160
+ validateSessionLogEntry(entry: SessionLogEntry): string[];
161
+ buildEntityFrontmatter(name: string, fields: CreateEntityFields): void;
162
+ buildKnowledgeFrontmatter(name: string, fields: CreateKnowledgeFields): void;
163
+ }
164
+ export interface OpCheckResult {
165
+ slug: string;
166
+ errors: string[];
167
+ created?: boolean;
168
+ warning?: string;
169
+ }
170
+ export interface OpSpec {
171
+ /** Stable identifier used by `schema <name>` and the op catalog. */
172
+ name: string;
173
+ op: BatchOperation['op'];
174
+ kind?: 'entity' | 'knowledge' | 'session';
175
+ schema: z.ZodType;
176
+ description: string;
177
+ /** Optional fields force-included in the generated example payload. */
178
+ exampleInclude: string[];
179
+ check(op: any, ctx: OpCheckContext): OpCheckResult;
180
+ }
181
+ export declare const OP_SPECS: OpSpec[];
182
+ /** Find the spec for a (shape-valid) operation. */
183
+ export declare function specFor(op: BatchOperation): OpSpec;
184
+ /** Op names for error text ("expected one of […]") — derived, never hardcoded. */
185
+ export declare function validOpNames(): string[];
186
+ /** Create kinds for error text — derived from the table. */
187
+ export declare function validCreateKinds(): string[];
@@ -0,0 +1,322 @@
1
+ // src/batch-ops.ts
2
+ // The Op Spec (see CONTEXT.md): one table defining every batch operation —
3
+ // shape (Zod), semantic check, description, and example hints — from which
4
+ // validation, error text, and schema introspection all derive. Adding an op
5
+ // means adding one entry here.
6
+ import { z } from 'zod';
7
+ import { ENTITY_TYPES, ENTITY_STATUSES, SESSION_LOG_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, getContract, } from './contracts.js';
8
+ import { slugify } from './vault.js';
9
+ import { HafezError } from './types.js';
10
+ // --- Shapes ---
11
+ // All enums are built from contracts.ts tuples so help-renderer, file validators,
12
+ // and CLI-input validator share a single source of truth.
13
+ // Every z.object(...) is .strict() — unknown keys become `unrecognized_keys` issues
14
+ // (not silently dropped), which is load-bearing for the error-formatter's did-you-mean
15
+ // hints and for catching typos like `{"kind": "parent"}` on a link op.
16
+ export const SessionLogSchema = z.object({
17
+ type: z.enum([...SESSION_LOG_TYPES]),
18
+ summary: z.string(),
19
+ body: z.string().optional(),
20
+ agent: z.string(),
21
+ }).strict();
22
+ export const UpdateFieldsSchema = z.object({
23
+ status: z.enum([...ENTITY_STATUSES]).optional(),
24
+ current_state: z.string().optional(),
25
+ session_log: SessionLogSchema.optional(),
26
+ 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(),
31
+ clear_actions: z.boolean().optional(),
32
+ // Knowledge metadata
33
+ description: z.string().nullable().optional(),
34
+ resource: z.string().nullable().optional(),
35
+ domain: z.array(z.string()).optional(),
36
+ 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(),
43
+ }).strict();
44
+ export const CreateEntityFieldsSchema = z.object({
45
+ type: z.enum([...ENTITY_TYPES]),
46
+ purpose: z.string().optional(),
47
+ description: z.string().optional(),
48
+ resource: z.string().optional(),
49
+ domain: z.array(z.string()).optional(),
50
+ 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(),
56
+ }).strict();
57
+ export const CreateKnowledgeFieldsSchema = z.object({
58
+ subtype: z.enum([...VALID_SUBTYPES]).optional(),
59
+ synthesis: z.string().optional(),
60
+ insight: z.string().optional(),
61
+ 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(),
65
+ 'session-date': z.string().optional(),
66
+ }).strict();
67
+ export const CreateSessionFieldsSchema = z.object({
68
+ synthesis: z.string().optional(),
69
+ insight: z.string().optional(),
70
+ related: z.array(z.string()).optional(),
71
+ 'session-date': z.string().optional(),
72
+ }).strict();
73
+ const CreateEntityBranch = z.object({
74
+ op: z.literal('create'),
75
+ kind: z.literal('entity'),
76
+ name: z.string(),
77
+ fields: CreateEntityFieldsSchema,
78
+ }).strict();
79
+ const CreateKnowledgeBranch = z.object({
80
+ op: z.literal('create'),
81
+ kind: z.literal('knowledge'),
82
+ name: z.string(),
83
+ fields: CreateKnowledgeFieldsSchema.optional(),
84
+ }).strict();
85
+ const CreateSessionBranch = z.object({
86
+ op: z.literal('create'),
87
+ kind: z.literal('session'),
88
+ name: z.string(),
89
+ fields: CreateSessionFieldsSchema.optional(),
90
+ }).strict();
91
+ const UpdateBranch = z.object({
92
+ op: z.literal('update'),
93
+ slug: z.string(),
94
+ fields: UpdateFieldsSchema,
95
+ }).strict();
96
+ const CaptureBranch = z.object({
97
+ op: z.literal('capture'),
98
+ name: z.string(),
99
+ notes: z.string().optional(),
100
+ }).strict();
101
+ const LinkBranch = z.object({
102
+ op: z.literal('link'),
103
+ slug: z.string(),
104
+ target: z.string(),
105
+ relation: z.enum(['parent', 'related']),
106
+ }).strict();
107
+ const UnlinkBranch = z.object({
108
+ op: z.literal('unlink'),
109
+ slug: z.string(),
110
+ target: z.string(),
111
+ relation: z.enum(['parent', 'related']),
112
+ }).strict();
113
+ const PromoteBranch = z.object({
114
+ op: z.literal('promote'),
115
+ slug: z.string(),
116
+ target: z.enum(['entity', 'project', 'knowledge']),
117
+ }).strict();
118
+ // Nested discriminated union: outer on `op`, inner on `kind` for create branch.
119
+ // A flat `discriminatedUnion('op', [...])` cannot work because three branches share
120
+ // op=create — Zod requires unique discriminator values per branch and throws at
121
+ // schema construction time if violated.
122
+ const CreateBranch = z.discriminatedUnion('kind', [
123
+ CreateEntityBranch,
124
+ CreateKnowledgeBranch,
125
+ CreateSessionBranch,
126
+ ]);
127
+ export const BatchOperationSchema = z.discriminatedUnion('op', [
128
+ UpdateBranch,
129
+ // create is a nested discriminatedUnion on `kind` — see CreateBranch above.
130
+ // discriminatedUnion accepts a ZodDiscriminatedUnion as a branch via option-flattening.
131
+ CreateBranch,
132
+ CaptureBranch,
133
+ LinkBranch,
134
+ UnlinkBranch,
135
+ PromoteBranch,
136
+ ]);
137
+ function pushErr(errors, err) {
138
+ const e = err;
139
+ errors.push(e.details?.length ? `${e.message}: ${e.details.join('; ')}` : e.message);
140
+ }
141
+ export const OP_SPECS = [
142
+ {
143
+ name: 'update',
144
+ op: 'update',
145
+ schema: UpdateBranch,
146
+ description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …).',
147
+ exampleInclude: ['status'],
148
+ check(op, ctx) {
149
+ const errors = [];
150
+ const target = ctx.resolve(op.slug);
151
+ if (!target)
152
+ errors.push(`'${op.slug}' not found`);
153
+ else {
154
+ try {
155
+ ctx.validateKindFields(target.kind, op.fields);
156
+ }
157
+ catch (err) {
158
+ pushErr(errors, err);
159
+ }
160
+ }
161
+ if (op.fields.session_log) {
162
+ const logErrors = ctx.validateSessionLogEntry(op.fields.session_log);
163
+ if (logErrors.length > 0)
164
+ errors.push(`Invalid session log entry: ${logErrors.join('; ')}`);
165
+ }
166
+ if (op.fields.related) {
167
+ for (const r of op.fields.related) {
168
+ if (!ctx.existsAnywhere(r))
169
+ errors.push(`Related slug '${r}' does not exist`);
170
+ }
171
+ }
172
+ return { slug: op.slug, errors };
173
+ },
174
+ },
175
+ {
176
+ name: 'create-entity',
177
+ op: 'create',
178
+ kind: 'entity',
179
+ schema: CreateEntityBranch,
180
+ description: 'Create a new entity (capture / entity / project).',
181
+ exampleInclude: ['type'],
182
+ check(op, ctx) {
183
+ const errors = [];
184
+ 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 };
195
+ },
196
+ },
197
+ {
198
+ name: 'create-knowledge',
199
+ op: 'create',
200
+ kind: 'knowledge',
201
+ schema: CreateKnowledgeBranch,
202
+ description: 'Create a new knowledge note (insight / plan subtype).',
203
+ exampleInclude: ['subtype', 'synthesis'],
204
+ check(op, ctx) {
205
+ const errors = [];
206
+ 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 };
217
+ },
218
+ },
219
+ {
220
+ name: 'create-session',
221
+ op: 'create',
222
+ kind: 'session',
223
+ schema: CreateSessionBranch,
224
+ description: 'Create a new session note linked to an entity.',
225
+ exampleInclude: ['synthesis'],
226
+ check(op, ctx) {
227
+ const slug = slugify(op.name);
228
+ if (ctx.existsAnywhere(slug) || ctx.sessionExists(slug)) {
229
+ return { slug, errors: [], warning: `session '${slug}' already exists — op will be skipped (batch continues)` };
230
+ }
231
+ ctx.simulateSession(slug);
232
+ return { slug, errors: [], created: true };
233
+ },
234
+ },
235
+ {
236
+ name: 'capture',
237
+ op: 'capture',
238
+ schema: CaptureBranch,
239
+ description: 'Quick capture — a raw inbox item with no structure required.',
240
+ exampleInclude: ['notes'],
241
+ check(op, ctx) {
242
+ const errors = [];
243
+ 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 };
248
+ },
249
+ },
250
+ {
251
+ name: 'link',
252
+ op: 'link',
253
+ schema: LinkBranch,
254
+ description: 'Add a parent or related link between two entities.',
255
+ exampleInclude: [],
256
+ check(op, ctx) {
257
+ const errors = [];
258
+ if (!ctx.resolve(op.slug))
259
+ errors.push(`'${op.slug}' not found`);
260
+ if (!ctx.existsAnywhere(op.target))
261
+ errors.push(`Target '${op.target}' does not exist`);
262
+ return { slug: op.slug, errors };
263
+ },
264
+ },
265
+ {
266
+ name: 'unlink',
267
+ op: 'unlink',
268
+ schema: UnlinkBranch,
269
+ description: 'Remove a parent or related link between two entities.',
270
+ exampleInclude: [],
271
+ check(op, ctx) {
272
+ const errors = [];
273
+ if (!ctx.resolve(op.slug))
274
+ errors.push(`'${op.slug}' not found`);
275
+ return { slug: op.slug, errors };
276
+ },
277
+ },
278
+ {
279
+ name: 'promote',
280
+ op: 'promote',
281
+ schema: PromoteBranch,
282
+ description: 'Promote a capture to entity/project/knowledge, or an entity to project.',
283
+ exampleInclude: [],
284
+ check(op, ctx) {
285
+ const errors = [];
286
+ const cur = ctx.resolve(op.slug);
287
+ if (!cur)
288
+ errors.push(`'${op.slug}' not found`);
289
+ else {
290
+ const currentType = cur.kind === 'knowledge' ? 'knowledge' : cur.type;
291
+ try {
292
+ const contract = getContract(currentType);
293
+ if (!contract.canPromoteTo.includes(op.target)) {
294
+ errors.push(`Cannot promote ${currentType} to ${op.target}. Valid targets: ${contract.canPromoteTo.join(', ') || 'none (terminal type)'}`);
295
+ }
296
+ else {
297
+ ctx.simulate(op.slug, op.target === 'knowledge' ? { kind: 'knowledge', type: 'knowledge' } : { kind: 'entity', type: op.target });
298
+ }
299
+ }
300
+ catch (err) {
301
+ pushErr(errors, err);
302
+ }
303
+ }
304
+ return { slug: op.slug, errors };
305
+ },
306
+ },
307
+ ];
308
+ /** Find the spec for a (shape-valid) operation. */
309
+ export function specFor(op) {
310
+ const spec = OP_SPECS.find(s => s.op === op.op && (s.kind === undefined || s.kind === op.kind));
311
+ if (!spec)
312
+ throw new HafezError('VALIDATION_FAILED', `Unknown batch op: ${op.op}`);
313
+ return spec;
314
+ }
315
+ /** Op names for error text ("expected one of […]") — derived, never hardcoded. */
316
+ export function validOpNames() {
317
+ return [...new Set(OP_SPECS.map(s => s.op))];
318
+ }
319
+ /** Create kinds for error text — derived from the table. */
320
+ export function validCreateKinds() {
321
+ return OP_SPECS.filter(s => s.op === 'create' && s.kind).map(s => s.kind);
322
+ }
@@ -0,0 +1,15 @@
1
+ /** Bad invocation (unknown flag, missing value, missing positional).
2
+ * Distinct from crashes: the error adapter maps it to exit 2. */
3
+ export declare class UsageError extends Error {
4
+ }
5
+ export type FlagType = 'string' | 'boolean';
6
+ /** Flag names WITHOUT the leading dashes, e.g. { 'add-action': 'string' }. */
7
+ export type FlagSpec = Record<string, FlagType>;
8
+ export interface ParsedArgs {
9
+ flags: Record<string, string | boolean | undefined>;
10
+ positionals: string[];
11
+ }
12
+ export declare function parseArgs(args: string[], spec: FlagSpec, usage: string): ParsedArgs;
13
+ /** Comma-separated list flag → trimmed array (undefined stays undefined). */
14
+ export declare function splitList(value: string | boolean | undefined): string[] | undefined;
15
+ export declare function str(value: string | boolean | undefined): string | undefined;
@@ -0,0 +1,49 @@
1
+ // src/cli/args.ts
2
+ // The one flag parser behind every CLI command. Each command declares its
3
+ // flags; parseArgs enforces the declaration — unknown flags and missing
4
+ // values are UsageErrors instead of silently ignored tokens (the getFlag era
5
+ // needed a documented workaround because values stayed in the positional
6
+ // stream).
7
+ /** Bad invocation (unknown flag, missing value, missing positional).
8
+ * Distinct from crashes: the error adapter maps it to exit 2. */
9
+ export class UsageError extends Error {
10
+ }
11
+ export function parseArgs(args, spec, usage) {
12
+ const flags = {};
13
+ const positionals = [];
14
+ for (let i = 0; i < args.length; i++) {
15
+ const token = args[i];
16
+ if (token.startsWith('--')) {
17
+ const name = token.slice(2);
18
+ const type = spec[name];
19
+ if (!type)
20
+ throw new UsageError(`Unknown flag --${name}\n${usage}`);
21
+ if (type === 'boolean') {
22
+ flags[name] = true;
23
+ continue;
24
+ }
25
+ const value = args[i + 1];
26
+ // A declared flag in value position means the value was omitted
27
+ // (`--brief --clear-actions`) — consuming it would both corrupt the
28
+ // string and silently drop the swallowed flag.
29
+ if (value === undefined || (value.startsWith('--') && value.slice(2) in spec)) {
30
+ throw new UsageError(`--${name} requires a value\n${usage}`);
31
+ }
32
+ flags[name] = value;
33
+ i++;
34
+ }
35
+ else {
36
+ positionals.push(token);
37
+ }
38
+ }
39
+ return { flags, positionals };
40
+ }
41
+ /** Comma-separated list flag → trimmed array (undefined stays undefined). */
42
+ export function splitList(value) {
43
+ if (typeof value !== 'string')
44
+ return undefined;
45
+ return value.split(',').map(s => s.trim());
46
+ }
47
+ export function str(value) {
48
+ return typeof value === 'string' ? value : undefined;
49
+ }
@@ -9,6 +9,7 @@
9
9
  // without those, the `unrecognized_keys` and `invalid_union_discriminator`
10
10
  // paths are unreachable.
11
11
  import { getOpSchema, listOps } from './schema-introspect.js';
12
+ import { validOpNames, validCreateKinds } from '../batch-ops.js';
12
13
  // --- did-you-mean (cheap Levenshtein) ---
13
14
  export function levenshtein(a, b) {
14
15
  if (a === b)
@@ -91,7 +92,8 @@ export function opNameOf(raw) {
91
92
  return null;
92
93
  if (op === 'create') {
93
94
  const kind = raw.kind;
94
- if (typeof kind === 'string')
95
+ const names = new Set(listOps().map(o => o.name));
96
+ if (typeof kind === 'string' && names.has(`create-${kind}`))
95
97
  return `create-${kind}`;
96
98
  return 'create';
97
99
  }
@@ -126,7 +128,8 @@ function toStr(v) {
126
128
  return String(v);
127
129
  }
128
130
  }
129
- const VALID_OPS = ['update', 'create', 'capture', 'link', 'unlink', 'promote'];
131
+ // Derived from the Op Spec table — never hardcoded here.
132
+ const VALID_OPS = validOpNames();
130
133
  /**
131
134
  * Format a single Zod issue into a readable block. Caller supplies the op
132
135
  * index and the raw op JSON so the block can reference the right `hafez
@@ -160,7 +163,7 @@ export function formatIssue(issue, opIndex, raw) {
160
163
  return [
161
164
  header,
162
165
  ` field: kind`,
163
- ` issue: unknown kind ${got} (expected one of [entity, knowledge, session])`,
166
+ ` issue: unknown kind ${got} (expected one of [${validCreateKinds().join(', ')}])`,
164
167
  ` ${tip}`,
165
168
  ].join('\n');
166
169
  }