hivecode-mcp 0.13.0 → 0.13.1

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
@@ -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
@@ -3198,18 +3200,51 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3198
3200
  // So agents coordinate among themselves, but a human can never make an agent
3199
3201
  // act without that agent's owner approving it.
3200
3202
  const newId = () => 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
3203
+
3204
+ const getMemberNames = () => {
3205
+ const names = new Set()
3206
+ if (provider && provider.awareness) {
3207
+ for (const s of provider.awareness.getStates().values()) {
3208
+ const u = s && s.user
3209
+ if (u && u.name) names.add(u.name)
3210
+ }
3211
+ }
3212
+ // Union with durable activity map (non-gone keys)
3213
+ try {
3214
+ const act = doc.getMap('activity')
3215
+ if (act) {
3216
+ for (const [k, v] of act.entries()) {
3217
+ if (v && !v.gone && k) names.add(k)
3218
+ }
3219
+ }
3220
+ } catch { }
3221
+ // Union with recent chat senders
3222
+ try {
3223
+ const chatArr = doc.getArray('chat')
3224
+ if (chatArr) {
3225
+ for (const msg of chatArr.toArray()) {
3226
+ if (msg && msg.by) names.add(msg.by)
3227
+ }
3228
+ }
3229
+ } catch { }
3230
+ names.add(name)
3231
+ return [...names]
3232
+ }
3233
+
3201
3234
  function assign(to, text) {
3202
3235
  if (!to || !text) return null
3236
+ const normTo = String(to).trim() === '@all' ? 'all' : String(to).trim()
3203
3237
  const id = newId()
3204
- const ownerOf = owners.get(to)
3238
+ const ownerOf = owners.get(normTo)
3205
3239
  // 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
3240
+ // or the asker IS the target's owner, or an @all task.
3241
+ const auto = kind === 'ai' || !ownerOf || name === ownerOf || normTo === 'all'
3208
3242
  const status = auto ? 'accepted' : 'pending'
3209
3243
  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?)`)
3244
+ const membersAt = getMemberNames()
3245
+ tasks.set(id, { id, to: normTo, by: name, byKind: kind, text: String(text), status, decidedBy, at: fmtTime(), membersAt, createdAt: Date.now() })
3246
+ if (auto) say(`@${normTo}: ${text} (task ${id} — ${kind === 'ai' ? 'AI coordination, proceeding' : 'from owner, proceeding'})`)
3247
+ else say(`@${normTo}: ${text} (task ${id} — ${ownerOf} must approve: do it or ignore?)`)
3213
3248
  return id
3214
3249
  }
3215
3250
  // A decision is about a task that is WAITING for one. Approving an accepted or done task used to
@@ -3228,19 +3263,182 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3228
3263
  say(`${accept ? `@${t.to} ` : ''}task ${id} ${accept ? 'APPROVED' : 'denied'} by ${by}: "${t.text}"`)
3229
3264
  return { ok: true }
3230
3265
  }
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.
3266
+ // Only the ASSIGNEE finishes a task, and only work it was cleared to do.
3267
+ // For @all tasks (Claude addition iii), any member can complete it provided they
3268
+ // were in the room when the task was created, or hold a claim / signed receipt / review on the work.
3234
3269
  function complete(id, note = '') {
3235
3270
  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` }
3271
+ const isAll = t.to === 'all' || t.to === '@all'
3272
+ if (!isAll && t.to !== name) return { error: `task ${id} is ${t.to}'s -- only ${t.to} can complete it` }
3237
3273
  if (t.status === 'done') return { ok: true, already: true }
3238
3274
  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 })
