hafez 1.0.1
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/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli/batch-errors.d.ts +24 -0
- package/dist/cli/batch-errors.js +236 -0
- package/dist/cli/commands-schema.d.ts +3 -0
- package/dist/cli/commands-schema.js +80 -0
- package/dist/cli/commands.d.ts +133 -0
- package/dist/cli/commands.js +628 -0
- package/dist/cli/format.d.ts +9 -0
- package/dist/cli/format.js +158 -0
- package/dist/cli/help-agent.d.ts +6 -0
- package/dist/cli/help-agent.js +158 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +304 -0
- package/dist/cli/resolve-vault.d.ts +13 -0
- package/dist/cli/resolve-vault.js +124 -0
- package/dist/cli/schema-introspect.d.ts +70 -0
- package/dist/cli/schema-introspect.js +372 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -0
- package/dist/contracts.d.ts +26 -0
- package/dist/contracts.js +87 -0
- package/dist/db.d.ts +68 -0
- package/dist/db.js +663 -0
- package/dist/digest.d.ts +29 -0
- package/dist/digest.js +94 -0
- package/dist/export-okf.d.ts +12 -0
- package/dist/export-okf.js +176 -0
- package/dist/git.d.ts +12 -0
- package/dist/git.js +355 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +909 -0
- package/dist/knowledge-index.d.ts +3 -0
- package/dist/knowledge-index.js +135 -0
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +176 -0
- package/dist/migrate-knowledge-v2.d.ts +11 -0
- package/dist/migrate-knowledge-v2.js +158 -0
- package/dist/migrate-next-actions.d.ts +6 -0
- package/dist/migrate-next-actions.js +32 -0
- package/dist/migrate-types.d.ts +11 -0
- package/dist/migrate-types.js +142 -0
- package/dist/parser.d.ts +18 -0
- package/dist/parser.js +68 -0
- package/dist/query.d.ts +24 -0
- package/dist/query.js +167 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +59 -0
- package/dist/sections.d.ts +54 -0
- package/dist/sections.js +297 -0
- package/dist/templates.d.ts +8 -0
- package/dist/templates.js +39 -0
- package/dist/types.d.ts +321 -0
- package/dist/types.js +11 -0
- package/dist/vault.d.ts +12 -0
- package/dist/vault.js +45 -0
- package/package.json +67 -0
- package/skill/SKILL.md +190 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/knowledge-index.ts
|
|
2
|
+
// Generates the vault-root index.md covering entities (grouped by status) and
|
|
3
|
+
// knowledge (grouped by domain) — local-only, auto-regenerated on every entity/knowledge write.
|
|
4
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
5
|
+
import { join, basename } from 'path';
|
|
6
|
+
import { parseFilePath } from './vault.js';
|
|
7
|
+
import { deriveSummary } from './sections.js';
|
|
8
|
+
import { ENTITY_STATUSES } from './contracts.js';
|
|
9
|
+
function truncate(s) {
|
|
10
|
+
return s.length > 100 ? s.slice(0, 97) + '...' : s;
|
|
11
|
+
}
|
|
12
|
+
function capitalize(s) {
|
|
13
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
14
|
+
}
|
|
15
|
+
function formatEntry(item) {
|
|
16
|
+
const desc = item.summary ? ` — ${item.summary}` : '';
|
|
17
|
+
return `- [[${item.slug}]]${desc} (${item.suffix})`;
|
|
18
|
+
}
|
|
19
|
+
/** Parse every .md directly in dir (non-recursive — auto-excludes archive/), skipping malformed files. */
|
|
20
|
+
function scanParsed(dir) {
|
|
21
|
+
const docs = [];
|
|
22
|
+
for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
23
|
+
try {
|
|
24
|
+
const { frontmatter: fm, body } = parseFilePath(join(dir, file));
|
|
25
|
+
if (!fm.name)
|
|
26
|
+
continue;
|
|
27
|
+
docs.push({ slug: basename(file, '.md'), fm, body });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return docs;
|
|
34
|
+
}
|
|
35
|
+
/** Local-only artifacts the CLI generates inside the vault. They must never
|
|
36
|
+
* show up in the user's `git status`, but forcing a tracked .gitignore entry
|
|
37
|
+
* would dirty their vault — so they go into .git/info/exclude instead. */
|
|
38
|
+
const LOCAL_EXCLUDES = ['/index.md', '/.hafez.db', '/.hafez.db-shm', '/.hafez.db-wal'];
|
|
39
|
+
/** Ensure every generated local-only artifact is covered by .git/info/exclude. */
|
|
40
|
+
export function ensureLocalExclude(vaultPath) {
|
|
41
|
+
try {
|
|
42
|
+
const gitDir = join(vaultPath, '.git');
|
|
43
|
+
if (!existsSync(gitDir) || !statSync(gitDir).isDirectory())
|
|
44
|
+
return;
|
|
45
|
+
const excludePath = join(gitDir, 'info', 'exclude');
|
|
46
|
+
const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : '';
|
|
47
|
+
const lines = new Set(existing.split('\n').map(l => l.trim()));
|
|
48
|
+
const missing = LOCAL_EXCLUDES.filter(p => !lines.has(p));
|
|
49
|
+
if (missing.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
mkdirSync(join(gitDir, 'info'), { recursive: true });
|
|
52
|
+
const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
|
|
53
|
+
writeFileSync(excludePath, existing + sep + missing.join('\n') + '\n');
|
|
54
|
+
}
|
|
55
|
+
catch { /* best effort — never fail a vault write over ignore hygiene */ }
|
|
56
|
+
}
|
|
57
|
+
export function generateVaultIndex(vaultPath) {
|
|
58
|
+
const entitiesDir = join(vaultPath, 'entities');
|
|
59
|
+
const knowledgeDir = join(vaultPath, 'knowledge');
|
|
60
|
+
const hasEntities = existsSync(entitiesDir);
|
|
61
|
+
const hasKnowledge = existsSync(knowledgeDir);
|
|
62
|
+
if (!hasEntities && !hasKnowledge)
|
|
63
|
+
return;
|
|
64
|
+
const lines = ['# Vault Index', '', '> Auto-generated by hafez. Do not edit manually.', ''];
|
|
65
|
+
if (hasEntities) {
|
|
66
|
+
const byStatus = new Map();
|
|
67
|
+
for (const { slug, fm, body } of scanParsed(entitiesDir)) {
|
|
68
|
+
const status = fm.status ?? 'active';
|
|
69
|
+
const list = byStatus.get(status) ?? [];
|
|
70
|
+
list.push({ slug, name: fm.name, summary: truncate(deriveSummary('entity', fm, body)), suffix: fm.type ?? 'entity' });
|
|
71
|
+
byStatus.set(status, list);
|
|
72
|
+
}
|
|
73
|
+
if (byStatus.size > 0) {
|
|
74
|
+
// Known statuses in canonical order, then any non-standard values (hand-edited
|
|
75
|
+
// files reach here unvalidated) so no live entity silently vanishes.
|
|
76
|
+
const known = ENTITY_STATUSES;
|
|
77
|
+
const extra = [...byStatus.keys()].filter(s => !known.includes(s)).sort();
|
|
78
|
+
lines.push('## Entities', '');
|
|
79
|
+
for (const status of [...known, ...extra]) {
|
|
80
|
+
const items = byStatus.get(status);
|
|
81
|
+
if (!items)
|
|
82
|
+
continue;
|
|
83
|
+
lines.push(`### ${capitalize(status)}`);
|
|
84
|
+
for (const item of items.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
85
|
+
lines.push(formatEntry(item));
|
|
86
|
+
}
|
|
87
|
+
lines.push('');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (hasKnowledge) {
|
|
92
|
+
const entries = [];
|
|
93
|
+
for (const { slug, fm, body } of scanParsed(knowledgeDir)) {
|
|
94
|
+
entries.push({
|
|
95
|
+
slug,
|
|
96
|
+
name: fm.name,
|
|
97
|
+
domains: Array.isArray(fm.domain) ? fm.domain : fm.domain ? [fm.domain] : [],
|
|
98
|
+
summary: truncate(deriveSummary('knowledge', fm, body)),
|
|
99
|
+
suffix: fm.confidence ?? 'observation',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const groups = new Map();
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.domains.length === 0) {
|
|
105
|
+
const list = groups.get('Uncategorized') ?? [];
|
|
106
|
+
list.push(entry);
|
|
107
|
+
groups.set('Uncategorized', list);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
for (const domain of entry.domains) {
|
|
111
|
+
const key = capitalize(domain);
|
|
112
|
+
const list = groups.get(key) ?? [];
|
|
113
|
+
list.push(entry);
|
|
114
|
+
groups.set(key, list);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const sortedKeys = [...groups.keys()].filter(k => k !== 'Uncategorized').sort();
|
|
119
|
+
if (groups.has('Uncategorized'))
|
|
120
|
+
sortedKeys.push('Uncategorized');
|
|
121
|
+
if (sortedKeys.length > 0) {
|
|
122
|
+
lines.push('## Knowledge', '');
|
|
123
|
+
for (const key of sortedKeys) {
|
|
124
|
+
lines.push(`### ${key}`);
|
|
125
|
+
const items = groups.get(key).sort((a, b) => a.name.localeCompare(b.name));
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
lines.push(formatEntry(item));
|
|
128
|
+
}
|
|
129
|
+
lines.push('');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
ensureLocalExclude(vaultPath);
|
|
134
|
+
writeFileSync(join(vaultPath, 'index.md'), lines.join('\n'));
|
|
135
|
+
}
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function mergeVaultContent(remote: string, local: string): string;
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// src/merge.ts
|
|
2
|
+
import { parseContent, serializeFile } from './vault.js';
|
|
3
|
+
import { parseSessionLogHeading } from './parser.js';
|
|
4
|
+
// Fields that are ISO date strings — latest date wins
|
|
5
|
+
const DATE_FIELDS = ['last-touched', 'last-reinforced', 'created'];
|
|
6
|
+
// Fields that are numeric counters — max wins
|
|
7
|
+
const COUNT_FIELDS = ['reinforcement-count', 'staleness-days'];
|
|
8
|
+
// Fields that are always arrays — union with dedup
|
|
9
|
+
const ARRAY_FIELDS_UNCAPPED = ['tags', 'domain', 'related'];
|
|
10
|
+
export function mergeVaultContent(remote, local) {
|
|
11
|
+
const r = parseContent(remote);
|
|
12
|
+
const l = parseContent(local);
|
|
13
|
+
const fm = mergeFrontmatter(r.frontmatter, l.frontmatter);
|
|
14
|
+
const body = mergeBody(r.body, l.body);
|
|
15
|
+
return serializeFile(fm, body);
|
|
16
|
+
}
|
|
17
|
+
function mergeFrontmatter(remote, local) {
|
|
18
|
+
// Start with remote as base, overlay local (local wins for scalars)
|
|
19
|
+
const merged = { ...remote, ...local };
|
|
20
|
+
// Date fields: latest wins
|
|
21
|
+
for (const key of DATE_FIELDS) {
|
|
22
|
+
const r = remote[key], l = local[key];
|
|
23
|
+
if (r && l)
|
|
24
|
+
merged[key] = r > l ? r : l;
|
|
25
|
+
else if (r)
|
|
26
|
+
merged[key] = r;
|
|
27
|
+
}
|
|
28
|
+
// Count fields: max wins
|
|
29
|
+
for (const key of COUNT_FIELDS) {
|
|
30
|
+
const r = remote[key], l = local[key];
|
|
31
|
+
if (r != null && l != null)
|
|
32
|
+
merged[key] = Math.max(r, l);
|
|
33
|
+
else if (r != null)
|
|
34
|
+
merged[key] = r;
|
|
35
|
+
}
|
|
36
|
+
// Array fields: union
|
|
37
|
+
for (const key of ARRAY_FIELDS_UNCAPPED) {
|
|
38
|
+
merged[key] = unionArrays(remote[key], local[key]);
|
|
39
|
+
if (merged[key].length === 0)
|
|
40
|
+
delete merged[key];
|
|
41
|
+
}
|
|
42
|
+
return merged;
|
|
43
|
+
}
|
|
44
|
+
function unionArrays(a, b) {
|
|
45
|
+
// Local (b) items first so they survive cap truncation
|
|
46
|
+
const arrA = Array.isArray(a) ? a : a ? [a] : [];
|
|
47
|
+
const arrB = Array.isArray(b) ? b : b ? [b] : [];
|
|
48
|
+
return [...new Set([...arrB, ...arrA])];
|
|
49
|
+
}
|
|
50
|
+
function splitSections(body) {
|
|
51
|
+
const parts = body.split(/\n(?=## )/g);
|
|
52
|
+
const sections = [];
|
|
53
|
+
let preamble = '';
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
if (part.startsWith('## ')) {
|
|
56
|
+
const nlIdx = part.indexOf('\n');
|
|
57
|
+
const heading = nlIdx === -1 ? part : part.slice(0, nlIdx);
|
|
58
|
+
const content = nlIdx === -1 ? '' : part.slice(nlIdx + 1);
|
|
59
|
+
sections.push({ heading: heading.trim(), content: content.trim() });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
preamble = part.trim();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { preamble, sections };
|
|
66
|
+
}
|
|
67
|
+
function mergeBody(remoteBody, localBody) {
|
|
68
|
+
if (remoteBody === localBody)
|
|
69
|
+
return localBody;
|
|
70
|
+
const remote = splitSections(remoteBody);
|
|
71
|
+
const local = splitSections(localBody);
|
|
72
|
+
// Build merged sections starting from local
|
|
73
|
+
const localHeadings = new Set(local.sections.map(s => s.heading));
|
|
74
|
+
const mergedSections = [];
|
|
75
|
+
for (const ls of local.sections) {
|
|
76
|
+
const rs = remote.sections.find(s => s.heading === ls.heading);
|
|
77
|
+
if (ls.heading === '## Session Log' && rs) {
|
|
78
|
+
mergedSections.push({ heading: ls.heading, content: mergeSessionLogs(rs.content, ls.content) });
|
|
79
|
+
}
|
|
80
|
+
else if (ls.heading === '## Next Actions' && rs) {
|
|
81
|
+
mergedSections.push({ heading: ls.heading, content: mergeNextActions(rs.content, ls.content) });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Brief, Current State, anything else: local wins
|
|
85
|
+
mergedSections.push(ls);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Append remote-only sections (not in local)
|
|
89
|
+
for (const rs of remote.sections) {
|
|
90
|
+
if (!localHeadings.has(rs.heading)) {
|
|
91
|
+
mergedSections.push(rs);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Reassemble
|
|
95
|
+
const preamble = local.preamble || remote.preamble;
|
|
96
|
+
const parts = [preamble, ...mergedSections.map(s => `${s.heading}\n\n${s.content}`)].filter(Boolean);
|
|
97
|
+
return parts.join('\n\n');
|
|
98
|
+
}
|
|
99
|
+
function mergeSessionLogs(remoteContent, localContent) {
|
|
100
|
+
const remoteEntries = parseLogEntries(remoteContent);
|
|
101
|
+
const localEntries = parseLogEntries(localContent);
|
|
102
|
+
// Index local entries by identity key for dedup
|
|
103
|
+
const localKeys = new Set(localEntries.map(e => e.key));
|
|
104
|
+
const merged = [...localEntries];
|
|
105
|
+
// Add remote entries not present in local
|
|
106
|
+
for (const re of remoteEntries) {
|
|
107
|
+
if (!localKeys.has(re.key)) {
|
|
108
|
+
merged.push(re);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Sort newest-first, with stable tiebreaker on identity key
|
|
112
|
+
merged.sort((a, b) => b.date.localeCompare(a.date) || a.key.localeCompare(b.key));
|
|
113
|
+
return merged.map(e => e.raw).join('\n\n');
|
|
114
|
+
}
|
|
115
|
+
function parseLogEntries(content) {
|
|
116
|
+
const entries = [];
|
|
117
|
+
const blocks = content.split(/\n(?=### \d{4}-\d{2}-\d{2})/);
|
|
118
|
+
for (const block of blocks) {
|
|
119
|
+
const trimmed = block.trim();
|
|
120
|
+
if (!trimmed)
|
|
121
|
+
continue;
|
|
122
|
+
const firstLine = trimmed.split('\n')[0];
|
|
123
|
+
const parsed = parseSessionLogHeading(firstLine);
|
|
124
|
+
if (parsed) {
|
|
125
|
+
entries.push({
|
|
126
|
+
key: `${parsed.date}|${parsed.agent}|${parsed.type}`,
|
|
127
|
+
date: parsed.date,
|
|
128
|
+
raw: trimmed,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
function mergeNextActions(remoteContent, localContent) {
|
|
135
|
+
const remoteItems = parseActionItems(remoteContent);
|
|
136
|
+
const localItems = parseActionItems(localContent);
|
|
137
|
+
// Build merged set: start with local items
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const merged = [];
|
|
140
|
+
// Add all local items
|
|
141
|
+
for (const item of localItems) {
|
|
142
|
+
seen.add(item.text);
|
|
143
|
+
merged.push(item);
|
|
144
|
+
}
|
|
145
|
+
// Add remote items not in local
|
|
146
|
+
for (const item of remoteItems) {
|
|
147
|
+
if (!seen.has(item.text)) {
|
|
148
|
+
seen.add(item.text);
|
|
149
|
+
merged.push(item);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// If remote has it checked but local has it unchecked, upgrade to checked
|
|
153
|
+
const existing = merged.find(m => m.text === item.text);
|
|
154
|
+
if (existing && item.checked && !existing.checked) {
|
|
155
|
+
existing.checked = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return merged.map(i => `- [${i.checked ? 'x' : ' '}] ${i.text}`).join('\n');
|
|
160
|
+
}
|
|
161
|
+
function parseActionItems(content) {
|
|
162
|
+
const items = [];
|
|
163
|
+
for (const line of content.split('\n')) {
|
|
164
|
+
const unchecked = line.match(/^- \[ \] (.+)$/);
|
|
165
|
+
if (unchecked) {
|
|
166
|
+
items.push({ checked: false, text: unchecked[1] });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const checked = line.match(/^- \[x\] (.+)$/);
|
|
170
|
+
if (checked) {
|
|
171
|
+
items.push({ checked: true, text: checked[1] });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return items;
|
|
176
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface MigrationReport {
|
|
2
|
+
sessionsToMove: string[];
|
|
3
|
+
emptyToDelete: string[];
|
|
4
|
+
insightToRename: string[];
|
|
5
|
+
relatedToGenerate: string[];
|
|
6
|
+
gitignoreNeeded: boolean;
|
|
7
|
+
gitCommitFailed: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function migrateKnowledgeV2(vaultPath: string, opts: {
|
|
10
|
+
apply: boolean;
|
|
11
|
+
}): MigrationReport;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, appendFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parseFilePath, serializeFile } from './vault.js';
|
|
4
|
+
import { syncRelatedSection, findSection } from './sections.js';
|
|
5
|
+
import { generateVaultIndex } from './knowledge-index.js';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
export function migrateKnowledgeV2(vaultPath, opts) {
|
|
8
|
+
const report = {
|
|
9
|
+
sessionsToMove: [],
|
|
10
|
+
emptyToDelete: [],
|
|
11
|
+
insightToRename: [],
|
|
12
|
+
relatedToGenerate: [],
|
|
13
|
+
gitignoreNeeded: false,
|
|
14
|
+
gitCommitFailed: false,
|
|
15
|
+
};
|
|
16
|
+
const knowledgeDir = join(vaultPath, 'knowledge');
|
|
17
|
+
const sessionsDir = join(vaultPath, 'sessions');
|
|
18
|
+
const entitiesDir = join(vaultPath, 'entities');
|
|
19
|
+
// Step 1: Find session knowledge notes (by frontmatter subtype, not filename)
|
|
20
|
+
if (existsSync(knowledgeDir)) {
|
|
21
|
+
for (const file of readdirSync(knowledgeDir).filter(f => f.endsWith('.md'))) {
|
|
22
|
+
try {
|
|
23
|
+
const { frontmatter } = parseFilePath(join(knowledgeDir, file));
|
|
24
|
+
if (frontmatter.subtype === 'session') {
|
|
25
|
+
report.sessionsToMove.push(file);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Step 2: Find empty-body knowledge notes
|
|
34
|
+
if (existsSync(knowledgeDir)) {
|
|
35
|
+
for (const file of readdirSync(knowledgeDir).filter(f => f.endsWith('.md'))) {
|
|
36
|
+
if (report.sessionsToMove.includes(file))
|
|
37
|
+
continue; // already moving
|
|
38
|
+
try {
|
|
39
|
+
const { body } = parseFilePath(join(knowledgeDir, file));
|
|
40
|
+
// "Empty" = body only has section headings (## ...) and whitespace
|
|
41
|
+
const stripped = body.replace(/^## .+$/gm, '').trim();
|
|
42
|
+
if (!stripped)
|
|
43
|
+
report.emptyToDelete.push(file);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Step 3: Find knowledge notes with ## Insight heading
|
|
51
|
+
if (existsSync(knowledgeDir)) {
|
|
52
|
+
for (const file of readdirSync(knowledgeDir).filter(f => f.endsWith('.md'))) {
|
|
53
|
+
if (report.sessionsToMove.includes(file) || report.emptyToDelete.includes(file))
|
|
54
|
+
continue;
|
|
55
|
+
try {
|
|
56
|
+
const { body } = parseFilePath(join(knowledgeDir, file));
|
|
57
|
+
if (findSection(body, 'Insight'))
|
|
58
|
+
report.insightToRename.push(file);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Step 4: Find files needing ## Related section generation
|
|
66
|
+
for (const [dir, kind] of [[entitiesDir, 'entity'], [knowledgeDir, 'knowledge']]) {
|
|
67
|
+
if (!existsSync(dir))
|
|
68
|
+
continue;
|
|
69
|
+
for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
70
|
+
if (kind === 'knowledge' && (report.sessionsToMove.includes(file) || report.emptyToDelete.includes(file)))
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const { frontmatter: fm, body } = parseFilePath(join(dir, file));
|
|
74
|
+
const hasLinks = (fm.related?.length > 0) || fm.parent;
|
|
75
|
+
if (hasLinks) {
|
|
76
|
+
const synced = syncRelatedSection(body, fm);
|
|
77
|
+
if (synced !== body)
|
|
78
|
+
report.relatedToGenerate.push(`${kind === 'entity' ? 'entities' : 'knowledge'}/${file}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Step 5: Check .gitignore
|
|
87
|
+
const gitignorePath = join(vaultPath, '.gitignore');
|
|
88
|
+
if (existsSync(gitignorePath)) {
|
|
89
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
90
|
+
if (!content.includes('/index.md'))
|
|
91
|
+
report.gitignoreNeeded = true;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
report.gitignoreNeeded = true;
|
|
95
|
+
}
|
|
96
|
+
if (!opts.apply)
|
|
97
|
+
return report;
|
|
98
|
+
// --- Apply ---
|
|
99
|
+
// Step 1: Move sessions
|
|
100
|
+
if (report.sessionsToMove.length > 0) {
|
|
101
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
102
|
+
for (const file of report.sessionsToMove) {
|
|
103
|
+
renameSync(join(knowledgeDir, file), join(sessionsDir, file));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Step 2: Delete empty notes
|
|
107
|
+
for (const file of report.emptyToDelete) {
|
|
108
|
+
unlinkSync(join(knowledgeDir, file));
|
|
109
|
+
}
|
|
110
|
+
// Step 3: Rename ## Insight to ## Synthesis, add ## Sources if missing
|
|
111
|
+
for (const file of report.insightToRename) {
|
|
112
|
+
const filePath = join(knowledgeDir, file);
|
|
113
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
114
|
+
let updated = content.replace(/^## Insight$/gm, '## Synthesis');
|
|
115
|
+
// Add ## Sources section if missing (new notes get it by default)
|
|
116
|
+
if (!findSection(updated, 'Sources')) {
|
|
117
|
+
updated = updated.trimEnd() + '\n\n## Sources\n';
|
|
118
|
+
}
|
|
119
|
+
writeFileSync(filePath, updated);
|
|
120
|
+
}
|
|
121
|
+
// Step 4: Generate ## Related sections
|
|
122
|
+
for (const relPath of report.relatedToGenerate) {
|
|
123
|
+
const filePath = join(vaultPath, relPath);
|
|
124
|
+
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
125
|
+
const newBody = syncRelatedSection(body, fm);
|
|
126
|
+
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
127
|
+
}
|
|
128
|
+
// Step 5: Update .gitignore
|
|
129
|
+
if (report.gitignoreNeeded) {
|
|
130
|
+
appendFileSync(gitignorePath, '\n/index.md\n');
|
|
131
|
+
}
|
|
132
|
+
// Git commit only the files this migration touched
|
|
133
|
+
const filesToStage = [];
|
|
134
|
+
for (const f of report.sessionsToMove) {
|
|
135
|
+
filesToStage.push(`knowledge/${f}`); // deleted from knowledge/
|
|
136
|
+
filesToStage.push(`sessions/${f}`); // added to sessions/
|
|
137
|
+
}
|
|
138
|
+
for (const f of report.emptyToDelete)
|
|
139
|
+
filesToStage.push(`knowledge/${f}`);
|
|
140
|
+
for (const f of report.insightToRename)
|
|
141
|
+
filesToStage.push(`knowledge/${f}`);
|
|
142
|
+
for (const relPath of report.relatedToGenerate)
|
|
143
|
+
filesToStage.push(relPath);
|
|
144
|
+
if (report.gitignoreNeeded)
|
|
145
|
+
filesToStage.push('.gitignore');
|
|
146
|
+
if (filesToStage.length > 0) {
|
|
147
|
+
spawnSync('git', ['add', '--', ...filesToStage], { cwd: vaultPath, stdio: 'pipe' });
|
|
148
|
+
}
|
|
149
|
+
const commitResult = spawnSync('git', ['commit', '-m', 'migrate: knowledge-v2 (wiki links, synthesis, session move, cleanup)'], { cwd: vaultPath, stdio: 'pipe' });
|
|
150
|
+
if (commitResult.status !== 0) {
|
|
151
|
+
const stderr = commitResult.stderr?.toString() ?? 'unknown error';
|
|
152
|
+
process.stderr.write(`Warning: git commit failed: ${stderr}\n`);
|
|
153
|
+
report.gitCommitFailed = true;
|
|
154
|
+
}
|
|
155
|
+
// Post-commit: generate index.md (not committed — gitignored)
|
|
156
|
+
generateVaultIndex(vaultPath);
|
|
157
|
+
return report;
|
|
158
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/migrate-next-actions.ts
|
|
2
|
+
import { readdirSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { parseFilePath, serializeFile } from './vault.js';
|
|
5
|
+
import { addNextAction } from './sections.js';
|
|
6
|
+
export function migrateNextActions(vaultPath, apply) {
|
|
7
|
+
const entDir = join(vaultPath, 'entities');
|
|
8
|
+
const files = readdirSync(entDir).filter(f => f.endsWith('.md'));
|
|
9
|
+
const report = { candidates: 0, migrated: 0, details: [] };
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
const filePath = join(entDir, file);
|
|
12
|
+
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
13
|
+
const nextAction = fm['next-action'];
|
|
14
|
+
// Skip if no next-action or it's null/empty
|
|
15
|
+
if (!nextAction || typeof nextAction !== 'string' || nextAction.trim() === '')
|
|
16
|
+
continue;
|
|
17
|
+
// Skip if already has a Next Actions section (idempotent)
|
|
18
|
+
if (body.includes('## Next Actions')) {
|
|
19
|
+
report.details.push(`${file}: skipped (already has ## Next Actions)`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
report.candidates++;
|
|
23
|
+
report.details.push(`${file}: next-action = "${nextAction}"`);
|
|
24
|
+
if (apply) {
|
|
25
|
+
const newBody = addNextAction(body, nextAction);
|
|
26
|
+
delete fm['next-action'];
|
|
27
|
+
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
28
|
+
report.migrated++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return report;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface MigrationReport {
|
|
2
|
+
typeChanges: number;
|
|
3
|
+
statusChanges: number;
|
|
4
|
+
knowledgeSubtypeAdded: number;
|
|
5
|
+
bodySectionsRestructured: number;
|
|
6
|
+
malformed: string[];
|
|
7
|
+
skipped: string[];
|
|
8
|
+
details: string[];
|
|
9
|
+
applied: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function migrateTypes(vaultPath: string, apply: boolean): MigrationReport;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// src/migrate-types.ts
|
|
2
|
+
import { readdirSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { parseFilePath, serializeFile } from './vault.js';
|
|
5
|
+
// --- Type and status mapping tables ---
|
|
6
|
+
const TYPE_MAP = {
|
|
7
|
+
thread: 'entity',
|
|
8
|
+
idea: 'entity',
|
|
9
|
+
area: 'project',
|
|
10
|
+
inbox: 'capture',
|
|
11
|
+
};
|
|
12
|
+
const STATUS_MAP = {
|
|
13
|
+
simmering: 'paused',
|
|
14
|
+
dormant: 'paused',
|
|
15
|
+
};
|
|
16
|
+
function newReport(applied) {
|
|
17
|
+
return { typeChanges: 0, statusChanges: 0, knowledgeSubtypeAdded: 0, bodySectionsRestructured: 0, malformed: [], skipped: [], details: [], applied };
|
|
18
|
+
}
|
|
19
|
+
/** Migrate a single entity file's frontmatter. Returns null if no change needed. */
|
|
20
|
+
function migrateEntityFrontmatter(fm, filename, report) {
|
|
21
|
+
const rawType = fm.type;
|
|
22
|
+
// Malformed: missing type field
|
|
23
|
+
if (rawType === undefined || rawType === null) {
|
|
24
|
+
report.malformed.push(filename);
|
|
25
|
+
report.details.push(`${filename}: SKIPPED — missing type field`);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Already-valid type and status — skip
|
|
29
|
+
const mappedType = TYPE_MAP[rawType] ?? rawType;
|
|
30
|
+
const mappedStatus = STATUS_MAP[fm.status] ?? fm.status;
|
|
31
|
+
const typeChanged = mappedType !== rawType;
|
|
32
|
+
const statusChanged = mappedStatus !== fm.status;
|
|
33
|
+
if (!typeChanged && !statusChanged) {
|
|
34
|
+
report.skipped.push(filename);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const newFm = { ...fm };
|
|
38
|
+
if (typeChanged) {
|
|
39
|
+
newFm.type = mappedType;
|
|
40
|
+
report.typeChanges++;
|
|
41
|
+
report.details.push(`${filename}: type ${rawType} → ${mappedType}`);
|
|
42
|
+
}
|
|
43
|
+
if (statusChanged) {
|
|
44
|
+
newFm.status = mappedStatus;
|
|
45
|
+
report.statusChanges++;
|
|
46
|
+
report.details.push(`${filename}: status ${fm.status} → ${mappedStatus}`);
|
|
47
|
+
}
|
|
48
|
+
return newFm;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Restructure body sections for old template types.
|
|
52
|
+
* - idea entities: ## Hypothesis → ## Context
|
|
53
|
+
* Returns the new body and whether any restructuring occurred.
|
|
54
|
+
* Checks unconditionally for ## Hypothesis — only idea entities use that heading,
|
|
55
|
+
* and this makes the rename idempotent regardless of whether the type rename already ran.
|
|
56
|
+
*/
|
|
57
|
+
function migrateEntityBody(body) {
|
|
58
|
+
let newBody = body;
|
|
59
|
+
let changed = false;
|
|
60
|
+
// idea template used ## Hypothesis where entity template expects ## Context.
|
|
61
|
+
// Check unconditionally so a partial migration (type renamed, body not yet renamed) is safe to rerun.
|
|
62
|
+
const re = /^## Hypothesis$/m;
|
|
63
|
+
if (re.test(newBody)) {
|
|
64
|
+
newBody = newBody.replace(re, '## Context');
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
return { body: newBody, changed };
|
|
68
|
+
}
|
|
69
|
+
/** Scan entity dir (and entities/archive if present) and return all .md files with their paths. */
|
|
70
|
+
function collectEntityFiles(vaultPath) {
|
|
71
|
+
const entDir = join(vaultPath, 'entities');
|
|
72
|
+
const files = readdirSync(entDir)
|
|
73
|
+
.filter(f => f.endsWith('.md'))
|
|
74
|
+
.map(f => join(entDir, f));
|
|
75
|
+
const archiveDir = join(entDir, 'archive');
|
|
76
|
+
if (existsSync(archiveDir)) {
|
|
77
|
+
readdirSync(archiveDir)
|
|
78
|
+
.filter(f => f.endsWith('.md'))
|
|
79
|
+
.forEach(f => files.push(join(archiveDir, f)));
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
export function migrateTypes(vaultPath, apply) {
|
|
84
|
+
const report = newReport(apply);
|
|
85
|
+
// --- 1. Entity files ---
|
|
86
|
+
const entityFiles = collectEntityFiles(vaultPath);
|
|
87
|
+
const entityWrites = [];
|
|
88
|
+
for (const filePath of entityFiles) {
|
|
89
|
+
const filename = relative(vaultPath, filePath);
|
|
90
|
+
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
91
|
+
const newFm = migrateEntityFrontmatter(fm, filename, report);
|
|
92
|
+
// Skip malformed files entirely — no body migration either
|
|
93
|
+
if (report.malformed.includes(filename))
|
|
94
|
+
continue;
|
|
95
|
+
// Check body restructuring (unconditional — idempotent regardless of type rename order)
|
|
96
|
+
const { body: newBody, changed: bodyChanged } = migrateEntityBody(body);
|
|
97
|
+
if (bodyChanged) {
|
|
98
|
+
report.bodySectionsRestructured++;
|
|
99
|
+
report.details.push(`${filename}: ## Hypothesis → ## Context`);
|
|
100
|
+
}
|
|
101
|
+
if (newFm === null && !bodyChanged)
|
|
102
|
+
continue; // nothing to do
|
|
103
|
+
// Use newFm if frontmatter changed, else original fm (for body-only changes)
|
|
104
|
+
const finalFm = newFm ?? fm;
|
|
105
|
+
entityWrites.push({ filePath, content: serializeFile(finalFm, newBody) });
|
|
106
|
+
}
|
|
107
|
+
// --- 2. Knowledge files: add subtype: insight ---
|
|
108
|
+
const knowledgeDir = join(vaultPath, 'knowledge');
|
|
109
|
+
const knowledgeFiles = existsSync(knowledgeDir)
|
|
110
|
+
? readdirSync(knowledgeDir).filter(f => f.endsWith('.md')).map(f => join(knowledgeDir, f))
|
|
111
|
+
: [];
|
|
112
|
+
const knowledgeWrites = [];
|
|
113
|
+
const REVIEW_KEYWORDS = /reference|guide|template|framework|checklist|how-to|integration/i;
|
|
114
|
+
for (const filePath of knowledgeFiles) {
|
|
115
|
+
const filename = relative(vaultPath, filePath);
|
|
116
|
+
const { frontmatter: fm, body } = parseFilePath(filePath);
|
|
117
|
+
if (fm.subtype !== undefined) {
|
|
118
|
+
report.skipped.push(filename);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const newFm = { ...fm, subtype: 'insight' };
|
|
122
|
+
report.knowledgeSubtypeAdded++;
|
|
123
|
+
const needsReview = REVIEW_KEYWORDS.test(filename);
|
|
124
|
+
report.details.push(`${filename}: added subtype: insight${needsReview ? ' ⚠ review — may not be an insight' : ''}`);
|
|
125
|
+
knowledgeWrites.push({ filePath, content: serializeFile(newFm, body) });
|
|
126
|
+
}
|
|
127
|
+
if (!apply)
|
|
128
|
+
return report;
|
|
129
|
+
// --- Write all changed files. Crash during write-back → git checkout HEAD -- entities/ knowledge/ ---
|
|
130
|
+
const allWrites = [...entityWrites, ...knowledgeWrites];
|
|
131
|
+
try {
|
|
132
|
+
for (const { filePath, content } of allWrites) {
|
|
133
|
+
writeFileSync(filePath, content);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
process.stderr.write(`\nMigration failed during write: ${err}\n`);
|
|
138
|
+
process.stderr.write(`Recovery: git -C ${vaultPath} checkout HEAD -- entities/ knowledge/\n`);
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
return report;
|
|
142
|
+
}
|