@imdeadpool/guardex 7.0.15 → 7.0.18
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/CONTRIBUTING.md +1 -1
- package/README.md +182 -51
- package/bin/multiagent-safety.js +993 -172
- package/package.json +3 -2
- package/templates/AGENTS.multiagent-safety.md +4 -4
- package/templates/githooks/post-checkout +1 -1
- package/templates/githooks/post-merge +19 -6
- package/templates/githooks/pre-commit +8 -8
- package/templates/scripts/agent-branch-merge.sh +421 -0
- package/templates/scripts/agent-branch-start.sh +43 -3
- package/templates/scripts/agent-session-state.js +110 -0
- package/templates/scripts/codex-agent.sh +124 -2
- package/templates/scripts/install-vscode-active-agents-extension.js +92 -0
- package/templates/scripts/openspec/init-change-workspace.sh +77 -9
- package/templates/scripts/openspec/init-plan-workspace.sh +592 -48
- package/templates/vscode/guardex-active-agents/README.md +21 -0
- package/templates/vscode/guardex-active-agents/extension.js +317 -0
- package/templates/vscode/guardex-active-agents/package.json +57 -0
- package/templates/vscode/guardex-active-agents/session-schema.js +407 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const cp = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions');
|
|
6
|
+
const SESSION_SCHEMA_VERSION = 1;
|
|
7
|
+
const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
|
|
8
|
+
const MAX_CHANGED_PATH_PREVIEW = 3;
|
|
9
|
+
const ACTIVE_SESSIONS_FILTER_PREFIX = ACTIVE_SESSIONS_RELATIVE_DIR.split(path.sep).join('/');
|
|
10
|
+
|
|
11
|
+
function toNonEmptyString(value, fallback = '') {
|
|
12
|
+
const normalized = typeof value === 'string' ? value.trim() : String(value || '').trim();
|
|
13
|
+
return normalized || fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toPositiveInteger(value) {
|
|
17
|
+
const normalized = Number.parseInt(String(value || ''), 10);
|
|
18
|
+
return Number.isInteger(normalized) && normalized > 0 ? normalized : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sanitizeBranchForFile(branch) {
|
|
22
|
+
const normalized = toNonEmptyString(branch, 'session');
|
|
23
|
+
return normalized.replace(/[^a-zA-Z0-9._-]+/g, '__').replace(/^_+|_+$/g, '') || 'session';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sessionFileNameForBranch(branch) {
|
|
27
|
+
return `${sanitizeBranchForFile(branch)}.json`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function activeSessionsDirForRepo(repoRoot) {
|
|
31
|
+
return path.join(path.resolve(repoRoot), ACTIVE_SESSIONS_RELATIVE_DIR);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sessionFilePathForBranch(repoRoot, branch) {
|
|
35
|
+
return path.join(activeSessionsDirForRepo(repoRoot), sessionFileNameForBranch(branch));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function splitOutputLines(output) {
|
|
39
|
+
if (typeof output !== 'string') {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return output
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.filter((line) => line.trim().length > 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runGitLines(worktreePath, args) {
|
|
49
|
+
try {
|
|
50
|
+
const output = cp.execFileSync('git', ['-C', worktreePath, ...args], {
|
|
51
|
+
encoding: 'utf8',
|
|
52
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
53
|
+
});
|
|
54
|
+
return splitOutputLines(output);
|
|
55
|
+
} catch (_error) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unquoteGitPath(value) {
|
|
61
|
+
if (typeof value !== 'string') {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const trimmed = value.trim();
|
|
66
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
|
67
|
+
return trimmed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(trimmed);
|
|
72
|
+
} catch (_error) {
|
|
73
|
+
return trimmed.slice(1, -1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatFileCount(count) {
|
|
78
|
+
return `${count} file${count === 1 ? '' : 's'}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function previewChangedPaths(paths) {
|
|
82
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (paths.length <= MAX_CHANGED_PATH_PREVIEW) {
|
|
87
|
+
return paths.join(', ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const preview = paths.slice(0, MAX_CHANGED_PATH_PREVIEW).join(', ');
|
|
91
|
+
return `${preview}, +${paths.length - MAX_CHANGED_PATH_PREVIEW} more`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function deriveRepoChangeStatus(statusPair) {
|
|
95
|
+
if (statusPair === '??') {
|
|
96
|
+
return {
|
|
97
|
+
statusCode: '??',
|
|
98
|
+
statusLabel: 'U',
|
|
99
|
+
statusText: 'Untracked',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const code = [statusPair[1], statusPair[0]].find((value) => value && value !== ' ') || 'M';
|
|
104
|
+
const statusTextByCode = {
|
|
105
|
+
A: 'Added',
|
|
106
|
+
C: 'Copied',
|
|
107
|
+
D: 'Deleted',
|
|
108
|
+
M: 'Modified',
|
|
109
|
+
R: 'Renamed',
|
|
110
|
+
T: 'Type changed',
|
|
111
|
+
U: 'Conflicted',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
statusCode: code,
|
|
116
|
+
statusLabel: code,
|
|
117
|
+
statusText: statusTextByCode[code] || 'Changed',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseRepoChangeLine(repoRoot, line) {
|
|
122
|
+
if (typeof line !== 'string' || line.length < 4) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const statusPair = line.slice(0, 2);
|
|
127
|
+
if (statusPair === '!!') {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const rawPath = line.slice(3).trim();
|
|
132
|
+
if (!rawPath) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let relativePath = rawPath;
|
|
137
|
+
let originalPath = '';
|
|
138
|
+
if (rawPath.includes(' -> ')) {
|
|
139
|
+
const parts = rawPath.split(' -> ');
|
|
140
|
+
if (parts.length === 2) {
|
|
141
|
+
originalPath = unquoteGitPath(parts[0]);
|
|
142
|
+
relativePath = parts[1];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
relativePath = unquoteGitPath(relativePath);
|
|
147
|
+
if (!relativePath) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const normalizedRelativePath = relativePath.split(path.sep).join('/');
|
|
152
|
+
if (
|
|
153
|
+
normalizedRelativePath === ACTIVE_SESSIONS_FILTER_PREFIX
|
|
154
|
+
|| normalizedRelativePath.startsWith(`${ACTIVE_SESSIONS_FILTER_PREFIX}/`)
|
|
155
|
+
) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const status = deriveRepoChangeStatus(statusPair);
|
|
160
|
+
return {
|
|
161
|
+
...status,
|
|
162
|
+
originalPath,
|
|
163
|
+
relativePath,
|
|
164
|
+
absolutePath: path.join(path.resolve(repoRoot), relativePath),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function collectWorktreeChangedPaths(worktreePath) {
|
|
169
|
+
const changedGroups = [
|
|
170
|
+
runGitLines(worktreePath, ['diff', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]),
|
|
171
|
+
runGitLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]),
|
|
172
|
+
runGitLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
if (changedGroups.some((group) => group === null)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return [...new Set(changedGroups.flat())]
|
|
180
|
+
.filter((relativePath) => relativePath && relativePath !== LOCK_FILE_RELATIVE)
|
|
181
|
+
.sort((left, right) => left.localeCompare(right));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function deriveSessionActivity(session) {
|
|
185
|
+
const changedPaths = collectWorktreeChangedPaths(session.worktreePath);
|
|
186
|
+
if (!changedPaths) {
|
|
187
|
+
return {
|
|
188
|
+
activityKind: 'thinking',
|
|
189
|
+
activityLabel: 'thinking',
|
|
190
|
+
activityCountLabel: '',
|
|
191
|
+
activitySummary: 'Worktree activity unavailable.',
|
|
192
|
+
changeCount: 0,
|
|
193
|
+
changedPaths: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (changedPaths.length === 0) {
|
|
198
|
+
return {
|
|
199
|
+
activityKind: 'thinking',
|
|
200
|
+
activityLabel: 'thinking',
|
|
201
|
+
activityCountLabel: '',
|
|
202
|
+
activitySummary: 'Worktree clean.',
|
|
203
|
+
changeCount: 0,
|
|
204
|
+
changedPaths: [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
activityKind: 'working',
|
|
210
|
+
activityLabel: 'working',
|
|
211
|
+
activityCountLabel: formatFileCount(changedPaths.length),
|
|
212
|
+
activitySummary: previewChangedPaths(changedPaths),
|
|
213
|
+
changeCount: changedPaths.length,
|
|
214
|
+
changedPaths,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildSessionRecord(input) {
|
|
219
|
+
const repoRoot = path.resolve(toNonEmptyString(input.repoRoot));
|
|
220
|
+
const worktreePath = path.resolve(toNonEmptyString(input.worktreePath));
|
|
221
|
+
const branch = toNonEmptyString(input.branch);
|
|
222
|
+
const pid = toPositiveInteger(input.pid);
|
|
223
|
+
const startedAt = input.startedAt ? new Date(input.startedAt) : new Date();
|
|
224
|
+
|
|
225
|
+
if (!branch) {
|
|
226
|
+
throw new Error('branch is required');
|
|
227
|
+
}
|
|
228
|
+
if (!repoRoot) {
|
|
229
|
+
throw new Error('repoRoot is required');
|
|
230
|
+
}
|
|
231
|
+
if (!worktreePath) {
|
|
232
|
+
throw new Error('worktreePath is required');
|
|
233
|
+
}
|
|
234
|
+
if (!pid) {
|
|
235
|
+
throw new Error('pid must be a positive integer');
|
|
236
|
+
}
|
|
237
|
+
if (Number.isNaN(startedAt.getTime())) {
|
|
238
|
+
throw new Error('startedAt must be a valid date');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
243
|
+
repoRoot,
|
|
244
|
+
branch,
|
|
245
|
+
taskName: toNonEmptyString(input.taskName, 'task'),
|
|
246
|
+
agentName: toNonEmptyString(input.agentName, 'agent'),
|
|
247
|
+
worktreePath,
|
|
248
|
+
pid,
|
|
249
|
+
cliName: toNonEmptyString(input.cliName, 'codex'),
|
|
250
|
+
startedAt: startedAt.toISOString(),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function deriveSessionLabel(branch, worktreePath) {
|
|
255
|
+
const worktreeLeaf = toNonEmptyString(path.basename(worktreePath || ''));
|
|
256
|
+
if (worktreeLeaf) {
|
|
257
|
+
return worktreeLeaf;
|
|
258
|
+
}
|
|
259
|
+
return toNonEmptyString(branch).replace(/[\\/]+/g, '-') || 'unknown-agent';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeSessionRecord(input, options = {}) {
|
|
263
|
+
if (!input || typeof input !== 'object') {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const repoRoot = toNonEmptyString(input.repoRoot);
|
|
268
|
+
const branch = toNonEmptyString(input.branch);
|
|
269
|
+
const worktreePath = toNonEmptyString(input.worktreePath);
|
|
270
|
+
const startedAt = new Date(input.startedAt);
|
|
271
|
+
const pid = toPositiveInteger(input.pid);
|
|
272
|
+
|
|
273
|
+
if (!repoRoot || !branch || !worktreePath || !pid || Number.isNaN(startedAt.getTime())) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
schemaVersion: toPositiveInteger(input.schemaVersion) || SESSION_SCHEMA_VERSION,
|
|
279
|
+
repoRoot: path.resolve(repoRoot),
|
|
280
|
+
branch,
|
|
281
|
+
taskName: toNonEmptyString(input.taskName, 'task'),
|
|
282
|
+
agentName: toNonEmptyString(input.agentName, 'agent'),
|
|
283
|
+
worktreePath: path.resolve(worktreePath),
|
|
284
|
+
pid,
|
|
285
|
+
cliName: toNonEmptyString(input.cliName, 'codex'),
|
|
286
|
+
startedAt: startedAt.toISOString(),
|
|
287
|
+
filePath: toNonEmptyString(options.filePath),
|
|
288
|
+
label: deriveSessionLabel(branch, worktreePath),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function formatElapsedFrom(startedAt, now = Date.now()) {
|
|
293
|
+
const startedAtMs = startedAt instanceof Date ? startedAt.getTime() : Date.parse(startedAt);
|
|
294
|
+
if (!Number.isFinite(startedAtMs)) {
|
|
295
|
+
return '0s';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const totalSeconds = Math.max(0, Math.floor((now - startedAtMs) / 1000));
|
|
299
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
300
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
|
|
301
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
302
|
+
const seconds = totalSeconds % 60;
|
|
303
|
+
|
|
304
|
+
if (days > 0) {
|
|
305
|
+
return `${days}d ${hours}h`;
|
|
306
|
+
}
|
|
307
|
+
if (hours > 0) {
|
|
308
|
+
return `${hours}h ${minutes}m`;
|
|
309
|
+
}
|
|
310
|
+
if (minutes > 0) {
|
|
311
|
+
return `${minutes}m ${seconds}s`;
|
|
312
|
+
}
|
|
313
|
+
return `${seconds}s`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isPidAlive(pid) {
|
|
317
|
+
const normalizedPid = toPositiveInteger(pid);
|
|
318
|
+
if (!normalizedPid) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
process.kill(normalizedPid, 0);
|
|
324
|
+
return true;
|
|
325
|
+
} catch (_error) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function readActiveSessions(repoRoot, options = {}) {
|
|
331
|
+
const activeSessionsDir = activeSessionsDirForRepo(repoRoot);
|
|
332
|
+
if (!fs.existsSync(activeSessionsDir)) {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const now = options.now || Date.now();
|
|
337
|
+
const sessions = [];
|
|
338
|
+
for (const entry of fs.readdirSync(activeSessionsDir, { withFileTypes: true })) {
|
|
339
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const filePath = path.join(activeSessionsDir, entry.name);
|
|
344
|
+
let parsed;
|
|
345
|
+
try {
|
|
346
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
347
|
+
} catch (_error) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const normalized = normalizeSessionRecord(parsed, { filePath });
|
|
352
|
+
if (!normalized) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (!options.includeStale && !isPidAlive(normalized.pid)) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
normalized.elapsedLabel = formatElapsedFrom(normalized.startedAt, now);
|
|
360
|
+
Object.assign(normalized, deriveSessionActivity(normalized));
|
|
361
|
+
sessions.push(normalized);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
sessions.sort((left, right) => {
|
|
365
|
+
const timeDelta = Date.parse(right.startedAt) - Date.parse(left.startedAt);
|
|
366
|
+
if (timeDelta !== 0) {
|
|
367
|
+
return timeDelta;
|
|
368
|
+
}
|
|
369
|
+
return left.label.localeCompare(right.label);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return sessions;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function readRepoChanges(repoRoot) {
|
|
376
|
+
const statusLines = runGitLines(repoRoot, ['status', '--porcelain=v1', '--untracked-files=all']);
|
|
377
|
+
if (!statusLines) {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return statusLines
|
|
382
|
+
.map((line) => parseRepoChangeLine(repoRoot, line))
|
|
383
|
+
.filter(Boolean)
|
|
384
|
+
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
ACTIVE_SESSIONS_RELATIVE_DIR,
|
|
389
|
+
SESSION_SCHEMA_VERSION,
|
|
390
|
+
activeSessionsDirForRepo,
|
|
391
|
+
buildSessionRecord,
|
|
392
|
+
collectWorktreeChangedPaths,
|
|
393
|
+
deriveSessionLabel,
|
|
394
|
+
deriveSessionActivity,
|
|
395
|
+
formatElapsedFrom,
|
|
396
|
+
formatFileCount,
|
|
397
|
+
isPidAlive,
|
|
398
|
+
normalizeSessionRecord,
|
|
399
|
+
parseRepoChangeLine,
|
|
400
|
+
previewChangedPaths,
|
|
401
|
+
readActiveSessions,
|
|
402
|
+
readRepoChanges,
|
|
403
|
+
deriveRepoChangeStatus,
|
|
404
|
+
sanitizeBranchForFile,
|
|
405
|
+
sessionFileNameForBranch,
|
|
406
|
+
sessionFilePathForBranch,
|
|
407
|
+
};
|