hafez 1.0.1 → 1.0.2
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 +8 -10
- package/dist/cli/commands.d.ts +4 -8
- package/dist/cli/commands.js +12 -65
- package/dist/cli/index.js +4 -66
- package/dist/db.js +48 -32
- package/dist/export-okf.js +1 -1
- package/dist/git.d.ts +7 -0
- package/dist/git.js +175 -163
- package/dist/index.js +77 -16
- package/dist/merge.js +36 -9
- package/dist/types.d.ts +1 -3
- package/dist/vault.js +12 -1
- package/package.json +5 -2
- package/dist/migrate-knowledge-v2.d.ts +0 -11
- package/dist/migrate-knowledge-v2.js +0 -158
- package/dist/migrate-next-actions.d.ts +0 -6
- package/dist/migrate-next-actions.js +0 -32
- package/dist/migrate-types.d.ts +0 -11
- package/dist/migrate-types.js +0 -142
package/dist/types.d.ts
CHANGED
|
@@ -80,7 +80,6 @@ export interface ParsedSessionLogEntry {
|
|
|
80
80
|
}
|
|
81
81
|
export interface UpdateFields {
|
|
82
82
|
status?: EntityStatus;
|
|
83
|
-
next_action?: string | null;
|
|
84
83
|
current_state?: string;
|
|
85
84
|
session_log?: SessionLogEntry;
|
|
86
85
|
brief?: string | null;
|
|
@@ -100,7 +99,6 @@ export interface UpdateFields {
|
|
|
100
99
|
add_source?: string;
|
|
101
100
|
}
|
|
102
101
|
export interface UpdateResult {
|
|
103
|
-
previous_next_action?: string | null;
|
|
104
102
|
matched_action?: string;
|
|
105
103
|
}
|
|
106
104
|
export interface CreateEntityFields {
|
|
@@ -261,7 +259,7 @@ export type BatchOperation = {
|
|
|
261
259
|
slug: string;
|
|
262
260
|
target: 'entity' | 'project' | 'knowledge';
|
|
263
261
|
};
|
|
264
|
-
export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED';
|
|
262
|
+
export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'VAULT_LOCKED';
|
|
265
263
|
export declare class HafezError extends Error {
|
|
266
264
|
code: HafezErrorCode;
|
|
267
265
|
details?: string[] | undefined;
|
package/dist/vault.js
CHANGED
|
@@ -3,17 +3,28 @@ import matter from 'gray-matter';
|
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
import { readFileSync, existsSync } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
|
+
import { HafezError } from './types.js';
|
|
6
7
|
// Prevent gray-matter from auto-coercing YAML date strings into JS Date objects
|
|
7
8
|
const MATTER_OPTIONS = { engines: { yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) } };
|
|
8
9
|
export function slugify(name) {
|
|
10
|
+
// Unicode-aware: non-Latin letters/digits are kept, so "日本語のメモ" gets a
|
|
11
|
+
// real slug instead of collapsing to ''.
|
|
9
12
|
return name
|
|
10
13
|
.toLowerCase()
|
|
11
14
|
.replace(/['']/g, '')
|
|
12
|
-
.replace(/[
|
|
15
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
13
16
|
.replace(/^-|-$/g, '')
|
|
14
17
|
.replace(/-{2,}/g, '-');
|
|
15
18
|
}
|
|
19
|
+
// Slugs are path components: anything with separators (or a leading dot, or a
|
|
20
|
+
// Windows drive colon) could escape the vault — "../../outside/secret" reads,
|
|
21
|
+
// writes, and deletes files anywhere on disk. Block only those; spaces and
|
|
22
|
+
// non-Latin names are legitimate in a hand-edited (Obsidian) vault. Single
|
|
23
|
+
// choke point for every slug-consuming operation.
|
|
16
24
|
export function resolveFilePath(vaultPath, slug, kind) {
|
|
25
|
+
if (!slug || slug.startsWith('.') || /[/\\:\0]/.test(slug)) {
|
|
26
|
+
throw new HafezError('VALIDATION_FAILED', `Invalid slug: '${slug}'`);
|
|
27
|
+
}
|
|
17
28
|
const dir = kind === 'entity' ? 'entities' : 'knowledge';
|
|
18
29
|
return join(vaultPath, dir, `${slug}.md`);
|
|
19
30
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hafez",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"build": "tsc",
|
|
20
|
+
"build": "rm -rf dist && tsc",
|
|
21
21
|
"install:local": "mkdir -p ~/.local/bin && ln -sf \"$(pwd)/dist/cli.js\" ~/.local/bin/hafez && rm -f ~/.claude/skills/hafez/SKILL.md && mkdir -p ~/.claude/skills/hafez && cp \"$(pwd)/skill/SKILL.md\" ~/.claude/skills/hafez/SKILL.md",
|
|
22
|
+
"pretest": "npm run build",
|
|
22
23
|
"test": "vitest run",
|
|
23
24
|
"test:watch": "vitest",
|
|
24
25
|
"prepare": "npm run build"
|
|
@@ -27,11 +28,13 @@
|
|
|
27
28
|
"better-sqlite3": "^12.6.2",
|
|
28
29
|
"gray-matter": "^4.0.3",
|
|
29
30
|
"js-yaml": "^4.1.1",
|
|
31
|
+
"proper-lockfile": "^4.1.2",
|
|
30
32
|
"simple-git": "^3.33.0",
|
|
31
33
|
"zod": "^4.3.6"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
34
36
|
"@types/better-sqlite3": "^7.6.13",
|
|
37
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
35
38
|
"@types/js-yaml": "^4.0.9",
|
|
36
39
|
"@types/node": "^25.4.0",
|
|
37
40
|
"tsx": "^4.21.0",
|
|
@@ -1,11 +0,0 @@
|
|
|
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;
|
|
@@ -1,158 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/migrate-types.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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;
|
package/dist/migrate-types.js
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
}
|