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.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/bin/cs-devops-agent +151 -0
- package/cleanup-sessions.sh +70 -0
- package/docs/PROJECT_INFO.md +115 -0
- package/docs/RELEASE_NOTES.md +189 -0
- package/docs/SESSION_MANAGEMENT.md +120 -0
- package/docs/TESTING.md +331 -0
- package/docs/houserules.md +267 -0
- package/docs/infrastructure.md +68 -0
- package/docs/testing-guide.md +224 -0
- package/package.json +68 -0
- package/src/agent-commands.js +211 -0
- package/src/claude-session-manager.js +488 -0
- package/src/close-session.js +316 -0
- package/src/cs-devops-agent-worker.js +1660 -0
- package/src/run-with-agent.js +372 -0
- package/src/session-coordinator.js +1207 -0
- package/src/setup-cs-devops-agent.js +985 -0
- package/src/worktree-manager.js +768 -0
- package/start-devops-session.sh +299 -0
|
@@ -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
|
+
})();
|