intentspec 0.1.0__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.
Files changed (53) hide show
  1. intentspec/__init__.py +3 -0
  2. intentspec/cli.py +620 -0
  3. intentspec-0.1.0.dist-info/METADATA +384 -0
  4. intentspec-0.1.0.dist-info/RECORD +53 -0
  5. intentspec-0.1.0.dist-info/WHEEL +5 -0
  6. intentspec-0.1.0.dist-info/entry_points.txt +3 -0
  7. intentspec-0.1.0.dist-info/licenses/LICENSE +22 -0
  8. intentspec-0.1.0.dist-info/top_level.txt +3 -0
  9. intentspec_core/__init__.py +71 -0
  10. intentspec_core/common/__init__.py +2 -0
  11. intentspec_core/common/errors.py +52 -0
  12. intentspec_core/common/utils.py +125 -0
  13. intentspec_core/doctor.py +112 -0
  14. intentspec_core/draft.py +430 -0
  15. intentspec_core/examples.py +52 -0
  16. intentspec_core/iir/__init__.py +2 -0
  17. intentspec_core/iir/builder.py +130 -0
  18. intentspec_core/iir/models.py +35 -0
  19. intentspec_core/importers/__init__.py +1 -0
  20. intentspec_core/importers/_parsing.py +218 -0
  21. intentspec_core/importers/cursor_rules.py +45 -0
  22. intentspec_core/importers/gemini_structured.py +65 -0
  23. intentspec_core/importers/instruction_markdown.py +290 -0
  24. intentspec_core/importers/openai_structured.py +70 -0
  25. intentspec_core/importers/reverse.py +168 -0
  26. intentspec_core/imports/__init__.py +2 -0
  27. intentspec_core/imports/resolver.py +171 -0
  28. intentspec_core/resources/__init__.py +1 -0
  29. intentspec_core/resources/examples/__init__.py +1 -0
  30. intentspec_core/resources/examples/code_review.intent.yaml +84 -0
  31. intentspec_core/resources/examples/customer_brief.intent.yaml +112 -0
  32. intentspec_core/resources/examples/customer_brief.output.md +31 -0
  33. intentspec_core/resources/examples/imported_customer_brief.intent.yaml +68 -0
  34. intentspec_core/resources/examples/imported_customer_brief.output.md +18 -0
  35. intentspec_core/resources/examples/invalid_dangerous_permission.intent.yaml +45 -0
  36. intentspec_core/resources/examples/invalid_missing_goal.intent.yaml +34 -0
  37. intentspec_core/resources/examples/market_analysis.intent.yaml +80 -0
  38. intentspec_core/resources/examples/packs/no-network.intent.yaml +20 -0
  39. intentspec_core/resources/examples/packs/privacy.intent.yaml +20 -0
  40. intentspec_core/resources/examples/packs/zh-report.intent.yaml +28 -0
  41. intentspec_core/resources/examples/report_json.intent.yaml +96 -0
  42. intentspec_core/resources/examples/report_json.output.json +11 -0
  43. intentspec_core/schemas.py +17 -0
  44. intentspec_core/spec/__init__.py +2 -0
  45. intentspec_core/spec/models.py +164 -0
  46. intentspec_core/spec/parser.py +143 -0
  47. intentspec_core/targets/__init__.py +2 -0
  48. intentspec_core/targets/compiler.py +498 -0
  49. intentspec_core/testing/__init__.py +2 -0
  50. intentspec_core/testing/tester.py +264 -0
  51. intentspec_core/validator.py +212 -0
  52. intentspec_mcp/__init__.py +2 -0
  53. intentspec_mcp/server.py +383 -0
