@weldr/runr 0.3.1 → 0.7.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/CHANGELOG.md +150 -1
- package/README.md +124 -111
- package/dist/audit/classifier.js +331 -0
- package/dist/cli.js +593 -282
- package/dist/commands/audit.js +259 -0
- package/dist/commands/bundle.js +180 -0
- package/dist/commands/continue.js +276 -0
- package/dist/commands/doctor.js +430 -45
- package/dist/commands/hooks.js +352 -0
- package/dist/commands/init.js +368 -8
- package/dist/commands/intervene.js +109 -0
- package/dist/commands/journal.js +167 -0
- package/dist/commands/meta.js +245 -0
- package/dist/commands/mode.js +157 -0
- package/dist/commands/orchestrate.js +29 -0
- package/dist/commands/packs.js +47 -0
- package/dist/commands/preflight.js +8 -5
- package/dist/commands/resume.js +421 -3
- package/dist/commands/run.js +63 -4
- package/dist/commands/status.js +47 -0
- package/dist/commands/submit.js +374 -0
- package/dist/config/schema.js +61 -1
- package/dist/diagnosis/analyzer.js +86 -1
- package/dist/diagnosis/formatter.js +3 -0
- package/dist/diagnosis/index.js +1 -0
- package/dist/diagnosis/stop-explainer.js +267 -0
- package/dist/diagnostics/stop-explainer.js +267 -0
- package/dist/guards/checkpoint.js +119 -0
- package/dist/journal/builder.js +497 -0
- package/dist/journal/redactor.js +68 -0
- package/dist/journal/renderer.js +220 -0
- package/dist/journal/types.js +7 -0
- package/dist/orchestrator/artifacts.js +17 -2
- package/dist/orchestrator/receipt.js +304 -0
- package/dist/output/stop-footer.js +185 -0
- package/dist/packs/actions.js +176 -0
- package/dist/packs/loader.js +200 -0
- package/dist/packs/renderer.js +46 -0
- package/dist/receipt/intervention.js +465 -0
- package/dist/receipt/writer.js +296 -0
- package/dist/redaction/redactor.js +95 -0
- package/dist/repo/context.js +147 -20
- package/dist/review/check-parser.js +211 -0
- package/dist/store/checkpoint-metadata.js +111 -0
- package/dist/store/run-store.js +21 -0
- package/dist/supervisor/runner.js +161 -10
- package/dist/tasks/task-metadata.js +74 -1
- package/dist/ux/brain.js +528 -0
- package/dist/ux/render.js +123 -0
- package/dist/ux/safe-commands.js +133 -0
- package/dist/ux/state.js +193 -0
- package/dist/ux/telemetry.js +110 -0
- package/package.json +5 -1
- package/packs/pr/pack.json +50 -0
- package/packs/pr/templates/AGENTS.md.tmpl +120 -0
- package/packs/pr/templates/CLAUDE.md.tmpl +101 -0
- package/packs/pr/templates/bundle.md.tmpl +27 -0
- package/packs/solo/pack.json +82 -0
- package/packs/solo/templates/AGENTS.md.tmpl +80 -0
- package/packs/solo/templates/CLAUDE.md.tmpl +126 -0
- package/packs/solo/templates/bundle.md.tmpl +27 -0
- package/packs/solo/templates/claude-cmd-bundle.md.tmpl +40 -0
- package/packs/solo/templates/claude-cmd-resume.md.tmpl +43 -0
- package/packs/solo/templates/claude-cmd-submit.md.tmpl +51 -0
- package/packs/solo/templates/claude-skill.md.tmpl +96 -0
- package/packs/trunk/pack.json +50 -0
- package/packs/trunk/templates/AGENTS.md.tmpl +87 -0
- package/packs/trunk/templates/CLAUDE.md.tmpl +126 -0
- package/packs/trunk/templates/bundle.md.tmpl +27 -0
- package/dist/commands/__tests__/report.test.js +0 -202
- package/dist/config/__tests__/presets.test.js +0 -104
- package/dist/context/__tests__/artifact.test.js +0 -130
- package/dist/context/__tests__/pack.test.js +0 -191
- package/dist/env/__tests__/fingerprint.test.js +0 -116
- package/dist/orchestrator/__tests__/policy.test.js +0 -185
- package/dist/orchestrator/__tests__/schema-version.test.js +0 -65
- package/dist/supervisor/__tests__/evidence-gate.test.js +0 -111
- package/dist/supervisor/__tests__/ownership.test.js +0 -103
- package/dist/supervisor/__tests__/state-machine.test.js +0 -290
- package/dist/workers/__tests__/claude.test.js +0 -88
- package/dist/workers/__tests__/codex.test.js +0 -81
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { RunStore } from '../store/run-store.js';
|
|
5
|
+
import { loadConfig, resolveConfigPath } from '../config/load.js';
|
|
6
|
+
import { clearActiveState } from './hooks.js';
|
|
7
|
+
/**
|
|
8
|
+
* Check if git object exists locally.
|
|
9
|
+
*/
|
|
10
|
+
async function objectExists(repoPath, sha) {
|
|
11
|
+
try {
|
|
12
|
+
await execa('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: repoPath });
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Check if branch exists.
|
|
21
|
+
*/
|
|
22
|
+
async function branchExists(repoPath, branch) {
|
|
23
|
+
try {
|
|
24
|
+
await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
25
|
+
cwd: repoPath
|
|
26
|
+
});
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if working tree is clean.
|
|
35
|
+
*/
|
|
36
|
+
async function isWorkingTreeClean(repoPath) {
|
|
37
|
+
const result = await execa('git', ['status', '--porcelain'], { cwd: repoPath });
|
|
38
|
+
return result.stdout.trim().length === 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get current branch name.
|
|
42
|
+
*/
|
|
43
|
+
async function getCurrentBranch(repoPath) {
|
|
44
|
+
const result = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath });
|
|
45
|
+
return result.stdout.trim();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get conflicted files (sorted alphabetically).
|
|
49
|
+
*/
|
|
50
|
+
async function getConflictedFiles(repoPath) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await execa('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
53
|
+
cwd: repoPath
|
|
54
|
+
});
|
|
55
|
+
return result.stdout
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map(f => f.trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.sort();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Get files changed in a commit.
|
|
67
|
+
*/
|
|
68
|
+
async function getFilesInCommit(repoPath, sha) {
|
|
69
|
+
try {
|
|
70
|
+
const result = await execa('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', sha], {
|
|
71
|
+
cwd: repoPath
|
|
72
|
+
});
|
|
73
|
+
return result.stdout
|
|
74
|
+
.split('\n')
|
|
75
|
+
.map(f => f.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check if cherry-pick would cause conflict (dry-run).
|
|
84
|
+
*/
|
|
85
|
+
async function wouldCherryPickConflict(repoPath, targetBranch, sha) {
|
|
86
|
+
try {
|
|
87
|
+
// Try cherry-pick with --no-commit to detect conflicts without making changes
|
|
88
|
+
await execa('git', ['cherry-pick', '--no-commit', sha], { cwd: repoPath });
|
|
89
|
+
// If successful, reset and report no conflicts
|
|
90
|
+
await execa('git', ['reset', '--hard', 'HEAD'], { cwd: repoPath });
|
|
91
|
+
return { conflicts: false, files: [] };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Get conflicted files
|
|
95
|
+
const files = await getConflictedFiles(repoPath);
|
|
96
|
+
// Abort and reset
|
|
97
|
+
try {
|
|
98
|
+
await execa('git', ['cherry-pick', '--abort'], { cwd: repoPath });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Reset as fallback
|
|
102
|
+
await execa('git', ['reset', '--hard', 'HEAD'], { cwd: repoPath });
|
|
103
|
+
}
|
|
104
|
+
return { conflicts: true, files };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Emit validation failure event and exit.
|
|
109
|
+
*/
|
|
110
|
+
function failValidation(runStore, runId, reason, details) {
|
|
111
|
+
runStore.appendEvent({
|
|
112
|
+
type: 'submit_validation_failed',
|
|
113
|
+
source: 'submit',
|
|
114
|
+
payload: {
|
|
115
|
+
run_id: runId,
|
|
116
|
+
reason,
|
|
117
|
+
details
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
console.error(`Submit blocked: ${reason}`);
|
|
121
|
+
console.error(details);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Submit command: Cherry-pick verified checkpoint to integration branch.
|
|
126
|
+
*/
|
|
127
|
+
export async function submitCommand(options) {
|
|
128
|
+
const runStore = RunStore.init(options.runId, options.repo);
|
|
129
|
+
// Load run state
|
|
130
|
+
let state;
|
|
131
|
+
try {
|
|
132
|
+
state = runStore.readState();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
console.error(`Error: run state not found for ${options.runId}`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Load config with workflow settings
|
|
140
|
+
const config = loadConfig(resolveConfigPath(options.repo, options.config));
|
|
141
|
+
// Get workflow config (use safe defaults if not configured)
|
|
142
|
+
const workflow = config.workflow ?? {
|
|
143
|
+
profile: 'solo',
|
|
144
|
+
mode: 'flow',
|
|
145
|
+
integration_branch: 'dev',
|
|
146
|
+
require_clean_tree: true,
|
|
147
|
+
require_verification: true,
|
|
148
|
+
submit_strategy: 'cherry-pick'
|
|
149
|
+
};
|
|
150
|
+
// Validation: checkpoint exists in state
|
|
151
|
+
const checkpointSha = state.checkpoint_commit_sha;
|
|
152
|
+
if (!checkpointSha) {
|
|
153
|
+
failValidation(runStore, options.runId, 'no_checkpoint', 'Run has no checkpoint_commit_sha in state.json');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Validation: checkpoint exists as git object
|
|
157
|
+
if (!(await objectExists(options.repo, checkpointSha))) {
|
|
158
|
+
failValidation(runStore, options.runId, 'run_not_ready', `Checkpoint commit not found locally: ${checkpointSha}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Validation: verification evidence (if required)
|
|
162
|
+
if (workflow.require_verification) {
|
|
163
|
+
if (!state.last_verification_evidence) {
|
|
164
|
+
failValidation(runStore, options.runId, 'verification_missing', 'Verification required but last_verification_evidence is missing');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Validation: clean working tree (if required)
|
|
169
|
+
if (workflow.require_clean_tree) {
|
|
170
|
+
if (!(await isWorkingTreeClean(options.repo))) {
|
|
171
|
+
failValidation(runStore, options.runId, 'dirty_tree', 'Working tree is not clean (uncommitted changes present)');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Validation: target branch exists
|
|
176
|
+
const targetBranch = options.to ?? workflow.integration_branch;
|
|
177
|
+
if (!(await branchExists(options.repo, targetBranch))) {
|
|
178
|
+
failValidation(runStore, options.runId, 'target_branch_missing', `Target branch does not exist: ${targetBranch}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// DRY RUN: print plan and check for conflicts (no events, no changes)
|
|
182
|
+
if (options.dryRun) {
|
|
183
|
+
console.log('Submit plan (dry-run):');
|
|
184
|
+
console.log(` run_id: ${options.runId}`);
|
|
185
|
+
console.log(` checkpoint: ${checkpointSha}`);
|
|
186
|
+
console.log(` target: ${targetBranch}`);
|
|
187
|
+
console.log(` strategy: cherry-pick`);
|
|
188
|
+
console.log(` push: ${options.push ? 'yes' : 'no'}`);
|
|
189
|
+
// Check for potential conflicts without making changes
|
|
190
|
+
const startingBranch = await getCurrentBranch(options.repo);
|
|
191
|
+
try {
|
|
192
|
+
await execa('git', ['checkout', targetBranch], { cwd: options.repo });
|
|
193
|
+
const conflictCheck = await wouldCherryPickConflict(options.repo, targetBranch, checkpointSha);
|
|
194
|
+
if (conflictCheck.conflicts) {
|
|
195
|
+
console.log('');
|
|
196
|
+
console.log(' ⚠️ Would conflict:');
|
|
197
|
+
for (const file of conflictCheck.files) {
|
|
198
|
+
console.log(` - ${file}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
console.log('');
|
|
203
|
+
console.log(' ✓ No conflicts detected');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
// Always restore starting branch
|
|
208
|
+
try {
|
|
209
|
+
await execa('git', ['checkout', startingBranch], { cwd: options.repo });
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Best effort
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Capture starting branch for restoration
|
|
218
|
+
const startingBranch = await getCurrentBranch(options.repo);
|
|
219
|
+
try {
|
|
220
|
+
// Checkout target branch
|
|
221
|
+
await execa('git', ['checkout', targetBranch], { cwd: options.repo });
|
|
222
|
+
// Cherry-pick checkpoint
|
|
223
|
+
try {
|
|
224
|
+
await execa('git', ['cherry-pick', checkpointSha], { cwd: options.repo });
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Get conflicted files
|
|
228
|
+
const conflictedFiles = await getConflictedFiles(options.repo);
|
|
229
|
+
// Abort cherry-pick
|
|
230
|
+
try {
|
|
231
|
+
await execa('git', ['cherry-pick', '--abort'], { cwd: options.repo });
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Ignore abort errors
|
|
235
|
+
}
|
|
236
|
+
// Restore starting branch (best-effort)
|
|
237
|
+
try {
|
|
238
|
+
await execa('git', ['checkout', startingBranch], { cwd: options.repo });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Ignore restoration errors
|
|
242
|
+
}
|
|
243
|
+
// Verify tree is actually clean after abort
|
|
244
|
+
const treeClean = await isWorkingTreeClean(options.repo);
|
|
245
|
+
const currentBranch = await getCurrentBranch(options.repo);
|
|
246
|
+
const branchRestored = currentBranch === startingBranch;
|
|
247
|
+
// Log warnings if invariants fail
|
|
248
|
+
if (!branchRestored) {
|
|
249
|
+
console.warn(`Warning: Could not restore branch. Expected ${startingBranch}, got ${currentBranch}`);
|
|
250
|
+
}
|
|
251
|
+
if (!treeClean) {
|
|
252
|
+
console.warn('Warning: Working tree is not clean after conflict cleanup');
|
|
253
|
+
}
|
|
254
|
+
// Emit enhanced conflict event
|
|
255
|
+
runStore.appendEvent({
|
|
256
|
+
type: 'submit_conflict',
|
|
257
|
+
source: 'submit',
|
|
258
|
+
payload: {
|
|
259
|
+
run_id: options.runId,
|
|
260
|
+
checkpoint_sha: checkpointSha,
|
|
261
|
+
target_branch: targetBranch,
|
|
262
|
+
conflicted_files: conflictedFiles,
|
|
263
|
+
recovery_branch: currentBranch,
|
|
264
|
+
recovery_state: treeClean ? 'clean' : 'dirty',
|
|
265
|
+
suggested_commands: [
|
|
266
|
+
`git checkout ${targetBranch}`,
|
|
267
|
+
`git cherry-pick ${checkpointSha}`,
|
|
268
|
+
'# resolve conflicts',
|
|
269
|
+
'git add .',
|
|
270
|
+
'git cherry-pick --continue'
|
|
271
|
+
]
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
// Print conflict message with recovery recipe (exact spec format)
|
|
275
|
+
console.error('');
|
|
276
|
+
console.error('Cherry-pick conflict detected.');
|
|
277
|
+
console.error('');
|
|
278
|
+
console.error(`Checkpoint: ${checkpointSha}`);
|
|
279
|
+
console.error(`Target: ${targetBranch}`);
|
|
280
|
+
console.error('');
|
|
281
|
+
console.error('Conflicted files:');
|
|
282
|
+
for (const file of conflictedFiles) {
|
|
283
|
+
console.error(` - ${file}`);
|
|
284
|
+
}
|
|
285
|
+
console.error('');
|
|
286
|
+
// Recovery state with checkmarks
|
|
287
|
+
console.error('Recovery state:');
|
|
288
|
+
if (branchRestored) {
|
|
289
|
+
console.error(` ✓ Branch restored to ${startingBranch}`);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
console.error(` ✗ Branch NOT restored (currently on ${currentBranch})`);
|
|
293
|
+
}
|
|
294
|
+
if (treeClean) {
|
|
295
|
+
console.error(' ✓ Working tree is clean');
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
console.error(' ✗ Working tree is NOT clean');
|
|
299
|
+
}
|
|
300
|
+
console.error('');
|
|
301
|
+
// Copy-paste ready recovery commands
|
|
302
|
+
console.error('To resolve manually:');
|
|
303
|
+
console.error(` git checkout ${targetBranch}`);
|
|
304
|
+
console.error(` git cherry-pick ${checkpointSha}`);
|
|
305
|
+
console.error(' # resolve conflicts');
|
|
306
|
+
console.error(' git add .');
|
|
307
|
+
console.error(' git cherry-pick --continue');
|
|
308
|
+
// Conditional CHANGELOG tip:
|
|
309
|
+
// - Only show if CHANGELOG.md exists
|
|
310
|
+
// - Only show if checkpoint doesn't already modify CHANGELOG.md
|
|
311
|
+
// - Suppress if CHANGELOG is in conflicted files (already mentioned above)
|
|
312
|
+
const changelogPath = path.join(options.repo, 'CHANGELOG.md');
|
|
313
|
+
const changelogExists = fs.existsSync(changelogPath);
|
|
314
|
+
const checkpointModifiesChangelog = (await getFilesInCommit(options.repo, checkpointSha))
|
|
315
|
+
.some(f => f.toLowerCase().includes('changelog'));
|
|
316
|
+
const changelogInConflicts = conflictedFiles.some(f => f.toLowerCase().includes('changelog'));
|
|
317
|
+
if (changelogExists && !checkpointModifiesChangelog && !changelogInConflicts) {
|
|
318
|
+
console.error('');
|
|
319
|
+
console.error('If this adds new features, consider updating CHANGELOG.md.');
|
|
320
|
+
}
|
|
321
|
+
else if (changelogInConflicts) {
|
|
322
|
+
console.error('');
|
|
323
|
+
console.error('Tip: CHANGELOG.md conflicts are common; consider moving');
|
|
324
|
+
console.error(' changelog updates into a dedicated task.');
|
|
325
|
+
}
|
|
326
|
+
console.error('');
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Push to origin (if requested)
|
|
331
|
+
if (options.push) {
|
|
332
|
+
try {
|
|
333
|
+
await execa('git', ['push', 'origin', targetBranch], { cwd: options.repo });
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
console.error('Warning: cherry-pick succeeded but push failed');
|
|
337
|
+
console.error(String(error));
|
|
338
|
+
// Don't fail - cherry-pick succeeded, which is the primary goal
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Emit success event
|
|
342
|
+
runStore.appendEvent({
|
|
343
|
+
type: 'run_submitted',
|
|
344
|
+
source: 'submit',
|
|
345
|
+
payload: {
|
|
346
|
+
run_id: options.runId,
|
|
347
|
+
checkpoint_sha: checkpointSha,
|
|
348
|
+
target_branch: targetBranch,
|
|
349
|
+
strategy: 'cherry-pick',
|
|
350
|
+
submitted_at: new Date().toISOString()
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// Clear active state sentinel (run is now submitted)
|
|
354
|
+
clearActiveState(options.repo);
|
|
355
|
+
console.log(`✓ Submitted ${checkpointSha} to ${targetBranch}`);
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
// Git error (not conflict)
|
|
359
|
+
failValidation(runStore, options.runId, 'git_error', `Git error: ${String(error)}`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
// Always restore starting branch (best-effort)
|
|
364
|
+
try {
|
|
365
|
+
const currentBranch = await getCurrentBranch(options.repo);
|
|
366
|
+
if (currentBranch !== startingBranch) {
|
|
367
|
+
await execa('git', ['checkout', startingBranch], { cwd: options.repo });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Ignore restoration errors (best-effort)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
package/dist/config/schema.js
CHANGED
|
@@ -149,6 +149,30 @@ const resilienceSchema = z.object({
|
|
|
149
149
|
/** Maximum review rounds per milestone before stopping with review_loop_detected (default: 2) */
|
|
150
150
|
max_review_rounds: z.number().int().positive().default(2)
|
|
151
151
|
});
|
|
152
|
+
// Receipts configuration for output capture and redaction
|
|
153
|
+
const receiptsConfigSchema = z.object({
|
|
154
|
+
/** Enable secret redaction in command output */
|
|
155
|
+
redact: z.boolean().default(true),
|
|
156
|
+
/** How much command output to capture: full, truncated, or metadata_only */
|
|
157
|
+
capture_cmd_output: z.enum(['full', 'truncated', 'metadata_only']).default('truncated'),
|
|
158
|
+
/** Maximum output bytes to store (when truncated) */
|
|
159
|
+
max_output_bytes: z.number().int().positive().default(10240) // 10KB
|
|
160
|
+
});
|
|
161
|
+
// Workflow configuration for integration strategy
|
|
162
|
+
const workflowConfigSchema = z.object({
|
|
163
|
+
/** Workflow profile preset (solo/pr/trunk) */
|
|
164
|
+
profile: z.enum(['solo', 'pr', 'trunk']).default('solo'),
|
|
165
|
+
/** Workflow mode: flow (permissive) or ledger (strict) */
|
|
166
|
+
mode: z.enum(['flow', 'ledger']).default('flow'),
|
|
167
|
+
/** Target branch for integrating verified checkpoints */
|
|
168
|
+
integration_branch: z.string(),
|
|
169
|
+
/** Submit strategy (v1: cherry-pick only) */
|
|
170
|
+
submit_strategy: z.literal('cherry-pick').default('cherry-pick'),
|
|
171
|
+
/** Require clean working tree before submit */
|
|
172
|
+
require_clean_tree: z.boolean().default(true),
|
|
173
|
+
/** Require verification evidence before submit */
|
|
174
|
+
require_verification: z.boolean().default(true)
|
|
175
|
+
});
|
|
152
176
|
export const agentConfigSchema = z.object({
|
|
153
177
|
agent: agentSchema,
|
|
154
178
|
repo: repoSchema.default({}),
|
|
@@ -156,5 +180,41 @@ export const agentConfigSchema = z.object({
|
|
|
156
180
|
verification: verificationSchema,
|
|
157
181
|
workers: workersSchema.default({}),
|
|
158
182
|
phases: phasesSchema.default({}),
|
|
159
|
-
resilience: resilienceSchema.default({})
|
|
183
|
+
resilience: resilienceSchema.default({}),
|
|
184
|
+
workflow: workflowConfigSchema.optional(),
|
|
185
|
+
receipts: receiptsConfigSchema.default({})
|
|
160
186
|
});
|
|
187
|
+
/**
|
|
188
|
+
* Get default workflow config values for a given profile.
|
|
189
|
+
*/
|
|
190
|
+
export function getWorkflowProfileDefaults(profile) {
|
|
191
|
+
switch (profile) {
|
|
192
|
+
case 'solo':
|
|
193
|
+
return {
|
|
194
|
+
profile: 'solo',
|
|
195
|
+
mode: 'flow',
|
|
196
|
+
integration_branch: 'dev',
|
|
197
|
+
submit_strategy: 'cherry-pick',
|
|
198
|
+
require_clean_tree: true,
|
|
199
|
+
require_verification: true
|
|
200
|
+
};
|
|
201
|
+
case 'pr':
|
|
202
|
+
return {
|
|
203
|
+
profile: 'pr',
|
|
204
|
+
mode: 'flow',
|
|
205
|
+
integration_branch: 'main',
|
|
206
|
+
submit_strategy: 'cherry-pick',
|
|
207
|
+
require_clean_tree: true,
|
|
208
|
+
require_verification: false
|
|
209
|
+
};
|
|
210
|
+
case 'trunk':
|
|
211
|
+
return {
|
|
212
|
+
profile: 'trunk',
|
|
213
|
+
mode: 'ledger',
|
|
214
|
+
integration_branch: 'main',
|
|
215
|
+
submit_strategy: 'cherry-pick',
|
|
216
|
+
require_clean_tree: true,
|
|
217
|
+
require_verification: true
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -20,6 +20,8 @@ function categoryToFamily(category) {
|
|
|
20
20
|
return 'guard';
|
|
21
21
|
case 'verification_failure':
|
|
22
22
|
return 'verification';
|
|
23
|
+
case 'review_loop_detected':
|
|
24
|
+
return 'review';
|
|
23
25
|
case 'worker_parse_failure':
|
|
24
26
|
return 'worker';
|
|
25
27
|
case 'stall_timeout':
|
|
@@ -69,7 +71,8 @@ export function diagnoseStop(context) {
|
|
|
69
71
|
diagnoseStallTimeout(context),
|
|
70
72
|
diagnoseMaxTicksReached(context),
|
|
71
73
|
diagnoseTimeBudgetExceeded(context),
|
|
72
|
-
diagnoseGuardViolationDirty(context)
|
|
74
|
+
diagnoseGuardViolationDirty(context),
|
|
75
|
+
diagnoseReviewLoopDetected(context)
|
|
73
76
|
].filter((r) => r.confidence > 0);
|
|
74
77
|
// Sort by confidence descending
|
|
75
78
|
results.sort((a, b) => b.confidence - a.confidence);
|
|
@@ -647,6 +650,88 @@ function diagnoseGuardViolationDirty(ctx) {
|
|
|
647
650
|
: []
|
|
648
651
|
};
|
|
649
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Rule 11: Review loop detected
|
|
655
|
+
*/
|
|
656
|
+
function diagnoseReviewLoopDetected(ctx) {
|
|
657
|
+
const signals = [];
|
|
658
|
+
let confidence = 0;
|
|
659
|
+
let pattern = 'unknown';
|
|
660
|
+
let reviewRounds = 0;
|
|
661
|
+
let maxReviewRounds = 2;
|
|
662
|
+
let milestoneIndex = ctx.state.milestone_index;
|
|
663
|
+
let requestedChanges = [];
|
|
664
|
+
// Check stop reason or find event
|
|
665
|
+
if (ctx.state.stop_reason === 'review_loop_detected') {
|
|
666
|
+
confidence = 0.9;
|
|
667
|
+
// Find the review_loop_detected event for details
|
|
668
|
+
const event = ctx.events.find((e) => e.type === 'review_loop_detected');
|
|
669
|
+
if (event?.payload) {
|
|
670
|
+
const payload = event.payload;
|
|
671
|
+
const sameFingerprint = payload.same_fingerprint;
|
|
672
|
+
reviewRounds = payload.review_rounds ?? 0;
|
|
673
|
+
maxReviewRounds = payload.max_review_rounds ?? 2;
|
|
674
|
+
milestoneIndex = payload.milestone_index ?? ctx.state.milestone_index;
|
|
675
|
+
const lastChanges = payload.last_changes;
|
|
676
|
+
pattern = sameFingerprint ? 'identical_review_feedback' : 'max_review_rounds_exceeded';
|
|
677
|
+
if (lastChanges && lastChanges.length > 0) {
|
|
678
|
+
requestedChanges = lastChanges.slice(0, 2);
|
|
679
|
+
}
|
|
680
|
+
signals.push({
|
|
681
|
+
source: 'event.review_loop_detected',
|
|
682
|
+
pattern,
|
|
683
|
+
snippet: `Milestone ${milestoneIndex + 1}, ${reviewRounds} rounds (max: ${maxReviewRounds}), changes: ${requestedChanges.join('; ')}`
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
// Event not found but stop_reason matches
|
|
688
|
+
signals.push({
|
|
689
|
+
source: 'state.stop_reason',
|
|
690
|
+
pattern: 'review_loop_detected',
|
|
691
|
+
snippet: ctx.state.last_error?.slice(0, 200) || 'Review loop detected'
|
|
692
|
+
});
|
|
693
|
+
confidence = 0.7;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
category: 'review_loop_detected',
|
|
698
|
+
confidence,
|
|
699
|
+
signals,
|
|
700
|
+
nextActions: confidence > 0
|
|
701
|
+
? [
|
|
702
|
+
{
|
|
703
|
+
title: 'Open run artifacts',
|
|
704
|
+
command: `node dist/cli.js open ${ctx.runId}`,
|
|
705
|
+
why: 'View all run artifacts including review digest and timeline'
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
title: 'Read review digest',
|
|
709
|
+
command: `cat ${ctx.runDir}/review_digest.md`,
|
|
710
|
+
why: 'See exact requested changes and review verdict'
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
title: 'View run journal',
|
|
714
|
+
command: `node dist/cli.js journal ${ctx.runId}`,
|
|
715
|
+
why: 'Understand what the agent did across review rounds'
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
title: 'Rewrite milestone acceptance criteria',
|
|
719
|
+
why: 'Make criteria explicit as 3-7 checkboxes with concrete file paths and testable conditions. Loops usually mean the agent cannot translate review feedback into deterministic work.'
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
title: 'Check resume plan',
|
|
723
|
+
command: `node dist/cli.js resume ${ctx.runId} --plan`,
|
|
724
|
+
why: 'Preview what resume will do before running it'
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
title: 'Run diagnostics',
|
|
728
|
+
command: `node dist/cli.js doctor`,
|
|
729
|
+
why: 'Verify environment and repository health'
|
|
730
|
+
}
|
|
731
|
+
]
|
|
732
|
+
: []
|
|
733
|
+
};
|
|
734
|
+
}
|
|
650
735
|
// ============================================================================
|
|
651
736
|
// Helpers
|
|
652
737
|
// ============================================================================
|
|
@@ -16,6 +16,7 @@ const categoryDescriptions = {
|
|
|
16
16
|
time_budget_exceeded: 'Ran out of allocated time.',
|
|
17
17
|
guard_violation_dirty: 'Working directory has uncommitted changes.',
|
|
18
18
|
ownership_violation: 'Task modified files outside its declared owns: paths.',
|
|
19
|
+
review_loop_detected: 'Review feedback repeated or max review rounds exceeded.',
|
|
19
20
|
unknown: 'Could not determine specific cause.'
|
|
20
21
|
};
|
|
21
22
|
/**
|
|
@@ -130,6 +131,8 @@ function getEscalationAdvice(category) {
|
|
|
130
131
|
return 'For complex tasks, allocate more time upfront: --time 120 or higher.';
|
|
131
132
|
case 'guard_violation_dirty':
|
|
132
133
|
return 'Always use --worktree for runs on repos with active development.';
|
|
134
|
+
case 'review_loop_detected':
|
|
135
|
+
return 'If feedback loops persist, split the milestone into smaller steps or add explicit acceptance criteria as checkboxes. Consider adjusting verification commands to catch issues earlier.';
|
|
133
136
|
default:
|
|
134
137
|
return 'Review the timeline and logs carefully. Open an issue if the problem persists.';
|
|
135
138
|
}
|
package/dist/diagnosis/index.js
CHANGED