hivecode-mcp 0.13.0 → 0.14.0

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/sync.js CHANGED
@@ -22,7 +22,7 @@ import crypto from 'crypto'
22
22
  import * as Y from 'yjs'
23
23
  import { WebsocketProvider } from 'y-websocket'
24
24
  import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
25
- import { WebSocket } from 'ws'
25
+ import { HiveSocket, transportFor } from './hive-transport.js' // a WebSocket, or HTTPS long-poll where WebSockets are refused
26
26
  import ignore from 'ignore'
27
27
  import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
28
28
  import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
@@ -31,6 +31,7 @@ import { gateConfig, runGate, verdictRecord, refusalMessage, forcedMessage } fro
31
31
  import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, writeAllowed, isSafeRelPath } from './token.js'
32
32
  import { validateMemberName } from './hive-mailbox.js' // a name the mention parser cannot produce is not a name
33
33
  import { genIdentity, authorChange, verifyReceipt, contentHash } from './substrate.js' // convergent-substrate: signed provenance for every landed change
34
+ import { authorsOf } from './hive-authors.js'
34
35
  // `languageFor` belongs in this list and was missing: reconcile() calls it on the
35
36
  // clean-rewrite path, so a local whole-file rewrite of a supported file threw
36
37
  // ReferenceError and killed the sync process outright. Nothing caught it -- the client
@@ -584,6 +585,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
584
585
  const claims = doc.getMap('claims')
585
586
  const swarmState = doc.getMap('swarm_state')
586
587
  const swarmSchema = doc.getMap('swarm_schema')
588
+ const reviews = doc.getMap('reviews') // relPath -> Array of { by, at, verdict: 'pass'|'fail', note, hash }
587
589
  // ATTESTATION LEDGER — path -> { <peerName>: { bytes, lines, at } }.
588
590
  //
589
591
  // The one thing a four-agent build could not do without a human relaying numbers by hand: tell
@@ -1119,7 +1121,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1119
1121
  if (name) wsParams.name = String(name).slice(0, 48)
1120
1122
  if (kind) wsParams.kind = kind
1121
1123
  if (passive) wsParams.passive = 'true'
