@agentshouse/kit 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (150) hide show
  1. package/README.md +41 -1671
  2. package/bin/connect-linux.sh +501 -53
  3. package/bin/connect-windows.ps1 +430 -0
  4. package/bin/kit.js +200 -159
  5. package/bootstrap.json +28 -0
  6. package/docs/configuration.md +104 -0
  7. package/docs/google.md +33 -0
  8. package/docs/linux.md +77 -0
  9. package/docs/macos.md +26 -0
  10. package/docs/telegram.md +29 -0
  11. package/docs/windows.md +36 -0
  12. package/package.json +7 -2
  13. package/src/adapter-mcp.js +92 -12
  14. package/src/agents.js +63 -8
  15. package/src/binding.js +52 -0
  16. package/src/blocks.js +2 -26
  17. package/src/bridge.js +28 -14
  18. package/src/bytes.js +19 -8
  19. package/src/configuration.js +14 -211
  20. package/src/connector-login.js +3 -84
  21. package/src/contract.js +44 -12
  22. package/src/conversation-originals.js +1 -3
  23. package/src/delivery.js +4 -7
  24. package/src/directory.js +6 -26
  25. package/src/draft.js +4 -82
  26. package/src/git-main.js +138 -0
  27. package/src/git.js +62 -0
  28. package/src/google/calendar.js +39 -45
  29. package/src/google/drive.js +61 -186
  30. package/src/google/entries.js +349 -0
  31. package/src/google/gateway.js +3 -6
  32. package/src/google/gmail.js +33 -23
  33. package/src/google/oauth.js +106 -66
  34. package/src/google/source.js +21 -23
  35. package/src/host.js +6 -2
  36. package/src/house-main.js +65 -8
  37. package/src/house.js +446 -127
  38. package/src/how-we-work.md +25 -0
  39. package/src/inbound.js +2 -18
  40. package/src/kit-work-stream.js +120 -0
  41. package/src/launcher.js +46 -10
  42. package/src/local-files.js +3 -11
  43. package/src/login.js +27 -14
  44. package/src/mcp.js +18 -0
  45. package/src/mount.js +9 -18
  46. package/src/original.js +4 -4
  47. package/src/pacing.js +41 -0
  48. package/src/plan.js +37 -45
  49. package/src/repository.js +3 -61
  50. package/src/session-bin/git +2 -0
  51. package/src/setup.js +14 -15
  52. package/src/skill-set/ask-matt/PHASE-BOUNDARIES.md +55 -0
  53. package/src/skill-set/ask-matt/SKILL.md +91 -0
  54. package/src/skill-set/ask-matt/agents/openai.yaml +4 -0
  55. package/src/skill-set/code-review/LICENSE +21 -0
  56. package/src/skill-set/code-review/SKILL.md +89 -0
  57. package/src/skill-set/code-review/agents/openai.yaml +3 -0
  58. package/src/skill-set/codebase-design/DEEPENING.md +37 -0
  59. package/src/skill-set/codebase-design/DESIGN-IT-TWICE.md +44 -0
  60. package/src/skill-set/codebase-design/LICENSE +21 -0
  61. package/src/skill-set/codebase-design/SKILL.md +116 -0
  62. package/src/skill-set/codebase-design/agents/openai.yaml +3 -0
  63. package/src/skill-set/diagnosing-bugs/LICENSE +21 -0
  64. package/src/skill-set/diagnosing-bugs/SKILL.md +140 -0
  65. package/src/skill-set/diagnosing-bugs/agents/openai.yaml +3 -0
  66. package/src/skill-set/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  67. package/src/skill-set/domain-modeling/ADR-FORMAT.md +47 -0
  68. package/src/skill-set/domain-modeling/CONTEXT-FORMAT.md +60 -0
  69. package/src/skill-set/domain-modeling/LICENSE +21 -0
  70. package/src/skill-set/domain-modeling/SKILL.md +76 -0
  71. package/src/skill-set/domain-modeling/agents/openai.yaml +3 -0
  72. package/src/skill-set/grill-me/LICENSE +21 -0
  73. package/src/skill-set/grill-me/SKILL.md +8 -0
  74. package/src/skill-set/grill-me/agents/openai.yaml +4 -0
  75. package/src/skill-set/grill-with-docs/LICENSE +21 -0
  76. package/src/skill-set/grill-with-docs/SKILL.md +8 -0
  77. package/src/skill-set/grill-with-docs/agents/openai.yaml +4 -0
  78. package/src/skill-set/grilling/LICENSE +21 -0
  79. package/src/{starter-set → skill-set}/grilling/SKILL.md +1 -1
  80. package/src/skill-set/handoff/LICENSE +21 -0
  81. package/src/skill-set/handoff/SKILL.md +17 -0
  82. package/src/skill-set/handoff/agents/openai.yaml +4 -0
  83. package/src/skill-set/implement/LICENSE +21 -0
  84. package/src/skill-set/implement/SKILL.md +16 -0
  85. package/src/skill-set/implement/agents/openai.yaml +4 -0
  86. package/src/skill-set/improve-codebase-architecture/HTML-REPORT.md +123 -0
  87. package/src/skill-set/improve-codebase-architecture/LICENSE +21 -0
  88. package/src/skill-set/improve-codebase-architecture/SKILL.md +72 -0
  89. package/src/skill-set/improve-codebase-architecture/agents/openai.yaml +4 -0
  90. package/src/skill-set/prototype/LICENSE +21 -0
  91. package/src/skill-set/prototype/LOGIC.md +67 -0
  92. package/src/skill-set/prototype/SKILL.md +28 -0
  93. package/src/skill-set/prototype/UI.md +112 -0
  94. package/src/skill-set/prototype/agents/openai.yaml +3 -0
  95. package/src/skill-set/research/LICENSE +21 -0
  96. package/src/skill-set/research/SKILL.md +14 -0
  97. package/src/skill-set/research/agents/openai.yaml +3 -0
  98. package/src/skill-set/resolving-merge-conflicts/LICENSE +21 -0
  99. package/src/skill-set/resolving-merge-conflicts/SKILL.md +16 -0
  100. package/src/skill-set/resolving-merge-conflicts/agents/openai.yaml +3 -0
  101. package/src/skill-set/tdd/LICENSE +21 -0
  102. package/src/skill-set/tdd/SKILL.md +40 -0
  103. package/src/skill-set/tdd/agents/openai.yaml +3 -0
  104. package/src/skill-set/tdd/mocking.md +59 -0
  105. package/src/skill-set/tdd/tests.md +77 -0
  106. package/src/skill-set/teach/GLOSSARY-FORMAT.md +35 -0
  107. package/src/skill-set/teach/LEARNING-RECORD-FORMAT.md +46 -0
  108. package/src/skill-set/teach/LICENSE +21 -0
  109. package/src/skill-set/teach/MISSION-FORMAT.md +31 -0
  110. package/src/skill-set/teach/RESOURCES-FORMAT.md +32 -0
  111. package/src/skill-set/teach/SKILL.md +141 -0
  112. package/src/skill-set/teach/agents/openai.yaml +4 -0
  113. package/src/skill-set/to-questionnaire/LICENSE +21 -0
  114. package/src/skill-set/to-questionnaire/SKILL.md +55 -0
  115. package/src/skill-set/to-questionnaire/agents/openai.yaml +4 -0
  116. package/src/skill-set/to-spec/LICENSE +21 -0
  117. package/src/skill-set/to-spec/SKILL.md +76 -0
  118. package/src/skill-set/to-spec/agents/openai.yaml +4 -0
  119. package/src/skill-set/to-tickets/LICENSE +21 -0
  120. package/src/skill-set/to-tickets/SKILL.md +106 -0
  121. package/src/skill-set/to-tickets/agents/openai.yaml +4 -0
  122. package/src/skill-set/wait-what/LICENSE +21 -0
  123. package/src/skill-set/wait-what/SKILL.md +8 -0
  124. package/src/skill-set/wait-what/agents/openai.yaml +4 -0
  125. package/src/skill-set/wayfinder/LICENSE +21 -0
  126. package/src/skill-set/wayfinder/SKILL.md +129 -0
  127. package/src/skill-set/wayfinder/agents/openai.yaml +4 -0
  128. package/src/skill-set/writing-for-agents/LICENSE +21 -0
  129. package/src/skill-set/writing-for-agents/SKILL-MECHANICS.md +22 -0
  130. package/src/skill-set/writing-for-agents/SKILL.md +83 -0
  131. package/src/skill-set/writing-for-agents/agents/openai.yaml +3 -0
  132. package/src/skill-set.js +85 -0
  133. package/src/source.js +93 -54
  134. package/src/state.js +19 -1
  135. package/src/sync-library.js +7 -11
  136. package/src/sync-state.js +12 -3
  137. package/src/sync.js +142 -43
  138. package/src/tasks.js +92 -0
  139. package/src/telegram-view.js +217 -0
  140. package/src/telegram.js +204 -69
  141. package/src/turn-frames.js +69 -0
  142. package/src/worker.js +738 -189
  143. package/src/working-copy.js +1274 -0
  144. package/src/workspace.js +299 -88
  145. package/src/google/declared.js +0 -146
  146. package/src/google/id.js +0 -1
  147. package/src/instance/google.js +0 -1
  148. package/src/starter-set.js +0 -17
  149. /package/src/{starter-set/grilling → skill-set/ask-matt}/LICENSE +0 -0
  150. /package/src/{starter-set → skill-set}/grilling/agents/openai.yaml +0 -0
