aiwcli 0.10.0 → 0.10.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 (29) hide show
  1. package/dist/commands/clean.js +1 -0
  2. package/dist/commands/clear.d.ts +14 -2
  3. package/dist/commands/clear.js +298 -58
  4. package/dist/lib/gitignore-manager.d.ts +9 -0
  5. package/dist/lib/gitignore-manager.js +121 -0
  6. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  7. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  8. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  9. package/dist/templates/_shared/hooks/session_end.py +45 -26
  10. package/dist/templates/_shared/hooks/session_start.py +21 -4
  11. package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -34
  12. package/dist/templates/_shared/lib/base/hook_utils.py +9 -1
  13. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  14. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  15. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  16. package/dist/templates/_shared/lib/context/context_formatter.py +1 -0
  17. package/dist/templates/_shared/lib/context/context_selector.py +25 -8
  18. package/dist/templates/_shared/lib/context/context_store.py +22 -5
  19. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  20. package/dist/templates/_shared/lib/templates/plan_context.py +22 -0
  21. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  22. package/dist/templates/_shared/scripts/save_handoff.py +2 -10
  23. package/dist/templates/cc-native/.claude/settings.json +2 -12
  24. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +6 -1
  25. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  26. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +57 -72
  27. package/oclif.manifest.json +17 -2
  28. package/package.json +1 -1
  29. package/dist/templates/cc-native/_cc-native/hooks/plan_accepted.py +0 -127
