@imdeadpool/guardex 7.0.21 → 7.0.23
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/README.md +39 -29
- package/package.json +5 -1
- package/src/cli/args.js +89 -0
- package/src/cli/main.js +645 -2873
- package/src/context.js +195 -31
- package/src/core/stdin.js +52 -0
- package/src/core/versions.js +33 -0
- package/src/doctor/index.js +1071 -0
- package/src/finish/index.js +456 -358
- package/src/git/index.js +604 -1
- package/src/hooks/index.js +64 -0
- package/src/output/index.js +72 -5
- package/src/report/session-severity.js +213 -0
- package/src/sandbox/index.js +301 -52
- package/src/scaffold/index.js +627 -0
- package/src/toolchain/index.js +559 -179
- package/templates/AGENTS.multiagent-safety.md +25 -0
- package/templates/scripts/agent-branch-finish.sh +86 -6
- package/templates/scripts/agent-session-state.js +62 -1
- package/templates/scripts/agent-worktree-prune.sh +15 -1
- package/templates/scripts/codex-agent.sh +38 -0
- package/templates/scripts/install-vscode-active-agents-extension.js +38 -11
- package/templates/scripts/openspec/init-plan-workspace.sh +34 -3
- package/templates/vscode/guardex-active-agents/README.md +9 -6
- package/templates/vscode/guardex-active-agents/extension.js +805 -77
- package/templates/vscode/guardex-active-agents/icon.png +0 -0
- package/templates/vscode/guardex-active-agents/package.json +15 -3
- package/templates/vscode/guardex-active-agents/session-schema.js +311 -4
package/src/output/index.js
CHANGED
|
@@ -87,6 +87,62 @@ function detectAutoFinishSummaryStatus(summary) {
|
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
const AUTO_FINISH_DETAIL_PRIORITY = new Map([
|
|
91
|
+
['fail', 0],
|
|
92
|
+
['pending', 1],
|
|
93
|
+
['done', 2],
|
|
94
|
+
['skip', 3],
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function autoFinishDetailPriority(status) {
|
|
98
|
+
return AUTO_FINISH_DETAIL_PRIORITY.get(status) ?? AUTO_FINISH_DETAIL_PRIORITY.size;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sortAutoFinishDetailEntries(details) {
|
|
102
|
+
return details
|
|
103
|
+
.map((detail, index) => {
|
|
104
|
+
const status = detectAutoFinishDetailStatus(detail) || 'other';
|
|
105
|
+
return {
|
|
106
|
+
detail,
|
|
107
|
+
index,
|
|
108
|
+
status,
|
|
109
|
+
priority: autoFinishDetailPriority(status),
|
|
110
|
+
};
|
|
111
|
+
})
|
|
112
|
+
.sort((left, right) => (left.priority - right.priority) || (left.index - right.index));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function summarizeHiddenAutoFinishDetails(hiddenEntries) {
|
|
116
|
+
const counts = new Map();
|
|
117
|
+
for (const entry of hiddenEntries) {
|
|
118
|
+
counts.set(entry.status, (counts.get(entry.status) || 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const segments = ['fail', 'pending', 'done', 'skip', 'other']
|
|
122
|
+
.map((status) => {
|
|
123
|
+
const count = counts.get(status) || 0;
|
|
124
|
+
return count > 0 ? `${status}=${count}` : '';
|
|
125
|
+
})
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
|
|
128
|
+
let status = null;
|
|
129
|
+
if ((counts.get('fail') || 0) > 0) {
|
|
130
|
+
status = 'fail';
|
|
131
|
+
} else if ((counts.get('pending') || 0) > 0) {
|
|
132
|
+
status = 'pending';
|
|
133
|
+
} else if ((counts.get('done') || 0) > 0) {
|
|
134
|
+
status = 'done';
|
|
135
|
+
} else if ((counts.get('skip') || 0) > 0) {
|
|
136
|
+
status = 'skip';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
status,
|
|
141
|
+
message: `… ${hiddenEntries.length} more branch result(s) hidden: ${segments.join(', ')}. ` +
|
|
142
|
+
'Re-run with --verbose-auto-finish for full details.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
90
146
|
function statusDot(status) {
|
|
91
147
|
if (status === 'active') {
|
|
92
148
|
return colorize('●', '32');
|
|
@@ -281,7 +337,14 @@ function detectRecoverableAutoFinishConflict(message) {
|
|
|
281
337
|
if (/rebase --continue/i.test(text) && /rebase --abort/i.test(text)) {
|
|
282
338
|
return {
|
|
283
339
|
rawLabel: 'auto-finish requires manual rebase.',
|
|
284
|
-
summary: 'manual rebase required
|
|
340
|
+
summary: 'manual rebase required on the branch before auto-finish can continue',
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (/Reattach '.+' in a regular worktree, then rebase it onto origin\/.+ manually\./i.test(text)) {
|
|
345
|
+
return {
|
|
346
|
+
rawLabel: 'auto-finish requires manual rebase.',
|
|
347
|
+
summary: 'manual rebase required on the branch before auto-finish can continue',
|
|
285
348
|
};
|
|
286
349
|
}
|
|
287
350
|
|
|
@@ -353,15 +416,19 @@ function printAutoFinishSummary(summary, options = {}) {
|
|
|
353
416
|
detectAutoFinishSummaryStatus(summary),
|
|
354
417
|
),
|
|
355
418
|
);
|
|
356
|
-
const
|
|
419
|
+
const sortedDetailEntries = verbose ? [] : sortAutoFinishDetailEntries(details);
|
|
420
|
+
const visibleDetails = verbose
|
|
421
|
+
? details
|
|
422
|
+
: sortedDetailEntries.slice(0, detailLimit).map((entry) => summarizeAutoFinishDetail(entry.detail));
|
|
357
423
|
for (const detail of visibleDetails) {
|
|
358
424
|
console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ${detail}`, detectAutoFinishDetailStatus(detail)));
|
|
359
425
|
}
|
|
360
|
-
if (!verbose &&
|
|
426
|
+
if (!verbose && sortedDetailEntries.length > visibleDetails.length) {
|
|
427
|
+
const hiddenSummary = summarizeHiddenAutoFinishDetails(sortedDetailEntries.slice(visibleDetails.length));
|
|
361
428
|
console.log(
|
|
362
429
|
colorizeDoctorOutput(
|
|
363
|
-
`[${TOOL_NAME}]
|
|
364
|
-
'warn',
|
|
430
|
+
`[${TOOL_NAME}] ${hiddenSummary.message}`,
|
|
431
|
+
hiddenSummary.status || 'warn',
|
|
365
432
|
),
|
|
366
433
|
);
|
|
367
434
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const TASK_SIZE_UPPER_BOUNDS = {
|
|
2
|
+
'narrow-patch': 1_800_000,
|
|
3
|
+
'medium-change': 4_000_000,
|
|
4
|
+
'large-change': 8_000_000,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const TASK_SIZE_VALUES = new Set(Object.keys(TASK_SIZE_UPPER_BOUNDS));
|
|
8
|
+
const FRAGMENTATION_PRESET_SCORES = {
|
|
9
|
+
clean: 0,
|
|
10
|
+
'few-extra-checks': 5,
|
|
11
|
+
'repeated-follow-ups': 10,
|
|
12
|
+
looping: 18,
|
|
13
|
+
'dominant-loop': 25,
|
|
14
|
+
};
|
|
15
|
+
const FINISH_PATH_PRESET_SCORES = {
|
|
16
|
+
'clear-early': 0,
|
|
17
|
+
'minor-hesitation': 5,
|
|
18
|
+
'late-decision': 10,
|
|
19
|
+
reopening: 15,
|
|
20
|
+
};
|
|
21
|
+
const POST_PROOF_PRESET_SCORES = {
|
|
22
|
+
'stops-soon': 0,
|
|
23
|
+
'small-tail': 5,
|
|
24
|
+
'notable-tail': 10,
|
|
25
|
+
'heavy-tail': 15,
|
|
26
|
+
};
|
|
27
|
+
const DRIVER_TIE_BREAK = ['fragmentation', 'writeStdin', 'finishPath', 'postProof', 'cost'];
|
|
28
|
+
const DRIVER_LABELS = {
|
|
29
|
+
cost: 'cost vs expected scope',
|
|
30
|
+
fragmentation: 'turn fragmentation',
|
|
31
|
+
writeStdin: 'write_stdin churn',
|
|
32
|
+
finishPath: 'finish-path discipline',
|
|
33
|
+
postProof: 'post-proof drift',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function parseRequiredPositiveInteger(name, rawValue, { allowZero = true } = {}) {
|
|
37
|
+
const parsed = Number.parseInt(String(rawValue || ''), 10);
|
|
38
|
+
if (!Number.isFinite(parsed) || (!allowZero && parsed <= 0) || (allowZero && parsed < 0)) {
|
|
39
|
+
throw new Error(`${name} requires ${allowZero ? 'a non-negative integer' : 'a positive integer'} value`);
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseBooleanFlag(name, rawValue) {
|
|
45
|
+
const normalized = String(rawValue || '').trim().toLowerCase();
|
|
46
|
+
if (normalized === 'yes' || normalized === 'true' || normalized === '1') {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (normalized === 'no' || normalized === 'false' || normalized === '0') {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`${name} requires yes/no (or true/false, 1/0)`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clampScore(value, min, max) {
|
|
56
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseTaskSize(rawTaskSize) {
|
|
60
|
+
const normalized = String(rawTaskSize || '').trim();
|
|
61
|
+
if (!TASK_SIZE_VALUES.has(normalized)) {
|
|
62
|
+
throw new Error(`--task-size must be one of: ${Array.from(TASK_SIZE_VALUES).join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveExpectedUpperBound(taskSize, rawExpectedBound) {
|
|
68
|
+
if (rawExpectedBound) {
|
|
69
|
+
return parseRequiredPositiveInteger('--expected-bound', rawExpectedBound, { allowZero: false });
|
|
70
|
+
}
|
|
71
|
+
return TASK_SIZE_UPPER_BOUNDS[taskSize];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scoreCost(tokens, expectedUpperBound) {
|
|
75
|
+
const ratio = tokens / expectedUpperBound;
|
|
76
|
+
if (ratio <= 1.0) return 0;
|
|
77
|
+
if (ratio <= 1.5) return 5;
|
|
78
|
+
if (ratio <= 2.5) return 10;
|
|
79
|
+
if (ratio <= 4.0) return 18;
|
|
80
|
+
if (ratio <= 6.0) return 24;
|
|
81
|
+
return 30;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scoreFragmentation(execCount, override) {
|
|
85
|
+
if (override) {
|
|
86
|
+
if (Object.prototype.hasOwnProperty.call(FRAGMENTATION_PRESET_SCORES, override)) {
|
|
87
|
+
return FRAGMENTATION_PRESET_SCORES[override];
|
|
88
|
+
}
|
|
89
|
+
return clampScore(parseRequiredPositiveInteger('--fragmentation', override), 0, 25);
|
|
90
|
+
}
|
|
91
|
+
if (execCount <= 4) return 0;
|
|
92
|
+
if (execCount <= 8) return 5;
|
|
93
|
+
if (execCount <= 16) return 10;
|
|
94
|
+
if (execCount <= 28) return 18;
|
|
95
|
+
return 25;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scoreWriteStdin(writeStdinCount) {
|
|
99
|
+
if (writeStdinCount <= 0) return 0;
|
|
100
|
+
if (writeStdinCount <= 3) return 5;
|
|
101
|
+
if (writeStdinCount <= 6) return 10;
|
|
102
|
+
return 15;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scoreFinishPath(completionBeforeTail, override) {
|
|
106
|
+
if (override) {
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(FINISH_PATH_PRESET_SCORES, override)) {
|
|
108
|
+
return FINISH_PATH_PRESET_SCORES[override];
|
|
109
|
+
}
|
|
110
|
+
return clampScore(parseRequiredPositiveInteger('--finish-path', override), 0, 15);
|
|
111
|
+
}
|
|
112
|
+
return completionBeforeTail ? 0 : 5;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scorePostProof(completionBeforeTail, override) {
|
|
116
|
+
if (override) {
|
|
117
|
+
if (Object.prototype.hasOwnProperty.call(POST_PROOF_PRESET_SCORES, override)) {
|
|
118
|
+
return POST_PROOF_PRESET_SCORES[override];
|
|
119
|
+
}
|
|
120
|
+
return clampScore(parseRequiredPositiveInteger('--post-proof', override), 0, 15);
|
|
121
|
+
}
|
|
122
|
+
return completionBeforeTail ? 0 : 10;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function labelForTotal(total) {
|
|
126
|
+
if (total <= 15) return 'Healthy';
|
|
127
|
+
if (total <= 30) return 'Mildly fragmented';
|
|
128
|
+
if (total <= 50) return 'Inefficient';
|
|
129
|
+
if (total <= 75) return 'Runaway';
|
|
130
|
+
return 'Catastrophic';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildSessionSeverityReport(options) {
|
|
134
|
+
const taskSize = parseTaskSize(options.taskSize);
|
|
135
|
+
const tokens = parseRequiredPositiveInteger('--tokens', options.tokens);
|
|
136
|
+
const execCount = parseRequiredPositiveInteger('--exec-count', options.execCount);
|
|
137
|
+
const writeStdinCount = parseRequiredPositiveInteger('--write-stdin-count', options.writeStdinCount);
|
|
138
|
+
const completionBeforeTail = parseBooleanFlag('--completion-before-tail', options.completionBeforeTail);
|
|
139
|
+
const expectedUpperBound = resolveExpectedUpperBound(taskSize, options.expectedBound);
|
|
140
|
+
const costRatio = tokens / expectedUpperBound;
|
|
141
|
+
const scores = {
|
|
142
|
+
cost: scoreCost(tokens, expectedUpperBound),
|
|
143
|
+
fragmentation: scoreFragmentation(execCount, options.fragmentation),
|
|
144
|
+
writeStdin: scoreWriteStdin(writeStdinCount),
|
|
145
|
+
finishPath: scoreFinishPath(completionBeforeTail, options.finishPath),
|
|
146
|
+
postProof: scorePostProof(completionBeforeTail, options.postProof),
|
|
147
|
+
};
|
|
148
|
+
const total = scores.cost + scores.fragmentation + scores.writeStdin + scores.finishPath + scores.postProof;
|
|
149
|
+
const label = labelForTotal(total);
|
|
150
|
+
const rankedDimensions = Object.entries(scores)
|
|
151
|
+
.map(([key, score]) => ({ key, score, label: DRIVER_LABELS[key] }))
|
|
152
|
+
.filter((entry) => entry.score > 0)
|
|
153
|
+
.sort((left, right) => {
|
|
154
|
+
if (right.score !== left.score) {
|
|
155
|
+
return right.score - left.score;
|
|
156
|
+
}
|
|
157
|
+
return DRIVER_TIE_BREAK.indexOf(left.key) - DRIVER_TIE_BREAK.indexOf(right.key);
|
|
158
|
+
});
|
|
159
|
+
const primaryDriver = rankedDimensions[0] ? rankedDimensions[0].label : 'none';
|
|
160
|
+
const secondaries = rankedDimensions.slice(1).map((entry) => entry.label);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
taskSize,
|
|
164
|
+
expectedUpperBound,
|
|
165
|
+
tokens,
|
|
166
|
+
execCount,
|
|
167
|
+
writeStdinCount,
|
|
168
|
+
completionBeforeTail,
|
|
169
|
+
costRatio,
|
|
170
|
+
scores: {
|
|
171
|
+
...scores,
|
|
172
|
+
total,
|
|
173
|
+
},
|
|
174
|
+
label,
|
|
175
|
+
primaryDriver,
|
|
176
|
+
secondaries,
|
|
177
|
+
outputLine: `Score ${total}/100 — ${label}. Primary: ${primaryDriver}. Secondaries: ${
|
|
178
|
+
secondaries.length > 0 ? secondaries.join(', ') : 'none'
|
|
179
|
+
}.`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderSessionSeverityReport(report) {
|
|
184
|
+
return [
|
|
185
|
+
report.outputLine,
|
|
186
|
+
'',
|
|
187
|
+
`Task size: ${report.taskSize}`,
|
|
188
|
+
`Expected upper bound: ${report.expectedUpperBound}`,
|
|
189
|
+
`Actual tokens: ${report.tokens}`,
|
|
190
|
+
`Exec count: ${report.execCount}`,
|
|
191
|
+
`write_stdin count: ${report.writeStdinCount}`,
|
|
192
|
+
`Completion before tail churn: ${report.completionBeforeTail ? 'yes' : 'no'}`,
|
|
193
|
+
`Cost ratio: ${report.costRatio.toFixed(2)}x`,
|
|
194
|
+
'',
|
|
195
|
+
`A. Cost vs expected scope: ${report.scores.cost}`,
|
|
196
|
+
`B. Turn fragmentation: ${report.scores.fragmentation}`,
|
|
197
|
+
`C. write_stdin churn: ${report.scores.writeStdin}`,
|
|
198
|
+
`D. Finish-path discipline: ${report.scores.finishPath}`,
|
|
199
|
+
`E. Post-proof drift: ${report.scores.postProof}`,
|
|
200
|
+
'',
|
|
201
|
+
`Total: ${report.scores.total}`,
|
|
202
|
+
`Label: ${report.label}`,
|
|
203
|
+
`Primary driver: ${report.primaryDriver}`,
|
|
204
|
+
`Secondary drivers: ${report.secondaries.length > 0 ? report.secondaries.join(', ') : 'none'}`,
|
|
205
|
+
].join('\n');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
TASK_SIZE_UPPER_BOUNDS,
|
|
210
|
+
buildSessionSeverityReport,
|
|
211
|
+
renderSessionSeverityReport,
|
|
212
|
+
labelForTotal,
|
|
213
|
+
};
|
package/src/sandbox/index.js
CHANGED
|
@@ -1,68 +1,317 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
const {
|
|
2
|
+
fs,
|
|
3
|
+
path,
|
|
4
|
+
SHORT_TOOL_NAME,
|
|
5
|
+
LOCK_FILE_RELATIVE,
|
|
6
|
+
defaultAgentWorktreeRelativeDir,
|
|
7
|
+
} = require('../context');
|
|
8
|
+
const { run, runPackageAsset } = require('../core/runtime');
|
|
9
|
+
const {
|
|
10
|
+
resolveRepoRoot,
|
|
11
|
+
currentBranchName,
|
|
12
|
+
readProtectedBranches,
|
|
13
|
+
gitRefExists,
|
|
14
|
+
ensureRepoBranch,
|
|
15
|
+
} = require('../git');
|
|
16
|
+
|
|
17
|
+
function hasGuardexBootstrapFiles(repoRoot) {
|
|
18
|
+
const required = [
|
|
19
|
+
'AGENTS.md',
|
|
20
|
+
'.githooks/pre-commit',
|
|
21
|
+
'.githooks/pre-push',
|
|
22
|
+
LOCK_FILE_RELATIVE,
|
|
23
|
+
];
|
|
24
|
+
return required.every((relativePath) => require('../context').fs.existsSync(path.join(repoRoot, relativePath)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
|
|
28
|
+
if (options.dryRun || options.allowProtectedBaseWrite) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const repoRoot = resolveRepoRoot(options.target);
|
|
33
|
+
if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const branch = currentBranchName(repoRoot);
|
|
38
|
+
if (branch !== 'main') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const protectedBranches = readProtectedBranches(repoRoot);
|
|
43
|
+
if (!protectedBranches.includes(branch)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
repoRoot,
|
|
49
|
+
branch,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertProtectedMainWriteAllowed(options, commandName) {
|
|
54
|
+
const blocked = protectedBaseWriteBlock(options);
|
|
55
|
+
if (!blocked) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${commandName} blocked on protected branch '${blocked.branch}' in an initialized repo.\n` +
|
|
61
|
+
`Keep local '${blocked.branch}' pull-only: start an agent branch/worktree first:\n` +
|
|
62
|
+
` gx branch start "<task>" "codex"\n` +
|
|
63
|
+
`Override once only when intentional: --allow-protected-base-write`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractAgentBranchStartMetadata(output) {
|
|
68
|
+
const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
|
|
69
|
+
const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
|
|
70
|
+
return {
|
|
71
|
+
branch: branchMatch ? branchMatch[1].trim() : '',
|
|
72
|
+
worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
|
|
77
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
78
|
+
const relativeTarget = path.relative(repoRoot, resolvedTarget);
|
|
79
|
+
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
|
80
|
+
throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
|
|
81
|
+
}
|
|
82
|
+
if (!relativeTarget || relativeTarget === '.') {
|
|
83
|
+
return worktreePath;
|
|
84
|
+
}
|
|
85
|
+
return path.join(worktreePath, relativeTarget);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function appendManagedForceArgs(args, options) {
|
|
89
|
+
if (!options.force) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
args.push('--force');
|
|
93
|
+
for (const managedPath of options.forceManagedPaths || []) {
|
|
94
|
+
args.push(managedPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildSandboxSetupArgs(options, sandboxTarget) {
|
|
99
|
+
const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
|
|
100
|
+
appendManagedForceArgs(args, options);
|
|
101
|
+
if (options.skipAgents) args.push('--skip-agents');
|
|
102
|
+
if (options.skipPackageJson) args.push('--skip-package-json');
|
|
103
|
+
if (options.skipGitignore) args.push('--no-gitignore');
|
|
104
|
+
if (options.dryRun) args.push('--dry-run');
|
|
105
|
+
return args;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isSpawnFailure(result) {
|
|
109
|
+
return Boolean(result?.error) && typeof result?.status !== 'number';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function protectedBaseSandboxBranchPrefix() {
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const stamp = [
|
|
115
|
+
now.getUTCFullYear(),
|
|
116
|
+
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
|
117
|
+
String(now.getUTCDate()).padStart(2, '0'),
|
|
118
|
+
].join('') + '-' + [
|
|
119
|
+
String(now.getUTCHours()).padStart(2, '0'),
|
|
120
|
+
String(now.getUTCMinutes()).padStart(2, '0'),
|
|
121
|
+
String(now.getUTCSeconds()).padStart(2, '0'),
|
|
122
|
+
].join('');
|
|
123
|
+
return `agent/gx/${stamp}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
|
|
127
|
+
return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
|
|
131
|
+
run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
|
|
132
|
+
if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
|
|
133
|
+
return `origin/${baseBranch}`;
|
|
134
|
+
}
|
|
135
|
+
if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
|
|
136
|
+
return baseBranch;
|
|
137
|
+
}
|
|
138
|
+
if (currentBranchName(repoRoot) === baseBranch) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
|
|
145
|
+
const branchPrefix = protectedBaseSandboxBranchPrefix();
|
|
146
|
+
let selectedBranch = '';
|
|
147
|
+
let selectedWorktreePath = '';
|
|
148
|
+
|
|
149
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
150
|
+
const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
|
|
151
|
+
const candidateBranch = `${branchPrefix}-${suffix}`;
|
|
152
|
+
const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
|
|
153
|
+
if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
|
|
154
|
+
continue;
|
|
15
155
|
}
|
|
156
|
+
if (fs.existsSync(candidateWorktreePath)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
selectedBranch = candidateBranch;
|
|
160
|
+
selectedWorktreePath = candidateWorktreePath;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
16
163
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`Keep local '${blocked.branch}' pull-only: start an agent branch/worktree first:\n` +
|
|
20
|
-
` gx branch start "<task>" "codex"\n` +
|
|
21
|
-
`Override once only when intentional: --allow-protected-base-write`,
|
|
22
|
-
);
|
|
164
|
+
if (!selectedBranch || !selectedWorktreePath) {
|
|
165
|
+
throw new Error('Unable to allocate unique sandbox branch/worktree');
|
|
23
166
|
}
|
|
24
167
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
168
|
+
fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
|
|
169
|
+
const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
|
|
170
|
+
const addArgs = startRef
|
|
171
|
+
? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
|
|
172
|
+
: ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
|
|
173
|
+
const addResult = run('git', addArgs);
|
|
174
|
+
if (isSpawnFailure(addResult)) {
|
|
175
|
+
throw addResult.error;
|
|
176
|
+
}
|
|
177
|
+
if (addResult.status !== 0) {
|
|
178
|
+
throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
|
|
179
|
+
}
|
|
30
180
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
181
|
+
if (!startRef) {
|
|
182
|
+
const renameResult = run(
|
|
183
|
+
'git',
|
|
184
|
+
['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
|
|
185
|
+
{ timeout: 20_000 },
|
|
186
|
+
);
|
|
187
|
+
if (isSpawnFailure(renameResult)) {
|
|
188
|
+
throw renameResult.error;
|
|
189
|
+
}
|
|
190
|
+
if (renameResult.status !== 0) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
(renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
|
|
35
193
|
);
|
|
36
|
-
if (!options.dryRun) {
|
|
37
|
-
parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
|
|
38
|
-
}
|
|
39
194
|
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
metadata: {
|
|
199
|
+
branch: selectedBranch,
|
|
200
|
+
worktreePath: selectedWorktreePath,
|
|
201
|
+
},
|
|
202
|
+
stdout:
|
|
203
|
+
`[agent-branch-start] Created branch: ${selectedBranch}\n` +
|
|
204
|
+
`[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
|
|
205
|
+
stderr: addResult.stderr || '',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
|
|
210
|
+
if (sandboxSuffix === 'gx-doctor') {
|
|
211
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const startResult = runPackageAsset('branchStart', [
|
|
215
|
+
'--task',
|
|
216
|
+
taskName,
|
|
217
|
+
'--agent',
|
|
218
|
+
SHORT_TOOL_NAME,
|
|
219
|
+
'--base',
|
|
220
|
+
blocked.branch,
|
|
221
|
+
], { cwd: blocked.repoRoot });
|
|
222
|
+
if (isSpawnFailure(startResult)) {
|
|
223
|
+
throw startResult.error;
|
|
224
|
+
}
|
|
225
|
+
if (startResult.status !== 0) {
|
|
226
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
227
|
+
}
|
|
40
228
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
229
|
+
const metadata = extractAgentBranchStartMetadata(startResult.stdout);
|
|
230
|
+
const currentBranch = currentBranchName(blocked.repoRoot);
|
|
231
|
+
const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
|
|
232
|
+
const repoRootPath = path.resolve(blocked.repoRoot);
|
|
233
|
+
const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
|
|
234
|
+
const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
|
|
235
|
+
|
|
236
|
+
if (!hasSafeWorktree || branchChanged) {
|
|
237
|
+
const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
|
|
238
|
+
if (!restoreResult.ok) {
|
|
239
|
+
const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
|
|
240
|
+
throw new Error(
|
|
241
|
+
`sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
|
|
242
|
+
(detail ? `\n${detail}` : ''),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
58
246
|
}
|
|
59
247
|
|
|
60
248
|
return {
|
|
61
|
-
|
|
62
|
-
|
|
249
|
+
metadata,
|
|
250
|
+
stdout: startResult.stdout || '',
|
|
251
|
+
stderr: startResult.stderr || '',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function cleanupProtectedBaseSandbox(repoRoot, metadata) {
|
|
256
|
+
const result = {
|
|
257
|
+
worktree: 'skipped',
|
|
258
|
+
branch: 'skipped',
|
|
259
|
+
note: 'missing sandbox metadata',
|
|
63
260
|
};
|
|
261
|
+
|
|
262
|
+
if (!metadata?.worktreePath || !metadata?.branch) {
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (fs.existsSync(metadata.worktreePath)) {
|
|
267
|
+
const removeResult = run(
|
|
268
|
+
'git',
|
|
269
|
+
['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
|
|
270
|
+
{ timeout: 30_000 },
|
|
271
|
+
);
|
|
272
|
+
if (isSpawnFailure(removeResult)) {
|
|
273
|
+
throw removeResult.error;
|
|
274
|
+
}
|
|
275
|
+
if (removeResult.status !== 0) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
(removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
result.worktree = 'removed';
|
|
281
|
+
} else {
|
|
282
|
+
result.worktree = 'missing';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
|
|
286
|
+
const branchDeleteResult = run(
|
|
287
|
+
'git',
|
|
288
|
+
['-C', repoRoot, 'branch', '-D', metadata.branch],
|
|
289
|
+
{ timeout: 20_000 },
|
|
290
|
+
);
|
|
291
|
+
if (isSpawnFailure(branchDeleteResult)) {
|
|
292
|
+
throw branchDeleteResult.error;
|
|
293
|
+
}
|
|
294
|
+
if (branchDeleteResult.status !== 0) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
(branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
result.branch = 'deleted';
|
|
300
|
+
} else {
|
|
301
|
+
result.branch = 'missing';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
result.note = 'sandbox worktree pruned';
|
|
305
|
+
return result;
|
|
64
306
|
}
|
|
65
307
|
|
|
66
308
|
module.exports = {
|
|
67
|
-
|
|
309
|
+
protectedBaseWriteBlock,
|
|
310
|
+
assertProtectedMainWriteAllowed,
|
|
311
|
+
extractAgentBranchStartMetadata,
|
|
312
|
+
resolveSandboxTarget,
|
|
313
|
+
buildSandboxSetupArgs,
|
|
314
|
+
isSpawnFailure,
|
|
315
|
+
startProtectedBaseSandbox,
|
|
316
|
+
cleanupProtectedBaseSandbox,
|
|
68
317
|
};
|