hafez 2.2.0 → 2.3.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 (67) hide show
  1. package/README.md +48 -1
  2. package/dist/apply.d.ts +12 -0
  3. package/dist/apply.js +82 -17
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +80 -15
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +3 -3
  8. package/dist/cli/commands.d.ts +2 -0
  9. package/dist/cli/commands.js +98 -46
  10. package/dist/cli/format.d.ts +4 -1
  11. package/dist/cli/format.js +19 -0
  12. package/dist/cli/index.js +11 -2
  13. package/dist/cli/upgrade.js +1 -1
  14. package/dist/contracts.d.ts +9 -0
  15. package/dist/contracts.js +9 -0
  16. package/dist/dates.d.ts +3 -0
  17. package/dist/dates.js +9 -0
  18. package/dist/db.d.ts +34 -4
  19. package/dist/db.js +163 -21
  20. package/dist/digest.js +14 -7
  21. package/dist/document.d.ts +2 -0
  22. package/dist/document.js +2 -2
  23. package/dist/git.d.ts +13 -2
  24. package/dist/git.js +47 -5
  25. package/dist/graph/activity.d.ts +5 -0
  26. package/dist/graph/activity.js +70 -0
  27. package/dist/graph/client.js +326 -0
  28. package/dist/graph/copy-fonts.d.ts +1 -0
  29. package/dist/graph/copy-fonts.js +13 -0
  30. package/dist/graph/fields.d.ts +8 -0
  31. package/dist/graph/fields.js +12 -0
  32. package/dist/graph/fonts/geist-OFL.txt +93 -0
  33. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  34. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  35. package/dist/graph/fonts/geist.woff2 +0 -0
  36. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  37. package/dist/graph/fonts/outfit.woff2 +0 -0
  38. package/dist/graph/fonts.d.ts +14 -0
  39. package/dist/graph/fonts.js +17 -0
  40. package/dist/graph/live.d.ts +16 -0
  41. package/dist/graph/live.js +99 -0
  42. package/dist/graph/open.d.ts +1 -0
  43. package/dist/graph/open.js +20 -0
  44. package/dist/graph/server.d.ts +17 -0
  45. package/dist/graph/server.js +189 -0
  46. package/dist/health.d.ts +6 -0
  47. package/dist/health.js +310 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +37 -9
  50. package/dist/internal.d.ts +21 -0
  51. package/dist/internal.js +9 -0
  52. package/dist/read-json.d.ts +26 -0
  53. package/dist/read-json.js +53 -0
  54. package/dist/stale.d.ts +2 -2
  55. package/dist/stale.js +4 -9
  56. package/dist/surfaces/items.d.ts +44 -0
  57. package/dist/surfaces/items.js +68 -9
  58. package/dist/surfaces/surfaces.js +3 -1
  59. package/dist/templates.d.ts +2 -0
  60. package/dist/templates.js +6 -1
  61. package/dist/types.d.ts +178 -8
  62. package/dist/vault.d.ts +9 -2
  63. package/dist/vault.js +10 -0
  64. package/dist/workstream.d.ts +17 -0
  65. package/dist/workstream.js +78 -0
  66. package/package.json +9 -3
  67. package/skill/hafez/SKILL.md +12 -6
