@agentshouse/kit 0.2.0 → 0.2.1-alpha.10

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 (113) hide show
  1. package/README.md +20 -2
  2. package/dist/acp.js +120 -0
  3. package/dist/agents.js +96 -0
  4. package/dist/api.js +39 -3
  5. package/dist/authority-main.js +22 -0
  6. package/dist/blocks.js +51 -0
  7. package/dist/bootstrap.json +28 -0
  8. package/dist/bridge.js +214 -0
  9. package/dist/clis.js +114 -0
  10. package/dist/configure-main.js +6 -0
  11. package/dist/conversations.js +561 -0
  12. package/dist/copies.js +796 -0
  13. package/dist/enrol-main.js +4 -0
  14. package/dist/files.js +21 -0
  15. package/dist/git-main.js +153 -0
  16. package/dist/git.js +81 -0
  17. package/dist/home.js +19 -6
  18. package/dist/house-main.js +106 -3
  19. package/dist/instructions.js +33 -0
  20. package/dist/kit-main.js +2 -0
  21. package/dist/login.js +6 -2
  22. package/dist/operation.js +9 -0
  23. package/dist/resident.js +113 -2
  24. package/dist/secret-input.js +16 -0
  25. package/dist/sign-in.js +87 -0
  26. package/dist/skills.js +83 -0
  27. package/dist/stream.js +17 -1
  28. package/dist/translate.js +51 -0
  29. package/dist/update.js +116 -0
  30. package/how-we-work.md +25 -0
  31. package/package.json +29 -3
  32. package/skills/ask-matt/LICENSE +21 -0
  33. package/skills/ask-matt/PHASE-BOUNDARIES.md +55 -0
  34. package/skills/ask-matt/SKILL.md +91 -0
  35. package/skills/ask-matt/agents/openai.yaml +4 -0
  36. package/skills/code-review/LICENSE +21 -0
  37. package/skills/code-review/SKILL.md +89 -0
  38. package/skills/code-review/agents/openai.yaml +3 -0
  39. package/skills/codebase-design/DEEPENING.md +37 -0
  40. package/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  41. package/skills/codebase-design/LICENSE +21 -0
  42. package/skills/codebase-design/SKILL.md +116 -0
  43. package/skills/codebase-design/agents/openai.yaml +3 -0
  44. package/skills/diagnosing-bugs/LICENSE +21 -0
  45. package/skills/diagnosing-bugs/SKILL.md +140 -0
  46. package/skills/diagnosing-bugs/agents/openai.yaml +3 -0
  47. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  48. package/skills/domain-modeling/ADR-FORMAT.md +47 -0
  49. package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  50. package/skills/domain-modeling/LICENSE +21 -0
  51. package/skills/domain-modeling/SKILL.md +76 -0
  52. package/skills/domain-modeling/agents/openai.yaml +3 -0
  53. package/skills/grill-me/LICENSE +21 -0
  54. package/skills/grill-me/SKILL.md +8 -0
  55. package/skills/grill-me/agents/openai.yaml +4 -0
  56. package/skills/grill-with-docs/LICENSE +21 -0
  57. package/skills/grill-with-docs/SKILL.md +8 -0
  58. package/skills/grill-with-docs/agents/openai.yaml +4 -0
  59. package/skills/grilling/LICENSE +21 -0
  60. package/skills/grilling/SKILL.md +30 -0
  61. package/skills/grilling/agents/openai.yaml +3 -0
  62. package/skills/handoff/LICENSE +21 -0
  63. package/skills/handoff/SKILL.md +17 -0
  64. package/skills/handoff/agents/openai.yaml +4 -0
  65. package/skills/implement/LICENSE +21 -0
  66. package/skills/implement/SKILL.md +16 -0
  67. package/skills/implement/agents/openai.yaml +4 -0
  68. package/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
  69. package/skills/improve-codebase-architecture/LICENSE +21 -0
  70. package/skills/improve-codebase-architecture/SKILL.md +72 -0
  71. package/skills/improve-codebase-architecture/agents/openai.yaml +4 -0
  72. package/skills/prototype/LICENSE +21 -0
  73. package/skills/prototype/LOGIC.md +67 -0
  74. package/skills/prototype/SKILL.md +28 -0
  75. package/skills/prototype/UI.md +112 -0
  76. package/skills/prototype/agents/openai.yaml +3 -0
  77. package/skills/research/LICENSE +21 -0
  78. package/skills/research/SKILL.md +14 -0
  79. package/skills/research/agents/openai.yaml +3 -0
  80. package/skills/resolving-merge-conflicts/LICENSE +21 -0
  81. package/skills/resolving-merge-conflicts/SKILL.md +16 -0
  82. package/skills/resolving-merge-conflicts/agents/openai.yaml +3 -0
  83. package/skills/tdd/LICENSE +21 -0
  84. package/skills/tdd/SKILL.md +40 -0
  85. package/skills/tdd/agents/openai.yaml +3 -0
  86. package/skills/tdd/mocking.md +59 -0
  87. package/skills/tdd/tests.md +77 -0
  88. package/skills/teach/GLOSSARY-FORMAT.md +35 -0
  89. package/skills/teach/LEARNING-RECORD-FORMAT.md +46 -0
  90. package/skills/teach/LICENSE +21 -0
  91. package/skills/teach/MISSION-FORMAT.md +31 -0
  92. package/skills/teach/RESOURCES-FORMAT.md +32 -0
  93. package/skills/teach/SKILL.md +141 -0
  94. package/skills/teach/agents/openai.yaml +4 -0
  95. package/skills/to-questionnaire/LICENSE +21 -0
  96. package/skills/to-questionnaire/SKILL.md +55 -0
  97. package/skills/to-questionnaire/agents/openai.yaml +4 -0
  98. package/skills/to-spec/LICENSE +21 -0
  99. package/skills/to-spec/SKILL.md +76 -0
  100. package/skills/to-spec/agents/openai.yaml +4 -0
  101. package/skills/to-tickets/LICENSE +21 -0
  102. package/skills/to-tickets/SKILL.md +106 -0
  103. package/skills/to-tickets/agents/openai.yaml +4 -0
  104. package/skills/wait-what/LICENSE +21 -0
  105. package/skills/wait-what/SKILL.md +8 -0
  106. package/skills/wait-what/agents/openai.yaml +4 -0
  107. package/skills/wayfinder/LICENSE +21 -0
  108. package/skills/wayfinder/SKILL.md +129 -0
  109. package/skills/wayfinder/agents/openai.yaml +4 -0
  110. package/skills/writing-for-agents/LICENSE +21 -0
  111. package/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
  112. package/skills/writing-for-agents/SKILL.md +83 -0
  113. package/skills/writing-for-agents/agents/openai.yaml +3 -0
