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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/dist/cli/batch-errors.d.ts +24 -0
  4. package/dist/cli/batch-errors.js +236 -0
  5. package/dist/cli/commands-schema.d.ts +3 -0
  6. package/dist/cli/commands-schema.js +80 -0
  7. package/dist/cli/commands.d.ts +133 -0
  8. package/dist/cli/commands.js +628 -0
  9. package/dist/cli/format.d.ts +9 -0
  10. package/dist/cli/format.js +158 -0
  11. package/dist/cli/help-agent.d.ts +6 -0
  12. package/dist/cli/help-agent.js +158 -0
  13. package/dist/cli/index.d.ts +1 -0
  14. package/dist/cli/index.js +304 -0
  15. package/dist/cli/resolve-vault.d.ts +13 -0
  16. package/dist/cli/resolve-vault.js +124 -0
  17. package/dist/cli/schema-introspect.d.ts +70 -0
  18. package/dist/cli/schema-introspect.js +372 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +3 -0
  21. package/dist/contracts.d.ts +26 -0
  22. package/dist/contracts.js +87 -0
  23. package/dist/db.d.ts +68 -0
  24. package/dist/db.js +663 -0
  25. package/dist/digest.d.ts +29 -0
  26. package/dist/digest.js +94 -0
  27. package/dist/export-okf.d.ts +12 -0
  28. package/dist/export-okf.js +176 -0
  29. package/dist/git.d.ts +12 -0
  30. package/dist/git.js +355 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.js +909 -0
  33. package/dist/knowledge-index.d.ts +3 -0
  34. package/dist/knowledge-index.js +135 -0
  35. package/dist/merge.d.ts +1 -0
  36. package/dist/merge.js +176 -0
  37. package/dist/migrate-knowledge-v2.d.ts +11 -0
  38. package/dist/migrate-knowledge-v2.js +158 -0
  39. package/dist/migrate-next-actions.d.ts +6 -0
  40. package/dist/migrate-next-actions.js +32 -0
  41. package/dist/migrate-types.d.ts +11 -0
  42. package/dist/migrate-types.js +142 -0
  43. package/dist/parser.d.ts +18 -0
  44. package/dist/parser.js +68 -0
  45. package/dist/query.d.ts +24 -0
  46. package/dist/query.js +167 -0
  47. package/dist/schema.d.ts +9 -0
  48. package/dist/schema.js +59 -0
  49. package/dist/sections.d.ts +54 -0
  50. package/dist/sections.js +297 -0
  51. package/dist/templates.d.ts +8 -0
  52. package/dist/templates.js +39 -0
  53. package/dist/types.d.ts +321 -0
  54. package/dist/types.js +11 -0
  55. package/dist/vault.d.ts +12 -0
  56. package/dist/vault.js +45 -0
  57. package/package.json +67 -0
  58. package/skill/SKILL.md +190 -0
@@ -0,0 +1,3 @@
1
+ /** Ensure every generated local-only artifact is covered by .git/info/exclude. */
2
+ export declare function ensureLocalExclude(vaultPath: string): void;
3
+ export declare function generateVaultIndex(vaultPath: string): void;
@@ -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
+ }
@@ -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,6 @@
1
+ export interface MigrationReport {
2
+ candidates: number;
3
+ migrated: number;
4
+ details: string[];
5
+ }
6
+ export declare function migrateNextActions(vaultPath: string, apply: boolean): MigrationReport;
@@ -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
+ }