@nazty_labs/common-ground 0.5.1

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/SETUP.md +219 -0
  4. package/dist/access.d.ts +172 -0
  5. package/dist/access.js +175 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +198 -0
  8. package/dist/commands.d.ts +189 -0
  9. package/dist/commands.js +202 -0
  10. package/dist/discovery.d.ts +73 -0
  11. package/dist/discovery.js +417 -0
  12. package/dist/errors.d.ts +15 -0
  13. package/dist/errors.js +22 -0
  14. package/dist/export.d.ts +14 -0
  15. package/dist/export.js +86 -0
  16. package/dist/guidance.d.ts +11 -0
  17. package/dist/guidance.js +75 -0
  18. package/dist/hooks.d.ts +4 -0
  19. package/dist/hooks.js +141 -0
  20. package/dist/init.d.ts +304 -0
  21. package/dist/init.js +150 -0
  22. package/dist/maintenance.d.ts +126 -0
  23. package/dist/maintenance.js +23 -0
  24. package/dist/matching.d.ts +17 -0
  25. package/dist/matching.js +32 -0
  26. package/dist/model.d.ts +974 -0
  27. package/dist/model.js +21 -0
  28. package/dist/navigation.d.ts +164 -0
  29. package/dist/navigation.js +164 -0
  30. package/dist/operations.d.ts +10 -0
  31. package/dist/operations.js +130 -0
  32. package/dist/paging.d.ts +5 -0
  33. package/dist/paging.js +32 -0
  34. package/dist/retrieval.d.ts +146 -0
  35. package/dist/retrieval.js +150 -0
  36. package/dist/review-files.d.ts +3 -0
  37. package/dist/review-files.js +106 -0
  38. package/dist/review.d.ts +86 -0
  39. package/dist/review.js +124 -0
  40. package/dist/server.d.ts +8 -0
  41. package/dist/server.js +105 -0
  42. package/dist/source-search.d.ts +63 -0
  43. package/dist/source-search.js +245 -0
  44. package/dist/store.d.ts +452 -0
  45. package/dist/store.js +718 -0
  46. package/dist/version.d.ts +1 -0
  47. package/dist/version.js +2 -0
  48. package/dist/workflow.d.ts +450 -0
  49. package/dist/workflow.js +317 -0
  50. package/docs/architecture.md +65 -0
  51. package/docs/audit-0.4.0.md +42 -0
  52. package/docs/demo.md +42 -0
  53. package/docs/discovery.md +70 -0
  54. package/docs/knowledge-policy.md +51 -0
  55. package/docs/pillar-contract.md +98 -0
  56. package/docs/quiet-workflow.md +98 -0
  57. package/docs/releases.md +157 -0
  58. package/package.json +52 -0
  59. package/schemas/admission.schema.json +75 -0
  60. package/schemas/knowledge.schema.json +192 -0
  61. package/schemas/patch.schema.json +220 -0
  62. package/schemas/update.schema.json +218 -0