intentspec/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """IntentSpec CLI package."""
2
+
3
+ __version__ = "0.1.0"
intentspec/cli.py ADDED
@@ -0,0 +1,620 @@
1
+ """Typer CLI for IntentSpec v0.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ import yaml
11
+ from intentspec_core import (
12
+ SUPPORTED_IMPORT_TYPES,
13
+ SUPPORTED_TARGETS,
14
+ IntentSpecError,
15
+ IntentSpecTestError,
16
+ IntentSpecValidationError,
17
+ compile_target,
18
+ copy_example,
19
+ generate_json_schema,
20
+ heuristic_draft_payload,
21
+ import_from_artifact,
22
+ inspect_document,
23
+ list_examples,
24
+ load_document,
25
+ load_spec,
26
+ resolve_document_imports,
27
+ resolve_imports,
28
+ run_doctor,
29
+ test_output,
30
+ validate_document,
31
+ validate_spec,
32
+ )
33
+ from intentspec_core.common.utils import dump_json, read_text, write_text
34
+ from intentspec_core.targets.compiler import CompiledArtifact
35
+ from intentspec_core.testing.tester import TestReport
36
+ from intentspec_core.validator import ValidationReport
37
+
38
+ app = typer.Typer(
39
+ add_completion=False,
40
+ no_args_is_help=True,
41
+ help="IntentSpec local-first contract compiler for AI task systems.",
42
+ )
43
+
44
+ REPORT_FORMATS = {"text", "json"}
45
+ DEFAULT_INIT_FILENAME = "intent.yaml"
46
+ DEFAULT_INIT_CONTENT = """version: "0.1"
47
+ kind: "IntentSpec"
48
+
49
+ metadata:
50
+ name: "my_intent"
51
+ title: "Describe the task title"
52
+ description: "Describe what this intent should produce."
53
+ owner: "team-name"
54
+
55
+ task:
56
+ goal: "Describe the main goal for the system."
57
+ audience:
58
+ - "Primary audience"
59
+ priority: "medium"
60
+
61
+ context:
62
+ domain: "General"
63
+ facts: []
64
+ assumptions: []
65
+ glossary: {}
66
+
67
+ inputs: []
68
+
69
+ permissions:
70
+ web: false
71
+ filesystem:
72
+ read: true
73
+ write: false
74
+ network: false
75
+ tools:
76
+ send_email: false
77
+ read_calendar: false
78
+ read_gmail: false
79
+ create_file: false
80
+ delete_file: false
81
+ purchase: false
82
+
83
+ constraints:
84
+ - "Do not fabricate facts."
85
+
86
+ evidence:
87
+ require_sources: false
88
+ mark_uncertainty: true
89
+ distinguish:
90
+ - "fact"
91
+ - "inference"
92
+
93
+ output:
94
+ format: "markdown"
95
+ language: "zh-CN"
96
+ max_words: 500
97
+ sections:
98
+ - "Summary"
99
+
100
+ quality:
101
+ tone:
102
+ - "clear"
103
+ must_include: []
104
+ must_avoid: []
105
+
106
+ human_gates: []
107
+
108
+ tests:
109
+ - name: "Must include all sections"
110
+ assert:
111
+ - type: "required_sections"
112
+ """
113
+
114
+
115
+ def _normalize_report_format(value: str) -> str:
116
+ normalized = value.lower()
117
+ if normalized not in REPORT_FORMATS:
118
+ raise typer.BadParameter("Use one of: text, json.")
119
+ return normalized
120
+
121
+
122
+ @app.command()
123
+ def validate(
124
+ file: Path,
125
+ report_format: str = typer.Option(
126
+ "text",
127
+ "--format",
128
+ callback=_normalize_report_format,
129
+ help="Output format: text or json.",
130
+ ),
131
+ ) -> None:
132
+ try:
133
+ report = _validate_document(file)
134
+ except IntentSpecError as exc:
135
+ _emit_error(exc, report_format)
136
+ raise typer.Exit(code=1) from exc
137
+
138
+ _emit_report(report, report_format)
139
+ raise typer.Exit(code=0 if report.ok else 1)
140
+
141
+
142
+ @app.command()
143
+ def inspect(
144
+ file: Path,
145
+ report_format: str = typer.Option(
146
+ "text",
147
+ "--format",
148
+ callback=_normalize_report_format,
149
+ help="Output format: text or json.",
150
+ ),
151
+ ) -> None:
152
+ try:
153
+ document, _ = _load_validated_document(file, emit_warnings=False)
154
+ payload = inspect_document(document)
155
+ except IntentSpecError as exc:
156
+ _emit_error(exc, report_format)
157
+ raise typer.Exit(code=1) from exc
158
+
159
+ if report_format == "json":
160
+ typer.echo(dump_json(payload))
161
+ else:
162
+ typer.echo(_render_iir_text(payload))
163
+
164
+
165
+ @app.command()
166
+ def compile(
167
+ file: Path,
168
+ target: str = typer.Option(
169
+ ...,
170
+ "--target",
171
+ help=f"One of: {', '.join(sorted(SUPPORTED_TARGETS))}",
172
+ ),
173
+ out: Path | None = typer.Option(None, "--out", help="Optional output file or directory."),
174
+ ) -> None:
175
+ try:
176
+ spec, _ = _load_validated_spec(file, emit_warnings=True)
177
+ artifact = compile_target(spec, target)
178
+ except typer.Exit:
179
+ raise
180
+ except IntentSpecError as exc:
181
+ _emit_error(exc, "text")
182
+ raise typer.Exit(code=1) from exc
183
+
184
+ _emit_compiled_artifact(artifact, out)
185
+
186
+
187
+ @app.command("test")
188
+ def test_command(
189
+ file: Path,
190
+ output: Path = typer.Option(..., "--output", help="Output text or JSON file to test."),
191
+ report_format: str = typer.Option(
192
+ "text",
193
+ "--format",
194
+ callback=_normalize_report_format,
195
+ help="Output format: text or json.",
196
+ ),
197
+ ) -> None:
198
+ try:
199
+ report = _run_test(file, output)
200
+ except IntentSpecError as exc:
201
+ _emit_error(exc, report_format)
202
+ raise typer.Exit(code=1) from exc
203
+
204
+ _emit_report(report, report_format)
205
+ raise typer.Exit(code=0 if report.ok else 2)
206
+
207
+
208
+ @app.command()
209
+ def init(
210
+ file: Path = typer.Option(
211
+ Path(DEFAULT_INIT_FILENAME),
212
+ "--file",
213
+ help="Path to the new intent file.",
214
+ ),
215
+ ) -> None:
216
+ _init_file(file)
217
+
218
+
219
+ @app.command()
220
+ def schema(
221
+ out: Path | None = typer.Option(None, "--out", help="Optional output file path."),
222
+ ) -> None:
223
+ rendered = dump_json(generate_json_schema())
224
+ if out is not None:
225
+ write_text(out, rendered)
226
+ typer.echo(f"Wrote schema to {out}")
227
+ return
228
+ typer.echo(rendered)
229
+
230
+
231
+ @app.command()
232
+ def doctor(
233
+ report_format: str = typer.Option(
234
+ "text",
235
+ "--format",
236
+ callback=_normalize_report_format,
237
+ help="Output format: text or json.",
238
+ ),
239
+ ) -> None:
240
+ report = run_doctor(Path.cwd())
241
+ _emit_report(report, report_format)
242
+ raise typer.Exit(code=0 if report.ok else 1)
243
+
244
+
245
+ @app.command()
246
+ def examples(
247
+ copy: str | None = typer.Option(None, "--copy", help="Copy a packaged example by filename."),
248
+ out: Path | None = typer.Option(None, "--out", help="Destination path for --copy."),
249
+ ) -> None:
250
+ if copy is None:
251
+ typer.echo("Available examples:")
252
+ for example in list_examples():
253
+ typer.echo(f"- {example.name}")
254
+ return
255
+
256
+ if out is None:
257
+ typer.echo("Error: --out is required when using --copy.", err=True)
258
+ raise typer.Exit(code=1)
259
+ if out.exists():
260
+ typer.echo(f"Error: refusing to overwrite existing path: {out}", err=True)
261
+ raise typer.Exit(code=1)
262
+
263
+ try:
264
+ destination = copy_example(copy, out)
265
+ except FileNotFoundError as exc:
266
+ typer.echo(f"Error: {exc}", err=True)
267
+ raise typer.Exit(code=1) from exc
268
+ typer.echo(f"Copied example '{copy}' to {destination}")
269
+
270
+
271
+ @app.command()
272
+ def workflow(
273
+ workdir: Path = typer.Option(
274
+ Path("."),
275
+ "--workdir",
276
+ help="Working directory for relative paths.",
277
+ ),
278
+ intent_file: Path = typer.Option(
279
+ Path(DEFAULT_INIT_FILENAME),
280
+ "--intent-file",
281
+ help="IntentSpec file path.",
282
+ ),
283
+ prompt_out: Path = typer.Option(
284
+ Path("task.prompt.md"),
285
+ "--prompt-out",
286
+ help="Prompt output path.",
287
+ ),
288
+ structured_out: Path = typer.Option(
289
+ Path("task.openai-structured.json"),
290
+ "--openai-structured-out",
291
+ help="Structured output payload path.",
292
+ ),
293
+ agents_out: Path = typer.Option(
294
+ Path("AGENTS.md"),
295
+ "--agents-out",
296
+ help="AGENTS.md output path.",
297
+ ),
298
+ mcp_plan_out: Path = typer.Option(
299
+ Path("task.mcp-plan.json"),
300
+ "--mcp-plan-out",
301
+ help="MCP plan output path.",
302
+ ),
303
+ output_file: Path = typer.Option(
304
+ None,
305
+ "--output-file",
306
+ help="AI output file path. Defaults to ai_output.md or ai_output.json by format.",
307
+ ),
308
+ init: bool = typer.Option(
309
+ False,
310
+ "--init",
311
+ help="Create the intent file before running the workflow.",
312
+ ),
313
+ skip_test: bool = typer.Option(
314
+ False,
315
+ "--skip-test",
316
+ help="Skip the final intent test step.",
317
+ ),
318
+ ) -> None:
319
+ try:
320
+ resolved_workdir = workdir.resolve()
321
+ resolved_intent_file = _resolve_workflow_path(resolved_workdir, intent_file)
322
+ resolved_prompt_out = _resolve_workflow_path(resolved_workdir, prompt_out)
323
+ resolved_structured_out = _resolve_workflow_path(resolved_workdir, structured_out)
324
+ resolved_agents_out = _resolve_workflow_path(resolved_workdir, agents_out)
325
+ resolved_mcp_plan_out = _resolve_workflow_path(resolved_workdir, mcp_plan_out)
326
+
327
+ if init:
328
+ _ensure_intent_file(resolved_intent_file)
329
+
330
+ spec, report = _load_validated_spec(resolved_intent_file, emit_warnings=False)
331
+ resolved_output_file = _resolve_workflow_output_file(
332
+ resolved_workdir,
333
+ output_file,
334
+ spec.output.format,
335
+ )
336
+ typer.echo(report.to_text())
337
+ _emit_compiled_artifact(compile_target(spec, "prompt"), resolved_prompt_out)
338
+ if spec.output.format == "json":
339
+ _emit_compiled_artifact(
340
+ compile_target(spec, "openai-structured"),
341
+ resolved_structured_out,
342
+ )
343
+ _emit_compiled_artifact(compile_target(spec, "agents-md"), resolved_agents_out)
344
+ _emit_compiled_artifact(compile_target(spec, "mcp-plan"), resolved_mcp_plan_out)
345
+
346
+ if skip_test:
347
+ typer.echo("Skipped final intent test because --skip-test was provided.")
348
+ raise typer.Exit(code=0)
349
+
350
+ test_report = _run_test(resolved_intent_file, resolved_output_file)
351
+ typer.echo(test_report.to_text())
352
+ raise typer.Exit(code=0 if test_report.ok else 2)
353
+ except typer.Exit:
354
+ raise
355
+ except IntentSpecError as exc:
356
+ _emit_error(exc, "text")
357
+ raise typer.Exit(code=1) from exc
358
+
359
+
360
+ @app.command()
361
+ def draft(
362
+ request: str,
363
+ out: Path | None = typer.Option(None, "--out", help="Optional output file path."),
364
+ backend: str = typer.Option(
365
+ "heuristic",
366
+ "--backend",
367
+ help="Draft backend. Only heuristic is supported.",
368
+ ),
369
+ ) -> None:
370
+ if backend != "heuristic":
371
+ typer.echo("Error: only the heuristic draft backend is supported in v0.1.", err=True)
372
+ raise typer.Exit(code=1)
373
+ payload = heuristic_draft_payload(request)
374
+ rendered = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
375
+ if out is not None:
376
+ if out.exists():
377
+ typer.echo(f"Error: refusing to overwrite existing file: {out}", err=True)
378
+ raise typer.Exit(code=1)
379
+ write_text(out, rendered)
380
+ typer.echo(f"Wrote draft intent to {out}")
381
+ return
382
+ typer.echo(rendered)
383
+
384
+
385
+ def _validate_import_type(value: str | None) -> str | None:
386
+ if value is None:
387
+ return None
388
+ if value not in SUPPORTED_IMPORT_TYPES:
389
+ raise typer.BadParameter(
390
+ f"Use one of: {', '.join(sorted(SUPPORTED_IMPORT_TYPES))}."
391
+ )
392
+ return value
393
+
394
+
395
+ @app.command("import")
396
+ def import_command(
397
+ file: Path,
398
+ source_type: str | None = typer.Option(
399
+ None,
400
+ "--type",
401
+ callback=_validate_import_type,
402
+ help=f"Source artifact type. One of: {', '.join(sorted(SUPPORTED_IMPORT_TYPES))}. "
403
+ "Inferred from file when omitted.",
404
+ ),
405
+ out: Path | None = typer.Option(None, "--out", help="Optional output YAML file path."),
406
+ ) -> None:
407
+ try:
408
+ payload = import_from_artifact(file, source_type)
409
+ except IntentSpecError as exc:
410
+ _emit_error(exc, "text")
411
+ raise typer.Exit(code=1) from exc
412
+
413
+ rendered = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
414
+ if out is not None:
415
+ if out.exists():
416
+ typer.echo(f"Error: refusing to overwrite existing file: {out}", err=True)
417
+ raise typer.Exit(code=1)
418
+ write_text(out, rendered)
419
+ typer.echo(f"Wrote imported intent to {out}")
420
+ return
421
+ typer.echo(rendered)
422
+
423
+
424
+ def main() -> None:
425
+ app()
426
+
427
+
428
+ def _validate_document(file: Path) -> ValidationReport:
429
+ document = resolve_document_imports(load_document(file))
430
+ return validate_document(document)
431
+
432
+
433
+ def _load_validated_spec(file: Path, *, emit_warnings: bool) -> tuple[Any, ValidationReport]:
434
+ spec = resolve_imports(load_spec(file))
435
+ report = validate_spec(spec)
436
+ if not report.ok:
437
+ typer.echo(report.to_text(), err=True)
438
+ raise typer.Exit(code=1)
439
+ if emit_warnings and report.warnings:
440
+ typer.echo("Warnings:", err=True)
441
+ for warning in report.warnings:
442
+ typer.echo(f"- {warning}", err=True)
443
+ return spec, report
444
+
445
+
446
+ def _load_validated_document(
447
+ file: Path, *, emit_warnings: bool
448
+ ) -> tuple[Any, ValidationReport]:
449
+ document = resolve_document_imports(load_document(file))
450
+ report = validate_document(document)
451
+ if not report.ok:
452
+ typer.echo(report.to_text(), err=True)
453
+ raise typer.Exit(code=1)
454
+ if emit_warnings and report.warnings:
455
+ typer.echo("Warnings:", err=True)
456
+ for warning in report.warnings:
457
+ typer.echo(f"- {warning}", err=True)
458
+ return document, report
459
+
460
+
461
+ def _run_test(file: Path, output: Path) -> TestReport:
462
+ spec = resolve_imports(load_spec(file))
463
+ validation_report = validate_spec(spec)
464
+ _ensure_validation_report_ok(
465
+ validation_report,
466
+ file,
467
+ suggestion=(
468
+ "Run `intent validate <file>` and fix the reported errors before testing "
469
+ "output."
470
+ ),
471
+ )
472
+
473
+ if not output.exists():
474
+ raise IntentSpecTestError(
475
+ f"Output file not found: {output}",
476
+ suggestion="Create the output file or pass the correct --output path.",
477
+ details={"path": str(output)},
478
+ )
479
+ if not output.is_file():
480
+ raise IntentSpecTestError(
481
+ f"Output path is not a file: {output}",
482
+ suggestion="Pass a readable file path to --output, not a directory.",
483
+ details={"path": str(output)},
484
+ )
485
+ try:
486
+ output_text = read_text(output)
487
+ except OSError as exc:
488
+ raise IntentSpecTestError(
489
+ f"Unable to read output file: {output}",
490
+ suggestion="Verify the output path is readable and points to a text or JSON file.",
491
+ details={"path": str(output)},
492
+ ) from exc
493
+
494
+ return test_output(spec, output_text)
495
+
496
+
497
+ def _ensure_validation_report_ok(
498
+ report: ValidationReport,
499
+ file: Path,
500
+ *,
501
+ suggestion: str,
502
+ ) -> None:
503
+ if report.ok:
504
+ return
505
+
506
+ errors = report.errors or ["Unknown validation error."]
507
+ raise IntentSpecValidationError(
508
+ f"IntentSpec validation failed for {file}: {'; '.join(errors)}",
509
+ suggestion=suggestion,
510
+ details={
511
+ "path": str(file),
512
+ "errors": report.errors,
513
+ "warnings": report.warnings,
514
+ },
515
+ )
516
+
517
+
518
+ def _emit_report(report: Any, report_format: str) -> None:
519
+ if report_format == "json":
520
+ typer.echo(json.dumps(report.to_dict(), ensure_ascii=False, indent=2))
521
+ return
522
+ typer.echo(report.to_text())
523
+
524
+
525
+ def _emit_error(exc: IntentSpecError, report_format: str) -> None:
526
+ if report_format == "json":
527
+ typer.echo(
528
+ json.dumps(
529
+ {"ok": False, "error": exc.to_dict()},
530
+ ensure_ascii=False,
531
+ indent=2,
532
+ )
533
+ )
534
+ return
535
+ typer.echo(f"Error: {exc}", err=True)
536
+ if exc.suggestion:
537
+ typer.echo(f"Suggested fix: {exc.suggestion}", err=True)
538
+
539
+
540
+ def _emit_compiled_artifact(artifact: CompiledArtifact, out: Path | None) -> None:
541
+ if artifact.kind == "bundle":
542
+ if out is None:
543
+ raise IntentSpecError(
544
+ "Target produces a bundle and requires --out to point to a directory.",
545
+ suggestion="Pass --out <directory> when compiling a bundle target.",
546
+ )
547
+ _write_bundle(out, artifact.files)
548
+ typer.echo(f"Wrote bundle output to {out}")
549
+ return
550
+
551
+ if artifact.content is None:
552
+ raise IntentSpecError("Compiled artifact is missing content.")
553
+
554
+ if out is not None:
555
+ write_text(out, artifact.content)
556
+ typer.echo(f"Wrote compiled output to {out}")
557
+ return
558
+ typer.echo(artifact.content)
559
+
560
+
561
+ def _write_bundle(directory: Path, files: dict[str, str]) -> None:
562
+ for relative_path, content in files.items():
563
+ write_text(directory / relative_path, content)
564
+
565
+
566
+ def _init_file(file: Path) -> None:
567
+ if file.exists():
568
+ typer.echo(f"Refusing to overwrite existing file: {file}", err=True)
569
+ raise typer.Exit(code=1)
570
+ write_text(file, DEFAULT_INIT_CONTENT)
571
+ typer.echo(f"Created {file}")
572
+
573
+
574
+ def _resolve_workflow_path(workdir: Path, path: Path) -> Path:
575
+ if path.is_absolute():
576
+ return path
577
+ return workdir / path
578
+
579
+
580
+ def _resolve_workflow_output_file(
581
+ workdir: Path,
582
+ output_file: Path | None,
583
+ output_format: str,
584
+ ) -> Path:
585
+ if output_file is None:
586
+ default_name = "ai_output.json" if output_format == "json" else "ai_output.md"
587
+ return workdir / default_name
588
+ return _resolve_workflow_path(workdir, output_file)
589
+
590
+
591
+ def _ensure_intent_file(file: Path) -> None:
592
+ if file.exists():
593
+ typer.echo(f"Using existing intent file: {file}")
594
+ return
595
+ _init_file(file)
596
+
597
+
598
+ def _render_iir_text(payload: dict[str, Any]) -> str:
599
+ if payload.get("kind") == "IntentPack":
600
+ lines = [
601
+ f"Source path: {payload['source_path']}",
602
+ f"Pack: {payload['metadata']['name']}",
603
+ "Constraints:",
604
+ *[f"- {item}" for item in payload["merged_constraints"]],
605
+ ]
606
+ return "\n".join(lines)
607
+
608
+ lines = [
609
+ f"Source path: {payload['source_path']}",
610
+ f"Goal: {payload['normalized_goal']}",
611
+ "Risk signals:",
612
+ *[f"- {item}" for item in payload["risk_signals"]],
613
+ "Constraints:",
614
+ *[f"- {item}" for item in payload["merged_constraints"]],
615
+ ]
616
+ return "\n".join(lines)
617
+
618
+
619
+ if __name__ == "__main__":
620
+ main()