commandmate 0.3.1 → 0.3.3

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 (151) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +11 -11
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +4 -4
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/config.json +3 -3
  7. package/.next/cache/webpack/client-production/0.pack +0 -0
  8. package/.next/cache/webpack/client-production/1.pack +0 -0
  9. package/.next/cache/webpack/client-production/2.pack +0 -0
  10. package/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  12. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  13. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  14. package/.next/cache/webpack/server-production/0.pack +0 -0
  15. package/.next/cache/webpack/server-production/index.pack +0 -0
  16. package/.next/next-server.js.nft.json +1 -1
  17. package/.next/prerender-manifest.json +1 -1
  18. package/.next/required-server-files.json +1 -1
  19. package/.next/routes-manifest.json +1 -1
  20. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/server/app/api/app/update-check/route.js +1 -1
  23. package/.next/server/app/api/external-apps/[id]/health/route.js +1 -1
  24. package/.next/server/app/api/external-apps/[id]/route.js +1 -1
  25. package/.next/server/app/api/external-apps/route.js +1 -1
  26. package/.next/server/app/api/hooks/claude-done/route.js +1 -1
  27. package/.next/server/app/api/ollama/models/route.js +1 -0
  28. package/.next/server/app/api/ollama/models/route.js.nft.json +1 -0
  29. package/.next/server/app/api/ollama/models.body +1 -0
  30. package/.next/server/app/api/ollama/models.meta +1 -0
  31. package/.next/server/app/api/repositories/clone/[jobId]/route.js +1 -1
  32. package/.next/server/app/api/repositories/clone/route.js +1 -1
  33. package/.next/server/app/api/repositories/excluded/route.js +7 -7
  34. package/.next/server/app/api/repositories/restore/route.js +3 -3
  35. package/.next/server/app/api/repositories/route.js +13 -11
  36. package/.next/server/app/api/repositories/route.js.nft.json +1 -1
  37. package/.next/server/app/api/repositories/scan/route.js +1 -1
  38. package/.next/server/app/api/repositories/sync/route.js +3 -3
  39. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  40. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js.nft.json +1 -1
  41. package/.next/server/app/api/worktrees/[id]/cli-tool/route.js +1 -1
  42. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  43. package/.next/server/app/api/worktrees/[id]/current-output/route.js.nft.json +1 -1
  44. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js +1 -0
  45. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js.nft.json +1 -0
  46. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js +9 -0
  47. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js.nft.json +1 -0
  48. package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
  49. package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
  50. package/.next/server/app/api/worktrees/[id]/interrupt/route.js.nft.json +1 -1
  51. package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
  52. package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
  53. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  54. package/.next/server/app/api/worktrees/[id]/logs/route.js +2 -2
  55. package/.next/server/app/api/worktrees/[id]/memos/[memoId]/route.js +1 -1
  56. package/.next/server/app/api/worktrees/[id]/memos/route.js +1 -1
  57. package/.next/server/app/api/worktrees/[id]/messages/route.js +1 -1
  58. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  59. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
  60. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  61. package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
  62. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  63. package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
  64. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -0
  65. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js.nft.json +1 -0
  66. package/.next/server/app/api/worktrees/[id]/schedules/route.js +4 -0
  67. package/.next/server/app/api/worktrees/[id]/schedules/route.js.nft.json +1 -0
  68. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  69. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  70. package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
  71. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  72. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
  73. package/.next/server/app/api/worktrees/[id]/start-polling/route.js.nft.json +1 -1
  74. package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
  75. package/.next/server/app/api/worktrees/[id]/tree/route.js +1 -1
  76. package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
  77. package/.next/server/app/api/worktrees/[id]/viewed/route.js +1 -1
  78. package/.next/server/app/api/worktrees/route.js +1 -1
  79. package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
  80. package/.next/server/app/login/page.js.nft.json +1 -1
  81. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  82. package/.next/server/app/page.js +1 -1
  83. package/.next/server/app/page.js.nft.json +1 -1
  84. package/.next/server/app/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/proxy/[...path]/route.js +1 -1
  86. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  87. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  88. package/.next/server/app/worktrees/[id]/page.js +8 -3
  89. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  90. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  91. package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
  92. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app-paths-manifest.json +10 -5
  94. package/.next/server/chunks/2314.js +1 -0
  95. package/.next/server/chunks/3860.js +1 -1
  96. package/.next/server/chunks/4559.js +1 -1
  97. package/.next/server/chunks/539.js +10 -10
  98. package/.next/server/chunks/5853.js +1 -1
  99. package/.next/server/chunks/6228.js +1 -0
  100. package/.next/server/chunks/7425.js +112 -37
  101. package/.next/server/chunks/7566.js +1 -1
  102. package/.next/server/chunks/8693.js +1 -1
  103. package/.next/server/chunks/9446.js +1 -0
  104. package/.next/server/functions-config-manifest.json +1 -1
  105. package/.next/server/middleware-build-manifest.js +1 -1
  106. package/.next/server/middleware-manifest.json +5 -5
  107. package/.next/server/pages/500.html +1 -1
  108. package/.next/server/server-reference-manifest.json +1 -1
  109. package/.next/static/chunks/8091-274bc0716106e7fc.js +1 -0
  110. package/.next/static/chunks/app/page-060057e02b841125.js +1 -0
  111. package/.next/static/chunks/app/worktrees/[id]/page-78580947c201d698.js +1 -0
  112. package/.next/static/chunks/{main-db79434ee4a6c931.js → main-2feda12a4d321111.js} +1 -1
  113. package/.next/static/css/e85de230ef5ddc40.css +3 -0
  114. package/.next/trace +5 -5
  115. package/.next/types/app/api/ollama/models/route.ts +343 -0
  116. package/.next/types/app/api/worktrees/[id]/execution-logs/[logId]/route.ts +343 -0
  117. package/.next/types/app/api/worktrees/[id]/execution-logs/route.ts +343 -0
  118. package/.next/types/app/api/worktrees/[id]/schedules/[scheduleId]/route.ts +343 -0
  119. package/.next/types/app/api/worktrees/[id]/schedules/route.ts +343 -0
  120. package/README.md +74 -76
  121. package/dist/cli/utils/docs-reader.d.ts.map +1 -1
  122. package/dist/cli/utils/docs-reader.js +1 -0
  123. package/dist/server/server.js +5 -0
  124. package/dist/server/src/config/cmate-constants.js +79 -0
  125. package/dist/server/src/config/schedule-config.js +60 -0
  126. package/dist/server/src/lib/auto-yes-manager.js +2 -2
  127. package/dist/server/src/lib/claude-executor.js +158 -0
  128. package/dist/server/src/lib/cli-patterns.js +73 -9
  129. package/dist/server/src/lib/cli-tools/gemini.js +81 -22
  130. package/dist/server/src/lib/cli-tools/manager.js +4 -2
  131. package/dist/server/src/lib/cli-tools/types.js +64 -2
  132. package/dist/server/src/lib/cli-tools/vibe-local.js +163 -0
  133. package/dist/server/src/lib/cmate-parser.js +262 -0
  134. package/dist/server/src/lib/db-instance.js +3 -0
  135. package/dist/server/src/lib/db-migrations.js +145 -2
  136. package/dist/server/src/lib/db.js +51 -1
  137. package/dist/server/src/lib/env-sanitizer.js +57 -0
  138. package/dist/server/src/lib/prompt-detector.js +4 -3
  139. package/dist/server/src/lib/response-poller.js +22 -11
  140. package/dist/server/src/lib/schedule-manager.js +401 -0
  141. package/dist/server/src/lib/selected-agents-validator.js +99 -0
  142. package/dist/server/src/types/cmate.js +6 -0
  143. package/dist/server/src/types/sidebar.js +9 -4
  144. package/package.json +2 -1
  145. package/.next/server/chunks/7536.js +0 -1
  146. package/.next/static/chunks/8091-925542bdfc843dce.js +0 -1
  147. package/.next/static/chunks/app/page-238b5a70d8c101e9.js +0 -1
  148. package/.next/static/chunks/app/worktrees/[id]/page-a556551ce5c69dec.js +0 -1
  149. package/.next/static/css/b9ea6a4fad17dc32.css +0 -3
  150. /package/.next/static/{hmAjbCPjxX_C0Os7rphI1 → O7EDFfAYQNe_HRbORxQAC}/_buildManifest.js +0 -0
  151. /package/.next/static/{hmAjbCPjxX_C0Os7rphI1 → O7EDFfAYQNe_HRbORxQAC}/_ssgManifest.js +0 -0
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ /**
3
+ * Vibe Local CLI tool implementation
4
+ * Provides integration with vibe-local (vibe-coder) in interactive mode
5
+ *
6
+ * @remarks Issue #368: Rewritten from non-interactive pipe mode to interactive REPL mode.
7
+ * Previous implementation used `echo 'msg' | vibe-local` which caused the process to exit
8
+ * immediately with "(Cancelled)" + "Goodbye!", making response polling impossible.
9
+ * Now launches `vibe-local -y` in interactive mode within tmux (same approach as Claude/Codex/Gemini).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.VibeLocalTool = void 0;
13
+ const base_1 = require("./base");
14
+ const types_1 = require("./types");
15
+ const tmux_1 = require("../tmux");
16
+ const pasted_text_helper_1 = require("../pasted-text-helper");
17
+ const db_instance_1 = require("../db-instance");
18
+ const db_1 = require("../db");
19
+ /**
20
+ * Extract error message from unknown error type (DRY)
21
+ */
22
+ function getErrorMessage(error) {
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+ /**
26
+ * Wait for vibe-local to initialize after launch.
27
+ * vibe-local shows a permission check prompt, banner, and model loading.
28
+ */
29
+ const VIBE_LOCAL_INIT_WAIT_MS = 5000;
30
+ /**
31
+ * Vibe Local CLI tool implementation
32
+ * Manages vibe-local interactive sessions using tmux
33
+ */
34
+ class VibeLocalTool extends base_1.BaseCLITool {
35
+ id = 'vibe-local';
36
+ name = 'Vibe Local';
37
+ command = 'vibe-local';
38
+ /**
39
+ * Check if vibe-local session is running for a worktree
40
+ */
41
+ async isRunning(worktreeId) {
42
+ const sessionName = this.getSessionName(worktreeId);
43
+ return await (0, tmux_1.hasSession)(sessionName);
44
+ }
45
+ /**
46
+ * Start a new vibe-local session for a worktree
47
+ * Launches `vibe-local -y` in interactive mode within tmux
48
+ *
49
+ * @param worktreeId - Worktree ID
50
+ * @param worktreePath - Worktree path
51
+ */
52
+ async startSession(worktreeId, worktreePath) {
53
+ const vibeLocalAvailable = await this.isInstalled();
54
+ if (!vibeLocalAvailable) {
55
+ throw new Error('vibe-local is not installed or not in PATH');
56
+ }
57
+ const sessionName = this.getSessionName(worktreeId);
58
+ const exists = await (0, tmux_1.hasSession)(sessionName);
59
+ if (exists) {
60
+ console.log(`Vibe Local session ${sessionName} already exists`);
61
+ return;
62
+ }
63
+ try {
64
+ // Create tmux session with large history buffer
65
+ await (0, tmux_1.createSession)({
66
+ sessionName,
67
+ workingDirectory: worktreePath,
68
+ historyLimit: 50000,
69
+ });
70
+ // Wait a moment for the session to be created
71
+ await new Promise((resolve) => setTimeout(resolve, 100));
72
+ // Read Ollama model preference from DB
73
+ // [SEC-001] Re-validate model name at point of use (defense-in-depth)
74
+ let vibeLocalCommand = 'vibe-local -y';
75
+ try {
76
+ const db = (0, db_instance_1.getDbInstance)();
77
+ const wt = (0, db_1.getWorktreeById)(db, worktreeId);
78
+ if (wt?.vibeLocalModel && types_1.OLLAMA_MODEL_PATTERN.test(wt.vibeLocalModel)) {
79
+ vibeLocalCommand = `vibe-local -y -m ${wt.vibeLocalModel}`;
80
+ }
81
+ }
82
+ catch {
83
+ // DB read failure is non-fatal; use default model
84
+ }
85
+ // Start vibe-local in interactive mode with auto-approve (-y)
86
+ // -y flag skips the permission confirmation prompt
87
+ await (0, tmux_1.sendKeys)(sessionName, vibeLocalCommand, true);
88
+ // Wait for vibe-local to initialize (banner + model loading)
89
+ await new Promise((resolve) => setTimeout(resolve, VIBE_LOCAL_INIT_WAIT_MS));
90
+ console.log(`✓ Started Vibe Local session: ${sessionName}`);
91
+ }
92
+ catch (error) {
93
+ const errorMessage = getErrorMessage(error);
94
+ throw new Error(`Failed to start Vibe Local session: ${errorMessage}`);
95
+ }
96
+ }
97
+ /**
98
+ * Send a message to vibe-local interactive session
99
+ *
100
+ * @param worktreeId - Worktree ID
101
+ * @param message - Message to send
102
+ */
103
+ async sendMessage(worktreeId, message) {
104
+ const sessionName = this.getSessionName(worktreeId);
105
+ const exists = await (0, tmux_1.hasSession)(sessionName);
106
+ if (!exists) {
107
+ throw new Error(`Vibe Local session ${sessionName} does not exist. Start the session first.`);
108
+ }
109
+ try {
110
+ // Send message to vibe-local (without Enter)
111
+ await (0, tmux_1.sendKeys)(sessionName, message, false);
112
+ // Wait a moment for the text to be typed
113
+ await new Promise((resolve) => setTimeout(resolve, 100));
114
+ // vibe-local uses IME mode: first Enter creates a new line,
115
+ // second Enter on empty line submits the message.
116
+ // Send Enter twice with a short delay between.
117
+ await (0, tmux_1.sendSpecialKey)(sessionName, 'C-m');
118
+ await new Promise((resolve) => setTimeout(resolve, 200));
119
+ await (0, tmux_1.sendSpecialKey)(sessionName, 'C-m');
120
+ // Wait a moment for the message to be processed
121
+ await new Promise((resolve) => setTimeout(resolve, 200));
122
+ // Detect [Pasted text] and resend Enter for multi-line messages
123
+ if (message.includes('\n')) {
124
+ await (0, pasted_text_helper_1.detectAndResendIfPastedText)(sessionName);
125
+ }
126
+ console.log(`✓ Sent message to Vibe Local session: ${sessionName}`);
127
+ }
128
+ catch (error) {
129
+ const errorMessage = getErrorMessage(error);
130
+ throw new Error(`Failed to send message to Vibe Local: ${errorMessage}`);
131
+ }
132
+ }
133
+ /**
134
+ * Kill vibe-local session
135
+ *
136
+ * @param worktreeId - Worktree ID
137
+ */
138
+ async killSession(worktreeId) {
139
+ const sessionName = this.getSessionName(worktreeId);
140
+ try {
141
+ const exists = await (0, tmux_1.hasSession)(sessionName);
142
+ if (exists) {
143
+ // Send Ctrl+C to interrupt any running operation
144
+ await (0, tmux_1.sendSpecialKey)(sessionName, 'C-c');
145
+ await new Promise((resolve) => setTimeout(resolve, 300));
146
+ // Send Ctrl+C again to ensure exit
147
+ await (0, tmux_1.sendSpecialKey)(sessionName, 'C-c');
148
+ await new Promise((resolve) => setTimeout(resolve, 500));
149
+ }
150
+ // Kill the tmux session
151
+ const killed = await (0, tmux_1.killSession)(sessionName);
152
+ if (killed) {
153
+ console.log(`✓ Stopped Vibe Local session: ${sessionName}`);
154
+ }
155
+ }
156
+ catch (error) {
157
+ const errorMessage = getErrorMessage(error);
158
+ console.error(`Error stopping Vibe Local session: ${errorMessage}`);
159
+ throw error;
160
+ }
161
+ }
162
+ }
163
+ exports.VibeLocalTool = VibeLocalTool;
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+ /**
3
+ * CMATE.md Parser
4
+ * Issue #294: Schedule execution feature
5
+ *
6
+ * Parses CMATE.md files (Markdown table format) from worktree root directories.
7
+ * Provides a generic table parser and a specialized schedule section parser.
8
+ *
9
+ * Security:
10
+ * - Path traversal prevention (realpath + worktree directory validation)
11
+ * - Unicode control character sanitization
12
+ * - Name validation with strict pattern matching
13
+ * - Cron expression validation
14
+ */
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.MIN_CRON_INTERVAL = exports.CONTROL_CHAR_REGEX = exports.isValidCronExpression = exports.MAX_SCHEDULE_ENTRIES = exports.MAX_CRON_EXPRESSION_LENGTH = exports.NAME_PATTERN = exports.CMATE_FILENAME = void 0;
20
+ exports.sanitizeMessageContent = sanitizeMessageContent;
21
+ exports.validateCmatePath = validateCmatePath;
22
+ exports.parseCmateFile = parseCmateFile;
23
+ exports.parseSchedulesSection = parseSchedulesSection;
24
+ exports.readCmateFile = readCmateFile;
25
+ const fs_1 = require("fs");
26
+ const path_1 = __importDefault(require("path"));
27
+ const types_1 = require("../lib/cli-tools/types");
28
+ const schedule_config_1 = require("../config/schedule-config");
29
+ const cmate_constants_1 = require("../config/cmate-constants");
30
+ Object.defineProperty(exports, "CMATE_FILENAME", { enumerable: true, get: function () { return cmate_constants_1.CMATE_FILENAME; } });
31
+ Object.defineProperty(exports, "NAME_PATTERN", { enumerable: true, get: function () { return cmate_constants_1.NAME_PATTERN; } });
32
+ Object.defineProperty(exports, "MAX_CRON_EXPRESSION_LENGTH", { enumerable: true, get: function () { return cmate_constants_1.MAX_CRON_EXPRESSION_LENGTH; } });
33
+ Object.defineProperty(exports, "MAX_SCHEDULE_ENTRIES", { enumerable: true, get: function () { return cmate_constants_1.MAX_SCHEDULE_ENTRIES; } });
34
+ Object.defineProperty(exports, "isValidCronExpression", { enumerable: true, get: function () { return cmate_constants_1.isValidCronExpression; } });
35
+ /**
36
+ * @deprecated Use CONTROL_CHAR_PATTERN from '../config/cmate-constants' instead.
37
+ * Kept for backward compatibility with existing tests.
38
+ */
39
+ exports.CONTROL_CHAR_REGEX = new RegExp(cmate_constants_1.CONTROL_CHAR_PATTERN.source, 'g');
40
+ /** Minimum cron interval pattern (every minute) */
41
+ exports.MIN_CRON_INTERVAL = '* * * * *';
42
+ // =============================================================================
43
+ // Sanitization
44
+ // =============================================================================
45
+ /**
46
+ * Remove Unicode control characters from a string.
47
+ * Preserves tabs (\t), newlines (\n), and carriage returns (\r).
48
+ *
49
+ * @param content - Raw string to sanitize
50
+ * @returns Sanitized string with control characters removed
51
+ */
52
+ function sanitizeMessageContent(content) {
53
+ return (0, cmate_constants_1.sanitizeContent)(content);
54
+ }
55
+ // =============================================================================
56
+ // Path Validation
57
+ // =============================================================================
58
+ /**
59
+ * Validate that a CMATE.md file path is within the expected worktree directory.
60
+ * Prevents path traversal attacks by resolving symlinks and verifying containment.
61
+ *
62
+ * @param filePath - Path to CMATE.md file
63
+ * @param worktreeDir - Expected worktree directory
64
+ * @returns true if path is valid and within worktree directory
65
+ * @throws Error if path traversal is detected
66
+ */
67
+ function validateCmatePath(filePath, worktreeDir) {
68
+ const realFilePath = (0, fs_1.realpathSync)(filePath);
69
+ const realWorktreeDir = (0, fs_1.realpathSync)(worktreeDir);
70
+ // Ensure the file is within the worktree directory
71
+ if (!realFilePath.startsWith(realWorktreeDir + path_1.default.sep) &&
72
+ realFilePath !== path_1.default.join(realWorktreeDir, cmate_constants_1.CMATE_FILENAME)) {
73
+ throw new Error(`Path traversal detected: ${filePath} is not within ${worktreeDir}`);
74
+ }
75
+ return true;
76
+ }
77
+ // =============================================================================
78
+ // Generic Markdown Table Parser
79
+ // =============================================================================
80
+ /**
81
+ * Parse a CMATE.md file into a generic structure.
82
+ * Returns a Map where keys are section names (from ## headers)
83
+ * and values are arrays of row data (each row is an array of cell values).
84
+ *
85
+ * [S1-010] Generic design: returns Map<string, string[][]>
86
+ *
87
+ * @param content - Raw CMATE.md file content
88
+ * @returns Map of section name to table rows
89
+ */
90
+ function parseCmateFile(content) {
91
+ const result = new Map();
92
+ const lines = content.split('\n');
93
+ let currentSection = null;
94
+ let headerParsed = false;
95
+ let separatorParsed = false;
96
+ for (const line of lines) {
97
+ const trimmed = line.trim();
98
+ // Detect section headers (## SectionName)
99
+ const headerMatch = trimmed.match(/^##\s+(.+)$/);
100
+ if (headerMatch) {
101
+ currentSection = headerMatch[1].trim();
102
+ headerParsed = false;
103
+ separatorParsed = false;
104
+ if (!result.has(currentSection)) {
105
+ result.set(currentSection, []);
106
+ }
107
+ continue;
108
+ }
109
+ // Skip empty lines
110
+ if (!trimmed) {
111
+ continue;
112
+ }
113
+ // Skip non-table lines
114
+ if (!trimmed.startsWith('|')) {
115
+ continue;
116
+ }
117
+ if (!currentSection) {
118
+ continue;
119
+ }
120
+ // Parse table row
121
+ if (!headerParsed) {
122
+ // First row is header - skip it
123
+ headerParsed = true;
124
+ continue;
125
+ }
126
+ if (!separatorParsed) {
127
+ // Second row is separator (|---|---|) - skip it
128
+ if (trimmed.match(/^\|[\s-:|]+\|$/)) {
129
+ separatorParsed = true;
130
+ continue;
131
+ }
132
+ // If it's not a separator, treat it as data
133
+ separatorParsed = true;
134
+ }
135
+ // Parse data row
136
+ const cells = trimmed
137
+ .split('|')
138
+ .slice(1, -1) // Remove leading and trailing empty strings from split
139
+ .map((cell) => cell.trim());
140
+ if (cells.length > 0) {
141
+ result.get(currentSection).push(cells);
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+ // =============================================================================
147
+ // Schedule Section Parser
148
+ // =============================================================================
149
+ /**
150
+ * Parse the Schedules section of a CMATE.md file into typed ScheduleEntry objects.
151
+ *
152
+ * Expected table format:
153
+ * | Name | Cron | Message | CLI Tool | Enabled |
154
+ * |------|------|---------|----------|---------|
155
+ * | daily-review | 0 9 * * * | Review code changes | claude | true |
156
+ *
157
+ * Entries with invalid names, cron expressions, or missing required fields
158
+ * are silently skipped with a console.warn.
159
+ *
160
+ * @param rows - Raw table rows from parseCmateFile() for the Schedules section
161
+ * @returns Array of validated ScheduleEntry objects
162
+ */
163
+ function parseSchedulesSection(rows) {
164
+ const entries = [];
165
+ for (const row of rows) {
166
+ if (entries.length >= cmate_constants_1.MAX_SCHEDULE_ENTRIES) {
167
+ console.warn(`[cmate-parser] Maximum schedule entries (${cmate_constants_1.MAX_SCHEDULE_ENTRIES}) reached, skipping remaining`);
168
+ break;
169
+ }
170
+ // Minimum required columns: Name, Cron, Message
171
+ if (row.length < 3) {
172
+ console.warn('[cmate-parser] Skipping row with insufficient columns:', row);
173
+ continue;
174
+ }
175
+ const [name, cronExpression, message, cliToolId, enabledStr, permissionStr] = row;
176
+ // Validate name
177
+ const sanitizedName = sanitizeMessageContent(name);
178
+ if (!cmate_constants_1.NAME_PATTERN.test(sanitizedName)) {
179
+ console.warn(`[cmate-parser] Skipping entry with invalid name: "${sanitizedName}"`);
180
+ continue;
181
+ }
182
+ // Validate cron expression
183
+ if (!(0, cmate_constants_1.isValidCronExpression)(cronExpression)) {
184
+ console.warn(`[cmate-parser] Skipping entry "${sanitizedName}" with invalid cron: "${cronExpression}"`);
185
+ continue;
186
+ }
187
+ // Sanitize message
188
+ const sanitizedMessage = sanitizeMessageContent(message);
189
+ if (!sanitizedMessage) {
190
+ console.warn(`[cmate-parser] Skipping entry "${sanitizedName}" with empty message`);
191
+ continue;
192
+ }
193
+ // Parse enabled (default: true)
194
+ const enabled = enabledStr === undefined ||
195
+ enabledStr === '' ||
196
+ enabledStr.toLowerCase() === 'true';
197
+ // Parse and validate CLI tool ID [SEC-002]
198
+ const resolvedCliToolId = cliToolId?.trim() || 'claude';
199
+ if (!(0, types_1.isCliToolType)(resolvedCliToolId)) {
200
+ console.warn(`[cmate-parser] Skipping entry "${sanitizedName}" with invalid CLI tool: "${resolvedCliToolId}"`);
201
+ continue;
202
+ }
203
+ const defaultPermission = schedule_config_1.DEFAULT_PERMISSIONS[resolvedCliToolId] ?? '';
204
+ let permission = permissionStr?.trim() || defaultPermission;
205
+ // Validate permission against allowed values
206
+ let allowedValues;
207
+ switch (resolvedCliToolId) {
208
+ case 'codex':
209
+ allowedValues = schedule_config_1.CODEX_SANDBOXES;
210
+ break;
211
+ case 'gemini':
212
+ case 'vibe-local':
213
+ // No permission flags for gemini/vibe-local; only empty string is valid
214
+ allowedValues = [];
215
+ if (permission) {
216
+ console.warn(`[cmate-parser] Permission "${permission}" ignored for ${resolvedCliToolId} in entry "${sanitizedName}" (no permission flags supported)`);
217
+ permission = '';
218
+ }
219
+ break;
220
+ default:
221
+ allowedValues = schedule_config_1.CLAUDE_PERMISSIONS;
222
+ break;
223
+ }
224
+ if (allowedValues.length > 0 && permission && !allowedValues.includes(permission)) {
225
+ console.warn(`[cmate-parser] Invalid permission "${permission}" for ${resolvedCliToolId} in entry "${sanitizedName}", using default "${defaultPermission}"`);
226
+ permission = defaultPermission;
227
+ }
228
+ entries.push({
229
+ name: sanitizedName,
230
+ cronExpression: cronExpression.trim(),
231
+ message: sanitizedMessage,
232
+ cliToolId: resolvedCliToolId,
233
+ enabled,
234
+ permission,
235
+ });
236
+ }
237
+ return entries;
238
+ }
239
+ /**
240
+ * Read and parse a CMATE.md file from a worktree directory.
241
+ *
242
+ * @param worktreeDir - Path to the worktree directory
243
+ * @returns Parsed CmateConfig, or null if the file doesn't exist
244
+ * @throws Error if path traversal is detected
245
+ */
246
+ function readCmateFile(worktreeDir) {
247
+ const filePath = path_1.default.join(worktreeDir, cmate_constants_1.CMATE_FILENAME);
248
+ try {
249
+ // Validate path before reading
250
+ validateCmatePath(filePath, worktreeDir);
251
+ const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
252
+ return parseCmateFile(content);
253
+ }
254
+ catch (error) {
255
+ if (error instanceof Error &&
256
+ 'code' in error &&
257
+ error.code === 'ENOENT') {
258
+ return null;
259
+ }
260
+ throw error;
261
+ }
262
+ }
@@ -45,6 +45,9 @@ function getDbInstance() {
45
45
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
46
46
  }
47
47
  dbInstance = new better_sqlite3_1.default(dbPath);
48
+ // Issue #294: Enable foreign key enforcement BEFORE migrations
49
+ // This ensures ON DELETE CASCADE works correctly for all tables
50
+ dbInstance.pragma('foreign_keys = ON');
48
51
  (0, db_migrations_1.runMigrations)(dbInstance);
49
52
  }
50
53
  return dbInstance;
@@ -19,7 +19,7 @@ const db_1 = require("./db");
19
19
  * Current schema version
20
20
  * Increment this when adding new migrations
21
21
  */
22
- exports.CURRENT_SCHEMA_VERSION = 16;
22
+ exports.CURRENT_SCHEMA_VERSION = 19;
23
23
  /**
24
24
  * Migration registry
25
25
  * All migrations should be added to this array in order
@@ -717,6 +717,149 @@ const migrations = [
717
717
  `);
718
718
  console.log('✓ Removed issue_no column from external_apps table');
719
719
  }
720
+ },
721
+ {
722
+ version: 17,
723
+ name: 'add-scheduled-executions-and-execution-logs',
724
+ up: (db) => {
725
+ // Issue #294: Schedule execution feature
726
+ // [S3-002] Clean up orphan records BEFORE creating new tables with FK constraints
727
+ // These records may exist if worktrees/repositories were deleted while FK was disabled
728
+ db.exec(`
729
+ DELETE FROM chat_messages WHERE worktree_id NOT IN (SELECT id FROM worktrees);
730
+ `);
731
+ db.exec(`
732
+ DELETE FROM session_states WHERE worktree_id NOT IN (SELECT id FROM worktrees);
733
+ `);
734
+ db.exec(`
735
+ DELETE FROM worktree_memos WHERE worktree_id NOT IN (SELECT id FROM worktrees);
736
+ `);
737
+ db.exec(`
738
+ UPDATE clone_jobs SET repository_id = NULL
739
+ WHERE repository_id IS NOT NULL AND repository_id NOT IN (SELECT id FROM repositories);
740
+ `);
741
+ // Create scheduled_executions table
742
+ db.exec(`
743
+ CREATE TABLE scheduled_executions (
744
+ id TEXT PRIMARY KEY,
745
+ worktree_id TEXT NOT NULL,
746
+ cli_tool_id TEXT DEFAULT 'claude',
747
+ name TEXT NOT NULL,
748
+ message TEXT NOT NULL,
749
+ cron_expression TEXT,
750
+ enabled INTEGER DEFAULT 1,
751
+ last_executed_at INTEGER,
752
+ next_execute_at INTEGER,
753
+ created_at INTEGER NOT NULL,
754
+ updated_at INTEGER NOT NULL,
755
+ UNIQUE(worktree_id, name),
756
+ FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
757
+ );
758
+ `);
759
+ // Create index on worktree_id for scheduled_executions
760
+ db.exec(`
761
+ CREATE INDEX idx_scheduled_executions_worktree
762
+ ON scheduled_executions(worktree_id);
763
+ `);
764
+ // Create index on enabled for filtering active schedules
765
+ db.exec(`
766
+ CREATE INDEX idx_scheduled_executions_enabled
767
+ ON scheduled_executions(enabled);
768
+ `);
769
+ // Create execution_logs table
770
+ db.exec(`
771
+ CREATE TABLE execution_logs (
772
+ id TEXT PRIMARY KEY,
773
+ schedule_id TEXT NOT NULL,
774
+ worktree_id TEXT NOT NULL,
775
+ message TEXT NOT NULL,
776
+ result TEXT,
777
+ exit_code INTEGER,
778
+ status TEXT DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'timeout', 'cancelled')),
779
+ started_at INTEGER NOT NULL,
780
+ completed_at INTEGER,
781
+ created_at INTEGER NOT NULL,
782
+ FOREIGN KEY (schedule_id) REFERENCES scheduled_executions(id) ON DELETE CASCADE,
783
+ FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
784
+ );
785
+ `);
786
+ // Create indexes for execution_logs
787
+ db.exec(`
788
+ CREATE INDEX idx_execution_logs_schedule
789
+ ON execution_logs(schedule_id);
790
+ `);
791
+ db.exec(`
792
+ CREATE INDEX idx_execution_logs_worktree
793
+ ON execution_logs(worktree_id);
794
+ `);
795
+ db.exec(`
796
+ CREATE INDEX idx_execution_logs_status
797
+ ON execution_logs(status);
798
+ `);
799
+ console.log('✓ Cleaned up orphan records');
800
+ console.log('✓ Created scheduled_executions table');
801
+ console.log('✓ Created execution_logs table');
802
+ console.log('✓ Created indexes for schedule tables');
803
+ },
804
+ down: (db) => {
805
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_status');
806
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_worktree');
807
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_schedule');
808
+ db.exec('DROP TABLE IF EXISTS execution_logs');
809
+ db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_enabled');
810
+ db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_worktree');
811
+ db.exec('DROP TABLE IF EXISTS scheduled_executions');
812
+ console.log('✓ Dropped scheduled_executions and execution_logs tables');
813
+ }
814
+ },
815
+ {
816
+ version: 18,
817
+ name: 'add-selected-agents-column',
818
+ up: (db) => {
819
+ // Issue #368: Add selected_agents column for agent selection persistence
820
+ // NOTE (R1-010): The literal values 'claude', 'codex' in the SQL CASE below
821
+ // are fixed at migration time and do NOT sync with TypeScript CLI_TOOL_IDS.
822
+ // Changes to CLI_TOOL_IDS will not retroactively affect already-migrated data.
823
+ // Migration tests cover all CLIToolType values to catch sync issues.
824
+ // Step 1: Add column
825
+ db.exec(`
826
+ ALTER TABLE worktrees ADD COLUMN selected_agents TEXT;
827
+ `);
828
+ // Step 2: Initialize existing data based on cli_tool_id
829
+ // - If cli_tool_id is 'claude' or 'codex' -> default ["claude","codex"]
830
+ // - Otherwise (e.g. 'gemini', 'vibe-local') -> [cli_tool_id, "claude"]
831
+ db.exec(`
832
+ UPDATE worktrees SET selected_agents =
833
+ CASE
834
+ WHEN cli_tool_id NOT IN ('claude', 'codex')
835
+ THEN json_array(cli_tool_id, 'claude')
836
+ ELSE '["claude","codex"]'
837
+ END;
838
+ `);
839
+ console.log('✓ Added selected_agents column to worktrees table');
840
+ console.log('✓ Initialized selected_agents based on cli_tool_id');
841
+ },
842
+ down: () => {
843
+ // selected_agents is a nullable TEXT column; dropping it requires table recreation
844
+ // which is disproportionate for a rollback. The column is harmless if unused.
845
+ console.log('No rollback for selected_agents column (SQLite limitation)');
846
+ }
847
+ },
848
+ {
849
+ version: 19,
850
+ name: 'add-vibe-local-model-column',
851
+ up: (db) => {
852
+ // Issue #368: Add vibe_local_model column for Ollama model selection
853
+ // NULL means use the default model (vibe-local decides)
854
+ db.exec(`
855
+ ALTER TABLE worktrees ADD COLUMN vibe_local_model TEXT DEFAULT NULL;
856
+ `);
857
+ console.log('✓ Added vibe_local_model column to worktrees table');
858
+ },
859
+ down: () => {
860
+ // vibe_local_model is a nullable TEXT column; harmless if unused
861
+ console.log('No rollback for vibe_local_model column (SQLite limitation)');
862
+ }
720
863
  }
721
864
  ];
722
865
  /**
@@ -904,7 +1047,7 @@ function validateSchema(db) {
904
1047
  ORDER BY name
905
1048
  `).all();
906
1049
  const tableNames = tables.map(t => t.name);
907
- const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs'];
1050
+ const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs', 'scheduled_executions', 'execution_logs'];
908
1051
  const missingTables = requiredTables.filter(t => !tableNames.includes(t));
909
1052
  if (missingTables.length > 0) {
910
1053
  console.error('Missing required tables:', missingTables.join(', '));