@cleocode/core 2026.4.11 → 2026.4.13

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 (184) hide show
  1. package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
  2. package/dist/codebase-map/analyzers/architecture.js +0 -1
  3. package/dist/codebase-map/analyzers/architecture.js.map +1 -1
  4. package/dist/conduit/local-transport.d.ts +18 -8
  5. package/dist/conduit/local-transport.d.ts.map +1 -1
  6. package/dist/conduit/local-transport.js +23 -13
  7. package/dist/conduit/local-transport.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +6 -0
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.js +175 -68950
  16. package/dist/index.js.map +1 -7
  17. package/dist/init.d.ts +1 -2
  18. package/dist/init.d.ts.map +1 -1
  19. package/dist/init.js +1 -2
  20. package/dist/init.js.map +1 -1
  21. package/dist/internal.d.ts +8 -3
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +13 -6
  24. package/dist/internal.js.map +1 -1
  25. package/dist/memory/learnings.d.ts +2 -2
  26. package/dist/memory/patterns.d.ts +6 -6
  27. package/dist/output.d.ts +32 -11
  28. package/dist/output.d.ts.map +1 -1
  29. package/dist/output.js +67 -67
  30. package/dist/output.js.map +1 -1
  31. package/dist/paths.js +80 -14
  32. package/dist/paths.js.map +1 -1
  33. package/dist/skills/dynamic-skill-generator.d.ts +0 -2
  34. package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
  35. package/dist/skills/dynamic-skill-generator.js.map +1 -1
  36. package/dist/store/agent-registry-accessor.d.ts +203 -12
  37. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  38. package/dist/store/agent-registry-accessor.js +618 -100
  39. package/dist/store/agent-registry-accessor.js.map +1 -1
  40. package/dist/store/api-key-kdf.d.ts +73 -0
  41. package/dist/store/api-key-kdf.d.ts.map +1 -0
  42. package/dist/store/api-key-kdf.js +84 -0
  43. package/dist/store/api-key-kdf.js.map +1 -0
  44. package/dist/store/cleanup-legacy.js +171 -0
  45. package/dist/store/cleanup-legacy.js.map +1 -0
  46. package/dist/store/conduit-sqlite.d.ts +184 -0
  47. package/dist/store/conduit-sqlite.d.ts.map +1 -0
  48. package/dist/store/conduit-sqlite.js +570 -0
  49. package/dist/store/conduit-sqlite.js.map +1 -0
  50. package/dist/store/global-salt.d.ts +78 -0
  51. package/dist/store/global-salt.d.ts.map +1 -0
  52. package/dist/store/global-salt.js +147 -0
  53. package/dist/store/global-salt.js.map +1 -0
  54. package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
  55. package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
  56. package/dist/store/migrate-signaldock-to-conduit.js +555 -0
  57. package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
  58. package/dist/store/nexus-sqlite.js +28 -3
  59. package/dist/store/nexus-sqlite.js.map +1 -1
  60. package/dist/store/signaldock-sqlite.d.ts +122 -19
  61. package/dist/store/signaldock-sqlite.d.ts.map +1 -1
  62. package/dist/store/signaldock-sqlite.js +401 -251
  63. package/dist/store/signaldock-sqlite.js.map +1 -1
  64. package/dist/store/sqlite-backup.js +122 -4
  65. package/dist/store/sqlite-backup.js.map +1 -1
  66. package/dist/system/backup.d.ts +0 -26
  67. package/dist/system/backup.d.ts.map +1 -1
  68. package/dist/system/runtime.d.ts +0 -2
  69. package/dist/system/runtime.d.ts.map +1 -1
  70. package/dist/system/runtime.js +3 -3
  71. package/dist/system/runtime.js.map +1 -1
  72. package/dist/tasks/add.d.ts +1 -1
  73. package/dist/tasks/add.d.ts.map +1 -1
  74. package/dist/tasks/add.js +98 -23
  75. package/dist/tasks/add.js.map +1 -1
  76. package/dist/tasks/complete.d.ts.map +1 -1
  77. package/dist/tasks/complete.js +4 -1
  78. package/dist/tasks/complete.js.map +1 -1
  79. package/dist/tasks/find.d.ts.map +1 -1
  80. package/dist/tasks/find.js +4 -1
  81. package/dist/tasks/find.js.map +1 -1
  82. package/dist/tasks/labels.d.ts.map +1 -1
  83. package/dist/tasks/labels.js +4 -1
  84. package/dist/tasks/labels.js.map +1 -1
  85. package/dist/tasks/relates.d.ts.map +1 -1
  86. package/dist/tasks/relates.js +16 -4
  87. package/dist/tasks/relates.js.map +1 -1
  88. package/dist/tasks/show.d.ts.map +1 -1
  89. package/dist/tasks/show.js +4 -1
  90. package/dist/tasks/show.js.map +1 -1
  91. package/dist/tasks/update.d.ts.map +1 -1
  92. package/dist/tasks/update.js +32 -6
  93. package/dist/tasks/update.js.map +1 -1
  94. package/dist/validation/engine.d.ts.map +1 -1
  95. package/dist/validation/engine.js +16 -4
  96. package/dist/validation/engine.js.map +1 -1
  97. package/dist/validation/param-utils.d.ts +5 -3
  98. package/dist/validation/param-utils.d.ts.map +1 -1
  99. package/dist/validation/param-utils.js +8 -6
  100. package/dist/validation/param-utils.js.map +1 -1
  101. package/dist/validation/protocols/_shared.d.ts.map +1 -1
  102. package/dist/validation/protocols/_shared.js +13 -6
  103. package/dist/validation/protocols/_shared.js.map +1 -1
  104. package/package.json +9 -7
  105. package/src/adapters/__tests__/manager.test.ts +0 -1
  106. package/src/codebase-map/analyzers/architecture.ts +0 -1
  107. package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
  108. package/src/conduit/__tests__/local-transport.test.ts +14 -12
  109. package/src/conduit/local-transport.ts +23 -13
  110. package/src/config.ts +0 -1
  111. package/src/errors.ts +24 -0
  112. package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
  113. package/src/init.ts +1 -2
  114. package/src/internal.ts +96 -2
  115. package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
  116. package/src/memory/__tests__/engine-compat.test.ts +2 -2
  117. package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
  118. package/src/observability/__tests__/index.test.ts +4 -4
  119. package/src/observability/__tests__/log-filter.test.ts +4 -4
  120. package/src/output.ts +73 -75
  121. package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
  122. package/src/sessions/__tests__/session-grade.test.ts +2 -2
  123. package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
  124. package/src/skills/dynamic-skill-generator.ts +0 -2
  125. package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
  126. package/src/store/__tests__/api-key-kdf.test.ts +113 -0
  127. package/src/store/__tests__/backup-crypto.test.ts +101 -0
  128. package/src/store/__tests__/backup-pack.test.ts +491 -0
  129. package/src/store/__tests__/backup-unpack.test.ts +298 -0
  130. package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
  131. package/src/store/__tests__/global-salt.test.ts +195 -0
  132. package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
  133. package/src/store/__tests__/regenerators.test.ts +234 -0
  134. package/src/store/__tests__/restore-conflict-report.test.ts +274 -0
  135. package/src/store/__tests__/restore-json-merge.test.ts +521 -0
  136. package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
  137. package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
  138. package/src/store/__tests__/sqlite-backup.test.ts +5 -1
  139. package/src/store/__tests__/t310-integration.test.ts +1150 -0
  140. package/src/store/__tests__/t310-readiness.test.ts +111 -0
  141. package/src/store/__tests__/t311-integration.test.ts +661 -0
  142. package/src/store/agent-registry-accessor.ts +847 -140
  143. package/src/store/api-key-kdf.ts +104 -0
  144. package/src/store/backup-crypto.ts +209 -0
  145. package/src/store/backup-pack.ts +739 -0
  146. package/src/store/backup-unpack.ts +583 -0
  147. package/src/store/conduit-sqlite.ts +655 -0
  148. package/src/store/global-salt.ts +175 -0
  149. package/src/store/migrate-signaldock-to-conduit.ts +669 -0
  150. package/src/store/regenerators.ts +243 -0
  151. package/src/store/restore-conflict-report.ts +317 -0
  152. package/src/store/restore-json-merge.ts +653 -0
  153. package/src/store/signaldock-sqlite.ts +431 -254
  154. package/src/store/sqlite-backup.ts +185 -10
  155. package/src/store/t310-readiness.ts +119 -0
  156. package/src/system/backup.ts +2 -62
  157. package/src/system/runtime.ts +4 -6
  158. package/src/tasks/__tests__/error-hints.test.ts +256 -0
  159. package/src/tasks/add.ts +99 -9
  160. package/src/tasks/complete.ts +4 -1
  161. package/src/tasks/find.ts +4 -1
  162. package/src/tasks/labels.ts +4 -1
  163. package/src/tasks/relates.ts +16 -4
  164. package/src/tasks/show.ts +4 -1
  165. package/src/tasks/update.ts +32 -3
  166. package/src/validation/__tests__/error-hints.test.ts +97 -0
  167. package/src/validation/engine.ts +16 -1
  168. package/src/validation/param-utils.ts +10 -7
  169. package/src/validation/protocols/_shared.ts +14 -6
  170. package/src/validation/protocols/cant/architecture-decision.cant +80 -0
  171. package/src/validation/protocols/cant/artifact-publish.cant +95 -0
  172. package/src/validation/protocols/cant/consensus.cant +74 -0
  173. package/src/validation/protocols/cant/contribution.cant +82 -0
  174. package/src/validation/protocols/cant/decomposition.cant +92 -0
  175. package/src/validation/protocols/cant/implementation.cant +67 -0
  176. package/src/validation/protocols/cant/provenance.cant +88 -0
  177. package/src/validation/protocols/cant/release.cant +96 -0
  178. package/src/validation/protocols/cant/research.cant +66 -0
  179. package/src/validation/protocols/cant/specification.cant +67 -0
  180. package/src/validation/protocols/cant/testing.cant +88 -0
  181. package/src/validation/protocols/cant/validation.cant +65 -0
  182. package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
  183. package/templates/config.template.json +0 -1
  184. package/templates/global-config.template.json +0 -1
