agentxchain 2.127.0 → 2.129.0

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/src/lib/intake.js CHANGED
@@ -27,6 +27,8 @@ const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
27
27
  // intent files, but current first-party intake writers do not transition into it.
28
28
  const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
29
29
  const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'failed']);
30
+ const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
31
+ const PRIORITY_RANK = { p0: 0, p1: 1, p2: 2, p3: 3 };
30
32
 
31
33
  const VALID_TRANSITIONS = {
32
34
  detected: ['triaged', 'suppressed'],
@@ -502,6 +504,196 @@ export function intakeStatus(root, intentId) {
502
504
  return { ok: true, summary, exitCode: 0 };
503
505
  }
504
506
 
507
+ export function findNextDispatchableIntent(root) {
508
+ const dirs = intakeDirs(root);
509
+ if (!existsSync(dirs.intents)) {
510
+ return { ok: false, error: 'no intents directory' };
511
+ }
512
+
513
+ const intents = readJsonDir(dirs.intents)
514
+ .filter((intent) => intent && DISPATCHABLE_STATUSES.has(intent.status));
515
+
516
+ if (intents.length === 0) {
517
+ return { ok: false, error: 'no dispatchable intents' };
518
+ }
519
+
520
+ const approved = intents.filter((intent) => intent.status === 'approved');
521
+ const candidates = approved.length > 0 ? approved : intents;
522
+
523
+ candidates.sort((a, b) => {
524
+ const aPriority = PRIORITY_RANK[a.priority] ?? Number.MAX_SAFE_INTEGER;
525
+ const bPriority = PRIORITY_RANK[b.priority] ?? Number.MAX_SAFE_INTEGER;
526
+ if (aPriority !== bPriority) return aPriority - bPriority;
527
+
528
+ const aTime = Date.parse(a.approved_at || a.planned_at || a.created_at || a.updated_at || 0);
529
+ const bTime = Date.parse(b.approved_at || b.planned_at || b.created_at || b.updated_at || 0);
530
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
531
+ return aTime - bTime;
532
+ }
533
+
534
+ return String(a.intent_id || '').localeCompare(String(b.intent_id || ''));
535
+ });
536
+
537
+ const intent = candidates[0];
538
+ return {
539
+ ok: true,
540
+ intentId: intent.intent_id,
541
+ status: intent.status,
542
+ priority: intent.priority || null,
543
+ charter: intent.charter || null,
544
+ acceptance_count: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract.length : 0,
545
+ intent,
546
+ };
547
+ }
548
+
549
+ /**
550
+ * Return all approved-but-unconsumed intents sorted by priority (BUG-15).
551
+ * Used by `status` to surface the pending intent queue.
552
+ */
553
+ export function findPendingApprovedIntents(root) {
554
+ const dirs = intakeDirs(root);
555
+ if (!existsSync(dirs.intents)) return [];
556
+
557
+ return readJsonDir(dirs.intents)
558
+ .filter((intent) => intent && intent.status === 'approved')
559
+ .sort((a, b) => {
560
+ const aPriority = PRIORITY_RANK[a.priority] ?? Number.MAX_SAFE_INTEGER;
561
+ const bPriority = PRIORITY_RANK[b.priority] ?? Number.MAX_SAFE_INTEGER;
562
+ if (aPriority !== bPriority) return aPriority - bPriority;
563
+ const aTime = Date.parse(a.approved_at || a.created_at || 0);
564
+ const bTime = Date.parse(b.approved_at || b.created_at || 0);
565
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
566
+ return String(a.intent_id || '').localeCompare(String(b.intent_id || ''));
567
+ })
568
+ .map((intent) => ({
569
+ intent_id: intent.intent_id,
570
+ priority: intent.priority || 'p0',
571
+ charter: intent.charter || intent.description || null,
572
+ acceptance_count: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract.length : 0,
573
+ approved_at: intent.approved_at || null,
574
+ }));
575
+ }
576
+
577
+ /**
578
+ * Unified intent consumption entry point (BUG-16).
579
+ * Both manual (resume/step --resume) and continuous/scheduler paths should call
580
+ * this single function to consume the next approved intent.
581
+ *
582
+ * @param {string} root
583
+ * @param {{ role?: string, writeDispatchBundle?: boolean, allowTerminalRestart?: boolean, provenance?: object }} options
584
+ * @returns {{ ok: boolean, intentId?: string, intent?: object, error?: string }}
585
+ */
586
+ export function consumeNextApprovedIntent(root, options = {}) {
587
+ const queued = findNextDispatchableIntent(root);
588
+ if (!queued.ok) {
589
+ return { ok: false, error: queued.error || 'no dispatchable intents' };
590
+ }
591
+
592
+ const prepared = prepareIntentForDispatch(root, queued.intentId, {
593
+ role: options.role,
594
+ writeDispatchBundle: options.writeDispatchBundle ?? false,
595
+ allowTerminalRestart: options.allowTerminalRestart ?? false,
596
+ provenance: options.provenance,
597
+ });
598
+
599
+ if (!prepared.ok) {
600
+ return { ok: false, error: prepared.error, intentId: queued.intentId };
601
+ }
602
+
603
+ return {
604
+ ok: true,
605
+ intentId: queued.intentId,
606
+ status: queued.status,
607
+ priority: queued.priority,
608
+ charter: queued.charter,
609
+ intent: prepared.intent,
610
+ run_id: prepared.run_id,
611
+ turn_id: prepared.turn_id,
612
+ role: prepared.role,
613
+ };
614
+ }
615
+
616
+ export function prepareIntentForDispatch(root, intentId, options = {}) {
617
+ const loadedIntent = readIntent(root, intentId);
618
+ if (!loadedIntent.ok) {
619
+ return loadedIntent;
620
+ }
621
+
622
+ const startingStatus = loadedIntent.intent.status;
623
+ let intent = loadedIntent.intent;
624
+ let planned = false;
625
+
626
+ if (intent.status === 'approved') {
627
+ const plannedResult = planIntent(root, intent.intent_id, {
628
+ projectName: options.projectName,
629
+ force: options.forcePlan === true,
630
+ });
631
+ if (!plannedResult.ok) {
632
+ return {
633
+ ok: false,
634
+ error: `plan failed: ${plannedResult.error}`,
635
+ intent_status: intent.status,
636
+ exitCode: plannedResult.exitCode || 1,
637
+ };
638
+ }
639
+ intent = plannedResult.intent;
640
+ planned = true;
641
+ }
642
+
643
+ if (intent.status === 'planned') {
644
+ const startResult = startIntent(root, intent.intent_id, {
645
+ allowTerminalRestart: options.allowTerminalRestart === true,
646
+ provenance: options.provenance,
647
+ role: options.role || undefined,
648
+ writeDispatchBundle: options.writeDispatchBundle,
649
+ });
650
+ if (!startResult.ok) {
651
+ return {
652
+ ok: false,
653
+ error: `start failed: ${startResult.error}`,
654
+ intent_status: intent.status,
655
+ exitCode: startResult.exitCode || 1,
656
+ };
657
+ }
658
+ return {
659
+ ok: true,
660
+ intent_id: intent.intent_id,
661
+ starting_status: startingStatus,
662
+ final_status: startResult.intent.status,
663
+ planned,
664
+ started: true,
665
+ run_id: startResult.run_id,
666
+ turn_id: startResult.turn_id,
667
+ role: startResult.role,
668
+ intent: startResult.intent,
669
+ exitCode: 0,
670
+ };
671
+ }
672
+
673
+ if (intent.status === 'executing') {
674
+ return {
675
+ ok: true,
676
+ intent_id: intent.intent_id,
677
+ starting_status: startingStatus,
678
+ final_status: intent.status,
679
+ planned,
680
+ started: false,
681
+ run_id: intent.target_run || null,
682
+ turn_id: intent.target_turn || null,
683
+ role: options.role || null,
684
+ intent,
685
+ exitCode: 0,
686
+ };
687
+ }
688
+
689
+ return {
690
+ ok: false,
691
+ error: `intent ${intentId} is in unsupported status "${intent.status}" for dispatch preparation`,
692
+ intent_status: intent.status,
693
+ exitCode: 1,
694
+ };
695
+ }
696
+
505
697
  // ---------------------------------------------------------------------------
