dsh-plugin-wiki-tools 0.6.0 → 0.8.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/index.js CHANGED
@@ -16,6 +16,7 @@ import z from '@deepseek-ai/schemastery'
16
16
  import { Vault } from './lib/vault.js'
17
17
  import { quickView, searchVault } from './lib/search.js'
18
18
  import { lintVault } from './lib/lint.js'
19
+ import { scaffoldVault, SCAFFOLD_MODES } from './lib/scaffold.js'
19
20
 
20
21
  export const name = 'wiki-tools'
21
22
  export const inject = ['tools']
@@ -38,8 +39,8 @@ export const Config = z.object({
38
39
  }).default({}),
39
40
  })
40
41
 
41
- const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'comparison', 'meta']
42
- const STATUSES = ['seed', 'developing', 'solid']
42
+ const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'synthesis', 'comparison', 'decision', 'session', 'meta']
43
+ const STATUSES = ['seed', 'developing', 'mature', 'evergreen']
43
44
 
44
45
  /**
45
46
  * Build the three wiki tool definitions over one vault. Exported for tests.
@@ -125,12 +126,22 @@ export function createTools(vault, options = {}) {
125
126
  status: {
126
127
  type: 'string',
127
128
  enum: STATUSES,
128
- description: 'Frontmatter status; defaults to developing (kept on update).',
129
+ description: 'Frontmatter status (seed | developing | mature | evergreen); defaults to developing (kept on update).',
129
130
  },
130
131
  summary: {
131
132
  type: 'string',
132
133
  description: 'One-line master-index entry; defaults to the first content line.',
133
134
  },
135
+ extra_frontmatter: {
136
+ type: 'object',
137
+ additionalProperties: true,
138
+ description:
139
+ 'Flat schema fields to merge into frontmatter, e.g. related, sources, question, answer_quality '
140
+ + '(question/synthesis), entity_type/role (entity), complexity/domain/aliases (concept), '
141
+ + 'source_type/author/url/key_claims (source), subjects/dimensions/verdict (comparison), '
142
+ + 'decision_date (decision). Values are scalars or scalar lists; nesting is forbidden; '
143
+ + 'managed fields (type/title/status/created/updated/tags) are rejected.',
144
+ },
134
145
  source_path: {
135
146
  type: 'string',
136
147
  description: 'Vault-relative .raw/ source this page derives from, for delta tracking.',
@@ -162,11 +173,90 @@ export function createTools(vault, options = {}) {
162
173
  return { alreadyIngested: true, hash: tracked.hash, title: args.title }
163
174
  }
164
175
  }
165
- return await vault.writePage(args)
176
+ const { extra_frontmatter: extraFrontmatter, ...rest } = args
177
+ return await vault.writePage({ ...rest, extraFrontmatter })
166
178
  },
167
179
  presentCall: args => ({ card: 'generic', title: `Write wiki page: ${args.title}`, kind: 'other', rawInput: { title: args.title, type: args.type } }),
168
180
  })
169
181
 
182
+ const wikiRename = defineTool({
183
+ name: 'wiki_rename',
184
+ description:
185
+ 'Rename one wiki page and rewrite every [[wikilink]] to it across the vault (aliases and heading '
186
+ + 'anchors preserved), move the file to the new title, retitle its frontmatter, swap its master-index '
187
+ + 'and folder _index entries, and log the rename. The append-only log and dated lint reports keep the '
188
+ + 'old name as history. Use this instead of manual file renames, which strand every inbound link.',
189
+ parameters: {
190
+ title: {
191
+ type: 'string',
192
+ required: true,
193
+ description: 'Exact current page title.',
194
+ },
195
+ new_title: {
196
+ type: 'string',
197
+ required: true,
198
+ description: 'New title; also the new filename and [[wikilink]] target. Title Case with spaces.',
199
+ },
200
+ },
201
+ output: {
202
+ schema: {
203
+ type: 'object',
204
+ additionalProperties: true,
205
+ },
206
+ render: (_args, value) => [{
207
+ type: 'text',
208
+ text: typeof value === 'object' && value !== null && 'to' in value
209
+ ? `wiki_rename: [[${value.from}]] → [[${value.to}]]; rewrote links in ${value.linksRewritten} files; page at ${value.path}`
210
+ : 'wiki_rename: failed',
211
+ }],
212
+ },
213
+ async execute(args) {
214
+ return await vault.renamePage({ title: args.title, newTitle: args.new_title })
215
+ },
216
+ presentCall: args => ({ card: 'generic', title: `Rename wiki page: ${args.title} → ${args.new_title}`, kind: 'other', rawInput: { from: args.title, to: args.new_title } }),
217
+ })
218
+
219
+ const wikiScaffold = defineTool({
220
+ name: 'wiki_scaffold',
221
+ description:
222
+ 'Scaffold a wiki vault in one call: the chosen mode\u2019s folder structure with per-folder _index.md, '
223
+ + 'the core files (wiki/index.md, log.md, hot.md, overview.md), the mode\u2019s key seed pages, a raw-source '
224
+ + 'manifest, and the vault AGENTS.md conventions file. Idempotent — existing files are kept. Modes: '
225
+ + 'generic (matches wiki_write routing), sitemap, repository, business, personal, research, book. '
226
+ + 'The result carries a suggested typeFolders config for non-generic modes to paste into the profile.',
227
+ parameters: {
228
+ mode: {
229
+ type: 'string',
230
+ required: true,
231
+ enum: Object.keys(SCAFFOLD_MODES),
232
+ description: 'Scaffold mode; pick by what the vault is for (generic for a general knowledge base).',
233
+ },
234
+ purpose: {
235
+ type: 'string',
236
+ description: 'One-line vault purpose, written into overview.md and AGENTS.md.',
237
+ },
238
+ },
239
+ output: {
240
+ schema: {
241
+ type: 'object',
242
+ additionalProperties: true,
243
+ },
244
+ render: (_args, value) => [{
245
+ type: 'text',
246
+ text: typeof value === 'object' && value !== null && 'created' in value
247
+ ? `wiki_scaffold (${value.mode}): created ${value.created.length} files, skipped ${value.skipped.length} existing`
248
+ + (Object.keys(value.suggestedTypeFolders ?? {}).length > 0
249
+ ? `; suggested typeFolders: ${JSON.stringify(value.suggestedTypeFolders)}`
250
+ : '')
251
+ : 'wiki_scaffold: failed',
252
+ }],
253
+ },
254
+ async execute(args) {
255
+ return await scaffoldVault(vault.root, args)
256
+ },
257
+ presentCall: args => ({ card: 'generic', title: `Scaffold wiki vault (${args.mode})`, kind: 'other', rawInput: { mode: args.mode } }),
258
+ })
259
+
170
260
  const wikiLint = defineTool({
171
261
  name: 'wiki_lint',
172
262
  description:
@@ -190,7 +280,7 @@ export function createTools(vault, options = {}) {
190
280
  presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
191
281
  })
192
282
 
193
- return [wikiQuery, wikiWrite, wikiLint]
283
+ return [wikiQuery, wikiWrite, wikiRename, wikiScaffold, wikiLint]
194
284
  }
195
285
 
196
286
  /**
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Vault scaffolding: the mechanical half of the wiki skill's SCAFFOLD
3
+ * operation as one call. Creates the mode's folder structure, the core wiki
4
+ * files (index, log, hot cache, overview), per-folder sub-indexes, the mode's
5
+ * key seed pages, the vault AGENTS.md conventions file, and the raw-source
6
+ * manifest. Idempotent: existing files are kept and reported as skipped.
7
+ *
8
+ * @module dsh-plugin-wiki-tools/lib/scaffold
9
+ */
10
+
11
+ import { mkdir, writeFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+ import { readFile } from 'node:fs/promises'
14
+ import { today } from './vault.js'
15
+
16
+ /**
17
+ * Folder sets per scaffold mode. `generic` matches the wiki_write routing
18
+ * (TYPE_FOLDERS); the six named modes follow the wiki skill's modes reference.
19
+ * `stubFolder` holds the mode's overview page; `stubs` are the key pages the
20
+ * mode's reference lists, each seeded into the folder named alongside it.
21
+ */
22
+ export const SCAFFOLD_MODES = {
23
+ generic: {
24
+ label: 'Generic knowledge base',
25
+ folders: ['wiki/sources', 'wiki/entities', 'wiki/concepts', 'wiki/domains', 'wiki/questions', 'wiki/comparisons', 'wiki/meta'],
26
+ stubs: [],
27
+ typeFolders: {},
28
+ },
29
+ sitemap: {
30
+ label: 'Website / sitemap',
31
+ folders: ['wiki/pages', 'wiki/structure', 'wiki/audits', 'wiki/keywords', 'wiki/entities'],
32
+ stubs: [
33
+ { title: 'Site Overview', folder: 'wiki/structure', type: 'page' },
34
+ { title: 'Navigation Structure', folder: 'wiki/structure', type: 'page' },
35
+ { title: 'Content Gaps', folder: 'wiki/audits', type: 'page' },
36
+ { title: 'Redirect Map', folder: 'wiki/audits', type: 'page' },
37
+ { title: 'Keyword Clusters', folder: 'wiki/keywords', type: 'page' },
38
+ ],
39
+ typeFolders: { source: 'wiki/pages', entity: 'wiki/entities' },
40
+ },
41
+ repository: {
42
+ label: 'GitHub / repository',
43
+ folders: ['wiki/modules', 'wiki/components', 'wiki/decisions', 'wiki/dependencies', 'wiki/flows'],
44
+ stubs: [
45
+ { title: 'Architecture Overview', folder: 'wiki/modules', type: 'module' },
46
+ { title: 'Data Flow', folder: 'wiki/flows', type: 'flow' },
47
+ { title: 'Tech Stack', folder: 'wiki/dependencies', type: 'dependency' },
48
+ { title: 'Dependency Graph', folder: 'wiki/dependencies', type: 'dependency' },
49
+ { title: 'Key Decisions', folder: 'wiki/decisions', type: 'decision' },
50
+ ],
51
+ typeFolders: { source: 'wiki/modules', comparison: 'wiki/decisions' },
52
+ },
53
+ business: {
54
+ label: 'Business / project',
55
+ folders: ['wiki/stakeholders', 'wiki/decisions', 'wiki/deliverables', 'wiki/intel', 'wiki/comms'],
56
+ stubs: [
57
+ { title: 'Project Overview', folder: 'wiki/deliverables', type: 'deliverable' },
58
+ { title: 'Stakeholder Map', folder: 'wiki/stakeholders', type: 'stakeholder' },
59
+ { title: 'Decision Log', folder: 'wiki/decisions', type: 'decision' },
60
+ { title: 'Competitor Landscape', folder: 'wiki/intel', type: 'competitor' },
61
+ ],
62
+ typeFolders: { entity: 'wiki/stakeholders', source: 'wiki/comms', decision: 'wiki/decisions' },
63
+ },
64
+ personal: {
65
+ label: 'Personal / second brain',
66
+ folders: ['wiki/goals', 'wiki/learning', 'wiki/people', 'wiki/areas', 'wiki/resources'],
67
+ stubs: [
68
+ { title: 'North Star', folder: 'wiki/goals', type: 'goal' },
69
+ { title: 'Annual Goals', folder: 'wiki/goals', type: 'goal' },
70
+ ],
71
+ typeFolders: { concept: 'wiki/learning', domain: 'wiki/areas', entity: 'wiki/people', source: 'wiki/resources' },
72
+ },
73
+ research: {
74
+ label: 'Research',
75
+ folders: ['wiki/papers', 'wiki/concepts', 'wiki/entities', 'wiki/thesis', 'wiki/gaps'],
76
+ stubs: [
77
+ { title: 'Research Overview', folder: 'wiki/thesis', type: 'thesis' },
78
+ { title: 'Open Questions', folder: 'wiki/gaps', type: 'gap' },
79
+ ],
80
+ typeFolders: { source: 'wiki/papers', question: 'wiki/gaps', concept: 'wiki/concepts' },
81
+ },
82
+ book: {
83
+ label: 'Book / course',
84
+ folders: ['wiki/characters', 'wiki/themes', 'wiki/concepts', 'wiki/timeline', 'wiki/synthesis'],
85
+ stubs: [
86
+ { title: 'Book Overview', folder: 'wiki/timeline', type: 'chapter' },
87
+ { title: 'My Takeaways', folder: 'wiki/synthesis', type: 'synthesis' },
88
+ ],
89
+ typeFolders: { concept: 'wiki/concepts', question: 'wiki/synthesis' },
90
+ },
91
+ }
92
+
93
+ /**
94
+ * Scaffold one vault for a mode. Every artifact is written only when absent,
95
+ * so re-running after a partial scaffold or over an existing vault is safe.
96
+ * @param {string} root - absolute vault root.
97
+ * @param {object} input - scaffold options.
98
+ * @param {keyof typeof SCAFFOLD_MODES} input.mode - scaffold mode.
99
+ * @param {string} [input.purpose] - one-line vault purpose for overview and AGENTS.md.
100
+ * @returns {Promise<{ mode: string, created: string[], skipped: string[], suggestedTypeFolders: Record<string, string> }>}
101
+ */
102
+ export async function scaffoldVault(root, { mode, purpose }) {
103
+ const spec = SCAFFOLD_MODES[mode]
104
+ if (spec === undefined) {
105
+ throw new Error(`wiki-tools: unknown scaffold mode "${mode}"; choose one of ${Object.keys(SCAFFOLD_MODES).join(', ')}`)
106
+ }
107
+ const date = today()
108
+ const created = []
109
+ const skipped = []
110
+ const writeIfAbsent = async (relPath, content) => {
111
+ const path = join(root, relPath)
112
+ if (await readFile(path, 'utf8').then(() => true, () => false)) {
113
+ skipped.push(relPath)
114
+ return
115
+ }
116
+ await mkdir(join(path, '..'), { recursive: true })
117
+ await writeFile(path, content, 'utf8')
118
+ created.push(relPath)
119
+ }
120
+
121
+ await writeIfAbsent('.raw/.manifest.json', '{"sources":{}}\n')
122
+ for (const folder of spec.folders) {
123
+ await writeIfAbsent(`${folder}/_index.md`, [
124
+ '---',
125
+ 'type: meta',
126
+ `title: "${folder.split('/').pop()} Index"`,
127
+ `updated: ${date}`,
128
+ '---',
129
+ '',
130
+ `# ${folder.split('/').pop()}`,
131
+ '',
132
+ ].join('\n'))
133
+ }
134
+
135
+ await writeIfAbsent('wiki/index.md', indexTemplate(date))
136
+ await writeIfAbsent('wiki/log.md', `# Wiki Log\n`)
137
+ await writeIfAbsent('wiki/hot.md', [
138
+ '---',
139
+ 'type: meta',
140
+ 'title: "Hot Cache"',
141
+ `updated: ${date}`,
142
+ '---',
143
+ '',
144
+ '# Recent Context',
145
+ '',
146
+ `Scaffolded ${date}. ${purpose ?? spec.label}.`,
147
+ '',
148
+ ].join('\n'))
149
+ await writeIfAbsent('wiki/overview.md', [
150
+ '---',
151
+ 'type: overview',
152
+ `title: "Overview"`,
153
+ `updated: ${date}`,
154
+ '---',
155
+ '',
156
+ '# Overview',
157
+ '',
158
+ purpose ?? spec.label,
159
+ '',
160
+ ].join('\n'))
161
+
162
+ for (const stub of spec.stubs) {
163
+ await writeIfAbsent(`${stub.folder}/${stub.title}.md`, [
164
+ '---',
165
+ `type: ${stub.type}`,
166
+ `title: "${stub.title}"`,
167
+ 'status: seed',
168
+ `created: ${date}`,
169
+ `updated: ${date}`,
170
+ 'tags:',
171
+ ` - ${stub.type}`,
172
+ '---',
173
+ '',
174
+ `# ${stub.title}`,
175
+ '',
176
+ `Seed page from ${mode} scaffold. Fill in.`,
177
+ '',
178
+ ].join('\n'))
179
+ }
180
+
181
+ await writeIfAbsent('AGENTS.md', agentsTemplate(mode, spec.label, purpose, date))
182
+
183
+ return { mode, created, skipped, suggestedTypeFolders: spec.typeFolders }
184
+ }
185
+
186
+ /**
187
+ * Master-index template with one empty section per generic type, in catalog
188
+ * order; named-mode vaults keep the same sections for their mapped types.
189
+ * @param {string} date - scaffold date.
190
+ * @returns {string} the index file content.
191
+ */
192
+ function indexTemplate(date) {
193
+ const sections = ['Entities', 'Concepts', 'Sources', 'Questions']
194
+ return [
195
+ '---',
196
+ 'type: meta',
197
+ 'title: "Wiki Index"',
198
+ `updated: ${date}`,
199
+ '---',
200
+ '',
201
+ '# Wiki Index',
202
+ '',
203
+ ...sections.flatMap(section => [`## ${section}`, '']),
204
+ ].join('\n')
205
+ }
206
+
207
+ /**
208
+ * Vault conventions file: the rules every contributing agent follows.
209
+ * @param {string} mode - scaffold mode id.
210
+ * @param {string} label - human-readable mode label.
211
+ * @param {string | undefined} purpose - one-line vault purpose.
212
+ * @param {string} date - scaffold date.
213
+ * @returns {string} the AGENTS.md content.
214
+ */
215
+ function agentsTemplate(mode, label, purpose, date) {
216
+ return `# Wiki Vault Conventions
217
+
218
+ Mode: ${mode} (${label})
219
+ Purpose: ${purpose ?? '(fill in)'}
220
+ Created: ${date}
221
+
222
+ ## Rules
223
+
224
+ - Every page uses flat YAML frontmatter: type, title, status, created, updated, tags at minimum.
225
+ - status is one of seed | developing | mature | evergreen.
226
+ - Wikilinks use [[Note Name]]; filenames are unique across the vault, no paths needed.
227
+ - .raw/ holds immutable sources; never modify them.
228
+ - wiki/index.md is the master catalog; every page is listed in its section.
229
+ - wiki/log.md is append-only; new entries go at the TOP; never edit past entries.
230
+ - wiki/hot.md is a ~500-word cache of recent context; overwrite it completely each update.
231
+ - Prefer the wiki tools (wiki_query, wiki_write, wiki_rename, wiki_lint) over raw file edits;
232
+ they keep frontmatter, the index, the folder _index.md files, and the log consistent.
233
+ - Contradictions between pages get > [!contradiction] callouts on both pages, never silent edits.
234
+ `
235
+ }
package/lib/vault.js CHANGED
@@ -12,8 +12,8 @@
12
12
  */
13
13
 
14
14
  import { createHash } from 'node:crypto'
15
- import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
16
- import { basename, isAbsolute, join, relative, sep } from 'node:path'
15
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
16
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
17
17
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
18
18
 
19
19
  /** Page-type to vault folder routing (the suite's generic mode). */
@@ -24,6 +24,9 @@ export const TYPE_FOLDERS = {
24
24
  domain: 'wiki/domains',
25
25
  question: 'wiki/questions',
26
26
  comparison: 'wiki/comparisons',
27
+ synthesis: 'wiki/questions',
28
+ decision: 'wiki/meta',
29
+ session: 'wiki/meta',
27
30
  meta: 'wiki/meta',
28
31
  }
29
32
 
@@ -158,25 +161,29 @@ export class Vault {
158
161
 
159
162
  /**
160
163
  * Write one wiki page with complete bookkeeping: frontmatter completion,
161
- * filename-uniqueness guard, master-index entry, and a log entry. Existing
162
- * pages keep `created` and any unknown frontmatter fields; `updated` moves
163
- * to today.
164
+ * filename-uniqueness guard, master-index entry, folder `_index.md` entry,
165
+ * and a log entry. Existing pages keep `created` and any unknown frontmatter
166
+ * fields; `updated` moves to today.
164
167
  * @param {object} input - the write request.
165
168
  * @param {string} input.type - page type (routed to a folder).
166
169
  * @param {string} input.title - page title; also the filename and wikilink target.
167
170
  * @param {string} input.content - markdown body after frontmatter.
168
171
  * @param {string[]} [input.tags] - frontmatter tags; defaults to `[type]`.
169
- * @param {string} [input.status] - frontmatter status; defaults to `developing` on create.
172
+ * @param {string} [input.status] - frontmatter status; one of seed/developing/mature/evergreen.
170
173
  * @param {string} [input.summary] - one-line index entry; defaults to the first content line.
174
+ * @param {Record<string, unknown>} [input.extraFrontmatter] - flat schema fields to merge
175
+ * (related, sources, question, answer_quality, entity_type, aliases, …); must stay flat and
176
+ * cannot override the managed fields.
171
177
  * @returns {Promise<{ path: string, created: boolean, title: string }>}
172
178
  */
173
- async writePage({ type, title, content, tags, status, summary }) {
179
+ async writePage({ type, title, content, tags, status, summary, extraFrontmatter }) {
174
180
  const path = this.pagePath(type, title)
175
181
  return await this.enqueue(path, async () => {
176
182
  await this.assertRoot()
177
183
  const cleanTitle = title.endsWith('.md') ? title.slice(0, -3) : title
178
184
  const existing = await this.readPage(path)
179
185
  await this.assertUniqueFilename(cleanTitle, path)
186
+ validateExtraFrontmatter(extraFrontmatter)
180
187
  const date = today()
181
188
  const fields = {
182
189
  ...(existing?.fields ?? {}),
@@ -186,11 +193,13 @@ export class Vault {
186
193
  created: existing?.fields?.created ?? date,
187
194
  updated: date,
188
195
  tags: tags ?? existing?.fields?.tags ?? [type],
196
+ ...(extraFrontmatter ?? {}),
189
197
  }
190
198
  const file = `---\n${stringifyYaml(fields).trimEnd()}\n---\n\n${content.replace(/^\s*\n/, '')}\n`
191
199
  await mkdir(join(path, '..'), { recursive: true })
192
200
  await writeFile(path, file, 'utf8')
193
201
  await this.updateIndex(type, cleanTitle, summary ?? firstContentLine(content))
202
+ await this.updateFolderIndex(type, cleanTitle, summary ?? firstContentLine(content))
194
203
  await this.prependLog(`## [${date}] ${existing === undefined ? 'create' : 'update'} | ${cleanTitle}`, [
195
204
  `- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
196
205
  ])
@@ -198,6 +207,119 @@ export class Vault {
198
207
  })
199
208
  }
200
209
 
210
+ /**
211
+ * Add or refresh one entry in the owning folder's `_index.md` sub-index, the
212
+ * wiki-ingest contract's per-folder catalog. Missing files are created with
213
+ * a single `## <Type>` section; existing entries are replaced in place in
214
+ * the section's dominant separator style.
215
+ * @param {string} type - page type selecting folder and section label.
216
+ * @param {string} title - page title.
217
+ * @param {string} summary - one-line description.
218
+ */
219
+ async updateFolderIndex(type, title, summary) {
220
+ const folder = this.typeFolders[type]
221
+ const indexPath = join(this.root, folder, '_index.md')
222
+ const entryPattern = new RegExp(`^\\s*-?\\s*\\[\\[${escapeRegExp(title)}\\]\\]`)
223
+ let raw = await readFile(indexPath, 'utf8').catch(() => undefined)
224
+ if (raw === undefined) {
225
+ const heading = `## ${capitalize(type)}s`
226
+ const file = `---\ntype: meta\ntitle: "${capitalize(basename(folder))} Index"\nupdated: ${today()}\n---\n\n${heading}\n\n- [[${title}]]: ${summary.replace(/\n/g, ' ')}\n`
227
+ await writeFile(indexPath, file, 'utf8')
228
+ return
229
+ }
230
+ const lines = raw.split('\n')
231
+ const at = lines.findIndex(line => entryPattern.test(line))
232
+ const entry = `- [[${title}]]: ${summary.replace(/\n/g, ' ')}`
233
+ if (at >= 0) {
234
+ lines[at] = entry
235
+ } else {
236
+ let insert = lines.length
237
+ while (insert > 0 && lines[insert - 1].trim() === '') insert -= 1
238
+ lines.splice(insert, 0, entry)
239
+ }
240
+ const updatedLine = lines.findIndex(line => /^updated: /.test(line))
241
+ if (updatedLine >= 0) lines[updatedLine] = `updated: ${today()}`
242
+ await writeFile(indexPath, `${lines.join('\n')}\n`, 'utf8')
243
+ }
244
+
245
+ /**
246
+ * Rename one page and rewrite every wikilink to it across the vault:
247
+ * `[[Old]]` → `[[New]]`, preserving aliases (`[[Old|x]]` → `[[New|x]]`) and
248
+ * heading anchors. The append-only log and dated lint reports are historical
249
+ * records and keep the old name; frontmatter aliases keep resolving.
250
+ * @param {object} input - the rename request.
251
+ * @param {string} input.title - exact current page title.
252
+ * @param {string} input.newTitle - the new title and filename.
253
+ * @returns {Promise<{ from: string, to: string, path: string, linksRewritten: number, filesRewritten: string[] }>}
254
+ */
255
+ async renamePage({ title, newTitle }) {
256
+ return await this.enqueue(join(this.root, 'wiki'), async () => {
257
+ await this.assertRoot()
258
+ const cleanNew = newTitle.endsWith('.md') ? newTitle.slice(0, -3) : newTitle
259
+ if (!/^[^/\\]+(\.md)?$/.test(newTitle) || newTitle.includes('\n')) {
260
+ throw new Error(`wiki-tools: newTitle must be a plain filename without path separators (got ${JSON.stringify(newTitle)})`)
261
+ }
262
+ if (cleanNew === title) throw new Error('wiki-tools: newTitle equals the current title')
263
+ const pages = await collectMarkdown(join(this.root, 'wiki'))
264
+ const target = pages.find(page => page.name === title)
265
+ if (target === undefined) throw new Error(`wiki-tools: no page named "${title}" exists in the vault`)
266
+ if (pages.some(page => page.name !== title && page.name.toLowerCase() === cleanNew.toLowerCase())) {
267
+ throw new Error(`wiki-tools: filename "${cleanNew}.md" already exists; wikilinks need unique filenames`)
268
+ }
269
+ const type = typeof target.fields?.type === 'string' && target.fields.type in this.typeFolders
270
+ ? target.fields.type
271
+ : 'meta'
272
+
273
+ // 1. Rewrite links everywhere except immutable records.
274
+ const rewritten = []
275
+ for (const page of pages) {
276
+ if (page.name === title || page.name.toLowerCase() === 'log' || /^lint-report-/.test(page.name)) continue
277
+ const raw = await readFile(page.path, 'utf8').catch(() => undefined)
278
+ if (raw === undefined) continue
279
+ const pattern = new RegExp(`\\[\\[${escapeRegExp(title)}(\]\]|\||#)`, 'g')
280
+ const updated = raw.replace(pattern, `[[${cleanNew}$1`)
281
+ if (updated !== raw) {
282
+ await writeFile(page.path, updated, 'utf8')
283
+ rewritten.push(page.name)
284
+ }
285
+ }
286
+
287
+ // 2. Move the page and retitle its frontmatter.
288
+ const newPath = this.pagePath(type, cleanNew)
289
+ const oldRaw = await readFile(target.path, 'utf8')
290
+ const retitled = oldRaw.replace(/^(title:.*)$/m, `title: ${cleanNew}`)
291
+ await mkdir(dirname(newPath), { recursive: true })
292
+ await writeFile(newPath, retitled, 'utf8')
293
+ if (newPath !== target.path) await rm(target.path)
294
+
295
+ // 3. Swap index entries: drop the old lines, add the new.
296
+ await this.removeIndexEntries(title)
297
+ const summary = firstContentLine(splitFrontmatter(retitled, newPath).content)
298
+ await this.updateIndex(type, cleanNew, summary)
299
+ await this.updateFolderIndex(type, cleanNew, summary)
300
+ await this.prependLog(`## [${today()}] rename | ${title}`, [
301
+ `- Renamed: [[${title}]] → [[${cleanNew}]] (${rewritten.length} files' links rewritten)`,
302
+ ])
303
+ return { from: title, to: cleanNew, path: newPath, linksRewritten: rewritten.length, filesRewritten: rewritten }
304
+ })
305
+ }
306
+
307
+ /**
308
+ * Remove one page's entry lines from the master index and its folder
309
+ * `_index.md`, used when a rename replaces rather than refreshes.
310
+ * @param {string} title - page title whose entries are removed.
311
+ */
312
+ async removeIndexEntries(title) {
313
+ const pattern = new RegExp(`^\\s*-?\\s*\\[\\[${escapeRegExp(title)}\\]\\]`)
314
+ for (const indexPath of [join(this.root, 'wiki', 'index.md'), ...Object.values(this.typeFolders).map(folder => join(this.root, folder, '_index.md'))]) {
315
+ const raw = await readFile(indexPath, 'utf8').catch(() => undefined)
316
+ if (raw === undefined) continue
317
+ const lines = raw.split('\n')
318
+ const filtered = lines.filter(line => !pattern.test(line))
319
+ if (filtered.length !== lines.length) await writeFile(indexPath, `${filtered.join('\n')}\n`, 'utf8')
320
+ }
321
+ }
322
+
201
323
  /**
202
324
  * Reject a title whose filename already exists elsewhere in the tree:
203
325
  * wikilinks address pages by bare filename, so duplicates break resolution.
@@ -325,6 +447,33 @@ function escapeRegExp(value) {
325
447
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
326
448
  }
327
449
 
450
+ /** Frontmatter fields writePage manages itself; extraFrontmatter cannot override them. */
451
+ const MANAGED_FIELDS = new Set(['type', 'title', 'status', 'created', 'updated', 'tags'])
452
+
453
+ /**
454
+ * Validate caller-supplied schema fields: flat mapping of primitives or lists
455
+ * of primitives (the vault schema forbids nesting for Obsidian's Properties UI).
456
+ * @param {Record<string, unknown> | undefined} extra - the extraFrontmatter input.
457
+ * @returns {void} throws on any violation.
458
+ */
459
+ function validateExtraFrontmatter(extra) {
460
+ if (extra === undefined) return
461
+ if (typeof extra !== 'object' || extra === null || Array.isArray(extra)) {
462
+ throw new Error('wiki-tools: extraFrontmatter must be a flat object of schema fields')
463
+ }
464
+ for (const [key, value] of Object.entries(extra)) {
465
+ if (MANAGED_FIELDS.has(key)) {
466
+ throw new Error(`wiki-tools: extraFrontmatter cannot override managed field "${key}"`)
467
+ }
468
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
469
+ throw new Error(`wiki-tools: extraFrontmatter must stay flat; "${key}" is an object (the schema forbids nesting)`)
470
+ }
471
+ if (Array.isArray(value) && value.some(item => item !== null && typeof item === 'object')) {
472
+ throw new Error(`wiki-tools: extraFrontmatter list "${key}" may hold only scalars`)
473
+ }
474
+ }
475
+ }
476
+
328
477
  /**
329
478
  * Pick the index separator for one entry: reuse the matched line's style, else
330
479
  * the section's dominant style (`: ` or ` — `), else the canonical colon.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wiki-tools",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Native DeepSeek Harness tools for an Obsidian wiki vault: wiki_query, wiki_write, and wiki_lint implement the mechanical core (path routing, delta tracking, index/log bookkeeping, health checks) of the wiki skill suite.",
5
5
  "license": "MIT",
6
6
  "type": "module",