@michelj/context-guard 0.4.4 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Coordinator.md +88 -0
- package/Executor.md +53 -0
- package/README.md +72 -102
- package/README.zh-CN.md +72 -102
- package/SKILL.md +26 -33
- package/THIRD_PARTY_NOTICES.md +47 -0
- package/Tester.md +53 -0
- package/bin/build-runtime.mjs +96 -0
- package/bin/context-guard-skill.js +287 -69
- package/bin/postinstall.js +1 -1
- package/hooks.json +80 -4
- package/licenses/JSONParse-MIT.txt +24 -0
- package/licenses/Marked-MIT.txt +44 -0
- package/licenses/Portless-Apache-2.0.txt +201 -0
- package/package.json +31 -5
- package/prototype/LICENSES/Marked-MIT.txt +44 -0
- package/prototype/LICENSES/Ready-redistribution.txt +14 -0
- package/prototype/attachments.mjs +75 -0
- package/prototype/coordinator-markdown.mjs +283 -0
- package/prototype/coordinator-working-blot.mjs +124 -0
- package/prototype/vendor/marked.mjs +2189 -0
- package/prototype/workbench-app.js +5197 -0
- package/prototype/workbench-data.js +33 -0
- package/prototype/workbench-sync.mjs +898 -0
- package/prototype/workbench.css +1050 -0
- package/prototype/workbench.html +139 -4861
- package/prototype/working-blot-atlas.png +0 -0
- package/references/agent-handoff.md +40 -0
- package/references/claude-runtime.md +120 -0
- package/references/cloud-sync-interface.md +66 -0
- package/references/design-current.md +14 -0
- package/references/map-mount.md +41 -0
- package/references/map-read.md +50 -0
- package/references/memory-definition.md +120 -0
- package/references/memory-filesystem-v2/Bug.en.md +162 -0
- package/references/memory-filesystem-v2/Bug.md +162 -0
- package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
- package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
- package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
- package/references/memory-filesystem-v2/Idea.en.md +36 -0
- package/references/memory-filesystem-v2/Idea.md +36 -0
- package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
- package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
- package/references/memory-filesystem-v2/README.md +60 -0
- package/references/memory-filesystem-v2/Todo.en.md +137 -0
- package/references/memory-filesystem-v2/Todo.md +137 -0
- package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
- package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
- package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
- package/references/named-workbench.md +124 -0
- package/references/plan-review.md +12 -0
- package/references/server-memory.md +276 -0
- package/references/test-check.md +7 -0
- package/references/user-reply.md +38 -0
- package/references/workbench-interface.md +531 -0
- package/roles.md +13 -0
- package/scripts/context_guard.py +1163 -321
- package/scripts/context_guard_hook.py +1864 -63
- package/scripts/map_owns.py +68 -138
- package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
- package/scripts/shared/filesystem-v2.mjs +430 -0
- package/scripts/shared/io.mjs +117 -0
- package/scripts/shared/map-model.mjs +506 -0
- package/scripts/shared/memory-schema.mjs +13 -0
- package/scripts/shared/protocol-blobs.mjs +112 -0
- package/scripts/shared/protocol-map.mjs +146 -0
- package/scripts/shared/protocol-snapshots.mjs +84 -0
- package/scripts/shared/protocol-store.mjs +624 -0
- package/scripts/shared/protocol-workflow.mjs +226 -0
- package/scripts/shared/protocol.mjs +125 -0
- package/scripts/shared/vendor/jsonparse.cjs +413 -0
- package/scripts/workbench/access.mjs +496 -0
- package/scripts/workbench/attachments.mjs +92 -0
- package/scripts/workbench/browser-login.mjs +78 -0
- package/scripts/workbench/claude-runtime.mjs +372 -0
- package/scripts/workbench/cli.mjs +980 -0
- package/scripts/workbench/device-heartbeat.mjs +72 -0
- package/scripts/workbench/hook-status.mjs +38 -0
- package/scripts/workbench/inbox.mjs +155 -0
- package/scripts/workbench/journal.mjs +56 -0
- package/scripts/workbench/memory-merge.mjs +65 -0
- package/scripts/workbench/memory.mjs +252 -0
- package/scripts/workbench/named-proxy.mjs +108 -0
- package/scripts/workbench/named.mjs +152 -0
- package/scripts/workbench/portless-routes.mjs +51 -0
- package/scripts/workbench/project.mjs +327 -0
- package/scripts/workbench/projections.mjs +68 -0
- package/scripts/workbench/protocol-client.mjs +165 -0
- package/scripts/workbench/protocol-delivery.mjs +133 -0
- package/scripts/workbench/protocol-device.mjs +316 -0
- package/scripts/workbench/protocol-events.mjs +53 -0
- package/scripts/workbench/protocol-repository.mjs +58 -0
- package/scripts/workbench/reconcile.mjs +244 -0
- package/scripts/workbench/registry.mjs +111 -0
- package/scripts/workbench/runtime.mjs +54 -0
- package/scripts/workbench/server.mjs +1171 -0
- package/scripts/workbench/store.mjs +243 -0
- package/scripts/workbench/sync-coordinator.mjs +518 -0
- package/scripts/workbench/sync.mjs +86 -0
- package/references/bug-record-template.md +0 -37
- package/references/context-template.md +0 -19
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { entries } from '../shared/map-model.mjs';
|
|
3
|
+
|
|
4
|
+
const PROPOSAL_BASES = new Set(['new-module', 'new-interface', 'new-component', 'new-responsibility']);
|
|
5
|
+
const PROPOSAL_FIELDS = new Set(['parentId', 'title', 'purpose', 'reason', 'basis', 'files', 'kind']);
|
|
6
|
+
const INPUT_FIELDS = new Set(['summary', 'decisions', 'next', 'files', 'assignments', 'proposal', 'nodeIds', 'planId', 'verification', 'assessment']);
|
|
7
|
+
|
|
8
|
+
function normalizeRepoPath(value) {
|
|
9
|
+
let file = String(value || '').trim().replaceAll('\\', '/');
|
|
10
|
+
while (file.startsWith('./')) file = file.slice(2);
|
|
11
|
+
if (!file || file.startsWith('/') || file.startsWith('~') || /^[A-Za-z]:\//.test(file)) throw new Error(`Archive file must be repo-relative: ${value}`);
|
|
12
|
+
const parts = file.split('/').filter(Boolean);
|
|
13
|
+
if (!parts.length || parts.some(part => part === '.' || part === '..')) throw new Error(`Archive file escapes the project: ${value}`);
|
|
14
|
+
file = parts.join('/');
|
|
15
|
+
if (String(value || '').trim().replaceAll('\\', '/').endsWith('/')) file += '/';
|
|
16
|
+
if (file.length > 500) throw new Error(`Archive file path is too long: ${value}`);
|
|
17
|
+
return file;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ownScore(owned, file) {
|
|
21
|
+
const target = normalizeRepoPath(owned), candidate = normalizeRepoPath(file);
|
|
22
|
+
if (target === candidate) return 10000 + target.length;
|
|
23
|
+
const directory = target.endsWith('/') ? target : `${target}/`;
|
|
24
|
+
return candidate.startsWith(directory) ? 1000 + directory.length : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ownerForPath(doc, file) {
|
|
28
|
+
const index = entries(doc.root);
|
|
29
|
+
let best = null;
|
|
30
|
+
for (const [id, { node }] of index) {
|
|
31
|
+
// Proposed nodes do not own product scope until a human accepts them.
|
|
32
|
+
if (node.proposal === 'cancelled' || node.proposal === 'proposed') continue;
|
|
33
|
+
for (const owned of node.owns || []) {
|
|
34
|
+
const score = ownScore(owned, file);
|
|
35
|
+
if (!score) continue;
|
|
36
|
+
const rank = node.kind === 'work' ? 0 : 1;
|
|
37
|
+
if (!best || score > best.score || (score === best.score && rank < best.rank)) best = { id, node, score, rank };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return best;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizedAssignments(input, files, index, directlyMapped) {
|
|
44
|
+
const raw = input.assignments ?? [];
|
|
45
|
+
if (!Array.isArray(raw) || raw.length > 100) throw new Error('Archive assignments must be an array with at most 100 items');
|
|
46
|
+
const knownFiles = new Set(files), assigned = new Map(), normalized = [];
|
|
47
|
+
for (const item of raw) {
|
|
48
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error('Each archive assignment must be an object');
|
|
49
|
+
const nodeId = String(item.nodeId || '').trim();
|
|
50
|
+
const reason = String(item.reason || '').trim();
|
|
51
|
+
const assignedFiles = [...new Set((Array.isArray(item.files) ? item.files : []).map(normalizeRepoPath))].sort();
|
|
52
|
+
const entry = index.get(nodeId);
|
|
53
|
+
if (!entry || ['proposed', 'cancelled'].includes(entry.node.proposal)) throw new Error(`Archive assignment needs an accepted Map node: ${nodeId || '(empty)'}`);
|
|
54
|
+
if (!reason || reason.length > 500) throw new Error(`Archive assignment ${nodeId} needs a concise reason`);
|
|
55
|
+
if (!assignedFiles.length) throw new Error(`Archive assignment ${nodeId} needs files`);
|
|
56
|
+
for (const file of assignedFiles) {
|
|
57
|
+
if (!knownFiles.has(file)) throw new Error(`Archive assignment file was not changed by this session: ${file}`);
|
|
58
|
+
if (directlyMapped.has(file)) throw new Error(`Archive assignment is unnecessary because owns already covers: ${file}`);
|
|
59
|
+
if (assigned.has(file)) throw new Error(`Archive file is assigned more than once: ${file}`);
|
|
60
|
+
assigned.set(file, nodeId);
|
|
61
|
+
}
|
|
62
|
+
normalized.push({ nodeId, reason, files: assignedFiles });
|
|
63
|
+
}
|
|
64
|
+
return { normalized, assigned };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isSupportOnlyPath(file) {
|
|
68
|
+
const lower = file.toLowerCase(), parts = lower.split('/');
|
|
69
|
+
const basename = parts.at(-1);
|
|
70
|
+
if (['test', 'tests', '__tests__', 'docs', 'doc', 'references', '.github'].includes(parts[0])) return true;
|
|
71
|
+
if (/^(readme|changelog|contributing|license|todo)(\.|$)/.test(basename) || basename === 'skill.md') return true;
|
|
72
|
+
if (/^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|package\.json)$/.test(basename)) return true;
|
|
73
|
+
if (/(^|[._-])(test|tests|spec)([._-]|$)/.test(basename)) return true;
|
|
74
|
+
return /(^|\/)([^/]*\.config\.[^/]+|[^/]*rc(?:\.[^/]+)?)$/.test(lower);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizedProposal(input, files, unclassified, index) {
|
|
78
|
+
if (input.proposal === undefined || input.proposal === null) return null;
|
|
79
|
+
const raw = input.proposal;
|
|
80
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Archive proposal must be an object');
|
|
81
|
+
const unknown = Object.keys(raw).filter(key => !PROPOSAL_FIELDS.has(key));
|
|
82
|
+
if (unknown.length) throw new Error(`Archive proposal has unsupported fields: ${unknown.join(', ')}`);
|
|
83
|
+
const parentId = String(raw.parentId || '').trim();
|
|
84
|
+
const title = String(raw.title || '').trim();
|
|
85
|
+
const purpose = String(raw.purpose || '').trim();
|
|
86
|
+
const reason = String(raw.reason || '').trim();
|
|
87
|
+
const basis = String(raw.basis || '').trim();
|
|
88
|
+
const kind = String(raw.kind || 'work').trim();
|
|
89
|
+
const proposedFiles = [...new Set((Array.isArray(raw.files) ? raw.files : []).map(normalizeRepoPath))].sort();
|
|
90
|
+
const parent = index.get(parentId)?.node;
|
|
91
|
+
if (!parent || ['proposed', 'cancelled'].includes(parent.proposal)) throw new Error(`Archive proposal needs an accepted parent node: ${parentId || '(empty)'}`);
|
|
92
|
+
if (!title || title.length > 120) throw new Error('Archive proposal needs a title of at most 120 characters');
|
|
93
|
+
if (!purpose || purpose.length > 500) throw new Error('Archive proposal needs a purpose of at most 500 characters');
|
|
94
|
+
if (!reason || reason.length > 1000) throw new Error('Archive proposal needs a reason of at most 1000 characters');
|
|
95
|
+
if (!PROPOSAL_BASES.has(basis)) throw new Error(`Archive proposal basis must be one of: ${[...PROPOSAL_BASES].join(', ')}`);
|
|
96
|
+
if (!['module', 'work'].includes(kind)) throw new Error('Archive proposal kind must be module or work');
|
|
97
|
+
if (!proposedFiles.length) throw new Error('Archive proposal needs files');
|
|
98
|
+
const knownFiles = new Set(files), available = new Set(unclassified);
|
|
99
|
+
for (const file of proposedFiles) {
|
|
100
|
+
if (!knownFiles.has(file)) throw new Error(`Archive proposal file was not changed by this session: ${file}`);
|
|
101
|
+
if (!available.has(file)) throw new Error(`Archive proposal file already belongs to an existing node: ${file}`);
|
|
102
|
+
}
|
|
103
|
+
if (proposedFiles.every(isSupportOnlyPath)) throw new Error('Tests, docs, and configuration cannot be the sole evidence for a new Map node');
|
|
104
|
+
const normalizedTitle = title.toLocaleLowerCase();
|
|
105
|
+
for (const { node } of index.values()) {
|
|
106
|
+
if (node.proposal === 'cancelled' || String(node.title || '').trim().toLocaleLowerCase() !== normalizedTitle) continue;
|
|
107
|
+
if (node.proposal !== 'proposed') throw new Error(`Archive proposal duplicates an accepted node title: ${title}`);
|
|
108
|
+
}
|
|
109
|
+
return { parentId, title, purpose, reason, basis, files: proposedFiles, kind };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function archiveKey(sessionId, files, input, governance) {
|
|
113
|
+
return createHash('sha256').update(JSON.stringify([
|
|
114
|
+
sessionId,
|
|
115
|
+
files,
|
|
116
|
+
String(input.summary || '').trim(),
|
|
117
|
+
String(input.decisions || '').trim(),
|
|
118
|
+
String(input.next || '').trim(),
|
|
119
|
+
governance,
|
|
120
|
+
input.planId || null, input.verification || null, input.assessment || null,
|
|
121
|
+
])).digest('hex');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function archiveMemory(input, sessionId, key, files, extra = {}) {
|
|
125
|
+
return {
|
|
126
|
+
text: String(input.summary || input.decisions || input.next || 'Agent 完成了一次代码变更。').trim(),
|
|
127
|
+
state: 'success',
|
|
128
|
+
session: sessionId,
|
|
129
|
+
record: `.codex/context/sessions/${String(sessionId).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-._]+|[-._]+$/g, '').slice(0, 120) || 'session'}.md`,
|
|
130
|
+
paths: files,
|
|
131
|
+
archiveKey: key,
|
|
132
|
+
recorded_at: new Date().toISOString(),
|
|
133
|
+
...(input.planId ? { plan_id: input.planId, verification: input.verification, assessment: input.assessment } : {}),
|
|
134
|
+
...extra,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findDuplicateProposal(index, proposal) {
|
|
139
|
+
const title = proposal.title.toLocaleLowerCase(), files = new Set(proposal.files);
|
|
140
|
+
for (const [id, { node }] of index) {
|
|
141
|
+
if (node.proposal !== 'proposed') continue;
|
|
142
|
+
const sameTitle = String(node.title || '').trim().toLocaleLowerCase() === title;
|
|
143
|
+
const overlaps = (node.owns || []).some(file => files.has(normalizeRepoPath(file)));
|
|
144
|
+
if (sameTitle || overlaps) return { id, node };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function buildArchiveReconciliation(doc, sessionId, input = {}) {
|
|
150
|
+
if (!doc?.root) throw new Error('Map has no root node');
|
|
151
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Archive input must be an object');
|
|
152
|
+
const unknown = Object.keys(input).filter(key => !INPUT_FIELDS.has(key));
|
|
153
|
+
if (unknown.length) throw new Error(`Archive input has unsupported fields: ${unknown.join(', ')}`);
|
|
154
|
+
const files = [...new Set((Array.isArray(input.files) ? input.files : []).map(normalizeRepoPath))].sort();
|
|
155
|
+
if (!files.length && !(input.nodeIds || []).length) {
|
|
156
|
+
if (input.proposal || (input.assignments || []).length) throw new Error('Archive governance input needs changed files');
|
|
157
|
+
return { key: null, files: [], mapped: {}, assignments: [], unclassified: [], uncovered: [], proposedId: null, operations: [], operationId: null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const index = entries(doc.root), mapped = new Map(), directlyMapped = new Set();
|
|
161
|
+
if (!Array.isArray(input.nodeIds || [])) throw new Error('nodeIds must be an array');
|
|
162
|
+
for (const id of input.nodeIds || []) {
|
|
163
|
+
const node = index.get(id)?.node;
|
|
164
|
+
if (!node || ['proposed', 'cancelled'].includes(node.proposal)) throw new Error('Plan archive needs an accepted node');
|
|
165
|
+
mapped.set(id, []);
|
|
166
|
+
}
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const owner = ownerForPath(doc, file);
|
|
169
|
+
if (!owner) continue;
|
|
170
|
+
directlyMapped.add(file);
|
|
171
|
+
mapped.set(owner.id, [...(mapped.get(owner.id) || []), file]);
|
|
172
|
+
}
|
|
173
|
+
const assignments = normalizedAssignments(input, files, index, directlyMapped);
|
|
174
|
+
for (const [file, nodeId] of assignments.assigned) mapped.set(nodeId, [...(mapped.get(nodeId) || []), file].sort());
|
|
175
|
+
let unclassified = files.filter(file => !directlyMapped.has(file) && !assignments.assigned.has(file));
|
|
176
|
+
const proposal = normalizedProposal(input, files, unclassified, index);
|
|
177
|
+
const governance = { assignments: assignments.normalized, proposal };
|
|
178
|
+
const key = archiveKey(sessionId, files, input, governance), operations = [];
|
|
179
|
+
|
|
180
|
+
for (const [id, ownedFiles] of mapped) {
|
|
181
|
+
const node = index.get(id).node;
|
|
182
|
+
if ((node.memories || []).some(memory => memory?.archiveKey === key)) continue;
|
|
183
|
+
const assignmentEvidence = assignments.normalized.filter(item => item.nodeId === id);
|
|
184
|
+
operations.push({
|
|
185
|
+
type: 'update',
|
|
186
|
+
id,
|
|
187
|
+
fields: { memories: [...(node.memories || []), archiveMemory(input, sessionId, key, ownedFiles, assignmentEvidence.length ? { assignmentEvidence } : {})] },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let proposedId = null, proposalDuplicate = false;
|
|
192
|
+
if (proposal) {
|
|
193
|
+
const duplicate = findDuplicateProposal(index, proposal);
|
|
194
|
+
const evidence = { basis: proposal.basis, reason: proposal.reason, parentId: proposal.parentId, files: proposal.files };
|
|
195
|
+
if (duplicate) {
|
|
196
|
+
proposedId = duplicate.id;
|
|
197
|
+
proposalDuplicate = true;
|
|
198
|
+
if (duplicate.node.proposedBy === sessionId && !(duplicate.node.memories || []).some(memory => memory?.archiveKey === key)) {
|
|
199
|
+
operations.push({
|
|
200
|
+
type: 'update',
|
|
201
|
+
id: duplicate.id,
|
|
202
|
+
fields: {
|
|
203
|
+
owns: [...new Set([...(duplicate.node.owns || []).map(normalizeRepoPath), ...proposal.files])].sort(),
|
|
204
|
+
memories: [...(duplicate.node.memories || []), archiveMemory(input, sessionId, key, proposal.files, { proposalEvidence: evidence })],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const proposalKey = createHash('sha256').update(JSON.stringify(proposal)).digest('hex');
|
|
210
|
+
proposedId = `P${proposalKey.slice(0, 16)}`;
|
|
211
|
+
if (index.has(proposedId)) throw new Error(`Map node ID collision: ${proposedId}`);
|
|
212
|
+
operations.push({
|
|
213
|
+
type: 'create',
|
|
214
|
+
parentId: proposal.parentId,
|
|
215
|
+
node: {
|
|
216
|
+
id: proposedId,
|
|
217
|
+
title: proposal.title,
|
|
218
|
+
purpose: proposal.purpose,
|
|
219
|
+
kind: proposal.kind,
|
|
220
|
+
state: 'untested',
|
|
221
|
+
owns: proposal.files,
|
|
222
|
+
memories: [archiveMemory(input, sessionId, key, proposal.files, { proposalEvidence: evidence })],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const proposed = new Set(proposal.files);
|
|
227
|
+
unclassified = unclassified.filter(file => !proposed.has(file));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (input.planId && unclassified.length) throw new Error(`Plan archive has unclassified files: ${unclassified.join(', ')}`);
|
|
231
|
+
return {
|
|
232
|
+
key,
|
|
233
|
+
files,
|
|
234
|
+
mapped: Object.fromEntries(mapped),
|
|
235
|
+
assignments: assignments.normalized,
|
|
236
|
+
unclassified,
|
|
237
|
+
// Kept as a compatibility alias for older clients. It no longer triggers node creation.
|
|
238
|
+
uncovered: unclassified,
|
|
239
|
+
proposedId,
|
|
240
|
+
proposalDuplicate,
|
|
241
|
+
operations,
|
|
242
|
+
operationId: `archive:${key}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { atomicWrite, encode, readJSON, withFileLock } from '../shared/io.mjs';
|
|
6
|
+
import { MapError } from '../shared/map-model.mjs';
|
|
7
|
+
|
|
8
|
+
export const globalWorkbenchDirectory = () => path.resolve(
|
|
9
|
+
process.env.CONTEXT_GUARD_NAMED_STATE_DIR || path.join(os.homedir(), '.context-guard/named-workbench'),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const projectRegistryPath = (dir = globalWorkbenchDirectory()) => path.join(dir, 'projects.json');
|
|
13
|
+
|
|
14
|
+
const directoryFailureCodes = new Set(['EACCES', 'EPERM', 'ENOTDIR', 'EROFS', 'EEXIST', 'EISDIR']);
|
|
15
|
+
|
|
16
|
+
// Missing directories are normal; recursive creation makes them. Unavailable
|
|
17
|
+
// means the default path cannot be used: not a directory, or not writable.
|
|
18
|
+
export async function defaultDirectoryAvailability() {
|
|
19
|
+
const overridden = process.env.CONTEXT_GUARD_NAMED_STATE_DIR !== undefined && process.env.CONTEXT_GUARD_NAMED_STATE_DIR !== '';
|
|
20
|
+
const dir = globalWorkbenchDirectory();
|
|
21
|
+
const unavailable = (code = 'EACCES') => ({ path: dir, overridden, unavailable: true, code });
|
|
22
|
+
const available = { path: dir, overridden, unavailable: false, code: '' };
|
|
23
|
+
try {
|
|
24
|
+
const stat = await fs.stat(dir);
|
|
25
|
+
if (!stat.isDirectory()) return unavailable('ENOTDIR');
|
|
26
|
+
await fs.access(dir, fsConstants.W_OK);
|
|
27
|
+
return available;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code !== 'ENOENT') return unavailable(directoryFailureCodes.has(error.code) ? error.code : 'EACCES');
|
|
30
|
+
try {
|
|
31
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
return available;
|
|
33
|
+
} catch (mkdirError) {
|
|
34
|
+
return unavailable(directoryFailureCodes.has(mkdirError.code) ? mkdirError.code : 'EACCES');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalize(raw) {
|
|
40
|
+
if (raw === null) return { version: 1, projects: [] };
|
|
41
|
+
if (!raw || raw.version !== 1 || !Array.isArray(raw.projects)) {
|
|
42
|
+
throw new MapError('INVALID_PROJECT_REGISTRY', 'Invalid global workbench registry; restore it before registering projects', 409);
|
|
43
|
+
}
|
|
44
|
+
const ids = new Set(), origins = new Set();
|
|
45
|
+
for (const item of raw.projects) {
|
|
46
|
+
if (!item || typeof item.projectId !== 'string' || !item.projectId || typeof item.sharedDir !== 'string' || !path.isAbsolute(item.sharedDir)) {
|
|
47
|
+
throw new MapError('INVALID_PROJECT_REGISTRY', 'Invalid project record in global workbench registry', 409);
|
|
48
|
+
}
|
|
49
|
+
if (ids.has(item.projectId)) throw new MapError('INVALID_PROJECT_REGISTRY', 'Duplicate project identity in global workbench registry', 409);
|
|
50
|
+
ids.add(item.projectId);
|
|
51
|
+
if (item.origin) {
|
|
52
|
+
let origin;
|
|
53
|
+
try { origin = new URL(item.origin).origin; } catch { throw new MapError('INVALID_PROJECT_REGISTRY', 'Invalid project origin in global workbench registry', 409); }
|
|
54
|
+
if (origin !== item.origin || origins.has(origin)) throw new MapError('INVALID_PROJECT_REGISTRY', 'Duplicate or non-canonical project origin in global workbench registry', 409);
|
|
55
|
+
origins.add(origin);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return raw;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readProjectRegistry({ dir = globalWorkbenchDirectory() } = {}) {
|
|
62
|
+
return normalize(await readJSON(projectRegistryPath(dir), null));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function registeredProject(project, options = {}) {
|
|
66
|
+
const registry = await readProjectRegistry(options);
|
|
67
|
+
return registry.projects.find(item => item.projectId === project.projectId) || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function rememberProject(project, { dir = globalWorkbenchDirectory(), name = '', origin = '', state = null } = {}) {
|
|
71
|
+
const file = projectRegistryPath(dir);
|
|
72
|
+
return withFileLock(file + '.lock', async () => {
|
|
73
|
+
const registry = normalize(await readJSON(file, null));
|
|
74
|
+
const previous = registry.projects.find(item => item.projectId === project.projectId) || null;
|
|
75
|
+
const canonicalOrigin = origin ? new URL(origin).origin : previous?.origin || '';
|
|
76
|
+
const collision = canonicalOrigin && registry.projects.find(item => item.projectId !== project.projectId && item.origin === canonicalOrigin);
|
|
77
|
+
if (collision) throw new MapError('PROJECT_NAME_CONFLICT', 'The named workbench belongs to another registered project', 409);
|
|
78
|
+
const roots = [...new Set([
|
|
79
|
+
...(Array.isArray(previous?.roots) ? previous.roots : []),
|
|
80
|
+
project.worktreeRoot,
|
|
81
|
+
project.openedRoot,
|
|
82
|
+
].filter(item => typeof item === 'string' && path.isAbsolute(item)))];
|
|
83
|
+
const record = {
|
|
84
|
+
version: 1,
|
|
85
|
+
projectId: project.projectId,
|
|
86
|
+
kind: project.kind,
|
|
87
|
+
sharedDir: project.sharedDir,
|
|
88
|
+
roots,
|
|
89
|
+
name: name || previous?.name || '',
|
|
90
|
+
origin: canonicalOrigin,
|
|
91
|
+
main: project.binding?.main || previous?.main || null,
|
|
92
|
+
stateFile: project.kind === 'git'
|
|
93
|
+
? path.join(project.sharedDir, 'workbench.json')
|
|
94
|
+
: path.join(project.worktreeRoot, '.codex/context/private/workbench.json'),
|
|
95
|
+
runtime: state ? {
|
|
96
|
+
instance: state.instance || '',
|
|
97
|
+
buildId: state.buildId || '',
|
|
98
|
+
runtimeSchema: state.runtimeSchema || null,
|
|
99
|
+
port: Number(new URL(state.url).port),
|
|
100
|
+
} : previous?.runtime || null,
|
|
101
|
+
updatedAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
const next = {
|
|
104
|
+
version: 1,
|
|
105
|
+
projects: [...registry.projects.filter(item => item.projectId !== project.projectId), record]
|
|
106
|
+
.sort((a, b) => a.projectId.localeCompare(b.projectId)),
|
|
107
|
+
};
|
|
108
|
+
await atomicWrite(file, encode(next));
|
|
109
|
+
return record;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const WORKBENCH_PROTOCOL = 2;
|
|
2
|
+
export const WORKBENCH_RUNTIME_SCHEMA = 4;
|
|
3
|
+
export const WORKBENCH_BUILD = 'project-workbench-v22';
|
|
4
|
+
export const WORKBENCH_CAPABILITIES = Object.freeze([
|
|
5
|
+
'git-common-dir-project',
|
|
6
|
+
'named-origin-verification',
|
|
7
|
+
'prepared-session-binding',
|
|
8
|
+
'private-main-baseline',
|
|
9
|
+
'stable-worktree-identity',
|
|
10
|
+
'global-project-registry',
|
|
11
|
+
'session-auto-binding',
|
|
12
|
+
'dynamic-session-access',
|
|
13
|
+
'confirmed-main-baseline-migration',
|
|
14
|
+
'runtime-instance-check',
|
|
15
|
+
'journal-recovery',
|
|
16
|
+
'node-managed-attachments',
|
|
17
|
+
'interface-v2-device-channel',
|
|
18
|
+
'project-cloud-login',
|
|
19
|
+
'independent-cloud-heartbeat',
|
|
20
|
+
'device-managed-heartbeat',
|
|
21
|
+
'macos-desktop-session-open',
|
|
22
|
+
'agent-result-before-review',
|
|
23
|
+
'production-data-isolation',
|
|
24
|
+
'claude-reviewed-runtime',
|
|
25
|
+
'developer-main-structure',
|
|
26
|
+
'coordinator-context-streaming',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function runtimeIdentity() {
|
|
30
|
+
return {
|
|
31
|
+
protocol: WORKBENCH_PROTOCOL,
|
|
32
|
+
runtimeSchema: WORKBENCH_RUNTIME_SCHEMA,
|
|
33
|
+
buildId: WORKBENCH_BUILD,
|
|
34
|
+
capabilities: [...WORKBENCH_CAPABILITIES],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function compatibleRuntime(value) {
|
|
39
|
+
if (!value || value.protocol !== WORKBENCH_PROTOCOL || value.runtimeSchema !== WORKBENCH_RUNTIME_SCHEMA) return false;
|
|
40
|
+
const advertised = new Set(Array.isArray(value.capabilities) ? value.capabilities : []);
|
|
41
|
+
return WORKBENCH_CAPABILITIES.every(capability => advertised.has(capability));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function upgradeableRuntime(value) {
|
|
45
|
+
return !!value
|
|
46
|
+
&& value.protocol === WORKBENCH_PROTOCOL
|
|
47
|
+
&& Number.isInteger(value.runtimeSchema)
|
|
48
|
+
&& value.runtimeSchema > 0
|
|
49
|
+
&& value.runtimeSchema <= WORKBENCH_RUNTIME_SCHEMA
|
|
50
|
+
&& /^project-workbench-v\d+$/.test(String(value.buildId || ''))
|
|
51
|
+
&& Array.isArray(value.capabilities)
|
|
52
|
+
&& value.capabilities.includes('git-common-dir-project')
|
|
53
|
+
&& value.capabilities.includes('named-origin-verification');
|
|
54
|
+
}
|