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
@@ -3,7 +3,8 @@
3
3
  // `hafez upgrade` — agent-directed update check. It CHECKS AND PRINTS; it
4
4
  // never installs. The printed steps install code and overwrite the user's
5
5
  // agent instructions, so the privileged step is the human's — same discipline
6
- // as `onboard` (writes nothing outside the vault) and as the release skill
6
+ // as bare `onboard` (writes nothing; only `--install`/`--remove`, run after
7
+ // the user's yes, write outside the vault) and as the release skill
7
8
  // (`npm publish` is the user's action). It also sidesteps a process replacing
8
9
  // its own binary mid-run.
9
10
  //
@@ -11,6 +12,11 @@
11
12
  // or the native SQLite binding is unbuilt, which is exactly when someone is
12
13
  // trying to upgrade. Nothing here touches the core.
13
14
  //
15
+ // The skills and the Block are checked by their hashes, in every place
16
+ // `onboard --install` writes them (install.ts), and only where they are
17
+ // installed: no Block means invoke-only, never a Block to offer. Refreshing
18
+ // them is `onboard --install`'s job, so that is the step printed.
19
+ //
14
20
  // Untrusted input: the registry response is rendered into a document an agent
15
21
  // will execute commands from. Only the `version` field is read, only after it
16
22
  // matches plain X.Y.Z, and no caught error's message, cause, or stack ever
@@ -21,8 +27,9 @@ import { existsSync, readFileSync } from 'fs';
21
27
  import { homedir } from 'os';
22
28
  import { dirname, join } from 'path';
23
29
  import { fileURLToPath } from 'url';
24
- import { UsageError } from './args.js';
25
- import { INTEGRATION_SNIPPET } from './onboard.js';
30
+ import { UsageError, helpIfAsked } from './args.js';
31
+ import { HARNESSES, detectAgent, isHarness } from '../harness.js';
32
+ import { checkInstall, fixBrokenMarkers, legacyLine, removeLegacy } from './install.js';
26
33
  const PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
27
34
  const REGISTRY_TIMEOUT_MS = 3000;
28
35
  const NPM_PROBE_TIMEOUT_MS = 3000;
@@ -122,28 +129,70 @@ function devCheckoutStep(packageRoot) {
122
129
  }
123
130
  return { kind: 'command', text: `cd '${packageRoot}' && ${DEV_CHECKOUT_WORK}` };
124
131
  }
