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,351 @@
1
+ // src/cli/upgrade.ts
2
+ //
3
+ // `hafez upgrade` — agent-directed update check. It CHECKS AND PRINTS; it
4
+ // never installs. The printed steps install code and overwrite the user's
5
+ // agent instructions, so the privileged step is the human's — same discipline
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
8
+ // (`npm publish` is the user's action). It also sidesteps a process replacing
9
+ // its own binary mid-run.
10
+ //
11
+ // `pre-vault` on purpose: it must still work when the vault is unresolvable
12
+ // or the native SQLite binding is unbuilt, which is exactly when someone is
13
+ // trying to upgrade. Nothing here touches the core.
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
+ //
20
+ // Untrusted input: the registry response is rendered into a document an agent
21
+ // will execute commands from. Only the `version` field is read, only after it
22
+ // matches plain X.Y.Z, and no caught error's message, cause, or stack ever
23
+ // reaches stdout — `JSON.parse` embeds a slice of the offending body in its
24
+ // message, so every failure branch renders a fixed string constant.
25
+ import { execFileSync } from 'child_process';
26
+ import { existsSync, readFileSync } from 'fs';
27
+ import { homedir } from 'os';
28
+ import { dirname, join } from 'path';
29
+ import { fileURLToPath } from 'url';
30
+ import { UsageError, helpIfAsked } from './args.js';
31
+ import { HARNESSES, detectAgent, isHarness } from '../harness.js';
32
+ import { checkInstall, fixBrokenMarkers } from './install.js';
33
+ const PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
34
+ const REGISTRY_TIMEOUT_MS = 3000;
35
+ const NPM_PROBE_TIMEOUT_MS = 3000;
36
+ const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/hafez/latest';
37
+ const DEV_CHECKOUT_WORK = 'git pull && npm run build && npm run install:local';
38
+ const NPM_PROBE_NOTE = 'The local npm version could not be determined, so every install flag is included above. ' +
39
+ 'npm majors that predate a flag print `npm warn Unknown cli config` and proceed — that warning is harmless.';
40
+ const ETARGET_NOTE = 'If the install fails with ETARGET, your configured registry (a mirror or proxy) does not have the pinned version — ' +
41
+ 'append `--registry=https://registry.npmjs.org` to the install command.';
42
+ const VERIFY_NOTE = 'Re-run `hafez upgrade` to verify.';
43
+ /** Three-way compare on the numeric segments. Hand-rolled string compare is
44
+ * the trap this exists to avoid: `1.10.0` < `1.9.0` lexicographically.
45
+ * Anything that is not plain X.Y.Z (a prerelease, a build tag) is `unknown` —
46
+ * this tool never guesses at a version it cannot order. */
47
+ export function compareVersions(local, latest) {
48
+ if (!PLAIN_SEMVER.test(local) || !PLAIN_SEMVER.test(latest))
49
+ return 'unknown';
50
+ const a = local.split('.').map(Number);
51
+ const b = latest.split('.').map(Number);
52
+ for (let i = 0; i < 3; i++) {
53
+ if (a[i] < b[i])
54
+ return 'behind';
55
+ if (a[i] > b[i])
56
+ return 'ahead';
57
+ }
58
+ return 'equal';
59
+ }
60
+ /** A path that cannot be made safe by single-quoting it. Spaces, `$(`, and
61
+ * globs are all inert inside single quotes; a quote, newline, or control
62
+ * character is not. */
63
+ function unquotable(path) {
64
+ return /['\u0000-\u001f]/.test(path);
65
+ }
66
+ function exists(path) {
67
+ try {
68
+ return existsSync(path);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ /** The installed version, from our own package.json — trusted input, so it is
75
+ * echoed as-is. A non-plain version (a dev prerelease) still prints; it just
76
+ * makes the comparison `unknown`. */
77
+ function readLocalVersion(packageRoot) {
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'));
80
+ return typeof parsed?.version === 'string' && parsed.version.length > 0 ? parsed.version : 'unknown';
81
+ }
82
+ catch {
83
+ return 'unknown';
84
+ }
85
+ }
86
+ /** Reachability and parseability are different facts, so they get different
87
+ * statuses: no usable answer at all (transport failure, timeout, non-2xx, or
88
+ * an unparseable body) is `reachable: false`; a parsed answer whose `version`
89
+ * is not plain X.Y.Z is `reachable: true` with a null latest. Both print no
90
+ * install command — there is no verified version to pin. The `reachable:
91
+ * false` message covers the mirror-404 case too, so it does not claim the
92
+ * user is offline when the registry answered. */
93
+ async function checkRegistry(deps) {
94
+ try {
95
+ const res = await deps.fetch(deps.registryUrl, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) });
96
+ if (!res.ok)
97
+ return { latest: null, reachable: false };
98
+ const body = (await res.json());
99
+ const version = body?.version;
100
+ if (typeof version !== 'string' || !PLAIN_SEMVER.test(version))
101
+ return { latest: null, reachable: true };
102
+ return { latest: version, reachable: true };
103
+ }
104
+ catch {
105
+ // Deliberately discards the caught value: its message can carry a slice of
106
+ // the response body straight into an agent's execution context.
107
+ return { latest: null, reachable: false };
108
+ }
109
+ }
110
+ function probeNpmMajor(deps) {
111
+ try {
112
+ const major = Number(deps.npmVersion().trim().split('.')[0]);
113
+ return Number.isInteger(major) && major > 0 ? major : null;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ /** The dev-checkout upgrade runs git and npm scripts, so it MUST name its
120
+ * directory: an agent's cwd is the user's own project, not this checkout, and
121
+ * a bare `git pull && npm run build` would upgrade whatever repo it happens to
122
+ * be standing in. */
123
+ function devCheckoutStep(packageRoot) {
124
+ if (unquotable(packageRoot)) {
125
+ return {
126
+ kind: 'action',
127
+ text: `Upgrade the development checkout at ${packageRoot} by hand — its path contains characters that single-quoting cannot make safe. In that directory run: ${DEV_CHECKOUT_WORK}`,
128
+ };
129
+ }
130
+ return { kind: 'command', text: `cd '${packageRoot}' && ${DEV_CHECKOUT_WORK}` };
131
+ }
132
+ function readIntegration(home) {
133
+ try {
134
+ const { skills, blocks } = checkInstall(home);
135
+ return { mode: blocks.length > 0 ? 'always-on' : 'invoke-only', blocks, skills };
136
+ }
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 => ({
154
+ kind: 'action',
155
+ text: fixBrokenMarkers(b.path, 'hafez upgrade'),
156
+ }));
157
+ }
158
+ const { blocks, skills } = integration;
159
+ const skillDrift = skills.some(s => s.state === 'stale' || s.state === 'hand-edited');
160
+ const blockDrift = (b) => b.state === 'stale' || (b.state === 'hand-edited' && b.renderChanged);
161
+ const installed = blocks.length > 0 || skills.some(s => s.state !== 'absent');
162
+ if (!installed || (!upgrading && !skillDrift && !blocks.some(blockDrift)))
163
+ return [];
164
+ if (blocks.length > 0) {
165
+ // Skills alone: any Block's run writes them, so pick one that run will not
166
+ // stop on to ask about a hand-edit the status says needs nothing.
167
+ const skillsOnly = [blocks.find(b => b.state !== 'hand-edited') ?? blocks[0]];
168
+ const targets = upgrading ? blocks : blocks.filter(blockDrift);
169
+ return (targets.length > 0 ? targets : skillsOnly).map(b => ({
170
+ kind: 'command',
171
+ text: `hafez onboard --install --mode always-on --harness ${b.harness}`,
172
+ }));
173
+ }
174
+ const harness = detectAgent(env);
175
+ if (isHarness(harness))
176
+ return [{ kind: 'command', text: `hafez onboard --install --mode invoke-only --harness ${harness}` }];
177
+ return [{
178
+ kind: 'action',
179
+ text: `Run \`hafez onboard --install --mode invoke-only --harness <${HARNESS_IDS}>\`, naming the harness you are running in.`,
180
+ }];
181
+ }
182
+ /** The action list, in execution order. Every command is built from static
183
+ * constants; the only interpolated values are the regex-validated version,
184
+ * the quoted package root, and harness ids from the HARNESSES table. */
185
+ function deriveSteps(state, ctx) {
186
+ const steps = [];
187
+ let alternativeInstall = null;
188
+ const upgrading = state.comparison === 'behind' && state.latest !== null;
189
+ if (upgrading) {
190
+ if (state.install === 'dev-checkout') {
191
+ // A contributor whose ~/.local/bin/hafez symlinks into a source tree
192
+ // would have the symlink shadow any global install forever — and the
193
+ // mandatory re-run would report `behind` forever with it.
194
+ steps.push(devCheckoutStep(ctx.packageRoot));
195
+ }
196
+ else {
197
+ // Pinned, never `@latest`: an unpinned command resolves at install time
198
+ // and can install something other than what was just verified.
199
+ const scripts = state.npmMajor === null || state.npmMajor >= 11 ? ' --allow-scripts=better-sqlite3' : '';
200
+ const remote = state.npmMajor === null || state.npmMajor >= 12 ? ' --allow-remote=root' : '';
201
+ steps.push({ kind: 'command', text: `npm install -g hafez@${state.latest}${scripts}` });
202
+ // Same package, same install scripts — the tarball needs the scripts flag too.
203
+ alternativeInstall =
204
+ `npm install -g https://github.com/shrvn-r/hafez/releases/download/v${state.latest}/hafez.tgz${scripts}${remote}`;
205
+ if (state.npmMajor === null)
206
+ steps.push({ kind: 'action', text: NPM_PROBE_NOTE });
207
+ steps.push({ kind: 'action', text: ETARGET_NOTE });
208
+ }
209
+ }
210
+ steps.push(...integrationSteps(state.integration, upgrading, ctx.env));
211
+ // A SCHEMA_VERSION bump does not self-heal: the read-only path warns and
212
+ // returns null, so the first post-upgrade query answers *empty* until a
213
+ // write command or an explicit rebuild.
214
+ if (upgrading)
215
+ steps.push({ kind: 'command', text: 'hafez index rebuild' });
216
+ if (steps.length > 0)
217
+ steps.push({ kind: 'action', text: VERIFY_NOTE });
218
+ return { steps, alternativeInstall };
219
+ }
220
+ export async function buildUpgradeState(deps) {
221
+ const current = readLocalVersion(deps.packageRoot);
222
+ const registry = await checkRegistry(deps);
223
+ const base = {
224
+ current,
225
+ latest: registry.latest,
226
+ comparison: registry.latest === null ? 'unknown' : compareVersions(current, registry.latest),
227
+ registryReachable: registry.reachable,
228
+ npmMajor: probeNpmMajor(deps),
229
+ // Published tarballs carry only dist, skill, README.md and LICENSE.
230
+ install: exists(join(deps.packageRoot, 'src')) ? 'dev-checkout' : 'registry',
231
+ integration: readIntegration(deps.home),
232
+ };
233
+ return { ...base, ...deriveSteps(base, { packageRoot: deps.packageRoot, env: deps.env }) };
234
+ }
235
+ function versionLines(state) {
236
+ const lines = [`Installed version: ${state.current}`];
237
+ if (state.latest === null) {
238
+ lines.push(state.registryReachable
239
+ ? "Published version: unknown — the registry's answer is not a plain X.Y.Z. Check manually at https://www.npmjs.com/package/hafez."
240
+ : "Published version: couldn't check (offline?) — the registry gave no usable answer. If HAFEZ_REGISTRY_URL points at a mirror, it may simply not carry hafez. Nothing has changed.");
241
+ return lines;
242
+ }
243
+ lines.push(`Published version: ${state.latest}`);
244
+ if (state.comparison === 'behind')
245
+ lines.push('Your install is BEHIND the published version — steps below.');
246
+ else if (state.comparison === 'equal')
247
+ lines.push('Your install is current. Checked against the npm registry only — a just-cut GitHub Release may not be published yet.');
248
+ else if (state.comparison === 'ahead')
249
+ lines.push('Local build is ahead of the published version — nothing to do.');
250
+ else
251
+ lines.push('Could not compare: the installed version is not a plain X.Y.Z. Check manually.');
252
+ return lines;
253
+ }
254
+ const BLOCK_STATE = {
255
+ current: () => 'current',
256
+ stale: () => 'stale',
257
+ 'hand-edited': b => b.renderChanged
258
+ ? "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"
259
+ : 'hand-edited, over the current Block — nothing to update',
260
+ broken: () => 'broken markers',
261
+ };
262
+ function integrationLines(integration) {
263
+ if (integration === null)
264
+ return ['Skills and Block: could not be read.'];
265
+ const lines = integration.mode === 'always-on'
266
+ ? ['Mode: always-on (a Block is installed).']
267
+ : [
268
+ 'Mode: invoke-only (no Block in any instructions file Hafez writes); installing a Block is not offered.',
269
+ "A Block pasted into Cursor's User Rules lives in its Settings and is not checked here.",
270
+ ];
271
+ for (const b of integration.blocks)
272
+ lines.push(`- Block in ${b.path}: ${BLOCK_STATE[b.state](b)}`);
273
+ for (const s of integration.skills) {
274
+ const lost = s.state === 'hand-edited' ? ' — `onboard --install` replaces it, and the edit is lost' : '';
275
+ lines.push(`- ${s.path}: ${s.state}${lost}`);
276
+ }
277
+ return lines;
278
+ }
279
+ export function renderUpgrade(state) {
280
+ const out = [
281
+ '# Hafez — Upgrade Check',
282
+ '',
283
+ 'You are an agent reading this on behalf of your user. This command checks',
284
+ 'and prints — it never installs anything. The steps below install code and',
285
+ "overwrite the user's agent instructions, so show them verbatim, get an",
286
+ 'explicit yes, and only then run them. Never run them unattended.',
287
+ '',
288
+ '## Status',
289
+ '',
290
+ ...versionLines(state),
291
+ ];
292
+ if (state.install === 'dev-checkout') {
293
+ out.push('Install source: development checkout (this package has a src/ directory) — upgrade with git, not npm.');
294
+ }
295
+ out.push('');
296
+ out.push(...integrationLines(state.integration));
297
+ out.push('');
298
+ out.push('## Steps');
299
+ out.push('');
300
+ if (state.steps.length === 0) {
301
+ out.push('Nothing to do.');
302
+ return out.join('\n') + '\n';
303
+ }
304
+ out.push('In this order, after the user approves:');
305
+ out.push('');
306
+ state.steps.forEach((step, i) => {
307
+ out.push(`${i + 1}. [${step.kind}] ${step.text}`);
308
+ });
309
+ if (state.alternativeInstall !== null) {
310
+ out.push('');
311
+ out.push('Alternative to the npm install above — ONLY if hafez was installed from');
312
+ out.push('the GitHub release tarball rather than the npm registry. Run one or the');
313
+ out.push('other, never both:');
314
+ out.push('');
315
+ out.push(` ${state.alternativeInstall}`);
316
+ }
317
+ return out.join('\n') + '\n';
318
+ }
319
+ function defaultDeps() {
320
+ const here = dirname(fileURLToPath(import.meta.url));
321
+ return {
322
+ fetch: (...args) => globalThis.fetch(...args),
323
+ // The documented escape hatch for private registries and proxied networks.
324
+ registryUrl: process.env.HAFEZ_REGISTRY_URL || DEFAULT_REGISTRY_URL,
325
+ packageRoot: join(here, '..', '..'),
326
+ home: homedir(),
327
+ env: process.env,
328
+ // Bounded for the same reason the registry fetch is: a global prefix on a
329
+ // network mount or a cold cache can hang npm, and `upgrade` has to stay
330
+ // answerable when the environment is the thing that is broken. A timeout
331
+ // kills the probe, which degrades to npmMajor: null and every flag.
332
+ npmVersion: () => execFileSync('npm', ['--version'], {
333
+ encoding: 'utf-8',
334
+ stdio: ['ignore', 'pipe', 'ignore'],
335
+ timeout: NPM_PROBE_TIMEOUT_MS,
336
+ }),
337
+ };
338
+ }
339
+ const USAGE = 'Usage: hafez upgrade [--json]';
340
+ /** `deps` is the third parameter, unreachable from dispatch (PreVaultRun is
341
+ * `(args, ctx)`) — the tests inject it directly. */
342
+ export async function cmdUpgrade(args, ctx, deps = defaultDeps()) {
343
+ helpIfAsked(args, USAGE);
344
+ for (const arg of args) {
345
+ if (arg.startsWith('--'))
346
+ throw new UsageError(`Unknown flag ${arg}\n${USAGE}`);
347
+ throw new UsageError(`upgrade takes no arguments\n${USAGE}`);
348
+ }
349
+ const state = await buildUpgradeState(deps);
350
+ return ctx.json ? JSON.stringify(state, null, 2) : renderUpgrade(state);
351
+ }
@@ -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,3 +1,9 @@
1
+ import type { KnowledgeSubtype, UpdateFields } from './types.js';
2
+ export declare const ENTITY_TYPES: readonly ["capture", "entity", "project"];
3
+ export declare const ENTITY_STATUSES: readonly ["active", "paused", "done"];
4
+ export declare const SESSION_LOG_TYPES: readonly ["progress", "decision", "blocker", "research"];
5
+ export declare const CONFIDENCE_LEVELS: readonly ["observation", "pattern", "principle"];
6
+ export declare const VALID_SUBTYPES: readonly ["insight", "plan"];
1
7
  export interface TypeContract {
2
8
  type: string;
3
9
  description: string;
@@ -12,12 +18,10 @@ export interface TypeContract {
12
18
  export declare const ALL_CONTRACTS: TypeContract[];
13
19
  export declare function getContract(type: string): TypeContract;
14
20
  export declare function hasCapability(type: string, section: string): boolean;
15
- import type { KnowledgeSubtype } from './types.js';
16
- export declare const ENTITY_TYPES: readonly ["capture", "entity", "project"];
17
- export declare const ENTITY_STATUSES: readonly ["active", "paused", "done"];
18
- export declare const SESSION_LOG_TYPES: readonly ["progress", "decision", "blocker", "research"];
19
- export declare const CONFIDENCE_LEVELS: readonly ["observation", "pattern", "principle"];
20
- export declare const VALID_SUBTYPES: readonly ["insight", "plan"];
21
+ /** A knowledge note with no persisted subtype is an insight — the one place
22
+ * that default lives. Every consumer (templates, index rows, exports) reads
23
+ * it from here so classification can't drift between surfaces. */
24
+ export declare const DEFAULT_KNOWLEDGE_SUBTYPE: "insight";
21
25
  export declare const ALIAS_KEYS: {
22
26
  readonly 'update.fields.insight': "synthesis";
23
27
  readonly 'create.knowledge.fields.insight': "synthesis";
@@ -30,3 +34,58 @@ export declare const APPEND_ONLY_SECTIONS: ReadonlySet<string>;
30
34
  /** Session-file sections. Sessions have no TypeContract (they are not typed
31
35
  * entities), but their headings are still structural for Document splitting. */
32
36
  export declare const SESSION_SECTIONS: readonly ["Summary", "Entities Touched", "Decisions"];
37
+ /** Statuses a plan-subtype knowledge note may carry. Plans are tracked work
38
+ * with a lifecycle; the file validator and the mutation gate read this one
39
+ * tuple so they cannot drift. */
40
+ export declare const PLAN_STATUSES: readonly ["active", "done"];
41
+ /** Statuses by knowledge subtype. An empty list means `status` is not part of
42
+ * that subtype's contract at all — mutations setting it are rejected. */
43
+ export declare const SUBTYPE_STATUSES: Record<KnowledgeSubtype, readonly string[]>;
44
+ /** What a mutation is aimed at — enough to answer "does this document's
45
+ * contract have that field / that section?". Knowledge capabilities are
46
+ * subtype-level (plans and insights have different bodies and lifecycles),
47
+ * entity capabilities are type-level. */
48
+ export type DocTarget = {
49
+ kind: 'entity';
50
+ type: string;
51
+ } | {
52
+ kind: 'knowledge';
53
+ subtype?: KnowledgeSubtype;
54
+ };
55
+ /** Sections a document of this shape can carry — the one answer to "would
56
+ * text written into section X survive on this document?". */
57
+ export declare function sectionsFor(target: DocTarget): string[];
58
+ /** Statuses this document may carry. Empty ⇒ `status` is not a field of its
59
+ * contract, so setting it is a validation error. */
60
+ export declare function statusesFor(target: DocTarget): readonly string[];
61
+ /** Create-op fields that write their text into a named body section. The
62
+ * chosen template decides whether that section exists; when it doesn't, the
63
+ * text has nowhere to land, so the op is rejected (see `unreachableFields`).
64
+ * Update-op content fields are absent on purpose — `setSection` /
65
+ * `appendToSection` create a missing section, so update never loses text. */
66
+ export declare const CREATE_FIELD_SECTIONS: Record<string, string>;
67
+ /** Fields present in `fields` whose target section this document cannot hold.
68
+ * Policy is reject-never-route: text is never silently dropped and never
69
+ * redirected into a section the caller did not name. */
70
+ export declare function unreachableFields(target: DocTarget, fields: Record<string, unknown>): Array<{
71
+ field: string;
72
+ section: string;
73
+ }>;
74
+ /** Human label for a target, used in every gate's error text. */
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>>>;