@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +127 -1
  2. package/README.md +124 -165
  3. package/dist/audit/classifier.js +331 -0
  4. package/dist/cli.js +570 -300
  5. package/dist/commands/audit.js +259 -0
  6. package/dist/commands/bundle.js +180 -0
  7. package/dist/commands/continue.js +276 -0
  8. package/dist/commands/doctor.js +430 -45
  9. package/dist/commands/hooks.js +352 -0
  10. package/dist/commands/init.js +368 -8
  11. package/dist/commands/intervene.js +109 -0
  12. package/dist/commands/meta.js +245 -0
  13. package/dist/commands/mode.js +157 -0
  14. package/dist/commands/orchestrate.js +29 -0
  15. package/dist/commands/packs.js +47 -0
  16. package/dist/commands/preflight.js +8 -5
  17. package/dist/commands/resume.js +421 -3
  18. package/dist/commands/run.js +63 -4
  19. package/dist/commands/status.js +47 -0
  20. package/dist/commands/submit.js +374 -0
  21. package/dist/config/schema.js +61 -1
  22. package/dist/diagnosis/analyzer.js +86 -1
  23. package/dist/diagnosis/formatter.js +3 -0
  24. package/dist/diagnosis/index.js +1 -0
  25. package/dist/diagnosis/stop-explainer.js +267 -0
  26. package/dist/diagnostics/stop-explainer.js +267 -0
  27. package/dist/guards/checkpoint.js +119 -0
  28. package/dist/journal/builder.js +36 -3
  29. package/dist/journal/renderer.js +19 -0
  30. package/dist/orchestrator/artifacts.js +17 -2
  31. package/dist/orchestrator/receipt.js +304 -0
  32. package/dist/output/stop-footer.js +185 -0
  33. package/dist/packs/actions.js +176 -0
  34. package/dist/packs/loader.js +200 -0
  35. package/dist/packs/renderer.js +46 -0
  36. package/dist/receipt/intervention.js +465 -0
  37. package/dist/receipt/writer.js +296 -0
  38. package/dist/redaction/redactor.js +95 -0
  39. package/dist/repo/context.js +147 -20
  40. package/dist/review/check-parser.js +211 -0
  41. package/dist/store/checkpoint-metadata.js +111 -0
  42. package/dist/store/run-store.js +21 -0
  43. package/dist/supervisor/runner.js +130 -10
  44. package/dist/tasks/task-metadata.js +74 -1
  45. package/dist/ux/brain.js +528 -0
  46. package/dist/ux/render.js +123 -0
  47. package/dist/ux/safe-commands.js +133 -0
  48. package/dist/ux/state.js +193 -0
  49. package/dist/ux/telemetry.js +110 -0
  50. package/package.json +3 -1
  51. package/packs/pr/pack.json +50 -0
  52. package/packs/pr/templates/AGENTS.md.tmpl +120 -0
  53. package/packs/pr/templates/CLAUDE.md.tmpl +101 -0
  54. package/packs/pr/templates/bundle.md.tmpl +27 -0
  55. package/packs/solo/pack.json +82 -0
  56. package/packs/solo/templates/AGENTS.md.tmpl +80 -0
  57. package/packs/solo/templates/CLAUDE.md.tmpl +126 -0
  58. package/packs/solo/templates/bundle.md.tmpl +27 -0
  59. package/packs/solo/templates/claude-cmd-bundle.md.tmpl +40 -0
  60. package/packs/solo/templates/claude-cmd-resume.md.tmpl +43 -0
  61. package/packs/solo/templates/claude-cmd-submit.md.tmpl +51 -0
  62. package/packs/solo/templates/claude-skill.md.tmpl +96 -0
  63. package/packs/trunk/pack.json +50 -0
  64. package/packs/trunk/templates/AGENTS.md.tmpl +87 -0
  65. package/packs/trunk/templates/CLAUDE.md.tmpl +126 -0
  66. package/packs/trunk/templates/bundle.md.tmpl +27 -0
@@ -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
- // Log effective configuration for transparency
47
- console.log(formatResumeConfig(options));
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
- throw new Error(`Run state not found for ${options.runId}`);
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',
@@ -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(config.scope.allowlist, activeRuns);
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, config.scope.allowlist);
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: config.scope.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: config.scope.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);
@@ -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.