@treeseed/dsh-chapters 0.1.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.
Files changed (119) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +644 -0
  3. package/cordis.patch.yml +88 -0
  4. package/docs/architecture.md +460 -0
  5. package/docs/contract.md +375 -0
  6. package/docs/development.md +437 -0
  7. package/docs/host-compaction-seam.md +117 -0
  8. package/docs/knowledge-repo.md +730 -0
  9. package/docs/provider.md +98 -0
  10. package/docs/verify.md +200 -0
  11. package/lib/archive.d.ts +88 -0
  12. package/lib/archive.js +114 -0
  13. package/lib/archive.js.map +1 -0
  14. package/lib/arrival.d.ts +23 -0
  15. package/lib/arrival.js +86 -0
  16. package/lib/arrival.js.map +1 -0
  17. package/lib/artifact-query.d.ts +53 -0
  18. package/lib/artifact-query.js +130 -0
  19. package/lib/artifact-query.js.map +1 -0
  20. package/lib/client.js +234 -0
  21. package/lib/client.js.map +1 -0
  22. package/lib/commands.d.ts +28 -0
  23. package/lib/commands.js +305 -0
  24. package/lib/commands.js.map +1 -0
  25. package/lib/compose.d.ts +30 -0
  26. package/lib/compose.js +131 -0
  27. package/lib/compose.js.map +1 -0
  28. package/lib/continue-core.d.ts +153 -0
  29. package/lib/continue-core.js +252 -0
  30. package/lib/continue-core.js.map +1 -0
  31. package/lib/engine-core.d.ts +219 -0
  32. package/lib/engine-core.js +405 -0
  33. package/lib/engine-core.js.map +1 -0
  34. package/lib/engine.d.ts +211 -0
  35. package/lib/engine.js +571 -0
  36. package/lib/engine.js.map +1 -0
  37. package/lib/enrich-queue.d.ts +66 -0
  38. package/lib/enrich-queue.js +100 -0
  39. package/lib/enrich-queue.js.map +1 -0
  40. package/lib/enrich-store.d.ts +31 -0
  41. package/lib/enrich-store.js +103 -0
  42. package/lib/enrich-store.js.map +1 -0
  43. package/lib/enrich-wire.d.ts +73 -0
  44. package/lib/enrich-wire.js +209 -0
  45. package/lib/enrich-wire.js.map +1 -0
  46. package/lib/enrich.d.ts +73 -0
  47. package/lib/enrich.js +150 -0
  48. package/lib/enrich.js.map +1 -0
  49. package/lib/gitops.d.ts +107 -0
  50. package/lib/gitops.js +539 -0
  51. package/lib/gitops.js.map +1 -0
  52. package/lib/index.d.ts +81 -0
  53. package/lib/index.js +305 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/indexing.d.ts +85 -0
  56. package/lib/indexing.js +212 -0
  57. package/lib/indexing.js.map +1 -0
  58. package/lib/injections.d.ts +66 -0
  59. package/lib/injections.js +83 -0
  60. package/lib/injections.js.map +1 -0
  61. package/lib/notice.d.ts +60 -0
  62. package/lib/notice.js +84 -0
  63. package/lib/notice.js.map +1 -0
  64. package/lib/provider.d.ts +44 -0
  65. package/lib/provider.js +66 -0
  66. package/lib/provider.js.map +1 -0
  67. package/lib/redact.d.ts +24 -0
  68. package/lib/redact.js +85 -0
  69. package/lib/redact.js.map +1 -0
  70. package/lib/registry.d.ts +120 -0
  71. package/lib/registry.js +130 -0
  72. package/lib/registry.js.map +1 -0
  73. package/lib/render.d.ts +40 -0
  74. package/lib/render.js +280 -0
  75. package/lib/render.js.map +1 -0
  76. package/lib/repo.d.ts +78 -0
  77. package/lib/repo.js +184 -0
  78. package/lib/repo.js.map +1 -0
  79. package/lib/rules-commands.d.ts +24 -0
  80. package/lib/rules-commands.js +138 -0
  81. package/lib/rules-commands.js.map +1 -0
  82. package/lib/rules.d.ts +90 -0
  83. package/lib/rules.js +185 -0
  84. package/lib/rules.js.map +1 -0
  85. package/lib/search.d.ts +48 -0
  86. package/lib/search.js +152 -0
  87. package/lib/search.js.map +1 -0
  88. package/lib/signature.d.ts +28 -0
  89. package/lib/signature.js +149 -0
  90. package/lib/signature.js.map +1 -0
  91. package/lib/store.d.ts +130 -0
  92. package/lib/store.js +235 -0
  93. package/lib/store.js.map +1 -0
  94. package/lib/sync.d.ts +183 -0
  95. package/lib/sync.js +719 -0
  96. package/lib/sync.js.map +1 -0
  97. package/lib/tools.d.ts +84 -0
  98. package/lib/tools.js +581 -0
  99. package/lib/tools.js.map +1 -0
  100. package/lib/treedx/client.d.ts +57 -0
  101. package/lib/treedx/client.js +82 -0
  102. package/lib/treedx/client.js.map +1 -0
  103. package/lib/treedx/provider.d.ts +39 -0
  104. package/lib/treedx/provider.js +479 -0
  105. package/lib/treedx/provider.js.map +1 -0
  106. package/lib/treedx/state.d.ts +27 -0
  107. package/lib/treedx/state.js +65 -0
  108. package/lib/treedx/state.js.map +1 -0
  109. package/lib/types.d.ts +100 -0
  110. package/lib/types.js +19 -0
  111. package/lib/types.js.map +1 -0
  112. package/lib/vocabulary.d.ts +41 -0
  113. package/lib/vocabulary.js +118 -0
  114. package/lib/vocabulary.js.map +1 -0
  115. package/package.json +132 -0
  116. package/presets/chapters/agent.cordis.yml +272 -0
  117. package/presets/chapters/preset.yml +3 -0
  118. package/presets/probe-roots/agent.cordis.yml +11 -0
  119. package/presets/probe-roots/preset.yml +3 -0
