@imdeadpool/guardex 7.0.20 → 7.0.22

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.
@@ -3,31 +3,28 @@ const {
3
3
  path,
4
4
  TOOL_NAME,
5
5
  SHORT_TOOL_NAME,
6
+ GUARDEX_HOME_DIR,
7
+ AGENT_WORKTREE_RELATIVE_DIRS,
8
+ TEMPLATE_ROOT,
9
+ HOOK_NAMES,
10
+ LOCK_FILE_RELATIVE,
11
+ LEGACY_MANAGED_PACKAGE_SCRIPTS,
12
+ USER_LEVEL_SKILL_ASSETS,
13
+ AGENTS_MARKER_START,
14
+ AGENTS_MARKER_END,
15
+ GITIGNORE_MARKER_START,
16
+ GITIGNORE_MARKER_END,
17
+ SHARED_VSCODE_SETTINGS_RELATIVE,
18
+ REPO_SCAN_IGNORED_FOLDERS_SETTING,
19
+ MANAGED_REPO_SCAN_IGNORED_FOLDERS,
20
+ REPO_SCAFFOLD_DIRECTORIES,
21
+ OMX_SCAFFOLD_DIRECTORIES,
22
+ OMX_SCAFFOLD_FILES,
23
+ toDestinationPath,
6
24
  EXECUTABLE_RELATIVE_PATHS,
7
25
  CRITICAL_GUARDRAIL_PATHS,
8
26
  } = require('../context');
9
-
10
- function toDestinationPath(relativeTemplatePath) {
11
- if (relativeTemplatePath.startsWith('scripts/')) {
12
- return relativeTemplatePath;
13
- }
14
- if (relativeTemplatePath.startsWith('githooks/')) {
15
- return `.${relativeTemplatePath}`;
16
- }
17
- if (relativeTemplatePath.startsWith('codex/')) {
18
- return `.${relativeTemplatePath}`;
19
- }
20
- if (relativeTemplatePath.startsWith('claude/')) {
21
- return `.${relativeTemplatePath}`;
22
- }
23
- if (relativeTemplatePath.startsWith('github/')) {
24
- return `.${relativeTemplatePath}`;
25
- }
26
- if (relativeTemplatePath.startsWith('vscode/')) {
27
- return relativeTemplatePath;
28
- }
29
- throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
30
- }
27
+ const { run } = require('../core/runtime');
31
28
 
32
29
  function ensureParentDir(repoRoot, filePath, dryRun) {
33
30
  if (dryRun) return;
@@ -129,6 +126,644 @@ function managedForceConflictMessage(relativePath) {
129
126
  );
130
127
  }
131
128
 
