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 +15 -8
- package/dist/batch-ops.d.ts +187 -0
- package/dist/batch-ops.js +322 -0
- package/dist/cli/args.d.ts +15 -0
- package/dist/cli/args.js +49 -0
- package/dist/cli/batch-errors.js +6 -3
- package/dist/cli/commands.d.ts +1 -109
- package/dist/cli/commands.js +197 -256
- package/dist/cli/help-agent.d.ts +0 -5
- package/dist/cli/help-agent.js +37 -9
- package/dist/cli/index.js +61 -69
- package/dist/cli/onboard.d.ts +1 -1
- package/dist/cli/onboard.js +7 -1
- package/dist/cli/resolve-vault.js +2 -1
- package/dist/cli/schema-introspect.d.ts +1 -1
- package/dist/cli/schema-introspect.js +6 -22
- package/dist/cli.js +20 -2
- package/dist/contracts.d.ts +6 -0
- package/dist/contracts.js +8 -2
- package/dist/db.js +20 -30
- package/dist/digest.js +2 -0
- package/dist/document.d.ts +108 -0
- package/dist/{sections.js → document.js} +173 -22
- package/dist/export-okf.js +10 -22
- package/dist/git.d.ts +13 -7
- package/dist/git.js +79 -21
- package/dist/index.d.ts +3 -4
- package/dist/index.js +508 -406
- package/dist/knowledge-index.js +9 -7
- package/dist/merge.js +13 -25
- package/dist/query.d.ts +1 -1
- package/dist/query.js +3 -1
- package/dist/types.d.ts +62 -1
- package/dist/vault.d.ts +34 -6
- package/dist/vault.js +71 -7
- package/package.json +4 -4
- package/skill/SKILL.md +7 -1
- package/dist/parser.d.ts +0 -18
- package/dist/parser.js +0 -68
- package/dist/sections.d.ts +0 -54
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Hafez
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/cli/args.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cli/batch-errors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 [
|
|
166
|
+
` issue: unknown kind ${got} (expected one of [${validCreateKinds().join(', ')}])`,
|
|
164
167
|
` ${tip}`,
|
|
165
168
|
].join('\n');
|
|
166
169
|
}
|