hafez 1.4.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 (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +225 -114
  4. package/dist/batch-ops.d.ts +89 -26
  5. package/dist/batch-ops.js +176 -18
  6. package/dist/cli/args.d.ts +43 -5
  7. package/dist/cli/args.js +69 -8
  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 +4 -3
  13. package/dist/cli/commands.js +300 -99
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +18 -4
  17. package/dist/cli/index.js +72 -30
  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 +13 -4
  26. package/dist/cli/upgrade.js +90 -44
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +17 -1
  30. package/dist/contracts.js +44 -0
  31. package/dist/db.d.ts +15 -2
  32. package/dist/db.js +69 -48
  33. package/dist/digest.d.ts +6 -4
  34. package/dist/digest.js +28 -17
  35. package/dist/document.d.ts +41 -3
  36. package/dist/document.js +218 -62
  37. package/dist/export-okf.d.ts +13 -0
  38. package/dist/export-okf.js +11 -7
  39. package/dist/harness.d.ts +58 -0
  40. package/dist/harness.js +50 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +94 -104
  43. package/dist/merge.js +7 -5
  44. package/dist/near-match.d.ts +9 -0
  45. package/dist/near-match.js +34 -0
  46. package/dist/stale.d.ts +41 -0
  47. package/dist/stale.js +43 -0
  48. package/dist/stopwords.d.ts +6 -0
  49. package/dist/stopwords.js +25 -0
  50. package/dist/surfaces/items.d.ts +366 -0
  51. package/dist/surfaces/items.js +523 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +45 -16
  55. package/dist/validate.d.ts +7 -0
  56. package/dist/validate.js +109 -0
  57. package/dist/vault.js +1 -1
  58. package/package.json +4 -3
  59. package/skill/hafez/SKILL.md +68 -0
  60. package/skill/hafez-save/SKILL.md +29 -0
  61. package/dist/cli/help-agent.d.ts +0 -1
  62. package/dist/cli/help-agent.js +0 -210
  63. package/skill/SKILL.md +0 -137
@@ -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;