@weldr/runr 0.4.0 → 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 +127 -1
- package/README.md +124 -165
- package/dist/audit/classifier.js +331 -0
- package/dist/cli.js +570 -300
- 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/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 +36 -3
- package/dist/journal/renderer.js +19 -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 +130 -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 +3 -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/resume.js
CHANGED
|
@@ -7,6 +7,8 @@ import { runSupervisorLoop } from '../supervisor/runner.js';
|
|
|
7
7
|
import { prepareForResume } from '../supervisor/state-machine.js';
|
|
8
8
|
import { captureFingerprint, compareFingerprints } from '../env/fingerprint.js';
|
|
9
9
|
import { recreateWorktree } from '../repo/worktree.js';
|
|
10
|
+
import { git } from '../repo/git.js';
|
|
11
|
+
import { getRunsRoot } from '../store/runs-root.js';
|
|
10
12
|
/**
|
|
11
13
|
* Format effective configuration for display at resume.
|
|
12
14
|
*/
|
|
@@ -42,16 +44,335 @@ function readTaskArtifact(runDir) {
|
|
|
42
44
|
}
|
|
43
45
|
return fs.readFileSync(taskPath, 'utf-8');
|
|
44
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Extract ignored changes summary from timeline events.
|
|
49
|
+
*/
|
|
50
|
+
function getIgnoredChangesSummary(runId, repo) {
|
|
51
|
+
const runsRoot = getRunsRoot(repo);
|
|
52
|
+
const timelinePath = path.join(runsRoot, runId, 'timeline.jsonl');
|
|
53
|
+
if (!fs.existsSync(timelinePath)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const lines = fs.readFileSync(timelinePath, 'utf-8').split('\n').filter(l => l.trim());
|
|
58
|
+
const ignoredEvents = lines
|
|
59
|
+
.map(line => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(line);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.filter(e => e && e.type === 'ignored_changes');
|
|
68
|
+
if (ignoredEvents.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const lastEvent = ignoredEvents[ignoredEvents.length - 1];
|
|
72
|
+
const payload = lastEvent.payload;
|
|
73
|
+
if (payload.ignored_count === 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
count: payload.ignored_count,
|
|
78
|
+
sample: payload.ignored_sample
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build resume plan by discovering last checkpoint and computing deltas.
|
|
87
|
+
* @internal - Exported for testing only, not part of public API
|
|
88
|
+
*/
|
|
89
|
+
export async function _buildResumePlan(options) {
|
|
90
|
+
const { state, repoPath, runStore, config } = options;
|
|
91
|
+
// Find last checkpoint
|
|
92
|
+
// PRIORITY 1: Try sidecar metadata first (fast, reliable)
|
|
93
|
+
// PRIORITY 2: Fallback to git log parsing (legacy support)
|
|
94
|
+
let checkpointSha = null;
|
|
95
|
+
let lastCheckpointMilestoneIndex = -1;
|
|
96
|
+
let checkpointSource = 'none';
|
|
97
|
+
// Try sidecar metadata first
|
|
98
|
+
try {
|
|
99
|
+
const { findLatestCheckpointBySidecar } = await import('../store/checkpoint-metadata.js');
|
|
100
|
+
const sidecarResult = await findLatestCheckpointBySidecar(repoPath, state.run_id);
|
|
101
|
+
if (sidecarResult) {
|
|
102
|
+
checkpointSha = sidecarResult.sha;
|
|
103
|
+
lastCheckpointMilestoneIndex = sidecarResult.milestoneIndex;
|
|
104
|
+
checkpointSource = 'sidecar';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Sidecar read failed, fall through to git log parsing
|
|
109
|
+
}
|
|
110
|
+
// Fallback to git log if sidecar not found
|
|
111
|
+
if (checkpointSha === null) {
|
|
112
|
+
// First: try new format with run_id
|
|
113
|
+
try {
|
|
114
|
+
const runSpecificPrefix = `chore(runr): checkpoint ${state.run_id} milestone `;
|
|
115
|
+
const result = await git([
|
|
116
|
+
'log',
|
|
117
|
+
'-z',
|
|
118
|
+
'--grep', runSpecificPrefix,
|
|
119
|
+
'--fixed-strings',
|
|
120
|
+
'-n', '1',
|
|
121
|
+
'--pretty=format:%H%x00%s'
|
|
122
|
+
], repoPath);
|
|
123
|
+
if (result.stdout.trim()) {
|
|
124
|
+
const parts = result.stdout.trim().split('\0');
|
|
125
|
+
checkpointSha = parts[0] || null;
|
|
126
|
+
const commitMessage = parts[1] || '';
|
|
127
|
+
// Extract milestone index from commit message
|
|
128
|
+
// Format: "chore(runr): checkpoint <run_id> milestone <N>"
|
|
129
|
+
const match = commitMessage.match(/milestone (\d+)/);
|
|
130
|
+
if (match) {
|
|
131
|
+
lastCheckpointMilestoneIndex = parseInt(match[1], 10);
|
|
132
|
+
checkpointSource = 'git_log_run_specific';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Run-specific checkpoint not found
|
|
138
|
+
}
|
|
139
|
+
// Fallback: try legacy format (without run_id)
|
|
140
|
+
if (checkpointSha === null) {
|
|
141
|
+
try {
|
|
142
|
+
const legacyPrefix = 'chore(agent): checkpoint milestone ';
|
|
143
|
+
const result = await git([
|
|
144
|
+
'log',
|
|
145
|
+
'-z',
|
|
146
|
+
'--grep', legacyPrefix,
|
|
147
|
+
'--fixed-strings',
|
|
148
|
+
'-n', '1',
|
|
149
|
+
'--pretty=format:%H%x00%s'
|
|
150
|
+
], repoPath);
|
|
151
|
+
if (result.stdout.trim()) {
|
|
152
|
+
const parts = result.stdout.trim().split('\0');
|
|
153
|
+
checkpointSha = parts[0] || null;
|
|
154
|
+
const commitMessage = parts[1] || '';
|
|
155
|
+
// Extract milestone index from commit message
|
|
156
|
+
// Format: "chore(agent): checkpoint milestone <N>"
|
|
157
|
+
const match = commitMessage.match(/checkpoint milestone (\d+)/);
|
|
158
|
+
if (match) {
|
|
159
|
+
lastCheckpointMilestoneIndex = parseInt(match[1], 10);
|
|
160
|
+
checkpointSource = 'git_log_legacy';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// No checkpoint found at all, start from beginning
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} // End of git log fallback
|
|
169
|
+
// Emit event showing which checkpoint source was selected
|
|
170
|
+
if (checkpointSha) {
|
|
171
|
+
runStore.appendEvent({
|
|
172
|
+
type: 'resume_checkpoint_selected',
|
|
173
|
+
source: 'resume',
|
|
174
|
+
payload: {
|
|
175
|
+
source: checkpointSource,
|
|
176
|
+
sha: checkpointSha,
|
|
177
|
+
milestone_index: lastCheckpointMilestoneIndex
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const resumeFromMilestoneIndex = lastCheckpointMilestoneIndex + 1;
|
|
182
|
+
const remainingMilestones = Math.max(0, state.milestones.length - resumeFromMilestoneIndex);
|
|
183
|
+
// Compute delta
|
|
184
|
+
let diffstat;
|
|
185
|
+
let lockfilesChanged = false;
|
|
186
|
+
if (checkpointSha) {
|
|
187
|
+
try {
|
|
188
|
+
const diffStatResult = await git(['diff', '--stat', `${checkpointSha}..HEAD`], repoPath);
|
|
189
|
+
diffstat = diffStatResult.stdout.trim() || undefined;
|
|
190
|
+
const diffNamesResult = await git(['diff', '--name-only', `${checkpointSha}..HEAD`], repoPath);
|
|
191
|
+
const changedFiles = diffNamesResult.stdout.trim().split('\n').filter(f => f);
|
|
192
|
+
lockfilesChanged = changedFiles.some(f => f === 'package-lock.json' ||
|
|
193
|
+
f === 'pnpm-lock.yaml' ||
|
|
194
|
+
f === 'yarn.lock');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Diff failed, skip deltas
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const ignoredSummary = getIgnoredChangesSummary(state.run_id, state.repo_path);
|
|
201
|
+
return {
|
|
202
|
+
runId: state.run_id,
|
|
203
|
+
checkpointSha,
|
|
204
|
+
lastCheckpointMilestoneIndex,
|
|
205
|
+
resumeFromMilestoneIndex,
|
|
206
|
+
remainingMilestones,
|
|
207
|
+
checkpointSource,
|
|
208
|
+
delta: {
|
|
209
|
+
diffstat,
|
|
210
|
+
lockfilesChanged,
|
|
211
|
+
ignoredNoiseCount: ignoredSummary?.count ?? 0,
|
|
212
|
+
ignoredNoiseSample: ignoredSummary?.sample ?? []
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Assert working tree is clean (REFUSE policy).
|
|
218
|
+
* If autoStash=true, creates stash and returns info.
|
|
219
|
+
*/
|
|
220
|
+
async function assertCleanWorkingTree(repoPath, options = {}) {
|
|
221
|
+
try {
|
|
222
|
+
const statusResult = await git(['status', '--porcelain'], repoPath);
|
|
223
|
+
const dirtyFiles = statusResult.stdout.trim().split('\n').filter(f => f.trim());
|
|
224
|
+
if (dirtyFiles.length === 0) {
|
|
225
|
+
return null; // Clean, no stash needed
|
|
226
|
+
}
|
|
227
|
+
// Dirty working tree detected
|
|
228
|
+
if (options.autoStash) {
|
|
229
|
+
// Create stash with deterministic message
|
|
230
|
+
const timestamp = new Date().toISOString();
|
|
231
|
+
const stashMessage = `runr-autostash-${options.runId || 'unknown'}-${timestamp}`;
|
|
232
|
+
await git(['stash', 'push', '-u', '-m', stashMessage], repoPath);
|
|
233
|
+
// Get stash ref (should be stash@{0} after push)
|
|
234
|
+
const stashRef = 'stash@{0}';
|
|
235
|
+
console.log(`Auto-stashed ${dirtyFiles.length} uncommitted change${dirtyFiles.length === 1 ? '' : 's'}`);
|
|
236
|
+
console.log(` Stash ref: ${stashRef}`);
|
|
237
|
+
console.log(` Message: ${stashMessage}`);
|
|
238
|
+
console.log(` To restore: git stash pop ${stashRef}`);
|
|
239
|
+
return {
|
|
240
|
+
stashRef,
|
|
241
|
+
stashMessage,
|
|
242
|
+
fileCount: dirtyFiles.length
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// Not auto-stashing, refuse with error
|
|
246
|
+
const sampleFiles = dirtyFiles.slice(0, 5).map(f => f.trim());
|
|
247
|
+
const hasMore = dirtyFiles.length > 5;
|
|
248
|
+
let errorMessage = `Working tree has ${dirtyFiles.length} uncommitted change${dirtyFiles.length === 1 ? '' : 's'}:\n`;
|
|
249
|
+
errorMessage += sampleFiles.join('\n');
|
|
250
|
+
if (hasMore) {
|
|
251
|
+
errorMessage += `\n... and ${dirtyFiles.length - 5} more`;
|
|
252
|
+
}
|
|
253
|
+
errorMessage += '\n\nRun `git stash && runr resume` to stash changes before resuming.';
|
|
254
|
+
errorMessage += '\nOr use `runr resume --auto-stash` to stash automatically.';
|
|
255
|
+
throw new Error(errorMessage);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error instanceof Error && error.message.includes('Working tree has')) {
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
// Git command failed, assume clean
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get working tree status (non-throwing version for plan mode).
|
|
267
|
+
*/
|
|
268
|
+
async function getWorkingTreeStatus(repoPath) {
|
|
269
|
+
try {
|
|
270
|
+
const statusResult = await git(['status', '--porcelain'], repoPath);
|
|
271
|
+
const dirtyFiles = statusResult.stdout.trim().split('\n').filter(f => f.trim());
|
|
272
|
+
return {
|
|
273
|
+
clean: dirtyFiles.length === 0,
|
|
274
|
+
dirtyPaths: dirtyFiles.slice(0, 10), // Sample up to 10
|
|
275
|
+
dirtyCount: dirtyFiles.length
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Git command failed, assume clean
|
|
280
|
+
return {
|
|
281
|
+
clean: true,
|
|
282
|
+
dirtyPaths: [],
|
|
283
|
+
dirtyCount: 0
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Format resume plan as JSON.
|
|
289
|
+
*/
|
|
290
|
+
async function formatResumePlanJson(plan, state, effectiveRepoPath, checkpointSource) {
|
|
291
|
+
const repoStatus = await getWorkingTreeStatus(effectiveRepoPath);
|
|
292
|
+
const warnings = [];
|
|
293
|
+
if (!plan.delta.diffstat && plan.checkpointSha) {
|
|
294
|
+
warnings.push('Could not compute diffstat');
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
schema_version: 1,
|
|
298
|
+
run_id: plan.runId,
|
|
299
|
+
repo_path: state.repo_path,
|
|
300
|
+
effective_repo_path: effectiveRepoPath,
|
|
301
|
+
checkpoint: {
|
|
302
|
+
sha: plan.checkpointSha,
|
|
303
|
+
milestone_index: plan.lastCheckpointMilestoneIndex,
|
|
304
|
+
source: checkpointSource
|
|
305
|
+
},
|
|
306
|
+
resume: {
|
|
307
|
+
from_milestone_index: plan.resumeFromMilestoneIndex,
|
|
308
|
+
phase: 'IMPLEMENT', // Resume always goes to IMPLEMENT
|
|
309
|
+
remaining_milestones: plan.remainingMilestones
|
|
310
|
+
},
|
|
311
|
+
repo_state: {
|
|
312
|
+
working_tree_clean: repoStatus.clean,
|
|
313
|
+
dirty_paths_sample: repoStatus.dirtyPaths,
|
|
314
|
+
dirty_count: repoStatus.dirtyCount
|
|
315
|
+
},
|
|
316
|
+
delta: {
|
|
317
|
+
diffstat: plan.delta.diffstat,
|
|
318
|
+
lockfiles_changed: plan.delta.lockfilesChanged,
|
|
319
|
+
ignored_noise_count: plan.delta.ignoredNoiseCount,
|
|
320
|
+
ignored_noise_sample: plan.delta.ignoredNoiseSample
|
|
321
|
+
},
|
|
322
|
+
warnings
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Format resume plan for display.
|
|
327
|
+
*/
|
|
328
|
+
function formatResumePlan(plan) {
|
|
329
|
+
const lines = [];
|
|
330
|
+
lines.push(`Resume plan:`);
|
|
331
|
+
lines.push(` Checkpoint: ${plan.checkpointSha?.slice(0, 8) ?? 'none'} (milestone ${plan.lastCheckpointMilestoneIndex})`);
|
|
332
|
+
lines.push(` Resume from: milestone ${plan.resumeFromMilestoneIndex}`);
|
|
333
|
+
lines.push(` Remaining: ${plan.remainingMilestones} milestone${plan.remainingMilestones === 1 ? '' : 's'}`);
|
|
334
|
+
if (plan.delta.lockfilesChanged) {
|
|
335
|
+
lines.push(` Delta: lockfiles changed`);
|
|
336
|
+
}
|
|
337
|
+
if (plan.delta.ignoredNoiseCount > 0) {
|
|
338
|
+
const sample = plan.delta.ignoredNoiseSample.slice(0, 3).join(', ');
|
|
339
|
+
lines.push(` Ignored: ${plan.delta.ignoredNoiseCount} files (${sample}${plan.delta.ignoredNoiseSample.length > 3 ? ', ...' : ''})`);
|
|
340
|
+
}
|
|
341
|
+
return lines.join('\n');
|
|
342
|
+
}
|
|
45
343
|
export async function resumeCommand(options) {
|
|
46
|
-
//
|
|
47
|
-
|
|
344
|
+
// Early flag validation
|
|
345
|
+
// --json implies --plan
|
|
346
|
+
if (options.json) {
|
|
347
|
+
options.plan = true;
|
|
348
|
+
}
|
|
349
|
+
// --auto-stash is incompatible with --plan (plan is read-only)
|
|
350
|
+
if (options.autoStash && options.plan) {
|
|
351
|
+
console.error('Error: --auto-stash cannot be used with --plan (plan mode is read-only)');
|
|
352
|
+
process.exitCode = 1;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Log effective configuration for transparency (skip in JSON mode)
|
|
356
|
+
if (!options.json) {
|
|
357
|
+
console.log(formatResumeConfig(options));
|
|
358
|
+
}
|
|
48
359
|
const runStore = RunStore.init(options.runId, options.repo);
|
|
49
360
|
let state;
|
|
50
361
|
try {
|
|
51
362
|
state = runStore.readState();
|
|
52
363
|
}
|
|
53
364
|
catch {
|
|
54
|
-
|
|
365
|
+
if (options.json) {
|
|
366
|
+
console.error(JSON.stringify({
|
|
367
|
+
error: 'run_not_found',
|
|
368
|
+
message: `Run state not found for ${options.runId}`
|
|
369
|
+
}, null, 2));
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
throw new Error(`Run state not found for ${options.runId}`);
|
|
373
|
+
}
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
return;
|
|
55
376
|
}
|
|
56
377
|
const { config: configSnapshot, worktree: worktreeInfo } = readConfigSnapshot(runStore.path);
|
|
57
378
|
const config = configSnapshot ??
|
|
@@ -121,9 +442,106 @@ export async function resumeCommand(options) {
|
|
|
121
442
|
console.warn('\nWARNING: Forcing resume despite environment mismatch (--force)\n');
|
|
122
443
|
}
|
|
123
444
|
}
|
|
445
|
+
// INSERTION 1: Dirty tree check (REFUSE policy)
|
|
446
|
+
// Skip in plan mode - plan is read-only
|
|
447
|
+
let stashInfo = null;
|
|
448
|
+
if (!options.plan) {
|
|
449
|
+
stashInfo = await assertCleanWorkingTree(effectiveRepoPath, {
|
|
450
|
+
autoStash: options.autoStash,
|
|
451
|
+
runId: options.runId
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// INSERTION 2: Build and print resume plan
|
|
455
|
+
const plan = await _buildResumePlan({
|
|
456
|
+
state,
|
|
457
|
+
repoPath: effectiveRepoPath,
|
|
458
|
+
runStore,
|
|
459
|
+
config
|
|
460
|
+
});
|
|
461
|
+
// If --plan mode, output plan and exit
|
|
462
|
+
if (options.plan) {
|
|
463
|
+
if (options.json) {
|
|
464
|
+
const planJson = await formatResumePlanJson(plan, state, effectiveRepoPath, plan.checkpointSource);
|
|
465
|
+
console.log(JSON.stringify(planJson, null, 2));
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
console.log(formatResumePlan(plan));
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// Not in plan mode - print plan in text format
|
|
473
|
+
console.log(formatResumePlan(plan));
|
|
124
474
|
// Use shared helper to prepare state for resume
|
|
125
475
|
const updated = prepareForResume(state, { resumeToken: options.runId });
|
|
476
|
+
// Override milestone_index and phase from plan (fixes FINALIZE bug)
|
|
477
|
+
const previousMilestoneIndex = state.milestone_index;
|
|
478
|
+
updated.milestone_index = plan.resumeFromMilestoneIndex;
|
|
479
|
+
updated.phase = plan.resumeFromMilestoneIndex >= state.milestones.length ? 'FINALIZE' : 'IMPLEMENT';
|
|
480
|
+
// Detect milestone index drift
|
|
481
|
+
if (previousMilestoneIndex !== plan.resumeFromMilestoneIndex) {
|
|
482
|
+
// Determine drift reason
|
|
483
|
+
let reason;
|
|
484
|
+
if (previousMilestoneIndex <= plan.lastCheckpointMilestoneIndex) {
|
|
485
|
+
reason = 'already_checkpointed';
|
|
486
|
+
}
|
|
487
|
+
else if (previousMilestoneIndex < plan.resumeFromMilestoneIndex) {
|
|
488
|
+
reason = 'state_behind_checkpoint';
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
reason = 'state_ahead_of_checkpoint';
|
|
492
|
+
}
|
|
493
|
+
// Emit correction event
|
|
494
|
+
runStore.appendEvent({
|
|
495
|
+
type: 'milestone_index_corrected',
|
|
496
|
+
source: 'resume',
|
|
497
|
+
payload: {
|
|
498
|
+
previous: previousMilestoneIndex,
|
|
499
|
+
corrected_to: plan.resumeFromMilestoneIndex,
|
|
500
|
+
last_checkpoint_milestone_index: plan.lastCheckpointMilestoneIndex,
|
|
501
|
+
checkpoint_sha: plan.checkpointSha ?? null,
|
|
502
|
+
checkpoint_source: plan.checkpointSource,
|
|
503
|
+
reason
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
// User-facing message
|
|
507
|
+
if (reason === 'already_checkpointed') {
|
|
508
|
+
console.log(`✓ Checkpoint found at milestone ${plan.lastCheckpointMilestoneIndex}. Resuming from milestone ${plan.resumeFromMilestoneIndex}.`);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
console.warn('Resume warning:');
|
|
512
|
+
console.warn(` State milestone_index=${previousMilestoneIndex} but last checkpoint is milestone ${plan.lastCheckpointMilestoneIndex}.`);
|
|
513
|
+
console.warn(` Resuming from milestone ${plan.resumeFromMilestoneIndex} (checkpoint is ground truth).`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
126
516
|
runStore.writeState(updated);
|
|
517
|
+
// INSERTION 3: Resume provenance event
|
|
518
|
+
runStore.appendEvent({
|
|
519
|
+
type: 'resume',
|
|
520
|
+
source: 'cli',
|
|
521
|
+
payload: {
|
|
522
|
+
checkpoint_sha: plan.checkpointSha,
|
|
523
|
+
last_checkpoint_milestone_index: plan.lastCheckpointMilestoneIndex,
|
|
524
|
+
resume_from_milestone_index: plan.resumeFromMilestoneIndex,
|
|
525
|
+
remaining_milestones: plan.remainingMilestones,
|
|
526
|
+
delta: {
|
|
527
|
+
lockfiles_changed: plan.delta.lockfilesChanged,
|
|
528
|
+
ignored_noise_count: plan.delta.ignoredNoiseCount,
|
|
529
|
+
ignored_noise_sample: plan.delta.ignoredNoiseSample
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
// Record auto-stash if it happened
|
|
534
|
+
if (stashInfo) {
|
|
535
|
+
runStore.appendEvent({
|
|
536
|
+
type: 'auto_stash_created',
|
|
537
|
+
source: 'cli',
|
|
538
|
+
payload: {
|
|
539
|
+
stash_ref: stashInfo.stashRef,
|
|
540
|
+
stash_message: stashInfo.stashMessage,
|
|
541
|
+
file_count: stashInfo.fileCount
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
}
|
|
127
545
|
runStore.appendEvent({
|
|
128
546
|
type: 'run_resumed',
|
|
129
547
|
source: 'cli',
|
package/dist/commands/run.js
CHANGED
|
@@ -13,6 +13,10 @@ import { runDoctorChecks } from './doctor.js';
|
|
|
13
13
|
import { captureFingerprint } from '../env/fingerprint.js';
|
|
14
14
|
import { loadTaskMetadata } from '../tasks/task-metadata.js';
|
|
15
15
|
import { getActiveRuns, checkAllowlistOverlaps, formatAllowlistWarning } from '../supervisor/collision.js';
|
|
16
|
+
import { updateActiveState, clearActiveState } from './hooks.js';
|
|
17
|
+
import { printStopFooter } from '../output/stop-footer.js';
|
|
18
|
+
import { computeBrain } from '../ux/brain.js';
|
|
19
|
+
import { resolveRepoState } from '../ux/state.js';
|
|
16
20
|
function makeRunId() {
|
|
17
21
|
const now = new Date();
|
|
18
22
|
const parts = [
|
|
@@ -194,6 +198,15 @@ export async function runCommand(options) {
|
|
|
194
198
|
const taskText = taskMetadata.body;
|
|
195
199
|
const ownsRaw = taskMetadata.owns_raw;
|
|
196
200
|
const ownsNormalized = taskMetadata.owns_normalized;
|
|
201
|
+
// Merge task-local allowlist_add with config allowlist (additive only)
|
|
202
|
+
const effectiveAllowlist = [
|
|
203
|
+
...config.scope.allowlist,
|
|
204
|
+
...taskMetadata.allowlist_add
|
|
205
|
+
];
|
|
206
|
+
// Log if task has local scope additions
|
|
207
|
+
if (taskMetadata.allowlist_add.length > 0 && !options.json) {
|
|
208
|
+
console.log(`Task-local scope additions: ${taskMetadata.allowlist_add.join(', ')}`);
|
|
209
|
+
}
|
|
197
210
|
// Auto-inject git excludes for agent artifacts BEFORE any git status checks.
|
|
198
211
|
// This prevents .agent/ and .agent-worktrees/ from appearing as dirty on fresh repos.
|
|
199
212
|
ensureRepoInfoExclude(repoPath, [
|
|
@@ -233,7 +246,7 @@ export async function runCommand(options) {
|
|
|
233
246
|
if (!options.forceParallel) {
|
|
234
247
|
const activeRuns = getActiveRuns(repoPath);
|
|
235
248
|
if (activeRuns.length > 0) {
|
|
236
|
-
const overlaps = checkAllowlistOverlaps(
|
|
249
|
+
const overlaps = checkAllowlistOverlaps(effectiveAllowlist, activeRuns);
|
|
237
250
|
if (overlaps.length > 0) {
|
|
238
251
|
console.warn('');
|
|
239
252
|
console.warn(formatAllowlistWarning(overlaps));
|
|
@@ -244,7 +257,7 @@ export async function runCommand(options) {
|
|
|
244
257
|
let freshTargetRoot = null;
|
|
245
258
|
if (options.freshTarget) {
|
|
246
259
|
try {
|
|
247
|
-
freshTargetRoot = await freshenTargetRoot(repoPath,
|
|
260
|
+
freshTargetRoot = await freshenTargetRoot(repoPath, effectiveAllowlist);
|
|
248
261
|
console.log(`Fresh target: cleaned ${freshTargetRoot}`);
|
|
249
262
|
}
|
|
250
263
|
catch (error) {
|
|
@@ -410,7 +423,7 @@ export async function runCommand(options) {
|
|
|
410
423
|
raw: ownsRaw,
|
|
411
424
|
normalized: ownsNormalized
|
|
412
425
|
},
|
|
413
|
-
allowlist:
|
|
426
|
+
allowlist: effectiveAllowlist,
|
|
414
427
|
denylist: config.scope.denylist
|
|
415
428
|
});
|
|
416
429
|
state.current_branch = preflight.repo_context.current_branch;
|
|
@@ -468,7 +481,7 @@ export async function runCommand(options) {
|
|
|
468
481
|
raw: ownsRaw,
|
|
469
482
|
normalized: ownsNormalized
|
|
470
483
|
},
|
|
471
|
-
allowlist:
|
|
484
|
+
allowlist: effectiveAllowlist,
|
|
472
485
|
denylist: config.scope.denylist
|
|
473
486
|
});
|
|
474
487
|
state.current_branch = preflight.repo_context.current_branch;
|
|
@@ -518,6 +531,11 @@ export async function runCommand(options) {
|
|
|
518
531
|
}
|
|
519
532
|
if (runStore) {
|
|
520
533
|
runStore.writeSummary('# Summary\n\nRun initialized. Supervisor loop not yet executed.');
|
|
534
|
+
// Update sentinel file to indicate run is active
|
|
535
|
+
updateActiveState(options.repo, {
|
|
536
|
+
run_id: runId,
|
|
537
|
+
status: 'RUNNING'
|
|
538
|
+
});
|
|
521
539
|
await runSupervisorLoop({
|
|
522
540
|
runStore,
|
|
523
541
|
repoPath: effectiveRepoPath,
|
|
@@ -531,6 +549,47 @@ export async function runCommand(options) {
|
|
|
531
549
|
forceParallel: options.forceParallel,
|
|
532
550
|
ownedPaths: ownsNormalized
|
|
533
551
|
});
|
|
552
|
+
// Update sentinel file based on final run state
|
|
553
|
+
const finalState = runStore.readState();
|
|
554
|
+
if (finalState.stop_reason === 'complete') {
|
|
555
|
+
// Run finished successfully
|
|
556
|
+
clearActiveState(options.repo);
|
|
557
|
+
}
|
|
558
|
+
else if (finalState.stop_reason) {
|
|
559
|
+
// Run stopped with an error
|
|
560
|
+
updateActiveState(options.repo, {
|
|
561
|
+
run_id: runId,
|
|
562
|
+
status: 'STOPPED',
|
|
563
|
+
stop_reason: finalState.stop_reason
|
|
564
|
+
});
|
|
565
|
+
// Print stop footer with next steps (unless JSON mode)
|
|
566
|
+
if (!options.json) {
|
|
567
|
+
// Extract review loop data from timeline if available
|
|
568
|
+
let reviewLoopData;
|
|
569
|
+
if (finalState.stop_reason === 'review_loop_detected') {
|
|
570
|
+
// Find the review_loop_detected event in the timeline
|
|
571
|
+
const events = runStore.readTimeline();
|
|
572
|
+
const reviewLoopEvent = events.find((e) => e.type === 'review_loop_detected');
|
|
573
|
+
if (reviewLoopEvent?.payload) {
|
|
574
|
+
const payload = reviewLoopEvent.payload;
|
|
575
|
+
reviewLoopData = {
|
|
576
|
+
reviewRound: payload.review_rounds,
|
|
577
|
+
maxReviewRounds: payload.max_review_rounds,
|
|
578
|
+
reviewerRequests: payload.reviewer_requests,
|
|
579
|
+
commandsToSatisfy: payload.commands_to_satisfy
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Compute brain actions for consistent UX across front door, continue, and stop-footer
|
|
584
|
+
const repoState = await resolveRepoState(options.repo);
|
|
585
|
+
const brainOutput = computeBrain({
|
|
586
|
+
state: repoState,
|
|
587
|
+
stopDiagnosis: null, // Diagnosis not available yet at stop time
|
|
588
|
+
stopExplainer: null,
|
|
589
|
+
});
|
|
590
|
+
printStopFooter(finalState, reviewLoopData, brainOutput.actions);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
534
593
|
}
|
|
535
594
|
if (!options.json) {
|
|
536
595
|
console.log(summaryLine);
|
package/dist/commands/status.js
CHANGED
|
@@ -3,6 +3,46 @@ import path from 'node:path';
|
|
|
3
3
|
import { RunStore } from '../store/run-store.js';
|
|
4
4
|
import { getRunsRoot } from '../store/runs-root.js';
|
|
5
5
|
import { getActiveRuns, getCollisionRisk } from '../supervisor/collision.js';
|
|
6
|
+
/**
|
|
7
|
+
* Extract ignored changes summary from timeline events.
|
|
8
|
+
* Returns null if no ignored changes found.
|
|
9
|
+
*/
|
|
10
|
+
function getIgnoredChangesSummary(runId, repo) {
|
|
11
|
+
const runsRoot = getRunsRoot(repo);
|
|
12
|
+
const timelinePath = path.join(runsRoot, runId, 'timeline.jsonl');
|
|
13
|
+
if (!fs.existsSync(timelinePath)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const lines = fs.readFileSync(timelinePath, 'utf-8').split('\n').filter(l => l.trim());
|
|
18
|
+
const ignoredEvents = lines
|
|
19
|
+
.map(line => {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(line);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.filter(e => e && e.type === 'ignored_changes');
|
|
28
|
+
if (ignoredEvents.length === 0) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// Take the last ignored_changes event (most recent)
|
|
32
|
+
const lastEvent = ignoredEvents[ignoredEvents.length - 1];
|
|
33
|
+
const payload = lastEvent.payload;
|
|
34
|
+
if (payload.ignored_count === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
count: payload.ignored_count,
|
|
39
|
+
sample: payload.ignored_sample
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
6
46
|
/**
|
|
7
47
|
* Get status of a single run.
|
|
8
48
|
*/
|
|
@@ -10,6 +50,13 @@ export async function statusCommand(options) {
|
|
|
10
50
|
const runStore = RunStore.init(options.runId, options.repo);
|
|
11
51
|
const state = runStore.readState();
|
|
12
52
|
console.log(JSON.stringify(state, null, 2));
|
|
53
|
+
// Show ignored tool noise summary for trust/forensics
|
|
54
|
+
const ignoredSummary = getIgnoredChangesSummary(options.runId, options.repo);
|
|
55
|
+
if (ignoredSummary) {
|
|
56
|
+
const { count, sample } = ignoredSummary;
|
|
57
|
+
const samplePaths = sample.slice(0, 3).join(', ');
|
|
58
|
+
console.log(`\nIgnored tool noise: ${count} file${count === 1 ? '' : 's'} (sample: ${samplePaths}${sample.length > 3 ? ', ...' : ''})`);
|
|
59
|
+
}
|
|
13
60
|
}
|
|
14
61
|
/**
|
|
15
62
|
* Get status of all runs in the repo.
|