506
698
  // Approve
507
699
  // ---------------------------------------------------------------------------
@@ -750,7 +942,9 @@ export function startIntent(root, intentId, options = {}) {
750
942
  }
751
943
 
752
944
  // Assign governed turn
753
- const assignResult = assignGovernedTurn(root, config, roleId.role);
945
+ const assignResult = assignGovernedTurn(root, config, roleId.role, {
946
+ intakeContext,
947
+ });
754
948
  if (!assignResult.ok) {
755
949
  return { ok: false, error: `turn assignment failed: ${assignResult.error}`, exitCode: 1 };
756
950
  }
@@ -763,24 +957,18 @@ export function startIntent(root, intentId, options = {}) {
763
957
  return { ok: false, error: 'turn assignment succeeded but turn not found in state', exitCode: 1 };
764
958
  }
765
959
 
766
- assignedTurn.intake_context = intakeContext;
767
- if (state.active_turns?.[assignedTurn.turn_id]) {
768
- state.active_turns[assignedTurn.turn_id].intake_context = intakeContext;
769
- safeWriteJson(statePath, state);
770
- }
960
+ if (options.writeDispatchBundle !== false) {
961
+ const bundleResult = writeDispatchBundle(root, state, config);
962
+ if (!bundleResult.ok) {
963
+ return { ok: false, error: `dispatch bundle failed: ${bundleResult.error}`, exitCode: 1 };
964
+ }
771
965
 
772
- // Write dispatch bundle
773
- const bundleResult = writeDispatchBundle(root, state, config);
774
- if (!bundleResult.ok) {
775
- return { ok: false, error: `dispatch bundle failed: ${bundleResult.error}`, exitCode: 1 };
966
+ finalizeDispatchManifest(root, assignedTurn.turn_id, {
967
+ run_id: state.run_id,
968
+ role: assignedTurn.assigned_role,
969
+ });
776
970
  }
777
971
 
778
- // Finalize dispatch manifest
779
- finalizeDispatchManifest(root, assignedTurn.turn_id, {
780
- run_id: state.run_id,
781
- role: assignedTurn.assigned_role,
782
- });
783
-
784
972
  // Update intent: planned → executing
785
973
  const now = nowISO();
786
974
  intent.status = 'executing';
@@ -1426,83 +1614,39 @@ export function consumePreemptionMarker(root, options = {}) {
1426
1614
  };
1427
1615
  }
