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.
- abvx_set-0.2.3.dist-info/METADATA +125 -0
- abvx_set-0.2.3.dist-info/RECORD +11 -0
- abvx_set-0.2.3.dist-info/WHEEL +5 -0
- abvx_set-0.2.3.dist-info/entry_points.txt +3 -0
- abvx_set-0.2.3.dist-info/licenses/LICENSE +21 -0
- abvx_set-0.2.3.dist-info/top_level.txt +1 -0
- scripts/__init__.py +2 -0
- scripts/plan_config_apply.py +848 -0
- scripts/resolve_inputs.py +90 -0
- scripts/validate_registry.py +155 -0
- scripts/write_summary.py +161 -0
|
@@ -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())
|