hafez 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -32
- package/dist/apply.d.ts +33 -0
- package/dist/apply.js +693 -0
- package/dist/batch-ops.d.ts +372 -26
- package/dist/batch-ops.js +355 -73
- package/dist/cli/args.d.ts +48 -6
- package/dist/cli/args.js +74 -9
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +28 -3
- package/dist/cli/commands.js +440 -157
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +55 -0
- package/dist/cli/index.js +215 -153
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +51 -0
- package/dist/cli/upgrade.js +351 -0
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +65 -6
- package/dist/contracts.js +148 -23
- package/dist/dates.d.ts +10 -0
- package/dist/dates.js +27 -0
- package/dist/db.d.ts +40 -2
- package/dist/db.js +363 -66
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +135 -88
- package/dist/document.js +665 -379
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +50 -20
- package/dist/git.js +165 -48
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +301 -826
- package/dist/knowledge-index.d.ts +23 -1
- package/dist/knowledge-index.js +62 -6
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +98 -64
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/schema.js +4 -4
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/templates.d.ts +6 -0
- package/dist/templates.js +27 -27
- package/dist/types.d.ts +78 -22
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +51 -5
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -186
- package/dist/query.d.ts +0 -24
- package/dist/query.js +0 -169
- package/skill/SKILL.md +0 -196
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// src/surfaces/surfaces.ts
|
|
2
|
+
//
|
|
3
|
+
// Surfaces of the Agent Contract and how the static ones compose Items. A
|
|
4
|
+
// static Surface is text the binary writes out (the Block, the two skill
|
|
5
|
+
// files); a binary Surface is command output whose Items carry a render
|
|
6
|
+
// function. Every Surface has the label the owner table names it by.
|
|
7
|
+
//
|
|
8
|
+
// Hashes (onboard decision #58): first 12 hex of SHA-256. The Block's begin
|
|
9
|
+
// marker hashes the text between the marker lines; a skill file's `hash:`
|
|
10
|
+
// frontmatter line hashes everything after the closing `---`.
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { ITEMS } from './items.js';
|
|
13
|
+
export const SURFACES = {
|
|
14
|
+
block: {
|
|
15
|
+
label: 'Block',
|
|
16
|
+
compose: place => [
|
|
17
|
+
'## Hafez',
|
|
18
|
+
'',
|
|
19
|
+
'Hafez is this user\'s vault: what they are working on (entities) and what they have learned (knowledge). You reach it only through the `hafez` command.',
|
|
20
|
+
'',
|
|
21
|
+
`**Load.** ${place('loadTriggers')}`,
|
|
22
|
+
'',
|
|
23
|
+
'**Write as it happens.** Each of these is due the moment it occurs:',
|
|
24
|
+
'',
|
|
25
|
+
place('writeTriggers'),
|
|
26
|
+
'',
|
|
27
|
+
place('createLines'),
|
|
28
|
+
'',
|
|
29
|
+
'**Rules.**',
|
|
30
|
+
'',
|
|
31
|
+
`- ${place('neverAsk')}`,
|
|
32
|
+
`- ${place('readBeforeWrite')}`,
|
|
33
|
+
`- ${place('oneCommand')}`,
|
|
34
|
+
`- ${place('openActions')}`,
|
|
35
|
+
`- ${place('neverEditByHand')}`,
|
|
36
|
+
`- ${place('neverSaveUnprompted')}`,
|
|
37
|
+
'',
|
|
38
|
+
].join('\n'),
|
|
39
|
+
},
|
|
40
|
+
hafezSkillDescription: {
|
|
41
|
+
label: '`hafez` skill description',
|
|
42
|
+
compose: () => 'Hafez vault: the less common operations and the tool itself. Use when the user asks what is in the vault ("what am I working on?", "what\'s stale?"), wants to promote, link, unlink, delete, change a status or validate, or wants to update or reconfigure Hafez ("upgrade hafez", "set up hafez"). Also the reference to read when no Hafez block is in your instructions.',
|
|
43
|
+
},
|
|
44
|
+
hafezSkillBody: {
|
|
45
|
+
label: '`hafez` skill body',
|
|
46
|
+
compose: place => [
|
|
47
|
+
'',
|
|
48
|
+
'# Hafez',
|
|
49
|
+
'',
|
|
50
|
+
'- Update an entity or note: `hafez read <slug>`. The end of its output shows the write command.',
|
|
51
|
+
'- Create an entity or a knowledge note: the two `hafez create` lines below.',
|
|
52
|
+
'- Save the session: the `hafez-save` skill. No skill: `hafez schema digest`.',
|
|
53
|
+
'',
|
|
54
|
+
place('neverAsk'),
|
|
55
|
+
place('readBeforeWrite'),
|
|
56
|
+
place('openActions'),
|
|
57
|
+
'',
|
|
58
|
+
'## Create',
|
|
59
|
+
'',
|
|
60
|
+
place('createLines'),
|
|
61
|
+
'',
|
|
62
|
+
'## Find',
|
|
63
|
+
'',
|
|
64
|
+
place('find'),
|
|
65
|
+
'',
|
|
66
|
+
'## Less common operations',
|
|
67
|
+
'',
|
|
68
|
+
place('rareOps'),
|
|
69
|
+
'',
|
|
70
|
+
`${place('batchShape')} Machine-readable shape of any op: \`hafez schema <op>\`, with \`--examples\` for a sample.`,
|
|
71
|
+
'',
|
|
72
|
+
'## The tool itself',
|
|
73
|
+
'',
|
|
74
|
+
place('upgradeReconfigure'),
|
|
75
|
+
'',
|
|
76
|
+
].join('\n'),
|
|
77
|
+
},
|
|
78
|
+
hafezSaveDescription: {
|
|
79
|
+
label: '`hafez-save` description',
|
|
80
|
+
compose: place => place('saveTrigger'),
|
|
81
|
+
},
|
|
82
|
+
hafezSaveBody: {
|
|
83
|
+
label: '`hafez-save` body',
|
|
84
|
+
compose: place => ['', '# Save the session', '', place('saveMechanics'), ''].join('\n'),
|
|
85
|
+
},
|
|
86
|
+
read: { label: 'Binary: `read`' },
|
|
87
|
+
readHeader: { label: 'Binary: `read` header' },
|
|
88
|
+
readFooter: { label: 'Binary: `read` Footer' },
|
|
89
|
+
readMiss: { label: 'Binary: `read` miss' },
|
|
90
|
+
updateSuccess: { label: 'Binary: `update` success output' },
|
|
91
|
+
updateMisuse: { label: 'Binary: `update` misuse error' },
|
|
92
|
+
createKnowledge: { label: 'Binary: `create knowledge` output' },
|
|
93
|
+
digestError: { label: 'Binary: `digest` error' },
|
|
94
|
+
batchMisuse: { label: 'Binary: `batch` misuse error' },
|
|
95
|
+
mechanised: { label: 'Binary, mechanised' },
|
|
96
|
+
schema: { label: '`schema`' },
|
|
97
|
+
onboard: { label: '`onboard`' },
|
|
98
|
+
};
|
|
99
|
+
export const contentHash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
100
|
+
/** Compose a static Surface, placing each Item's text in the form its role
|
|
101
|
+
* (Owner or fallback) calls for. `onPlace` sees every placement, nested ones
|
|
102
|
+
* included. */
|
|
103
|
+
function composeWith(items, surfaceId, surface, onPlace = () => { }) {
|
|
104
|
+
const place = (id) => {
|
|
105
|
+
onPlace(id);
|
|
106
|
+
const item = items[id];
|
|
107
|
+
if (!item)
|
|
108
|
+
throw new Error(`${surfaceId} places unknown Item ${id}`);
|
|
109
|
+
const text = item.text;
|
|
110
|
+
if (typeof text === 'function')
|
|
111
|
+
return text(place, item.owner === surfaceId ? 'owner' : 'fallback');
|
|
112
|
+
if (text === undefined)
|
|
113
|
+
throw new Error(`${surfaceId} places ${id}, which has no text`);
|
|
114
|
+
return text;
|
|
115
|
+
};
|
|
116
|
+
if (!surface.compose)
|
|
117
|
+
throw new Error(`${surfaceId} is not a static surface`);
|
|
118
|
+
return surface.compose(place);
|
|
119
|
+
}
|
|
120
|
+
const compose = (id) => composeWith(ITEMS, id, SURFACES[id]);
|
|
121
|
+
/** An Item's text as a binary Surface places it at run time, in the form its
|
|
122
|
+
* role there (Owner or fallback) calls for. */
|
|
123
|
+
export function placeIn(surfaceId, id) {
|
|
124
|
+
const item = ITEMS[id];
|
|
125
|
+
if (surfaceId !== item.owner && surfaceId !== item.fallback) {
|
|
126
|
+
throw new Error(`${surfaceId} places ${id}, which is neither its owner nor its fallback`);
|
|
127
|
+
}
|
|
128
|
+
return composeWith(ITEMS, surfaceId, { label: SURFACES[surfaceId].label, compose: place => place(id) });
|
|
129
|
+
}
|
|
130
|
+
/** YAML folded scalar lines: greedy wrap at 76 columns, two-space indent. */
|
|
131
|
+
function fold(text) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
let line = '';
|
|
134
|
+
for (const word of text.split(' ')) {
|
|
135
|
+
if (line && 2 + line.length + 1 + word.length > 76) {
|
|
136
|
+
lines.push(line);
|
|
137
|
+
line = word;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
line = line ? `${line} ${word}` : word;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
lines.push(line);
|
|
144
|
+
return lines.map(l => ` ${l}`).join('\n');
|
|
145
|
+
}
|
|
146
|
+
function skillFile(name, description, body) {
|
|
147
|
+
const text = compose(body);
|
|
148
|
+
return `---\nname: ${name}\ndescription: >\n${fold(compose(description))}\nhash: ${contentHash(text)}\n---\n${text}`;
|
|
149
|
+
}
|
|
150
|
+
export function renderBlock() {
|
|
151
|
+
const inner = compose('block');
|
|
152
|
+
return `<!-- hafez:begin ${contentHash(inner)} -->\n${inner}<!-- hafez:end -->\n`;
|
|
153
|
+
}
|
|
154
|
+
/** `onboard --block`, what the SessionStart hook prints: the Block, then the
|
|
155
|
+
* read line for each entity matching the folder. */
|
|
156
|
+
export function renderBlockHook(matches) {
|
|
157
|
+
return matches.length === 0 ? renderBlock() : `${renderBlock()}\n${ITEMS.folderMatch.render(matches)}\n`;
|
|
158
|
+
}
|
|
159
|
+
export const renderHafezSkill = () => skillFile('hafez', 'hafezSkillDescription', 'hafezSkillBody');
|
|
160
|
+
/** `help --agent`: the `hafez` skill body, the one text, without the blank
|
|
161
|
+
* line that parts it from the skill file's frontmatter or the newline that
|
|
162
|
+
* ends the file (the CLI adds one to every output). */
|
|
163
|
+
export const renderAgentHelp = () => compose('hafezSkillBody').replace(/^\n/, '').replace(/\n$/, '');
|
|
164
|
+
export const renderHafezSaveSkill = () => skillFile('hafez-save', 'hafezSaveDescription', 'hafezSaveBody');
|
|
165
|
+
// --- The owner table, rendered into a marked region of the contract README ---
|
|
166
|
+
export const OWNER_TABLE_BEGIN = '<!-- owner-table:begin (rendered from src/surfaces/items.ts; edit the Item, then run scripts/render-surfaces.ts) -->';
|
|
167
|
+
export const OWNER_TABLE_END = '<!-- owner-table:end -->';
|
|
168
|
+
export function renderOwnerTable() {
|
|
169
|
+
const rows = Object.values(ITEMS).map((item) => {
|
|
170
|
+
const fallback = item.fallback ? SURFACES[item.fallback].label : '—';
|
|
171
|
+
return `| ${item.label} | ${SURFACES[item.owner].label} | ${fallback} | ${item.invokeOnly} |`;
|
|
172
|
+
});
|
|
173
|
+
return ['| Item | Owner | Fallback | Invoke-only |', '|---|---|---|---|', ...rows, ''].join('\n');
|
|
174
|
+
}
|
|
175
|
+
/** Replace the marked owner-table region of `doc` with a fresh render. */
|
|
176
|
+
export function spliceOwnerTable(doc) {
|
|
177
|
+
const begin = doc.indexOf(OWNER_TABLE_BEGIN);
|
|
178
|
+
const end = doc.indexOf(OWNER_TABLE_END, begin);
|
|
179
|
+
if (begin < 0 || end < 0)
|
|
180
|
+
throw new Error('owner table markers missing or out of order');
|
|
181
|
+
if (doc.indexOf(OWNER_TABLE_BEGIN, begin + 1) >= 0 || doc.indexOf(OWNER_TABLE_END, end + 1) >= 0) {
|
|
182
|
+
throw new Error('owner table markers appear more than once');
|
|
183
|
+
}
|
|
184
|
+
return doc.slice(0, begin + OWNER_TABLE_BEGIN.length) + '\n' + renderOwnerTable() + doc.slice(end);
|
|
185
|
+
}
|
|
186
|
+
// --- Drift check 2: the typed Item table ---
|
|
187
|
+
/** Every problem with the Item table: an Item with no Owner or no evidence
|
|
188
|
+
* pointer, an Item its Owner or static fallback never places, or one placed more than once in a
|
|
189
|
+
* Surface or in a Surface that is neither its Owner nor its declared fallback.
|
|
190
|
+
* Placement is checked for static Surfaces only; binary ones place at run time. */
|
|
191
|
+
export function itemProblems(items, surfaces) {
|
|
192
|
+
const problems = [];
|
|
193
|
+
const placed = {};
|
|
194
|
+
for (const [sid, surface] of Object.entries(surfaces)) {
|
|
195
|
+
if (!surface.compose)
|
|
196
|
+
continue;
|
|
197
|
+
const counts = (placed[sid] = {});
|
|
198
|
+
try {
|
|
199
|
+
composeWith(items, sid, surface, id => { counts[id] = (counts[id] ?? 0) + 1; });
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
problems.push(err.message);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const [id, item] of Object.entries(items)) {
|
|
206
|
+
if (!item.owner)
|
|
207
|
+
problems.push(`${id}: no owner`);
|
|
208
|
+
else if (!surfaces[item.owner])
|
|
209
|
+
problems.push(`${id}: unknown owner ${item.owner}`);
|
|
210
|
+
if (item.fallback && !surfaces[item.fallback])
|
|
211
|
+
problems.push(`${id}: unknown fallback ${item.fallback}`);
|
|
212
|
+
if (!item.evidence?.trim())
|
|
213
|
+
problems.push(`${id}: no evidence pointer`);
|
|
214
|
+
for (const [sid, counts] of Object.entries(placed)) {
|
|
215
|
+
const n = counts[id] ?? 0;
|
|
216
|
+
if (n > 0 && sid !== item.owner && sid !== item.fallback) {
|
|
217
|
+
problems.push(`${id}: placed in ${sid}, which is neither its owner nor its fallback`);
|
|
218
|
+
}
|
|
219
|
+
if (n > 1)
|
|
220
|
+
problems.push(`${id}: placed ${n} times in ${sid}`);
|
|
221
|
+
}
|
|
222
|
+
if (item.owner && surfaces[item.owner]?.compose && !placed[item.owner]?.[id]) {
|
|
223
|
+
problems.push(`${id}: not placed in its owner ${item.owner}`);
|
|
224
|
+
}
|
|
225
|
+
if (item.fallback && surfaces[item.fallback]?.compose && !placed[item.fallback]?.[id]) {
|
|
226
|
+
problems.push(`${id}: not placed in its fallback ${item.fallback}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return problems;
|
|
230
|
+
}
|
package/dist/templates.d.ts
CHANGED
|
@@ -6,3 +6,9 @@ export declare function bodyTemplate(type: EntityType, fields?: {
|
|
|
6
6
|
export declare function knowledgeBodyTemplate(subtype?: KnowledgeSubtype, fields?: {
|
|
7
7
|
synthesis?: string;
|
|
8
8
|
}): string;
|
|
9
|
+
/** Session bodies. Sessions have no TypeContract, but their headings are
|
|
10
|
+
* structural for Document splitting — so the list lives in contracts with
|
|
11
|
+
* every other section set, and this renders it. */
|
|
12
|
+
export declare function sessionBodyTemplate(fields?: {
|
|
13
|
+
synthesis?: string;
|
|
14
|
+
}): string;
|
package/dist/templates.js
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import { SUBTYPE_SECTIONS } from './contracts.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export function bodyTemplate(type, fields) {
|
|
8
|
-
const sections = ITEM_TEMPLATES[type];
|
|
9
|
-
if (!sections)
|
|
10
|
-
throw new Error(`Unknown item type: ${type}`);
|
|
1
|
+
import { SUBTYPE_SECTIONS, DEFAULT_KNOWLEDGE_SUBTYPE, SESSION_SECTIONS, ENTITY_TYPES, getContract } from './contracts.js';
|
|
2
|
+
/** Render a body from a section list, injecting each field's text under the
|
|
3
|
+
* section that field writes into (contracts.CREATE_FIELD_SECTIONS names the
|
|
4
|
+
* pairs; a field whose section this template lacks is rejected upstream by
|
|
5
|
+
* `unreachableFields`, so an unplaced value never reaches here). */
|
|
6
|
+
function render(sections, content) {
|
|
11
7
|
const lines = [];
|
|
12
8
|
for (const section of sections) {
|
|
13
|
-
lines.push(section);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (section === '## Notes' && fields?.notes) {
|
|
18
|
-
lines.push('', fields.notes);
|
|
19
|
-
}
|
|
9
|
+
lines.push(`## ${section}`);
|
|
10
|
+
const text = content[section];
|
|
11
|
+
if (text)
|
|
12
|
+
lines.push('', text);
|
|
20
13
|
lines.push('');
|
|
21
14
|
}
|
|
22
15
|
return lines.join('\n');
|
|
23
16
|
}
|
|
17
|
+
export function bodyTemplate(type, fields) {
|
|
18
|
+
// The template IS the contract's default sections — the Document module
|
|
19
|
+
// derives its structural set from the same place, so the two can't drift.
|
|
20
|
+
if (!ENTITY_TYPES.includes(type))
|
|
21
|
+
throw new Error(`Unknown item type: ${type}`);
|
|
22
|
+
return render(getContract(type).defaultSections, { Purpose: fields?.purpose, Notes: fields?.notes });
|
|
23
|
+
}
|
|
24
24
|
export function knowledgeBodyTemplate(subtype, fields) {
|
|
25
25
|
if (subtype === 'session') {
|
|
26
26
|
throw new Error("subtype 'session' is no longer supported for knowledge notes. " +
|
|
27
27
|
"Use batch kind: 'session' instead. See knowledge-v2 migration.");
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return
|
|
29
|
+
// 'Summary' was a second injection target here until sessions stopped being
|
|
30
|
+
// a knowledge subtype; no SUBTYPE_SECTIONS entry has carried it since — the
|
|
31
|
+
// session body is its own template below.
|
|
32
|
+
return render(SUBTYPE_SECTIONS[subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE], { Synthesis: fields?.synthesis });
|
|
33
|
+
}
|
|
34
|
+
/** Session bodies. Sessions have no TypeContract, but their headings are
|
|
35
|
+
* structural for Document splitting — so the list lives in contracts with
|
|
36
|
+
* every other section set, and this renders it. */
|
|
37
|
+
export function sessionBodyTemplate(fields) {
|
|
38
|
+
return render([...SESSION_SECTIONS], { Summary: fields?.synthesis });
|
|
39
39
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
export type
|
|
3
|
-
export type
|
|
4
|
-
export type
|
|
1
|
+
import type { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES } from './contracts.js';
|
|
2
|
+
export type EntityType = typeof ENTITY_TYPES[number];
|
|
3
|
+
export type EntityStatus = typeof ENTITY_STATUSES[number];
|
|
4
|
+
export type ConfidenceLevel = typeof CONFIDENCE_LEVELS[number];
|
|
5
|
+
export type SessionLogType = typeof SESSION_LOG_TYPES[number];
|
|
5
6
|
export type ReadDepth = 'frontmatter' | 'summary' | 'full';
|
|
6
|
-
export type QueryFilter =
|
|
7
|
+
export type QueryFilter = EntityStatus | 'all' | 'stale' | 'capture';
|
|
7
8
|
export type LinkRelation = 'parent' | 'related';
|
|
8
|
-
export type KnowledgeSubtype =
|
|
9
|
+
export type KnowledgeSubtype = typeof VALID_SUBTYPES[number];
|
|
10
|
+
/** Plans are the one knowledge subtype with a lifecycle (see PLAN_STATUSES). */
|
|
11
|
+
export type PlanStatus = typeof PLAN_STATUSES[number];
|
|
12
|
+
/** Sort fields, and the two an entity query cannot honour — reinforcement
|
|
13
|
+
* lives on knowledge notes only. Asking for one on the entity branch is a
|
|
14
|
+
* validation error, not a silent fall back to recency, so the entity opts
|
|
15
|
+
* type declares only what that branch can actually sort by. */
|
|
16
|
+
export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
|
|
17
|
+
export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count'>;
|
|
9
18
|
export interface EntityQueryOpts {
|
|
10
19
|
filter?: QueryFilter;
|
|
11
20
|
type?: EntityType;
|
|
12
21
|
parent?: string;
|
|
13
22
|
domain?: string;
|
|
14
|
-
confidence?: ConfidenceLevel;
|
|
15
23
|
since?: string;
|
|
16
24
|
before?: string;
|
|
17
25
|
createdSince?: string;
|
|
18
26
|
createdBefore?: string;
|
|
19
27
|
tags?: string[];
|
|
20
|
-
sort_by?:
|
|
28
|
+
sort_by?: EntitySortField;
|
|
21
29
|
sort_order?: 'asc' | 'desc';
|
|
22
30
|
limit?: number;
|
|
23
31
|
offset?: number;
|
|
@@ -26,12 +34,36 @@ export interface KnowledgeQueryOpts {
|
|
|
26
34
|
domain?: string;
|
|
27
35
|
confidence?: ConfidenceLevel;
|
|
28
36
|
subtype?: KnowledgeSubtype;
|
|
37
|
+
since?: string;
|
|
38
|
+
before?: string;
|
|
39
|
+
createdSince?: string;
|
|
40
|
+
createdBefore?: string;
|
|
29
41
|
tags?: string[];
|
|
30
|
-
sort_by?:
|
|
42
|
+
sort_by?: SortField;
|
|
31
43
|
sort_order?: 'asc' | 'desc';
|
|
32
44
|
limit?: number;
|
|
33
45
|
offset?: number;
|
|
34
46
|
}
|
|
47
|
+
/** `kind: 'all'` (or unset): reaches both sides, so it takes opts from both
|
|
48
|
+
* and can honour every sort field. Status filters narrow the entity side only
|
|
49
|
+
* — knowledge notes carry no status and are never excluded by one. */
|
|
50
|
+
export interface AllQueryOpts extends Omit<EntityQueryOpts, 'sort_by'> {
|
|
51
|
+
kind?: 'all';
|
|
52
|
+
confidence?: ConfidenceLevel;
|
|
53
|
+
subtype?: KnowledgeSubtype;
|
|
54
|
+
sort_by?: SortField;
|
|
55
|
+
}
|
|
56
|
+
/** The unified query, discriminated on `kind`. Naming a single kind narrows
|
|
57
|
+
* this query to that kind's opts, because a narrowed branch maps no more than
|
|
58
|
+
* the dedicated one does: an out-of-scope condition would land on a column the
|
|
59
|
+
* selected kind leaves NULL and silently exclude every row. The read surface
|
|
60
|
+
* rejects those at runtime for callers without types; the union is what makes
|
|
61
|
+
* them a compile error first. */
|
|
62
|
+
export type UnifiedQueryOpts = AllQueryOpts | (EntityQueryOpts & {
|
|
63
|
+
kind: 'entity';
|
|
64
|
+
}) | (KnowledgeQueryOpts & {
|
|
65
|
+
kind: 'knowledge';
|
|
66
|
+
});
|
|
35
67
|
export interface EntityFrontmatter {
|
|
36
68
|
name: string;
|
|
37
69
|
type: EntityType;
|
|
@@ -58,7 +90,7 @@ export interface KnowledgeFrontmatter {
|
|
|
58
90
|
'reinforcement-count'?: number;
|
|
59
91
|
tags?: string[];
|
|
60
92
|
'session-date'?: string;
|
|
61
|
-
status?:
|
|
93
|
+
status?: PlanStatus;
|
|
62
94
|
'last-touched'?: string;
|
|
63
95
|
}
|
|
64
96
|
export interface SessionFrontmatter {
|
|
@@ -82,12 +114,11 @@ export type ParsedVaultFile = {
|
|
|
82
114
|
frontmatter: SessionFrontmatter;
|
|
83
115
|
body: string;
|
|
84
116
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
117
|
+
/** What read() returns: the indexed arms of ParsedVaultFile (a session slug is
|
|
118
|
+
* never resolvable through read()), so consumers narrow on `kind`. */
|
|
119
|
+
export type ParsedFile = Extract<ParsedVaultFile, {
|
|
120
|
+
kind: 'entity' | 'knowledge';
|
|
121
|
+
}>;
|
|
91
122
|
export interface SessionLogEntry {
|
|
92
123
|
type: SessionLogType;
|
|
93
124
|
summary: string;
|
|
@@ -105,12 +136,14 @@ export interface UpdateFields {
|
|
|
105
136
|
status?: EntityStatus;
|
|
106
137
|
current_state?: string;
|
|
107
138
|
session_log?: SessionLogEntry;
|
|
139
|
+
agent?: string;
|
|
108
140
|
brief?: string | null;
|
|
109
141
|
add_action?: string;
|
|
110
142
|
add_actions?: string[];
|
|
111
143
|
complete_action?: string;
|
|
112
144
|
remove_action?: string;
|
|
113
145
|
clear_actions?: boolean;
|
|
146
|
+
parent?: string;
|
|
114
147
|
description?: string | null;
|
|
115
148
|
resource?: string | null;
|
|
116
149
|
domain?: string[];
|
|
@@ -118,11 +151,14 @@ export interface UpdateFields {
|
|
|
118
151
|
tags?: string[];
|
|
119
152
|
related?: string[];
|
|
120
153
|
synthesis?: string;
|
|
154
|
+
insight?: string;
|
|
121
155
|
add_evidence?: string;
|
|
122
156
|
add_source?: string;
|
|
123
157
|
}
|
|
124
158
|
export interface UpdateResult {
|
|
125
|
-
|
|
159
|
+
completed_action?: string;
|
|
160
|
+
removed_action?: string;
|
|
161
|
+
logged?: true;
|
|
126
162
|
}
|
|
127
163
|
export interface CreateEntityFields {
|
|
128
164
|
type: EntityType;
|
|
@@ -139,7 +175,10 @@ export interface CreateEntityFields {
|
|
|
139
175
|
}
|
|
140
176
|
export interface CreateKnowledgeFields {
|
|
141
177
|
subtype?: KnowledgeSubtype;
|
|
178
|
+
status?: PlanStatus;
|
|
179
|
+
confidence?: ConfidenceLevel;
|
|
142
180
|
synthesis?: string;
|
|
181
|
+
insight?: string;
|
|
143
182
|
description?: string;
|
|
144
183
|
domain?: string[];
|
|
145
184
|
related?: string[];
|
|
@@ -191,7 +230,6 @@ export interface ValidationIssue {
|
|
|
191
230
|
export interface ValidationReport {
|
|
192
231
|
broken_slugs: ValidationIssue[];
|
|
193
232
|
orphaned_knowledge: string[];
|
|
194
|
-
oversized_related: ValidationIssue[];
|
|
195
233
|
missing_fields: ValidationIssue[];
|
|
196
234
|
total_entities: number;
|
|
197
235
|
total_knowledge: number;
|
|
@@ -241,8 +279,16 @@ export interface ChangelogEntry {
|
|
|
241
279
|
export interface BatchResult {
|
|
242
280
|
op: string;
|
|
243
281
|
slug: string;
|
|
244
|
-
|
|
282
|
+
/** 'skipped' = the Op Spec green-lit the op but there was nothing to do
|
|
283
|
+
* (today: a duplicate session note) — no write, no error, batch continues.
|
|
284
|
+
* Dry-run reports the same disposition as a warning. */
|
|
285
|
+
status: 'ok' | 'error' | 'skipped';
|
|
245
286
|
error?: string;
|
|
287
|
+
/** Why an op was skipped — same text the dry-run warning carries. */
|
|
288
|
+
warning?: string;
|
|
289
|
+
/** Error class when the failure — or the skip — maps to a specific HafezError
|
|
290
|
+
* code, so a single-op caller unwrapping this result keeps the right exit code. */
|
|
291
|
+
code?: 'SLUG_EXISTS';
|
|
246
292
|
created?: boolean;
|
|
247
293
|
}
|
|
248
294
|
export type BatchOperation = {
|
|
@@ -282,7 +328,13 @@ export type BatchOperation = {
|
|
|
282
328
|
op: 'promote';
|
|
283
329
|
slug: string;
|
|
284
330
|
target: 'entity' | 'project' | 'knowledge';
|
|
331
|
+
} | {
|
|
332
|
+
op: 'delete';
|
|
333
|
+
slug: string;
|
|
285
334
|
};
|
|
335
|
+
/** What `delete` may remove. An entity is never deleted: it is retired with
|
|
336
|
+
* `status: done`. */
|
|
337
|
+
export type DeletableKind = 'knowledge' | 'session';
|
|
286
338
|
export interface BatchOpValidation {
|
|
287
339
|
index: number;
|
|
288
340
|
op: string;
|
|
@@ -290,6 +342,10 @@ export interface BatchOpValidation {
|
|
|
290
342
|
created?: boolean;
|
|
291
343
|
errors: string[];
|
|
292
344
|
warning?: string;
|
|
345
|
+
/** Error class of the primary failure (NOT_FOUND / SLUG_EXISTS) when it
|
|
346
|
+
* maps to a specific HafezError code; single-op callers surface it as that
|
|
347
|
+
* code, batch-level failures stay VALIDATION_FAILED. */
|
|
348
|
+
primaryCode?: 'NOT_FOUND' | 'SLUG_EXISTS';
|
|
293
349
|
}
|
|
294
350
|
export interface BatchValidationReport {
|
|
295
351
|
valid: boolean;
|
|
@@ -341,9 +397,7 @@ export interface Hafez {
|
|
|
341
397
|
items: QueryResult[];
|
|
342
398
|
total: number;
|
|
343
399
|
}>;
|
|
344
|
-
queryUnified(opts?:
|
|
345
|
-
kind?: 'entity' | 'knowledge' | 'all';
|
|
346
|
-
}): Promise<{
|
|
400
|
+
queryUnified(opts?: UnifiedQueryOpts): Promise<{
|
|
347
401
|
items: UnifiedResult[];
|
|
348
402
|
total: number;
|
|
349
403
|
}>;
|
|
@@ -353,6 +407,8 @@ export interface Hafez {
|
|
|
353
407
|
link(slug: string, target: string, relation: LinkRelation): Promise<void>;
|
|
354
408
|
unlink(slug: string, target: string, relation: LinkRelation): Promise<void>;
|
|
355
409
|
promote(slug: string, target: 'entity' | 'project' | 'knowledge'): Promise<string>;
|
|
410
|
+
/** Delete a knowledge or session note; resolves to the kind deleted. */
|
|
411
|
+
delete(slug: string): Promise<DeletableKind>;
|
|
356
412
|
batch(operations: BatchOperation[]): Promise<BatchResult[]>;
|
|
357
413
|
validateBatch(operations: BatchOperation[]): Promise<BatchValidationReport>;
|
|
358
414
|
sync(): Promise<{
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ValidationReport } from './types.js';
|
|
2
|
+
/** The vault integrity scan. Deliberately independent of the SQLite index:
|
|
3
|
+
* this is the one operation that does not trust the index, so it reads the
|
|
4
|
+
* files directly. Re-implementing it as an index query was considered and
|
|
5
|
+
* rejected — an integrity checker that reads the index is checking the
|
|
6
|
+
* index's model of the vault, not the vault. */
|
|
7
|
+
export declare function validateVault(vaultPath: string): ValidationReport;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { join, basename } from 'path';
|
|
2
|
+
import { parseFilePath, scanVaultDir, kindDir, vaultFileExists } from './vault.js';
|
|
3
|
+
import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
|
|
4
|
+
import { parentCycle } from './batch-ops.js';
|
|
5
|
+
/** The vault integrity scan. Deliberately independent of the SQLite index:
|
|
6
|
+
* this is the one operation that does not trust the index, so it reads the
|
|
7
|
+
* files directly. Re-implementing it as an index query was considered and
|
|
8
|
+
* rejected — an integrity checker that reads the index is checking the
|
|
9
|
+
* index's model of the vault, not the vault. */
|
|
10
|
+
export function validateVault(vaultPath) {
|
|
11
|
+
const exists = (slug) => vaultFileExists(vaultPath, slug);
|
|
12
|
+
const report = {
|
|
13
|
+
broken_slugs: [],
|
|
14
|
+
orphaned_knowledge: [],
|
|
15
|
+
missing_fields: [],
|
|
16
|
+
total_entities: 0,
|
|
17
|
+
total_knowledge: 0,
|
|
18
|
+
};
|
|
19
|
+
const knowledgeSlugs = new Set();
|
|
20
|
+
const referencedKnowledge = new Set();
|
|
21
|
+
const parents = new Map();
|
|
22
|
+
// Scan entities
|
|
23
|
+
const entDir = join(vaultPath, kindDir('entity'));
|
|
24
|
+
{
|
|
25
|
+
const files = scanVaultDir(entDir);
|
|
26
|
+
report.total_entities = files.length;
|
|
27
|
+
for (const filePath of files) {
|
|
28
|
+
const slug = basename(filePath, '.md');
|
|
29
|
+
let fm;
|
|
30
|
+
try {
|
|
31
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const errors = validateEntityFrontmatter(fm);
|
|
38
|
+
for (const e of errors)
|
|
39
|
+
report.missing_fields.push({ slug, field: '', issue: e });
|
|
40
|
+
if (fm.parent) {
|
|
41
|
+
parents.set(slug, fm.parent);
|
|
42
|
+
if (!exists(fm.parent))
|
|
43
|
+
report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
|
|
44
|
+
}
|
|
45
|
+
if (fm.related && Array.isArray(fm.related)) {
|
|
46
|
+
for (const r of fm.related) {
|
|
47
|
+
if (!exists(r))
|
|
48
|
+
report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
|
|
49
|
+
// Track referenced knowledge
|
|
50
|
+
referencedKnowledge.add(r);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Parent cycles: reported once each, under the first member found
|
|
56
|
+
const inCycle = new Set();
|
|
57
|
+
for (const [slug, parent] of parents) {
|
|
58
|
+
if (inCycle.has(slug))
|
|
59
|
+
continue;
|
|
60
|
+
const cycle = parentCycle(slug, parent, s => parents.get(s));
|
|
61
|
+
if (!cycle)
|
|
62
|
+
continue;
|
|
63
|
+
for (const s of cycle)
|
|
64
|
+
inCycle.add(s);
|
|
65
|
+
report.broken_slugs.push({ slug, field: 'parent', issue: `parent cycle: ${cycle.join(' → ')}` });
|
|
66
|
+
}
|
|
67
|
+
// Scan knowledge
|
|
68
|
+
const knDir = join(vaultPath, kindDir('knowledge'));
|
|
69
|
+
{
|
|
70
|
+
const files = scanVaultDir(knDir);
|
|
71
|
+
report.total_knowledge = files.length;
|
|
72
|
+
for (const filePath of files) {
|
|
73
|
+
const slug = basename(filePath, '.md');
|
|
74
|
+
knowledgeSlugs.add(slug);
|
|
75
|
+
let fm;
|
|
76
|
+
try {
|
|
77
|
+
fm = parseFilePath(filePath).frontmatter;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const errors = validateKnowledgeFrontmatter(fm);
|
|
84
|
+
for (const e of errors)
|
|
85
|
+
report.missing_fields.push({ slug, field: '', issue: e });
|
|
86
|
+
if (fm.related && Array.isArray(fm.related)) {
|
|
87
|
+
for (const r of fm.related) {
|
|
88
|
+
if (!exists(r))
|
|
89
|
+
report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Find orphaned knowledge: no related, and not referenced by any entity
|
|
95
|
+
for (const slug of knowledgeSlugs) {
|
|
96
|
+
let fm;
|
|
97
|
+
try {
|
|
98
|
+
fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue; // already reported above
|
|
102
|
+
}
|
|
103
|
+
const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
|
|
104
|
+
if (!hasRelated && !referencedKnowledge.has(slug)) {
|
|
105
|
+
report.orphaned_knowledge.push(slug);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return report;
|
|
109
|
+
}
|