switchman-dev 0.1.6 → 0.1.7

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.
@@ -1,13 +1,15 @@
1
1
  import { spawn, spawnSync } from 'child_process';
2
- import { mkdirSync, writeFileSync } from 'fs';
3
- import { join } from 'path';
2
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { basename, join } from 'path';
4
5
 
5
- import { completeLeaseTask, createTask, failLeaseTask, getTaskSpec, listAuditEvents, listLeases, listTasks, listWorktrees, logAuditEvent, retryTask, startTaskLease, upsertTaskSpec } from './db.js';
6
+ import { completeLeaseTask, createTask, createTempResource, failLeaseTask, finishOperationJournalEntry, getTaskSpec, listAuditEvents, listBoundaryValidationStates, listDependencyInvalidations, listLeases, listMergeQueue, listPolicyOverrides, listTasks, listTempResources, listWorktrees, logAuditEvent, retryTask, startOperationJournalEntry, startTaskLease, updateTempResource, upsertTaskSpec } from './db.js';
6
7
  import { scanAllWorktrees } from './detector.js';
7
8
  import { runAiMergeGate } from './merge-gate.js';
8
9
  import { evaluateTaskOutcome } from './outcome.js';
10
+ import { loadChangePolicy } from './policy.js';
9
11
  import { buildTaskSpec, planPipelineTasks } from './planner.js';
10
- import { getWorktreeBranch } from './git.js';
12
+ import { getWorktreeBranch, gitBranchExists, gitMaterializeIntegrationBranch, gitPrepareIntegrationRecoveryWorktree, gitRemoveWorktree, gitRevParse, listGitWorktrees } from './git.js';
11
13
 
