@covibes/zeroshot 5.2.1 → 5.4.0

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 (98) hide show
  1. package/CHANGELOG.md +174 -189
  2. package/README.md +226 -195
  3. package/cli/commands/providers.js +149 -0
  4. package/cli/index.js +3145 -2366
  5. package/cli/lib/first-run.js +40 -3
  6. package/cli/message-formatters-normal.js +28 -6
  7. package/cluster-templates/base-templates/debug-workflow.json +24 -78
  8. package/cluster-templates/base-templates/full-workflow.json +99 -316
  9. package/cluster-templates/base-templates/single-worker.json +23 -15
  10. package/cluster-templates/base-templates/worker-validator.json +105 -36
  11. package/cluster-templates/conductor-bootstrap.json +9 -7
  12. package/lib/docker-config.js +14 -1
  13. package/lib/git-remote-utils.js +165 -0
  14. package/lib/id-detector.js +10 -7
  15. package/lib/provider-defaults.js +62 -0
  16. package/lib/provider-detection.js +59 -0
  17. package/lib/provider-names.js +57 -0
  18. package/lib/settings/claude-auth.js +78 -0
  19. package/lib/settings.js +298 -15
  20. package/lib/stream-json-parser.js +4 -238
  21. package/package.json +27 -6
  22. package/scripts/setup-merge-queue.sh +170 -0
  23. package/scripts/validate-templates.js +100 -0
  24. package/src/agent/agent-config.js +140 -63
  25. package/src/agent/agent-context-builder.js +336 -165
  26. package/src/agent/agent-hook-executor.js +337 -67
  27. package/src/agent/agent-lifecycle.js +386 -287
  28. package/src/agent/agent-stuck-detector.js +7 -7
  29. package/src/agent/agent-task-executor.js +944 -683
  30. package/src/agent/output-extraction.js +217 -0
  31. package/src/agent/output-reformatter.js +175 -0
  32. package/src/agent/schema-utils.js +146 -0
  33. package/src/agent-wrapper.js +112 -31
  34. package/src/agents/git-pusher-template.js +285 -0
  35. package/src/claude-task-runner.js +145 -44
  36. package/src/config-router.js +13 -13
  37. package/src/config-validator.js +1049 -563
  38. package/src/input-helpers.js +65 -0
  39. package/src/isolation-manager.js +499 -320
  40. package/src/issue-providers/README.md +305 -0
  41. package/src/issue-providers/azure-devops-provider.js +273 -0
  42. package/src/issue-providers/base-provider.js +232 -0
  43. package/src/issue-providers/github-provider.js +179 -0
  44. package/src/issue-providers/gitlab-provider.js +241 -0
  45. package/src/issue-providers/index.js +196 -0
  46. package/src/issue-providers/jira-provider.js +239 -0
  47. package/src/ledger.js +50 -11
  48. package/src/lib/safe-exec.js +88 -0
  49. package/src/orchestrator.js +1348 -757
  50. package/src/preflight.js +306 -149
  51. package/src/process-metrics.js +98 -56
  52. package/src/providers/anthropic/cli-builder.js +73 -0
  53. package/src/providers/anthropic/index.js +204 -0
  54. package/src/providers/anthropic/models.js +23 -0
  55. package/src/providers/anthropic/output-parser.js +177 -0
  56. package/src/providers/base-provider.js +251 -0
  57. package/src/providers/capabilities.js +60 -0
  58. package/src/providers/google/cli-builder.js +55 -0
  59. package/src/providers/google/index.js +116 -0
  60. package/src/providers/google/models.js +24 -0
  61. package/src/providers/google/output-parser.js +101 -0
  62. package/src/providers/index.js +91 -0
  63. package/src/providers/openai/cli-builder.js +133 -0
  64. package/src/providers/openai/index.js +136 -0
  65. package/src/providers/openai/models.js +21 -0
  66. package/src/providers/openai/output-parser.js +143 -0
  67. package/src/providers/opencode/cli-builder.js +42 -0
  68. package/src/providers/opencode/index.js +103 -0
  69. package/src/providers/opencode/models.js +55 -0
  70. package/src/providers/opencode/output-parser.js +122 -0
  71. package/src/schemas/sub-cluster.js +68 -39
  72. package/src/status-footer.js +94 -48
  73. package/src/sub-cluster-wrapper.js +92 -36
  74. package/src/task-runner.js +8 -6
  75. package/src/template-resolver.js +12 -9
  76. package/src/tui/data-poller.js +123 -99
  77. package/src/tui/formatters.js +4 -3
  78. package/src/tui/keybindings.js +259 -318
  79. package/src/tui/layout.js +20 -3
  80. package/src/tui/renderer.js +11 -21
  81. package/task-lib/attachable-watcher.js +150 -111
  82. package/task-lib/claude-recovery.js +156 -0
  83. package/task-lib/commands/episodes.js +105 -0
  84. package/task-lib/commands/list.js +3 -3
  85. package/task-lib/commands/logs.js +231 -189
  86. package/task-lib/commands/resume.js +3 -2
  87. package/task-lib/commands/run.js +12 -3
  88. package/task-lib/commands/schedules.js +111 -61
  89. package/task-lib/config.js +0 -2
  90. package/task-lib/runner.js +128 -50
  91. package/task-lib/scheduler.js +3 -3
  92. package/task-lib/store.js +464 -152
  93. package/task-lib/tui/formatters.js +94 -45
  94. package/task-lib/tui/renderer.js +13 -3
  95. package/task-lib/tui.js +73 -32
  96. package/task-lib/watcher.js +157 -100
  97. package/src/agents/git-pusher-agent.json +0 -20
  98. package/src/github.js +0 -103
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Issue Provider Registry
3
+ *
4
+ * Manages issue provider registration and auto-detection
5
+ */
6
+
7
+ const GitHubProvider = require('./github-provider');
8
+ const GitLabProvider = require('./gitlab-provider');
9
+ const JiraProvider = require('./jira-provider');
10
+ const AzureDevOpsProvider = require('./azure-devops-provider');
11
+ const { detectGitContext } = require('../../lib/git-remote-utils');
12
+
13
+ /** @type {Map<string, typeof IssueProvider>} */
14
+ const providers = new Map();
15
+
16
+ /**
17
+ * Register a provider class
18
+ * @param {typeof IssueProvider} ProviderClass
19
+ */
20
+ function registerProvider(ProviderClass) {
21
+ if (!ProviderClass.id) {
22
+ throw new Error(`Provider ${ProviderClass.name} must define static id property`);
23
+ }
24
+ providers.set(ProviderClass.id, ProviderClass);
25
+ }
26
+
27
+ /**
28
+ * Detect which provider matches the input
29
+ * Automatically detects provider from git remote when in a git repository
30
+ *
31
+ * @param {string} input - User input (URL, issue key, number)
32
+ * @param {Object} settings - User settings
33
+ * @param {string|null} forceProvider - Force specific provider (from CLI flag)
34
+ * @param {Object|null|undefined} [gitContextOverride] - Override git context (for testing)
35
+ * - undefined: auto-detect from git remote (default)
36
+ * - null: explicitly no git context
37
+ * - object: use provided git context
38
+ * @returns {typeof IssueProvider|null}
39
+ */
40
+ function detectProvider(input, settings, forceProvider = null, gitContextOverride = undefined) {
41
+ // Force flag takes precedence
42
+ if (forceProvider) {
43
+ return providers.get(forceProvider) || null;
44
+ }
45
+
46
+ // Use provided gitContext or auto-detect
47
+ const gitContext = gitContextOverride === undefined ? detectGitContext() : gitContextOverride;
48
+
49
+ // Auto-detect by checking each provider's detectIdentifier
50
+ for (const ProviderClass of providers.values()) {
51
+ if (ProviderClass.detectIdentifier(input, settings, gitContext)) {
52
+ return ProviderClass;
53
+ }
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Get provider class by ID
61
+ * @param {string} providerId - Provider identifier
62
+ * @returns {typeof IssueProvider|null}
63
+ */
64
+ function getProvider(providerId) {
65
+ return providers.get(providerId) || null;
66
+ }
67
+
68
+ /**
69
+ * List all registered provider IDs
70
+ * @returns {string[]}
71
+ */
72
+ function listProviders() {
73
+ return Array.from(providers.keys());
74
+ }
75
+
76
+ /**
77
+ * List providers that support PR/MR creation
78
+ * @returns {string[]}
79
+ */
80
+ function listPRProviders() {
81
+ return Array.from(providers.values())
82
+ .filter((p) => p.supportsPR())
83
+ .map((p) => p.id);
84
+ }
85
+
86
+ /**
87
+ * Determine which git platform to create PR/MR on.
88
+ * ALWAYS uses git remote context, NEVER the issue provider.
89
+ *
90
+ * This is the unified replacement for platform-detector.js
91
+ *
92
+ * @param {string} [cwd=process.cwd()] - Working directory
93
+ * @returns {string} Platform ID ('github', 'gitlab', 'azure-devops')
94
+ * @throws {Error} If platform cannot be determined or doesn't support PRs
95
+ *
96
+ * @example
97
+ * // In a GitHub repo
98
+ * getPlatformForPR() // → 'github'
99
+ *
100
+ * @example
101
+ * // In a GitLab repo with Jira issue
102
+ * getPlatformForPR() // → 'gitlab' (creates GitLab MR, not related to Jira)
103
+ */
104
+ function getPlatformForPR(cwd = process.cwd()) {
105
+ const gitContext = detectGitContext(cwd);
106
+
107
+ if (!gitContext?.provider) {
108
+ throw new Error(
109
+ 'Cannot determine git platform for --pr mode. ' +
110
+ 'Ensure you are in a git repository with a remote URL from GitHub, GitLab, or Azure DevOps.'
111
+ );
112
+ }
113
+
114
+ const platform = gitContext.provider;
115
+ const ProviderClass = providers.get(platform);
116
+
117
+ if (!ProviderClass || !ProviderClass.supportsPR()) {
118
+ const supported = listPRProviders().join(', ');
119
+ throw new Error(
120
+ `Platform '${platform}' does not support --pr mode. Supported platforms: ${supported}`
121
+ );
122
+ }
123
+
124
+ return platform;
125
+ }
126
+
127
+ /**
128
+ * Get PR tool info for a platform
129
+ * @param {string} platform - Platform ID
130
+ * @returns {{ name: string, checkCmd: string, installHint: string, displayName: string }|null}
131
+ */
132
+ function getPRToolForPlatform(platform) {
133
+ const ProviderClass = providers.get(platform);
134
+ return ProviderClass?.getPRTool() || null;
135
+ }
136
+
137
+ /**
138
+ * Get aggregated settings schema from all issue providers
139
+ * @returns {Object.<string, Object>} Combined settings schema
140
+ */
141
+ function getIssueProviderSettingsSchema() {
142
+ const schema = {};
143
+ for (const ProviderClass of providers.values()) {
144
+ Object.assign(schema, ProviderClass.getSettingsSchema());
145
+ }
146
+ return schema;
147
+ }
148
+
149
+ /**
150
+ * Get default values for all issue provider settings
151
+ * @returns {Object.<string, any>} Default values keyed by setting name
152
+ */
153
+ function getIssueProviderSettingsDefaults() {
154
+ const defaults = {};
155
+ const schema = getIssueProviderSettingsSchema();
156
+ for (const [key, config] of Object.entries(schema)) {
157
+ defaults[key] = config.default;
158
+ }
159
+ return defaults;
160
+ }
161
+
162
+ /**
163
+ * Validate an issue provider setting
164
+ * Delegates to the appropriate provider's validateSetting method
165
+ * @param {string} key - Setting key
166
+ * @param {any} value - Setting value
167
+ * @returns {string|null|undefined} Error message if invalid, null if valid, undefined if not an issue provider setting
168
+ */
169
+ function validateIssueProviderSetting(key, value) {
170
+ for (const ProviderClass of providers.values()) {
171
+ const result = ProviderClass.validateSetting(key, value);
172
+ if (result !== undefined) {
173
+ return result;
174
+ }
175
+ }
176
+ return undefined; // Not an issue provider setting
177
+ }
178
+
179
+ // Auto-register providers on module load
180
+ registerProvider(GitHubProvider);
181
+ registerProvider(GitLabProvider);
182
+ registerProvider(JiraProvider);
183
+ registerProvider(AzureDevOpsProvider);
184
+
185
+ module.exports = {
186
+ registerProvider,
187
+ detectProvider,
188
+ getProvider,
189
+ listProviders,
190
+ listPRProviders,
191
+ getPlatformForPR,
192
+ getPRToolForPlatform,
193
+ getIssueProviderSettingsSchema,
194
+ getIssueProviderSettingsDefaults,
195
+ validateIssueProviderSetting,
196
+ };
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Jira Provider - Fetch issues from Jira via jira CLI (go-jira)
3
+ * Supports both Jira Cloud (*.atlassian.net) and self-hosted (Server/Data Center)
4
+ */
5
+
6
+ const IssueProvider = require('./base-provider');
7
+ const { execSync } = require('../lib/safe-exec');
8
+
9
+ class JiraProvider extends IssueProvider {
10
+ static id = 'jira';
11
+ static displayName = 'Jira';
12
+
13
+ /**
14
+ * Detect Jira issue keys and URLs
15
+ * Matches:
16
+ * - Jira Cloud URLs (*.atlassian.net)
17
+ * - Self-hosted Jira URLs (via jiraInstance setting)
18
+ * - Jira issue keys (KEY-123 format)
19
+ * - Bare numbers when defaultIssueSource=jira (requires jiraProject setting)
20
+ *
21
+ * Note: Jira doesn't use git context since it's not a git hosting platform.
22
+ * Git context will never match 'jira', so this provider only activates via
23
+ * explicit Jira URLs, Jira issue keys, or settings.
24
+ *
25
+ * @param {string} input - Issue identifier (URL, key, or number)
26
+ * @param {Object} settings - User settings
27
+ * @param {Object|null} gitContext - Git context (unused for Jira, but kept for API consistency)
28
+ * @returns {boolean} True if this provider should handle the input
29
+ */
30
+ static detectIdentifier(input, settings, gitContext = null) {
31
+ // Jira Cloud URLs
32
+ if (/atlassian\.net\/browse\/[A-Z][A-Z0-9]+-\d+/.test(input)) {
33
+ return true;
34
+ }
35
+
36
+ // Self-hosted Jira URLs
37
+ if (
38
+ settings.jiraInstance &&
39
+ input.includes(settings.jiraInstance) &&
40
+ /\/browse\/[A-Z][A-Z0-9]+-\d+/.test(input)
41
+ ) {
42
+ return true;
43
+ }
44
+
45
+ // Jira issue key pattern (KEY-123)
46
+ if (/^[A-Z][A-Z0-9]+-\d+$/.test(input)) {
47
+ return true;
48
+ }
49
+
50
+ // Bare numbers - use shared priority cascade logic
51
+ // Jira requires jiraProject setting to convert bare numbers to issue keys
52
+ // Note: gitContext check will never match 'jira' since Jira isn't a git host
53
+ return IssueProvider.detectBareNumber(input, settings, gitContext, 'jira', {
54
+ requiredSettings: ['jiraProject'],
55
+ });
56
+ }
57
+
58
+ static getRequiredTool() {
59
+ return {
60
+ name: 'jira',
61
+ checkCmd: 'jira version',
62
+ installHint: 'Install jira-cli: brew install go-jira (or https://github.com/go-jira/jira)',
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Check jira CLI authentication
68
+ * go-jira uses config files (~/.jira.d/) rather than explicit auth commands
69
+ */
70
+ static checkAuth() {
71
+ try {
72
+ // go-jira uses 'jira session' to verify authentication
73
+ // If not configured, it fails with endpoint/login errors
74
+ execSync('jira session', { encoding: 'utf8', stdio: 'pipe', timeout: 5000 });
75
+ return { authenticated: true, error: null, recovery: [] };
76
+ } catch (err) {
77
+ const stderr = err.stderr || err.message || '';
78
+
79
+ // go-jira has various error patterns for auth issues
80
+ if (
81
+ stderr.includes('endpoint') ||
82
+ stderr.includes('login') ||
83
+ stderr.includes('authentication') ||
84
+ stderr.includes('401')
85
+ ) {
86
+ return {
87
+ authenticated: false,
88
+ error: 'Jira CLI not configured',
89
+ recovery: [
90
+ 'Create ~/.jira.d/config.yml with your Jira endpoint',
91
+ 'See: https://github.com/go-jira/jira#configuration',
92
+ 'Example config:\n endpoint: https://yourcompany.atlassian.net\n login: your-email@company.com',
93
+ 'Then verify: jira session',
94
+ ],
95
+ };
96
+ }
97
+
98
+ // If command just doesn't exist or times out, it's likely not configured
99
+ if (stderr.includes('command not found') || stderr.includes('ETIMEDOUT')) {
100
+ return {
101
+ authenticated: false,
102
+ error: 'Jira CLI not configured',
103
+ recovery: [
104
+ 'Configure ~/.jira.d/config.yml',
105
+ 'See: https://github.com/go-jira/jira#configuration',
106
+ ],
107
+ };
108
+ }
109
+
110
+ // Default: assume not authenticated
111
+ return {
112
+ authenticated: false,
113
+ error: stderr.trim() || 'Jira CLI not configured or authentication failed',
114
+ recovery: [
115
+ 'Create ~/.jira.d/config.yml with your Jira endpoint',
116
+ 'See: https://github.com/go-jira/jira#configuration',
117
+ ],
118
+ };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Jira-specific settings schema
124
+ */
125
+ static getSettingsSchema() {
126
+ return {
127
+ jiraInstance: {
128
+ type: 'string',
129
+ nullable: true,
130
+ default: null,
131
+ description: "Self-hosted Jira URL (e.g., 'jira.company.com')",
132
+ },
133
+ jiraProject: {
134
+ type: 'string',
135
+ nullable: true,
136
+ default: null,
137
+ description: "Default Jira project key for bare numbers (e.g., 'PROJ')",
138
+ pattern: /^[A-Z][A-Z0-9]*$/,
139
+ patternMsg: 'jiraProject must be a valid Jira project key (e.g., PROJ, ABC)',
140
+ },
141
+ };
142
+ }
143
+
144
+ fetchIssue(identifier, settings) {
145
+ try {
146
+ const issueKey = this._extractIssueKey(identifier, settings);
147
+
148
+ // Fetch issue using jira CLI
149
+ const cmd = `jira issue view ${issueKey} --template json`;
150
+ const output = execSync(cmd, { encoding: 'utf8' });
151
+ const issue = JSON.parse(output);
152
+
153
+ return this._parseIssue(issue);
154
+ } catch (error) {
155
+ throw new Error(`Failed to fetch Jira issue: ${error.message}`);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Extract Jira issue key from URL or return as-is
161
+ * @private
162
+ */
163
+ _extractIssueKey(identifier, settings) {
164
+ // URL format: https://company.atlassian.net/browse/KEY-123
165
+ const urlMatch = identifier.match(/\/browse\/([A-Z][A-Z0-9]+-\d+)/);
166
+ if (urlMatch) {
167
+ return urlMatch[1];
168
+ }
169
+
170
+ // Bare number: construct from jiraProject setting
171
+ if (/^\d+$/.test(identifier) && settings.jiraProject) {
172
+ return `${settings.jiraProject}-${identifier}`;
173
+ }
174
+
175
+ // Assume it's already a key
176
+ return identifier;
177
+ }
178
+
179
+ /**
180
+ * Parse Jira issue into standardized InputData format
181
+ * @private
182
+ */
183
+ _parseIssue(issue) {
184
+ const fields = issue.fields || {};
185
+ const key = issue.key;
186
+ const summary = fields.summary || '';
187
+ const description = fields.description || '';
188
+ const labels = fields.labels || [];
189
+ const comments = fields.comment?.comments || [];
190
+
191
+ let context = `# Jira Issue ${key}\n\n`;
192
+ context += `## Title\n${summary}\n\n`;
193
+
194
+ if (description) {
195
+ context += `## Description\n${description}\n\n`;
196
+ }
197
+
198
+ if (labels.length > 0) {
199
+ context += `## Labels\n`;
200
+ context += labels.map((l) => `- ${l}`).join('\n');
201
+ context += '\n\n';
202
+ }
203
+
204
+ if (comments.length > 0) {
205
+ context += `## Comments\n\n`;
206
+ for (const comment of comments) {
207
+ const author = comment.author?.displayName || comment.author?.name || 'unknown';
208
+ context += `### ${author} (${comment.created})\n`;
209
+ context += `${comment.body}\n\n`;
210
+ }
211
+ }
212
+
213
+ // Map Jira labels to GitHub format
214
+ const mappedLabels = labels.map((name) => ({ name }));
215
+
216
+ // Map Jira comments to GitHub format
217
+ const mappedComments = comments.map((comment) => ({
218
+ author: { login: comment.author?.displayName || comment.author?.name || 'unknown' },
219
+ createdAt: comment.created,
220
+ body: comment.body,
221
+ }));
222
+
223
+ // Extract issue number from key (KEY-123 → 123)
224
+ const numberMatch = key.match(/-(\d+)$/);
225
+ const number = numberMatch ? parseInt(numberMatch[1], 10) : null;
226
+
227
+ return {
228
+ number,
229
+ title: summary,
230
+ body: description,
231
+ labels: mappedLabels,
232
+ comments: mappedComments,
233
+ url: fields.self || null,
234
+ context,
235
+ };
236
+ }
237
+ }
238
+
239
+ module.exports = JiraProvider;
package/src/ledger.js CHANGED
@@ -15,20 +15,38 @@ const crypto = require('crypto');
15
15
  class Ledger extends EventEmitter {
16
16
  constructor(dbPath = ':memory:') {
17
17
  super();
18
- this.db = new Database(dbPath);
18
+ this.dbPath = dbPath;
19
+ const busyTimeoutMs = (() => {
20
+ const raw = process.env.ZEROSHOT_SQLITE_BUSY_TIMEOUT_MS;
21
+ if (!raw) return 5000;
22
+ const value = Number(raw);
23
+ return Number.isFinite(value) && value >= 0 ? value : 5000;
24
+ })();
25
+
26
+ this.db = new Database(dbPath, { timeout: busyTimeoutMs });
19
27
  this.cache = new Map(); // LRU cache for queries
20
28
  this.cacheLimit = 1000;
21
29
  this._closed = false; // Track closed state to prevent write-after-close
30
+ this._lastTimestamp = 0;
22
31
  this._initSchema();
23
32
  }
24
33
 
25
34
  _initSchema() {
26
- // Enable WAL mode for concurrent reads
27
- this.db.pragma('journal_mode = WAL');
35
+ const journalMode = (process.env.ZEROSHOT_SQLITE_JOURNAL_MODE || 'WAL').trim().toUpperCase();
36
+ // Enable WAL mode for concurrent reads (default), but allow overrides for network filesystems.
37
+ this.db.pragma(`journal_mode = ${journalMode}`);
28
38
  // Force synchronous writes so other processes see changes immediately
29
39
  this.db.pragma('synchronous = NORMAL');
30
- // Checkpoint WAL frequently for cross-process visibility
31
- this.db.pragma('wal_autocheckpoint = 1');
40
+ // Autocheckpoint trades latency for WAL growth; 1-page checkpoints are extremely slow on
41
+ // higher-latency disks (common in Kubernetes PVs). Default to SQLite-ish behavior (1000 pages),
42
+ // but allow override for niche correctness/debugging needs.
43
+ const walAutocheckpointPages = (() => {
44
+ const raw = process.env.ZEROSHOT_SQLITE_WAL_AUTOCHECKPOINT_PAGES;
45
+ if (!raw) return 1000;
46
+ const value = Number(raw);
47
+ return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 1000;
48
+ })();
49
+ this.db.pragma(`wal_autocheckpoint = ${walAutocheckpointPages}`);
32
50
 
33
51
  // Create messages table
34
52
  this.db.exec(`
@@ -52,6 +70,7 @@ class Ledger extends EventEmitter {
52
70
  `);
53
71
 
54
72
  this._prepareStatements();
73
+ this._loadLastTimestamp();
55
74
  }
56
75
 
57
76
  _prepareStatements() {
@@ -69,6 +88,13 @@ class Ledger extends EventEmitter {
69
88
  };
70
89
  }
71
90
 
91
+ _loadLastTimestamp() {
92
+ const row = this.db.prepare('SELECT MAX(timestamp) AS max_timestamp FROM messages').get();
93
+ if (row && Number.isFinite(row.max_timestamp)) {
94
+ this._lastTimestamp = row.max_timestamp;
95
+ }
96
+ }
97
+
72
98
  /**
73
99
  * Append a message to the ledger
74
100
  * @param {Object} message - Message object
@@ -83,7 +109,10 @@ class Ledger extends EventEmitter {
83
109
  }
84
110
 
85
111
  const id = message.id || `msg_${crypto.randomBytes(16).toString('hex')}`;
86
- const timestamp = message.timestamp || Date.now();
112
+ const baseTimestamp = Math.max(Date.now(), this._lastTimestamp + 1);
113
+ const requestedTimestamp = typeof message.timestamp === 'number' ? message.timestamp : null;
114
+ const timestamp =
115
+ requestedTimestamp !== null ? Math.max(requestedTimestamp, baseTimestamp) : baseTimestamp;
87
116
 
88
117
  const record = {
89
118
  id,
@@ -113,6 +142,8 @@ class Ledger extends EventEmitter {
113
142
  // Invalidate cache
114
143
  this.cache.clear();
115
144
 
145
+ this._lastTimestamp = Math.max(this._lastTimestamp, timestamp);
146
+
116
147
  // Emit event for subscriptions
117
148
  const fullMessage = this._deserializeMessage(record);
118
149
  this.emit('message', fullMessage);
@@ -149,13 +180,13 @@ class Ledger extends EventEmitter {
149
180
  // Create transaction function - all inserts happen atomically
150
181
  const insertMany = this.db.transaction((msgs) => {
151
182
  const results = [];
152
- const baseTimestamp = Date.now();
183
+ const baseTimestamp = Math.max(Date.now(), this._lastTimestamp + 1);
153
184
 
154
185
  for (let i = 0; i < msgs.length; i++) {
155
186
  const message = msgs[i];
156
187
  const id = message.id || `msg_${crypto.randomBytes(16).toString('hex')}`;
157
188
  // Use incrementing timestamps to preserve order within batch
158
- const timestamp = message.timestamp || (baseTimestamp + i);
189
+ const timestamp = baseTimestamp + i;
159
190
 
160
191
  const record = {
161
192
  id,
@@ -184,16 +215,18 @@ class Ledger extends EventEmitter {
184
215
  results.push(this._deserializeMessage(record));
185
216
  }
186
217
 
187
- return results;
218
+ return { results, baseTimestamp };
188
219
  });
189
220
 
190
221
  try {
191
222
  // Execute transaction (atomic - all or nothing)
192
- const appendedMessages = insertMany(messages);
223
+ const { results: appendedMessages, baseTimestamp } = insertMany(messages);
193
224
 
194
225
  // Invalidate cache
195
226
  this.cache.clear();
196
227
 
228
+ this._lastTimestamp = Math.max(this._lastTimestamp, baseTimestamp + messages.length - 1);
229
+
197
230
  // Emit events for subscriptions AFTER transaction commits
198
231
  // This ensures listeners see consistent state
199
232
  for (const fullMessage of appendedMessages) {
@@ -248,7 +281,13 @@ class Ledger extends EventEmitter {
248
281
  params.push(typeof until === 'number' ? until : new Date(until).getTime());
249
282
  }
250
283
 
251
- let sql = `SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY timestamp ASC`;
284
+ // Defend against prototype pollution affecting default query ordering.
285
+ // Only treat `criteria.order` as set if it's an own property.
286
+ const orderValue = Object.prototype.hasOwnProperty.call(criteria, 'order')
287
+ ? criteria.order
288
+ : undefined;
289
+ const direction = String(orderValue ?? 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
290
+ let sql = `SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY timestamp ${direction}`;
252
291
 
253
292
  if (limit) {
254
293
  sql += ` LIMIT ?`;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Safe subprocess execution with mandatory timeouts.
3
+ *
4
+ * NEVER use child_process.exec() or execSync() directly.
5
+ * These wrappers enforce timeouts to prevent infinite hangs.
6
+ */
7
+
8
+ const { exec: nodeExec, execSync: nodeExecSync } = require('child_process');
9
+
10
+ /** Default timeout: 30 seconds */
11
+ const DEFAULT_TIMEOUT_MS = 30000;
12
+
13
+ /**
14
+ * Execute command with mandatory timeout.
15
+ * Supports both Promise and callback styles for gradual migration.
16
+ *
17
+ * @param {string} command - Command to execute
18
+ * @param {object} [options] - Options (timeout uses default if not specified)
19
+ * @param {function} [callback] - Optional callback(error, stdout, stderr)
20
+ * @returns {Promise<{stdout: string, stderr: string}>|void} Promise if no callback, void if callback
21
+ *
22
+ * @example
23
+ * // Promise style (preferred)
24
+ * const { stdout } = await exec('ls -la', { timeout: 5000 });
25
+ *
26
+ * @example
27
+ * // Callback style (for legacy code migration)
28
+ * exec('ls -la', { timeout: 5000 }, (error, stdout) => { ... });
29
+ */
30
+ function exec(command, optionsOrCallback = {}, callbackArg = null) {
31
+ // Handle overloaded signature: exec(cmd, callback)
32
+ const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : callbackArg;
33
+ const options = typeof optionsOrCallback === 'function' ? {} : optionsOrCallback;
34
+
35
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
36
+
37
+ if (timeout <= 0) {
38
+ const err = new Error('exec() timeout must be > 0. Infinite waits are forbidden.');
39
+ if (callback) {
40
+ callback(err);
41
+ return;
42
+ }
43
+ return Promise.reject(err);
44
+ }
45
+
46
+ // Callback style
47
+ if (callback) {
48
+ nodeExec(command, { ...options, timeout }, (error, stdout, stderr) => {
49
+ if (error && error.killed && error.signal === 'SIGTERM') {
50
+ error.message = `Command timed out after ${timeout}ms: ${command}`;
51
+ }
52
+ callback(error, stdout, stderr);
53
+ });
54
+ return;
55
+ }
56
+
57
+ // Promise style
58
+ return new Promise((resolve, reject) => {
59
+ nodeExec(command, { ...options, timeout }, (error, stdout, stderr) => {
60
+ if (error) {
61
+ if (error.killed && error.signal === 'SIGTERM') {
62
+ error.message = `Command timed out after ${timeout}ms: ${command}`;
63
+ }
64
+ reject(error);
65
+ } else {
66
+ resolve({ stdout, stderr });
67
+ }
68
+ });
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Execute command with mandatory timeout (sync)
74
+ * @param {string} command - Command to execute
75
+ * @param {object} [options] - Options (timeout required or uses default)
76
+ * @returns {string} stdout
77
+ */
78
+ function execSync(command, options = {}) {
79
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
80
+
81
+ if (timeout <= 0) {
82
+ throw new Error('execSync() timeout must be > 0. Infinite waits are forbidden.');
83
+ }
84
+
85
+ return nodeExecSync(command, { ...options, timeout });
86
+ }
87
+
88
+ module.exports = { exec, execSync, DEFAULT_TIMEOUT_MS };