@agentshouse/kit 0.2.1-c8179f06883b6 → 0.2.1-c83d468784cc9

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 +37 -36
  2. package/dist/acp.js +90 -19
  3. package/dist/afk.js +17 -0
  4. package/dist/agents.js +154 -48
  5. package/dist/api.js +5 -5
  6. package/dist/app-git.js +10 -26
  7. package/dist/authority-main.js +3 -3
  8. package/dist/bridge.js +7 -10
  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 +57 -42
  14. package/dist/connection.js +105 -0
  15. package/dist/conversations.js +572 -182
  16. package/dist/copies.js +350 -162
  17. package/dist/end.js +15 -4
  18. package/dist/events.js +70 -8
  19. package/dist/files.js +139 -31
  20. package/dist/git.js +57 -21
  21. package/dist/home.js +56 -12
  22. package/dist/house-cli.js +299 -0
  23. package/dist/house-main.js +7 -228
  24. package/dist/instructions.js +7 -11
  25. package/dist/kit-cli.js +68 -0
  26. package/dist/kit-main.js +8 -67
  27. package/dist/login.js +43 -19
  28. package/dist/logout.js +1 -3
  29. package/dist/mcp.js +6 -2
  30. package/dist/parts.js +10 -0
  31. package/dist/placement.js +19 -36
  32. package/dist/relay.js +21 -0
  33. package/dist/resident.js +100 -44
  34. package/dist/scope.js +15 -4
  35. package/dist/scripts.js +255 -0
  36. package/dist/secret-input.js +1 -1
  37. package/dist/service.js +23 -1
  38. package/dist/sign-in.js +22 -39
  39. package/dist/silence.js +28 -0
  40. package/dist/skills.js +49 -35
  41. package/dist/transcription.js +85 -45
  42. package/dist/translate.js +2 -8
  43. package/dist/update.js +113 -126
  44. package/dist/wire.js +37 -3
  45. package/dist/work.js +3 -5
  46. package/package.json +20 -20
  47. package/dist/bootstrap.json +0 -24
  48. package/dist/clis-main.js +0 -23
  49. package/dist/codex.js +0 -61
  50. package/dist/stream.js +0 -77
  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/changes/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,6 +46,13 @@ 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
  }
@@ -48,6 +72,9 @@ function expiredAuthority(error) {
48
72
  return undefined;
49
73
  }
50
74
  }
75
+ function grouped(items, size) {
76
+ return items.flatMap((_, index) => (index % size === 0 ? [items.slice(index, index + size)] : []));
77
+ }
51
78
  function covers(path, prefix) {
52
79
  return prefix === '' || path === prefix || path.startsWith(`${prefix}/`);
53
80
  }
@@ -56,6 +83,12 @@ function sidecar(path) {
56
83
  segments.splice(-1, 0, PROVENANCE);
57
84
  return segments.join('/');
58
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
+ }
59
92
  function rootOf(handle) {
60
93
  return `/rooms/${handle}`;
61
94
  }
@@ -83,32 +116,59 @@ async function removeFile(repository, path) {
83
116
  return;
84
117
  }
85
118
  }
86
- async function readOnlyPlaced(repository, held, written, removed) {
119
+ async function readOnlyFiles(repository, held) {
87
120
  const tracked = new Set((await git(repository, ['ls-files', '-z'])).split('\0'));
88
121
  const holding = new Set(held);
89
- for (const path of removed) {
90
- if (!holding.has(path))
91
- continue;
92
- if (!tracked.has(path))
93
- await removeFile(repository, path);
94
- holding.delete(path);
95
- }
96
- for (const [path, content] of written) {
97
- if (tracked.has(path))
98
- continue;
99
- if (!holding.has(path) && (await exists(join(repository, path))))
100
- continue;
101
- await writeText(join(repository, path), content);
102
- holding.add(path);
103
- }
104
- 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 };
105
165
  }
106
166
  async function ruled(path, rules) {
107
167
  if ((await readFile(path, 'utf8').catch(() => null)) !== rules)
108
168
  await writeFile(path, rules);
109
169
  }
110
170
  async function ignoreRules(repository, writable, copy) {
111
- 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));
112
172
  const protectedPath = (path) => copy.protected.some((prefix) => prefix !== '' && covers(path, prefix));
113
173
  const whole = copy.protected.filter((prefix) => prefix !== '' && !writable.some((path) => covers(path, prefix)));