@@ -0,0 +1,653 @@
1
+ /**
2
+ * A/B regenerate-and-compare engine for JSON file restore.
3
+ *
4
+ * Classifies each field of the three managed CLEO JSON config files into one
5
+ * of five categories (identical, machine-local, user-intent, project-identity,
6
+ * auto-detect, unknown) and resolves conflicts deterministically per ADR-038 §10
7
+ * and T311 spec §6.
8
+ *
9
+ * PURE MODULE — no I/O, no global state, no disk writes. All output is returned
10
+ * as a {@link JsonRestoreReport} for the caller (T357) to format and persist.
11
+ *
12
+ * @task T354
13
+ * @epic T311
14
+ * @why ADR-038 §10 — intelligent A/B regenerate-and-compare for restore.
15
+ * Classifies each field of imported JSON files into 4 categories
16
+ * (machine-local, user-intent, project-identity, auto-detect, unknown)
17
+ * and resolves conflicts deterministically.
18
+ * @what Pure classification engine. Reads regenerated A from regenerators.ts,
19
+ * imported B from caller, returns a JsonRestoreReport for T357 to format.
20
+ * @see packages/contracts/src/backup-manifest.ts
21
+ * @see packages/core/src/store/regenerators.ts
22
+ * @module restore-json-merge
23
+ */
24
+
25
+ import {
26
+ regenerateConfigJson,
27
+ regenerateProjectContextJson,
28
+ regenerateProjectInfoJson,
29
+ } from './regenerators.js';
30
+
31
+ // ============================================================================
32
+ // Public types
33
+ // ============================================================================
34
+
35
+ /**
36
+ * The three JSON filenames managed by the restore engine.
37
+ *
38
+ * Each filename maps to a distinct classification ruleset (spec §6.3).
39
+ *
40
+ * @task T354
41
+ * @epic T311
42
+ */
43
+ export type FilenameForRestore = 'config.json' | 'project-info.json' | 'project-context.json';
44
+
45
+ /**
46
+ * Five-way field category taxonomy per T311 spec §6.3 / ADR-038 §10.
47
+ *
48
+ * - `identical` — A and B are byte-equal (JSON.stringify match); no conflict.
49
+ * - `machine-local` — field is expected to differ between machines; keep A.
50
+ * - `user-intent` — field represents user-applied config in config.json; keep B.
51
+ * - `project-identity` — field identifies the project in project-info.json; keep B.
52
+ * - `auto-detect` — field is auto-detected from the local environment; keep A.
53
+ * - `unknown` — no classification rule matched; flag for manual review.
54
+ *
55
+ * @task T354
56
+ * @epic T311
57
+ */
58
+ export type FieldCategory =
59
+ | 'identical'
60
+ | 'machine-local'
61
+ | 'user-intent'
62
+ | 'project-identity'
63
+ | 'auto-detect'
64
+ | 'unknown';
65
+
66
+ /**
67
+ * Resolution applied to a field when building the merged output.
68
+ *
69
+ * - `A` — use the locally regenerated value.
70
+ * - `B` — use the imported value.
71
+ * - `manual-review` — no safe auto-resolution; local value (A) is kept as a
72
+ * safe default pending operator review.
73
+ *
74
+ * @task T354
75
+ * @epic T311
76
+ */
77
+ export type Resolution = 'A' | 'B' | 'manual-review';
78
+
79
+ /**
80
+ * Classification and resolution for a single leaf field in the comparison.
81
+ *
82
+ * Produced by {@link regenerateAndCompare} for every leaf path encountered
83
+ * during the recursive walk of both objects.
84
+ *
85
+ * @task T354
86
+ * @epic T311
87
+ */
88
+ export interface FieldClassification {
89
+ /** JSON dot-path of the field, e.g. "brain.embeddingProvider" or "testing.framework". */
90
+ path: string;
91
+ /** Value from locally regenerated content (A). `undefined` when absent in A. */
92
+ local: unknown;
93
+ /** Value from the imported bundle (B). `undefined` when absent in B. */
94
+ imported: unknown;
95
+ /**
96
+ * Taxonomy category per spec §6.3 classification rules.
97
+ * `identical` means `JSON.stringify(A) === JSON.stringify(B)` — no conflict.
98
+ */
99
+ category: FieldCategory;
100
+ /**
101
+ * The resolution applied (or to be applied) to disk.
102
+ * `A` = use local value; `B` = use imported value;
103
+ * `manual-review` = operator must decide.
104
+ */
105
+ resolution: Resolution;
106
+ /** Human-readable explanation for the resolution. */
107
+ rationale: string;
108
+ }
109
+
110
+ /**
111
+ * Complete A/B comparison result for one JSON file.
112
+ *
113
+ * Returned by {@link regenerateAndCompare}. Caller persists `applied` to disk
114
+ * and writes the full report to `.cleo/restore-conflicts.md`.
115
+ *
116
+ * @task T354
117
+ * @epic T311
118
+ */
119
+ export interface JsonRestoreReport {
120
+ /** Which file was compared. */
121
+ filename: FilenameForRestore;
122
+ /** The locally regenerated object (A). */
123
+ localGenerated: unknown;
124
+ /** The imported object (B). */
125
+ imported: unknown;
126
+ /**
127
+ * Per-field classification results.
128
+ * All leaf fields — including identical ones — are listed here.
129
+ */
130
+ classifications: FieldClassification[];
131
+ /** The final merged object produced by applying all resolutions. */
132
+ applied: unknown;
133
+ /**
134
+ * Count of fields with `resolution === 'manual-review'`.
135
+ * Equals the number of `unknown`-category classifications.
136
+ */
137
+ conflictCount: number;
138
+ }
139
+
140
+ /**
141
+ * Input to {@link regenerateAndCompare}.
142
+ *
143
+ * The caller supplies the imported content (B) and the locally regenerated
144
+ * content (A). The engine classifies and resolves each field.
145
+ *
146
+ * @task T354
147
+ * @epic T311
148
+ */
149
+ export interface RegenerateAndCompareInput {
150
+ /** Which of the three managed JSON files is being compared. */
151
+ filename: FilenameForRestore;
152
+ /** Parsed content of the imported file (B — from bundle json/ directory). */
153
+ imported: unknown;
154
+ /**
155
+ * Locally regenerated content (A).
156
+ *
157
+ * When provided, the engine uses this value directly as the A side.
158
+ * When omitted (or when `filename` is provided with a `projectRoot`),
159
+ * the engine calls the appropriate generator from regenerators.ts.
160
+ *
161
+ * Providing this field allows callers to pass a pre-computed A — useful
162
+ * in tests and in the CLI import command to avoid redundant regeneration.
163
+ */
164
+ localGenerated: unknown;
165
+ }
166
+
167
+ // ============================================================================
168
+ // Classification pattern tables
169
+ // ============================================================================
170
+
171
+ /**
172
+ * Field-path prefixes and exact paths that identify machine-local fields.
173
+ *
174
+ * Applies to ALL three files. Resolution: A (keep local).
175
+ *
176
+ * Spec §6.3 table row: "Machine-local — all three files".
177
+ */
178
+ const MACHINE_LOCAL_PATTERNS: readonly string[] = [
179
+ 'projectRoot',
180
+ 'machineKey',
181
+ 'machineFingerprint',
182
+ 'hostname',
183
+ 'cwd',
184
+ 'createdAt',
185
+ 'detectedAt',
186
+ 'lastUpdated',
187
+ 'projectId',
188
+ 'projectHash',
189
+ ];
190
+
191
+ /**
192
+ * Field-path prefixes and exact paths that identify user-intent fields.
193
+ *
194
+ * Applies ONLY to `config.json`. Resolution: B (keep imported).
195
+ *
196
+ * Spec §6.3 table row: "User intent — config.json only".
197
+ */
198
+ const USER_INTENT_PATTERNS_CONFIG: readonly string[] = [
199
+ 'enabledFeatures',
200
+ 'brain',
201
+ 'hooks',
202
+ 'tools',
203
+ 'contributor',
204
+ ];
205
+
206
+ /**
207
+ * Field-path prefixes and exact paths that identify project-identity fields.
208
+ *
209
+ * Applies ONLY to `project-info.json`. Resolution: B (keep imported).
210
+ *
211
+ * Spec §6.3 table row: "Project identity — project-info.json only".
212
+ */
213
+ const PROJECT_IDENTITY_PATTERNS: readonly string[] = [
214
+ 'name',
215
+ 'description',
216
+ 'type',
217
+ 'primaryType',
218
+ 'tags',
219
+ 'labels',
220
+ ];
221
+
222
+ /**
223
+ * Field-path prefixes and exact paths that identify auto-detect fields.
224
+ *
225
+ * Applies ONLY to `project-context.json`. Resolution: A (local detection preferred).
226
+ *
227
+ * Spec §6.3 table row: "Auto-detect — project-context.json only".
228
+ */
229
+ const AUTO_DETECT_PATTERNS_CONTEXT: readonly string[] = [
230
+ 'testing',
231
+ 'build',
232
+ 'directories',
233
+ 'conventions',
234
+ 'llmHints',
235
+ 'projectTypes',
236
+ 'monorepo',
237
+ 'schemaVersion',
238
+ ];
239
+
240
+ // ============================================================================
241
+ // Path-matching helpers
242
+ // ============================================================================
243
+
244
+ /**
245
+ * Returns `true` when `fieldPath` is an exact match for, or starts with a
246
+ * dot-separated prefix of, any pattern in `patterns`.
247
+ *
248
+ * Examples:
249
+ * - matchesPattern('brain.embeddingProvider', ['brain']) → true
250
+ * - matchesPattern('brain', ['brain']) → true
251
+ * - matchesPattern('brainX', ['brain']) → false (not a prefix boundary)
252
+ *
253
+ * @param fieldPath - Dot-separated path to test.
254
+ * @param patterns - List of exact or prefix patterns.
255
+ * @returns `true` when `fieldPath` matches any pattern.
256
+ */
257
+ function matchesPattern(fieldPath: string, patterns: readonly string[]): boolean {
258
+ for (const pattern of patterns) {
259
+ if (fieldPath === pattern || fieldPath.startsWith(`${pattern}.`)) {
260
+ return true;
261
+ }
262
+ }
263
+ return false;
264
+ }
265
+
266
+ /**
267
+ * Heuristic: returns `true` when `value` is a string that looks like an
268
+ * absolute filesystem path (Unix `/…` or Windows `C:\…`).
269
+ *
270
+ * Spec §6.3: "any string value that is an absolute filesystem path" is
271
+ * classified as machine-local.
272
+ *
273
+ * @param value - Candidate value to inspect.
274
+ * @returns `true` when `value` is an absolute-path string.
275
+ */
276
+ function isAbsolutePathString(value: unknown): boolean {
277
+ if (typeof value !== 'string') return false;
278
+ // Unix absolute: starts with /
279
+ if (value.startsWith('/')) return true;
280
+ // Windows absolute: drive letter + colon + backslash or forward-slash
281
+ return /^[A-Za-z]:[/\\]/.test(value);
282
+ }
283
+
284
+ // ============================================================================
285
+ // Core classification logic
286
+ // ============================================================================
287
+
288
+ /**
289
+ * Classifies a single leaf field and determines its resolution.
290
+ *
291
+ * Applies the precedence order from spec §6.3:
292
+ * identical > machine-local > user-intent > project-identity > auto-detect > unknown
293
+ *
294
+ * @param filename - Which JSON file the field belongs to.
295
+ * @param path - Dot-notation path, e.g. "brain.embeddingProvider".
296
+ * @param localValue - Value from locally regenerated content (A).
297
+ * @param importedValue - Value from imported bundle (B).
298
+ * @returns A complete {@link FieldClassification} for the field.
299
+ */
300
+ function classifyField(
301
+ filename: FilenameForRestore,
302
+ path: string,
303
+ localValue: unknown,
304
+ importedValue: unknown,
305
+ ): FieldClassification {
306
+ // 1. Identical — highest precedence.
307
+ if (JSON.stringify(localValue) === JSON.stringify(importedValue)) {
308
+ return {
309
+ path,
310
+ local: localValue,
311
+ imported: importedValue,
312
+ category: 'identical',
313
+ resolution: 'A',
314
+ rationale: 'values are identical — no merge required',
315
+ };
316
+ }
317
+
318
+ // 2. Machine-local — applies to all files; path-pattern OR absolute-path heuristic.
319
+ if (
320
+ matchesPattern(path, MACHINE_LOCAL_PATTERNS) ||
321
+ isAbsolutePathString(localValue) ||
322
+ isAbsolutePathString(importedValue)
323
+ ) {
324
+ return {
325
+ path,
326
+ local: localValue,
327
+ imported: importedValue,
328
+ category: 'machine-local',
329
+ resolution: 'A',
330
+ rationale: 'machine-local field — expected to differ between machines; keeping local value',
331
+ };
332
+ }
333
+
334
+ // 3. User-intent — config.json only.
335
+ if (filename === 'config.json' && matchesPattern(path, USER_INTENT_PATTERNS_CONFIG)) {
336
+ return {
337
+ path,
338
+ local: localValue,
339
+ imported: importedValue,
340
+ category: 'user-intent',
341
+ resolution: 'B',
342
+ rationale: 'user intent field in config.json — preserving imported value from source',
343
+ };
344
+ }
345
+
346
+ // 4. Project-identity — project-info.json only.
347
+ if (filename === 'project-info.json' && matchesPattern(path, PROJECT_IDENTITY_PATTERNS)) {
348
+ return {
349
+ path,
350
+ local: localValue,
351
+ imported: importedValue,
352
+ category: 'project-identity',
353
+ resolution: 'B',
354
+ rationale: 'project identity field — preserving imported value from source',
355
+ };
356
+ }
357
+
358
+ // 5. Auto-detect — project-context.json only.
359
+ if (filename === 'project-context.json' && matchesPattern(path, AUTO_DETECT_PATTERNS_CONTEXT)) {
360
+ return {
361
+ path,
362
+ local: localValue,
363
+ imported: importedValue,
364
+ category: 'auto-detect',
365
+ resolution: 'A',
366
+ rationale:
367
+ 'auto-detect field in project-context.json — local detection is always preferred over a potentially stale import',
368
+ };
369
+ }
370
+
371
+ // 6. Unknown — no rule matched; flag for manual review.
372
+ return {
373
+ path,
374
+ local: localValue,
375
+ imported: importedValue,
376
+ category: 'unknown',
377
+ resolution: 'manual-review',
378
+ rationale:
379
+ 'unclassified field — no auto-resolution rule applies; needs human review before applying',
380
+ };
381
+ }
382
+
383
+ // ============================================================================
384
+ // Recursive walk
385
+ // ============================================================================
386
+
387
+ /**
388
+ * Returns `true` when `value` is a plain object (not null, not an array).
389
+ *
390
+ * Arrays are treated as atomic leaf values — no per-element classification.
391
+ *
392
+ * @param value - Value to test.
393
+ * @returns `true` when `value` is a plain object.
394
+ */
395
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
396
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
397
+ }
398
+
399
+ /**
400
+ * Recursively walks two objects (or leaf values) at `prefix` and appends a
401
+ * {@link FieldClassification} for every leaf field encountered.
402
+ *
403
+ * For plain objects, recurses into the union of keys from both sides.
404
+ * For all other values (primitives, arrays, or when one side is missing),
405
+ * delegates to {@link classifyField}.
406
+ *
407
+ * Missing-on-one-side rules:
408
+ * - Field present in B but absent in A → `local = undefined`.
409
+ * - Field present in A but absent in B → `imported = undefined`.
410
+ *
411
+ * @param filename - Which JSON file is being classified.
412
+ * @param prefix - Current dot-path prefix (empty string at root).
413
+ * @param local - Value from locally regenerated content (A).
414
+ * @param imported - Value from imported bundle (B).
415
+ * @param classifications - Accumulator array; entries are pushed here.
416
+ */
417
+ function walkAndClassify(
418
+ filename: FilenameForRestore,
419
+ prefix: string,
420
+ local: unknown,
421
+ imported: unknown,
422
+ classifications: FieldClassification[],
423
+ ): void {
424
+ if (isPlainObject(local) && isPlainObject(imported)) {
425
+ // Both are plain objects — recurse into the union of all keys.
426
+ const allKeys = new Set([...Object.keys(local), ...Object.keys(imported)]);
427
+ for (const key of allKeys) {
428
+ const childPath = prefix === '' ? key : `${prefix}.${key}`;
429
+ walkAndClassify(filename, childPath, local[key], imported[key], classifications);
430
+ }
431
+ return;
432
+ }
433
+
434
+ if (isPlainObject(local) && imported === undefined) {
435
+ // Local is an object, imported is absent — recurse with imported as undefined leaves.
436
+ const allKeys = Object.keys(local);
437
+ for (const key of allKeys) {
438
+ const childPath = prefix === '' ? key : `${prefix}.${key}`;
439
+ walkAndClassify(filename, childPath, local[key], undefined, classifications);
440
+ }
441
+ return;
442
+ }
443
+
444
+ if (local === undefined && isPlainObject(imported)) {
445
+ // Local is absent, imported is an object — recurse with local as undefined leaves.
446
+ const allKeys = Object.keys(imported);
447
+ for (const key of allKeys) {
448
+ const childPath = prefix === '' ? key : `${prefix}.${key}`;
449
+ walkAndClassify(filename, childPath, undefined, imported[key], classifications);
450
+ }
451
+ return;
452
+ }
453
+
454
+ // Leaf: primitive, array, or one is an object while the other is not.
455
+ // Treat the whole value as a single field.
456
+ classifications.push(classifyField(filename, prefix, local, imported));
457
+ }
458
+
459
+ // ============================================================================
460
+ // Dot-path object manipulation helpers
461
+ // ============================================================================
462
+
463
+ /**
464
+ * Gets a value from a nested object using a dot-notation path.
465
+ *
466
+ * Returns `undefined` when any segment along the path is absent or not an object.
467
+ *
468
+ * @param obj - The root object to traverse.
469
+ * @param path - Dot-separated field path, e.g. "brain.embeddingProvider".
470
+ * @returns The value at `path`, or `undefined`.
471
+ */
472
+ function dotGet(obj: unknown, path: string): unknown {
473
+ const parts = path.split('.');
474
+ let current: unknown = obj;
475
+ for (const part of parts) {
476
+ if (!isPlainObject(current)) return undefined;
477
+ current = current[part];
478
+ }
479
+ return current;
480
+ }
481
+
482
+ /**
483
+ * Sets a value in a nested object using a dot-notation path, creating
484
+ * intermediate plain-object nodes as needed.
485
+ *
486
+ * Mutates `obj` in place.
487
+ *
488
+ * @param obj - The root object to mutate.
489
+ * @param path - Dot-separated field path.
490
+ * @param value - Value to set.
491
+ */
492
+ function dotSet(obj: Record<string, unknown>, path: string, value: unknown): void {
493
+ const parts = path.split('.');
494
+ let current: Record<string, unknown> = obj;
495
+ for (let i = 0; i < parts.length - 1; i++) {
496
+ const part = parts[i] as string;
497
+ if (!isPlainObject(current[part])) {
498
+ current[part] = {};
499
+ }
500
+ current = current[part] as Record<string, unknown>;
501
+ }
502
+ const lastPart = parts[parts.length - 1] as string;
503
+ current[lastPart] = value;
504
+ }
505
+
506
+ /**
507
+ * Produces a deep clone of a plain-object tree using JSON round-trip.
508
+ *
509
+ * Arrays and primitives at the root are also handled correctly.
510
+ *
511
+ * @param value - Value to clone.
512
+ * @returns A structurally identical but referentially independent copy.
513
+ */
514
+ function deepClone(value: unknown): unknown {
515
+ return JSON.parse(JSON.stringify(value ?? null));
516
+ }
517
+
518
+ // ============================================================================
519
+ // Applied-result builder
520
+ // ============================================================================
521
+
522
+ /**
523
+ * Builds the merged result object by applying all resolved classifications
524
+ * on top of a deep clone of `localGenerated` (A).
525
+ *
526
+ * Resolution mapping:
527
+ * - `A` (identical, machine-local, auto-detect) — keep the local value (no-op, already in clone).
528
+ * - `B` (user-intent, project-identity) — overwrite with imported value.
529
+ * - `manual-review` (unknown) — keep local value as safe default.
530
+ *
531
+ * @param localGenerated - Locally regenerated content (A).
532
+ * @param classifications - All field classifications produced by the walk.
533
+ * @returns The merged object with all B-resolution fields overwritten.
534
+ */
535
+ function buildApplied(localGenerated: unknown, classifications: FieldClassification[]): unknown {
536
+ const result = deepClone(localGenerated) as Record<string, unknown>;
537
+
538
+ for (const classification of classifications) {
539
+ if (classification.resolution === 'B') {
540
+ // B resolution: overwrite with the imported value.
541
+ // When imported value is undefined (field absent in B), remove from result.
542
+ if (classification.imported === undefined) {
543
+ // Field was in A but absent in B — for a B resolution this means we
544
+ // want to remove it (preserve the "absent" state from the source).
545
+ // Walk the path and delete the terminal key.
546
+ const parts = classification.path.split('.');
547
+ const parentPath = parts.slice(0, -1).join('.');
548
+ const key = parts[parts.length - 1] as string;
549
+ const parent = parentPath === '' ? result : dotGet(result, parentPath);
550
+ if (isPlainObject(parent)) {
551
+ delete (parent as Record<string, unknown>)[key];
552
+ }
553
+ } else {
554
+ dotSet(result, classification.path, classification.imported);
555
+ }
556
+ }
557
+ // resolution A or manual-review: keep the local value — already in clone.
558
+ }
559
+
560
+ return result;
561
+ }
562
+
563
+ // ============================================================================
564
+ // Public API
565
+ // ============================================================================
566
+
567
+ /**
568
+ * Runs the A/B regenerate-and-compare classification engine for a single
569
+ * JSON file.
570
+ *
571
+ * Does NOT write anything to disk. Returns a {@link JsonRestoreReport}
572
+ * containing the per-field classifications, the merged `applied` result, and
573
+ * a count of fields that require manual review.
574
+ *
575
+ * The `localGenerated` field on the input is used directly as the A side.
576
+ * Pass the result of the appropriate generator from `regenerators.ts` (or any
577
+ * equivalent object for testing).
578
+ *
579
+ * Classification precedence (spec §6.3):
580
+ * identical > machine-local > user-intent > project-identity > auto-detect > unknown
581
+ *
582
+ * @task T354
583
+ * @epic T311
584
+ * @param input - Filename, imported content (B), and locally regenerated content (A).
585
+ * @returns A complete `JsonRestoreReport` for the caller to persist.
586
+ */
587
+ export function regenerateAndCompare(input: RegenerateAndCompareInput): JsonRestoreReport {
588
+ const { filename, imported, localGenerated } = input;
589
+
590
+ const classifications: FieldClassification[] = [];
591
+ walkAndClassify(filename, '', localGenerated, imported, classifications);
592
+
593
+ const applied = buildApplied(localGenerated, classifications);
594
+
595
+ const conflictCount = classifications.filter((c) => c.resolution === 'manual-review').length;
596
+
597
+ return {
598
+ filename,
599
+ localGenerated,
600
+ imported,
601
+ classifications,
602
+ applied,
603
+ conflictCount,
604
+ };
605
+ }
606
+
607
+ /**
608
+ * Convenience wrapper that runs `regenerateAndCompare` for all three JSON
609
+ * files in a single call, generating the A sides from `projectRoot`.
610
+ *
611
+ * Useful in the CLI import command when all three files are present in the
612
+ * bundle. Each generator runs independently.
613
+ *
614
+ * Does NOT write anything to disk.
615
+ *
616
+ * @task T354
617
+ * @epic T311
618
+ * @param projectRoot - Absolute path to the project root (for A generation).
619
+ * @param importedConfig - Imported config.json content (B).
620
+ * @param importedInfo - Imported project-info.json content (B).
621
+ * @param importedContext - Imported project-context.json content (B).
622
+ * @returns Object containing one `JsonRestoreReport` per managed JSON file.
623
+ */
624
+ export function regenerateAndCompareAll(
625
+ projectRoot: string,
626
+ importedConfig: unknown,
627
+ importedInfo: unknown,
628
+ importedContext: unknown,
629
+ ): {
630
+ config: JsonRestoreReport;
631
+ projectInfo: JsonRestoreReport;
632
+ projectContext: JsonRestoreReport;
633
+ } {
634
+ const config = regenerateAndCompare({
635
+ filename: 'config.json',
636
+ imported: importedConfig,
637
+ localGenerated: regenerateConfigJson(projectRoot).content,
638
+ });
639
+
640
+ const projectInfo = regenerateAndCompare({
641
+ filename: 'project-info.json',
642
+ imported: importedInfo,
643
+ localGenerated: regenerateProjectInfoJson(projectRoot).content,
644
+ });
645
+
646
+ const projectContext = regenerateAndCompare({
647
+ filename: 'project-context.json',
648
+ imported: importedContext,
649
+ localGenerated: regenerateProjectContextJson(projectRoot).content,
650
+ });
651
+
652
+ return { config, projectInfo, projectContext };
653
+ }