@agentshouse/kit 0.2.1-cbabe8ad337ff → 0.2.1-cbff58027f078

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 (133) hide show
  1. package/README.md +47 -46
  2. package/dist/acp.js +90 -19
  3. package/dist/afk.js +17 -0
  4. package/dist/agents.js +154 -44
  5. package/dist/api.js +7 -3
  6. package/dist/app-git.js +10 -26
  7. package/dist/authority-main.js +2 -2
  8. package/dist/bridge.js +11 -25
  9. package/dist/building.js +50 -0
  10. package/dist/calls.js +71 -0
  11. package/dist/chatgpt.js +32 -0
  12. package/dist/chunks.js +49 -0
  13. package/dist/clis.js +95 -48
  14. package/dist/connection.js +105 -0
  15. package/dist/conversations.js +574 -179
  16. package/dist/copies.js +431 -218
  17. package/dist/end.js +29 -4
  18. package/dist/enrol-main.js +7 -1
  19. package/dist/events.js +70 -8
  20. package/dist/files.js +139 -31
  21. package/dist/git.js +57 -21
  22. package/dist/home.js +89 -7
  23. package/dist/house-cli.js +299 -0
  24. package/dist/house-main.js +7 -177
  25. package/dist/instructions.js +8 -10
  26. package/dist/kit-cli.js +68 -0
  27. package/dist/kit-main.js +8 -22
  28. package/dist/login.js +113 -23
  29. package/dist/logout.js +0 -2
  30. package/dist/mcp.js +6 -2
  31. package/dist/parts.js +44 -16
  32. package/dist/placement.js +31 -0
  33. package/dist/relay.js +33 -2
  34. package/dist/resident.js +111 -44
  35. package/dist/scope.js +15 -4
  36. package/dist/scripts.js +255 -0
  37. package/dist/service-main.js +9 -0
  38. package/dist/service.js +55 -0
  39. package/dist/sign-in.js +22 -39
  40. package/dist/silence.js +28 -0
  41. package/dist/skills.js +49 -35
  42. package/dist/transcription.js +85 -45
  43. package/dist/translate.js +2 -8
  44. package/dist/update.js +113 -105
  45. package/dist/wire.js +40 -0
  46. package/dist/work.js +3 -5
  47. package/package.json +20 -20
  48. package/dist/bootstrap.json +0 -24
  49. package/dist/clis-main.js +0 -23
  50. package/dist/stream.js +0 -72
  51. package/how-we-work.md +0 -23
  52. package/skills/ask-matt/LICENSE +0 -21
  53. package/skills/ask-matt/PHASE-BOUNDARIES.md +0 -55
  54. package/skills/ask-matt/SKILL.md +0 -91
  55. package/skills/ask-matt/agents/openai.yaml +0 -4
  56. package/skills/code-review/LICENSE +0 -21
  57. package/skills/code-review/SKILL.md +0 -89
  58. package/skills/code-review/agents/openai.yaml +0 -3
  59. package/skills/codebase-design/DEEPENING.md +0 -37
  60. package/skills/codebase-design/DESIGN-IT-TWICE.md +0 -44
  61. package/skills/codebase-design/LICENSE +0 -21
  62. package/skills/codebase-design/SKILL.md +0 -116
  63. package/skills/codebase-design/agents/openai.yaml +0 -3
  64. package/skills/diagnosing-bugs/LICENSE +0 -21
  65. package/skills/diagnosing-bugs/SKILL.md +0 -140
  66. package/skills/diagnosing-bugs/agents/openai.yaml +0 -3
  67. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +0 -44
  68. package/skills/domain-modeling/ADR-FORMAT.md +0 -47
  69. package/skills/domain-modeling/CONTEXT-FORMAT.md +0 -60
  70. package/skills/domain-modeling/LICENSE +0 -21
  71. package/skills/domain-modeling/SKILL.md +0 -76
  72. package/skills/domain-modeling/agents/openai.yaml +0 -3
  73. package/skills/grill-me/LICENSE +0 -21
  74. package/skills/grill-me/SKILL.md +0 -8
  75. package/skills/grill-me/agents/openai.yaml +0 -4
  76. package/skills/grill-with-docs/LICENSE +0 -21
  77. package/skills/grill-with-docs/SKILL.md +0 -8
  78. package/skills/grill-with-docs/agents/openai.yaml +0 -4
  79. package/skills/grilling/LICENSE +0 -21
  80. package/skills/grilling/SKILL.md +0 -30
  81. package/skills/grilling/agents/openai.yaml +0 -3
  82. package/skills/handoff/LICENSE +0 -21
  83. package/skills/handoff/SKILL.md +0 -17
  84. package/skills/handoff/agents/openai.yaml +0 -4
  85. package/skills/implement/LICENSE +0 -21
  86. package/skills/implement/SKILL.md +0 -16
  87. package/skills/implement/agents/openai.yaml +0 -4
  88. package/skills/improve-codebase-architecture/HTML-REPORT.md +0 -123
  89. package/skills/improve-codebase-architecture/LICENSE +0 -21
  90. package/skills/improve-codebase-architecture/SKILL.md +0 -72
  91. package/skills/improve-codebase-architecture/agents/openai.yaml +0 -4
  92. package/skills/prototype/LICENSE +0 -21
  93. package/skills/prototype/LOGIC.md +0 -67
  94. package/skills/prototype/SKILL.md +0 -28
  95. package/skills/prototype/UI.md +0 -112
  96. package/skills/prototype/agents/openai.yaml +0 -3
  97. package/skills/research/LICENSE +0 -21
  98. package/skills/research/SKILL.md +0 -14
  99. package/skills/research/agents/openai.yaml +0 -3
  100. package/skills/resolving-merge-conflicts/LICENSE +0 -21
  101. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  102. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  103. package/skills/tdd/LICENSE +0 -21
  104. package/skills/tdd/SKILL.md +0 -40
  105. package/skills/tdd/agents/openai.yaml +0 -3
  106. package/skills/tdd/mocking.md +0 -59
  107. package/skills/tdd/tests.md +0 -77
  108. package/skills/teach/GLOSSARY-FORMAT.md +0 -35
  109. package/skills/teach/LEARNING-RECORD-FORMAT.md +0 -46
  110. package/skills/teach/LICENSE +0 -21
  111. package/skills/teach/MISSION-FORMAT.md +0 -31
  112. package/skills/teach/RESOURCES-FORMAT.md +0 -32
  113. package/skills/teach/SKILL.md +0 -141
  114. package/skills/teach/agents/openai.yaml +0 -4
  115. package/skills/to-questionnaire/LICENSE +0 -21
  116. package/skills/to-questionnaire/SKILL.md +0 -55
  117. package/skills/to-questionnaire/agents/openai.yaml +0 -4
  118. package/skills/to-spec/LICENSE +0 -21
  119. package/skills/to-spec/SKILL.md +0 -76
  120. package/skills/to-spec/agents/openai.yaml +0 -4
  121. package/skills/to-tickets/LICENSE +0 -21
  122. package/skills/to-tickets/SKILL.md +0 -106
  123. package/skills/to-tickets/agents/openai.yaml +0 -4
  124. package/skills/wait-what/LICENSE +0 -21
  125. package/skills/wait-what/SKILL.md +0 -8
  126. package/skills/wait-what/agents/openai.yaml +0 -4
  127. package/skills/wayfinder/LICENSE +0 -21
  128. package/skills/wayfinder/SKILL.md +0 -129
  129. package/skills/wayfinder/agents/openai.yaml +0 -4
  130. package/skills/writing-for-agents/LICENSE +0 -21
  131. package/skills/writing-for-agents/SKILL-MECHANICS.md +0 -22
  132. package/skills/writing-for-agents/SKILL.md +0 -83
  133. package/skills/writing-for-agents/agents/openai.yaml +0 -3
