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 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 && 'path' in value
168
- ? `wiki_write: ${value.created ? 'created' : 'updated'} ${value.path}`
169
- : `wiki_write: skipped ${args.title} (source unchanged)`,
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
- if (args.source_path !== undefined) {
174
- const tracked = await vault.trackSource({
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
- * @returns {Promise<{ path: string, created: boolean, title: string }>}
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
- delete manifest.sources[rel]
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
- const previous = manifest.sources[rel] ?? manifest.sources[sourcePath]
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.10.1",
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",