3275
+
3276
+ if (isAll) {
3277
+ const wasPresent = Array.isArray(t.membersAt) ? t.membersAt.includes(name) : (t.by === name)
3278
+ const hasClaim = coordinator ? coordinator.board().some((c) => c && c.by === name) : false
3279
+ let hasReceipt = false
3280
+ for (const e of fileDocs.values()) {
3281
+ if (e && e.ledger) {
3282
+ const rs = e.ledger.toArray()
3283
+ if (rs.some((r) => r && (r.name === name || (identity && r.author === identity.id)))) {
3284
+ hasReceipt = true
3285
+ break
3286
+ }
3287
+ }
3288
+ }
3289
+ let hasReview = false
3290
+ try {
3291
+ for (const r of getReviews('')) {
3292
+ if (r && r.by === name) { hasReview = true; break }
3293
+ }
3294
+ if (!hasReview) {
3295
+ for (const [k, r] of reviews.entries()) {
3296
+ if (r && r.by === name) { hasReview = true; break }
3297
+ }
3298
+ }
3299
+ } catch { }
3300
+ if (!wasPresent && !hasClaim && !hasReceipt && !hasReview) {
3301
+ 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' }
3302
+ }
3303
+ }
3304
+
3305
+ tasks.set(id, { ...t, status: 'done', completedBy: name, decidedBy: t.decidedBy })
3240
3306
  say(`task ${id} done by ${name}${note ? ': ' + note : ''}`)
3241
3307
  return { ok: true }
3242
3308
  }
