abvx-set 0.2.3__py3-none-any.whl

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.
@@ -0,0 +1,848 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import difflib
5
+ import json
6
+ from pathlib import Path
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ REGISTRY_DIR = ROOT / 'registry' / 'repos'
10
+
11
+
12
+ SUPPORTED_AGENTSGEN_FIELDS = {
13
+ 'init': 'init',
14
+ 'pack': 'pack',
15
+ 'check': 'check',
16
+ 'repomap': 'repomap',
17
+ 'snippets': 'snippets',
18
+ 'analyze_url': 'analyze',
19
+ 'meta_url': 'meta',
20
+ }
21
+
22
+ SUPPORTED_ID_FIELDS = {
23
+ 'pre_task': 'id_pre_task',
24
+ 'weekly_review': 'id_weekly_review',
25
+ }
26
+
27
+ DEFAULT_REPOMAP_POLICY = {
28
+ 'compact_budget': 4000,
29
+ 'top_ranked_files': 5,
30
+ 'focus': None,
31
+ 'changed': False,
32
+ }
33
+
34
+
35
+ def repomap_policy_mode(policy: dict[str, object] | None) -> str:
36
+ if not isinstance(policy, dict):
37
+ return 'full'
38
+ focus = str(policy.get('focus', '') or '').strip()
39
+ changed = bool(policy.get('changed', False))
40
+ if focus and changed:
41
+ return 'focus+changed'
42
+ if focus:
43
+ return 'focus'
44
+ if changed:
45
+ return 'changed'
46
+ return 'full'
47
+
48
+
49
+ def repomap_policy_label(mode: str) -> str:
50
+ return {
51
+ 'full': 'Full Repo Slice',
52
+ 'focus': 'Focused Code Slice',
53
+ 'changed': 'Changed Files Slice',
54
+ 'focus+changed': 'Hybrid Slice',
55
+ }.get(mode, 'Full Repo Slice')
56
+
57
+
58
+
59
+
60
+
61
+ def resolve_id_config(tools: dict[str, object]) -> dict[str, object] | None:
62
+ id_tool = tools.get('id')
63
+ if not isinstance(id_tool, dict):
64
+ return None
65
+ enabled = id_tool.get('enabled') is True
66
+ owner_id = str(id_tool.get('owner_id', '') or '').strip() or None
67
+ target = str(id_tool.get('target', '') or 'set').strip() or 'set'
68
+ pre_task = id_tool.get('pre_task') is True
69
+ weekly_review = id_tool.get('weekly_review') is True
70
+ if not enabled and not owner_id and not pre_task and not weekly_review:
71
+ return None
72
+ return {
73
+ 'enabled': enabled,
74
+ 'owner_id': owner_id,
75
+ 'target': target,
76
+ 'pre_task': pre_task,
77
+ 'weekly_review': weekly_review,
78
+ }
79
+
80
+
81
+ def resolve_proof_loop_config(agentsgen: dict[str, object]) -> dict[str, object] | None:
82
+ proof_loop = agentsgen.get('proof_loop')
83
+ if not isinstance(proof_loop, dict):
84
+ return None
85
+ enabled = proof_loop.get('enabled') is True
86
+ task_id = proof_loop.get('task_id')
87
+ expected_artifacts = [
88
+ str(item).strip()
89
+ for item in proof_loop.get('expected_artifacts', [])
90
+ if isinstance(item, str) and str(item).strip()
91
+ ]
92
+ if not enabled and not task_id and not expected_artifacts:
93
+ return None
94
+ return {
95
+ 'enabled': enabled,
96
+ 'task_id': str(task_id).strip() if isinstance(task_id, str) and str(task_id).strip() else None,
97
+ 'expected_artifacts': expected_artifacts,
98
+ }
99
+
100
+
101
+ def build_capabilities(data: dict[str, object]) -> list[dict[str, object]]:
102
+ tools = data.get('tools', {}) if isinstance(data.get('tools'), dict) else {}
103
+ agentsgen = tools.get('agentsgen', {}) if isinstance(tools.get('agentsgen'), dict) else {}
104
+ id_config = resolve_id_config(tools)
105
+ capabilities: list[dict[str, object]] = []
106
+ for key, value in agentsgen.items():
107
+ if key == 'proof_loop':
108
+ continue
109
+ requested = False
110
+ if isinstance(value, bool):
111
+ requested = value
112
+ elif isinstance(value, str):
113
+ requested = bool(value)
114
+ if value is None:
115
+ requested = False
116
+ if not requested:
117
+ continue
118
+ supported = key in SUPPORTED_AGENTSGEN_FIELDS
119
+ item: dict[str, object] = {
120
+ 'tool': 'agentsgen',
121
+ 'key': key,
122
+ 'requested': True,
123
+ 'supported_by_set': supported,
124
+ }
125
+ if supported:
126
+ item['set_input'] = SUPPORTED_AGENTSGEN_FIELDS[key]
127
+ else:
128
+ item['wiring_gap'] = {
129
+ 'kind': 'missing-set-input',
130
+ 'capability': f'agentsgen.{key}',
131
+ 'message': f'agentsgen.{key} is in registry but not yet wired into SET action inputs',
132
+ }
133
+ capabilities.append(item)
134
+ proof_loop = resolve_proof_loop_config(agentsgen)
135
+ if proof_loop and proof_loop.get('enabled'):
136
+ capabilities.append({
137
+ 'tool': 'agentsgen',
138
+ 'key': 'proof_loop',
139
+ 'requested': True,
140
+ 'supported_by_set': True,
141
+ 'set_input': 'proof_loop',
142
+ 'task_id': proof_loop.get('task_id'),
143
+ })
144
+ if id_config and id_config.get('enabled'):
145
+ for key, set_input in SUPPORTED_ID_FIELDS.items():
146
+ if id_config.get(key) is True:
147
+ capabilities.append({
148
+ 'tool': 'id',
149
+ 'key': key,
150
+ 'requested': True,
151
+ 'supported_by_set': True,
152
+ 'set_input': set_input,
153
+ 'owner_id': id_config.get('owner_id'),
154
+ 'target': id_config.get('target'),
155
+ })
156
+ return capabilities
157
+
158
+
159
+ def resolve_repomap_policy(agentsgen: dict[str, object]) -> dict[str, object] | None:
160
+ repomap_enabled = agentsgen.get('repomap') is True
161
+ policy = agentsgen.get('repomap_policy')
162
+ if not repomap_enabled and not isinstance(policy, dict):
163
+ return None
164
+
165
+ resolved = dict(DEFAULT_REPOMAP_POLICY)
166
+ if isinstance(policy, dict):
167
+ compact_budget = policy.get('compact_budget')
168
+ top_ranked_files = policy.get('top_ranked_files')
169
+ focus = policy.get('focus')
170
+ changed = policy.get('changed')
171
+ if isinstance(compact_budget, int) and compact_budget > 0:
172
+ resolved['compact_budget'] = compact_budget
173
+ if isinstance(top_ranked_files, int) and top_ranked_files > 0:
174
+ resolved['top_ranked_files'] = top_ranked_files
175
+ if focus is None or isinstance(focus, str):
176
+ resolved['focus'] = focus
177
+ if isinstance(changed, bool):
178
+ resolved['changed'] = changed
179
+ return resolved
180
+
181
+
182
+ def list_repo_configs() -> list[tuple[Path, dict[str, object]]]:
183
+ entries: list[tuple[Path, dict[str, object]]] = []
184
+ for path in sorted(REGISTRY_DIR.glob('*.json')):
185
+ data = json.loads(path.read_text())
186
+ if isinstance(data, dict) and isinstance(data.get('repo'), str):
187
+ entries.append((path, data))
188
+ return entries
189
+
190
+
191
+ def load_config(repo: str) -> tuple[Path, dict[str, object]]:
192
+ for path, data in list_repo_configs():
193
+ if data.get('repo') == repo:
194
+ return path, data
195
+ raise SystemExit(f'Repo config not found for {repo!r}')
196
+
197
+
198
+ def pick_workflow_preset(presets: list[str]) -> str | None:
199
+ for name in ('site-ai', 'repo-docs', 'minimal'):
200
+ if name in presets:
201
+ return name
202
+ return None
203
+
204
+
205
+ def render_workflow_yaml(workflow: dict[str, object]) -> str:
206
+ with_block = workflow['with']
207
+ lines = [
208
+ 'name: SET',
209
+ '',
210
+ 'on:',
211
+ ' workflow_dispatch:',
212
+ '',
213
+ 'jobs:',
214
+ ' set:',
215
+ ' runs-on: ubuntu-latest',
216
+ ' steps:',
217
+ ' - uses: actions/checkout@v4',
218
+ f" - uses: {workflow['uses']}",
219
+ ' with:',
220
+ ]
221
+ for key, value in with_block.items():
222
+ lines.append(f' {key}: "{value}"')
223
+ return '\n'.join(lines) + '\n'
224
+
225
+
226
+ def repo_slug(repo: str) -> str:
227
+ return repo.replace('/', '-')
228
+
229
+
230
+ def normalize_workflow_text(text: str) -> str:
231
+ return text.rstrip() + '\n'
232
+
233
+
234
+ def compare_workflow(plan: dict[str, object], repo_root: Path) -> dict[str, object]:
235
+ workflow = plan['proposed_changes'][0]['workflow']
236
+ relative_path = Path(workflow['path'])
237
+ actual_path = repo_root / relative_path
238
+ expected_text = normalize_workflow_text(render_workflow_yaml(workflow))
239
+ result: dict[str, object] = {
240
+ 'repo_root': str(repo_root),
241
+ 'workflow_path': str(relative_path),
242
+ 'actual_path': str(actual_path),
243
+ 'status': 'missing',
244
+ 'diff_preview': [],
245
+ }
246
+ if not actual_path.exists():
247
+ return result
248
+ actual_text = normalize_workflow_text(actual_path.read_text())
249
+ if actual_text == expected_text:
250
+ result['status'] = 'matches'
251
+ return result
252
+ diff_lines = list(
253
+ difflib.unified_diff(
254
+ expected_text.splitlines(),
255
+ actual_text.splitlines(),
256
+ fromfile='expected/.github/workflows/set.yml',
257
+ tofile=str(relative_path),
258
+ lineterm='',
259
+ )
260
+ )
261
+ result['status'] = 'drift'
262
+ result['diff_preview'] = diff_lines[:80]
263
+ return result
264
+
265
+
266
+ def build_review_payload(
267
+ repo: str,
268
+ workflow: dict[str, object],
269
+ capabilities: list[dict[str, object]],
270
+ unmapped: list[str],
271
+ repomap_policy: dict[str, object] | None,
272
+ proof_loop: dict[str, object] | None,
273
+ id_config: dict[str, object] | None,
274
+ dry_run: bool = True,
275
+ ) -> dict[str, object]:
276
+ apply_readiness = 'blocked' if unmapped else 'ready'
277
+ blocked_by = list(unmapped)
278
+ workflow_yaml = render_workflow_yaml(workflow)
279
+ branch_name = f'codex/set-plan-{repo_slug(repo)}'
280
+ title = f'chore(set): plan config apply for {repo}'
281
+ body_lines = [
282
+ f'# Planned SET workflow for `{repo}`',
283
+ '',
284
+ 'This is a planning-only export generated from the central SET registry.',
285
+ '',
286
+ '## Proposed workflow target',
287
+ f'- Path: `{workflow["path"]}`',
288
+ f'- Uses: `{workflow["uses"]}`',
289
+ '',
290
+ '## Resolved inputs',
291
+ ]
292
+ for key, value in workflow['with'].items():
293
+ body_lines.append(f'- `{key}`: `{value}`')
294
+ if unmapped:
295
+ body_lines.extend(['', '## Unmapped fields'])
296
+ for item in unmapped:
297
+ body_lines.append(f'- {item}')
298
+ if repomap_policy:
299
+ body_lines.extend(
300
+ [
301
+ '',
302
+ '## Repomap policy',
303
+ f"- `mode`: `{repomap_policy_mode(repomap_policy)}`",
304
+ f"- `label`: `{repomap_policy_label(repomap_policy_mode(repomap_policy))}`",
305
+ f"- `compact_budget`: `{repomap_policy['compact_budget']}`",
306
+ f"- `top_ranked_files`: `{repomap_policy['top_ranked_files']}`",
307
+ ]
308
+ )
309
+ if repomap_policy.get('focus'):
310
+ body_lines.append(f"- `focus`: `{repomap_policy['focus']}`")
311
+ body_lines.append(f"- `changed`: `{str(bool(repomap_policy.get('changed', False))).lower()}`")
312
+ if proof_loop and proof_loop.get('enabled'):
313
+ body_lines.extend([
314
+ '',
315
+ '## Proof loop',
316
+ '- `enabled`: `true`',
317
+ f"- `task_id`: `{proof_loop.get('task_id') or 'missing'}`",
318
+ ])
319
+ if proof_loop.get('expected_artifacts'):
320
+ body_lines.append(
321
+ f"- `expected_artifacts`: `{', '.join(str(item) for item in proof_loop.get('expected_artifacts', []))}`"
322
+ )
323
+ if id_config and id_config.get('enabled'):
324
+ body_lines.extend([
325
+ '',
326
+ '## ID integration',
327
+ '- `enabled`: `true`',
328
+ f"- `owner_id`: `{id_config.get('owner_id') or 'missing'}`",
329
+ f"- `target`: `{id_config.get('target') or 'set'}`",
330
+ f"- `pre_task`: `{str(bool(id_config.get('pre_task'))).lower()}`",
331
+ f"- `weekly_review`: `{str(bool(id_config.get('weekly_review'))).lower()}`",
332
+ ])
333
+ body_lines.extend([
334
+ '',
335
+ '## Notes',
336
+ '- Generated by `python3 scripts/plan_config_apply.py`.',
337
+ '- Planning only: no branch, no PR, no write to target repo.',
338
+ '- Dry-run mode: proposed changes are explicit and would only be written with a manual apply flow.',
339
+ f'- Would write: `{workflow["path"]}`',
340
+ ])
341
+ body = '\n'.join(body_lines) + '\n'
342
+ apply_simulation = {
343
+ 'repo': repo,
344
+ 'base_branch': 'main',
345
+ 'head_branch': branch_name,
346
+ 'target_files': [
347
+ {
348
+ 'path': workflow['path'],
349
+ 'source_export': 'workflow.set.yml',
350
+ 'action': 'write',
351
+ }
352
+ ],
353
+ 'suggested_commit_message': title,
354
+ 'manual_steps': [
355
+ f'git checkout -b {branch_name}',
356
+ f'cp workflow.set.yml {workflow["path"]}',
357
+ f'git add {workflow["path"]}',
358
+ f'git commit -m "{title}"',
359
+ f'gh pr create --repo {repo} --base main --head {branch_name} --title "{title}" --body-file pr-body.md',
360
+ ],
361
+ }
362
+ return {
363
+ 'version': 1,
364
+ 'kind': 'set-pr-payload',
365
+ 'repo': repo,
366
+ 'dry_run': dry_run,
367
+ 'capabilities': capabilities,
368
+ 'repomap_policy': repomap_policy,
369
+ 'repomap_policy_mode': repomap_policy_mode(repomap_policy),
370
+ 'proof_loop': proof_loop,
371
+ 'repomap_policy_label': repomap_policy_label(repomap_policy_mode(repomap_policy)),
372
+ 'apply_readiness': apply_readiness,
373
+ 'blocked_by': blocked_by,
374
+ 'operator_queue': '',
375
+ 'next_action_label': 'Resolve unmapped fields' if unmapped else 'Review and apply planned workflow',
376
+ 'recommended_operator_step': unmapped[0] if unmapped else apply_simulation['manual_steps'][0],
377
+ 'next_shell_command': apply_simulation['manual_steps'][0],
378
+ 'target_branch': 'main',
379
+ 'head_branch': branch_name,
380
+ 'target_workflow_path': workflow['path'],
381
+ 'title': title,
382
+ 'suggested_commit_message': title,
383
+ 'body': body,
384
+ 'workflow_yaml': workflow_yaml,
385
+ 'gh_pr_create': {
386
+ 'repo': repo,
387
+ 'base': 'main',
388
+ 'head': branch_name,
389
+ 'title': title,
390
+ 'body_file': 'pr-body.md',
391
+ },
392
+ 'apply_simulation': apply_simulation,
393
+ }
394
+
395
+
396
+ def build_plan(
397
+ config_path: Path,
398
+ data: dict[str, object],
399
+ repo_root: Path | None = None,
400
+ dry_run: bool = True,
401
+ ) -> dict[str, object]:
402
+ tools = data.get('tools', {}) if isinstance(data.get('tools'), dict) else {}
403
+ agentsgen = tools.get('agentsgen', {}) if isinstance(tools.get('agentsgen'), dict) else {}
404
+ presets = data.get('presets', []) if isinstance(data.get('presets'), list) else []
405
+ workflow_preset = pick_workflow_preset([p for p in presets if isinstance(p, str)])
406
+ repomap_policy = resolve_repomap_policy(agentsgen)
407
+ proof_loop = resolve_proof_loop_config(agentsgen)
408
+ id_config = resolve_id_config(tools)
409
+
410
+ with_block: dict[str, str] = {'agentsgen': 'true', 'autodetect': 'true', 'path': '.'}
411
+ if workflow_preset:
412
+ with_block['workflow_preset'] = workflow_preset
413
+
414
+ for key in ('init', 'pack', 'check', 'repomap', 'snippets'):
415
+ if key in agentsgen and isinstance(agentsgen[key], bool):
416
+ with_block[key] = 'true' if agentsgen[key] else 'false'
417
+ if agentsgen.get('repomap') is True and repomap_policy:
418
+ with_block['repomap_compact_budget'] = str(repomap_policy['compact_budget'])
419
+ if isinstance(repomap_policy.get('focus'), str) and str(repomap_policy.get('focus')).strip():
420
+ with_block['repomap_focus'] = str(repomap_policy['focus']).strip()
421
+ if repomap_policy.get('changed') is True:
422
+ with_block['repomap_changed'] = 'true'
423
+
424
+ site = data.get('site') if isinstance(data.get('site'), dict) else {}
425
+ site_url = site.get('url') if isinstance(site, dict) else None
426
+ analyze_url = agentsgen.get('analyze_url')
427
+ meta_url = agentsgen.get('meta_url')
428
+ if isinstance(site_url, str) and site_url:
429
+ with_block['site_url'] = site_url
430
+ if workflow_preset == 'site-ai' or with_block.get('pack') == 'true':
431
+ with_block['site_pack'] = 'true'
432
+ if isinstance(analyze_url, str) and analyze_url:
433
+ with_block['analyze'] = 'true'
434
+ with_block['analyze_url'] = analyze_url
435
+ if isinstance(meta_url, str) and meta_url:
436
+ with_block['meta'] = 'true'
437
+ with_block['meta_url'] = meta_url
438
+ if proof_loop and proof_loop.get('enabled'):
439
+ with_block['proof_loop'] = 'true'
440
+ if proof_loop.get('task_id'):
441
+ with_block['proof_task_id'] = str(proof_loop['task_id'])
442
+ if proof_loop.get('expected_artifacts'):
443
+ with_block['proof_expected_artifacts'] = ','.join(
444
+ str(item) for item in proof_loop.get('expected_artifacts', [])
445
+ )
446
+ if id_config and id_config.get('enabled'):
447
+ with_block['id_enabled'] = 'true'
448
+ if id_config.get('owner_id'):
449
+ with_block['id_owner_id'] = str(id_config['owner_id'])
450
+ if id_config.get('target'):
451
+ with_block['id_target'] = str(id_config['target'])
452
+ if id_config.get('pre_task') is True:
453
+ with_block['id_pre_task'] = 'true'
454
+ if id_config.get('weekly_review') is True:
455
+ with_block['id_weekly_review'] = 'true'
456
+
457
+ capabilities = build_capabilities(data)
458
+ unmapped = [
459
+ capability['wiring_gap']['message']
460
+ for capability in capabilities
461
+ if capability.get('wiring_gap')
462
+ ]
463
+
464
+ if proof_loop and proof_loop.get('enabled') and not proof_loop.get('task_id'):
465
+ unmapped.append('agentsgen.proof_loop is enabled but proof_task_id is missing')
466
+ if id_config and id_config.get('enabled'):
467
+ if not id_config.get('owner_id'):
468
+ unmapped.append('id integration is enabled but owner_id is missing')
469
+ if id_config.get('pre_task') is True and not id_config.get('target'):
470
+ unmapped.append('id integration pre_task is enabled but target is missing')
471
+
472
+ workflow = {
473
+ 'path': '.github/workflows/set.yml',
474
+ 'uses': 'markoblogo/SET@main',
475
+ 'with': with_block,
476
+ }
477
+ review_payload = build_review_payload(
478
+ data['repo'],
479
+ workflow,
480
+ capabilities,
481
+ unmapped,
482
+ repomap_policy,
483
+ proof_loop,
484
+ id_config,
485
+ dry_run=dry_run,
486
+ )
487
+ plan = {
488
+ 'version': 1,
489
+ 'dry_run': dry_run,
490
+ 'mode': 'planning-only',
491
+ 'repo': data['repo'],
492
+ 'source_config': str(config_path),
493
+ 'repomap_policy': repomap_policy,
494
+ 'repomap_policy_mode': repomap_policy_mode(repomap_policy),
495
+ 'proof_loop': proof_loop,
496
+ 'id_integration': id_config,
497
+ 'proposed_changes': [
498
+ {
499
+ 'type': 'workflow',
500
+ 'workflow': workflow,
501
+ }
502
+ ],
503
+ 'capabilities': capabilities,
504
+ 'review_payload': review_payload,
505
+ 'unmapped': unmapped,
506
+ 'notes': [
507
+ 'This planner does not open a PR or write files to the target repo.',
508
+ 'Use the output to review the intended SET workflow before any future apply step.',
509
+ ],
510
+ }
511
+ plan['review_payload']['operator_queue'] = derive_operator_queue(plan)
512
+ if repo_root is not None:
513
+ plan['workflow_check'] = compare_workflow(plan, repo_root)
514
+ return plan
515
+
516
+
517
+
518
+
519
+ def derive_status_hint(plan: dict[str, object]) -> str:
520
+ if plan.get('unmapped'):
521
+ return 'needs-wiring'
522
+ return 'ready-for-review'
523
+
524
+
525
+ def derive_priority_hint(plan: dict[str, object]) -> str:
526
+ workflow = plan['proposed_changes'][0]['workflow']
527
+ preset = workflow['with'].get('workflow_preset')
528
+ if plan['repo'] == 'markoblogo/lab.abvx':
529
+ return 'high'
530
+ if plan.get('unmapped'):
531
+ return 'medium'
532
+ if preset == 'site-ai':
533
+ return 'high'
534
+ return 'normal'
535
+
536
+
537
+ def derive_apply_readiness(plan: dict[str, object]) -> str:
538
+ if plan.get('unmapped'):
539
+ return 'blocked'
540
+ return 'ready'
541
+
542
+
543
+ def derive_blocked_by(plan: dict[str, object]) -> list[str]:
544
+ return list(plan.get('unmapped', []))
545
+
546
+
547
+ def derive_wiring_gaps(plan: dict[str, object]) -> list[dict[str, object]]:
548
+ return [
549
+ capability['wiring_gap']
550
+ for capability in plan.get('capabilities', [])
551
+ if isinstance(capability, dict) and capability.get('wiring_gap')
552
+ ]
553
+
554
+
555
+ def derive_operator_queue(plan: dict[str, object]) -> str:
556
+ if derive_apply_readiness(plan) == 'blocked' and derive_wiring_gaps(plan):
557
+ return 'blocked-by-orchestrator'
558
+ if derive_apply_readiness(plan) == 'ready' and derive_priority_hint(plan) == 'high':
559
+ return 'ready-now'
560
+ return 'review-later'
561
+
562
+
563
+ def derive_next_action_label(plan: dict[str, object]) -> str:
564
+ if plan.get('unmapped'):
565
+ return 'Resolve unmapped fields'
566
+ return 'Review and apply planned workflow'
567
+
568
+
569
+ def derive_recommended_operator_step(plan: dict[str, object]) -> str:
570
+ if plan.get('unmapped'):
571
+ return plan['unmapped'][0]
572
+ return plan['review_payload']['apply_simulation']['manual_steps'][0]
573
+
574
+
575
+ def derive_next_shell_command(plan: dict[str, object]) -> str:
576
+ return plan['review_payload']['apply_simulation']['manual_steps'][0]
577
+
578
+
579
+ def derive_workflow_sync_status(plan: dict[str, object]) -> str:
580
+ workflow_check = plan.get('workflow_check')
581
+ if isinstance(workflow_check, dict):
582
+ return str(workflow_check.get('status', 'unknown'))
583
+ return 'not-checked'
584
+
585
+
586
+ def render_text(plan: dict[str, object]) -> str:
587
+ workflow = plan['proposed_changes'][0]['workflow']
588
+ with_block = workflow['with']
589
+ gh_pr = plan['review_payload']['gh_pr_create']
590
+ apply_sim = plan['review_payload']['apply_simulation']
591
+ lines = [
592
+ 'SET config apply plan',
593
+ f"repo: {plan['repo']}",
594
+ f"source_config: {plan['source_config']}",
595
+ f"target_workflow: {workflow['path']}",
596
+ 'with:',
597
+ ]
598
+ for key, value in with_block.items():
599
+ lines.append(f' {key}: {value}')
600
+ unmapped = plan.get('unmapped', [])
601
+ if unmapped:
602
+ lines.append('unmapped:')
603
+ for item in unmapped:
604
+ lines.append(f' - {item}')
605
+ lines.append('blocked_by:')
606
+ for item in derive_blocked_by(plan):
607
+ lines.append(f' - {item}')
608
+ lines.append('wiring_gaps:')
609
+ for gap in derive_wiring_gaps(plan):
610
+ lines.append(f" - {gap['message']}")
611
+ lines.extend([
612
+ f"apply_readiness: {derive_apply_readiness(plan)}",
613
+ f"operator_queue: {derive_operator_queue(plan)}",
614
+ f"wiring_gap_count: {len(derive_wiring_gaps(plan))}",
615
+ f"dry_run: {str(plan.get('dry_run', False)).lower()}",
616
+ 'would_write:',
617
+ f" - {workflow['path']}",
618
+ f"next_action_label: {derive_next_action_label(plan)}",
619
+ f"recommended_operator_step: {derive_recommended_operator_step(plan)}",
620
+ f"next_shell_command: {derive_next_shell_command(plan)}",
621
+ f"workflow_sync_status: {derive_workflow_sync_status(plan)}",
622
+ f"repomap_policy: {json.dumps(plan.get('repomap_policy')) if plan.get('repomap_policy') else 'none'}",
623
+ f"proof_loop: {json.dumps(plan.get('proof_loop')) if plan.get('proof_loop') else 'none'}",
624
+ f"id_integration: {json.dumps(plan.get('id_integration')) if plan.get('id_integration') else 'none'}",
625
+ 'review_bundle:',
626
+ ' - plan.json',
627
+ ' - workflow.set.yml',
628
+ ' - pr-body.md',
629
+ ' - gh-pr-create.json',
630
+ ' - apply-simulation.json',
631
+ 'gh_pr_create:',
632
+ f" repo: {gh_pr['repo']}",
633
+ f" base: {gh_pr['base']}",
634
+ f" head: {gh_pr['head']}",
635
+ f" title: {gh_pr['title']}",
636
+ f" body_file: {gh_pr['body_file']}",
637
+ 'apply_simulation:',
638
+ f" base_branch: {apply_sim['base_branch']}",
639
+ f" head_branch: {apply_sim['head_branch']}",
640
+ f" target_file: {apply_sim['target_files'][0]['path']}",
641
+ f" commit_message: {apply_sim['suggested_commit_message']}",
642
+ ' manual_steps:',
643
+ ])
644
+ for step in apply_sim['manual_steps']:
645
+ lines.append(f' - {step}')
646
+ workflow_check = plan.get('workflow_check')
647
+ if isinstance(workflow_check, dict):
648
+ lines.extend([
649
+ 'workflow_check:',
650
+ f" status: {workflow_check.get('status', 'unknown')}",
651
+ f" actual_path: {workflow_check.get('actual_path', 'unknown')}",
652
+ ])
653
+ diff_preview = workflow_check.get('diff_preview', [])
654
+ if diff_preview:
655
+ lines.append(' diff_preview:')
656
+ for item in diff_preview:
657
+ lines.append(f' {item}')
658
+ return '\n'.join(lines)
659
+
660
+
661
+ def render_batch_text(plans: list[dict[str, object]]) -> str:
662
+ lines = ['SET config apply batch summary', f'repo_count: {len(plans)}']
663
+ for plan in plans:
664
+ status_hint = derive_status_hint(plan)
665
+ priority_hint = derive_priority_hint(plan)
666
+ workflow = plan['proposed_changes'][0]['workflow']
667
+ gh_pr = plan['review_payload']['gh_pr_create']
668
+ lines.extend([
669
+ f'- {plan["repo"]}',
670
+ f' workflow_preset: {workflow["with"].get("workflow_preset", "none")}',
671
+ f' target_workflow: {workflow["path"]}',
672
+ f' head_branch: {gh_pr["head"]}',
673
+ f' title: {gh_pr["title"]}',
674
+ f' status_hint: {status_hint}',
675
+ f' priority_hint: {priority_hint}',
676
+ f' apply_readiness: {derive_apply_readiness(plan)}',
677
+ f' operator_queue: {derive_operator_queue(plan)}',
678
+ f' wiring_gap_count: {len(derive_wiring_gaps(plan))}',
679
+ f' next_action_label: {derive_next_action_label(plan)}',
680
+ f' recommended_operator_step: {derive_recommended_operator_step(plan)}',
681
+ f' next_shell_command: {derive_next_shell_command(plan)}',
682
+ f' workflow_sync_status: {derive_workflow_sync_status(plan)}',
683
+ f' unmapped_count: {len(plan.get("unmapped", []))}',
684
+ ])
685
+ return '\n'.join(lines)
686
+
687
+
688
+ def export_plan(plan: dict[str, object], export_dir: Path) -> list[Path]:
689
+ export_dir.mkdir(parents=True, exist_ok=True)
690
+ review_payload = plan['review_payload']
691
+ outputs = {
692
+ 'plan.json': json.dumps(plan, indent=2) + '\n',
693
+ 'workflow.set.yml': review_payload['workflow_yaml'],
694
+ 'pr-body.md': review_payload['body'],
695
+ 'gh-pr-create.json': json.dumps(review_payload['gh_pr_create'], indent=2) + '\n',
696
+ 'apply-simulation.json': json.dumps(review_payload['apply_simulation'], indent=2) + '\n',
697
+ }
698
+ written = []
699
+ for name, content in outputs.items():
700
+ path = export_dir / name
701
+ path.write_text(content)
702
+ written.append(path)
703
+ return written
704
+
705
+
706
+ def export_batch(plans: list[dict[str, object]], export_dir: Path) -> list[Path]:
707
+ export_dir.mkdir(parents=True, exist_ok=True)
708
+ written: list[Path] = []
709
+ summary = {
710
+ 'version': 1,
711
+ 'mode': 'planning-only-batch',
712
+ 'repo_count': len(plans),
713
+ 'repos': [
714
+ {
715
+ 'repo': plan['repo'],
716
+ 'head_branch': plan['review_payload']['gh_pr_create']['head'],
717
+ 'target_workflow': plan['proposed_changes'][0]['workflow']['path'],
718
+ 'title': plan['review_payload']['gh_pr_create']['title'],
719
+ 'status_hint': derive_status_hint(plan),
720
+ 'priority_hint': derive_priority_hint(plan),
721
+ 'apply_readiness': derive_apply_readiness(plan),
722
+ 'operator_queue': derive_operator_queue(plan),
723
+ 'blocked_by': derive_blocked_by(plan),
724
+ 'wiring_gaps': derive_wiring_gaps(plan),
725
+ 'next_action_label': derive_next_action_label(plan),
726
+ 'recommended_operator_step': derive_recommended_operator_step(plan),
727
+ 'next_shell_command': derive_next_shell_command(plan),
728
+ 'workflow_sync_status': derive_workflow_sync_status(plan),
729
+ 'repomap_policy': plan.get('repomap_policy'),
730
+ 'id_integration': plan.get('id_integration'),
731
+ 'workflow_check': plan.get('workflow_check'),
732
+ 'unmapped_count': len(plan.get('unmapped', [])),
733
+ }
734
+ for plan in plans
735
+ ],
736
+ }
737
+ batch_path = export_dir / 'batch-summary.json'
738
+ batch_path.write_text(json.dumps(summary, indent=2) + '\n')
739
+ written.append(batch_path)
740
+ for plan in plans:
741
+ repo_dir = export_dir / repo_slug(plan['repo'])
742
+ written.extend(export_plan(plan, repo_dir))
743
+ return written
744
+
745
+
746
+ def resolve_targets(repos: list[str], use_all: bool) -> list[tuple[Path, dict[str, object]]]:
747
+ if use_all:
748
+ return list_repo_configs()
749
+ if not repos:
750
+ raise SystemExit('Provide at least one repo or use --all')
751
+ return [load_config(repo) for repo in repos]
752
+
753
+
754
+ def resolve_repo_roots(targets: list[tuple[Path, dict[str, object]]], values: list[str]) -> dict[str, Path]:
755
+ if not values:
756
+ return {}
757
+ repo_names = [str(data['repo']) for _, data in targets]
758
+ multi = len(repo_names) > 1
759
+ mapping: dict[str, Path] = {}
760
+ for value in values:
761
+ if '=' in value:
762
+ repo, path = value.split('=', 1)
763
+ if repo not in repo_names:
764
+ raise SystemExit(f'Unknown repo in --repo-root mapping: {repo}')
765
+ mapping[repo] = Path(path)
766
+ continue
767
+ if multi:
768
+ raise SystemExit('For multiple repos, pass --repo-root owner/name=/absolute/path')
769
+ mapping[repo_names[0]] = Path(value)
770
+ return mapping
771
+
772
+
773
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
774
+ parser = argparse.ArgumentParser(description='Planning-only SET config apply helper.')
775
+ parser.add_argument('repos', nargs='*', help='Repo name(s) in owner/name format')
776
+ parser.add_argument('--all', action='store_true', help='Plan against every repo in registry/repos')
777
+ parser.add_argument('--format', choices=('text', 'json'), default='text')
778
+ parser.add_argument('--export-dir', help='Optional local directory for reviewable planner outputs.')
779
+ parser.add_argument(
780
+ '--dry-run',
781
+ action='store_true',
782
+ help='Explicit planning-only mode. This is the current default behavior.',
783
+ )
784
+ parser.add_argument('--repo-root', action='append', default=[], help='Optional local repo root path for workflow drift checking. Use /path for one repo or owner/name=/path for multiple repos.')
785
+ return parser.parse_args(argv)
786
+
787
+
788
+ def main() -> int:
789
+ args = parse_args()
790
+ targets = resolve_targets(args.repos, args.all)
791
+ repo_roots = resolve_repo_roots(targets, args.repo_root)
792
+ dry_run = True
793
+ if args.dry_run:
794
+ dry_run = True
795
+ plans = [
796
+ build_plan(
797
+ config_path,
798
+ data,
799
+ repo_roots.get(str(data['repo'])),
800
+ dry_run=dry_run,
801
+ )
802
+ for config_path, data in targets
803
+ ]
804
+ multi = len(plans) > 1
805
+ if args.export_dir:
806
+ if multi:
807
+ written = export_batch(plans, Path(args.export_dir))
808
+ for plan in plans:
809
+ repo_dir = Path(args.export_dir) / repo_slug(plan['repo'])
810
+ plan['exported_files'] = [str(path) for path in sorted(repo_dir.iterdir())]
811
+ batch_summary_path = Path(args.export_dir) / 'batch-summary.json'
812
+ else:
813
+ written = export_plan(plans[0], Path(args.export_dir))
814
+ plans[0]['exported_files'] = [str(path) for path in written]
815
+ batch_summary_path = None
816
+ else:
817
+ written = []
818
+ batch_summary_path = None
819
+
820
+ if args.format == 'json':
821
+ if multi:
822
+ payload = {'version': 1, 'mode': 'planning-only-batch', 'repo_count': len(plans), 'plans': plans}
823
+ if batch_summary_path:
824
+ payload['batch_summary_file'] = str(batch_summary_path)
825
+ print(json.dumps(payload, indent=2))
826
+ else:
827
+ print(json.dumps(plans[0], indent=2))
828
+ else:
829
+ if multi:
830
+ print(render_batch_text(plans))
831
+ if args.export_dir:
832
+ print('exported_files:')
833
+ if batch_summary_path:
834
+ print(f' - {batch_summary_path}')
835
+ for plan in plans:
836
+ for path in plan.get('exported_files', []):
837
+ print(f' - {path}')
838
+ else:
839
+ print(render_text(plans[0]))
840
+ if args.export_dir:
841
+ print('exported_files:')
842
+ for path in plans[0]['exported_files']:
843
+ print(f' - {path}')
844
+ return 0
845
+
846
+
847
+ if __name__ == '__main__':
848
+ raise SystemExit(main())