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,53 @@
1
+ import { type HarnessId } from '../harness.js';
2
+ export declare const MODES: readonly ["always-on", "invoke-only"];
3
+ export type Mode = (typeof MODES)[number];
4
+ export declare const isMode: (value: string) => value is Mode;
5
+ export declare const SKILL_DIRS: string[];
6
+ export type HashState = 'absent' | 'current' | 'stale' | 'hand-edited';
7
+ /** What to do about broken markers: a hand edit, then `rerun`. */
8
+ export declare const fixBrokenMarkers: (path: string, rerun: string) => string;
9
+ /** Codex reads an `AGENTS.override.md` in place of `AGENTS.md` when one
10
+ * exists: the first candidate present, else the last. */
11
+ export declare const instructionsFile: (home: string, candidates: readonly string[]) => string;
12
+ export interface InstallState {
13
+ /** In the Harness's instructions file; null where it keeps no file.
14
+ * `broken`: markers `--install` and `--remove` refuse until fixed by hand. */
15
+ block: HashState | 'broken' | null;
16
+ /** Across both skill directories: the one state every file shares, else
17
+ * hand-edited when any file is, else stale. */
18
+ skills: HashState;
19
+ /** Some skill file carries a hash: `onboard --install` has run here. */
20
+ onboarded: boolean;
21
+ }
22
+ export declare function installState(home: string, harness: HarnessId): InstallState;
23
+ export declare function install(opts: {
24
+ home: string;
25
+ harness: HarnessId;
26
+ mode: Mode;
27
+ confirm: boolean;
28
+ }): string;
29
+ export interface SkillCheck {
30
+ path: string;
31
+ state: HashState;
32
+ }
33
+ export interface BlockCheck {
34
+ harness: HarnessId;
35
+ path: string;
36
+ /** `broken`: markers `--install` would refuse until fixed by hand. */
37
+ state: Exclude<HashState, 'absent'> | 'broken';
38
+ /** The Block Hafez writes has changed since this one was written. */
39
+ renderChanged: boolean;
40
+ }
41
+ /** Both skills in both directories, and the Block in the instructions file
42
+ * each Harness reads — the one `--install` would write. Only Blocks that are
43
+ * there are listed: none means invoke-only. */
44
+ export declare function checkInstall(home: string): {
45
+ skills: SkillCheck[];
46
+ blocks: BlockCheck[];
47
+ };
48
+ /** Remove everything Hafez wrote: both skills from both directories, and the
49
+ * Block region from every instructions file a Harness reads. Nothing else. */
50
+ export declare function remove({ home, confirm }: {
51
+ home: string;
52
+ confirm: boolean;
53
+ }): string;
@@ -0,0 +1,297 @@
1
+ // src/cli/install.ts
2
+ //
3
+ // `onboard --install` and `onboard --remove`: the files Hafez owns outside the
4
+ // vault (onboard decision #58). Both skills go to both skill directories; in
5
+ // always-on Mode the Block goes between its hash markers in the Harness's
6
+ // instructions file. Nothing outside the markers is written, save a newline
7
+ // ending a last line that had none, so the Block's begin marker starts a line.
8
+ //
9
+ // Mode is never stored: a Block present means always-on. The home directory
10
+ // is an argument (the temp-home seam); nothing here reads the environment.
11
+ // Every check runs before the first write, so a refusal writes nothing.
12
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs';
13
+ import { dirname, join } from 'path';
14
+ import { HafezError } from '../types.js';
15
+ import { HARNESSES } from '../harness.js';
16
+ import { contentHash, renderBlock, renderHafezSaveSkill, renderHafezSkill } from '../surfaces/surfaces.js';
17
+ export const MODES = ['always-on', 'invoke-only'];
18
+ export const isMode = (value) => MODES.includes(value);
19
+ export const SKILL_DIRS = ['.claude/skills', '.agents/skills'];
20
+ const SKILLS = [
21
+ { name: 'hafez', render: renderHafezSkill },
22
+ { name: 'hafez-save', render: renderHafezSaveSkill },
23
+ ];
24
+ /** An editor that re-saves a file with CRLF endings has not edited it. */
25
+ const lf = (text) => text.replace(/\r\n/g, '\n');
26
+ /** The one check that tells the states apart. Current is the render exactly;
27
+ * hand-edited is text that no longer matches its own hash; anything else (an
28
+ * older render, or a file from before hashes) is stale. */
29
+ function hashState(found, rendered) {
30
+ if (!found)
31
+ return 'absent';
32
+ if (lf(found.text) === rendered)
33
+ return 'current';
34
+ if (found.hash !== null && contentHash(lf(found.body)) !== found.hash)
35
+ return 'hand-edited';
36
+ return 'stale';
37
+ }
38
+ // --- The Block region ---
39
+ const BEGIN = /^<!-- hafez:begin ([0-9a-f]{12}) -->$/;
40
+ const END = '<!-- hafez:end -->';
41
+ /** What broken markers are, for a refusal or a report about `path`. */
42
+ const brokenMarkers = (path) => `The Hafez markers in ${path} are broken: expected one "<!-- hafez:begin <hash> -->" line followed by one "${END}" line.`;
43
+ /** What to do about broken markers: a hand edit, then `rerun`. */
44
+ export const fixBrokenMarkers = (path, rerun) => `${brokenMarkers(path)} Fix or delete them by hand, then re-run \`${rerun}\`.`;
45
+ /** Find the one Block region in `file`, or null when there are no markers.
46
+ * Markers that are duplicated, unpaired, out of order or missing their hash
47
+ * are refused: which bytes are Hafez's is then unknowable. */
48
+ function findRegion(file, displayPath) {
49
+ const begins = [];
50
+ const ends = [];
51
+ let offset = 0;
52
+ for (const raw of file.split('\n')) {
53
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
54
+ const at = { line, start: offset, next: Math.min(offset + raw.length + 1, file.length) };
55
+ if (line.startsWith('<!-- hafez:begin'))
56
+ begins.push(at);
57
+ else if (line.startsWith('<!-- hafez:end'))
58
+ ends.push(at);
59
+ offset += raw.length + 1;
60
+ }
61
+ if (begins.length === 0 && ends.length === 0)
62
+ return null;
63
+ const [begin] = begins;
64
+ const [end] = ends;
65
+ const hash = begin ? BEGIN.exec(begin.line)?.[1] : undefined;
66
+ if (begins.length !== 1 || ends.length !== 1 || end.start < begin.start || !hash || end.line !== END) {
67
+ throw new HafezError('VALIDATION_FAILED', `${brokenMarkers(displayPath)} Fix or delete them by hand, then re-run.`);
68
+ }
69
+ return {
70
+ start: begin.start,
71
+ end: end.start + END.length,
72
+ next: end.next,
73
+ hash,
74
+ body: file.slice(begin.next, end.start),
75
+ text: file.slice(begin.start, end.start + END.length),
76
+ };
77
+ }
78
+ /** A line diff of the user's Block against the render: ` ` both, `-` theirs
79
+ * only, `+` the render only. */
80
+ function lineDiff(theirs, ours) {
81
+ const a = lf(theirs).replace(/\n$/, '').split('\n');
82
+ const b = ours.replace(/\n$/, '').split('\n');
83
+ const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
84
+ for (let i = a.length - 1; i >= 0; i--) {
85
+ for (let j = b.length - 1; j >= 0; j--) {
86
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
87
+ }
88
+ }
89
+ const out = [];
90
+ let i = 0;
91
+ let j = 0;
92
+ while (i < a.length || j < b.length) {
93
+ if (i < a.length && j < b.length && a[i] === b[j]) {
94
+ out.push(` ${a[i++]}`);
95
+ j++;
96
+ }
97
+ else if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1])) {
98
+ out.push(`-${a[i++]}`);
99
+ }
100
+ else {
101
+ out.push(`+${b[j++]}`);
102
+ }
103
+ }
104
+ return out.join('\n');
105
+ }
106
+ const write = (path, text) => {
107
+ mkdirSync(dirname(path), { recursive: true });
108
+ // In place, never write-then-rename: an instructions file may be a symlink
109
+ // serving two harnesses, and a rename would replace the link with a copy.
110
+ writeFileSync(path, text);
111
+ };
112
+ function readInstalled(path) {
113
+ return existsSync(path) ? readFileSync(path, 'utf-8') : null;
114
+ }
115
+ /** A skill file's hash is its frontmatter `hash:` line, over everything after
116
+ * the closing `---`. */
117
+ function parseSkill(text) {
118
+ const close = text.startsWith('---\n') ? text.indexOf('\n---\n', 3) : -1;
119
+ if (close < 0)
120
+ return { text, hash: null, body: text };
121
+ const hash = /^hash: ([0-9a-f]{12})$/m.exec(text.slice(4, close + 1))?.[1] ?? null;
122
+ return { text, hash, body: text.slice(close + 5) };
123
+ }
124
+ /** Delete a skill file and its directory once empty. Either skill directory
125
+ * may be a symlink to the other, so the file can already be gone, and a
126
+ * linked directory is the user's link: never removed. */
127
+ function removeSkill(path) {
128
+ if (existsSync(path))
129
+ unlinkSync(path);
130
+ const dir = dirname(path);
131
+ if (existsSync(dir) && lstatSync(dir).isDirectory() && readdirSync(dir).length === 0)
132
+ rmdirSync(dir);
133
+ }
134
+ /** Both skills in both directories, with what is installed at each. */
135
+ const skillFiles = (home) => SKILL_DIRS.flatMap(dir => SKILLS.map(({ name, render }) => {
136
+ const rel = `${dir}/${name}/SKILL.md`;
137
+ const path = join(home, rel);
138
+ return { rel, path, render, text: readInstalled(path) };
139
+ }));
140
+ function skillSteps(home, want) {
141
+ return skillFiles(home).map(({ rel, path, render, text }) => {
142
+ if (want === 'remove') {
143
+ return text === null ? { line: `~/${rel}: absent` } : { line: `~/${rel}: removed`, write: () => removeSkill(path) };
144
+ }
145
+ const rendered = render();
146
+ const state = hashState(text === null ? null : parseSkill(text), rendered);
147
+ if (state === 'current')
148
+ return { line: `~/${rel}: current` };
149
+ return { line: `~/${rel}: ${state === 'absent' ? 'written' : `replaced (was ${state})`}`, write: () => write(path, rendered) };
150
+ });
151
+ }
152
+ const RENDERED = findRegion(renderBlock(), 'the render');
153
+ function blockStep({ home, rel, want, confirm, rerun, suffix = '' }) {
154
+ const path = join(home, rel);
155
+ const label = `Block in ~/${rel}`;
156
+ const file = readInstalled(path);
157
+ const text = file ?? '';
158
+ const region = findRegion(text, `~/${rel}`);
159
+ const state = hashState(region, RENDERED.text);
160
+ if (region && state === 'hand-edited' && !confirm) {
161
+ return {
162
+ line: `${label}: hand-edited, left alone`,
163
+ note: [
164
+ `The Block in ~/${rel} was edited by hand, so it was left alone. Show the user this diff (\`-\` their text, \`+\` the Block Hafez writes). Only after their yes, run this; it ${want === 'write' ? 'replaces' : 'removes'} their text:`,
165
+ '',
166
+ rerun,
167
+ '',
168
+ '````diff',
169
+ lineDiff(region.body, RENDERED.body),
170
+ '````',
171
+ ].join('\n'),
172
+ };
173
+ }
174
+ if (want === 'remove') {
175
+ if (!region)
176
+ return { line: `${label}: absent${suffix}` };
177
+ return { line: `${label}: removed${suffix}`, write: () => write(path, text.slice(0, region.start) + text.slice(region.next)) };
178
+ }
179
+ if (state === 'current')
180
+ return { line: `${label}: current` };
181
+ if (!region) {
182
+ const before = text === '' || text.endsWith('\n') ? text : `${text}\n`;
183
+ return { line: `${label}: written`, write: () => write(path, before + renderBlock()) };
184
+ }
185
+ return {
186
+ line: `${label}: replaced (was ${state})`,
187
+ write: () => write(path, text.slice(0, region.start) + RENDERED.text + text.slice(region.end)),
188
+ };
189
+ }
190
+ /** Codex reads an `AGENTS.override.md` in place of `AGENTS.md` when one
191
+ * exists: the first candidate present, else the last. */
192
+ export const instructionsFile = (home, candidates) => candidates.find(rel => existsSync(join(home, rel))) ?? candidates[candidates.length - 1];
193
+ function run({ header, steps, tail = [], unchanged, listAll }) {
194
+ for (const step of steps)
195
+ step.write?.();
196
+ const acted = steps.filter(s => s.write || s.note);
197
+ const shown = listAll ? steps : acted;
198
+ const notes = steps.flatMap(s => (s.note ? [s.note] : []));
199
+ return [
200
+ header,
201
+ '',
202
+ ...shown.map(s => `- ${s.line}`),
203
+ ...(acted.length === 0 ? [...(shown.length ? [''] : []), unchanged] : []),
204
+ ...[...notes, ...tail].flatMap(text => ['', text]),
205
+ ].join('\n');
206
+ }
207
+ export function installState(home, harness) {
208
+ const { instructions } = HARNESSES[harness];
209
+ let block = null;
210
+ if (instructions !== null) {
211
+ const rel = instructionsFile(home, instructions);
212
+ try {
213
+ block = hashState(findRegion(readInstalled(join(home, rel)) ?? '', `~/${rel}`), RENDERED.text);
214
+ }
215
+ catch {
216
+ block = 'broken';
217
+ }
218
+ }
219
+ const skills = SKILL_DIRS.flatMap(dir => SKILLS.map(({ name, render }) => {
220
+ const text = readInstalled(join(home, dir, name, 'SKILL.md'));
221
+ const found = text === null ? null : parseSkill(text);
222
+ return { state: hashState(found, render()), hashed: found?.hash != null };
223
+ }));
224
+ const states = new Set(skills.map(s => s.state));
225
+ const [only] = states;
226
+ const merged = states.size === 1 ? only : states.has('hand-edited') ? 'hand-edited' : 'stale';
227
+ return { block, skills: merged, onboarded: skills.some(s => s.hashed) };
228
+ }
229
+ // --- The two commands ---
230
+ export function install(opts) {
231
+ const { home, harness, mode, confirm } = opts;
232
+ const { label, instructions } = HARNESSES[harness];
233
+ const steps = skillSteps(home, 'write');
234
+ const tail = [];
235
+ if (instructions === null) {
236
+ if (mode === 'always-on') {
237
+ steps.push({ line: `Block: printed below, for ${label}'s User Rules` });
238
+ tail.push(`${label} keeps its User Rules in Settings, not a file, so the Block was not written. Show the user the Block below and ask them to paste it into ${label} Settings → Rules → User Rules, marker lines included.`, `\`\`\`\`\n${renderBlock()}\`\`\`\``);
239
+ }
240
+ else {
241
+ steps.push({ line: `Block: not written (invoke-only). If it is pasted in ${label}'s User Rules, ask the user to delete it there.` });
242
+ }
243
+ }
244
+ else {
245
+ steps.push(blockStep({
246
+ home,
247
+ rel: instructionsFile(home, instructions),
248
+ want: mode === 'always-on' ? 'write' : 'remove',
249
+ confirm,
250
+ rerun: `hafez onboard --install --harness ${harness} --mode ${mode} --confirm`,
251
+ suffix: mode === 'invoke-only' ? ' (invoke-only)' : '',
252
+ }));
253
+ }
254
+ return run({ header: `${label}, ${mode}:`, steps, tail, unchanged: 'Nothing changed: the install is current.', listAll: true });
255
+ }
256
+ /** Both skills in both directories, and the Block in the instructions file
257
+ * each Harness reads — the one `--install` would write. Only Blocks that are
258
+ * there are listed: none means invoke-only. */
259
+ export function checkInstall(home) {
260
+ const skills = skillFiles(home).map(({ rel, render, text }) => ({
261
+ path: `~/${rel}`,
262
+ state: hashState(text === null ? null : parseSkill(text), render()),
263
+ }));
264
+ const blocks = [];
265
+ for (const [harness, { instructions }] of Object.entries(HARNESSES)) {
266
+ if (instructions === null)
267
+ continue;
268
+ const rel = instructionsFile(home, instructions);
269
+ const path = `~/${rel}`;
270
+ const text = readInstalled(join(home, rel));
271
+ if (text === null)
272
+ continue;
273
+ let region;
274
+ try {
275
+ region = findRegion(text, path);
276
+ }
277
+ catch {
278
+ blocks.push({ harness, path, state: 'broken', renderChanged: false });
279
+ continue;
280
+ }
281
+ if (!region)
282
+ continue;
283
+ const state = hashState(region, RENDERED.text);
284
+ blocks.push({ harness, path, state, renderChanged: region.hash !== RENDERED.hash });
285
+ }
286
+ return { skills, blocks };
287
+ }
288
+ /** Remove everything Hafez wrote: both skills from both directories, and the
289
+ * Block region from every instructions file a Harness reads. Nothing else. */
290
+ export function remove({ home, confirm }) {
291
+ const files = [...new Set(Object.values(HARNESSES).flatMap(h => h.instructions ?? []))];
292
+ const steps = [
293
+ ...skillSteps(home, 'remove'),
294
+ ...files.map(rel => blockStep({ home, rel, want: 'remove', confirm, rerun: 'hafez onboard --remove --confirm' })),
295
+ ];
296
+ return run({ header: 'Removing what Hafez wrote:', steps, unchanged: 'Nothing to remove.', listAll: false });
297
+ }
@@ -1,2 +1,15 @@
1
- export declare const INTEGRATION_SNIPPET = "## Hafez \u2014 Session Memory\n\n- Session start: if the first message names a project or topic, load context\n with `hafez read <slug>` (or `hafez search \"<topic>\"`). Don't load\n speculatively.\n- Mid-session: new workstream -> create an entity. Durable insight -> create\n or update a knowledge note (search first). User mentions a project by\n name -> check if it's a Hafez entity. Use `hafez batch` for 2+ mutations.\n- Session end: record a digest (`hafez digest | hafez batch`; on shells\n that can't pipe, e.g. PowerShell 5.1: `--file` on both) with the\n entities touched, decisions made, and a short narrative.\n- Knowledge boundary: useful beyond this repo/session -> vault knowledge\n note. Specific to one repo's code -> that repo's own docs, not the vault.";
2
- export declare function renderOnboard(): string;
1
+ import { type OnboardHarness } from '../harness.js';
2
+ import { type ConfigHome } from './resolve-vault.js';
3
+ import { type BlockHookCtx } from './block-hook.js';
4
+ /** What `onboard` reads from its surroundings, each defaulting to the process's. */
5
+ export type OnboardCtx = Partial<ConfigHome> & Pick<BlockHookCtx, 'cwd' | 'vaultFlag'>;
6
+ /** `ctx` is the temp-home seam: tests pass a temporary home and environment;
7
+ * the install functions are given the home, never read it. `vaultFlag` is the
8
+ * global `--vault`; without it the bare form reports on the registered vault.
9
+ * `--block` returns a promise of its render, or of null when it must print nothing. */
10
+ export declare function cmdOnboard(args: string[], ctx?: OnboardCtx): string | Promise<string | null>;
11
+ export declare function renderOnboard({ home, harness, vault }: {
12
+ home: string;
13
+ harness: OnboardHarness;
14
+ vault: string;
15
+ }): string;