package/dist/copies.js CHANGED
@@ -1,20 +1,37 @@
1
1
  import { parse } from '@agentshouse/mdmodel';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { finalRefusal, HouseRefusal, retrying } from './api.js';
6
- import { blobId, blobText, git, HOUSE_REF, importCommits, localCopies, revision, run, succeeded, tree, } from './git.js';
6
+ import { chunkTexts, stagedChunks } from './chunks.js';
7
+ import { blobId, blobText, git, isText, HOUSE_REF, importCommits, localCopies, revision, run, succeeded, tree, } from './git.js';
8
+ import { paused } from './files.js';
7
9
  import { kitHome } from './home.js';
8
10
  import { operationId } from './operation.js';
9
11
  import { refusalOf } from './refusals.js';
10
12
  import { changed } from './translate.js';
11
- import { relayed } from './relay.js';
12
13
  const BRANCH = 'main';
13
14
  const PROVENANCE = '_provenance';
14
- const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
15
+ const IDENTITY = { GIT_COMMITTER_NAME: 'Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
16
+ const ROOMS = 'rooms/';
17
+ const ENUMERATE = '/kit/door/enumerate';
18
+ const NOT_READY = 'not ready yet; Kit copies it once House has built it';
19
+ const STAGED_CHUNKS = 'house-chunks';
20
+ // A Room answered not ready is asked again on this schedule: its chunk build usually catches up within seconds, so the first asks come soon and the last repeats until it does.
21
+ const ASKED_AGAIN_MS = [1000, 2000, 3000, 5000, 8000, 11_000, 15_000];
22
+ class NotReady extends Error {
23
+ rooms;
24
+ constructor(rooms) {
25
+ super(NOT_READY);
26
+ this.rooms = rooms;
27
+ }
28
+ }
15
29
  function causeOf(error) {
16
30
  return error instanceof Error ? error.message : String(error);
17
31
  }
32
+ function houseCause(error) {
33
+ return error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error);
34
+ }
18
35
  function logged(error) {
19
36
  process.stderr.write(`kit: ${causeOf(error)}\n`);
20
37
  }
