superdev-cli 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 (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,512 @@
1
+ // Connect, synchronize, preview and resolve. Section 12.9.
2
+ //
3
+ // The four commands refused for as long as DEC-TBD-006, 007 and 008 were open,
4
+ // which was correct: a merge policy invented by whoever wrote the code is a
5
+ // policy nobody agreed to. Those decisions are now recorded and this implements
6
+ // them, and nothing more than them.
7
+ //
8
+ // Local first, throughout. A pull never overwrites a record this machine also
9
+ // changed; it records a conflict and leaves the local value in place. Nothing
10
+ // leaves the machine unencrypted, and nothing leaves it that DEC-TBD-007 keeps
11
+ // local. The only transport is a directory, so nothing reaches the network.
12
+ //
13
+ // The offline queue is the cursor, not a second table. Every change is already
14
+ // in the record with its version, and a sync compares against the base agreed
15
+ // last time, so work done with no remote reachable is simply work the next sync
16
+ // finds. There is nothing to drain and nothing to lose if the queue itself were
17
+ // lost, which is the strongest form a queue can take.
18
+
19
+ import { create, mutate, query, recordActivity } from "../db/store.mjs";
20
+ import { ensureKey, fingerprint, keyExists, loadKey, open, rowHash, seal } from "./crypto.mjs";
21
+ import {
22
+ OUTCOME, buildBundle, compare, differingFields, foreignLeases, leaseExpiry, mergeFields,
23
+ } from "./merge.mjs";
24
+ import { SHARED_TABLES, WITHHELD, isShared, project } from "./policy.mjs";
25
+ import { transportFor } from "./transport.mjs";
26
+
27
+ export const E = {
28
+ NOT_CONNECTED: "E_NOT_CONNECTED",
29
+ NO_PEER: "E_NO_PEER",
30
+ CONFLICTS_OPEN: "E_CONFLICTS_OPEN",
31
+ UNKNOWN_CONFLICT: "E_UNKNOWN_CONFLICT",
32
+ LEASE_HELD: "E_LEASE_HELD",
33
+ };
34
+
35
+ export class SyncError extends Error {
36
+ constructor(code, message, detail) {
37
+ super(message);
38
+ this.name = "SyncError";
39
+ this.code = code;
40
+ if (detail !== undefined) this.detail = detail;
41
+ }
42
+ }
43
+
44
+ const nowIso = () => new Date().toISOString();
45
+
46
+ /** The peer this project syncs with, or nothing. */
47
+ async function peerOf(db) {
48
+ return db.get("SELECT * FROM sync_peers ORDER BY connected_at DESC LIMIT 1");
49
+ }
50
+
51
+ // ------------------------------------------------------------------ connect
52
+
53
+ /**
54
+ * Point this project at a remote and create its key.
55
+ *
56
+ * Connecting writes nothing to the remote and reads nothing from it beyond
57
+ * checking it is there. Somebody connecting is saying where their copy lives,
58
+ * not agreeing to send it, and the first send is a separate act.
59
+ */
60
+ export async function connect(root, { location, alias = null, transport = "directory", apply = false } = {}) {
61
+ if (!location) {
62
+ throw new SyncError(E.NO_PEER,
63
+ "Say where the remote is: superdev cloud connect <directory>. The only transport implemented is a directory on this machine, which may be a shared or mounted volume.");
64
+ }
65
+ const existingKey = keyExists(root);
66
+ const plan = { location, alias, transport, createsKey: !existingKey };
67
+ if (!apply) return { applied: false, plan };
68
+
69
+ const key = ensureKey(root);
70
+ const print = fingerprint(key);
71
+
72
+ return mutate(root, async (db) => {
73
+ const project_ = await db.get("SELECT id, slug, name FROM projects LIMIT 1");
74
+ const name = alias ?? `${project_.slug}-${print.slice(0, 6)}`;
75
+ const carrier = transportFor({ transport, location });
76
+ carrier.open();
77
+
78
+ const existing = await peerOf(db);
79
+ if (existing) {
80
+ await db.run(
81
+ `UPDATE sync_peers SET transport = ?, location = ?, alias = ?, key_fingerprint = ?,
82
+ status = 'connected', connected_at = ? WHERE id = ?`,
83
+ transport, location, name, print, nowIso(), existing.id,
84
+ );
85
+ } else {
86
+ await create(db, "sync_peer", {
87
+ project_id: project_.id,
88
+ peer_type: transport,
89
+ transport,
90
+ location,
91
+ alias: name,
92
+ key_fingerprint: print,
93
+ status: "connected",
94
+ connected_at: nowIso(),
95
+ local_priority: 1,
96
+ }, { projectId: project_.id, activity: false });
97
+ }
98
+
99
+ await recordActivity(db, project_.id, {
100
+ type: "scope_changed",
101
+ actor: "superdev",
102
+ summary: `Connected to a ${transport} remote as ${name}. Nothing has been sent yet.`,
103
+ metadata: { transport, keyFingerprint: print },
104
+ });
105
+
106
+ return {
107
+ applied: true,
108
+ alias: name,
109
+ keyFingerprint: print,
110
+ createdKey: !existingKey,
111
+ location,
112
+ transport,
113
+ };
114
+ });
115
+ }
116
+
117
+ // ------------------------------------------------------------------- status
118
+
119
+ export async function status(root) {
120
+ return query(root, async (db) => {
121
+ const peer = await peerOf(db);
122
+ const conflicts = await db.all(
123
+ "SELECT * FROM sync_conflicts WHERE status = 'open' ORDER BY detected_at").catch(() => []);
124
+ const held = await db.all(
125
+ `SELECT task_id, lease_holder, lease_expires_at FROM task_assignments
126
+ WHERE active = 1 AND lease_holder IS NOT NULL`).catch(() => []);
127
+
128
+ if (!peer) {
129
+ return {
130
+ connected: false,
131
+ why: "No remote is configured for this project. Everything works without one: section 12.9 says synchronization is not required for the local plugin to function.",
132
+ conflicts: [],
133
+ leases: [],
134
+ shared: SHARED_TABLES.length,
135
+ withheld: Object.keys(WITHHELD).length,
136
+ };
137
+ }
138
+
139
+ let reachable = false;
140
+ try {
141
+ reachable = transportFor(peer).reachable();
142
+ } catch {
143
+ reachable = false;
144
+ }
145
+
146
+ const tracked = await db.get(
147
+ "SELECT COUNT(*) AS n FROM sync_base WHERE peer_id = ?", peer.id).catch(() => ({ n: 0 }));
148
+
149
+ return {
150
+ connected: peer.status === "connected",
151
+ reachable,
152
+ transport: peer.transport,
153
+ location: peer.location,
154
+ alias: peer.alias,
155
+ keyFingerprint: peer.key_fingerprint,
156
+ lastSyncedAt: peer.last_synced_at,
157
+ trackedRecords: Number(tracked?.n ?? 0),
158
+ conflicts: conflicts.map((c) => ({
159
+ id: c.id, recordType: c.record_type, recordId: c.record_id, detectedAt: c.detected_at,
160
+ })),
161
+ leases: held,
162
+ shared: SHARED_TABLES.length,
163
+ withheld: Object.keys(WITHHELD).length,
164
+ };
165
+ });
166
+ }
167
+
168
+ // --------------------------------------------------------------------- sync
169
+
170
+ /** Read every bundle the remote holds that this side did not write. */
171
+ function readPeerBundles(carrier, key, selfAlias) {
172
+ const out = [];
173
+ for (const entry of carrier.list(selfAlias)) {
174
+ const bytes = carrier.get(entry.name);
175
+ if (!bytes) continue;
176
+ // A bundle that cannot be opened is reported, never skipped in silence: a
177
+ // reader needs to know their colleague's copy is unreadable.
178
+ try {
179
+ out.push({ name: entry.name, bundle: open(key, bytes) });
180
+ } catch (error) {
181
+ out.push({ name: entry.name, error: error.message });
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * Plan and optionally perform one synchronization.
189
+ *
190
+ * Without `apply` this is `sync --dry-run`: everything is read, compared and
191
+ * reported, and nothing is written locally or remotely. The two paths share
192
+ * every line that decides anything, so the preview cannot describe a different
193
+ * sync from the one that would run.
194
+ */
195
+ export async function synchronize(root, { apply = false, alias = null } = {}) {
196
+ const peer = await query(root, (db) => peerOf(db));
197
+ if (!peer) {
198
+ throw new SyncError(E.NOT_CONNECTED,
199
+ "This project is not connected to a remote. Run superdev cloud connect <directory> first. Nothing local depends on it.");
200
+ }
201
+ const key = loadKey(root);
202
+ const carrier = transportFor(peer);
203
+ if (!carrier.reachable()) {
204
+ throw new SyncError(E.NOT_CONNECTED,
205
+ `The remote at ${peer.location} cannot be reached, so there is nothing to synchronize with. Local work is unaffected and the next sync will find it.`);
206
+ }
207
+ const self = alias ?? peer.alias;
208
+
209
+ const plan = await query(root, async (db) => {
210
+ const project_ = await db.get("SELECT id FROM projects LIMIT 1");
211
+ const bundles = readPeerBundles(carrier, key, self);
212
+ const unreadable = bundles.filter((b) => b.error);
213
+
214
+ const bases = new Map();
215
+ for (const row of await db.all("SELECT * FROM sync_base WHERE peer_id = ?", peer.id)) {
216
+ bases.set(`${row.record_type}:${row.record_id}`, row);
217
+ }
218
+
219
+ const incoming = [];
220
+ const agreed = [];
221
+ const conflicts = [];
222
+ const leases = [];
223
+
224
+ for (const { bundle } of bundles.filter((b) => !b.error)) {
225
+ for (const [table, rows] of Object.entries(bundle.records ?? {})) {
226
+ if (!isShared(table)) continue;
227
+ for (const remote of rows) {
228
+ if (!remote?.id) continue;
229
+ const local = await db.get(`SELECT * FROM ${table} WHERE id = ?`, remote.id).catch(() => null);
230
+ const localProjected = local ? project(table, local) : null;
231
+ const base = bases.get(`${table}:${remote.id}`);
232
+ const verdict = compare({ local: localProjected, remote, base });
233
+
234
+ if (verdict.outcome === OUTCOME.NEW_REMOTE || verdict.outcome === OUTCOME.REMOTE_ONLY) {
235
+ incoming.push({ table, id: remote.id, row: remote, kind: verdict.outcome });
236
+ } else if (verdict.outcome === OUTCOME.UNCHANGED) {
237
+ // Both sides hold the same value, so this is what they agree on and
238
+ // the base may safely move to it.
239
+ agreed.push({ table, id: remote.id, row: remote });
240
+ } else if (verdict.outcome === OUTCOME.BOTH) {
241
+ conflicts.push({
242
+ table, id: remote.id,
243
+ local: localProjected, remote,
244
+ fields: differingFields(localProjected, remote).map((f) => f.field),
245
+ });
246
+ }
247
+ }
248
+ }
249
+ leases.push(...foreignLeases(bundle, self));
250
+ }
251
+
252
+ // What this side would send. Counted rather than listed: a bundle is every
253
+ // shared record, and a list of two thousand ids helps nobody.
254
+ const outgoing = await buildBundle(db, { projectId: project_.id, alias: self, tables: SHARED_TABLES });
255
+ const outgoingCount = Object.values(outgoing.records).reduce((n, rows) => n + rows.length, 0);
256
+
257
+ return {
258
+ projectId: project_.id, self, incoming, agreed, conflicts, leases, unreadable,
259
+ outgoing, outgoingCount,
260
+ peers: bundles.map((b) => b.name),
261
+ };
262
+ });
263
+
264
+ const summary = {
265
+ peer: peer.alias,
266
+ location: peer.location,
267
+ transport: peer.transport,
268
+ peersFound: plan.peers.length,
269
+ incoming: plan.incoming.length,
270
+ conflicts: plan.conflicts.length,
271
+ agreed: plan.agreed.length,
272
+ outgoing: plan.outgoingCount,
273
+ leases: plan.leases,
274
+ unreadable: plan.unreadable.map((u) => `${u.name}: ${u.error}`),
275
+ withheldTables: Object.keys(WITHHELD).length,
276
+ };
277
+
278
+ if (!apply) return { applied: false, ...summary };
279
+
280
+ // Conflicts are recorded before anything is applied, so a run that stops
281
+ // halfway leaves the disagreement visible rather than invisible.
282
+ const applied = await mutate(root, async (db) => {
283
+ const at = nowIso();
284
+ let written = 0;
285
+
286
+ for (const conflict of plan.conflicts) {
287
+ const already = await db.get(
288
+ `SELECT id FROM sync_conflicts WHERE record_type = ? AND record_id = ? AND status = 'open'`,
289
+ conflict.table, conflict.id);
290
+ if (already) continue;
291
+ await create(db, "sync_conflict", {
292
+ project_id: plan.projectId,
293
+ record_type: conflict.table,
294
+ record_id: conflict.id,
295
+ local_version: conflict.local?.version ?? null,
296
+ remote_version: conflict.remote?.version ?? null,
297
+ local_value_json: JSON.stringify(conflict.local),
298
+ remote_value_json: JSON.stringify(conflict.remote),
299
+ detected_at: at,
300
+ status: "open",
301
+ }, { projectId: plan.projectId, activity: false });
302
+ }
303
+
304
+ // Only what nobody here touched. A conflicted record keeps its local value
305
+ // until somebody resolves it deliberately.
306
+ for (const change of plan.incoming) {
307
+ const columns = Object.keys(change.row);
308
+ const marks = columns.map(() => "?").join(",");
309
+ await db.run(
310
+ `INSERT OR REPLACE INTO ${change.table} (${columns.join(",")}) VALUES (${marks})`,
311
+ ...columns.map((c) => change.row[c]),
312
+ );
313
+ await db.run(
314
+ `INSERT OR REPLACE INTO sync_base
315
+ (id, project_id, peer_id, record_type, record_id, base_version, base_hash, synced_at)
316
+ VALUES (
317
+ (SELECT id FROM sync_base WHERE peer_id = ? AND record_type = ? AND record_id = ?),
318
+ ?, ?, ?, ?, ?, ?, ?)`,
319
+ peer.id, change.table, change.id,
320
+ plan.projectId, peer.id, change.table, change.id,
321
+ change.row.version ?? null, rowHash(change.row), at,
322
+ );
323
+ written += 1;
324
+ }
325
+
326
+ // Leases the other side holds, so a claim here is refused with a name.
327
+ for (const lease of plan.leases) {
328
+ const assignment = await db.get(
329
+ "SELECT id FROM task_assignments WHERE task_id = ? AND active = 1", lease.taskId);
330
+ if (assignment) continue;
331
+ const task = await db.get("SELECT id, project_id FROM tasks WHERE id = ?", lease.taskId);
332
+ if (!task) continue;
333
+ await create(db, "task_assignment", {
334
+ task_id: lease.taskId,
335
+ assigned_at: at,
336
+ active: 1,
337
+ lease_holder: lease.holder,
338
+ lease_expires_at: lease.expiresAt,
339
+ origin_peer: peer.id,
340
+ }, { projectId: plan.projectId, activity: false });
341
+ }
342
+
343
+ // Records both sides already hold identically. The base may move to these
344
+ // and to nothing else.
345
+ //
346
+ // Writing this side's own outgoing state as the agreed base is wrong and
347
+ // was the first bug this engine had: a drop-box transport carries no
348
+ // acknowledgement, so a record only this side changed has not been agreed
349
+ // by anyone. Marking it agreed made the next incoming change look like the
350
+ // only movement, and the local edit was overwritten without a conflict.
351
+ // That is precisely the silent loss DEC-TBD-006 refuses, produced by the
352
+ // code meant to prevent it.
353
+ for (const row of plan.agreed) {
354
+ await db.run(
355
+ `INSERT OR REPLACE INTO sync_base
356
+ (id, project_id, peer_id, record_type, record_id, base_version, base_hash, synced_at)
357
+ VALUES (
358
+ (SELECT id FROM sync_base WHERE peer_id = ? AND record_type = ? AND record_id = ?),
359
+ ?, ?, ?, ?, ?, ?, ?)`,
360
+ peer.id, row.table, row.id,
361
+ plan.projectId, peer.id, row.table, row.id,
362
+ row.row.version ?? null, rowHash(row.row), at,
363
+ );
364
+ }
365
+
366
+ await db.run(
367
+ "UPDATE sync_peers SET last_synced_at = ?, status = 'connected' WHERE id = ?", at, peer.id);
368
+
369
+ await recordActivity(db, plan.projectId, {
370
+ type: "scope_changed",
371
+ actor: "superdev",
372
+ summary: `Synchronized with ${peer.alias}: ${written} records taken in, ${plan.outgoingCount} offered, ${plan.conflicts.length} conflicts recorded.`,
373
+ metadata: { incoming: written, outgoing: plan.outgoingCount, conflicts: plan.conflicts.length },
374
+ });
375
+
376
+ return { written };
377
+ });
378
+
379
+ // Sending happens after the local transaction commits, so a failure to write
380
+ // to the remote never leaves the local database claiming a sync that did not
381
+ // finish. The bundle is sealed here and nowhere else.
382
+ carrier.put(self, seal(key, plan.outgoing));
383
+
384
+ return { applied: true, ...summary, taken: applied.written };
385
+ }
386
+
387
+ // ------------------------------------------------------------------ resolve
388
+
389
+ /** Every disagreement still waiting for somebody to settle it. */
390
+ export async function openConflicts(root) {
391
+ return query(root, (db) => db.all(
392
+ "SELECT * FROM sync_conflicts WHERE status = 'open' ORDER BY detected_at, id").catch(() => []));
393
+ }
394
+
395
+ /**
396
+ * Settle one conflict, or report what cannot be settled without a person.
397
+ *
398
+ * `merge` takes each side's change where only one side moved the field, and
399
+ * refuses when both moved the same one: there is no correct answer to that and
400
+ * inventing one is how a merge quietly loses an edit.
401
+ */
402
+ export async function resolveConflict(root, conflictId, { choice = "local", apply = false, actor = "superdev" } = {}) {
403
+ if (!["local", "remote", "merged"].includes(choice)) {
404
+ throw new SyncError(E.UNKNOWN_CONFLICT,
405
+ `A conflict is settled by keeping local, keeping remote, or merging. ${choice} is none of those.`);
406
+ }
407
+ const conflict = await query(root, (db) =>
408
+ db.get("SELECT * FROM sync_conflicts WHERE id = ?", conflictId));
409
+ if (!conflict) throw new SyncError(E.UNKNOWN_CONFLICT, `There is no conflict ${conflictId}.`);
410
+ if (conflict.status === "resolved") {
411
+ throw new SyncError(E.UNKNOWN_CONFLICT, `${conflictId} was already settled by keeping ${conflict.resolution}.`);
412
+ }
413
+
414
+ const local = JSON.parse(conflict.local_value_json ?? "null");
415
+ const remote = JSON.parse(conflict.remote_value_json ?? "null");
416
+
417
+ let row = local;
418
+ let unsettled = [];
419
+ if (choice === "remote") row = remote;
420
+ if (choice === "merged") {
421
+ const merged = mergeFields({ local, remote, base: null });
422
+ row = merged.merged;
423
+ unsettled = merged.unsettled;
424
+ if (unsettled.length) {
425
+ throw new SyncError(E.CONFLICTS_OPEN,
426
+ `${conflictId} cannot be merged: both sides changed ${unsettled.join(", ")}. Keep one side with --keep local or --keep remote, or edit the record and sync again.`,
427
+ { unsettled });
428
+ }
429
+ }
430
+
431
+ const plan = {
432
+ conflictId, choice,
433
+ recordType: conflict.record_type,
434
+ recordId: conflict.record_id,
435
+ fields: differingFields(local, remote).map((f) => f.field),
436
+ };
437
+ if (!apply) return { applied: false, plan };
438
+
439
+ return mutate(root, async (db) => {
440
+ if (choice !== "local") {
441
+ const columns = Object.keys(row);
442
+ await db.run(
443
+ `INSERT OR REPLACE INTO ${conflict.record_type} (${columns.join(",")}) VALUES (${columns.map(() => "?").join(",")})`,
444
+ ...columns.map((c) => row[c]),
445
+ );
446
+ }
447
+ await db.run(
448
+ "UPDATE sync_conflicts SET status = 'resolved', resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?",
449
+ choice, actor, nowIso(), conflictId,
450
+ );
451
+ const peer = await peerOf(db);
452
+ if (peer) {
453
+ // The settled value becomes the new agreed base, so the next sync sees
454
+ // agreement rather than reporting the same conflict again.
455
+ await db.run(
456
+ `INSERT OR REPLACE INTO sync_base
457
+ (id, project_id, peer_id, record_type, record_id, base_version, base_hash, synced_at)
458
+ VALUES (
459
+ (SELECT id FROM sync_base WHERE peer_id = ? AND record_type = ? AND record_id = ?),
460
+ ?, ?, ?, ?, ?, ?, ?)`,
461
+ peer.id, conflict.record_type, conflict.record_id,
462
+ conflict.project_id, peer.id, conflict.record_type, conflict.record_id,
463
+ row?.version ?? null, rowHash(row ?? {}), nowIso(),
464
+ );
465
+ }
466
+ await recordActivity(db, conflict.project_id, {
467
+ type: "scope_changed",
468
+ actor,
469
+ summary: `Conflict on ${conflict.record_type} ${conflict.record_id} settled by keeping ${choice}.`,
470
+ metadata: { conflict: conflictId, choice },
471
+ });
472
+ return { applied: true, plan };
473
+ });
474
+ }
475
+
476
+ // ------------------------------------------------------------------- leases
477
+
478
+ /**
479
+ * Take a lease on a task so other machines are refused.
480
+ *
481
+ * Called when a task is claimed while a peer is configured. Without a peer this
482
+ * does nothing: a lease is a statement to somebody else, and with nobody to
483
+ * hear it the local unique index is already the whole answer.
484
+ */
485
+ export async function takeLease(root, taskId, { holder = null, minutes } = {}) {
486
+ return mutate(root, async (db) => {
487
+ const peer = await peerOf(db);
488
+ if (!peer) return null;
489
+ const at = nowIso();
490
+ await db.run(
491
+ `UPDATE task_assignments SET lease_holder = ?, lease_expires_at = ?
492
+ WHERE task_id = ? AND active = 1`,
493
+ holder ?? peer.alias, leaseExpiry(at, minutes), taskId,
494
+ );
495
+ return { taskId, holder: holder ?? peer.alias, expiresAt: leaseExpiry(at, minutes) };
496
+ });
497
+ }
498
+
499
+ /** Whoever holds this task from elsewhere, or nothing. */
500
+ export async function heldElsewhere(root, taskId) {
501
+ return query(root, async (db) => {
502
+ const peer = await peerOf(db);
503
+ if (!peer) return null;
504
+ const row = await db.get(
505
+ `SELECT lease_holder, lease_expires_at, origin_peer FROM task_assignments
506
+ WHERE task_id = ? AND active = 1 AND lease_holder IS NOT NULL AND origin_peer IS NOT NULL`,
507
+ taskId);
508
+ if (!row) return null;
509
+ if (row.lease_expires_at && Date.parse(row.lease_expires_at) <= Date.now()) return null;
510
+ return { holder: row.lease_holder, expiresAt: row.lease_expires_at };
511
+ });
512
+ }
@@ -0,0 +1,116 @@
1
+ // Where a bundle goes. DEC-TBD-006, the transport half.
2
+ //
3
+ // The protocol does not care what carries the bytes, so the transport is an
4
+ // adapter and the engine never names one. Exactly one is implemented: a
5
+ // directory on this machine, which may be a shared drive or a mounted volume.
6
+ //
7
+ // That is deliberate and it is not a placeholder. A directory transport is
8
+ // enough to synchronize two working copies, which is the case the local product
9
+ // actually has, and it lets every other part of synchronization, the merge, the
10
+ // conflict, the lease, the queue, the encryption, be built and proven now
11
+ // rather than described. A hosted transport is a second adapter implementing
12
+ // these four functions, and nothing above it changes.
13
+ //
14
+ // Nothing here reaches the network. When a hosted transport is added it is a
15
+ // deliberate act with the owner's authorization, not a consequence of this.
16
+
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+
20
+ export const E = {
21
+ UNREACHABLE: "E_PEER_UNREACHABLE",
22
+ UNKNOWN_TRANSPORT: "E_UNKNOWN_TRANSPORT",
23
+ BUSY: "E_PEER_BUSY",
24
+ };
25
+
26
+ export class TransportError extends Error {
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.name = "TransportError";
30
+ this.code = code;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * A directory holding one encrypted bundle per participant.
36
+ *
37
+ * Each side writes only its own file and reads everyone else's, so two machines
38
+ * syncing at the same moment cannot overwrite each other's work. Writes go to a
39
+ * temporary name and are renamed into place, which is atomic on every
40
+ * filesystem this runs on, so a reader never sees half a bundle.
41
+ */
42
+ function directory(location) {
43
+ const at = resolve(location);
44
+
45
+ return {
46
+ describe: () => at,
47
+
48
+ reachable() {
49
+ try {
50
+ return existsSync(at) && statSync(at).isDirectory();
51
+ } catch {
52
+ return false;
53
+ }
54
+ },
55
+
56
+ open() {
57
+ mkdirSync(at, { recursive: true });
58
+ // A marker so a reader can tell this directory is in use by Superdev
59
+ // rather than pointing at something else by accident.
60
+ const marker = join(at, "superdev-remote");
61
+ if (!existsSync(marker)) {
62
+ writeFileSync(marker, "This directory holds encrypted Superdev project bundles. The contents are unreadable without the project key, which is never stored here.\n");
63
+ }
64
+ },
65
+
66
+ /** Write this side's bundle, atomically. */
67
+ put(name, bytes) {
68
+ if (!this.reachable()) {
69
+ throw new TransportError(E.UNREACHABLE,
70
+ `The remote directory ${at} does not exist or is not a directory, so nothing can be written to it.`);
71
+ }
72
+ const target = join(at, `${name}.bundle`);
73
+ const temporary = `${target}.writing`;
74
+ writeFileSync(temporary, bytes);
75
+ renameSync(temporary, target);
76
+ },
77
+
78
+ /** Every bundle except this side's own. */
79
+ list(exclude) {
80
+ if (!this.reachable()) return [];
81
+ return readdirSync(at)
82
+ .filter((f) => f.endsWith(".bundle") && f !== `${exclude}.bundle`)
83
+ .map((f) => ({ name: f.replace(/\.bundle$/, ""), at: join(at, f) }));
84
+ },
85
+
86
+ get(name) {
87
+ const file = join(at, `${name}.bundle`);
88
+ if (!existsSync(file)) return null;
89
+ return readFileSync(file);
90
+ },
91
+
92
+ forget(name) {
93
+ const file = join(at, `${name}.bundle`);
94
+ if (existsSync(file)) rmSync(file);
95
+ },
96
+ };
97
+ }
98
+
99
+ const ADAPTERS = { directory };
100
+
101
+ /** The adapter for a peer, or a refusal naming what is available. */
102
+ export function transportFor(peer) {
103
+ const kind = String(peer?.transport ?? "directory");
104
+ const make = ADAPTERS[kind];
105
+ if (!make) {
106
+ throw new TransportError(E.UNKNOWN_TRANSPORT,
107
+ `${kind} is not a transport this version can use. Available: ${Object.keys(ADAPTERS).join(", ")}.`);
108
+ }
109
+ if (!peer?.location) {
110
+ throw new TransportError(E.UNREACHABLE,
111
+ "This peer has no location recorded, so there is nowhere to read from or write to.");
112
+ }
113
+ return make(peer.location);
114
+ }
115
+
116
+ export const AVAILABLE_TRANSPORTS = Object.keys(ADAPTERS);