package/dist/store.js ADDED
@@ -0,0 +1,718 @@
1
+ import { GroundError } from './errors.js';
2
+ import { createReadStream, promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { Registry, LegacyRegistry, PillarDefinition, ChapterDefinition, Chapter, Update, relativePath, unique } from './model.js';
6
+ import { reviewDocuments } from './review-files.js';
7
+ import { writeKnowledgeExport } from './export.js';
8
+ const ignored = new Set(['.git', 'node_modules', '.common-ground', 'dist', 'build', 'target', '.nx', '.next', 'coverage', '.env']);
9
+ const hash = (s) => createHash('sha256').update(s).digest('hex');
10
+ const stable = (v) => JSON.stringify(v, (_, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b))) : x);
11
+ export const same = (a, b) => stable(a) === stable(b);
12
+ export class Store {
13
+ root;
14
+ constructor(root) { this.root = path.resolve(root); }
15
+ file(name) { return path.join(this.root, '.common-ground', name); }
16
+ async safe(relative, allowMissing = false) {
17
+ if (path.isAbsolute(relative) || relative.includes('\\') || relative.split('/').some(p => p === '..' || !p))
18
+ throw new Error('Unsafe repository path');
19
+ let current = this.root;
20
+ for (const part of relative.split('/')) {
21
+ current = path.join(current, part);
22
+ try {
23
+ if ((await fs.lstat(current)).isSymbolicLink())
24
+ throw new Error(`Symlinks are not supported: ${relative}`);
25
+ }
26
+ catch (e) {
27
+ if (e.code === 'ENOENT' && allowMissing)
28
+ return current;
29
+ throw e;
30
+ }
31
+ }
32
+ return current;
33
+ }
34
+ chapters(registry) {
35
+ return registry.pillars.flatMap(p => p.chapters.map(chapter => ({ key: `${p.id}/${chapter.id}`, pillar: p, chapter })));
36
+ }
37
+ chapter(registry, key) {
38
+ const entry = this.chapters(registry).find(c => c.key === key);
39
+ if (!entry)
40
+ throw new Error(`Unknown chapter: ${key}. Use list_chapters first.`);
41
+ return entry.chapter;
42
+ }
43
+ validateRegistry(value) {
44
+ const registry = Registry.parse(value);
45
+ unique(registry.pillars.map(p => p.id), 'pillar IDs');
46
+ const entries = this.chapters(registry);
47
+ unique(entries.map(e => e.key), 'chapter IDs');
48
+ const factKeys = new Set(entries.flatMap(e => e.chapter.facts.map(f => `${e.key}/${f.id}`)));
49
+ for (const { key, chapter } of entries) {
50
+ unique(chapter.facts.map(f => f.id), 'fact IDs');
51
+ for (const fact of chapter.facts) {
52
+ unique(fact.dependsOn, 'fact dependencies');
53
+ for (const dependency of fact.dependsOn) {
54
+ if (dependency === `${key}/${fact.id}` || !factKeys.has(dependency))
55
+ throw new Error(`Invalid dependency ${dependency} in ${key}/${fact.id}`);
56
+ }
57
+ }
58
+ }
59
+ for (let i = 0; i < entries.length; i++)
60
+ for (let j = i + 1; j < entries.length; j++) {
61
+ if (entries[i].chapter.paths.some(a => entries[j].chapter.paths.some(b => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`))))
62
+ throw new Error(`Overlapping ownership: ${entries[i].key} and ${entries[j].key}`);
63
+ }
64
+ return registry;
65
+ }
66
+ async read() {
67
+ await this.safe('.common-ground/knowledge.json');
68
+ const text = await fs.readFile(this.file('knowledge.json'), 'utf8');
69
+ let raw;
70
+ try {
71
+ raw = JSON.parse(text);
72
+ }
73
+ catch (error) {
74
+ throw new GroundError('REGISTRY_INVALID', `Invalid registry JSON: ${error.message}`, ['.common-ground/knowledge.json'], 'Repair the registry JSON from a known-good Git revision; do not reinitialize over it.');
75
+ }
76
+ if (raw && typeof raw === 'object' && 'schemaVersion' in raw && raw.schemaVersion === 1)
77
+ throw new GroundError('REGISTRY_MIGRATION_REQUIRED', 'Schema v1 requires migration: cground migrate --approve, then cground init.', ['.common-ground/knowledge.json'], 'Review and approve the schema migration before running cground migrate --approve.');
78
+ try {
79
+ return this.validateRegistry(raw);
80
+ }
81
+ catch (error) {
82
+ throw new GroundError('REGISTRY_INVALID', `Invalid registry: ${error.message}`, ['.common-ground/knowledge.json'], 'Repair invalid records or references from source and Git history; do not replace the registry with an empty one.');
83
+ }
84
+ }
85
+ async persist(registry) {
86
+ const validated = this.validateRegistry(registry);
87
+ await this.atomic('knowledge.json', validated);
88
+ // A disposable export failure must not turn a successful shared write into a failed transaction.
89
+ try {
90
+ await writeKnowledgeExport(this, validated);
91
+ }
92
+ catch (e) {
93
+ console.error(`Common Ground: knowledge saved, but Markdown export could not refresh: ${e.message}. Run cground validate to retry.`);
94
+ }
95
+ }
96
+ async atomic(name, value) {
97
+ await this.safe(`.common-ground/${name}`, true);
98
+ await fs.mkdir(path.dirname(this.file(name)), { recursive: true });
99
+ const tmp = this.file(`${name}.${randomUUID()}.tmp`);
100
+ try {
101
+ await fs.writeFile(tmp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' });
102
+ await fs.rename(tmp, this.file(name));
103
+ }
104
+ finally {
105
+ await fs.rm(tmp, { force: true });
106
+ }
107
+ }
108
+ async lock(fn) {
109
+ await this.safe('.common-ground', true);
110
+ await fs.mkdir(this.file('local'), { recursive: true });
111
+ await this.safe('.common-ground/local');
112
+ const lock = this.file('local/write.lock');
113
+ try {
114
+ await fs.mkdir(lock);
115
+ }
116
+ catch {
117
+ throw new Error('Another writer holds the lock. If it crashed, remove .common-ground/local/write.lock after checking no writer is active.');
118
+ }
119
+ try {
120
+ return await fn();
121
+ }
122
+ finally {
123
+ await fs.rmdir(lock);
124
+ }
125
+ }
126
+ async walk(scopes = [], limit = 3000) {
127
+ const files = new Set();
128
+ let visited = 0;
129
+ let truncated = false;
130
+ const visit = async (rel) => {
131
+ if (++visited > limit) {
132
+ truncated = true;
133
+ return;
134
+ }
135
+ if (rel.split('/').some(p => ignored.has(p) || p.startsWith('.env')))
136
+ return;
137
+ let stat;
138
+ try {
139
+ stat = await fs.lstat(path.join(this.root, rel));
140
+ }
141
+ catch (e) {
142
+ if (e.code === 'ENOENT')
143
+ return;
144
+ throw e;
145
+ }
146
+ if (stat.isSymbolicLink())
147
+ return;
148
+ if (stat.isDirectory()) {
149
+ for (const name of (await fs.readdir(path.join(this.root, rel))).sort()) {
150
+ if (visited > limit) {
151
+ truncated = true;
152
+ break;
153
+ }
154
+ await visit(rel ? `${rel}/${name}` : name);
155
+ }
156
+ }
157
+ else if (stat.isFile())
158
+ files.add(rel);
159
+ };
160
+ for (const scope of scopes.length ? scopes : ['']) {
161
+ if (scope)
162
+ await this.safe(scope, true);
163
+ await visit(scope);
164
+ }
165
+ return { files: [...files].sort(), truncated };
166
+ }
167
+ async sourceHash(relative) {
168
+ const file = await this.safe(relative);
169
+ if (!(await fs.stat(file)).isFile())
170
+ throw new Error(`Source is not a regular file: ${relative}`);
171
+ const digest = createHash('sha256');
172
+ for await (const chunk of createReadStream(file))
173
+ digest.update(chunk);
174
+ return digest.digest('hex');
175
+ }
176
+ async snapshot(pillar, cache) {
177
+ const scopes = pillar.facts.flatMap(f => f.sourceScope);
178
+ const scan = scopes.length ? await this.walk([...new Set(scopes)], 10000) : { files: [], truncated: false };
179
+ if (scan.truncated)
180
+ throw new Error('Chapter scope exceeds beta scan limit (10,000 entries); narrow its scope.');
181
+ const sources = {};
182
+ for (const rel of [...new Set([...scan.files, ...pillar.facts.flatMap(f => f.evidence.map(e => e.path))])].sort()) {
183
+ if (rel.split('/').some(p => ignored.has(p) || p.startsWith('.env')))
184
+ throw new Error(`Excluded evidence path: ${rel}`);
185
+ // Cache is explicitly request-scoped; mutation and publication calls do not supply one.
186
+ let pending = cache?.get(rel);
187
+ if (!pending) {
188
+ pending = this.sourceHash(rel);
189
+ cache?.set(rel, pending);
190
+ }
191
+ sources[rel] = await pending;
192
+ }
193
+ return sources;
194
+ }
195
+ async validateFacts(pillar) {
196
+ unique(pillar.facts.map(f => f.id), 'fact IDs');
197
+ for (const fact of pillar.facts) {
198
+ for (const scope of fact.sourceScope)
199
+ if (!pillar.paths.some(p => scope === p || scope.startsWith(`${p}/`)))
200
+ throw new GroundError('SOURCE_SCOPE_OUTSIDE_CHAPTER', `Fact source scope outside chapter: ${scope}. Keep sourceScope within chapter paths; put cross-chapter supporting paths in evidence instead.`, ['sourceScope'], 'Use evidence for external citations; evidence is tracked automatically without expanding ownership.');
201
+ for (const evidence of fact.evidence) {
202
+ const file = await this.safe(evidence.path);
203
+ if ((await fs.stat(file)).size > 2_000_000)
204
+ throw new Error('Evidence file too large');
205
+ if (!(await fs.readFile(file, 'utf8')).includes(evidence.quote))
206
+ throw new Error(`Evidence quote not found: ${fact.id} in ${evidence.path}`);
207
+ }
208
+ }
209
+ }
210
+ facts(registry) {
211
+ return this.chapters(registry).flatMap(({ key, chapter }) => chapter.facts.map(fact => ({ key: `${key}/${fact.id}`, chapterId: key, chapter, fact })));
212
+ }
213
+ fact(registry, key) { const result = this.facts(registry).find(f => f.key === key); if (!result)
214
+ throw new Error(`Unknown fact: ${key}`); return result; }
215
+ impact(registry, key, factIds) {
216
+ const chapter = this.chapter(registry, key);
217
+ const all = this.facts(registry);
218
+ const byKey = new Map(all.map(entry => [entry.key, entry]));
219
+ const dependents = new Map();
220
+ for (const entry of all)
221
+ for (const dep of entry.fact.dependsOn)
222
+ dependents.set(dep, [...(dependents.get(dep) ?? []), entry.key]);
223
+ const ids = new Set(chapter.facts.map(f => f.id));
224
+ const visited = new Set((factIds ?? [...ids]).filter(id => ids.has(id)).map(id => `${key}/${id}`));
225
+ for (const current of visited) {
226
+ for (const dep of byKey.get(current).fact.dependsOn)
227
+ visited.add(dep);
228
+ for (const dependent of dependents.get(current) ?? [])
229
+ visited.add(dependent);
230
+ }
231
+ return [...visited].sort();
232
+ }
233
+ related(registry, key, factIds) { return [...new Set([key, ...this.impact(registry, key, factIds).map(k => k.slice(0, k.lastIndexOf('/')))])].sort(); }
234
+ upstreamFacts(registry, key, index = new Map(this.facts(registry).map(e => [e.key, e]))) {
235
+ const visited = new Set();
236
+ const pending = [key];
237
+ while (pending.length) {
238
+ const current = pending.pop();
239
+ for (const dep of index.get(current).fact.dependsOn)
240
+ if (dep !== key && !visited.has(dep)) {
241
+ visited.add(dep);
242
+ pending.push(dep);
243
+ }
244
+ }
245
+ return [...visited];
246
+ }
247
+ dependencyFingerprints(registry, key) {
248
+ const index = new Map(this.facts(registry).map(e => [e.key, e]));
249
+ const keys = new Set(this.chapter(registry, key).facts.flatMap(f => this.upstreamFacts(registry, `${key}/${f.id}`, index)));
250
+ return Object.fromEntries([...keys].sort().map(k => [k, hash(stable(index.get(k).fact))]));
251
+ }
252
+ async context(registry, keys, cache) {
253
+ const result = {};
254
+ for (const key of keys) {
255
+ const chapter = this.chapter(registry, key);
256
+ result[key] = { chapterHash: hash(stable(chapter)), snapshot: await this.snapshot(chapter, cache) };
257
+ }
258
+ return result;
259
+ }
260
+ async status(key, registry, cache) {
261
+ const reg = registry ?? await this.read();
262
+ const chapter = this.chapter(reg, key);
263
+ try {
264
+ const current = await this.snapshot(chapter, cache);
265
+ const changed = [...new Set([...Object.keys(current), ...Object.keys(chapter.sources)])].filter(p => current[p] !== chapter.sources[p]);
266
+ const dependencies = Object.keys(this.dependencyFingerprints(reg, key));
267
+ let dependencyDrift = !same(chapter.dependencyFingerprints, this.dependencyFingerprints(reg, key));
268
+ for (const dep of dependencies) {
269
+ const entry = this.fact(reg, dep);
270
+ const current = await this.snapshot({ paths: entry.fact.sourceScope, facts: [entry.fact] }, cache);
271
+ const baseline = Object.fromEntries(Object.entries(entry.chapter.sources).filter(([p]) => entry.fact.sourceScope.some(s => p === s || p.startsWith(`${s}/`)) || entry.fact.evidence.some(e => e.path === p)));
272
+ if (!same(current, baseline))
273
+ dependencyDrift = true;
274
+ }
275
+ let locallyReviewed = false;
276
+ const reviewName = `local/review-${key.replace('/', '--')}.json`;
277
+ try {
278
+ await this.safe(`.common-ground/${reviewName}`);
279
+ const review = JSON.parse(await fs.readFile(this.file(reviewName), 'utf8'));
280
+ locallyReviewed = same(review.context, await this.context(reg, Object.keys(review.context), cache)) && this.related(reg, key).every(k => Object.hasOwn(review.context, k));
281
+ }
282
+ catch (e) {
283
+ if (e.code !== 'ENOENT')
284
+ throw e;
285
+ }
286
+ return { chapterId: key, revision: chapter.revision, status: !chapter.facts.length ? 'unpopulated' : (changed.length || dependencyDrift) && !locallyReviewed ? 'needs-review' : 'evidence-unchanged', locallyReviewed, changedPaths: changed, dependencyDrift };
287
+ }
288
+ catch (e) {
289
+ return { chapterId: key, revision: chapter.revision, status: 'needs-review', error: e.message };
290
+ }
291
+ }
292
+ async migrate() {
293
+ return this.lock(async () => {
294
+ await this.safe('.common-ground/knowledge.json');
295
+ const raw = JSON.parse(await fs.readFile(this.file('knowledge.json'), 'utf8'));
296
+ if (raw.schemaVersion === 2)
297
+ return { migrated: false };
298
+ const legacy = LegacyRegistry.parse(raw);
299
+ const registry = { schemaVersion: 2, pillars: legacy.pillars.map(({ paths, facts, sources, revision, ...p }) => ({ ...p, chapters: [{ id: 'overview', title: 'Overview', scope: p.scope, excludes: p.excludes, paths, facts: facts.map(f => ({ ...f, sourceScope: [...new Set(f.evidence.map(e => e.path))], dependsOn: [] })), sources, revision, dependencyFingerprints: {} }] })) };
300
+ this.validateRegistry(registry);
301
+ await this.atomic('local/pre-v2-migration.json', raw);
302
+ await this.persist(registry);
303
+ return { migrated: true, pillars: registry.pillars.length, backup: '.common-ground/local/pre-v2-migration.json' };
304
+ });
305
+ }
306
+ async approveDefinitions(definitions, standaloneReason) {
307
+ return this.lock(async () => {
308
+ let registry;
309
+ try {
310
+ registry = await this.read();
311
+ }
312
+ catch (e) {
313
+ if (e.code !== 'ENOENT')
314
+ throw e;
315
+ registry = { schemaVersion: 2, pillars: [] };
316
+ }
317
+ if (registry.pillars.length && !standaloneReason?.trim())
318
+ throw new Error('New pillars require developer approval and an uncovered standalone responsibility explanation.');
319
+ for (const input of definitions) {
320
+ const def = PillarDefinition.parse(input);
321
+ for (const chapter of def.chapters)
322
+ for (const p of chapter.paths)
323
+ await this.safe(p);
324
+ registry.pillars.push({ ...def, chapters: def.chapters.map(c => ({ ...c, revision: 1, facts: [], sources: {}, dependencyFingerprints: {} })) });
325
+ }
326
+ await this.persist(registry);
327
+ return registry;
328
+ });
329
+ }
330
+ async addChapters(pillarId, definitions) {
331
+ return this.lock(async () => {
332
+ const registry = await this.read();
333
+ const pillar = registry.pillars.find(p => p.id === pillarId);
334
+ if (!pillar)
335
+ throw new Error('Unknown pillar');
336
+ for (const input of definitions) {
337
+ const def = ChapterDefinition.parse(input);
338
+ for (const p of def.paths)
339
+ await this.safe(p);
340
+ pillar.chapters.push({ ...def, revision: 1, facts: [], sources: {}, dependencyFingerprints: {} });
341
+ }
342
+ await this.persist(registry);
343
+ return pillar;
344
+ });
345
+ }
346
+ /** Validate an entire initial map or empty-chapter batch before one shared write. */
347
+ async bootstrap(definitions, batches, dryRun, expected) {
348
+ const execute = async () => {
349
+ let original;
350
+ try {
351
+ original = await this.read();
352
+ }
353
+ catch (e) {
354
+ if (e.code !== 'ENOENT')
355
+ throw e;
356
+ }
357
+ if (definitions && original)
358
+ throw new Error('Bootstrap requires an absent registry; use seed-batch for approved empty chapters.');
359
+ if (!definitions && !original)
360
+ throw new Error('Approve boundaries first or use bootstrap with pillars.');
361
+ const registry = original ? structuredClone(original) : { schemaVersion: 2, pillars: definitions.map(input => {
362
+ const def = PillarDefinition.parse(input);
363
+ return { ...def, chapters: def.chapters.map(c => ({ ...c, revision: 1, facts: [], sources: {}, dependencyFingerprints: {} })) };
364
+ }) };
365
+ unique(batches.map(b => b.chapterId), 'batch chapters');
366
+ for (const batch of batches) {
367
+ const chapter = this.chapter(registry, batch.chapterId);
368
+ if (chapter.facts.length)
369
+ throw new Error('Seed is only for an approved, empty chapter.');
370
+ chapter.facts = Chapter.shape.facts.parse(batch.facts);
371
+ }
372
+ this.validateRegistry(registry);
373
+ if (definitions && this.chapters(registry).some(({ chapter }) => !chapter.facts.length))
374
+ throw new Error('Bootstrap requires facts for every chapter; use approve for boundary-only setup.');
375
+ const keys = batches.map(b => b.chapterId);
376
+ const required = [...new Set(keys.flatMap(key => this.related(registry, key)))];
377
+ const counts = {};
378
+ for (const key of required) {
379
+ const chapter = this.chapter(registry, key);
380
+ for (const p of chapter.paths)
381
+ await this.safe(p);
382
+ await this.validateFacts(chapter);
383
+ if (keys.includes(key)) {
384
+ chapter.sources = await this.snapshot(chapter);
385
+ chapter.dependencyFingerprints = this.dependencyFingerprints(registry, key);
386
+ counts[key] = Object.keys(chapter.sources).length;
387
+ }
388
+ }
389
+ // Include live upstream sources as well as the chapters being seeded in conflict checks.
390
+ const context = await this.context(registry, required);
391
+ for (const key of keys)
392
+ if (!same(this.chapter(registry, key).sources, context[key].snapshot))
393
+ throw new GroundError('SOURCE_CONFLICT', 'Source changed during bootstrap.', ['verification'], 'Re-read the changed source and documentation, then prepare a new proposal or bootstrap preflight.');
394
+ const token = hash(stable({ original: original ?? null, registry, context }));
395
+ if (!dryRun) {
396
+ if (!expected || expected !== token)
397
+ throw new GroundError('PREFLIGHT_CONFLICT', 'Preflight conflict: rerun --dry-run and review the current payload and sources.', ['preflight'], 'Rerun --dry-run and review the new payload and sources before publishing with the new token.');
398
+ // Repeat source checks immediately before publication, under the writer lock.
399
+ if (!same(context, await this.context(registry, required)))
400
+ throw new GroundError('SOURCE_CONFLICT', 'Source changed during bootstrap.', ['verification'], 'Re-read the changed source and documentation, then prepare a new proposal or bootstrap preflight.');
401
+ let current;
402
+ try {
403
+ current = await this.read();
404
+ }
405
+ catch (e) {
406
+ if (e.code !== 'ENOENT')
407
+ throw e;
408
+ }
409
+ if (!same(original ?? null, current ?? null))
410
+ throw new GroundError('REGISTRY_CONFLICT', 'Registry changed during bootstrap.', ['.common-ground/knowledge.json'], 'Reload the current chapters and review their revisions before preparing again.');
411
+ await this.persist(registry);
412
+ }
413
+ return { dryRun, validation: 'structure, exact quotations and freshness; not semantic verification', preflight: token,
414
+ approvalRequired: dryRun ? { boundaries: !!definitions, facts: true } : false,
415
+ approved: dryRun ? undefined : { boundaries: !!definitions, facts: true, content: token, declarationOnly: true, humanReviewVerified: false },
416
+ chapters: Object.keys(counts), factCount: batches.reduce((n, b) => n + b.facts.length, 0), sourceFileCounts: counts,
417
+ outputPath: '.common-ground/knowledge.json', next: dryRun ? 'Verify boundaries and facts. Obtain content approval unless explicit developer delegation already authorizes publishing this initial map within scope, then apply with --approve and --preflight.' : 'Use cground lookup for relevant facts and source paths. Task contexts are optional for deferred additions or aggregate reporting.' };
418
+ };
419
+ return dryRun ? execute() : this.lock(execute);
420
+ }
421
+ async seed(key, facts) {
422
+ return this.lock(async () => {
423
+ const registry = await this.read();
424
+ const chapter = this.chapter(registry, key);
425
+ if (chapter.facts.length)
426
+ throw new Error('Seed is only for an approved, empty chapter.');
427
+ const next = Chapter.parse({ ...chapter, facts });
428
+ await this.validateFacts(next);
429
+ next.sources = await this.snapshot(next);
430
+ Object.assign(chapter, next);
431
+ this.validateRegistry(registry);
432
+ chapter.dependencyFingerprints = this.dependencyFingerprints(registry, key);
433
+ await this.persist(registry);
434
+ return chapter;
435
+ });
436
+ }
437
+ async admit(key, additions) {
438
+ return this.lock(async () => {
439
+ const registry = await this.read();
440
+ const chapter = this.chapter(registry, key);
441
+ const parsed = Chapter.shape.facts.parse(additions);
442
+ if (!parsed.length)
443
+ return chapter;
444
+ const next = Chapter.parse({ ...chapter, revision: chapter.revision + 1, facts: [...chapter.facts, ...parsed] });
445
+ await this.validateFacts(next);
446
+ next.sources = await this.snapshot(next);
447
+ Object.assign(chapter, next);
448
+ this.validateRegistry(registry);
449
+ chapter.dependencyFingerprints = this.dependencyFingerprints(registry, key);
450
+ await this.persist(registry);
451
+ return chapter;
452
+ });
453
+ }
454
+ async reviewPlan(key, factIds) {
455
+ const registry = await this.read();
456
+ if (factIds?.some(id => !this.chapter(registry, key).facts.some(f => f.id === id)))
457
+ throw new Error('Unknown initiating fact');
458
+ return { chapterId: key, factIds, policy: 'Trace fact dependencies and dependents; fully review the chapters containing those facts. Read source this session and use review_checklist for directory, sibling, child, and referenced documentation. Corrections and explicit maintenance require reasons; unchanged facts need no rewrite.', affectedFacts: this.impact(registry, key, factIds), chapters: await Promise.all(this.related(registry, key, factIds).map(async (chapterId) => { const c = this.chapter(registry, chapterId); return { chapterId, title: c.title, revision: c.revision, factCount: c.facts.length, freshness: await this.status(chapterId, registry) }; })) };
459
+ }
460
+ resolveTarget(registry, target) {
461
+ if (target === 'all')
462
+ return this.chapters(registry).map(c => c.key);
463
+ const matches = [];
464
+ const pillar = registry.pillars.find(p => p.id === target);
465
+ if (pillar)
466
+ matches.push(pillar.chapters.map(c => `${pillar.id}/${c.id}`));
467
+ const chapter = this.chapters(registry).find(c => c.key === target);
468
+ if (chapter)
469
+ matches.push([chapter.key]);
470
+ for (const fact of this.facts(registry))
471
+ if (fact.key === target || fact.fact.id === target)
472
+ matches.push([fact.chapterId]);
473
+ if (matches.length !== 1)
474
+ throw new Error(matches.length ? 'Ambiguous target; use pillar/chapter/fact or pillar/chapter.' : 'Unknown target; use a pillar, pillar/chapter, pillar/chapter/fact, or all.');
475
+ return matches[0];
476
+ }
477
+ async requestTidy(target, expectedRegistry) {
478
+ return this.lock(async () => {
479
+ const registry = await this.read();
480
+ if (expectedRegistry && !same(registry, expectedRegistry))
481
+ throw new Error('Knowledge changed; rerun the check before cleanup.');
482
+ const roots = [...new Set((Array.isArray(target) ? target : [target]).flatMap(item => this.resolveTarget(registry, item)))];
483
+ const chapters = [...new Set(roots.flatMap(key => this.related(registry, key)))].sort();
484
+ if (!chapters.length)
485
+ return { tidyId: null, instruction: 'No chapters to tidy. Complete approved setup first.' };
486
+ const tidyId = randomUUID();
487
+ await this.atomic(`local/tidy-${tidyId}.json`, { registryHash: hash(stable(registry)), roots, chapters });
488
+ return { tidyId, requiredChapters: chapters, instruction: 'Developer-requested cleanup only. The calling agent must read and verify source and documentation, then submit prepare_update with this tidyId. No facts have been changed.' };
489
+ });
490
+ }
491
+ async tidyScope(id, registry) {
492
+ if (!/^[0-9a-f-]{36}$/.test(id))
493
+ throw new Error('Invalid tidy ID');
494
+ await this.safe(`.common-ground/local/tidy-${id}.json`);
495
+ const ticket = JSON.parse(await fs.readFile(this.file(`local/tidy-${id}.json`), 'utf8'));
496
+ if (ticket.registryHash !== hash(stable(registry)))
497
+ throw new Error('Knowledge changed; request a new tidy plan.');
498
+ if (!Array.isArray(ticket.chapters) || !ticket.chapters.length)
499
+ throw new Error('Invalid tidy scope');
500
+ for (const key of ticket.chapters)
501
+ this.chapter(registry, key);
502
+ return ticket.chapters;
503
+ }
504
+ async reviewFiles(registry, keys, touchedPaths, candidate = registry) {
505
+ for (const file of touchedPaths)
506
+ relativePath.parse(file);
507
+ const sourceFiles = [...new Set(keys.flatMap(key => [this.chapter(registry, key), this.chapter(candidate, key)]
508
+ .flatMap(c => c.facts.flatMap(f => f.evidence.map(e => e.path)))))].sort();
509
+ const documentFiles = await reviewDocuments(this, [...sourceFiles, ...touchedPaths]);
510
+ return { sourceFiles, documentFiles };
511
+ }
512
+ async fileSnapshots(files) {
513
+ const snapshots = {};
514
+ for (const file of files) {
515
+ relativePath.parse(file);
516
+ if (file.split('/').some(p => ignored.has(p) || p.startsWith('.env')))
517
+ throw new Error(`Excluded review path: ${file}`);
518
+ try {
519
+ const safe = await this.safe(file);
520
+ const stat = await fs.stat(safe);
521
+ if (!stat.isFile() || stat.size > 2_000_000)
522
+ throw new Error(`Review file exceeds beta limits: ${file}`);
523
+ snapshots[file] = hash(await fs.readFile(safe));
524
+ }
525
+ catch (error) {
526
+ if (error.code !== 'ENOENT')
527
+ throw error;
528
+ snapshots[file] = 'missing';
529
+ }
530
+ }
531
+ return snapshots;
532
+ }
533
+ async evaluate(input, registry) {
534
+ const request = Update.parse(input);
535
+ unique(request.reviews.map(r => r.chapterId), 'chapter reviews');
536
+ if (request.factIds?.some(id => !this.chapter(registry, request.chapterId).facts.some(f => f.id === id)))
537
+ throw new Error('Unknown initiating fact');
538
+ const candidate = structuredClone(registry);
539
+ for (const review of request.reviews)
540
+ this.chapter(candidate, review.chapterId).facts = review.facts;
541
+ this.validateRegistry(candidate);
542
+ const tidyScope = request.tidyId ? await this.tidyScope(request.tidyId, registry) : [];
543
+ if (request.tidyId && !tidyScope.includes(request.chapterId))
544
+ throw new Error('Initiating chapter is outside the tidy scope');
545
+ const citationOnly = (before, after) => !!after && !same(before.evidence, after.evidence) && same({ ...before, evidence: [] }, { ...after, evidence: [] });
546
+ const citationReview = request.reviews.some(r => r.facts.some(f => { const old = this.chapter(registry, r.chapterId).facts.find(o => o.id === f.id); return old && citationOnly(old, f); }));
547
+ if (!request.tidyId && !request.touchedPaths.length && !citationReview)
548
+ throw new Error('Routine updates need touched paths; developer-requested cleanup needs a tidyId.');
549
+ const maintenanceReviews = request.reviews.filter(r => r.maintenance?.length);
550
+ const initialScope = new Set([...tidyScope, ...this.related(registry, request.chapterId, request.factIds), ...this.related(candidate, request.chapterId, request.factIds)]);
551
+ if (maintenanceReviews.some(r => !initialScope.has(r.chapterId)))
552
+ throw new Error('Maintenance is outside the affected review scope; request a separate developer-directed tidy.');
553
+ const roots = [...new Set(maintenanceReviews.map(r => r.chapterId))];
554
+ const required = [...new Set([...tidyScope, ...this.related(registry, request.chapterId, request.factIds), ...this.related(candidate, request.chapterId, request.factIds), ...roots.flatMap(key => [...this.related(registry, key), ...this.related(candidate, key)])])].sort();
555
+ const affected = new Set([...this.impact(registry, request.chapterId, request.factIds), ...this.impact(candidate, request.chapterId, request.factIds)]);
556
+ if (!same(required, request.reviews.map(r => r.chapterId).sort()))
557
+ throw new Error(`Review every linked chapter: ${required.join(', ')}`);
558
+ const snapshots = {};
559
+ const changed = new Set();
560
+ for (const review of request.reviews) {
561
+ const old = this.chapter(registry, review.chapterId);
562
+ if (old.revision !== review.expectedRevision)
563
+ throw new GroundError('REGISTRY_CONFLICT', 'Chapter revision conflict; reload.', ['.common-ground/knowledge.json'], 'Reload the current chapters and review their revisions before preparing again.');
564
+ unique(review.reviewedFactIds, 'reviewed fact IDs');
565
+ unique(review.invalidatedFactIds, 'invalidated fact IDs');
566
+ unique((review.maintenance ?? []).map(m => m.factId), 'maintenance fact IDs');
567
+ if (!same([...review.reviewedFactIds].sort(), old.facts.map(f => f.id).sort()))
568
+ throw new Error('Every existing fact must be reviewed.');
569
+ const next = Chapter.parse({ ...old, facts: review.facts });
570
+ await this.validateFacts(next);
571
+ snapshots[review.chapterId] = await this.snapshot(next);
572
+ if (!same([...old.facts].sort((a, b) => a.id.localeCompare(b.id)), [...next.facts].sort((a, b) => a.id.localeCompare(b.id))))
573
+ changed.add(review.chapterId);
574
+ }
575
+ for (const review of request.reviews) {
576
+ const old = this.chapter(registry, review.chapterId);
577
+ for (const item of review.maintenance ?? []) {
578
+ const before = old.facts.find(f => f.id === item.factId), after = review.facts.find(f => f.id === item.factId);
579
+ if (!before || same(before, after) || !review.invalidatedFactIds.includes(item.factId))
580
+ throw new Error('Maintenance must identify an existing changed fact as invalidated.');
581
+ if ((item.action === 'merge' || item.action === 'remove') && after)
582
+ throw new Error('Merged or removed facts must be deleted in place.');
583
+ if ((item.action === 'correct' || item.action === 'tighten') && !after)
584
+ throw new Error('Corrections must preserve the existing fact ID.');
585
+ if (item.action === 'merge') {
586
+ if (!item.replacement || item.replacement === `${review.chapterId}/${item.factId}`)
587
+ throw new Error('Merge requires a different surviving replacement fact.');
588
+ this.fact(candidate, item.replacement);
589
+ }
590
+ else if (item.replacement)
591
+ throw new Error('Only merge maintenance accepts a replacement fact.');
592
+ }
593
+ if (!changed.has(review.chapterId)) {
594
+ if (review.invalidatedFactIds.length)
595
+ throw new Error('Unchanged facts cannot be marked invalidated');
596
+ continue;
597
+ }
598
+ if (request.tidyId && !tidyScope.includes(review.chapterId))
599
+ throw new Error('Changed chapter is outside the tidy scope; request a broader tidy plan.');
600
+ if (!review.invalidatedFactIds.length)
601
+ throw new Error('Changed facts require an invalidation reason.');
602
+ if (review.facts.some(f => !old.facts.some(o => o.id === f.id)))
603
+ throw new Error('New facts require developer-reviewed admission.');
604
+ for (const fact of old.facts)
605
+ if (!same(fact, review.facts.find(f => f.id === fact.id)) && !review.invalidatedFactIds.includes(fact.id))
606
+ throw new Error(`Unrelated fact edit: ${fact.id}`);
607
+ for (const id of review.invalidatedFactIds) {
608
+ const fact = old.facts.find(f => f.id === id);
609
+ if (!fact || same(fact, review.facts.find(f => f.id === id)))
610
+ throw new Error(`Invalid invalidated fact: ${id}`);
611
+ if (!request.tidyId && citationOnly(fact, review.facts.find(f => f.id === id)) && affected.has(`${review.chapterId}/${id}`))
612
+ continue;
613
+ const maintenance = review.maintenance?.find(m => m.factId === id);
614
+ if (maintenance) {
615
+ const anchored = request.touchedPaths.some(p => required.some(key => [...this.chapter(registry, key).paths, ...this.chapter(registry, key).facts.flatMap(f => f.evidence.map(e => e.path))].some(s => p === s || p.startsWith(`${s}/`))));
616
+ if (!request.tidyId && !anchored)
617
+ throw new Error('Maintenance requires relevant touched paths or a developer-requested tidyId.');
618
+ continue;
619
+ }
620
+ if (request.tidyId)
621
+ throw new Error('Every tidy edit requires an explicit maintenance action and reason.');
622
+ const direct = fact.evidence.some(e => request.touchedPaths.includes(e.path) && old.sources[e.path] !== snapshots[review.chapterId][e.path]);
623
+ if (!affected.has(`${review.chapterId}/${id}`))
624
+ throw new Error(`Fact is outside the selected dependency review: ${id}`);
625
+ const upstreamChanged = this.upstreamFacts(registry, `${review.chapterId}/${id}`).some(dep => {
626
+ const entry = this.fact(registry, dep);
627
+ const depReview = request.reviews.find(r => r.chapterId === entry.chapterId);
628
+ return depReview?.invalidatedFactIds.includes(entry.fact.id) && request.touchedPaths.some(p => (entry.fact.sourceScope.some(s => p === s || p.startsWith(`${s}/`)) || entry.fact.evidence.some(e => e.path === p)) && entry.chapter.sources[p] !== snapshots[entry.chapterId][p]);
629
+ });
630
+ if (!direct && !upstreamChanged)
631
+ throw new Error(`No directly touched, changed evidence or changed dependency for fact: ${id}`);
632
+ }
633
+ }
634
+ const files = await this.reviewFiles(registry, required, request.touchedPaths, candidate);
635
+ unique(request.verification.sourceFiles, 'verified source files');
636
+ unique(request.verification.documentFiles, 'verified documentation files');
637
+ for (const kind of ['sourceFiles', 'documentFiles'])
638
+ for (const file of files[kind]) {
639
+ if (!request.verification[kind].includes(file))
640
+ throw new Error(`Session verification required for ${kind}: ${file}. Read the file (or verify its deletion) before attesting; use review_checklist.`);
641
+ }
642
+ // A declaration is not proof that an external agent read a file; snapshots detect subsequent drift.
643
+ const reviewSnapshots = await this.fileSnapshots([...new Set([...request.verification.sourceFiles, ...request.verification.documentFiles])]);
644
+ return { request, required, snapshots, reviewSnapshots, changed: [...changed].sort() };
645
+ }
646
+ async markReviewed(registry, keys, snapshots) {
647
+ const context = Object.fromEntries(keys.map(key => [key, { chapterHash: hash(stable(this.chapter(registry, key))), snapshot: snapshots[key] }]));
648
+ for (const key of keys)
649
+ await this.atomic(`local/review-${key.replace('/', '--')}.json`, { context });
650
+ }
651
+ async prepare(input) {
652
+ const registry = await this.read();
653
+ const result = await this.evaluate(input, registry);
654
+ if (!result.changed.length) {
655
+ await this.markReviewed(registry, result.required, result.snapshots);
656
+ return { noop: true };
657
+ }
658
+ const id = randomUUID();
659
+ await this.atomic(`local/${id}.json`, { request: result.request, registryHash: hash(stable(registry)), snapshots: result.snapshots, reviewSnapshots: result.reviewSnapshots });
660
+ return { noop: false, proposalId: id, changedChapters: result.changed, reviewedChapters: result.required };
661
+ }
662
+ async commit(id) {
663
+ if (!/^[0-9a-f-]{36}$/.test(id))
664
+ throw new Error('Invalid proposal ID');
665
+ return this.lock(async () => {
666
+ await this.safe(`.common-ground/local/${id}.json`);
667
+ const proposal = JSON.parse(await fs.readFile(this.file(`local/${id}.json`), 'utf8'));
668
+ const registry = await this.read();
669
+ if (hash(stable(registry)) !== proposal.registryHash)
670
+ throw new GroundError('REGISTRY_CONFLICT', 'Knowledge changed; prepare a new proposal.', ['.common-ground/knowledge.json'], 'Reload the current chapters and review their revisions before preparing again.');
671
+ // Detect changed or deleted quotations before semantic validation can mask the source conflict.
672
+ const reviewed = await this.fileSnapshots(Object.keys(proposal.reviewSnapshots));
673
+ const changedFiles = Object.keys(proposal.reviewSnapshots).filter(file => reviewed[file] !== proposal.reviewSnapshots[file]);
674
+ if (changedFiles.length)
675
+ throw new GroundError('SOURCE_CONFLICT', changedFiles.some(file => proposal.request.verification.sourceFiles.includes(file)) ? 'Source changed during review; prepare a new proposal.' : 'Reviewed source or documentation changed; prepare a new proposal.', changedFiles, 'Re-read the changed source and documentation, then prepare a new proposal.');
676
+ const evaluated = await this.evaluate(proposal.request, registry);
677
+ if (!same(evaluated.snapshots, proposal.snapshots))
678
+ throw new GroundError('SOURCE_CONFLICT', 'Source changed during review; prepare a new proposal.', ['verification'], 'Re-read the changed source and documentation, then prepare a new proposal or bootstrap preflight.');
679
+ if (!same(evaluated.reviewSnapshots, proposal.reviewSnapshots))
680
+ throw new GroundError('SOURCE_CONFLICT', 'Reviewed source or documentation changed; prepare a new proposal.', ['verification'], 'Re-read the changed source and documentation, then prepare a new proposal or bootstrap preflight.');
681
+ let task, taskName;
682
+ if (proposal.taskId) {
683
+ if (!/^[0-9a-f-]{36}$/.test(proposal.taskId))
684
+ throw new Error('Invalid task ID');
685
+ taskName = `local/task-${proposal.taskId}.json`;
686
+ await this.safe(`.common-ground/${taskName}`);
687
+ task = JSON.parse(await fs.readFile(this.file(taskName), 'utf8'));
688
+ if (!task.pending.includes(id))
689
+ throw new Error('Prepared update does not belong to this task.');
690
+ for (const review of evaluated.request.reviews)
691
+ for (const factId of review.invalidatedFactIds) {
692
+ const key = `${review.chapterId}/${factId}`;
693
+ const before = this.chapter(registry, review.chapterId).facts.find(f => f.id === factId) ?? null;
694
+ const after = review.facts.find(f => f.id === factId) ?? null;
695
+ task.changeDetails ??= {};
696
+ task.changeDetails[key] = { before: task.changeDetails[key]?.before ?? before, after, reason: review.maintenance?.find(item => item.factId === factId)?.reason ?? review.reason };
697
+ task.changes[key] = after?.statement ?? '(removed)';
698
+ }
699
+ task.pending = task.pending.filter((pending) => pending !== id);
700
+ }
701
+ for (const key of evaluated.changed) {
702
+ const c = this.chapter(registry, key);
703
+ c.facts = evaluated.request.reviews.find(r => r.chapterId === key).facts;
704
+ c.revision++;
705
+ c.sources = evaluated.snapshots[key];
706
+ }
707
+ for (const key of evaluated.changed)
708
+ this.chapter(registry, key).dependencyFingerprints = this.dependencyFingerprints(registry, key);
709
+ await this.persist(registry);
710
+ // A failed local receipt leaves the pending ID visible at finish; never report false silence.
711
+ if (taskName)
712
+ await this.atomic(taskName, task);
713
+ await this.markReviewed(registry, evaluated.required, evaluated.snapshots);
714
+ await fs.rm(this.file(`local/${id}.json`));
715
+ return { changedChapters: evaluated.changed, reviewedChapters: evaluated.required };
716
+ });
717
+ }
718
+ }