xuym-codex-collab 0.4.2 → 0.4.4

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.
@@ -1,5 +1,6 @@
1
1
  import { digest, verifyEvent } from './config.mjs';
2
2
  import { Denied } from './protocol.mjs';
3
+ import { validReviewContext, assertReviewIntent } from './natural-review.mjs';
3
4
 
4
5
  export const inboxIdPattern = /^N-[A-F0-9]{32}$/;
5
6
  export const inboxTaskId = (id) => `T-${id.slice(2)}`;
@@ -7,7 +8,8 @@ export const inboxMessageKey = (agentId, chatId, messageId) => `inbox-message:${
7
8
  const text = (value, max) => typeof value === 'string' && !!value.trim() && value.length <= max;
8
9
  const fields = (value, allowed) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every((key) => allowed.includes(key));
9
10
  export function validateInboxSpec(spec, team) {
10
- if (!fields(spec, ['schema_version', 'id', 'repo', 'target_agent', 'chat_id', 'sender_id', 'content', 'message_key', 'admin_dispatch']) || spec.schema_version !== 1 || !inboxIdPattern.test(spec.id || '') || spec.repo !== team.repo || !text(spec.target_agent, 64) || !/^[a-zA-Z0-9_-]+$/.test(spec.target_agent) || !Object.hasOwn(team.agents, spec.target_agent) || !text(spec.chat_id, 200) || !text(spec.sender_id, 200) || !text(spec.content, 4000) || !text(spec.message_key, 300) || typeof spec.admin_dispatch !== 'boolean' || spec.id !== `N-${digest([spec.target_agent, spec.message_key]).slice(0, 32).toUpperCase()}`) throw new Error('invalid natural-language inbox specification');
11
+ if (!fields(spec, ['schema_version', 'id', 'repo', 'target_agent', 'chat_id', 'sender_id', 'content', 'message_key', 'admin_dispatch', 'review_context']) || spec.schema_version !== 1 || !inboxIdPattern.test(spec.id || '') || spec.repo !== team.repo || !text(spec.target_agent, 64) || !/^[a-zA-Z0-9_-]+$/.test(spec.target_agent) || !Object.hasOwn(team.agents, spec.target_agent) || !text(spec.chat_id, 200) || !text(spec.sender_id, 200) || !text(spec.content, 4000) || !text(spec.message_key, 300) || typeof spec.admin_dispatch !== 'boolean' || spec.id !== `N-${digest([spec.target_agent, spec.message_key]).slice(0, 32).toUpperCase()}`) throw new Error('invalid natural-language inbox specification');
12
+ if (spec.review_context !== undefined && (!validReviewContext(spec.review_context) || team.agents[spec.target_agent].wecom_userid !== spec.sender_id)) throw new Denied('invalid human review context');
11
13
  const owner = team.agents[spec.target_agent].wecom_userid === spec.sender_id;
12
14
  const senders = Object.values(team.agents).filter((agent) => agent.wecom_userid === spec.sender_id);
13
15
  const member = senders.some((agent) => agent.role === 'member');
@@ -22,11 +24,11 @@ export function reduceInbox(spec, events, team) {
22
24
  const keys = new Set(); const ids = new Set(); let previousTime = -Infinity;
23
25
  for (const event of events) {
24
26
  verifyEvent(team, event);
25
- if (!fields(event, ['schema_version', 'request_id', 'seq', 'event_id', 'actor_id', 'occurred_at', 'idempotency_key', 'team_hash', 'signature', 'type', 'spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash'])) throw new Error('unsupported inbox event fields');
27
+ if (!fields(event, ['schema_version', 'request_id', 'seq', 'event_id', 'actor_id', 'occurred_at', 'idempotency_key', 'team_hash', 'signature', 'type', 'spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash', 'review_seq', 'decision', 'summary', 'task_event_hash'])) throw new Error('unsupported inbox event fields');
26
28
  const time = Date.parse(event.occurred_at);
27
29
  if (event.schema_version !== 1 || event.request_id !== spec.id || event.actor_id !== spec.target_agent || event.seq !== state.seq + 1 || !Number.isFinite(time) || !event.occurred_at.endsWith('Z') || time < previousTime || !/^[a-f0-9-]{36}$/.test(event.event_id || '') || !text(event.idempotency_key, 300) || keys.has(event.idempotency_key) || ids.has(event.event_id)) throw new Error('invalid inbox sequence, actor or duplicate event');
28
- const allowed = { received: ['spec_hash'], clarified: ['response'], replied: ['content', 'message_key'], task_created: ['task_id', 'task_hash', 'response'], answered: ['response'] }[event.type];
29
- if (!allowed || ['spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash'].some((key) => event[key] !== undefined && !allowed.includes(key))) throw new Error('invalid inbox event payload');
30
+ const allowed = { received: ['spec_hash'], clarified: ['response'], replied: ['content', 'message_key'], task_created: ['task_id', 'task_hash', 'response'], answered: ['response'], reviewed: ['task_id', 'review_seq', 'decision', 'summary', 'task_event_hash', 'response'] }[event.type];
31
+ if (!allowed || ['spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash', 'review_seq', 'decision', 'summary', 'task_event_hash'].some((key) => event[key] !== undefined && !allowed.includes(key))) throw new Error('invalid inbox event payload');
30
32
  switch (event.type) {
31
33
  case 'received':
32
34
  if (state.seq || event.spec_hash !== digest(spec) || event.idempotency_key !== spec.message_key) throw new Error('invalid inbox reception');
@@ -38,6 +40,11 @@ export function reduceInbox(spec, events, team) {
38
40
  case 'replied':
39
41
  if (state.status !== 'clarification' || !text(event.content, 4000) || !text(event.message_key, 300) || event.idempotency_key !== event.message_key) throw new Error('invalid clarification reply');
40
42
  state.status = 'pending'; break;
43
+ case 'reviewed': {
44
+ if (state.status !== 'pending' || !Number.isSafeInteger(event.review_seq) || event.review_seq < 2 || !text(event.response, 2000) || !/^[a-f0-9]{64}$/.test(event.task_event_hash || '')) throw new Error('invalid inbox human review');
45
+ assertReviewIntent({ spec, events: events.slice(0, event.seq - 1) }, { task_id: event.task_id, expected_seq: event.review_seq - 1, decision: event.decision, ...(event.summary !== undefined ? { summary: event.summary } : {}) }, spec.target_agent, team);
46
+ state.status = 'reviewed'; state.review_task_id = event.task_id; break;
47
+ }
41
48
  case 'task_created':
42
49
  if (state.status !== 'pending' || event.task_id !== inboxTaskId(spec.id) || !/^[a-f0-9]{64}$/.test(event.task_hash || '') || !text(event.response, 2000)) throw new Error('invalid inbox task resolution');
43
50
  state.status = 'task_created'; state.task_id = event.task_id; break;
@@ -0,0 +1,105 @@
1
+ import { Conflict, Denied } from './protocol.mjs';
2
+
3
+ export const reviewTaskPattern = /^T-[A-Z0-9-]{1,62}$/;
4
+ const fields = (value, allowed) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every((key) => allowed.includes(key));
5
+
6
+ // Freeze the human's review scope when their authenticated group message arrives.
7
+ // Model interpretation must never silently substitute a newer task revision.
8
+ export function reviewContext(tasks, agentId, senderId, chatId, team) {
9
+ if (team.agents[agentId]?.wecom_userid !== senderId) return [];
10
+ return tasks.filter((task) => task.spec.source?.channel === 'wecom' && task.spec.source.chat_id === chatId
11
+ && ['stage_review', 'review'].includes(task.state.status) && task.state.assignee === agentId
12
+ && (task.team || team).agents[agentId]?.wecom_userid === senderId)
13
+ .map((task) => ({ task_id: task.spec.id, expected_seq: task.state.seq, title: task.spec.title,
14
+ commit: task.state.checkpoint.commit, stage: task.state.stage }))
15
+ .sort((a, b) => a.task_id.localeCompare(b.task_id));
16
+ }
17
+
18
+ export function validReviewContext(value) {
19
+ return Array.isArray(value) && value.length <= 100 && new Set(value.map((item) => item?.task_id)).size === value.length
20
+ && value.every((item) => fields(item, ['task_id', 'expected_seq', 'title', 'commit', 'stage'])
21
+ && reviewTaskPattern.test(item.task_id || '') && Number.isSafeInteger(item.expected_seq) && item.expected_seq > 0
22
+ && typeof item.title === 'string' && !!item.title.trim() && item.title.length <= 200
23
+ && /^[a-f0-9]{40}$/.test(item.commit || '') && Number.isSafeInteger(item.stage) && item.stage >= 0);
24
+ }
25
+
26
+ export function assertReviewIntent(request, review, agentId, team) {
27
+ if (!fields(review, ['task_id', 'expected_seq', 'decision', 'summary']) || !reviewTaskPattern.test(review.task_id || '')
28
+ || !Number.isSafeInteger(review.expected_seq) || review.expected_seq < 1
29
+ || !['approved', 'changes_requested'].includes(review.decision)
30
+ || review.summary !== undefined && (typeof review.summary !== 'string' || !review.summary.trim() || review.summary.length > 2000)
31
+ || review.decision === 'changes_requested' && !review.summary) throw new Conflict('invalid human review interpretation; rejection requires feedback');
32
+ if (request.spec.sender_id !== team.agents[agentId]?.wecom_userid) throw new Denied('only the authenticated bot owner may review');
33
+ const candidates = request.spec.review_context || [];
34
+ const target = candidates.find((item) => item.task_id === review.task_id && item.expected_seq === review.expected_seq);
35
+ if (!target) throw new Conflict('review target/version was not pending when this human message arrived; ask for a new review message');
36
+ const humanMessages = [request.spec.content, ...request.events.filter((event) => event.type === 'replied').map((event) => event.content)];
37
+ // Keep a person's task selection across clarification turns. Never use the
38
+ // model's question/answer text as evidence of which task the person selected.
39
+ let selected = false; let revisionSelected = false;
40
+ for (let i = humanMessages.length - 1; i >= 0; i--) {
41
+ const selection = reviewSelection(humanMessages[i], candidates, i > 0);
42
+ if (!selected && selection.ids.length) {
43
+ if (selection.ids.length !== 1 || selection.ids[0] !== target.task_id) throw new Conflict('explicit task selection does not match the selected review; clarify conflicting choices');
44
+ selected = true;
45
+ }
46
+ if (!revisionSelected && selection.revisions.length) {
47
+ if (selection.revisions.length !== 1 || selection.revisions[0] !== target.expected_seq) throw new Conflict('explicit task or revision does not match the selected review');
48
+ revisionSelected = true;
49
+ }
50
+ }
51
+ if (candidates.length > 1 && !selected) throw new Conflict('multiple pending reviews; clarify the task first using its title, list number or ID');
52
+ const decisionText = (value) => {
53
+ for (const item of candidates.toSorted((a, b) => b.title.length - a.title.length)) value = value.replaceAll(item.title, '').replaceAll(item.task_id, '');
54
+ return value.replace(/\bN-[A-F0-9]{32}\b|\bR-[1-9][0-9]*\b/g, '').trim();
55
+ };
56
+ const selectionOnly = (value) => /^(?:(?:我选|选择|选|是)\s*)?(?:第\s*(?:[1-9][0-9]*|[一二三四五六七八九十百]+)\s*[个项条])?(?:这个|那个|任务|这条|那条)?[。!!\s]*$/.test(value);
57
+ let latestIntent = null;
58
+ for (const human of humanMessages.toReversed()) {
59
+ const value = decisionText(human);
60
+ if (selectionOnly(value)) continue;
61
+ latestIntent = explicitReviewDecision(value); break;
62
+ }
63
+ if (review.summary && !humanMessages.some((message) => message.includes(review.summary))) throw new Conflict('review feedback must quote actual human instructions');
64
+ if (review.decision === 'changes_requested') {
65
+ const feedback = review.summary.replace(/先别通过|不要通过|不通过|退回|需要修改|审核|通过|同意|批准/g, '').replace(/[\s,,。!!;;::]/g, '');
66
+ if (feedback.length < 2) throw new Conflict('rejection feedback must quote concrete human instructions; ask for missing feedback');
67
+ }
68
+ if (latestIntent !== review.decision) throw new Conflict('no matching explicit human review decision; clarify instead of guessing');
69
+ return target;
70
+ }
71
+
72
+ // A conservative consent check supplements model interpretation. Unclear or
73
+ // conditional prose becomes a clarification, never an inferred approval.
74
+ export function explicitReviewDecision(content) {
75
+ const value = content.trim().replace(/^@[^\s@]{1,40}\s+/, '');
76
+ if (/[??]/.test(value) || /(?:如果|假如|若是|等.+(?:通过|完成)|通过(?:以后|之后|后)再|不要退回|别退回|不退回)/.test(value)) return null;
77
+ if (/(?:退回|不通过|不批准|不同意|先别通过|不要通过|还不行|需要修改|再改|再补|补充|补一下|改成|改为)/.test(value)) return 'changes_requested';
78
+ if (/(?:先等等|先等一下|等一下|稍等|暂缓|暂不|先不|先别|别急|待确认)/.test(value)) return null;
79
+ if (/(?:^|[,,。!!;;\n])\s*(?:我(?:确认|同意)\s*)?(?:审核|验收)?(?:通过|同意|批准|没问题|可以了|可以继续|就这样|OK|ok)(?=$|[,,。!!;;\s]|了|吧)/.test(value)) return 'approved';
80
+ return null;
81
+ }
82
+
83
+ function reviewSelection(value, candidates, afterClarification) {
84
+ const ids = new Set(value.match(/\bT-[A-Z0-9-]{1,62}\b/g) || []);
85
+ const revisions = [...new Set((value.match(/\bR-[1-9][0-9]*\b/g) || []).map((item) => Number(item.slice(2))))];
86
+ // Match longer titles first so a title prefix isn't a second selection.
87
+ for (const candidate of candidates.toSorted((a, b) => b.title.length - a.title.length)) {
88
+ if (value.includes(candidate.title)) {
89
+ for (const sameTitle of candidates.filter((item) => item.title === candidate.title)) ids.add(sameTitle.task_id);
90
+ value = value.replaceAll(candidate.title, '');
91
+ }
92
+ }
93
+ for (const match of value.matchAll(/第\s*([1-9][0-9]*|[一二三四五六七八九十百]+)\s*(?:个|项|条)/g)) {
94
+ ids.add(afterClarification ? candidates[reviewIndex(match[1]) - 1]?.task_id || 'invalid-list-selection' : 'unconfirmed-list-selection');
95
+ }
96
+ return { ids: [...ids], revisions };
97
+ }
98
+
99
+ function reviewIndex(token) {
100
+ if (/^[0-9]+$/.test(token)) return Number(token);
101
+ if (token === '一百') return 100;
102
+ const digits = '零一二三四五六七八九';
103
+ if (token.includes('十')) { const [tens, ones] = token.split('十'); return (tens ? digits.indexOf(tens) : 1) * 10 + (ones ? digits.indexOf(ones) : 0); }
104
+ return digits.indexOf(token);
105
+ }
@@ -22,19 +22,19 @@ export function verifyRecord(record, publicKey) {
22
22
  return body;
23
23
  }
24
24
  export function recordHash(record) { const { signature, ...body } = record; return digest(body); }
25
- export function policyVersion(team, previous, actorId, privateKey, enrollment) {
25
+ export function policyVersion(team, previous, actorId, privateKey, enrollment, invitationReplacement) {
26
26
  validateTeam(team);
27
27
  const value = { schema_version: 1, version: previous ? previous.version + 1 : 1,
28
28
  parent_hash: previous ? recordHash(previous) : null, team_hash: digest(team), team, actor_id: actorId,
29
- ...(enrollment ? { enrollment } : {}) };
29
+ ...(enrollment ? { enrollment } : {}), ...(invitationReplacement ? { invitation_replacement: invitationReplacement } : {}) };
30
30
  return signRecord(value, privateKey);
31
31
  }
32
32
  export function validateHistory(records, rootHash) {
33
33
  if (!Array.isArray(records) || !records.length || !hashPattern.test(rootHash || '') || recordHash(records[0]) !== rootHash) fail();
34
- const teams = new Map();
34
+ const teams = new Map(), revokedInvites = new Set(), replacementTargets = new Set(), consumedInvites = new Set();
35
35
  let previous;
36
36
  for (const record of records) {
37
- fields(record, ['schema_version', 'version', 'parent_hash', 'team_hash', 'team', 'actor_id', 'enrollment', 'signature']);
37
+ fields(record, ['schema_version', 'version', 'parent_hash', 'team_hash', 'team', 'actor_id', 'enrollment', 'invitation_replacement', 'signature']);
38
38
  if (record.schema_version !== 1 || record.version !== (previous?.version || 0) + 1 || record.parent_hash !== (previous ? recordHash(previous) : null)) fail();
39
39
  const team = validateTeam(record.team);
40
40
  const authority = previous?.team || team;
@@ -45,13 +45,25 @@ export function validateHistory(records, rootHash) {
45
45
  if (previous) for (const [id, agent] of Object.entries(authority.agents)) {
46
46
  if (!team.agents[id] || team.agents[id].public_key !== agent.public_key || team.agents[id].role !== agent.role || team.agents[id].wecom_userid !== agent.wecom_userid) fail();
47
47
  }
48
+ if (record.invitation_replacement) {
49
+ const replacement = record.invitation_replacement;
50
+ fields(replacement, ['old_invite_id', 'new_invite_id']);
51
+ const { old_invite_id: oldId, new_invite_id: newId } = replacement;
52
+ if (!previous || record.enrollment || record.team_hash !== previous.team_hash ||
53
+ !uuidPattern.test(oldId || '') || !uuidPattern.test(newId || '') || oldId === newId ||
54
+ revokedInvites.has(oldId) || revokedInvites.has(newId) || replacementTargets.has(newId) ||
55
+ consumedInvites.has(oldId) || consumedInvites.has(newId)) fail();
56
+ revokedInvites.add(oldId); replacementTargets.add(newId);
57
+ }
48
58
  if (record.enrollment) {
59
+ if (revokedInvites.has(record.enrollment.invite_id)) fail();
60
+ consumedInvites.add(record.enrollment.invite_id);
49
61
  fields(record.enrollment, ['invite_id', 'request_id']);
50
62
  if (!uuidPattern.test(record.enrollment.invite_id || '') || !uuidPattern.test(record.enrollment.request_id || '')) fail();
51
63
  }
52
64
  teams.set(record.team_hash, team); previous = record;
53
65
  }
54
- return { records, teams, latest: previous, root_hash: rootHash };
66
+ return { records, teams, latest: previous, root_hash: rootHash, revoked_invites: revokedInvites };
55
67
  }
56
68
  export async function readRegistry(checkout, rootHash) {
57
69
  const dir = join(checkout, registryPath, 'versions');
@@ -76,6 +88,7 @@ export function invitationRecord(input, record, actorId, privateKey) {
76
88
  return signed;
77
89
  }
78
90
  export function validateInvitation(invite, history) {
91
+ if (history.revoked_invites?.has(invite?.invite_id)) fail();
79
92
  fields(invite, ['schema_version', 'invite_id', 'agent_id', 'capabilities', 'bot_id', 'chat_ids', 'commands_enabled', 'prefer', 'claim_public_key', 'expires_at', 'team_hash', 'actor_id', 'signature', 'owner_userid']);
80
93
  const team = history.teams.get(invite.team_hash);
81
94
  if (invite.schema_version !== 1 || !uuidPattern.test(invite.invite_id || '') || !idPattern.test(invite.agent_id || '') || !team || team.agents[invite.actor_id]?.role !== 'admin' ||
@@ -1,4 +1,5 @@
1
1
  import { stageReviewRule } from './review.mjs';
2
+ import { reviewContext, assertReviewIntent } from './natural-review.mjs';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { digest } from './config.mjs';
4
5
  import { canClaimDesktop, Conflict, Denied, eligible, leaseToken, validRefs } from './protocol.mjs';
@@ -22,7 +23,7 @@ export class PeerService {
22
23
  const { chatId, messageId, senderId, content, allowAdmin = false } = input || {};
23
24
  if (!plainFields(input, ['chatId', 'messageId', 'senderId', 'content', 'allowAdmin']) || !validText(chatId, 200) || !validText(messageId, 200) || !validText(senderId, 200) || !validText(content, 4000) || typeof allowAdmin !== 'boolean') throw new Conflict('invalid group request');
24
25
  const key = inboxMessageKey(this.agentId, chatId, messageId);
25
- return this.store.transactInbox(key, (requests) => {
26
+ return this.store.transactInbox(key, async (requests) => {
26
27
  const owner = this.team.agents[this.agentId]?.wecom_userid === senderId;
27
28
  const senderAgents = Object.values(this.team.agents).filter((agent) => agent.wecom_userid === senderId);
28
29
  const member = senderAgents.some((agent) => agent.role === 'member');
@@ -36,8 +37,9 @@ export class PeerService {
36
37
  if (!referencedId && waiting.length > 1) throw Object.assign(new Conflict('multiple requests await clarification; include the N-request ID in your reply'), { code: 'AMBIGUOUS_INBOX_REPLY' });
37
38
  if (parent) return { id: parent.spec.id, event: { type: 'replied', content, message_key: key } };
38
39
  const id = `N-${digest([this.agentId, key]).slice(0, 32).toUpperCase()}`;
40
+ const context = reviewContext(await this.store.listLocal(), this.agentId, senderId, chatId, this.team);
39
41
  return { id, spec: { schema_version: 1, id, repo: this.team.repo, target_agent: this.agentId, chat_id: chatId,
40
- sender_id: senderId, content, message_key: key, admin_dispatch: !!admin }, event: { type: 'received' } };
42
+ sender_id: senderId, content, message_key: key, admin_dispatch: !!admin, ...(context.length ? { review_context: context } : {}) }, event: { type: 'received' } };
41
43
  }, { replay: (request) => {
42
44
  const event = request.events.find((item) => item.idempotency_key === key);
43
45
  const original = event.type === 'received' ? request.spec.content : event.content;
@@ -50,18 +52,33 @@ export class PeerService {
50
52
  } });
51
53
  }
52
54
  async resolveRequest(id, expectedSeq, resolution, { defaultWorkflow } = {}) {
53
- const { action, response, task: input } = resolution || {};
54
- if (!plainFields(resolution, ['action', 'response', 'task']) || response !== undefined && !validText(response, 2000)) throw new Conflict('invalid request resolution');
55
+ const { action, response, task: input, review } = resolution || {};
56
+ if (action === 'review' && response !== undefined) throw new Conflict('review confirmation is generated from the committed decision');
57
+ if (!plainFields(resolution, ['action', 'response', 'task', 'review']) || response !== undefined && !validText(response, 2000)) throw new Conflict('invalid request resolution');
55
58
  if (!inboxIdPattern.test(id || '') || !Number.isSafeInteger(expectedSeq) || expectedSeq < 1) throw new Conflict('provide request ID and latest expected_seq');
56
- if (!['create_task', 'clarify', 'answer'].includes(action)) throw new Conflict('unsupported request resolution');
59
+ if (!['create_task', 'clarify', 'answer', 'review'].includes(action)) throw new Conflict('unsupported request resolution');
60
+ if (action !== 'review' && review !== undefined) throw new Conflict('review payload requires review action');
61
+ if (action === 'review' && (input !== undefined || !review)) throw new Conflict('review requires a human interpretation without a new task');
57
62
  if (action === 'create_task') {
58
63
  if (!plainFields(input, ['title', 'description', 'acceptance', 'workflow']) || !validText(input.title, 200) || input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 4000) || !validList(input.acceptance, 32, 1000) || input.workflow !== undefined && !validList(input.workflow, 16, 64)) throw new Conflict('invalid structured task');
59
- } else if (input !== undefined || !validText(response, 2000)) throw new Conflict('answer and clarification require a response without a task');
64
+ } else if (action !== 'review' && (input !== undefined || !validText(response, 2000))) throw new Conflict('answer and clarification require a response without a task');
60
65
  const key = `inbox-resolve:${id}:${expectedSeq}`;
61
- const result = await this.store.transactInbox(key, (requests) => {
66
+ const result = await this.store.transactInbox(key, async (requests) => {
62
67
  const request = requests.find((item) => item.spec.id === id);
63
68
  if (!request || request.spec.target_agent !== this.agentId) throw new Denied('only the receiving bot identity may interpret this request');
64
69
  if (request.state.status !== 'pending' || request.state.seq !== expectedSeq) throw new Conflict('request changed or is no longer pending');
70
+ if (action === 'review') {
71
+ assertReviewIntent(request, review, this.agentId, this.team);
72
+ const task = await this.store.readLocal(review.task_id);
73
+ const frozen = request.spec.review_context.find((item) => item.task_id === review.task_id);
74
+ if (task.state.seq !== review.expected_seq || task.state.checkpoint?.commit !== frozen.commit
75
+ || !['stage_review', 'review'].includes(task.state.status) || task.state.assignee !== this.agentId
76
+ || task.spec.source?.channel !== 'wecom' || task.spec.source.chat_id !== request.spec.chat_id) throw new Conflict('review changed; ask the human to review the latest result in a new message');
77
+ if ((task.team || this.team).agents[this.agentId]?.wecom_userid !== request.spec.sender_id) throw new Denied('sender is not the pinned human reviewer');
78
+ return { id, taskReview: review, event: { type: 'reviewed', task_id: review.task_id, review_seq: review.expected_seq + 1,
79
+ decision: review.decision, ...(review.summary ? { summary: review.summary } : {}),
80
+ response: `任务「${task.spec.title}」${review.decision === 'approved' ? '审核通过。' : `已退回修改:${review.summary}`}(${review.task_id})` } };
81
+ }
65
82
  if (action !== 'create_task') return { id, event: { type: action === 'clarify' ? 'clarified' : 'answered', response } };
66
83
  if (!input) throw new Conflict('a structured task is required');
67
84
  const taskSpec = { schema_version: 2, id: inboxTaskId(id), repo: this.team.repo, title: input.title,
@@ -70,8 +87,12 @@ export class PeerService {
70
87
  return { id, taskSpec, event: { type: 'task_created', task_id: taskSpec.id, task_hash: digest(taskSpec), response: response || `已创建任务 ${taskSpec.id}:${taskSpec.title}。分工:${taskSpec.workflow.join(' → ')};等待绑定对话认领。` } };
71
88
  }, { replay: async (request) => {
72
89
  const event = request.events.find((item) => item.idempotency_key === key);
73
- const expectedType = { create_task: 'task_created', clarify: 'clarified', answer: 'answered' }[action];
90
+ const expectedType = { create_task: 'task_created', clarify: 'clarified', answer: 'answered', review: 'reviewed' }[action];
74
91
  if (event.type !== expectedType || response !== undefined && event.response !== response) throw new Conflict('request already resolved with different content');
92
+ if (action === 'review') {
93
+ assertReviewIntent(request, review, this.agentId, this.team);
94
+ if (event.task_id !== review.task_id || event.review_seq !== review.expected_seq + 1 || event.decision !== review.decision || event.summary !== review.summary) throw new Conflict('request already resolved with a different review');
95
+ }
75
96
  if (action === 'create_task') {
76
97
  const accepted = await this.store.readLocal(event.task_id);
77
98
  const taskSpec = { schema_version: 2, id: inboxTaskId(id), repo: this.team.repo, title: input.title, description: input.description ?? '', base_branch: 'main', acceptance: input.acceptance, workflow: input.workflow || accepted.spec.workflow, execution_mode: 'desktop_poll', source: { channel: 'wecom', chat_id: request.spec.chat_id } };
@@ -80,7 +101,8 @@ export class PeerService {
80
101
  } });
81
102
  if (result.state.task_id) await this.publish(await this.read(result.state.task_id), 'created');
82
103
  else await this.publish(result, 'inbox_response');
83
- return { request: result, ...(result.state.task_id ? { task: await this.read(result.state.task_id) } : {}) };
104
+ const taskId = result.state.task_id || result.state.review_task_id;
105
+ return { request: result, ...(taskId ? { task: await this.read(taskId) } : {}) };
84
106
  }
85
107
  async create(input, idempotencyKey = randomUUID()) {
86
108
  const id = input.id || `T-${digest(`${this.agentId}:${idempotencyKey}`).slice(0, 32).toUpperCase()}`;
@@ -132,6 +132,16 @@ export class PeerGitStore {
132
132
  const task = await this.readLocal(state.task_id); const resolution = events.at(-1);
133
133
  if (digest(task.spec) !== resolution.task_hash || task.events[0].actor_id !== spec.target_agent || task.spec.execution_mode !== 'desktop_poll' || task.spec.source?.chat_id !== spec.chat_id || task.events[0].idempotency_key !== `inbox-task:${id}`) throw new Error('inbox resolution does not match its signed task');
134
134
  }
135
+ if (state.review_task_id) {
136
+ const resolution = events.at(-1); const task = await this.readLocal(state.review_task_id);
137
+ const reviewed = task.events[resolution.review_seq - 1];
138
+ const before = reducePeerTask(task.spec, task.events.slice(0, resolution.review_seq - 1), task.team || this.team);
139
+ const frozen = spec.review_context.find((item) => item.task_id === resolution.task_id);
140
+ if (!reviewed || reviewed.type !== 'reviewed' || reviewed.actor_id !== spec.target_agent || reviewed.decision !== resolution.decision
141
+ || reviewed.summary !== resolution.summary || reviewed.idempotency_key !== `inbox-review:${id}:${resolution.seq - 1}`
142
+ || digest(reviewed) !== resolution.task_event_hash || task.spec.source?.channel !== 'wecom' || task.spec.source.chat_id !== spec.chat_id
143
+ || before.checkpoint?.commit !== frozen.commit || before.stage !== frozen.stage || task.spec.title !== frozen.title || (task.team || this.team).agents[spec.target_agent]?.wecom_userid !== spec.sender_id) throw new Error('inbox review does not match its signed task decision');
144
+ }
135
145
  return { spec, events, state, team };
136
146
  }
137
147
  async listInboxLocal() {
@@ -142,8 +152,8 @@ export class PeerGitStore {
142
152
  return Promise.all(entries.sort().map((id) => this.readInboxLocal(id)));
143
153
  }
144
154
  async listInbox() { return this.locked(async () => { await this.sync(); return this.listInboxLocal(); }); }
145
- // One remote Git CAS publishes BOTH the interpreted task and its resolution.
146
- // Retries cannot leave a task created with a still-pending request.
155
+ // One remote Git CAS publishes the task creation/review and its inbox resolution.
156
+ // Retries cannot leave a task mutation with a still-pending request.
147
157
  async transactInbox(idempotencyKey, build, { replay = () => {} } = {}) {
148
158
  if (this.enrollmentOnly) throw new Error('enrollment client cannot write inbox requests');
149
159
  if (typeof idempotencyKey !== 'string' || !idempotencyKey || idempotencyKey.length > 300) throw new Error('invalid inbox idempotency key');
@@ -162,12 +172,27 @@ export class PeerGitStore {
162
172
  if (!spec || spec.id !== built.id || spec.target_agent !== this.agentId) throw new Error('inbox transaction identity mismatch');
163
173
  const now = new Date(this.clock()).toISOString();
164
174
  const team = current?.team || this.team;
175
+ let reviewedTask; let taskEvent;
176
+ if (built.taskReview) {
177
+ const review = built.taskReview; reviewedTask = await this.readLocal(review.task_id);
178
+ if (reviewedTask.state.seq !== review.expected_seq) throw new Error('review version changed');
179
+ taskEvent = makeSigner(reviewedTask.team || this.team, this.agentId, this.privateKey)({ schema_version: 2, task_id: review.task_id,
180
+ seq: review.expected_seq + 1, event_id: randomUUID(), actor_id: this.agentId, occurred_at: now,
181
+ idempotency_key: `inbox-review:${spec.id}:${current.state.seq}`, type: 'reviewed', decision: review.decision,
182
+ ...(review.summary ? { summary: review.summary } : {}) });
183
+ reducePeerTask(reviewedTask.spec, [...reviewedTask.events, taskEvent], reviewedTask.team || this.team);
184
+ }
165
185
  const event = makeSigner(team, this.agentId, this.privateKey)({ schema_version: 1, request_id: spec.id,
166
186
  seq: (current?.state.seq || 0) + 1, event_id: randomUUID(), actor_id: this.agentId, occurred_at: now,
167
- idempotency_key: idempotencyKey, ...built.event, ...(current ? {} : { spec_hash: digest(spec) }) });
187
+ idempotency_key: idempotencyKey, ...built.event, ...(taskEvent ? { task_event_hash: digest(taskEvent) } : {}), ...(current ? {} : { spec_hash: digest(spec) }) });
168
188
  reduceInbox(spec, [...(current?.events || []), event], team);
169
189
  if ((event.type === 'task_created') !== !!built.taskSpec) throw new Error('inbox task must be published with its resolution');
190
+ if ((event.type === 'reviewed') !== !!built.taskReview) throw new Error('inbox review must be published with its task decision');
170
191
  const paths = [this.inboxRoot(spec.id)];
192
+ if (reviewedTask) {
193
+ const taskRoot = this.root(reviewedTask.spec.id); paths.push(taskRoot);
194
+ await writeFile(join(taskRoot, 'events', `${String(taskEvent.seq).padStart(6, '0')}-${taskEvent.event_id}.json`), JSON.stringify(taskEvent, null, 2) + '\n', { flag: 'wx' });
195
+ }
171
196
  if (built.taskSpec) {
172
197
  const taskSpec = built.taskSpec;
173
198
  const taskEvent = this.sign({ schema_version: 2, task_id: taskSpec.id, seq: 1, event_id: randomUUID(), actor_id: this.agentId,
@@ -61,7 +61,8 @@
61
61
  "clarified",
62
62
  "replied",
63
63
  "task_created",
64
- "answered"
64
+ "answered",
65
+ "reviewed"
65
66
  ]
66
67
  },
67
68
  "spec_hash": {
@@ -88,11 +89,31 @@
88
89
  },
89
90
  "task_id": {
90
91
  "type": "string",
91
- "pattern": "^T-[A-F0-9]{32}$"
92
+ "pattern": "^T-[A-Z0-9-]{1,62}$"
92
93
  },
93
94
  "task_hash": {
94
95
  "type": "string",
95
96
  "pattern": "^[a-f0-9]{64}$"
97
+ },
98
+ "review_seq": {
99
+ "type": "integer",
100
+ "minimum": 2
101
+ },
102
+ "decision": {
103
+ "enum": [
104
+ "approved",
105
+ "changes_requested"
106
+ ]
107
+ },
108
+ "summary": {
109
+ "type": "string",
110
+ "minLength": 1,
111
+ "maxLength": 2000,
112
+ "pattern": "\\S"
113
+ },
114
+ "task_event_hash": {
115
+ "type": "string",
116
+ "pattern": "^[a-f0-9]{64}$"
96
117
  }
97
118
  },
98
119
  "oneOf": [
@@ -131,6 +152,26 @@
131
152
  "required": [
132
153
  "task_id"
133
154
  ]
155
+ },
156
+ {
157
+ "required": [
158
+ "review_seq"
159
+ ]
160
+ },
161
+ {
162
+ "required": [
163
+ "decision"
164
+ ]
165
+ },
166
+ {
167
+ "required": [
168
+ "summary"
169
+ ]
170
+ },
171
+ {
172
+ "required": [
173
+ "task_event_hash"
174
+ ]
134
175
  }
135
176
  ]
136
177
  }
@@ -170,6 +211,26 @@
170
211
  "required": [
171
212
  "task_id"
172
213
  ]
214
+ },
215
+ {
216
+ "required": [
217
+ "review_seq"
218
+ ]
219
+ },
220
+ {
221
+ "required": [
222
+ "decision"
223
+ ]
224
+ },
225
+ {
226
+ "required": [
227
+ "summary"
228
+ ]
229
+ },
230
+ {
231
+ "required": [
232
+ "task_event_hash"
233
+ ]
173
234
  }
174
235
  ]
175
236
  }
@@ -205,6 +266,26 @@
205
266
  "required": [
206
267
  "task_id"
207
268
  ]
269
+ },
270
+ {
271
+ "required": [
272
+ "review_seq"
273
+ ]
274
+ },
275
+ {
276
+ "required": [
277
+ "decision"
278
+ ]
279
+ },
280
+ {
281
+ "required": [
282
+ "summary"
283
+ ]
284
+ },
285
+ {
286
+ "required": [
287
+ "task_event_hash"
288
+ ]
208
289
  }
209
290
  ]
210
291
  }
@@ -213,6 +294,9 @@
213
294
  "properties": {
214
295
  "type": {
215
296
  "const": "task_created"
297
+ },
298
+ "task_id": {
299
+ "pattern": "^T-[A-F0-9]{32}$"
216
300
  }
217
301
  },
218
302
  "required": [
@@ -236,6 +320,26 @@
236
320
  "required": [
237
321
  "spec_hash"
238
322
  ]
323
+ },
324
+ {
325
+ "required": [
326
+ "review_seq"
327
+ ]
328
+ },
329
+ {
330
+ "required": [
331
+ "decision"
332
+ ]
333
+ },
334
+ {
335
+ "required": [
336
+ "summary"
337
+ ]
338
+ },
339
+ {
340
+ "required": [
341
+ "task_event_hash"
342
+ ]
239
343
  }
240
344
  ]
241
345
  }
@@ -275,10 +379,84 @@
275
379
  "required": [
276
380
  "task_id"
277
381
  ]
382
+ },
383
+ {
384
+ "required": [
385
+ "review_seq"
386
+ ]
387
+ },
388
+ {
389
+ "required": [
390
+ "decision"
391
+ ]
392
+ },
393
+ {
394
+ "required": [
395
+ "summary"
396
+ ]
397
+ },
398
+ {
399
+ "required": [
400
+ "task_event_hash"
401
+ ]
278
402
  }
279
403
  ]
280
404
  }
405
+ },
406
+ {
407
+ "properties": {
408
+ "type": {
409
+ "const": "reviewed"
410
+ }
411
+ },
412
+ "required": [
413
+ "task_id",
414
+ "review_seq",
415
+ "decision",
416
+ "task_event_hash",
417
+ "response"
418
+ ],
419
+ "not": {
420
+ "anyOf": [
421
+ {
422
+ "required": [
423
+ "content"
424
+ ]
425
+ },
426
+ {
427
+ "required": [
428
+ "message_key"
429
+ ]
430
+ },
431
+ {
432
+ "required": [
433
+ "spec_hash"
434
+ ]
435
+ },
436
+ {
437
+ "required": [
438
+ "task_hash"
439
+ ]
440
+ }
441
+ ]
442
+ },
443
+ "allOf": [
444
+ {
445
+ "if": {
446
+ "properties": {
447
+ "decision": {
448
+ "const": "changes_requested"
449
+ }
450
+ }
451
+ },
452
+ "then": {
453
+ "required": [
454
+ "summary"
455
+ ]
456
+ }
457
+ }
458
+ ]
281
459
  }
282
460
  ],
283
- "$comment": "Runtime also verifies Ed25519 signatures and historical team hash, receiving-agent authorship, exact sequence, unique IDs and idempotency keys, nondecreasing UTC timestamps, signed spec digest and allowed transitions. received opens pending; clarified waits for a reply; replied returns to pending; task_created and answered resolve pending. A task_created event and its desktop_poll task commit atomically; runtime validates the exact task digest, deterministic task ID, original group, creator and creation key. Replies are scoped to the same bot, group and sender."
461
+ "$comment": "Runtime also verifies Ed25519 signatures and historical team hash, receiving-agent authorship, exact sequence, unique IDs and idempotency keys, nondecreasing UTC timestamps, signed spec digest and allowed transitions. received opens pending; clarified waits for a reply; replied returns to pending; task_created and answered resolve pending. A task_created event and its desktop_poll task commit atomically; runtime validates the exact task digest, deterministic task ID, original group, creator and creation key. Replies are scoped to the same bot, group and sender. reviewed resolves a pending request and atomically appends the matching task reviewed event. Runtime verifies frozen context, explicit owner consent, pinned reviewer, unchanged code revision and the signed task-event digest; task_created keeps the deterministic inbox-derived task ID constraint."
284
462
  }