@@ -0,0 +1,189 @@
1
+ // src/graph/server.ts
2
+ // The graph server (#251): the Vault's Graph and the page that draws it,
3
+ // served on 127.0.0.1 over node:http. Read-only by construction: every route
4
+ // is a GET, and none of them reaches a write on the Hafez instance.
5
+ import { createServer } from 'http';
6
+ import { readFileSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { HafezError } from '../types.js';
10
+ import { GRAPH_LAYERS } from '../contracts.js';
11
+ import { readJsonOf } from '../read-json.js';
12
+ import { parseSessionLog } from '../document.js';
13
+ import { DEFAULT_POLL_MS, startLive } from './live.js';
14
+ import { FONT_FILES } from './fonts.js';
15
+ import { activity } from './activity.js';
16
+ /** Where the port search starts when no port is asked for. */
17
+ export const DEFAULT_PORT = 4747;
18
+ /** The esbuild bundle of src/graph/client/. From src/graph (tests) and from
19
+ * dist/graph (installed) alike, `../../dist/graph` is the build output. */
20
+ const BUILD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'graph');
21
+ const BUNDLE_PATH = join(BUILD_DIR, 'client.js');
22
+ const PAGE = `<!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <meta name="viewport" content="width=device-width, initial-scale=1">
27
+ <title>Hafez graph</title>
28
+ <link rel="icon" href="data:,">
29
+ </head>
30
+ <body>
31
+ <script src="/client.js"></script>
32
+ </body>
33
+ </html>
34
+ `;
35
+ export async function startGraphServer({ os, port, pollMs = DEFAULT_POLL_MS }) {
36
+ // Every layer: the page holds them all and toggles them locally (#257).
37
+ let graphJson = '';
38
+ /** Each node's name by id, for `/activity`'s rows. */
39
+ let names = new Map();
40
+ /** `/activity`'s body, read on first request after each rebuild. */
41
+ let activityJson;
42
+ // The server trusts its poll (#264, #271): the index syncs here, at start and
43
+ // on each commit the poll sees, never per request. On an instance whose
44
+ // indexSync is 'manual', `/note` then reads the index as the Graph drew it.
45
+ const rebuild = async () => {
46
+ await os.syncIndex();
47
+ const graph = await os.graph({ layers: [...GRAPH_LAYERS] });
48
+ graphJson = JSON.stringify(graph);
49
+ names = new Map(graph.nodes.map(n => [n.id, n.name]));
50
+ activityJson = undefined;
51
+ };
52
+ const live = await startLive(os, pollMs, rebuild);
53
+ await rebuild().catch(async (err) => { await live.stop(); throw err; });
54
+ let bundle;
55
+ // One entry per fixed GET route; a route is added here and nowhere else, but
56
+ // for the four matched before the record: the `/events` stream, `/activity`
57
+ // (built asynchronously), and the `/note/` and `/fonts/` prefixes.
58
+ const routes = {
59
+ '/': () => ({ type: 'text/html', body: PAGE }),
60
+ '/client.js': () => ({ type: 'text/javascript', body: bundle ??= readFileSync(BUNDLE_PATH, 'utf-8') }),
61
+ '/graph': () => ({ type: 'application/json', body: graphJson }),
62
+ };
63
+ // A page on any other origin can reach 127.0.0.1 too; checking the Host
64
+ // header is what keeps DNS rebinding from reading the Vault through it.
65
+ let allowedHosts = new Set();
66
+ const server = createServer((req, res) => {
67
+ if (!allowedHosts.has(req.headers.host ?? ''))
68
+ return send(res, 403, { type: 'text/plain', body: 'Forbidden' });
69
+ if (req.method !== 'GET')
70
+ return send(res, 405, { type: 'text/plain', body: 'Method Not Allowed' }, { Allow: 'GET' });
71
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
72
+ if (path === '/events')
73
+ return live.subscribe(req, res);
74
+ if (path === '/activity')
75
+ return void sendActivity(res);
76
+ if (path.startsWith(NOTE_PREFIX))
77
+ return void sendNote(os, res, path.slice(NOTE_PREFIX.length));
78
+ if (path.startsWith(FONT_PREFIX))
79
+ return sendFont(res, path.slice(FONT_PREFIX.length));
80
+ const route = routes[path];
81
+ if (!route)
82
+ return send(res, 404, { type: 'text/plain', body: 'Not Found' });
83
+ try {
84
+ send(res, 200, route());
85
+ }
86
+ catch (err) {
87
+ send(res, 500, { type: 'text/plain', body: err.message });
88
+ }
89
+ });
90
+ async function sendActivity(res) {
91
+ const body = activityJson ??= activity(os, names).then(rows => JSON.stringify(rows));
92
+ try {
93
+ send(res, 200, { type: 'application/json', body: await body });
94
+ }
95
+ catch (err) {
96
+ if (activityJson === body)
97
+ activityJson = undefined; // the next request tries again
98
+ send(res, 500, { type: 'text/plain', body: err.message });
99
+ }
100
+ }
101
+ const bound = await (port === undefined ? listenFirstFree(server, DEFAULT_PORT) : listen(server, port))
102
+ .catch(async (err) => { await live.stop(); throw err; });
103
+ allowedHosts = new Set([`127.0.0.1:${bound}`, `localhost:${bound}`]);
104
+ return {
105
+ url: `http://127.0.0.1:${bound}`,
106
+ close: async () => {
107
+ await live.stop();
108
+ await new Promise(resolve => {
109
+ server.close(() => resolve());
110
+ server.closeAllConnections();
111
+ });
112
+ os.close();
113
+ },
114
+ };
115
+ }
116
+ function listen(server, port) {
117
+ return new Promise((resolve, reject) => {
118
+ const onError = (err) => reject(err);
119
+ server.once('error', onError);
120
+ server.listen(port, '127.0.0.1', () => {
121
+ server.off('error', onError);
122
+ resolve(server.address().port);
123
+ });
124
+ });
125
+ }
126
+ async function listenFirstFree(server, from) {
127
+ for (let port = from;; port++) {
128
+ try {
129
+ return await listen(server, port);
130
+ }
131
+ catch (err) {
132
+ if (err.code !== 'EADDRINUSE' || port >= 65535)
133
+ throw err;
134
+ }
135
+ }
136
+ }
137
+ const NOTE_PREFIX = '/note/';
138
+ /** How many Session Log entries `/note` gives an entity. */
139
+ const LOG_SHOWN = 5;
140
+ /** `/note/:slug`: `read --json` at summary depth; an entity's adds `log`, its
141
+ * Session Log's last LOG_SHOWN entries newest first (#271). A slug with no
142
+ * note, or one that cannot name a note at all, is 404 with the closest slugs,
143
+ * as a `read --json` miss prints them. */
144
+ async function sendNote(os, res, encoded) {
145
+ let slug = encoded;
146
+ try {
147
+ slug = decodeURIComponent(encoded);
148
+ send(res, 200, { type: 'application/json', body: JSON.stringify(await noteJson(os, slug)) });
149
+ }
150
+ catch (err) {
151
+ const miss = err instanceof URIError || (err instanceof HafezError && (err.code === 'NOT_FOUND' || err.code === 'VALIDATION_FAILED'));
152
+ if (!miss)
153
+ return send(res, 500, { type: 'text/plain', body: err.message });
154
+ const closest = err instanceof HafezError && err.code === 'NOT_FOUND' ? err.details ?? [] : [];
155
+ send(res, 404, { type: 'application/json', body: JSON.stringify({ slug, closest }) });
156
+ }
157
+ }
158
+ async function noteJson(os, slug) {
159
+ const summary = await os.read(slug, 'summary');
160
+ if (summary.kind !== 'entity')
161
+ return readJsonOf(os, slug, 'summary', summary);
162
+ // The whole entity, read once: readJsonOf's Footer and pointers, and the log.
163
+ const whole = await os.read(slug, 'full');
164
+ const log = parseSessionLog(whole.body).slice(0, LOG_SHOWN);
165
+ return { ...await readJsonOf(os, slug, 'summary', summary, whole), log };
166
+ }
167
+ const FONT_PREFIX = '/fonts/';
168
+ /** Read once each, on first request. */
169
+ const fontCache = new Map();
170
+ /** `/fonts/:file`: a bundled font or its licence, by the fixed list in fonts.ts
171
+ * and nothing else. The name is matched as given, never joined to a path. */
172
+ function sendFont(res, file) {
173
+ if (!FONT_FILES.has(file))
174
+ return send(res, 404, { type: 'text/plain', body: 'Not Found' });
175
+ try {
176
+ let body = fontCache.get(file);
177
+ if (!body)
178
+ fontCache.set(file, body = readFileSync(join(BUILD_DIR, 'fonts', file)));
179
+ const type = file.endsWith('.woff2') ? 'font/woff2' : 'text/plain';
180
+ send(res, 200, { type, body }, { 'Cache-Control': 'max-age=86400' });
181
+ }
182
+ catch (err) {
183
+ send(res, 500, { type: 'text/plain', body: err.message });
184
+ }
185
+ }
186
+ function send(res, status, { type, body }, headers = {}) {
187
+ res.writeHead(status, { 'Content-Type': type.startsWith('font/') ? type : `${type}; charset=utf-8`, ...headers });
188
+ res.end(body);
189
+ }
@@ -0,0 +1,6 @@
1
+ import type { HealthReport } from './types.js';
2
+ /** The threshold a recurring name lists at (#239). */
3
+ export declare const RECURRING_MIN_UNITS = 10;
4
+ /** The share of a decision's summary tokens a note must carry. */
5
+ export declare const RESTATE_SHARE = 0.5;
6
+ export declare function healthReport(vaultPath: string): HealthReport;
package/dist/health.js ADDED
@@ -0,0 +1,310 @@
1
+ // src/health.ts
2
+ // The Health pass (see CONTEXT.md; #239, #244): what placement would change,
3
+ // as a typed report. Like the Vault Scan (validate.ts) it reads the vault
4
+ // files directly and never the index, so it reports what is on disk. It
5
+ // applies nothing. Reference definitions: tools/research/ws_caps_prefix.py
6
+ // (through src/workstream.ts), ws_recurring_name.py, ws_leak.py,
7
+ // ws_restate.py; (iii) and (v) follow ADR 0002.
8
+ import { join, basename } from 'path';
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { parseFilePath, scanVaultDir, kindDir, archiveLogPath } from './vault.js';
11
+ import { ACTION_RE, actionAdded, documentForFile, escapeRegex, parseSessionLogHeading, splitLogEntries, stripActionStamp, WIKI_LINK_RE } from './document.js';
12
+ import { workstreams, wordTokens } from './workstream.js';
13
+ const ENTITY_DOC = documentForFile({ kind: 'entity' });
14
+ const KNOWLEDGE_DOC = documentForFile({ kind: 'knowledge' });
15
+ function parseActions(body) {
16
+ return ENTITY_DOC.actionLines(body).map(block => {
17
+ const [line, ...carried] = block.split('\n');
18
+ const m = ACTION_RE.exec(line);
19
+ const folded = carried.filter(l => l.startsWith(' ') && l.trim()).map(l => l.trim());
20
+ return { text: [stripActionStamp(m[2]), ...folded].join(' '), added: actionAdded(m[2]) };
21
+ });
22
+ }
23
+ /** The entries of a Session Log section or an archive, as ws_common.py
24
+ * reads them: the summary is the first `Summary:` line anywhere in the
25
+ * entry. */
26
+ function parseLog(content) {
27
+ return splitLogEntries(content).map(block => {
28
+ const [heading, ...rest] = block.split('\n');
29
+ const head = parseSessionLogHeading(heading);
30
+ const lines = rest.map(l => l.trim()).filter(Boolean);
31
+ const at = lines.findIndex(l => l.startsWith('Summary:'));
32
+ const summary = at === -1 ? '' : lines[at].slice(8).trim();
33
+ const text = lines.map((l, i) => i === at ? summary : l).join(' ');
34
+ return { day: head.date, type: head.type, summary, text };
35
+ });
36
+ }
37
+ function loadLog(vaultPath, slug, body) {
38
+ const section = ENTITY_DOC.findSection(body, 'Session Log')?.content ?? '';
39
+ const archive = archiveLogPath(vaultPath, slug);
40
+ return [...parseLog(section), ...(existsSync(archive) ? parseLog(readFileSync(archive, 'utf-8')) : [])];
41
+ }
42
+ /** Every parseable entity, slug order. A file that fails to parse is
43
+ * `validate`'s to report, not this pass's. */
44
+ function loadEntities(vaultPath) {
45
+ const out = [];
46
+ for (const filePath of scanVaultDir(join(vaultPath, kindDir('entity'))).sort()) {
47
+ const slug = basename(filePath, '.md');
48
+ let fm, body;
49
+ try {
50
+ ({ frontmatter: fm, body } = parseFilePath(filePath));
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ out.push({
56
+ slug,
57
+ name: String(fm.name ?? slug),
58
+ created: String(fm.created ?? ''),
59
+ parent: typeof fm.parent === 'string' ? fm.parent : null,
60
+ related: Array.isArray(fm.related) ? fm.related.map(String) : [],
61
+ body,
62
+ actions: parseActions(body),
63
+ log: loadLog(vaultPath, slug, body),
64
+ });
65
+ }
66
+ return out;
67
+ }
68
+ /** Every parseable knowledge note, slug order. */
69
+ function loadKnowledge(vaultPath) {
70
+ const out = [];
71
+ for (const filePath of scanVaultDir(join(vaultPath, kindDir('knowledge'))).sort()) {
72
+ const slug = basename(filePath, '.md');
73
+ let fm, body;
74
+ try {
75
+ ({ frontmatter: fm, body } = parseFilePath(filePath));
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ out.push({
81
+ slug,
82
+ name: String(fm.name ?? slug),
83
+ created: String(fm.created ?? ''),
84
+ parent: null,
85
+ related: Array.isArray(fm.related) ? fm.related.map(String) : [],
86
+ body,
87
+ });
88
+ }
89
+ return out;
90
+ }
91
+ /** Check (i): the family module over each entity, against the entities
92
+ * whose `parent` or `related` name it. */
93
+ function checkWorkstreams(entities) {
94
+ return entities.flatMap(e => {
95
+ const naming = entities.filter(o => o.parent === e.slug || o.related.includes(e.slug));
96
+ return workstreams(e.body, naming).map(w => ({ entity: e.slug, ...w }));
97
+ });
98
+ }
99
+ // --- Check (ii): ws_recurring_name.py ---
100
+ /** The threshold a recurring name lists at (#239). */
101
+ export const RECURRING_MIN_UNITS = 10;
102
+ const SLUGISH_RE = /(?<![\w/.])([a-z][a-z0-9]*(?:-[a-z0-9]+)+)(?![\w/])/g;
103
+ const CAPWORD = '[A-Z][a-zA-Z0-9]+';
104
+ const PROPER_RE = new RegExp(`(?<![.!?:;]\\s)(?<!^)(?<!\\n)\\b(${CAPWORD}(?: ${CAPWORD})?)\\b`, 'g');
105
+ const FAMILY_RE = /\b(WS\d+|WS-[A-Z])\b/g;
106
+ const DATE_LIKE_RE = /^\d{4}-\d{2}-\d{2}$|^\d+-\d+$/;
107
+ const SLUGISH_SKIP_HEADS = new Set(['add', 'dry', 'read', 'auto']);
108
+ /** The script's list minus the owner's first name (a leak pattern in the public
109
+ * tree): months, weekdays, agents, function
110
+ * words, number words, acronyms, products, pronouns, quantifiers. */
111
+ const RECURRING_STOP = new Set(`January February March April May June July August September October November December
112
+ Jan Feb Mar Apr Jun Jul Aug Sep Sept Oct Nov Dec Monday Tuesday Wednesday Thursday Friday Saturday Sunday
113
+ Claude Codex Summary Decision Decisions Next Step Steps Phase Session Sessions Day Week Workshop
114
+ The A An And Or But Not No Yes For From With Into Over Under Then Now Done Open Owner Owned Fix Fixed
115
+ Also New Old One Two Three Four Five Six Seven Eight Nine Ten First Second Third Last Final Draft
116
+ PR PRs CI API URL CLI SDK UK US ID MD JSON YAML CSV PDF HTML SQL FTS SHA IP UI UX QA DAX
117
+ Notion GitHub Google Windows Linux WSL Python Node Power BI Excel Teams Outlook Slack Telegram
118
+ If When Where What Which Who Why How This That These Those It Its He His Him She Her They Them We Our
119
+ Still Both Either Neither Each Every Any Some Most Many Few Such Same Other Another Only Just Even
120
+ Before After Since Until While Because So Than Too Very Here There`.split(/\s+/));
121
+ const isUpper = (w) => w === w.toUpperCase() && w !== w.toLowerCase();
122
+ /** The terms of one unit's text: slug-shaped names that are no note's slug,
123
+ * capitalised words or bigrams not at a sentence start (minus the stop set,
124
+ * the entity's own name tokens and all-caps runs, which are check (i)), and
125
+ * `WS` family codes. */
126
+ function termsIn(text, ownTokens, slugs) {
127
+ const terms = new Set();
128
+ for (const m of text.matchAll(SLUGISH_RE)) {
129
+ const s = m[1];
130
+ if (slugs.has(s) || DATE_LIKE_RE.test(s) || s.length < 6)
131
+ continue;
132
+ if (s.startsWith('http') || s.startsWith('www') || SLUGISH_SKIP_HEADS.has(s.split('-')[0]))
133
+ continue;
134
+ terms.add(s);
135
+ }
136
+ for (const m of text.matchAll(PROPER_RE)) {
137
+ const words = m[1].split(' ');
138
+ if (words.some(w => RECURRING_STOP.has(w) || ownTokens.has(w.toLowerCase())))
139
+ continue;
140
+ if (words.every(isUpper))
141
+ continue;
142
+ terms.add(m[1]);
143
+ }
144
+ for (const m of text.matchAll(FAMILY_RE))
145
+ terms.add(m[1]);
146
+ return terms;
147
+ }
148
+ /** Check (ii): a term in RECURRING_MIN_UNITS or more units (log entries and
149
+ * action lines) of one entity across two or more days; an undated action
150
+ * line counts as a unit but not as a day. */
151
+ function checkRecurringNames(entities, slugs) {
152
+ const out = [];
153
+ for (const e of entities) {
154
+ const own = wordTokens(`${e.name} ${e.slug}`);
155
+ const units = new Map();
156
+ const add = (text, day) => {
157
+ for (const t of termsIn(text, own, slugs))
158
+ units.set(t, [...(units.get(t) ?? []), day]);
159
+ };
160
+ for (const entry of e.log)
161
+ add(entry.text, entry.day);
162
+ for (const a of e.actions)
163
+ add(a.text, a.added);
164
+ const rows = [];
165
+ for (const [term, days] of units) {
166
+ const distinct = [...new Set(days.filter((d) => d !== null))].sort();
167
+ if (days.length >= RECURRING_MIN_UNITS && distinct.length >= 2)
168
+ rows.push({ entity: e.slug, term, units: days.length, days: distinct });
169
+ }
170
+ out.push(...rows.sort((a, b) => b.units - a.units || a.term.localeCompare(b.term)));
171
+ }
172
+ return out;
173
+ }
174
+ // --- Check (iv): ws_leak.py ---
175
+ /** The child's slug, or its name when longer than three characters, as a
176
+ * whole word (ws_leak.py `mentions`). */
177
+ function mentionsRe(c) {
178
+ const pats = [escapeRegex(c.slug)];
179
+ if (c.name.length > 3)
180
+ pats.push(escapeRegex(c.name));
181
+ return new RegExp(`(?<![\\w-])(?:${pats.join('|')})(?![\\w-])`, 'i');
182
+ }
183
+ /** Check (iv): for each child C and each P that is C's parent or an entity
184
+ * in C's related, P's log entries dated on or after C's `created` naming C.
185
+ * Pairs with no hit are left out. */
186
+ function checkLeakage(entities) {
187
+ const bySlug = new Map(entities.map(e => [e.slug, e]));
188
+ const out = [];
189
+ for (const c of entities) {
190
+ const parents = [...(c.parent ? [c.parent] : []), ...c.related.filter(r => r !== c.parent)];
191
+ const rx = mentionsRe(c);
192
+ for (const p of parents) {
193
+ const P = bySlug.get(p);
194
+ if (!P)
195
+ continue;
196
+ const after = P.log.filter(e => e.day >= c.created);
197
+ const hits = after.filter(e => rx.test(e.text)).length;
198
+ if (hits === 0)
199
+ continue;
200
+ out.push({ parent: p, child: c.slug, relation: p === c.parent ? 'parent' : 'related', hits, entries: after.length });
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+ // --- Checks (iii) and (v): ADR 0002 ---
206
+ const WIKI_LINK_ALL_RE = new RegExp(WIKI_LINK_RE.source, 'g');
207
+ /** A parent and its child are never `related` (parent subsumes related):
208
+ * the link line for such a pair would be rejected, so neither check lists it. */
209
+ const parentBound = (a, b) => a.parent === b.slug || b.parent === a.slug;
210
+ /** Check (iii): a same-kind `related` edge with one end. A target that is
211
+ * not a note of the kind is a cross-kind edge or a broken slug, neither
212
+ * this check's. */
213
+ function checkOneWayEdges(notes) {
214
+ const bySlug = new Map(notes.map(n => [n.slug, n]));
215
+ const out = [];
216
+ for (const a of notes) {
217
+ for (const b of a.related) {
218
+ const B = bySlug.get(b);
219
+ if (B && b !== a.slug && !B.related.includes(a.slug) && !parentBound(a, B))
220
+ out.push({ from: a.slug, to: b });
221
+ }
222
+ }
223
+ return out;
224
+ }
225
+ /** Check (v): a `[[slug]]` in a note's body naming another note of the
226
+ * kind, with no `related` edge either way; a pair mentioned from both sides
227
+ * lists once, from the first in slug order. */
228
+ function checkUnlinkedMentions(notes) {
229
+ const bySlug = new Map(notes.map(n => [n.slug, n]));
230
+ const seen = new Set();
231
+ const out = [];
232
+ for (const a of notes) {
233
+ for (const m of a.body.matchAll(WIKI_LINK_ALL_RE)) {
234
+ const b = m[1];
235
+ const B = bySlug.get(b);
236
+ if (!B || b === a.slug || a.related.includes(b) || B.related.includes(a.slug) || parentBound(a, B))
237
+ continue;
238
+ const pair = [a.slug, b].sort().join('\0');
239
+ if (seen.has(pair))
240
+ continue;
241
+ seen.add(pair);
242
+ out.push({ from: a.slug, to: b });
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+ // --- Check (vi): ws_restate.py ---
248
+ /** The share of a decision's summary tokens a note must carry. */
249
+ export const RESTATE_SHARE = 0.5;
250
+ /** The script's own stop list, verbatim; not src/stopwords.ts. */
251
+ const RESTATE_STOP = new Set(`the a an and or but not of to in on for with by from at as is are was were be been being it its
252
+ this that these those we our you your they their he she his her them us i me my if then than so no yes
253
+ do does did done has have had having can could will would should may might must into over under out up
254
+ down off about after before between through during per via one two three four five six seven eight nine
255
+ ten first second third new old now here there when where which who whom whose what why how all any each
256
+ every some such same other another only also just even still both either neither own very more most less
257
+ few many much see entry entries issue map ticket pr`.split(/\s+/));
258
+ function restateTokens(text) {
259
+ const out = new Set();
260
+ for (const m of text.toLowerCase().matchAll(/[a-z0-9][a-z0-9'’\-]*/g)) {
261
+ if (m[0].length >= 3 && !RESTATE_STOP.has(m[0]))
262
+ out.add(m[0]);
263
+ }
264
+ return out;
265
+ }
266
+ /** Check (vi): for each `decision` entry on an entity, each knowledge note
267
+ * created on the entry's day and `related` to the entity whose name plus
268
+ * Synthesis carries RESTATE_SHARE or more of the entry's summary tokens. */
269
+ function checkRestatedDecisions(entities, knowledge) {
270
+ const out = [];
271
+ for (const e of entities) {
272
+ const notes = knowledge.filter(n => n.related.includes(e.slug));
273
+ if (!notes.length)
274
+ continue;
275
+ for (const entry of e.log) {
276
+ if (entry.type !== 'decision')
277
+ continue;
278
+ const st = restateTokens(entry.summary);
279
+ if (!st.size)
280
+ continue;
281
+ for (const n of notes) {
282
+ if (n.created !== entry.day)
283
+ continue;
284
+ const nt = restateTokens(`${n.name} ${KNOWLEDGE_DOC.findSection(n.body, 'Synthesis')?.content ?? ''}`);
285
+ const share = [...st].filter(t => nt.has(t)).length / st.size;
286
+ if (share >= RESTATE_SHARE)
287
+ out.push({ day: entry.day, entity: e.slug, note: n.slug, share, summary: entry.summary });
288
+ }
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+ /** Every note's slug, every kind: a slug-shaped name that is a note is not
294
+ * a recurring name. */
295
+ function allSlugs(vaultPath) {
296
+ const kinds = ['entity', 'knowledge', 'session'];
297
+ return new Set(kinds.flatMap(k => scanVaultDir(join(vaultPath, kindDir(k))).map(f => basename(f, '.md'))));
298
+ }
299
+ export function healthReport(vaultPath) {
300
+ const entities = loadEntities(vaultPath);
301
+ const knowledge = loadKnowledge(vaultPath);
302
+ return {
303
+ workstreams: checkWorkstreams(entities),
304
+ recurring_names: checkRecurringNames(entities, allSlugs(vaultPath)),
305
+ leakage: checkLeakage(entities),
306
+ one_way_edges: [...checkOneWayEdges(entities), ...checkOneWayEdges(knowledge)],
307
+ unlinked_mentions: [...checkUnlinkedMentions(entities), ...checkUnlinkedMentions(knowledge)],
308
+ restated_decisions: checkRestatedDecisions(entities, knowledge),
309
+ };
310
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Hafez, HafezConfig } from './types.js';
2
2
  export declare function createHafez(config: HafezConfig): Hafez;
3
3
  export { HafezError } from './types.js';
4
- export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, SessionQueryOpts, SessionQueryResult, SearchKind, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, KnowledgeSortField, SessionSortField, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
4
+ export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, SessionQueryOpts, SessionQueryResult, SearchKind, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, KnowledgeSortField, SessionSortField, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, CreateSessionFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype, Graph, GraphNode, GraphEdge, GraphOpts, ChangesSince, NoteChange, ChangeOp, FileChange } from './types.js';
5
5
  export { getBrief, getNextActions } from './document.js';
6
6
  export { parseSessionLogHeading, parseSessionLog } from './document.js';
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // src/index.ts
2
- import { parseFilePath, slugify, kindFromPath, kindDir, isMutableRelPath, findVaultFile, findReadableFile, vaultFileExists, resolveFilePath, scanVaultDir, archiveLogPath, MUTABLE_KINDS } from './vault.js';
2
+ import { parseFilePath, slugify, kindFromPath, kindDir, isMutableRelPath, findVaultFile, findReadableFile, vaultFileExists, resolveFilePath, scanVaultDir, archiveLogPath, noteAtRelPath, MUTABLE_KINDS } from './vault.js';
3
3
  import { createGitJournal } from './git.js';
4
4
  import { validateSessionLogEntry } from './schema.js';
5
5
  import { validateVault } from './validate.js';
6
+ import { healthReport } from './health.js';
6
7
  import { documentForFile } from './document.js';
7
8
  import { createIndex } from './db.js';
8
9
  import { closestSlugs } from './closest.js';
@@ -14,7 +15,9 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
14
15
  import { join, basename } from 'path';
15
16
  import { lock as lockVault } from 'proper-lockfile';
16
17
  export function createHafez(config) {
17
- const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
18
+ const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite, indexSync = 'read' } = config;
19
+ // When reads catch the index up with the files (src/internal.ts, off the
20
+ // public types): 'once' is the CLI's (#269), 'manual' the graph server's (#271).
18
21
  // The persistence seam (CONTEXT.md: Journal). Everything below talks to
19
22
  // history through this port; git is just the default adapter.
20
23
  const journal = config.persistence ?? createGitJournal(vaultPath, gitConfig);
@@ -66,8 +69,12 @@ export function createHafez(config) {
66
69
  // outside hafez — a hand edit, a git pull — are represented too; that
67
70
  // costs a stat per file, where the old path paid a parse per file on
68
71
  // every single mutation.
72
+ // Under 'once' the process's first read already caught up.
69
73
  const idx = getIndex();
70
- idx?.syncIfStale();
74
+ if (indexSync === 'once')
75
+ idx?.syncForRead();
76
+ else
77
+ idx?.syncIfStale();
71
78
  generateVaultIndex(vaultPath, idx?.indexEntries());
72
79
  }
73
80
  catch (err) {
@@ -78,7 +85,7 @@ export function createHafez(config) {
78
85
  let _indexInitialized = false;
79
86
  function getIndex() {
80
87
  if (!_indexInitialized) {
81
- _index = readOnly ? createIndex(vaultPath, { readonly: true }) : createIndex(vaultPath);
88
+ _index = readOnly ? createIndex(vaultPath, { readonly: true, sync: indexSync }) : createIndex(vaultPath, { sync: indexSync });
82
89
  // A null read-only open means the on-disk index is missing or from another
83
90
  // schema version. Don't latch it: retry on the next call so a long-lived
84
91
  // read-only instance starts serving once a write-capable process rebuilds.
@@ -97,7 +104,7 @@ export function createHafez(config) {
97
104
  if (!filePath) {
98
105
  // A miss carries the closest slugs as its details: the recovery the
99
106
  // `read` miss output renders (#91).
100
- const closest = withIndex(idx => { idx.syncIfStale(); return closestSlugs(slug, idx.indexEntries()); }, []);
107
+ const closest = withIndex(idx => { idx.syncForRead(); return closestSlugs(slug, idx.indexEntries()); }, []);
101
108
  throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
102
109
  }
103
110
  const parsed = parseFilePath(filePath);
@@ -483,6 +490,9 @@ export function createHafez(config) {
483
490
  async function validate() {
484
491
  return validateVault(vaultPath);
485
492
  }
493
+ async function health() {
494
+ return healthReport(vaultPath);
495
+ }
486
496
  async function promote(slug, target) {
487
497
  // Rollback safety rides the Spine: applyOperation's promote arm captures
488
498
  // both the source file and (for cross-kind moves) the knowledge target
@@ -498,7 +508,8 @@ export function createHafez(config) {
498
508
  knowledge_count: 0,
499
509
  stale: [], no_next_action: [], recently_touched: [], recently_created: [],
500
510
  };
501
- return {
511
+ // An InternalHafez: syncIndex() is here, off the public Hafez type.
512
+ const hafez = {
502
513
  read,
503
514
  update,
504
515
  promote,
@@ -528,7 +539,10 @@ export function createHafez(config) {
528
539
  query_sessions: async (opts) => withIndex(idx => idx.querySessions(opts), { items: [], total: 0 }),
529
540
  archive,
530
541
  validate,
531
- search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
542
+ health,
543
+ search: async (query, kind) => withIndex(idx => { idx.syncForRead(); return idx.search(query, kind); }, []),
544
+ graph: async (opts) => withIndex(idx => idx.queryGraph(opts), { nodes: [], edges: [] }),
545
+ syncIndex: async () => { withIndex(idx => idx.syncIfStale(), undefined); },
532
546
  rebuildIndex: async () => {
533
547
  if (readOnly)
534
548
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
@@ -542,13 +556,26 @@ export function createHafez(config) {
542
556
  const idx = getIndex();
543
557
  if (!idx)
544
558
  return emptyStats;
545
- idx.syncIfStale();
559
+ idx.syncForRead();
546
560
  const s = idx.getStats();
547
561
  s.recently_touched = await rankRecents(s.recently_touched, 'last_touched', 'modified');
548
562
  s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
549
563
  return s;
550
564
  },
551
- changelog: (since) => journal.changelog(since),
565
+ changelog: async (since, limit) => {
566
+ if (limit !== undefined && !(Number.isInteger(limit) && limit > 0)) {
567
+ throw new HafezError('VALIDATION_FAILED', `changelog limit must be a positive whole number of commits, got ${limit}`);
568
+ }
569
+ return journal.changelog(since, limit);
570
+ },
571
+ changesSince: async (cursor) => {
572
+ const { cursor: next, files } = await journal.changesSince(cursor);
573
+ const changes = files.flatMap(({ path, op }) => {
574
+ const note = noteAtRelPath(path);
575
+ return note ? [{ ...note, op }] : [];
576
+ });
577
+ return { cursor: next, changes };
578
+ },
552
579
  close: () => {
553
580
  // Forget the handle even if closing it throws: a retry must not
554
581
  // re-throw, and getIndex() must never hand out a closed handle.
@@ -561,6 +588,7 @@ export function createHafez(config) {
561
588
  }
562
589
  },
563
590
  };
591
+ return hafez;
564
592
  }
565
593
  export { HafezError } from './types.js';
566
594
  export { getBrief, getNextActions } from './document.js';
@@ -0,0 +1,21 @@
1
+ import type { Hafez, HafezConfig } from './types.js';
2
+ import type { IndexSync } from './db.js';
3
+ export interface InternalHafezConfig extends HafezConfig {
4
+ /** When the index catches up with files changed outside hafez (a hand edit,
5
+ * a raw git pull). 'read' (default): before every index read, a stat per
6
+ * note, so a long-lived instance never reads a stale index. 'once': the
7
+ * first index read syncs, later reads trust the index — the CLI's mode, one
8
+ * scan per process (#269); a file changed outside hafez after that read is
9
+ * seen by the next process, or after `syncIndex()`. Writes through the
10
+ * instance are indexed as they commit in every mode. 'manual': reads trust
11
+ * the index, and `syncIndex()` syncs it — the graph server's mode, which
12
+ * syncs when its poll sees a commit (#271). */
13
+ indexSync?: IndexSync;
14
+ }
15
+ export interface InternalHafez extends Hafez {
16
+ /** Catch the index up with files changed outside hafez, in every mode:
17
+ * what a read does first under `indexSync` 'read', and the first read under 'once'. */
18
+ syncIndex(): Promise<void>;
19
+ }
20
+ /** createHafez with the internal options: every instance it builds is an InternalHafez. */
21
+ export declare function createInternalHafez(config: InternalHafezConfig): InternalHafez;
@@ -0,0 +1,9 @@
1
+ // src/internal.ts
2
+ // What hafez's own CLI and graph server reach that the package does not
3
+ // export: src/index.ts re-exports none of it, and package.json's `exports`
4
+ // map stops a deep import. Off the library surface by the owner's call (#270).
5
+ import { createHafez } from './index.js';
6
+ /** createHafez with the internal options: every instance it builds is an InternalHafez. */
7
+ export function createInternalHafez(config) {
8
+ return createHafez(config);
9
+ }