s9n-devops-agent 1.0.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.
@@ -0,0 +1,1660 @@
1
+ // package.json must have: { "type": "module" }
2
+ // npm i -D chokidar execa
3
+
4
+ /**
5
+ * ============================================================================
6
+ * DEVOPS-AGENT WORKER WITH INTELLIGENT BRANCH MANAGEMENT
7
+ * ============================================================================
8
+ *
9
+ * PURPOSE:
10
+ * Automates git commits and branch management for continuous development workflow.
11
+ * Watches for file changes and commit messages, automatically committing and pushing.
12
+ *
13
+ * KEY FEATURES:
14
+ * 1. Automatic daily branch creation and management
15
+ * 2. Watches for changes and cs-devops-agents when ready
16
+ * 3. Micro-revision versioning (v0.20, v0.21, v0.22...)
17
+ * 4. Intelligent commit message handling from .claude-commit-msg
18
+ * 5. Daily rollover with automatic merging
19
+ *
20
+ * ============================================================================
21
+ * WORKFLOW OVERVIEW:
22
+ * ============================================================================
23
+ *
24
+ * STARTUP:
25
+ * 1. Check if new day → trigger rollover if needed
26
+ * 2. Ensure correct branch (create if missing)
27
+ * 3. Check for pending changes and existing commit message
28
+ * 4. Start watching for changes
29
+ *
30
+ * DURING THE DAY:
31
+ * 1. Watch all files (except .git, node_modules, etc.)
32
+ * 2. When .claude-commit-msg changes → read and validate message
33
+ * 3. Check if day changed (midnight crossed) → handle rollover if needed
34
+ * 4. If message is valid → commit all changes with that message
35
+ * 5. Clear message file after successful push
36
+ * 6. Continue watching...
37
+ *
38
+ * DAILY ROLLOVER (at midnight or first run of new day):
39
+ * 1. Merge previous version branch → main
40
+ * 2. Create new version branch (increment by 0.01)
41
+ * 3. Merge yesterday's daily branch → new version branch
42
+ * 4. Create today's daily branch from version branch
43
+ * 5. Push everything to remote
44
+ *
45
+ * ============================================================================
46
+ * BRANCH STRUCTURE:
47
+ * ============================================================================
48
+ *
49
+ * main
50
+ * └── v0.20 (Sept 15)
51
+ * └── dev_sdd_2025-09-15 (daily work)
52
+ * └── v0.21 (Sept 16)
53
+ * └── dev_sdd_2025-09-16 (daily work)
54
+ * └── v0.22 (Sept 17)
55
+ * └── dev_sdd_2025-09-17 (daily work)
56
+ * ... and so on
57
+ *
58
+ * Each day:
59
+ * - Version branch created from main (v0.XX)
60
+ * - Daily branch created from version branch (dev_sdd_YYYY-MM-DD)
61
+ * - All commits happen on daily branch
62
+ * - Next day: everything merges forward
63
+ *
64
+ * ============================================================================
65
+ * CONFIGURATION (via environment variables):
66
+ * ============================================================================
67
+ *
68
+ * Core Settings:
69
+ * AC_BRANCH - Static branch name (overrides daily branches)
70
+ * AC_BRANCH_PREFIX - Prefix for daily branches (default: "dev_sdd_")
71
+ * AC_TZ - Timezone for date calculations (default: "Asia/Dubai")
72
+ * AC_PUSH - Auto-push after commit (default: true)
73
+ *
74
+ * Message Handling:
75
+ * AC_MSG_FILE - Path to commit message file (default: .claude-commit-msg)
76
+ * AC_REQUIRE_MSG - Require valid commit message (default: true)
77
+ * AC_MSG_MIN_BYTES - Minimum message size (default: 20)
78
+ * AC_MSG_PATTERN - Regex for conventional commits (feat|fix|refactor|docs|test|chore)
79
+ *
80
+ * Behavior:
81
+ * AC_DEBOUNCE_MS - Delay before processing changes (default: 1500ms)
82
+ * AC_MSG_DEBOUNCE_MS - Delay after message file changes (default: 3000ms)
83
+ * AC_CLEAR_MSG_WHEN - When to clear message file: "push"|"commit"|"never"
84
+ * AC_ROLLOVER_PROMPT - Prompt before daily rollover (default: true)
85
+ *
86
+ * ============================================================================
87
+ * USAGE:
88
+ * ============================================================================
89
+ *
90
+ * Basic usage:
91
+ * node cs-devops-agent-worker.js
92
+ *
93
+ * The worker handles midnight crossovers automatically:
94
+ * - If running when clock hits midnight, next commit triggers rollover
95
+ * - No need to restart the worker for new days
96
+ * - Ensures commits always go to the correct daily branch
97
+ *
98
+ * With custom branch:
99
+ * AC_BRANCH=feature-xyz node cs-devops-agent-worker.js
100
+ *
101
+ * Disable auto-push:
102
+ * AC_PUSH=false node cs-devops-agent-worker.js
103
+ *
104
+ * Custom timezone:
105
+ * AC_TZ="America/New_York" node cs-devops-agent-worker.js
106
+ *
107
+ * ============================================================================
108
+ */
109
+
110
+ import fs from "fs";
111
+ import path from "path";
112
+ import chokidar from "chokidar";
113
+ import { execa } from "execa";
114
+ import readline from "node:readline";
115
+ import { stdin as input, stdout as output } from 'node:process';
116
+
117
+ // ============================================================================
118
+ // CONFIGURATION SECTION - All settings can be overridden via environment vars
119
+ // ============================================================================
120
+ const STATIC_BRANCH = process.env.AC_BRANCH || null; // e.g., "v0.2" (otherwise daily dev_sdd_<date>)
121
+ const BRANCH_PREFIX = process.env.AC_BRANCH_PREFIX || "dev_sdd_";
122
+ const TZ = process.env.AC_TZ || "Asia/Dubai";
123
+ const DATE_STYLE = process.env.AC_DATE_STYLE || "dash"; // "dash" (YYYY-MM-DD) | "compact" (YYYYMMDD)
124
+ const PUSH = (process.env.AC_PUSH || "true").toLowerCase() === "true";
125
+
126
+ // legacy quiet scheduler (kept as fallback; set AC_QUIET_MS=0 to disable)
127
+ const DEBOUNCE_MS = Number(process.env.AC_DEBOUNCE_MS || 1500);
128
+ const QUIET_MS = Number(process.env.AC_QUIET_MS || 0);
129
+
130
+ // message gating
131
+ const REQUIRE_MSG = (process.env.AC_REQUIRE_MSG || "true").toLowerCase() !== "false";
132
+ const REQUIRE_MSG_AFTER_CHANGE =
133
+ (process.env.AC_REQUIRE_MSG_AFTER_CHANGE || "false").toLowerCase() !== "false"; // default false to avoid pycache noise
134
+ const MSG_MIN_BYTES = Number(process.env.AC_MSG_MIN_BYTES || 20);
135
+ const MSG_PATTERN = new RegExp(process.env.AC_MSG_PATTERN || '^(feat|fix|refactor|docs|test|chore)(\\([^)]+\\))?:\\s', 'm');
136
+
137
+ // message path + triggering
138
+ const MSG_FILE_ENV = process.env.AC_MSG_FILE || ""; // path relative to git root
139
+ const TRIGGER_ON_MSG = (process.env.AC_TRIGGER_ON_MSG || "true").toLowerCase() !== "false";
140
+ const MSG_DEBOUNCE_MS = Number(process.env.AC_MSG_DEBOUNCE_MS || 3000);
141
+
142
+ const CONFIRM_ON_START = (process.env.AC_CONFIRM_ON_START || "true").toLowerCase() !== "false";
143
+ const CS_DEVOPS_AGENT_ON_START = (process.env.AC_CS_DEVOPS_AGENT_ON_START || "true").toLowerCase() === "true";
144
+
145
+ const USE_POLLING = (process.env.AC_USE_POLLING || "false").toLowerCase() === "true";
146
+ let DEBUG = (process.env.AC_DEBUG || "true").toLowerCase() !== "false"; // Made mutable for runtime toggle
147
+
148
+ // clear message when: "push" | "commit" | "never"
149
+ const CLEAR_MSG_WHEN = (process.env.AC_CLEAR_MSG_WHEN || "push").toLowerCase();
150
+
151
+ // --- daily & version rollover ---
152
+ const DAILY_PREFIX = process.env.AC_DAILY_PREFIX || "dev_sdd_";
153
+ const ROLLOVER_PROMPT = (process.env.AC_ROLLOVER_PROMPT || "true").toLowerCase() !== "false";
154
+ const FORCE_ROLLOVER = (process.env.AC_FORCE_ROLLOVER || "false").toLowerCase() === "true";
155
+
156
+ // version branch naming: v0.<minor> -> v0.20, v0.21, v0.22, ... (increments by 0.01)
157
+ // Each day gets a micro-revision increment (e.g., v0.2 → v0.21 → v0.22)
158
+ const VERSION_PREFIX = process.env.AC_VERSION_PREFIX || "v0.";
159
+ const VERSION_START_MINOR = Number(process.env.AC_VERSION_START_MINOR || "20"); // Start at v0.20 for micro-revisions
160
+ const VERSION_BASE_REF = process.env.AC_VERSION_BASE_REF || "origin/main"; // where new version branches start
161
+ // ------------------------------------------------
162
+
163
+ const log = (...a) => console.log("[cs-devops-agent]", ...a);
164
+ const dlog = (...a) => { if (DEBUG) console.log("[debug]", ...a); };
165
+
166
+ async function run(cmd, args, opts = {}) {
167
+ try {
168
+ if (DEBUG) console.log("[cmd]", cmd, args.join(" "));
169
+ const { stdout } = await execa(cmd, args, { stdio: "pipe", ...opts });
170
+ return { ok: true, stdout: stdout ?? "" };
171
+ } catch (err) {
172
+ const msg = err?.stderr || err?.shortMessage || err?.message || String(err);
173
+ console.error("[err]", cmd, args.join(" "), "\n" + msg.trim());
174
+ return { ok: false, stdout: "" };
175
+ }
176
+ }
177
+
178
+ function clearMsgFile(p) {
179
+ try {
180
+ fs.writeFileSync(p, "");
181
+ log(`cleared message file ${path.relative(process.cwd(), p)}`);
182
+ } catch (e) {
183
+ dlog("clear msg failed:", e.message);
184
+ }
185
+ }
186
+
187
+ // ============================================================================
188
+ // DATE/TIME UTILITIES - Handle timezone-aware date formatting
189
+ // ============================================================================
190
+
191
+ /**
192
+ * Get today's date string in specified timezone and format
193
+ * @param {string} tz - Timezone (e.g., "Asia/Dubai")
194
+ * @param {string} style - "dash" for YYYY-MM-DD or "compact" for YYYYMMDD
195
+ * @returns {string} Formatted date string
196
+ */
197
+ function todayStr(tz = TZ, style = DATE_STYLE) {
198
+ const d = new Intl.DateTimeFormat("en-CA", {
199
+ timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit",
200
+ }).format(new Date()); // "YYYY-MM-DD"
201
+ return style === "compact" ? d.replaceAll("-", "") : d;
202
+ }
203
+ function todayDateStr() { return todayStr(TZ, DATE_STYLE); }
204
+ function dailyNameFor(dateStr) { return `${DAILY_PREFIX}${dateStr}`; }
205
+
206
+ function targetBranchName() {
207
+ // Use a pinned branch if AC_BRANCH is set, otherwise today's daily
208
+ return STATIC_BRANCH || `${BRANCH_PREFIX}${todayDateStr()}`;
209
+ }
210
+
211
+ // ============================================================================
212
+ // GIT OPERATIONS - Core git functionality wrapped in async functions
213
+ // ============================================================================
214
+
215
+ /**
216
+ * Get the current git branch name
217
+ * @returns {Promise<string>} Current branch name or empty string on error
218
+ */
219
+ async function currentBranch() {
220
+ const { ok, stdout } = await run("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
221
+ return ok ? stdout.trim() : "";
222
+ }
223
+ /**
224
+ * Check if a git branch exists locally
225
+ * @param {string} name - Branch name to check
226
+ * @returns {Promise<boolean>} True if branch exists
227
+ */
228
+ async function branchExists(name) {
229
+ const { ok } = await run("git", ["rev-parse", "--verify", "--quiet", name]);
230
+ return ok;
231
+ }
232
+ /**
233
+ * Ensure we're on the specified branch (create if needed, switch if exists)
234
+ * @param {string} name - Target branch name
235
+ * @returns {Promise<{ok: boolean, created: boolean, switched: boolean}>} Operation result
236
+ */
237
+ async function ensureBranch(name) {
238
+ const cur = await currentBranch();
239
+ dlog("ensureBranch: current =", cur, "target =", name);
240
+
241
+ // Already on target branch
242
+ if (cur === name) return { ok: true, created: false, switched: false };
243
+
244
+ // Branch exists, just switch to it
245
+ if (await branchExists(name)) {
246
+ const r = await run("git", ["checkout", name]);
247
+ return { ok: r.ok, created: false, switched: r.ok };
248
+ } else {
249
+ // Branch doesn't exist, create it from current HEAD
250
+ const r = await run("git", ["checkout", "-b", name]);
251
+ return { ok: r.ok, created: r.ok, switched: r.ok };
252
+ }
253
+ }
254
+ async function hasUncommittedChanges() {
255
+ const r = await run("git", ["status", "--porcelain"]);
256
+ return r.ok && r.stdout.trim().length > 0;
257
+ }
258
+ /**
259
+ * Get a summary of git status with file counts by type
260
+ * @param {number} maxLines - Maximum preview lines to return
261
+ * @returns {Promise<Object>} Status summary with counts and preview
262
+ */
263
+ async function summarizeStatus(maxLines = 20) {
264
+ const r = await run("git", ["status", "--porcelain"]);
265
+ const lines = (r.stdout || "").split("\n").filter(Boolean);
266
+ let added = 0, modified = 0, deleted = 0, untracked = 0;
267
+
268
+ // Parse git status codes
269
+ for (const l of lines) {
270
+ if (l.startsWith("??")) untracked++; // Untracked files
271
+ else if (/\bD\b/.test(l)) deleted++; // Deleted files
272
+ else if (/\bM\b/.test(l)) modified++; // Modified files
273
+ else added++; // Added files
274
+ }
275
+
276
+ return { count: lines.length, added, modified, deleted, untracked, preview: lines.slice(0, maxLines).join("\n") };
277
+ }
278
+ async function stagedCount() {
279
+ const r = await run("git", ["diff", "--cached", "--name-only"]);
280
+ return r.ok ? r.stdout.split("\n").filter(Boolean).length : 0;
281
+ }
282
+ async function unstageIfStaged(file) {
283
+ await run("git", ["restore", "--staged", file]);
284
+ }
285
+ async function defaultRemote() {
286
+ const r = await run("git", ["remote"]);
287
+ const remotes = (r.stdout || "").split("\n").map(s => s.trim()).filter(Boolean);
288
+ return remotes.includes("origin") ? "origin" : remotes[0] || null;
289
+ }
290
+ async function pushBranch(branch) {
291
+ const remote = await defaultRemote();
292
+ if (!remote) {
293
+ console.error("[cs-devops-agent] No git remote configured. Run:");
294
+ console.error(" git remote add origin <git-url>");
295
+ return false;
296
+ }
297
+
298
+ // First check if remote branch exists
299
+ const remoteExistsResult = await run("git", ["ls-remote", "--heads", remote, branch]);
300
+ const remoteExists = remoteExistsResult.ok && remoteExistsResult.stdout.trim().length > 0;
301
+
302
+ if (!remoteExists) {
303
+ // Remote branch doesn't exist, create it with -u (set upstream)
304
+ log(`Creating new remote branch ${branch} on ${remote}...`);
305
+ const r = await run("git", ["push", "-u", remote, branch]);
306
+ if (!r.ok) {
307
+ log(`Failed to create remote branch: ${r.stderr}`);
308
+ }
309
+ return r.ok;
310
+ }
311
+
312
+ // Remote branch exists, attempt normal push
313
+ let r = await run("git", ["push", remote, branch]);
314
+
315
+ // If push failed, check if it's because we're behind
316
+ if (!r.ok) {
317
+ // Try to pull and merge remote changes
318
+ log("Push failed, attempting to pull remote changes...");
319
+ const pullResult = await run("git", ["pull", "--no-rebase", remote, branch]);
320
+
321
+ if (pullResult.ok) {
322
+ log("Successfully pulled and merged remote changes, retrying push...");
323
+ // Retry push after successful pull
324
+ r = await run("git", ["push", remote, branch]);
325
+ } else {
326
+ // If pull also failed, try force setting upstream
327
+ // This handles cases where local and remote have diverged
328
+ log("Pull failed, attempting to force set upstream...");
329
+ r = await run("git", ["push", "-u", remote, branch]);
330
+ }
331
+ }
332
+
333
+ return r.ok;
334
+ }
335
+
336
+ // ============================================================================
337
+ // COMMIT MESSAGE HANDLING - Manage .claude-commit-msg file
338
+ // ============================================================================
339
+
340
+ /**
341
+ * Find the commit message file (session-specific or default)
342
+ * Searches in order:
343
+ * 1. Environment variable AC_MSG_FILE path (if set)
344
+ * 2. Session-specific file: .devops-commit-*.msg (for multi-agent sessions)
345
+ * 3. Default: .claude-commit-msg in repository root
346
+ * 4. Common nested locations (MVPEmails/DistilledConceptExtractor/)
347
+ * @param {string} repoRoot - Git repository root path
348
+ * @returns {string} Path to message file (may not exist)
349
+ */
350
+ function resolveMsgPath(repoRoot) {
351
+ // Priority 1: Explicit environment variable
352
+ if (MSG_FILE_ENV) {
353
+ const p = path.resolve(repoRoot, MSG_FILE_ENV);
354
+ if (fs.existsSync(p)) {
355
+ log(`Using message file from AC_MSG_FILE: ${MSG_FILE_ENV}`);
356
+ return p;
357
+ }
358
+ // If AC_MSG_FILE is set but doesn't exist yet, use it anyway
359
+ // (it will be created when the agent writes a commit message)
360
+ if (MSG_FILE_ENV.startsWith('.devops-commit-') && MSG_FILE_ENV.endsWith('.msg')) {
361
+ log(`Will watch for session message file: ${MSG_FILE_ENV}`);
362
+ return p;
363
+ }
364
+ dlog("MSG_FILE_ENV set but not found at", p);
365
+ }
366
+
367
+ // Priority 2: Look for session-specific commit message files
368
+ // Pattern: .devops-commit-*.msg (used by multi-agent sessions)
369
+ try {
370
+ const files = fs.readdirSync(repoRoot);
371
+ const sessionMsgFiles = files.filter(f =>
372
+ f.startsWith('.devops-commit-') && f.endsWith('.msg')
373
+ );
374
+
375
+ if (sessionMsgFiles.length > 0) {
376
+ // Use the most recently modified session message file
377
+ const mostRecent = sessionMsgFiles
378
+ .map(f => {
379
+ const fullPath = path.join(repoRoot, f);
380
+ const stats = fs.statSync(fullPath);
381
+ return { path: fullPath, mtime: stats.mtime, name: f };
382
+ })
383
+ .sort((a, b) => b.mtime - a.mtime)[0];
384
+
385
+ log(`Found session message file: ${mostRecent.name}`);
386
+ return mostRecent.path;
387
+ }
388
+ } catch (err) {
389
+ dlog("Error scanning for session message files:", err.message);
390
+ }
391
+
392
+ // Priority 3: Default .claude-commit-msg in root
393
+ const rootDefault = path.join(repoRoot, ".claude-commit-msg");
394
+ if (fs.existsSync(rootDefault)) return rootDefault;
395
+
396
+ // Priority 4: Common nested candidates
397
+ const candidates = [
398
+ path.join(repoRoot, "MVPemails/DistilledConceptExtractor/.claude-commit-msg"),
399
+ path.join(repoRoot, "MVPEmails/DistilledConceptExtractor/.claude-commit-msg"),
400
+ ];
401
+ for (const c of candidates) if (fs.existsSync(c)) return c;
402
+
403
+ // Fallback: If AC_MSG_FILE was set but doesn't exist, return it anyway for watching
404
+ if (MSG_FILE_ENV) {
405
+ return path.resolve(repoRoot, MSG_FILE_ENV);
406
+ }
407
+
408
+ // Final fallback: Default path even if absent
409
+ return rootDefault;
410
+ }
411
+ function fileMtimeMs(p) { try { return fs.statSync(p).mtimeMs; } catch { return 0; } }
412
+ function readMsgFile(p) { try { return fs.readFileSync(p, "utf8").trim(); } catch { return ""; } }
413
+ /**
414
+ * Validate commit message follows conventional format
415
+ * Expected format: type(scope): description
416
+ * Types: feat|fix|refactor|docs|test|chore
417
+ * @param {string} msg - Commit message to validate
418
+ * @returns {boolean} True if message is valid
419
+ */
420
+ function conventionalHeaderOK(msg) {
421
+ if (!msg || msg.length < MSG_MIN_BYTES) return false;
422
+ return MSG_PATTERN.test(msg);
423
+ }
424
+ /**
425
+ * Check if commit message is ready to use
426
+ * Considers:
427
+ * - Message requirement settings
428
+ * - Conventional format validation
429
+ * - Whether message was updated after last code change
430
+ * @param {string} msgPath - Path to message file
431
+ * @returns {boolean} True if ready to commit
432
+ */
433
+ function msgReady(msgPath) {
434
+ if (!REQUIRE_MSG) return true; // Messages not required
435
+
436
+ const msg = readMsgFile(msgPath);
437
+ if (!conventionalHeaderOK(msg)) return false; // Invalid format
438
+
439
+ if (!REQUIRE_MSG_AFTER_CHANGE) return true; // Don't require fresh message
440
+
441
+ // Check if message was updated after last non-message change
442
+ const mtime = fileMtimeMs(msgPath);
443
+ return mtime >= lastNonMsgChangeTs;
444
+ }
445
+
446
+ // ============================================================================
447
+ // DAILY ROLLOVER SYSTEM - Handle day transitions and version increments
448
+ // ============================================================================
449
+
450
+ /**
451
+ * Find the most recent daily branch
452
+ * @param {string} prefix - Branch prefix to search (e.g., "dev_sdd_")
453
+ * @returns {Promise<string|null>} Latest branch name or null
454
+ */
455
+ async function latestDaily(prefix = DAILY_PREFIX) {
456
+ const { ok, stdout } = await run("git", [
457
+ "for-each-ref",
458
+ "--format=%(refname:short) %(committerdate:iso-strict)",
459
+ "--sort=-committerdate",
460
+ "refs/heads/" + prefix + "*"
461
+ ]);
462
+ if (!ok) return null;
463
+ const line = stdout.trim().split("\n").filter(Boolean)[0];
464
+ return line ? line.split(" ")[0] : null;
465
+ }
466
+ /**
467
+ * Calculate the next version branch using configurable increments
468
+ * e.g., v0.20 -> v0.21 -> v0.22 (increments by 0.01 each day)
469
+ * or v0.20 -> v0.30 -> v0.40 (increments by 0.1 each day)
470
+ * @returns {Promise<string>} Next version branch name
471
+ */
472
+ async function nextVersionBranch() {
473
+ const { ok, stdout } = await run("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
474
+ let maxMinor = -1;
475
+ if (ok) {
476
+ for (const b of stdout.split("\n")) {
477
+ // Match version branches with decimal format (e.g., v0.20, v0.21)
478
+ const m = b.trim().match(/^v0\.(\d+)$/);
479
+ if (m) maxMinor = Math.max(maxMinor, parseInt(m[1], 10));
480
+ }
481
+ }
482
+ // Get the daily increment from environment (default 1 = 0.01)
483
+ const dailyIncrement = parseInt(process.env.AC_VERSION_INCREMENT) || 1;
484
+ // Increment by configured amount
485
+ const next = maxMinor >= 0 ? maxMinor + dailyIncrement : VERSION_START_MINOR;
486
+ return `${VERSION_PREFIX}${next}`;
487
+ }
488
+
489
+ /**
490
+ * Get the latest version branch (for merging into main)
491
+ * @returns {Promise<string|null>} Latest version branch name or null
492
+ */
493
+ async function latestVersionBranch() {
494
+ const { ok, stdout } = await run("git", ["for-each-ref", "--format=%(refname:short)", "--sort=-version:refname", "refs/heads/v0.*"]);
495
+ if (!ok) return null;
496
+ const branches = stdout.trim().split("\n").filter(Boolean);
497
+ // Return the first (latest) version branch
498
+ return branches[0] || null;
499
+ }
500
+ async function mergeInto(target, source) {
501
+ // assumes we are on 'target'
502
+ return run("git", ["merge", "--no-ff", "-m", `rollup: merge ${source} into ${target}`, source]);
503
+ }
504
+
505
+ async function promptYesNo(question) {
506
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
507
+ const ans = await new Promise((res) => rl.question(question, res));
508
+ rl.close();
509
+ return /^y(es)?$/i.test((ans || "").trim());
510
+ }
511
+
512
+ /**
513
+ * DAILY ROLLOVER ORCHESTRATOR
514
+ *
515
+ * This is the heart of the branching strategy. At the start of each new day:
516
+ * 1. Merge yesterday's version branch → main (preserve completed work)
517
+ * 2. Create today's version branch from main (v0.XX+1)
518
+ * 3. Merge yesterday's daily branch → today's version branch (carry forward WIP)
519
+ * 4. Create today's daily branch from version branch (ready for new commits)
520
+ *
521
+ * Example flow (Sept 16 morning):
522
+ * main ← merge v0.21 (yesterday's version)
523
+ * create v0.22 from main (today's version)
524
+ * v0.22 ← merge dev_sdd_2025-09-15 (yesterday's work)
525
+ * create dev_sdd_2025-09-16 from v0.22 (today's branch)
526
+ *
527
+ * NOTE: This function is called:
528
+ * 1. At startup (initial check)
529
+ * 2. Before EVERY commit (to handle midnight crossover)
530
+ *
531
+ * @param {string} repoRoot - Repository root path
532
+ */
533
+ async function rolloverIfNewDay(repoRoot) {
534
+ // Skip if user pinned a static branch
535
+ if (STATIC_BRANCH) return;
536
+
537
+ const today = todayDateStr();
538
+ const todayDaily = dailyNameFor(today);
539
+
540
+ // If today's daily already exists and not forcing, nothing to do
541
+ // This check prevents multiple rollovers on the same day
542
+ if (await branchExists(todayDaily) && !FORCE_ROLLOVER) {
543
+ // But we still need to ensure we're on the right branch!
544
+ const current = await currentBranch();
545
+ if (current !== todayDaily) {
546
+ log(`Day changed while running - switching to ${todayDaily}`);
547
+ await ensureBranch(todayDaily);
548
+ }
549
+ return;
550
+ }
551
+
552
+ // Get necessary branch references
553
+ const yDaily = await latestDaily(); // Yesterday's (latest) daily branch
554
+ const vLast = await latestVersionBranch(); // Last version branch to merge into main
555
+ const vNext = await nextVersionBranch(); // New version branch (micro-revision increment)
556
+ const baseRef = VERSION_BASE_REF; // origin/main
557
+
558
+ // If working tree dirty, avoid merge conflicts
559
+ const dirty = await hasUncommittedChanges();
560
+
561
+ let proceed;
562
+ if (FORCE_ROLLOVER) {
563
+ proceed = !dirty;
564
+ } else if (ROLLOVER_PROMPT) {
565
+ const plan = [
566
+ `New day detected. Daily rollover plan with micro-revisions:`,
567
+ vLast ? ` 1) merge last version ${vLast} -> main` : ` 1) (no previous version to merge into main)`,
568
+ ` 2) create new version branch: ${vNext} (increment by 0.01)`,
569
+ yDaily ? ` 3) merge ${yDaily} -> ${vNext}` : ` 3) (no previous daily to merge)`,
570
+ ` 4) push ${vNext}`,
571
+ ` 5) create today's branch: ${todayDaily} from ${vNext} and push`,
572
+ dirty ? `\n⚠️ Working tree has uncommitted changes; rollover will be skipped.` : ""
573
+ ].join("\n");
574
+ console.log("\n[cs-devops-agent] " + plan + "\n");
575
+ proceed = !dirty && (await promptYesNo("Proceed with daily rollover? (y/N) "));
576
+ } else {
577
+ // auto-rollover without prompt
578
+ proceed = !dirty;
579
+ }
580
+
581
+ if (!proceed) return;
582
+
583
+ // Fetch latest changes from remote
584
+ await run("git", ["fetch", "--all", "--prune"]);
585
+
586
+ // Step 1: Merge last version branch into main (if exists)
587
+ if (vLast) {
588
+ log(`Merging last version ${vLast} into main...`);
589
+ // Checkout main first
590
+ const r0 = await run("git", ["checkout", "main"]);
591
+ if (!r0.ok) {
592
+ // Try origin/main if local main doesn't exist
593
+ await run("git", ["checkout", "-b", "main", "origin/main"]);
594
+ }
595
+
596
+ // Pull latest main
597
+ await run("git", ["pull", "origin", "main"]);
598
+
599
+ // Merge the last version branch into main
600
+ const mergeResult = await mergeInto("main", vLast);
601
+ if (!mergeResult.ok) {
602
+ console.error("[cs-devops-agent] Failed to merge ${vLast} into main. Resolve conflicts and restart.");
603
+ return;
604
+ }
605
+
606
+ // Push updated main
607
+ await pushBranch("main");
608
+ log(`Merged ${vLast} into main successfully`);
609
+ }
610
+
611
+ // Step 2: Create new version branch from updated main
612
+ const r1 = await run("git", ["checkout", "-B", vNext, baseRef]);
613
+ if (!r1.ok) {
614
+ console.error("[cs-devops-agent] failed to create version branch");
615
+ return;
616
+ }
617
+ log(`Created new version branch: ${vNext} (micro-revision increment)`);
618
+
619
+ // Step 3: Merge latest daily into the new version branch (if exists)
620
+ if (yDaily) {
621
+ log(`Merging daily ${yDaily} into ${vNext}...`);
622
+ const r2 = await mergeInto(vNext, yDaily);
623
+ if (!r2.ok) {
624
+ console.error("[cs-devops-agent] merge produced conflicts. Resolve, push, then restart worker.");
625
+ return;
626
+ }
627
+ }
628
+
629
+ // Step 4: Push the new version branch
630
+ const pushedV = await pushBranch(vNext);
631
+ console.log(`[cs-devops-agent] push ${vNext}: ${pushedV ? "ok" : "failed"}`);
632
+
633
+ // Step 5: Create and push today's daily branch from the new version branch
634
+ const r3 = await run("git", ["checkout", "-B", todayDaily, vNext]);
635
+ if (!r3.ok) {
636
+ console.error("[cs-devops-agent] failed to create today's branch");
637
+ return;
638
+ }
639
+ const pushedD = await pushBranch(todayDaily);
640
+ console.log(`[cs-devops-agent] push ${todayDaily}: ${pushedD ? "ok" : "failed"}`);
641
+
642
+ log(`Daily rollover complete: ${vNext} (v0.${vNext.substring(3)/100} in semantic versioning)`);
643
+ }
644
+
645
+ // ============================================================================
646
+ // COMMIT ORCHESTRATION - Handle the actual commit/push workflow
647
+ // ============================================================================
648
+
649
+ // Timing tracking for debouncing and quiet periods
650
+ let lastAnyChangeTs = 0; // Last time ANY file changed
651
+ let lastNonMsgChangeTs = 0; // Last time a NON-message file changed
652
+ let timer, busy = false; // Debounce timer and busy flag
653
+
654
+ function isQuietNow() {
655
+ return Date.now() - lastAnyChangeTs >= QUIET_MS;
656
+ }
657
+
658
+ /**
659
+ * MAIN COMMIT FUNCTION
660
+ *
661
+ * Executes a single commit cycle:
662
+ * 1. CHECK FOR DAY ROLLOVER (handle if we crossed midnight)
663
+ * 2. Ensure we're on correct branch
664
+ * 3. Detect infrastructure changes
665
+ * 4. Stage all changes (except message file)
666
+ * 5. Read and validate commit message
667
+ * 6. Update infrastructure documentation if needed
668
+ * 7. Commit with message (enhanced if infra changes)
669
+ * 8. Push to remote (if enabled)
670
+ * 9. Clear message file (if configured)
671
+ *
672
+ * @param {string} repoRoot - Repository root path
673
+ * @param {string} msgPath - Path to commit message file
674
+ */
675
+ async function commitOnce(repoRoot, msgPath) {
676
+ if (busy) return; // Prevent concurrent commits
677
+ busy = true;
678
+ try {
679
+ // IMPORTANT: Check for day rollover before EVERY commit
680
+ // This handles the case where the worker has been running past midnight
681
+ await rolloverIfNewDay(repoRoot);
682
+ const BRANCH = STATIC_BRANCH || `${BRANCH_PREFIX}${todayDateStr()}`;
683
+ const ensured = await ensureBranch(BRANCH);
684
+ log(`branch target=${BRANCH} ensured ok=${ensured.ok} created=${ensured.created} switched=${ensured.switched}`);
685
+ if (!ensured.ok) return;
686
+
687
+ // Get list of changed files for infrastructure detection
688
+ const { stdout: changedFiles } = await run("git", ["diff", "--name-only", "HEAD"]);
689
+ const changedFilesList = changedFiles ? changedFiles.split('\n').filter(f => f) : [];
690
+
691
+ // Detect infrastructure changes
692
+ const infraChanges = detectInfrastructureChanges(changedFilesList);
693
+ if (infraChanges.hasInfraChanges) {
694
+ log(infraChanges.summary);
695
+ }
696
+
697
+ await run("git", ["add", "-A"]);
698
+ await unstageIfStaged(path.relative(repoRoot, msgPath));
699
+
700
+ const n = await stagedCount();
701
+ log(`staged files=${n}`);
702
+ if (n === 0) return;
703
+
704
+ let msg = readMsgFile(msgPath);
705
+ const header = (msg.split("\n")[0] || "").slice(0, 120);
706
+ dlog("msgPath:", path.relative(repoRoot, msgPath), "size:", msg.length, "header:", header);
707
+
708
+ // Enhance commit message if infrastructure changes detected
709
+ if (infraChanges.hasInfraChanges && !msg.startsWith('infra')) {
710
+ const originalMsg = msg;
711
+ const [firstLine, ...rest] = msg.split('\n');
712
+
713
+ // If the message doesn't already have infra prefix, add it
714
+ if (!firstLine.match(/^(feat|fix|docs|style|refactor|test|chore|infra)/)) {
715
+ msg = `infra: ${firstLine}`;
716
+ } else {
717
+ msg = firstLine.replace(/^(\w+)/, 'infra');
718
+ }
719
+
720
+ // Add infrastructure details to commit body
721
+ const infraDetails = `\n\nInfrastructure changes:\n${infraChanges.files.map(f => `- ${f.file} (${f.category})`).join('\n')}`;
722
+ msg = rest.length > 0 ? `${msg}\n${rest.join('\n')}${infraDetails}` : `${msg}${infraDetails}`;
723
+ }
724
+
725
+ let committed = false;
726
+ if (REQUIRE_MSG && conventionalHeaderOK(msg)) {
727
+ // Update infrastructure documentation before commit
728
+ if (infraChanges.hasInfraChanges) {
729
+ await updateInfrastructureDoc(infraChanges, msg);
730
+ // Re-stage to include the documentation update
731
+ await run("git", ["add", "Documentation/infrastructure.md"]);
732
+ }
733
+
734
+ // Handle worktrees: .git might be a file pointing to the actual git dir
735
+ let gitDir = path.join(repoRoot, ".git");
736
+ if (fs.existsSync(gitDir) && fs.statSync(gitDir).isFile()) {
737
+ // In a worktree, .git is a file containing the path to the actual git directory
738
+ const gitFileContent = fs.readFileSync(gitDir, 'utf8');
739
+ const match = gitFileContent.match(/gitdir: (.+)/);
740
+ if (match) {
741
+ gitDir = match[1].trim();
742
+ }
743
+ }
744
+
745
+ const tmp = path.join(gitDir, ".ac-msg.txt");
746
+ fs.writeFileSync(tmp, msg + "\n");
747
+ committed = (await run("git", ["commit", "-F", tmp])).ok;
748
+ try { fs.unlinkSync(tmp); } catch {}
749
+ } else if (!REQUIRE_MSG) {
750
+ committed = (await run("git", ["commit", "-m", "chore: cs-devops-agent"])).ok;
751
+ } else {
752
+ log("message not ready; skipping commit");
753
+ return;
754
+ }
755
+
756
+ if (!committed) { log("commit failed"); return; }
757
+ if (CLEAR_MSG_WHEN === "commit") clearMsgFile(msgPath);
758
+
759
+ const sha = (await run("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
760
+ log(`committed ${sha} on ${await currentBranch()}`);
761
+
762
+ if (PUSH) {
763
+ const ok = await pushBranch(BRANCH);
764
+ log(`push ${ok ? "ok" : "failed"}`);
765
+ if (ok && CLEAR_MSG_WHEN === "push") clearMsgFile(msgPath);
766
+ }
767
+ } finally {
768
+ busy = false;
769
+ }
770
+ }
771
+
772
+ function schedule(repoRoot, msgPath) {
773
+ if (QUIET_MS <= 0) return; // disabled
774
+ clearTimeout(timer);
775
+ timer = setTimeout(async () => {
776
+ if (!isQuietNow()) return;
777
+ if (!msgReady(msgPath)) {
778
+ dlog("not committing: message not ready or updated yet");
779
+ return;
780
+ }
781
+ await commitOnce(repoRoot, msgPath);
782
+ }, QUIET_MS);
783
+ }
784
+
785
+ // ============================================================================
786
+ // INFRASTRUCTURE CHANGE DETECTION
787
+ // ============================================================================
788
+
789
+ /**
790
+ * Detect if changes include infrastructure files
791
+ * @param {string[]} changedFiles - Array of changed file paths
792
+ * @returns {object} - Infrastructure change details
793
+ */
794
+ function detectInfrastructureChanges(changedFiles) {
795
+ const infraPatterns = [
796
+ { pattern: /package(-lock)?\.json$/, category: 'Dependencies' },
797
+ { pattern: /\.env(\..*)?$/, category: 'Config' },
798
+ { pattern: /.*config.*\.(js|json|yml|yaml)$/, category: 'Config' },
799
+ { pattern: /Dockerfile$/, category: 'Build' },
800
+ { pattern: /docker-compose\.(yml|yaml)$/, category: 'Build' },
801
+ { pattern: /\.github\/workflows\//, category: 'Build' },
802
+ { pattern: /migrations?\//, category: 'Database' },
803
+ { pattern: /(routes?|api)\//, category: 'API' },
804
+ { pattern: /\.gitlab-ci\.yml$/, category: 'Build' },
805
+ { pattern: /webpack\.config\.js$/, category: 'Build' },
806
+ { pattern: /tsconfig\.json$/, category: 'Build' },
807
+ { pattern: /jest\.config\.js$/, category: 'Build' },
808
+ { pattern: /\.eslintrc/, category: 'Build' },
809
+ { pattern: /\.prettierrc/, category: 'Build' }
810
+ ];
811
+
812
+ const detected = {
813
+ hasInfraChanges: false,
814
+ categories: new Set(),
815
+ files: [],
816
+ summary: ''
817
+ };
818
+
819
+ for (const file of changedFiles) {
820
+ for (const { pattern, category } of infraPatterns) {
821
+ if (pattern.test(file)) {
822
+ detected.hasInfraChanges = true;
823
+ detected.categories.add(category);
824
+ detected.files.push({ file, category });
825
+ break;
826
+ }
827
+ }
828
+ }
829
+
830
+ if (detected.hasInfraChanges) {
831
+ detected.summary = `Infrastructure changes detected in: ${Array.from(detected.categories).join(', ')}`;
832
+ }
833
+
834
+ return detected;
835
+ }
836
+
837
+ /**
838
+ * Update infrastructure documentation
839
+ * @param {object} infraChanges - Infrastructure change details
840
+ * @param {string} commitMsg - Commit message
841
+ */
842
+ async function updateInfrastructureDoc(infraChanges, commitMsg) {
843
+ const TRACK_INFRA = (process.env.AC_TRACK_INFRA || "true").toLowerCase() !== "false";
844
+ if (!TRACK_INFRA || !infraChanges.hasInfraChanges) return;
845
+
846
+ const docPath = path.join(
847
+ process.cwd(),
848
+ process.env.AC_INFRA_DOC_PATH || 'Documentation/infrastructure.md'
849
+ );
850
+
851
+ // Ensure Documentation directory exists
852
+ const docDir = path.dirname(docPath);
853
+ if (!fs.existsSync(docDir)) {
854
+ fs.mkdirSync(docDir, { recursive: true });
855
+ }
856
+
857
+ // Create file if it doesn't exist
858
+ if (!fs.existsSync(docPath)) {
859
+ const template = `# Infrastructure Change Log
860
+
861
+ This document tracks all infrastructure changes made to the project.
862
+
863
+ ---
864
+
865
+ <!-- New entries will be added above this line -->`;
866
+ fs.writeFileSync(docPath, template);
867
+ }
868
+
869
+ // Prepare entry
870
+ const date = new Date().toISOString().split('T')[0];
871
+ const agent = process.env.AGENT_NAME || process.env.USER || 'System';
872
+ const categories = Array.from(infraChanges.categories).join(', ');
873
+
874
+ const entry = `
875
+ ## ${date} - ${agent}
876
+
877
+ ### Category: ${categories}
878
+ **Change Type**: Modified
879
+ **Component**: ${infraChanges.files[0].category}
880
+ **Description**: ${commitMsg.split('\n')[0]}
881
+ **Files Changed**:
882
+ ${infraChanges.files.map(f => `- ${f.file}`).join('\n')}
883
+
884
+ ---
885
+ `;
886
+
887
+ // Read current content and insert new entry
888
+ let content = fs.readFileSync(docPath, 'utf8');
889
+ const insertMarker = '<!-- New entries will be added above this line -->';
890
+
891
+ if (content.includes(insertMarker)) {
892
+ content = content.replace(insertMarker, entry + '\n' + insertMarker);
893
+ } else {
894
+ content += '\n' + entry;
895
+ }
896
+
897
+ fs.writeFileSync(docPath, content);
898
+ log(`Updated infrastructure documentation: ${docPath}`);
899
+ }
900
+
901
+ // ============================================================================
902
+ // WORKTREE DETECTION AND MANAGEMENT
903
+ // ============================================================================
904
+
905
+ /**
906
+ * Detect if we're running CS_DevOpsAgent on another repository and should use worktrees
907
+ * @param {string} repoRoot - The root of the target repository
908
+ * @returns {object} - Worktree info or null
909
+ */
910
+ async function detectAndSetupWorktree(repoRoot) {
911
+ // Check if we're in the CS_DevOpsAgent repo itself - never use worktrees here
912
+ const autoCommitMarkers = ['worktree-manager.js', 'cs-devops-agent-worker.js', 'setup-cs-devops-agent.js'];
913
+ const isCS_DevOpsAgentRepo = autoCommitMarkers.every(file =>
914
+ fs.existsSync(path.join(repoRoot, file))
915
+ );
916
+
917
+ if (isCS_DevOpsAgentRepo) {
918
+ dlog("Running in CS_DevOpsAgent repository - worktrees disabled");
919
+ return null;
920
+ }
921
+
922
+ // Check environment variables for agent identification
923
+ const agentName = process.env.AGENT_NAME || process.env.AI_AGENT || null;
924
+ const agentTask = process.env.AGENT_TASK || process.env.AI_TASK || 'development';
925
+ const useWorktree = (process.env.AC_USE_WORKTREE || "auto").toLowerCase();
926
+
927
+ // Skip worktree if explicitly disabled
928
+ if (useWorktree === "false" || useWorktree === "no") {
929
+ dlog("Worktree usage disabled via AC_USE_WORKTREE");
930
+ return null;
931
+ }
932
+
933
+ // If no agent name provided, try to detect from environment
934
+ let detectedAgent = agentName;
935
+ if (!detectedAgent) {
936
+ // Check for common AI agent indicators
937
+ if (process.env.COPILOT_API_KEY || process.env.GITHUB_COPILOT_ENABLED) {
938
+ detectedAgent = 'copilot';
939
+ } else if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY) {
940
+ detectedAgent = 'claude';
941
+ } else if (process.env.CURSOR_API_KEY || process.env.CURSOR_ENABLED) {
942
+ detectedAgent = 'cursor';
943
+ } else if (process.env.AIDER_API_KEY || process.env.OPENAI_API_KEY) {
944
+ detectedAgent = 'aider';
945
+ } else if (process.env.USER && process.env.USER.includes('agent')) {
946
+ detectedAgent = process.env.USER.replace(/[^a-zA-Z0-9]/g, '');
947
+ }
948
+ }
949
+
950
+ // Only proceed if we have an agent name or it's explicitly requested
951
+ if (!detectedAgent && useWorktree !== "true") {
952
+ dlog("No agent detected and worktrees not explicitly requested");
953
+ return null;
954
+ }
955
+
956
+ // Generate agent name if needed
957
+ if (!detectedAgent) {
958
+ detectedAgent = `agent-${Date.now().toString(36)}`;
959
+ log(`Generated agent name: ${detectedAgent}`);
960
+ }
961
+
962
+ // Check if we're already in a worktree
963
+ const { stdout: worktreeList } = await run("git", ["worktree", "list", "--porcelain"]);
964
+ const currentPath = process.cwd();
965
+ const isInWorktree = worktreeList.includes(currentPath) &&
966
+ !worktreeList.startsWith(`worktree ${currentPath}\n`);
967
+
968
+ if (isInWorktree) {
969
+ log(`Already running in worktree: ${currentPath}`);
970
+ return { isWorktree: true, path: currentPath, agent: detectedAgent };
971
+ }
972
+
973
+ // Try to use existing worktree manager if available
974
+ const worktreeManagerPath = path.join(repoRoot, '.worktrees', 'worktree-manager.js');
975
+ const hasWorktreeManager = fs.existsSync(worktreeManagerPath) ||
976
+ fs.existsSync(path.join(repoRoot, 'worktree-manager.js'));
977
+
978
+ if (hasWorktreeManager) {
979
+ log(`Found worktree manager - creating worktree for ${detectedAgent}`);
980
+ const { ok, stdout } = await run("node", [
981
+ worktreeManagerPath,
982
+ "create",
983
+ "--agent", detectedAgent,
984
+ "--task", agentTask
985
+ ]);
986
+
987
+ if (ok) {
988
+ const worktreePath = path.join(repoRoot, '.worktrees', `${detectedAgent}-${agentTask}`);
989
+ log(`Worktree created at: ${worktreePath}`);
990
+ return { isWorktree: true, path: worktreePath, agent: detectedAgent, created: true };
991
+ }
992
+ }
993
+
994
+ // Fallback: Create simple worktree without manager
995
+ const worktreesDir = path.join(repoRoot, '.worktrees');
996
+ const worktreeName = `${detectedAgent}-${agentTask}`;
997
+ const worktreePath = path.join(worktreesDir, worktreeName);
998
+ const branchName = `agent/${detectedAgent}/${agentTask}`;
999
+
1000
+ if (!fs.existsSync(worktreePath)) {
1001
+ log(`Creating worktree for ${detectedAgent} at ${worktreePath}`);
1002
+
1003
+ // Ensure worktrees directory exists
1004
+ if (!fs.existsSync(worktreesDir)) {
1005
+ fs.mkdirSync(worktreesDir, { recursive: true });
1006
+ }
1007
+
1008
+ // Create worktree with new branch
1009
+ const { ok } = await run("git", [
1010
+ "worktree", "add",
1011
+ "-b", branchName,
1012
+ worktreePath,
1013
+ "HEAD"
1014
+ ]);
1015
+
1016
+ if (ok) {
1017
+ // Create agent config file
1018
+ const agentConfig = {
1019
+ agent: detectedAgent,
1020
+ worktree: worktreeName,
1021
+ branch: branchName,
1022
+ task: agentTask,
1023
+ created: new Date().toISOString(),
1024
+ autoCommit: {
1025
+ enabled: true,
1026
+ prefix: `agent_${detectedAgent}_`,
1027
+ messagePrefix: `[${detectedAgent.toUpperCase()}]`
1028
+ }
1029
+ };
1030
+
1031
+ fs.writeFileSync(
1032
+ path.join(worktreePath, '.agent-config'),
1033
+ JSON.stringify(agentConfig, null, 2)
1034
+ );
1035
+
1036
+ log(`Worktree created successfully for ${detectedAgent}`);
1037
+ return { isWorktree: true, path: worktreePath, agent: detectedAgent, created: true };
1038
+ } else {
1039
+ log(`Failed to create worktree for ${detectedAgent}`);
1040
+ }
1041
+ } else {
1042
+ log(`Using existing worktree at: ${worktreePath}`);
1043
+ return { isWorktree: true, path: worktreePath, agent: detectedAgent };
1044
+ }
1045
+
1046
+ return null;
1047
+ }
1048
+
1049
+ // ============================================================================
1050
+ // SETTINGS MANAGEMENT - Interactive settings editor
1051
+ // ============================================================================
1052
+
1053
+ /**
1054
+ * Load and manage settings interactively
1055
+ */
1056
+ async function handleSettingsCommand() {
1057
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
1058
+ const globalSettingsPath = path.join(homeDir, '.devops-agent', 'settings.json');
1059
+ const projectSettingsPath = path.join(process.cwd(), 'local_deploy', 'project-settings.json');
1060
+
1061
+ // Load current settings
1062
+ let globalSettings = {};
1063
+ let projectSettings = {};
1064
+
1065
+ try {
1066
+ if (fs.existsSync(globalSettingsPath)) {
1067
+ globalSettings = JSON.parse(fs.readFileSync(globalSettingsPath, 'utf8'));
1068
+ }
1069
+ } catch (err) {
1070
+ log("Could not load global settings: " + err.message);
1071
+ }
1072
+
1073
+ try {
1074
+ if (fs.existsSync(projectSettingsPath)) {
1075
+ projectSettings = JSON.parse(fs.readFileSync(projectSettingsPath, 'utf8'));
1076
+ }
1077
+ } catch (err) {
1078
+ log("Could not load project settings: " + err.message);
1079
+ }
1080
+
1081
+ console.log("\n" + "=".repeat(60));
1082
+ console.log("CONFIGURATION SETTINGS");
1083
+ console.log("=".repeat(60));
1084
+
1085
+ console.log("\nGLOBAL SETTINGS (applies to all projects):");
1086
+ console.log(" 1) Developer initials: " + (globalSettings.developerInitials || "[not set]"));
1087
+ console.log(" 2) Email: " + (globalSettings.email || "[not set]"));
1088
+
1089
+ console.log("\nPROJECT SETTINGS (this repository only):");
1090
+ const vs = projectSettings.versioningStrategy || {};
1091
+ console.log(" 3) Version prefix: " + (vs.prefix || "v0."));
1092
+ console.log(" 4) Starting version: " + (vs.startMinor || "20"));
1093
+ const increment = vs.dailyIncrement || 1;
1094
+ const incrementDisplay = (increment / 100).toFixed(2);
1095
+ console.log(" 5) Daily increment: " + incrementDisplay + " (" + increment + ")");
1096
+
1097
+ console.log("\nOptions:");
1098
+ console.log(" Enter number (1-5) to edit a setting");
1099
+ console.log(" Enter 'v' to view all settings in detail");
1100
+ console.log(" Enter 'r' to reset project settings");
1101
+ console.log(" Enter 'q' or press Enter to return");
1102
+
1103
+ const rl = readline.createInterface({
1104
+ input: input,
1105
+ output: output
1106
+ });
1107
+
1108
+ const choice = await new Promise(resolve => {
1109
+ rl.question("\nYour choice: ", resolve);
1110
+ });
1111
+
1112
+ rl.close();
1113
+
1114
+ switch (choice.trim().toLowerCase()) {
1115
+ case '1':
1116
+ await editDeveloperInitials(globalSettings, globalSettingsPath);
1117
+ break;
1118
+ case '2':
1119
+ await editEmail(globalSettings, globalSettingsPath);
1120
+ break;
1121
+ case '3':
1122
+ await editVersionPrefix(projectSettings, projectSettingsPath);
1123
+ break;
1124
+ case '4':
1125
+ await editStartingVersion(projectSettings, projectSettingsPath);
1126
+ break;
1127
+ case '5':
1128
+ await editDailyIncrement(projectSettings, projectSettingsPath);
1129
+ break;
1130
+ case 'v':
1131
+ console.log("\nFull Settings:");
1132
+ console.log("\nGlobal (~/.devops-agent/settings.json):");
1133
+ console.log(JSON.stringify(globalSettings, null, 2));
1134
+ console.log("\nProject (local_deploy/project-settings.json):");
1135
+ console.log(JSON.stringify(projectSettings, null, 2));
1136
+ break;
1137
+ case 'r':
1138
+ const confirmRl = readline.createInterface({ input, output });
1139
+ const confirm = await new Promise(resolve => {
1140
+ confirmRl.question("Reset project settings to defaults? (y/N): ", resolve);
1141
+ });
1142
+ confirmRl.close();
1143
+ if (confirm.toLowerCase() === 'y') {
1144
+ projectSettings = {
1145
+ versioningStrategy: {
1146
+ prefix: "v0.",
1147
+ startMinor: 20,
1148
+ dailyIncrement: 1,
1149
+ configured: false
1150
+ }
1151
+ };
1152
+ saveProjectSettings(projectSettings, projectSettingsPath);
1153
+ console.log("Project settings reset to defaults.");
1154
+ }
1155
+ break;
1156
+ case 'q':
1157
+ case '':
1158
+ // Return to main prompt
1159
+ break;
1160
+ default:
1161
+ console.log("Invalid choice.");
1162
+ }
1163
+ }
1164
+
1165
+ async function editDeveloperInitials(settings, settingsPath) {
1166
+ const rl = readline.createInterface({ input, output });
1167
+ const initials = await new Promise(resolve => {
1168
+ rl.question("Enter 3-letter developer initials: ", resolve);
1169
+ });
1170
+ rl.close();
1171
+
1172
+ if (initials.length === 3 && /^[a-zA-Z]+$/.test(initials)) {
1173
+ settings.developerInitials = initials.toLowerCase();
1174
+ settings.configured = true;
1175
+ saveGlobalSettings(settings, settingsPath);
1176
+ console.log("Developer initials updated to: " + initials.toLowerCase());
1177
+ } else {
1178
+ console.log("Invalid initials. Must be exactly 3 letters.");
1179
+ }
1180
+ }
1181
+
1182
+ async function editEmail(settings, settingsPath) {
1183
+ const rl = readline.createInterface({ input, output });
1184
+ const email = await new Promise(resolve => {
1185
+ rl.question("Enter email address: ", resolve);
1186
+ });
1187
+ rl.close();
1188
+
1189
+ settings.email = email.trim();
1190
+ saveGlobalSettings(settings, settingsPath);
1191
+ console.log("Email updated to: " + email.trim());
1192
+ }
1193
+
1194
+ async function editVersionPrefix(settings, settingsPath) {
1195
+ const rl = readline.createInterface({ input, output });
1196
+ const prefix = await new Promise(resolve => {
1197
+ rl.question("Enter version prefix (e.g., v0., v1., v2.): ", resolve);
1198
+ });
1199
+ rl.close();
1200
+
1201
+ const cleaned = prefix.trim();
1202
+ if (cleaned) {
1203
+ if (!settings.versioningStrategy) settings.versioningStrategy = {};
1204
+ settings.versioningStrategy.prefix = cleaned.endsWith('.') ? cleaned : cleaned + '.';
1205
+ saveProjectSettings(settings, settingsPath);
1206
+ console.log("Version prefix updated to: " + settings.versioningStrategy.prefix);
1207
+ process.env.AC_VERSION_PREFIX = settings.versioningStrategy.prefix;
1208
+ }
1209
+ }
1210
+
1211
+ async function editStartingVersion(settings, settingsPath) {
1212
+ const rl = readline.createInterface({ input, output });
1213
+ const version = await new Promise(resolve => {
1214
+ rl.question("Enter starting version number (e.g., 20 for v0.20): ", resolve);
1215
+ });
1216
+ rl.close();
1217
+
1218
+ const num = parseInt(version.trim());
1219
+ if (!isNaN(num) && num >= 0) {
1220
+ if (!settings.versioningStrategy) settings.versioningStrategy = {};
1221
+ settings.versioningStrategy.startMinor = num;
1222
+ saveProjectSettings(settings, settingsPath);
1223
+ console.log("Starting version updated to: " + num);
1224
+ process.env.AC_VERSION_START_MINOR = num.toString();
1225
+ } else {
1226
+ console.log("Invalid version number.");
1227
+ }
1228
+ }
1229
+
1230
+ async function editDailyIncrement(settings, settingsPath) {
1231
+ const rl = readline.createInterface({ input, output });
1232
+ console.log("\nDaily increment options:");
1233
+ console.log(" 1) 0.01 per day (v0.20 → v0.21 → v0.22)");
1234
+ console.log(" 2) 0.1 per day (v0.20 → v0.30 → v0.40)");
1235
+ console.log(" 3) 0.2 per day (v0.20 → v0.40 → v0.60)");
1236
+ console.log(" 4) Custom value");
1237
+
1238
+ const choice = await new Promise(resolve => {
1239
+ rl.question("Select option (1-4): ", resolve);
1240
+ });
1241
+
1242
+ let increment = 1;
1243
+ switch (choice.trim()) {
1244
+ case '1':
1245
+ increment = 1;
1246
+ break;
1247
+ case '2':
1248
+ increment = 10;
1249
+ break;
1250
+ case '3':
1251
+ increment = 20;
1252
+ break;
1253
+ case '4':
1254
+ const custom = await new Promise(resolve => {
1255
+ rl.question("Enter increment value (e.g., 5 for 0.05): ", resolve);
1256
+ });
1257
+ const customNum = parseInt(custom.trim());
1258
+ if (!isNaN(customNum) && customNum > 0) {
1259
+ increment = customNum;
1260
+ } else {
1261
+ console.log("Invalid value, using default (0.01).");
1262
+ }
1263
+ break;
1264
+ default:
1265
+ console.log("Invalid choice, using default (0.01).");
1266
+ }
1267
+
1268
+ rl.close();
1269
+
1270
+ if (!settings.versioningStrategy) settings.versioningStrategy = {};
1271
+ settings.versioningStrategy.dailyIncrement = increment;
1272
+ saveProjectSettings(settings, settingsPath);
1273
+ const display = (increment / 100).toFixed(2);
1274
+ console.log("Daily increment updated to: " + display);
1275
+ process.env.AC_VERSION_INCREMENT = increment.toString();
1276
+ }
1277
+
1278
+ function saveGlobalSettings(settings, settingsPath) {
1279
+ const dir = path.dirname(settingsPath);
1280
+ if (!fs.existsSync(dir)) {
1281
+ fs.mkdirSync(dir, { recursive: true });
1282
+ }
1283
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1284
+ }
1285
+
1286
+ function saveProjectSettings(settings, settingsPath) {
1287
+ const dir = path.dirname(settingsPath);
1288
+ if (!fs.existsSync(dir)) {
1289
+ fs.mkdirSync(dir, { recursive: true });
1290
+ }
1291
+ settings.versioningStrategy.configured = true;
1292
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1293
+ }
1294
+
1295
+ // ============================================================================
1296
+ // MAIN ENTRY POINT - Initialize and start the cs-devops-agent worker
1297
+ // ============================================================================
1298
+
1299
+ // Display copyright and license information immediately
1300
+ console.log("\n" + "=".repeat(70));
1301
+ console.log(" CS_DevOpsAgent - Intelligent Git Automation System");
1302
+ console.log(" Version 2.4.0 | Build 20240930.1");
1303
+ console.log(" \n Copyright (c) 2024 SecondBrain Labs");
1304
+ console.log(" Author: Sachin Dev Duggal");
1305
+ console.log(" \n Licensed under the MIT License");
1306
+ console.log(" This software is provided 'as-is' without any warranty.");
1307
+ console.log(" See LICENSE file for full license text.");
1308
+ console.log("=".repeat(70));
1309
+ console.log();
1310
+
1311
+ /**
1312
+ * MAIN EXECUTION FLOW:
1313
+ * 1. Setup: Find git root, change to it
1314
+ * 2. Worktree Detection: Check if we should use worktrees for agent isolation
1315
+ * 3. Daily Rollover: Check if new day, handle merges if needed
1316
+ * 4. Message File: Locate .claude-commit-msg file
1317
+ * 5. Startup Commit: Commit pending changes if message exists
1318
+ * 6. File Watcher: Monitor all files for changes
1319
+ * 7. Message Trigger: Auto-commit when message file updates
1320
+ * 8. Loop forever...
1321
+ */
1322
+ (async () => {
1323
+ const { stdout: toplevel } = await run("git", ["rev-parse", "--show-toplevel"]);
1324
+ const repoRoot = toplevel.trim() || process.cwd();
1325
+
1326
+ // Check if we should use a worktree for this agent
1327
+ const worktreeInfo = await detectAndSetupWorktree(repoRoot);
1328
+
1329
+ if (worktreeInfo && worktreeInfo.created) {
1330
+ // If we just created a worktree, we need to switch to it
1331
+ log(`Switching to worktree at: ${worktreeInfo.path}`);
1332
+ process.chdir(worktreeInfo.path);
1333
+
1334
+ // Update environment variables for the worktree context
1335
+ process.env.AGENT_NAME = worktreeInfo.agent;
1336
+ process.env.AC_BRANCH_PREFIX = `agent_${worktreeInfo.agent}_`;
1337
+ process.env.AC_MSG_FILE = `.${worktreeInfo.agent}-commit-msg`;
1338
+ } else if (worktreeInfo && worktreeInfo.isWorktree) {
1339
+ // Already in a worktree, just ensure we're using it
1340
+ process.chdir(worktreeInfo.path);
1341
+ } else {
1342
+ // No worktree, proceed normally
1343
+ process.chdir(repoRoot);
1344
+ }
1345
+
1346
+ log(`repo=${repoRoot}`);
1347
+ log(`node=${process.version} cwd=${process.cwd()}`);
1348
+ log(`prefix=${BRANCH_PREFIX}, tz=${TZ}, style=${DATE_STYLE}, push=${PUSH}`);
1349
+ log(`static branch=${STATIC_BRANCH ?? "(dynamic daily)"}`);
1350
+
1351
+ // Rollover at start (new day -> version branch bump + today's daily)
1352
+ await rolloverIfNewDay(repoRoot);
1353
+
1354
+ const msgPath = resolveMsgPath(repoRoot);
1355
+ const msgExists = fs.existsSync(msgPath);
1356
+ const msgSize = msgExists ? fs.statSync(msgPath).size : 0;
1357
+ log(`message file=${path.relative(repoRoot, msgPath)} exists=${msgExists} size=${msgSize}`);
1358
+ dlog("env.AC_MSG_FILE =", MSG_FILE_ENV, " TRIGGER_ON_MSG =", TRIGGER_ON_MSG, " MSG_DEBOUNCE_MS =", MSG_DEBOUNCE_MS);
1359
+
1360
+ // Startup commit (if pending + message already present + allowed)
1361
+ // Note: rolloverIfNewDay already ensured we're on the right branch
1362
+ const BRANCH = STATIC_BRANCH || `${BRANCH_PREFIX}${todayDateStr()}`;
1363
+ await ensureBranch(BRANCH);
1364
+ const pending = await hasUncommittedChanges();
1365
+ const hasMsg = msgExists && readMsgFile(msgPath).length > 0;
1366
+
1367
+ if (pending && hasMsg && (!REQUIRE_MSG || msgReady(msgPath))) {
1368
+ const { count, added, modified, deleted, untracked, preview } = await summarizeStatus();
1369
+ log(`pending changes: files=${count} (A=${added}, M=${modified}, D=${deleted}, ?=${untracked}) on ${await currentBranch()}`);
1370
+ dlog("status preview:\n" + preview);
1371
+ const header = readMsgFile(msgPath).split("\n")[0].slice(0, 120);
1372
+ log(`message header: ${header}`);
1373
+
1374
+ let proceed = CS_DEVOPS_AGENT_ON_START;
1375
+ if (!CS_DEVOPS_AGENT_ON_START && CONFIRM_ON_START) {
1376
+ proceed = await promptYesNo("Commit these changes now using the message file? (y/N) ");
1377
+ }
1378
+ if (proceed) await commitOnce(repoRoot, msgPath);
1379
+ else log("startup commit skipped; watching…");
1380
+ } else {
1381
+ log("watching…");
1382
+ }
1383
+
1384
+ // ============================================================================
1385
+ // FILE WATCHER SETUP - Monitor for changes and trigger commits
1386
+ // ============================================================================
1387
+
1388
+ // Canonicalize message path (resolves symlinks & correct casing on disk)
1389
+ const msgReal = fs.existsSync(msgPath) ? fs.realpathSync(msgPath) : msgPath;
1390
+ const relMsg = path.relative(repoRoot, msgReal);
1391
+
1392
+ // Helper to compare paths (case-insensitive for compatibility)
1393
+ const samePath = (a, b) =>
1394
+ path.resolve(repoRoot, a).toLowerCase() === path.resolve(repoRoot, b).toLowerCase();
1395
+
1396
+ let msgTimer; // Debounce timer for message file changes
1397
+
1398
+ // Start watching all files (except ignored patterns)
1399
+ chokidar
1400
+ .watch(["**/*", "!node_modules/**", "!.git/**"], {
1401
+ ignoreInitial: true,
1402
+ usePolling: USE_POLLING,
1403
+ interval: 500,
1404
+ ignored: ["**/__pycache__/**", "**/*.pyc", "**/.DS_Store", "**/logs/**"],
1405
+ })
1406
+ .on("all", async (evt, p) => {
1407
+ const now = Date.now();
1408
+ const isMsg = samePath(p, relMsg);
1409
+
1410
+ // Track timing of non-message changes
1411
+ if (!isMsg) {
1412
+ lastAnyChangeTs = now;
1413
+ lastNonMsgChangeTs = now;
1414
+ }
1415
+ dlog(`watcher: ${evt} ${p}`);
1416
+
1417
+ // ========== SPECIAL HANDLING FOR MESSAGE FILE ==========
1418
+ // When .claude-commit-msg changes, wait a bit then commit
1419
+ if (TRIGGER_ON_MSG && isMsg) {
1420
+ clearTimeout(msgTimer);
1421
+ msgTimer = setTimeout(async () => {
1422
+ if (msgReady(msgReal)) {
1423
+ await commitOnce(repoRoot, msgReal);
1424
+ } else {
1425
+ dlog("message changed but not ready yet");
1426
+ }
1427
+ }, MSG_DEBOUNCE_MS);
1428
+ return;
1429
+ }
1430
+
1431
+ // ========== LEGACY QUIET MODE (if configured) ==========
1432
+ // Wait for quiet period before committing (no-op if AC_QUIET_MS=0)
1433
+ schedule(repoRoot, msgReal);
1434
+ });
1435
+
1436
+ // ============================================================================
1437
+ // INTERACTIVE COMMAND INTERFACE - Handle user commands during execution
1438
+ // ============================================================================
1439
+
1440
+ // Extract session ID from branch name or message file
1441
+ const sessionId = (() => {
1442
+ const branch = STATIC_BRANCH || BRANCH;
1443
+ const match = branch.match(/([a-z0-9]{4}-[a-z0-9]{4})/i);
1444
+ if (match) return match[1];
1445
+
1446
+ // Try to get from message file name
1447
+ const msgFileName = path.basename(msgPath);
1448
+ const msgMatch = msgFileName.match(/\.devops-commit-([a-z0-9]{4}-[a-z0-9]{4})\.msg/);
1449
+ if (msgMatch) return msgMatch[1];
1450
+
1451
+ return null;
1452
+ })();
1453
+
1454
+ console.log("\n" + "=".repeat(60));
1455
+ console.log("[cs-devops-agent] INTERACTIVE COMMANDS AVAILABLE:");
1456
+ console.log(" help - Show available commands");
1457
+ console.log(" status - Show current session status");
1458
+ console.log(" settings - View and edit configuration settings");
1459
+ console.log(" verbose - Toggle verbose/debug logging");
1460
+ console.log(" commit - Force commit now");
1461
+ console.log(" push - Push current branch");
1462
+ console.log(" exit - Cleanly close session and exit");
1463
+ if (sessionId) {
1464
+ console.log(`\nSession ID: ${sessionId}`);
1465
+ console.log(`To close from another terminal: echo "CLOSE_SESSION" > .devops-command-${sessionId}`);
1466
+ }
1467
+ console.log("=".repeat(60) + "\n");
1468
+
1469
+ // Create readline interface for interactive commands
1470
+ const rl = readline.createInterface({
1471
+ input: input,
1472
+ output: output,
1473
+ prompt: '[agent] > ',
1474
+ terminal: true
1475
+ });
1476
+
1477
+ // Command handler
1478
+ rl.on('line', async (line) => {
1479
+ const cmd = line.trim().toLowerCase();
1480
+
1481
+ switch (cmd) {
1482
+ case 'help':
1483
+ case 'h':
1484
+ case '?':
1485
+ console.log("\nAvailable commands:");
1486
+ console.log(" help/h/? - Show this help");
1487
+ console.log(" status/s - Show session status and uncommitted changes");
1488
+ console.log(" settings/config - View and edit configuration settings");
1489
+ console.log(" verbose/v - Toggle verbose/debug logging (currently: " + (DEBUG ? "ON" : "OFF") + ")");
1490
+ console.log(" commit/c - Force commit now (stages all changes)");
1491
+ console.log(" push/p - Push current branch to remote");
1492
+ console.log(" exit/quit/q - Cleanly close session and exit");
1493
+ console.log(" clear/cls - Clear the screen");
1494
+ break;
1495
+
1496
+ case 'status':
1497
+ case 's':
1498
+ const currentBranchName = await currentBranch();
1499
+ console.log(`\nSession Status:`);
1500
+ console.log(` Branch: ${currentBranchName}`);
1501
+ console.log(` Working directory: ${process.cwd()}`);
1502
+ console.log(` Message file: ${path.basename(msgPath)}`);
1503
+ console.log(` Auto-push: ${PUSH ? "enabled" : "disabled"}`);
1504
+ console.log(` Debug mode: ${DEBUG ? "ON" : "OFF"}`);
1505
+
1506
+ const statusSummary = await summarizeStatus(10);
1507
+ if (statusSummary.count > 0) {
1508
+ console.log(`\n Uncommitted changes: ${statusSummary.count} files`);
1509
+ console.log(` Added: ${statusSummary.added}, Modified: ${statusSummary.modified}, Deleted: ${statusSummary.deleted}, Untracked: ${statusSummary.untracked}`);
1510
+ if (statusSummary.preview) {
1511
+ console.log("\n Preview:");
1512
+ statusSummary.preview.split('\n').forEach(line => console.log(` ${line}`));
1513
+ }
1514
+ } else {
1515
+ console.log("\n No uncommitted changes");
1516
+ }
1517
+ break;
1518
+
1519
+ case 'settings':
1520
+ case 'config':
1521
+ await handleSettingsCommand();
1522
+ break;
1523
+
1524
+ case 'verbose':
1525
+ case 'v':
1526
+ DEBUG = !DEBUG;
1527
+ console.log(`\nVerbose/Debug logging: ${DEBUG ? "ENABLED" : "DISABLED"}`);
1528
+ if (DEBUG) {
1529
+ console.log("Debug output will now be shown for all operations.");
1530
+ } else {
1531
+ console.log("Debug output disabled. Only important messages will be shown.");
1532
+ }
1533
+ break;
1534
+
1535
+ case 'commit':
1536
+ case 'c':
1537
+ console.log("\nForcing commit...");
1538
+ const hasChanges = await hasUncommittedChanges();
1539
+ if (hasChanges) {
1540
+ // Check for message file
1541
+ let commitMsg = readMsgFile(msgPath);
1542
+ if (!commitMsg) {
1543
+ console.log("No commit message found. Enter message (or 'cancel' to abort):");
1544
+ rl.prompt();
1545
+ const msgInput = await new Promise(resolve => {
1546
+ rl.once('line', resolve);
1547
+ });
1548
+ if (msgInput.trim().toLowerCase() === 'cancel') {
1549
+ console.log("Commit cancelled.");
1550
+ break;
1551
+ }
1552
+ commitMsg = msgInput.trim() || "chore: manual commit from interactive mode";
1553
+ fs.writeFileSync(msgPath, commitMsg);
1554
+ }
1555
+ await commitOnce(repoRoot, msgPath);
1556
+ } else {
1557
+ console.log("No changes to commit.");
1558
+ }
1559
+ break;
1560
+
1561
+ case 'push':
1562
+ case 'p':
1563
+ const branchName = await currentBranch();
1564
+ console.log(`\nPushing branch ${branchName}...`);
1565
+ const pushResult = await pushBranch(branchName);
1566
+ if (pushResult) {
1567
+ console.log("Push successful!");
1568
+ } else {
1569
+ console.log("Push failed. Check the logs above for details.");
1570
+ }
1571
+ break;
1572
+
1573
+ case 'exit':
1574
+ case 'quit':
1575
+ case 'q':
1576
+ console.log("\nInitiating clean shutdown...");
1577
+
1578
+ // Check for uncommitted changes
1579
+ const uncommitted = await hasUncommittedChanges();
1580
+ if (uncommitted) {
1581
+ console.log("You have uncommitted changes. Commit them before exit? (y/n)");
1582
+ rl.prompt();
1583
+ const answer = await new Promise(resolve => {
1584
+ rl.once('line', resolve);
1585
+ });
1586
+
1587
+ if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
1588
+ console.log("Committing changes...");
1589
+ const exitMsg = "chore: session cleanup - final commit before exit";
1590
+ fs.writeFileSync(msgPath, exitMsg);
1591
+ await commitOnce(repoRoot, msgPath);
1592
+ }
1593
+ }
1594
+
1595
+ // Push any unpushed commits
1596
+ if (PUSH) {
1597
+ const branch = await currentBranch();
1598
+ console.log("Pushing final changes...");
1599
+ await pushBranch(branch);
1600
+ }
1601
+
1602
+ // Clean up message file
1603
+ if (fs.existsSync(msgPath)) {
1604
+ fs.unlinkSync(msgPath);
1605
+ }
1606
+
1607
+ // Create cleanup marker if in a session
1608
+ if (sessionId) {
1609
+ const cleanupMarker = path.join(process.cwd(), '.session-cleanup-requested');
1610
+ fs.writeFileSync(cleanupMarker, JSON.stringify({
1611
+ sessionId: sessionId,
1612
+ timestamp: new Date().toISOString(),
1613
+ branch: await currentBranch(),
1614
+ worktree: process.cwd()
1615
+ }, null, 2));
1616
+ console.log("\n✓ Session cleanup complete.");
1617
+ console.log("Run 'npm run devops:close' from the main repo to remove the worktree.");
1618
+ }
1619
+
1620
+ console.log("\nGoodbye!");
1621
+ rl.close();
1622
+ process.exit(0);
1623
+ break;
1624
+
1625
+ case 'clear':
1626
+ case 'cls':
1627
+ console.clear();
1628
+ console.log("[cs-devops-agent] Interactive mode - Type 'help' for commands");
1629
+ break;
1630
+
1631
+ case '':
1632
+ // Empty line, just show prompt again
1633
+ break;
1634
+
1635
+ default:
1636
+ if (cmd) {
1637
+ console.log(`Unknown command: '${cmd}'. Type 'help' for available commands.`);
1638
+ }
1639
+ break;
1640
+ }
1641
+
1642
+ rl.prompt();
1643
+ });
1644
+
1645
+ // Show initial prompt
1646
+ rl.prompt();
1647
+
1648
+ // Handle Ctrl+C gracefully
1649
+ rl.on('SIGINT', () => {
1650
+ console.log("\n\nReceived SIGINT. Type 'exit' for clean shutdown or Ctrl+C again to force quit.");
1651
+ rl.prompt();
1652
+
1653
+ // Allow force quit on second Ctrl+C
1654
+ rl.once('SIGINT', () => {
1655
+ console.log("\nForce quitting...");
1656
+ process.exit(1);
1657
+ });
1658
+ });
1659
+
1660
+ })();