@aiwayds/dsh-topics-memory 0.8.1 → 0.9.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/lib/okf.d.ts CHANGED
@@ -86,7 +86,25 @@ export declare function slugify(title: string): string;
86
86
  export declare function pathToSlug(path: string): string;
87
87
  /** `foo` → `topics/foo.md`. */
88
88
  export declare function slugToPath(slug: string): string;
89
- /** Bundle-relative paths of `depends` entries that point at topics. */
89
+ /**
90
+ * Strip every `topics/` (or `topics:` — a colon form the wild has produced)
91
+ * prefix and `.md` suffix. The canonical depends entry carries exactly one
92
+ * of each, but bundles written by the save path that re-wrapped preserved
93
+ * values (fixed in the same release) can carry any number — idempotent by
94
+ * construction.
95
+ */
96
+ export declare function unwrapTopicRef(raw: string): string;
97
+ /**
98
+ * Canonical bundle-relative path for a `depends` entry, accepting every form
99
+ * the wild has produced: bare slug (`foo`), canonical path (`topics/foo.md`),
100
+ * multi-wrapped (`topics/topics/foo.md.md`). Idempotent. A value that still
101
+ * carries a path separator or scheme after unwrapping is not a bundle-internal
102
+ * topic reference and comes back as its unwrapped self (a dead edge for the
103
+ * graph, but never re-wrapped into a fake one); the empty string means the
104
+ * entry unwrapped to nothing and should be dropped by the caller.
105
+ */
106
+ export declare function normalizeDependsEntry(raw: string): string;
107
+ /** Topic slugs referenced by `depends`, tolerant of wrapped entries. */
90
108
  export declare function dependsSlugs(fm: {
91
109
  depends: readonly string[];
92
110
  }): string[];
@@ -111,7 +129,9 @@ export declare function normalizeLinkTarget(target: string): string | undefined;
111
129
  * Topic slugs referenced from a body: `[[wikilinks]]` (with optional
112
130
  * `#heading` / `|alias`) plus markdown links pointing at bundle paths.
113
131
  * These are the human-authored edges alongside `depends` — both feed the
114
- * retrieval graph walk and the backlinks index.
132
+ * retrieval graph walk and the backlinks index. Inline code and fenced
133
+ * code blocks are stripped first: topics that document link syntax would
134
+ * otherwise mint permanent phantom edges out of their own examples.
115
135
  */
116
136
  export declare function bodyLinkSlugs(body: string): string[];
117
137
  /** The conventional profile headings, for validators and tooling. */