1122
- const wsOpts = { WebSocketPolyfill: WebSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
1124
+ // HiveSocket: a WebSocket where the network allows one, HTTPS long-poll where it refuses the upgrade
1125
+ // (Antigravity's cloud VMs, many company proxies). Same bytes, same relay gates, either way.
1126
+ const wsOpts = { WebSocketPolyfill: HiveSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
1123
1127
  // ONE SOCKET PER PARTICIPANT. When the relay offers it (it says so in /health), file docs stop
1124
1128
  // costing a socket each: they ride one physical socket as channels, and the relay admits each
1125
1129
  // one through the same gates a per-file socket passes. Against a relay that does not offer it,
@@ -3198,18 +3202,51 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3198
3202
  // So agents coordinate among themselves, but a human can never make an agent
3199
3203
  // act without that agent's owner approving it.
3200
3204
  const newId = () => 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
3205
+
3206
+ const getMemberNames = () => {
3207
+ const names = new Set()
3208
+ if (provider && provider.awareness) {
3209
+ for (const s of provider.awareness.getStates().values()) {
3210
+ const u = s && s.user
3211
+ if (u && u.name) names.add(u.name)
3212
+ }
3213
+ }
3214
+ // Union with durable activity map (non-gone keys)
3215
+ try {
3216
+ const act = doc.getMap('activity')
3217
+ if (act) {
3218
+ for (const [k, v] of act.entries()) {
3219
+ if (v && !v.gone && k) names.add(k)
3220
+ }
3221
+ }
3222
+ } catch { }
3223
+ // Union with recent chat senders
3224
+ try {
3225
+ const chatArr = doc.getArray('chat')
3226
+ if (chatArr) {
3227
+ for (const msg of chatArr.toArray()) {
3228
+ if (msg && msg.by) names.add(msg.by)
3229
+ }
3230
+ }
3231
+ } catch { }
3232
+ names.add(name)
3233
+ return [...names]
3234
+ }
3235
+
3201
3236
  function assign(to, text) {
3202
3237
  if (!to || !text) return null
3238
+ const normTo = String(to).trim() === '@all' ? 'all' : String(to).trim()
3203
3239
  const id = newId()
3204
- const ownerOf = owners.get(to)
3240
+ const ownerOf = owners.get(normTo)
3205
3241
  // auto-accept when it's AI->AI coordination, the target has no owner to ask,
3206
- // or the asker IS the target's owner. Otherwise it needs owner approval.
3207
- const auto = kind === 'ai' || !ownerOf || name === ownerOf
3242
+ // or the asker IS the target's owner, or an @all task.
3243
+ const auto = kind === 'ai' || !ownerOf || name === ownerOf || normTo === 'all'
3208
3244
  const status = auto ? 'accepted' : 'pending'
3209
3245
  const decidedBy = auto ? (kind === 'ai' ? `${name} (AI coordination)` : name) : null
3210
- tasks.set(id, { id, to, by: name, byKind: kind, text: String(text), status, decidedBy, at: fmtTime() })
3211
- if (auto) say(`@${to}: ${text} (task ${id} — ${kind === 'ai' ? 'AI coordination, proceeding' : 'from owner, proceeding'})`)
3212
- else say(`@${to}: ${text} (task ${id} — ${ownerOf} must approve: do it or ignore?)`)
3246
+ const membersAt = getMemberNames()
3247
+ tasks.set(id, { id, to: normTo, by: name, byKind: kind, text: String(text), status, decidedBy, at: fmtTime(), membersAt, createdAt: Date.now() })
3248
+ if (auto) say(`@${normTo}: ${text} (task ${id} — ${kind === 'ai' ? 'AI coordination, proceeding' : 'from owner, proceeding'})`)
3249
+ else say(`@${normTo}: ${text} (task ${id} — ${ownerOf} must approve: do it or ignore?)`)
3213
3250
  return id
3214
3251
  }
3215
3252
  // A decision is about a task that is WAITING for one. Approving an accepted or done task used to
@@ -3228,19 +3265,182 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3228
3265
  say(`${accept ? `@${t.to} ` : ''}task ${id} ${accept ? 'APPROVED' : 'denied'} by ${by}: "${t.text}"`)
3229
3266
  return { ok: true }
3230
3267
  }
3231
- // Only the ASSIGNEE finishes a task, and only work it was cleared to do. Before 29 Sep 2026 anyone
3232
- // could mark anyone's task done -- including one still PENDING its owner's approval, or DENIED --
3233
- // so "done" could mean the owner said no and the room was told otherwise. Done twice is done.
3268
+ // Only the ASSIGNEE finishes a task, and only work it was cleared to do.
3269
+ // For @all tasks (Claude addition iii), any member can complete it provided they
3270
+ // were in the room when the task was created, or hold a claim / signed receipt / review on the work.
3234
3271
  function complete(id, note = '') {
3235
3272
  const t = tasks.get(id); if (!t) return { error: 'no such task' }
3236
- if (t.to !== name) return { error: `task ${id} is ${t.to}'s -- only ${t.to} can complete it` }
3273
+ const isAll = t.to === 'all' || t.to === '@all'
3274
+ if (!isAll && t.to !== name) return { error: `task ${id} is ${t.to}'s -- only ${t.to} can complete it` }
3237
3275
  if (t.status === 'done') return { ok: true, already: true }
3238
3276
  if (t.status !== 'accepted') return { error: `task ${id} is ${t.status}: only accepted work can be completed${t.status === 'pending' ? ` (it is waiting for ${owners.get(t.to) || 'its owner'} to approve)` : ''}` }
3239
- tasks.set(id, { ...t, status: 'done', decidedBy: t.decidedBy })
3277
+
3278
+ if (isAll) {
3279
+ const wasPresent = Array.isArray(t.membersAt) ? t.membersAt.includes(name) : (t.by === name)
3280
+ const hasClaim = coordinator ? coordinator.board().some((c) => c && c.by === name) : false
3281
+ let hasReceipt = false
3282
+ for (const e of fileDocs.values()) {
3283
+ if (e && e.ledger) {
3284
+ const rs = e.ledger.toArray()
3285
+ if (rs.some((r) => r && (r.name === name || (identity && r.author === identity.id)))) {
3286
+ hasReceipt = true
3287
+ break
3288
+ }
3289
+ }
3290
+ }
3291
+ let hasReview = false
3292
+ try {
3293
+ for (const r of getReviews('')) {
3294
+ if (r && r.by === name) { hasReview = true; break }
3295
+ }
3296
+ if (!hasReview) {
3297
+ for (const [k, r] of reviews.entries()) {
3298
+ if (r && r.by === name) { hasReview = true; break }
3299
+ }
3300
+ }
3301
+ } catch { }
3302
+ if (!wasPresent && !hasClaim && !hasReceipt && !hasReview) {
3303
+ return { error: 'Refused: completing an @all task requires having been in the room when it was created, or having a claim/receipt/review on the work' }
3304
+ }
3305
+ }
3306
+
3307
+ tasks.set(id, { ...t, status: 'done', completedBy: name, decidedBy: t.decidedBy })
3240
3308
  say(`task ${id} done by ${name}${note ? ': ' + note : ''}`)
3241
3309
  return { ok: true }
3242
3310
  }
3243
- const myTasks = () => [...tasks.values()].filter((t) => t.to === name)
3311
+ const myTasks = () => [...tasks.values()].filter((t) => t.to === name || t.to === 'all' || t.to === '@all')
3312
+
3313
+ /**
3314
+ * Room review: record a non-writer review verdict on files in the room.
3315
+ * Stored in Y.Map 'reviews' as an append-only list per file: [{ by, at, verdict, note, hash }].
3316
+ */
3317
+ async function review(files, verdict, note = '') {
3318
+ if (!Array.isArray(files) || files.length === 0) {
3319
+ return { error: 'files must be a non-empty array' }
3320
+ }
3321
+ const v = String(verdict || '').toLowerCase()
3322
+ if (v !== 'pass' && v !== 'fail') {
3323
+ return { error: "verdict must be 'pass' or 'fail'" }
3324
+ }
3325
+
3326
+ // 1. Validate manifest presence (Claude addition i)
3327
+ for (const rel of files) {
3328
+ if (!inManifest(rel)) {
3329
+ return { error: `Refused: ${rel} is not in room manifest` }
3330
+ }
3331
+ }
3332
+
3333
+ // Ensure all target files are synced
3334
+ for (const rel of files) {
3335
+ const fe = openFile(rel)
3336
+ if (!fe.synced && fe.provider) {
3337
+ await new Promise((res) => {
3338
+ const t = setTimeout(res, 3000)
3339
+ fe.provider.on('sync', (isSynced) => {
3340
+ if (isSynced) {
3341
+ clearTimeout(t)
3342
+ res()
3343
+ }
3344
+ })
3345
+ })
3346
+ }
3347
+ if (!fe.synced) {
3348
+ return { error: `Refused: ${rel} has not finished syncing yet` }
3349
+ }
3350
+ }
3351
+
3352
+ // 2. Validate non-contributor status
3353
+ for (const rel of files) {
3354
+ const fe = openFile(rel)
3355
+ const curText = fe.text ? fe.text.toString() : ''
3356
+ const rs = fe.ledger ? fe.ledger.toArray() : []
3357
+ const authors = authorsOf(rs, verifyReceipt, curText)
3358
+ const isContributor = authors.contributors.some(
3359
+ (c) => c && (c.name === name || (identity && c.author === identity.id))
3360
+ )
3361
+ const head = fe.head ? fe.head.get('cur') : null
3362
+ const isHeadLander = head && head.by === name && head.hash === contentHash(curText)
3363
+ const boardEntry = board.get(rel)
3364
+ const isBoardAuthor = boardEntry && boardEntry.by === name
3365
+ const claimEntry = coordinator.sense(rel)
3366
+ const isClaimant = claimEntry && claimEntry.by === name
3367
+ if (isContributor || isHeadLander || isBoardAuthor || isClaimant) {
3368
+ return { error: `Refused: @${name} contributed to ${rel}'s held content; review must come from a non-contributor` }
3369
+ }
3370
+ }
3371
+
3372
+ // 3. Record review on each file
3373
+ const stamp = fmtTime()
3374
+ for (const rel of files) {
3375
+ const fe = openFile(rel)
3376
+ const curText = fe.text ? fe.text.toString() : ''
3377
+ const h = contentHash(curText)
3378
+ const entry = {
3379
+ file: rel,
3380
+ by: name,
3381
+ at: stamp,
3382
+ ts: Date.now(),
3383
+ verdict: v,
3384
+ note: String(note || ''),
3385
+ hash: h,
3386
+ }
3387
+ // ONLY per-review key to prevent concurrent clobbers under LWW
3388
+ reviews.set(`${rel}::${entry.ts}::${newId()}`, entry)
3389
+ }
3390
+
3391
+ say(`review ${v.toUpperCase()} on ${files.join(', ')} by ${name}${note ? ': ' + note : ''}`)
3392
+ return { ok: true, files, verdict: v }
3393
+ }
3394
+
3395
+ function getReviews(relPath) {
3396
+ const mapEntries = []
3397
+ const prefix = relPath ? relPath + '::' : ''
3398
+ for (const [k, r] of reviews.entries()) {
3399
+ if (relPath && k === relPath && Array.isArray(r)) {
3400
+ for (const item of r) if (item && typeof item === 'object') mapEntries.push(item)
3401
+ } else if (r && (!relPath || r.file === relPath || k.startsWith(prefix))) {
3402
+ mapEntries.push(r)
3403
+ }
3404
+ }
3405
+ const seen = new Set()
3406
+ const unique = []
3407
+ for (const r of mapEntries) {
3408
+ const key = `${r.file}|${r.by}|${r.ts}|${r.hash}|${r.verdict}`
3409
+ if (!seen.has(key)) {
3410
+ seen.add(key)
3411
+ unique.push(r)
3412
+ }
3413
+ }
3414
+ return unique.sort((a, b) => (a.ts || 0) - (b.ts || 0))
3415
+ }
3416
+
3417
+ function getReviewStatus(relPath) {
3418
+ const fe = openFile(relPath)
3419
+ const curText = fe.text ? fe.text.toString() : ''
3420
+ const curHash = contentHash(curText)
3421
+ const all = getReviews(relPath)
3422
+
3423
+ // Ignore reviews authored by contributors to the current held content
3424
+ const rs = fe.ledger ? fe.ledger.toArray() : []
3425
+ const authors = authorsOf(rs, verifyReceipt, curText)
3426
+ const contributorNames = new Set(authors.contributors.map((c) => c.name).filter(Boolean))
3427
+ const head = fe.head ? fe.head.get('cur') : null
3428
+ if (head && head.by && head.hash === curHash) contributorNames.add(head.by)
3429
+
3430
+ const validReviews = all.filter((r) => r && !contributorNames.has(r.by))
3431
+ const matching = validReviews.filter((r) => r.hash === curHash)
3432
+ if (!matching.length) {
3433
+ return { verdict: 'unreviewed', hash: curHash, stale: validReviews.length > 0, reviews: validReviews }
3434
+ }
3435
+ let lastFail = -1
3436
+ let lastPass = -1
3437
+ for (let i = 0; i < matching.length; i++) {
3438
+ if (matching[i].verdict === 'fail') lastFail = i
3439
+ if (matching[i].verdict === 'pass') lastPass = i
3440
+ }
3441
+ const finalVerdict = (lastPass > lastFail) ? 'pass' : 'fail'
3442
+ return { verdict: finalVerdict, hash: curHash, stale: false, reviews: matching }
3443
+ }
3244
3444
 
3245
3445
  // --- mission control: pause / resume / steer an agent ---
3246
3446
  // A human (or an owner) can PAUSE an agent: it should finish its current step,
@@ -4584,6 +4784,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4584
4784
  forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
4585
4785
  resolveFork, // propose the reconciled file; ICR-validated (parse + dangling) before it lands, refused otherwise
4586
4786
  edit: editFile, // first-class authored write: intent + transition stamped race-free, then normal reconcile
4787
+ // --- non-writer review & approval ---
4788
+ review,
4789
+ reviews: getReviews,
4790
+ reviewStatus: getReviewStatus,
4587
4791
  // --- convergent substrate: provenance (I1) ---
4588
4792
  identity: () => identity ? { id: identity.id, name: identity.name, pk: identity.pk } : null, // never exposes the private key
4589
4793
  provenanceOf: (relPath) => { const e = fileDocs.get(relPath); return e && e.ledger ? e.ledger.toArray() : [] }, // signed receipts for a file