hafez 2.1.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.
- package/README.md +72 -5
- package/dist/apply.d.ts +14 -1
- package/dist/apply.js +94 -32
- package/dist/batch-ops.d.ts +31 -27
- package/dist/batch-ops.js +83 -16
- package/dist/cli/block-hook.d.ts +6 -0
- package/dist/cli/block-hook.js +27 -12
- package/dist/cli/commands.d.ts +3 -1
- package/dist/cli/commands.js +183 -73
- package/dist/cli/format.d.ts +11 -3
- package/dist/cli/format.js +61 -4
- package/dist/cli/index.js +40 -19
- package/dist/cli/install.d.ts +9 -0
- package/dist/cli/install.js +36 -15
- package/dist/cli/onboard.js +30 -9
- package/dist/cli/upgrade.js +5 -4
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.js +9 -0
- package/dist/dates.d.ts +7 -0
- package/dist/dates.js +21 -1
- package/dist/db.d.ts +50 -10
- package/dist/db.js +251 -65
- package/dist/digest.js +14 -7
- package/dist/document.d.ts +2 -0
- package/dist/document.js +2 -2
- package/dist/git.d.ts +13 -2
- package/dist/git.js +121 -14
- package/dist/graph/activity.d.ts +5 -0
- package/dist/graph/activity.js +70 -0
- package/dist/graph/client.js +326 -0
- package/dist/graph/copy-fonts.d.ts +1 -0
- package/dist/graph/copy-fonts.js +13 -0
- package/dist/graph/fields.d.ts +8 -0
- package/dist/graph/fields.js +12 -0
- package/dist/graph/fonts/geist-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono.woff2 +0 -0
- package/dist/graph/fonts/geist.woff2 +0 -0
- package/dist/graph/fonts/outfit-OFL.txt +93 -0
- package/dist/graph/fonts/outfit.woff2 +0 -0
- package/dist/graph/fonts.d.ts +14 -0
- package/dist/graph/fonts.js +17 -0
- package/dist/graph/live.d.ts +16 -0
- package/dist/graph/live.js +99 -0
- package/dist/graph/open.d.ts +1 -0
- package/dist/graph/open.js +20 -0
- package/dist/graph/server.d.ts +17 -0
- package/dist/graph/server.js +189 -0
- package/dist/health.d.ts +6 -0
- package/dist/health.js +310 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +66 -18
- package/dist/internal.d.ts +21 -0
- package/dist/internal.js +9 -0
- package/dist/knowledge-index.d.ts +1 -1
- package/dist/read-json.d.ts +26 -0
- package/dist/read-json.js +53 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +11 -0
- package/dist/stale.d.ts +2 -2
- package/dist/stale.js +4 -9
- package/dist/surfaces/items.d.ts +100 -1
- package/dist/surfaces/items.js +148 -10
- package/dist/surfaces/surfaces.d.ts +8 -5
- package/dist/surfaces/surfaces.js +34 -17
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +6 -1
- package/dist/types.d.ts +239 -25
- package/dist/validate.js +44 -56
- package/dist/vault.d.ts +29 -9
- package/dist/vault.js +39 -8
- package/dist/workstream.d.ts +17 -0
- package/dist/workstream.js +78 -0
- package/package.json +10 -4
- package/skill/hafez/SKILL.md +16 -7
|
@@ -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
|
+
}
|
package/dist/health.d.ts
ADDED
|
@@ -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, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, 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';
|