12
14
  function sleepSync(ms) {
13
15
  if (ms > 0) {
@@ -37,6 +39,10 @@ function parseDependencies(description) {
37
39
  .filter(Boolean);
38
40
  }
39
41
 
42
+ function stringifyResourceDetails(details) {
43
+ return JSON.stringify(details);
44
+ }
45
+
40
46
  function getPipelineMetadata(db, pipelineId) {
41
47
  const events = listAuditEvents(db, { eventType: 'pipeline_created', limit: 500 });
42
48
  for (const event of events) {
@@ -52,6 +58,276 @@ function getPipelineMetadata(db, pipelineId) {
52
58
  return null;
53
59
  }
54
60
 
61
+ function parseAuditDetails(details) {
62
+ try {
63
+ return JSON.parse(details || '{}');
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ function pipelineOwnsAuditEvent(event, pipelineId) {
70
+ if (event.task_id?.startsWith(`${pipelineId}-`)) return true;
71
+ const details = parseAuditDetails(event.details);
72
+ if (details.pipeline_id === pipelineId) return true;
73
+ if (details.source_pipeline_id === pipelineId) return true;
74
+ if (Array.isArray(details.task_ids) && details.task_ids.some((taskId) => String(taskId).startsWith(`${pipelineId}-`))) {
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function buildPipelineTrustAudit(db, pipelineId, { limit = 8 } = {}) {
81
+ const interestingEventTypes = new Set([
82
+ 'boundary_validation_state',
83
+ 'dependency_invalidations_updated',
84
+ 'pipeline_followups_created',
85
+ 'policy_override_created',
86
+ 'policy_override_revoked',
87
+ 'task_retried',
88
+ 'pipeline_task_retry_scheduled',
89
+ ]);
90
+
91
+ const events = listAuditEvents(db, { limit: 2000 })
92
+ .filter((event) => interestingEventTypes.has(event.event_type))
93
+ .filter((event) => pipelineOwnsAuditEvent(event, pipelineId))
94
+ .slice(0, limit);
95
+
96
+ const mappedEvents = events.map((event) => {
97
+ const details = parseAuditDetails(event.details);
98
+
99
+ switch (event.event_type) {
100
+ case 'boundary_validation_state': {
101
+ const missing = details.missing_task_types || [];
102
+ const summary = details.status === 'satisfied'
103
+ ? 'Policy validation requirements are currently satisfied.'
104
+ : missing.length > 0
105
+ ? `Policy validation is waiting on ${missing.join(', ')}.`
106
+ : `Policy validation state changed to ${details.status || 'pending'}.`;
107
+ return {
108
+ category: 'policy',
109
+ created_at: event.created_at,
110
+ event_type: event.event_type,
111
+ status: event.status,
112
+ reason_code: event.reason_code || null,
113
+ summary,
114
+ missing_task_types: missing,
115
+ next_action: missing.length > 0 ? `switchman pipeline review ${pipelineId}` : `switchman pipeline status ${pipelineId}`,
116
+ };
117
+ }
118
+ case 'dependency_invalidations_updated':
119
+ {
120
+ const reasonTypes = details.reason_types || [];
121
+ const revalidationSets = details.revalidation_sets || [];
122
+ const reasonSummary = revalidationSets.length > 0
123
+ ? ` across ${revalidationSets.join(', ')} revalidation`
124
+ : reasonTypes.length > 0
125
+ ? ` across ${reasonTypes.join(', ')}`
126
+ : '';
127
+ return {
128
+ category: 'stale',
129
+ created_at: event.created_at,
130
+ event_type: event.event_type,
131
+ status: event.status,
132
+ reason_code: event.reason_code || null,
133
+ summary: `A shared change marked ${details.stale_count || 0} dependent task${details.stale_count === 1 ? '' : 's'} stale${reasonSummary}.`,
134
+ affected_task_ids: details.affected_task_ids || [],
135
+ next_action: `switchman explain stale --pipeline ${pipelineId}`,
136
+ };
137
+ }
138
+ case 'pipeline_followups_created':
139
+ return {
140
+ category: 'remediation',
141
+ created_at: event.created_at,
142
+ event_type: event.event_type,
143
+ status: event.status,
144
+ reason_code: event.reason_code || null,
145
+ summary: details.created_count > 0
146
+ ? `Created ${details.created_count} follow-up task${details.created_count === 1 ? '' : 's'} to satisfy validation or policy work.`
147
+ : 'No new follow-up tasks were needed after review.',
148
+ created_count: details.created_count || 0,
149
+ next_action: details.created_count > 0 ? `switchman pipeline status ${pipelineId}` : `switchman pipeline status ${pipelineId}`,
150
+ };
151
+ case 'policy_override_created':
152
+ return {
153
+ category: 'policy',
154
+ created_at: event.created_at,
155
+ event_type: event.event_type,
156
+ status: event.status,
157
+ reason_code: event.reason_code || null,
158
+ summary: `Policy override ${details.override_id || 'unknown'} was approved${details.task_types?.length ? ` for ${details.task_types.join(', ')}` : ''}${details.reason ? `: ${details.reason}` : ''}.`,
159
+ next_action: `switchman pipeline status ${pipelineId}`,
160
+ };
161
+ case 'policy_override_revoked':
162
+ return {
163
+ category: 'policy',
164
+ created_at: event.created_at,
165
+ event_type: event.event_type,
166
+ status: event.status,
167
+ reason_code: event.reason_code || null,
168
+ summary: `Policy override ${details.override_id || 'unknown'} was revoked${details.revoked_reason ? `: ${details.revoked_reason}` : ''}.`,
169
+ next_action: `switchman pipeline status ${pipelineId}`,
170
+ };
171
+ case 'task_retried':
172
+ case 'pipeline_task_retry_scheduled':
173
+ return {
174
+ category: 'remediation',
175
+ created_at: event.created_at,
176
+ event_type: event.event_type,
177
+ status: event.status,
178
+ reason_code: event.reason_code || null,
179
+ summary: `Scheduled revalidation for ${event.task_id || 'a pipeline task'}.`,
180
+ task_id: event.task_id || null,
181
+ next_action: `switchman pipeline status ${pipelineId}`,
182
+ };
183
+ default:
184
+ return {
185
+ category: 'trust',
186
+ created_at: event.created_at,
187
+ event_type: event.event_type,
188
+ status: event.status,
189
+ reason_code: event.reason_code || null,
190
+ summary: event.event_type,
191
+ next_action: `switchman pipeline status ${pipelineId}`,
192
+ };
193
+ }
194
+ });
195
+
196
+ const staleClusterEntries = buildStaleClusters(listDependencyInvalidations(db, { pipelineId }), { pipelineId })
197
+ .map((cluster) => ({
198
+ category: 'stale',
199
+ created_at: cluster.invalidations[0]?.created_at || null,
200
+ event_type: 'stale_cluster_active',
201
+ status: cluster.severity === 'block' ? 'denied' : 'warn',
202
+ reason_code: 'dependent_work_stale',
203
+ summary: `${cluster.title}.`,
204
+ affected_task_ids: cluster.affected_task_ids,
205
+ next_action: cluster.next_action,
206
+ }));
207
+
208
+ const staleWaveEntries = summarizeStaleCausalWaves(
209
+ buildStaleClusters(listDependencyInvalidations(db, { pipelineId }), { pipelineId }),
210
+ ).map((wave) => ({
211
+ category: 'stale',
212
+ created_at: null,
213
+ event_type: 'stale_wave_active',
214
+ status: 'warn',
215
+ reason_code: 'dependent_work_stale',
216
+ summary: `${wave.summary}. Affects ${wave.affected_pipeline_ids.join(', ') || 'unknown'} across ${wave.cluster_count} stale cluster${wave.cluster_count === 1 ? '' : 's'}.`,
217
+ affected_pipeline_ids: wave.affected_pipeline_ids,
218
+ next_action: `switchman explain stale --pipeline ${pipelineId}`,
219
+ }));
220
+
221
+ return [...mappedEvents, ...staleWaveEntries, ...staleClusterEntries]
222
+ .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
223
+ .slice(0, limit);
224
+ }
225
+
226
+ function buildPipelinePolicyEvidence(status, requiredTaskTypes = [], { completedLeaseByTask = new Map(), completionAuditByTask = new Map() } = {}) {
227
+ const completedTasks = (status.tasks || []).filter((task) => task.status === 'done' && task.task_spec?.task_type);
228
+ const pipelineId = status.pipeline_id;
229
+ const evidenceObjects = [];
230
+ const evidenceByTaskType = {};
231
+
232
+ for (const taskType of requiredTaskTypes) {
233
+ evidenceByTaskType[taskType] = completedTasks
234
+ .filter((task) => task.task_spec?.task_type === taskType)
235
+ .map((task) => {
236
+ const lease = completedLeaseByTask.get(task.id) || null;
237
+ const auditEvent = completionAuditByTask.get(task.id) || null;
238
+ const evidence = {
239
+ evidence_id: `policy-evidence:${pipelineId}:${taskType}:${task.id}`,
240
+ schema_version: 1,
241
+ requirement_key: `completed_task_type:${taskType}`,
242
+ requirement_type: 'completed_task_type',
243
+ pipeline_id: pipelineId,
244
+ task_id: task.id,
245
+ title: task.title,
246
+ task_type: task.task_spec?.task_type || null,
247
+ worktree: task.worktree || task.suggested_worktree || null,
248
+ artifact_path: task.task_spec?.primary_output_path || null,
249
+ subsystem_tags: task.task_spec?.subsystem_tags || [],
250
+ satisfied_at: task.completed_at || auditEvent?.created_at || null,
251
+ satisfied_by: {
252
+ actor_type: lease?.agent ? 'agent' : 'task_completion',
253
+ agent: lease?.agent || null,
254
+ worktree: lease?.worktree || task.worktree || task.suggested_worktree || null,
255
+ lease_id: lease?.id || null,
256
+ audit_event_id: auditEvent?.id || null,
257
+ audit_event_type: auditEvent?.event_type || null,
258
+ },
259
+ };
260
+ evidenceObjects.push(evidence);
261
+ return evidence;
262
+ });
263
+ }
264
+
265
+ return {
266
+ evidence_by_task_type: evidenceByTaskType,
267
+ evidence_objects: evidenceObjects,
268
+ };
269
+ }
270
+
271
+ function buildPipelinePolicySatisfactionHistory(policyState) {
272
+ return (policyState.evidence_objects || [])
273
+ .map((evidence) => ({
274
+ category: 'policy',
275
+ created_at: evidence.satisfied_at || null,
276
+ event_type: 'policy_requirement_satisfied',
277
+ status: 'allowed',
278
+ reason_code: null,
279
+ summary: `Policy requirement ${evidence.requirement_key} is satisfied by ${evidence.task_id}.`,
280
+ requirement_key: evidence.requirement_key,
281
+ evidence_id: evidence.evidence_id,
282
+ task_id: evidence.task_id,
283
+ satisfied_by: evidence.satisfied_by,
284
+ next_action: `switchman pipeline status ${policyState.pipeline_id || '<pipelineId>'}`,
285
+ }))
286
+ .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
287
+ }
288
+
289
+ function buildPipelinePolicyOverrideState(db, pipelineId, requiredTaskTypes = []) {
290
+ if (!db || !pipelineId) {
291
+ return {
292
+ active_overrides: [],
293
+ active_task_types: [],
294
+ active_requirement_keys: [],
295
+ override_history: [],
296
+ };
297
+ }
298
+
299
+ const overrides = listPolicyOverrides(db, { pipelineId, limit: 200 });
300
+ const activeOverrides = overrides.filter((entry) => entry.status === 'active');
301
+ const activeTaskTypes = uniq(activeOverrides.flatMap((entry) => entry.task_types || []));
302
+ const activeRequirementKeys = uniq(activeOverrides.flatMap((entry) => entry.requirement_keys || []));
303
+
304
+ const overrideHistory = overrides.map((entry) => ({
305
+ category: 'policy',
306
+ created_at: entry.status === 'active' ? entry.created_at : (entry.revoked_at || entry.created_at),
307
+ event_type: entry.status === 'active' ? 'policy_override_active' : 'policy_override_revoked',
308
+ status: entry.status === 'active' ? 'warn' : 'info',
309
+ reason_code: entry.status === 'active' ? 'policy_override' : 'policy_override_revoked',
310
+ override_id: entry.id,
311
+ task_types: entry.task_types || [],
312
+ requirement_keys: entry.requirement_keys || [],
313
+ approved_by: entry.approved_by || null,
314
+ summary: entry.status === 'active'
315
+ ? `Policy override ${entry.id} allows ${entry.task_types?.join(', ') || 'governed requirements'} for pipeline ${pipelineId}.`
316
+ : `Policy override ${entry.id} was revoked${entry.revoked_reason ? `: ${entry.revoked_reason}` : ''}.`,
317
+ next_action: `switchman pipeline status ${pipelineId}`,
318
+ }));
319
+
320
+ const missingButOverridden = requiredTaskTypes.filter((taskType) => activeTaskTypes.includes(taskType));
321
+
322
+ return {
323
+ active_overrides: activeOverrides,
324
+ active_task_types: activeTaskTypes,
325
+ active_requirement_keys: activeRequirementKeys,
326
+ missing_but_overridden: missingButOverridden,
327
+ override_history: overrideHistory,
328
+ };
329
+ }
330
+
55
331
  function withTaskSpec(db, task) {
56
332
  return {
57
333
  ...task,
@@ -67,6 +343,373 @@ function nextPipelineTaskId(tasks, pipelineId) {
67
343
  return `${pipelineId}-${String(nextNumber).padStart(2, '0')}`;
68
344
  }
69
345
 
346
+ function rankEnforcement(level = 'none') {
347
+ return level === 'blocked' ? 2 : level === 'warn' ? 1 : 0;
348
+ }
349
+
350
+ function normalizeDependencyInvalidation(item) {
351
+ const details = item.details || {};
352
+ const objectNames = Array.isArray(details.object_names) ? details.object_names : [];
353
+ const contractNames = Array.isArray(details.contract_names) ? details.contract_names : [];
354
+ const modulePaths = Array.isArray(details.module_paths) ? details.module_paths : [];
355
+ return {
356
+ ...item,
357
+ severity: item.severity || details.severity || (item.reason_type === 'semantic_contract_drift' ? 'blocked' : 'warn'),
358
+ details,
359
+ revalidation_set: details.revalidation_set || (item.reason_type === 'semantic_contract_drift' ? 'contract' : item.reason_type === 'semantic_object_overlap' ? 'semantic_object' : item.reason_type === 'shared_module_drift' ? 'shared_module' : item.reason_type === 'subsystem_overlap' ? 'subsystem' : 'scope'),
360
+ stale_area: item.reason_type === 'subsystem_overlap'
361
+ ? `subsystem:${item.subsystem_tag}`
362
+ : item.reason_type === 'semantic_contract_drift'
363
+ ? `contract:${contractNames.join('|') || 'unknown'}`
364
+ : item.reason_type === 'semantic_object_overlap'
365
+ ? `object:${objectNames.join('|') || 'unknown'}`
366
+ : item.reason_type === 'shared_module_drift'
367
+ ? `module:${modulePaths.join('|') || 'unknown'}`
368
+ : `${item.source_scope_pattern} ↔ ${item.affected_scope_pattern}`,
369
+ summary: item.reason_type === 'semantic_contract_drift'
370
+ ? `${details?.source_task_title || item.source_task_id} changed shared contract ${contractNames.join(', ') || 'unknown'}`
371
+ : item.reason_type === 'semantic_object_overlap'
372
+ ? `${details?.source_task_title || item.source_task_id} changed shared exported object ${objectNames.join(', ') || 'unknown'}`
373
+ : item.reason_type === 'shared_module_drift'
374
+ ? `${details?.source_task_title || item.source_task_id} changed shared module ${modulePaths.join(', ') || 'unknown'} used by ${(details.dependent_files || []).join(', ') || item.affected_task_id}`
375
+ : `${details?.source_task_title || item.source_task_id} changed shared ${item.reason_type === 'subsystem_overlap' ? `subsystem:${item.subsystem_tag}` : 'scope'}`,
376
+ };
377
+ }
378
+
379
+ function buildStaleClusters(invalidations = [], { pipelineId = null } = {}) {
380
+ const clusters = new Map();
381
+ for (const invalidation of invalidations.map(normalizeDependencyInvalidation)) {
382
+ if (pipelineId && invalidation.affected_pipeline_id !== pipelineId) continue;
383
+ const clusterKey = invalidation.affected_pipeline_id
384
+ ? `pipeline:${invalidation.affected_pipeline_id}`
385
+ : `task:${invalidation.affected_task_id}`;
386
+ if (!clusters.has(clusterKey)) {
387
+ clusters.set(clusterKey, {
388
+ key: clusterKey,
389
+ affected_pipeline_id: invalidation.affected_pipeline_id || null,
390
+ affected_task_ids: new Set(),
391
+ source_task_titles: new Set(),
392
+ source_worktrees: new Set(),
393
+ stale_areas: new Set(),
394
+ revalidation_sets: new Set(),
395
+ dependent_files: new Set(),
396
+ dependent_areas: new Set(),
397
+ module_paths: new Set(),
398
+ invalidations: [],
399
+ severity: 'warn',
400
+ highest_affected_priority: 0,
401
+ highest_source_priority: 0,
402
+ });
403
+ }
404
+ const cluster = clusters.get(clusterKey);
405
+ cluster.invalidations.push(invalidation);
406
+ cluster.affected_task_ids.add(invalidation.affected_task_id);
407
+ if (invalidation.details?.source_task_title) cluster.source_task_titles.add(invalidation.details.source_task_title);
408
+ if (invalidation.source_worktree) cluster.source_worktrees.add(invalidation.source_worktree);
409
+ cluster.stale_areas.add(invalidation.stale_area);
410
+ if (invalidation.revalidation_set) cluster.revalidation_sets.add(invalidation.revalidation_set);
411
+ for (const filePath of invalidation.details?.dependent_files || []) cluster.dependent_files.add(filePath);
412
+ for (const area of invalidation.details?.dependent_areas || []) cluster.dependent_areas.add(area);
413
+ for (const modulePath of invalidation.details?.module_paths || []) cluster.module_paths.add(modulePath);
414
+ if (invalidation.severity === 'blocked') cluster.severity = 'block';
415
+ cluster.highest_affected_priority = Math.max(cluster.highest_affected_priority, Number(invalidation.details?.affected_task_priority || 0));
416
+ cluster.highest_source_priority = Math.max(cluster.highest_source_priority, Number(invalidation.details?.source_task_priority || 0));
417
+ }
418
+
419
+ const clusterEntries = [...clusters.values()].map((cluster) => ({
420
+ key: cluster.key,
421
+ affected_pipeline_id: cluster.affected_pipeline_id,
422
+ affected_task_ids: [...cluster.affected_task_ids],
423
+ invalidation_count: cluster.invalidations.length,
424
+ source_task_ids: [...new Set(cluster.invalidations.map((item) => item.source_task_id).filter(Boolean))],
425
+ source_pipeline_ids: [...new Set(cluster.invalidations.map((item) => item.source_pipeline_id).filter(Boolean))],
426
+ source_task_titles: [...cluster.source_task_titles],
427
+ source_worktrees: [...cluster.source_worktrees],
428
+ stale_areas: [...cluster.stale_areas],
429
+ revalidation_sets: [...cluster.revalidation_sets],
430
+ dependent_files: [...cluster.dependent_files],
431
+ dependent_areas: [...cluster.dependent_areas],
432
+ module_paths: [...cluster.module_paths],
433
+ revalidation_set_type: cluster.revalidation_sets.has('contract')
434
+ ? 'contract'
435
+ : cluster.revalidation_sets.has('shared_module')
436
+ ? 'shared_module'
437
+ : cluster.revalidation_sets.has('semantic_object')
438
+ ? 'semantic_object'
439
+ : cluster.revalidation_sets.has('subsystem')
440
+ ? 'subsystem'
441
+ : 'scope',
442
+ rerun_priority: cluster.severity === 'block'
443
+ ? (cluster.revalidation_sets.has('contract') || cluster.highest_affected_priority >= 8 ? 'urgent' : 'high')
444
+ : cluster.revalidation_sets.has('shared_module') && cluster.dependent_files.size >= 3
445
+ ? 'high'
446
+ : cluster.highest_affected_priority >= 8
447
+ ? 'high'
448
+ : cluster.highest_affected_priority >= 5
449
+ ? 'medium'
450
+ : 'low',
451
+ rerun_priority_score: (cluster.severity === 'block' ? 100 : 0)
452
+ + (cluster.revalidation_sets.has('contract') ? 30 : cluster.revalidation_sets.has('shared_module') ? 20 : cluster.revalidation_sets.has('semantic_object') ? 15 : 0)
453
+ + (cluster.highest_affected_priority * 3)
454
+ + (cluster.dependent_files.size * 4)
455
+ + (cluster.dependent_areas.size * 2)
456
+ + cluster.module_paths.size
457
+ + cluster.invalidations.length,
458
+ rerun_breadth_score: (cluster.dependent_files.size * 4) + (cluster.dependent_areas.size * 2) + cluster.module_paths.size,
459
+ highest_affected_priority: cluster.highest_affected_priority,
460
+ highest_source_priority: cluster.highest_source_priority,
461
+ severity: cluster.severity,
462
+ invalidations: cluster.invalidations,
463
+ title: cluster.affected_pipeline_id
464
+ ? `Pipeline ${cluster.affected_pipeline_id} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`
465
+ : `${[...cluster.affected_task_ids][0]} has ${cluster.invalidations.length} stale ${cluster.revalidation_sets.has('contract') ? 'contract' : cluster.revalidation_sets.has('shared_module') ? 'shared-module' : cluster.revalidation_sets.has('semantic_object') ? 'semantic-object' : 'dependency'} invalidation${cluster.invalidations.length === 1 ? '' : 's'}`,
466
+ detail: `${[...cluster.source_task_titles][0] || cluster.invalidations[0]?.source_task_id || 'unknown source'} (${[...cluster.stale_areas].join(', ')})`,
467
+ next_action: cluster.affected_pipeline_id
468
+ ? `switchman task retry-stale --pipeline ${cluster.affected_pipeline_id}`
469
+ : `switchman task retry ${[...cluster.affected_task_ids][0]}`,
470
+ }));
471
+
472
+ const causeGroups = new Map();
473
+ for (const cluster of clusterEntries) {
474
+ const primary = cluster.invalidations[0] || {};
475
+ const details = primary.details || {};
476
+ const causeKey = cluster.revalidation_set_type === 'contract'
477
+ ? `contract:${(details.contract_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
478
+ : cluster.revalidation_set_type === 'shared_module'
479
+ ? `shared_module:${(details.module_paths || cluster.module_paths || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
480
+ : cluster.revalidation_set_type === 'semantic_object'
481
+ ? `semantic_object:${(details.object_names || []).join('|') || cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`
482
+ : `dependency:${cluster.stale_areas.join('|')}|source:${cluster.source_task_ids.join('|') || 'unknown'}`;
483
+ if (!causeGroups.has(causeKey)) causeGroups.set(causeKey, []);
484
+ causeGroups.get(causeKey).push(cluster);
485
+ }
486
+
487
+ for (const [causeKey, relatedClusters] of causeGroups.entries()) {
488
+ const relatedPipelines = [...new Set(relatedClusters.map((cluster) => cluster.affected_pipeline_id).filter(Boolean))];
489
+ const primary = relatedClusters[0];
490
+ const details = primary.invalidations[0]?.details || {};
491
+ const causeSummary = primary.revalidation_set_type === 'contract'
492
+ ? `shared contract drift in ${(details.contract_names || []).join(', ') || 'unknown contract'}`
493
+ : primary.revalidation_set_type === 'shared_module'
494
+ ? `shared module drift in ${(details.module_paths || primary.module_paths || []).join(', ') || 'unknown module'}`
495
+ : primary.revalidation_set_type === 'semantic_object'
496
+ ? `shared exported object drift in ${(details.object_names || []).join(', ') || 'unknown object'}`
497
+ : `shared dependency drift across ${primary.stale_areas.join(', ')}`;
498
+ for (let index = 0; index < relatedClusters.length; index += 1) {
499
+ relatedClusters[index].causal_group_id = `cause-${causeKey}`;
500
+ relatedClusters[index].causal_group_size = relatedClusters.length;
501
+ relatedClusters[index].causal_group_rank = index + 1;
502
+ relatedClusters[index].causal_group_summary = causeSummary;
503
+ relatedClusters[index].related_affected_pipelines = relatedPipelines;
504
+ }
505
+ }
506
+
507
+ return clusterEntries.sort((a, b) =>
508
+ b.rerun_priority_score - a.rerun_priority_score
509
+ || (a.severity === 'block' ? -1 : 1) - (b.severity === 'block' ? -1 : 1)
510
+ || (a.revalidation_set_type === 'contract' ? -1 : 1) - (b.revalidation_set_type === 'contract' ? -1 : 1)
511
+ || (a.revalidation_set_type === 'shared_module' ? -1 : 1) - (b.revalidation_set_type === 'shared_module' ? -1 : 1)
512
+ || b.invalidation_count - a.invalidation_count);
513
+ }
514
+
515
+ export function summarizeStaleCausalWaves(staleClusters = []) {
516
+ const grouped = new Map();
517
+ for (const cluster of staleClusters) {
518
+ if (!cluster.causal_group_id) continue;
519
+ if (!grouped.has(cluster.causal_group_id)) {
520
+ grouped.set(cluster.causal_group_id, {
521
+ causal_group_id: cluster.causal_group_id,
522
+ summary: cluster.causal_group_summary || cluster.title,
523
+ revalidation_set_type: cluster.revalidation_set_type,
524
+ source_task_ids: [...(cluster.source_task_ids || [])],
525
+ source_pipeline_ids: [...(cluster.source_pipeline_ids || [])],
526
+ related_affected_pipelines: new Set(cluster.related_affected_pipelines || []),
527
+ affected_pipeline_ids: new Set(),
528
+ cluster_count: 0,
529
+ invalidation_count: 0,
530
+ highest_rerun_priority_score: 0,
531
+ });
532
+ }
533
+ const entry = grouped.get(cluster.causal_group_id);
534
+ if (cluster.affected_pipeline_id) entry.affected_pipeline_ids.add(cluster.affected_pipeline_id);
535
+ for (const pipelineId of cluster.related_affected_pipelines || []) entry.related_affected_pipelines.add(pipelineId);
536
+ entry.cluster_count += 1;
537
+ entry.invalidation_count += cluster.invalidation_count || 0;
538
+ entry.highest_rerun_priority_score = Math.max(entry.highest_rerun_priority_score, cluster.rerun_priority_score || 0);
539
+ }
540
+
541
+ return [...grouped.values()]
542
+ .map((entry) => ({
543
+ ...entry,
544
+ affected_pipeline_ids: [...entry.affected_pipeline_ids],
545
+ related_affected_pipelines: [...entry.related_affected_pipelines],
546
+ }))
547
+ .sort((a, b) =>
548
+ b.highest_rerun_priority_score - a.highest_rerun_priority_score
549
+ || b.cluster_count - a.cluster_count
550
+ || b.invalidation_count - a.invalidation_count
551
+ || String(a.summary).localeCompare(String(b.summary)));
552
+ }
553
+
554
+ export function getPipelineStaleWaveContext(db, pipelineId) {
555
+ if (!pipelineId) {
556
+ return {
557
+ stale_clusters: [],
558
+ stale_causal_waves: [],
559
+ shared_wave_count: 0,
560
+ largest_wave_size: 0,
561
+ primary_wave: null,
562
+ };
563
+ }
564
+
565
+ const staleClusters = buildStaleClusters(listDependencyInvalidations(db), {});
566
+ const staleCausalWaves = summarizeStaleCausalWaves(staleClusters)
567
+ .filter((wave) => wave.affected_pipeline_ids.includes(pipelineId));
568
+
569
+ return {
570
+ stale_clusters: staleClusters.filter((cluster) => cluster.affected_pipeline_id === pipelineId),
571
+ stale_causal_waves: staleCausalWaves,
572
+ shared_wave_count: staleCausalWaves.length,
573
+ largest_wave_size: staleCausalWaves.reduce((max, wave) => Math.max(max, Number(wave.cluster_count || 0)), 0),
574
+ primary_wave: staleCausalWaves[0] || null,
575
+ };
576
+ }
577
+
578
+ export function summarizePipelinePolicyState(db, status, changePolicy, boundaryValidations = []) {
579
+ const tasks = status.tasks || [];
580
+ const implementationTasks = tasks.filter((task) => task.task_spec?.task_type === 'implementation');
581
+ const completedTasks = tasks.filter((task) => task.status === 'done' && task.task_spec?.task_type);
582
+ const completedLeaseByTask = new Map();
583
+ for (const lease of status.leases || []) {
584
+ if (lease.status !== 'completed') continue;
585
+ if (!completedLeaseByTask.has(lease.task_id)) {
586
+ completedLeaseByTask.set(lease.task_id, lease);
587
+ }
588
+ }
589
+ const completionAuditByTask = new Map();
590
+ for (const event of status.audit_events || []) {
591
+ if (event.event_type !== 'task_completed' || !event.task_id) continue;
592
+ if (!completionAuditByTask.has(event.task_id)) {
593
+ completionAuditByTask.set(event.task_id, event);
594
+ }
595
+ }
596
+ const completedTaskTypes = new Set(completedTasks.map((task) => task.task_spec?.task_type).filter(Boolean));
597
+ const governedDomains = uniq(
598
+ implementationTasks
599
+ .flatMap((task) => task.task_spec?.subsystem_tags || [])
600
+ .filter((domain) => changePolicy.domain_rules?.[domain]),
601
+ );
602
+ const activeRules = governedDomains.map((domain) => ({
603
+ domain,
604
+ ...(changePolicy.domain_rules?.[domain] || { required_completed_task_types: [], enforcement: 'none', rationale: [] }),
605
+ }));
606
+ const requiredTaskTypes = uniq([
607
+ ...implementationTasks.flatMap((task) => task.task_spec?.validation_rules?.required_completed_task_types || []),
608
+ ...activeRules.flatMap((rule) => rule.required_completed_task_types || []),
609
+ ]);
610
+ const rawMissingTaskTypes = uniq([
611
+ ...boundaryValidations.flatMap((validation) => validation.missing_task_types || []),
612
+ ...requiredTaskTypes.filter((taskType) => !completedTaskTypes.has(taskType)),
613
+ ]);
614
+ const overrideState = buildPipelinePolicyOverrideState(db, status.pipeline_id, rawMissingTaskTypes);
615
+ const missingTaskTypes = rawMissingTaskTypes.filter((taskType) => !overrideState.active_task_types.includes(taskType));
616
+ const overriddenTaskTypes = rawMissingTaskTypes.filter((taskType) => overrideState.active_task_types.includes(taskType));
617
+ const satisfiedTaskTypes = requiredTaskTypes.filter((taskType) => completedTaskTypes.has(taskType));
618
+ const evidenceState = buildPipelinePolicyEvidence(status, requiredTaskTypes, {
619
+ completedLeaseByTask,
620
+ completionAuditByTask,
621
+ });
622
+ const evidenceByTaskType = evidenceState.evidence_by_task_type;
623
+ const requirementStatus = requiredTaskTypes.map((taskType) => ({
624
+ task_type: taskType,
625
+ satisfied: satisfiedTaskTypes.includes(taskType),
626
+ overridden: overriddenTaskTypes.includes(taskType),
627
+ override_ids: overrideState.active_overrides
628
+ .filter((entry) => (entry.task_types || []).includes(taskType))
629
+ .map((entry) => entry.id),
630
+ evidence: evidenceByTaskType[taskType] || [],
631
+ }));
632
+ const rationale = uniq([
633
+ ...implementationTasks.flatMap((task) => task.task_spec?.validation_rules?.rationale || []),
634
+ ...activeRules.flatMap((rule) => rule.rationale || []),
635
+ ...boundaryValidations.flatMap((validation) => validation.rationale || validation.details?.rationale || []),
636
+ ]);
637
+ const enforcement = [...implementationTasks.map((task) => task.task_spec?.validation_rules?.enforcement || 'none'), ...activeRules.map((rule) => rule.enforcement || 'none')]
638
+ .reduce((highest, current) => rankEnforcement(current) > rankEnforcement(highest) ? current : highest, 'none');
639
+
640
+ const policyState = {
641
+ active: governedDomains.length > 0 || boundaryValidations.length > 0,
642
+ domains: governedDomains,
643
+ active_rules: activeRules,
644
+ required_task_types: requiredTaskTypes,
645
+ satisfied_task_types: satisfiedTaskTypes,
646
+ raw_missing_task_types: rawMissingTaskTypes,
647
+ missing_task_types: missingTaskTypes,
648
+ overridden_task_types: overriddenTaskTypes,
649
+ requirement_status: requirementStatus,
650
+ evidence_by_task_type: evidenceByTaskType,
651
+ evidence_schema_version: 1,
652
+ evidence_objects: evidenceState.evidence_objects,
653
+ satisfaction_history: [],
654
+ overrides: overrideState.active_overrides,
655
+ override_history: overrideState.override_history,
656
+ pipeline_id: status.pipeline_id,
657
+ enforcement,
658
+ rationale,
659
+ blocked_validations: boundaryValidations.filter((validation) => validation.severity === 'blocked').length,
660
+ warned_validations: boundaryValidations.filter((validation) => validation.severity !== 'blocked').length,
661
+ summary: governedDomains.length === 0
662
+ ? 'No explicit governed-domain policy requirements are active for this pipeline.'
663
+ : missingTaskTypes.length > 0
664
+ ? `Governed domains ${governedDomains.join(', ')} still require ${missingTaskTypes.join(', ')} before landing.`
665
+ : overriddenTaskTypes.length > 0
666
+ ? `Governed domains ${governedDomains.join(', ')} are currently allowed to proceed with overrides for ${overriddenTaskTypes.join(', ')}.`
667
+ : `Governed domains ${governedDomains.join(', ')} have satisfied their current policy requirements.`,
668
+ };
669
+
670
+ policyState.satisfaction_history = [
671
+ ...buildPipelinePolicySatisfactionHistory(policyState),
672
+ ...overrideState.override_history,
673
+ ].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
674
+ return policyState;
675
+ }
676
+
677
+ function buildPipelinePolicyRemediationCommand(status, policyState) {
678
+ if (policyState.missing_task_types.length === 0) {
679
+ return `switchman pipeline status ${status.pipeline_id}`;
680
+ }
681
+
682
+ return `switchman pipeline review ${status.pipeline_id}`;
683
+ }
684
+
685
+ export async function evaluatePipelinePolicyGate(db, repoRoot, pipelineId) {
686
+ const status = getPipelineStatus(db, pipelineId);
687
+ const aiGate = await runAiMergeGate(db, repoRoot);
688
+ const changePolicy = loadChangePolicy(repoRoot);
689
+ const policyState = summarizePipelinePolicyState(db, status, changePolicy, aiGate.boundary_validations || []);
690
+ const blocked = policyState.active
691
+ && policyState.enforcement === 'blocked'
692
+ && policyState.missing_task_types.length > 0;
693
+ const nextAction = blocked ? buildPipelinePolicyRemediationCommand(status, policyState) : null;
694
+ const overrideApplied = policyState.overridden_task_types.length > 0;
695
+ const overrideSummary = overrideApplied
696
+ ? `Policy override active for ${policyState.overridden_task_types.join(', ')} by ${policyState.overrides.map((entry) => entry.approved_by || 'unknown').join(', ')}.`
697
+ : null;
698
+
699
+ return {
700
+ ok: !blocked,
701
+ pipeline_id: pipelineId,
702
+ reason_code: blocked ? 'policy_requirements_incomplete' : null,
703
+ summary: blocked
704
+ ? `Policy blocked landing for governed domains ${policyState.domains.join(', ')} because ${policyState.missing_task_types.join(', ')} is still required.`
705
+ : policyState.summary,
706
+ next_action: nextAction,
707
+ policy_state: policyState,
708
+ override_applied: overrideApplied,
709
+ override_summary: overrideSummary,
710
+ };
711
+ }
712
+
70
713
  export function startPipeline(db, { title, description = null, priority = 5, pipelineId = null, maxTasks = 5 }) {
71
714
  const resolvedPipelineId = pipelineId || makePipelineId();
72
715
  const registeredWorktrees = listWorktrees(db);
@@ -151,6 +794,8 @@ export function getPipelineStatus(db, pipelineId) {
151
794
  description: metadata?.description || null,
152
795
  priority: metadata?.priority || tasks[0].priority,
153
796
  counts,
797
+ leases: listLeases(db),
798
+ audit_events: listAuditEvents(db, { limit: 2000 }).filter((event) => pipelineOwnsAuditEvent(event, pipelineId)),
154
799
  tasks: tasks.map((task) => {
155
800
  const dependencies = parseDependencies(task.description);
156
801
  const blockedBy = dependencies.filter((dependencyId) =>
@@ -174,6 +819,35 @@ export function getPipelineStatus(db, pipelineId) {
174
819
  };
175
820
  }
176
821
 
822
+ export function inferPipelineIdFromBranch(db, branch) {
823
+ const normalizedBranch = String(branch || '').trim();
824
+ if (!normalizedBranch) return null;
825
+
826
+ const landingPrefix = 'switchman/pipeline-landing/';
827
+ if (normalizedBranch.startsWith(landingPrefix)) {
828
+ return normalizedBranch.slice(landingPrefix.length) || null;
829
+ }
830
+
831
+ const worktreesByName = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
832
+ const matchingPipelines = new Set();
833
+
834
+ for (const task of listTasks(db)) {
835
+ const taskSpec = getTaskSpec(db, task.id);
836
+ const pipelineId = taskSpec?.pipeline_id || null;
837
+ if (!pipelineId || !task.worktree) continue;
838
+ const worktree = worktreesByName.get(task.worktree);
839
+ if (worktree?.branch === normalizedBranch) {
840
+ matchingPipelines.add(pipelineId);
841
+ }
842
+ }
843
+
844
+ if (matchingPipelines.size === 1) {
845
+ return [...matchingPipelines][0];
846
+ }
847
+
848
+ return null;
849
+ }
850
+
177
851
  function parseTaskFailure(description) {
178
852
  const lines = String(description || '')
179
853
  .split('\n')
@@ -593,6 +1267,7 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
593
1267
  const status = getPipelineStatus(db, pipelineId);
594
1268
  const report = await scanAllWorktrees(db, repoRoot);
595
1269
  const aiGate = await runAiMergeGate(db, repoRoot);
1270
+ const changePolicy = loadChangePolicy(repoRoot);
596
1271
  const allLeases = listLeases(db);
597
1272
  const ciGateOk = report.conflicts.length === 0
598
1273
  && report.fileConflicts.length === 0
@@ -629,6 +1304,15 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
629
1304
  }));
630
1305
  const changedFiles = uniq(worktreeChanges.flatMap((entry) => entry.files));
631
1306
  const subsystemTags = uniq(completedTasks.flatMap((task) => task.task_spec?.subsystem_tags || []));
1307
+ const policyState = summarizePipelinePolicyState(db, status, changePolicy, aiGate.boundary_validations || []);
1308
+ const policyOverrideSummary = policyState.overridden_task_types.length > 0
1309
+ ? `Landing currently relies on policy overrides for ${policyState.overridden_task_types.join(', ')}.`
1310
+ : null;
1311
+ const staleClusters = buildStaleClusters(aiGate.dependency_invalidations || [], { pipelineId });
1312
+ const staleCausalWaves = summarizeStaleCausalWaves(staleClusters);
1313
+ const trustAudit = [...policyState.satisfaction_history, ...buildPipelineTrustAudit(db, pipelineId)]
1314
+ .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
1315
+ .slice(0, 8);
632
1316
  const riskNotes = [];
633
1317
  if (!ciGateOk) riskNotes.push('Repo gate is blocked by conflicts, unmanaged changes, or stale worktrees.');
634
1318
  if (aiGate.status !== 'pass') riskNotes.push(aiGate.summary);
@@ -647,10 +1331,15 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
647
1331
  const reviewerChecklist = [
648
1332
  ciGateOk ? 'Repo gate passed' : 'Resolve repo gate failures before merge',
649
1333
  aiGate.status === 'pass' ? 'AI merge gate passed' : `Review AI merge gate findings: ${aiGate.summary}`,
1334
+ policyState.active
1335
+ ? (policyState.missing_task_types.length > 0
1336
+ ? `Complete policy requirements: ${policyState.missing_task_types.join(', ')}`
1337
+ : `Confirm governed domains remain satisfied: ${policyState.domains.join(', ')}`)
1338
+ : null,
650
1339
  completedTasks.some((task) => task.task_spec?.risk_level === 'high')
651
1340
  ? 'Confirm high-risk tasks have the expected tests and docs'
652
1341
  : 'Review changed files and task outcomes',
653
- ];
1342
+ ].filter(Boolean);
654
1343
  const prTitle = status.title.startsWith('Implement:')
655
1344
  ? status.title.replace(/^Implement:\s*/i, '')
656
1345
  : status.title;
@@ -663,6 +1352,33 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
663
1352
  '## Validation',
664
1353
  `- Repo gate: ${ciGateOk ? 'pass' : 'blocked'}`,
665
1354
  `- AI merge gate: ${aiGate.status}`,
1355
+ ...(policyState.active
1356
+ ? [
1357
+ `- Policy domains: ${policyState.domains.join(', ')}`,
1358
+ `- Policy enforcement: ${policyState.enforcement}`,
1359
+ `- Policy requirements: ${policyState.required_task_types.join(', ') || 'none'}`,
1360
+ `- Policy evidence: ${policyState.satisfied_task_types.map((taskType) => `${taskType}:${(policyState.evidence_by_task_type?.[taskType] || []).map((entry) => entry.task_id).join(',')}`).join(' | ') || 'none'}`,
1361
+ `- Policy evidence objects: ${policyState.evidence_objects.map((entry) => `${entry.evidence_id}:${entry.satisfied_by.agent || entry.satisfied_by.worktree || 'task_completion'}`).join(' | ') || 'none'}`,
1362
+ `- Policy missing: ${policyState.missing_task_types.join(', ') || 'none'}`,
1363
+ `- Policy overrides: ${policyState.overrides.map((entry) => `${entry.id}:${(entry.task_types || []).join(',') || 'all'}:${entry.approved_by || 'unknown'}`).join(' | ') || 'none'}`,
1364
+ ...(policyOverrideSummary ? [`- Policy override effect: ${policyOverrideSummary}`] : []),
1365
+ ]
1366
+ : []),
1367
+ ...(staleClusters.length > 0
1368
+ ? [
1369
+ `- Stale clusters: ${staleClusters.map((cluster) => `${cluster.affected_pipeline_id || cluster.affected_task_ids[0]}:${cluster.invalidation_count}`).join(' | ')}`,
1370
+ ]
1371
+ : []),
1372
+ ...(staleCausalWaves.length > 0
1373
+ ? [
1374
+ `- Stale waves: ${staleCausalWaves.map((wave) => `${wave.summary}:${wave.affected_pipeline_ids.join(',') || 'unknown'}`).join(' | ')}`,
1375
+ ]
1376
+ : []),
1377
+ ...(trustAudit.length > 0
1378
+ ? [
1379
+ `- Trust audit: ${trustAudit.slice(0, 3).map((entry) => `${entry.category}:${entry.summary}`).join(' | ')}`,
1380
+ ]
1381
+ : []),
666
1382
  '',
667
1383
  '## Reviewer Checklist',
668
1384
  ...reviewerChecklist.map((item) => `- ${item}`),
@@ -702,6 +1418,45 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
702
1418
  '## Reviewer Notes',
703
1419
  ...reviewerChecklist.map((item) => `- ${item}`),
704
1420
  '',
1421
+ ...(policyState.active
1422
+ ? [
1423
+ '## Policy Requirements',
1424
+ `- Domains: ${policyState.domains.join(', ')}`,
1425
+ `- Enforcement: ${policyState.enforcement}`,
1426
+ `- Required task types: ${policyState.required_task_types.join(', ') || 'none'}`,
1427
+ `- Satisfied task types: ${policyState.satisfied_task_types.join(', ') || 'none'}`,
1428
+ `- Missing task types: ${policyState.missing_task_types.join(', ') || 'none'}`,
1429
+ `- Overridden task types: ${policyState.overridden_task_types.join(', ') || 'none'}`,
1430
+ ...policyState.requirement_status
1431
+ .filter((requirement) => requirement.evidence.length > 0)
1432
+ .map((requirement) => `- Evidence for ${requirement.task_type}: ${requirement.evidence.map((entry) => entry.artifact_path ? `${entry.task_id} (${entry.artifact_path}, by ${entry.satisfied_by?.agent || entry.satisfied_by?.worktree || 'task_completion'})` : `${entry.task_id} (by ${entry.satisfied_by?.agent || entry.satisfied_by?.worktree || 'task_completion'})`).join(', ')}`),
1433
+ ...policyState.overrides.slice(0, 5).map((entry) => `- Override ${entry.id}: ${(entry.task_types || []).join(', ') || 'all requirements'} by ${entry.approved_by || 'unknown'} (${entry.reason})`),
1434
+ ...policyState.satisfaction_history.slice(0, 5).map((entry) => `- Satisfaction history: ${entry.summary}`),
1435
+ ...policyState.rationale.slice(0, 5).map((item) => `- ${item}`),
1436
+ '',
1437
+ ]
1438
+ : []),
1439
+ ...(staleClusters.length > 0
1440
+ ? [
1441
+ '## Stale Clusters',
1442
+ ...staleClusters.map((cluster) => `- ${cluster.title}: ${cluster.detail} -> ${cluster.next_action}`),
1443
+ '',
1444
+ ]
1445
+ : []),
1446
+ ...(staleCausalWaves.length > 0
1447
+ ? [
1448
+ '## Stale Waves',
1449
+ ...staleCausalWaves.map((wave) => `- ${wave.summary}: affects ${wave.affected_pipeline_ids.join(', ') || 'unknown'} -> ${wave.cluster_count} cluster(s), ${wave.invalidation_count} invalidation(s)`),
1450
+ '',
1451
+ ]
1452
+ : []),
1453
+ ...(trustAudit.length > 0
1454
+ ? [
1455
+ '## Policy & Stale Audit',
1456
+ ...trustAudit.map((entry) => `- ${entry.created_at}: [${entry.category}] ${entry.summary} -> ${entry.next_action}`),
1457
+ '',
1458
+ ]
1459
+ : []),
705
1460
  '## Provenance',
706
1461
  ...provenance.map((entry) => `- ${entry.task_id}: ${entry.title} (${entry.task_type || 'unknown'}, ${entry.worktree || 'unassigned'}, lease ${entry.lease_id || 'none'})`),
707
1462
  ...(provenance.length === 0 ? ['- No completed task provenance yet'] : []),
@@ -736,6 +1491,11 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
736
1491
  risk_notes: riskNotes,
737
1492
  changed_files: changedFiles,
738
1493
  subsystem_tags: subsystemTags,
1494
+ policy_state: policyState,
1495
+ policy_override_summary: policyOverrideSummary,
1496
+ stale_clusters: staleClusters,
1497
+ stale_causal_waves: staleCausalWaves,
1498
+ trust_audit: trustAudit,
739
1499
  },
740
1500
  counts: status.counts,
741
1501
  ci_gate: {
@@ -747,23 +1507,210 @@ export async function buildPipelinePrSummary(db, repoRoot, pipelineId) {
747
1507
  status: aiGate.status,
748
1508
  summary: aiGate.summary,
749
1509
  },
1510
+ policy_state: policyState,
1511
+ stale_clusters: staleClusters,
1512
+ stale_causal_waves: staleCausalWaves,
1513
+ trust_audit: trustAudit,
750
1514
  worktree_changes: worktreeChanges,
751
1515
  markdown,
752
1516
  };
753
1517
  }
754
1518
 
1519
+ export async function buildPipelineLandingSummary(db, repoRoot, pipelineId) {
1520
+ const status = getPipelineStatus(db, pipelineId);
1521
+ const changePolicy = loadChangePolicy(repoRoot);
1522
+ const aiGate = await runAiMergeGate(db, repoRoot);
1523
+ const policyState = summarizePipelinePolicyState(db, status, changePolicy, aiGate.boundary_validations || []);
1524
+ const policyOverrideSummary = policyState.overridden_task_types.length > 0
1525
+ ? `Landing currently relies on policy overrides for ${policyState.overridden_task_types.join(', ')}.`
1526
+ : null;
1527
+ const staleClusters = buildStaleClusters(listDependencyInvalidations(db, { pipelineId }), { pipelineId });
1528
+ const staleCausalWaves = summarizeStaleCausalWaves(staleClusters);
1529
+ const trustAudit = [...policyState.satisfaction_history, ...buildPipelineTrustAudit(db, pipelineId)]
1530
+ .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')))
1531
+ .slice(0, 8);
1532
+ let landing;
1533
+ let landingError = null;
1534
+ try {
1535
+ landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
1536
+ requireCompleted: false,
1537
+ });
1538
+ } catch (err) {
1539
+ landingError = String(err.message || 'Landing branch is not ready yet.');
1540
+ landing = {
1541
+ branch: null,
1542
+ strategy: 'unavailable',
1543
+ synthetic: false,
1544
+ component_branches: [],
1545
+ stale: false,
1546
+ last_failure: null,
1547
+ };
1548
+ }
1549
+ const readyToQueue = status.counts.failed === 0
1550
+ && status.counts.pending === 0
1551
+ && status.counts.in_progress === 0
1552
+ && status.counts.done > 0
1553
+ && !landingError
1554
+ && !landing.stale
1555
+ && staleClusters.length === 0
1556
+ && !landing.last_failure;
1557
+ const nextAction = staleClusters[0]?.next_action
1558
+ || (landingError
1559
+ ? `switchman pipeline status ${pipelineId}`
1560
+ : landing.last_failure?.command
1561
+ || (landing.stale
1562
+ ? `switchman pipeline land ${pipelineId} --refresh`
1563
+ : `switchman queue add --pipeline ${pipelineId}`));
1564
+ const queueItems = listMergeQueue(db)
1565
+ .filter((item) => item.source_pipeline_id === pipelineId)
1566
+ .sort((a, b) => Number.parseInt(b.id.slice(1), 10) - Number.parseInt(a.id.slice(1), 10));
1567
+ const currentQueueItem = queueItems[0] || null;
1568
+ const queueState = {
1569
+ status: currentQueueItem?.status || 'not_queued',
1570
+ item_id: currentQueueItem?.id || null,
1571
+ target_branch: currentQueueItem?.target_branch || 'main',
1572
+ merged_commit: currentQueueItem?.merged_commit || null,
1573
+ next_action: currentQueueItem?.next_action || (currentQueueItem
1574
+ ? null
1575
+ : (readyToQueue ? `switchman queue add --pipeline ${pipelineId}` : nextAction)),
1576
+ last_error_code: currentQueueItem?.last_error_code || null,
1577
+ last_error_summary: currentQueueItem?.last_error_summary || null,
1578
+ policy_override_summary: policyOverrideSummary,
1579
+ };
1580
+ const recoveryState = landing.last_recovery
1581
+ ? {
1582
+ status: landing.last_recovery.state?.status || 'prepared',
1583
+ recovery_path: landing.last_recovery.recovery_path || null,
1584
+ inspect_command: landing.last_recovery.inspect_command || null,
1585
+ resume_command: landing.last_recovery.resume_command || null,
1586
+ }
1587
+ : null;
1588
+
1589
+ const markdown = [
1590
+ `# Pipeline Landing Summary: ${status.title}`,
1591
+ '',
1592
+ `- Pipeline: \`${pipelineId}\``,
1593
+ `- Ready to queue: ${readyToQueue ? 'yes' : 'no'}`,
1594
+ `- Landing branch: ${landing.branch ? `\`${landing.branch}\`` : 'not resolved yet'}`,
1595
+ `- Strategy: ${landing.strategy}`,
1596
+ `- Synthetic: ${landing.synthetic ? 'yes' : 'no'}`,
1597
+ '',
1598
+ '## Component Branches',
1599
+ ...(landing.component_branches.length > 0
1600
+ ? landing.component_branches.map((branch) => `- ${branch}`)
1601
+ : ['- None inferred yet']),
1602
+ '',
1603
+ '## Landing State',
1604
+ ...(landingError
1605
+ ? [`- ${landingError}`]
1606
+ : landing.last_failure
1607
+ ? [
1608
+ `- Failure: ${landing.last_failure.reason_code || 'landing_branch_materialization_failed'}`,
1609
+ ...(landing.last_failure.failed_branch ? [`- Failed branch: ${landing.last_failure.failed_branch}`] : []),
1610
+ ...(landing.last_failure.conflicting_files?.length ? [`- Conflicts: ${landing.last_failure.conflicting_files.join(', ')}`] : []),
1611
+ ]
1612
+ : landing.stale
1613
+ ? landing.stale_reasons.map((reason) => `- Stale: ${reason.summary}`)
1614
+ : staleClusters.length > 0
1615
+ ? staleClusters.map((cluster) => `- Stale cluster: ${cluster.title}`)
1616
+ : ['- Current and ready for queueing']),
1617
+ '',
1618
+ '## Recovery State',
1619
+ ...(recoveryState
1620
+ ? [
1621
+ `- Status: ${recoveryState.status}`,
1622
+ ...(recoveryState.recovery_path ? [`- Path: ${recoveryState.recovery_path}`] : []),
1623
+ ...(recoveryState.resume_command ? [`- Resume: ${recoveryState.resume_command}`] : []),
1624
+ ]
1625
+ : ['- No active recovery worktree']),
1626
+ '',
1627
+ ...(staleClusters.length > 0
1628
+ ? [
1629
+ '## Stale Clusters',
1630
+ ...staleClusters.map((cluster) => `- ${cluster.title}: ${cluster.detail} -> ${cluster.next_action}`),
1631
+ '',
1632
+ ]
1633
+ : []),
1634
+ ...(staleCausalWaves.length > 0
1635
+ ? [
1636
+ '## Stale Waves',
1637
+ ...staleCausalWaves.map((wave) => `- ${wave.summary}: affects ${wave.affected_pipeline_ids.join(', ') || 'unknown'} -> ${wave.cluster_count} cluster(s), ${wave.invalidation_count} invalidation(s)`),
1638
+ '',
1639
+ ]
1640
+ : []),
1641
+ ...(trustAudit.length > 0
1642
+ ? [
1643
+ '## Policy & Stale Audit',
1644
+ ...trustAudit.map((entry) => `- ${entry.created_at}: [${entry.category}] ${entry.summary} -> ${entry.next_action}`),
1645
+ '',
1646
+ ]
1647
+ : []),
1648
+ '',
1649
+ ...(policyState.active
1650
+ ? [
1651
+ '## Policy Record',
1652
+ `- Domains: ${policyState.domains.join(', ')}`,
1653
+ `- Enforcement: ${policyState.enforcement}`,
1654
+ `- Required task types: ${policyState.required_task_types.join(', ') || 'none'}`,
1655
+ `- Missing task types: ${policyState.missing_task_types.join(', ') || 'none'}`,
1656
+ `- Overridden task types: ${policyState.overridden_task_types.join(', ') || 'none'}`,
1657
+ ...policyState.requirement_status
1658
+ .filter((requirement) => requirement.evidence.length > 0)
1659
+ .map((requirement) => `- Evidence for ${requirement.task_type}: ${requirement.evidence.map((entry) => `${entry.task_id} by ${entry.satisfied_by?.agent || entry.satisfied_by?.worktree || 'task_completion'}`).join(', ')}`),
1660
+ ...policyState.overrides.slice(0, 5).map((entry) => `- Override ${entry.id}: ${(entry.task_types || []).join(', ') || 'all requirements'} by ${entry.approved_by || 'unknown'} (${entry.reason})`),
1661
+ ...(policyOverrideSummary ? [`- Override effect: ${policyOverrideSummary}`] : []),
1662
+ '',
1663
+ ]
1664
+ : []),
1665
+ '',
1666
+ '## Queue State',
1667
+ `- Status: ${queueState.status}`,
1668
+ ...(queueState.item_id ? [`- Item: ${queueState.item_id}`] : []),
1669
+ `- Target branch: ${queueState.target_branch}`,
1670
+ ...(queueState.merged_commit ? [`- Merged commit: ${queueState.merged_commit}`] : []),
1671
+ ...(queueState.last_error_summary ? [`- Queue error: ${queueState.last_error_summary}`] : []),
1672
+ ...(queueState.policy_override_summary ? [`- Policy override: ${queueState.policy_override_summary}`] : []),
1673
+ '',
1674
+ `## Next Action`,
1675
+ `- ${queueState.next_action || nextAction}`,
1676
+ ].join('\n');
1677
+
1678
+ return {
1679
+ pipeline_id: pipelineId,
1680
+ title: status.title,
1681
+ ready_to_queue: readyToQueue,
1682
+ counts: status.counts,
1683
+ policy_state: policyState,
1684
+ landing,
1685
+ recovery_state: recoveryState,
1686
+ stale_clusters: staleClusters,
1687
+ stale_causal_waves: staleCausalWaves,
1688
+ trust_audit: trustAudit,
1689
+ queue_state: queueState,
1690
+ policy_override_summary: policyOverrideSummary,
1691
+ landing_error: landingError,
1692
+ next_action: queueState.next_action || nextAction,
1693
+ markdown,
1694
+ };
1695
+ }
1696
+
755
1697
  export async function exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir = null) {
756
1698
  const summary = await buildPipelinePrSummary(db, repoRoot, pipelineId);
1699
+ const landingSummary = await buildPipelineLandingSummary(db, repoRoot, pipelineId);
757
1700
  const bundleDir = outputDir || join(repoRoot, '.switchman', 'pipelines', pipelineId);
758
1701
  mkdirSync(bundleDir, { recursive: true });
759
1702
 
760
1703
  const summaryJsonPath = join(bundleDir, 'pr-summary.json');
761
1704
  const summaryMarkdownPath = join(bundleDir, 'pr-summary.md');
762
1705
  const prBodyPath = join(bundleDir, 'pr-body.md');
1706
+ const landingSummaryJsonPath = join(bundleDir, 'pipeline-landing-summary.json');
1707
+ const landingSummaryMarkdownPath = join(bundleDir, 'pipeline-landing-summary.md');
763
1708
 
764
1709
  writeFileSync(summaryJsonPath, `${JSON.stringify(summary, null, 2)}\n`);
765
1710
  writeFileSync(summaryMarkdownPath, `${summary.markdown}\n`);
766
1711
  writeFileSync(prBodyPath, `${summary.pr_artifact.body}\n`);
1712
+ writeFileSync(landingSummaryJsonPath, `${JSON.stringify(landingSummary, null, 2)}\n`);
1713
+ writeFileSync(landingSummaryMarkdownPath, `${landingSummary.markdown}\n`);
767
1714
 
768
1715
  logAuditEvent(db, {
769
1716
  eventType: 'pipeline_pr_bundle_exported',
@@ -772,7 +1719,7 @@ export async function exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir
772
1719
  details: JSON.stringify({
773
1720
  pipeline_id: pipelineId,
774
1721
  output_dir: bundleDir,
775
- files: [summaryJsonPath, summaryMarkdownPath, prBodyPath],
1722
+ files: [summaryJsonPath, summaryMarkdownPath, prBodyPath, landingSummaryJsonPath, landingSummaryMarkdownPath],
776
1723
  }),
777
1724
  });
778
1725
 
@@ -783,90 +1730,1310 @@ export async function exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir
783
1730
  summary_json: summaryJsonPath,
784
1731
  summary_markdown: summaryMarkdownPath,
785
1732
  pr_body_markdown: prBodyPath,
1733
+ landing_summary_json: landingSummaryJsonPath,
1734
+ landing_summary_markdown: landingSummaryMarkdownPath,
786
1735
  },
787
1736
  summary,
1737
+ landing_summary: landingSummary,
788
1738
  };
789
1739
  }
790
1740
 
791
- function resolvePipelineHeadBranch(db, repoRoot, pipelineStatus, explicitHeadBranch = null) {
792
- if (explicitHeadBranch) return explicitHeadBranch;
1741
+ function resolvePipelineBranchForTask(worktreesByName, task) {
1742
+ const worktreeName = task.worktree || task.suggested_worktree || null;
1743
+ const branch = worktreeName ? worktreesByName.get(worktreeName)?.branch || null : null;
1744
+ return branch && branch !== 'main' && branch !== 'unknown' ? branch : null;
1745
+ }
793
1746
 
1747
+ function collectPipelineLandingCandidates(db, pipelineStatus) {
794
1748
  const worktreesByName = new Map(listWorktrees(db).map((worktree) => [worktree.name, worktree]));
795
- const resolveBranchForTask = (task) => {
796
- const worktreeName = task.worktree || task.suggested_worktree || null;
797
- const branch = worktreeName ? worktreesByName.get(worktreeName)?.branch || null : null;
798
- return branch && branch !== 'main' && branch !== 'unknown' ? branch : null;
799
- };
1749
+ const orderedBranches = [];
1750
+ const branchToWorktree = new Map();
1751
+
1752
+ for (const task of pipelineStatus.tasks) {
1753
+ const branch = resolvePipelineBranchForTask(worktreesByName, task);
1754
+ if (!branch) continue;
1755
+ if (!branchToWorktree.has(branch) && task.worktree) {
1756
+ branchToWorktree.set(branch, task.worktree);
1757
+ }
1758
+ if (!orderedBranches.includes(branch)) {
1759
+ orderedBranches.push(branch);
1760
+ }
1761
+ }
800
1762
 
801
1763
  const implementationBranches = uniq(
802
1764
  pipelineStatus.tasks
803
1765
  .filter((task) => task.task_spec?.task_type === 'implementation')
804
- .map(resolveBranchForTask)
1766
+ .map((task) => resolvePipelineBranchForTask(worktreesByName, task))
805
1767
  .filter(Boolean),
806
1768
  );
807
- if (implementationBranches.length === 1) {
808
- return implementationBranches[0];
809
- }
1769
+ const candidateBranches = uniq(orderedBranches);
1770
+ const prioritizedBranches = [
1771
+ ...implementationBranches,
1772
+ ...candidateBranches.filter((branch) => !implementationBranches.includes(branch)),
1773
+ ];
810
1774
 
811
- const candidateBranches = uniq(
812
- pipelineStatus.tasks
813
- .map(resolveBranchForTask)
814
- .filter(Boolean),
815
- );
1775
+ return {
1776
+ implementationBranches,
1777
+ candidateBranches,
1778
+ prioritizedBranches,
1779
+ branchToWorktree,
1780
+ worktreesByName,
1781
+ };
1782
+ }
816
1783
 
817
- if (candidateBranches.length === 1) {
818
- return candidateBranches[0];
819
- }
1784
+ function getPipelineLandingBranchName(pipelineId, landingBranch = null) {
1785
+ return landingBranch || `switchman/pipeline-landing/${pipelineId}`;
1786
+ }
1787
+
1788
+ function listPipelineLandingEvents(db, pipelineId, branch) {
1789
+ return listAuditEvents(db, {
1790
+ eventType: 'pipeline_landing_branch_materialized',
1791
+ status: 'allowed',
1792
+ limit: 500,
1793
+ }).flatMap((event) => {
1794
+ try {
1795
+ const details = JSON.parse(event.details || '{}');
1796
+ if (details.pipeline_id !== pipelineId || details.branch !== branch) {
1797
+ return [];
1798
+ }
1799
+ return [{
1800
+ audit_id: event.id,
1801
+ created_at: event.created_at,
1802
+ ...details,
1803
+ }];
1804
+ } catch {
1805
+ return [];
1806
+ }
1807
+ });
1808
+ }
1809
+
1810
+ function getLatestLandingResolvedEvent(db, pipelineId, branch) {
1811
+ return listAuditEvents(db, {
1812
+ eventType: 'pipeline_landing_recovery_resumed',
1813
+ status: 'allowed',
1814
+ limit: 200,
1815
+ }).flatMap((event) => {
1816
+ try {
1817
+ const details = JSON.parse(event.details || '{}');
1818
+ if (details.pipeline_id !== pipelineId || details.branch !== branch) {
1819
+ return [];
1820
+ }
1821
+ return [{
1822
+ audit_id: event.id,
1823
+ created_at: event.created_at,
1824
+ ...details,
1825
+ }];
1826
+ } catch {
1827
+ return [];
1828
+ }
1829
+ })[0] || null;
1830
+ }
1831
+
1832
+ function getLatestLandingRecoveryPrepared(db, pipelineId, branch) {
1833
+ return listAuditEvents(db, {
1834
+ eventType: 'pipeline_landing_recovery_prepared',
1835
+ status: 'allowed',
1836
+ limit: 200,
1837
+ }).flatMap((event) => {
1838
+ try {
1839
+ const details = JSON.parse(event.details || '{}');
1840
+ if (details.pipeline_id !== pipelineId || details.branch !== branch) {
1841
+ return [];
1842
+ }
1843
+ return [{
1844
+ audit_id: event.id,
1845
+ created_at: event.created_at,
1846
+ ...details,
1847
+ }];
1848
+ } catch {
1849
+ return [];
1850
+ }
1851
+ })[0] || null;
1852
+ }
1853
+
1854
+ function getLatestLandingRecoveryCleared(db, pipelineId, branch) {
1855
+ return listAuditEvents(db, {
1856
+ eventType: 'pipeline_landing_recovery_cleared',
1857
+ status: 'allowed',
1858
+ limit: 200,
1859
+ }).flatMap((event) => {
1860
+ try {
1861
+ const details = JSON.parse(event.details || '{}');
1862
+ if (details.pipeline_id !== pipelineId || details.branch !== branch) {
1863
+ return [];
1864
+ }
1865
+ return [{
1866
+ audit_id: event.id,
1867
+ created_at: event.created_at,
1868
+ ...details,
1869
+ }];
1870
+ } catch {
1871
+ return [];
1872
+ }
1873
+ })[0] || null;
1874
+ }
820
1875
 
821
- const currentBranch = getWorktreeBranch(repoRoot);
822
- if (currentBranch && currentBranch !== 'main') {
823
- return currentBranch;
1876
+ function buildRecoveryState(repoRoot, branch, recoveryRecord, latestResolved) {
1877
+ if (!recoveryRecord) return null;
1878
+ const recoveryPath = recoveryRecord.recovery_path || null;
1879
+ const pathExists = recoveryPath ? existsSync(recoveryPath) : false;
1880
+ const gitWorktrees = listGitWorktrees(repoRoot);
1881
+ const normalizedRecoveryPath = recoveryPath
1882
+ ? (pathExists ? realpathSync(recoveryPath) : recoveryPath)
1883
+ : null;
1884
+ const tracked = recoveryPath
1885
+ ? gitWorktrees.some((worktree) => worktree.path === recoveryPath || worktree.path === normalizedRecoveryPath)
1886
+ : false;
1887
+ const branchWorktree = branch
1888
+ ? gitWorktrees.find((worktree) => worktree.branch === branch) || null
1889
+ : null;
1890
+ const resolved = Boolean(latestResolved && latestResolved.audit_id > recoveryRecord.audit_id);
1891
+ let status;
1892
+
1893
+ if (resolved) {
1894
+ if (tracked) {
1895
+ status = 'resolved';
1896
+ } else if (branchWorktree) {
1897
+ status = 'resolved_moved';
1898
+ } else if (pathExists) {
1899
+ status = 'resolved_untracked';
1900
+ } else {
1901
+ status = 'resolved_missing';
1902
+ }
1903
+ } else if (tracked) {
1904
+ status = 'active';
1905
+ } else if (branchWorktree) {
1906
+ status = 'moved';
1907
+ } else if (pathExists) {
1908
+ status = 'untracked';
1909
+ } else {
1910
+ status = 'missing';
824
1911
  }
825
1912
 
826
- return null;
1913
+ return {
1914
+ path: recoveryPath,
1915
+ exists: pathExists,
1916
+ tracked,
1917
+ branch_worktree_path: branchWorktree?.path || null,
1918
+ status,
1919
+ };
827
1920
  }
828
1921
 
829
- export async function publishPipelinePr(
1922
+ function getLatestLandingFailure(db, pipelineId, branch) {
1923
+ return listAuditEvents(db, {
1924
+ eventType: 'pipeline_landing_branch_materialized',
1925
+ status: 'denied',
1926
+ limit: 200,
1927
+ }).flatMap((event) => {
1928
+ try {
1929
+ const details = JSON.parse(event.details || '{}');
1930
+ if (details.pipeline_id !== pipelineId || details.branch !== branch) {
1931
+ return [];
1932
+ }
1933
+ return [{
1934
+ audit_id: event.id,
1935
+ created_at: event.created_at,
1936
+ reason_code: event.reason_code || null,
1937
+ ...details,
1938
+ }];
1939
+ } catch {
1940
+ return [];
1941
+ }
1942
+ })[0] || null;
1943
+ }
1944
+
1945
+ function collectBranchHeadCommits(repoRoot, branches) {
1946
+ return Object.fromEntries(
1947
+ branches.map((branch) => [branch, gitRevParse(repoRoot, branch)]),
1948
+ );
1949
+ }
1950
+
1951
+ export function getPipelineLandingBranchStatus(
830
1952
  db,
831
1953
  repoRoot,
832
1954
  pipelineId,
833
1955
  {
834
1956
  baseBranch = 'main',
835
- headBranch = null,
836
- draft = false,
837
- ghCommand = 'gh',
838
- outputDir = null,
1957
+ landingBranch = null,
1958
+ requireCompleted = true,
839
1959
  } = {},
840
1960
  ) {
841
- const bundle = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir);
842
- const status = getPipelineStatus(db, pipelineId);
843
- const resolvedHeadBranch = resolvePipelineHeadBranch(db, repoRoot, status, headBranch);
844
-
845
- if (!resolvedHeadBranch) {
846
- throw new Error(`Could not determine a head branch for pipeline ${pipelineId}. Pass --head <branch>.`);
1961
+ const pipelineStatus = getPipelineStatus(db, pipelineId);
1962
+ if (requireCompleted) {
1963
+ const unfinishedTasks = pipelineStatus.tasks.filter((task) => task.status !== 'done');
1964
+ if (unfinishedTasks.length > 0) {
1965
+ throw new Error(`Pipeline ${pipelineId} is not ready to land. Complete remaining tasks first: ${unfinishedTasks.map((task) => task.id).join(', ')}.`);
1966
+ }
847
1967
  }
848
1968
 
849
- const args = [
850
- 'pr',
851
- 'create',
852
- '--base',
853
- baseBranch,
854
- '--head',
855
- resolvedHeadBranch,
856
- '--title',
857
- bundle.summary.pr_artifact.title,
858
- '--body-file',
859
- bundle.files.pr_body_markdown,
860
- ];
861
-
862
- if (draft) {
863
- args.push('--draft');
1969
+ const { candidateBranches, prioritizedBranches } = collectPipelineLandingCandidates(db, pipelineStatus);
1970
+ if (candidateBranches.length === 0) {
1971
+ throw new Error(`Pipeline ${pipelineId} has no landed worktree branch to materialize.`);
864
1972
  }
865
1973
 
866
- const result = spawnSync(ghCommand, args, {
867
- cwd: repoRoot,
868
- encoding: 'utf8',
869
- });
1974
+ if (candidateBranches.length === 1) {
1975
+ const branch = candidateBranches[0];
1976
+ return {
1977
+ pipeline_id: pipelineId,
1978
+ branch,
1979
+ base_branch: baseBranch,
1980
+ synthetic: false,
1981
+ branch_exists: Boolean(gitRevParse(repoRoot, branch)),
1982
+ branch_head_commit: gitRevParse(repoRoot, branch),
1983
+ component_branches: [branch],
1984
+ component_commits: { [branch]: gitRevParse(repoRoot, branch) },
1985
+ strategy: 'single_branch',
1986
+ stale: false,
1987
+ stale_reasons: [],
1988
+ last_materialized: null,
1989
+ };
1990
+ }
1991
+
1992
+ const resolvedLandingBranch = getPipelineLandingBranchName(pipelineId, landingBranch);
1993
+ const branchExists = gitBranchExists(repoRoot, resolvedLandingBranch);
1994
+ const branchHeadCommit = branchExists ? gitRevParse(repoRoot, resolvedLandingBranch) : null;
1995
+ const baseCommit = gitRevParse(repoRoot, baseBranch);
1996
+ const componentCommits = collectBranchHeadCommits(repoRoot, prioritizedBranches);
1997
+ const latestMaterialized = listPipelineLandingEvents(db, pipelineId, resolvedLandingBranch)[0] || null;
1998
+ const latestResolved = getLatestLandingResolvedEvent(db, pipelineId, resolvedLandingBranch);
1999
+ const latestRecoveryPreparedCandidate = getLatestLandingRecoveryPrepared(db, pipelineId, resolvedLandingBranch);
2000
+ const latestRecoveryCleared = getLatestLandingRecoveryCleared(db, pipelineId, resolvedLandingBranch);
2001
+ const latestRecoveryPrepared = latestRecoveryPreparedCandidate
2002
+ && (!latestRecoveryCleared || latestRecoveryPreparedCandidate.audit_id > latestRecoveryCleared.audit_id)
2003
+ ? latestRecoveryPreparedCandidate
2004
+ : null;
2005
+ const recoveryState = buildRecoveryState(repoRoot, resolvedLandingBranch, latestRecoveryPrepared, latestResolved);
2006
+ const latestEvent = latestResolved && (!latestMaterialized || latestResolved.audit_id > latestMaterialized.audit_id)
2007
+ ? latestResolved
2008
+ : latestMaterialized;
2009
+ const latestFailureCandidate = getLatestLandingFailure(db, pipelineId, resolvedLandingBranch);
2010
+ const latestFailure = latestFailureCandidate && (!latestEvent || latestFailureCandidate.audit_id > latestEvent.audit_id)
2011
+ ? latestFailureCandidate
2012
+ : null;
2013
+ const staleReasons = [];
2014
+
2015
+ if (!latestEvent) {
2016
+ staleReasons.push({
2017
+ code: 'not_materialized',
2018
+ summary: 'Landing branch has not been materialized yet.',
2019
+ });
2020
+ } else {
2021
+ if (!branchExists) {
2022
+ staleReasons.push({
2023
+ code: 'landing_branch_missing',
2024
+ summary: `Landing branch ${resolvedLandingBranch} no longer exists.`,
2025
+ });
2026
+ }
2027
+
2028
+ const recordedComponents = Array.isArray(latestEvent.component_branches)
2029
+ ? latestEvent.component_branches
2030
+ : [];
2031
+ if (recordedComponents.join('\n') !== prioritizedBranches.join('\n')) {
2032
+ staleReasons.push({
2033
+ code: 'component_set_changed',
2034
+ summary: 'The pipeline now resolves to a different set of component branches.',
2035
+ });
2036
+ }
2037
+
2038
+ if (latestEvent.base_commit && baseCommit && latestEvent.base_commit !== baseCommit) {
2039
+ staleReasons.push({
2040
+ code: 'base_branch_moved',
2041
+ summary: `${baseBranch} moved from ${latestEvent.base_commit.slice(0, 12)} to ${baseCommit.slice(0, 12)}.`,
2042
+ });
2043
+ }
2044
+
2045
+ const recordedCommits = latestEvent.component_commits || {};
2046
+ for (const branch of prioritizedBranches) {
2047
+ const previousCommit = recordedCommits[branch] || null;
2048
+ const currentCommit = componentCommits[branch] || null;
2049
+ if (previousCommit && currentCommit && previousCommit !== currentCommit) {
2050
+ staleReasons.push({
2051
+ code: 'component_branch_moved',
2052
+ branch,
2053
+ summary: `${branch} moved from ${previousCommit.slice(0, 12)} to ${currentCommit.slice(0, 12)}.`,
2054
+ });
2055
+ }
2056
+ }
2057
+
2058
+ if (
2059
+ latestEvent.head_commit &&
2060
+ branchHeadCommit &&
2061
+ latestEvent.head_commit !== branchHeadCommit
2062
+ ) {
2063
+ staleReasons.push({
2064
+ code: 'landing_branch_drifted',
2065
+ summary: `Landing branch head changed from ${latestEvent.head_commit.slice(0, 12)} to ${branchHeadCommit.slice(0, 12)} outside Switchman.`,
2066
+ });
2067
+ }
2068
+ }
2069
+
2070
+ return {
2071
+ pipeline_id: pipelineId,
2072
+ branch: resolvedLandingBranch,
2073
+ base_branch: baseBranch,
2074
+ synthetic: true,
2075
+ branch_exists: branchExists,
2076
+ branch_head_commit: branchHeadCommit,
2077
+ component_branches: prioritizedBranches,
2078
+ component_commits: componentCommits,
2079
+ strategy: 'synthetic_integration_branch',
2080
+ stale: staleReasons.some((reason) => reason.code !== 'not_materialized'),
2081
+ stale_reasons: staleReasons.filter((reason) => reason.code !== 'not_materialized'),
2082
+ last_failure: latestFailure ? {
2083
+ audit_id: latestFailure.audit_id,
2084
+ created_at: latestFailure.created_at,
2085
+ reason_code: latestFailure.reason_code || null,
2086
+ failed_branch: latestFailure.failed_branch || null,
2087
+ conflicting_files: Array.isArray(latestFailure.conflicting_files) ? latestFailure.conflicting_files : [],
2088
+ output: latestFailure.output || null,
2089
+ command: latestFailure.command || null,
2090
+ next_action: latestFailure.next_action || null,
2091
+ } : null,
2092
+ last_recovery: latestRecoveryPrepared ? {
2093
+ audit_id: latestRecoveryPrepared.audit_id,
2094
+ created_at: latestRecoveryPrepared.created_at,
2095
+ recovery_path: latestRecoveryPrepared.recovery_path || null,
2096
+ failed_branch: latestRecoveryPrepared.failed_branch || null,
2097
+ conflicting_files: Array.isArray(latestRecoveryPrepared.conflicting_files) ? latestRecoveryPrepared.conflicting_files : [],
2098
+ inspect_command: latestRecoveryPrepared.inspect_command || null,
2099
+ resume_command: latestRecoveryPrepared.resume_command || null,
2100
+ state: recoveryState,
2101
+ } : null,
2102
+ last_materialized: latestEvent ? {
2103
+ audit_id: latestEvent.audit_id,
2104
+ created_at: latestEvent.created_at,
2105
+ head_commit: latestEvent.head_commit || null,
2106
+ base_commit: latestEvent.base_commit || null,
2107
+ component_branches: Array.isArray(latestEvent.component_branches) ? latestEvent.component_branches : [],
2108
+ component_commits: latestEvent.component_commits || {},
2109
+ } : null,
2110
+ };
2111
+ }
2112
+
2113
+ export function resolvePipelineLandingTarget(
2114
+ db,
2115
+ repoRoot,
2116
+ pipelineStatus,
2117
+ {
2118
+ explicitHeadBranch = null,
2119
+ requireCompleted = false,
2120
+ allowCurrentBranchFallback = true,
2121
+ } = {},
2122
+ ) {
2123
+ if (explicitHeadBranch) {
2124
+ return {
2125
+ branch: explicitHeadBranch,
2126
+ worktree: null,
2127
+ strategy: 'explicit',
2128
+ };
2129
+ }
2130
+
2131
+ if (requireCompleted) {
2132
+ const unfinishedTasks = pipelineStatus.tasks.filter((task) => task.status !== 'done');
2133
+ if (unfinishedTasks.length > 0) {
2134
+ throw new Error(`Pipeline ${pipelineStatus.pipeline_id} is not ready to queue. Complete remaining tasks first: ${unfinishedTasks.map((task) => task.id).join(', ')}.`);
2135
+ }
2136
+ }
2137
+
2138
+ const { implementationBranches, candidateBranches, branchToWorktree } = collectPipelineLandingCandidates(db, pipelineStatus);
2139
+ if (implementationBranches.length === 1) {
2140
+ const branch = implementationBranches[0];
2141
+ const worktree = branchToWorktree.get(branch) || null;
2142
+ return { branch, worktree, strategy: 'implementation_branch' };
2143
+ }
2144
+
2145
+ if (candidateBranches.length === 1) {
2146
+ const branch = candidateBranches[0];
2147
+ const worktree = branchToWorktree.get(branch) || null;
2148
+ return { branch, worktree, strategy: 'single_branch' };
2149
+ }
2150
+
2151
+ if (allowCurrentBranchFallback) {
2152
+ const currentBranch = getWorktreeBranch(repoRoot);
2153
+ if (currentBranch && currentBranch !== 'main') {
2154
+ return { branch: currentBranch, worktree: null, strategy: 'current_branch' };
2155
+ }
2156
+ }
2157
+
2158
+ throw new Error(`Pipeline ${pipelineStatus.pipeline_id} spans multiple branches (${candidateBranches.join(', ') || 'none inferred'}). Queue a branch or worktree explicitly.`);
2159
+ }
2160
+
2161
+ export function materializePipelineLandingBranch(
2162
+ db,
2163
+ repoRoot,
2164
+ pipelineId,
2165
+ {
2166
+ baseBranch = 'main',
2167
+ landingBranch = null,
2168
+ requireCompleted = true,
2169
+ refresh = false,
2170
+ } = {},
2171
+ ) {
2172
+ const pipelineStatus = getPipelineStatus(db, pipelineId);
2173
+ const { candidateBranches, prioritizedBranches, branchToWorktree } = collectPipelineLandingCandidates(db, pipelineStatus);
2174
+ const landingStatus = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2175
+ baseBranch,
2176
+ landingBranch,
2177
+ requireCompleted,
2178
+ });
2179
+
2180
+ if (candidateBranches.length === 1) {
2181
+ const branch = candidateBranches[0];
2182
+ return {
2183
+ pipeline_id: pipelineId,
2184
+ branch,
2185
+ base_branch: baseBranch,
2186
+ worktree: branchToWorktree.get(branch) || null,
2187
+ synthetic: false,
2188
+ component_branches: [branch],
2189
+ strategy: 'single_branch',
2190
+ head_commit: null,
2191
+ };
2192
+ }
2193
+
2194
+ if (landingStatus.last_materialized && !landingStatus.stale) {
2195
+ return {
2196
+ pipeline_id: pipelineId,
2197
+ branch: landingStatus.branch,
2198
+ base_branch: baseBranch,
2199
+ worktree: null,
2200
+ synthetic: true,
2201
+ component_branches: landingStatus.component_branches,
2202
+ component_commits: landingStatus.component_commits,
2203
+ strategy: 'synthetic_integration_branch',
2204
+ head_commit: landingStatus.branch_head_commit,
2205
+ refreshed: false,
2206
+ reused_existing: true,
2207
+ stale: false,
2208
+ stale_reasons: [],
2209
+ last_materialized: landingStatus.last_materialized,
2210
+ };
2211
+ }
2212
+
2213
+ if (landingStatus.stale && !refresh) {
2214
+ const summaries = landingStatus.stale_reasons.map((reason) => reason.summary).join(' ');
2215
+ throw new Error(`Landing branch ${landingStatus.branch} is stale. ${summaries} Run \`switchman pipeline land ${pipelineId} --refresh${landingBranch ? ` --branch ${landingBranch}` : ''}\` to rebuild it.`);
2216
+ }
2217
+
2218
+ const resolvedLandingBranch = getPipelineLandingBranchName(pipelineId, landingBranch);
2219
+ const tempWorktreePath = join(tmpdir(), `switchman-landing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
2220
+ let materialized;
2221
+ const landingOperation = startOperationJournalEntry(db, {
2222
+ scopeType: 'pipeline',
2223
+ scopeId: pipelineId,
2224
+ operationType: 'landing_materialize',
2225
+ details: JSON.stringify({
2226
+ pipeline_id: pipelineId,
2227
+ branch: resolvedLandingBranch,
2228
+ base_branch: baseBranch,
2229
+ component_branches: prioritizedBranches,
2230
+ refresh: Boolean(refresh),
2231
+ }),
2232
+ });
2233
+ const landingTempResource = createTempResource(db, {
2234
+ scopeType: 'pipeline',
2235
+ scopeId: pipelineId,
2236
+ operationId: landingOperation.id,
2237
+ resourceType: 'landing_temp_worktree',
2238
+ path: tempWorktreePath,
2239
+ branch: resolvedLandingBranch,
2240
+ details: stringifyResourceDetails({
2241
+ pipeline_id: pipelineId,
2242
+ branch: resolvedLandingBranch,
2243
+ base_branch: baseBranch,
2244
+ component_branches: prioritizedBranches,
2245
+ operation_type: 'landing_materialize',
2246
+ }),
2247
+ });
2248
+ try {
2249
+ materialized = gitMaterializeIntegrationBranch(repoRoot, {
2250
+ branch: resolvedLandingBranch,
2251
+ baseBranch,
2252
+ mergeBranches: prioritizedBranches,
2253
+ tempWorktreePath,
2254
+ });
2255
+ finishOperationJournalEntry(db, landingOperation.id, {
2256
+ status: 'completed',
2257
+ details: JSON.stringify({
2258
+ pipeline_id: pipelineId,
2259
+ branch: resolvedLandingBranch,
2260
+ base_branch: baseBranch,
2261
+ component_branches: prioritizedBranches,
2262
+ head_commit: materialized.head_commit,
2263
+ refresh: Boolean(refresh),
2264
+ }),
2265
+ });
2266
+ } catch (err) {
2267
+ finishOperationJournalEntry(db, landingOperation.id, {
2268
+ status: 'failed',
2269
+ details: JSON.stringify({
2270
+ pipeline_id: pipelineId,
2271
+ branch: resolvedLandingBranch,
2272
+ base_branch: baseBranch,
2273
+ component_branches: prioritizedBranches,
2274
+ reason_code: err?.code || 'landing_branch_materialization_failed',
2275
+ failed_branch: err?.details?.failed_branch || null,
2276
+ error: String(err?.message || err),
2277
+ }),
2278
+ });
2279
+ const reasonCode = err?.code || 'landing_branch_materialization_failed';
2280
+ const nextAction = reasonCode === 'landing_branch_merge_conflict'
2281
+ ? `open a recovery worktree with switchman pipeline land ${pipelineId} --recover`
2282
+ : reasonCode === 'landing_branch_missing_component'
2283
+ ? `restore the missing branch and rerun switchman pipeline land ${pipelineId} --refresh`
2284
+ : reasonCode === 'landing_branch_missing_base'
2285
+ ? `restore ${baseBranch} and rerun switchman pipeline land ${pipelineId} --refresh`
2286
+ : `inspect the landing failure and rerun switchman pipeline land ${pipelineId} --refresh`;
2287
+ logAuditEvent(db, {
2288
+ eventType: 'pipeline_landing_branch_materialized',
2289
+ status: 'denied',
2290
+ reasonCode,
2291
+ details: JSON.stringify({
2292
+ pipeline_id: pipelineId,
2293
+ branch: resolvedLandingBranch,
2294
+ base_branch: baseBranch,
2295
+ component_branches: prioritizedBranches,
2296
+ failed_branch: err?.details?.failed_branch || null,
2297
+ conflicting_files: err?.details?.conflicting_files || [],
2298
+ output: err?.details?.output || String(err.message || '').slice(0, 1000),
2299
+ command: reasonCode === 'landing_branch_merge_conflict'
2300
+ ? `switchman pipeline land ${pipelineId} --recover`
2301
+ : `switchman pipeline land ${pipelineId} --refresh`,
2302
+ next_action: nextAction,
2303
+ }),
2304
+ });
2305
+ const wrapped = new Error(`${String(err.message || 'Landing branch materialization failed.')}\nnext: switchman explain landing ${pipelineId}`);
2306
+ wrapped.code = reasonCode;
2307
+ throw wrapped;
2308
+ } finally {
2309
+ updateTempResource(db, landingTempResource.id, {
2310
+ status: existsSync(tempWorktreePath) ? 'active' : 'released',
2311
+ details: stringifyResourceDetails({
2312
+ pipeline_id: pipelineId,
2313
+ branch: resolvedLandingBranch,
2314
+ base_branch: baseBranch,
2315
+ component_branches: prioritizedBranches,
2316
+ operation_type: 'landing_materialize',
2317
+ released_by: existsSync(tempWorktreePath) ? null : 'materialize_cleanup',
2318
+ }),
2319
+ });
2320
+ }
2321
+ const baseCommit = gitRevParse(repoRoot, baseBranch);
2322
+ const componentCommits = collectBranchHeadCommits(repoRoot, prioritizedBranches);
2323
+
2324
+ logAuditEvent(db, {
2325
+ eventType: 'pipeline_landing_branch_materialized',
2326
+ status: 'allowed',
2327
+ details: JSON.stringify({
2328
+ pipeline_id: pipelineId,
2329
+ branch: resolvedLandingBranch,
2330
+ base_branch: baseBranch,
2331
+ base_commit: baseCommit,
2332
+ component_branches: prioritizedBranches,
2333
+ component_commits: componentCommits,
2334
+ head_commit: materialized.head_commit,
2335
+ }),
2336
+ });
2337
+
2338
+ return {
2339
+ pipeline_id: pipelineId,
2340
+ branch: resolvedLandingBranch,
2341
+ base_branch: baseBranch,
2342
+ worktree: null,
2343
+ synthetic: true,
2344
+ component_branches: prioritizedBranches,
2345
+ component_commits: componentCommits,
2346
+ strategy: 'synthetic_integration_branch',
2347
+ head_commit: materialized.head_commit,
2348
+ refreshed: refresh || Boolean(landingStatus.last_materialized),
2349
+ reused_existing: false,
2350
+ stale: false,
2351
+ stale_reasons: [],
2352
+ last_materialized: {
2353
+ head_commit: materialized.head_commit,
2354
+ base_commit: baseCommit,
2355
+ component_branches: prioritizedBranches,
2356
+ component_commits: componentCommits,
2357
+ },
2358
+ };
2359
+ }
2360
+
2361
+ export function getPipelineLandingExplainReport(
2362
+ db,
2363
+ repoRoot,
2364
+ pipelineId,
2365
+ options = {},
2366
+ ) {
2367
+ const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2368
+ requireCompleted: false,
2369
+ ...options,
2370
+ });
2371
+ const nextAction = landing.last_failure?.next_action
2372
+ || (landing.stale
2373
+ ? `switchman pipeline land ${pipelineId} --refresh`
2374
+ : landing.synthetic
2375
+ ? `switchman queue add --pipeline ${pipelineId}`
2376
+ : `switchman queue add ${landing.branch}`);
2377
+ return {
2378
+ pipeline_id: pipelineId,
2379
+ landing,
2380
+ next_action: nextAction,
2381
+ };
2382
+ }
2383
+
2384
+ export function preparePipelineLandingRecovery(
2385
+ db,
2386
+ repoRoot,
2387
+ pipelineId,
2388
+ {
2389
+ baseBranch = 'main',
2390
+ landingBranch = null,
2391
+ recoveryPath = null,
2392
+ replaceExisting = false,
2393
+ } = {},
2394
+ ) {
2395
+ const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2396
+ baseBranch,
2397
+ landingBranch,
2398
+ requireCompleted: true,
2399
+ });
2400
+ if (!landing.synthetic) {
2401
+ throw new Error(`Pipeline ${pipelineId} does not need a synthetic landing recovery worktree.`);
2402
+ }
2403
+ if (landing.last_failure?.reason_code !== 'landing_branch_merge_conflict') {
2404
+ if (
2405
+ !replaceExisting
2406
+ && landing.last_recovery?.state?.status === 'active'
2407
+ && landing.last_recovery?.recovery_path
2408
+ ) {
2409
+ return {
2410
+ pipeline_id: pipelineId,
2411
+ branch: landing.branch,
2412
+ base_branch: baseBranch,
2413
+ recovery_path: landing.last_recovery.recovery_path,
2414
+ failed_branch: landing.last_recovery.failed_branch || null,
2415
+ conflicting_files: landing.last_recovery.conflicting_files || [],
2416
+ inspect_command: landing.last_recovery.inspect_command || `git -C ${JSON.stringify(landing.last_recovery.recovery_path)} status`,
2417
+ resume_command: landing.last_recovery.resume_command || `switchman queue add --pipeline ${pipelineId}`,
2418
+ reused_existing: true,
2419
+ };
2420
+ }
2421
+ throw new Error(`Pipeline ${pipelineId} does not have a merge-conflict landing failure to recover.`);
2422
+ }
2423
+ if (landing.last_recovery?.state?.status && !replaceExisting) {
2424
+ if (landing.last_recovery.state.status === 'active' && landing.last_recovery.recovery_path) {
2425
+ return {
2426
+ pipeline_id: pipelineId,
2427
+ branch: landing.branch,
2428
+ base_branch: baseBranch,
2429
+ recovery_path: landing.last_recovery.recovery_path,
2430
+ failed_branch: landing.last_recovery.failed_branch || null,
2431
+ conflicting_files: landing.last_recovery.conflicting_files || [],
2432
+ inspect_command: landing.last_recovery.inspect_command || `git -C ${JSON.stringify(landing.last_recovery.recovery_path)} status`,
2433
+ resume_command: landing.last_recovery.resume_command || `switchman queue add --pipeline ${pipelineId}`,
2434
+ reused_existing: true,
2435
+ };
2436
+ }
2437
+ throw new Error(`Recovery worktree already exists for ${pipelineId} at ${landing.last_recovery.recovery_path}. Reuse it or rerun with \`switchman pipeline land ${pipelineId} --recover --replace-recovery\`.`);
2438
+ }
2439
+ if (landing.last_recovery?.state?.path && replaceExisting) {
2440
+ cleanupPipelineLandingRecovery(db, repoRoot, pipelineId, {
2441
+ baseBranch,
2442
+ landingBranch,
2443
+ recoveryPath: landing.last_recovery.state.path,
2444
+ reason: 'replaced',
2445
+ });
2446
+ }
2447
+
2448
+ const resolvedRecoveryPath = recoveryPath || join(
2449
+ tmpdir(),
2450
+ `${basename(repoRoot)}-landing-recover-${pipelineId}-${Date.now()}`,
2451
+ );
2452
+ const recoveryPrepareOperation = startOperationJournalEntry(db, {
2453
+ scopeType: 'pipeline',
2454
+ scopeId: pipelineId,
2455
+ operationType: 'landing_recovery_prepare',
2456
+ details: JSON.stringify({
2457
+ pipeline_id: pipelineId,
2458
+ branch: landing.branch,
2459
+ base_branch: baseBranch,
2460
+ recovery_path: resolvedRecoveryPath,
2461
+ }),
2462
+ });
2463
+ const recoveryResource = createTempResource(db, {
2464
+ scopeType: 'pipeline',
2465
+ scopeId: pipelineId,
2466
+ operationId: recoveryPrepareOperation.id,
2467
+ resourceType: 'landing_recovery_worktree',
2468
+ path: resolvedRecoveryPath,
2469
+ branch: landing.branch,
2470
+ details: stringifyResourceDetails({
2471
+ pipeline_id: pipelineId,
2472
+ branch: landing.branch,
2473
+ base_branch: baseBranch,
2474
+ operation_type: 'landing_recovery_prepare',
2475
+ recovery_path: resolvedRecoveryPath,
2476
+ }),
2477
+ });
2478
+ const prepared = gitPrepareIntegrationRecoveryWorktree(repoRoot, {
2479
+ branch: landing.branch,
2480
+ baseBranch,
2481
+ mergeBranches: landing.component_branches,
2482
+ recoveryPath: resolvedRecoveryPath,
2483
+ });
2484
+ if (prepared.ok) {
2485
+ try {
2486
+ gitRemoveWorktree(repoRoot, resolvedRecoveryPath);
2487
+ } catch {
2488
+ // Best-effort cleanup; repair will reconcile any leftover tracked resource.
2489
+ }
2490
+ updateTempResource(db, recoveryResource.id, {
2491
+ status: existsSync(resolvedRecoveryPath) ? 'active' : 'released',
2492
+ details: stringifyResourceDetails({
2493
+ pipeline_id: pipelineId,
2494
+ branch: landing.branch,
2495
+ base_branch: baseBranch,
2496
+ operation_type: 'landing_recovery_prepare',
2497
+ recovery_path: resolvedRecoveryPath,
2498
+ released_by: existsSync(resolvedRecoveryPath) ? null : 'recovery_prepare_cleanup',
2499
+ reason: 'conflict_already_resolved',
2500
+ }),
2501
+ });
2502
+ finishOperationJournalEntry(db, recoveryPrepareOperation.id, {
2503
+ status: 'failed',
2504
+ details: JSON.stringify({
2505
+ pipeline_id: pipelineId,
2506
+ branch: landing.branch,
2507
+ base_branch: baseBranch,
2508
+ recovery_path: resolvedRecoveryPath,
2509
+ error: 'Recovery preparation no longer needed because the landing merge conflict is already resolved.',
2510
+ }),
2511
+ });
2512
+ throw new Error(`Pipeline ${pipelineId} no longer has an unresolved landing merge conflict to recover.`);
2513
+ }
2514
+
2515
+ const inspectCommand = `git -C ${JSON.stringify(prepared.recovery_path)} status`;
2516
+ const resumeCommand = `switchman queue add --pipeline ${pipelineId}`;
2517
+
2518
+ logAuditEvent(db, {
2519
+ eventType: 'pipeline_landing_recovery_prepared',
2520
+ status: 'allowed',
2521
+ details: JSON.stringify({
2522
+ pipeline_id: pipelineId,
2523
+ branch: landing.branch,
2524
+ base_branch: baseBranch,
2525
+ recovery_path: prepared.recovery_path,
2526
+ failed_branch: prepared.failed_branch,
2527
+ conflicting_files: prepared.conflicting_files,
2528
+ inspect_command: inspectCommand,
2529
+ resume_command: resumeCommand,
2530
+ }),
2531
+ });
2532
+ finishOperationJournalEntry(db, recoveryPrepareOperation.id, {
2533
+ status: 'completed',
2534
+ details: JSON.stringify({
2535
+ pipeline_id: pipelineId,
2536
+ branch: landing.branch,
2537
+ base_branch: baseBranch,
2538
+ recovery_path: prepared.recovery_path,
2539
+ failed_branch: prepared.failed_branch,
2540
+ conflicting_files: prepared.conflicting_files,
2541
+ }),
2542
+ });
2543
+ updateTempResource(db, recoveryResource.id, {
2544
+ status: 'active',
2545
+ details: stringifyResourceDetails({
2546
+ pipeline_id: pipelineId,
2547
+ branch: landing.branch,
2548
+ base_branch: baseBranch,
2549
+ operation_type: 'landing_recovery_prepare',
2550
+ recovery_path: prepared.recovery_path,
2551
+ failed_branch: prepared.failed_branch,
2552
+ conflicting_files: prepared.conflicting_files,
2553
+ }),
2554
+ });
2555
+
2556
+ return {
2557
+ pipeline_id: pipelineId,
2558
+ branch: landing.branch,
2559
+ base_branch: baseBranch,
2560
+ recovery_path: prepared.recovery_path,
2561
+ failed_branch: prepared.failed_branch,
2562
+ conflicting_files: prepared.conflicting_files,
2563
+ inspect_command: inspectCommand,
2564
+ resume_command: resumeCommand,
2565
+ reused_existing: false,
2566
+ };
2567
+ }
2568
+
2569
+ export function resumePipelineLandingRecovery(
2570
+ db,
2571
+ repoRoot,
2572
+ pipelineId,
2573
+ {
2574
+ baseBranch = 'main',
2575
+ landingBranch = null,
2576
+ recoveryPath = null,
2577
+ } = {},
2578
+ ) {
2579
+ const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2580
+ baseBranch,
2581
+ landingBranch,
2582
+ requireCompleted: true,
2583
+ });
2584
+ if (!landing.synthetic) {
2585
+ throw new Error(`Pipeline ${pipelineId} does not need a synthetic landing recovery worktree.`);
2586
+ }
2587
+
2588
+ const resolvedRecoveryPath = recoveryPath || landing.last_recovery?.recovery_path || null;
2589
+ if (!landing.last_failure && landing.last_materialized && landing.branch_head_commit) {
2590
+ return {
2591
+ pipeline_id: pipelineId,
2592
+ branch: landing.branch,
2593
+ base_branch: baseBranch,
2594
+ recovery_path: resolvedRecoveryPath,
2595
+ head_commit: landing.branch_head_commit,
2596
+ resume_command: `switchman queue add --pipeline ${pipelineId}`,
2597
+ already_resumed: true,
2598
+ };
2599
+ }
2600
+ if (!resolvedRecoveryPath) {
2601
+ throw new Error(`No recovery worktree is recorded for ${pipelineId}. Run \`switchman pipeline land ${pipelineId} --recover\` first.`);
2602
+ }
2603
+ const recoveryResumeOperation = startOperationJournalEntry(db, {
2604
+ scopeType: 'pipeline',
2605
+ scopeId: pipelineId,
2606
+ operationType: 'landing_recovery_resume',
2607
+ details: JSON.stringify({
2608
+ pipeline_id: pipelineId,
2609
+ branch: landing.branch,
2610
+ recovery_path: resolvedRecoveryPath,
2611
+ }),
2612
+ });
2613
+
2614
+ const currentBranch = getWorktreeBranch(resolvedRecoveryPath);
2615
+ if (currentBranch !== landing.branch) {
2616
+ finishOperationJournalEntry(db, recoveryResumeOperation.id, {
2617
+ status: 'failed',
2618
+ details: JSON.stringify({
2619
+ pipeline_id: pipelineId,
2620
+ branch: landing.branch,
2621
+ recovery_path: resolvedRecoveryPath,
2622
+ error: `Recovery worktree is on ${currentBranch || 'no branch'}.`,
2623
+ }),
2624
+ });
2625
+ throw new Error(`Recovery worktree must be on ${landing.branch}, but is on ${currentBranch || 'no branch'}.`);
2626
+ }
2627
+
2628
+ const statusResult = spawnSync('git', ['status', '--porcelain'], {
2629
+ cwd: resolvedRecoveryPath,
2630
+ encoding: 'utf8',
2631
+ });
2632
+ if (statusResult.status !== 0) {
2633
+ finishOperationJournalEntry(db, recoveryResumeOperation.id, {
2634
+ status: 'failed',
2635
+ details: JSON.stringify({
2636
+ pipeline_id: pipelineId,
2637
+ branch: landing.branch,
2638
+ recovery_path: resolvedRecoveryPath,
2639
+ error: `Could not inspect recovery worktree ${resolvedRecoveryPath}.`,
2640
+ }),
2641
+ });
2642
+ throw new Error(`Could not inspect recovery worktree ${resolvedRecoveryPath}.`);
2643
+ }
2644
+ const pendingChanges = String(statusResult.stdout || '').trim();
2645
+ if (pendingChanges) {
2646
+ finishOperationJournalEntry(db, recoveryResumeOperation.id, {
2647
+ status: 'failed',
2648
+ details: JSON.stringify({
2649
+ pipeline_id: pipelineId,
2650
+ branch: landing.branch,
2651
+ recovery_path: resolvedRecoveryPath,
2652
+ error: 'Recovery worktree still has unresolved or uncommitted changes.',
2653
+ }),
2654
+ });
2655
+ throw new Error(`Recovery worktree ${resolvedRecoveryPath} still has unresolved or uncommitted changes. Commit the resolved landing branch first.`);
2656
+ }
2657
+
2658
+ const recoveredHead = gitRevParse(resolvedRecoveryPath, 'HEAD');
2659
+ const branchHead = gitRevParse(repoRoot, landing.branch);
2660
+ if (!recoveredHead || !branchHead || recoveredHead !== branchHead) {
2661
+ finishOperationJournalEntry(db, recoveryResumeOperation.id, {
2662
+ status: 'failed',
2663
+ details: JSON.stringify({
2664
+ pipeline_id: pipelineId,
2665
+ branch: landing.branch,
2666
+ recovery_path: resolvedRecoveryPath,
2667
+ error: 'Recovery worktree head is not aligned with the landing branch head.',
2668
+ }),
2669
+ });
2670
+ throw new Error(`Recovery worktree ${resolvedRecoveryPath} is not aligned with ${landing.branch}. Push or commit the resolved landing branch there first.`);
2671
+ }
2672
+
2673
+ const componentCommits = collectBranchHeadCommits(repoRoot, landing.component_branches);
2674
+ logAuditEvent(db, {
2675
+ eventType: 'pipeline_landing_recovery_resumed',
2676
+ status: 'allowed',
2677
+ details: JSON.stringify({
2678
+ pipeline_id: pipelineId,
2679
+ branch: landing.branch,
2680
+ base_branch: baseBranch,
2681
+ base_commit: gitRevParse(repoRoot, baseBranch),
2682
+ head_commit: branchHead,
2683
+ component_branches: landing.component_branches,
2684
+ component_commits: componentCommits,
2685
+ recovery_path: resolvedRecoveryPath,
2686
+ resume_command: `switchman queue add --pipeline ${pipelineId}`,
2687
+ }),
2688
+ });
2689
+ finishOperationJournalEntry(db, recoveryResumeOperation.id, {
2690
+ status: 'completed',
2691
+ details: JSON.stringify({
2692
+ pipeline_id: pipelineId,
2693
+ branch: landing.branch,
2694
+ recovery_path: resolvedRecoveryPath,
2695
+ head_commit: branchHead,
2696
+ }),
2697
+ });
2698
+ const activeRecoveryResources = listTempResources(db, {
2699
+ scopeType: 'pipeline',
2700
+ scopeId: pipelineId,
2701
+ resourceType: 'landing_recovery_worktree',
2702
+ status: 'active',
2703
+ limit: 20,
2704
+ }).filter((resource) => resource.path === resolvedRecoveryPath);
2705
+ for (const resource of activeRecoveryResources) {
2706
+ updateTempResource(db, resource.id, {
2707
+ status: 'resolved',
2708
+ details: stringifyResourceDetails({
2709
+ pipeline_id: pipelineId,
2710
+ branch: landing.branch,
2711
+ operation_type: 'landing_recovery_resume',
2712
+ recovery_path: resolvedRecoveryPath,
2713
+ head_commit: branchHead,
2714
+ }),
2715
+ });
2716
+ }
2717
+
2718
+ return {
2719
+ pipeline_id: pipelineId,
2720
+ branch: landing.branch,
2721
+ base_branch: baseBranch,
2722
+ recovery_path: resolvedRecoveryPath,
2723
+ head_commit: branchHead,
2724
+ resume_command: `switchman queue add --pipeline ${pipelineId}`,
2725
+ already_resumed: false,
2726
+ };
2727
+ }
2728
+
2729
+ export function cleanupPipelineLandingRecovery(
2730
+ db,
2731
+ repoRoot,
2732
+ pipelineId,
2733
+ {
2734
+ baseBranch = 'main',
2735
+ landingBranch = null,
2736
+ recoveryPath = null,
2737
+ reason = 'manual_cleanup',
2738
+ } = {},
2739
+ ) {
2740
+ const landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2741
+ baseBranch,
2742
+ landingBranch,
2743
+ requireCompleted: true,
2744
+ });
2745
+ const targetPath = recoveryPath || landing.last_recovery?.recovery_path || null;
2746
+ if (!targetPath) {
2747
+ throw new Error(`No recovery worktree is recorded for ${pipelineId}.`);
2748
+ }
2749
+ const recoveryCleanupOperation = startOperationJournalEntry(db, {
2750
+ scopeType: 'pipeline',
2751
+ scopeId: pipelineId,
2752
+ operationType: 'landing_recovery_cleanup',
2753
+ details: JSON.stringify({
2754
+ pipeline_id: pipelineId,
2755
+ branch: landing.branch,
2756
+ recovery_path: targetPath,
2757
+ reason,
2758
+ }),
2759
+ });
2760
+
2761
+ const exists = existsSync(targetPath);
2762
+ const normalizedTargetPath = exists ? realpathSync(targetPath) : targetPath;
2763
+ const tracked = listGitWorktrees(repoRoot).some((worktree) =>
2764
+ worktree.path === targetPath || worktree.path === normalizedTargetPath,
2765
+ );
2766
+ if (tracked && exists) {
2767
+ gitRemoveWorktree(repoRoot, targetPath);
2768
+ }
2769
+ const trackedRecoveryResources = listTempResources(db, {
2770
+ scopeType: 'pipeline',
2771
+ scopeId: pipelineId,
2772
+ resourceType: 'landing_recovery_worktree',
2773
+ limit: 20,
2774
+ }).filter((resource) => resource.path === targetPath && resource.status !== 'released');
2775
+ for (const resource of trackedRecoveryResources) {
2776
+ const nextStatus = resource.status === 'abandoned' && !existsSync(targetPath)
2777
+ ? 'abandoned'
2778
+ : existsSync(targetPath)
2779
+ ? 'active'
2780
+ : 'released';
2781
+ updateTempResource(db, resource.id, {
2782
+ status: nextStatus,
2783
+ details: stringifyResourceDetails({
2784
+ pipeline_id: pipelineId,
2785
+ branch: landing.branch,
2786
+ operation_type: 'landing_recovery_cleanup',
2787
+ recovery_path: targetPath,
2788
+ removed: tracked && exists,
2789
+ reason,
2790
+ prior_status: resource.status,
2791
+ }),
2792
+ });
2793
+ }
2794
+
2795
+ logAuditEvent(db, {
2796
+ eventType: 'pipeline_landing_recovery_cleared',
2797
+ status: 'allowed',
2798
+ details: JSON.stringify({
2799
+ pipeline_id: pipelineId,
2800
+ branch: landing.branch,
2801
+ recovery_path: targetPath,
2802
+ existed: exists,
2803
+ tracked,
2804
+ reason,
2805
+ }),
2806
+ });
2807
+ finishOperationJournalEntry(db, recoveryCleanupOperation.id, {
2808
+ status: 'completed',
2809
+ details: JSON.stringify({
2810
+ pipeline_id: pipelineId,
2811
+ branch: landing.branch,
2812
+ recovery_path: targetPath,
2813
+ removed: tracked && exists,
2814
+ reason,
2815
+ }),
2816
+ });
2817
+
2818
+ return {
2819
+ pipeline_id: pipelineId,
2820
+ branch: landing.branch,
2821
+ recovery_path: targetPath,
2822
+ existed: exists,
2823
+ tracked,
2824
+ removed: tracked && exists,
2825
+ reason,
2826
+ };
2827
+ }
2828
+
2829
+ export function repairPipelineState(
2830
+ db,
2831
+ repoRoot,
2832
+ pipelineId,
2833
+ {
2834
+ baseBranch = 'main',
2835
+ landingBranch = null,
2836
+ } = {},
2837
+ ) {
2838
+ const actions = [];
2839
+ let notes = [];
2840
+ let landing;
2841
+
2842
+ try {
2843
+ landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2844
+ baseBranch,
2845
+ landingBranch,
2846
+ requireCompleted: false,
2847
+ });
2848
+ } catch (err) {
2849
+ return {
2850
+ pipeline_id: pipelineId,
2851
+ repaired: false,
2852
+ actions,
2853
+ notes: [String(err.message || 'Pipeline repair found no landing state to repair.')],
2854
+ next_action: `switchman pipeline status ${pipelineId}`,
2855
+ };
2856
+ }
2857
+
2858
+ const recoveryStatus = landing.last_recovery?.state?.status || null;
2859
+ if (['missing', 'resolved_missing', 'untracked', 'resolved_untracked', 'moved', 'resolved_moved'].includes(recoveryStatus) && landing.last_recovery?.recovery_path) {
2860
+ const cleared = cleanupPipelineLandingRecovery(db, repoRoot, pipelineId, {
2861
+ baseBranch,
2862
+ landingBranch,
2863
+ recoveryPath: landing.last_recovery.recovery_path,
2864
+ reason: `repair_${recoveryStatus}_recovery`,
2865
+ });
2866
+ actions.push({
2867
+ kind: 'recovery_state_cleared',
2868
+ recovery_path: cleared.recovery_path,
2869
+ removed: cleared.removed,
2870
+ recovery_status: recoveryStatus,
2871
+ branch_worktree_path: landing.last_recovery?.state?.branch_worktree_path || null,
2872
+ });
2873
+ landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2874
+ baseBranch,
2875
+ landingBranch,
2876
+ requireCompleted: false,
2877
+ });
2878
+ }
2879
+
2880
+ const needsLandingRefresh = landing.synthetic
2881
+ && !landing.last_failure
2882
+ && (
2883
+ landing.stale
2884
+ || (landing.branch_exists && !landing.last_materialized)
2885
+ );
2886
+
2887
+ if (needsLandingRefresh) {
2888
+ const refreshed = materializePipelineLandingBranch(db, repoRoot, pipelineId, {
2889
+ baseBranch,
2890
+ landingBranch,
2891
+ requireCompleted: false,
2892
+ refresh: true,
2893
+ });
2894
+ actions.push({
2895
+ kind: landing.last_materialized ? 'landing_branch_refreshed' : 'landing_branch_reconciled',
2896
+ branch: refreshed.branch,
2897
+ head_commit: refreshed.head_commit || null,
2898
+ });
2899
+ landing = getPipelineLandingBranchStatus(db, repoRoot, pipelineId, {
2900
+ baseBranch,
2901
+ landingBranch,
2902
+ requireCompleted: false,
2903
+ });
2904
+ }
2905
+
2906
+ if (actions.length === 0) {
2907
+ notes = ['No repair action was needed.'];
2908
+ }
2909
+
2910
+ const nextAction = landing.last_failure?.next_action
2911
+ || (landing.synthetic
2912
+ ? `switchman queue add --pipeline ${pipelineId}`
2913
+ : landing.branch
2914
+ ? `switchman queue add ${landing.branch}`
2915
+ : `switchman pipeline status ${pipelineId}`);
2916
+
2917
+ return {
2918
+ pipeline_id: pipelineId,
2919
+ repaired: actions.length > 0,
2920
+ actions,
2921
+ notes,
2922
+ landing,
2923
+ next_action: nextAction,
2924
+ };
2925
+ }
2926
+
2927
+ export function preparePipelineLandingTarget(
2928
+ db,
2929
+ repoRoot,
2930
+ pipelineId,
2931
+ {
2932
+ baseBranch = 'main',
2933
+ explicitHeadBranch = null,
2934
+ requireCompleted = false,
2935
+ allowCurrentBranchFallback = true,
2936
+ landingBranch = null,
2937
+ } = {},
2938
+ ) {
2939
+ const pipelineStatus = getPipelineStatus(db, pipelineId);
2940
+ const completedPipeline = pipelineStatus.tasks.length > 0 && pipelineStatus.tasks.every((task) => task.status === 'done');
2941
+ const { candidateBranches } = collectPipelineLandingCandidates(db, pipelineStatus);
2942
+
2943
+ if (!explicitHeadBranch && completedPipeline && candidateBranches.length > 1) {
2944
+ return materializePipelineLandingBranch(db, repoRoot, pipelineId, {
2945
+ baseBranch,
2946
+ landingBranch,
2947
+ requireCompleted: true,
2948
+ refresh: true,
2949
+ });
2950
+ }
2951
+
2952
+ try {
2953
+ const resolved = resolvePipelineLandingTarget(db, repoRoot, pipelineStatus, {
2954
+ explicitHeadBranch,
2955
+ requireCompleted,
2956
+ allowCurrentBranchFallback,
2957
+ });
2958
+ return {
2959
+ pipeline_id: pipelineId,
2960
+ ...resolved,
2961
+ synthetic: false,
2962
+ component_branches: [resolved.branch],
2963
+ head_commit: null,
2964
+ };
2965
+ } catch (err) {
2966
+ if (!String(err.message || '').includes('spans multiple branches')) {
2967
+ throw err;
2968
+ }
2969
+ return materializePipelineLandingBranch(db, repoRoot, pipelineId, {
2970
+ baseBranch,
2971
+ landingBranch,
2972
+ requireCompleted: true,
2973
+ refresh: true,
2974
+ });
2975
+ }
2976
+ }
2977
+
2978
+ export async function publishPipelinePr(
2979
+ db,
2980
+ repoRoot,
2981
+ pipelineId,
2982
+ {
2983
+ baseBranch = 'main',
2984
+ headBranch = null,
2985
+ draft = false,
2986
+ ghCommand = 'gh',
2987
+ outputDir = null,
2988
+ } = {},
2989
+ ) {
2990
+ const policyGate = await evaluatePipelinePolicyGate(db, repoRoot, pipelineId);
2991
+ if (!policyGate.ok) {
2992
+ logAuditEvent(db, {
2993
+ eventType: 'pipeline_pr_published',
2994
+ status: 'denied',
2995
+ reasonCode: policyGate.reason_code,
2996
+ details: JSON.stringify({
2997
+ pipeline_id: pipelineId,
2998
+ base_branch: baseBranch,
2999
+ head_branch: headBranch,
3000
+ policy_state: policyGate.policy_state,
3001
+ next_action: policyGate.next_action,
3002
+ }),
3003
+ });
3004
+ throw new Error(`${policyGate.summary} Next: ${policyGate.next_action}`);
3005
+ }
3006
+
3007
+ const bundle = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir);
3008
+ const resolvedLandingTarget = preparePipelineLandingTarget(db, repoRoot, pipelineId, {
3009
+ baseBranch,
3010
+ explicitHeadBranch: headBranch,
3011
+ requireCompleted: false,
3012
+ allowCurrentBranchFallback: true,
3013
+ });
3014
+ const resolvedHeadBranch = resolvedLandingTarget.branch;
3015
+
3016
+ const args = [
3017
+ 'pr',
3018
+ 'create',
3019
+ '--base',
3020
+ baseBranch,
3021
+ '--head',
3022
+ resolvedHeadBranch,
3023
+ '--title',
3024
+ bundle.summary.pr_artifact.title,
3025
+ '--body-file',
3026
+ bundle.files.pr_body_markdown,
3027
+ ];
3028
+
3029
+ if (draft) {
3030
+ args.push('--draft');
3031
+ }
3032
+
3033
+ const result = spawnSync(ghCommand, args, {
3034
+ cwd: repoRoot,
3035
+ encoding: 'utf8',
3036
+ });
870
3037
 
