dsh-plugin-wiki-tools 0.10.1 → 0.12.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 +7 -14
- package/lib/lint.js +21 -0
- package/lib/vault.js +78 -4
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -164,23 +164,16 @@ export function createTools(vault, options = {}) {
|
|
|
164
164
|
},
|
|
165
165
|
render: (args, value) => [{
|
|
166
166
|
type: 'text',
|
|
167
|
-
text: typeof value === 'object' && value !== null &&
|
|
168
|
-
? `wiki_write: ${
|
|
169
|
-
:
|
|
167
|
+
text: typeof value === 'object' && value !== null && value.alreadyIngested === true
|
|
168
|
+
? `wiki_write: skipped ${args.title} — source hash unchanged (pass force: true to re-ingest)`
|
|
169
|
+
: typeof value === 'object' && value !== null && 'path' in value
|
|
170
|
+
? `wiki_write: ${value.created ? 'created' : 'updated'} ${value.path}`
|
|
171
|
+
: `wiki_write: skipped ${args.title} (source unchanged)`,
|
|
170
172
|
}],
|
|
171
173
|
},
|
|
172
174
|
async execute(args) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
sourcePath: args.source_path,
|
|
176
|
-
pagesCreated: [args.title],
|
|
177
|
-
})
|
|
178
|
-
if (tracked.alreadyIngested && args.force !== true) {
|
|
179
|
-
return { alreadyIngested: true, hash: tracked.hash, title: args.title }
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
const { extra_frontmatter: extraFrontmatter, ...rest } = args
|
|
183
|
-
return await vault.writePage({ ...rest, extraFrontmatter })
|
|
175
|
+
const { extra_frontmatter: extraFrontmatter, source_path: sourcePath, ...rest } = args
|
|
176
|
+
return await vault.writePage({ ...rest, extraFrontmatter, sourcePath })
|
|
184
177
|
},
|
|
185
178
|
presentCall: args => ({ card: 'generic', title: `Write wiki page: ${args.title}`, kind: 'other', rawInput: { title: args.title, type: args.type } }),
|
|
186
179
|
})
|
package/lib/lint.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import {
|
|
12
|
+
PAGE_STATUSES,
|
|
12
13
|
buildAliasMap,
|
|
13
14
|
collectMarkdown,
|
|
14
15
|
isMachineryPage,
|
|
@@ -37,6 +38,7 @@ export async function lintVault(root) {
|
|
|
37
38
|
const inbound = checkDeadLinks(pages, add)
|
|
38
39
|
checkOrphans(pages, inbound, indexed, add)
|
|
39
40
|
checkFrontmatterGaps(pages, add)
|
|
41
|
+
checkStatusVocabulary(pages, add)
|
|
40
42
|
checkEmptySections(pages, add)
|
|
41
43
|
await checkHotCacheStaleness(root, pages, add)
|
|
42
44
|
|
|
@@ -172,6 +174,25 @@ function checkFrontmatterGaps(pages, add) {
|
|
|
172
174
|
}
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Status values outside the lifecycle vocabulary (seed/developing/mature/
|
|
179
|
+
* evergreen). A stray value silently breaks status-based queries and
|
|
180
|
+
* promotion flows, so it is flagged even though links stay intact.
|
|
181
|
+
* @param {Pages} pages - collected pages.
|
|
182
|
+
* @param {Add} add - issue recorder.
|
|
183
|
+
*/
|
|
184
|
+
function checkStatusVocabulary(pages, add) {
|
|
185
|
+
for (const page of pages) {
|
|
186
|
+
if (isMachineryPage(page.name)) continue
|
|
187
|
+
const status = page.fields?.status
|
|
188
|
+
if (typeof status === 'string' && !PAGE_STATUSES.includes(status)) {
|
|
189
|
+
add('status-vocabulary', 'warn', page.name,
|
|
190
|
+
`status "${status}" is outside the lifecycle vocabulary (${PAGE_STATUSES.join('/')})`,
|
|
191
|
+
`Set status to one of ${PAGE_STATUSES.join('/')}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
175
196
|
/**
|
|
176
197
|
* Headings with no content before the next heading.
|
|
177
198
|
* @param {Pages} pages - collected pages.
|
package/lib/vault.js
CHANGED
|
@@ -32,6 +32,9 @@ export const TYPE_FOLDERS = {
|
|
|
32
32
|
meta: 'wiki/meta',
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Lifecycle status vocabulary; writePage rejects anything else and lint flags it. */
|
|
36
|
+
export const PAGE_STATUSES = ['seed', 'developing', 'mature', 'evergreen']
|
|
37
|
+
|
|
35
38
|
/** Master-index section headings follow the mapped folder basename, computed per vault. */
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -73,6 +76,18 @@ export function today() {
|
|
|
73
76
|
return new Date().toISOString().slice(0, 10)
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Compare a stored manifest key against a normalized vault-relative path.
|
|
81
|
+
* Hand-written entries (the wiki-ingest skill era) may carry the platform's
|
|
82
|
+
* backslash separators; matching must not depend on them.
|
|
83
|
+
* @param {string} storedKey - key as written in `.raw/.manifest.json`.
|
|
84
|
+
* @param {string} rel - normalized `.raw/…` path.
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
function sameSourceKey(storedKey, rel) {
|
|
88
|
+
return storedKey === rel || storedKey.replace(/\\/g, '/') === rel
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
/**
|
|
77
92
|
* One vault root. All bookkeeping mutations go through {@link Vault.writePage},
|
|
78
93
|
* which completes frontmatter, updates the master index, and prepends a log
|
|
@@ -274,9 +289,13 @@ export class Vault {
|
|
|
274
289
|
* @param {Record<string, unknown>} [input.extraFrontmatter] - flat schema fields to merge
|
|
275
290
|
* (related, sources, question, answer_quality, entity_type, aliases, …); must stay flat and
|
|
276
291
|
* cannot override the managed fields.
|
|
277
|
-
* @
|
|
292
|
+
* @param {string} [input.sourcePath] - vault-relative `.raw/` source this page derives from;
|
|
293
|
+
* registers/refreshes the manifest entry (sha256 delta tracking) and returns `alreadyIngested`
|
|
294
|
+
* without writing when the hash is unchanged.
|
|
295
|
+
* @param {boolean} [input.force] - with sourcePath, write even when the source hash is unchanged.
|
|
296
|
+
* @returns {Promise<{ path: string, created: boolean, title: string } | { alreadyIngested: true, title: string, sourceHash: string, detail: string }>}
|
|
278
297
|
*/
|
|
279
|
-
async writePage({ type, title, content, tags, status, summary, extraFrontmatter }) {
|
|
298
|
+
async writePage({ type, title, content, tags, status, summary, extraFrontmatter, sourcePath, force }) {
|
|
280
299
|
const path = this.pagePath(type, title)
|
|
281
300
|
return await this.enqueue(path, async () => await this.withFileLock(relative(this.root, path).split(sep).join('/'), async () => {
|
|
282
301
|
await this.assertRoot()
|
|
@@ -284,6 +303,22 @@ export class Vault {
|
|
|
284
303
|
const existing = await this.readPage(path)
|
|
285
304
|
await this.assertUniqueFilename(cleanTitle, path)
|
|
286
305
|
validateExtraFrontmatter(extraFrontmatter)
|
|
306
|
+
if (status !== undefined && !PAGE_STATUSES.includes(status)) {
|
|
307
|
+
throw new Error(`wiki-tools: status must be one of ${PAGE_STATUSES.join('/')} (got ${JSON.stringify(status)})`)
|
|
308
|
+
}
|
|
309
|
+
// Source registration is mechanical bookkeeping, not model work: when the
|
|
310
|
+
// page summarizes a `.raw/` source, the manifest entry (sha256 delta
|
|
311
|
+
// tracking) is written by this tool, and an unchanged hash is rejected
|
|
312
|
+
// unless the caller forces a re-ingest.
|
|
313
|
+
if (sourcePath !== undefined) {
|
|
314
|
+
const { hash, alreadyIngested } = await this.inspectSource(sourcePath)
|
|
315
|
+
if (alreadyIngested && force !== true) {
|
|
316
|
+
return {
|
|
317
|
+
alreadyIngested: true, title: cleanTitle, sourceHash: hash,
|
|
318
|
+
detail: 'source hash unchanged; pass force: true to re-ingest, or omit source_path for a plain page write',
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
287
322
|
const date = today()
|
|
288
323
|
const fields = {
|
|
289
324
|
...(existing?.fields ?? {}),
|
|
@@ -304,6 +339,13 @@ export class Vault {
|
|
|
304
339
|
`- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
|
|
305
340
|
])
|
|
306
341
|
this.gitCommit(`wiki: ${existing === undefined ? 'create' : 'update'} ${cleanTitle}`)
|
|
342
|
+
if (sourcePath !== undefined) {
|
|
343
|
+
await this.trackSource({
|
|
344
|
+
sourcePath,
|
|
345
|
+
pagesCreated: existing === undefined ? [cleanTitle] : [],
|
|
346
|
+
pagesUpdated: existing === undefined ? [] : [cleanTitle],
|
|
347
|
+
})
|
|
348
|
+
}
|
|
307
349
|
return { path, created: existing === undefined, title: cleanTitle }
|
|
308
350
|
}))
|
|
309
351
|
}
|
|
@@ -437,7 +479,9 @@ export class Vault {
|
|
|
437
479
|
await rm(absolute)
|
|
438
480
|
const manifestPath = join(this.root, '.raw', '.manifest.json')
|
|
439
481
|
const manifest = await readJson(manifestPath, { sources: {} })
|
|
440
|
-
|
|
482
|
+
for (const key of Object.keys(manifest.sources)) {
|
|
483
|
+
if (sameSourceKey(key, rel)) delete manifest.sources[key]
|
|
484
|
+
}
|
|
441
485
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
442
486
|
await this.prependLog(`## [${today()}] archive | ${rel}`, [
|
|
443
487
|
`- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
|
|
@@ -553,8 +597,13 @@ export class Vault {
|
|
|
553
597
|
const hash = createHash('sha256').update(raw).digest('hex')
|
|
554
598
|
const manifestPath = join(this.root, '.raw', '.manifest.json')
|
|
555
599
|
const manifest = await readJson(manifestPath, { sources: {} })
|
|
556
|
-
|
|
600
|
+
let previous
|
|
601
|
+
let twinKey
|
|
602
|
+
for (const [key, entry] of Object.entries(manifest.sources)) {
|
|
603
|
+
if (sameSourceKey(key, rel)) { previous = entry; twinKey = key; break }
|
|
604
|
+
}
|
|
557
605
|
const alreadyIngested = previous !== undefined && previous.hash === hash
|
|
606
|
+
if (twinKey !== undefined && twinKey !== rel) delete manifest.sources[twinKey]
|
|
558
607
|
manifest.sources[rel] = {
|
|
559
608
|
hash,
|
|
560
609
|
ingested_at: today(),
|
|
@@ -566,6 +615,31 @@ export class Vault {
|
|
|
566
615
|
return { hash, alreadyIngested }
|
|
567
616
|
})
|
|
568
617
|
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Hash one raw source and report whether the manifest already carries this
|
|
621
|
+
* exact hash — the wiki-ingest delta check's mechanical half.
|
|
622
|
+
* @param {string} sourcePath - vault-relative (or absolute) `.raw/` path.
|
|
623
|
+
* @returns {Promise<{ rel: string, hash: string, alreadyIngested: boolean }>}
|
|
624
|
+
*/
|
|
625
|
+
async inspectSource(sourcePath) {
|
|
626
|
+
const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
|
|
627
|
+
const rel = relative(this.root, absolute).split(sep).join('/')
|
|
628
|
+
if (!rel.startsWith('.raw/')) {
|
|
629
|
+
throw new Error('wiki-tools: source_path must be a vault-relative path under .raw/')
|
|
630
|
+
}
|
|
631
|
+
const raw = await readFile(absolute).catch(error => {
|
|
632
|
+
if (error.code === 'ENOENT') throw new Error(`wiki-tools: source ${sourcePath} not found under the vault`)
|
|
633
|
+
throw error
|
|
634
|
+
})
|
|
635
|
+
const hash = createHash('sha256').update(raw).digest('hex')
|
|
636
|
+
const manifest = await readJson(join(this.root, '.raw', '.manifest.json'), { sources: {} })
|
|
637
|
+
let previous
|
|
638
|
+
for (const [key, entry] of Object.entries(manifest.sources)) {
|
|
639
|
+
if (sameSourceKey(key, rel)) { previous = entry; break }
|
|
640
|
+
}
|
|
641
|
+
return { rel, hash, alreadyIngested: previous !== undefined && previous.hash === hash }
|
|
642
|
+
}
|
|
569
643
|
}
|
|
570
644
|
|
|
571
645
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wiki-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|