@@ -0,0 +1,85 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { cp, lstat, mkdir, readdir, realpath, rm, symlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { KitRefusal } from "./refusal.js";
6
+ import { readNote, writeNote, writeText } from "./state.js";
7
+
8
+ export const SKILL_SET_SOURCE = fileURLToPath(new URL("skill-set/", import.meta.url));
9
+ const KIT_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+
11
+ function recordPath(directory) {
12
+ return join(directory, "state", "skill-set.json");
13
+ }
14
+
15
+ function howWeWorkNotePath(directory) {
16
+ return join(directory, "state", "how-we-work");
17
+ }
18
+
19
+ function placed(home, name) {
20
+ return {
21
+ skill: join(home, ".agents", "skills", name),
22
+ link: join(home, ".claude", "skills", name),
23
+ };
24
+ }
25
+
26
+ async function present(path) {
27
+ try {
28
+ await lstat(path);
29
+ return true;
30
+ } catch (error) {
31
+ if (error.code === "ENOENT") return false;
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ async function removeRecorded(home, names) {
37
+ for (const name of names) {
38
+ const { skill, link } = placed(home, name);
39
+ await rm(link, { recursive: true, force: true });
40
+ await rm(skill, { recursive: true, force: true });
41
+ }
42
+ }
43
+
44
+ async function installed(directory, home, version) {
45
+ const names = [];
46
+ for (const name of (await readdir(SKILL_SET_SOURCE)).sort()) {
47
+ const { skill, link } = placed(home, name);
48
+ if (!(await present(skill)) && !(await present(link))) names.push(name);
49
+ }
50
+ await writeNote(recordPath(directory), { names });
51
+ await mkdir(join(home, ".agents", "skills"), { recursive: true });
52
+ await mkdir(join(home, ".claude", "skills"), { recursive: true });
53
+ const shared =
54
+ (await realpath(join(home, ".claude", "skills"))) === (await realpath(join(home, ".agents", "skills")));
55
+ for (const name of names) {
56
+ const { skill, link } = placed(home, name);
57
+ await cp(join(SKILL_SET_SOURCE, name), skill, { recursive: true });
58
+ if (!shared) await symlink(join("..", "..", ".agents", "skills", name), link);
59
+ }
60
+ await writeNote(recordPath(directory), { version, names });
61
+ }
62
+
63
+ export async function reconcileSkillSet({ directory, home, accepted, version = KIT_VERSION }) {
64
+ try {
65
+ const record = await readNote(recordPath(directory), null);
66
+ if (accepted && record?.version === version) return;
67
+ if (record !== null) await removeRecorded(home, record.names);
68
+ if (accepted) await installed(directory, home, version);
69
+ else await rm(recordPath(directory), { force: true });
70
+ } catch (error) {
71
+ if (error instanceof KitRefusal) throw error;
72
+ throw new KitRefusal("kit_skill_set_unavailable", `${error.path ?? home} (${error.code ?? "unknown"})`);
73
+ }
74
+ }
75
+
76
+ export async function howWeWorkOwed(directory) {
77
+ return (
78
+ (await readNote(recordPath(directory), null)) !== null &&
79
+ !(await present(howWeWorkNotePath(directory)))
80
+ );
81
+ }
82
+
83
+ export async function noteHowWeWork(directory) {
84
+ await writeText(howWeWorkNotePath(directory), "");
85
+ }
package/src/source.js CHANGED
@@ -12,8 +12,9 @@ import {
12
12
  newCheckpoint,
13
13
  stallOf,
14
14
  } from "./delivery.js";
15
- import { captureBatch, openSession } from "./house.js";
16
- import { googlePage } from "./google/source.js";
15
+ import { captureBatch, maintainControl, openSession } from "./house.js";
16
+ import { GOOGLE_SOURCES } from "./google/oauth.js";
17
+ import { googlePage, recordAccountEmail } from "./google/source.js";
17
18
  import { houseAnswered, houseAnsweredFailure, houseFailed, KitRefusal } from "./refusal.js";
18
19
  import { recordAcceptedOriginals, sameReference, saveSourceOriginals } from "./source-originals.js";
19
20
  import {
@@ -29,14 +30,15 @@ import {
29
30
  writeCheckpoint,
30
31
  } from "./state.js";
31
32
 
33
+ const GOOGLE = Object.freeze({ id: GOOGLE_SOURCES, preset: "google" });
34
+
32
35
  async function acquire(context, service, checkpoint, limit) {
33
- if (context.instance.preset === "google") {
36
+ if (service.google !== undefined) {
34
37
  return googlePage(
35
38
  {
36
39
  directory: context.directory,
37
- instance: context.instance.id,
38
- client: context.googleOAuthClient,
39
- service,
40
+ account: service.google.account,
41
+ service: service.google.service,
40
42
  cursor: checkpoint.cursor,
41
43
  limit,
42
44
  backfillFloor:
@@ -169,6 +171,7 @@ async function deliverService(context, service) {
169
171
  context.instance.preset === "custom"
170
172
  ? { ...context, environment: await resolveEnvironment(service.connector) }
171
173
  : context;
174
+ if (service.google !== undefined) await recordAccountEmail(context, service.google);
172
175
  await runDeliveryLoop(resolved, service, progress);
173
176
  } catch (refusal) {
174
177
  if (!(refusal instanceof KitRefusal) || refusal.code === "kit_command_cancelled") throw refusal;
@@ -197,25 +200,70 @@ async function deliverService(context, service) {
197
200
  };
198
201
  }
199
202
 
203
+ function initialBackfill(initial) {
204
+ if (initial.new === true) return { kind: "days", bound: 0 };
205
+ if (initial.days !== undefined) return { kind: "days", bound: initial.days };
206
+ if (initial.items !== undefined) return { kind: "count", bound: initial.items };
207
+ return { kind: "all" };
208
+ }
209
+
210
+ export async function captureEntry(context, entry) {
211
+ const delivered = await deliverService(
212
+ { ...context, instance: GOOGLE },
213
+ {
214
+ service: entry.source,
215
+ sourceRef: entry.source,
216
+ rooms: entry.rooms,
217
+ backfill: initialBackfill(entry.initial),
218
+ google: { account: entry.account, service: entry.service },
219
+ },
220
+ );
221
+ return { ...delivered, service: entry.service };
222
+ }
223
+
200
224
  export async function captureSource(options, dependencies = {}) {
201
225
  const configuration = await readConfiguration(options.configuration);
202
226
  const instance = sourceInstance(configuration, options.instance);
203
227
  const services = declaredServices(instance, options.service ?? null);
204
- const googleOAuthClient =
205
- instance.preset === "google" ? dependencies.googleOAuthClient() : null;
206
228
  const session = await openSession(options, dependencies);
207
- const context = {
208
- instance,
229
+ return maintainControl(
209
230
  session,
210
- directory: options.directory,
211
- signal: options.signal,
212
- now: session.now,
213
- dependencies,
214
- googleOAuthClient,
231
+ async (signal) => {
232
+ const context = {
233
+ instance,
234
+ session,
235
+ directory: options.directory,
236
+ signal,
237
+ now: session.now,
238
+ dependencies,
239
+ };
240
+ const delivered = [];
241
+ for (const service of services) delivered.push(await deliverService(context, service));
242
+ return { instance: instance.id, services: delivered };
243
+ },
244
+ options.signal,
245
+ );
246
+ }
247
+
248
+ export async function localServiceState(directory, instance, service) {
249
+ return {
250
+ health: (await readHealth(directory, instance, service)) ?? {
251
+ state: "pending",
252
+ category: null,
253
+ observed_at: null,
254
+ },
255
+ checkpoint: await readCheckpoint(directory, instance, service),
256
+ gaps: await readCoverageGaps(directory, instance, service),
257
+ dropped: await readDroppedItems(directory, instance, service),
258
+ originals: (await readSourceOriginals(directory, instance, service)).map((reference) => ({
259
+ room: reference.room,
260
+ record: reference.record,
261
+ position: reference.position,
262
+ native_object_id: reference.native_object_id,
263
+ save: reference.save,
264
+ error: reference.error,
265
+ })),
215
266
  };
216
- const delivered = [];
217
- for (const service of services) delivered.push(await deliverService(context, service));
218
- return { instance: instance.id, services: delivered };
219
267
  }
220
268
 
221
269
  export async function sourceStatus(options) {
@@ -228,24 +276,7 @@ export async function sourceStatus(options) {
228
276
  service: service.service,
229
277
  source_ref: service.sourceRef,
230
278
  rooms: service.rooms,
231
- health: (await readHealth(options.directory, instance.id, service.service)) ?? {
232
- state: "pending",
233
- category: null,
234
- observed_at: null,
235
- },
236
- checkpoint: await readCheckpoint(options.directory, instance.id, service.service),
237
- gaps: await readCoverageGaps(options.directory, instance.id, service.service),
238
- dropped: await readDroppedItems(options.directory, instance.id, service.service),
239
- originals: (await readSourceOriginals(options.directory, instance.id, service.service)).map(
240
- (reference) => ({
241
- room: reference.room,
242
- record: reference.record,
243
- position: reference.position,
244
- native_object_id: reference.native_object_id,
245
- save: reference.save,
246
- error: reference.error,
247
- }),
248
- ),
279
+ ...(await localServiceState(options.directory, instance.id, service.service)),
249
280
  });
250
281
  }
251
282
  return { instance: instance.id, preset: instance.preset, services: reported };
@@ -255,32 +286,40 @@ export async function retrySourceOriginals(options, dependencies = {}) {
255
286
  const configuration = await readConfiguration(options.configuration);
256
287
  const instance = sourceInstance(configuration, options.instance);
257
288
  const services = declaredServices(instance, options.service ?? null);
258
- const context = { instance, directory: options.directory, signal: options.signal };
259
289
  const outstanding = [];
260
290
  for (const service of services) {
261
291
  outstanding.push((await readSourceOriginals(options.directory, instance.id, service.service)).length);
262
292
  }
263
- if (outstanding.some((count) => count > 0)) context.session = await openSession(options, dependencies);
264
- const retried = [];
265
- for (const [position, service] of services.entries()) {
266
- retried.push({
267
- service: service.service,
268
- originals:
269
- outstanding[position] === 0
270
- ? []
271
- : await saveSourceOriginals(context, serviceBinding(context, service)),
272
- });
273
- }
274
- return { instance: instance.id, services: retried };
293
+ const retry = async (context) => {
294
+ const retried = [];
295
+ for (const [position, service] of services.entries()) {
296
+ retried.push({
297
+ service: service.service,
298
+ originals:
299
+ outstanding[position] === 0
300
+ ? []
301
+ : await saveSourceOriginals(context, serviceBinding(context, service)),
302
+ });
303
+ }
304
+ return { instance: instance.id, services: retried };
305
+ };
306
+ const context = { instance, directory: options.directory, signal: options.signal };
307
+ if (outstanding.every((count) => count === 0)) return retry(context);
308
+ const session = await openSession(options, dependencies);
309
+ return maintainControl(session, (signal) => retry({ ...context, session, signal }), options.signal);
275
310
  }
276
311
 
277
312
  export async function resetSource(options, dependencies = {}) {
278
313
  const configuration = await readConfiguration(options.configuration);
279
314
  const instance = sourceInstance(configuration, options.instance);
280
315
  declaredServices(instance, options.service);
281
- await openSession(options, dependencies);
282
- return {
283
- instance: instance.id,
284
- services: await resetCheckpoints(options.directory, instance.id, options.service),
285
- };
316
+ const session = await openSession(options, dependencies);
317
+ return maintainControl(
318
+ session,
319
+ async () => ({
320
+ instance: instance.id,
321
+ services: await resetCheckpoints(options.directory, instance.id, options.service),
322
+ }),
323
+ options.signal,
324
+ );
286
325
  }
package/src/state.js CHANGED
@@ -9,10 +9,28 @@ function segment(name) {
9
9
  return name;
10
10
  }
11
11
 
12
- function notePath(directory, instance, service, kind) {
12
+ export function notePath(directory, instance, service, kind) {
13
13
  return join(directory, "state", segment(instance), kind, `${segment(service)}.json`);
14
14
  }
15
15
 
16
+ export async function noteNames(directory, instance, kind) {
17
+ let present;
18
+ try {
19
+ present = await readdir(join(directory, "state", segment(instance), kind));
20
+ } catch (error) {
21
+ if (error.code === "ENOENT") return [];
22
+ throw new KitRefusal("kit_state_unreadable", kind);
23
+ }
24
+ return present
25
+ .filter((name) => name.endsWith(".json"))
26
+ .map((name) => name.slice(0, -".json".length))
27
+ .sort();
28
+ }
29
+
30
+ export async function removeNotes(directory, instance, service, kinds) {
31
+ for (const kind of kinds) await rm(notePath(directory, instance, service, kind), { force: true });
32
+ }
33
+
16
34
  function repositoryPath(directory, key) {
17
35
  if (!/^[a-f0-9]{64}$/.test(key)) throw new KitRefusal("kit_state_name_invalid", key);
18
36
  return join(directory, "state", "repositories", `${key}.json`);
@@ -100,13 +100,12 @@ function recordedMap(authority) {
100
100
  return new Map(authority.files.map((file) => [file.path, file]));
101
101
  }
102
102
 
103
- function structuredOrReplace(root, relative, recorded, content) {
103
+ export function structuredOrReplace(root, relative, recorded, content) {
104
104
  const path = housePath(root, relative);
105
+ const whole = [{ op: "replace", path, base: recorded.revision, content }];
105
106
  const before = parse(recorded.content, relative);
106
107
  const after = parse(content, relative);
107
- if (!before.ok || !after.ok) {
108
- return [{ op: "replace", path, base: recorded.revision, content }];
109
- }
108
+ if (!before.ok || !after.ok) return whole;
110
109
  const ops = [];
111
110
  if (before.document.preamble !== after.document.preamble) {
112
111
  ops.push({
@@ -124,9 +123,7 @@ function structuredOrReplace(root, relative, recorded, content) {
124
123
  if (JSON.stringify(before.document.fields[name]) === JSON.stringify(after.document.fields[name])) {
125
124
  continue;
126
125
  }
127
- if (after.document.fields[name] === undefined) {
128
- return [{ op: "replace", path, base: recorded.revision, content }];
129
- }
126
+ if (after.document.fields[name] === undefined) return whole;
130
127
  const change = { op: "set_field", path, field: name, value: after.document.fields[name] };
131
128
  if (before.document.fields[name] !== undefined) change.base = before.document.fields[name];
132
129
  ops.push(change);
@@ -139,12 +136,11 @@ function structuredOrReplace(root, relative, recorded, content) {
139
136
  const prior = before.document.sections[name];
140
137
  const next = after.document.sections[name];
141
138
  if (prior === next) continue;
142
- if (prior === undefined || next === undefined) {
143
- return [{ op: "replace", path, base: recorded.revision, content }];
144
- }
139
+ if (prior === undefined || next === undefined) return whole;
145
140
  ops.push({ op: "replace_section", path, section: name, base: prior, content: next });
146
141
  }
147
- return ops.length === 0 ? [{ op: "replace", path, base: recorded.revision, content }] : ops;
142
+ if (ops.length === 0) return whole;
143
+ return applyChanges({ [relative]: recorded.content }, ops, root)[relative] === content ? ops : whole;
148
144
  }
149
145
 
150
146
  export function deriveChanges(state, authority, localFiles) {
package/src/sync-state.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, readdir, realpath, rm } from "node:fs/promises";
3
- import { join, relative, resolve } from "node:path";
2
+ import { mkdir, readFile, readdir, realpath, rm, rmdir } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
4
  import { EXPLICIT_SYNC_VERSION } from "./contract.js";
5
5
  import { KitRefusal } from "./refusal.js";
6
6
  import { readNote, writeNote, writeText } from "./state.js";
@@ -85,12 +85,21 @@ export async function writeReplicaFile(path, content, signal) {
85
85
  return writeText(path, content, signal, 0o644);
86
86
  }
87
87
 
88
- export async function removeReplicaFile(path) {
88
+ export async function removeReplicaFile(replica, rootPath, file) {
89
+ const root = locate(replica, rootPath);
90
+ const path = locate(replica, rootPath, file);
89
91
  try {
90
92
  await rm(path);
91
93
  } catch (error) {
92
94
  if (error.code !== "ENOENT") throw new KitRefusal("kit_state_unreadable", path);
93
95
  }
96
+ for (let directory = dirname(path); directory.startsWith(`${root}/`); directory = dirname(directory)) {
97
+ try {
98
+ await rmdir(directory);
99
+ } catch (error) {
100
+ if (error.code !== "ENOENT") return;
101
+ }
102
+ }
94
103
  }
95
104
 
96
105
  export async function listFiles(root) {