3243
- const myTasks = () => [...tasks.values()].filter((t) => t.to === name)
3309
+ const myTasks = () => [...tasks.values()].filter((t) => t.to === name || t.to === 'all' || t.to === '@all')
3310
+
3311
+ /**
3312
+ * Room review: record a non-writer review verdict on files in the room.
3313
+ * Stored in Y.Map 'reviews' as an append-only list per file: [{ by, at, verdict, note, hash }].
3314
+ */
3315
+ async function review(files, verdict, note = '') {
3316
+ if (!Array.isArray(files) || files.length === 0) {
3317
+ return { error: 'files must be a non-empty array' }
3318
+ }
3319
+ const v = String(verdict || '').toLowerCase()
3320
+ if (v !== 'pass' && v !== 'fail') {
3321
+ return { error: "verdict must be 'pass' or 'fail'" }
3322
+ }
3323
+
3324
+ // 1. Validate manifest presence (Claude addition i)
3325
+ for (const rel of files) {
3326
+ if (!inManifest(rel)) {
3327
+ return { error: `Refused: ${rel} is not in room manifest` }
3328
+ }
3329
+ }
3330
+
3331
+ // Ensure all target files are synced
3332
+ for (const rel of files) {
3333
+ const fe = openFile(rel)
3334
+ if (!fe.synced && fe.provider) {
3335
+ await new Promise((res) => {
3336
+ const t = setTimeout(res, 3000)
3337
+ fe.provider.on('sync', (isSynced) => {
3338
+ if (isSynced) {
3339
+ clearTimeout(t)
3340
+ res()
3341
+ }
3342
+ })
3343
+ })
3344
+ }
3345
+ if (!fe.synced) {
3346
+ return { error: `Refused: ${rel} has not finished syncing yet` }
3347
+ }
3348
+ }
3349
+
3350
+ // 2. Validate non-contributor status
3351
+ for (const rel of files) {
3352
+ const fe = openFile(rel)
3353
+ const curText = fe.text ? fe.text.toString() : ''
3354
+ const rs = fe.ledger ? fe.ledger.toArray() : []
3355
+ const authors = authorsOf(rs, verifyReceipt, curText)
3356
+ const isContributor = authors.contributors.some(
3357
+ (c) => c && (c.name === name || (identity && c.author === identity.id))
3358
+ )
3359
+ const head = fe.head ? fe.head.get('cur') : null
3360
+ const isHeadLander = head && head.by === name && head.hash === contentHash(curText)
3361
+ const boardEntry = board.get(rel)
3362
+ const isBoardAuthor = boardEntry && boardEntry.by === name
3363
+ const claimEntry = coordinator.sense(rel)
3364
+ const isClaimant = claimEntry && claimEntry.by === name
3365
+ if (isContributor || isHeadLander || isBoardAuthor || isClaimant) {
3366
+ return { error: `Refused: @${name} contributed to ${rel}'s held content; review must come from a non-contributor` }
3367
+ }
3368
+ }
3369
+
3370
+ // 3. Record review on each file
3371
+ const stamp = fmtTime()
3372
+ for (const rel of files) {
3373
+ const fe = openFile(rel)
3374
+ const curText = fe.text ? fe.text.toString() : ''
3375
+ const h = contentHash(curText)
3376
+ const entry = {
3377
+ file: rel,
3378
+ by: name,
3379
+ at: stamp,
3380
+ ts: Date.now(),
3381
+ verdict: v,
3382
+ note: String(note || ''),
3383
+ hash: h,
3384
+ }
3385
+ // ONLY per-review key to prevent concurrent clobbers under LWW
3386
+ reviews.set(`${rel}::${entry.ts}::${newId()}`, entry)
3387
+ }
3388
+
3389
+ say(`review ${v.toUpperCase()} on ${files.join(', ')} by ${name}${note ? ': ' + note : ''}`)
3390
+ return { ok: true, files, verdict: v }
3391
+ }
3392
+
3393
+ function getReviews(relPath) {
3394
+ const mapEntries = []
3395
+ const prefix = relPath ? relPath + '::' : ''
3396
+ for (const [k, r] of reviews.entries()) {
3397
+ if (relPath && k === relPath && Array.isArray(r)) {
3398
+ for (const item of r) if (item && typeof item === 'object') mapEntries.push(item)
3399
+ } else if (r && (!relPath || r.file === relPath || k.startsWith(prefix))) {
3400
+ mapEntries.push(r)
3401
+ }
3402
+ }
3403
+ const seen = new Set()
3404
+ const unique = []
3405
+ for (const r of mapEntries) {
3406
+ const key = `${r.file}|${r.by}|${r.ts}|${r.hash}|${r.verdict}`
3407
+ if (!seen.has(key)) {
3408
+ seen.add(key)
3409
+ unique.push(r)
3410
+ }
3411
+ }
3412
+ return unique.sort((a, b) => (a.ts || 0) - (b.ts || 0))
3413
+ }
3414
+
3415
+ function getReviewStatus(relPath) {
3416
+ const fe = openFile(relPath)
3417
+ const curText = fe.text ? fe.text.toString() : ''
3418
+ const curHash = contentHash(curText)
3419
+ const all = getReviews(relPath)
3420
+
3421
+ // Ignore reviews authored by contributors to the current held content
3422
+ const rs = fe.ledger ? fe.ledger.toArray() : []
3423
+ const authors = authorsOf(rs, verifyReceipt, curText)
3424
+ const contributorNames = new Set(authors.contributors.map((c) => c.name).filter(Boolean))
3425
+ const head = fe.head ? fe.head.get('cur') : null
3426
+ if (head && head.by && head.hash === curHash) contributorNames.add(head.by)
3427
+
3428
+ const validReviews = all.filter((r) => r && !contributorNames.has(r.by))
3429
+ const matching = validReviews.filter((r) => r.hash === curHash)
3430
+ if (!matching.length) {
3431
+ return { verdict: 'unreviewed', hash: curHash, stale: validReviews.length > 0, reviews: validReviews }
3432
+ }
3433
+ let lastFail = -1
3434
+ let lastPass = -1
3435
+ for (let i = 0; i < matching.length; i++) {
3436
+ if (matching[i].verdict === 'fail') lastFail = i
3437
+ if (matching[i].verdict === 'pass') lastPass = i
3438
+ }
3439
+ const finalVerdict = (lastPass > lastFail) ? 'pass' : 'fail'
3440
+ return { verdict: finalVerdict, hash: curHash, stale: false, reviews: matching }
3441
+ }
3244
3442
 
3245
3443
  // --- mission control: pause / resume / steer an agent ---
3246
3444
  // A human (or an owner) can PAUSE an agent: it should finish its current step,
@@ -4584,6 +4782,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4584
4782
  forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
4585
4783
  resolveFork, // propose the reconciled file; ICR-validated (parse + dangling) before it lands, refused otherwise
4586
4784
  edit: editFile, // first-class authored write: intent + transition stamped race-free, then normal reconcile
4785
+ // --- non-writer review & approval ---
4786
+ review,
4787
+ reviews: getReviews,
4788
+ reviewStatus: getReviewStatus,
4587
4789
  // --- convergent substrate: provenance (I1) ---
4588
4790
  identity: () => identity ? { id: identity.id, name: identity.name, pk: identity.pk } : null, // never exposes the private key
4589
4791
  provenanceOf: (relPath) => { const e = fileDocs.get(relPath); return e && e.ledger ? e.ledger.toArray() : [] }, // signed receipts for a file