package/lib/sync.js ADDED
@@ -0,0 +1,719 @@
1
+ /**
2
+ * The sync loop (knowledge-repo.md §3.1, §5): workspace store → git mirror →
3
+ * remote. The mirror lives INSIDE the workspace (`.dsh-knowledge`) so it is
4
+ * transport, not storage (§3.1); the read path (the read tool) never touches
5
+ * the clone.
6
+ *
7
+ * Best-effort by rule (§5.3): runSync never throws into the conversation
8
+ * path — every failure returns `{ok: false, detail}` and lands in the status
9
+ * file the /chapters-status command reads.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
14
+ import { selectProvider } from "./provider.js";
15
+ import { buildIndexShards, entryFromChapter, parseCuration, stitchFragments } from "./indexing.js";
16
+ import { analyzeTopics, planAppends } from "./vocabulary.js";
17
+ /**
18
+ * Per-project credentials — stored under the DSH HOME, never the workspace
19
+ * (user directive 2026-09-18: the project tree is the agent's reading room;
20
+ * the plugin is the only accessor). 0600 file per projectKey. Note honestly:
21
+ * same-uid files are a hygiene boundary, not a cryptographic one — the real
22
+ * guarantee is that the plugin never echoes the token anywhere an agent sees.
23
+ */
24
+ export const tokenPath = (projectKey) => dshHomePath('dsh-chapters', 'credentials', projectKey);
25
+ /** Pre-move location, migrated on first read (delete after copying). */
26
+ const legacyTokenPath = (cwd, storeRoot, projectKey) => path.join(cwd, storeRoot, '.git-auth', projectKey);
27
+ export function readToken(cwd, storeRoot, projectKey) {
28
+ try {
29
+ const raw = fs.readFileSync(tokenPath(projectKey), 'utf8').trim();
30
+ if (raw.length > 0)
31
+ return raw;
32
+ }
33
+ catch { /* not in home yet */ }
34
+ const legacy = legacyTokenPath(cwd, storeRoot, projectKey);
35
+ try {
36
+ const raw = fs.readFileSync(legacy, 'utf8').trim();
37
+ if (raw.length === 0)
38
+ return undefined;
39
+ fs.mkdirSync(path.dirname(tokenPath(projectKey)), { recursive: true, mode: 0o700 });
40
+ fs.writeFileSync(tokenPath(projectKey), raw + '\n', { mode: 0o600 });
41
+ fs.rmSync(legacy, { force: true });
42
+ return raw;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ export function writeToken(projectKey, token) {
49
+ const p = tokenPath(projectKey);
50
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
51
+ fs.writeFileSync(p, token + '\n', { mode: 0o600 });
52
+ }
53
+ /**
54
+ * Deepest-cwd match (record §8): the active knowledge project for a
55
+ * workspace path. Shared by the notice line, the scheduler, and the commands.
56
+ */
57
+ export function projectForCwd(records, cwd) {
58
+ let best;
59
+ for (const [, rec] of records) {
60
+ if (cwd === rec.cwd || cwd.startsWith(`${rec.cwd}/`) || cwd.startsWith(`${rec.cwd}${path.sep}`)) {
61
+ if (best === undefined || rec.cwd.length > best.cwd.length
62
+ || (rec.cwd.length === best.cwd.length && rec.linkedAt > best.linkedAt))
63
+ best = rec;
64
+ }
65
+ }
66
+ return best;
67
+ }
68
+ /** The mirror directory, relative to the workspace (record §3.1). */
69
+ export const DEFAULT_CLONE_DIR = '.dsh-knowledge';
70
+ const LOCK_STALE_MS = 10 * 60 * 1000;
71
+ /**
72
+ * File lock around store transactions and clone ops (§5.2): the host-plane
73
+ * plugin and the realm engine may be different module instances in the same
74
+ * process, so an in-process mutex is not safe — a file is the honest lock.
75
+ * Stale locks (>10 min) are stolen; live ones make runSync skip, not block.
76
+ */
77
+ export function syncLockPath(storeDir) {
78
+ return path.join(storeDir, '.sync.lock');
79
+ }
80
+ function acquireLock(lockPath, force) {
81
+ const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
82
+ try {
83
+ if (!force) {
84
+ const existing = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, 'utf8')) : null;
85
+ if (existing !== null && Date.now() - existing.at < LOCK_STALE_MS) {
86
+ return { held: false, release() { }, detail: 'another sync is in progress (lock held) — skipped, will retry at the next archive' };
87
+ }
88
+ }
89
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
90
+ fs.writeFileSync(lockPath, payload);
91
+ return { held: true, release() { try {
92
+ fs.unlinkSync(lockPath);
93
+ }
94
+ catch { /* best-effort */ } } };
95
+ }
96
+ catch (error) {
97
+ // A lock that cannot be written is reported as such, not as "held" —
98
+ // conflating the two would hide a real failure as a benign skip.
99
+ return { held: false, release() { }, detail: `lock unavailable at ${lockPath}: ${String(error?.message ?? error)}` };
100
+ }
101
+ }
102
+ /**
103
+ * Map workspace-store files to repo paths (record §2.2):
104
+ * <storeRoot>/<rootSession>/chapters/x.md → chapters/<projectKey>/<rootSession>/x.md
105
+ * <storeRoot>/<rootSession>/artifacts/ab/ab….txt → artifacts/<projectKey>/ab/ab….txt
106
+ * Artifacts are content-addressed and shared across sessions, so their
107
+ * per-session prefix is dropped; chapters keep it.
108
+ */
109
+ export function planStoreToRepo(storeDir, projectKey) {
110
+ const out = [];
111
+ for (const sessionDir of fs.existsSync(storeDir) ? fs.readdirSync(storeDir, { withFileTypes: true }) : []) {
112
+ if (!sessionDir.isDirectory() || sessionDir.name.startsWith('.'))
113
+ continue;
114
+ // P3: the rules author tree is project-level, not session-level
115
+ if (sessionDir.name === 'rules')
116
+ continue;
117
+ const chaptersDir = path.join(storeDir, sessionDir.name, 'chapters');
118
+ if (fs.existsSync(chaptersDir)) {
119
+ for (const f of fs.readdirSync(chaptersDir)) {
120
+ if (!f.endsWith('.md'))
121
+ continue;
122
+ out.push({ abs: path.join(chaptersDir, f), rel: path.join('chapters', projectKey, sessionDir.name, f) });
123
+ }
124
+ }
125
+ const artifactsDir = path.join(storeDir, sessionDir.name, 'artifacts');
126
+ const walk = (dir, rel) => {
127
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
128
+ const p = path.join(dir, f.name);
129
+ if (f.isDirectory())
130
+ walk(p, path.join(rel, f.name));
131
+ else
132
+ out.push({ abs: p, rel: path.join('artifacts', projectKey, rel, f.name) });
133
+ }
134
+ };
135
+ if (fs.existsSync(artifactsDir))
136
+ walk(artifactsDir, '');
137
+ }
138
+ // rules/<harness>/*.md -> rules/<projectKey>/<harness>/*.md (record §7.1:
139
+ // rules are chapters of a different kind, same transport, author-partitioned)
140
+ const rulesRoot = path.join(storeDir, 'rules');
141
+ if (fs.existsSync(rulesRoot)) {
142
+ for (const h of fs.readdirSync(rulesRoot, { withFileTypes: true })) {
143
+ if (!h.isDirectory())
144
+ continue;
145
+ for (const f of fs.readdirSync(path.join(rulesRoot, h.name))) {
146
+ if (!f.endsWith('.md'))
147
+ continue;
148
+ out.push({ abs: path.join(rulesRoot, h.name, f), rel: path.join('rules', projectKey, h.name, f) });
149
+ }
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+ /**
155
+ * Copy store files into the mirror. A file absent in the mirror is new; a
156
+ * file present with DIFFERENT bytes is rewritten (P2 enrichment legitimately
157
+ * updates the frontmatter of chapter files — model-owned fields only, with
158
+ * the body hash guard in enrich-store.ts ensuring the verbatim body never
159
+ * changes). Cross-machine safety is the same argument that already governs
160
+ * collection JSONL (§3.3): paths are partitioned by rootSession, a session
161
+ * belongs to exactly one machine, so only its author rewrites its files and
162
+ * ff-only stays conflict-free. Byte-identical files are skipped.
163
+ */
164
+ function copyNewFiles(cloneDir, files) {
165
+ let copied = 0;
166
+ let skipped = 0;
167
+ for (const f of files) {
168
+ const dst = path.join(cloneDir, f.rel);
169
+ if (fs.existsSync(dst)) {
170
+ try {
171
+ if (fs.readFileSync(dst).equals(fs.readFileSync(f.abs))) {
172
+ skipped += 1;
173
+ continue;
174
+ }
175
+ }
176
+ catch {
177
+ skipped += 1;
178
+ continue;
179
+ }
180
+ }
181
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
182
+ fs.copyFileSync(f.abs, dst);
183
+ copied += 1;
184
+ }
185
+ return { copied, skipped };
186
+ }
187
+ export const statusFilePath = (cwd, storeRoot) => path.join(cwd, storeRoot, '.sync-status.json');
188
+ const topicSlug = (topic) => topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'topic';
189
+ /**
190
+ * Build the Layer-2 index inside the clone (record §10.2): entries from every
191
+ * chapter/rule file (frontmatter), curation facts from every harness's
192
+ * curation.jsonl, the manifest from the clone's index/manifest.json. Returns
193
+ * true when shards or the manifest changed (the commit step then ships them).
194
+ * Deterministic: the index is a pure function of the clone's corpus, so any
195
+ * machine can rebuild it identically (the recovery path, record §10.2).
196
+ */
197
+ export function buildIndexInClone(cloneDir) {
198
+ const entries = [];
199
+ const collect = (top, kind) => {
200
+ const root = path.join(cloneDir, top);
201
+ if (!fs.existsSync(root))
202
+ return;
203
+ const walk = (dir, rel) => {
204
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
205
+ const p = path.join(dir, e.name);
206
+ if (e.isDirectory()) {
207
+ walk(p, rel === '' ? e.name : path.join(rel, e.name));
208
+ continue;
209
+ }
210
+ if (e.name.endsWith('.md')) {
211
+ const relPath = rel === '' ? e.name : path.join(rel, e.name);
212
+ const text = fs.readFileSync(p, 'utf8');
213
+ const entry = entryFromChapter(path.join(top, relPath), text, fs.statSync(p).mtimeMs);
214
+ entries.push({ ...entry, kind });
215
+ }
216
+ }
217
+ };
218
+ walk(root, '');
219
+ };
220
+ collect('chapters', 'chapter');
221
+ collect('rules', 'rule');
222
+ const curation = [];
223
+ const editsDir = path.join(cloneDir, 'edits');
224
+ if (fs.existsSync(editsDir)) {
225
+ for (const harness of fs.readdirSync(editsDir, { withFileTypes: true })) {
226
+ if (!harness.isDirectory())
227
+ continue;
228
+ const file = path.join(editsDir, harness.name, 'curation.jsonl');
229
+ if (fs.existsSync(file))
230
+ curation.push(...parseCuration(fs.readFileSync(file, 'utf8')));
231
+ }
232
+ }
233
+ const manifestPath = path.join(cloneDir, 'index', 'manifest.json');
234
+ let manifest = {};
235
+ if (fs.existsSync(manifestPath)) {
236
+ try {
237
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
238
+ }
239
+ catch {
240
+ manifest = {};
241
+ }
242
+ }
243
+ const { shards, manifest: next, changed } = buildIndexShards(stitchFragments(entries), curation, manifest);
244
+ const indexDir = path.join(cloneDir, 'index');
245
+ if (!changed) {
246
+ if (fs.existsSync(indexDir)) {
247
+ const expected = new Set([...shards.keys()].map(topicSlug));
248
+ for (const f of fs.readdirSync(indexDir)) {
249
+ if (f.endsWith('.md') && !expected.has(f))
250
+ fs.unlinkSync(path.join(indexDir, f));
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+ fs.mkdirSync(indexDir, { recursive: true });
256
+ for (const [topic, content] of shards) {
257
+ fs.writeFileSync(path.join(indexDir, `${topicSlug(topic)}.md`), content);
258
+ }
259
+ fs.writeFileSync(manifestPath, JSON.stringify(next, null, 1));
260
+ return true;
261
+ }
262
+ /**
263
+ * Publish the store + collections into the mirror and rebuild its index.
264
+ * Returns the count of newly written/changed files (the commit trigger).
265
+ * Shared by the normal pass and the diverged-rebuild pass.
266
+ */
267
+ function publishIntoClone(cloneDir, storeDir, opts) {
268
+ let vocabReport;
269
+ const storeFiles = planStoreToRepo(storeDir, opts.project.projectKey);
270
+ const { copied: storeCopied, skipped } = copyNewFiles(cloneDir, storeFiles);
271
+ let copied = storeCopied;
272
+ // collection files: write-if-changed. Unlike chapters they are REGENERATED
273
+ // (a session keeps collecting signatures, so its JSONL grows); only THIS
274
+ // harness writes its own sessions' files, so ff-only stays conflict-free.
275
+ for (const c of opts.collections ?? []) {
276
+ const rel = path.join('collections', opts.project.projectKey, `${c.sessionId}.jsonl`);
277
+ const abs = path.join(cloneDir, rel);
278
+ const content = c.lines.join('\n') + (c.lines.length > 0 ? '\n' : '');
279
+ if (!fs.existsSync(abs) || fs.readFileSync(abs, 'utf8') !== content) {
280
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
281
+ fs.writeFileSync(abs, content);
282
+ copied += 1;
283
+ }
284
+ }
285
+ // P2 §6.3: emergent vocabulary pass. Shadow mode (default) only reports;
286
+ // applied merges append topic-alias facts to edits/<author>/curation.jsonl
287
+ // — every merge git-visible and revertible, consumed by the index build
288
+ // just below on the NEXT rebuild and by the one here.
289
+ try {
290
+ const vr = runVocabularyPass(cloneDir, opts.project, opts.vocab);
291
+ if (vr !== undefined) {
292
+ vocabReport = vr.report;
293
+ copied += vr.wrote;
294
+ }
295
+ }
296
+ catch (error) {
297
+ vocabReport = `vocabulary pass failed (${String(error?.message ?? error).slice(0, 80)})`;
298
+ }
299
+ let indexChanged = false;
300
+ try {
301
+ indexChanged = buildIndexInClone(cloneDir);
302
+ if (indexChanged)
303
+ copied += 1;
304
+ // record §6.3: the derived vocabulary artifact — deterministic (no
305
+ // timestamps), so identical inputs never dirty the mirror
306
+ emitVocabularyJson(cloneDir);
307
+ }
308
+ catch { /* index failure never blocks the pass (record §5.3) */ }
309
+ return { copied, skipped, ...(vocabReport !== undefined ? { vocabReport } : {}) };
310
+ }
311
+ /** topics/vocabulary.json: canonical labels, live aliases, frequency. */
312
+ function emitVocabularyJson(cloneDir) {
313
+ const root = path.join(cloneDir, 'chapters');
314
+ if (!fs.existsSync(root))
315
+ return;
316
+ const labels = new Map();
317
+ const walk = (dir) => {
318
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
319
+ const p = path.join(dir, e.name);
320
+ if (e.isDirectory()) {
321
+ walk(p);
322
+ continue;
323
+ }
324
+ if (!e.name.endsWith('.md'))
325
+ continue;
326
+ try {
327
+ for (const t of entryFromChapter('', fs.readFileSync(p, 'utf8')).topics)
328
+ labels.set(t, (labels.get(t) ?? 0) + 1);
329
+ }
330
+ catch { /* skip */ }
331
+ }
332
+ };
333
+ walk(root);
334
+ const alias = new Map();
335
+ const editsRoot = path.join(cloneDir, 'edits');
336
+ if (fs.existsSync(editsRoot)) {
337
+ for (const h of fs.readdirSync(editsRoot, { withFileTypes: true })) {
338
+ if (!h.isDirectory())
339
+ continue;
340
+ const file = path.join(editsRoot, h.name, 'curation.jsonl');
341
+ if (!fs.existsSync(file))
342
+ continue;
343
+ for (const f of parseCuration(fs.readFileSync(file, 'utf8')))
344
+ if (f.type === 'topic-alias')
345
+ alias.set(f.from, f.to);
346
+ }
347
+ }
348
+ const resolve = (l) => { let x = l; for (let i = 0; i < 10 && alias.has(x); i++)
349
+ x = alias.get(x); return x; };
350
+ const canonical = new Map();
351
+ for (const [l, n] of labels)
352
+ canonical.set(resolve(l), (canonical.get(resolve(l)) ?? 0) + n);
353
+ const doc = {
354
+ canonical: Object.fromEntries([...canonical.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1)),
355
+ aliases: Object.fromEntries([...alias.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1)),
356
+ unvetted: [...labels.keys()].filter((l) => alias.has(l)).sort(),
357
+ };
358
+ const text = JSON.stringify(doc, null, 1) + '\n';
359
+ const out = path.join(cloneDir, 'topics', 'vocabulary.json');
360
+ if (!fs.existsSync(out) || fs.readFileSync(out, 'utf8') !== text) {
361
+ fs.mkdirSync(path.dirname(out), { recursive: true });
362
+ fs.writeFileSync(out, text);
363
+ }
364
+ }
365
+ /** one line of the sync report describing the vocabulary pass outcome. */
366
+ function runVocabularyPass(cloneDir, project, opts) {
367
+ if (opts === undefined)
368
+ return undefined;
369
+ const root = path.join(cloneDir, 'chapters', project.projectKey);
370
+ if (!fs.existsSync(root))
371
+ return { report: 'vocabulary: no chapters yet', wrote: 0 };
372
+ const chapterTopics = [];
373
+ const walk = (dir) => {
374
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
375
+ if (e.isDirectory()) {
376
+ walk(path.join(dir, e.name));
377
+ continue;
378
+ }
379
+ if (!e.name.endsWith('.md'))
380
+ continue;
381
+ try {
382
+ const entry = entryFromChapter('', fs.readFileSync(path.join(dir, e.name), 'utf8'));
383
+ if (entry.topics.length > 0)
384
+ chapterTopics.push(entry.topics);
385
+ }
386
+ catch { /* unreadable chapter: skip, the index build reports it */ }
387
+ }
388
+ };
389
+ walk(root);
390
+ const { candidates } = analyzeTopics(chapterTopics, opts);
391
+ // existing aliases from EVERY edits/<harness>/curation.jsonl (chain-aware)
392
+ const existing = new Map();
393
+ const editsRoot = path.join(cloneDir, 'edits');
394
+ if (fs.existsSync(editsRoot)) {
395
+ for (const h of fs.readdirSync(editsRoot, { withFileTypes: true })) {
396
+ if (!h.isDirectory())
397
+ continue;
398
+ const file = path.join(editsRoot, h.name, 'curation.jsonl');
399
+ if (!fs.existsSync(file))
400
+ continue;
401
+ for (const f of parseCuration(fs.readFileSync(file, 'utf8'))) {
402
+ if (f.type === 'topic-alias')
403
+ existing.set(f.from, f.to);
404
+ }
405
+ }
406
+ }
407
+ const { lines, plan } = planAppends(candidates, existing, new Date().toISOString());
408
+ if (lines.length === 0) {
409
+ return { report: `vocabulary: ${candidates.length} candidate(s), ${existing.size} alias(es) known, nothing new`, wrote: 0 };
410
+ }
411
+ if (!opts.apply) {
412
+ return { report: `vocabulary: ${lines.length} merge candidate(s) SHADOWED (set vocabApply): ${lines.slice(0, 3).map((l) => { const o = JSON.parse(l); return `${o.from}->${o.to}`; }).join(', ')}${lines.length > 3 ? ' …' : ''}`, wrote: 0 };
413
+ }
414
+ const authorDir = path.join(editsRoot, project.harnessId);
415
+ fs.mkdirSync(authorDir, { recursive: true });
416
+ fs.appendFileSync(path.join(authorDir, 'curation.jsonl'), lines.join('\n') + '\n');
417
+ return { report: `vocabulary: applied ${lines.length} topic-alias curation entr${lines.length === 1 ? 'y' : 'ies'} (author ${project.harnessId})`, wrote: 1 };
418
+ }
419
+ /**
420
+ * One sync pass (record §5): lock → ensure clone (offline init fallback) →
421
+ * publish → commit → pull → push. Failure semantics §5.3: EVERY remote-path
422
+ * failure (clone, pull, push) degrades to local-only mode — the mirror stays
423
+ * current for search and the status file says so with detail; only a local
424
+ * commit failure or lock loss returns without a current mirror. A diverged
425
+ * pull rebuilds the mirror from the remote (safe: the mirror is transport,
426
+ * the store is truth, §3.1). ok=true means the remote accepted the push.
427
+ */
428
+ export async function runSync(opts) {
429
+ const steps = [];
430
+ const storeDir = path.join(opts.cwd, opts.storeRoot);
431
+ const cloneDir = path.join(opts.cwd, opts.cloneDir);
432
+ const record = (ok, mode, detail) => {
433
+ const result = { ok, steps, detail, mode };
434
+ try {
435
+ fs.mkdirSync(storeDir, { recursive: true });
436
+ fs.writeFileSync(statusFilePath(opts.cwd, opts.storeRoot), JSON.stringify({
437
+ at: new Date().toISOString(),
438
+ projectKey: opts.project.projectKey,
439
+ lastOk: ok, mode, detail, steps,
440
+ }, null, 1));
441
+ }
442
+ catch { /* status is best-effort too */ }
443
+ return result;
444
+ };
445
+ let provider;
446
+ try {
447
+ provider = opts.provider ?? selectProvider(opts.project);
448
+ }
449
+ catch (error) {
450
+ return record(false, 'local-only', `provider unavailable: ${String(error?.message ?? error)}`);
451
+ }
452
+ const lock = acquireLock(syncLockPath(storeDir), opts.force ?? false);
453
+ if (!lock.held) {
454
+ return record(false, 'local-only', lock.detail ?? 'lock unavailable');
455
+ }
456
+ const remote = { url: opts.project.remote, ...(opts.token !== undefined ? { token: opts.token } : {}) };
457
+ const author = { name: 'dsh-chapters', email: `${opts.project.harnessId}@dsh-chapters.local` };
458
+ const publishCommit = async () => {
459
+ const { copied, skipped, vocabReport } = publishIntoClone(cloneDir, storeDir, opts);
460
+ steps.push(`published ${copied} new file(s), ${skipped} already mirrored`);
461
+ if (vocabReport !== undefined)
462
+ steps.push(vocabReport);
463
+ // P3: the commit is ATTEMPTED EVEN WHEN copied === 0. Command-written
464
+ // curation facts (rule approvals) change the worktree without any store
465
+ // copy; post-r37, stageAllAndCommit's workdir-vs-HEAD diff is the honest
466
+ // gate and reports 'nothing to commit' when the tree truly is clean.
467
+ const commit = await provider.stageAllAndCommit(cloneDir, `dsh-chapters: ${opts.project.projectKey} (+${copied} file(s)) — harness ${opts.project.harnessId}`, author);
468
+ steps.push(commit.detail);
469
+ return { copied, committed: commit.ok && commit.changed === true };
470
+ };
471
+ try {
472
+ let offline = false;
473
+ let rebuild = false;
474
+ let clone = await provider.ensureClone(cloneDir, remote);
475
+ if (!clone.ok && clone.code === 'origin-mismatch') {
476
+ // the workspace re-linked to a different repo: the mirror is transport
477
+ // for the CURRENT project — rebuild it (never sync into the wrong pool)
478
+ steps.push(`origin changed (${clone.detail}) — rebuilding mirror`);
479
+ rebuild = true;
480
+ clone = { ok: true, detail: 'rebuild' };
481
+ }
482
+ if (clone.ok && !rebuild) {
483
+ steps.push(clone.detail);
484
+ }
485
+ else {
486
+ if (rebuild) {
487
+ const rm = await provider.removeMirror(cloneDir);
488
+ if (!rm.ok)
489
+ return record(false, 'local-only', `rebuild remove: ${rm.detail}`);
490
+ const recl = await provider.ensureClone(cloneDir, remote);
491
+ if (!recl.ok) {
492
+ const init = await provider.initLocal(cloneDir, remote);
493
+ if (!init.ok)
494
+ return record(false, 'local-only', `rebuild clone: ${recl.detail}; init: ${init.detail}`);
495
+ offline = true;
496
+ }
497
+ else
498
+ steps.push(recl.detail);
499
+ }
500
+ else {
501
+ // §5.3: unreachable remote ⇒ offline mirror. publish/commit/index/
502
+ // search all keep working; the push lands when the remote returns.
503
+ const init = await provider.initLocal(cloneDir, remote);
504
+ if (!init.ok)
505
+ return record(false, 'local-only', `clone: ${clone.detail}; local init: ${init.detail}`);
506
+ offline = true;
507
+ steps.push(`${clone.code === 'auth' ? 'credentials rejected' : 'remote unreachable'} (${clone.detail}) — ${init.detail}`);
508
+ }
509
+ }
510
+ const { committed } = await publishCommit();
511
+ void committed;
512
+ // Divergence is resolved by REBUILDING from the store (record §5.2 via
513
+ // §15): destroy transport, republish from truth, re-derive, push. Shared
514
+ // by every place divergence surfaces — pull, first push (TreeDX commit
515
+ // 409 under a race), or retry push. Bounded: rebuildAndPush never
516
+ // re-enters itself.
517
+ const rebuildAndPush = async (why) => {
518
+ steps.push(`diverged (${why}) — rebuilding mirror from remote`);
519
+ const rm = await provider.removeMirror(cloneDir);
520
+ if (!rm.ok)
521
+ return record(false, 'local-only', `rebuild remove: ${rm.detail}`);
522
+ const recl = await provider.ensureClone(cloneDir, remote);
523
+ if (!recl.ok)
524
+ return record(false, 'local-only', `rebuild clone: ${recl.detail}`);
525
+ await publishCommit();
526
+ const repush = await provider.push(cloneDir, remote);
527
+ steps.push(repush.detail);
528
+ return repush.ok
529
+ ? record(true, 'synced', 'rebuilt and pushed')
530
+ : record(false, 'local-only', `rebuild push: ${repush.detail}`);
531
+ };
532
+ if (offline) {
533
+ // The clone failure's OWN reason rides into the status (live-TreeDX
534
+ // lesson: a 401 reported only as "unreachable" sends the user chasing
535
+ // the wrong ghost — the auth detail must survive the offline fallback).
536
+ return record(false, 'local-only', clone.code === 'auth'
537
+ ? `credentials rejected at clone time (${clone.detail}) — re-link with /chapters-link <url> <token>; local mirror current; push deferred`
538
+ : `remote unreachable at clone time (${clone.detail}) — local mirror current; push deferred`);
539
+ }
540
+ const pull = await provider.pullFastForward(cloneDir, remote);
541
+ if (!pull.ok) {
542
+ if (pull.code === 'diverged')
543
+ return rebuildAndPush(pull.detail);
544
+ return record(false, 'local-only', `pull unavailable (${pull.detail}) — local mirror current; push deferred`);
545
+ }
546
+ steps.push(pull.detail);
547
+ let pushed = await provider.push(cloneDir, remote);
548
+ if (!pushed.ok && pushed.code === 'diverged')
549
+ return rebuildAndPush(`push: ${pushed.detail}`);
550
+ if (!pushed.ok && pushed.code === 'rejected') {
551
+ const retryPull = await provider.pullFastForward(cloneDir, remote);
552
+ if (!retryPull.ok) {
553
+ if (retryPull.code === 'diverged')
554
+ return rebuildAndPush(retryPull.detail);
555
+ return record(false, 'local-only', `retry pull: ${retryPull.detail}`);
556
+ }
557
+ pushed = await provider.push(cloneDir, remote);
558
+ steps.push('push rejected — ff-retried');
559
+ }
560
+ if (!pushed.ok) {
561
+ return record(false, 'local-only', `push failed (${pushed.detail}) — local mirror current; push deferred`);
562
+ }
563
+ steps.push(pushed.detail);
564
+ return record(true, 'synced', 'synced');
565
+ }
566
+ catch (error) {
567
+ return record(false, 'local-only', String(error?.message ?? error));
568
+ }
569
+ finally {
570
+ lock.release();
571
+ }
572
+ }
573
+ /**
574
+ * Pull-only pass for the pre-fork refresh point (record §5.1): ensure the
575
+ * mirror exists (clone, offline-init fallback) and ff-pull it. Never throws,
576
+ * never pushes, never commits — a read-side refresh.
577
+ */
578
+ export async function runPull(opts) {
579
+ let provider;
580
+ try {
581
+ provider = opts.provider ?? selectProvider(opts.project);
582
+ }
583
+ catch (error) {
584
+ return { ok: false, detail: `provider unavailable: ${String(error?.message ?? error)}` };
585
+ }
586
+ const cloneDir = path.join(opts.cwd, opts.cloneDir);
587
+ const remote = { url: opts.project.remote, ...(opts.token !== undefined ? { token: opts.token } : {}) };
588
+ try {
589
+ const clone = await provider.ensureClone(cloneDir, remote);
590
+ if (!clone.ok) {
591
+ const init = await provider.initLocal(cloneDir, remote);
592
+ return { ok: false, detail: `clone: ${clone.detail}${init.ok ? ' (offline mirror present)' : `; init: ${init.detail}`}` };
593
+ }
594
+ const pull = await provider.pullFastForward(cloneDir, remote);
595
+ return { ok: pull.ok, detail: `${clone.detail}; ${pull.detail}` };
596
+ }
597
+ catch (error) {
598
+ return { ok: false, detail: String(error?.message ?? error) };
599
+ }
600
+ }
601
+ /**
602
+ * A workspace-scoped collections reader for the scheduler (§5): only session
603
+ * trees whose store directory lives in THIS workspace publish. Shared by the
604
+ * host plugin and the realm engine (the two schedulers are the same code over
605
+ * different module copies — the file lock keeps them honest).
606
+ */
607
+ export function makeCollectionsReader(sessions, storeRoot) {
608
+ return (cwd) => {
609
+ const out = [];
610
+ for (const [sid, st] of sessions()) {
611
+ if (st.collections.length === 0)
612
+ continue;
613
+ if (!fs.existsSync(path.join(cwd, storeRoot, st.rootSession)))
614
+ continue;
615
+ out.push({ sessionId: sid, lines: st.collections.map((c) => JSON.stringify(c)) });
616
+ }
617
+ return out;
618
+ };
619
+ }
620
+ export function createSyncScheduler(deps) {
621
+ const cloneDir = deps.cloneDir ?? DEFAULT_CLONE_DIR;
622
+ const timers = new Map();
623
+ const inFlight = new Map();
624
+ const pendingSettled = [];
625
+ const mkTimer = deps.setTimer ?? ((fn, ms) => {
626
+ const t = setTimeout(fn, ms);
627
+ t.unref?.();
628
+ return { cancel: () => clearTimeout(t) };
629
+ });
630
+ const start = (cwd, projectOverride) => {
631
+ const existing = inFlight.get(cwd);
632
+ if (existing !== undefined)
633
+ return existing;
634
+ const project = projectOverride ?? deps.resolveProject(cwd);
635
+ if (project === undefined) {
636
+ return Promise.resolve({ ok: false, steps: [], detail: 'no knowledge project linked for this workspace', mode: 'local-only' });
637
+ }
638
+ const token = deps.tokenFor(cwd, project.projectKey);
639
+ const pass = runSync({
640
+ cwd,
641
+ storeRoot: deps.storeRoot,
642
+ cloneDir,
643
+ project,
644
+ ...(token !== undefined ? { token } : {}),
645
+ collections: deps.collectionsFor(cwd),
646
+ ...(deps.vocab !== undefined ? { vocab: deps.vocab } : {}),
647
+ ...(deps.provider !== undefined ? { provider: deps.provider } : {}),
648
+ }).finally(() => { inFlight.delete(cwd); });
649
+ if (deps.onSyncDone !== undefined) {
650
+ const notify = (r) => { try {
651
+ deps.onSyncDone?.(cwd, r.ok);
652
+ }
653
+ catch { /* never break the pass */ } };
654
+ pass.then(notify, () => notify({ ok: false, steps: [], detail: 'pass rejected', mode: 'local-only' }));
655
+ }
656
+ inFlight.set(cwd, pass);
657
+ return pass;
658
+ };
659
+ return {
660
+ schedule(cwd, _why) {
661
+ timers.get(cwd)?.cancel();
662
+ const timer = mkTimer(() => {
663
+ timers.delete(cwd);
664
+ pendingSettled.push(start(cwd).then(() => undefined));
665
+ if (pendingSettled.length > 32)
666
+ pendingSettled.splice(0, pendingSettled.length - 32);
667
+ }, deps.debounceMs);
668
+ timers.set(cwd, timer);
669
+ },
670
+ run(cwd, _why, project) {
671
+ timers.get(cwd)?.cancel();
672
+ timers.delete(cwd);
673
+ return start(cwd, project);
674
+ },
675
+ async pullFor(cwd) {
676
+ const project = deps.resolveProject(cwd);
677
+ if (project === undefined)
678
+ return { ok: false, detail: 'no linked project' };
679
+ const token = deps.tokenFor(cwd, project.projectKey);
680
+ const prior = inFlight.get(cwd);
681
+ if (prior !== undefined) {
682
+ await prior;
683
+ return { ok: true, detail: 'awaited in-flight sync instead of a second pull' };
684
+ }
685
+ return runPull({
686
+ cwd,
687
+ storeRoot: deps.storeRoot,
688
+ cloneDir,
689
+ project,
690
+ ...(token !== undefined ? { token } : {}),
691
+ ...(deps.provider !== undefined ? { provider: deps.provider } : {}),
692
+ });
693
+ },
694
+ hasPending(cwd) {
695
+ return timers.has(cwd) || inFlight.has(cwd);
696
+ },
697
+ async drain() {
698
+ for (const t of timers.values())
699
+ t.cancel();
700
+ timers.clear();
701
+ let batch;
702
+ do {
703
+ batch = pendingSettled.splice(0);
704
+ await Promise.all(batch.map((p) => p.catch(() => undefined)));
705
+ } while (pendingSettled.length > 0);
706
+ await Promise.all([...inFlight.values()].map((p) => p.catch(() => undefined)));
707
+ },
708
+ };
709
+ }
710
+ /** Read the last sync status (for /chapters-status). Null when never run. */
711
+ export function readSyncStatus(cwd, storeRoot) {
712
+ try {
713
+ return JSON.parse(fs.readFileSync(statusFilePath(cwd, storeRoot), 'utf8'));
714
+ }
715
+ catch {
716
+ return null;
717
+ }
718
+ }
719
+ //# sourceMappingURL=sync.js.map