114
174
  const house = [
@@ -117,7 +177,7 @@ async function ignoreRules(repository, writable, copy) {
117
177
  ].map(pattern);
118
178
  const granted = readOnly.filter((path) => !protectedPath(path)).map(pattern);
119
179
  await ruled(join(repository, '.gitignore'), [
120
- '# 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',
121
181
  '# content: Git does not track them, and House refuses a change to them even',
122
182
  '# when this file is edited or a path is force-added.',
123
183
  '/.gitignore',
@@ -132,7 +192,7 @@ async function ignoreRules(repository, writable, copy) {
132
192
  '',
133
193
  ].join('\n'));
134
194
  await ruled(join(repository, '.ignore'), [
135
- '# 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',
136
196
  '# content that .gitignore keeps out of Git.',
137
197
  `!${PROVENANCE}`,
138
198
  ...[...house, ...granted].map((rule) => `!${rule}`),
@@ -168,6 +228,7 @@ export class LocalCopies {
168
228
  house;
169
229
  copies = new Map();
170
230
  queues = new Map();
231
+ waiting = new Set();
171
232
  answering = Promise.resolve();
172
233
  constructor(house) {
173
234
  this.house = house;
@@ -175,7 +236,7 @@ export class LocalCopies {
175
236
  key: 'owner',
176
237
  submit: async (operation, changes) => {
177
238
  try {
178
- const outcome = await retrying(() => house.post('/kit/door/batch', { operation_id: operation, changes }));
239
+ const outcome = await retrying(() => house.post('/kit/door/files/batch', { operation_id: operation, changes }));
179
240
  return { accepted: outcome.contents };
180
241
  }
181
242
  catch (error) {
@@ -199,70 +260,129 @@ export class LocalCopies {
199
260
  this.copies.set(copy.key, copy);
200
261
  }
201
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
+ }
202
270
  async mapping() {
203
271
  await this.answering;
204
272
  return [...this.copies.values()]
205
- .filter((copy) => copy.active)
273
+ .filter((copy) => copy.unfollowed === undefined)
206
274
  .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
207
275
  .sort();
208
276
  }
209
- async connected() {
210
- const copied = await this.answered(() => this.fetched(false));
211
- if (copied.refused)
212
- logged(copied.text);
277
+ connected() {
278
+ return this.selected();
279
+ }
280
+ async changed(rooms) {
281
+ if (rooms.length > 0)
282
+ await this.selected(rooms);
213
283
  }
214
284
  fetch() {
215
285
  return this.answered(() => this.fetched(true));
216
286
  }
287
+ async selected(rooms) {
288
+ const copied = await this.answered(() => this.fetched(false, rooms));
289
+ if (copied.refused)
290
+ logged(copied.text);
291
+ }
217
292
  answered(work) {
218
293
  const answered = this.answering.then(work);
219
294
  this.answering = answered.catch(() => undefined);
220
295
  return answered;
221
296
  }
222
- async fetched(refreshing) {
223
- const { local_copy: answer } = await this.house.post('/kit/local-copy/selection', {});
224
- const synced = new Map(answer.rooms.map((room) => [`rooms/${room.room_ref}`, room.room_handle]));
225
- return this.holding([...new Set([...this.copies.keys(), ...synced.keys()])].sort(), async () => {
297
+ async fetched(refreshing, rooms) {
298
+ const { local_copy: answer } = await this.house.post('/kit/local-copies/selection/read', 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 () => {
226
303
  const said = [];
227
304
  let refused = false;
228
- for (const copy of this.copies.values()) {
229
- if (synced.has(copy.key) || !copy.active)
305
+ const mark = refreshing ? 'said' : 'unsaid';
306
+ for (const copy of held()) {
307
+ if (synced.has(copy.key) || copy.unfollowed === 'said' || copy.unfollowed === mark)
230
308
  continue;
231
- await this.save({ ...copy, active: false });
232
- said.push(`${rootOf(copy.room_handle)}: no longer synced to this Environment; its files stay`);
309
+ await this.save({ ...copy, unfollowed: mark });
310
+ if (refreshing)
311
+ said.push(`${rootOf(copy.room_handle)}: no longer followed; kept as it is`);
233
312
  }
234
313
  const current = [];
314
+ const wanted = new Map();
235
315
  for (const [key, handle] of synced) {
236
316
  try {
237
317
  const held = this.copies.get(key);
238
318
  if (held === undefined || !(await exists(held.repository))) {
239
- said.push(await this.materialize(key, handle));
319
+ wanted.set(key, handle);
240
320
  continue;
241
321
  }
242
- const copy = held.room_handle === handle ? held : await this.save({ ...held, room_handle: handle });
243
- if (copy.active)
244
- current.push(copy);
245
- else
246
- said.push(...moved(copy, await this.save(await this.received({ ...copy, active: true }))));
322
+ const { unfollowed, ...followed } = held;
323
+ current.push(unfollowed === undefined && held.room_handle === handle ? held : await this.save({ ...followed, room_handle: handle }));
247
324
  }
248
325
  catch (error) {
249
326
  refused = true;
250
327
  said.push(`${rootOf(handle)}: ${causeOf(error)}`);
251
328
  }
252
329
  }
253
- if (refreshing && current.length > 0) {
254
- try {
255
- const refreshed = await this.refreshed(current);
256
- current.forEach((copy, index) => said.push(...moved(copy, refreshed[index])));
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
+ }
257
352
  }
258
- catch (error) {
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) {
259
361
  refused = true;
260
- said.push(error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error));
362
+ unsettled.push(...reading.map((copy) => copy.room_ref));
363
+ said.push(failed);
261
364
  }
262
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);
263
375
  return { refused, text: said.length === 0 ? 'Nothing new from House.' : said.join('\n') };
264
376
  });
265
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
+ }
385
+ }
266
386
  async observed(report) {
267
387
  const key = await this.keyAt(dirname(report.common));
268
388
  if (key === undefined)
@@ -291,11 +411,11 @@ export class LocalCopies {
291
411
  async push(cwd, selector, caller) {
292
412
  const located = await run(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel']);
293
413
  if (located.status !== 0)
294
- return { refused: true, text: `${cwd} is not a House Local copy; run house push inside one` };
414
+ return outside(cwd);
295
415
  const [common, worktree] = located.stdout.toString('utf8').split('\n');
296
416
  const key = await this.keyAt(dirname(common));
297
417
  if (key === undefined)
298
- return { refused: true, text: `${worktree} is not a House Local copy; run house push inside one` };
418
+ return outside(worktree);
299
419
  return this.exclusive(key, () => this.pushed(key, worktree, selector, caller));
300
420
  }
301
421
  async keyAt(repository) {
@@ -316,7 +436,7 @@ export class LocalCopies {
316
436
  return key === undefined ? work() : this.exclusive(key, () => this.holding(rest, work));
317
437
  }
318
438
  async save(copy) {
319
- const path = join(kitHome(), 'local-copies', `${copy.key}.json`);
439
+ const path = recordOf(copy.key);
320
440
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
321
441
  const temporary = `${path}.${process.pid}`;
322
442
  await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
@@ -324,128 +444,170 @@ export class LocalCopies {
324
444
  this.copies.set(copy.key, copy);
325
445
  return copy;
326
446
  }
327
- async read(handle) {
328
- const prefixes = [rootOf(handle)];
329
- const discovery = await this.house.post('/kit/door/discover', { prefixes });
330
- let bundle = await this.house.post('/kit/door/bootstrap', { prefixes });
331
- if ('transfer' in bundle)
332
- bundle = (await (await fetch(relayed(bundle.transfer.url))).json());
333
- const [view] = bundle.scopes;
334
- if (view === undefined)
335
- return null;
336
- const authority = discovery.authorities.find((entry) => entry.room_ref === view.manifest.anchor);
337
- const contents = new Map(view.contents.map((entry) => [entry.path, entry.content]));
338
- const read = {
339
- anchor: view.manifest.anchor,
340
- position: view.manifest.position,
341
- log_epoch: view.manifest.log_epoch,
342
- write: authority.operations.write,
343
- protected: authority.protected_paths,
344
- writable: new Map(),
345
- revisions: {},
346
- readOnly: new Map(),
347
- };
348
- for (const file of view.manifest.files) {
349
- const content = contents.get(file.path);
350
- if (file.writable) {
351
- read.writable.set(file.path, content);
352
- read.revisions[file.path] = file.revision;
353
- }
354
- else {
355
- read.readOnly.set(file.path, content);
447
+ async discovered(handles) {
448
+ const discover = (group) => this.house.post('/kit/door/rooms/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 };
454
+ }
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) };
356
468
  }
357
- if (file.provenance !== undefined)
358
- read.readOnly.set(sidecar(file.path), file.provenance);
469
+ if (renewed && (await readdir(directory)).length === held.size)
470
+ throw failure;
359
471
  }
360
- return read;
361
472
  }
362
- async materialize(key, handle) {
363
- const read = await this.read(handle);
364
- if (read === null)
365
- throw new Error('House serves nothing of it to copy');
366
- const repository = join(localCopies(), 'rooms', read.anchor);
473
+ async materialized({ key, handle, authority }, whole) {
474
+ const repository = join(localCopies(), 'rooms', authority.room_ref);
367
475
  if (await exists(repository))
368
- throw new Error(`${repository} already exists and House Kit leaves it as it is`);
369
- const staging = join(dirname(repository), `.${randomUUID()}`);
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 };
370
479
  await mkdir(staging, { recursive: true });
371
- let house;
372
- let readOnly;
480
+ let copy;
373
481
  try {
374
482
  await git(staging, ['init', '-q', '-b', BRANCH]);
375
- house = await importCommits(staging, [
376
- { message: `House ${rootOf(handle)}`, from: null, files: read.writable },
377
- ]);
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);
378
486
  await git(staging, ['update-ref', `refs/heads/${BRANCH}`, house]);
379
487
  await git(staging, ['reset', '-q', '--hard']);
380
- readOnly = await readOnlyPlaced(staging, [], read.readOnly, []);
381
- 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 });
382
503
  await rename(staging, repository);
383
504
  }
384
505
  catch (error) {
385
506
  await rm(staging, { recursive: true, force: true });
386
507
  throw error;
387
508
  }
388
- await this.save({
389
- key,
390
- room_ref: read.anchor,
391
- room_handle: handle,
392
- repository,
393
- active: true,
394
- house,
395
- position: read.position,
396
- log_epoch: read.log_epoch,
397
- write: read.write,
398
- protected: read.protected,
399
- read_only: readOnly,
400
- manifests: { [house]: { parent: null, files: read.revisions } },
401
- moves: [],
402
- });
403
- return `${rootOf(handle)}: copied to ${repository}`;
509
+ return this.save(copy);
404
510
  }
405
- async received(copy) {
406
- const read = await this.read(copy.room_handle);
407
- if (read === null)
408
- return copy;
409
- const house = await importCommits(copy.repository, [
410
- { message: `House ${rootOf(copy.room_handle)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
411
- ]);
412
- const removed = copy.read_only.filter((path) => !read.readOnly.has(path));
413
- const readOnly = await readOnlyPlaced(copy.repository, copy.read_only, read.readOnly, removed);
414
- const next = {
415
- ...copy,
416
- house,
417
- position: read.position,
418
- log_epoch: read.log_epoch,
419
- write: read.write,
420
- protected: read.protected,
421
- read_only: readOnly,
422
- manifests: { ...copy.manifests, [house]: { parent: null, files: read.revisions } },
423
- };
424
- await ignoreRules(copy.repository, [...read.writable.keys()], next);
425
- 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}`;
426
541
  }
427
- async refreshed(copies) {
428
- const discovery = await this.house.post('/kit/door/discover', {
429
- prefixes: copies.map((copy) => rootOf(copy.room_handle)),
430
- });
431
- const regranted = new Map();
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 = [];
432
580
  for (const copy of copies) {
433
- const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
581
+ const authority = authorities.find((entry) => entry.room_ref === copy.room_ref);
434
582
  if (authority !== undefined &&
435
583
  JSON.stringify([authority.operations.write, authority.protected_paths]) !== JSON.stringify([copy.write, copy.protected])) {
436
- regranted.set(copy.key, await this.save(await this.received(copy)));
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);
437
595
  }
438
596
  }
439
- const caught = await this.caughtUp(copies.filter((copy) => !regranted.has(copy.key)), false);
440
- return copies.map((copy) => regranted.get(copy.key) ?? caught.find((held) => held.key === copy.key));
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);
441
601
  }
442
- async caughtUp(copies, whole) {
602
+ async caughtUp(copies, whole, unready) {
443
603
  const caught = new Map(copies.map((copy) => [copy.key, copy]));
604
+ const read = new Set();
605
+ const waiting = unready ?? [];
444
606
  let asking = [...copies];
445
607
  while (asking.length > 0) {
446
608
  let answer;
447
609
  try {
448
- answer = await this.house.post('/kit/door/enumerate', {
610
+ answer = await this.house.post(ENUMERATE, {
449
611
  prefixes: asking.map((copy) => rootOf(copy.room_handle)),
450
612
  positions: asking.map((copy) => ({ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch })),
451
613
  });
@@ -454,8 +616,22 @@ export class LocalCopies {
454
616
  const expired = asking.find((copy) => copy.room_ref === expiredAuthority(error));
455
617
  if (expired === undefined)
456
618
  throw error;
457
- caught.set(expired.key, await this.save(await this.received(expired)));
458
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
+ }
459
635
  continue;
460
636
  }
461
637
  const advanced = [];
@@ -467,8 +643,10 @@ export class LocalCopies {
467
643
  caught.set(copy.key, next);
468
644
  advanced.push(next);
469
645
  }
470
- asking = whole ? advanced : [];
646
+ asking = advanced.filter((copy) => whole || read.has(copy.key));
471
647
  }
648
+ if (unready === undefined && waiting.length > 0)
649
+ throw new NotReady(waiting);
472
650
  return copies.map((copy) => caught.get(copy.key));
473
651
  }
474
652
  async incorporated(copy, changes, position, logEpoch) {
@@ -532,9 +710,6 @@ export class LocalCopies {
532
710
  }
533
711
  copy = await this.save(without(copy));
534
712
  }
535
- if (!copy.active) {
536
- return { refused: true, text: `${rootOf(copy.room_handle)} is no longer synced to this Environment, so House takes no push from it` };
537
- }
538
713
  const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
539
714
  const base = listed.find((ancestor) => ancestor in copy.manifests);
540
715
  if (base === undefined) {
@@ -545,7 +720,7 @@ export class LocalCopies {
545
720
  changes = await this.derived(copy, base, commit);
546
721
  }
547
722
  catch (error) {
548
- return { refused: true, text: causeOf(error) };
723
+ return { refused: true, text: houseCause(error) };
549
724
  }
550
725
  if (changes.length === 0)
551
726
  return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
@@ -572,7 +747,18 @@ export class LocalCopies {
572
747
  const after = await tree(copy.repository, commit);
573
748
  const moved = movedPaths(copy.moves, before, after);
574
749
  const arrived = new Set(moved.values());
575
- 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/rooms/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);
576
762
  const changes = [];
577
763
  for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
578
764
  if (arrived.has(path))
@@ -585,7 +771,7 @@ export class LocalCopies {
585
771
  changes.push({ op: 'rename', path: `${root}/${path}`, to: `${root}/${to}`, base: known });
586
772
  const moving = after.get(to);
587
773
  if (moving !== prior) {
588
- 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) });
589
775
  }
590
776
  continue;
591
777
  }
@@ -594,9 +780,9 @@ export class LocalCopies {
594
780
  if (next === undefined)
595
781
  changes.push({ op: 'remove', path: `${root}/${path}`, base: known });
596
782
  else if (prior === undefined)
597
- changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next, path) });
783
+ changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next) });
598
784
  else
599
- 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)));
600
786
  }
601
787
  return changes;
602
788
  }
@@ -625,8 +811,10 @@ export class LocalCopies {
625
811
  }
626
812
  if (outcome.refused.code === 'expired')
627
813
  return this.recovered(copy);
628
- const rebased = rebaseFixes(outcome.refused);
629
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);
630
818
  try {
631
819
  settled = (await this.caughtUp([settled], true))[0];
632
820
  }
@@ -663,7 +851,7 @@ export class LocalCopies {
663
851
  written.push({
664
852
  path: `${root}/${path}`,
665
853
  revision: entry === undefined ? null : (revisionAt(current, current.house, path) ?? null),
666
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
854
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
667
855
  });
668
856
  }
669
857
  return this.finished(await this.save({ ...current, push: { ...push, state: 'accepted', written } }));
@@ -707,7 +895,7 @@ export class LocalCopies {
707
895
  {
708
896
  path: `${rootOf(copy.room_handle)}/${path}`,
709
897
  revision: entry === undefined ? null : (revisionAt(copy, copy.house, path) ?? null),
710
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
898
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
711
899
  },
712
900
  ];
713
901
  }
@@ -752,7 +940,7 @@ export class LocalCopies {
752
940
  async landed(repository, changes, house, selected) {
753
941
  const text = async (path) => {
754
942
  const entry = house.get(relative(path));
755
- return entry === undefined ? undefined : blobText(repository, entry, path);
943
+ return entry === undefined ? undefined : blobText(repository, entry);
756
944
  };
757
945
  for (const change of changes) {
758
946
  const current = await text(change.path);
@@ -778,7 +966,7 @@ export class LocalCopies {
778
966
  return true;
779
967
  }
780
968
  async revisionText(path, wanted) {
781
- const page = await this.house.post('/kit/door/revisions', {
969
+ const page = await this.house.post('/kit/door/files/revisions/list', {
782
970
  path,
783
971
  revision: wanted,
784
972
  });