125
- function skillCopyStep(source, target, sourceExists) {
126
- if (!sourceExists) {
127
- // Emitting the cp anyway would hand the agent a step that fails with
128
- // ENOENT and reads as a failed upgrade, for a check that found nothing.
129
- return {
130
- kind: 'action',
131
- text: `No bundled skill at ${source}, so the installed copy at ${target} cannot be refreshed from this ` +
132
- 'package. Re-copy it from the installed hafez package after upgrading — see the README Updating section.',
133
- };
132
+ function readIntegration(home) {
133
+ try {
134
+ const { skills, blocks, legacy } = checkInstall(home);
135
+ return { mode: blocks.length > 0 || legacy.length > 0 ? 'always-on' : 'invoke-only', blocks, skills, legacy };
134
136
  }
135
- if (unquotable(source) || unquotable(target)) {
136
- return {
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ const HARNESS_IDS = Object.keys(HARNESSES).join('|');
142
+ /** `onboard --install` writes both skills on every run, so a skill needs no
143
+ * run of its own when a Block's run is printed. Broken markers make that run
144
+ * refuse, so they get only the hand fix until it is done. When upgrading,
145
+ * every installed piece is refreshed: the new version renders new text, and
146
+ * this check can only compare against the running one's. */
147
+ function integrationSteps(integration, upgrading, env) {
148
+ if (integration === null) {
149
+ return [{ kind: 'action', text: 'The installed skills and Block could not be read, so they were not checked.' }];
150
+ }
151
+ const broken = integration.blocks.filter(b => b.state === 'broken');
152
+ if (broken.length > 0) {
153
+ return broken.map(b => ({
137
154
  kind: 'action',
138
- text: 'Copy the bundled skill file over the installed one by hand — the paths contain characters ' +
139
- `that single-quoting cannot make safe. Source: ${source} — Target: ${target}`,
140
- };
155
+ text: fixBrokenMarkers(b.path, 'hafez upgrade'),
156
+ }));
157
+ }
158
+ const { blocks, skills, legacy } = integration;
159
+ // The 1.x section goes first: the always-on install writes a Block beside
160
+ // it, and both would be loaded.
161
+ const removals = legacy.map(l => ({ kind: 'action', text: removeLegacy(l.path, l.line) }));
162
+ const unblocked = legacy
163
+ .filter(l => !blocks.some(b => b.harness === l.harness))
164
+ .map(l => ({ kind: 'command', text: `hafez onboard --install --mode always-on --harness ${l.harness}` }));
165
+ // With no Block, the always-on install refreshes the skills too.
166
+ const rest = blocks.length === 0 && legacy.length > 0 ? [] : blockSteps(integration, upgrading, env);
167
+ return [...removals, ...unblocked, ...rest];
168
+ }
169
+ function blockSteps({ blocks, skills }, upgrading, env) {
170
+ const skillDrift = skills.some(s => s.state === 'stale' || s.state === 'hand-edited');
171
+ const blockDrift = (b) => b.state === 'stale' || (b.state === 'hand-edited' && b.renderChanged);
172
+ const installed = blocks.length > 0 || skills.some(s => s.state !== 'absent');
173
+ if (!installed || (!upgrading && !skillDrift && !blocks.some(blockDrift)))
174
+ return [];
175
+ if (blocks.length > 0) {
176
+ // Skills alone: any Block's run writes them, so pick one that run will not
177
+ // stop on to ask about a hand-edit the status says needs nothing.
178
+ const skillsOnly = [blocks.find(b => b.state !== 'hand-edited') ?? blocks[0]];
179
+ const targets = upgrading ? blocks : blocks.filter(blockDrift);
180
+ return (targets.length > 0 ? targets : skillsOnly).map(b => ({
181
+ kind: 'command',
182
+ text: `hafez onboard --install --mode always-on --harness ${b.harness}`,
183
+ }));
141
184
  }
142
- return { kind: 'command', text: `mkdir -p '${dirname(target)}' && cp '${source}' '${target}'` };
185
+ const harness = detectAgent(env);
186
+ if (isHarness(harness))
187
+ return [{ kind: 'command', text: `hafez onboard --install --mode invoke-only --harness ${harness}` }];
188
+ return [{
189
+ kind: 'action',
190
+ text: `Run \`hafez onboard --install --mode invoke-only --harness <${HARNESS_IDS}>\`, naming the harness you are running in.`,
191
+ }];
143
192
  }
144
193
  /** The action list, in execution order. Every command is built from static
145
- * constants; the only interpolated values are the regex-validated version and
146
- * the quoted absolute paths (package root, skill source, skill target). */
194
+ * constants; the only interpolated values are the regex-validated version,
195
+ * the quoted package root, and harness ids from the HARNESSES table. */
147
196
  function deriveSteps(state, ctx) {
148
197
  const steps = [];
149
198
  let alternativeInstall = null;
@@ -169,12 +218,7 @@ function deriveSteps(state, ctx) {
169
218
  steps.push({ kind: 'action', text: ETARGET_NOTE });
170
219
  }
171
220
  }
172
- // Unconditional when the skill exists: the bundled skill ships INSIDE the
173
- // installed package, so on the main upgrade path the bundled copy is equally
174
- // stale and any byte-compare reports "current" in exactly the silent-drift
175
- // case detection would exist for. Copying an identical file is a no-op.
176
- if (state.skill === 'present')
177
- steps.push(skillCopyStep(state.skillSource, state.skillTarget, ctx.skillSourceExists));
221
+ steps.push(...integrationSteps(state.integration, upgrading, ctx.env));
178
222
  // A SCHEMA_VERSION bump does not self-heal: the read-only path warns and
179
223
  // returns null, so the first post-upgrade query answers *empty* until a
180
224
  // write command or an explicit rebuild.
@@ -195,14 +239,9 @@ export async function buildUpgradeState(deps) {
195
239
  npmMajor: probeNpmMajor(deps),
196
240
  // Published tarballs carry only dist, skill, README.md and LICENSE.
197
241
  install: exists(join(deps.packageRoot, 'src')) ? 'dev-checkout' : 'registry',
198
- skill: exists(deps.installedSkillPath) ? 'present' : 'absent',
199
- skillSource: join(deps.packageRoot, 'skill', 'SKILL.md'),
200
- skillTarget: deps.installedSkillPath,
201
- };
202
- return {
203
- ...base,
204
- ...deriveSteps(base, { packageRoot: deps.packageRoot, skillSourceExists: exists(base.skillSource) }),
242
+ integration: readIntegration(deps.home),
205
243
  };
244
+ return { ...base, ...deriveSteps(base, { packageRoot: deps.packageRoot, env: deps.env }) };
206
245
  }
207
246
  function versionLines(state) {
208
247
  const lines = [`Installed version: ${state.current}`];
@@ -223,6 +262,33 @@ function versionLines(state) {
223
262
  lines.push('Could not compare: the installed version is not a plain X.Y.Z. Check manually.');
224
263
  return lines;
225
264
  }
265
+ const BLOCK_STATE = {
266
+ current: () => 'current',
267
+ stale: () => 'stale',
268
+ 'hand-edited': b => b.renderChanged
269
+ ? "hand-edited, and the Block Hafez writes has changed since. `onboard --install` leaves it alone and prints a diff; replace it with --confirm only after the user's yes"
270
+ : 'hand-edited, over the current Block — nothing to update',
271
+ broken: () => 'broken markers',
272
+ };
273
+ function integrationLines(integration) {
274
+ if (integration === null)
275
+ return ['Skills and Block: could not be read.'];
276
+ const lines = integration.mode === 'always-on'
277
+ ? [integration.blocks.length > 0 ? 'Mode: always-on (a Block is installed).' : 'Mode: always-on (legacy: a Hafez 1.x section with no markers).']
278
+ : [
279
+ 'Mode: invoke-only (no Block in any instructions file Hafez writes); installing a Block is not offered.',
280
+ "A Block pasted into Cursor's User Rules lives in its Settings and is not checked here.",
281
+ ];
282
+ for (const b of integration.blocks)
283
+ lines.push(`- Block in ${b.path}: ${BLOCK_STATE[b.state](b)}`);
284
+ for (const l of integration.legacy)
285
+ lines.push(`- ${legacyLine(l.path, l.line)}, from Hafez 1.x, with no markers`);
286
+ for (const s of integration.skills) {
287
+ const lost = s.state === 'hand-edited' ? ' — `onboard --install` replaces it, and the edit is lost' : '';
288
+ lines.push(`- ${s.path}: ${s.state}${lost}`);
289
+ }
290
+ return lines;
291
+ }
226
292
  export function renderUpgrade(state) {
227
293
  const out = [
228
294
  '# Hafez — Upgrade Check',
@@ -240,16 +306,7 @@ export function renderUpgrade(state) {
240
306
  out.push('Install source: development checkout (this package has a src/ directory) — upgrade with git, not npm.');
241
307
  }
242
308
  out.push('');
243
- out.push(state.skill === 'present'
244
- ? `Skill: installed at ${state.skillTarget}. The skill ships inside the hafez package, so an upgraded package leaves this copy stale — the re-copy step is idempotent, run it either way.`
245
- : `Skill: none at ${state.skillTarget} — nothing to re-copy (a non-Claude-Code harness, not drift).`);
246
- out.push('');
247
- out.push('Integration snippet — compare this against the Hafez section of the');
248
- out.push("user's instructions file (Claude Code: ~/.claude/CLAUDE.md; any");
249
- out.push('AGENTS.md harness: its global AGENTS.md) and reconcile differences');
250
- out.push('with them. Do not rewrite their file unasked.');
251
- out.push('');
252
- out.push(INTEGRATION_SNIPPET);
309
+ out.push(...integrationLines(state.integration));
253
310
  out.push('');
254
311
  out.push('## Steps');
255
312
  out.push('');
@@ -279,7 +336,8 @@ function defaultDeps() {
279
336
  // The documented escape hatch for private registries and proxied networks.
280
337
  registryUrl: process.env.HAFEZ_REGISTRY_URL || DEFAULT_REGISTRY_URL,
281
338
  packageRoot: join(here, '..', '..'),
282
- installedSkillPath: join(homedir(), '.claude', 'skills', 'hafez', 'SKILL.md'),
339
+ home: homedir(),
340
+ env: process.env,
283
341
  // Bounded for the same reason the registry fetch is: a global prefix on a
284
342
  // network mount or a cold cache can hang npm, and `upgrade` has to stay
285
343
  // answerable when the environment is the thing that is broken. A timeout
@@ -295,6 +353,7 @@ const USAGE = 'Usage: hafez upgrade [--json]';
295
353
  /** `deps` is the third parameter, unreachable from dispatch (PreVaultRun is
296
354
  * `(args, ctx)`) — the tests inject it directly. */
297
355
  export async function cmdUpgrade(args, ctx, deps = defaultDeps()) {
356
+ helpIfAsked(args, USAGE);
298
357
  for (const arg of args) {
299
358
  if (arg.startsWith('--'))
300
359
  throw new UsageError(`Unknown flag ${arg}\n${USAGE}`);
@@ -0,0 +1,8 @@
1
+ export interface SlugCandidate {
2
+ slug: string;
3
+ name: string;
4
+ description: string | null;
5
+ }
6
+ /** Up to five slugs closest to `miss`, closest first. Ties go to the note
7
+ * whose name the miss covers most, then to slug order. */
8
+ export declare function closestSlugs(miss: string, candidates: SlugCandidate[]): string[];
@@ -0,0 +1,65 @@
1
+ // src/closest.ts
2
+ //
3
+ // The closest slugs a `read` miss offers (#91). A miss is usually a slug
4
+ // guessed from a repo folder name, so the words of the miss are matched
5
+ // against each note's name and slug at full weight and its description at
6
+ // half. A note is close when its matched weight covers at least half the
7
+ // words of the miss.
8
+ import { tokenize } from './stopwords.js';
9
+ const LIMIT = 5;
10
+ /** Lowercased words with stopwords dropped and a plural `s` stripped, so
11
+ * `launchpads` and `launchpad` are one word. */
12
+ function words(text) {
13
+ return tokenize(text, { stem: true });
14
+ }
15
+ /** At most one insertion, deletion or substitution apart. */
16
+ function oneEditApart(a, b) {
17
+ if (Math.abs(a.length - b.length) > 1)
18
+ return false;
19
+ let i = 0;
20
+ while (i < a.length && i < b.length && a[i] === b[i])
21
+ i++;
22
+ if (a.length === b.length)
23
+ return a.slice(i + 1) === b.slice(i + 1);
24
+ return a.length > b.length ? a.slice(i + 1) === b.slice(i) : a.slice(i) === b.slice(i + 1);
25
+ }
26
+ /** A word of the miss matches a word of a note when equal, when the miss cut
27
+ * it short (four letters or more), or when one typo apart (five or more). */
28
+ function matches(missWord, word) {
29
+ if (missWord === word)
30
+ return true;
31
+ if (missWord.length >= 4 && word.startsWith(missWord))
32
+ return true;
33
+ return Math.min(missWord.length, word.length) >= 5 && oneEditApart(missWord, word);
34
+ }
35
+ /** Up to five slugs closest to `miss`, closest first. Ties go to the note
36
+ * whose name the miss covers most, then to slug order. */
37
+ export function closestSlugs(miss, candidates) {
38
+ const missWords = words(miss);
39
+ if (missWords.length === 0)
40
+ return [];
41
+ const scored = candidates.map(c => {
42
+ const nameWords = [...new Set([...words(c.name), ...words(c.slug)])];
43
+ const descriptionWords = words(c.description ?? '');
44
+ let weight = 0;
45
+ const covered = new Set();
46
+ for (const m of missWords) {
47
+ const hit = nameWords.filter(w => matches(m, w));
48
+ if (hit.length > 0) {
49
+ weight += 1;
50
+ for (const w of hit)
51
+ covered.add(w);
52
+ }
53
+ else if (descriptionWords.some(w => matches(m, w))) {
54
+ weight += 0.5;
55
+ }
56
+ }
57
+ const coverage = nameWords.length > 0 ? covered.size / nameWords.length : 0;
58
+ return { slug: c.slug, score: weight / missWords.length, coverage };
59
+ });
60
+ return scored
61
+ .filter(s => s.score >= 0.5)
62
+ .sort((a, b) => b.score - a.score || b.coverage - a.coverage || a.slug.localeCompare(b.slug))
63
+ .slice(0, LIMIT)
64
+ .map(s => s.slug);
65
+ }
@@ -1,4 +1,4 @@
1
- import type { KnowledgeSubtype } from './types.js';
1
+ import type { KnowledgeSubtype, UpdateFields } from './types.js';
2
2
  export declare const ENTITY_TYPES: readonly ["capture", "entity", "project"];
3
3
  export declare const ENTITY_STATUSES: readonly ["active", "paused", "done"];
4
4
  export declare const SESSION_LOG_TYPES: readonly ["progress", "decision", "blocker", "research"];
@@ -73,3 +73,19 @@ export declare function unreachableFields(target: DocTarget, fields: Record<stri
73
73
  }>;
74
74
  /** Human label for a target, used in every gate's error text. */
75
75
  export declare function describeTarget(target: DocTarget): string;
76
+ /** Which Footer a document's `read` ends with. Entity and project share one;
77
+ * a capture's is the promote line, since it is sorted before it is written to. */
78
+ export type FooterForm = 'entity' | 'insight' | 'plan' | 'capture';
79
+ export declare function footerForm(target: DocTarget): FooterForm;
80
+ /** The update fields each Footer names, in the order it names them. Every
81
+ * other field that contract's update accepts is in WRITE_LINE_OMITS with its
82
+ * reason; drift check 3 (tests/read-footer.test.ts) holds the two together
83
+ * against the Op Spec update shape and the kind gate. */
84
+ export declare const WRITE_LINE_FIELDS: {
85
+ readonly entity: readonly ["session_log", "complete_action", "remove_action", "add_actions", "brief", "current_state", "related", "parent"];
86
+ readonly insight: readonly ["add_evidence", "add_source", "synthesis", "confidence", "domain", "related"];
87
+ readonly plan: readonly ["status", "domain", "related"];
88
+ };
89
+ export type WriteLineField = (typeof WRITE_LINE_FIELDS)[keyof typeof WRITE_LINE_FIELDS][number];
90
+ /** Update fields a contract accepts that its Footer leaves out, and why. */
91
+ export declare const WRITE_LINE_OMITS: Record<Exclude<FooterForm, 'capture'>, Partial<Record<keyof UpdateFields, string>>>;
package/dist/contracts.js CHANGED
@@ -172,3 +172,49 @@ export function describeTarget(target) {
172
172
  ? `type '${target.type}'`
173
173
  : `knowledge subtype '${target.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}'`;
174
174
  }
175
+ export function footerForm(target) {
176
+ if (target.kind === 'knowledge')
177
+ return target.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE;
178
+ return target.type === 'capture' ? 'capture' : 'entity';
179
+ }
180
+ /** The update fields each Footer names, in the order it names them. Every
181
+ * other field that contract's update accepts is in WRITE_LINE_OMITS with its
182
+ * reason; drift check 3 (tests/read-footer.test.ts) holds the two together
183
+ * against the Op Spec update shape and the kind gate. */
184
+ export const WRITE_LINE_FIELDS = {
185
+ entity: ['session_log', 'complete_action', 'remove_action', 'add_actions', 'brief', 'current_state', 'related', 'parent'],
186
+ insight: ['add_evidence', 'add_source', 'synthesis', 'confidence', 'domain', 'related'],
187
+ plan: ['status', 'domain', 'related'],
188
+ };
189
+ const SET_AT_CREATE = 'set at create, rarely rewritten; `hafez schema update` has it';
190
+ const INSIGHT_ALIAS = 'an alias of synthesis';
191
+ const PLAN_BODY = 'a plan\'s body is Goal, Steps and Dependencies, which no op writes';
192
+ /** Update fields a contract accepts that its Footer leaves out, and why. */
193
+ export const WRITE_LINE_OMITS = {
194
+ entity: {
195
+ status: 'a lifecycle change, not a write-back: the `hafez` skill\'s rare ops carry the status line',
196
+ agent: 'detected from the Harness; the override is never shown',
197
+ add_action: 'the one-line form of add_actions, written by the same flag',
198
+ complete_actions: 'the repeated form of complete_action, written by the same flag',
199
+ remove_actions: 'the repeated form of remove_action, written by the same flag',
200
+ clear_actions: 'drops lines unseen; the Footer drops one line by its Handle',
201
+ description: SET_AT_CREATE,
202
+ resource: SET_AT_CREATE,
203
+ domain: SET_AT_CREATE,
204
+ tags: SET_AT_CREATE,
205
+ },
206
+ insight: {
207
+ insight: INSIGHT_ALIAS,
208
+ description: SET_AT_CREATE,
209
+ tags: SET_AT_CREATE,
210
+ },
211
+ plan: {
212
+ synthesis: PLAN_BODY,
213
+ add_evidence: PLAN_BODY,
214
+ add_source: PLAN_BODY,
215
+ insight: INSIGHT_ALIAS,
216
+ confidence: 'an insight matures by confidence; a plan moves by status',
217
+ description: SET_AT_CREATE,
218
+ tags: SET_AT_CREATE,
219
+ },
220
+ };
package/dist/db.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type VaultIndexRow } from './knowledge-index.js';
2
- import type { SearchResult, VaultStats, QueryResult, KnowledgeQueryResult, UnifiedResult, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts } from './types.js';
3
- export declare const SCHEMA_VERSION = "10";
2
+ import type { SearchResult, VaultStats, QueryResult, KnowledgeQueryResult, UnifiedResult, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, SortField } from './types.js';
3
+ export declare const SCHEMA_VERSION = "11";
4
4
  interface IndexItem {
5
5
  slug: string;
6
6
  kind: 'entity' | 'knowledge';
@@ -90,4 +90,17 @@ export declare function createIndex(vaultPath: string): HafezIndex;
90
90
  export declare function createIndex(vaultPath: string, opts: {
91
91
  readonly: true;
92
92
  }): HafezIndex | null;
93
+ /** Opts a branch cannot map into SQL. Declared per branch and rejected on
94
+ * sight: the CLI has rejected these since 43124ba, but the rule lived only
95
+ * there, so a library caller got the silent drop the CLI had already closed.
96
+ * Asserted against the CLI's flag matrix in query-surface.test.ts. */
97
+ export declare const ENTITY_ONLY_OPTS: readonly ["filter", "type", "parent"];
98
+ export declare const KNOWLEDGE_ONLY_OPTS: readonly ["confidence", "subtype"];
99
+ /** Every opt any branch might carry, flattened. Only the unified read uses it,
100
+ * and only to check: the checks must see an opt the caller's own type forbids,
101
+ * because an untyped caller can still send it. */
102
+ export type UnmappableCheckOpts = Omit<EntityQueryOpts, 'sort_by'> & Omit<KnowledgeQueryOpts, 'sort_by'> & {
103
+ kind?: 'entity' | 'knowledge' | 'all';
104
+ sort_by?: SortField;
105
+ };
93
106
  export {};
package/dist/db.js CHANGED
@@ -7,6 +7,9 @@ import { ensureLocalExclude } from './knowledge-index.js';
7
7
  import { WIKI_LINK_RE, documentForFile } from './document.js';
8
8
  import { ENTITY_STATUSES, ENTITY_TYPES, CONFIDENCE_LEVELS, VALID_SUBTYPES, DEFAULT_KNOWLEDGE_SUBTYPE } from './contracts.js';
9
9
  import { HafezError } from './types.js';
10
+ import { STOPWORDS } from './stopwords.js';
11
+ import { ENTITY_AGE_DAYS_SQL, ENTITY_STALE_SQL } from './stale.js';
12
+ import { localDay } from './dates.js';
10
13
  // Bump this when indexing logic changes to force a full rebuild.
11
14
  // v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
12
15
  // v6: description column on items.
@@ -18,8 +21,10 @@ import { HafezError } from './types.js';
18
21
  // v10: re-indexing an externally changed file is delete-then-insert instead of
19
22
  // INSERT OR REPLACE. Indexes written by v9 and earlier may hold ghost FTS
20
23
  // rows (REPLACE never fired the delete trigger) and must be rebuilt, not reused.
24
+ // v11: next_action and the searchable body store action text with its
25
+ // added-date stamp stripped.
21
26
  // Existing instances will fully rebuild the index on first access after upgrade.
22
- export const SCHEMA_VERSION = '10';
27
+ export const SCHEMA_VERSION = '11';
23
28
  const SCHEMA = `
24
29
  CREATE TABLE IF NOT EXISTS items (
25
30
  slug TEXT PRIMARY KEY,
@@ -79,18 +84,6 @@ CREATE TABLE IF NOT EXISTS meta (
79
84
  value TEXT NOT NULL
80
85
  );
81
86
  `;
82
- const STOPWORDS = new Set([
83
- 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
84
- 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
85
- 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
86
- 'may', 'might', 'can', 'shall', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
87
- 'that', 'this', 'these', 'those', 'it', 'its', 'my', 'your', 'his', 'her',
88
- 'our', 'their', 'what', 'which', 'who', 'whom', 'how', 'when', 'where', 'why',
89
- 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'some', 'any', 'such',
90
- 'about', 'into', 'over', 'after', 'before',
91
- 'work', 'works', 'working', 'use', 'using', 'used', 'get', 'got', 'make', 'made',
92
- 'need', 'want', 'like', 'just', 'also', 'still', 'already', 'very', 'much',
93
- ]);
94
87
  function hashContent(content) {
95
88
  return createHash('sha256').update(content).digest('hex');
96
89
  }
@@ -213,13 +206,8 @@ function queryItemsFn(db, opts) {
213
206
  if (opts.filter === 'stale') {
214
207
  conditions.push("i.kind = 'entity'");
215
208
  conditions.push("i.status = 'active'");
216
- conditions.push(`(
217
- julianday('now') - julianday(i.last_touched) >
218
- CASE
219
- WHEN i.staleness_days IS NOT NULL THEN i.staleness_days
220
- ELSE 14
221
- END
222
- )`);
209
+ conditions.push(`(${ENTITY_STALE_SQL})`);
210
+ params.today = localDay();
223
211
  }
224
212
  if (opts.filter === 'capture') {
225
213
  conditions.push("i.type = 'capture'");
@@ -256,14 +244,13 @@ function queryItemsFn(db, opts) {
256
244
  assertCount(opts.offset, 'offset');
257
245
  let sortCol = 'i.last_touched';
258
246
  if (opts.sort_by) {
259
- const isKnowledgeOnly = KNOWLEDGE_ONLY_SORTS.has(opts.sort_by);
260
- const isEntityQuery = opts.kind === 'entity';
261
- if (isKnowledgeOnly && isEntityQuery) {
262
- sortCol = 'i.last_touched';
247
+ // Same rule as the unmappable opts, one code path over: a sort field the
248
+ // branch cannot honour is rejected, not quietly swapped for recency.
249
+ if (KNOWLEDGE_ONLY_SORTS.has(opts.sort_by) && opts.kind === 'entity') {
250
+ outOfScope('sort_by', opts.sort_by, 'entity');
263
251
  }
264
- else if (SORT_COL_MAP[opts.sort_by]) {
252
+ if (SORT_COL_MAP[opts.sort_by])
265
253
  sortCol = SORT_COL_MAP[opts.sort_by];
266
- }
267
254
  }
268
255
  let sortOrder;
269
256
  if (opts.sort_order) {
@@ -379,16 +366,11 @@ function getStatsFn(db) {
379
366
  by_type[row.type] = row.cnt;
380
367
  }
381
368
  const stale = db.prepare(`
382
- SELECT slug, name, type, CAST(julianday('now') - julianday(last_touched) AS INTEGER) as days_since_touched
383
- FROM items
384
- WHERE kind = 'entity' AND status = 'active'
385
- AND julianday('now') - julianday(last_touched) >
386
- CASE
387
- WHEN staleness_days IS NOT NULL THEN staleness_days
388
- ELSE 14
389
- END
369
+ SELECT slug, name, type, ${ENTITY_AGE_DAYS_SQL} as days_since_touched
370
+ FROM items i
371
+ WHERE kind = 'entity' AND status = 'active' AND ${ENTITY_STALE_SQL}
390
372
  ORDER BY days_since_touched DESC
391
- `).all();
373
+ `).all({ today: localDay() });
392
374
  const no_next_action = db.prepare(`
393
375
  SELECT slug, name, type FROM items
394
376
  WHERE kind = 'entity' AND status = 'active' AND next_action_count = 0
@@ -627,7 +609,8 @@ export function createIndex(vaultPath, opts) {
627
609
  last_reinforced: fm['last-reinforced'] ?? null,
628
610
  created: fm.created,
629
611
  last_touched: fm['last-touched'] ?? null,
630
- body,
612
+ // Search sees action text, not the added-date stamp (file format only).
613
+ body: doc.stripNextActionStamps(body),
631
614
  file_hash: hash,
632
615
  subtype: kind === 'knowledge' ? (fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE) : null,
633
616
  description: fm.description ?? null,
@@ -878,6 +861,27 @@ function validateEntityFilterOpts(opts) {
878
861
  assertEnum(opts?.filter, QUERY_FILTERS, 'filter');
879
862
  assertEnum(opts?.type, ENTITY_TYPES, 'type');
880
863
  }
864
+ /** Opts a branch cannot map into SQL. Declared per branch and rejected on
865
+ * sight: the CLI has rejected these since 43124ba, but the rule lived only
866
+ * there, so a library caller got the silent drop the CLI had already closed.
867
+ * Asserted against the CLI's flag matrix in query-surface.test.ts. */
868
+ export const ENTITY_ONLY_OPTS = ['filter', 'type', 'parent'];
869
+ export const KNOWLEDGE_ONLY_OPTS = ['confidence', 'subtype'];
870
+ /** The one sentence both scope rejections speak — opts here, sort fields in
871
+ * queryItemsFn — so a caller sees the same shape whichever one they hit. */
872
+ function outOfScope(label, name, branch) {
873
+ const belongsTo = branch === 'entity' ? 'knowledge notes only' : 'entities only';
874
+ throw new HafezError('VALIDATION_FAILED', `Invalid ${label} for a ${branch} query: "${name}" — ${belongsTo}. Use kind: 'all' to query both.`);
875
+ }
876
+ function rejectUnmappableOpts(opts, reject, branch) {
877
+ if (!opts)
878
+ return;
879
+ // Only a *defined* value is a request — an explicit `undefined` (what a
880
+ // spread of unset CLI flags produces) asks for nothing.
881
+ for (const name of reject)
882
+ if (opts[name] !== undefined)
883
+ outOfScope('opt', name, branch);
884
+ }
881
885
  function validateKnowledgeFilterOpts(opts) {
882
886
  assertEnum(opts?.confidence, CONFIDENCE_LEVELS, 'confidence');
883
887
  assertEnum(opts?.subtype, VALID_SUBTYPES, 'subtype');
@@ -885,7 +889,9 @@ function validateKnowledgeFilterOpts(opts) {
885
889
  function queryEntitiesFn(index, opts) {
886
890
  index.syncIfStale();
887
891
  validateEntityFilterOpts(opts);
888
- validateKnowledgeFilterOpts(opts);
892
+ // Was validateKnowledgeFilterOpts — it checked the *value* of an opt this
893
+ // branch then discarded, so a valid-looking one returned unfiltered.
894
+ rejectUnmappableOpts(opts, KNOWLEDGE_ONLY_OPTS, 'entity');
889
895
  validateTimeOpts(opts);
890
896
  validateListOpts(opts);
891
897
  const qopts = { kind: 'entity' };
@@ -908,6 +914,7 @@ function queryRelatedToFn(index, slug, relation) {
908
914
  function queryKnowledgeFn(index, opts) {
909
915
  index.syncIfStale();
910
916
  validateKnowledgeFilterOpts(opts);
917
+ rejectUnmappableOpts(opts, ENTITY_ONLY_OPTS, 'knowledge');
911
918
  validateTimeOpts(opts);
912
919
  validateListOpts(opts);
913
920
  const qopts = { kind: 'knowledge' };
@@ -919,19 +926,33 @@ function queryKnowledgeFn(index, opts) {
919
926
  }
920
927
  function queryUnifiedFn(index, opts) {
921
928
  index.syncIfStale();
922
- assertEnum(opts?.kind, ['entity', 'knowledge', 'all'], 'kind');
923
- validateEntityFilterOpts(opts);
924
- validateKnowledgeFilterOpts(opts);
925
- validateTimeOpts(opts);
926
- validateListOpts(opts);
929
+ // The public type is a union discriminated on `kind`, so an out-of-scope opt
930
+ // is a compile error. The runtime still has to see every opt to reject it —
931
+ // an untyped caller (JS, JSON over MCP) reaches here regardless — so widen
932
+ // once, here, and let the checks below speak for both.
933
+ const o = opts;
934
+ assertEnum(o?.kind, ['entity', 'knowledge', 'all'], 'kind');
935
+ validateEntityFilterOpts(o);
936
+ validateKnowledgeFilterOpts(o);
937
+ // Only `kind: 'all'` (or unset) maps both sides. `kind` also narrows this
938
+ // query to one of them, and a narrowed branch maps no more than the
939
+ // dedicated one does: the out-of-scope conditions land on columns the
940
+ // selected kind leaves NULL, so every row is excluded and the caller gets
941
+ // 0 results with exit 0 — the same wrong answer, one function over.
942
+ if (o?.kind === 'entity')
943
+ rejectUnmappableOpts(o, KNOWLEDGE_ONLY_OPTS, 'entity');
944
+ if (o?.kind === 'knowledge')
945
+ rejectUnmappableOpts(o, ENTITY_ONLY_OPTS, 'knowledge');
946
+ validateTimeOpts(o);
947
+ validateListOpts(o);
927
948
  const qopts = {};
928
- if (opts?.kind && opts.kind !== 'all')
929
- qopts.kind = opts.kind;
949
+ if (o?.kind && o.kind !== 'all')
950
+ qopts.kind = o.kind;
930
951
  // When kind is 'all' or unset, no kind filter — both entities and knowledge are returned
931
- mapEntityFilterOpts(qopts, opts);
932
- mapKnowledgeFilterOpts(qopts, opts);
933
- mapTimeOpts(qopts, opts);
934
- mapListOpts(qopts, opts);
952
+ mapEntityFilterOpts(qopts, o);
953
+ mapKnowledgeFilterOpts(qopts, o);
954
+ mapTimeOpts(qopts, o);
955
+ mapListOpts(qopts, o);
935
956
  const result = index.queryItems(qopts);
936
957
  const items = result.items.map(i => {
937
958
  if (i.kind === 'entity') {
package/dist/digest.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { z } from 'zod';
2
- import type { BatchOperation } from './types.js';
2
+ import { type BatchOperation } from './types.js';
3
3
  export declare const DigestInputSchema: z.ZodObject<{
4
4
  entities_touched: z.ZodArray<z.ZodString>;
5
5
  decisions: z.ZodDefault<z.ZodArray<z.ZodString>>;
6
6
  narrative: z.ZodString;
7
7
  session_date: z.ZodString;
8
- agent: z.ZodDefault<z.ZodString>;
8
+ agent: z.ZodOptional<z.ZodString>;
9
9
  }, z.core.$strip>;
10
10
  export type DigestInput = z.infer<typeof DigestInputSchema>;
11
11
  /**
@@ -24,6 +24,8 @@ export declare function parseDigestInput(raw: unknown): {
24
24
  /**
25
25
  * Convert session context into a hafez batch payload.
26
26
  * Pure function — no file I/O, no vault access.
27
- * Unknown slugs in entities_touched are silently omitted (caller should warn).
27
+ * An unknown slug in entities_touched is a NOT_FOUND naming it (and only the
28
+ * unknown ones), never a session log that points nowhere. A knowledge slug is
29
+ * known: the note links it, but a session log is entity-only.
28
30
  */
29
- export declare function digest(input: DigestInput, existingSlugs: Set<string>, now?: Date): BatchOperation[];
31
+ export declare function digest(input: DigestInput, entitySlugs: Set<string>, knowledgeSlugs?: Set<string>): BatchOperation[];