@@ -29,9 +46,35 @@ const REBASED = new Set([
29
46
  function rebaseFixes(refusal) {
30
47
  return refusal.code === 'edit_conflict' && refusal.conflicts.some((code) => REBASED.has(code));
31
48
  }
49
+ function textOnly(paths) {
50
+ const [named, them] = paths.length === 1 ? [`${paths[0]} is`, 'it'] : [`${paths.join(', ')} are`, 'them'];
51
+ return `${named} not text, and house push sends text only; remove ${them} from the commit and place ${them} with house upload`;
52
+ }
53
+ function outside(path) {
54
+ return { refused: true, text: `${path} is not a House Local copy; run house push inside one` };
55
+ }
32
56
  function short(commit) {
33
57
  return commit.slice(0, 12);
34
58
  }
59
+ function moved(before, after) {
60
+ if (before.house !== after.house)
61
+ return [`${rootOf(after.room_handle)}: ${HOUSE_REF} is at ${short(after.house)}`];
62
+ return before.position === after.position ? [] : [`${rootOf(after.room_handle)}: read-only files refreshed`];
63
+ }
64
+ function expiredAuthority(error) {
65
+ if (!(error instanceof HouseRefusal))
66
+ return undefined;
67
+ try {
68
+ const { error: refused } = JSON.parse(error.text);
69
+ return refused?.code === 'expired' && typeof refused.authority === 'string' ? refused.authority : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ function grouped(items, size) {
76
+ return items.flatMap((_, index) => (index % size === 0 ? [items.slice(index, index + size)] : []));
77
+ }
35
78
  function covers(path, prefix) {
36
79
  return prefix === '' || path === prefix || path.startsWith(`${prefix}/`);
37
80
  }
@@ -40,6 +83,12 @@ function sidecar(path) {
40
83
  segments.splice(-1, 0, PROVENANCE);
41
84
  return segments.join('/');
42
85
  }
86
+ function recordOf(key) {
87
+ return join(kitHome(), 'local-copies', `${key}.json`);
88
+ }
89
+ function stagingPrefix() {
90
+ return `.${createHash('sha256').update(kitHome()).digest('hex').slice(0, 16)}-`;
91
+ }
43
92
  function rootOf(handle) {
44
93
  return `/rooms/${handle}`;
45
94
  }
@@ -67,32 +116,59 @@ async function removeFile(repository, path) {
67
116
  return;
68
117
  }
69
118
  }
70
- async function readOnlyPlaced(repository, held, written, removed) {
119
+ async function readOnlyFiles(repository, held) {
71
120
  const tracked = new Set((await git(repository, ['ls-files', '-z'])).split('\0'));
72
121
  const holding = new Set(held);
73
- for (const path of removed) {
74
- if (!holding.has(path))
75
- continue;
76
- if (!tracked.has(path))
77
- await removeFile(repository, path);
78
- holding.delete(path);
79
- }
80
- for (const [path, content] of written) {
81
- if (tracked.has(path))
82
- continue;
83
- if (!holding.has(path) && (await exists(join(repository, path))))
84
- continue;
85
- await writeText(join(repository, path), content);
86
- holding.add(path);
87
- }
88
- return [...holding].sort();
122
+ return {
123
+ remove: async (path) => {
124
+ if (!holding.has(path))
125
+ return;
126
+ if (!tracked.has(path))
127
+ await removeFile(repository, path);
128
+ holding.delete(path);
129
+ },
130
+ place: async (path, content) => {
131
+ if (tracked.has(path))
132
+ return;
133
+ if (!holding.has(path) && (await exists(join(repository, path))))
134
+ return;
135
+ await writeText(join(repository, path), content);
136
+ holding.add(path);
137
+ },
138
+ held: () => [...holding].sort(),
139
+ };
140
+ }
141
+ async function readOnlyPlaced(repository, held, written, removed) {
142
+ const files = await readOnlyFiles(repository, held);
143
+ for (const path of removed)
144
+ await files.remove(path);
145
+ for (const [path, content] of written)
146
+ await files.place(path, content);
147
+ return files.held();
148
+ }
149
+ async function importedWhole(repository, scope, texts, commit, readOnly) {
150
+ const revisions = {};
151
+ async function* tracked() {
152
+ for await (const { path, content, revision: held, provenance } of texts) {
153
+ if (declaredWritable(path, scope)) {
154
+ revisions[path] = held;
155
+ yield [path, content];
156
+ }
157
+ else {
158
+ await readOnly.place(path, content);
159
+ }
160
+ if (provenance !== undefined)
161
+ await readOnly.place(sidecar(path), provenance);
162
+ }
163
+ }
164
+ return { house: await importCommits(repository, [{ ...commit, files: tracked() }]), revisions };
89
165
  }
90
166
  async function ruled(path, rules) {
91
167
  if ((await readFile(path, 'utf8').catch(() => null)) !== rules)
92
168
  await writeFile(path, rules);
93
169
  }
94
170
  async function ignoreRules(repository, writable, copy) {
95
- const readOnly = ('read_only' in copy ? copy.read_only : [...copy.readOnly.keys()]).filter((path) => !path.split('/').includes(PROVENANCE));
171
+ const readOnly = copy.read_only.filter((path) => !path.split('/').includes(PROVENANCE));
96
172
  const protectedPath = (path) => copy.protected.some((prefix) => prefix !== '' && covers(path, prefix));
97
173
  const whole = copy.protected.filter((prefix) => prefix !== '' && !writable.some((path) => covers(path, prefix)));
98
174
  const house = [
@@ -101,7 +177,7 @@ async function ignoreRules(repository, writable, copy) {
101
177
  ].map(pattern);
102
178
  const granted = readOnly.filter((path) => !protectedPath(path)).map(pattern);
103
179
  await ruled(join(repository, '.gitignore'), [
104
- '# House Kit writes this file and .ignore. The paths below are read-only House',
180
+ '# Kit writes this file and .ignore. The paths below are read-only House',
105
181
  '# content: Git does not track them, and House refuses a change to them even',
106
182
  '# when this file is edited or a path is force-added.',
107
183
  '/.gitignore',
@@ -116,7 +192,7 @@ async function ignoreRules(repository, writable, copy) {
116
192
  '',
117
193
  ].join('\n'));
118
194
  await ruled(join(repository, '.ignore'), [
119
- '# House Kit writes this file so native search still reads the read-only House',
195
+ '# Kit writes this file so native search still reads the read-only House',
120
196
  '# content that .gitignore keeps out of Git.',
121
197
  `!${PROVENANCE}`,
122
198
  ...[...house, ...granted].map((rule) => `!${rule}`),
@@ -152,8 +228,8 @@ export class LocalCopies {
152
228
  house;
153
229
  copies = new Map();
154
230
  queues = new Map();
155
- delivering = new Set();
156
- selecting = Promise.resolve();
231
+ waiting = new Set();
232
+ answering = Promise.resolve();
157
233
  constructor(house) {
158
234
  this.house = house;
159
235
  this.owner = {
@@ -184,43 +260,128 @@ export class LocalCopies {
184
260
  this.copies.set(copy.key, copy);
185
261
  }
186
262
  }
263
+ async swept() {
264
+ const rooms = join(localCopies(), 'rooms');
265
+ for (const name of await readdir(rooms).catch(() => [])) {
266
+ if (name.startsWith(stagingPrefix()))
267
+ await rm(join(rooms, name), { recursive: true, force: true });
268
+ }
269
+ }
187
270
  async mapping() {
188
- await this.selecting;
271
+ await this.answering;
189
272
  return [...this.copies.values()]
190
- .filter((copy) => copy.active)
273
+ .filter((copy) => copy.unfollowed === undefined)
191
274
  .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
192
275
  .sort();
193
276
  }
194
- select() {
195
- const selected = this.selecting.then(() => this.reselect());
196
- this.selecting = selected.catch(() => undefined);
197
- return selected;
198
- }
199
- async reselect() {
200
- const { local_copy: chosen } = await this.house.post('/kit/local-copy/selection', {});
201
- const selected = (chosen?.rooms ?? []).flatMap((room) => room.room_handle === null ? [] : [{ key: `rooms/${room.room_ref}`, handle: room.room_handle }]);
202
- const keys = new Set(selected.map((scope) => scope.key));
203
- const work = [];
204
- for (const key of this.copies.keys()) {
205
- if (keys.has(key))
206
- continue;
207
- work.push(this.exclusive(key, async () => {
208
- const copy = this.copies.get(key);
209
- if (copy.active)
210
- await this.save({ ...copy, active: false });
211
- }));
212
- }
213
- for (const scope of selected)
214
- work.push(this.exclusive(scope.key, () => this.apply(scope)).catch(logged));
215
- await Promise.all(work);
216
- }
217
- entries(frame) {
218
- const copy = [...this.copies.values()].find((held) => held.active && held.room_ref === frame.authority);
219
- if (copy === undefined)
220
- return;
221
- if (copy.log_epoch === frame.log_epoch && BigInt(frame.position) <= BigInt(copy.position))
222
- return;
223
- this.deliver(copy.key);
277
+ connected() {
278
+ return this.selected();
279
+ }
280
+ async changed(rooms) {
281
+ if (rooms.length > 0)
282
+ await this.selected(rooms);
283
+ }
284
+ fetch() {
285
+ return this.answered(() => this.fetched(true));
286
+ }
287
+ async selected(rooms) {
288
+ const copied = await this.answered(() => this.fetched(false, rooms));
289
+ if (copied.refused)
290
+ logged(copied.text);
291
+ }
292
+ answered(work) {
293
+ const answered = this.answering.then(work);
294
+ this.answering = answered.catch(() => undefined);
295
+ return answered;
296
+ }
297
+ async fetched(refreshing, rooms) {
298
+ const { local_copy: answer } = await this.house.post('/kit/local-copy/selection', rooms === undefined ? {} : { rooms });
299
+ const synced = new Map(answer.rooms.map((room) => [`${ROOMS}${room.room_ref}`, room.room_handle]));
300
+ const named = rooms === undefined ? null : new Set(rooms.map((room) => `${ROOMS}${room}`));
301
+ const held = () => [...this.copies.values()].filter((copy) => named === null || named.has(copy.key));
302
+ return this.holding([...new Set([...held().map((copy) => copy.key), ...synced.keys()])].sort(), async () => {
303
+ const said = [];
304
+ let refused = false;
305
+ const mark = refreshing ? 'said' : 'unsaid';
306
+ for (const copy of held()) {
307
+ if (synced.has(copy.key) || copy.unfollowed === 'said' || copy.unfollowed === mark)
308
+ continue;
309
+ await this.save({ ...copy, unfollowed: mark });
310
+ if (refreshing)
311
+ said.push(`${rootOf(copy.room_handle)}: no longer followed; kept as it is`);
312
+ }
313
+ const current = [];
314
+ const wanted = new Map();
315
+ for (const [key, handle] of synced) {
316
+ try {
317
+ const held = this.copies.get(key);
318
+ if (held === undefined || !(await exists(held.repository))) {
319
+ wanted.set(key, handle);
320
+ continue;
321
+ }
322
+ const { unfollowed, ...followed } = held;
323
+ current.push(unfollowed === undefined && held.room_handle === handle ? held : await this.save({ ...followed, room_handle: handle }));
324
+ }
325
+ catch (error) {
326
+ refused = true;
327
+ said.push(`${rootOf(handle)}: ${causeOf(error)}`);
328
+ }
329
+ }
330
+ const unready = [];
331
+ const unsettled = [];
332
+ if (wanted.size > 0) {
333
+ const { authorities } = await this.discovered([...wanted.values()]);
334
+ for (const [key, handle] of wanted) {
335
+ const authority = authorities.find((entry) => `${ROOMS}${entry.room_ref}` === key);
336
+ try {
337
+ if (authority === undefined)
338
+ throw new Error('House serves nothing of it to copy');
339
+ said.push(await this.copied({ key, handle, authority }));
340
+ }
341
+ catch (error) {
342
+ if (error instanceof NotReady) {
343
+ unready.push(...error.rooms);
344
+ said.push(`${rootOf(handle)}: ${NOT_READY}`);
345
+ }
346
+ else {
347
+ refused = true;
348
+ unsettled.push(key.slice(ROOMS.length));
349
+ said.push(`${rootOf(handle)}: ${causeOf(error)}`);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ const reading = refreshing ? current : current.filter((copy) => this.waiting.has(copy.room_ref));
355
+ if (reading.length > 0) {
356
+ const failed = await this.refreshed(reading, unready).then(() => null, houseCause);
357
+ for (const copy of reading) {
358
+ said.push(...(unready.includes(copy.room_ref) ? [`${rootOf(copy.room_handle)}: ${NOT_READY}`] : moved(copy, this.copies.get(copy.key))));
359
+ }
360
+ if (failed !== null) {
361
+ refused = true;
362
+ unsettled.push(...reading.map((copy) => copy.room_ref));
363
+ said.push(failed);
364
+ }
365
+ }
366
+ for (const room of rooms ?? [...this.waiting]) {
367
+ if (!unready.includes(room) && !unsettled.includes(room))
368
+ this.waiting.delete(room);
369
+ }
370
+ const due = unready.filter((room) => !this.waiting.has(room));
371
+ for (const room of due)
372
+ this.waiting.add(room);
373
+ if (due.length > 0)
374
+ void this.askedAgain(due);
375
+ return { refused, text: said.length === 0 ? 'Nothing new from House.' : said.join('\n') };
376
+ });
377
+ }
378
+ async askedAgain(rooms) {
379
+ let asking = rooms;
380
+ for (let step = 0; asking.length > 0; step++) {
381
+ await paused(ASKED_AGAIN_MS[Math.min(step, ASKED_AGAIN_MS.length - 1)]);
382
+ await this.selected().catch(logged);
383
+ asking = asking.filter((room) => this.waiting.has(room));
384
+ }
224
385
  }
225
386
  async observed(report) {
226
387
  const key = await this.keyAt(dirname(report.common));
@@ -250,11 +411,11 @@ export class LocalCopies {
250
411
  async push(cwd, selector, caller) {
251
412
  const located = await run(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel']);
252
413
  if (located.status !== 0)
253
- return { refused: true, text: `${cwd} is not a House Local copy; run house push inside one` };
414
+ return outside(cwd);
254
415
  const [common, worktree] = located.stdout.toString('utf8').split('\n');
255
416
  const key = await this.keyAt(dirname(common));
256
417
  if (key === undefined)
257
- return { refused: true, text: `${worktree} is not a House Local copy; run house push inside one` };
418
+ return outside(worktree);
258
419
  return this.exclusive(key, () => this.pushed(key, worktree, selector, caller));
259
420
  }
260
421
  async keyAt(repository) {
@@ -270,19 +431,12 @@ export class LocalCopies {
270
431
  this.queues.set(key, queued.catch(() => undefined));
271
432
  return queued;
272
433
  }
273
- deliver(key) {
274
- if (this.delivering.has(key))
275
- return;
276
- this.delivering.add(key);
277
- void this.exclusive(key, async () => {
278
- this.delivering.delete(key);
279
- const copy = this.copies.get(key);
280
- if (copy.active)
281
- await this.save(await this.caughtUp(copy));
282
- }).catch(logged);
434
+ holding(keys, work) {
435
+ const [key, ...rest] = keys;
436
+ return key === undefined ? work() : this.exclusive(key, () => this.holding(rest, work));
283
437
  }
284
438
  async save(copy) {
285
- const path = join(kitHome(), 'local-copies', `${copy.key}.json`);
439
+ const path = recordOf(copy.key);
286
440
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
287
441
  const temporary = `${path}.${process.pid}`;
288
442
  await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
@@ -290,161 +444,210 @@ export class LocalCopies {
290
444
  this.copies.set(copy.key, copy);
291
445
  return copy;
292
446
  }
293
- async read(handle) {
294
- const prefixes = [rootOf(handle)];
295
- const discovery = await this.house.post('/kit/door/discover', { prefixes });
296
- let bundle = await this.house.post('/kit/door/bootstrap', { prefixes });
297
- if ('transfer' in bundle)
298
- bundle = (await (await fetch(relayed(bundle.transfer.url))).json());
299
- const [view] = bundle.scopes;
300
- if (view === undefined)
301
- return null;
302
- const authority = discovery.authorities.find((entry) => entry.room_ref === view.manifest.anchor);
303
- const contents = new Map(view.contents.map((entry) => [entry.path, entry.content]));
304
- const read = {
305
- anchor: view.manifest.anchor,
306
- position: view.manifest.position,
307
- log_epoch: view.manifest.log_epoch,
308
- write: authority.operations.write,
309
- protected: authority.protected_paths,
310
- writable: new Map(),
311
- revisions: {},
312
- readOnly: new Map(),
313
- };
314
- for (const file of view.manifest.files) {
315
- const content = contents.get(file.path);
316
- if (file.writable) {
317
- read.writable.set(file.path, content);
318
- read.revisions[file.path] = file.revision;
319
- }
320
- else {
321
- read.readOnly.set(file.path, content);
322
- }
323
- if (file.provenance !== undefined)
324
- read.readOnly.set(sidecar(file.path), file.provenance);
325
- }
326
- return read;
447
+ async discovered(handles) {
448
+ const discover = (group) => this.house.post('/kit/door/discover', { prefixes: group.map(rootOf) });
449
+ const [first, ...rest] = handles;
450
+ const { bounds, authorities } = await discover([first]);
451
+ for (const group of grouped(rest, bounds.max_prefixes))
452
+ authorities.push(...(await discover(group)).authorities);
453
+ return { bounds, authorities };
327
454
  }
328
- async apply(scope) {
329
- let copy = this.copies.get(scope.key);
330
- if (copy === undefined || !(await exists(copy.repository))) {
331
- await this.materialize(scope);
332
- return;
333
- }
334
- if (copy.room_handle !== scope.handle)
335
- copy = await this.save({ ...copy, room_handle: scope.handle });
336
- if (!copy.active) {
337
- await this.save(await this.received({ ...copy, active: true }));
338
- return;
339
- }
340
- const discovery = await this.house.post('/kit/door/discover', {
341
- prefixes: [rootOf(copy.room_handle)],
342
- });
343
- const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
344
- if (authority === undefined)
345
- return;
346
- if (authority.managed_replication !== 'admitted') {
347
- logged(`operation_denied: ${rootOf(copy.room_handle)} is not synchronized to this Environment`);
348
- return;
349
- }
350
- if (JSON.stringify([authority.operations.write, authority.protected_paths]) !==
351
- JSON.stringify([copy.write, copy.protected])) {
352
- await this.save(await this.received(copy));
353
- return;
455
+ async chunked(root, room, directory) {
456
+ await mkdir(directory, { recursive: true });
457
+ for (let renewed = false;; renewed = true) {
458
+ const { rooms } = await this.house.post('/kit/door/room-chunks/list', { prefixes: [root] });
459
+ const list = rooms.find((entry) => entry.room_ref === room);
460
+ if (list === undefined)
461
+ throw new Error('House serves nothing of it to copy');
462
+ if (!list.ready)
463
+ throw new NotReady([room]);
464
+ const held = new Set(await readdir(directory));
465
+ const failure = await stagedChunks(directory, list.chunks.filter(({ hash }) => !held.has(hash)));
466
+ if (failure === undefined) {
467
+ return { position: list.position, log_epoch: list.log_epoch, texts: chunkTexts(directory, list.chunks, list.models) };
468
+ }
469
+ if (renewed && (await readdir(directory)).length === held.size)
470
+ throw failure;
354
471
  }
355
- await this.save(await this.caughtUp(copy));
356
472
  }
357
- async materialize(scope) {
358
- const read = await this.read(scope.handle);
359
- if (read === null) {
360
- logged(`House serves nothing of ${rootOf(scope.handle)} to copy`);
361
- return;
362
- }
363
- const repository = join(localCopies(), 'rooms', read.anchor);
364
- if (await exists(repository)) {
365
- logged(`${repository} already exists and House Kit leaves it as it is`);
366
- return;
367
- }
368
- const staging = join(dirname(repository), `.${randomUUID()}`);
473
+ async materialized({ key, handle, authority }, whole) {
474
+ const repository = join(localCopies(), 'rooms', authority.room_ref);
475
+ if (await exists(repository))
476
+ throw new Error(`${repository} already exists and Kit leaves it as it is`);
477
+ const staging = join(dirname(repository), `${stagingPrefix()}${randomUUID()}`);
478
+ const scope = { write: authority.operations.write, protected: authority.protected_paths };
369
479
  await mkdir(staging, { recursive: true });
370
- let house;
371
- let readOnly;
480
+ let copy;
372
481
  try {
373
482
  await git(staging, ['init', '-q', '-b', BRANCH]);
374
- house = await importCommits(staging, [
375
- { message: `House ${rootOf(scope.handle)}`, from: null, files: read.writable },
376
- ]);
483
+ const read = await whole(join(staging, '.git', STAGED_CHUNKS));
484
+ const readOnly = await readOnlyFiles(staging, []);
485
+ const { house, revisions } = await importedWhole(staging, scope, read.texts, { message: `House ${rootOf(handle)}`, from: null }, readOnly);
377
486
  await git(staging, ['update-ref', `refs/heads/${BRANCH}`, house]);
378
487
  await git(staging, ['reset', '-q', '--hard']);
379
- readOnly = await readOnlyPlaced(staging, [], read.readOnly, []);
380
- await ignoreRules(staging, [...read.writable.keys()], read);
488
+ copy = {
489
+ key,
490
+ room_ref: authority.room_ref,
491
+ room_handle: handle,
492
+ repository,
493
+ house,
494
+ position: read.position,
495
+ log_epoch: read.log_epoch,
496
+ ...scope,
497
+ read_only: readOnly.held(),
498
+ manifests: { [house]: { parent: null, files: revisions } },
499
+ moves: [],
500
+ };
501
+ await ignoreRules(staging, Object.keys(revisions), copy);
502
+ await rm(join(staging, '.git', STAGED_CHUNKS), { recursive: true, force: true });
381
503
  await rename(staging, repository);
382
504
  }
383
505
  catch (error) {
384
506
  await rm(staging, { recursive: true, force: true });
385
507
  throw error;
386
508
  }
387
- await this.save({
388
- key: scope.key,
389
- room_ref: read.anchor,
390
- room_handle: scope.handle,
391
- repository,
392
- active: true,
393
- house,
394
- position: read.position,
395
- log_epoch: read.log_epoch,
396
- write: read.write,
397
- protected: read.protected,
398
- read_only: readOnly,
399
- manifests: { [house]: { parent: null, files: read.revisions } },
400
- moves: [],
401
- });
509
+ return this.save(copy);
402
510
  }
403
- async received(copy) {
404
- const read = await this.read(copy.room_handle);
405
- if (read === null)
406
- return copy;
407
- const house = await importCommits(copy.repository, [
408
- { message: `House ${rootOf(copy.room_handle)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
409
- ]);
410
- const removed = copy.read_only.filter((path) => !read.readOnly.has(path));
411
- const readOnly = await readOnlyPlaced(copy.repository, copy.read_only, read.readOnly, removed);
412
- const next = {
413
- ...copy,
414
- house,
415
- position: read.position,
416
- log_epoch: read.log_epoch,
417
- write: read.write,
418
- protected: read.protected,
419
- read_only: readOnly,
420
- manifests: { ...copy.manifests, [house]: { parent: null, files: read.revisions } },
421
- };
422
- await ignoreRules(copy.repository, [...read.writable.keys()], next);
423
- return next;
511
+ async copied(wanted) {
512
+ const { handle, authority } = wanted;
513
+ const root = rootOf(handle);
514
+ let answer = null;
515
+ try {
516
+ if (!this.waiting.has(authority.room_ref)) {
517
+ answer = await this.house.post(ENUMERATE, {
518
+ prefixes: [root],
519
+ positions: [
520
+ { authority: authority.room_ref, position: '0', log_epoch: authority.log_epoch, page: { through: authority.position, read: 0 } },
521
+ ],
522
+ });
523
+ }
524
+ }
525
+ catch (error) {
526
+ if (expiredAuthority(error) !== authority.room_ref)
527
+ throw error;
528
+ }
529
+ const view = answer?.authorities.find((entry) => entry.authority === authority.room_ref);
530
+ if (answer !== null && view === undefined)
531
+ throw new Error('House serves nothing of it to copy');
532
+ if (view !== undefined && view.next === undefined) {
533
+ const texts = view.changes.filter((change) => !('removed' in change));
534
+ const copy = await this.materialized(wanted, async () => ({ position: view.position, log_epoch: view.log_epoch, texts }));
535
+ return `${root}: copied to ${copy.repository}`;
536
+ }
537
+ const copy = await this.materialized(wanted, (directory) => this.chunked(root, authority.room_ref, directory));
538
+ const [caught] = await this.caughtUp([copy], true);
539
+ await succeeded(copy.repository, ['merge', '--ff-only', '-q', caught.house]);
540
+ return `${root}: copied to ${copy.repository}`;
424
541
  }
425
- async caughtUp(start) {
426
- let copy = start;
427
- for (;;) {
542
+ async received(copy, authority) {
543
+ const root = rootOf(copy.room_handle);
544
+ const directory = join(copy.repository, '.git', STAGED_CHUNKS);
545
+ const scope = { write: authority.operations.write, protected: authority.protected_paths };
546
+ try {
547
+ const read = await this.chunked(root, copy.room_ref, directory);
548
+ const readOnly = await readOnlyFiles(copy.repository, copy.read_only);
549
+ for (const path of copy.read_only)
550
+ await readOnly.remove(path);
551
+ const commit = { message: `House ${root} at position ${read.position}`, from: copy.house, replaces: true };
552
+ const { house, revisions } = await importedWhole(copy.repository, scope, read.texts, commit, readOnly);
553
+ const next = {
554
+ ...copy,
555
+ ...scope,
556
+ house,
557
+ position: read.position,
558
+ log_epoch: read.log_epoch,
559
+ read_only: readOnly.held(),
560
+ manifests: { ...copy.manifests, [house]: { parent: null, files: revisions } },
561
+ };
562
+ await ignoreRules(copy.repository, Object.keys(revisions), next);
563
+ return next;
564
+ }
565
+ finally {
566
+ await rm(directory, { recursive: true, force: true });
567
+ }
568
+ }
569
+ async reread(copy) {
570
+ const { authorities } = await this.discovered([copy.room_handle]);
571
+ const authority = authorities.find((entry) => entry.room_ref === copy.room_ref);
572
+ if (authority === undefined)
573
+ throw new NotReady([copy.room_ref]);
574
+ return this.received(copy, authority);
575
+ }
576
+ async refreshed(copies, unready) {
577
+ const { bounds, authorities } = await this.discovered(copies.map((copy) => copy.room_handle));
578
+ const read = [];
579
+ const unchanged = [];
580
+ for (const copy of copies) {
581
+ const authority = authorities.find((entry) => entry.room_ref === copy.room_ref);
582
+ if (authority !== undefined &&
583
+ JSON.stringify([authority.operations.write, authority.protected_paths]) !== JSON.stringify([copy.write, copy.protected])) {
584
+ try {
585
+ read.push(await this.save(await this.received(copy, authority)));
586
+ }
587
+ catch (error) {
588
+ if (!(error instanceof NotReady))
589
+ throw error;
590
+ unready.push(...error.rooms);
591
+ }
592
+ }
593
+ else {
594
+ unchanged.push(copy);
595
+ }
596
+ }
597
+ for (const group of grouped(unchanged, bounds.max_prefixes))
598
+ await this.caughtUp(group, false, unready);
599
+ for (const group of grouped(read, bounds.max_prefixes))
600
+ await this.caughtUp(group, true, unready);
601
+ }
602
+ async caughtUp(copies, whole, unready) {
603
+ const caught = new Map(copies.map((copy) => [copy.key, copy]));
604
+ const read = new Set();
605
+ const waiting = unready ?? [];
606
+ let asking = [...copies];
607
+ while (asking.length > 0) {
428
608
  let answer;
429
609
  try {
430
- answer = await this.house.post('/kit/door/enumerate', {
431
- prefixes: [rootOf(copy.room_handle)],
432
- positions: [{ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch }],
610
+ answer = await this.house.post(ENUMERATE, {
611
+ prefixes: asking.map((copy) => rootOf(copy.room_handle)),
612
+ positions: asking.map((copy) => ({ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch })),
433
613
  });
434
614
  }
435
615
  catch (error) {
436
- const code = error instanceof HouseRefusal ? (refusalOf(error.text)?.code ?? null) : null;
437
- if (code === 'position_expired')
438
- return this.received(copy);
439
- if (code !== 'replica_behind')
616
+ const expired = asking.find((copy) => copy.room_ref === expiredAuthority(error));
617
+ if (expired === undefined)
440
618
  throw error;
441
- return copy;
619
+ asking = asking.filter((copy) => copy !== expired);
620
+ if (read.has(expired.key)) {
621
+ waiting.push(expired.room_ref);
622
+ continue;
623
+ }
624
+ read.add(expired.key);
625
+ try {
626
+ const next = await this.save(await this.reread(expired));
627
+ caught.set(expired.key, next);
628
+ asking.push(next);
629
+ }
630
+ catch (refused) {
631
+ if (!(refused instanceof NotReady))
632
+ throw refused;
633
+ waiting.push(expired.room_ref);
634
+ }
635
+ continue;
442
636
  }
443
- const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
444
- if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
445
- return copy;
446
- copy = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
637
+ const advanced = [];
638
+ for (const copy of asking) {
639
+ const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
640
+ if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
641
+ continue;
642
+ const next = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
643
+ caught.set(copy.key, next);
644
+ advanced.push(next);
645
+ }
646
+ asking = advanced.filter((copy) => whole || read.has(copy.key));
447
647
  }
648
+ if (unready === undefined && waiting.length > 0)
649
+ throw new NotReady(waiting);
650
+ return copies.map((copy) => caught.get(copy.key));
448
651
  }
449
652
  async incorporated(copy, changes, position, logEpoch) {
450
653
  const houseTree = await tree(copy.repository, copy.house);
@@ -507,9 +710,6 @@ export class LocalCopies {
507
710
  }
508
711
  copy = await this.save(without(copy));
509
712
  }
510
- if (!copy.active) {
511
- return { refused: true, text: `${rootOf(copy.room_handle)} is no longer synced to this Environment, so House takes no push from it` };
512
- }
513
713
  const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
514
714
  const base = listed.find((ancestor) => ancestor in copy.manifests);
515
715
  if (base === undefined) {
@@ -520,7 +720,7 @@ export class LocalCopies {
520
720
  changes = await this.derived(copy, base, commit);
521
721
  }
522
722
  catch (error) {
523
- return { refused: true, text: causeOf(error) };
723
+ return { refused: true, text: houseCause(error) };
524
724
  }
525
725
  if (changes.length === 0)
526
726
  return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
@@ -547,7 +747,18 @@ export class LocalCopies {
547
747
  const after = await tree(copy.repository, commit);
548
748
  const moved = movedPaths(copy.moves, before, after);
549
749
  const arrived = new Set(moved.values());
550
- const text = (entry, path) => blobText(copy.repository, entry, path);
750
+ const arriving = [...after].filter(([path, entry]) => before.get(path) !== entry);
751
+ if (arriving.length > 0) {
752
+ const { bounds } = await this.house.post('/kit/door/discover', { prefixes: [root] });
753
+ const binary = [];
754
+ for (const [path, entry] of arriving) {
755
+ if (!(await isText(copy.repository, entry, bounds.max_authored_file_bytes)))
756
+ binary.push(path);
757
+ }
758
+ if (binary.length > 0)
759
+ throw new Error(textOnly(binary));
760
+ }
761
+ const text = (entry) => blobText(copy.repository, entry);
551
762
  const changes = [];
552
763
  for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
553
764
  if (arrived.has(path))
@@ -560,7 +771,7 @@ export class LocalCopies {
560
771
  changes.push({ op: 'rename', path: `${root}/${path}`, to: `${root}/${to}`, base: known });
561
772
  const moving = after.get(to);
562
773
  if (moving !== prior) {
563
- changes.push({ op: 'replace', path: `${root}/${to}`, base: known, content: await text(moving, to) });
774
+ changes.push({ op: 'replace', path: `${root}/${to}`, base: known, content: await text(moving) });
564
775
  }
565
776
  continue;
566
777
  }
@@ -569,9 +780,9 @@ export class LocalCopies {
569
780
  if (next === undefined)
570
781
  changes.push({ op: 'remove', path: `${root}/${path}`, base: known });
571
782
  else if (prior === undefined)
572
- changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next, path) });
783
+ changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next) });
573
784
  else
574
- changes.push(...changed(`${root}/${path}`, path, known, await text(prior, path), await text(next, path)));
785
+ changes.push(...changed(`${root}/${path}`, path, known, await text(prior), await text(next)));
575
786
  }
576
787
  return changes;
577
788
  }
@@ -598,12 +809,14 @@ export class LocalCopies {
598
809
  const { upload, ...unstaged } = copy.push;
599
810
  return this.settle(await this.save({ ...copy, push: unstaged }), caller, true);
600
811
  }
601
- if (outcome.refused.code === 'operation_expired')
812
+ if (outcome.refused.code === 'expired')
602
813
  return this.recovered(copy);
603
- const rebased = rebaseFixes(outcome.refused);
604
814
  let settled = await this.save(without(copy));
815
+ if (settled.unfollowed !== undefined)
816
+ return { refused: true, text: outcome.refused.text };
817
+ const rebased = rebaseFixes(outcome.refused);
605
818
  try {
606
- settled = await this.save(await this.caughtUp(settled));
819
+ settled = (await this.caughtUp([settled], true))[0];
607
820
  }
608
821
  catch {
609
822
  return {
@@ -622,7 +835,7 @@ export class LocalCopies {
622
835
  }
623
836
  async recovered(copy) {
624
837
  const push = copy.push;
625
- const current = await this.save(await this.caughtUp(copy));
838
+ const current = (await this.caughtUp([copy], true))[0];
626
839
  const house = await tree(copy.repository, current.house);
627
840
  const root = rootOf(current.room_handle);
628
841
  if (!(await this.landed(copy.repository, push.changes, house, await tree(copy.repository, push.commit)))) {
@@ -638,7 +851,7 @@ export class LocalCopies {
638
851
  written.push({
639
852
  path: `${root}/${path}`,
640
853
  revision: entry === undefined ? null : (revisionAt(current, current.house, path) ?? null),
641
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
854
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
642
855
  });
643
856
  }
644
857
  return this.finished(await this.save({ ...current, push: { ...push, state: 'accepted', written } }));
@@ -666,7 +879,7 @@ export class LocalCopies {
666
879
  const push = copy.push;
667
880
  let written = push.written;
668
881
  if (push.changes.some((change) => change.op === 'rename')) {
669
- copy = await this.save(await this.caughtUp(copy));
882
+ copy = (await this.caughtUp([copy], true))[0];
670
883
  const house = await tree(copy.repository, copy.house);
671
884
  const sent = await tree(copy.repository, push.house);
672
885
  if (push.changes.some((change) => change.op === 'rename' && !house.has(relative(change.to)))) {
@@ -682,7 +895,7 @@ export class LocalCopies {
682
895
  {
683
896
  path: `${rootOf(copy.room_handle)}/${path}`,
684
897
  revision: entry === undefined ? null : (revisionAt(copy, copy.house, path) ?? null),
685
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
898
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
686
899
  },
687
900
  ];
688
901
  }
@@ -727,7 +940,7 @@ export class LocalCopies {
727
940
  async landed(repository, changes, house, selected) {
728
941
  const text = async (path) => {
729
942
  const entry = house.get(relative(path));
730
- return entry === undefined ? undefined : blobText(repository, entry, path);
943
+ return entry === undefined ? undefined : blobText(repository, entry);
731
944
  };
732
945
  for (const change of changes) {
733
946
  const current = await text(change.path);