1428
1616
 
1429
- const startingStatus = loadedIntent.intent.status;
1430
- let intent = loadedIntent.intent;
1431
- let planned = false;
1432
- let started = false;
1433
-
1434
- if (intent.status === 'approved') {
1435
- const plannedResult = planIntent(root, intent.intent_id, {
1436
- projectName: options.projectName,
1437
- force: options.forcePlan === true,
1438
- });
1439
- if (!plannedResult.ok) {
1440
- return {
1441
- ok: false,
1442
- error: `failed to plan injected intent ${intent.intent_id}: ${plannedResult.error}`,
1443
- marker,
1444
- intent_status: intent.status,
1445
- exitCode: plannedResult.exitCode || 1,
1446
- };
1447
- }
1448
- intent = plannedResult.intent;
1449
- planned = true;
1450
- }
1451
-
1452
- if (intent.status === 'planned') {
1453
- const startResult = startIntent(root, intent.intent_id, {
1454
- role: options.role || undefined,
1455
- });
1456
- if (!startResult.ok) {
1457
- return {
1458
- ok: false,
1459
- error: `failed to start injected intent ${intent.intent_id}: ${startResult.error}`,
1460
- marker,
1461
- intent_status: intent.status,
1462
- exitCode: startResult.exitCode || 1,
1463
- };
1464
- }
1465
- clearPreemptionMarker(root);
1617
+ const prepared = prepareIntentForDispatch(root, marker.intent_id, options);
1618
+ if (!prepared.ok) {
1466
1619
  return {
1467
- ok: true,
1620
+ ...prepared,
1621
+ error: `failed to prepare injected intent ${marker.intent_id}: ${prepared.error}`,
1468
1622
  marker,
1469
- intent_id: intent.intent_id,
1470
- starting_status: startingStatus,
1471
- final_status: startResult.intent.status,
1472
- planned,
1473
- started: true,
1474
- run_id: startResult.run_id,
1475
- turn_id: startResult.turn_id,
1476
- role: startResult.role,
1477
- intent: startResult.intent,
1478
- exitCode: 0,
1479
1623
  };
1480
1624
  }
1481
1625
 
1482
- if (intent.status === 'executing') {
1626
+ if (prepared.final_status === 'executing') {
1483
1627
  clearPreemptionMarker(root);
1484
1628
  return {
1485
1629
  ok: true,
1486
1630
  marker,
1487
- intent_id: intent.intent_id,
1488
- starting_status: startingStatus,
1489
- final_status: intent.status,
1490
- planned,
1491
- started: false,
1492
- run_id: intent.target_run || null,
1493
- turn_id: intent.target_turn || null,
1494
- role: options.role || null,
1495
- intent,
1631
+ intent_id: prepared.intent_id,
1632
+ starting_status: prepared.starting_status,
1633
+ final_status: prepared.final_status,
1634
+ planned: prepared.planned,
1635
+ started: prepared.started,
1636
+ run_id: prepared.run_id,
1637
+ turn_id: prepared.turn_id,
1638
+ role: prepared.role,
1639
+ intent: prepared.intent,
1496
1640
  exitCode: 0,
1497
1641
  };
1498
1642
  }
1499
1643
 
1500
1644
  return {
1501
1645
  ok: false,
1502
- error: `cannot consume preemption marker from intent status "${intent.status}"`,
1646
+ error: `cannot consume preemption marker from intent status "${prepared.final_status}"`,
1503
1647
  marker,
1504
- intent_id: intent.intent_id,
1505
- intent_status: intent.status,
1648
+ intent_id: prepared.intent_id,
1649
+ intent_status: prepared.final_status,
1506
1650
  exitCode: 1,
1507
1651
  };
1508
1652
  }
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import { loadAllChainReports, loadChainReport, loadLatestChainReport } from './chain-reports.js';
4
4
  import { buildPlanProgressSummary, loadLatestPlan } from './mission-plans.js';
5
5
  import { getActiveRepoDecisions } from './repo-decisions.js';
6
+ import { getCoordinatorStatus } from './coordinator-state.js';
6
7
 
7
8
  const MISSION_ATTENTION_TERMINALS = new Set(['operator_abort', 'parent_validation_failed']);
8
9
  const MISSION_ATTENTION_RUN_STATUSES = new Set(['blocked', 'failed']);
@@ -143,9 +144,21 @@ export function buildMissionSnapshot(root, missionArtifact) {
143
144
  const latestPlan = loadLatestPlan(root, missionArtifact.mission_id);
144
145
  const activeRepoDecisions = getActiveRepoDecisions(root);
145
146
 
147
+ // Load coordinator status if mission is bound to a multi-repo coordinator
148
+ let coordinatorStatus = null;
149
+ if (missionArtifact.coordinator && missionArtifact.coordinator.super_run_id) {
150
+ const workspacePath = missionArtifact.coordinator.workspace_path || root;
151
+ try {
152
+ const cs = getCoordinatorStatus(workspacePath);
153
+ coordinatorStatus = cs || { unreachable: true, super_run_id: missionArtifact.coordinator.super_run_id };
154
+ } catch {
155
+ coordinatorStatus = { unreachable: true, super_run_id: missionArtifact.coordinator.super_run_id };
156
+ }
157
+ }
158
+
146
159
  return {
147
160
  ...missionArtifact,
148
- derived_status: deriveMissionStatus(missionArtifact, chains, missingChainIds),
161
+ derived_status: deriveMissionStatus(missionArtifact, chains, missingChainIds, coordinatorStatus),
149
162
  chain_count: chainIds.length,
150
163
  attached_chain_count: chains.length,
151
164
  missing_chain_ids: missingChainIds,
@@ -155,6 +168,7 @@ export function buildMissionSnapshot(root, missionArtifact) {
155
168
  latest_terminal_reason: latestChain?.terminal_reason || null,
156
169
  latest_plan: buildPlanProgressSummary(latestPlan),
157
170
  active_repo_decisions_count: activeRepoDecisions.length,
171
+ coordinator_status: coordinatorStatus,
158
172
  chains,
159
173
  };
160
174
  }
@@ -163,19 +177,24 @@ export function loadAllMissionSnapshots(root) {
163
177
  return loadAllMissionArtifacts(root).map((mission) => buildMissionSnapshot(root, mission));
164
178
  }
165
179
 
166
- function deriveMissionStatus(missionArtifact, chains, missingChainIds) {
180
+ function deriveMissionStatus(missionArtifact, chains, missingChainIds, coordinatorStatus) {
167
181
  if (missionArtifact.status && missionArtifact.status !== 'active') {
168
182
  return missionArtifact.status;
169
183
  }
184
+ // Coordinator-bound missions: check coordinator health
185
+ if (coordinatorStatus && !coordinatorStatus.unreachable && coordinatorStatus.status === 'blocked') {
186
+ return 'needs_attention';
187
+ }
170
188
  if (missingChainIds.length > 0) return 'degraded';
171
- if (chains.length === 0) return 'planned';
189
+ if (chains.length === 0 && !coordinatorStatus) return 'planned';
172
190
  if (chains.some((chain) => (
173
191
  MISSION_ATTENTION_TERMINALS.has(chain.terminal_reason)
174
192
  || (chain.runs || []).some((run) => MISSION_ATTENTION_RUN_STATUSES.has(run.status))
175
193
  ))) {
176
194
  return 'needs_attention';
177
195
  }
178
- return 'progressing';
196
+ if (coordinatorStatus && !coordinatorStatus.unreachable) return 'progressing';
197
+ return chains.length > 0 ? 'progressing' : 'planned';
179
198
  }
180
199
 
181
200
  export function loadLatestMissionSnapshot(root) {
@@ -196,3 +215,36 @@ export function loadMissionAttachmentTarget(root, missionId) {
196
215
  if (missionId) return loadMissionArtifact(root, missionId);
197
216
  return loadLatestMissionArtifact(root);
198
217
  }
218
+
219
+ /**
220
+ * Bind a coordinator super_run_id to a mission artifact.
221
+ *
222
+ * @param {string} root - project root
223
+ * @param {string} missionId - mission to bind
224
+ * @param {{ super_run_id: string, config_path: string, workspace_path?: string }} coordinatorRef
225
+ * @returns {{ ok: boolean, mission?: object, error?: string }}
226
+ */
227
+ export function bindCoordinatorToMission(root, missionId, coordinatorRef) {
228
+ const mission = loadMissionArtifact(root, missionId);
229
+ if (!mission) {
230
+ return { ok: false, error: `Mission not found: ${missionId}` };
231
+ }
232
+
233
+ if (!coordinatorRef || typeof coordinatorRef.super_run_id !== 'string') {
234
+ return { ok: false, error: 'coordinator super_run_id is required' };
235
+ }
236
+
237
+ const updated = {
238
+ ...mission,
239
+ coordinator: {
240
+ super_run_id: coordinatorRef.super_run_id,
241
+ config_path: coordinatorRef.config_path || null,
242
+ workspace_path: coordinatorRef.workspace_path || '.',
243
+ },
244
+ updated_at: new Date().toISOString(),
245
+ };
246
+
247
+ mkdirSync(getMissionsDir(root), { recursive: true });
248
+ writeFileSync(join(getMissionsDir(root), `${updated.mission_id}.json`), JSON.stringify(updated, null, 2));
249
+ return { ok: true, mission: updated };
250
+ }
@@ -330,6 +330,53 @@ export function detectConfigVersion(raw) {
330
330
  return null;
331
331
  }
332
332
 
333
+ function formatInvalidReviewOnlyLocalCliBindingError(roleId, runtimeId) {
334
+ return `Role "${roleId}" uses invalid review_only + local_cli binding on runtime "${runtimeId}" — change write_authority to "authoritative" for local CLI automation, or move the role to "manual", "api_proxy", "mcp", or "remote_agent"`;
335
+ }
336
+
337
+ function formatInvalidAuthoritativeBindingError(roleId, runtimeId, runtimeType, writeAuthority) {
338
+ return `Role "${roleId}" has write_authority "${writeAuthority}" but uses ${runtimeType} runtime "${runtimeId}" — ${runtimeType} only supports review_only and proposed roles`;
339
+ }
340
+
341
+ export function findAuthorityRuntimeBindingIssues(data) {
342
+ const issues = [];
343
+
344
+ if (!data?.roles || !data?.runtimes) {
345
+ return issues;
346
+ }
347
+
348
+ for (const [roleId, role] of Object.entries(data.roles)) {
349
+ if (!role?.runtime || !data.runtimes[role.runtime]) {
350
+ continue;
351
+ }
352
+
353
+ const runtime = data.runtimes[role.runtime];
354
+ const contract = getRoleRuntimeCapabilityContract(roleId, role, runtime);
355
+
356
+ if (contract.effective_write_path === 'invalid_review_only_binding') {
357
+ issues.push({
358
+ role_id: roleId,
359
+ runtime_id: role.runtime,
360
+ runtime_type: runtime.type,
361
+ write_authority: role.write_authority,
362
+ effective_write_path: contract.effective_write_path,
363
+ message: formatInvalidReviewOnlyLocalCliBindingError(roleId, role.runtime),
364
+ });
365
+ } else if (contract.effective_write_path === 'invalid_authoritative_binding') {
366
+ issues.push({
367
+ role_id: roleId,
368
+ runtime_id: role.runtime,
369
+ runtime_type: runtime.type,
370
+ write_authority: role.write_authority,
371
+ effective_write_path: contract.effective_write_path,
372
+ message: formatInvalidAuthoritativeBindingError(roleId, role.runtime, runtime.type, role.write_authority),
373
+ });
374
+ }
375
+ }
376
+
377
+ return issues;
378
+ }
379
+
333
380
  /**
334
381
  * Validate a governed config.
335
382
  * Returns { ok, errors }.
@@ -458,21 +505,9 @@ export function validateV4Config(data, projectRoot) {
458
505
  }
459
506
  }
460
507
 
461
- // Cross-reference: review_only roles should not use authoritative runtimes
462
- if (data.roles && data.runtimes) {
463
- for (const [id, role] of Object.entries(data.roles)) {
464
- if (role.runtime && data.runtimes[role.runtime]) {
465
- const rt = data.runtimes[role.runtime];
466
- const contract = getRoleRuntimeCapabilityContract(id, role, rt);
467
- if (contract.effective_write_path === 'invalid_review_only_binding') {
468
- errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
469
- } else if (contract.effective_write_path === 'invalid_authoritative_binding') {
470
- errors.push(
471
- `Role "${id}" has write_authority "${role.write_authority}" but uses ${rt.type} runtime "${role.runtime}" — ${rt.type} only supports review_only and proposed roles`
472
- );
473
- }
474
- }
475
- }
508
+ // Cross-reference: role authority must match the runtime's write contract
509
+ for (const issue of findAuthorityRuntimeBindingIssues(data)) {
510
+ errors.push(issue.message);
476
511
  }
477
512
 
478
513
  // Routing (optional but validated if present)
@@ -540,7 +540,7 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority, op
540
540
  // The attribution system will handle later-accepted siblings correctly.
541
541
  warnings.push(`Undeclared file changes detected (likely from concurrent sibling turns): ${undeclared.join(', ')}`);
542
542
  } else {
543
- errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
543
+ errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}. If these files were changed by the operator (not the turn), add them to the dispatch baseline by committing or stashing them before dispatch.`);
544
544
  }
545
545
  }
546
546
  if (phantom.length > 0) {
@@ -701,7 +701,12 @@ function getWorkingTreeChanges(root) {
701
701
  }
702
702
  }
703
703
 
704
- function captureDirtyWorkspaceSnapshot(root) {
704
+ /**
705
+ * Capture hashes of all non-operational dirty files in the workspace.
706
+ * Used at baseline time AND at dispatch time to snapshot pre-existing dirt
707
+ * so acceptance can filter unchanged files (BUG-1 fix).
708
+ */
709
+ export function captureDirtyWorkspaceSnapshot(root) {
705
710
  const snapshot = {};
706
711
  for (const filePath of getWorkingTreeChanges(root).filter((filePath) => !isOperationalPath(filePath))) {
707
712
  snapshot[filePath] = getWorkspaceFileMarker(root, filePath);
@@ -18,6 +18,8 @@ export const VALID_RUN_EVENTS = [
18
18
  'turn_accepted',
19
19
  'turn_rejected',
20
20
  'turn_conflicted',
21
+ 'acceptance_failed',
22
+ 'turn_reissued',
21
23
  'run_blocked',
22
24
  'run_completed',
23
25
  'escalation_raised',
@@ -41,6 +43,7 @@ export const VALID_RUN_EVENTS = [
41
43
  * @param {string} [details.phase] - Current phase
42
44
  * @param {string} [details.status] - Current run status
43
45
  * @param {object} [details.turn] - Turn context (turn_id, role_id, etc.)
46
+ * @param {string} [details.intent_id] - Intake intent id when the event services queued intake work
44
47
  * @param {object} [details.payload] - Additional event-specific data
45
48
  * @returns {{ ok: boolean, event_id: string }}
46
49
  */
@@ -54,6 +57,7 @@ export function emitRunEvent(root, eventType, details = {}) {
54
57
  phase: details.phase || null,
55
58
  status: details.status || null,
56
59
  turn: details.turn || null,
60
+ intent_id: details.intent_id || null,
57
61
  payload: details.payload || {},
58
62
  };
59
63
 
@@ -23,6 +23,7 @@ import {
23
23
  rejectTurn,
24
24
  markRunBlocked,
25
25
  writeDispatchBundle,
26
+ refreshTurnBaselineSnapshot,
26
27
  getTurnStagingResultPath,
27
28
  approvePhaseGate,
28
29
  approveCompletionGate,
@@ -308,6 +309,8 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
308
309
  // ── Build dispatch contexts ──────────────────────────────────────────
309
310
  const contexts = [];
310
311
  for (const { turn, state: turnState } of turnsToDispatch) {
312
+ // BUG-1 fix: refresh baseline to capture files dirtied between assignment and dispatch
313
+ refreshTurnBaselineSnapshot(root, turn.turn_id);
311
314
  const bundleResult = writeDispatchBundle(root, turnState, config, { turnId: turn.turn_id });
312
315
  if (!bundleResult.ok) {
313
316
  errors.push(`writeDispatchBundle(${turn.assigned_role}): ${bundleResult.error}`);
@@ -442,6 +445,8 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
442
445
  const roleId = turn.assigned_role;
443
446
  const history = [];
444
447
 
448
+ // BUG-1 fix: refresh baseline to capture files dirtied between assignment and dispatch
449
+ refreshTurnBaselineSnapshot(root, turn.turn_id);
445
450
  const bundleResult = writeDispatchBundle(root, assignState, config);
446
451
  if (!bundleResult.ok) {
447
452
  errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
@@ -39,6 +39,8 @@ export {
39
39
  getActiveTurn,
40
40
  acquireAcceptanceLock as acquireLock,
41
41
  releaseAcceptanceLock as releaseLock,
42
+ refreshTurnBaselineSnapshot,
43
+ reissueTurn,
42
44
  } from './governed-state.js';
43
45
 
44
46
  // ── Dispatch ────────────────────────────────────────────────────────────────
@@ -103,8 +103,24 @@ export function writeSessionCheckpoint(root, state, reason, extra = {}) {
103
103
  const pendingGate = state.pending_phase_transition?.gate || state.pending_transition?.gate || null;
104
104
  const pendingRunCompletion = state.pending_run_completion?.gate || null;
105
105
 
106
- // Capture git baseline for repo-drift detection
107
- const baselineRef = extra.baseline_ref || captureBaselineRef(root);
106
+ // Capture git baseline for repo-drift detection.
107
+ // When a turn_baseline from captureBaseline() is provided, derive
108
+ // baseline_ref from it so session.json and state.json always agree
109
+ // on workspace-dirty status (BUG-2 fix).
110
+ let baselineRef;
111
+ if (extra.turn_baseline) {
112
+ baselineRef = {
113
+ git_head: extra.turn_baseline.head_ref || null,
114
+ git_branch: null,
115
+ workspace_dirty: !extra.turn_baseline.clean,
116
+ };
117
+ // Fill in git_branch if available
118
+ try {
119
+ baselineRef.git_branch = shellExec('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
120
+ } catch { /* non-fatal */ }
121
+ } else {
122
+ baselineRef = extra.baseline_ref || captureBaselineRef(root);
123
+ }
108
124
 
109
125
  const checkpoint = {
110
126
  session_id: sessionId,