@@ -45,6 +45,7 @@ export default class CleanCommand extends BaseCommand {
45
45
  async run() {
46
46
  const { flags } = await this.parse(CleanCommand);
47
47
  const targetDir = process.cwd();
48
+ this.logWarning("'aiw clean' is deprecated. Use 'aiw clear --output' instead.");
48
49
  // Validate: require either --method or --all
49
50
  if (!flags.method && !flags.all) {
50
51
  this.error('Either --method or --all is required.', {
@@ -8,6 +8,7 @@ export default class ClearCommand extends BaseCommand {
8
8
  static flags: {
9
9
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
10
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ output: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  template: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  debug: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  help: import("@oclif/core/interfaces").BooleanFlag<void>;
@@ -22,8 +23,11 @@ export default class ClearCommand extends BaseCommand {
22
23
  */
23
24
  private extractMethodNames;
24
25
  /**
25
- * Find all IDE method folders (e.g., .claude/commands/{method}/, .claude/skills/{method}/).
26
- * Searches within IDE configuration folders for method-specific subfolders.
26
+ * Find all IDE method folders by scanning subdirectories of each IDE root
27
+ * for children matching installed method names.
28
+ *
29
+ * For example, finds .claude/commands/cc-native/, .claude/skills/cc-native/,
30
+ * .windsurf/workflows/cc-native/ — without hardcoding which subdirectories exist.
27
31
  *
28
32
  * @param targetDir - Directory to search in
29
33
  * @param template - Optional template/method name to filter by
@@ -48,4 +52,12 @@ export default class ClearCommand extends BaseCommand {
48
52
  * @returns Array of workflow folder paths
49
53
  */
50
54
  private findWorkflowFolders;
55
+ /**
56
+ * Clean runtime output artifacts from _output/ at project root.
57
+ * Handles temp files, cache files, log rotation, and archive cleanup.
58
+ *
59
+ * @param targetDir - Project root directory
60
+ * @param flags - Command flags (dry-run, force)
61
+ */
62
+ private cleanRuntimeOutput;
51
63
  }
@@ -3,6 +3,8 @@ import { join } from 'node:path';
3
3
  import { confirm } from '@inquirer/prompts';
4
4
  import { Flags } from '@oclif/core';
5
5
  import BaseCommand from '../lib/base-command.js';
6
+ import { pruneGitignoreStaleEntries } from '../lib/gitignore-manager.js';
7
+ import { pathExists } from '../lib/paths.js';
6
8
  import { EXIT_CODES } from '../types/exit-codes.js';
7
9
  /**
8
10
  * Container folder for method-specific files
@@ -15,20 +17,59 @@ const AIWCLI_CONTAINER = '.aiwcli';
15
17
  */
16
18
  const OUTPUT_FOLDER_NAME = '_output';
17
19
  /**
18
- * IDE configuration folder names and their method subfolder structure
20
+ * IDE configuration folder names and settings file locations.
21
+ * Method subfolders are discovered dynamically via disk scanning.
19
22
  */
20
23
  const IDE_FOLDERS = {
21
24
  claude: {
22
25
  root: '.claude',
23
- methodSubfolders: ['commands', 'skills', 'agents'],
24
26
  settingsFile: 'settings.json',
25
27
  },
26
28
  windsurf: {
27
29
  root: '.windsurf',
28
- methodSubfolders: ['workflows'],
29
30
  settingsFile: 'hooks.json',
30
31
  },
31
32
  };
33
+ /**
34
+ * Get the set of installed method names by combining the settings.json registry
35
+ * with disk scan of .aiwcli/_* directories.
36
+ *
37
+ * @param targetDir - Directory containing the .aiwcli container
38
+ * @returns Set of method names (e.g., 'cc-native', 'bmad')
39
+ */
40
+ async function getInstalledMethodNames(targetDir) {
41
+ const methods = new Set();
42
+ // Source 1: settings.json methods registry
43
+ for (const ide of Object.values(IDE_FOLDERS)) {
44
+ const settingsPath = join(targetDir, ide.root, ide.settingsFile);
45
+ try {
46
+ const content = await fs.readFile(settingsPath, 'utf8');
47
+ const settings = JSON.parse(content);
48
+ if (settings.methods && typeof settings.methods === 'object') {
49
+ for (const method of Object.keys(settings.methods)) {
50
+ methods.add(method);
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // Settings file doesn't exist or can't be parsed
56
+ }
57
+ }
58
+ // Source 2: disk scan of .aiwcli/_* directories
59
+ const containerDir = join(targetDir, AIWCLI_CONTAINER);
60
+ try {
61
+ const entries = await fs.readdir(containerDir, { withFileTypes: true });
62
+ for (const entry of entries) {
63
+ if (entry.isDirectory() && entry.name.startsWith('_') && entry.name !== OUTPUT_FOLDER_NAME) {
64
+ methods.add(entry.name.slice(1)); // strip leading underscore
65
+ }
66
+ }
67
+ }
68
+ catch {
69
+ // Container doesn't exist
70
+ }
71
+ return methods;
72
+ }
32
73
  /**
33
74
  * AIW gitignore section header marker
34
75
  */
@@ -75,12 +116,11 @@ async function isSettingsFileEmpty(filePath) {
75
116
  * Check if an IDE folder should be fully deleted.
76
117
  * Returns true if:
77
118
  * 1. The settings file is empty (or doesn't exist)
78
- * 2. All method subfolders are empty (or don't exist)
119
+ * 2. All subdirectories are empty (or don't exist)
79
120
  * Backup files (e.g., settings.json.backup) are ignored.
80
121
  *
81
122
  * @param targetDir - Directory containing the IDE folder
82
123
  * @param ideFolder - IDE folder configuration
83
- * @param ideFolder.methodSubfolders - List of method subfolder names to check
84
124
  * @param ideFolder.root - Root folder name (e.g., '.claude')
85
125
  * @param ideFolder.settingsFile - Settings file name (e.g., 'settings.json')
86
126
  * @returns True if the IDE folder should be fully deleted
@@ -104,25 +144,15 @@ async function shouldDeleteIdeFolder(targetDir, ideFolder) {
104
144
  if (!settingsEmpty) {
105
145
  return false;
106
146
  }
107
- // Check if all method subfolders are empty (in parallel)
108
- const subfolderChecks = await Promise.all(ideFolder.methodSubfolders.map(async (subfolder) => {
109
- const subfolderPath = join(ideFolderPath, subfolder);
110
- return isDirectoryEmpty(subfolderPath);
111
- }));
112
- if (subfolderChecks.some((isEmpty) => !isEmpty)) {
113
- return false;
114
- }
115
147
  // Check the IDE folder itself - ignore backup files and check for other meaningful content
116
148
  try {
117
149
  const entries = await fs.readdir(ideFolderPath);
118
- // Filter entries to check (skip backup files, settings file, and known subfolders)
150
+ // Filter entries to check (skip backup files and settings file)
119
151
  const entriesToCheck = entries.filter((entry) => {
120
152
  if (entry.endsWith('.backup'))
121
153
  return false;
122
154
  if (entry === ideFolder.settingsFile)
123
155
  return false;
124
- if (ideFolder.methodSubfolders.includes(entry))
125
- return false;
126
156
  return true;
127
157
  });
128
158
  // Check all entries in parallel
@@ -321,8 +351,7 @@ async function updateIdeSettings(targetDir, ideFolder, methodsToRemove) {
321
351
  if (hook.hooks && Array.isArray(hook.hooks)) {
322
352
  const filteredInner = hook.hooks.filter((innerHook) => {
323
353
  if (typeof innerHook.command === 'string') {
324
- return !methodsToRemove.some((method) => innerHook.command?.toString().includes(`_${method}/`) ||
325
- innerHook.command?.toString().includes(`/${method}-`));
354
+ return !methodsToRemove.some((method) => innerHook.command?.toString().includes(`_${method}/`));
326
355
  }
327
356
  return true;
328
357
  });
@@ -382,6 +411,8 @@ export default class ClearCommand extends BaseCommand {
382
411
  '<%= config.bin %> <%= command.id %> -t cc-native',
383
412
  '<%= config.bin %> <%= command.id %> --dry-run',
384
413
  '<%= config.bin %> <%= command.id %> --force',
414
+ '<%= config.bin %> <%= command.id %> --output',
415
+ '<%= config.bin %> <%= command.id %> --output --dry-run',
385
416
  ];
386
417
  static flags = {
387
418
  ...BaseCommand.baseFlags,
@@ -395,14 +426,26 @@ export default class ClearCommand extends BaseCommand {
395
426
  description: 'Skip confirmation prompt',
396
427
  default: false,
397
428
  }),
429
+ output: Flags.boolean({
430
+ char: 'o',
431
+ description: 'Clean runtime output artifacts (temp files, caches, log rotation, archives)',
432
+ default: false,
433
+ exclusive: ['template'],
434
+ }),
398
435
  template: Flags.string({
399
436
  char: 't',
400
437
  description: 'Clear only a specific template (e.g., cc-native)',
438
+ exclusive: ['output'],
401
439
  }),
402
440
  };
403
441
  async run() {
404
442
  const { flags } = await this.parse(ClearCommand);
405
443
  const targetDir = process.cwd();
444
+ // Handle --output flag separately (mutually exclusive with --template)
445
+ if (flags.output) {
446
+ await this.cleanRuntimeOutput(targetDir, flags);
447
+ return;
448
+ }
406
449
  try {
407
450
  // Find all folders to clear
408
451
  const workflowFolders = await this.findWorkflowFolders(targetDir, flags.template);
@@ -473,25 +516,36 @@ export default class ClearCommand extends BaseCommand {
473
516
  catch {
474
517
  return false;
475
518
  }
476
- // Count existing method folders vs folders being deleted (in parallel)
477
- const subfolderResults = await Promise.all(ideFolder.methodSubfolders.map(async (subfolder) => {
478
- const subfolderPath = join(idePath, subfolder);
479
- try {
480
- const entries = await fs.readdir(subfolderPath, { withFileTypes: true });
481
- const methodDirs = entries.filter((e) => e.isDirectory());
482
- const beingDeleted = methodDirs.filter((entry) => {
483
- const fullPath = join(subfolderPath, entry.name);
484
- return ideMethodFolders.includes(fullPath);
485
- }).length;
486
- return { beingDeleted, total: methodDirs.length };
487
- }
488
- catch {
489
- // Subfolder doesn't exist
490
- return { beingDeleted: 0, total: 0 };
519
+ // Scan all subdirectories to count method folders vs folders being deleted
520
+ let totalMethodFolders = 0;
521
+ let foldersBeingDeleted = 0;
522
+ try {
523
+ const topEntries = await fs.readdir(idePath, { withFileTypes: true });
524
+ const subdirs = topEntries.filter((e) => e.isDirectory());
525
+ // Check each subdirectory for method folders
526
+ const subResults = await Promise.all(subdirs.map(async (subdir) => {
527
+ const subdirPath = join(idePath, subdir.name);
528
+ try {
529
+ const entries = await fs.readdir(subdirPath, { withFileTypes: true });
530
+ const methodDirs = entries.filter((e) => e.isDirectory());
531
+ const deleted = methodDirs.filter((entry) => {
532
+ const fullPath = join(subdirPath, entry.name);
533
+ return ideMethodFolders.includes(fullPath);
534
+ }).length;
535
+ return { deleted, total: methodDirs.length };
536
+ }
537
+ catch {
538
+ return { deleted: 0, total: 0 };
539
+ }
540
+ }));
541
+ for (const r of subResults) {
542
+ totalMethodFolders += r.total;
543
+ foldersBeingDeleted += r.deleted;
491
544
  }
492
- }));
493
- const totalMethodFolders = subfolderResults.reduce((sum, r) => sum + r.total, 0);
494
- const foldersBeingDeleted = subfolderResults.reduce((sum, r) => sum + r.beingDeleted, 0);
545
+ }
546
+ catch {
547
+ return false;
548
+ }
495
549
  // If all method folders are being deleted, check if settings would be empty
496
550
  if (totalMethodFolders > 0 && totalMethodFolders === foldersBeingDeleted) {
497
551
  // Check if settings file would become empty after removing methods
@@ -605,6 +659,11 @@ export default class ClearCommand extends BaseCommand {
605
659
  await updateGitignoreAfterClear(targetDir, [AIWCLI_CONTAINER]);
606
660
  this.logDebug('Updated .gitignore');
607
661
  }
662
+ // Prune stale gitignore entries (paths that no longer exist on disk)
663
+ const pruned = await pruneGitignoreStaleEntries(targetDir);
664
+ if (pruned) {
665
+ this.logDebug('Pruned stale .gitignore entries');
666
+ }
608
667
  // Update IDE settings files to remove method-specific entries
609
668
  let updatedClaudeSettings = false;
610
669
  let updatedWindsurfSettings = false;
@@ -674,7 +733,7 @@ export default class ClearCommand extends BaseCommand {
674
733
  parts.push(`${IDE_FOLDERS.windsurf.root}/ folder`);
675
734
  }
676
735
  this.logSuccess(`Cleared: ${parts.join(', ')}.`);
677
- if (removedAiwcliContainer) {
736
+ if (removedAiwcliContainer || pruned) {
678
737
  this.logSuccess('Updated .gitignore.');
679
738
  }
680
739
  if (updatedClaudeSettings) {
@@ -713,40 +772,49 @@ export default class ClearCommand extends BaseCommand {
713
772
  return methods;
714
773
  }
715
774
  /**
716
- * Find all IDE method folders (e.g., .claude/commands/{method}/, .claude/skills/{method}/).
717
- * Searches within IDE configuration folders for method-specific subfolders.
775
+ * Find all IDE method folders by scanning subdirectories of each IDE root
776
+ * for children matching installed method names.
777
+ *
778
+ * For example, finds .claude/commands/cc-native/, .claude/skills/cc-native/,
779
+ * .windsurf/workflows/cc-native/ — without hardcoding which subdirectories exist.
718
780
  *
719
781
  * @param targetDir - Directory to search in
720
782
  * @param template - Optional template/method name to filter by
721
783
  * @returns Array of IDE method folder paths
722
784
  */
723
785
  async findIdeMethodFolders(targetDir, template) {
724
- // Build list of all subfolder paths to check
725
- const subfolderChecks = [];
726
- for (const ide of Object.values(IDE_FOLDERS)) {
727
- const ideRoot = join(targetDir, ide.root);
728
- for (const subfolder of ide.methodSubfolders) {
729
- subfolderChecks.push({ ideRoot, subfolder });
730
- }
786
+ // Build method set: from --template flag, or from installed methods
787
+ const methodNames = template ? new Set([template]) : await getInstalledMethodNames(targetDir);
788
+ if (methodNames.size === 0) {
789
+ return [];
731
790
  }
732
- // Check all subfolders in parallel
733
- const subfolderResults = await Promise.all(subfolderChecks.map(async ({ ideRoot, subfolder }) => {
734
- const subfolderPath = join(ideRoot, subfolder);
791
+ // For each IDE root, scan all subdirectories for children matching method names
792
+ const ideRoots = Object.values(IDE_FOLDERS).map((ide) => join(targetDir, ide.root));
793
+ const ideResults = await Promise.all(ideRoots.map(async (ideRoot) => {
794
+ // Get all subdirectories of IDE root (e.g., .claude/commands/, .claude/skills/)
735
795
  try {
736
- const stat = await fs.stat(subfolderPath);
737
- if (!stat.isDirectory())
738
- return [];
739
- const entries = await fs.readdir(subfolderPath, { withFileTypes: true });
740
- return entries
741
- .filter((entry) => entry.isDirectory())
742
- .filter((entry) => !template || entry.name === template)
743
- .map((entry) => join(subfolderPath, entry.name));
796
+ const topEntries = await fs.readdir(ideRoot, { withFileTypes: true });
797
+ const subdirs = topEntries.filter((e) => e.isDirectory());
798
+ // For each subdirectory, check for method-named children
799
+ const subResults = await Promise.all(subdirs.map(async (subdir) => {
800
+ const subdirPath = join(ideRoot, subdir.name);
801
+ try {
802
+ const entries = await fs.readdir(subdirPath, { withFileTypes: true });
803
+ return entries
804
+ .filter((entry) => entry.isDirectory() && methodNames.has(entry.name))
805
+ .map((entry) => join(subdirPath, entry.name));
806
+ }
807
+ catch {
808
+ return [];
809
+ }
810
+ }));
811
+ return subResults.flat();
744
812
  }
745
813
  catch {
746
814
  return [];
747
815
  }
748
816
  }));
749
- return subfolderResults.flat();
817
+ return ideResults.flat();
750
818
  }
751
819
  /**
752
820
  * Find all output folders in the target directory.
@@ -832,4 +900,176 @@ export default class ClearCommand extends BaseCommand {
832
900
  }
833
901
  return foundFolders;
834
902
  }
903
+ /**
904
+ * Clean runtime output artifacts from _output/ at project root.
905
+ * Handles temp files, cache files, log rotation, and archive cleanup.
906
+ *
907
+ * @param targetDir - Project root directory
908
+ * @param flags - Command flags (dry-run, force)
909
+ */
910
+ async cleanRuntimeOutput(targetDir, flags) {
911
+ const outputDir = join(targetDir, '_output');
912
+ if (!(await pathExists(outputDir))) {
913
+ this.logInfo('No _output/ directory found.');
914
+ return;
915
+ }
916
+ const toDelete = [];
917
+ let logAction = null;
918
+ let archiveDir = null;
919
+ let archiveCount = 0;
920
+ try {
921
+ const entries = await fs.readdir(outputDir, { withFileTypes: true });
922
+ for (const entry of entries) {
923
+ const entryPath = join(outputDir, entry.name);
924
+ // Temp files: .index_*.tmp (orphaned atomic write files)
925
+ if (entry.isFile() && entry.name.startsWith('.index_') && entry.name.endsWith('.tmp')) {
926
+ toDelete.push({ path: entryPath, reason: 'temp file' });
927
+ continue;
928
+ }
929
+ // Cache files: .*-cache.json
930
+ if (entry.isFile() && entry.name.startsWith('.') && entry.name.endsWith('-cache.json')) {
931
+ toDelete.push({ path: entryPath, reason: 'cache file' });
932
+ continue;
933
+ }
934
+ // Log rotation: hook-log.jsonl > 1MB
935
+ if (entry.isFile() && entry.name === 'hook-log.jsonl') {
936
+ try {
937
+ const stat = await fs.stat(entryPath);
938
+ if (stat.size > 1_048_576) {
939
+ logAction = { path: entryPath, sizeBytes: stat.size };
940
+ }
941
+ }
942
+ catch {
943
+ // Can't stat log file
944
+ }
945
+ continue;
946
+ }
947
+ // Archive cleanup: contexts/_archive/
948
+ if (entry.isDirectory() && entry.name === 'contexts') {
949
+ const archivePath = join(entryPath, '_archive');
950
+ try {
951
+ const archiveEntries = await fs.readdir(archivePath);
952
+ if (archiveEntries.length > 0) {
953
+ archiveDir = archivePath;
954
+ archiveCount = archiveEntries.length;
955
+ }
956
+ }
957
+ catch {
958
+ // No archive directory
959
+ }
960
+ }
961
+ }
962
+ }
963
+ catch (error) {
964
+ const err = error;
965
+ this.error(`Cannot read _output/: ${err.message}`, {
966
+ exit: EXIT_CODES.GENERAL_ERROR,
967
+ });
968
+ }
969
+ // Nothing to clean
970
+ if (toDelete.length === 0 && !logAction && !archiveDir) {
971
+ this.logInfo('No runtime output artifacts to clean.');
972
+ return;
973
+ }
974
+ // Show what will be cleaned
975
+ this.log('');
976
+ this.logInfo('Runtime output cleanup:');
977
+ if (toDelete.length > 0) {
978
+ for (const item of toDelete) {
979
+ const relativePath = item.path.replace(targetDir + '\\', '').replace(targetDir + '/', '');
980
+ this.log(` ${relativePath} (${item.reason})`);
981
+ }
982
+ }
983
+ if (logAction) {
984
+ const sizeMB = (logAction.sizeBytes / 1_048_576).toFixed(1);
985
+ this.log(` _output/hook-log.jsonl (${sizeMB}MB → truncate to ~512KB)`);
986
+ }
987
+ if (archiveDir) {
988
+ this.log(` _output/contexts/_archive/ (${archiveCount} archived context(s))`);
989
+ }
990
+ this.log('');
991
+ // Dry run
992
+ if (flags['dry-run']) {
993
+ this.logInfo('Dry run complete. No files were modified.');
994
+ return;
995
+ }
996
+ // Confirm archive deletion (unless --force)
997
+ if (archiveDir && !flags.force) {
998
+ const shouldDelete = await confirm({
999
+ message: `Delete ${archiveCount} archived context(s)?`,
1000
+ default: false,
1001
+ });
1002
+ if (!shouldDelete) {
1003
+ archiveDir = null;
1004
+ archiveCount = 0;
1005
+ }
1006
+ }
1007
+ // Execute deletions
1008
+ let deletedCount = 0;
1009
+ for (const item of toDelete) {
1010
+ try {
1011
+ await fs.unlink(item.path);
1012
+ deletedCount++;
1013
+ }
1014
+ catch (error) {
1015
+ const err = error;
1016
+ this.logWarning(`Failed to delete ${item.path}: ${err.message}`);
1017
+ }
1018
+ }
1019
+ // Log rotation
1020
+ if (logAction) {
1021
+ try {
1022
+ const content = await fs.readFile(logAction.path, 'utf8');
1023
+ // Keep the most recent 512KB
1024
+ const truncated = content.slice(-524_288);
1025
+ // Find the first complete line
1026
+ const firstNewline = truncated.indexOf('\n');
1027
+ const cleaned = firstNewline >= 0 ? truncated.slice(firstNewline + 1) : truncated;
1028
+ await fs.writeFile(logAction.path, cleaned, 'utf8');
1029
+ this.logDebug('Rotated hook-log.jsonl');
1030
+ }
1031
+ catch (error) {
1032
+ const err = error;
1033
+ this.logWarning(`Failed to rotate log: ${err.message}`);
1034
+ }
1035
+ }
1036
+ // Archive cleanup
1037
+ let archivedCleaned = 0;
1038
+ if (archiveDir) {
1039
+ try {
1040
+ const archiveEntries = await fs.readdir(archiveDir);
1041
+ await Promise.all(archiveEntries.map(async (entry) => {
1042
+ try {
1043
+ await fs.rm(join(archiveDir, entry), { force: true, recursive: true });
1044
+ archivedCleaned++;
1045
+ }
1046
+ catch {
1047
+ // Individual entry failed
1048
+ }
1049
+ }));
1050
+ }
1051
+ catch (error) {
1052
+ const err = error;
1053
+ this.logWarning(`Failed to clean archive: ${err.message}`);
1054
+ }
1055
+ }
1056
+ // Summary
1057
+ this.log('');
1058
+ const parts = [];
1059
+ if (deletedCount > 0) {
1060
+ parts.push(`${deletedCount} file(s) removed`);
1061
+ }
1062
+ if (logAction) {
1063
+ parts.push('log rotated');
1064
+ }
1065
+ if (archivedCleaned > 0) {
1066
+ parts.push(`${archivedCleaned} archived context(s) removed`);
1067
+ }
1068
+ if (parts.length > 0) {
1069
+ this.logSuccess(`Output cleanup: ${parts.join(', ')}.`);
1070
+ }
1071
+ else {
1072
+ this.logInfo('No changes made.');
1073
+ }
1074
+ }
835
1075
  }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Prune stale entries from the AIW Installation section in .gitignore.
3
+ * Checks each entry against disk existence and removes entries whose paths don't exist.
4
+ * Removes the entire section if no entries remain after pruning.
5
+ *
6
+ * @param targetDir - Directory containing .gitignore
7
+ * @returns True if any entries were pruned
8
+ */
9
+ export declare function pruneGitignoreStaleEntries(targetDir: string): Promise<boolean>;
1
10
  /**
2
11
  * Update .gitignore with patterns for installed folders.
3
12
  *
@@ -1,5 +1,126 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { pathExists } from './paths.js';
4
+ /**
5
+ * AIW gitignore section header marker
6
+ */
7
+ const AIW_GITIGNORE_HEADER = '# AIW Installation';
8
+ /**
9
+ * Prune stale entries from the AIW Installation section in .gitignore.
10
+ * Checks each entry against disk existence and removes entries whose paths don't exist.
11
+ * Removes the entire section if no entries remain after pruning.
12
+ *
13
+ * @param targetDir - Directory containing .gitignore
14
+ * @returns True if any entries were pruned
15
+ */
16
+ export async function pruneGitignoreStaleEntries(targetDir) {
17
+ const gitignorePath = join(targetDir, '.gitignore');
18
+ try {
19
+ const content = await fs.readFile(gitignorePath, 'utf8');
20
+ if (!content.includes(AIW_GITIGNORE_HEADER)) {
21
+ return false;
22
+ }
23
+ const lines = content.split('\n');
24
+ const newLines = [];
25
+ let inAiwSection = false;
26
+ const aiwSectionLines = [];
27
+ let pruned = false;
28
+ for (const line of lines) {
29
+ if (line === AIW_GITIGNORE_HEADER) {
30
+ inAiwSection = true;
31
+ aiwSectionLines.push(line);
32
+ continue;
33
+ }
34
+ if (inAiwSection) {
35
+ // AIW section ends at empty line or another comment header
36
+ if (line === '' || (line.startsWith('#') && line !== AIW_GITIGNORE_HEADER)) {
37
+ inAiwSection = false;
38
+ const { lines: filtered, pruned: sectionPruned } = await pruneSection(aiwSectionLines, targetDir);
39
+ if (sectionPruned)
40
+ pruned = true;
41
+ newLines.push(...filtered, line);
42
+ }
43
+ else {
44
+ aiwSectionLines.push(line);
45
+ }
46
+ }
47
+ else {
48
+ newLines.push(line);
49
+ }
50
+ }
51
+ // Handle case where AIW section is at end of file
52
+ if (inAiwSection) {
53
+ const { lines: filtered, pruned: sectionPruned } = await pruneSection(aiwSectionLines, targetDir);
54
+ if (sectionPruned)
55
+ pruned = true;
56
+ newLines.push(...filtered);
57
+ }
58
+ if (!pruned) {
59
+ return false;
60
+ }
61
+ // Clean up: remove AIW section entirely if only header remains
62
+ let result = cleanupEmptySections(newLines.join('\n'));
63
+ // Ensure file ends properly
64
+ result = result.replace(/\n+$/, '\n');
65
+ if (result.trim() === '') {
66
+ result = '';
67
+ }
68
+ await fs.writeFile(gitignorePath, result, 'utf8');
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /**
76
+ * Prune stale entries from a parsed AIW section.
77
+ * Checks each gitignore pattern against disk existence.
78
+ */
79
+ async function pruneSection(sectionLines, targetDir) {
80
+ let pruned = false;
81
+ const filtered = [];
82
+ for (const line of sectionLines) {
83
+ // Always keep the header
84
+ if (line === AIW_GITIGNORE_HEADER) {
85
+ filtered.push(line);
86
+ continue;
87
+ }
88
+ // Check if the path exists on disk
89
+ const cleanPath = line.replace(/^\//, '').replace(/\/$/, '');
90
+ const absPath = join(targetDir, cleanPath);
91
+ if (await pathExists(absPath)) {
92
+ filtered.push(line);
93
+ }
94
+ else {
95
+ pruned = true;
96
+ }
97
+ }
98
+ return { lines: filtered, pruned };
99
+ }
100
+ /**
101
+ * Remove empty AIW sections (header with no patterns following).
102
+ */
103
+ function cleanupEmptySections(content) {
104
+ const lines = content.split('\n');
105
+ const newLines = [];
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const line = lines[i];
108
+ if (line === AIW_GITIGNORE_HEADER) {
109
+ // Look ahead to see if there are any patterns
110
+ const nextLine = lines[i + 1];
111
+ if (nextLine === undefined || nextLine === '' || nextLine.startsWith('#')) {
112
+ // Skip the header — section is empty
113
+ // Also remove trailing empty lines before the header
114
+ while (newLines.length > 0 && newLines.at(-1) === '') {
115
+ newLines.pop();
116
+ }
117
+ continue;
118
+ }
119
+ }
120
+ newLines.push(line);
121
+ }
122
+ return newLines.join('\n');
123
+ }
3
124
  /**
4
125
  * Update .gitignore with patterns for installed folders.
5
126
  *