129
+ function renderManagedFile(repoRoot, relativePath, content, options = {}) {
130
+ const destinationPath = path.join(repoRoot, relativePath);
131
+ const destinationExists = fs.existsSync(destinationPath);
132
+ const force = Boolean(options.force);
133
+ const dryRun = Boolean(options.dryRun);
134
+
135
+ if (destinationExists) {
136
+ const existingContent = fs.readFileSync(destinationPath, 'utf8');
137
+ if (existingContent === content) {
138
+ ensureExecutable(destinationPath, relativePath, dryRun);
139
+ return { status: 'unchanged', file: relativePath };
140
+ }
141
+ if (!force && !isCriticalGuardrailPath(relativePath)) {
142
+ throw new Error(managedForceConflictMessage(relativePath));
143
+ }
144
+ }
145
+
146
+ ensureParentDir(repoRoot, destinationPath, dryRun);
147
+ if (!dryRun) {
148
+ fs.writeFileSync(destinationPath, content, 'utf8');
149
+ ensureExecutable(destinationPath, relativePath, dryRun);
150
+ }
151
+
152
+ if (destinationExists && !force && isCriticalGuardrailPath(relativePath)) {
153
+ return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: relativePath };
154
+ }
155
+
156
+ return { status: destinationExists ? 'overwritten' : 'created', file: relativePath };
157
+ }
158
+
159
+ function ensureGeneratedScriptShim(repoRoot, spec, options = {}) {
160
+ const content = spec.kind === 'python'
161
+ ? renderPythonDispatchShim(spec.command)
162
+ : renderShellDispatchShim(spec.command);
163
+ return renderManagedFile(repoRoot, spec.relativePath, content, options);
164
+ }
165
+
166
+ function ensureHookShim(repoRoot, hookName, options = {}) {
167
+ return renderManagedFile(
168
+ repoRoot,
169
+ path.posix.join('.githooks', hookName),
170
+ renderShellDispatchShim(['hook', 'run', hookName]),
171
+ options,
172
+ );
173
+ }
174
+
175
+ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
176
+ const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
177
+ const destinationRelativePath = toDestinationPath(relativeTemplatePath);
178
+ const destinationPath = path.join(repoRoot, destinationRelativePath);
179
+
180
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
181
+ const destinationExists = fs.existsSync(destinationPath);
182
+
183
+ if (destinationExists) {
184
+ const existingContent = fs.readFileSync(destinationPath, 'utf8');
185
+ if (existingContent === sourceContent) {
186
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
187
+ return { status: 'unchanged', file: destinationRelativePath };
188
+ }
189
+ if (!force && !isCriticalGuardrailPath(destinationRelativePath)) {
190
+ throw new Error(managedForceConflictMessage(destinationRelativePath));
191
+ }
192
+ }
193
+
194
+ ensureParentDir(repoRoot, destinationPath, dryRun);
195
+ if (!dryRun) {
196
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
197
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
198
+ }
199
+
200
+ if (destinationExists && !force && isCriticalGuardrailPath(destinationRelativePath)) {
201
+ return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
202
+ }
203
+
204
+ return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
205
+ }
206
+
207
+ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
208
+ const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
209
+ const destinationRelativePath = toDestinationPath(relativeTemplatePath);
210
+ const destinationPath = path.join(repoRoot, destinationRelativePath);
211
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
212
+
213
+ if (fs.existsSync(destinationPath)) {
214
+ const existingContent = fs.readFileSync(destinationPath, 'utf8');
215
+ if (existingContent === sourceContent) {
216
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
217
+ return { status: 'unchanged', file: destinationRelativePath };
218
+ }
219
+
220
+ if (isCriticalGuardrailPath(destinationRelativePath)) {
221
+ if (!dryRun) {
222
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
223
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
224
+ }
225
+ return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
226
+ }
227
+
228
+ return { status: 'skipped-conflict', file: destinationRelativePath };
229
+ }
230
+
231
+ ensureParentDir(repoRoot, destinationPath, dryRun);
232
+ if (!dryRun) {
233
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
234
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
235
+ }
236
+
237
+ return { status: 'created', file: destinationRelativePath };
238
+ }
239
+
240
+ function lockFilePath(repoRoot) {
241
+ return path.join(repoRoot, LOCK_FILE_RELATIVE);
242
+ }
243
+
244
+ function ensureOmxScaffold(repoRoot, dryRun) {
245
+ const operations = [];
246
+
247
+ for (const relativeDir of REPO_SCAFFOLD_DIRECTORIES) {
248
+ const absoluteDir = path.join(repoRoot, relativeDir);
249
+ if (fs.existsSync(absoluteDir)) {
250
+ if (!fs.statSync(absoluteDir).isDirectory()) {
251
+ throw new Error(`Expected directory at ${relativeDir} but found a file.`);
252
+ }
253
+ operations.push({ status: 'unchanged', file: relativeDir });
254
+ continue;
255
+ }
256
+
257
+ if (!dryRun) {
258
+ fs.mkdirSync(absoluteDir, { recursive: true });
259
+ }
260
+ operations.push({ status: 'created', file: relativeDir });
261
+ }
262
+
263
+ for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
264
+ const absoluteDir = path.join(repoRoot, relativeDir);
265
+ if (fs.existsSync(absoluteDir)) {
266
+ if (!fs.statSync(absoluteDir).isDirectory()) {
267
+ throw new Error(`Expected directory at ${relativeDir} but found a file.`);
268
+ }
269
+ operations.push({ status: 'unchanged', file: relativeDir });
270
+ continue;
271
+ }
272
+
273
+ if (!dryRun) {
274
+ fs.mkdirSync(absoluteDir, { recursive: true });
275
+ }
276
+ operations.push({ status: 'created', file: relativeDir });
277
+ }
278
+
279
+ for (const [relativeFile, defaultContent] of OMX_SCAFFOLD_FILES.entries()) {
280
+ const absoluteFile = path.join(repoRoot, relativeFile);
281
+ if (fs.existsSync(absoluteFile)) {
282
+ if (!fs.statSync(absoluteFile).isFile()) {
283
+ throw new Error(`Expected file at ${relativeFile} but found a directory.`);
284
+ }
285
+ operations.push({ status: 'unchanged', file: relativeFile });
286
+ continue;
287
+ }
288
+
289
+ if (!dryRun) {
290
+ fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
291
+ fs.writeFileSync(absoluteFile, defaultContent, 'utf8');
292
+ }
293
+ operations.push({ status: 'created', file: relativeFile });
294
+ }
295
+
296
+ return operations;
297
+ }
298
+
299
+ function ensureLockRegistry(repoRoot, dryRun) {
300
+ const absolutePath = lockFilePath(repoRoot);
301
+ if (fs.existsSync(absolutePath)) {
302
+ return { status: 'unchanged', file: LOCK_FILE_RELATIVE };
303
+ }
304
+
305
+ if (!dryRun) {
306
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
307
+ fs.writeFileSync(absolutePath, JSON.stringify({ locks: {} }, null, 2) + '\n', 'utf8');
308
+ }
309
+
310
+ return { status: 'created', file: LOCK_FILE_RELATIVE };
311
+ }
312
+
313
+ function lockStateOrError(repoRoot) {
314
+ const lockPath = lockFilePath(repoRoot);
315
+ if (!fs.existsSync(lockPath)) {
316
+ return { ok: false, error: `${LOCK_FILE_RELATIVE} is missing` };
317
+ }
318
+
319
+ try {
320
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
321
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.locks !== 'object' || parsed.locks === null) {
322
+ return { ok: false, error: `${LOCK_FILE_RELATIVE} has invalid schema (expected { locks: {} })` };
323
+ }
324
+
325
+ for (const [filePath, entry] of Object.entries(parsed.locks)) {
326
+ if (!entry || typeof entry !== 'object') {
327
+ parsed.locks[filePath] = { branch: '', claimed_at: '', allow_delete: false };
328
+ continue;
329
+ }
330
+ if (!Object.prototype.hasOwnProperty.call(entry, 'allow_delete')) {
331
+ entry.allow_delete = false;
332
+ }
333
+ }
334
+
335
+ return { ok: true, raw: parsed, locks: parsed.locks };
336
+ } catch (error) {
337
+ return { ok: false, error: `${LOCK_FILE_RELATIVE} is invalid JSON: ${error.message}` };
338
+ }
339
+ }
340
+
341
+ function writeLockState(repoRoot, payload, dryRun) {
342
+ if (dryRun) return;
343
+ const lockPath = lockFilePath(repoRoot);
344
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
345
+ fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
346
+ }
347
+
348
+ function removeLegacyPackageScripts(repoRoot, dryRun) {
349
+ const packagePath = path.join(repoRoot, 'package.json');
350
+ if (!fs.existsSync(packagePath)) {
351
+ return { status: 'skipped', file: 'package.json', note: 'package.json not found' };
352
+ }
353
+
354
+ let pkg;
355
+ try {
356
+ pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
357
+ } catch (error) {
358
+ throw new Error(`Unable to parse package.json in target repo: ${error.message}`);
359
+ }
360
+
361
+ const existingScripts = pkg.scripts && typeof pkg.scripts === 'object'
362
+ ? pkg.scripts
363
+ : {};
364
+ pkg.scripts = existingScripts;
365
+ let changed = false;
366
+ for (const [key, value] of Object.entries(LEGACY_MANAGED_PACKAGE_SCRIPTS)) {
367
+ if (existingScripts[key] === value) {
368
+ delete existingScripts[key];
369
+ changed = true;
370
+ }
371
+ }
372
+
373
+ if (!changed) {
374
+ return { status: 'unchanged', file: 'package.json', note: 'no Guardex-managed agent:* scripts found' };
375
+ }
376
+
377
+ if (!dryRun) {
378
+ fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
379
+ }
380
+
381
+ return { status: dryRun ? 'would-update' : 'updated', file: 'package.json', note: 'removed Guardex-managed agent:* scripts' };
382
+ }
383
+
384
+ function installUserLevelAsset(asset, options = {}) {
385
+ const dryRun = Boolean(options.dryRun);
386
+ const force = Boolean(options.force);
387
+ const destinationPath = path.join(GUARDEX_HOME_DIR, asset.destination);
388
+ const sourceContent = fs.readFileSync(asset.source, 'utf8');
389
+ const destinationExists = fs.existsSync(destinationPath);
390
+
391
+ if (destinationExists) {
392
+ const existingContent = fs.readFileSync(destinationPath, 'utf8');
393
+ if (existingContent === sourceContent) {
394
+ return { status: 'unchanged', file: asset.destination };
395
+ }
396
+ if (!force) {
397
+ return { status: 'skipped-conflict', file: asset.destination };
398
+ }
399
+ }
400
+
401
+ if (!dryRun) {
402
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
403
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
404
+ }
405
+ return { status: destinationExists ? (dryRun ? 'would-update' : 'updated') : 'created', file: asset.destination };
406
+ }
407
+
408
+ function removeLegacyManagedRepoFile(repoRoot, relativePath, options = {}) {
409
+ const dryRun = Boolean(options.dryRun);
410
+ const force = Boolean(options.force);
411
+ const absolutePath = path.join(repoRoot, relativePath);
412
+ if (!fs.existsSync(absolutePath)) {
413
+ return { status: 'unchanged', file: relativePath, note: 'not present' };
414
+ }
415
+ if (!fs.statSync(absolutePath).isFile()) {
416
+ return { status: 'skipped-conflict', file: relativePath, note: 'not a regular file' };
417
+ }
418
+
419
+ const skillAsset = USER_LEVEL_SKILL_ASSETS.find((asset) => asset.destination === relativePath);
420
+ if (skillAsset) {
421
+ const userLevelPath = path.join(GUARDEX_HOME_DIR, skillAsset.destination);
422
+ if (!fs.existsSync(userLevelPath)) {
423
+ return { status: 'skipped', file: relativePath, note: 'user-level replacement not installed' };
424
+ }
425
+ }
426
+
427
+ const templateRelative = skillAsset
428
+ ? skillAsset.source.slice(TEMPLATE_ROOT.length + 1)
429
+ : relativePath.replace(/^\./, '');
430
+ const sourcePath = path.join(TEMPLATE_ROOT, templateRelative);
431
+ if (!fs.existsSync(sourcePath)) {
432
+ return { status: 'skipped', file: relativePath, note: 'template source missing' };
433
+ }
434
+
435
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
436
+ const existingContent = fs.readFileSync(absolutePath, 'utf8');
437
+ if (existingContent !== sourceContent && !force) {
438
+ return { status: 'skipped-conflict', file: relativePath, note: 'local edits differ from managed template' };
439
+ }
440
+
441
+ if (!dryRun) {
442
+ fs.rmSync(absolutePath, { force: true });
443
+ }
444
+ return { status: dryRun ? 'would-remove' : 'removed', file: relativePath };
445
+ }
446
+
447
+ function ensureAgentsSnippet(repoRoot, dryRun) {
448
+ const agentsPath = path.join(repoRoot, 'AGENTS.md');
449
+ const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'), 'utf8').trimEnd();
450
+ const managedRegex = new RegExp(
451
+ `${AGENTS_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${AGENTS_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
452
+ 'm',
453
+ );
454
+
455
+ if (!fs.existsSync(agentsPath)) {
456
+ if (!dryRun) {
457
+ fs.writeFileSync(agentsPath, `# AGENTS\n\n${snippet}\n`, 'utf8');
458
+ }
459
+ return { status: 'created', file: 'AGENTS.md' };
460
+ }
461
+
462
+ const existing = fs.readFileSync(agentsPath, 'utf8');
463
+ if (managedRegex.test(existing)) {
464
+ const next = existing.replace(managedRegex, snippet);
465
+ if (next === existing) {
466
+ return { status: 'unchanged', file: 'AGENTS.md' };
467
+ }
468
+ if (!dryRun) {
469
+ fs.writeFileSync(agentsPath, next, 'utf8');
470
+ }
471
+ return { status: 'updated', file: 'AGENTS.md', note: 'refreshed gitguardex-managed block' };
472
+ }
473
+
474
+ if (existing.includes(AGENTS_MARKER_START)) {
475
+ return { status: 'unchanged', file: 'AGENTS.md', note: 'existing marker found without managed end marker' };
476
+ }
477
+
478
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
479
+ if (!dryRun) {
480
+ fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
481
+ }
482
+
483
+ return { status: 'updated', file: 'AGENTS.md' };
484
+ }
485
+
486
+ function ensureManagedGitignore(repoRoot, dryRun) {
487
+ const gitignorePath = path.join(repoRoot, '.gitignore');
488
+ const managedBlock = [
489
+ GITIGNORE_MARKER_START,
490
+ ...require('../context').MANAGED_GITIGNORE_PATHS,
491
+ GITIGNORE_MARKER_END,
492
+ ].join('\n');
493
+ const managedRegex = new RegExp(
494
+ `${GITIGNORE_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${GITIGNORE_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
495
+ 'm',
496
+ );
497
+
498
+ if (!fs.existsSync(gitignorePath)) {
499
+ if (!dryRun) {
500
+ fs.writeFileSync(gitignorePath, `${managedBlock}\n`, 'utf8');
501
+ }
502
+ return { status: 'created', file: '.gitignore', note: 'added gitguardex-managed entries' };
503
+ }
504
+
505
+ const existing = fs.readFileSync(gitignorePath, 'utf8');
506
+ if (managedRegex.test(existing)) {
507
+ const next = existing.replace(managedRegex, managedBlock);
508
+ if (next === existing) {
509
+ return { status: 'unchanged', file: '.gitignore' };
510
+ }
511
+ if (!dryRun) {
512
+ fs.writeFileSync(gitignorePath, next, 'utf8');
513
+ }
514
+ return { status: 'updated', file: '.gitignore', note: 'refreshed gitguardex-managed entries' };
515
+ }
516
+
517
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
518
+ if (!dryRun) {
519
+ fs.writeFileSync(gitignorePath, `${existing}${separator}${managedBlock}\n`, 'utf8');
520
+ }
521
+ return { status: 'updated', file: '.gitignore', note: 'appended gitguardex-managed entries' };
522
+ }
523
+
524
+ function stripJsonComments(source) {
525
+ let result = '';
526
+ let inString = false;
527
+ let escapeNext = false;
528
+ let inLineComment = false;
529
+ let inBlockComment = false;
530
+
531
+ for (let index = 0; index < source.length; index += 1) {
532
+ const current = source[index];
533
+ const next = source[index + 1];
534
+
535
+ if (inLineComment) {
536
+ if (current === '\n' || current === '\r') {
537
+ inLineComment = false;
538
+ result += current;
539
+ }
540
+ continue;
541
+ }
542
+
543
+ if (inBlockComment) {
544
+ if (current === '*' && next === '/') {
545
+ inBlockComment = false;
546
+ index += 1;
547
+ continue;
548
+ }
549
+ if (current === '\n' || current === '\r') {
550
+ result += current;
551
+ }
552
+ continue;
553
+ }
554
+
555
+ if (inString) {
556
+ result += current;
557
+ if (escapeNext) {
558
+ escapeNext = false;
559
+ } else if (current === '\\') {
560
+ escapeNext = true;
561
+ } else if (current === '"') {
562
+ inString = false;
563
+ }
564
+ continue;
565
+ }
566
+
567
+ if (current === '"') {
568
+ inString = true;
569
+ result += current;
570
+ continue;
571
+ }
572
+
573
+ if (current === '/' && next === '/') {
574
+ inLineComment = true;
575
+ index += 1;
576
+ continue;
577
+ }
578
+
579
+ if (current === '/' && next === '*') {
580
+ inBlockComment = true;
581
+ index += 1;
582
+ continue;
583
+ }
584
+
585
+ result += current;
586
+ }
587
+
588
+ return result;
589
+ }
590
+
591
+ function stripJsonTrailingCommas(source) {
592
+ let result = '';
593
+ let inString = false;
594
+ let escapeNext = false;
595
+
596
+ for (let index = 0; index < source.length; index += 1) {
597
+ const current = source[index];
598
+
599
+ if (inString) {
600
+ result += current;
601
+ if (escapeNext) {
602
+ escapeNext = false;
603
+ } else if (current === '\\') {
604
+ escapeNext = true;
605
+ } else if (current === '"') {
606
+ inString = false;
607
+ }
608
+ continue;
609
+ }
610
+
611
+ if (current === '"') {
612
+ inString = true;
613
+ result += current;
614
+ continue;
615
+ }
616
+
617
+ if (current === ',') {
618
+ let lookahead = index + 1;
619
+ while (lookahead < source.length && /\s/.test(source[lookahead])) {
620
+ lookahead += 1;
621
+ }
622
+ if (source[lookahead] === '}' || source[lookahead] === ']') {
623
+ continue;
624
+ }
625
+ }
626
+
627
+ result += current;
628
+ }
629
+
630
+ return result;
631
+ }
632
+
633
+ function parseJsonObjectLikeFile(source, relativePath) {
634
+ let parsed;
635
+ try {
636
+ parsed = JSON.parse(stripJsonTrailingCommas(stripJsonComments(source)));
637
+ } catch (error) {
638
+ throw new Error(`Unable to parse ${relativePath} as JSON or JSONC: ${error.message}`);
639
+ }
640
+
641
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
642
+ throw new Error(`${relativePath} must contain a top-level object.`);
643
+ }
644
+
645
+ return parsed;
646
+ }
647
+
648
+ function uniqueStringList(values) {
649
+ const seen = new Set();
650
+ const result = [];
651
+
652
+ for (const value of values) {
653
+ if (typeof value !== 'string' || seen.has(value)) {
654
+ continue;
655
+ }
656
+ seen.add(value);
657
+ result.push(value);
658
+ }
659
+
660
+ return result;
661
+ }
662
+
663
+ function buildRepoVscodeSettings(existingSettings = {}) {
664
+ const nextSettings = { ...existingSettings };
665
+ const existingIgnoredFolders = Array.isArray(existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING])
666
+ ? existingSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING]
667
+ : [];
668
+
669
+ nextSettings[REPO_SCAN_IGNORED_FOLDERS_SETTING] = uniqueStringList([
670
+ ...existingIgnoredFolders,
671
+ ...MANAGED_REPO_SCAN_IGNORED_FOLDERS,
672
+ ]);
673
+
674
+ return nextSettings;
675
+ }
676
+
677
+ function ensureRepoVscodeSettings(repoRoot, dryRun) {
678
+ const settingsPath = path.join(repoRoot, SHARED_VSCODE_SETTINGS_RELATIVE);
679
+ const destinationExists = fs.existsSync(settingsPath);
680
+ const existingContent = destinationExists ? fs.readFileSync(settingsPath, 'utf8') : '';
681
+ const existingSettings = destinationExists
682
+ ? parseJsonObjectLikeFile(existingContent, SHARED_VSCODE_SETTINGS_RELATIVE)
683
+ : {};
684
+ const nextContent = `${JSON.stringify(buildRepoVscodeSettings(existingSettings), null, 2)}\n`;
685
+
686
+ if (destinationExists && existingContent === nextContent) {
687
+ return { status: 'unchanged', file: SHARED_VSCODE_SETTINGS_RELATIVE };
688
+ }
689
+
690
+ ensureParentDir(repoRoot, settingsPath, dryRun);
691
+ if (!dryRun) {
692
+ fs.writeFileSync(settingsPath, nextContent, 'utf8');
693
+ }
694
+
695
+ return {
696
+ status: destinationExists ? 'updated' : 'created',
697
+ file: SHARED_VSCODE_SETTINGS_RELATIVE,
698
+ note: 'shared VS Code repo scan ignores for Guardex worktrees',
699
+ };
700
+ }
701
+
702
+ function normalizeWorkspacePath(relativePath) {
703
+ return String(relativePath || '.').replace(/\\/g, '/');
704
+ }
705
+
706
+ function buildParentWorkspaceView(repoRoot) {
707
+ const parentDir = path.dirname(repoRoot);
708
+ const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
709
+ const workspacePath = path.join(parentDir, workspaceFileName);
710
+ const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
711
+
712
+ return {
713
+ workspacePath,
714
+ payload: {
715
+ folders: [
716
+ { path: repoRelativePath },
717
+ ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
718
+ path: normalizeWorkspacePath(
719
+ path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir),
720
+ ),
721
+ })),
722
+ ],
723
+ settings: {
724
+ 'scm.alwaysShowRepositories': true,
725
+ },
726
+ },
727
+ };
728
+ }
729
+
730
+ function ensureParentWorkspaceView(repoRoot, dryRun) {
731
+ const { workspacePath, payload } = buildParentWorkspaceView(repoRoot);
732
+ const operationFile = path.relative(repoRoot, workspacePath) || path.basename(workspacePath);
733
+ const nextContent = `${JSON.stringify(payload, null, 2)}\n`;
734
+ const note = 'parent VS Code workspace view';
735
+
736
+ if (!fs.existsSync(workspacePath)) {
737
+ if (!dryRun) {
738
+ fs.writeFileSync(workspacePath, nextContent, 'utf8');
739
+ }
740
+ return { status: dryRun ? 'would-create' : 'created', file: operationFile, note };
741
+ }
742
+
743
+ const currentContent = fs.readFileSync(workspacePath, 'utf8');
744
+ if (currentContent === nextContent) {
745
+ return { status: 'unchanged', file: operationFile, note };
746
+ }
747
+
748
+ if (!dryRun) {
749
+ fs.writeFileSync(workspacePath, nextContent, 'utf8');
750
+ }
751
+ return { status: dryRun ? 'would-update' : 'updated', file: operationFile, note };
752
+ }
753
+
754
+ function configureHooks(repoRoot, dryRun) {
755
+ if (dryRun) {
756
+ return { status: 'would-set', key: 'core.hooksPath', value: '.githooks' };
757
+ }
758
+
759
+ const result = run('git', ['-C', repoRoot, 'config', 'core.hooksPath', '.githooks']);
760
+ if (result.status !== 0) {
761
+ throw new Error(`Failed to set git hooksPath: ${(result.stderr || '').trim()}`);
762
+ }
763
+
764
+ return { status: 'set', key: 'core.hooksPath', value: '.githooks' };
765
+ }
766
+
132
767
  function printOperations(title, payload, dryRun = false) {
133
768
  console.log(`[${TOOL_NAME}] ${title}: ${payload.repoRoot}`);
134
769
  for (const operation of payload.operations) {
@@ -156,6 +791,8 @@ function printStandaloneOperations(title, rootLabel, operations, dryRun = false)
156
791
  }
157
792
 
158
793
  module.exports = {
794
+ HOOK_NAMES,
795
+ LOCK_FILE_RELATIVE,
159
796
  toDestinationPath,
160
797
  ensureParentDir,
161
798
  ensureExecutable,
@@ -164,6 +801,28 @@ module.exports = {
164
801
  renderShellDispatchShim,
165
802
  renderPythonDispatchShim,
166
803
  managedForceConflictMessage,
804
+ renderManagedFile,
805
+ ensureGeneratedScriptShim,
806
+ ensureHookShim,
807
+ copyTemplateFile,
808
+ ensureTemplateFilePresent,
809
+ ensureOmxScaffold,
810
+ ensureLockRegistry,
811
+ lockStateOrError,
812
+ writeLockState,
813
+ removeLegacyPackageScripts,
814
+ installUserLevelAsset,
815
+ removeLegacyManagedRepoFile,
816
+ ensureAgentsSnippet,
817
+ ensureManagedGitignore,
818
+ stripJsonComments,
819
+ stripJsonTrailingCommas,
820
+ parseJsonObjectLikeFile,
821
+ buildRepoVscodeSettings,
822
+ ensureRepoVscodeSettings,
823
+ buildParentWorkspaceView,
824
+ ensureParentWorkspaceView,
825
+ configureHooks,
167
826
  printOperations,
168
827
  printStandaloneOperations,
169
828
  };