@michelj/context-guard 0.4.4 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -0,0 +1,226 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { canonical, fail } from './protocol.mjs';
3
+ import { hash } from './io.mjs';
4
+
5
+ export const workflowTypes = new Set(['brief.submit', 'review.request', 'review.result', 'task.assign', 'task.message', 'task.report', 'task.rework', 'ci.request', 'ci.result', 'executor.state', 'task.control']);
6
+ export const scopedObjectKey = (p, session, ref) => hash(canonical([p.repositoryId, session.id, session.generation, ref]));
7
+
8
+ // All effects stay inside ProtocolStore's transaction. Model calls and GitHub
9
+ // polling belong to authenticated adapters, never to this state machine.
10
+ export async function reduceWorkflow(state, principal, message, emit, policy = {}) {
11
+ const { payload: p, session } = message;
12
+ const belongs = value => value.repositoryId === principal.repositoryId && canonical(value.session) === canonical(session);
13
+ const role = (...roles) => { if (!roles.includes(principal.role)) fail('FORBIDDEN', 'This operation requires a different registered role'); };
14
+ const taskKey = id => scopedObjectKey(principal, session, `task:${id}`);
15
+ const read = (ref, version, kind) => {
16
+ const object = state.objects[scopedObjectKey(principal, session, ref)];
17
+ const saved = object?.versions[version || object.latest];
18
+ if (!saved || kind && saved.kind !== kind) fail('NOT_FOUND', 'Referenced object or version is missing');
19
+ return { ...saved, version: version || object.latest };
20
+ };
21
+ const put = (ref, kind, content, version = randomUUID()) => {
22
+ const key = scopedObjectKey(principal, session, ref);
23
+ const object = state.objects[key] ||= { latest: '', versions: {} };
24
+ object.versions[version] = { kind, content: structuredClone(content) }; object.latest = version;
25
+ return { ref, version };
26
+ };
27
+ const changed = task => { task.version = randomUUID(); return { taskId: task.id, version: task.version, stage: task.stage }; };
28
+ const releaseSlot = task => {
29
+ task.busy = false;
30
+ if (Object.values(state.tasks).some(value => belongs(value) && value.busy)) return {};
31
+ const next = Object.values(state.tasks)
32
+ .filter(value => belongs(value) && value.stage === 'queued' && value.assignmentNotification)
33
+ .sort((left, right) => (left.assignmentOrder || 0) - (right.assignmentOrder || 0) || String(left.queuedAt || '').localeCompare(String(right.queuedAt || '')) || left.id.localeCompare(right.id))[0];
34
+ if (!next) return {};
35
+ next.busy = true; next.stage = 'assigned'; delete next.queuedAt;
36
+ next.assignmentSeq = emit(next.assignmentNotification); changed(next);
37
+ return { activatedTaskId: next.id };
38
+ };
39
+ const notify = (source = message) => emit({ ...source, id: randomUUID() });
40
+ if (message.type === 'brief.submit') {
41
+ role('coordinator');
42
+ const key = taskKey(p.taskId), previous = state.tasks[key];
43
+ if (previous && !['brief', 'brief-rejected'].includes(previous.stage)) fail('CONFLICT', 'The approved task cannot be rewritten');
44
+ const brief = put(`brief:${p.taskId}`, 'brief', { text: p.text, taskId: p.taskId });
45
+ const task = state.tasks[key] = { id: p.taskId, repositoryId: principal.repositoryId, session, brief, stage: 'brief', busy: false };
46
+ notify();
47
+ return { ...changed(task), ...brief };
48
+ }
49
+ let task = p.taskId ? state.tasks[taskKey(p.taskId)] : null;
50
+ if (message.type === 'review.result') {
51
+ task = Object.values(state.tasks).find(value => belongs(value) &&
52
+ value.review?.ref === p.ref && value.review?.version === p.version && value.review?.kind === p.kind);
53
+ }
54
+ if (message.type === 'executor.state') {
55
+ const executorId = principal.role === 'device'
56
+ ? Object.values(state.bindings).find(binding => binding.sessionId === session.id && binding.deviceId === principal.deviceId)?.agentId
57
+ : principal.agentId;
58
+ if (p.agentId !== executorId) fail('FORBIDDEN', 'Executor identity differs');
59
+ const tasks = Object.values(state.tasks).filter(value => belongs(value) && value.busy);
60
+ if (p.state === 'idle' && tasks.length || p.state === 'busy' && !tasks.some(value => value.id === p.taskId)) fail('CONFLICT', 'Executor state is determined by the task lifecycle');
61
+ return { state: tasks.length ? 'busy' : 'idle', ...(tasks.length ? { taskId: tasks[0].id } : {}) };
62
+ }
63
+ if (!task) fail('NOT_FOUND', 'Task is not registered in this Session');
64
+ const at = (...stages) => { if (!stages.includes(task.stage)) fail('CONFLICT', 'Task is at a different stage', { currentVersion: task.version }); };
65
+ if (message.type === 'task.message') {
66
+ role('coordinator');
67
+ at('assigned', 'plan-ready', 'plan-rejected', 'executing', 'rework', 'accepted');
68
+ if (Boolean(p.planRef) !== Boolean(p.planVersion) ||
69
+ p.planRef && (task.plan?.ref !== p.planRef || task.plan.version !== p.planVersion)) fail('CONFLICT', 'Task Plan changed before guidance');
70
+ const notification = { ...structuredClone(message), id: randomUUID() };
71
+ const seq = emit(notification);
72
+ return { taskId: task.id, version: task.version, stage: task.stage, notificationId: notification.id, seq };
73
+ }
74
+ if (message.type === 'review.request') {
75
+ role('coordinator');
76
+ if (p.kind === 'brief') {
77
+ at('brief');
78
+ if (p.ref !== task.brief.ref || p.version !== task.brief.version) fail('CONFLICT', 'Brief version differs');
79
+ } else {
80
+ at('plan-ready');
81
+ if (p.ref !== task.plan.ref || p.version !== task.plan.version || p.requirementsRef !== task.brief.ref || p.requirementsVersion !== task.brief.version) fail('CONFLICT', 'Plan or approved requirements differ');
82
+ }
83
+ task.review = structuredClone(p); notify(); return changed(task);
84
+ }
85
+ if (message.type === 'review.result') {
86
+ role(p.kind === 'plan' ? 'coordinator' : 'human');
87
+ if (p.receiptId) fail('FORBIDDEN', 'Review receipts are issued by the backend');
88
+ at(p.kind === 'brief' ? 'brief' : p.kind === 'acceptance' ? 'awaiting-merge' : 'plan-ready');
89
+ const receiptId = randomUUID();
90
+ const receipt = put(receiptId, 'reviewReceipt', { ...p, receiptId, issuer: principal.agentId }, receiptId);
91
+ task[`${p.kind}Review`] = { ...receipt, decision: p.decision, reason: p.reason };
92
+ if (p.kind === 'acceptance') task.acceptanceAt = new Date().toISOString();
93
+ task.stage = p.decision === 'approved' ? ({ brief: 'approved', plan: 'executing', acceptance: 'accepted' }[p.kind]) : `${p.kind}-rejected`;
94
+ delete task.review;
95
+ emit({ ...message, id: randomUUID(), payload: { ...p, receiptId } });
96
+ const status = changed(task);
97
+ const released = p.kind === 'acceptance' && p.decision === 'approved' ? releaseSlot(task) : {};
98
+ return { ...status, ...released, taskVersion: status.version, receiptId, version: receipt.version };
99
+ }
100
+ if (message.type === 'task.assign') {
101
+ role('coordinator'); at('approved');
102
+ if (p.briefRef !== task.brief.ref || p.briefVersion !== task.brief.version || task.briefReview?.decision !== 'approved') fail('CONFLICT', 'Human approval does not match this brief');
103
+ if (!await policy.verifyRouting?.(principal, message)) fail('FORBIDDEN', 'Main version and node access could not be verified');
104
+ task.assignment = structuredClone(p);
105
+ task.assignmentOrder = state.taskSequence = (state.taskSequence || 0) + 1;
106
+ task.assignmentNotification = { ...structuredClone(message), id: randomUUID() };
107
+ if (Object.values(state.tasks).some(value => belongs(value) && value.busy)) {
108
+ task.busy = false; task.stage = 'queued'; task.queuedAt = new Date().toISOString();
109
+ return changed(task);
110
+ }
111
+ task.busy = true; task.stage = 'assigned'; task.assignmentSeq = emit(task.assignmentNotification);
112
+ return changed(task);
113
+ }
114
+ if (message.type === 'task.report') {
115
+ role('executor', 'device');
116
+ if (!task.busy && !(p.stage === 'closed' && task.stage === 'closing')) fail('CONFLICT', 'Task is not active');
117
+ if (['started', 'finished'].includes(p.stage)) {
118
+ if (task.assignment?.mode !== 'session' || p.data.deliveryId !== task.assignmentNotification?.id) fail('CONFLICT', 'Execution report differs from the dispatched Session task');
119
+ if (p.stage === 'started') { at('assigned'); task.stage = 'executing'; task.startedAt = new Date().toISOString(); }
120
+ else {
121
+ at('executing'); task.stage = 'finished'; task.busy = false;
122
+ task.result = { ...structuredClone(p.data), finishedAt: new Date().toISOString() };
123
+ }
124
+ } else if (p.stage === 'planReady') {
125
+ at('assigned', 'plan-rejected', 'rework'); read(p.data.planRef, p.data.planVersion, 'plan');
126
+ task.plan = { ref: p.data.planRef, version: p.data.planVersion }; task.sourceSha = p.data.sourceSha; task.stage = 'plan-ready';
127
+ } else if (p.stage === 'progress') {
128
+ at('executing');
129
+ if (p.data.seq <= (task.progress?.seq ?? -1)) fail('CONFLICT', 'Progress sequence did not advance');
130
+ task.progress = structuredClone(p.data);
131
+ } else if (p.stage === 'interrupted') {
132
+ if (task.interrupted && Date.parse(p.data.occurredAt) <= Date.parse(task.interrupted.occurredAt)) fail('CONFLICT', 'Interruption observation did not advance');
133
+ task.interrupted = structuredClone(p.data);
134
+ // A second interruption during recovery must retain the business stage,
135
+ // otherwise a resumed receipt returns to "resuming" forever.
136
+ if (!['interrupted', 'resuming'].includes(task.stage)) task.previousStage = task.stage;
137
+ task.stage = 'interrupted';
138
+ } else if (p.stage === 'handoff') {
139
+ at('executing');
140
+ const refs = [p.data.ciTodoRef, ...p.data.unitTestRefs, ...p.data.experienceRefs];
141
+ task.references = Object.fromEntries(refs.map(ref => [ref, read(ref).version]));
142
+ read(p.data.ciTodoRef, task.references[p.data.ciTodoRef], 'ciTodo');
143
+ for (const ref of p.data.unitTestRefs) read(ref, task.references[ref], 'evidence');
144
+ for (const ref of p.data.experienceRefs) read(ref, task.references[ref], 'experience');
145
+ task.handoff = structuredClone(p.data); task.sourceSha = p.data.sourceSha; task.stage = 'awaiting-ci';
146
+ } else {
147
+ if (!task.control || p.data.controlId !== task.control.id) fail('CONFLICT', 'Control receipt differs');
148
+ if (p.stage === 'cancelled') { at('cancelling'); task.stage = 'cancelled'; }
149
+ else if (p.stage === 'resumed') {
150
+ // A retried control can arrive in another native turn with a new request
151
+ // ID. Acknowledge the same applied control without rewinding the task.
152
+ if (task.control.resumed) return { taskId: task.id, version: task.version, stage: task.stage };
153
+ at('resuming'); task.stage = task.previousStage; task.control.resumed = true;
154
+ }
155
+ else {
156
+ at('closing');
157
+ if (!await policy.verifyClose?.(principal, task, p.data)) fail('FORBIDDEN', 'Close receipt is not verified');
158
+ task.stage = 'closed'; task.busy = false;
159
+ }
160
+ }
161
+ notify();
162
+ const status = changed(task);
163
+ if (['closed', 'finished'].includes(task.stage)) Object.assign(status, releaseSlot(task));
164
+ return status;
165
+ }
166
+ if (message.type === 'ci.request') {
167
+ role('coordinator', 'ci'); at('awaiting-ci');
168
+ if (policy.verifyCiReceiver && !await policy.verifyCiReceiver(state, principal, session)) fail('UNAVAILABLE', 'Configure one matching independent CI receiver before requesting tests', { reason: 'CI_RECEIVER_REQUIRED' });
169
+ if (p.sourceSha !== task.sourceSha || p.ciTodoRef !== task.handoff.ciTodoRef || canonical(p.unitTestRefs) !== canonical(task.handoff.unitTestRefs)) fail('CONFLICT', 'CI request differs from the handoff');
170
+ const references = Object.fromEntries([p.ciTodoRef, ...p.unitTestRefs].map(ref => [ref, task.references[ref]]));
171
+ if (p.references && canonical(p.references) !== canonical(references)) fail('CONFLICT', 'CI object versions differ from the handoff');
172
+ task.stage = 'testing'; notify({ ...message, payload: { ...p, references } }); return changed(task);
173
+ }
174
+ if (message.type === 'ci.result') {
175
+ role('ci'); at('testing');
176
+ if (p.sourceSha !== task.sourceSha) fail('CONFLICT', 'CI tested a different source commit');
177
+ if (p.verdict === 'passed' && p.checks.some(check => check.status !== 'passed')) fail('CONFLICT', 'CI checks do not support the verdict');
178
+ if (new Set(p.checks.map(check => check.testId)).size !== p.checks.length) fail('INVALID_ARGUMENT', 'Duplicate test ID');
179
+ const todoRef = task.handoff.ciTodoRef, todoVersion = task.references[todoRef];
180
+ const todo = read(todoRef, todoVersion, 'ciTodo');
181
+ if (read(todoRef).version !== todoVersion) fail('CONFLICT', 'CI TODO changed after handoff');
182
+ const items = todo.content.items;
183
+ if (!Array.isArray(items) || !items.length || items.some(item => typeof item?.id !== 'string' || !item.id || item.id.length > 128) || new Set(items.map(item => item.id)).size !== items.length) fail('INVALID_ARGUMENT', 'CI TODO requires uniquely numbered items');
184
+ if (p.checks.some(check => !items.some(item => item.id === check.todoId))) fail('CONFLICT', 'CI result references an unknown TODO');
185
+ if (p.verdict === 'passed' && items.some(item => !p.checks.some(check => check.todoId === item.id && check.status === 'passed'))) fail('CONFLICT', 'CI did not cover every handed-off TODO');
186
+ const references = {};
187
+ for (const check of p.checks) {
188
+ references[check.evidenceRef] = read(check.evidenceRef, undefined, 'evidence').version;
189
+ if (check.reproductionRef) references[check.reproductionRef] = read(check.reproductionRef, undefined, 'evidence').version;
190
+ }
191
+ task.ci = { ...put(`ci:${p.taskId}:${randomUUID()}`, 'ciResult', { ...p, references }), verdict: p.verdict };
192
+ const annotated = items.map(item => {
193
+ const checks = p.checks.filter(check => check.todoId === item.id);
194
+ return { ...item, status: checks.length && checks.every(check => check.status === 'passed') ? 'done' : 'pending', testIds: checks.map(check => check.testId) };
195
+ });
196
+ task.ciTodoResult = put(todoRef, 'ciTodo', { ...todo.content, items: annotated, ciResultRef: task.ci.ref });
197
+ task.stage = p.verdict === 'passed' ? 'awaiting-merge' : 'ci-failed';
198
+ if (p.verdict === 'passed') task.review = { kind: 'acceptance', ref: task.ci.ref, version: task.ci.version };
199
+ delete task.acceptanceReview;
200
+ notify();
201
+ return { ...changed(task), ...task.ci, ciTodo: task.ciTodoResult };
202
+ }
203
+ if (message.type === 'task.rework') {
204
+ role('coordinator'); at('ci-failed', 'acceptance-rejected');
205
+ if (p.sourceSha !== task.sourceSha || p.ciResultRef !== task.ci.ref) fail('CONFLICT', 'Rework does not reference the failed revision');
206
+ const result = read(task.ci.ref, task.ci.version, 'ciResult');
207
+ if (task.stage === 'acceptance-rejected' && (p.reason !== task.acceptanceReview?.reason || p.failedTestIds.length)) fail('CONFLICT', 'Preserve the human rejection feedback; do not invent failed CI tests');
208
+ if (p.failedTestIds.some(id => !result.content.checks.some(check => check.testId === id && check.status !== 'passed'))) fail('CONFLICT', 'Rework test IDs differ from CI evidence');
209
+ task.rework = structuredClone(p); task.stage = 'rework'; notify(); return changed(task);
210
+ }
211
+ if (message.type === 'task.control') {
212
+ role('coordinator');
213
+ if (p.expectedVersion !== task.version) fail('CONFLICT', 'Task changed', { currentVersion: task.version });
214
+ if (!task.busy && p.action !== 'complete') fail('CONFLICT', 'Task has no execution slot');
215
+ if (p.action === 'complete') {
216
+ at('accepted', 'cancelled');
217
+ const verified = await policy.verifyCompletion?.(principal, task, p.data);
218
+ if (!verified) fail('FORBIDDEN', 'Completion policy and receipts are not verified');
219
+ task.completion = { proof: structuredClone(verified), closeReceiptId: message.id };
220
+ task.stage = 'closing';
221
+ } else if (p.action === 'resume') { at('interrupted', 'cancelled'); task.stage = 'resuming'; }
222
+ else { if (['closing', 'closed', 'cancelling'].includes(task.stage)) fail('CONFLICT', 'Control already pending'); task.previousStage = task.stage; task.stage = 'cancelling'; }
223
+ task.control = { id: message.id, action: p.action }; emit(structuredClone(message)); return changed(task);
224
+ }
225
+ fail('INVALID_ARGUMENT', 'Unsupported task operation');
226
+ }
@@ -0,0 +1,125 @@
1
+ // Shared wire validation. Authentication and business authorization run separately.
2
+ export class ProtocolError extends Error {
3
+ constructor(code, message, details) {
4
+ super(message); this.code = code; this.details = details;
5
+ this.status = ({ UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, ID_REUSED: 409, STALE_SESSION: 409, TOO_LARGE: 413, UNAVAILABLE: 503 })[code] || 400;
6
+ }
7
+ }
8
+ export const fail = (code, message, details) => { throw new ProtocolError(code, message, details); };
9
+ export const MAX_MESSAGE_BYTES = 256 * 1024;
10
+ const check = (ok, where) => { if (!ok) fail('INVALID_ARGUMENT', `Invalid ${where}`); };
11
+ const string = (max = 128, empty = false) => (v, p) => check(typeof v === 'string' && (empty || v.trim().length > 0) && v.length <= max, p);
12
+ const id = string(), version = string(4096), emptyVersion = string(4096, true), text = string(2000);
13
+ const integer = (min = 0, max = Number.MAX_SAFE_INTEGER) => (v, p) => check(Number.isSafeInteger(v) && v >= min && v <= max, p);
14
+ const choice = (...values) => (v, p) => check(values.includes(v), p);
15
+ const optional = rule => Object.assign((v, p) => { if (v !== undefined) rule(v, p); }, { optional: true });
16
+ const object = fields => (v, p = 'payload') => {
17
+ check(v !== null && typeof v === 'object' && !Array.isArray(v), p);
18
+ check(Object.keys(v).every(k => Object.hasOwn(fields, k)), `${p} fields`);
19
+ for (const [key, rule] of Object.entries(fields)) { check(rule.optional || Object.hasOwn(v, key), `${p}.${key}`); rule(v[key], `${p}.${key}`); }
20
+ };
21
+ const array = (rule, min = 0, max = 100) => (v, p) => { check(Array.isArray(v) && v.length >= min && v.length <= max, p); v.forEach((x, i) => rule(x, `${p}[${i}]`)); };
22
+ const ids = (v, p) => { array(id, 1)(v, p); check(new Set(v).size === v.length, p); };
23
+ const jsonObject = (v, p) => check(v !== null && typeof v === 'object' && !Array.isArray(v), p);
24
+ const sha = (v, p) => check(typeof v === 'string' && /^[a-f0-9]{40}$/.test(v), p);
25
+ const session = object({ id, generation: integer(1) });
26
+ const refs = array(id);
27
+ const reportData = {
28
+ started: object({ deliveryId: id }),
29
+ finished: object({ deliveryId: id, outcome: choice('success', 'failed', 'cancelled'), summary: text }),
30
+ planReady: object({ planRef: id, planVersion: version, sourceSha: sha }),
31
+ progress: object({ seq: integer(), summary: text }),
32
+ interrupted: object({ reason: text, occurredAt: (v, p) => check(typeof v === 'string' && /^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(v) && Number.isFinite(Date.parse(v)), p) }),
33
+ handoff: object({ sourceSha: sha, ciTodoRef: id, unitTestRefs: refs, experienceRefs: refs }),
34
+ cancelled: object({ controlId: id }), resumed: object({ controlId: id }), closed: object({ controlId: id, closeReceiptId: id }),
35
+ };
36
+ const controls = {
37
+ cancel: object({ reason: text }), resume: object({ reason: text }),
38
+ complete: object({ gitReceiptRef: optional(id), cloudReceiptRef: optional(id), archiveReceiptRef: id }),
39
+ };
40
+ const fields = {
41
+ node: { parentId: optional(id), title: text, purpose: optional(text), kind: choice('module', 'work'), state: choice('dirty', 'untested', 'success', 'failed'), order: optional(integer()), proposal: optional(choice('proposed', 'accepted', 'cancelled')),
42
+ owns: optional(array(string(500), 1)), proposalEvidence: optional(object({ parentId: id, basis: choice('new-module', 'new-interface', 'new-component', 'new-responsibility'), reason: string(1000), files: array(string(500), 1) })) },
43
+ todo: { nodeId: id, title: text, status: choice('pending', 'processing', 'done'), description: optional(text) },
44
+ bug: { nodeId: id, title: text, status: choice('open', 'resolved'), reproduction: optional(text) },
45
+ message: { nodeId: optional(id), text },
46
+ memory: { nodeId: id, text, refs: optional(array(object({ ref: id, version }))) },
47
+ idea: { nodeId: id, text, refs: optional(array(object({ ref: id, version }))) },
48
+ relation: { from: id, to: id, label: optional(text) },
49
+ access: { nodeId: id, agentId: id, allow: choice('read', 'write', 'none') },
50
+ };
51
+ const change = (v, p) => {
52
+ object({ op: choice('create', 'update', 'delete'), kind: choice(...Object.keys(fields)), id, fields: optional(jsonObject) })(v, p);
53
+ if (v.op === 'delete') { check(!Object.hasOwn(v, 'fields'), p); return; }
54
+ const rules = v.op === 'update' ? Object.fromEntries(Object.entries(fields[v.kind]).map(([k, r]) => [k, optional(r)])) : fields[v.kind];
55
+ object(rules)(v.fields, `${p}.fields`);
56
+ check(Object.keys(v.fields).length > 0, `${p}.fields`);
57
+ };
58
+ export const payloadRules = {
59
+ 'object.put': object({ kind: choice('plan', 'evidence', 'experience', 'ciTodo'), ref: id, baseVersion: emptyVersion, content: jsonObject }),
60
+ 'object.read': object({ ref: id, version }),
61
+ 'auth.open': object({ repository: string(2048), password: string(1024), clientId: id }),
62
+ 'auth.close': object({}),
63
+ 'sync.heartbeat': object({ creationResults: optional(array(object({ id, error: string(80) }), 0, 20)), sessions: array(object({
64
+ id,
65
+ generation: integer(1),
66
+ ackedSeq: integer(),
67
+ name: optional(string(240)),
68
+ platform: optional(string(64)),
69
+ execution: optional(object({ status: choice('active', 'stopped', 'interrupted', 'failed', 'unknown'), at: string(64, true) })),
70
+ })) }),
71
+ 'sync.read': object({ afterSeq: integer(), limit: integer(1, 100) }),
72
+ 'sync.ack': object({ items: array((v, p) => { object({ seq: integer(1), outcome: choice('applied', 'rejected', 'cancelled'), reason: optional(text), deliveryState: optional(choice('stored', 'received', 'uncertain')) })(v, p); check(v.outcome === 'applied' || !!v.reason, p); }, 1) }),
73
+ 'sync.event': object({ latestSeq: integer() }),
74
+ 'workbench.patch': object({ baseVersion: version, changes: array(change, 1) }),
75
+ 'main.structure.patch': object({ baseVersion: version, changes: array(change, 1) }),
76
+ 'workbench.read': v => {
77
+ object({ scope: choice('main', 'session'), nodeIds: optional(ids), version: optional(version), cursor: string(4096, true), limit: integer(1, 100), recovery: optional(choice(true, false)) })(v);
78
+ check(!v.recovery || v.scope === 'session' && !v.nodeIds, 'recovery scope');
79
+ },
80
+ 'blob.put': object({ name: string(255), size: integer(0, 64 * 1024 * 1024), sha256: (v, p) => check(typeof v === 'string' && /^[a-f0-9]{64}$/.test(v), p), mediaType: string(255) }),
81
+ 'blob.get': object({ blobId: id }),
82
+ 'merge.request': object({ sessionVersion: version, baseMainVersion: version, sourceSha: sha, contentRef: id }),
83
+ 'merge.result': v => { object({ mergeId: id, state: choice('merged', 'conflict', 'failed'), mainVersion: optional(version), error: optional(text) })(v); check(v.state === 'merged' ? !!v.mainVersion : !!v.error, 'merge result'); },
84
+ 'brief.submit': object({ taskId: id, text }),
85
+ 'review.request': v => { object({ kind: choice('brief', 'plan'), ref: id, version, taskId: id, requirementsRef: optional(id), requirementsVersion: optional(version), rulesVersion: optional(version) })(v); if (v.kind === 'plan') check(!!v.requirementsRef && !!v.requirementsVersion && !!v.rulesVersion, 'plan review references'); },
86
+ 'review.result': object({ kind: choice('brief', 'plan', 'acceptance'), ref: id, version, decision: choice('approved', 'rejected'), reason: text, receiptId: optional(id) }),
87
+ 'task.assign': object({ taskId: id, briefRef: id, briefVersion: version, sessionId: id, nodeIds: ids, mainVersion: version, mode: optional(choice('session', 'reviewed')) }),
88
+ 'task.message': object({ taskId: id, text, planRef: optional(id), planVersion: optional(version) }),
89
+ 'task.report': v => { object({ taskId: id, stage: choice(...Object.keys(reportData)), data: jsonObject })(v); reportData[v.stage](v.data); },
90
+ 'task.rework': v => { object({ taskId: id, sourceSha: sha, ciResultRef: id, failedTestIds: refs, reason: optional(text) })(v); check(v.failedTestIds.length > 0 || !!v.reason, 'rework evidence or human feedback'); check(new Set(v.failedTestIds).size === v.failedTestIds.length, 'unique failed tests'); },
91
+ 'ci.request': object({ taskId: id, sourceSha: sha, ciTodoRef: id, unitTestRefs: refs, references: optional(jsonObject) }),
92
+ 'ci.result': object({ taskId: id, sourceSha: sha, verdict: choice('passed', 'failed', 'incomplete'), checks: array(v => { object({ testId: id, todoId: id, status: choice('passed', 'failed', 'incomplete'), evidenceRef: id, reproductionRef: optional(id) })(v); check(v.status !== 'failed' || !!v.reproductionRef, 'failed check reproduction'); }, 1) }),
93
+ 'executor.state': v => { object({ agentId: id, state: choice('busy', 'idle'), taskId: optional(id) })(v); check(v.state === 'busy' ? !!v.taskId : v.taskId === undefined, 'executor task'); },
94
+ 'session.bind': object({ sessionId: id, worktreeId: id, agentId: id, expectedBindingVersion: emptyVersion }),
95
+ 'task.control': v => { object({ taskId: id, action: choice(...Object.keys(controls)), expectedVersion: version, data: jsonObject })(v); controls[v.action](v.data); },
96
+ };
97
+ const projectTypes = new Set(['auth.open', 'auth.close', 'sync.heartbeat', 'session.bind', 'main.structure.patch']);
98
+ export function validateMessage(input) {
99
+ let bytes;
100
+ try { bytes = Buffer.byteLength(JSON.stringify(input)); } catch { fail('INVALID_ARGUMENT', 'Message must be JSON'); }
101
+ if (bytes > MAX_MESSAGE_BYTES) fail('TOO_LARGE', 'Message exceeds 256 KiB');
102
+ const pending = [[input, 0]];
103
+ while (pending.length) {
104
+ const [value, depth] = pending.pop();
105
+ check(depth <= 64, 'JSON nesting depth');
106
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') continue;
107
+ if (typeof value === 'number') { check(Number.isFinite(value), 'JSON number'); continue; }
108
+ check(typeof value === 'object' && (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), 'JSON value');
109
+ for (const child of Object.values(value)) pending.push([child, depth + 1]);
110
+ }
111
+ object({ v: choice(2), id, type: choice(...Object.keys(payloadRules)), session: optional(session), payload: jsonObject })(input, 'message');
112
+ check(projectTypes.has(input.type) ? input.session === undefined : input.session !== undefined, 'message.session');
113
+ payloadRules[input.type](input.payload);
114
+ if (input.type === 'task.assign') check(input.payload.sessionId === input.session.id, 'assignment session');
115
+ return input;
116
+ }
117
+ export function errorReply(id, error) {
118
+ const known = error instanceof ProtocolError;
119
+ return { id, ok: false, error: { code: known ? error.code : 'UNAVAILABLE', message: known ? error.message : 'Operation temporarily unavailable', retryable: !known || error.code === 'UNAVAILABLE', ...(known && error.details ? { details: error.details } : {}) } };
120
+ }
121
+ export function canonical(value) {
122
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
123
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
124
+ return JSON.stringify(value);
125
+ }