hafez 1.4.0 → 2.1.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 +249 -114
  4. package/dist/batch-ops.d.ts +95 -26
  5. package/dist/batch-ops.js +178 -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 +73 -31
  18. package/dist/cli/install.d.ts +65 -0
  19. package/dist/cli/install.js +333 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +273 -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 +15 -4
  26. package/dist/cli/upgrade.js +103 -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 +46 -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 +527 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +49 -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
@@ -1,94 +1,277 @@
1
1
  // src/cli/onboard.ts
2
2
  //
3
- // `hafez onboard` — agent-directed first-run guide. Same pattern as
4
- // `hafez help --agent`: plain text printed for the agent operating on the
5
- // user's behalf. Covers the two things a fresh install leaves open — seeding
6
- // the empty vault and choosing how deeply the agent integrates.
3
+ // `hafez onboard` — the user side of the Agent Contract (onboard decision
4
+ // #58). Bare, it prints where things stand, then the script the agent follows
5
+ // for its user: ask the Mode, install, allow the command, offer the hook and
6
+ // any other Harness found, seed an empty vault, hand over with the teaching
7
+ // lines. The files are the state: re-running walks only what is not current.
7
8
  //
8
- // Static by design, and deliberately NOT part of the skill: the skill loads
9
- // into every session forever, this is read once. Living in the CLI also means
10
- // it works before the skill is installed.
11
- export const INTEGRATION_SNIPPET = `## Hafez — Session Memory
12
-
13
- - Session start: if the first message names a project or topic, load context
14
- with \`hafez read <slug>\` (or \`hafez search "<topic>"\`). Don't load
15
- speculatively.
16
- - Mid-session: new workstream -> create an entity. Durable insight -> create
17
- or update a knowledge note (search first). User mentions a project by
18
- name -> check if it's a Hafez entity. Use \`hafez batch\` for 2+ mutations.
19
- - Session end: record a digest (\`hafez digest | hafez batch\`; on shells
20
- that can't pipe, e.g. PowerShell 5.1: \`--file\` on both) with the
21
- entities touched, decisions made, and a short narrative.
22
- - Knowledge boundary: useful beyond this repo/session -> vault knowledge
23
- note. Specific to one repo's code -> that repo's own docs, not the vault.`;
24
- export function renderOnboard() {
25
- return `# Hafez — First Run
26
-
27
- You are an agent reading this on behalf of your user. Walk them through the
28
- steps below conversationally — do not dump this document on them.
29
-
30
- Prerequisites: \`hafez init\` resolves a vault, and you have read
31
- \`hafez help --agent\` (do that now if not). The CLI is the whole interface —
32
- no other pieces are required. If your harness is Claude Code, also install
33
- the bundled skill — see the README Setup (github.com/shrvn-r/hafez).
34
-
35
- Re-running onboard is normal and intended: on a vault that is already set
36
- up and integrated, skip Step 2 and use Step 1 as a seeding interview for
37
- whatever is new since last time. Seed current, active work — seeding never
38
- means documenting the past; using the vault as you go stays primary.
39
-
40
- ## Step 1 — Seed the vault
41
-
42
- An empty vault gives the agent nothing to trigger on, so the memory loop
43
- never starts. The goal here is a small set of high-quality entries — not a
44
- bulk import.
45
-
46
- 1. Interview the user: "What are you actively working on right now?" Aim for
47
- 3-7 projects or areas. For each one, draw out:
48
- - a one-line description (\`description\`)
49
- - current state / handoff context, 2-4 sentences (\`brief\`)
50
- - 1-2 concrete next actions (\`add_action\`)
51
- 2. Offer to mine sources they already have — a repo's README, existing notes,
52
- a TODO list. Read what they point you at, propose entries, and let them
53
- approve before writing anything.
54
- 3. Anything vague, aspirational, or someday-maybe becomes a capture, not a
55
- project. Captures are the inbox; promotion comes later. When in doubt,
56
- capture.
57
- 4. Write everything in ONE \`hafez batch\` call (see
58
- \`hafez schema create-entity --examples\`), then show the user
59
- \`hafez stats\`.
60
-
61
- Quality bar: every project entry should let a future agent with zero context
62
- pick the work up from the brief alone. If a brief would not do that, ask one
63
- more question.
64
-
65
- ## Step 2 — Choose the integration level
66
-
67
- Ask the user how they want Hafez wired into their sessions. Recommend the
68
- first option.
69
-
70
- RECOMMENDED — always active. Add the block below to the user's global agent
71
- instructions file — Claude Code: \`~/.claude/CLAUDE.md\`; any harness that
72
- follows the AGENTS.md standard (Codex, Cursor, Copilot, ...): its global
73
- AGENTS.md, e.g. \`~/.codex/AGENTS.md\` for Codex CLI. This makes memory
74
- automatic: context loads at session start, the vault is updated as work
75
- happens, and a digest records each session. Show them the block, get a yes,
76
- then append it verbatim:
77
-
78
- ${INTEGRATION_SNIPPET}
79
-
80
- FALLBACK — invoke only. If the user prefers to stay in control, add nothing.
81
- Explain how invocation works: the agent touches the vault only when they
82
- mention Hafez or name a project ("what am I working on?", "read <slug>").
83
- Nothing is loaded or written unless asked. They can upgrade later
84
- by re-running \`hafez onboard\`.
85
-
86
- ## Step 3 — Hand over
87
-
88
- Finish with three example asks tailored to what was just seeded, e.g.:
89
-
90
- - "What am I working on?"
91
- - "Read <their-project-slug> and pick up where we left off."
92
- - "Capture: <idea> — I'll triage it later."
93
- `;
9
+ // `--install` and `--remove` write the files Hafez owns outside the vault
10
+ // (install.ts). The binary has no interactive prompts: the agent asks, then
11
+ // passes the answer as a flag. `--block` is the SessionStart hook's render
12
+ // (block-hook.ts) and writes nothing.
13
+ import { existsSync, readFileSync } from 'fs';
14
+ import { homedir } from 'os';
15
+ import { join } from 'path';
16
+ import { UsageError, parseArgs, str } from './args.js';
17
+ import { HARNESSES, ONBOARD_HARNESSES, detectAgent, isHarness, isOnboardHarness } from '../harness.js';
18
+ import { MODES, SKILL_DIRS, fixBrokenMarkers, install, installState, instructionsFile, isMode, legacyLine, remove, removeLegacy } from './install.js';
19
+ import { noVaultMessage, resolveVaultPath } from './resolve-vault.js';
20
+ import { HafezError } from '../types.js';
21
+ import { kindDir, scanVaultDir } from '../vault.js';
22
+ import { ITEMS } from '../surfaces/items.js';
23
+ import { renderBlock } from '../surfaces/surfaces.js';
24
+ import { blockHook } from './block-hook.js';
25
+ const HARNESS_IDS = Object.keys(HARNESSES).join('|');
26
+ const MODE_IDS = MODES.join('|');
27
+ const ONBOARD_USAGE = `Usage:
28
+ hafez onboard
29
+ hafez onboard --install --mode <${MODE_IDS}> [--harness <${HARNESS_IDS}>] [--confirm]
30
+ hafez onboard --remove [--confirm]
31
+ hafez onboard --block [--harness <${HARNESS_IDS}>]`;
32
+ /** `ctx` is the temp-home seam: tests pass a temporary home and environment;
33
+ * the install functions are given the home, never read it. `vaultFlag` is the
34
+ * global `--vault`; without it the bare form reports on the registered vault.
35
+ * `--block` returns a promise of its render, or of null when it must print nothing. */
36
+ export function cmdOnboard(args, ctx = {}) {
37
+ const { home = homedir(), env = process.env, cwd, vaultFlag } = ctx;
38
+ const { flags, positionals } = parseArgs(args, { install: 'boolean', remove: 'boolean', block: 'boolean', mode: 'string', harness: 'string', confirm: 'boolean' }, ONBOARD_USAGE);
39
+ const fail = (why) => {
40
+ throw new UsageError(`${why}\n${ONBOARD_USAGE}`);
41
+ };
42
+ if (positionals.length)
43
+ fail(`Unexpected argument: ${positionals[0]}`);
44
+ const mode = str(flags.mode);
45
+ const harnessFlag = str(flags.harness);
46
+ const confirm = flags.confirm === true;
47
+ if (flags.install && flags.remove)
48
+ fail('--install and --remove are separate commands');
49
+ if (flags.block) {
50
+ if (flags.install || flags.remove || mode !== undefined || confirm)
51
+ fail('--block takes only --harness: it prints the Block and writes nothing');
52
+ if (harnessFlag !== undefined && !isHarness(harnessFlag))
53
+ fail(`Unknown harness: ${harnessFlag}`);
54
+ return blockHook({ home, env, cwd, vaultFlag });
55
+ }
56
+ if (flags.remove) {
57
+ if (mode !== undefined || harnessFlag !== undefined)
58
+ fail('--remove takes no --mode or --harness: it removes everything Hafez wrote');
59
+ return remove({ home, confirm });
60
+ }
61
+ if (flags.install) {
62
+ if (mode === undefined)
63
+ return fail(`--install needs --mode <${MODE_IDS}>`);
64
+ if (!isMode(mode))
65
+ return fail(`Unknown mode: ${mode}`);
66
+ const harness = harnessFlag ?? detectAgent(env);
67
+ if (!isHarness(harness)) {
68
+ return fail(harnessFlag === undefined ? `No harness detected: pass --harness <${HARNESS_IDS}>` : `Unknown harness: ${harnessFlag}`);
69
+ }
70
+ return install({ home, harness, mode, confirm });
71
+ }
72
+ if (mode !== undefined || harnessFlag !== undefined || confirm)
73
+ fail('--mode, --harness and --confirm go with --install, --remove or --block');
74
+ const harness = detectAgent(env);
75
+ if (!isOnboardHarness(harness)) {
76
+ return fail('No harness detected: run `hafez onboard` from inside Claude Code or Codex. Another harness: `hafez onboard --install --harness <h>`');
77
+ }
78
+ return renderOnboard({ home, harness, vault: resolvedVault(vaultFlag) });
79
+ }
80
+ function resolvedVault(flag) {
81
+ const resolution = resolveVaultPath(flag);
82
+ if (resolution === null)
83
+ throw new HafezError('NOT_FOUND', noVaultMessage());
84
+ if (typeof resolution === 'object')
85
+ throw new HafezError('NOT_FOUND', resolution.error);
86
+ return resolution;
87
+ }
88
+ // --- Bare onboard: status, then the script ---
89
+ const read = (home, rel) => {
90
+ try {
91
+ return readFileSync(join(home, rel), 'utf-8');
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ };
97
+ /** Whether the SessionStart hook and the permission entries are already in
98
+ * the Harness's config: present when found, absent otherwise (an unreadable
99
+ * file included). Claude Code's settings are JSON; Codex's rule and TOML are
100
+ * matched as text. */
101
+ function harnessConfig(home, harness, vault) {
102
+ const isHook = (command) => typeof command === 'string' && command.includes('hafez onboard --block');
103
+ if (harness === 'claude-code') {
104
+ const { hooks, allowEntry } = ONBOARD_HARNESSES[harness];
105
+ let settings = null;
106
+ try {
107
+ settings = JSON.parse(read(home, hooks) ?? 'null');
108
+ }
109
+ catch {
110
+ settings = null;
111
+ }
112
+ const groups = Array.isArray(settings?.hooks?.SessionStart) ? settings.hooks.SessionStart : [];
113
+ const allow = Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [];
114
+ return {
115
+ hook: groups.some((group) => Array.isArray(group?.hooks) && group.hooks.some((h) => isHook(h?.command))),
116
+ allowlist: allow.includes(allowEntry),
117
+ };
118
+ }
119
+ const { hooks, rules, config } = ONBOARD_HARNESSES[harness];
120
+ const toml = read(home, config) ?? '';
121
+ return {
122
+ hook: isHook(read(home, hooks)) || isHook(toml),
123
+ allowlist: /prefix_rule\(\s*pattern\s*=\s*\[\s*"hafez"\s*\]/.test(read(home, rules) ?? '') && toml.includes(JSON.stringify(vault)),
124
+ };
125
+ }
126
+ const hookEntry = (harness) => `{"matcher": "startup|compact", "hooks": [{"type": "command", "command": "hafez onboard --block --harness ${harness}"}]}`;
127
+ /** Mode is never stored: a Block present means always-on, skills `onboard`
128
+ * installed without one mean invoke-only. Neither is a first run, a setup from
129
+ * before `onboard` (a hand-pasted section, a skill with no hash) included. */
130
+ const modeOf = ({ block, onboarded }) => block !== null && block !== 'absent' ? 'always-on' : onboarded ? 'invoke-only' : null;
131
+ /** Current in its Mode: the skills current, and the Block too where there is
132
+ * one. A Harness with no Block file (Cursor) is current on its skills. */
133
+ const isCurrent = (state) => state.skills === 'current' && (state.block === null || state.block === 'current' || modeOf(state) === 'invoke-only');
134
+ const statusLine = (label, { block, skills }, extra = []) => `${label}: ${[...(block === null ? [] : [`Block ${block === 'broken' ? 'broken markers' : block}`]), `skills ${skills}`, ...extra].join(' | ')}`;
135
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
136
+ const listed = (names) => (names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`);
137
+ export function renderOnboard({ home, harness, vault }) {
138
+ const { label, instructions } = HARNESSES[harness];
139
+ const state = installState(home, harness);
140
+ const config = harnessConfig(home, harness, vault);
141
+ const others = Object.keys(HARNESSES)
142
+ .filter(id => id !== harness && existsSync(join(home, HARNESSES[id].dir)))
143
+ .map(id => ({ id, ...HARNESSES[id], state: installState(home, id) }));
144
+ const entities = scanVaultDir(join(vault, kindDir('entity'))).length;
145
+ const knowledge = scanVaultDir(join(vault, kindDir('knowledge'))).length;
146
+ const mode = modeOf(state);
147
+ const otherMode = mode === 'always-on' ? 'invoke-only' : 'always-on';
148
+ const installCurrent = isCurrent(state);
149
+ const teaching = (m) => ITEMS.teachingLines.render({ harness, mode: m });
150
+ const line2 = (m) => ITEMS.teachingLines.render({ harness, mode: m, line2Only: true });
151
+ const instructionsRel = `~/${instructionsFile(home, instructions)}`;
152
+ const block = `\`\`\`\`\n${renderBlock()}\`\`\`\``;
153
+ const showBlock = `show the user the Block below word for word, not a summary, say it goes into ${instructionsRel} between two marker lines and nothing else in that file changes, and get a yes. Then run:`;
154
+ const status = [
155
+ `Vault: ${vault} (${plural(entities, 'entity', 'entities')}, ${plural(knowledge, 'knowledge note', 'knowledge notes')})`,
156
+ statusLine(`${label} (this harness)`, state, [`hook ${config.hook ? 'present' : 'absent'}`, `allowlist ${config.allowlist ? 'present' : 'absent'}`]),
157
+ ...others.map(o => statusLine(`${o.label} (found at ~/${o.dir})`, o.state)),
158
+ // `--install` and `--remove` refuse broken markers: only a hand fix walks on.
159
+ ...[{ ...HARNESSES[harness], state }, ...others]
160
+ .filter(h => h.state.block === 'broken')
161
+ .map(h => fixBrokenMarkers(`~/${instructionsFile(home, h.instructions)}`, 'hafez onboard')),
162
+ ...[{ ...HARNESSES[harness], state }, ...others]
163
+ .filter(h => h.state.legacy !== null)
164
+ .map(h => `${legacyLine(`~/${instructionsFile(home, h.instructions)}`, h.state.legacy)}, from Hafez 1.x, with no markers.`),
165
+ ];
166
+ // With the install current there is no install step: switching the Mode is
167
+ // walked here, and re-running `onboard` afterwards walks what it changed.
168
+ const switchMode = !installCurrent || mode === null
169
+ ? []
170
+ : otherMode === 'always-on'
171
+ ? [`To switch to always-on, ${showBlock}`, '', 'hafez onboard --install --mode always-on', '', 'then `hafez onboard` again for the session-start hook.', '', block]
172
+ : [
173
+ 'To switch to invoke-only, run:',
174
+ '',
175
+ 'hafez onboard --install --mode invoke-only',
176
+ ...(config.hook ? ['', `and remove the hafez entry from hooks.SessionStart in ~/${ONBOARD_HARNESSES[harness].hooks}.`] : []),
177
+ ];
178
+ const steps = [];
179
+ steps.push({
180
+ title: 'Ask how Hafez should run',
181
+ body: [
182
+ 'Ask one question and recommend the first answer.',
183
+ '',
184
+ '- Always-on: you load the vault at the start of a session and write to it as work lands.',
185
+ '- Invoke-only: nothing happens unless the user asks. No load at start, no writes during the session.',
186
+ ...(mode === null ? [] : ['', `It runs ${mode} now.${switchMode.length ? ` ${switchMode[0]}` : ''}`, ...switchMode.slice(1)]),
187
+ ].join('\n'),
188
+ });
189
+ if (!installCurrent && state.block !== 'broken') {
190
+ steps.push({
191
+ title: 'Install',
192
+ body: [
193
+ // Either install leaves the 1.x section in place, beside the Block or alone.
194
+ ...(state.legacy === null ? [] : [`${removeLegacy(instructionsRel, state.legacy)} Do this before either install below.`, '']),
195
+ `Always-on: ${showBlock}`,
196
+ '',
197
+ 'hafez onboard --install --mode always-on',
198
+ '',
199
+ 'Invoke-only: no file of theirs is edited. Run:',
200
+ '',
201
+ 'hafez onboard --install --mode invoke-only',
202
+ '',
203
+ `Both install two skills, \`hafez\` and \`hafez-save\`, into ${SKILL_DIRS.map(d => `~/${d}`).join(' and ')}.`,
204
+ '',
205
+ block,
206
+ ].join('\n'),
207
+ });
208
+ }
209
+ if (installCurrent && state.legacy !== null) {
210
+ steps.push({ title: 'Remove the Hafez 1.x section', body: removeLegacy(instructionsRel, state.legacy) });
211
+ }
212
+ if (!config.allowlist) {
213
+ steps.push({ title: 'Allow the command', body: ITEMS.harnessPermissions.render({ harness, vault }) });
214
+ }
215
+ if (!config.hook && mode !== 'invoke-only') {
216
+ steps.push({
217
+ title: 'Offer the session-start hook (always-on only)',
218
+ body: 'Ask: should the Block be delivered again at the start of every session and after the context is compacted? '
219
+ + `If yes, show this entry, then add it to ~/${ONBOARD_HARNESSES[harness].hooks} under hooks.SessionStart:\n\n${hookEntry(harness)}`
220
+ // Live run (#101): Codex asks once whether to trust a new hook.
221
+ + (harness === 'codex' ? '\n\nTell the user Codex will ask once, at the next session start, whether to trust this hook: the answer is yes.' : ''),
222
+ });
223
+ }
224
+ const offered = others.filter(o => !isCurrent(o.state) && o.state.block !== 'broken');
225
+ if (offered.length > 0) {
226
+ const one = offered.length === 1;
227
+ steps.push({
228
+ title: 'Offer the other harness',
229
+ body: [
230
+ `${listed(offered.map(o => o.label))} ${one ? 'is' : 'are'} installed too. Ask once whether to set ${one ? 'it' : 'them'} up the same way. If yes:`,
231
+ '',
232
+ // A first run cannot know the answer to step 1 yet; the agent carries it.
233
+ ...offered.map(o => `hafez onboard --install --harness ${o.id} --mode ${mode ?? 'always-on'}`),
234
+ ].join('\n'),
235
+ });
236
+ }
237
+ steps.push(entities + knowledge === 0
238
+ ? {
239
+ title: 'Seed the vault',
240
+ body: [
241
+ 'An empty vault gives you nothing to load. Ask: "What are you working on right now?" Aim for three to seven pieces of work. For each, draw out one sentence of description, where things stand, and the next step, then write it:',
242
+ '',
243
+ ITEMS.createLines.render('<name>'),
244
+ '',
245
+ 'Anything vague or someday: `hafez capture "<name>"`. Read a README or notes only if the user points you at them. Do not ask for approval before writing. When done, show them `hafez stats`.',
246
+ ].join('\n'),
247
+ }
248
+ : { title: 'Seed the vault (skipped: the vault is not empty)' });
249
+ // On a re-run only line 2's subject can have changed: the Mode, if the
250
+ // user switches it in step 1.
251
+ steps.push({
252
+ title: 'Hand over',
253
+ body: mode === null
254
+ ? [
255
+ 'Say these to the user in your own words, then give three example asks drawn from their vault, each naming an entity or note in it.',
256
+ '',
257
+ 'Always-on:',
258
+ '',
259
+ teaching('always-on'),
260
+ '',
261
+ 'Invoke-only, line 2:',
262
+ '',
263
+ line2('invoke-only'),
264
+ ].join('\n')
265
+ : `The user has heard the teaching lines before. Only if they switched to ${otherMode} in step 1, tell them in your own words:\n\n${line2(otherMode)}`,
266
+ });
267
+ return [
268
+ '# Hafez setup',
269
+ '',
270
+ 'You are an agent reading this for your user. Talk them through it; do not paste this to them.',
271
+ '',
272
+ '## Where things stand',
273
+ '',
274
+ ...status,
275
+ ...steps.flatMap(({ title, body }, i) => ['', `## ${i + 1}. ${title}`, ...(body === undefined ? [] : ['', body])]),
276
+ ].join('\n');
94
277
  }
@@ -1,12 +1,19 @@
1
- export declare function getConfigDir(): string;
2
- export declare function getConfigPath(): string;
3
- export declare function readVaultConfig(): string | null;
1
+ /** Where the config is looked for. The environment and home are inputs so the
2
+ * `onboard --block` hook runs against a temporary home in tests. */
3
+ export interface ConfigHome {
4
+ env: Record<string, string | undefined>;
5
+ home: string;
6
+ }
7
+ export declare function getConfigDir({ env, home }?: ConfigHome): string;
8
+ export declare function getConfigPath(at?: ConfigHome): string;
9
+ export declare function readVaultConfig(at?: ConfigHome): string | null;
4
10
  export declare function saveVaultConfig(vaultPath: string): void;
11
+ export declare function isVault(dirPath: string): boolean;
5
12
  /**
6
13
  * Resolve vault path. Returns string or null.
7
14
  * If config exists but points to a bad path, returns an error object instead.
8
15
  */
9
- export declare function resolveVaultPath(flagValue?: string): string | null | {
16
+ export declare function resolveVaultPath(flagValue?: string, at?: ConfigHome): string | null | {
10
17
  error: string;
11
18
  };
12
19
  export declare function noVaultMessage(): string;
@@ -4,15 +4,16 @@ import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { execSync } from 'child_process';
6
6
  import { kindDir } from '../vault.js';
7
- export function getConfigDir() {
8
- const xdg = process.env.XDG_CONFIG_HOME;
9
- return join(xdg || join(homedir(), '.config'), 'hafez');
7
+ import { helpIfAsked } from './args.js';
8
+ const processHome = () => ({ env: process.env, home: homedir() });
9
+ export function getConfigDir({ env, home } = processHome()) {
10
+ return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'hafez');
10
11
  }
11
- export function getConfigPath() {
12
- return join(getConfigDir(), 'vault');
12
+ export function getConfigPath(at = processHome()) {
13
+ return join(getConfigDir(at), 'vault');
13
14
  }
14
- export function readVaultConfig() {
15
- const configPath = getConfigPath();
15
+ export function readVaultConfig(at = processHome()) {
16
+ const configPath = getConfigPath(at);
16
17
  if (!existsSync(configPath))
17
18
  return null;
18
19
  try {
@@ -28,25 +29,25 @@ export function saveVaultConfig(vaultPath) {
28
29
  mkdirSync(dir, { recursive: true });
29
30
  writeFileSync(join(dir, 'vault'), resolve(vaultPath) + '\n');
30
31
  }
31
- function isVault(dirPath) {
32
+ export function isVault(dirPath) {
32
33
  return existsSync(dirPath) && statSync(dirPath).isDirectory() && existsSync(join(dirPath, kindDir('entity')));
33
34
  }
34
35
  /**
35
36
  * Resolve vault path. Returns string or null.
36
37
  * If config exists but points to a bad path, returns an error object instead.
37
38
  */
38
- export function resolveVaultPath(flagValue) {
39
+ export function resolveVaultPath(flagValue, at = processHome()) {
39
40
  // 1. Explicit flag
40
41
  if (flagValue)
41
42
  return flagValue;
42
43
  // 2. Config file
43
- const configValue = readVaultConfig();
44
+ const configValue = readVaultConfig(at);
44
45
  if (configValue) {
45
46
  if (isVault(configValue))
46
47
  return configValue;
47
48
  // Config exists but points to bad path — loud failure
48
49
  return {
49
- error: `Config (${getConfigPath()}) points to ${configValue} but it doesn't look like a vault.\nRun 'hafez init --register /correct/path' to fix.`,
50
+ error: `Config (${getConfigPath(at)}) points to ${configValue} but it doesn't look like a vault.\nRun 'hafez init --register /correct/path' to fix.`,
50
51
  };
51
52
  }
52
53
  return null;
@@ -65,6 +66,7 @@ Get started:
65
66
  `;
66
67
  }
67
68
  export function cmdInit(args, flagValue) {
69
+ helpIfAsked(args, 'Usage: hafez init [--register <path>]');
68
70
  const registerIdx = args.indexOf('--register');
69
71
  if (registerIdx !== -1) {
70
72
  // hafez init --register <path>
@@ -47,8 +47,10 @@ function walk(node) {
47
47
  return inner;
48
48
  }
49
49
  if (type === 'pipe' || type === 'effects' || type === 'transform' || type === 'readonly') {
50
- // Zod effects / transforms / pipes — descend to the inner schema.
51
- const inner = def.in ?? def.schema ?? def.innerType;
50
+ // Zod effects / transforms / pipes — descend to the inner schema. A
51
+ // z.preprocess is a pipe whose input is the bare transform: its shape is
52
+ // the output side.
53
+ const inner = def.in?._def?.type === 'transform' ? def.out : def.in ?? def.schema ?? def.innerType;
52
54
  if (inner)
53
55
  return walk(inner);
54
56
  }
@@ -153,7 +155,6 @@ export const DIGEST_EXAMPLE = {
153
155
  decisions: ['Decision made'],
154
156
  narrative: 'One to three sentence cross-cutting summary.',
155
157
  session_date: '2026-01-15',
156
- agent: 'claude',
157
158
  };
158
159
  // Descriptions live in the Op Spec table (src/batch-ops.ts) — one row per op.
159
160
  const OP_DESCRIPTIONS = Object.fromEntries(OP_SPECS.map(s => [s.name, s.description]));
@@ -1,3 +1,4 @@
1
+ import { type BlockCheck, type LegacyCheck, type SkillCheck } from './install.js';
1
2
  export type Comparison = 'behind' | 'equal' | 'ahead' | 'unknown';
2
3
  /** `command` entries are directly executable (after user approval); `action`
3
4
  * entries are prose, so an agent that shells out every entry does not try to
@@ -13,19 +14,29 @@ export interface UpgradeState {
13
14
  registryReachable: boolean;
14
15
  npmMajor: number | null;
15
16
  install: 'registry' | 'dev-checkout';
16
- skill: 'present' | 'absent';
17
- skillSource: string;
18
- skillTarget: string;
17
+ /** Null when the installed files could not be read. */
18
+ integration: Integration | null;
19
19
  /** Mutually exclusive with the registry install in `steps` — exposed
20
20
  * separately so an agent iterating `steps` cannot run both. */
21
21
  alternativeInstall: string | null;
22
22
  steps: UpgradeStep[];
23
23
  }
24
+ /** Mode is not stored: a Block present means always-on, and so does a 1.x
25
+ * section with no markers (`legacy`). */
26
+ export interface Integration {
27
+ mode: 'always-on' | 'invoke-only';
28
+ blocks: BlockCheck[];
29
+ skills: SkillCheck[];
30
+ legacy: LegacyCheck[];
31
+ }
24
32
  export interface UpgradeDeps {
25
33
  fetch: typeof globalThis.fetch;
26
34
  registryUrl: string;
27
35
  packageRoot: string;
28
- installedSkillPath: string;
36
+ /** The temp-home seam, as onboard's. */
37
+ home: string;
38
+ /** Names the Harness for an invoke-only `onboard --install`. */
39
+ env: Record<string, string | undefined>;
29
40
  npmVersion: () => string;
30
41
  }
31
42
  /** Three-way compare on the numeric segments. Hand-rolled string compare is