smart-context-mcp 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/task-runner.js +8 -0
- package/src/server.js +8 -0
- package/src/storage/sqlite.js +72 -11
- package/src/task-runner.js +93 -17
- package/src/tools/smart-doctor.js +119 -4
- package/src/tools/smart-shell.js +43 -5
- package/src/utils/runtime-check.js +52 -0
- package/src/utils/runtime-config.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smart-context-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
|
|
5
5
|
"author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
|
|
6
6
|
"type": "module",
|
package/scripts/task-runner.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runTaskRunner } from '../src/task-runner.js';
|
|
3
|
+
import { checkNodeVersion } from '../src/utils/runtime-check.js';
|
|
4
|
+
|
|
5
|
+
const runtimeCheck = checkNodeVersion();
|
|
6
|
+
if (!runtimeCheck.ok) {
|
|
7
|
+
console.error(`[smart-context-task] Runtime check failed: ${runtimeCheck.message}`);
|
|
8
|
+
console.error(`[smart-context-task] Current: ${runtimeCheck.current}, Required: ${runtimeCheck.minimum}+`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
const requireValue = (argv, index, flag) => {
|
|
5
13
|
const value = argv[index + 1];
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { smartMetrics } from './tools/smart-metrics.js';
|
|
|
16
16
|
import { smartTurn } from './tools/smart-turn.js';
|
|
17
17
|
import { projectRoot, projectRootSource } from './utils/paths.js';
|
|
18
18
|
import { setServerForStreaming } from './streaming.js';
|
|
19
|
+
import { checkNodeVersion } from './utils/runtime-check.js';
|
|
19
20
|
import {
|
|
20
21
|
getSymbolBlame,
|
|
21
22
|
getFileAuthorshipStats,
|
|
@@ -44,6 +45,13 @@ export const asTextResult = (result) => ({
|
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
export const createDevctxServer = () => {
|
|
48
|
+
const runtimeCheck = checkNodeVersion();
|
|
49
|
+
if (!runtimeCheck.ok) {
|
|
50
|
+
console.error(`[devctx] Runtime check failed: ${runtimeCheck.message}`);
|
|
51
|
+
console.error(`[devctx] Current: ${runtimeCheck.current}, Required: ${runtimeCheck.minimum}+`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
const server = new McpServer({
|
|
48
56
|
name: 'devctx',
|
|
49
57
|
version,
|
package/src/storage/sqlite.js
CHANGED
|
@@ -2,12 +2,16 @@ import fs from 'node:fs';
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
6
|
import { projectRoot } from '../utils/runtime-config.js';
|
|
6
7
|
|
|
7
8
|
export const STATE_DB_FILENAME = 'state.sqlite';
|
|
8
9
|
export const SQLITE_SCHEMA_VERSION = 5;
|
|
9
10
|
export const ACTIVE_SESSION_SCOPE = 'project';
|
|
10
11
|
export const STATE_DB_SOFT_MAX_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
const STATE_DB_BUSY_TIMEOUT_MS = 1000;
|
|
13
|
+
const STATE_DB_LOCK_RETRY_ATTEMPTS = 3;
|
|
14
|
+
const STATE_DB_LOCK_RETRY_DELAY_MS = 75;
|
|
11
15
|
export const EXPECTED_TABLES = [
|
|
12
16
|
'active_session',
|
|
13
17
|
'context_access',
|
|
@@ -444,7 +448,32 @@ export const getMeta = (db, key) => {
|
|
|
444
448
|
const getSchemaVersion = (db) => Number(getMeta(db, 'schema_version') ?? 0);
|
|
445
449
|
const VALID_STATUSES = new Set(['planning', 'in_progress', 'blocked', 'completed']);
|
|
446
450
|
|
|
447
|
-
const
|
|
451
|
+
const isStateDbLockError = (error) =>
|
|
452
|
+
/database is locked|database table is locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(String(error?.message ?? error ?? ''));
|
|
453
|
+
|
|
454
|
+
const withStateDbLockRetry = async (operation) => {
|
|
455
|
+
for (let attempt = 1; attempt <= STATE_DB_LOCK_RETRY_ATTEMPTS; attempt += 1) {
|
|
456
|
+
try {
|
|
457
|
+
return await operation();
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (!isStateDbLockError(error) || attempt === STATE_DB_LOCK_RETRY_ATTEMPTS) {
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
await delay(STATE_DB_LOCK_RETRY_DELAY_MS * attempt);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
throw new Error('SQLite lock retry exhausted unexpectedly.');
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const applyPragmas = (db, { readOnly = false } = {}) => {
|
|
471
|
+
db.exec(`PRAGMA busy_timeout = ${STATE_DB_BUSY_TIMEOUT_MS}`);
|
|
472
|
+
|
|
473
|
+
if (readOnly) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
448
477
|
db.exec('PRAGMA foreign_keys = ON');
|
|
449
478
|
db.exec('PRAGMA journal_mode = WAL');
|
|
450
479
|
db.exec('PRAGMA synchronous = NORMAL');
|
|
@@ -505,17 +534,28 @@ export const listStateTables = (db) =>
|
|
|
505
534
|
|
|
506
535
|
export const openStateDb = async ({ filePath = getStateDbPath(), readOnly = false } = {}) => {
|
|
507
536
|
try {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
537
|
+
return await withStateDbLockRetry(async () => {
|
|
538
|
+
const { DatabaseSync } = await loadSqliteModule();
|
|
539
|
+
if (!readOnly) {
|
|
540
|
+
ensureStateDir(filePath);
|
|
541
|
+
}
|
|
512
542
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
543
|
+
let db = null;
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
db = new DatabaseSync(filePath, readOnly ? { readOnly: true } : {});
|
|
547
|
+
applyPragmas(db, { readOnly });
|
|
548
|
+
if (!readOnly) {
|
|
549
|
+
runStateMigrations(db);
|
|
550
|
+
}
|
|
551
|
+
return db;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
try {
|
|
554
|
+
db?.close();
|
|
555
|
+
} catch {}
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
519
559
|
} catch (error) {
|
|
520
560
|
throw enrichStateDbError(error, { filePath, readOnly });
|
|
521
561
|
}
|
|
@@ -1063,6 +1103,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1063
1103
|
const payload = readJsonFile(filePath);
|
|
1064
1104
|
|
|
1065
1105
|
if (!payload || typeof payload !== 'object') {
|
|
1106
|
+
const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
1066
1107
|
return {
|
|
1067
1108
|
type: 'session',
|
|
1068
1109
|
path: filePath,
|
|
@@ -1070,6 +1111,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1070
1111
|
eligible: false,
|
|
1071
1112
|
reason: 'invalid_json',
|
|
1072
1113
|
sessionId: null,
|
|
1114
|
+
sizeBytes,
|
|
1073
1115
|
};
|
|
1074
1116
|
}
|
|
1075
1117
|
|
|
@@ -1080,6 +1122,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1080
1122
|
WHERE session_id = ?
|
|
1081
1123
|
`).get(sessionId);
|
|
1082
1124
|
if (!sessionRow) {
|
|
1125
|
+
const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
1083
1126
|
return {
|
|
1084
1127
|
type: 'session',
|
|
1085
1128
|
path: filePath,
|
|
@@ -1087,6 +1130,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1087
1130
|
eligible: false,
|
|
1088
1131
|
reason: 'missing_in_sqlite',
|
|
1089
1132
|
sessionId,
|
|
1133
|
+
sizeBytes,
|
|
1090
1134
|
};
|
|
1091
1135
|
}
|
|
1092
1136
|
|
|
@@ -1094,6 +1138,8 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1094
1138
|
const sqliteUpdatedAt = toIsoString(sessionRow.updated_at);
|
|
1095
1139
|
const eligible = getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
|
|
1096
1140
|
|
|
1141
|
+
const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
|
1142
|
+
|
|
1097
1143
|
return {
|
|
1098
1144
|
type: 'session',
|
|
1099
1145
|
path: filePath,
|
|
@@ -1101,6 +1147,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
|
1101
1147
|
eligible,
|
|
1102
1148
|
reason: eligible ? 'imported_and_not_newer_than_sqlite' : 'legacy_file_newer_than_sqlite',
|
|
1103
1149
|
sessionId,
|
|
1150
|
+
sizeBytes,
|
|
1104
1151
|
fileUpdatedAt,
|
|
1105
1152
|
sqliteUpdatedAt,
|
|
1106
1153
|
};
|
|
@@ -1113,34 +1160,40 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
|
|
|
1113
1160
|
|
|
1114
1161
|
const payload = readJsonFile(activeSessionFile);
|
|
1115
1162
|
if (!payload || typeof payload !== 'object') {
|
|
1163
|
+
const sizeBytes = fs.statSync(activeSessionFile).size;
|
|
1116
1164
|
return {
|
|
1117
1165
|
type: 'active_session',
|
|
1118
1166
|
path: activeSessionFile,
|
|
1119
1167
|
eligible: false,
|
|
1120
1168
|
reason: 'invalid_json',
|
|
1121
1169
|
sessionId: null,
|
|
1170
|
+
sizeBytes,
|
|
1122
1171
|
};
|
|
1123
1172
|
}
|
|
1124
1173
|
|
|
1125
1174
|
const legacySessionId = typeof payload.sessionId === 'string' ? payload.sessionId : null;
|
|
1126
1175
|
const activeRow = getActiveSessionRow(db);
|
|
1127
1176
|
if (!legacySessionId) {
|
|
1177
|
+
const sizeBytes = fs.statSync(activeSessionFile).size;
|
|
1128
1178
|
return {
|
|
1129
1179
|
type: 'active_session',
|
|
1130
1180
|
path: activeSessionFile,
|
|
1131
1181
|
eligible: true,
|
|
1132
1182
|
reason: 'orphaned_legacy_file',
|
|
1133
1183
|
sessionId: null,
|
|
1184
|
+
sizeBytes,
|
|
1134
1185
|
};
|
|
1135
1186
|
}
|
|
1136
1187
|
|
|
1137
1188
|
if (!activeRow) {
|
|
1189
|
+
const sizeBytes = fs.statSync(activeSessionFile).size;
|
|
1138
1190
|
return {
|
|
1139
1191
|
type: 'active_session',
|
|
1140
1192
|
path: activeSessionFile,
|
|
1141
1193
|
eligible: true,
|
|
1142
1194
|
reason: 'sqlite_has_no_active_session',
|
|
1143
1195
|
sessionId: legacySessionId,
|
|
1196
|
+
sizeBytes,
|
|
1144
1197
|
};
|
|
1145
1198
|
}
|
|
1146
1199
|
|
|
@@ -1149,12 +1202,15 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
|
|
|
1149
1202
|
const eligible = activeRow.session_id === legacySessionId
|
|
1150
1203
|
&& getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
|
|
1151
1204
|
|
|
1205
|
+
const sizeBytes = fs.existsSync(activeSessionFile) ? fs.statSync(activeSessionFile).size : 0;
|
|
1206
|
+
|
|
1152
1207
|
return {
|
|
1153
1208
|
type: 'active_session',
|
|
1154
1209
|
path: activeSessionFile,
|
|
1155
1210
|
eligible,
|
|
1156
1211
|
reason: eligible ? 'sqlite_active_session_matches' : 'sqlite_active_session_differs',
|
|
1157
1212
|
sessionId: legacySessionId,
|
|
1213
|
+
sizeBytes,
|
|
1158
1214
|
fileUpdatedAt,
|
|
1159
1215
|
sqliteUpdatedAt,
|
|
1160
1216
|
};
|
|
@@ -1167,12 +1223,14 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
|
|
|
1167
1223
|
|
|
1168
1224
|
const { entries, invalidLines } = readMetricsEntries(metricsFile);
|
|
1169
1225
|
if (invalidLines.length > 0) {
|
|
1226
|
+
const sizeBytes = fs.statSync(metricsFile).size;
|
|
1170
1227
|
return {
|
|
1171
1228
|
type: 'metrics',
|
|
1172
1229
|
path: metricsFile,
|
|
1173
1230
|
eligible: false,
|
|
1174
1231
|
reason: 'invalid_jsonl',
|
|
1175
1232
|
entryCount: entries.length,
|
|
1233
|
+
sizeBytes,
|
|
1176
1234
|
invalidLines,
|
|
1177
1235
|
missingEntries: [],
|
|
1178
1236
|
};
|
|
@@ -1191,12 +1249,15 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
|
|
|
1191
1249
|
}
|
|
1192
1250
|
}
|
|
1193
1251
|
|
|
1252
|
+
const sizeBytes = fs.existsSync(metricsFile) ? fs.statSync(metricsFile).size : 0;
|
|
1253
|
+
|
|
1194
1254
|
return {
|
|
1195
1255
|
type: 'metrics',
|
|
1196
1256
|
path: metricsFile,
|
|
1197
1257
|
eligible: missingEntries.length === 0,
|
|
1198
1258
|
reason: missingEntries.length === 0 ? 'all_entries_imported' : 'sqlite_missing_entries',
|
|
1199
1259
|
entryCount: entries.length,
|
|
1260
|
+
sizeBytes,
|
|
1200
1261
|
invalidLines,
|
|
1201
1262
|
missingEntries,
|
|
1202
1263
|
};
|
package/src/task-runner.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
1
2
|
import { persistMetrics } from './metrics.js';
|
|
2
3
|
import { countTokens } from './tokenCounter.js';
|
|
4
|
+
import { projectRoot } from './utils/paths.js';
|
|
3
5
|
import { TASK_RUNNER_QUALITY_ANALYTICS_KIND } from './analytics/product-quality.js';
|
|
4
6
|
import { runHeadlessWrapper } from './orchestration/headless-wrapper.js';
|
|
5
7
|
import { smartContext } from './tools/smart-context.js';
|
|
@@ -22,6 +24,8 @@ import {
|
|
|
22
24
|
|
|
23
25
|
const START_MAX_TOKENS = 350;
|
|
24
26
|
const END_MAX_TOKENS = 350;
|
|
27
|
+
const RUNNER_LOCK_RETRY_ATTEMPTS = 3;
|
|
28
|
+
const RUNNER_LOCK_RETRY_DELAY_MS = 100;
|
|
25
29
|
|
|
26
30
|
const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
27
31
|
const truncate = (value, maxLength = 160) => {
|
|
@@ -39,6 +43,13 @@ const truncate = (value, maxLength = 160) => {
|
|
|
39
43
|
|
|
40
44
|
const asArray = (value) => Array.isArray(value) ? value : [];
|
|
41
45
|
const uniqueCompact = (values) => [...new Set(asArray(values).map((value) => normalizeWhitespace(value)).filter(Boolean))];
|
|
46
|
+
const extractContextTopFiles = (topFiles) => uniqueCompact(asArray(topFiles).map((item) => {
|
|
47
|
+
if (typeof item === 'string') {
|
|
48
|
+
return item;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return item?.file ?? item?.path ?? '';
|
|
52
|
+
})).slice(0, 3);
|
|
42
53
|
|
|
43
54
|
const extractPreflightTopFiles = (preflightResult) => {
|
|
44
55
|
if (!preflightResult) {
|
|
@@ -50,12 +61,7 @@ const extractPreflightTopFiles = (preflightResult) => {
|
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
if (preflightResult.tool === 'smart_search') {
|
|
53
|
-
return
|
|
54
|
-
if (typeof item === 'string') {
|
|
55
|
-
return item;
|
|
56
|
-
}
|
|
57
|
-
return item?.file ?? item?.path ?? '';
|
|
58
|
-
})).slice(0, 3);
|
|
64
|
+
return extractContextTopFiles(preflightResult.result?.topFiles);
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
return [];
|
|
@@ -100,7 +106,7 @@ const buildPreflightTask = ({ workflowProfile, prompt, startResult }) => {
|
|
|
100
106
|
const normalizedPrompt = normalizeWhitespace(prompt);
|
|
101
107
|
const persistedNextStep = normalizeWhitespace(startResult?.summary?.nextStep);
|
|
102
108
|
const currentFocus = normalizeWhitespace(startResult?.summary?.currentFocus);
|
|
103
|
-
const refreshedTopFiles =
|
|
109
|
+
const refreshedTopFiles = extractContextTopFiles(startResult?.refreshedContext?.topFiles);
|
|
104
110
|
|
|
105
111
|
if (workflowProfile.commandName === 'continue' || workflowProfile.commandName === 'resume') {
|
|
106
112
|
if (persistedNextStep) {
|
|
@@ -178,7 +184,7 @@ const buildContinuityGuidance = ({ startResult }) => {
|
|
|
178
184
|
];
|
|
179
185
|
const nextStep = normalizeWhitespace(startResult?.summary?.nextStep);
|
|
180
186
|
const currentFocus = normalizeWhitespace(startResult?.summary?.currentFocus);
|
|
181
|
-
const refreshedTopFiles =
|
|
187
|
+
const refreshedTopFiles = extractContextTopFiles(startResult?.refreshedContext?.topFiles);
|
|
182
188
|
const recommendedNextTools = asArray(startResult?.recommendedPath?.nextTools)
|
|
183
189
|
.map((tool) => normalizeWhitespace(tool))
|
|
184
190
|
.filter(Boolean)
|
|
@@ -253,6 +259,29 @@ const buildWorkflowPolicyPayload = ({ commandName, workflowProfile, preflightSum
|
|
|
253
259
|
preflight: preflightSummary,
|
|
254
260
|
});
|
|
255
261
|
|
|
262
|
+
const isRetriableLockError = (error) => {
|
|
263
|
+
const issue = error?.storageHealth?.issue ?? error?.cause?.storageHealth?.issue ?? null;
|
|
264
|
+
const retriable = error?.storageHealth?.retriable ?? error?.cause?.storageHealth?.retriable ?? false;
|
|
265
|
+
const message = String(error?.message ?? error?.cause?.message ?? error ?? '');
|
|
266
|
+
return retriable || issue === 'locked' || /database is locked|database table is locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(message);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const withRunnerLockRetry = async (operation) => {
|
|
270
|
+
for (let attempt = 1; attempt <= RUNNER_LOCK_RETRY_ATTEMPTS; attempt += 1) {
|
|
271
|
+
try {
|
|
272
|
+
return await operation();
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (!isRetriableLockError(error) || attempt === RUNNER_LOCK_RETRY_ATTEMPTS) {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await delay(RUNNER_LOCK_RETRY_DELAY_MS * attempt);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
throw new Error('Task runner lock retry exhausted unexpectedly.');
|
|
283
|
+
};
|
|
284
|
+
|
|
256
285
|
const recordRunnerMetrics = async ({
|
|
257
286
|
commandName,
|
|
258
287
|
client,
|
|
@@ -330,13 +359,13 @@ const runWorkflowCommand = async ({
|
|
|
330
359
|
});
|
|
331
360
|
const workflowProfile = buildWorkflowPolicyProfile({ commandName });
|
|
332
361
|
|
|
333
|
-
const start = await smartTurn({
|
|
362
|
+
const start = await withRunnerLockRetry(() => smartTurn({
|
|
334
363
|
phase: 'start',
|
|
335
364
|
sessionId,
|
|
336
365
|
prompt: requestedPrompt,
|
|
337
366
|
ensureSession: true,
|
|
338
367
|
maxTokens: START_MAX_TOKENS,
|
|
339
|
-
});
|
|
368
|
+
}));
|
|
340
369
|
|
|
341
370
|
const gate = evaluateRunnerGate({ startResult: start });
|
|
342
371
|
let preflightSummary = null;
|
|
@@ -363,7 +392,7 @@ const runWorkflowCommand = async ({
|
|
|
363
392
|
});
|
|
364
393
|
|
|
365
394
|
if (gate.requiresDoctor && !allowDegraded) {
|
|
366
|
-
const doctor = await smartDoctor();
|
|
395
|
+
const doctor = await withRunnerLockRetry(() => smartDoctor());
|
|
367
396
|
const blockedResult = buildRunnerBlockedResult({
|
|
368
397
|
commandName,
|
|
369
398
|
client,
|
|
@@ -423,7 +452,7 @@ const runWorkflowCommand = async ({
|
|
|
423
452
|
};
|
|
424
453
|
|
|
425
454
|
const runDoctorCommand = async ({ verifyIntegrity = true, client }) => {
|
|
426
|
-
const result = await smartDoctor({ verifyIntegrity });
|
|
455
|
+
const result = await withRunnerLockRetry(() => smartDoctor({ verifyIntegrity }));
|
|
427
456
|
await recordRunnerMetrics({
|
|
428
457
|
commandName: 'doctor',
|
|
429
458
|
client,
|
|
@@ -436,7 +465,7 @@ const runDoctorCommand = async ({ verifyIntegrity = true, client }) => {
|
|
|
436
465
|
};
|
|
437
466
|
|
|
438
467
|
const runStatusCommand = async ({ format = 'compact', maxItems = 10, client }) => {
|
|
439
|
-
const result = await smartStatus({ format, maxItems });
|
|
468
|
+
const result = await withRunnerLockRetry(() => smartStatus({ format, maxItems }));
|
|
440
469
|
await recordRunnerMetrics({
|
|
441
470
|
commandName: 'status',
|
|
442
471
|
client,
|
|
@@ -451,13 +480,13 @@ const runCheckpointCommand = async ({
|
|
|
451
480
|
event = 'milestone',
|
|
452
481
|
update = {},
|
|
453
482
|
}) => {
|
|
454
|
-
const result = await smartTurn({
|
|
483
|
+
const result = await withRunnerLockRetry(() => smartTurn({
|
|
455
484
|
phase: 'end',
|
|
456
485
|
sessionId,
|
|
457
486
|
event,
|
|
458
487
|
update,
|
|
459
488
|
maxTokens: END_MAX_TOKENS,
|
|
460
|
-
});
|
|
489
|
+
}));
|
|
461
490
|
await recordRunnerMetrics({
|
|
462
491
|
commandName: 'checkpoint',
|
|
463
492
|
client,
|
|
@@ -485,10 +514,57 @@ const runCleanupCommand = async ({
|
|
|
485
514
|
});
|
|
486
515
|
|
|
487
516
|
if (cleanupMode === 'legacy') {
|
|
488
|
-
const
|
|
517
|
+
const dryRun = await smartSummary({
|
|
489
518
|
action: 'cleanup_legacy',
|
|
490
|
-
apply,
|
|
519
|
+
apply: false,
|
|
491
520
|
});
|
|
521
|
+
|
|
522
|
+
if (!apply) {
|
|
523
|
+
const eligibleFiles = [];
|
|
524
|
+
|
|
525
|
+
if (dryRun.sessions?.candidates) {
|
|
526
|
+
for (const session of dryRun.sessions.candidates) {
|
|
527
|
+
if (session.deletable) {
|
|
528
|
+
eligibleFiles.push({
|
|
529
|
+
relativePath: session.relativePath || session.path,
|
|
530
|
+
sizeBytes: session.sizeBytes || 0,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (dryRun.metrics?.eligible && dryRun.metrics?.path) {
|
|
537
|
+
eligibleFiles.push({
|
|
538
|
+
relativePath: dryRun.metrics.path.replace(projectRoot + '/', ''),
|
|
539
|
+
sizeBytes: dryRun.metrics.sizeBytes || 0,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (dryRun.activeSession?.eligible && dryRun.activeSession?.path) {
|
|
544
|
+
eligibleFiles.push({
|
|
545
|
+
relativePath: dryRun.activeSession.path.replace(projectRoot + '/', ''),
|
|
546
|
+
sizeBytes: dryRun.activeSession.sizeBytes || 0,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (eligibleFiles.length > 0) {
|
|
551
|
+
console.log('\n📋 Legacy files eligible for cleanup:\n');
|
|
552
|
+
console.log('File Size');
|
|
553
|
+
console.log('─'.repeat(60));
|
|
554
|
+
for (const file of eligibleFiles) {
|
|
555
|
+
const sizeKB = file.sizeBytes ? `${(file.sizeBytes / 1024).toFixed(1)}KB` : 'N/A';
|
|
556
|
+
console.log(`${file.relativePath.padEnd(42)} ${sizeKB}`);
|
|
557
|
+
}
|
|
558
|
+
const totalKB = eligibleFiles.reduce((sum, f) => sum + (f.sizeBytes || 0), 0) / 1024;
|
|
559
|
+
console.log('─'.repeat(60));
|
|
560
|
+
console.log(`Total: ${eligibleFiles.length} files, ${totalKB.toFixed(1)}KB\n`);
|
|
561
|
+
console.log('💡 To apply cleanup, run: smart-context-task cleanup --cleanup-mode legacy --apply\n');
|
|
562
|
+
} else {
|
|
563
|
+
console.log('\n✅ No legacy files eligible for cleanup.\n');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const result = apply ? await smartSummary({ action: 'cleanup_legacy', apply: true }) : dryRun;
|
|
492
568
|
const payload = {
|
|
493
569
|
command: 'cleanup',
|
|
494
570
|
cleanupMode,
|
|
@@ -68,6 +68,76 @@ const readMaintenanceSnapshot = async ({ filePath, storageHealth }) => {
|
|
|
68
68
|
try {
|
|
69
69
|
return await withStateDbSnapshot((db) => {
|
|
70
70
|
const count = (tableName) => db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get().count;
|
|
71
|
+
const retentionDays = Number(getMeta(db, 'state_compaction_retention_days') ?? DEFAULT_RETENTION_DAYS);
|
|
72
|
+
const activeSessionId = db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null;
|
|
73
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
74
|
+
const hookTurnCutoff = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
75
|
+
const staleSessionsQuery = activeSessionId
|
|
76
|
+
? db.prepare(`
|
|
77
|
+
SELECT COUNT(*) AS count
|
|
78
|
+
FROM sessions
|
|
79
|
+
WHERE datetime(updated_at) < datetime(?)
|
|
80
|
+
AND session_id != ?
|
|
81
|
+
`)
|
|
82
|
+
: db.prepare(`
|
|
83
|
+
SELECT COUNT(*) AS count
|
|
84
|
+
FROM sessions
|
|
85
|
+
WHERE datetime(updated_at) < datetime(?)
|
|
86
|
+
`);
|
|
87
|
+
const staleSessions = activeSessionId
|
|
88
|
+
? staleSessionsQuery.get(cutoff, activeSessionId).count
|
|
89
|
+
: staleSessionsQuery.get(cutoff).count;
|
|
90
|
+
const staleSessionEventsQuery = db.prepare(`
|
|
91
|
+
SELECT COUNT(*) AS count
|
|
92
|
+
FROM (
|
|
93
|
+
SELECT
|
|
94
|
+
se.event_id,
|
|
95
|
+
se.created_at,
|
|
96
|
+
ROW_NUMBER() OVER (
|
|
97
|
+
PARTITION BY se.session_id
|
|
98
|
+
ORDER BY datetime(se.created_at) DESC, se.event_id DESC
|
|
99
|
+
) AS row_num
|
|
100
|
+
FROM session_events se
|
|
101
|
+
JOIN sessions s ON s.session_id = se.session_id
|
|
102
|
+
WHERE datetime(s.updated_at) >= datetime(@cutoff)
|
|
103
|
+
OR s.session_id = @activeSessionId
|
|
104
|
+
)
|
|
105
|
+
WHERE row_num > @keepLatest
|
|
106
|
+
AND datetime(created_at) < datetime(@cutoff)
|
|
107
|
+
`);
|
|
108
|
+
const staleMetricsQuery = db.prepare(`
|
|
109
|
+
SELECT COUNT(*) AS count
|
|
110
|
+
FROM (
|
|
111
|
+
SELECT
|
|
112
|
+
metric_id,
|
|
113
|
+
created_at,
|
|
114
|
+
ROW_NUMBER() OVER (
|
|
115
|
+
ORDER BY datetime(created_at) DESC, metric_id DESC
|
|
116
|
+
) AS row_num
|
|
117
|
+
FROM metrics_events
|
|
118
|
+
)
|
|
119
|
+
WHERE row_num > @keepLatest
|
|
120
|
+
AND datetime(created_at) < datetime(@cutoff)
|
|
121
|
+
`);
|
|
122
|
+
const staleHookTurns = db.prepare(`
|
|
123
|
+
SELECT COUNT(*) AS count
|
|
124
|
+
FROM hook_turn_state
|
|
125
|
+
WHERE datetime(updated_at) < datetime(?)
|
|
126
|
+
`).get(hookTurnCutoff).count;
|
|
127
|
+
const compactionEstimate = {
|
|
128
|
+
sessionsDeleted: staleSessions,
|
|
129
|
+
sessionEventsDeleted: staleSessionEventsQuery.get({
|
|
130
|
+
cutoff,
|
|
131
|
+
activeSessionId,
|
|
132
|
+
keepLatest: DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION,
|
|
133
|
+
}).count,
|
|
134
|
+
metricsEventsDeleted: staleMetricsQuery.get({
|
|
135
|
+
cutoff,
|
|
136
|
+
keepLatest: DEFAULT_KEEP_LATEST_METRICS,
|
|
137
|
+
}).count,
|
|
138
|
+
hookTurnStateDeleted: staleHookTurns,
|
|
139
|
+
};
|
|
140
|
+
compactionEstimate.totalDeletes = Object.values(compactionEstimate).reduce((total, value) => total + value, 0);
|
|
71
141
|
|
|
72
142
|
return {
|
|
73
143
|
available: true,
|
|
@@ -78,14 +148,15 @@ const readMaintenanceSnapshot = async ({ filePath, storageHealth }) => {
|
|
|
78
148
|
hookTurnState: count('hook_turn_state'),
|
|
79
149
|
workflowMetrics: count('workflow_metrics'),
|
|
80
150
|
},
|
|
81
|
-
activeSessionId
|
|
151
|
+
activeSessionId,
|
|
82
152
|
schemaVersion: Number(getMeta(db, 'schema_version') ?? 0),
|
|
83
153
|
lastCompactedAt: getMeta(db, 'state_compacted_at'),
|
|
84
|
-
retentionDays
|
|
154
|
+
retentionDays,
|
|
85
155
|
legacyImport: {
|
|
86
156
|
sessions: Number(getMeta(db, 'legacy_sessions_import_count') ?? 0),
|
|
87
157
|
metrics: Number(getMeta(db, 'legacy_metrics_import_count') ?? 0),
|
|
88
158
|
},
|
|
159
|
+
compactionEstimate,
|
|
89
160
|
};
|
|
90
161
|
}, { filePath });
|
|
91
162
|
} catch (error) {
|
|
@@ -227,21 +298,30 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
|
|
|
227
298
|
retentionDays,
|
|
228
299
|
schemaVersion,
|
|
229
300
|
activeSessionId,
|
|
301
|
+
compactionEstimate = {
|
|
302
|
+
sessionsDeleted: 0,
|
|
303
|
+
sessionEventsDeleted: 0,
|
|
304
|
+
metricsEventsDeleted: 0,
|
|
305
|
+
hookTurnStateDeleted: 0,
|
|
306
|
+
totalDeletes: 0,
|
|
307
|
+
},
|
|
230
308
|
} = maintenance;
|
|
231
309
|
const daysSinceCompaction = daysSince(lastCompactedAt);
|
|
232
310
|
const sessionEventThreshold = Math.max(SESSION_EVENTS_WARNING_FLOOR, counts.sessions * DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION * 5);
|
|
233
311
|
const needsCompaction = [];
|
|
312
|
+
const canReclaimRows = compactionEstimate.totalDeletes > 0;
|
|
234
313
|
|
|
235
314
|
if (storageHealth.issue === 'oversized') {
|
|
236
315
|
needsCompaction.push('database_size');
|
|
237
316
|
}
|
|
238
317
|
|
|
239
|
-
if (!lastCompactedAt && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
|
|
318
|
+
if (!lastCompactedAt && canReclaimRows && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
|
|
240
319
|
needsCompaction.push('never_compacted');
|
|
241
320
|
}
|
|
242
321
|
|
|
243
322
|
if (
|
|
244
323
|
daysSinceCompaction !== null
|
|
324
|
+
&& canReclaimRows
|
|
245
325
|
&& daysSinceCompaction > retentionDays
|
|
246
326
|
&& (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > DEFAULT_KEEP_LATEST_METRICS)
|
|
247
327
|
) {
|
|
@@ -249,12 +329,19 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
|
|
|
249
329
|
}
|
|
250
330
|
|
|
251
331
|
if (needsCompaction.length > 0) {
|
|
332
|
+
const totalRows = compactionEstimate.totalDeletes;
|
|
333
|
+
const estimatedBytes = Math.round(storageHealth.sizeBytes * (totalRows / (counts.sessions + counts.sessionEvents + counts.metricsEvents + counts.hookTurnState)));
|
|
334
|
+
const pctReduction = Math.round((estimatedBytes / storageHealth.sizeBytes) * 100);
|
|
335
|
+
const impactSummary = totalRows > 0
|
|
336
|
+
? `Estimated impact: ~${totalRows} rows, ~${(estimatedBytes / 1024 / 1024).toFixed(1)}MB (${pctReduction}% reduction)`
|
|
337
|
+
: 'Estimated impact: minimal (no rows to delete with current retention policy)';
|
|
338
|
+
|
|
252
339
|
return buildCheck({
|
|
253
340
|
id: 'compaction',
|
|
254
341
|
status: 'warning',
|
|
255
342
|
message: 'SQLite retention/compaction hygiene should be refreshed before the local state grows further.',
|
|
256
343
|
recommendedActions: [
|
|
257
|
-
|
|
344
|
+
`Run smart_summary with action="compact" to prune old events and metrics. ${impactSummary}`,
|
|
258
345
|
'Use vacuum=true if you expect large deletions and want to reclaim file size immediately.',
|
|
259
346
|
],
|
|
260
347
|
details: {
|
|
@@ -264,6 +351,33 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
|
|
|
264
351
|
schemaVersion,
|
|
265
352
|
activeSessionId,
|
|
266
353
|
counts,
|
|
354
|
+
compactionEstimate,
|
|
355
|
+
estimatedImpact: {
|
|
356
|
+
rowsToDelete: totalRows,
|
|
357
|
+
bytesReclaimed: estimatedBytes,
|
|
358
|
+
pctReduction,
|
|
359
|
+
},
|
|
360
|
+
lastCompactedAt,
|
|
361
|
+
daysSinceCompaction,
|
|
362
|
+
retentionDays,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!lastCompactedAt && !canReclaimRows && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
|
|
368
|
+
return buildCheck({
|
|
369
|
+
id: 'compaction',
|
|
370
|
+
status: 'info',
|
|
371
|
+
message: 'SQLite state has never been compacted, but the current retention policy would not reclaim any rows yet.',
|
|
372
|
+
recommendedActions: [],
|
|
373
|
+
details: {
|
|
374
|
+
available: true,
|
|
375
|
+
recommended: false,
|
|
376
|
+
reason: 'baseline_not_initialized',
|
|
377
|
+
schemaVersion,
|
|
378
|
+
activeSessionId,
|
|
379
|
+
counts,
|
|
380
|
+
compactionEstimate,
|
|
267
381
|
lastCompactedAt,
|
|
268
382
|
daysSinceCompaction,
|
|
269
383
|
retentionDays,
|
|
@@ -282,6 +396,7 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
|
|
|
282
396
|
schemaVersion,
|
|
283
397
|
activeSessionId,
|
|
284
398
|
counts,
|
|
399
|
+
compactionEstimate,
|
|
285
400
|
lastCompactedAt,
|
|
286
401
|
daysSinceCompaction,
|
|
287
402
|
retentionDays,
|
package/src/tools/smart-shell.js
CHANGED
|
@@ -10,7 +10,6 @@ import { recordDevctxOperation } from '../missed-opportunities.js';
|
|
|
10
10
|
|
|
11
11
|
const execFile = promisify(execFileCallback);
|
|
12
12
|
const isShellDisabled = () => process.env.DEVCTX_SHELL_DISABLED === 'true';
|
|
13
|
-
const blockedPattern = /[|&;<>`\n\r$()]/;
|
|
14
13
|
const allowedCommands = new Set(['pwd', 'ls', 'find', 'rg', 'git', 'npm', 'pnpm', 'yarn', 'bun']);
|
|
15
14
|
const allowedGitSubcommands = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'blame']);
|
|
16
15
|
const allowedPackageManagerSubcommands = new Set(['test', 'run', 'lint', 'build', 'typecheck', 'check']);
|
|
@@ -20,8 +19,8 @@ const dangerousPatterns = [
|
|
|
20
19
|
/sudo/i,
|
|
21
20
|
/curl.*\|/i,
|
|
22
21
|
/wget.*\|/i,
|
|
23
|
-
/eval/i,
|
|
24
|
-
/exec/i,
|
|
22
|
+
/(^|\s)eval(\s|$)/i,
|
|
23
|
+
/(^|\s)exec(\s|$)/i,
|
|
25
24
|
];
|
|
26
25
|
const MAX_COMMAND_LENGTH = 500;
|
|
27
26
|
|
|
@@ -79,6 +78,45 @@ const tokenize = (command) => {
|
|
|
79
78
|
return tokens;
|
|
80
79
|
};
|
|
81
80
|
|
|
81
|
+
const hasUnquotedShellOperators = (command) => {
|
|
82
|
+
let inQuote = null;
|
|
83
|
+
let prevWasEscape = false;
|
|
84
|
+
|
|
85
|
+
for (const char of command) {
|
|
86
|
+
if (prevWasEscape) {
|
|
87
|
+
prevWasEscape = false;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (char === '\\') {
|
|
92
|
+
prevWasEscape = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (inQuote) {
|
|
97
|
+
if (char === inQuote) {
|
|
98
|
+
inQuote = null;
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (char === '"' || char === "'") {
|
|
104
|
+
inQuote = char;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (/[|&;<>`\n\r$]/.test(char)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (char === '(' || char === ')') {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
};
|
|
119
|
+
|
|
82
120
|
const validateCommand = (command, tokens) => {
|
|
83
121
|
if (isShellDisabled()) {
|
|
84
122
|
return 'Shell execution is disabled (DEVCTX_SHELL_DISABLED=true)';
|
|
@@ -92,8 +130,8 @@ const validateCommand = (command, tokens) => {
|
|
|
92
130
|
return `Command too long (max ${MAX_COMMAND_LENGTH} chars)`;
|
|
93
131
|
}
|
|
94
132
|
|
|
95
|
-
if (
|
|
96
|
-
return 'Shell operators are not allowed (|, &, ;, <, >, `, $, (, ))';
|
|
133
|
+
if (hasUnquotedShellOperators(command)) {
|
|
134
|
+
return 'Shell operators are not allowed outside quotes (|, &, ;, <, >, `, $, (, ))';
|
|
97
135
|
}
|
|
98
136
|
|
|
99
137
|
for (const pattern of dangerousPatterns) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const MINIMUM_NODE_VERSION = 22;
|
|
2
|
+
const MINIMUM_NODE_VERSION_REASON = 'node:sqlite and node:test require Node 22+';
|
|
3
|
+
|
|
4
|
+
const parseNodeVersion = (versionString) => {
|
|
5
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(versionString);
|
|
6
|
+
if (!match) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
major: Number(match[1]),
|
|
12
|
+
minor: Number(match[2]),
|
|
13
|
+
patch: Number(match[3]),
|
|
14
|
+
raw: versionString,
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const checkNodeVersion = (versionString = process.version) => {
|
|
19
|
+
const current = parseNodeVersion(versionString);
|
|
20
|
+
|
|
21
|
+
if (!current) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
current: versionString,
|
|
25
|
+
minimum: MINIMUM_NODE_VERSION,
|
|
26
|
+
message: `Unable to parse Node version: ${versionString}`,
|
|
27
|
+
reason: MINIMUM_NODE_VERSION_REASON,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ok = current.major >= MINIMUM_NODE_VERSION;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
ok,
|
|
35
|
+
current: current.raw,
|
|
36
|
+
minimum: MINIMUM_NODE_VERSION,
|
|
37
|
+
message: ok
|
|
38
|
+
? `Node ${current.raw} meets minimum requirement (${MINIMUM_NODE_VERSION}+)`
|
|
39
|
+
: `Node ${current.raw} is below minimum requirement (${MINIMUM_NODE_VERSION}+). ${MINIMUM_NODE_VERSION_REASON}`,
|
|
40
|
+
reason: ok ? null : MINIMUM_NODE_VERSION_REASON,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const assertNodeVersion = (versionString = process.version) => {
|
|
45
|
+
const check = checkNodeVersion(versionString);
|
|
46
|
+
|
|
47
|
+
if (!check.ok) {
|
|
48
|
+
throw new Error(check.message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return check;
|
|
52
|
+
};
|
|
@@ -27,14 +27,14 @@ const readEnvValue = (...names) => {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
const defaultDevctxRoot = path.resolve(currentDir, '..', '..');
|
|
30
|
-
const defaultProjectRoot = path.resolve(
|
|
30
|
+
const defaultProjectRoot = path.resolve(process.cwd());
|
|
31
31
|
const projectRootArg = readArgValue('--project-root');
|
|
32
32
|
const projectRootEnv = readEnvValue('DEVCTX_PROJECT_ROOT', 'MCP_PROJECT_ROOT');
|
|
33
33
|
const rawProjectRoot = projectRootArg ?? projectRootEnv ?? defaultProjectRoot;
|
|
34
34
|
|
|
35
35
|
export const devctxRoot = defaultDevctxRoot;
|
|
36
36
|
export let projectRoot = path.resolve(rawProjectRoot);
|
|
37
|
-
export const projectRootSource = projectRootArg ? 'argv' : projectRootEnv ? 'env' : '
|
|
37
|
+
export const projectRootSource = projectRootArg ? 'argv' : projectRootEnv ? 'env' : 'cwd';
|
|
38
38
|
|
|
39
39
|
export const setProjectRoot = (newRoot) => {
|
|
40
40
|
projectRoot = path.resolve(newRoot);
|