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.
Files changed (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. 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
+ }
@@ -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
- const ITEM_TEMPLATES = {
3
- capture: ['## Notes'],
4
- entity: ['## Context', '## Session Log'],
5
- project: ['## Purpose', '## Goals', '## Session Log'],
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
- if (section === '## Purpose' && fields?.purpose) {
15
- lines.push('', fields.purpose);
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
- const sections = SUBTYPE_SECTIONS[subtype ?? 'insight'];
30
- const lines = [];
31
- for (const section of sections) {
32
- lines.push(`## ${section}`);
33
- if ((section === 'Synthesis' || section === 'Summary') && fields?.synthesis) {
34
- lines.push('', fields.synthesis);
35
- }
36
- lines.push('');
37
- }
38
- return lines.join('\n');
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
- export type EntityType = 'capture' | 'entity' | 'project';
2
- export type EntityStatus = 'active' | 'paused' | 'done';
3
- export type ConfidenceLevel = 'observation' | 'pattern' | 'principle';
4
- export type SessionLogType = 'progress' | 'decision' | 'blocker' | 'research';
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 = 'active' | 'paused' | 'done' | 'all' | 'stale' | 'capture';
7
+ export type QueryFilter = EntityStatus | 'all' | 'stale' | 'capture';
7
8
  export type LinkRelation = 'parent' | 'related';
8
- export type KnowledgeSubtype = 'insight' | 'plan';
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?: 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
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?: 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
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?: 'active' | 'done';
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
- export interface ParsedFile<T = EntityFrontmatter | KnowledgeFrontmatter> {
86
- /** Populated by read(): which side of the vault the slug resolved to. */
87
- kind?: 'entity' | 'knowledge';
88
- frontmatter: T;
89
- body: string;
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
- matched_action?: string;
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
- status: 'ok' | 'error';
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?: EntityQueryOpts & {
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;
@@ -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
+ }