hivecode-mcp 0.14.1 → 0.14.3
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.
- package/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- package/token.js +201 -201
package/hive-pr.js
CHANGED
|
@@ -1,513 +1,638 @@
|
|
|
1
|
-
// hive-pr — turn a room's landed work into a pull request, with per-agent attribution intact.
|
|
2
|
-
//
|
|
3
|
-
// node hive-pr.js "<relay|room>" <YourName> --repo <path> [options]
|
|
4
|
-
//
|
|
5
|
-
// --repo <path> the git working copy the PR is opened against (required)
|
|
6
|
-
// --branch <name> branch to create (default: hivecode/<room>-<stamp>)
|
|
7
|
-
// --base <name> branch to cut from (default: the repo's current HEAD)
|
|
8
|
-
// --title <text> PR title (default: derived from the room)
|
|
9
|
-
// --only <a,b,c> only these files
|
|
10
|
-
// --dry-run print the plan, touch nothing
|
|
11
|
-
// --push push the branch to origin
|
|
12
|
-
// --pr push and open a PR with `gh` (implies --push)
|
|
13
|
-
//
|
|
14
|
-
// WHY THIS EXISTS. A room is where the work happens; a pull request is where a team already
|
|
15
|
-
// trusts work to arrive. Asking a team to abandon pull requests is an enormous ask -- they are
|
|
16
|
-
// how review, compliance and audit happen, and in many companies they are mandatory for reasons
|
|
17
|
-
// nobody on the team controls. So we do not ask. The room's OUTPUT is a PR, and everything about
|
|
18
|
-
// the team's existing pipeline (CI, required reviewers, protected branches, deploy) keeps working
|
|
19
|
-
// exactly as it did.
|
|
20
|
-
//
|
|
21
|
-
// THE PART THAT IS NOT PLUMBING. Every change in a room is signed per participant, so we know
|
|
22
|
-
// which agent landed which file -- and that survives into git as ONE COMMIT PER AGENT, each with
|
|
23
|
-
// its real author and the full signed author id in a trailer. A reviewer opening the PR sees, in
|
|
24
|
-
// `git log`, exactly which agent wrote what. No orchestration framework can produce that: they
|
|
25
|
-
// run every agent inside one process under one identity, so the attribution never existed to
|
|
26
|
-
// carry across.
|
|
27
|
-
//
|
|
28
|
-
// Attribution is per FILE, not per line. The ledger records who landed each version of a file, so
|
|
29
|
-
// a file is credited to whoever landed its FINAL state, and everyone else who touched it rides
|
|
30
|
-
// along as a Co-authored-by trailer. Claiming line-level authorship would be a stronger statement
|
|
31
|
-
// than the data supports.
|
|
32
|
-
|
|
33
|
-
import fs from 'fs'
|
|
34
|
-
import os from 'os'
|
|
35
|
-
import path from 'path'
|
|
36
|
-
import { spawnSync } from 'child_process'
|
|
37
|
-
import { fileURLToPath } from 'url'
|
|
38
|
-
import { startSync, parseLink } from './sync.js'
|
|
39
|
-
import { accountFor, verdict, explain, DIVERGED } from './hive-pr-staleness.js'
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
* and
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
fs.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
1
|
+
// hive-pr — turn a room's landed work into a pull request, with per-agent attribution intact.
|
|
2
|
+
//
|
|
3
|
+
// node hive-pr.js "<relay|room>" <YourName> --repo <path> [options]
|
|
4
|
+
//
|
|
5
|
+
// --repo <path> the git working copy the PR is opened against (required)
|
|
6
|
+
// --branch <name> branch to create (default: hivecode/<room>-<stamp>)
|
|
7
|
+
// --base <name> branch to cut from (default: the repo's current HEAD)
|
|
8
|
+
// --title <text> PR title (default: derived from the room)
|
|
9
|
+
// --only <a,b,c> only these files
|
|
10
|
+
// --dry-run print the plan, touch nothing
|
|
11
|
+
// --push push the branch to origin
|
|
12
|
+
// --pr push and open a PR with `gh` (implies --push)
|
|
13
|
+
//
|
|
14
|
+
// WHY THIS EXISTS. A room is where the work happens; a pull request is where a team already
|
|
15
|
+
// trusts work to arrive. Asking a team to abandon pull requests is an enormous ask -- they are
|
|
16
|
+
// how review, compliance and audit happen, and in many companies they are mandatory for reasons
|
|
17
|
+
// nobody on the team controls. So we do not ask. The room's OUTPUT is a PR, and everything about
|
|
18
|
+
// the team's existing pipeline (CI, required reviewers, protected branches, deploy) keeps working
|
|
19
|
+
// exactly as it did.
|
|
20
|
+
//
|
|
21
|
+
// THE PART THAT IS NOT PLUMBING. Every change in a room is signed per participant, so we know
|
|
22
|
+
// which agent landed which file -- and that survives into git as ONE COMMIT PER AGENT, each with
|
|
23
|
+
// its real author and the full signed author id in a trailer. A reviewer opening the PR sees, in
|
|
24
|
+
// `git log`, exactly which agent wrote what. No orchestration framework can produce that: they
|
|
25
|
+
// run every agent inside one process under one identity, so the attribution never existed to
|
|
26
|
+
// carry across.
|
|
27
|
+
//
|
|
28
|
+
// Attribution is per FILE, not per line. The ledger records who landed each version of a file, so
|
|
29
|
+
// a file is credited to whoever landed its FINAL state, and everyone else who touched it rides
|
|
30
|
+
// along as a Co-authored-by trailer. Claiming line-level authorship would be a stronger statement
|
|
31
|
+
// than the data supports.
|
|
32
|
+
|
|
33
|
+
import fs from 'fs'
|
|
34
|
+
import os from 'os'
|
|
35
|
+
import path from 'path'
|
|
36
|
+
import { spawnSync } from 'child_process'
|
|
37
|
+
import { fileURLToPath } from 'url'
|
|
38
|
+
import { startSync, parseLink } from './sync.js'
|
|
39
|
+
import { accountFor, verdict, explain, DIVERGED, DESCENDS, UNPROVEN, sameText } from './hive-pr-staleness.js'
|
|
40
|
+
const toLF = (s) => (typeof s === 'string' && s.indexOf('\r\n') !== -1 ? s.replace(/\r\n/g, '\n') : s)
|
|
41
|
+
import { contentHash } from './substrate.js'
|
|
42
|
+
|
|
43
|
+
const argv = process.argv.slice(2)
|
|
44
|
+
const [LINK = '', NAME = 'hive-pr'] = argv.filter((a) => !a.startsWith('--'))
|
|
45
|
+
const flag = (k, d = '') => { const i = argv.indexOf('--' + k); return i === -1 ? d : (argv[i + 1] || '') }
|
|
46
|
+
const has = (k) => argv.includes('--' + k)
|
|
47
|
+
// --force takes a REASON, not a bare switch. A switch is typed once and never explained; a
|
|
48
|
+
// required reason has to be written down, ends up in the PR body, and is the thing somebody asks
|
|
49
|
+
// about later. hive_release already works this way for exactly the same argument.
|
|
50
|
+
const FORCE = flag('force')
|
|
51
|
+
// --seed <commit>: the commit a branch room was seeded from (hive-land passes the binding's record).
|
|
52
|
+
const SEED = flag('seed')
|
|
53
|
+
|
|
54
|
+
const REPO = flag('repo')
|
|
55
|
+
const DRY = has('dry-run')
|
|
56
|
+
const DO_PUSH = has('push') || has('pr')
|
|
57
|
+
const DO_PR = has('pr')
|
|
58
|
+
const ONLY = flag('only') ? flag('only').split(',').map((s) => s.trim()).filter(Boolean) : null
|
|
59
|
+
const PLAN_JSON = flag('plan-json')
|
|
60
|
+
|
|
61
|
+
function die(code, msg) { console.error(`${code} ${msg}`); process.exit(1) }
|
|
62
|
+
|
|
63
|
+
/** Run git in the target repo. Never throws on a non-zero exit -- the caller decides. */
|
|
64
|
+
function git(args, opts = {}) {
|
|
65
|
+
const r = spawnSync('git', args, { cwd: REPO, encoding: 'utf8', ...opts })
|
|
66
|
+
return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The same, WITHOUT trimming. For reading file CONTENT, where a trailing newline is part of the
|
|
70
|
+
* file and not whitespace around an answer.
|
|
71
|
+
*
|
|
72
|
+
* `git()` above trims, which is right for `rev-parse` and `log` and wrong for `cat-file blob` --
|
|
73
|
+
* and wrong in the worst way: it strips the final newline, so the content hash of a healthy file
|
|
74
|
+
* never matches the room's chain, every file reads DIVERGED, and the staleness check refuses
|
|
75
|
+
* every run. Caught by the end-to-end suite on its FIRST assertion; the unit suite could not
|
|
76
|
+
* have seen it, because it passes text straight in and never goes near git. */
|
|
77
|
+
function gitRaw(args, opts = {}) {
|
|
78
|
+
const r = spawnSync('git', args, { cwd: REPO, encoding: 'utf8', ...opts })
|
|
79
|
+
return { code: r.status, out: r.stdout == null ? '' : r.stdout, err: (r.stderr || '').trim() }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
import { authorsOf } from './hive-authors.js'
|
|
83
|
+
export { authorsOf }
|
|
84
|
+
|
|
85
|
+
const mailOf = (name) => `${String(name).toLowerCase().replace(/[^a-z0-9._-]+/g, '-') || 'agent'}@agents.hivecode.local`
|
|
86
|
+
|
|
87
|
+
/** Group files by the agent who landed their final state, oldest contribution first. */
|
|
88
|
+
export function planCommits(files) {
|
|
89
|
+
const groups = new Map()
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
if (!f.last) continue
|
|
92
|
+
const g = groups.get(f.last.author) || { author: f.last.author, name: f.last.name, at: f.first, files: [], others: new Map(), receipts: [] }
|
|
93
|
+
g.files.push(f)
|
|
94
|
+
g.at = Math.min(g.at, f.first)
|
|
95
|
+
if (Array.isArray(f.receipts)) g.receipts.push(...f.receipts)
|
|
96
|
+
for (const c of f.contributors) if (c.author !== f.last.author) g.others.set(c.author, c)
|
|
97
|
+
groups.set(f.last.author, g)
|
|
98
|
+
}
|
|
99
|
+
return [...groups.values()]
|
|
100
|
+
.map((g) => ({ ...g, others: [...g.others.values()] }))
|
|
101
|
+
.sort((a, b) => a.at - b.at || a.name.localeCompare(b.name))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function sanitizeIntent(raw) {
|
|
105
|
+
if (typeof raw !== 'string') return ''
|
|
106
|
+
// Collapse newlines and control characters to single spaces, and cap each at 200 chars
|
|
107
|
+
const clean = raw.replace(/[\x00-\x1f\x7f-\x9f\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
108
|
+
if (!clean) return ''
|
|
109
|
+
return clean.length > 200 ? clean.slice(0, 200).trim() : clean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// An intent that already starts with the agent's own name ("Claude-cloud: Issue #6 ...", the way
|
|
113
|
+
// agents often phrase them) must not be prefixed again: the mock run's commit read
|
|
114
|
+
// "Claude-cloud: Claude-cloud: Issue #6 ..." (0.14.3 P1-9). Case-insensitive, repeated prefixes too.
|
|
115
|
+
export function withoutOwnName(agent, intent) {
|
|
116
|
+
let s = String(intent || '')
|
|
117
|
+
const a = String(agent || '').trim()
|
|
118
|
+
if (!a) return s
|
|
119
|
+
const re = new RegExp(`^\\s*@?${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*`, 'i')
|
|
120
|
+
for (let i = 0; i < 5 && re.test(s); i++) s = s.replace(re, '')
|
|
121
|
+
return s.trim() || String(intent || '').trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function truncateSubject(agent, intent, max = 72) {
|
|
125
|
+
intent = withoutOwnName(agent, intent)
|
|
126
|
+
const prefix = `${agent}: `
|
|
127
|
+
const full = `${prefix}${intent}`
|
|
128
|
+
if (full.length <= max) return full
|
|
129
|
+
const available = max - prefix.length - 3 // space for '...'
|
|
130
|
+
if (available <= 0) return full.slice(0, max)
|
|
131
|
+
const sliced = intent.slice(0, available)
|
|
132
|
+
const lastSpace = sliced.lastIndexOf(' ')
|
|
133
|
+
const cut = (lastSpace > 0) ? sliced.slice(0, lastSpace) : sliced
|
|
134
|
+
return `${prefix}${cut}...`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildPlanJson({ room, groups = [], orphans = [], identical = [], blocked = [], hashes = {} }) {
|
|
138
|
+
const skip = new Set(identical)
|
|
139
|
+
return {
|
|
140
|
+
room,
|
|
141
|
+
groups: groups.map((g) => {
|
|
142
|
+
const gFiles = (g.files || []).map((f) => (typeof f === 'string' ? f : f.rel)).filter((rel) => !skip.has(rel))
|
|
143
|
+
const allReceipts = []
|
|
144
|
+
if (Array.isArray(g.receipts)) allReceipts.push(...g.receipts)
|
|
145
|
+
for (const f of g.files || []) {
|
|
146
|
+
if (f && Array.isArray(f.receipts)) allReceipts.push(...f.receipts)
|
|
147
|
+
}
|
|
148
|
+
const seen = new Set()
|
|
149
|
+
const intents = []
|
|
150
|
+
for (const r of allReceipts) {
|
|
151
|
+
if (!r || typeof r.intent !== 'string') continue
|
|
152
|
+
const clean = sanitizeIntent(r.intent)
|
|
153
|
+
if (!clean || clean.toLowerCase() === 'edit') continue
|
|
154
|
+
if (!r.author || r.author === g.author) {
|
|
155
|
+
if (!seen.has(clean)) {
|
|
156
|
+
seen.add(clean)
|
|
157
|
+
intents.push(clean)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
name: g.name,
|
|
163
|
+
author: g.author,
|
|
164
|
+
files: gFiles,
|
|
165
|
+
intents,
|
|
166
|
+
}
|
|
167
|
+
}),
|
|
168
|
+
orphans: orphans.map((o) => (typeof o === 'string' ? o : o.rel)),
|
|
169
|
+
identical: [...identical],
|
|
170
|
+
blocked: [...blocked],
|
|
171
|
+
// The room's content hash of every file a group would write, as this mirror read it: what a
|
|
172
|
+
// hive_review must match, and how a watcher tells a changed room from a re-run of the same one.
|
|
173
|
+
hashes: Object.fromEntries(Object.entries(hashes || {}).filter(([rel]) => !skip.has(rel))),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const OWN_FILES = new Set(['.hive.json', '.hive-shadow.json', '.hive-shadow.json.tmp', '.hive-lock.json', '.hive-id.json', '.hive-mcp-session.json', '.hive-stop-hook.json'])
|
|
178
|
+
export const isOwnFile = (rel) => {
|
|
179
|
+
const b = path.basename(rel)
|
|
180
|
+
return OWN_FILES.has(b) || /^HIVE_[A-Z0-9_]+\.md$/i.test(b) || b.startsWith('.hive')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function isOrphan({ rel, last, roomText, branchText }) {
|
|
184
|
+
if (last) return false
|
|
185
|
+
if (isOwnFile(rel)) return false
|
|
186
|
+
if (branchText !== null && branchText !== undefined && sameText(branchText, roomText)) return false
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const hashFile = (absPath) => { try { return contentHash(toLF(fs.readFileSync(absPath, 'utf8'))) } catch { return null } }
|
|
191
|
+
|
|
192
|
+
export function commitMessage(g, room) {
|
|
193
|
+
const list = g.files.map((f) => f.rel).sort()
|
|
194
|
+
|
|
195
|
+
// Collect receipts up to held content across files in the group
|
|
196
|
+
const allReceipts = []
|
|
197
|
+
if (Array.isArray(g.receipts)) allReceipts.push(...g.receipts)
|
|
198
|
+
for (const f of g.files) {
|
|
199
|
+
if (Array.isArray(f.receipts)) allReceipts.push(...f.receipts)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Deduplicate and filter non-edit intents for the committing author
|
|
203
|
+
const seenAuthorIntents = new Set()
|
|
204
|
+
const authorIntents = []
|
|
205
|
+
|
|
206
|
+
// Deduplicate and filter non-edit intents for co-authors
|
|
207
|
+
const seenCoauthorIntents = new Set()
|
|
208
|
+
const coauthorIntents = []
|
|
209
|
+
|
|
210
|
+
const othersMap = new Map((g.others || []).map((o) => [o.author, o.name]))
|
|
211
|
+
|
|
212
|
+
for (const r of allReceipts) {
|
|
213
|
+
if (!r || typeof r.intent !== 'string') continue
|
|
214
|
+
const clean = sanitizeIntent(r.intent)
|
|
215
|
+
if (!clean || clean.toLowerCase() === 'edit') continue
|
|
216
|
+
|
|
217
|
+
if (!r.author || r.author === g.author) {
|
|
218
|
+
if (!seenAuthorIntents.has(clean)) {
|
|
219
|
+
seenAuthorIntents.add(clean)
|
|
220
|
+
authorIntents.push(clean)
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
const coauthorName = r.name || othersMap.get(r.author) || 'co-author'
|
|
224
|
+
const key = `${coauthorName}: ${clean}`
|
|
225
|
+
if (!seenCoauthorIntents.has(key)) {
|
|
226
|
+
seenCoauthorIntents.add(key)
|
|
227
|
+
coauthorIntents.push({ name: coauthorName, intent: clean })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Subject: "<agent>: <first non-edit intent>" up to 72 chars, fallback to today's message
|
|
233
|
+
let head
|
|
234
|
+
if (authorIntents.length > 0) {
|
|
235
|
+
head = truncateSubject(g.name, authorIntents[0], 72)
|
|
236
|
+
} else {
|
|
237
|
+
head = list.length === 1 ? `${g.name}: ${list[0]}` : `${g.name}: ${list.length} files`
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Body: lists intents and files if non-edit intents exist, fallback to today's message
|
|
241
|
+
const lines = [head, '']
|
|
242
|
+
if (authorIntents.length > 0 || coauthorIntents.length > 0) {
|
|
243
|
+
lines.push('Intents:')
|
|
244
|
+
for (const intent of authorIntents) {
|
|
245
|
+
lines.push(` - ${intent}`)
|
|
246
|
+
}
|
|
247
|
+
for (const ci of coauthorIntents) {
|
|
248
|
+
lines.push(` - ${ci.name}: ${withoutOwnName(ci.name, ci.intent)}`)
|
|
249
|
+
}
|
|
250
|
+
lines.push('')
|
|
251
|
+
lines.push('Files:')
|
|
252
|
+
for (const f of list) {
|
|
253
|
+
lines.push(` - ${f}`)
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
for (const f of list) {
|
|
257
|
+
lines.push(` - ${f}`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
lines.push('')
|
|
261
|
+
lines.push(`Room: ${room}`)
|
|
262
|
+
lines.push(`Hivecode-Author: ${g.author}`)
|
|
263
|
+
for (const o of g.others) {
|
|
264
|
+
lines.push(`Co-authored-by: ${o.name} <${mailOf(o.name)}>`)
|
|
265
|
+
}
|
|
266
|
+
return lines.join('\n')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- main ---------------------------------------------------------------------------------
|
|
270
|
+
// COMPARED AS REAL PATHS, as hive-mcp.js does. The old test built `file:///` + argv[1], which is
|
|
271
|
+
// right only on Windows: on macOS and Linux argv[1] already starts with '/', the URL came out as
|
|
272
|
+
// `file:////...`, never matched, and the script exited 0 having done nothing at all.
|
|
273
|
+
const isMain = (() => {
|
|
274
|
+
if (!process.argv[1]) return false
|
|
275
|
+
const me = fileURLToPath(import.meta.url)
|
|
276
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(me) } catch { return path.resolve(process.argv[1]) === path.resolve(me) }
|
|
277
|
+
})()
|
|
278
|
+
if (isMain) {
|
|
279
|
+
if (!LINK || !REPO) {
|
|
280
|
+
console.error('usage: node hive-pr.js "<relay|room>" <YourName> --repo <path> [--branch b] [--dry-run] [--push] [--pr] [--force "<reason>"]')
|
|
281
|
+
process.exit(2)
|
|
282
|
+
}
|
|
283
|
+
if (!fs.existsSync(path.join(REPO, '.git'))) die('Q500', `${REPO} is not a git repository`)
|
|
284
|
+
|
|
285
|
+
const { relay, room } = parseLink(LINK)
|
|
286
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 13)
|
|
287
|
+
const BRANCH = flag('branch') || `hivecode/${room}-${stamp}`
|
|
288
|
+
const TITLE = flag('title') || `Hivecode: work from room ${room}`
|
|
289
|
+
|
|
290
|
+
// A scratch mirror of the room, so nothing here can disturb anyone's working folder.
|
|
291
|
+
// os.tmpdir(), not $TEMP: TEMP is a Windows variable, and without it the mirror was made in the
|
|
292
|
+
// current directory -- inside the very checkout the PR is cut from.
|
|
293
|
+
const dir = path.join(os.tmpdir(), `hive-pr-${room}-${process.pid}`)
|
|
294
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
295
|
+
|
|
296
|
+
// Provenance is what this whole tool reads. Without it the ledger is empty and every
|
|
297
|
+
// commit would collapse to one anonymous blob -- which is exactly the thing we are here
|
|
298
|
+
// to avoid, so turn it on for this client rather than failing later with an empty plan.
|
|
299
|
+
process.env.HIVE_PROVENANCE = process.env.HIVE_PROVENANCE || 'on'
|
|
300
|
+
|
|
301
|
+
const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: true, log: () => {} })
|
|
302
|
+
|
|
303
|
+
let done = false
|
|
304
|
+
// Cleared once the room answers. It used to stay armed for the whole run, so a landing that
|
|
305
|
+
// took longer than 20 s was killed as "could not reach the relay" -- mid-commit, possibly.
|
|
306
|
+
const unreachable = setTimeout(() => die('Q470', `could not reach the relay at ${relay}`), 20000)
|
|
307
|
+
hive.provider.on('sync', (s) => {
|
|
308
|
+
if (!s || done) return
|
|
309
|
+
done = true
|
|
310
|
+
clearTimeout(unreachable)
|
|
311
|
+
whenWholeRoomArrived()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// THE WHOLE ROOM, OR NOTHING. The file sub-docs arrive after the room doc, and this used to wait
|
|
315
|
+
// a flat 2.5 s for them. On a room bigger than what arrives in 2.5 s, the files still in flight
|
|
316
|
+
// were simply not in the mirror: not planned, not committed, not mentioned -- a PR that looks
|
|
317
|
+
// complete and silently leaves agents' work behind. Now the plan waits until every file the room
|
|
318
|
+
// lists is on disk here (a file's receipts travel in its own doc, so on disk means its ledger
|
|
319
|
+
// arrived too), keeps the 2.5 s floor, and refuses rather than land part of the room.
|
|
320
|
+
function whenWholeRoomArrived() {
|
|
321
|
+
const t0 = Date.now()
|
|
322
|
+
const limit = Number(process.env.HIVE_PR_WAIT_MS) || 120000
|
|
323
|
+
const tick = () => {
|
|
324
|
+
let st = null
|
|
325
|
+
try { st = hive.status() } catch { /* not ready yet */ }
|
|
326
|
+
if (st && st.filesMissing === 0 && Date.now() - t0 >= 2500) return run()
|
|
327
|
+
if (Date.now() - t0 > limit) {
|
|
328
|
+
const have = st ? `${st.filesHeld} of ${st.roomFiles}` : 'an unknown number'
|
|
329
|
+
console.error(`Q507 only ${have} of the room's files arrived in ${Math.round(limit / 1000)} s. Refusing to land part of the room: the missing files' work would be left out of the PR without anyone being told.`)
|
|
330
|
+
return finish(1)
|
|
331
|
+
}
|
|
332
|
+
setTimeout(tick, 250)
|
|
333
|
+
}
|
|
334
|
+
tick()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function walk(root, base = '') {
|
|
338
|
+
const out = []
|
|
339
|
+
for (const e of fs.readdirSync(path.join(root, base), { withFileTypes: true })) {
|
|
340
|
+
// Git's own and this client's bookkeeping (.hive.json, .hive-shadow.json, ...). Every other
|
|
341
|
+
// dotfile is project content: this skipped ALL of them, so an agent's edit to .gitignore or
|
|
342
|
+
// .github/workflows never reached the PR, and nothing said so. Only files with a verified
|
|
343
|
+
// receipt are committed either way, so bookkeeping that slipped through would still be left out.
|
|
344
|
+
if (e.name === '.git' || e.name.startsWith('.hive')) continue
|
|
345
|
+
const rel = base ? `${base}/${e.name}` : e.name
|
|
346
|
+
if (e.isDirectory()) out.push(...walk(root, rel))
|
|
347
|
+
else out.push(rel)
|
|
348
|
+
}
|
|
349
|
+
return out
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function run() {
|
|
353
|
+
let rels = walk(dir)
|
|
354
|
+
if (ONLY) rels = rels.filter((r) => ONLY.includes(r))
|
|
355
|
+
const files = []
|
|
356
|
+
for (const rel of rels) {
|
|
357
|
+
const receipts = hive.provenanceOf(rel) || []
|
|
358
|
+
const audit = hive.verifyProvenanceOf(rel) || { bad: [] }
|
|
359
|
+
const badIdx = new Set((audit.bad || []).map((b) => b.i))
|
|
360
|
+
let roomText = null
|
|
361
|
+
try { roomText = toLF(fs.readFileSync(path.join(dir, rel), 'utf8')) } catch { }
|
|
362
|
+
const a = authorsOf(receipts, (r) => !badIdx.has(receipts.indexOf(r)), roomText)
|
|
363
|
+
files.push({ rel, abs: path.join(dir, rel), ...a })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const base = flag('base') || git(['rev-parse', '--abbrev-ref', 'HEAD']).out || 'HEAD'
|
|
367
|
+
const attributed = []
|
|
368
|
+
const orphans = []
|
|
369
|
+
for (const f of files) {
|
|
370
|
+
if (f.last) {
|
|
371
|
+
attributed.push(f)
|
|
372
|
+
} else {
|
|
373
|
+
const sha = git(['rev-parse', `${base}:${f.rel}`])
|
|
374
|
+
const branchText = sha.code === 0 ? gitRaw(['cat-file', 'blob', sha.out]).out : null
|
|
375
|
+
const roomText = toLF(fs.readFileSync(f.abs, 'utf8'))
|
|
376
|
+
if (isOrphan({ rel: f.rel, last: f.last, roomText, branchText })) {
|
|
377
|
+
orphans.push(f)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const groups = planCommits(attributed)
|
|
382
|
+
|
|
383
|
+
console.log(`room ${room}: ${files.length} files, ${attributed.length} attributed, ${groups.length} agent(s)`)
|
|
384
|
+
for (const g of groups) {
|
|
385
|
+
console.log(` ${g.name} ${g.files.length} file(s)${g.others.length ? ` (+${g.others.length} co-author)` : ''}`)
|
|
386
|
+
for (const f of g.files) console.log(` ${f.rel}`)
|
|
387
|
+
}
|
|
388
|
+
if (orphans.length) console.log(` (${orphans.length} file(s) with no verified receipt -- not committed: ${orphans.map((o) => o.rel).join(', ')})`)
|
|
389
|
+
|
|
390
|
+
// ── HIV-28: DO NOT WRITE OVER A BRANCH CHANGE WE CANNOT ACCOUNT FOR ──────────────────
|
|
391
|
+
//
|
|
392
|
+
// Measured 19 Sep 2026: this tool produced a PR with three clean per-agent commits, real git
|
|
393
|
+
// authorship and signed trailers, which deleted the HIV-27 claim guard, the HIV-14 truncation
|
|
394
|
+
// fix and ~200 lines of tests. It looked perfect. The cause was the copyFileSync below with
|
|
395
|
+
// nothing in front of it.
|
|
396
|
+
//
|
|
397
|
+
// The check runs BEFORE `checkout -b`, so a refusal leaves no branch behind. Refusing after
|
|
398
|
+
// creating it would hand the operator a half-made artefact to clean up, and the next thing a
|
|
399
|
+
// person does with a branch that exists is push it.
|
|
400
|
+
const decisions = []
|
|
401
|
+
for (const g of groups) {
|
|
402
|
+
for (const f of g.files) {
|
|
403
|
+
// `git cat-file blob` and not `git show`: cat-file reads the object as stored, with no
|
|
404
|
+
// smudge filter. With core.autocrlf on -- true in this repo -- `show` through a working
|
|
405
|
+
// tree hands back bytes git never stored, and every content hash misses.
|
|
406
|
+
const sha = git(['rev-parse', `${base}:${f.rel}`])
|
|
407
|
+
const branchText = sha.code === 0 ? gitRaw(['cat-file', 'blob', sha.out]).out : null
|
|
408
|
+
const roomText = toLF(fs.readFileSync(f.abs, 'utf8'))
|
|
409
|
+
let d = accountFor({ rel: f.rel, branchText, roomText, receipts: hive.provenanceOf(f.rel) || [] })
|
|
410
|
+
// UNCHANGED ON THE BRANCH SINCE THE ROOM WAS SEEDED FROM IT (0.14.3 P0-3). A branch room
|
|
411
|
+
// starts as the branch at the seed commit, so a file whose branch copy is still that exact blob
|
|
412
|
+
// is one the room provably held at its start: writing the room's copy over it deletes nothing
|
|
413
|
+
// the room has not seen, whatever its receipts say. demo-3 (9 Nov) was refused here (Q506) on
|
|
414
|
+
// an untouched branch because one unsigned edit had cut test.js's chain off from the seed.
|
|
415
|
+
// Only a broken or missing chain is overruled; a file the branch changed since is still judged
|
|
416
|
+
// by its chain alone, so a real change under the room is still refused.
|
|
417
|
+
if (SEED && (d.state === DIVERGED || d.state === UNPROVEN) && sha.code === 0) {
|
|
418
|
+
const atSeed = git(['rev-parse', `${SEED}:${f.rel}`])
|
|
419
|
+
if (atSeed.code === 0 && atSeed.out === sha.out) {
|
|
420
|
+
d = { ...d, state: DESCENDS, why: `unchanged on the branch since the room was seeded from it (${SEED.slice(0, 7)}), so the room started from exactly this copy` }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
decisions.push({ ...d, _f: f })
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const v = verdict(decisions)
|
|
427
|
+
if (v.identical.length) console.log(` (${v.identical.length} file(s) already identical on ${base} -- nothing to write)`)
|
|
428
|
+
|
|
429
|
+
function writePlanJson() {
|
|
430
|
+
if (!PLAN_JSON) return
|
|
431
|
+
const identicalRels = (v && v.identical) ? v.identical.map((d) => d.rel) : []
|
|
432
|
+
const blockedRels = (v && v.blocked) ? v.blocked.map((d) => d.rel) : []
|
|
433
|
+
const plan = buildPlanJson({
|
|
434
|
+
room,
|
|
435
|
+
groups,
|
|
436
|
+
orphans,
|
|
437
|
+
identical: identicalRels,
|
|
438
|
+
blocked: blockedRels,
|
|
439
|
+
hashes: Object.fromEntries(groups.flatMap((g) => g.files || []).map((x) => { const h = hashFile(x.abs); return h ? [x.rel, h] : null }).filter(Boolean)),
|
|
440
|
+
})
|
|
441
|
+
try {
|
|
442
|
+
fs.mkdirSync(path.dirname(path.resolve(PLAN_JSON)), { recursive: true })
|
|
443
|
+
fs.writeFileSync(PLAN_JSON, JSON.stringify(plan, null, 2), 'utf8')
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error(`warning: could not write --plan-json to ${PLAN_JSON}: ${err.message}`)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (DRY && !groups.length) {
|
|
450
|
+
writePlanJson()
|
|
451
|
+
console.log('\n--dry-run: nothing written')
|
|
452
|
+
return finish(0)
|
|
453
|
+
}
|
|
454
|
+
if (!groups.length) {
|
|
455
|
+
writePlanJson()
|
|
456
|
+
console.error('Q501 nothing attributed to commit')
|
|
457
|
+
return finish(1)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// A DRY RUN ANSWERS WHAT THE REAL ONE WILL (0.14.3). It used to return "would land" before this
|
|
461
|
+
// check, so the watcher's dry pass went green and the real landing then refused with Q506, and
|
|
462
|
+
// nobody heard (9 Nov, issue #9). The same verdict, the same refusal, nothing written either way.
|
|
463
|
+
if (!v.ok) {
|
|
464
|
+
writePlanJson()
|
|
465
|
+
const lastTouch = (rel) => git(['log', '-1', '--format=%h %an %ad %s', '--date=short', base, '--', rel]).out
|
|
466
|
+
console.error('')
|
|
467
|
+
console.error(explain(v.blocked, lastTouch))
|
|
468
|
+
if (!FORCE) return finish(1)
|
|
469
|
+
// A forced run is still reported in full, and the reason travels into the PR body. The
|
|
470
|
+
// point is not to make forcing hard, it is to make it visible.
|
|
471
|
+
console.error('')
|
|
472
|
+
console.error(`--force given: "${FORCE}" -- ${DRY ? 'would overwrite' : 'overwriting'} ${v.blocked.length} file(s) anyway. This is recorded in the PR body.`)
|
|
473
|
+
}
|
|
474
|
+
if (DRY) {
|
|
475
|
+
writePlanJson()
|
|
476
|
+
console.log('\n--dry-run: nothing written')
|
|
477
|
+
return finish(0)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const co = git(['checkout', '-b', BRANCH, base])
|
|
481
|
+
if (co.code !== 0) { console.error(`Q502 could not create branch ${BRANCH}: ${co.err}`); return finish(1) }
|
|
482
|
+
|
|
483
|
+
// IDENTICAL files are dropped rather than copied. Copying one rewrites its line endings when
|
|
484
|
+
// the room and the blob differ in style -- which on this repo is most files -- producing a
|
|
485
|
+
// diff of every line for a change nobody made.
|
|
486
|
+
const skip = new Set(v.identical.map((d) => d.rel))
|
|
487
|
+
const isText = (buf) => !buf.includes(0)
|
|
488
|
+
function splitLinesWithEndings(text) {
|
|
489
|
+
const lines = [], endings = []
|
|
490
|
+
let start = 0
|
|
491
|
+
for (let i = 0; i < text.length; i++) {
|
|
492
|
+
if (text[i] === '\n') {
|
|
493
|
+
if (i > start && text[i - 1] === '\r') {
|
|
494
|
+
lines.push(text.slice(start, i - 1))
|
|
495
|
+
endings.push('\r\n')
|
|
496
|
+
} else {
|
|
497
|
+
lines.push(text.slice(start, i))
|
|
498
|
+
endings.push('\n')
|
|
499
|
+
}
|
|
500
|
+
start = i + 1
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (start < text.length) {
|
|
504
|
+
lines.push(text.slice(start))
|
|
505
|
+
endings.push('')
|
|
506
|
+
}
|
|
507
|
+
return { lines, endings }
|
|
508
|
+
}
|
|
509
|
+
function restoreEndings(newText, baseText, fallbackCrlf = false) {
|
|
510
|
+
if (!baseText) {
|
|
511
|
+
return fallbackCrlf ? toLF(newText).replace(/\n/g, '\r\n') : toLF(newText)
|
|
512
|
+
}
|
|
513
|
+
if (!baseText.includes('\r\n')) {
|
|
514
|
+
return fallbackCrlf ? toLF(newText).replace(/\n/g, '\r\n') : toLF(newText)
|
|
515
|
+
}
|
|
516
|
+
if (!baseText.replace(/\r\n/g, '').includes('\n')) {
|
|
517
|
+
return toLF(newText).replace(/\n/g, '\r\n')
|
|
518
|
+
}
|
|
519
|
+
const base = splitLinesWithEndings(baseText)
|
|
520
|
+
const crlfCount = base.endings.filter((e) => e === '\r\n').length
|
|
521
|
+
const lfCount = base.endings.filter((e) => e === '\n').length
|
|
522
|
+
const defaultEnding = crlfCount >= lfCount ? '\r\n' : '\n'
|
|
523
|
+
const contentToEnding = new Map()
|
|
524
|
+
for (let i = 0; i < base.lines.length; i++) {
|
|
525
|
+
const l = base.lines[i]
|
|
526
|
+
if (!contentToEnding.has(l)) contentToEnding.set(l, [])
|
|
527
|
+
contentToEnding.get(l).push(base.endings[i])
|
|
528
|
+
}
|
|
529
|
+
const { lines: newLines, endings: newEndings } = splitLinesWithEndings(newText)
|
|
530
|
+
const out = []
|
|
531
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
532
|
+
const l = newLines[i]
|
|
533
|
+
const isLast = (i === newLines.length - 1 && newEndings[i] === '')
|
|
534
|
+
let e = ''
|
|
535
|
+
if (!isLast) {
|
|
536
|
+
if (contentToEnding.has(l) && contentToEnding.get(l).length > 0) {
|
|
537
|
+
e = contentToEnding.get(l).shift()
|
|
538
|
+
} else if (i < base.endings.length && base.endings[i] !== '') {
|
|
539
|
+
e = base.endings[i]
|
|
540
|
+
} else {
|
|
541
|
+
e = defaultEnding
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
out.push(l + e)
|
|
545
|
+
}
|
|
546
|
+
return out.join('')
|
|
547
|
+
}
|
|
548
|
+
let made = 0
|
|
549
|
+
for (const g0 of groups) {
|
|
550
|
+
// THE COMMIT MESSAGE MUST DESCRIBE WHAT WAS COMMITTED. Built from g0.files it said
|
|
551
|
+
// "Builder: 2 files" and listed both while only one was written, because the other was
|
|
552
|
+
// already identical on the branch. A message that names files a commit does not contain is
|
|
553
|
+
// a small false success in the one artefact a reviewer trusts most -- and it is in `git log`
|
|
554
|
+
// forever.
|
|
555
|
+
const g = { ...g0, files: g0.files.filter((f) => !skip.has(f.rel)) }
|
|
556
|
+
if (!g.files.length) { console.log(` (nothing to write from ${g.name} — all already on ${base})`); continue }
|
|
557
|
+
for (const f of g.files) {
|
|
558
|
+
const dest = path.join(REPO, f.rel)
|
|
559
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
560
|
+
const rawNew = fs.readFileSync(f.abs)
|
|
561
|
+
if (!isText(rawNew)) {
|
|
562
|
+
fs.copyFileSync(f.abs, dest)
|
|
563
|
+
} else {
|
|
564
|
+
let baseText = null
|
|
565
|
+
try {
|
|
566
|
+
const sha = git(['rev-parse', `${base}:${f.rel}`])
|
|
567
|
+
if (sha.code === 0) {
|
|
568
|
+
const raw = gitRaw(['cat-file', 'blob', sha.out]).out
|
|
569
|
+
if (isText(Buffer.from(raw))) baseText = raw
|
|
570
|
+
}
|
|
571
|
+
} catch { }
|
|
572
|
+
let fallbackCrlf = false
|
|
573
|
+
try {
|
|
574
|
+
const attr = git(['check-attr', 'eol', '--', f.rel]).out
|
|
575
|
+
if (attr.includes('eol: crlf')) fallbackCrlf = true
|
|
576
|
+
else if (!baseText && fs.existsSync(dest)) {
|
|
577
|
+
const rawDisk = fs.readFileSync(dest, 'utf8')
|
|
578
|
+
const crlf = (rawDisk.match(/\r\n/g) || []).length
|
|
579
|
+
if (crlf && crlf * 2 >= (rawDisk.match(/\n/g) || []).length) fallbackCrlf = true
|
|
580
|
+
}
|
|
581
|
+
} catch { }
|
|
582
|
+
const formatted = restoreEndings(rawNew.toString('utf8'), baseText, fallbackCrlf)
|
|
583
|
+
fs.writeFileSync(dest, formatted, 'utf8')
|
|
584
|
+
}
|
|
585
|
+
git(['add', '--', f.rel])
|
|
586
|
+
}
|
|
587
|
+
const msg = commitMessage(g, room)
|
|
588
|
+
const r = git(['commit', '-m', msg, '--author', `${g.name} <${mailOf(g.name)}>`])
|
|
589
|
+
if (r.code !== 0) {
|
|
590
|
+
// "nothing to commit" is not a failure: the room's copy can equal what is already
|
|
591
|
+
// on the branch, and a no-op commit is the honest outcome.
|
|
592
|
+
if (/nothing to commit/i.test(r.out + r.err)) { console.log(` (no change from ${g.name})`); continue }
|
|
593
|
+
console.error(`Q503 commit failed for ${g.name}: ${r.err || r.out}`); return finish(1)
|
|
594
|
+
}
|
|
595
|
+
made++
|
|
596
|
+
}
|
|
597
|
+
console.log(`\nbranch ${BRANCH}: ${made} commit(s)`)
|
|
598
|
+
console.log(git(['log', '--format= %h %an %s', `${base}..${BRANCH}`]).out)
|
|
599
|
+
|
|
600
|
+
if (DO_PUSH) {
|
|
601
|
+
const p = git(['push', '-u', 'origin', BRANCH])
|
|
602
|
+
if (p.code !== 0) { console.error(`Q504 push failed: ${p.err}`); return finish(1) }
|
|
603
|
+
console.log(`pushed ${BRANCH}`)
|
|
604
|
+
}
|
|
605
|
+
if (DO_PR) {
|
|
606
|
+
// A forced overwrite is stated in the PR BODY, not only in the terminal of the person who
|
|
607
|
+
// ran it. The reviewer is the one who needs to know that files were written over branch
|
|
608
|
+
// content this room had never seen -- and a reviewer looking at three clean signed commits
|
|
609
|
+
// has no other way to find that out. That is the whole shape of the 19 Sep incident.
|
|
610
|
+
const forcedNote = (!v.ok && FORCE)
|
|
611
|
+
? ['',
|
|
612
|
+
'> **Forced past a staleness refusal.** ' + v.blocked.length + ' file(s) were written',
|
|
613
|
+
'> over branch content this room had never seen.',
|
|
614
|
+
'>',
|
|
615
|
+
'> Reason given: ' + JSON.stringify(FORCE),
|
|
616
|
+
'>',
|
|
617
|
+
'> ' + v.blocked.map((d) => d.rel).join(', ')]
|
|
618
|
+
: []
|
|
619
|
+
const body = [`Opened by Hivecode from room \`${room}\`.`, ...forcedNote, '', '| agent | files |', '|---|---|',
|
|
620
|
+
...groups.map((g) => `| ${g.name} | ${g.files.map((f) => '`' + f.rel + '`').join(', ')} |`),
|
|
621
|
+
'', 'Each commit carries its agent as the git author and the signed author id as a `Hivecode-Author` trailer.'].join('\n')
|
|
622
|
+
const r = spawnSync('gh', ['pr', 'create', '--title', TITLE, '--body', body, '--base', base, '--head', BRANCH],
|
|
623
|
+
{ cwd: REPO, encoding: 'utf8' })
|
|
624
|
+
if (r.status !== 0) { console.error(`Q505 gh pr create failed: ${(r.stderr || '').trim()}`); return finish(1) }
|
|
625
|
+
console.log((r.stdout || '').trim())
|
|
626
|
+
}
|
|
627
|
+
writePlanJson()
|
|
628
|
+
finish(0)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function finish(code) {
|
|
632
|
+
setTimeout(() => {
|
|
633
|
+
try { hive.stop() } catch { /* going away */ }
|
|
634
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
635
|
+
process.exit(code)
|
|
636
|
+
}, 400)
|
|
637
|
+
}
|
|
513
638
|
}
|