package/lib/okf.js CHANGED
@@ -216,9 +216,47 @@ export function pathToSlug(path) {
216
216
  export function slugToPath(slug) {
217
217
  return `topics/${slug}.md`;
218
218
  }
219
- /** Bundle-relative paths of `depends` entries that point at topics. */
219
+ /**
220
+ * Strip every `topics/` (or `topics:` — a colon form the wild has produced)
221
+ * prefix and `.md` suffix. The canonical depends entry carries exactly one
222
+ * of each, but bundles written by the save path that re-wrapped preserved
223
+ * values (fixed in the same release) can carry any number — idempotent by
224
+ * construction.
225
+ */
226
+ export function unwrapTopicRef(raw) {
227
+ let t = raw.trim();
228
+ for (;;) {
229
+ if (t.startsWith('topics/'))
230
+ t = t.slice('topics/'.length);
231
+ else if (t.startsWith('topics:'))
232
+ t = t.slice('topics:'.length);
233
+ else
234
+ break;
235
+ }
236
+ while (t.endsWith('.md'))
237
+ t = t.slice(0, -3);
238
+ return t;
239
+ }
240
+ /**
241
+ * Canonical bundle-relative path for a `depends` entry, accepting every form
242
+ * the wild has produced: bare slug (`foo`), canonical path (`topics/foo.md`),
243
+ * multi-wrapped (`topics/topics/foo.md.md`). Idempotent. A value that still
244
+ * carries a path separator or scheme after unwrapping is not a bundle-internal
245
+ * topic reference and comes back as its unwrapped self (a dead edge for the
246
+ * graph, but never re-wrapped into a fake one); the empty string means the
247
+ * entry unwrapped to nothing and should be dropped by the caller.
248
+ */
249
+ export function normalizeDependsEntry(raw) {
250
+ const t = unwrapTopicRef(raw);
251
+ if (t === '')
252
+ return '';
253
+ if (t.includes('/') || t.includes(':'))
254
+ return t;
255
+ return slugToPath(t);
256
+ }
257
+ /** Topic slugs referenced by `depends`, tolerant of wrapped entries. */
220
258
  export function dependsSlugs(fm) {
221
- return fm.depends.map(pathToSlug);
259
+ return fm.depends.map(unwrapTopicRef);
222
260
  }
223
261
  /** Render the auto-generated OKF `index.md` (progressive disclosure, §Bundle). */
224
262
  export function renderIndex(entries) {
@@ -268,16 +306,21 @@ export function normalizeLinkTarget(target) {
268
306
  * Topic slugs referenced from a body: `[[wikilinks]]` (with optional
269
307
  * `#heading` / `|alias`) plus markdown links pointing at bundle paths.
270
308
  * These are the human-authored edges alongside `depends` — both feed the
271
- * retrieval graph walk and the backlinks index.
309
+ * retrieval graph walk and the backlinks index. Inline code and fenced
310
+ * code blocks are stripped first: topics that document link syntax would
311
+ * otherwise mint permanent phantom edges out of their own examples.
272
312
  */
273
313
  export function bodyLinkSlugs(body) {
274
314
  const out = new Set();
275
- for (const m of body.matchAll(WIKILINK)) {
315
+ const prose = body
316
+ .replace(/```[\s\S]*?```/g, '')
317
+ .replace(/`[^`\n]*`/g, '');
318
+ for (const m of prose.matchAll(WIKILINK)) {
276
319
  const slug = normalizeLinkTarget(m[1]);
277
320
  if (slug !== undefined)
278
321
  out.add(slug);
279
322
  }
280
- for (const m of body.matchAll(MD_LINK)) {
323
+ for (const m of prose.matchAll(MD_LINK)) {
281
324
  const slug = normalizeLinkTarget(m[1]);
282
325
  if (slug !== undefined)
283
326
  out.add(slug);
package/lib/service.js CHANGED
@@ -446,7 +446,11 @@ export class TopicsService {
446
446
  type: 'Topic',
447
447
  title: input.title,
448
448
  tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
449
- depends: (input.depends ?? existing?.fm.depends ?? []).map(okf.slugToPath),
449
+ // normalizeDependsEntry, never bare slugToPath: the preserved-on-update
450
+ // fallback feeds already-canonical `topics/<slug>.md` entries back in,
451
+ // and wrapping those again stacked one layer per save (the double-wrap
452
+ // bug repaired on startup by BundleStore.repairDepends).
453
+ depends: dedupePaths((input.depends ?? existing?.fm.depends ?? []).map(okf.normalizeDependsEntry)),
450
454
  open_questions: input.openQuestions ?? existing?.fm.open_questions ?? [],
451
455
  impact: input.impact ?? existing?.fm.impact ?? [],
452
456
  status: input.status ?? existing?.fm.status ?? 'draft',
@@ -534,6 +538,17 @@ function dedupeLower(tags) {
534
538
  }
535
539
  return out;
536
540
  }
541
+ function dedupePaths(paths) {
542
+ const seen = new Set();
543
+ const out = [];
544
+ for (const p of paths) {
545
+ if (p === '' || seen.has(p))
546
+ continue;
547
+ seen.add(p);
548
+ out.push(p);
549
+ }
550
+ return out;
551
+ }
537
552
  function sanitizeTriggers(triggers) {
538
553
  const out = [];
539
554
  for (const t of triggers) {
package/lib/store.d.ts CHANGED
@@ -66,7 +66,8 @@ export declare class BundleStore {
66
66
  /** Last distill-lane run outcome (diagnostics for /topics status and e2e). */
67
67
  writeDistillState(state: unknown): Promise<void>;
68
68
  readDistillState(): Promise<Record<string, unknown> | undefined>;
69
- /** Create the directory skeleton, git repo, and initial index (idempotent). */
69
+ /** Create the directory skeleton, git repo, initial index, and repair
70
+ * wrapped depends entries left by older save paths (idempotent). */
70
71
  ensure(): Promise<void>;
71
72
  /** Repo initialized and not gitDisabled. */
72
73
  hasGit(): Promise<boolean>;
@@ -76,6 +77,16 @@ export declare class BundleStore {
76
77
  brokenTopics(): Promise<string[]>;
77
78
  readTopic(slug: string): Promise<okf.TopicDoc | undefined>;
78
79
  exists(slug: string): Promise<boolean>;
80
+ /**
81
+ * Upgrade repair for bundles written by the save path that stacked another
82
+ * `topics/….md` wrap onto preserved-on-update depends entries, leaving
83
+ * multi-wrapped references (`topics/topics/foo.md.md`) whose edges resolve
84
+ * to nothing. Unwraps in place behind the write queue, regenerates the
85
+ * derived files, and lands the result as one traceable commit; file
86
+ * rewrites happen even without git, the commit just doesn't. Idempotent —
87
+ * every clean startup re-scans and finds nothing to change.
88
+ */
89
+ private repairDepends;
79
90
  /** First free slug: `foo`, then `foo-2`, `foo-3`, … */
80
91
  uniqueSlug(base: string): Promise<string>;
81
92
  /**
package/lib/store.js CHANGED
@@ -28,6 +28,20 @@ async function atomicWrite(file, content) {
28
28
  await writeFile(tmp, content, 'utf8');
29
29
  await rename(tmp, file);
30
30
  }
31
+ function dedupePaths(paths) {
32
+ const seen = new Set();
33
+ const out = [];
34
+ for (const p of paths) {
35
+ if (p === '' || seen.has(p))
36
+ continue;
37
+ seen.add(p);
38
+ out.push(p);
39
+ }
40
+ return out;
41
+ }
42
+ function sameStrings(a, b) {
43
+ return a.length === b.length && a.every((v, i) => v === b[i]);
44
+ }
31
45
  export class BundleStore {
32
46
  root;
33
47
  actor;
@@ -79,7 +93,8 @@ export class BundleStore {
79
93
  return undefined;
80
94
  }
81
95
  }
82
- /** Create the directory skeleton, git repo, and initial index (idempotent). */
96
+ /** Create the directory skeleton, git repo, initial index, and repair
97
+ * wrapped depends entries left by older save paths (idempotent). */
83
98
  async ensure() {
84
99
  await mkdir(this.topicsDir(), { recursive: true });
85
100
  await mkdir(this.metaDir(), { recursive: true });
@@ -89,6 +104,7 @@ export class BundleStore {
89
104
  if (!(await existsSync(join(this.root, 'index.md')))) {
90
105
  await this.enqueue(() => this.regenerateIndex());
91
106
  }
107
+ await this.repairDepends();
92
108
  }
93
109
  /** Repo initialized and not gitDisabled. */
94
110
  async hasGit() {
@@ -170,6 +186,49 @@ export class BundleStore {
170
186
  async exists(slug) {
171
187
  return existsSync(this.topicPath(slug));
172
188
  }
189
+ /**
190
+ * Upgrade repair for bundles written by the save path that stacked another
191
+ * `topics/….md` wrap onto preserved-on-update depends entries, leaving
192
+ * multi-wrapped references (`topics/topics/foo.md.md`) whose edges resolve
193
+ * to nothing. Unwraps in place behind the write queue, regenerates the
194
+ * derived files, and lands the result as one traceable commit; file
195
+ * rewrites happen even without git, the commit just doesn't. Idempotent —
196
+ * every clean startup re-scans and finds nothing to change.
197
+ */
198
+ async repairDepends() {
199
+ await this.enqueue(async () => {
200
+ let files;
201
+ try {
202
+ files = await readdir(this.topicsDir());
203
+ }
204
+ catch {
205
+ return;
206
+ }
207
+ const changed = [];
208
+ for (const f of files) {
209
+ if (!f.endsWith('.md') || f === 'index.md')
210
+ continue;
211
+ const file = join(this.topicsDir(), f);
212
+ let doc;
213
+ try {
214
+ doc = okf.parseTopicDoc(await readFile(file, 'utf8'));
215
+ }
216
+ catch {
217
+ continue; // broken files are status()'s business, not the repair's
218
+ }
219
+ const normalized = dedupePaths(doc.fm.depends.map(okf.normalizeDependsEntry));
220
+ if (sameStrings(normalized, doc.fm.depends))
221
+ continue;
222
+ doc.fm.depends = normalized;
223
+ await atomicWrite(file, okf.serializeTopicDoc(doc));
224
+ changed.push(`topics/${f}`);
225
+ }
226
+ if (changed.length === 0)
227
+ return;
228
+ await this.regenerateIndex();
229
+ await this.commit([...changed, 'index.md'], `topics(migrate): repair wrapped depends entries in ${changed.length} topic file(s)`);
230
+ });
231
+ }
173
232
  /** First free slug: `foo`, then `foo-2`, `foo-3`, … */
174
233
  async uniqueSlug(base) {
175
234
  const clean = okf.slugify(base);
@@ -508,13 +567,19 @@ export class BundleStore {
508
567
  }
509
568
  async setConflicts(paths) {
510
569
  await this.enqueue(async () => {
511
- if (paths.length === 0) {
570
+ // Only topic files belong here: a rebase can also conflict on meta
571
+ // sidecars (observations.jsonl is appended on both machines), and
572
+ // storing those leaked non-topic paths into the conflicts mark, where
573
+ // /topics status displayed them as if they were topics and the demote
574
+ // gate keyed on slugs that can never exist.
575
+ const topics = [...new Set(paths.filter((p) => /^topics\/.+\.md$/.test(p)))];
576
+ if (topics.length === 0) {
512
577
  await atomicWrite(this.conflictsPath(), '[]\n');
513
578
  }
514
579
  else {
515
- await atomicWrite(this.conflictsPath(), `${JSON.stringify([...paths], null, 2)}\n`);
580
+ await atomicWrite(this.conflictsPath(), `${JSON.stringify(topics, null, 2)}\n`);
516
581
  }
517
- await this.commit(['meta/conflicts.json'], `topics(meta): mark ${paths.length} conflicted topic(s)`);
582
+ await this.commit(['meta/conflicts.json'], `topics(meta): mark ${topics.length} conflicted topic(s)`);
518
583
  });
519
584
  }
520
585
  // ------------------------------------------------------------------
package/lib/viz.js CHANGED
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * @module viz
11
11
  */
12
+ import { unwrapTopicRef } from "./okf.js";
12
13
  /** Build the relationship graph: depends edges + body-link edges, no self-loops. */
13
14
  export function buildGraph(roster) {
14
15
  const bySlug = new Set(roster.map((r) => r.slug));
@@ -31,7 +32,9 @@ export function buildGraph(roster) {
31
32
  };
32
33
  for (const topic of roster) {
33
34
  for (const dep of topic.depends) {
34
- const target = dep.replace(/^topics\//, '').replace(/\.md$/, '');
35
+ // unwrapTopicRef, not a single strip: pre-repair bundles can carry
36
+ // multi-wrapped depends entries, which would render a dead node here.
37
+ const target = unwrapTopicRef(dep);
35
38
  addEdge(topic.slug, target, 'depends');
36
39
  }
37
40
  for (const link of topic.links ?? [])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "dsh-plugin: OKF v0.2 topic memory for dsh - local-first git-tracked bundle, hot-path LLM-free injection, two-stage observer with background distill",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -38,7 +38,7 @@
38
38
  "build": "tsc -p tsconfig.json",
39
39
  "check": "tsc --noEmit -p tsconfig.json",
40
40
  "pretest": "tsc -p tsconfig.json",
41
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs",
41
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs",
42
42
  "smoke": "node scripts/smoke-boot.mjs"
43
43
  },
44
44
  "dependencies": {},
package/src/okf.ts CHANGED
@@ -254,9 +254,43 @@ export function slugToPath(slug: string): string {
254
254
  return `topics/${slug}.md`
255
255
  }
256
256
 
257
- /** Bundle-relative paths of `depends` entries that point at topics. */
257
+ /**
258
+ * Strip every `topics/` (or `topics:` — a colon form the wild has produced)
259
+ * prefix and `.md` suffix. The canonical depends entry carries exactly one
260
+ * of each, but bundles written by the save path that re-wrapped preserved
261
+ * values (fixed in the same release) can carry any number — idempotent by
262
+ * construction.
263
+ */
264
+ export function unwrapTopicRef(raw: string): string {
265
+ let t = raw.trim()
266
+ for (;;) {
267
+ if (t.startsWith('topics/')) t = t.slice('topics/'.length)
268
+ else if (t.startsWith('topics:')) t = t.slice('topics:'.length)
269
+ else break
270
+ }
271
+ while (t.endsWith('.md')) t = t.slice(0, -3)
272
+ return t
273
+ }
274
+
275
+ /**
276
+ * Canonical bundle-relative path for a `depends` entry, accepting every form
277
+ * the wild has produced: bare slug (`foo`), canonical path (`topics/foo.md`),
278
+ * multi-wrapped (`topics/topics/foo.md.md`). Idempotent. A value that still
279
+ * carries a path separator or scheme after unwrapping is not a bundle-internal
280
+ * topic reference and comes back as its unwrapped self (a dead edge for the
281
+ * graph, but never re-wrapped into a fake one); the empty string means the
282
+ * entry unwrapped to nothing and should be dropped by the caller.
283
+ */
284
+ export function normalizeDependsEntry(raw: string): string {
285
+ const t = unwrapTopicRef(raw)
286
+ if (t === '') return ''
287
+ if (t.includes('/') || t.includes(':')) return t
288
+ return slugToPath(t)
289
+ }
290
+
291
+ /** Topic slugs referenced by `depends`, tolerant of wrapped entries. */
258
292
  export function dependsSlugs(fm: { depends: readonly string[] }): string[] {
259
- return fm.depends.map(pathToSlug)
293
+ return fm.depends.map(unwrapTopicRef)
260
294
  }
261
295
 
262
296
  export interface IndexEntry {
@@ -312,15 +346,20 @@ export function normalizeLinkTarget(target: string): string | undefined {
312
346
  * Topic slugs referenced from a body: `[[wikilinks]]` (with optional
313
347
  * `#heading` / `|alias`) plus markdown links pointing at bundle paths.
314
348
  * These are the human-authored edges alongside `depends` — both feed the
315
- * retrieval graph walk and the backlinks index.
349
+ * retrieval graph walk and the backlinks index. Inline code and fenced
350
+ * code blocks are stripped first: topics that document link syntax would
351
+ * otherwise mint permanent phantom edges out of their own examples.
316
352
  */
317
353
  export function bodyLinkSlugs(body: string): string[] {
318
354
  const out = new Set<string>()
319
- for (const m of body.matchAll(WIKILINK)) {
355
+ const prose = body
356
+ .replace(/```[\s\S]*?```/g, '')
357
+ .replace(/`[^`\n]*`/g, '')
358
+ for (const m of prose.matchAll(WIKILINK)) {
320
359
  const slug = normalizeLinkTarget(m[1])
321
360
  if (slug !== undefined) out.add(slug)
322
361
  }
323
- for (const m of body.matchAll(MD_LINK)) {
362
+ for (const m of prose.matchAll(MD_LINK)) {
324
363
  const slug = normalizeLinkTarget(m[1])
325
364
  if (slug !== undefined) out.add(slug)
326
365
  }
package/src/service.ts CHANGED
@@ -484,7 +484,11 @@ export class TopicsService {
484
484
  type: 'Topic',
485
485
  title: input.title,
486
486
  tags: dedupeLower(input.tags ?? existing?.fm.tags ?? []),
487
- depends: (input.depends ?? existing?.fm.depends ?? []).map(okf.slugToPath),
487
+ // normalizeDependsEntry, never bare slugToPath: the preserved-on-update
488
+ // fallback feeds already-canonical `topics/<slug>.md` entries back in,
489
+ // and wrapping those again stacked one layer per save (the double-wrap
490
+ // bug repaired on startup by BundleStore.repairDepends).
491
+ depends: dedupePaths((input.depends ?? existing?.fm.depends ?? []).map(okf.normalizeDependsEntry)),
488
492
  open_questions: input.openQuestions ?? existing?.fm.open_questions ?? [],
489
493
  impact: input.impact ?? existing?.fm.impact ?? [],
490
494
  status: input.status ?? existing?.fm.status ?? 'draft',
@@ -577,6 +581,17 @@ function dedupeLower(tags: string[]): string[] {
577
581
  return out
578
582
  }
579
583
 
584
+ function dedupePaths(paths: string[]): string[] {
585
+ const seen = new Set<string>()
586
+ const out: string[] = []
587
+ for (const p of paths) {
588
+ if (p === '' || seen.has(p)) continue
589
+ seen.add(p)
590
+ out.push(p)
591
+ }
592
+ return out
593
+ }
594
+
580
595
  function sanitizeTriggers(triggers: readonly string[]): string[] {
581
596
  const out: string[] = []
582
597
  for (const t of triggers) {
package/src/store.ts CHANGED
@@ -77,6 +77,21 @@ async function atomicWrite(file: string, content: string): Promise<void> {
77
77
  await rename(tmp, file)
78
78
  }
79
79
 
80
+ function dedupePaths(paths: readonly string[]): string[] {
81
+ const seen = new Set<string>()
82
+ const out: string[] = []
83
+ for (const p of paths) {
84
+ if (p === '' || seen.has(p)) continue
85
+ seen.add(p)
86
+ out.push(p)
87
+ }
88
+ return out
89
+ }
90
+
91
+ function sameStrings(a: readonly string[], b: readonly string[]): boolean {
92
+ return a.length === b.length && a.every((v, i) => v === b[i])
93
+ }
94
+
80
95
  export interface BundleStoreOptions {
81
96
  /** Override the stamped actor (tests). Defaults to `agent:dsh-topics-memory@<host>`. */
82
97
  actor?: string
@@ -146,7 +161,8 @@ export class BundleStore {
146
161
  }
147
162
  }
148
163
 
149
- /** Create the directory skeleton, git repo, and initial index (idempotent). */
164
+ /** Create the directory skeleton, git repo, initial index, and repair
165
+ * wrapped depends entries left by older save paths (idempotent). */
150
166
  async ensure(): Promise<void> {
151
167
  await mkdir(this.topicsDir(), { recursive: true })
152
168
  await mkdir(this.metaDir(), { recursive: true })
@@ -156,6 +172,7 @@ export class BundleStore {
156
172
  if (!(await existsSync(join(this.root, 'index.md')))) {
157
173
  await this.enqueue(() => this.regenerateIndex())
158
174
  }
175
+ await this.repairDepends()
159
176
  }
160
177
 
161
178
  /** Repo initialized and not gitDisabled. */
@@ -234,6 +251,45 @@ export class BundleStore {
234
251
  return existsSync(this.topicPath(slug))
235
252
  }
236
253
 
254
+ /**
255
+ * Upgrade repair for bundles written by the save path that stacked another
256
+ * `topics/….md` wrap onto preserved-on-update depends entries, leaving
257
+ * multi-wrapped references (`topics/topics/foo.md.md`) whose edges resolve
258
+ * to nothing. Unwraps in place behind the write queue, regenerates the
259
+ * derived files, and lands the result as one traceable commit; file
260
+ * rewrites happen even without git, the commit just doesn't. Idempotent —
261
+ * every clean startup re-scans and finds nothing to change.
262
+ */
263
+ private async repairDepends(): Promise<void> {
264
+ await this.enqueue(async () => {
265
+ let files: string[]
266
+ try {
267
+ files = await readdir(this.topicsDir())
268
+ } catch {
269
+ return
270
+ }
271
+ const changed: string[] = []
272
+ for (const f of files) {
273
+ if (!f.endsWith('.md') || f === 'index.md') continue
274
+ const file = join(this.topicsDir(), f)
275
+ let doc: okf.TopicDoc
276
+ try {
277
+ doc = okf.parseTopicDoc(await readFile(file, 'utf8'))
278
+ } catch {
279
+ continue // broken files are status()'s business, not the repair's
280
+ }
281
+ const normalized = dedupePaths(doc.fm.depends.map(okf.normalizeDependsEntry))
282
+ if (sameStrings(normalized, doc.fm.depends)) continue
283
+ doc.fm.depends = normalized
284
+ await atomicWrite(file, okf.serializeTopicDoc(doc))
285
+ changed.push(`topics/${f}`)
286
+ }
287
+ if (changed.length === 0) return
288
+ await this.regenerateIndex()
289
+ await this.commit([...changed, 'index.md'], `topics(migrate): repair wrapped depends entries in ${changed.length} topic file(s)`)
290
+ })
291
+ }
292
+
237
293
  /** First free slug: `foo`, then `foo-2`, `foo-3`, … */
238
294
  async uniqueSlug(base: string): Promise<string> {
239
295
  const clean = okf.slugify(base)
@@ -575,12 +631,18 @@ export class BundleStore {
575
631
 
576
632
  async setConflicts(paths: readonly string[]): Promise<void> {
577
633
  await this.enqueue(async () => {
578
- if (paths.length === 0) {
634
+ // Only topic files belong here: a rebase can also conflict on meta
635
+ // sidecars (observations.jsonl is appended on both machines), and
636
+ // storing those leaked non-topic paths into the conflicts mark, where
637
+ // /topics status displayed them as if they were topics and the demote
638
+ // gate keyed on slugs that can never exist.
639
+ const topics = [...new Set(paths.filter((p) => /^topics\/.+\.md$/.test(p)))]
640
+ if (topics.length === 0) {
579
641
  await atomicWrite(this.conflictsPath(), '[]\n')
580
642
  } else {
581
- await atomicWrite(this.conflictsPath(), `${JSON.stringify([...paths], null, 2)}\n`)
643
+ await atomicWrite(this.conflictsPath(), `${JSON.stringify(topics, null, 2)}\n`)
582
644
  }
583
- await this.commit(['meta/conflicts.json'], `topics(meta): mark ${paths.length} conflicted topic(s)`)
645
+ await this.commit(['meta/conflicts.json'], `topics(meta): mark ${topics.length} conflicted topic(s)`)
584
646
  })
585
647
  }
586
648
 
package/src/viz.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { RetrievableTopic } from './retrieval.ts'
14
+ import { unwrapTopicRef } from './okf.ts'
14
15
 
15
16
  export interface VizNode {
16
17
  id: string
@@ -51,7 +52,9 @@ export function buildGraph(roster: readonly RetrievableTopic[]): VizGraph {
51
52
  }
52
53
  for (const topic of roster) {
53
54
  for (const dep of topic.depends) {
54
- const target = dep.replace(/^topics\//, '').replace(/\.md$/, '')
55
+ // unwrapTopicRef, not a single strip: pre-repair bundles can carry
56
+ // multi-wrapped depends entries, which would render a dead node here.
57
+ const target = unwrapTopicRef(dep)
55
58
  addEdge(topic.slug, target, 'depends')
56
59
  }
57
60
  for (const link of topic.links ?? []) addEdge(topic.slug, link, 'link')