871
3038
  const ok = !result.error && result.status === 0;
872
3039
  const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
@@ -894,16 +3061,149 @@ export async function publishPipelinePr(
894
3061
  pipeline_id: pipelineId,
895
3062
  base_branch: baseBranch,
896
3063
  head_branch: resolvedHeadBranch,
3064
+ landing_strategy: resolvedLandingTarget.strategy,
897
3065
  draft,
898
3066
  bundle,
899
3067
  output,
900
3068
  };
901
3069
  }
902
3070
 
3071
+ export async function commentPipelinePr(
3072
+ db,
3073
+ repoRoot,
3074
+ pipelineId,
3075
+ {
3076
+ prNumber,
3077
+ ghCommand = 'gh',
3078
+ outputDir = null,
3079
+ updateExisting = false,
3080
+ } = {},
3081
+ ) {
3082
+ if (!prNumber) {
3083
+ throw new Error('A pull request number is required.');
3084
+ }
3085
+
3086
+ const bundle = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir);
3087
+ const args = [
3088
+ 'pr',
3089
+ 'comment',
3090
+ String(prNumber),
3091
+ '--body-file',
3092
+ bundle.files.landing_summary_markdown,
3093
+ ];
3094
+
3095
+ if (updateExisting) {
3096
+ args.push('--edit-last', '--create-if-none');
3097
+ }
3098
+
3099
+ const result = spawnSync(ghCommand, args, {
3100
+ cwd: repoRoot,
3101
+ encoding: 'utf8',
3102
+ });
3103
+
3104
+ const ok = !result.error && result.status === 0;
3105
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
3106
+
3107
+ logAuditEvent(db, {
3108
+ eventType: 'pipeline_pr_commented',
3109
+ status: ok ? 'allowed' : 'denied',
3110
+ reasonCode: ok ? null : 'pr_comment_failed',
3111
+ details: JSON.stringify({
3112
+ pipeline_id: pipelineId,
3113
+ pr_number: String(prNumber),
3114
+ gh_command: ghCommand,
3115
+ update_existing: updateExisting,
3116
+ body_file: bundle.files.landing_summary_markdown,
3117
+ exit_code: result.status,
3118
+ output: output.slice(0, 500),
3119
+ }),
3120
+ });
3121
+
3122
+ if (!ok) {
3123
+ throw new Error(result.error?.message || output || `gh pr comment failed with status ${result.status}`);
3124
+ }
3125
+
3126
+ return {
3127
+ pipeline_id: pipelineId,
3128
+ pr_number: String(prNumber),
3129
+ bundle,
3130
+ output,
3131
+ updated_existing: updateExisting,
3132
+ };
3133
+ }
3134
+
3135
+ export async function syncPipelinePr(
3136
+ db,
3137
+ repoRoot,
3138
+ pipelineId,
3139
+ {
3140
+ prNumber,
3141
+ ghCommand = 'gh',
3142
+ outputDir = null,
3143
+ updateExisting = true,
3144
+ } = {},
3145
+ ) {
3146
+ const bundle = await exportPipelinePrBundle(db, repoRoot, pipelineId, outputDir);
3147
+ let comment = null;
3148
+
3149
+ if (prNumber) {
3150
+ const args = [
3151
+ 'pr',
3152
+ 'comment',
3153
+ String(prNumber),
3154
+ '--body-file',
3155
+ bundle.files.landing_summary_markdown,
3156
+ ];
3157
+
3158
+ if (updateExisting) {
3159
+ args.push('--edit-last', '--create-if-none');
3160
+ }
3161
+
3162
+ const result = spawnSync(ghCommand, args, {
3163
+ cwd: repoRoot,
3164
+ encoding: 'utf8',
3165
+ });
3166
+ const ok = !result.error && result.status === 0;
3167
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
3168
+
3169
+ logAuditEvent(db, {
3170
+ eventType: 'pipeline_pr_synced',
3171
+ status: ok ? 'allowed' : 'denied',
3172
+ reasonCode: ok ? null : 'pr_sync_failed',
3173
+ details: JSON.stringify({
3174
+ pipeline_id: pipelineId,
3175
+ pr_number: String(prNumber),
3176
+ gh_command: ghCommand,
3177
+ update_existing: updateExisting,
3178
+ body_file: bundle.files.landing_summary_markdown,
3179
+ exit_code: result.status,
3180
+ output: output.slice(0, 500),
3181
+ }),
3182
+ });
3183
+
3184
+ if (!ok) {
3185
+ throw new Error(result.error?.message || output || `gh pr comment failed with status ${result.status}`);
3186
+ }
3187
+
3188
+ comment = {
3189
+ pr_number: String(prNumber),
3190
+ output,
3191
+ updated_existing: updateExisting,
3192
+ };
3193
+ }
3194
+
3195
+ return {
3196
+ pipeline_id: pipelineId,
3197
+ bundle,
3198
+ comment,
3199
+ };
3200
+ }
3201
+
903
3202
  export async function createPipelineFollowupTasks(db, repoRoot, pipelineId) {
904
3203
  const status = getPipelineStatus(db, pipelineId);
905
3204
  const report = await scanAllWorktrees(db, repoRoot);
906
3205
  const aiGate = await runAiMergeGate(db, repoRoot);
3206
+ const changePolicy = loadChangePolicy(repoRoot);
907
3207
  const existingTitles = new Set(status.tasks.map((task) => task.title));
908
3208
  const hasPlannedTestsTask = status.tasks.some((task) =>
909
3209
  task.task_spec?.task_type === 'tests' && !task.title.startsWith('Add missing tests'),
@@ -973,6 +3273,46 @@ export async function createPipelineFollowupTasks(db, repoRoot, pipelineId) {
973
3273
  }
974
3274
  }
975
3275
 
3276
+ const implementationTasks = status.tasks.filter((task) => task.task_spec?.task_type === 'implementation');
3277
+ for (const task of implementationTasks) {
3278
+ const taskDomains = task.task_spec?.subsystem_tags || [];
3279
+ const requiredTaskTypes = new Set(task.task_spec?.validation_rules?.required_completed_task_types || []);
3280
+ for (const domain of taskDomains) {
3281
+ const rule = changePolicy.domain_rules?.[domain];
3282
+ for (const taskType of rule?.required_completed_task_types || []) {
3283
+ requiredTaskTypes.add(taskType);
3284
+ }
3285
+ }
3286
+
3287
+ const completedTypes = new Set(
3288
+ status.tasks
3289
+ .filter((candidate) => candidate.status === 'done')
3290
+ .map((candidate) => candidate.task_spec?.task_type)
3291
+ .filter(Boolean),
3292
+ );
3293
+
3294
+ if (requiredTaskTypes.has('tests') && !completedTypes.has('tests')) {
3295
+ maybeCreateTask(
3296
+ `Add policy-required tests for ${task.title}`,
3297
+ `Policy-triggered follow-up for ${task.id}. Domains: ${taskDomains.join(', ')}.`,
3298
+ );
3299
+ }
3300
+
3301
+ if (requiredTaskTypes.has('docs') && !completedTypes.has('docs')) {
3302
+ maybeCreateTask(
3303
+ `Add policy-required docs for ${task.title}`,
3304
+ `Policy-triggered follow-up for ${task.id}. Domains: ${taskDomains.join(', ')}.`,
3305
+ );
3306
+ }
3307
+
3308
+ if (requiredTaskTypes.has('governance') && !completedTypes.has('governance')) {
3309
+ maybeCreateTask(
3310
+ `Add policy review for ${task.title}`,
3311
+ `Policy-triggered governance follow-up for ${task.id}. Domains: ${taskDomains.join(', ')}.`,
3312
+ );
3313
+ }
3314
+ }
3315
+
976
3316
  logAuditEvent(db, {
977
3317
  eventType: 'pipeline_followups_created',
978
3318
  status: created.length > 0 ? 'allowed' : 'info',