@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,146 @@
1
+ import path from 'node:path';
2
+ import { atomicWrite, encode, hash, readJSON, withFileLock } from './io.mjs';
3
+ import { canonical, fail, ProtocolError } from './protocol.mjs';
4
+ import { entries, scopeDocumentToSession, applyOperations } from './map-model.mjs';
5
+
6
+ const collections = { todo: 'todos', bug: 'bugs', memory: 'memories', idea: 'ideas', message: 'messages', access: 'access' };
7
+ // Retry receipts must retain both the old and new endpoints of a relation.
8
+ export function operationGrants(document, operations, grants) {
9
+ const required = new Set(), flows = structuredClone(document.flows || []);
10
+ for (const op of operations) {
11
+ const position = op.legacyIndex ?? flows.findIndex(flow => flow.id === op.id);
12
+ const previous = op.type === 'relation' ? flows[position] : null;
13
+ for (const id of [op.id, op.parentId, previous?.from, previous?.to, op.fields?.from, op.fields?.to]) {
14
+ if (grants.includes(id)) required.add(id);
15
+ }
16
+ if (op.type === 'relation') {
17
+ if (op.action === 'delete') flows.splice(position, 1);
18
+ else if (op.action === 'create') flows.push({ ...op.fields, id: op.id });
19
+ else flows[position] = { ...previous, ...op.fields, id: op.id };
20
+ }
21
+ }
22
+ return [...required];
23
+ }
24
+ export async function verifyChangeReferences(changes, { object, blob }) {
25
+ for (const change of changes) for (const reference of change.fields?.refs || []) {
26
+ if (reference.ref.startsWith('blob:')) {
27
+ const metadata = await blob(reference.ref.slice(5));
28
+ if (metadata.sha256 !== reference.version) fail('CONFLICT', 'Attachment digest differs from its reference');
29
+ } else {
30
+ const saved = await object(reference.ref, reference.version);
31
+ if (saved.version !== reference.version) fail('CONFLICT', 'Object reference version differs');
32
+ }
33
+ }
34
+ }
35
+ // Legacy records have no IDs. A version-scoped reference cannot retarget after a
36
+ // deletion; the first v2 edit persists that reference as its permanent ID.
37
+ export function nodeProjection(node, version) {
38
+ const projected = structuredClone(node);
39
+ delete projected.children; delete projected._inbox;
40
+ for (const [kind, field] of Object.entries(collections)) if (Array.isArray(projected[field])) {
41
+ projected[field] = projected[field].map((item, index) => ({ ...item, id: item.id || `legacy-${hash(canonical([version, node.id, kind, index]))}` }));
42
+ }
43
+ return projected;
44
+ }
45
+
46
+ export function relationProjection(flows = [], version) {
47
+ return flows.map((flow, index) => ({ ...flow, id: flow.id || `legacy-${hash(canonical([version, 'relation', index]))}` }));
48
+ }
49
+
50
+ export function translateChanges(document, changes, actor, grants, version) {
51
+ let doc = scopeDocumentToSession(document, actor.sessionId); const operations = [];
52
+ const flowIds = relationProjection(doc.flows, version).map(flow => flow.id);
53
+ for (const { node } of entries(doc.root).values()) {
54
+ const projected = nodeProjection(node, version);
55
+ for (const field of Object.values(collections)) if (projected[field]) node[field] = projected[field];
56
+ }
57
+ const add = operation => { doc = applyOperations(doc, [operation], actor, grants).doc; operations.push(operation); };
58
+ for (const change of changes) {
59
+ const index = entries(doc.root), f = change.fields || {};
60
+ if (change.kind === 'idea' && !['human', 'coordinator'].includes(actor.kind)) fail('FORBIDDEN', 'Idea changes require Coordinator authorization');
61
+ if (change.kind === 'relation') {
62
+ const legacyIndex = flowIds.indexOf(change.id);
63
+ add({ type: 'relation', action: change.op, id: change.id,
64
+ ...(legacyIndex >= 0 && !doc.flows[legacyIndex].id ? { legacyIndex } : {}),
65
+ ...(change.fields ? { fields: change.fields } : {}) });
66
+ if (change.op === 'delete') flowIds.splice(legacyIndex, 1);
67
+ else if (change.op === 'create') flowIds.push(change.id);
68
+ continue;
69
+ }
70
+ if (change.kind === 'access' && actor.kind !== 'human') fail('FORBIDDEN', 'Only a human can change access');
71
+ if (change.kind === 'node') {
72
+ const { parentId, order, proposalEvidence, ...fields } = f;
73
+ if (proposalEvidence) {
74
+ if (change.op !== 'create') fail('INVALID_ARGUMENT', 'Proposal evidence belongs to node creation');
75
+ fields.memories = [{ text: proposalEvidence.reason, proposalEvidence }];
76
+ }
77
+ if (change.op === 'create') add({ type: 'create', parentId, ...(order !== undefined ? { order } : {}), node: { ...fields, id: change.id } });
78
+ else if (change.op === 'delete') {
79
+ const node = index.get(change.id)?.node;
80
+ if (node?.children?.length || node?._inbox?.length || (doc.flows || []).some(flow => flow.from === change.id || flow.to === change.id)) fail('CONFLICT', 'Remove children and relations explicitly before deleting a node');
81
+ add({ type: 'delete', id: change.id });
82
+ }
83
+ else {
84
+ if (parentId || order !== undefined) add({ type: 'move', id: change.id, parentId: parentId || index.get(change.id)?.parent?.id, ...(order !== undefined ? { order } : {}) });
85
+ if (Object.keys(fields).length) add({ type: 'update', id: change.id, fields });
86
+ }
87
+ continue;
88
+ }
89
+ const field = collections[change.kind];
90
+ if (!field) fail('INVALID_ARGUMENT', 'This Map record kind is not implemented yet');
91
+ const matches = [...index.values()].flatMap(({ node }) => (node[field] || [])
92
+ .flatMap((item, position) => item.id === change.id ? [{ node, item, position }] : []));
93
+ if (matches.length > 1) fail('CONFLICT', 'Record ID has multiple owners');
94
+ const existing = matches[0];
95
+ if (change.op === 'create' && existing) fail('CONFLICT', 'Record already exists');
96
+ if (change.op !== 'create' && !existing) fail('NOT_FOUND', 'Record is missing from this snapshot');
97
+ const nodeId = f.nodeId || existing?.node.id || (change.kind === 'message' ? doc.root.id : undefined), target = index.get(nodeId)?.node;
98
+ if (!target) fail('NOT_FOUND', 'Record owner is missing');
99
+ if (existing && existing.node.id !== nodeId) fail('INVALID_ARGUMENT', 'Moving records requires an explicit delete and create');
100
+ const list = structuredClone(target[field] || []), { nodeId: _nodeId, ...fields } = f;
101
+ if (change.op === 'create') list.push({ ...fields, id: change.id, ...(['todo', 'bug'].includes(change.kind) ? { sessions: [actor.sessionId] } : {}) });
102
+ else if (change.op === 'delete') list.splice(existing.position, 1);
103
+ else list[existing.position] = { ...list[existing.position], ...fields, id: change.id };
104
+ add({ type: 'update', id: nodeId, fields: { [field]: list } });
105
+ }
106
+ return operations;
107
+ }
108
+
109
+ export class ProtocolMap {
110
+ constructor(directory) { this.directory = directory; }
111
+ async patch(principal, message, { store, actor, grants, prepare, authorize, references = async () => {} }) {
112
+ const operationId = `v2:${hash(canonical([principal.repositoryId, principal.agentId, message.session, message.id]))}`;
113
+ const file = path.join(this.directory, `${hash(operationId)}.json`), fingerprint = hash(canonical(message));
114
+ try {
115
+ return await withFileLock(`${file}.lock`, async () => {
116
+ await authorize();
117
+ await references();
118
+ let intent = await readJSON(file, null);
119
+ if (intent && intent.fingerprint !== fingerprint) fail('ID_REUSED', 'Map request ID already has different content');
120
+ if (!intent) {
121
+ await store.serial(() => store.refresh());
122
+ if (store.version !== message.payload.baseVersion) fail('CONFLICT', 'Map changed', { currentVersion: store.version });
123
+ const initialGrants = await grants();
124
+ const operations = translateChanges(store.doc, message.payload.changes, actor, initialGrants, store.version);
125
+ const prepared = await prepare({ operationId, baseVersion: store.version, operations });
126
+ intent = { fingerprint, request: prepared.input, actor: prepared.actor,
127
+ requiredGrants: operationGrants(store.doc, operations, initialGrants) };
128
+ await atomicWrite(file, encode(intent));
129
+ }
130
+ // MapStore commits the Map and its durable operation receipt together.
131
+ // A crash after that commit reuses this exact translated request.
132
+ await authorize();
133
+ if (actor.kind !== 'human') {
134
+ const currentGrants = await grants();
135
+ if ((intent.requiredGrants || []).some(id => !currentGrants.includes(id))) fail('FORBIDDEN', 'A node grant was revoked');
136
+ }
137
+ const result = await store.commit(intent.request, intent.actor, grants, authorize);
138
+ return { version: result.version, operationId, committed: result.committed };
139
+ });
140
+ } catch (error) {
141
+ if (error instanceof ProtocolError) throw error;
142
+ const code = error.code === 'ID_REUSED' ? 'ID_REUSED' : error.status === 403 ? 'FORBIDDEN' : error.status === 404 ? 'NOT_FOUND' : error.status === 409 ? 'CONFLICT' : error.status === 400 ? 'INVALID_ARGUMENT' : 'UNAVAILABLE';
143
+ fail(code, code === 'UNAVAILABLE' ? 'Map outcome requires recovery; retry the same request ID' : error.message);
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,84 @@
1
+ import path from 'node:path';
2
+ import { atomicWrite, encode, hash, readJSON, withFileLock } from './io.mjs';
3
+ import { canonical, fail, MAX_MESSAGE_BYTES } from './protocol.mjs';
4
+ import { entries } from './map-model.mjs';
5
+ import { nodeProjection, relationProjection } from './protocol-map.mjs';
6
+
7
+ // Snapshots are private, immutable read projections, never a second Map authority.
8
+ export class WorkbenchSnapshots {
9
+ constructor(directory) { this.directory = directory; }
10
+ async read(principal, message, { load, grants, capture }) {
11
+ const p = message.payload;
12
+ if (p.recovery && !capture) fail('UNAVAILABLE', 'Recovery capture is not configured');
13
+ const captured = p.recovery && !p.cursor ? await capture() : null;
14
+ const allowed = [...new Set(await grants())].sort();
15
+ const scope = { repositoryId: principal.repositoryId, agentId: principal.agentId, deviceId: principal.deviceId,
16
+ role: principal.role || 'agent',
17
+ session: message.session, scope: p.scope, nodeIds: [...(p.nodeIds || [])].sort(), recovery: !!p.recovery };
18
+ const owner = hash(canonical(scope)), acl = hash(canonical(allowed));
19
+ let saved, offset = 0;
20
+ if (p.cursor) {
21
+ if (!/^[a-f0-9]{64}:\d+$/.test(p.cursor) || !p.version) fail('INVALID_ARGUMENT', 'Continuation requires its snapshot version and cursor');
22
+ const [snapshotId, index] = p.cursor.split(':'); offset = Number(index);
23
+ if (!Number.isSafeInteger(offset)) fail('INVALID_ARGUMENT', 'Invalid cursor offset');
24
+ saved = await readJSON(path.join(this.directory, `${snapshotId}.json`), null);
25
+ if (!saved) fail('NOT_FOUND', 'Snapshot is unavailable; restart the read');
26
+ if (saved.owner !== owner || saved.version !== p.version || saved.acl !== acl) fail('FORBIDDEN', 'Snapshot scope or authorization changed');
27
+ } else if (p.version && (saved = await readJSON(path.join(this.directory, `${hash(canonical([owner, acl, p.version]))}.json`), null))) {
28
+ // A caller explicitly pinned this verified version; network availability
29
+ // does not change its contents or relax the current authorization check.
30
+ } else {
31
+ const source = captured || await load();
32
+ if (p.version && p.version !== source.version) {
33
+ const snapshotId = hash(canonical([owner, acl, p.version]));
34
+ saved = await readJSON(path.join(this.directory, `${snapshotId}.json`), null);
35
+ if (!saved) fail('NOT_FOUND', 'Requested version is not retained');
36
+ } else {
37
+ const nodes = source.doc.root ? entries(source.doc.root) : new Map(), selected = [...(p.nodeIds || allowed)].sort();
38
+ for (const id of selected) if (!allowed.includes(id)) fail('FORBIDDEN', 'Node is outside the current grant');
39
+ const items = [];
40
+ for (const id of selected) {
41
+ const entry = nodes.get(id);
42
+ if (!entry) { if (p.nodeIds) fail('NOT_FOUND', 'Requested node is missing'); continue; }
43
+ const node = nodeProjection(entry.node, source.mapVersion || source.version);
44
+ if (!['human', 'coordinator'].includes(principal.role)) delete node.ideas;
45
+ // Keep legacy/user fields in each node; flatten only the tree topology.
46
+ items.push({ node, parentId: entry.parent?.id || null, bucket: entry.bucket || 'root' });
47
+ }
48
+ const { root: _root, ...metadata } = source.doc;
49
+ // Relations and project metadata can contain references outside the grant.
50
+ saved = { owner, acl, version: source.version, items,
51
+ ...(source.recovery ? { mapVersion: source.mapVersion, recovery: source.recovery } : {}),
52
+ metadata: { v: metadata.v, project: metadata.project, bootstrap: metadata.bootstrap,
53
+ flows: relationProjection(metadata.flows, source.mapVersion || source.version).filter(flow => selected.includes(flow.from) && selected.includes(flow.to)) } };
54
+ saved = JSON.parse(JSON.stringify(saved));
55
+ const snapshotId = hash(canonical([owner, acl, saved.version]));
56
+ await withFileLock(path.join(this.directory, `${snapshotId}.lock`), async () => {
57
+ const file = path.join(this.directory, `${snapshotId}.json`), prior = await readJSON(file, null);
58
+ if (prior && canonical(prior) !== canonical(saved)) fail('CONFLICT', 'Snapshot version was reused for different content');
59
+ if (!prior) await atomicWrite(file, encode(saved));
60
+ });
61
+ }
62
+ }
63
+ if (saved.owner !== owner || saved.acl !== acl || hash(canonical([...new Set(await grants())].sort())) !== acl) fail('FORBIDDEN', 'Snapshot authorization changed');
64
+ const pending = saved.recovery?.pendingMessages || [], total = saved.items.length + pending.length;
65
+ if (offset > total) fail('INVALID_ARGUMENT', 'Cursor is outside the snapshot');
66
+ const items = []; let size = Buffer.byteLength(JSON.stringify(saved.metadata)) + 8192;
67
+ const pendingMessages = [];
68
+ if (size > MAX_MESSAGE_BYTES) fail('TOO_LARGE', 'Snapshot metadata exceeds the page budget');
69
+ const combined = [...saved.items, ...pending];
70
+ for (let index = offset; index < Math.min(total, offset + p.limit); index++) {
71
+ const item = combined[index];
72
+ const bytes = Buffer.byteLength(JSON.stringify(item)) + 1;
73
+ if (size + bytes > MAX_MESSAGE_BYTES) {
74
+ if (!items.length && !pendingMessages.length) fail('TOO_LARGE', 'An item exceeds the page budget; move large content to an attachment');
75
+ break;
76
+ }
77
+ (index < saved.items.length ? items : pendingMessages).push(item); size += bytes;
78
+ }
79
+ const next = offset + items.length + pendingMessages.length, snapshotId = hash(canonical([owner, acl, saved.version]));
80
+ return { scope: p.scope, version: saved.version, metadata: saved.metadata, items,
81
+ ...(saved.recovery ? { mapVersion: saved.mapVersion, recovery: { resumeAfterSeq: saved.recovery.resumeAfterSeq, pendingMessages } } : {}),
82
+ nextCursor: next < total ? `${snapshotId}:${next}` : '' };
83
+ }
84
+ }