package/dist/copies.js ADDED
@@ -0,0 +1,796 @@
1
+ import { parse } from '@agentshouse/mdmodel';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { HouseRefusal } from './api.js';
6
+ import { blobId, blobText, git, HOUSE_REF, importCommits, revision, run, succeeded, tree, WORKING_COPIES, } from './git.js';
7
+ import { kitHome } from './home.js';
8
+ import { operationId } from './operation.js';
9
+ import { changed } from './translate.js';
10
+ const BRANCH = 'main';
11
+ const ROOM_HOMES = ['capture', 'library', 'collaboration'];
12
+ const PROVENANCE = '_provenance';
13
+ const REPLICA_RETRY_MS = 30_000;
14
+ const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
15
+ function causeOf(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ function logged(error) {
19
+ process.stderr.write(`kit: ${causeOf(error)}\n`);
20
+ }
21
+ export function refusalCode(text) {
22
+ try {
23
+ return String(JSON.parse(text).error.code);
24
+ }
25
+ catch {
26
+ return text.split(':')[0].trim();
27
+ }
28
+ }
29
+ function short(commit) {
30
+ return commit.slice(0, 12);
31
+ }
32
+ function covers(path, prefix) {
33
+ return prefix === '' || path === prefix || path.startsWith(`${prefix}/`);
34
+ }
35
+ function sidecar(path) {
36
+ const segments = path.split('/');
37
+ segments.splice(-1, 0, PROVENANCE);
38
+ return segments.join('/');
39
+ }
40
+ function rootOf(copy) {
41
+ return copy.private ? '/private' : `/rooms/${copy.room_handle}`;
42
+ }
43
+ function relative(path) {
44
+ return path.replace(/^\/private\//, '').replace(/^\/rooms\/[^/]+\//, '');
45
+ }
46
+ function prefixesOf(selected) {
47
+ return selected.private ? ['/private'] : ROOM_HOMES.map((home) => `${rootOf(selected)}/${home}`);
48
+ }
49
+ function declaredWritable(path, copy) {
50
+ const deepest = (scopes) => Math.max(-1, ...scopes.filter((scope) => covers(path, scope)).map((scope) => scope.length));
51
+ return deepest(copy.write) > deepest(copy.protected);
52
+ }
53
+ function pattern(path) {
54
+ return `/${path.replace(/[\\*?[]/g, '\\$&').replace(/ +$/, (spaces) => spaces.replace(/ /g, '\\ '))}`;
55
+ }
56
+ async function exists(path) {
57
+ return stat(path).then(() => true, () => false);
58
+ }
59
+ async function writeText(path, content) {
60
+ await mkdir(dirname(path), { recursive: true });
61
+ await writeFile(path, content);
62
+ }
63
+ async function removeFile(repository, path) {
64
+ await rm(join(repository, path), { force: true });
65
+ for (let folder = dirname(path); folder !== '.'; folder = dirname(folder)) {
66
+ if (!(await rmdir(join(repository, folder)).then(() => true, () => false)))
67
+ return;
68
+ }
69
+ }
70
+ async function readOnlyPlaced(repository, held, written, removed) {
71
+ const tracked = new Set((await git(repository, ['ls-files', '-z'])).split('\0'));
72
+ 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();
89
+ }
90
+ async function ruled(path, rules) {
91
+ if ((await readFile(path, 'utf8').catch(() => null)) !== rules)
92
+ await writeFile(path, rules);
93
+ }
94
+ 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));
96
+ const protectedPath = (path) => copy.protected.some((prefix) => prefix !== '' && covers(path, prefix));
97
+ const whole = copy.protected.filter((prefix) => prefix !== '' && !writable.some((path) => covers(path, prefix)));
98
+ const house = [
99
+ ...whole,
100
+ ...readOnly.filter((path) => protectedPath(path) && !whole.some((prefix) => covers(path, prefix))),
101
+ ].map(pattern);
102
+ const granted = readOnly.filter((path) => !protectedPath(path)).map(pattern);
103
+ await ruled(join(repository, '.gitignore'), [
104
+ '# House Kit writes this file and .ignore. The paths below are read-only House',
105
+ '# content: Git does not track them, and House refuses a change to them even',
106
+ '# when this file is edited or a path is force-added.',
107
+ '/.gitignore',
108
+ '/.ignore',
109
+ PROVENANCE,
110
+ '',
111
+ '# House maintains or protects these paths.',
112
+ ...house,
113
+ '',
114
+ '# Your current House grant does not allow writing these paths.',
115
+ ...granted,
116
+ '',
117
+ ].join('\n'));
118
+ await ruled(join(repository, '.ignore'), [
119
+ '# House Kit writes this file so native search still reads the read-only House',
120
+ '# content that .gitignore keeps out of Git.',
121
+ `!${PROVENANCE}`,
122
+ ...[...house, ...granted].map((rule) => `!${rule}`),
123
+ '',
124
+ ].join('\n'));
125
+ }
126
+ function revisionAt(copy, commit, path) {
127
+ let manifest = copy.manifests[commit];
128
+ while (manifest !== undefined) {
129
+ if (path in manifest.files)
130
+ return manifest.files[path] ?? undefined;
131
+ manifest = manifest.parent === null ? undefined : copy.manifests[manifest.parent];
132
+ }
133
+ return undefined;
134
+ }
135
+ function without(copy) {
136
+ const { push, ...settled } = copy;
137
+ return settled;
138
+ }
139
+ export function changedPaths(changes) {
140
+ return [
141
+ ...new Set(changes.flatMap((change) => (change.op === 'rename' ? [change.path, change.to] : [change.path]))),
142
+ ].sort();
143
+ }
144
+ function movedPaths(moves, before, after) {
145
+ const pairs = moves.filter(({ from, to }) => before.has(from) && !after.has(from) && after.has(to) && !before.has(to));
146
+ return new Map(pairs
147
+ .filter((pair) => pairs.filter((other) => other.from === pair.from || other.to === pair.to).length === 1)
148
+ .map(({ from, to }) => [from, to]));
149
+ }
150
+ export class WorkingCopies {
151
+ owner;
152
+ house;
153
+ copies = new Map();
154
+ queues = new Map();
155
+ delivering = new Set();
156
+ selecting = Promise.resolve();
157
+ constructor(house) {
158
+ this.house = house;
159
+ this.owner = {
160
+ key: 'owner',
161
+ submit: async (operation, changes) => {
162
+ try {
163
+ const outcome = await house.post('/kit/door/batch', { operation_id: operation, changes });
164
+ return { accepted: outcome.contents };
165
+ }
166
+ catch (error) {
167
+ if (!(error instanceof HouseRefusal) || error.status >= 500)
168
+ throw error;
169
+ return { refused: { code: refusalCode(error.text), text: error.message } };
170
+ }
171
+ },
172
+ };
173
+ }
174
+ async load() {
175
+ const home = join(kitHome(), 'working-copies');
176
+ const names = (await readdir(join(home, 'rooms')).catch(() => [])).filter((name) => name.endsWith('.json'));
177
+ for (const path of [...names.map((name) => join(home, 'rooms', name)), join(home, 'private.json')]) {
178
+ const text = await readFile(path, 'utf8').catch(() => null);
179
+ if (text === null)
180
+ continue;
181
+ const copy = JSON.parse(text);
182
+ this.copies.set(copy.key, copy);
183
+ }
184
+ }
185
+ async mapping() {
186
+ await this.selecting;
187
+ return [...this.copies.values()]
188
+ .filter((copy) => copy.active)
189
+ .map((copy) => `${rootOf(copy)}: ${copy.repository}`)
190
+ .sort();
191
+ }
192
+ select() {
193
+ const selected = this.selecting.then(() => this.reselect());
194
+ this.selecting = selected.catch(() => undefined);
195
+ return selected;
196
+ }
197
+ async reselect() {
198
+ const { working_copy: chosen } = await this.house.post('/kit/working-copy/selection', {});
199
+ const selected = [
200
+ ...(chosen?.rooms ?? [])
201
+ .filter((room) => room.room_handle !== null)
202
+ .map((room) => ({ key: `rooms/${room.room_ref}`, private: false, handle: room.room_handle })),
203
+ ...(chosen?.private === true ? [{ key: 'private', private: true, handle: null }] : []),
204
+ ];
205
+ const keys = new Set(selected.map((scope) => scope.key));
206
+ const work = [];
207
+ for (const key of this.copies.keys()) {
208
+ if (keys.has(key))
209
+ continue;
210
+ work.push(this.exclusive(key, async () => {
211
+ const copy = this.copies.get(key);
212
+ if (copy.active)
213
+ await this.save({ ...copy, active: false });
214
+ }));
215
+ }
216
+ for (const scope of selected)
217
+ work.push(this.exclusive(scope.key, () => this.apply(scope)).catch(logged));
218
+ await Promise.all(work);
219
+ }
220
+ entries(frame) {
221
+ const copy = [...this.copies.values()].find((held) => held.active && held.room_ref === frame.authority);
222
+ if (copy === undefined)
223
+ return;
224
+ if (copy.log_epoch === frame.log_epoch && BigInt(frame.position) <= BigInt(copy.position))
225
+ return;
226
+ this.deliver(copy.key);
227
+ }
228
+ async observed(report) {
229
+ const key = await this.keyAt(dirname(report.common));
230
+ if (key === undefined)
231
+ return;
232
+ await this.exclusive(key, async () => {
233
+ const copy = this.copies.get(key);
234
+ let moves = copy.moves;
235
+ for (const { from, to } of report.moves) {
236
+ const held = moves.find((move) => move.worktree === report.worktree && move.to === from);
237
+ moves = moves.filter((move) => move !== held);
238
+ if (held === undefined)
239
+ moves.push({ worktree: report.worktree, from, to });
240
+ else if (held.from !== to)
241
+ moves.push({ ...held, to });
242
+ }
243
+ if (moves.some((move) => move.worktree === report.worktree)) {
244
+ const index = new Set((await git(report.worktree, ['ls-files', '-z'])).split('\0'));
245
+ const head = await tree(report.worktree, 'HEAD').catch(() => new Map());
246
+ const live = ({ from, to }) => (index.has(to) && !index.has(from)) || (head.has(to) && !head.has(from));
247
+ moves = moves.filter((move) => move.worktree !== report.worktree || live(move));
248
+ }
249
+ if (JSON.stringify(moves) !== JSON.stringify(copy.moves))
250
+ await this.save({ ...copy, moves });
251
+ });
252
+ }
253
+ async push(cwd, selector, caller) {
254
+ const located = await run(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel']);
255
+ if (located.status !== 0)
256
+ return { refused: true, text: `${cwd} is not in a Git repository` };
257
+ const [common, worktree] = located.stdout.toString('utf8').split('\n');
258
+ const key = await this.keyAt(dirname(common));
259
+ if (key === undefined)
260
+ return { refused: true, text: `${worktree} is not a House working copy` };
261
+ return this.exclusive(key, () => this.pushed(key, worktree, selector, caller));
262
+ }
263
+ async keyAt(repository) {
264
+ const real = await realpath(repository).catch(() => repository);
265
+ for (const copy of this.copies.values()) {
266
+ if ((await realpath(copy.repository).catch(() => copy.repository)) === real)
267
+ return copy.key;
268
+ }
269
+ return undefined;
270
+ }
271
+ exclusive(key, work) {
272
+ const queued = (this.queues.get(key) ?? Promise.resolve()).then(work);
273
+ this.queues.set(key, queued.catch(() => undefined));
274
+ return queued;
275
+ }
276
+ deliver(key) {
277
+ if (this.delivering.has(key))
278
+ return;
279
+ this.delivering.add(key);
280
+ void this.exclusive(key, async () => {
281
+ this.delivering.delete(key);
282
+ const copy = this.copies.get(key);
283
+ if (copy.active)
284
+ await this.save(await this.caughtUp(copy));
285
+ }).catch(logged);
286
+ }
287
+ async save(copy) {
288
+ const path = join(kitHome(), 'working-copies', copy.private ? 'private.json' : `${copy.key}.json`);
289
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
290
+ const temporary = `${path}.${process.pid}`;
291
+ await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
292
+ await rename(temporary, path);
293
+ this.copies.set(copy.key, copy);
294
+ return copy;
295
+ }
296
+ async read(selected) {
297
+ const prefixes = prefixesOf(selected);
298
+ const discovery = await this.house.post('/kit/door/discover', { prefixes });
299
+ let bundle = await this.house.post('/kit/door/bootstrap', { prefixes });
300
+ if ('transfer' in bundle)
301
+ bundle = (await (await fetch(bundle.transfer.url)).json());
302
+ const [view] = bundle.scopes;
303
+ if (view === undefined)
304
+ return null;
305
+ const authority = discovery.authorities.find((entry) => entry.room_ref === view.manifest.anchor);
306
+ const contents = new Map(view.contents.map((entry) => [entry.path, entry.content]));
307
+ const read = {
308
+ anchor: view.manifest.anchor,
309
+ position: view.manifest.position,
310
+ log_epoch: view.manifest.log_epoch,
311
+ write: authority.operations.write,
312
+ protected: authority.protected_paths,
313
+ writable: new Map(),
314
+ revisions: {},
315
+ readOnly: new Map(),
316
+ };
317
+ for (const file of view.manifest.files) {
318
+ const content = contents.get(file.path);
319
+ if (file.writable) {
320
+ read.writable.set(file.path, content);
321
+ read.revisions[file.path] = file.revision;
322
+ }
323
+ else {
324
+ read.readOnly.set(file.path, content);
325
+ }
326
+ if (file.provenance !== undefined)
327
+ read.readOnly.set(sidecar(file.path), file.provenance);
328
+ }
329
+ return read;
330
+ }
331
+ async apply(scope) {
332
+ let copy = this.copies.get(scope.key);
333
+ if (copy === undefined || !(await exists(copy.repository))) {
334
+ await this.materialize(scope);
335
+ return;
336
+ }
337
+ if (copy.room_handle !== scope.handle)
338
+ copy = await this.save({ ...copy, room_handle: scope.handle });
339
+ if (!copy.active) {
340
+ await this.save(await this.received({ ...copy, active: true }));
341
+ return;
342
+ }
343
+ const discovery = await this.house.post('/kit/door/discover', {
344
+ prefixes: prefixesOf(copy),
345
+ });
346
+ const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
347
+ if (authority === undefined)
348
+ return;
349
+ if (authority.managed_replication !== 'admitted') {
350
+ logged(`operation_denied: ${rootOf(copy)} is not synchronized to this Environment`);
351
+ return;
352
+ }
353
+ if (JSON.stringify([authority.operations.write, authority.protected_paths]) !==
354
+ JSON.stringify([copy.write, copy.protected])) {
355
+ await this.save(await this.received(copy));
356
+ return;
357
+ }
358
+ await this.save(await this.caughtUp(copy));
359
+ }
360
+ async materialize(scope) {
361
+ const read = await this.read({ private: scope.private, room_handle: scope.handle });
362
+ if (read === null) {
363
+ logged(`House serves nothing of ${rootOf({ private: scope.private, room_handle: scope.handle })} to copy`);
364
+ return;
365
+ }
366
+ const repository = scope.private ? join(WORKING_COPIES, 'private') : join(WORKING_COPIES, 'rooms', read.anchor);
367
+ if (await exists(repository)) {
368
+ logged(`${repository} already exists and House Kit leaves it as it is`);
369
+ return;
370
+ }
371
+ const staging = join(dirname(repository), `.${randomUUID()}`);
372
+ await mkdir(staging, { recursive: true });
373
+ let house;
374
+ let readOnly;
375
+ try {
376
+ await git(staging, ['init', '-q', '-b', BRANCH]);
377
+ house = await importCommits(staging, [
378
+ { message: `House ${rootOf({ private: scope.private, room_handle: scope.handle })}`, from: null, files: read.writable },
379
+ ]);
380
+ await git(staging, ['update-ref', `refs/heads/${BRANCH}`, house]);
381
+ await git(staging, ['reset', '-q', '--hard']);
382
+ readOnly = await readOnlyPlaced(staging, [], read.readOnly, []);
383
+ await ignoreRules(staging, [...read.writable.keys()], read);
384
+ await rename(staging, repository);
385
+ }
386
+ catch (error) {
387
+ await rm(staging, { recursive: true, force: true });
388
+ throw error;
389
+ }
390
+ await this.save({
391
+ key: scope.key,
392
+ private: scope.private,
393
+ room_ref: read.anchor,
394
+ room_handle: scope.handle,
395
+ repository,
396
+ active: true,
397
+ house,
398
+ position: read.position,
399
+ log_epoch: read.log_epoch,
400
+ write: read.write,
401
+ protected: read.protected,
402
+ read_only: readOnly,
403
+ manifests: { [house]: { parent: null, files: read.revisions } },
404
+ moves: [],
405
+ });
406
+ }
407
+ async received(copy) {
408
+ const read = await this.read(copy);
409
+ if (read === null)
410
+ return copy;
411
+ const house = await importCommits(copy.repository, [
412
+ { message: `House ${rootOf(copy)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
413
+ ]);
414
+ const removed = copy.read_only.filter((path) => !read.readOnly.has(path));
415
+ const readOnly = await readOnlyPlaced(copy.repository, copy.read_only, read.readOnly, removed);
416
+ const next = {
417
+ ...copy,
418
+ house,
419
+ position: read.position,
420
+ log_epoch: read.log_epoch,
421
+ write: read.write,
422
+ protected: read.protected,
423
+ read_only: readOnly,
424
+ manifests: { ...copy.manifests, [house]: { parent: null, files: read.revisions } },
425
+ };
426
+ await ignoreRules(copy.repository, [...read.writable.keys()], next);
427
+ return next;
428
+ }
429
+ async caughtUp(start) {
430
+ let copy = start;
431
+ for (;;) {
432
+ let answer;
433
+ try {
434
+ answer = await this.house.post('/kit/door/enumerate', {
435
+ prefixes: prefixesOf(copy),
436
+ positions: [{ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch }],
437
+ });
438
+ }
439
+ catch (error) {
440
+ const code = error instanceof HouseRefusal ? refusalCode(error.text) : null;
441
+ if (code === 'position_expired')
442
+ return this.received(copy);
443
+ if (code !== 'replica_behind')
444
+ throw error;
445
+ setTimeout(() => this.deliver(copy.key), REPLICA_RETRY_MS).unref();
446
+ return copy;
447
+ }
448
+ const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
449
+ if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
450
+ return copy;
451
+ copy = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
452
+ }
453
+ }
454
+ async incorporated(copy, changes, position, logEpoch) {
455
+ const houseTree = await tree(copy.repository, copy.house);
456
+ const held = new Set(copy.read_only);
457
+ const files = new Map();
458
+ const revisions = {};
459
+ const written = new Map();
460
+ const removed = [];
461
+ for (const change of changes) {
462
+ const gone = 'removed' in change;
463
+ if (houseTree.has(change.path) || (!gone && !held.has(change.path) && declaredWritable(change.path, copy))) {
464
+ const unchanged = gone
465
+ ? !houseTree.has(change.path)
466
+ : houseTree.get(change.path) === blobId(change.content) &&
467
+ revisionAt(copy, copy.house, change.path) === change.revision;
468
+ if (unchanged)
469
+ continue;
470
+ files.set(change.path, gone ? null : change.content);
471
+ revisions[change.path] = gone ? null : change.revision;
472
+ }
473
+ else if (gone) {
474
+ removed.push(change.path, sidecar(change.path));
475
+ }
476
+ else {
477
+ written.set(change.path, change.content);
478
+ if (change.provenance !== undefined)
479
+ written.set(sidecar(change.path), change.provenance);
480
+ }
481
+ }
482
+ let { house, manifests } = copy;
483
+ if (files.size > 0) {
484
+ house = await importCommits(copy.repository, [
485
+ { message: `House ${rootOf(copy)} at position ${position}`, from: copy.house, files },
486
+ ]);
487
+ manifests = { ...manifests, [house]: { parent: copy.house, files: revisions } };
488
+ }
489
+ const next = {
490
+ ...copy,
491
+ house,
492
+ manifests,
493
+ position,
494
+ log_epoch: logEpoch,
495
+ read_only: await readOnlyPlaced(copy.repository, copy.read_only, written, removed),
496
+ };
497
+ await ignoreRules(copy.repository, [...(await tree(copy.repository, house)).keys()], next);
498
+ return next;
499
+ }
500
+ async pushed(key, worktree, selector, caller) {
501
+ let copy = this.copies.get(key);
502
+ if (copy.push?.state === 'submitted')
503
+ return this.settle(copy, caller);
504
+ const selected = await run(worktree, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${selector}^{commit}`]);
505
+ if (selected.status !== 0)
506
+ return { refused: true, text: `${selector} names no commit` };
507
+ const commit = selected.stdout.toString('utf8').trim();
508
+ if (copy.push !== undefined) {
509
+ const held = copy.push.acknowledged;
510
+ if (held === undefined || !(await succeeded(worktree, ['merge-base', '--is-ancestor', held, commit]))) {
511
+ return this.finished(copy);
512
+ }
513
+ copy = await this.save(without(copy));
514
+ }
515
+ if (!copy.active)
516
+ return { refused: true, text: `${rootOf(copy)} is no longer kept in this Environment` };
517
+ const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
518
+ const base = listed.find((ancestor) => ancestor in copy.manifests);
519
+ if (base === undefined) {
520
+ return { refused: true, text: `${short(commit)} holds no House state; merge or rebase onto ${HOUSE_REF}` };
521
+ }
522
+ let changes;
523
+ try {
524
+ changes = await this.derived(copy, base, commit);
525
+ }
526
+ catch (error) {
527
+ return { refused: true, text: causeOf(error) };
528
+ }
529
+ if (changes.length === 0)
530
+ return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
531
+ const branch = await git(worktree, ['symbolic-ref', '-q', 'HEAD']).then((name) => name.trim(), () => null);
532
+ const submitted = await this.save({
533
+ ...copy,
534
+ push: {
535
+ state: 'submitted',
536
+ caller: caller.key,
537
+ operation_id: operationId(),
538
+ commit,
539
+ base,
540
+ changes,
541
+ worktree,
542
+ branch,
543
+ house: copy.house,
544
+ },
545
+ });
546
+ return this.settle(submitted, caller);
547
+ }
548
+ async derived(copy, base, commit) {
549
+ const root = rootOf(copy);
550
+ const before = await tree(copy.repository, base);
551
+ const after = await tree(copy.repository, commit);
552
+ const moved = movedPaths(copy.moves, before, after);
553
+ const arrived = new Set(moved.values());
554
+ const text = (entry, path) => blobText(copy.repository, entry, path);
555
+ const changes = [];
556
+ for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
557
+ if (arrived.has(path))
558
+ continue;
559
+ const prior = before.get(path);
560
+ const next = after.get(path);
561
+ const known = revisionAt(copy, base, path);
562
+ const to = moved.get(path);
563
+ if (to !== undefined) {
564
+ changes.push({ op: 'rename', path: `${root}/${path}`, to: `${root}/${to}`, base: known });
565
+ const moving = after.get(to);
566
+ if (moving !== prior) {
567
+ changes.push({ op: 'replace', path: `${root}/${to}`, base: known, content: await text(moving, to) });
568
+ }
569
+ continue;
570
+ }
571
+ if (prior === next)
572
+ continue;
573
+ if (next === undefined)
574
+ changes.push({ op: 'remove', path: `${root}/${path}`, base: known });
575
+ else if (prior === undefined)
576
+ changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next, path) });
577
+ else
578
+ changes.push(...changed(`${root}/${path}`, path, known, await text(prior, path), await text(next, path)));
579
+ }
580
+ return changes;
581
+ }
582
+ async settle(start, caller, restaged = false) {
583
+ let copy = start;
584
+ const push = copy.push;
585
+ if (push.caller !== caller.key) {
586
+ return this.recovered(copy, `${short(push.commit)} was pushed by another caller and awaited its outcome`);
587
+ }
588
+ let outcome;
589
+ try {
590
+ outcome = await caller.submit(push.operation_id, push.changes, push.upload, async (upload) => {
591
+ copy = await this.save({ ...copy, push: { ...push, upload } });
592
+ });
593
+ }
594
+ catch (error) {
595
+ return {
596
+ refused: true,
597
+ text: `House's answer to ${short(push.commit)} did not arrive (${causeOf(error)}); run house git push again to recover its outcome.`,
598
+ };
599
+ }
600
+ if ('refused' in outcome) {
601
+ if (outcome.refused.code === 'upload_unavailable' && copy.push.upload !== undefined && !restaged) {
602
+ const { upload, ...unstaged } = copy.push;
603
+ return this.settle(await this.save({ ...copy, push: unstaged }), caller, true);
604
+ }
605
+ if (outcome.refused.code === 'operation_expired')
606
+ return this.recovered(copy, outcome.refused.text);
607
+ let settled = await this.save(without(copy));
608
+ try {
609
+ settled = await this.save(await this.caughtUp(settled));
610
+ }
611
+ catch (error) {
612
+ return { refused: true, text: `${outcome.refused.text}\n${HOUSE_REF} is unchanged: ${causeOf(error)}` };
613
+ }
614
+ return {
615
+ refused: true,
616
+ text: `${outcome.refused.text}\n${HOUSE_REF} holds House's current state; merge or rebase onto it, commit and push again.`,
617
+ };
618
+ }
619
+ return this.finished(await this.save({ ...copy, push: { ...copy.push, state: 'accepted', written: outcome.accepted } }));
620
+ }
621
+ async recovered(copy, reason) {
622
+ const push = copy.push;
623
+ const current = await this.save(await this.caughtUp(copy));
624
+ const house = await tree(copy.repository, current.house);
625
+ const root = rootOf(current);
626
+ if (!(await this.landed(copy.repository, push.changes, house, await tree(copy.repository, push.commit)))) {
627
+ await this.save(without(current));
628
+ return {
629
+ refused: true,
630
+ text: `${reason}; House does not hold ${short(push.commit)}; run house git push again to submit it as a new operation.`,
631
+ };
632
+ }
633
+ const written = [];
634
+ for (const path of changedPaths(push.changes).map(relative)) {
635
+ const entry = house.get(path);
636
+ written.push({
637
+ path: `${root}/${path}`,
638
+ revision: entry === undefined ? null : (revisionAt(current, current.house, path) ?? null),
639
+ content: entry === undefined ? null : await blobText(copy.repository, entry, path),
640
+ });
641
+ }
642
+ return this.finished(await this.save({ ...current, push: { ...push, state: 'accepted', written } }));
643
+ }
644
+ async finished(start) {
645
+ let copy = start;
646
+ const { commit } = copy.push;
647
+ try {
648
+ if (copy.push.acknowledged === undefined)
649
+ copy = await this.save(await this.acknowledged(copy));
650
+ const integrated = await this.integrated(copy.push);
651
+ if (integrated.done)
652
+ await this.save(without(copy));
653
+ return { refused: false, text: integrated.text };
654
+ }
655
+ catch (error) {
656
+ return {
657
+ refused: false,
658
+ text: `House accepted ${short(commit)}; local integration is pending (${causeOf(error)}); run house git push again to finish it.`,
659
+ };
660
+ }
661
+ }
662
+ async acknowledged(start) {
663
+ let copy = start;
664
+ const push = copy.push;
665
+ let written = push.written;
666
+ if (push.changes.some((change) => change.op === 'rename')) {
667
+ copy = await this.save(await this.caughtUp(copy));
668
+ const house = await tree(copy.repository, copy.house);
669
+ const sent = await tree(copy.repository, push.house);
670
+ if (push.changes.some((change) => change.op === 'rename' && !house.has(relative(change.to)))) {
671
+ throw new Error(`House's result for ${short(push.commit)} is not readable yet`);
672
+ }
673
+ const known = new Set(written.map((entry) => relative(entry.path)));
674
+ for (const path of new Set([...sent.keys(), ...house.keys()])) {
675
+ const entry = house.get(path);
676
+ if (known.has(path) || sent.get(path) === entry)
677
+ continue;
678
+ written = [
679
+ ...written,
680
+ {
681
+ path: `${rootOf(copy)}/${path}`,
682
+ revision: entry === undefined ? null : (revisionAt(copy, copy.house, path) ?? null),
683
+ content: entry === undefined ? null : await blobText(copy.repository, entry, path),
684
+ },
685
+ ];
686
+ }
687
+ }
688
+ const selected = await tree(copy.repository, push.commit);
689
+ const houseTree = await tree(copy.repository, copy.house);
690
+ const files = new Map();
691
+ const revisions = {};
692
+ for (const entry of written) {
693
+ const path = relative(entry.path);
694
+ if (!selected.has(path) && !houseTree.has(path) && !declaredWritable(path, copy))
695
+ continue;
696
+ files.set(path, entry.revision === null ? null : (entry.content ?? (await this.revisionText(entry.path, entry.revision))));
697
+ revisions[path] = entry.revision;
698
+ }
699
+ const edits = new Map([...files].filter(([path, content]) => (content === null ? selected.has(path) : selected.get(path) !== blobId(content))));
700
+ const received = [...files.keys()].filter((path) => revisionAt(copy, copy.house, path) === revisionAt(copy, push.house, path));
701
+ const accepted = `House ${rootOf(copy)} with ${push.commit} accepted`;
702
+ const house = await importCommits(copy.repository, [
703
+ ...(edits.size === 0 ? [] : [{ message: accepted, from: push.commit, files: edits }]),
704
+ {
705
+ message: accepted,
706
+ from: copy.house,
707
+ merge: edits.size === 0 ? push.commit : ':1',
708
+ files: new Map(received.map((path) => [path, files.get(path)])),
709
+ },
710
+ ]);
711
+ const acknowledged = edits.size === 0 ? push.commit : await revision(copy.repository, `${house}^2`);
712
+ const after = await tree(copy.repository, house);
713
+ return {
714
+ ...copy,
715
+ house,
716
+ manifests: {
717
+ ...copy.manifests,
718
+ [acknowledged]: { parent: push.base, files: revisions },
719
+ [house]: { parent: copy.house, files: Object.fromEntries(received.map((path) => [path, revisions[path] ?? null])) },
720
+ },
721
+ moves: copy.moves.filter(({ from, to }) => !(after.has(to) && !after.has(from))),
722
+ push: { ...push, acknowledged },
723
+ };
724
+ }
725
+ async landed(repository, changes, house, selected) {
726
+ const text = async (path) => {
727
+ const entry = house.get(relative(path));
728
+ return entry === undefined ? undefined : blobText(repository, entry, path);
729
+ };
730
+ for (const change of changes) {
731
+ const current = await text(change.path);
732
+ if (change.op === 'remove' && current === undefined)
733
+ continue;
734
+ if (change.op === 'rename' && current === undefined && house.get(relative(change.to)) === selected.get(relative(change.to))) {
735
+ continue;
736
+ }
737
+ if ((change.op === 'create' || change.op === 'replace') && current === change.content)
738
+ continue;
739
+ const parsed = current === undefined ? null : parse(current, change.path);
740
+ const document = parsed?.ok === true ? parsed.document : null;
741
+ if (document === null)
742
+ return false;
743
+ if (change.op === 'set_field' && JSON.stringify(document.fields[change.field]) === JSON.stringify(change.value))
744
+ continue;
745
+ if (change.op === 'replace_section' && document.sections[change.section] === change.content)
746
+ continue;
747
+ if (change.op === 'replace_preamble' && document.preamble === change.content)
748
+ continue;
749
+ return false;
750
+ }
751
+ return true;
752
+ }
753
+ async revisionText(path, wanted) {
754
+ const page = await this.house.post('/kit/door/revisions', {
755
+ path,
756
+ revision: wanted,
757
+ });
758
+ const content = page.revisions.find((entry) => entry.revision === wanted)?.content;
759
+ if (content === undefined)
760
+ throw new Error(`House's result for ${path} is not readable yet`);
761
+ return content;
762
+ }
763
+ async integrated(push) {
764
+ const { commit, worktree, branch } = push;
765
+ const acknowledged = push.acknowledged;
766
+ const accepted = `House accepted ${short(commit)}`;
767
+ if (acknowledged === commit)
768
+ return { done: true, text: `${accepted}.` };
769
+ const pending = (reason) => ({
770
+ done: false,
771
+ text: `${accepted}; local integration is pending (${reason}): ${acknowledged} holds House's result.`,
772
+ });
773
+ const head = await git(worktree, ['symbolic-ref', '-q', 'HEAD']).then((name) => name.trim(), () => null);
774
+ if (branch === null || head !== branch)
775
+ return pending('the branch changed');
776
+ const tip = await revision(worktree, 'HEAD');
777
+ const name = branch.replace(/^refs\/heads\//, '');
778
+ if (await succeeded(worktree, ['merge-base', '--is-ancestor', acknowledged, tip])) {
779
+ return { done: true, text: `${accepted}; ${name} holds it.` };
780
+ }
781
+ if (!(await succeeded(worktree, ['merge-base', '--is-ancestor', commit, tip])))
782
+ return pending('the branch changed');
783
+ if ((await git(worktree, ['status', '--porcelain', '--untracked-files=no'])).trim() !== '') {
784
+ return pending('uncommitted changes');
785
+ }
786
+ if (tip === commit) {
787
+ if (!(await succeeded(worktree, ['merge', '--ff-only', '-q', acknowledged])))
788
+ return pending('a conflict');
789
+ }
790
+ else if (!(await succeeded(worktree, ['rebase', '-q', '--onto', acknowledged, commit], IDENTITY))) {
791
+ await succeeded(worktree, ['rebase', '--abort']);
792
+ return pending('a conflict');
793
+ }
794
+ return { done: true, text: `${accepted}; ${name} is at ${short(await revision(worktree, 'HEAD'))}.` };
795
+ }
796
+ }