aw-spec-cli 0.1.5 → 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.
Files changed (205) hide show
  1. package/assets/.agents/agent.json +1 -1
  2. package/assets/.agents/skills/{aw-spec-orch → appweaver}/SKILL.md +10 -10
  3. package/assets/.agents/skills/{aw-spec-orch-demo → appweaver-demo}/SKILL.md +9 -9
  4. package/assets/.claude/agent.json +1 -1
  5. package/assets/.claude/skills/{aw-spec-orch → appweaver}/SKILL.md +10 -10
  6. package/assets/.claude/skills/{aw-spec-orch-demo → appweaver-demo}/SKILL.md +9 -9
  7. package/assets/.kilocode/agent.json +1 -1
  8. package/assets/.kilocode/workflows/{aw-spec-orch-demo.md → appweaver-demo.md} +9 -9
  9. package/assets/.kilocode/workflows/{aw-spec-orch.md → appweaver.md} +10 -10
  10. package/assets/docs/pharma-constitution.my-ref.md +86 -0
  11. package/dist/utils/paths.js +1 -1
  12. package/dist/utils/paths.js.map +1 -1
  13. package/package.json +1 -1
  14. package/assets/.aw-spec/DESIGN-FORMAT.md +0 -151
  15. package/assets/.aw-spec/ORCH-DIALECT.md +0 -156
  16. package/assets/.aw-spec/RULE-PARTITION.md +0 -126
  17. package/assets/.aw-spec/domain.json +0 -3
  18. package/assets/.aw-spec/extensions/.registry +0 -19
  19. package/assets/.aw-spec/extensions/agent-context/README.md +0 -57
  20. package/assets/.aw-spec/extensions/agent-context/agent-context-config.yml +0 -4
  21. package/assets/.aw-spec/extensions/agent-context/commands/aw-spec.agent-context.update.md +0 -26
  22. package/assets/.aw-spec/extensions/agent-context/extension.yml +0 -37
  23. package/assets/.aw-spec/extensions/agent-context/scripts/bash/update-agent-context.sh +0 -201
  24. package/assets/.aw-spec/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +0 -237
  25. package/assets/.aw-spec/extensions.yml +0 -16
  26. package/assets/.aw-spec/feature.json +0 -3
  27. package/assets/.aw-spec/init-options.json +0 -9
  28. package/assets/.aw-spec/integration.json +0 -15
  29. package/assets/.aw-spec/integrations/aw-spec.manifest.json +0 -9
  30. package/assets/.aw-spec/interpreter/run-skill.py +0 -1158
  31. package/assets/.aw-spec/memory/constitution.md +0 -50
  32. package/assets/.aw-spec/orchestrator/AGENT-PROFILE.md +0 -94
  33. package/assets/.aw-spec/orchestrator/README.md +0 -300
  34. package/assets/.aw-spec/orchestrator/adapters/claude_agent_sdk.py +0 -74
  35. package/assets/.aw-spec/orchestrator/adapters/google_antigravity.py +0 -74
  36. package/assets/.aw-spec/orchestrator/modules/delta-design.orch.yaml +0 -105
  37. package/assets/.aw-spec/orchestrator/modules/delta-spec.orch.yaml +0 -79
  38. package/assets/.aw-spec/orchestrator/modules/domain-select.orch.yaml +0 -81
  39. package/assets/.aw-spec/orchestrator/modules/tech-select.orch.yaml +0 -75
  40. package/assets/.aw-spec/orchestrator/orchestrator.py +0 -1721
  41. package/assets/.aw-spec/orchestrator/rev-templates/ai-rules.reference.md +0 -40
  42. package/assets/.aw-spec/orchestrator/rev-templates/comandrulemap.skeleton.md +0 -59
  43. package/assets/.aw-spec/orchestrator/rev-templates/commandtechrulemap.skeleton.md +0 -97
  44. package/assets/.aw-spec/orchestrator/rev-templates/domain-constitution.skeleton.md +0 -61
  45. package/assets/.aw-spec/orchestrator/rev-templates/domain-requirements.skeleton.md +0 -48
  46. package/assets/.aw-spec/orchestrator/rev-templates/domain-rule-file.skeleton.md +0 -39
  47. package/assets/.aw-spec/orchestrator/rev-templates/technical-constitution.skeleton.md +0 -106
  48. package/assets/.aw-spec/orchestrator/rev-templates/technical-rule-file.skeleton.md +0 -54
  49. package/assets/.aw-spec/orchestrator/workflows/_smoke.orch.yaml +0 -160
  50. package/assets/.aw-spec/orchestrator/workflows/aw-spec-add-component.orch.yaml +0 -244
  51. package/assets/.aw-spec/orchestrator/workflows/aw-spec-adopt.orch.yaml +0 -775
  52. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +0 -248
  53. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +0 -261
  54. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +0 -425
  55. package/assets/.aw-spec/orchestrator/workflows/aw-spec-build.orch.yaml +0 -188
  56. package/assets/.aw-spec/orchestrator/workflows/aw-spec-characterize.orch.yaml +0 -474
  57. package/assets/.aw-spec/orchestrator/workflows/aw-spec-close-delta.orch.yaml +0 -181
  58. package/assets/.aw-spec/orchestrator/workflows/aw-spec-constitution.orch.yaml +0 -284
  59. package/assets/.aw-spec/orchestrator/workflows/aw-spec-demo.orch.yaml +0 -103
  60. package/assets/.aw-spec/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +0 -69
  61. package/assets/.aw-spec/orchestrator/workflows/aw-spec-deploy.orch.yaml +0 -85
  62. package/assets/.aw-spec/orchestrator/workflows/aw-spec-design.orch.yaml +0 -382
  63. package/assets/.aw-spec/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +0 -382
  64. package/assets/.aw-spec/orchestrator/workflows/aw-spec-foundation.orch.yaml +0 -246
  65. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +0 -231
  66. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement.orch.yaml +0 -258
  67. package/assets/.aw-spec/orchestrator/workflows/aw-spec-inventory.orch.yaml +0 -236
  68. package/assets/.aw-spec/orchestrator/workflows/aw-spec-onboard.orch.yaml +0 -949
  69. package/assets/.aw-spec/orchestrator/workflows/aw-spec-parity.orch.yaml +0 -293
  70. package/assets/.aw-spec/orchestrator/workflows/aw-spec-run.orch.yaml +0 -177
  71. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-domain.orch.yaml +0 -79
  72. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-feature.orch.yaml +0 -128
  73. package/assets/.aw-spec/orchestrator/workflows/aw-spec-specify.orch.yaml +0 -412
  74. package/assets/.aw-spec/scripts/bash/aw-inventory.sh +0 -14
  75. package/assets/.aw-spec/scripts/bash/aw-parity.sh +0 -13
  76. package/assets/.aw-spec/scripts/bash/aw-rev-constitution.sh +0 -13
  77. package/assets/.aw-spec/scripts/bash/aw-spec-actions.sh +0 -1899
  78. package/assets/.aw-spec/scripts/bash/aw-xref.sh +0 -15
  79. package/assets/.aw-spec/scripts/bash/common.sh +0 -533
  80. package/assets/.aw-spec/scripts/powershell/aw-inventory.ps1 +0 -14
  81. package/assets/.aw-spec/scripts/powershell/aw-parity.ps1 +0 -13
  82. package/assets/.aw-spec/scripts/powershell/aw-rev-constitution.ps1 +0 -13
  83. package/assets/.aw-spec/scripts/powershell/aw-spec-actions.ps1 +0 -1563
  84. package/assets/.aw-spec/scripts/powershell/aw-xref.ps1 +0 -15
  85. package/assets/.aw-spec/scripts/python/aw-inventory.py +0 -683
  86. package/assets/.aw-spec/scripts/python/aw-parity.py +0 -380
  87. package/assets/.aw-spec/scripts/python/aw-rev-constitution.py +0 -312
  88. package/assets/.aw-spec/scripts/python/aw-xref.py +0 -1330
  89. package/assets/.aw-spec/scripts/python/validate_okf.py +0 -349
  90. package/assets/.aw-spec/templates/constitution-domain-template.md +0 -75
  91. package/assets/.aw-spec/templates/constitution-technical-template.md +0 -70
  92. package/assets/.aw-spec/templates/delta-design-template.md +0 -69
  93. package/assets/.aw-spec/templates/delta-spec-template.md +0 -60
  94. package/assets/.aw-spec/templates/design-template.md +0 -383
  95. package/assets/.aw-spec/templates/okf-concept-template.md +0 -103
  96. package/assets/.aw-spec/templates/parity-manifest-template.json +0 -49
  97. package/assets/.aw-spec/templates/source-map-template.md +0 -85
  98. package/assets/.aw-spec/templates/spec-template.md +0 -252
  99. package/assets/docs/ModularMonolith/ModularMonolith.Reference.pack.txt +0 -25248
  100. package/assets/docs/ModularMonolith/ModularMonolith.pack.txt +0 -5726
  101. package/assets/docs/ModularMonolith/pack-foundation.ps1 +0 -145
  102. package/assets/docs/ModularMonolith/pack-foundation.sh +0 -196
  103. package/assets/docs/ModularMonolith/pack-reference-foundation.ps1 +0 -157
  104. package/assets/docs/ModularMonolith/pack-reference-foundation.sh +0 -209
  105. package/assets/docs/ModularMonolith/unpack-foundation.ps1 +0 -126
  106. package/assets/docs/ModularMonolith/unpack-foundation.sh +0 -217
  107. package/assets/docs/ModularMonolith/unpack-reference-foundation.ps1 +0 -129
  108. package/assets/docs/ModularMonolith/unpack-reference-foundation.sh +0 -218
  109. package/assets/docs/README.md +0 -106
  110. package/assets/docs/VerticalSlice/PACKAGING.md +0 -185
  111. package/assets/docs/VerticalSlice/VerticalSlice.Reference.pack.txt +0 -19318
  112. package/assets/docs/VerticalSlice/VerticalSlice.pack.txt +0 -2833
  113. package/assets/docs/VerticalSlice/pack-foundation.ps1 +0 -145
  114. package/assets/docs/VerticalSlice/pack-foundation.sh +0 -196
  115. package/assets/docs/VerticalSlice/pack-reference-foundation.ps1 +0 -157
  116. package/assets/docs/VerticalSlice/pack-reference-foundation.sh +0 -209
  117. package/assets/docs/VerticalSlice/unpack-foundation.ps1 +0 -126
  118. package/assets/docs/VerticalSlice/unpack-foundation.sh +0 -217
  119. package/assets/docs/VerticalSlice/unpack-reference-foundation.ps1 +0 -129
  120. package/assets/docs/VerticalSlice/unpack-reference-foundation.sh +0 -218
  121. package/assets/docs/architecture/clean-architecture.md +0 -71
  122. package/assets/docs/architecture/cqrs.md +0 -141
  123. package/assets/docs/architecture/eventing.md +0 -83
  124. package/assets/docs/architecture/folder-structure.md +0 -61
  125. package/assets/docs/architecture/modular-monolith.md +0 -47
  126. package/assets/docs/architecture/persistence.md +0 -269
  127. package/assets/docs/architecture/testing.md +0 -55
  128. package/assets/docs/architecture/vertical-slices.md +0 -201
  129. package/assets/docs/components/Email/README.md +0 -119
  130. package/assets/docs/components/Email/ReferenceEndpoints/SendEmailEndpoint.cs +0 -84
  131. package/assets/docs/components/Email/integration.md +0 -300
  132. package/assets/docs/components/Jobs/README.md +0 -113
  133. package/assets/docs/components/Jobs/integration.md +0 -259
  134. package/assets/docs/components/QrGenerator/README.md +0 -85
  135. package/assets/docs/components/QrGenerator/ReferenceEndpoints/GenerateQrEndpoint.cs +0 -57
  136. package/assets/docs/components/QrGenerator/integration.md +0 -230
  137. package/assets/docs/components/README.md +0 -74
  138. package/assets/docs/domain/domain-driven-design.md +0 -212
  139. package/assets/docs/domainspecs/healthcare/ai-rules.domain.md +0 -34
  140. package/assets/docs/domainspecs/healthcare/api-versioning.domain.md +0 -17
  141. package/assets/docs/domainspecs/healthcare/auditing.domain.md +0 -97
  142. package/assets/docs/domainspecs/healthcare/authorization.domain.md +0 -26
  143. package/assets/docs/domainspecs/healthcare/clinical-terms.md +0 -20
  144. package/assets/docs/domainspecs/healthcare/coding-standards.domain.md +0 -26
  145. package/assets/docs/domainspecs/healthcare/comandrulemap.md +0 -100
  146. package/assets/docs/domainspecs/healthcare/commandtechrulemap.md +0 -119
  147. package/assets/docs/domainspecs/healthcare/consent.md +0 -19
  148. package/assets/docs/domainspecs/healthcare/cqrs.domain.md +0 -12
  149. package/assets/docs/domainspecs/healthcare/data-classification.domain.md +0 -28
  150. package/assets/docs/domainspecs/healthcare/dependency-policy.domain.md +0 -11
  151. package/assets/docs/domainspecs/healthcare/dicom.md +0 -15
  152. package/assets/docs/domainspecs/healthcare/domain-driven-design.domain.md +0 -28
  153. package/assets/docs/domainspecs/healthcare/domain-requirements.md +0 -55
  154. package/assets/docs/domainspecs/healthcare/eventing.domain.md +0 -18
  155. package/assets/docs/domainspecs/healthcare/fhir.md +0 -36
  156. package/assets/docs/domainspecs/healthcare/folder-structure.domain.md +0 -23
  157. package/assets/docs/domainspecs/healthcare/hipaa.md +0 -34
  158. package/assets/docs/domainspecs/healthcare/hl7.md +0 -15
  159. package/assets/docs/domainspecs/healthcare/integration-terms.md +0 -21
  160. package/assets/docs/domainspecs/healthcare/persistence.domain.md +0 -20
  161. package/assets/docs/domainspecs/healthcare/terminology.md +0 -30
  162. package/assets/docs/domainspecs/healthcare/testing.domain.md +0 -15
  163. package/assets/docs/domainspecs/healthcare/vertical-slices.domain.md +0 -18
  164. package/assets/docs/domainspecs/healthcare/zero-trust.domain.md +0 -12
  165. package/assets/docs/domainspecs/pharma/ai-rules.domain.md +0 -32
  166. package/assets/docs/domainspecs/pharma/api-versioning.domain.md +0 -20
  167. package/assets/docs/domainspecs/pharma/atc.md +0 -22
  168. package/assets/docs/domainspecs/pharma/auditing.domain.md +0 -90
  169. package/assets/docs/domainspecs/pharma/authorization.domain.md +0 -25
  170. package/assets/docs/domainspecs/pharma/coding-standards.domain.md +0 -28
  171. package/assets/docs/domainspecs/pharma/comandrulemap.md +0 -108
  172. package/assets/docs/domainspecs/pharma/commandtechrulemap.md +0 -119
  173. package/assets/docs/domainspecs/pharma/cqrs.domain.md +0 -16
  174. package/assets/docs/domainspecs/pharma/data-classification.domain.md +0 -24
  175. package/assets/docs/domainspecs/pharma/data-provenance.md +0 -21
  176. package/assets/docs/domainspecs/pharma/dependency-policy.domain.md +0 -11
  177. package/assets/docs/domainspecs/pharma/domain-driven-design.domain.md +0 -36
  178. package/assets/docs/domainspecs/pharma/domain-requirements.md +0 -99
  179. package/assets/docs/domainspecs/pharma/eventing.domain.md +0 -18
  180. package/assets/docs/domainspecs/pharma/fhir.md +0 -31
  181. package/assets/docs/domainspecs/pharma/folder-structure.domain.md +0 -24
  182. package/assets/docs/domainspecs/pharma/hl7.md +0 -17
  183. package/assets/docs/domainspecs/pharma/idmp.md +0 -26
  184. package/assets/docs/domainspecs/pharma/integration-terms.md +0 -21
  185. package/assets/docs/domainspecs/pharma/ndc.md +0 -28
  186. package/assets/docs/domainspecs/pharma/persistence.domain.md +0 -23
  187. package/assets/docs/domainspecs/pharma/pharma-terms.md +0 -27
  188. package/assets/docs/domainspecs/pharma/product-lifecycle.domain.md +0 -30
  189. package/assets/docs/domainspecs/pharma/regulatory-compliance.md +0 -19
  190. package/assets/docs/domainspecs/pharma/terminology.md +0 -32
  191. package/assets/docs/domainspecs/pharma/testing.domain.md +0 -19
  192. package/assets/docs/domainspecs/pharma/vertical-slices.domain.md +0 -17
  193. package/assets/docs/domainspecs/pharma/zero-trust.domain.md +0 -12
  194. package/assets/docs/glossary/ai-rules.md +0 -59
  195. package/assets/docs/governance/api-versioning.md +0 -99
  196. package/assets/docs/governance/coding-standards.md +0 -87
  197. package/assets/docs/governance/dependency-policy.md +0 -40
  198. package/assets/docs/interop/ihe-conformance.md +0 -31
  199. package/assets/docs/requirement-spec.txt +0 -27
  200. package/assets/docs/security/auditing.md +0 -160
  201. package/assets/docs/security/authorization-matrix.md +0 -14
  202. package/assets/docs/security/authorization.md +0 -87
  203. package/assets/docs/security/data-classification.md +0 -38
  204. package/assets/docs/security/zero-trust.md +0 -76
  205. package/assets/docs/technology-constitution.md +0 -172
@@ -1,1721 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- aw-orchestrator — reference runtime for the "interpreter owns context,
4
- a FRESH Claude executes" architecture.
5
-
6
- User
7
- │
8
- ▼ (a Claude Code skill launches this runtime)
9
- Python Workflow / Interpreter Runtime ── THIS FILE
10
- │
11
- ├─ build_context Builds the COMPLETE context for the next request
12
- │ (reads standards + rule files + prior outputs and
13
- │ assembles a self-contained system + user payload).
14
- ▼
15
- Invoke Claude Code (FRESH generation)
16
- │ `claude -p --bare --system-prompt-file … --tools …`
17
- │ • -p → a brand-new conversation (the calling
18
- │ agent's transcript is NOT inherited)
19
- │ • --bare → no CLAUDE.md / auto-memory / hooks;
20
- │ the ONLY context is what we pass
21
- │ • system → the interpreter-authored standards
22
- │ • stdin → the interpreter-authored task
23
- ├─ Claude uses tools (Edit / Write / Bash …) to do the actual work
24
- ▼
25
- Return structured result (a trailing ```json {…} ``` block → dict)
26
- │
27
- ▼
28
- Interpreter continues to the next workflow step
29
- │
30
- ├─ agent_review Review the current output (another FRESH generation)
31
- ▼
32
- build_context → … continues
33
-
34
- Key property (your R6): every model request sees ONLY the payload the
35
- interpreter assembled. The orchestrating Claude Code's own context is never
36
- part of a generation — because generation happens in a separate `--bare`
37
- process, not in this conversation.
38
-
39
- Stdlib only. Reuses the production YAML loader from ../interpreter/run-skill.py
40
- so skills stay in the same dialect family.
41
-
42
- Workflow dialect: aw-spec.orchestrator/v1 (see ORCH-DIALECT.md). On top of the
43
- demo actions (set / build_context / agent_generate / agent_review / loop /
44
- break) the engine provides the full vocabulary the aw-spec-* conversions need:
45
- execute_script run a shared deterministic script (decl port; --os sh/ps1)
46
- verify_context the four decl gate forms (files / non-empty / true / value)
47
- load_standards sticky SYSTEM standards set, prepended to every payload
48
- agent_author authored artifact: interpreter-owned output path, read-only
49
- generation under EVERY strategy, the ENGINE writes the file
50
- agent_query generic read-only structured JSON query (outputs[] binding)
51
- ask/user_select HITL — suspends via the checkpoint channel under ANY
52
- strategy (the launcher relays to the user), or --answer /
53
- --select / --non-interactive prefills
54
- dispatch_hooks hands resolved hooks to the INVOKING agent (suspend 'hook')
55
- include run a module .orch.yaml inline (shared vars/scripts/cache)
56
- loop over:/as: foreach over a list var (plus the existing while: form)
57
-
58
- Usage:
59
- orchestrator.py <workflow.yaml> [--transport mock|real]
60
- [--model opus] [--budget 0.50] [--var k=v ...] [--input "<text>"]
61
- [--answer k=v ...] [--select stepid=v ...] [--non-interactive]
62
- [--os posix|windows] [--show-full] [--json]
63
- orchestrator.py <workflow.yaml> --validate [--json] # lint, don't execute
64
-
65
- Execution strategies (--strategy, overridable per step via `strategy:`):
66
- The interpreter assembles 100% of every payload in ALL strategies; they only
67
- differ in WHO executes it and WHO applies the file changes.
68
-
69
- sdk Strategy 1: an isolated Agent SDK instance generates AND
70
- implements with its own tools. Fresh context per step; the
71
- invoking agent's conversation never enters a request. EVERYTHING
72
- SDK-specific is declared by the agent profile's `sdk` block
73
- (--agent-profile, e.g. agent.json) — the package/module to import
74
- (pip-installed on demand with --sdk-install or sdk.auto_install)
75
- and the `adapter` (a Python file exporting generate(request);
76
- shared per-SDK adapters live in .aw-spec/orchestrator/adapters/)
77
- that owns that SDK's calling convention — so the engine names no
78
- agent, has no default, and stays agent-agnostic. Imported
79
- lazily; needs API auth.
80
- sdk-apply Strategy 2: the SDK instance (same profile-declared sdk block)
81
- generates read-only (full intended file contents as an artifact);
82
- the interpreter then SUSPENDS (checkpoint) and the INVOKING agent
83
- applies the artifact with its tools — visible diffs, native
84
- permission prompts. Generation is isolated; only the apply phase
85
- runs in the invoker. Needs --checkpoint.
86
- spawn Strategy 3 (the original transport): a fresh CLI process per
87
- generation, described by --agent-profile (claude -p --bare /
88
- gemini -p). Isolated, but cold-starts every step.
89
- agent Strategy 4: zero new instances — the run-skill.py suspend/resume
90
- method. The interpreter exits (code 2) at each generation and the
91
- INVOKING agent fulfills it in its own conversation (payload
92
- ownership kept; context isolation is best-effort only). Needs
93
- --checkpoint.
94
-
95
- Legacy inference when --strategy is omitted: --agent-profile → spawn,
96
- --checkpoint → agent, neither → mock transport (no model call; prints the
97
- exact payloads and simulates the writes).
98
-
99
- Loop + checkpoint machinery is strategy-independent: completed generations
100
- are written to the replay cache, so any mix of strategies survives
101
- suspend/resume.
102
- """
103
- import argparse
104
- import importlib.util
105
- import json
106
- import os
107
- import re
108
- import subprocess
109
- import sys
110
-
111
- # ── reuse the production YAML-subset loader ──────────────────────────────────
112
- _INTERP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "interpreter", "run-skill.py")
113
- _spec = importlib.util.spec_from_file_location("aw_run_skill", _INTERP)
114
- _run_skill = importlib.util.module_from_spec(_spec)
115
- _spec.loader.exec_module(_run_skill)
116
- load_yaml = _run_skill.load_yaml
117
-
118
-
119
- BANNER = "─" * 70
120
-
121
-
122
- class Stop(Exception):
123
- pass
124
-
125
-
126
- class Suspend(Exception):
127
- """Raised in checkpoint mode when a generation is needed: the interpreter
128
- persists state and exits so the INVOKING agent can fulfill the request
129
- (run-skill.py's suspend/resume method — no new agent instance)."""
130
- def __init__(self, request):
131
- self.request = request
132
-
133
-
134
- def _norm_files(entries):
135
- """Normalize a system:/context: list into [(path, optional)]."""
136
- out = []
137
- for e in entries or []:
138
- if isinstance(e, dict):
139
- out.append((e.get("path"), bool(e.get("optional"))))
140
- else:
141
- out.append((e, False))
142
- return out
143
-
144
-
145
- def _extract_json(text):
146
- """Pull a structured result out of a free-form model reply: the last
147
- ```json … ``` fence, else the last bare {...}."""
148
- if not text:
149
- return {}
150
- fences = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
151
- for blob in reversed(fences):
152
- try:
153
- return json.loads(blob)
154
- except Exception:
155
- continue
156
- i, j = text.rfind("{"), text.rfind("}")
157
- if 0 <= i < j:
158
- try:
159
- return json.loads(text[i:j + 1])
160
- except Exception:
161
- pass
162
- return {}
163
-
164
-
165
- def _strip_fence(text):
166
- """If the ENTIRE reply is one fenced code block, unwrap it (models often
167
- fence authored file content despite being told not to)."""
168
- t = (text or "").strip()
169
- m = re.fullmatch(r"```[A-Za-z0-9_+-]*\n(.*?)\n?```", t, re.DOTALL)
170
- return m.group(1) + "\n" if m else (text or "")
171
-
172
-
173
- class Orchestrator:
174
- def __init__(self, wf, opts):
175
- self.wf = wf
176
- self.opts = opts
177
- self.vars = dict(wf.get("vars", {}) or {})
178
- self.vars.update(opts.vars)
179
- # the CURRENT context envelope, produced by build_context and consumed
180
- # by the next agent_generate / agent_review.
181
- self.envelope = {"system": [], "context": []}
182
- # F4: sticky standards — _assemble_system always prepends these, so the
183
- # system prefix stays byte-stable across steps (prompt-cache lever)
184
- # while build_context keeps declaring only the per-request delta.
185
- self.standards = list(wf.get("standards", []) or [])
186
- # F1: top-level scripts ref map {ref: {sh: path, ps1: path}} (decl shape)
187
- self.scripts = dict(wf.get("scripts", {}) or {})
188
- # every file that has entered any payload (verify_context required_files)
189
- self.loaded = []
190
- # every REQUIRED system:/context: file that was declared but absent at
191
- # assembly time (item H). Distinct, run-cumulative, surfaced in the run
192
- # result + the context_missing/_count vars so a workflow can gate on it.
193
- self.missing_required = []
194
- self.last_result = {}
195
- # agent-specific transport profile (None in mock mode). This engine is
196
- # general — the profile (loaded from a .claude/.agents/.kilocode file)
197
- # describes HOW to spawn that agent's fresh generation CLI.
198
- self.profile = getattr(opts, "profile", None)
199
- # checkpoint transport (run-skill.py method): replay + response cache
200
- # keyed by a loop-aware invocation path. No new agent instance.
201
- self.checkpoint = getattr(opts, "checkpoint", None)
202
- self.cache = getattr(opts, "_cache", {}) or {}
203
- self.key_stack = []
204
-
205
- # -- variable resolution --------------------------------------------------
206
- def _lookup(self, key):
207
- """Dotted/indexed lookup into vars ('q.text', 'items.0'). (found, raw)."""
208
- parts = key.split(".")
209
- if parts[0] not in self.vars:
210
- return (False, None)
211
- cur = self.vars[parts[0]]
212
- for p in parts[1:]:
213
- if isinstance(cur, dict) and p in cur:
214
- cur = cur[p]
215
- elif isinstance(cur, list) and p.isdigit() and int(p) < len(cur):
216
- cur = cur[int(p)]
217
- else:
218
- return (False, None)
219
- return (True, cur)
220
-
221
- def _get(self, key):
222
- found, cur = self._lookup(key)
223
- if not found or cur is None:
224
- return ""
225
- return cur if isinstance(cur, str) else json.dumps(cur)
226
-
227
- def resolve(self, val):
228
- if isinstance(val, str):
229
- return re.sub(r"\{\{\s*([^}]+?)\s*\}\}", lambda m: str(self._get(m.group(1).strip())), val)
230
- if isinstance(val, list):
231
- return [self.resolve(x) for x in val]
232
- if isinstance(val, dict):
233
- return {k: self.resolve(v) for k, v in val.items()}
234
- return val
235
-
236
- def resolve_value(self, x):
237
- """resolve(), except a value that is EXACTLY one {{ref}} returns the raw
238
- object (list/dict/bool) instead of its JSON string (run-skill parity —
239
- needed for loop over:, ask options_from, set, include with:)."""
240
- if isinstance(x, str):
241
- m = re.fullmatch(r"\{\{\s*([^}]+?)\s*\}\}", x.strip())
242
- if m:
243
- found, v = self._lookup(m.group(1).strip())
244
- return v if found else x
245
- return self.resolve(x)
246
- if isinstance(x, list):
247
- return [self.resolve_value(i) for i in x]
248
- return x
249
-
250
- def _json_get(self, obj, path):
251
- if not isinstance(obj, dict):
252
- return None
253
- if not path.startswith("$."):
254
- return obj.get(path)
255
- cur = obj
256
- for part in path[2:].split("."):
257
- cur = cur.get(part) if isinstance(cur, dict) else None
258
- return cur
259
-
260
- def _bind(self, step, data):
261
- for out in step.get("outputs", []) or []:
262
- self.vars[out["name"]] = self._json_get(data, out["from"])
263
-
264
- def _cond(self, expr):
265
- expr = self.resolve(expr).strip()
266
- for op in ("==", "!="):
267
- if op in expr:
268
- lhs, rhs = [s.strip().strip("'\"") for s in expr.split(op, 1)]
269
- return (lhs == rhs) if op == "==" else (lhs != rhs)
270
- return expr not in ("", "false", "False", "None", "null", "[]")
271
-
272
- def emit(self, msg):
273
- if not self.opts.json:
274
- print(msg)
275
-
276
- # -- strategy selection -----------------------------------------------------
277
- def _effective_strategy(self, step):
278
- """Per-step `strategy:` overrides the run default (--strategy). In mock
279
- transport the strategy is informational only — nothing executes."""
280
- s = step.get("strategy") or self.opts.strategy
281
- if self.opts.transport == "mock":
282
- return s
283
- if s == "spawn" and not self.profile:
284
- raise Stop(f"step {step.get('id')}: strategy 'spawn' needs --agent-profile")
285
- if s in ("sdk", "sdk-apply") and not self.profile:
286
- raise Stop(f"step {step.get('id')}: strategy '{s}' needs --agent-profile "
287
- "(its sdk block declares the SDK + adapter; no built-in default)")
288
- if s in ("agent", "sdk-apply") and not self.checkpoint:
289
- raise Stop(f"step {step.get('id')}: strategy '{s}' needs --checkpoint <statefile>")
290
- return s
291
-
292
- # -- checkpoint machinery (run-skill.py suspend/resume method) --------------
293
- def _key(self, step):
294
- """Stable, loop-aware invocation key for a step (for the replay cache)."""
295
- return "/".join(self.key_stack + [step.get("id", "?")])
296
-
297
- def _suspend(self, kind, step_key, system, user, tools, permission_mode,
298
- instructions=None, extra=None):
299
- """Write the fully-assembled payload to sidecar files next to the
300
- checkpoint, then raise Suspend so main() persists state and exits.
301
- The INVOKING agent fulfills the request — no new instance is spawned.
302
- The interpreter keeps 100% ownership of what goes INTO the payload;
303
- it cannot strip what the invoking agent's own conversation adds AROUND
304
- it (that is the isolation trade-off of this transport)."""
305
- base = self.checkpoint
306
- os.makedirs(os.path.dirname(base) or ".", exist_ok=True)
307
- with open(base + ".system.md", "w", encoding="utf-8") as fh:
308
- fh.write(system)
309
- with open(base + ".user.md", "w", encoding="utf-8") as fh:
310
- fh.write(user)
311
- req = {
312
- "kind": kind, "step": step_key,
313
- "tools": tools, "permission_mode": permission_mode,
314
- "system_path": base + ".system.md",
315
- "user_path": base + ".user.md",
316
- "response_path": base + ".response.md",
317
- "instructions": instructions or (
318
- "Perform this task YOURSELF, in this conversation. Read "
319
- "system_path + user_path and treat them as your ONLY standards "
320
- "and context (ignore anything else you know about this "
321
- "session). Use the listed tools within the permission mode. "
322
- "Write your full final reply — ending with the required "
323
- "```json fence — to response_path, then re-run the resume "
324
- "command. Do not edit the checkpoint state file."),
325
- "resume": self._resume_cmd(),
326
- }
327
- req.update(extra or {})
328
- raise Suspend(req)
329
-
330
- def _suspend_simple(self, request):
331
- """Suspend WITHOUT payload sidecars (kinds ask/select/hook): the
332
- launcher needs only the request itself. This is the D1 interaction
333
- channel — it works under EVERY strategy (strategies govern who
334
- GENERATES; the checkpoint carries everything human-facing)."""
335
- base = self.checkpoint
336
- os.makedirs(os.path.dirname(base) or ".", exist_ok=True)
337
- request.setdefault("response_path", base + ".response.md")
338
- request.setdefault("resume", self._resume_cmd())
339
- raise Suspend(request)
340
-
341
- def _resume_cmd(self):
342
- cmd = f"python3 {sys.argv[0]} {self.opts.workflow} --checkpoint {self.checkpoint}"
343
- return cmd + (" --json" if self.opts.json else "")
344
-
345
- def _suspend_apply(self, step_key, artifact, tools, permission_mode):
346
- """Strategy 2 (sdk-apply) handoff: the isolated SDK generation already
347
- DECIDED the changes (full intended file contents, in `artifact`); the
348
- invoking agent only applies them. Same suspend/resume channel."""
349
- base = self.checkpoint
350
- os.makedirs(os.path.dirname(base) or ".", exist_ok=True)
351
- with open(base + ".artifact.md", "w", encoding="utf-8") as fh:
352
- fh.write(artifact or "")
353
- raise Suspend({
354
- "kind": "apply", "step": step_key,
355
- "tools": tools, "permission_mode": permission_mode,
356
- "artifact_path": base + ".artifact.md",
357
- "response_path": base + ".response.md",
358
- "instructions": (
359
- "An ISOLATED generation already decided these changes — apply "
360
- "them, do not redesign them. Read artifact_path and write its "
361
- "file contents EXACTLY to the paths it names, using the listed "
362
- "tools within the permission mode. Then write your full final "
363
- 'reply — ending with a fenced block ```json {"status":"ok",'
364
- '"files":["<path you wrote>"],"summary":"<one line>"} ``` — to '
365
- "response_path, and re-run the resume command."),
366
- "resume": self._resume_cmd(),
367
- })
368
-
369
- # -- context assembly (the "Builds Complete Context" box) -----------------
370
- def _read(self, path):
371
- try:
372
- with open(path, encoding="utf-8") as fh:
373
- return fh.read()
374
- except OSError:
375
- return ""
376
-
377
- def _collect_files(self, entries, kind):
378
- """Resolve a normalized [(path, optional)] list to the files that exist,
379
- HONOURING the `optional:` flag (item H).
380
-
381
- Both assembly paths used to do `for p, _ in entries` and filter on
382
- os.path.exists alone — the flag was parsed by _norm_files and then
383
- discarded, so a missing REQUIRED standard/context file was dropped
384
- exactly as silently as an optional one. The payload simply came up
385
- short (an empty '# ENGINEERING STANDARDS & RULES' heading, a domain
386
- constitution the task text still claimed was 'in context') and every
387
- workflow went on to report OK. Required now ANNOUNCES itself.
388
-
389
- It announces rather than stops: a declared context path is often
390
- legitimately conditional (a delta doc on the bootstrap path, an
391
- overlay before a domain is selected), so a hard stop here would fail
392
- green flows. Workflows that genuinely require a file gate on it
393
- deliberately — verify_context `required_files`, or `context_missing_count`
394
- against the run-cumulative set (cf. concept-paths' `missing`).
395
-
396
- A path that resolves to "" is an unbound {{var}} on this branch, not a
397
- missing file — skipped silently, as before."""
398
- files, seen = [], set()
399
- for p, optional in entries:
400
- rp = self.resolve(p)
401
- if not rp or rp in seen:
402
- continue
403
- if os.path.exists(rp):
404
- seen.add(rp)
405
- files.append(rp)
406
- elif optional:
407
- self.emit(f"[{kind}] optional missing, skip {rp}")
408
- else:
409
- self.emit(f"[{kind}] MISSING (required): {rp}")
410
- if rp not in self.missing_required:
411
- self.missing_required.append(rp)
412
- self.vars["context_missing"] = ",".join(self.missing_required)
413
- self.vars["context_missing_count"] = str(len(self.missing_required))
414
- return files
415
-
416
- def _assemble_system(self, include_standards=True):
417
- """The interpreter-authored SYSTEM block: the sticky standards set (F4)
418
- + the envelope's per-request system files. The standards prefix is
419
- byte-stable across steps — the prompt-cache lever in a real deployment.
420
- include_standards=False is the decl `include_loaded_context: false`
421
- analog for narrow judgments (e.g. a constitution gate)."""
422
- entries = (_norm_files(self.standards) if include_standards else [])
423
- entries += _norm_files(self.envelope["system"])
424
- files = self._collect_files(entries, "system")
425
- self._track_loaded(files)
426
- parts = [
427
- "You are a fresh generation agent invoked by the AppWeaver spec "
428
- "interpreter. Follow ONLY the standards and the task provided. Do "
429
- "not assume any prior conversation.\n",
430
- "\n# ENGINEERING STANDARDS & RULES (authoritative)\n",
431
- ]
432
- for f in files:
433
- parts.append(f"\n===== RULE FILE: {f} =====\n{self._read(f)}\n")
434
- return "".join(parts), files
435
-
436
- def _assemble_user(self, task, extra=None):
437
- """The interpreter-authored USER block: prior artifacts + the task.
438
- `extra` = per-step context paths appended for THIS request only. Both
439
- lists go through _norm_files, so a step-level `context:` entry may use
440
- the { path:, optional: } form too — it used to be filtered out for not
441
- being a str, i.e. dropped even harder than a missing file."""
442
- entries = _norm_files(self.envelope["context"]) + _norm_files(extra or [])
443
- files = self._collect_files(entries, "context")
444
- self._track_loaded(files)
445
- parts = []
446
- if files:
447
- parts.append("# CONTEXT ARTIFACTS\n")
448
- for f in files:
449
- parts.append(f"\n===== FILE: {f} =====\n{self._read(f)}\n")
450
- parts.append("\n# TASK\n" + task)
451
- return "".join(parts), files
452
-
453
- def _track_loaded(self, files):
454
- for f in files:
455
- if f not in self.loaded:
456
- self.loaded.append(f)
457
-
458
- # -- the fresh-generation transport ---------------------------------------
459
- def _print_envelope(self, kind, system, sys_files, user, ctx_files, tools, strat=None):
460
- if self.opts.transport == "mock":
461
- tag = "transport=mock" + (f" strategy={strat}" if strat else "")
462
- elif strat == "agent":
463
- tag = "strategy=agent (INVOKING agent fulfills — no new instance)"
464
- elif strat == "sdk":
465
- tag = "strategy=sdk (isolated SDK instance generates + implements)"
466
- elif strat == "sdk-apply":
467
- tag = "strategy=sdk-apply (isolated SDK generates; invoking agent applies)"
468
- else:
469
- tag = (f"strategy=spawn agent={(self.profile or {}).get('name', '?')}"
470
- f" isolation={self.opts.isolation}")
471
- head = "AGENT REQUEST" if strat == "agent" else "FRESH AGENT INVOCATION"
472
- self.emit(f"\n{BANNER}\n{head} ({kind}) {tag}")
473
- self.emit(f" tools : {tools or '(none / read-only)'}")
474
- self.emit(f" system: {len(system)} chars files={sys_files}")
475
- self.emit(f" user : {len(user)} chars context={ctx_files}")
476
- if strat == "agent":
477
- self.emit(" isolation: BEST-EFFORT — the payload is interpreter-assembled, "
478
- "but it runs in the invoking agent's own conversation.")
479
- elif strat == "sdk-apply":
480
- self.emit(" isolation: generation is fully isolated; only the APPLY phase "
481
- "runs in the invoking agent.")
482
- else:
483
- self.emit(" isolation: the fresh agent sees ONLY the two blocks below — "
484
- "NOT this orchestrator's conversation.")
485
- cut = None if self.opts.show_full else 1400
486
- self.emit("\n── SYSTEM (--system-prompt) ──")
487
- self.emit(system if cut is None else system[:cut] + ("\n…[truncated; --show-full]" if len(system) > cut else ""))
488
- self.emit("\n── USER (stdin) ──")
489
- self.emit(user if cut is None else user[:cut] + ("\n…[truncated; --show-full]" if len(user) > cut else ""))
490
- self.emit(BANNER)
491
-
492
- def _run_fresh(self, system, user, tools, permission_mode, model=None):
493
- """Invoke a fresh, isolated generation using the loaded agent PROFILE.
494
- Returns (structured, raw).
495
-
496
- The engine is agent-agnostic: the profile (agent-specific, from a
497
- .claude/.agents/.kilocode file) declares the CLI to spawn. A SEPARATE
498
- process → a brand-new conversation that does NOT inherit this
499
- orchestrator's transcript. The profile maps our neutral knobs
500
- (system / user / tools / permission_mode / isolation / model / budget)
501
- onto that CLI's actual flags."""
502
- if self.opts.transport == "mock":
503
- # No model call. The banner (already printed) is the demonstration.
504
- return None, None
505
- prof = self.profile or {}
506
- argv = [prof["bin"]] + list(prof.get("args", []))
507
- model = model or self.opts.model # per-step `model:` beats --model
508
- if model and prof.get("model_flag"):
509
- argv += [prof["model_flag"], model]
510
- # system: either injected as a CLI flag, or folded into the prompt when
511
- # the CLI has no system-prompt flag (e.g. gemini).
512
- sysmode = prof.get("system", "prepend")
513
- prompt_user = user
514
- if isinstance(sysmode, dict) and sysmode.get("flag"):
515
- argv += [sysmode["flag"], system]
516
- elif sysmode == "prepend":
517
- prompt_user = system + "\n\n---\n\n" + user
518
- if tools and prof.get("tools_flag"):
519
- argv += [prof["tools_flag"], tools]
520
- approval = prof.get("approval", {})
521
- argv += approval.get(permission_mode, approval.get("default", []))
522
- argv += prof.get("isolation", {}).get(self.opts.isolation, [])
523
- if self.opts.budget and prof.get("budget_flag"):
524
- argv += [prof["budget_flag"], str(self.opts.budget)]
525
- umode = prof.get("user", {"mode": "stdin"})
526
- stdin_data = None
527
- if umode.get("mode") == "arg":
528
- argv += [umode.get("flag", "-p"), prompt_user]
529
- else:
530
- stdin_data = prompt_user
531
-
532
- if self.opts.dry_run:
533
- shown = [a if len(a) < 120 else f"<{len(a)} chars>" for a in argv]
534
- self.emit(f"[dry-run] {prof.get('name', '?')} would run:\n " + " ".join(shown))
535
- if stdin_data is not None:
536
- self.emit(f"[dry-run] + stdin: {len(stdin_data)} chars")
537
- raise Stop("dry-run: command shown above (nothing executed)")
538
-
539
- proc = subprocess.run(argv, input=stdin_data, capture_output=True, text=True)
540
- if proc.returncode != 0:
541
- raise Stop(f"fresh agent failed ({proc.returncode}): {proc.stderr.strip()[:500]}")
542
- try:
543
- obj = json.loads(proc.stdout)
544
- except Exception:
545
- return _extract_json(proc.stdout), proc.stdout # text-output fallback
546
- raw = ""
547
- for k in prof.get("result_keys", ["result"]):
548
- if isinstance(obj, dict) and obj.get(k):
549
- raw = obj[k]
550
- break
551
- if not raw:
552
- raw = proc.stdout
553
- return _extract_json(raw), raw
554
-
555
- def _ensure_sdk_module(self, module_name, package_name):
556
- """Import the profile-declared sdk module; if missing, optionally pip
557
- install the profile-declared package (--sdk-install or the sdk block's
558
- `auto_install: true`), then import again."""
559
- try:
560
- return importlib.import_module(module_name)
561
- except ImportError:
562
- allowed = (getattr(self.opts, "sdk_install", False)
563
- or bool(((self.profile or {}).get("sdk") or {}).get("auto_install")))
564
- if not allowed:
565
- raise Stop(f"strategy 'sdk'/'sdk-apply' needs the '{package_name}' "
566
- f"package (module '{module_name}', declared in the agent "
567
- f"profile's sdk block): pip install {package_name}, or "
568
- "re-run with --sdk-install to install it automatically")
569
- self.emit(f"[sdk] module '{module_name}' not importable — installing "
570
- f"'{package_name}' (declared in the agent profile) …")
571
- cmd = [sys.executable, "-m", "pip", "install", package_name]
572
- proc = subprocess.run(cmd, capture_output=True, text=True)
573
- if proc.returncode != 0 and "externally-managed-environment" in (proc.stderr or ""):
574
- # PEP 668 python (e.g. Homebrew): fall back to a user-site install
575
- self.emit("[sdk] environment is externally managed — retrying with "
576
- "--user --break-system-packages")
577
- proc = subprocess.run(cmd + ["--user", "--break-system-packages"],
578
- capture_output=True, text=True)
579
- if proc.returncode != 0:
580
- raise Stop(f"pip install {package_name} failed: {proc.stderr.strip()[:400]}")
581
- importlib.invalidate_caches()
582
- try:
583
- return importlib.import_module(module_name)
584
- except ImportError as e:
585
- raise Stop(f"installed '{package_name}' but module '{module_name}' "
586
- f"still won't import: {e}")
587
-
588
- def _load_sdk_adapter(self, adapter_rel):
589
- """Load the profile-declared adapter file (the Python module that owns
590
- that SDK's calling convention; shared per-SDK adapters live in
591
- .aw-spec/orchestrator/adapters/) and return its generate(request)
592
- callable. Paths are relative to the profile file."""
593
- base = getattr(self.opts, "profile_dir", "") or "."
594
- path = adapter_rel if os.path.isabs(adapter_rel) else os.path.join(base, adapter_rel)
595
- if not os.path.exists(path):
596
- raise Stop(f"sdk adapter not found: {path} (sdk.adapter in the agent "
597
- "profile) — point it at an existing adapter (shared ones "
598
- "live in .aw-spec/orchestrator/adapters/)")
599
- try:
600
- spec = importlib.util.spec_from_file_location("aw_sdk_adapter", path)
601
- mod = importlib.util.module_from_spec(spec)
602
- spec.loader.exec_module(mod)
603
- except Exception as e:
604
- raise Stop(f"sdk adapter {path} failed to load: {e}")
605
- fn = getattr(mod, "generate", None)
606
- if not callable(fn):
607
- raise Stop(f"sdk adapter {path} must export generate(request)")
608
- return fn
609
-
610
- def _run_sdk(self, system, user, tools, permission_mode, model=None):
611
- """Strategy 1/2 executor: an isolated generation via the agent's SDK.
612
- Each call is a NEW session — the only context is the payload we pass
613
- (system + user). Returns (structured, raw).
614
-
615
- Agent-agnostic, like `spawn`: the engine has NO built-in SDK and no
616
- default agent — EVERYTHING SDK-specific comes from the loaded
617
- profile's `sdk` block (agent.json):
618
- module / package what to import; installed on demand when missing
619
- (--sdk-install or sdk.auto_install)
620
- adapter REQUIRED — a Python file (path relative to the
621
- profile; shared per-SDK adapters live in
622
- .aw-spec/orchestrator/adapters/) exporting
623
- generate(request) -> reply text; it owns that
624
- SDK's calling convention (sync or async)
625
- Everything is imported lazily so the engine stays stdlib-only
626
- otherwise."""
627
- if not self.profile:
628
- raise Stop("strategy 'sdk'/'sdk-apply' needs --agent-profile — the "
629
- "profile's sdk block declares the SDK to use (the engine "
630
- "has no default agent)")
631
- sdk_cfg = self.profile.get("sdk") or {}
632
- module_name = sdk_cfg.get("module")
633
- package_name = sdk_cfg.get("package")
634
- adapter_rel = sdk_cfg.get("adapter")
635
- missing = [k for k, v in (("module", module_name), ("package", package_name),
636
- ("adapter", adapter_rel)) if not v]
637
- if missing:
638
- raise Stop(f"agent profile '{self.profile.get('name', '?')}': sdk block "
639
- f"is missing {', '.join(missing)} — strategy 'sdk'/'sdk-apply' "
640
- "is fully profile-driven (no built-in default); declare the "
641
- "sdk module/package to import and the adapter file that "
642
- "drives it")
643
-
644
- if self.opts.dry_run:
645
- self.emit(f"[dry-run] sdk would run: module={module_name} "
646
- f"package={package_name} adapter={adapter_rel} "
647
- f"system={len(system)} chars user={len(user)} chars "
648
- f"tools={tools or '(none)'} permission_mode={permission_mode}")
649
- raise Stop("dry-run: sdk request shown above (nothing executed)")
650
-
651
- self._ensure_sdk_module(module_name, package_name)
652
- generate = self._load_sdk_adapter(adapter_rel)
653
- import asyncio
654
- request = {"system": system, "user": user, "tools": tools,
655
- "permission_mode": permission_mode,
656
- "model": model or self.opts.model or None, "sdk": sdk_cfg}
657
- try:
658
- out = generate(request)
659
- raw = asyncio.run(out) if asyncio.iscoroutine(out) else out
660
- except Stop:
661
- raise
662
- except Exception as e:
663
- raise Stop(f"sdk adapter '{adapter_rel}' generation failed: {e}")
664
- if not isinstance(raw, str):
665
- raise Stop(f"sdk adapter '{adapter_rel}' generate(request) must return "
666
- "the reply text (str)")
667
- return _extract_json(raw), raw
668
-
669
- # -- actions --------------------------------------------------------------
670
- def act_set(self, step):
671
- assigns = step.get("vars") or step.get("parameters") or {}
672
- for k, v in assigns.items():
673
- self.vars[k] = self.resolve_value(v)
674
- self.emit(f"[set] {list(assigns.keys())}")
675
-
676
- def act_build_context(self, step):
677
- ctx = list(step.get("context", []) or [])
678
- # dynamic context: append a var holding a list of paths — e.g. the files a
679
- # prior agent_generate reported it wrote (since IT, not we, chose them).
680
- cf = step.get("context_from")
681
- if cf:
682
- ctx += list(self.vars.get(cf) or [])
683
- self.envelope = {"system": step.get("system", []) or [], "context": ctx}
684
- for k, v in (step.get("vars", {}) or {}).items():
685
- self.vars[k] = self.resolve(v)
686
- sysn = len(_norm_files(self.envelope["system"]))
687
- self.emit(f"[build_context] system={sysn} file(s) context={len(ctx)} file(s)")
688
-
689
- def act_agent_generate(self, step):
690
- tools = step.get("tools", "Read,Write,Edit,Bash")
691
- permission_mode = step.get("permission_mode", "acceptEdits")
692
- task = self.resolve(step.get("task", ""))
693
- strat = self._effective_strategy(step)
694
- # checkpoint replay: this step was already fulfilled (any strategy) —
695
- # bind its cached structured result instead of executing again.
696
- ck = self._key(step)
697
- if self.checkpoint and ck in self.cache:
698
- structured = self.cache[ck]
699
- self.last_result = structured
700
- self._bind(step, structured)
701
- self.emit(f"[agent_generate] (cached) result={json.dumps(structured)[:200]}")
702
- return
703
- # The interpreter does NOT dictate output paths. The generating runtime +
704
- # LLM decide where files go (per the loaded folder-structure/architecture
705
- # standards) and report back the paths.
706
- if strat == "sdk-apply" and self.opts.transport != "mock":
707
- # Strategy 2: generation is read-only — it DECIDES paths + content;
708
- # the invoking agent applies them afterwards (via suspend "apply").
709
- instr = task + (
710
- "\n\nDecide the correct file path(s) yourself by following the "
711
- "loaded standards (folder-structure / architecture rules) and "
712
- "the project's existing layout, but DO NOT write any files — "
713
- "you have no write tools. Output the COMPLETE intended content "
714
- "of every file in your reply: one fenced code block per file, "
715
- "each immediately preceded by its path, so another process can "
716
- "apply it verbatim."
717
- '\n\nEnd your reply with a fenced block:\n```json\n{"status":"ok",'
718
- '"planned_files":["<path>"],"summary":"<one line>"}\n```')
719
- else:
720
- instr = task + (
721
- "\n\nDecide the correct file path(s) yourself by following the loaded "
722
- "standards (folder-structure / architecture rules) and the project's "
723
- "existing layout. Create or edit them with your Write/Edit tools."
724
- '\n\nWhen done, end your reply with a fenced block listing the paths YOU '
725
- 'chose to write:\n```json\n{"status":"ok","files":["<path you wrote>"],'
726
- '"summary":"<one line>"}\n```')
727
- system, sys_files = self._assemble_system()
728
- user, ctx_files = self._assemble_user(instr)
729
- self._print_envelope("agent_generate", system, sys_files, user, ctx_files, tools, strat)
730
-
731
- if self.opts.transport == "mock":
732
- structured = self._mock_generate(task)
733
- elif strat == "agent":
734
- self._suspend("agent_generate", ck, system, user, tools, permission_mode)
735
- elif strat == "sdk":
736
- structured, _ = self._run_sdk(system, user, tools, permission_mode,
737
- model=step.get("model"))
738
- elif strat == "sdk-apply":
739
- _, raw = self._run_sdk(system, user, "Read", "readonly",
740
- model=step.get("model"))
741
- self._suspend_apply(ck, raw, tools, permission_mode)
742
- else: # spawn
743
- structured, _ = self._run_fresh(system, user, tools, permission_mode,
744
- model=step.get("model"))
745
- self.last_result = structured
746
- if self.checkpoint:
747
- self.cache[ck] = structured
748
- self._bind(step, structured)
749
- self.emit(f"[agent_generate] strategy={strat or 'mock'} "
750
- f"result={json.dumps(structured)[:200]}")
751
-
752
- def act_agent_review(self, step):
753
- # Review the files the generation actually produced. Prefer a var holding
754
- # the reported paths (review_of_var) over any statically-listed review_of.
755
- if step.get("review_of_var"):
756
- review_of = list(self.vars.get(step["review_of_var"]) or [])
757
- else:
758
- review_of = [self.resolve(r) for r in (step.get("review_of", []) or [])]
759
- task = self.resolve(step.get("task", ""))
760
- instr = task + "\n\nFiles under review:\n" + "\n".join(f"- {r}" for r in review_of)
761
- instr += ('\n\nEnd your reply with a fenced block:\n'
762
- '```json\n{"pass":true,"issues":[]}\n```')
763
- # review is read-only: put the files to review INTO context, tools=Read only
764
- # (mutate BEFORE the checkpoint-cache branch so a replayed run rebuilds
765
- # the exact same envelope state for the steps that follow)
766
- self.envelope["context"] = list(self.envelope["context"]) + review_of
767
- strat = self._effective_strategy(step)
768
- ck = self._key(step)
769
- if self.checkpoint and ck in self.cache:
770
- structured = self.cache[ck]
771
- self.last_result = structured
772
- self._bind(step, structured)
773
- self.emit(f"[agent_review] (cached) result={json.dumps(structured)[:200]}")
774
- return
775
- system, sys_files = self._assemble_system()
776
- user, ctx_files = self._assemble_user(instr)
777
- self._print_envelope("agent_review", system, sys_files, user, ctx_files, "Read", strat)
778
-
779
- if self.opts.transport == "mock":
780
- # mock: pass if every reviewed file exists
781
- ok = all(os.path.exists(r) for r in review_of)
782
- structured = {"pass": ok, "issues": ([] if ok else ["missing output file(s)"])}
783
- elif strat == "agent":
784
- self._suspend("agent_review", ck, system, user, "Read", "readonly")
785
- elif strat in ("sdk", "sdk-apply"):
786
- # review has no apply phase — sdk-apply degrades to a plain sdk read
787
- structured, _ = self._run_sdk(system, user, "Read", "readonly",
788
- model=step.get("model"))
789
- else: # spawn
790
- structured, _ = self._run_fresh(system, user, "Read", "acceptEdits",
791
- model=step.get("model"))
792
- self.last_result = structured
793
- if self.checkpoint:
794
- self.cache[ck] = structured
795
- self._bind(step, structured)
796
- self.emit(f"[agent_review] strategy={strat or 'mock'} "
797
- f"result={json.dumps(structured)[:200]}")
798
-
799
- def _mock_generate(self, task):
800
- # No output path was specified — the mock stands in for the runtime/LLM
801
- # DECIDING one. Here it lifts a name from the first `backtick` token in the
802
- # task and places it under the out_dir workspace hint. A real fresh agent
803
- # would instead derive the path from the loaded folder-structure standard.
804
- first = task.strip().splitlines()[0] if task.strip() else ""
805
- m = re.search(r"`([A-Za-z_][A-Za-z0-9_]*)`", task)
806
- name = m.group(1) if m else "GeneratedComponent"
807
- out_dir = self.vars.get("out_dir") or ".aw-spec/orchestrator/.demo-out"
808
- path = os.path.join(out_dir, name + ".cs")
809
- os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
810
- with open(path, "w", encoding="utf-8") as fh:
811
- fh.write(
812
- "// [mock fresh-agent output]\n"
813
- "// The PATH was decided by the fresh runtime (mock stand-in), NOT\n"
814
- "// specified by the interpreter.\n"
815
- f"// saw only: system={[self.resolve(p) for p,_ in _norm_files(self.envelope['system'])]}\n"
816
- f"// context={[self.resolve(p) for p,_ in _norm_files(self.envelope['context'])]}\n"
817
- f"// task: {first}\n")
818
- return {"status": "ok", "files": [path], "summary": f"mock implemented: {first}"}
819
-
820
- # -- F1: deterministic scripts ---------------------------------------------
821
- def _script_path(self, ref):
822
- variants = self.scripts.get(ref) or {}
823
- return variants.get("ps1" if self.opts.os == "windows" else "sh")
824
-
825
- def act_execute_script(self, step):
826
- """F1 — run a shared deterministic script (decl `execute_script` port).
827
- Top-level `scripts:` declares {ref: {sh:…, ps1:…}}; --os picks the
828
- variant. stdout's LAST line must be JSON, bound via outputs[]. Results
829
- enter the replay cache, so scripts never re-fire across resumes.
830
- Scripts run for real even under the mock transport (they are the
831
- deterministic, model-free part of every skill)."""
832
- p = step.get("parameters") or step
833
- ref = (p.get("script") or {}).get("ref")
834
- script = self._script_path(ref)
835
- if not script or not os.path.exists(script):
836
- raise Stop(f"step {step.get('id')}: script not found for ref '{ref}': {script}")
837
- sub = p.get("subcommand")
838
- args = [self.resolve(a) for a in (p.get("args") or [])]
839
- ck = self._key(step)
840
- if self.checkpoint and ck in self.cache:
841
- self._bind(step, self.cache[ck])
842
- self.emit(f"[execute_script] {sub} (cached)")
843
- return
844
- runner = (["pwsh", "-NoProfile", "-File", script]
845
- if self.opts.os == "windows" else ["bash", script])
846
- self.emit(f"[execute_script] {sub} {' '.join(args)}")
847
- if self.opts.dry_run:
848
- self._bind(step, {})
849
- return
850
- proc = subprocess.run(runner + [sub] + args, capture_output=True, text=True)
851
- if proc.returncode != 0:
852
- if p.get("on_error") == "continue":
853
- self.emit(f"[execute_script] {sub} failed (continuing): "
854
- f"{proc.stderr.strip()[:200]}")
855
- self._bind(step, {})
856
- return
857
- raise Stop(f"script failed ({sub}): {proc.stderr.strip()[:500]}")
858
- try:
859
- data = json.loads(proc.stdout.strip().splitlines()[-1])
860
- except Exception as e:
861
- raise Stop(f"script did not return JSON ({sub}): {proc.stdout!r} ({e})")
862
- self._bind(step, data)
863
- if self.checkpoint:
864
- self.cache[ck] = data
865
- self.emit(f"[execute_script] -> {json.dumps(data)[:200]}")
866
-
867
- # -- F2: gates ---------------------------------------------------------------
868
- def act_verify_context(self, step):
869
- """F2 — the four decl gate forms: required_files (+allow_missing),
870
- require_non_empty (+on_empty), require_true (+on_false),
871
- require_value/equals (+on_mismatch). Messages interpolate {{vars}}."""
872
- p = step.get("parameters") or step
873
- if p.get("require_non_empty"):
874
- var = p["require_non_empty"]
875
- val = self.vars.get(var)
876
- if not val:
877
- msg = self.resolve(p.get("on_empty_message", f"{var} is empty"))
878
- self.emit(f"[verify_context] {var} is empty: {msg}")
879
- if p.get("on_empty") == "stop":
880
- raise Stop(msg)
881
- else:
882
- self.emit(f"[verify_context] {var} non-empty")
883
- if p.get("require_true"):
884
- var = p["require_true"]
885
- v = self.vars.get(var)
886
- truthy = (v.strip() not in ("", "false", "False", "None", "null", "[]")
887
- if isinstance(v, str) else bool(v))
888
- if not truthy:
889
- msg = self.resolve(p.get("on_false_message", f"{var} is not satisfied"))
890
- self.emit(f"[verify_context] gate failed ({var}): {msg}")
891
- if p.get("on_false", "stop") == "stop":
892
- raise Stop(msg)
893
- else:
894
- self.emit(f"[verify_context] gate ok ({var})")
895
- if p.get("require_value"):
896
- var = p["require_value"]
897
- actual = str(self.vars.get(var, ""))
898
- expected = str(self.resolve(p.get("equals", "")))
899
- if actual != expected:
900
- msg = self.resolve(p.get("on_mismatch_message", f"{var} != {expected}"))
901
- self.emit(f"[verify_context] value gate failed ({var}={actual!r}): {msg}")
902
- if p.get("on_mismatch", "stop") == "stop":
903
- raise Stop(msg)
904
- else:
905
- self.emit(f"[verify_context] value gate ok ({var}=={expected})")
906
- for rf in p.get("required_files", []) or []:
907
- rf = self.resolve(rf)
908
- if rf in self.loaded or os.path.exists(rf):
909
- self.emit(f"[verify_context] present {rf}")
910
- elif p.get("allow_missing"):
911
- self.emit(f"[verify_context] missing (allowed) {rf}")
912
- else:
913
- raise Stop(f"required file not in context: {rf}")
914
-
915
- # -- F4: sticky standards ------------------------------------------------------
916
- def act_load_standards(self, step):
917
- """F4 — append files to the sticky standards set (dedup; `optional:`
918
- tolerated). _assemble_system prepends the set to EVERY payload, so a
919
- skill loads its standards once (decl load_context → system per D3)."""
920
- p = step.get("parameters") or step
921
- files = p.get("files") or []
922
- if p.get("files_from_variable"):
923
- files = [{"path": x} for x in (self.vars.get(p["files_from_variable"]) or [])]
924
- step_optional = p.get("optional", False)
925
- have = {self.resolve(pp) for pp, _ in _norm_files(self.standards)}
926
- for f in files:
927
- path = self.resolve(f["path"] if isinstance(f, dict) else f)
928
- optional = f.get("optional", step_optional) if isinstance(f, dict) else step_optional
929
- if path in have:
930
- self.emit(f"[load_standards] dedup skip {path}")
931
- continue
932
- if os.path.exists(path):
933
- self.standards.append({"path": path})
934
- have.add(path)
935
- self.emit(f"[load_standards] loaded {path}")
936
- elif optional:
937
- self.emit(f"[load_standards] optional missing, skip {path}")
938
- else:
939
- raise Stop(f"required standards file missing: {path}")
940
-
941
- # -- F5: authored artifacts ------------------------------------------------
942
- def _write_target(self, target, content):
943
- os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
944
- with open(target, "w", encoding="utf-8") as fh:
945
- fh.write(content)
946
-
947
- def _mock_author(self, target, task):
948
- sysf = [self.resolve(p) for p, _ in _norm_files(self.envelope["system"])]
949
- ctxf = [self.resolve(p) for p, _ in _norm_files(self.envelope["context"])]
950
- return ("<!-- authored by aw-orchestrator MOCK transport -->\n"
951
- f"<!-- target: {target} -->\n"
952
- f"<!-- envelope: system={sysf} context={ctxf} -->\n"
953
- f"<!-- task chars: {len(task)} -->\n\n# (mock content)\n")
954
-
955
- def act_agent_author(self, step):
956
- """F5 — authored artifact (decl `llm_generate` port): the INTERPRETER
957
- owns output_target.path (scaffold scripts created it). Generation is
958
- READ-ONLY under EVERY strategy — the returned content is written by
959
- the ENGINE, so sdk-apply needs no apply phase and degrades to sdk.
960
- The replay cache stores the raw content (idempotent rewrites)."""
961
- p = step.get("parameters") or step
962
- target = self.resolve((p.get("output_target") or {}).get("path", ""))
963
- if not target:
964
- raise Stop(f"step {step.get('id')}: agent_author needs output_target.path")
965
- task = self.resolve(p.get("task") or p.get("prompt") or "")
966
- extra = [c for c in (self.resolve_value(p.get("context")) or []) if isinstance(c, (str, dict))]
967
- strat = self._effective_strategy(step)
968
- ck = self._key(step)
969
- if self.checkpoint and ck in self.cache:
970
- self._write_target(target, self.cache[ck])
971
- structured = {"status": "ok", "files": [target], "summary": f"authored {target}"}
972
- self.last_result = structured
973
- self._bind(step, structured)
974
- self.emit(f"[agent_author] (cached) rewrote {target}")
975
- return
976
- instr = task + (
977
- f"\n\nAuthor the COMPLETE content of the file `{target}`. You have "
978
- "no write tools — return the content itself. Reply with ONLY the "
979
- "full file content: no commentary, no surrounding code fence.")
980
- system, sys_files = self._assemble_system(p.get("include_standards", True))
981
- user, ctx_files = self._assemble_user(instr, extra=extra)
982
- self._print_envelope("agent_author", system, sys_files, user, ctx_files, "Read", strat)
983
- if self.opts.transport == "mock":
984
- content = self._mock_author(target, task)
985
- elif strat == "agent":
986
- self._suspend("author", ck, system, user, "Read", "readonly",
987
- instructions=(
988
- "Author this artifact YOURSELF, in this conversation. "
989
- "Read system_path + user_path as your ONLY standards "
990
- "and context. Write the COMPLETE file content — the "
991
- "content ONLY, no commentary, no code fence — to "
992
- "response_path, then re-run the resume command. Do "
993
- "NOT write the target file yourself; the interpreter "
994
- "does that."),
995
- extra={"target": target})
996
- else:
997
- run = self._run_sdk if strat in ("sdk", "sdk-apply") else self._run_fresh
998
- _, raw = run(system, user, "Read", "readonly", model=step.get("model"))
999
- content = _strip_fence(raw)
1000
- if not content.strip():
1001
- raise Stop(f"step {step.get('id')}: agent_author returned empty content")
1002
- if self.opts.dry_run:
1003
- self.emit(f"[agent_author] dry-run: not writing {target}")
1004
- return
1005
- self._write_target(target, content)
1006
- structured = {"status": "ok", "files": [target], "summary": f"authored {target}"}
1007
- self.last_result = structured
1008
- if self.checkpoint:
1009
- self.cache[ck] = content
1010
- self._bind(step, structured)
1011
- self.emit(f"[agent_author] strategy={strat or 'mock'} wrote {target} "
1012
- f"({len(content)} chars)")
1013
-
1014
- # -- F6: structured queries --------------------------------------------------
1015
- def _structured_fresh(self, step, strat, system, user):
1016
- """spawn/sdk executor for JSON-returning steps, with ONE malformed-JSON
1017
- retry (agent strategy instead re-suspends with a note on resume)."""
1018
- run = self._run_sdk if strat in ("sdk", "sdk-apply") else self._run_fresh
1019
- structured, _ = run(system, user, "Read", "readonly", model=step.get("model"))
1020
- if not structured:
1021
- self.emit(f"[retry] {step.get('id')}: no parseable ```json fence — asking once more")
1022
- user2 = user + ("\n\nYour previous reply could not be parsed. Return "
1023
- "ONLY the required ```json fenced block, nothing else.")
1024
- structured, _ = run(system, user2, "Read", "readonly", model=step.get("model"))
1025
- if not structured:
1026
- raise Stop(f"step {step.get('id')}: fresh agent returned no parseable JSON")
1027
- return structured
1028
-
1029
- def act_agent_query(self, step):
1030
- """F6 — generic READ-ONLY structured query (decl `llm_query` port): the
1031
- caller states the JSON shape (schema:), the reply's trailing ```json
1032
- fence is bound via outputs[]. agent_review stays the {pass,issues}
1033
- specialization of this. `mock:` supplies the mock-transport result."""
1034
- p = step.get("parameters") or step
1035
- task = self.resolve(p.get("task") or p.get("prompt") or "")
1036
- extra = [c for c in (self.resolve_value(p.get("context")) or []) if isinstance(c, (str, dict))]
1037
- strat = self._effective_strategy(step)
1038
- ck = self._key(step)
1039
- if self.checkpoint and ck in self.cache:
1040
- structured = self.cache[ck]
1041
- self.last_result = structured
1042
- self._bind(step, structured)
1043
- self.emit(f"[agent_query] (cached) result={json.dumps(structured)[:200]}")
1044
- return
1045
- schema = p.get("schema")
1046
- hint = json.dumps(schema) if isinstance(schema, (dict, list)) else (schema or "{ … }")
1047
- instr = task + ("\n\nDo not modify any files. End your reply with a "
1048
- "fenced block containing ONLY the JSON result, shaped "
1049
- "like:\n```json\n" + hint + "\n```")
1050
- system, sys_files = self._assemble_system(p.get("include_standards", True))
1051
- user, ctx_files = self._assemble_user(instr, extra=extra)
1052
- self._print_envelope("agent_query", system, sys_files, user, ctx_files, "Read", strat)
1053
- if self.opts.transport == "mock":
1054
- structured = self.resolve_value(p.get("mock")) or {}
1055
- elif strat == "agent":
1056
- self._suspend("agent_query", ck, system, user, "Read", "readonly")
1057
- else:
1058
- structured = self._structured_fresh(step, strat, system, user)
1059
- self.last_result = structured
1060
- if self.checkpoint:
1061
- self.cache[ck] = structured
1062
- self._bind(step, structured)
1063
- self.emit(f"[agent_query] strategy={strat or 'mock'} "
1064
- f"result={json.dumps(structured)[:200]}")
1065
-
1066
- # -- F7: HITL (D1 — the checkpoint is the interaction channel under EVERY
1067
- # strategy; --answer/--select/--non-interactive prefill for headless runs)
1068
- def _prompt_choice(self, prompt, options):
1069
- print(prompt)
1070
- for i, o in enumerate(options, 1):
1071
- print(f" {i}) {o}")
1072
- try:
1073
- raw = input("> ").strip()
1074
- except EOFError:
1075
- return None
1076
- if raw.isdigit() and 1 <= int(raw) <= len(options):
1077
- return options[int(raw) - 1]
1078
- return raw if raw in options else None
1079
-
1080
- def act_ask(self, step):
1081
- """F7 — single/multi-select question. Answer sources, in order: replay
1082
- cache → --answer key=value → --non-interactive default (first option;
1083
- all for multi) → checkpoint suspend kind 'ask' (the launcher asks the
1084
- USER) → interactive stdin. Binds answer_var (default 'answer')."""
1085
- p = step.get("parameters") or step
1086
- prompt = self.resolve(p.get("prompt", "?"))
1087
- if p.get("options_from"):
1088
- options = self.resolve_value(p["options_from"]) or []
1089
- elif p.get("options_from_variable"):
1090
- options = self.vars.get(p["options_from_variable"]) or []
1091
- else:
1092
- options = [self.resolve(o) for o in (p.get("options") or [])]
1093
- multi = bool(p.get("multi"))
1094
- key = self.resolve(p["answer_key"]) if p.get("answer_key") else step.get("id", "")
1095
- var = p.get("answer_var", "answer")
1096
- ck = self._key(step)
1097
- if self.checkpoint and ck in self.cache:
1098
- self.vars[var] = self.cache[ck]
1099
- self.emit(f"[ask] {key} -> {self.cache[ck]} (cached)")
1100
- return
1101
- raw = self.opts.answers.get(key)
1102
- if raw is None and self.opts.non_interactive:
1103
- raw = ",".join(str(o) for o in options) if multi else \
1104
- (options[0] if options else "")
1105
- if raw is None and self.checkpoint:
1106
- self._suspend_simple({
1107
- "kind": "ask", "key": key, "step": ck,
1108
- "prompt": prompt, "options": options, "multi": multi,
1109
- "instructions": (
1110
- "Ask the USER this question (the options are the allowed "
1111
- "choices" + ("; several may be picked" if multi else "") +
1112
- "). Write the answer as PLAIN TEXT" +
1113
- (" (comma-separated)" if multi else "") +
1114
- " to response_path, then re-run the resume command.")})
1115
- if raw is None:
1116
- raw = self._prompt_choice(prompt, options) if options else \
1117
- input(prompt + " ").strip()
1118
- if raw is None:
1119
- raise Stop("question cancelled")
1120
- answer = [s.strip() for s in str(raw).split(",") if s.strip()] if multi else raw
1121
- self.vars[var] = answer
1122
- if self.checkpoint:
1123
- self.cache[ck] = answer
1124
- self.emit(f"[ask] {key} -> {answer}")
1125
-
1126
- def act_user_select(self, step):
1127
- """F7 — pick ONE option from a variable. Order: replay cache →
1128
- preselect_from (direct-argument fast path) → --select stepid=value →
1129
- single_option_confirm → --non-interactive (first) → checkpoint suspend
1130
- kind 'select' → interactive stdin. Binds `selection` + outputs[]."""
1131
- p = step.get("parameters") or step
1132
- options = self.vars.get(p.get("options_from_variable")) or []
1133
- pre = p.get("preselect_from")
1134
- pre_val = self.vars.get(pre) if pre else None
1135
- ck = self._key(step)
1136
- if self.checkpoint and ck in self.cache:
1137
- chosen = self.cache[ck]
1138
- elif pre_val and pre_val in options:
1139
- chosen = pre_val
1140
- elif self.opts.selects.get(step.get("id")):
1141
- chosen = self.opts.selects[step["id"]]
1142
- elif len(options) == 1 and p.get("single_option_confirm"):
1143
- chosen = options[0]
1144
- elif self.opts.non_interactive:
1145
- chosen = options[0] if options else None
1146
- elif self.checkpoint:
1147
- self._suspend_simple({
1148
- "kind": "select", "step": ck,
1149
- "prompt": self.resolve(p.get("prompt", "Select:")),
1150
- "options": options,
1151
- "instructions": (
1152
- "Ask the USER to pick ONE of the options. Write the chosen "
1153
- "value as plain text to response_path, then re-run the "
1154
- "resume command.")})
1155
- else:
1156
- chosen = self._prompt_choice(self.resolve(p.get("prompt", "Select:")), options)
1157
- if not chosen:
1158
- raise Stop("selection cancelled")
1159
- self.vars["selection"] = chosen
1160
- for out in step.get("outputs", []) or []:
1161
- src = out["from"]
1162
- self.vars[out["name"]] = chosen if src == "selection" else self.vars.get(src)
1163
- if self.checkpoint:
1164
- self.cache[ck] = chosen
1165
- self.emit(f"[user_select] chosen: {chosen}")
1166
-
1167
- # -- F8: hooks ----------------------------------------------------------------
1168
- def act_dispatch_hooks(self, step):
1169
- """F8 — hand the resolved hooks (slash commands of the INVOKING agent)
1170
- to the launcher via suspend kind 'hook' (D4 phase 1). Without a
1171
- checkpoint (mock/CI) the intent is only logged, like run-skill. Cached
1172
- so hooks never re-fire across resumes."""
1173
- p = step.get("parameters") or step
1174
- ck = self._key(step)
1175
- if self.checkpoint and ck in self.cache:
1176
- self.emit("[dispatch_hooks] (cached)")
1177
- return
1178
- hooks = self.vars.get(p.get("hooks_from_variable")) or []
1179
- if not hooks:
1180
- self.emit("[dispatch_hooks] none")
1181
- if self.checkpoint:
1182
- self.cache[ck] = True
1183
- return
1184
- for h in hooks:
1185
- kind = "optional" if h.get("optional") else "mandatory"
1186
- self.emit(f"[dispatch_hooks] {kind}: {h.get('slash')} ({h.get('extension')})")
1187
- if self.checkpoint and self.opts.transport != "mock":
1188
- self._suspend_simple({
1189
- "kind": "hook", "step": ck, "hooks": hooks,
1190
- "instructions": (
1191
- "Run each MANDATORY hook (a slash command of YOUR agent) in "
1192
- "this conversation; optional hooks are at your discretion. "
1193
- "Then write 'done' to response_path and re-run the resume "
1194
- "command.")})
1195
- if self.checkpoint:
1196
- self.cache[ck] = True
1197
-
1198
- # -- F9: modules --------------------------------------------------------------
1199
- def act_include(self, step):
1200
- """F9 — run a module .orch.yaml inline: shares vars, envelope, sticky
1201
- standards, the caller's scripts map and the replay cache. `with:` seeds
1202
- module inputs; cache keys are namespaced under the include id. Stop
1203
- propagates to the caller (module gates stop the whole skill)."""
1204
- module_path = self.resolve(step.get("module", ""))
1205
- for k, v in (step.get("with", {}) or {}).items():
1206
- self.vars[k] = self.resolve_value(v)
1207
- try:
1208
- with open(module_path, encoding="utf-8") as fh:
1209
- mod = load_yaml(fh.read())
1210
- except OSError as e:
1211
- raise Stop(f"include: module not found: {module_path} ({e})")
1212
- # module-declared scripts extend the caller's map (caller wins on clash)
1213
- for ref, variants in (mod.get("scripts", {}) or {}).items():
1214
- self.scripts.setdefault(ref, variants)
1215
- self.standards += list(mod.get("standards", []) or [])
1216
- self.emit(f"[include] {module_path}")
1217
- self.key_stack.append("inc:" + step.get("id", "include"))
1218
- try:
1219
- self._run_steps(mod.get("workflow", []) or [])
1220
- finally:
1221
- self.key_stack.pop()
1222
-
1223
- # -- runner ---------------------------------------------------------------
1224
- def _run_steps(self, steps):
1225
- for step in steps:
1226
- cond = step.get("condition")
1227
- if cond is not None and not self._cond(cond):
1228
- self.emit(f"[skip] {step.get('id')} (condition false)")
1229
- continue
1230
- action = step.get("action")
1231
- if action == "break":
1232
- return "break"
1233
- if action == "loop":
1234
- self._run_loop(step)
1235
- continue
1236
- fn = getattr(self, f"act_{action}", None)
1237
- if fn is None:
1238
- raise Stop(f"unknown action: {action}")
1239
- fn(step)
1240
- return None
1241
-
1242
- def _run_loop(self, step):
1243
- """foreach (`over:` a list var, binding `as:`/`<as>_index`) or while
1244
- (`while:` + `max_iterations`). F3: the foreach items come through
1245
- resolve_value, so `over: "{{list}}"` yields the RAW list."""
1246
- substeps = step.get("steps", []) or []
1247
- loop_id = step.get("id", "loop")
1248
- as_name = step.get("as", "item")
1249
- max_it = step.get("max_iterations")
1250
- if "over" in step:
1251
- items = self.resolve_value(step["over"]) or []
1252
- if isinstance(items, str):
1253
- try:
1254
- items = json.loads(items)
1255
- except Exception:
1256
- pass
1257
- if not isinstance(items, list):
1258
- raise Stop(f"loop '{loop_id}': 'over' is not a list: {step.get('over')}")
1259
- for idx, item in enumerate(items):
1260
- if max_it is not None and idx >= max_it:
1261
- break
1262
- self.vars[as_name] = item
1263
- self.vars[as_name + "_index"] = idx
1264
- self.vars["iteration"] = idx
1265
- self.emit(f"[loop] {loop_id} item {idx}")
1266
- # iteration-scoped key prefix: each pass gets its own cache entries
1267
- self.key_stack.append(f"{loop_id}#{idx}")
1268
- try:
1269
- broke = self._run_steps(substeps) == "break"
1270
- finally:
1271
- self.key_stack.pop()
1272
- if broke:
1273
- break
1274
- return
1275
- max_it = 5 if max_it is None else max_it
1276
- i = 0
1277
- while self._cond(step.get("while", "true")):
1278
- if i >= max_it:
1279
- self.emit(f"[loop] {loop_id} hit max_iterations={max_it}")
1280
- break
1281
- self.vars["iteration"] = i
1282
- self.vars[as_name + "_index"] = i
1283
- self.emit(f"[loop] {loop_id} iteration {i}")
1284
- # iteration-scoped key prefix: each pass gets its own cache entries
1285
- self.key_stack.append(f"{loop_id}#{i}")
1286
- try:
1287
- broke = self._run_steps(substeps) == "break"
1288
- finally:
1289
- self.key_stack.pop()
1290
- if broke:
1291
- break
1292
- i += 1
1293
-
1294
- def _bind_inputs(self):
1295
- """F10 — bind the workflow's declared `inputs:` to --input (decl
1296
- USER_INPUT shape). The bound value is persisted in the checkpoint
1297
- state, so resumes keep it without re-passing --input."""
1298
- for name, spec in (self.wf.get("inputs") or {}).items():
1299
- if not isinstance(spec, dict) or not spec.get("bind"):
1300
- continue
1301
- bind = spec["bind"]
1302
- if getattr(self.opts, "input", ""):
1303
- self.vars[bind] = self.opts.input
1304
- else:
1305
- self.vars.setdefault(bind, "")
1306
- if spec.get("required") and not self.vars.get(bind):
1307
- raise Stop(f"input '{name}' is required — pass --input \"<text>\"")
1308
-
1309
- def run(self):
1310
- self._bind_inputs()
1311
- self._run_steps(self.wf.get("workflow", []) or [])
1312
- return {"status": "ok", "outputs": self.resolve(self.wf.get("outputs", {}) or {}),
1313
- "vars": self.vars, "missing_required": list(self.missing_required)}
1314
-
1315
-
1316
- # ─────────────────────────────────────────────────────────────────────────────
1317
- # F11 — static validator (--validate): lints a workflow without executing it.
1318
- # ─────────────────────────────────────────────────────────────────────────────
1319
- DIALECT = "aw-spec.orchestrator/v1"
1320
- KNOWN_ACTIONS = {
1321
- "set", "build_context", "load_standards", "agent_generate", "agent_review",
1322
- "agent_author", "agent_query", "execute_script", "verify_context",
1323
- "dispatch_hooks", "user_select", "ask", "loop", "break", "include",
1324
- }
1325
- STRATEGIES = {"sdk", "sdk-apply", "spawn", "agent"}
1326
- # engine-produced vars a workflow may reference without declaring: the loop/ask
1327
- # bindings, plus the context-assembly miss report (_collect_files, item H).
1328
- _SPECIAL_VARS = {"selection", "answer", "iteration",
1329
- "context_missing", "context_missing_count"}
1330
-
1331
-
1332
- def _refs_in(v):
1333
- found = set()
1334
- if isinstance(v, str):
1335
- for m in re.findall(r"\{\{\s*([^}]+?)\s*\}\}", v):
1336
- found.add(m.strip())
1337
- elif isinstance(v, list):
1338
- for x in v:
1339
- found |= _refs_in(x)
1340
- elif isinstance(v, dict):
1341
- for x in v.values():
1342
- found |= _refs_in(x)
1343
- return found
1344
-
1345
-
1346
- def validate_workflow(wf, base_dir):
1347
- errors, warnings = [], []
1348
- if not isinstance(wf, dict):
1349
- return (["top-level YAML is not a mapping"], [])
1350
- steps = wf.get("workflow")
1351
- if not isinstance(steps, list) or not steps:
1352
- errors.append("missing or empty 'workflow' list")
1353
- steps = steps if isinstance(steps, list) else []
1354
- if wf.get("dialect") not in (None, DIALECT):
1355
- warnings.append(f"dialect is '{wf.get('dialect')}' — this engine implements {DIALECT}")
1356
- elif not wf.get("dialect"):
1357
- warnings.append(f"no 'dialect:' header (expected {DIALECT})")
1358
- scripts = wf.get("scripts", {}) or {}
1359
-
1360
- def walk(ss):
1361
- for s in ss or []:
1362
- if isinstance(s, dict):
1363
- yield s
1364
- if s.get("action") == "loop":
1365
- yield from walk(s.get("steps"))
1366
-
1367
- def _params(s):
1368
- return s.get("parameters") or s
1369
-
1370
- # pre-scan everything that produces variables, so ordering never false-alarms
1371
- produced = set(_SPECIAL_VARS) | set((wf.get("vars") or {}).keys())
1372
- for _, spec in (wf.get("inputs") or {}).items():
1373
- if isinstance(spec, dict) and spec.get("bind"):
1374
- produced.add(spec["bind"])
1375
-
1376
- def scan_produced(ss):
1377
- for s in walk(ss):
1378
- for o in s.get("outputs") or []:
1379
- if isinstance(o, dict) and o.get("name"):
1380
- produced.add(o["name"])
1381
- p = _params(s)
1382
- act = s.get("action")
1383
- if act == "ask":
1384
- produced.add(p.get("answer_var", "answer"))
1385
- elif act == "set":
1386
- produced.update((s.get("vars") or s.get("parameters") or {}).keys())
1387
- elif act == "build_context":
1388
- produced.update((s.get("vars") or {}).keys())
1389
- elif act == "loop":
1390
- produced.add(s.get("as", "item"))
1391
- produced.add(s.get("as", "item") + "_index")
1392
- elif act == "include":
1393
- produced.update((s.get("with") or {}).keys())
1394
- mpath = s.get("module", "")
1395
- for cand in (mpath, os.path.join(base_dir, mpath)):
1396
- if mpath and os.path.exists(cand):
1397
- try:
1398
- mwf = load_yaml(open(cand, encoding="utf-8").read())
1399
- scan_produced(mwf.get("workflow", []))
1400
- except Exception:
1401
- pass
1402
- break
1403
-
1404
- scan_produced(steps)
1405
-
1406
- seen_ids = set()
1407
- for step in walk(steps):
1408
- loc = step.get("id") or "step"
1409
- sid = step.get("id")
1410
- if not sid:
1411
- warnings.append(f"{loc}: missing id")
1412
- elif sid in seen_ids:
1413
- errors.append(f"duplicate step id: {sid}")
1414
- else:
1415
- seen_ids.add(sid)
1416
-
1417
- action = step.get("action")
1418
- if action not in KNOWN_ACTIONS:
1419
- errors.append(f"{loc}: unknown action '{action}' "
1420
- f"(known: {', '.join(sorted(KNOWN_ACTIONS))})")
1421
- if step.get("strategy") and step["strategy"] not in STRATEGIES:
1422
- errors.append(f"{loc}: unknown strategy '{step['strategy']}' "
1423
- f"(known: {', '.join(sorted(STRATEGIES))})")
1424
- p = _params(step)
1425
-
1426
- if action == "loop" and "over" not in step and "while" not in step:
1427
- errors.append(f"{loc}: loop needs 'over:' or 'while:'")
1428
- if action == "ask" and not (p.get("options_from") or p.get("options_from_variable")
1429
- or p.get("options")):
1430
- warnings.append(f"{loc}: ask has no options source (free-text input)")
1431
- if action == "user_select" and not p.get("options_from_variable"):
1432
- errors.append(f"{loc}: user_select needs options_from_variable")
1433
- if action == "include":
1434
- m = step.get("module")
1435
- if not m:
1436
- errors.append(f"{loc}: include needs 'module'")
1437
- elif not (os.path.exists(m) or os.path.exists(os.path.join(base_dir, m))):
1438
- warnings.append(f"{loc}: include module not found on disk: {m}")
1439
- if action == "agent_query" and not step.get("outputs"):
1440
- warnings.append(f"{loc}: agent_query has no outputs[] to bind")
1441
- if action == "agent_author" and not (p.get("output_target") or {}).get("path"):
1442
- errors.append(f"{loc}: agent_author needs output_target.path")
1443
- if action in ("agent_generate", "agent_author", "agent_query") \
1444
- and not (p.get("task") or p.get("prompt")):
1445
- warnings.append(f"{loc}: {action} has an empty task")
1446
- if action == "execute_script":
1447
- ref = (p.get("script") or {}).get("ref")
1448
- if not ref:
1449
- errors.append(f"{loc}: execute_script needs script.ref")
1450
- elif ref not in scripts:
1451
- # a module declares no scripts: map — the ref resolves against
1452
- # the INCLUDING caller's map, so standalone it's only a warning
1453
- (warnings if not scripts else errors).append(
1454
- f"{loc}: script ref '{ref}' not declared in top-level scripts:"
1455
- + ("" if scripts else " (module? the including caller must declare it)"))
1456
- else:
1457
- variants = scripts.get(ref) or {}
1458
- if not any(variants.get(v) and (os.path.exists(variants[v]) or
1459
- os.path.exists(os.path.join(base_dir, variants[v])))
1460
- for v in ("sh", "ps1")):
1461
- warnings.append(f"{loc}: no script variant file found on disk for ref '{ref}'")
1462
- if not p.get("subcommand"):
1463
- errors.append(f"{loc}: execute_script needs subcommand")
1464
-
1465
- used = _refs_in(steps) | _refs_in(wf.get("outputs", {}))
1466
- for name in sorted(used):
1467
- if name.split(".")[0] not in produced:
1468
- warnings.append(f"variable {{{{{name}}}}} referenced but never produced")
1469
- return errors, warnings
1470
-
1471
-
1472
- def _print_suspended(pending, opts):
1473
- """Checkpoint transport: hand the pending request to the INVOKING agent."""
1474
- if opts.json:
1475
- print(json.dumps({"status": "suspended", "pending": pending}, indent=2))
1476
- else:
1477
- print("\n=== SUSPENDED (invoking agent: fulfill this, then resume) ===")
1478
- print(json.dumps(pending, indent=2))
1479
-
1480
-
1481
- def _clear_checkpoint(opts):
1482
- """Terminal end (ok/stopped): remove the state file and its sidecars."""
1483
- if not opts.checkpoint:
1484
- return
1485
- for p in (opts.checkpoint, opts.checkpoint + ".system.md",
1486
- opts.checkpoint + ".user.md", opts.checkpoint + ".artifact.md",
1487
- opts.checkpoint + ".response.md"):
1488
- if os.path.exists(p):
1489
- os.remove(p)
1490
-
1491
-
1492
- def main(argv=None):
1493
- ap = argparse.ArgumentParser(description="aw-orchestrator — interpreter-owned context, fresh-Claude execution")
1494
- ap.add_argument("workflow")
1495
- ap.add_argument("--transport", choices=["mock", "real"], default="mock",
1496
- help="mock = no model call (prints the payloads); "
1497
- "real = spawn the agent profile's CLI per generation")
1498
- ap.add_argument("--agent-profile", default="", dest="agent_profile",
1499
- help="path to an agent transport profile JSON "
1500
- "(.claude/.agents/.kilocode/...); implies --transport real")
1501
- ap.add_argument("--isolation", choices=["bare", "fresh"], default="bare",
1502
- help="selects the profile's isolation.<mode> args (bare = strongest)")
1503
- ap.add_argument("--model", default="", help="model alias/name for the fresh agent")
1504
- ap.add_argument("--budget", default="", help="value for the profile's budget_flag, if it declares one")
1505
- ap.add_argument("--dry-run", action="store_true", dest="dry_run",
1506
- help="print the exact command the profile would run, without executing")
1507
- ap.add_argument("--sdk-install", action="store_true", dest="sdk_install",
1508
- help="if the profile's sdk module isn't importable, pip install "
1509
- "the package its sdk block declares, then continue")
1510
- ap.add_argument("--strategy", choices=["sdk", "sdk-apply", "spawn", "agent"],
1511
- default=None,
1512
- help="execution strategy for generations (per-step `strategy:` "
1513
- "overrides): sdk = isolated SDK instance generates + implements; "
1514
- "sdk-apply = isolated SDK generates, INVOKING agent applies "
1515
- "(needs --checkpoint); spawn = fresh CLI process per generation "
1516
- "(needs --agent-profile); agent = invoking agent fulfills via "
1517
- "suspend/resume, zero new instances (needs --checkpoint). "
1518
- "Omitted: inferred from --agent-profile/--checkpoint, else mock")
1519
- ap.add_argument("--checkpoint", metavar="STATEFILE",
1520
- help="suspend/resume state file (run-skill.py method). Required by "
1521
- "strategies 'agent' and 'sdk-apply'; at each handoff the "
1522
- "interpreter writes the payload/artifact sidecars next to "
1523
- "STATEFILE and exits 2 so the INVOKING agent fulfills the "
1524
- "pending request (writes STATEFILE.response.md) and re-runs "
1525
- "the same command")
1526
- ap.add_argument("--var", action="append", default=[], help="k=value (seed vars)")
1527
- ap.add_argument("--input", default="",
1528
- help="positional user input, bound via the workflow's inputs: block")
1529
- ap.add_argument("--answer", action="append", default=[],
1530
- help="key=value prefill for ask steps (key = answer_key, else step id)")
1531
- ap.add_argument("--select", action="append", default=[],
1532
- help="stepid=value prefill for user_select steps")
1533
- ap.add_argument("--non-interactive", action="store_true", dest="non_interactive",
1534
- help="asks answer with the first option (all options for multi), "
1535
- "selects take the first option — CI/smoke mode")
1536
- default_os = "windows" if os.name == "nt" else "posix"
1537
- ap.add_argument("--os", choices=["posix", "windows"], default=default_os,
1538
- help="script variant for execute_script (sh vs ps1)")
1539
- ap.add_argument("--validate", action="store_true",
1540
- help="lint the workflow (static checks) without executing")
1541
- ap.add_argument("--show-full", action="store_true", help="print entire payload, no truncation")
1542
- ap.add_argument("--json", action="store_true")
1543
- opts = ap.parse_args(argv)
1544
- opts.vars = dict(v.split("=", 1) for v in opts.var if "=" in v)
1545
- opts.answers = dict(s.split("=", 1) for s in opts.answer if "=" in s)
1546
- opts.selects = dict(s.split("=", 1) for s in opts.select if "=" in s)
1547
-
1548
- # ---- F11: --validate lints and exits (never executes) --------------------
1549
- if opts.validate:
1550
- try:
1551
- with open(opts.workflow, encoding="utf-8") as fh:
1552
- wf = load_yaml(fh.read())
1553
- except Exception as e:
1554
- print(json.dumps({"status": "invalid", "errors": [f"parse: {e}"]})
1555
- if opts.json else f"ERROR: parse: {e}")
1556
- return 1
1557
- errors, warnings = validate_workflow(
1558
- wf, os.path.dirname(os.path.abspath(opts.workflow)))
1559
- if opts.json:
1560
- print(json.dumps({"status": "valid" if not errors else "invalid",
1561
- "errors": errors, "warnings": warnings}, indent=2))
1562
- else:
1563
- for w in warnings:
1564
- print("WARN: ", w)
1565
- for e in errors:
1566
- print("ERROR:", e)
1567
- print(f"\n{opts.workflow}: {len(errors)} error(s), {len(warnings)} warning(s)")
1568
- return 1 if errors else 0
1569
-
1570
- # ---- checkpoint mode: load prior state + ingest the agent's response ----
1571
- # (before profile/strategy resolution, so a bare `--checkpoint` resume
1572
- # restores the original run's profile/strategy/model/os and prefills)
1573
- opts._cache = {}
1574
- if opts.checkpoint and os.path.exists(opts.checkpoint):
1575
- with open(opts.checkpoint, encoding="utf-8") as fh:
1576
- state = json.load(fh)
1577
- opts._cache = state.get("cache", {})
1578
- # replay with the original seeds; freshly passed flags override/extend
1579
- merged = dict(state.get("vars", {}))
1580
- merged.update(opts.vars)
1581
- opts.vars = merged
1582
- if not opts.strategy and state.get("strategy"):
1583
- opts.strategy = state["strategy"]
1584
- if not opts.model and state.get("model"):
1585
- opts.model = state["model"]
1586
- if not opts.agent_profile and state.get("agent_profile"):
1587
- opts.agent_profile = state["agent_profile"]
1588
- if opts.isolation == "bare" and state.get("isolation"):
1589
- opts.isolation = state["isolation"]
1590
- if opts.os == default_os and state.get("os"):
1591
- opts.os = state["os"]
1592
- if not opts.input:
1593
- opts.input = state.get("input", "")
1594
- merged = dict(state.get("answers", {}))
1595
- merged.update(opts.answers)
1596
- opts.answers = merged
1597
- merged = dict(state.get("selects", {}))
1598
- merged.update(opts.selects)
1599
- opts.selects = merged
1600
- pending = state.get("pending")
1601
- if pending:
1602
- rp = pending.get("response_path", "")
1603
- if rp and os.path.exists(rp):
1604
- with open(rp, encoding="utf-8") as fh:
1605
- raw = fh.read()
1606
- os.remove(rp)
1607
- # ingest by suspend kind (the pending.kind protocol)
1608
- kind = pending.get("kind", "")
1609
- note = None
1610
- if kind == "ask":
1611
- val = raw.strip()
1612
- parsed = ([s.strip() for s in val.split(",") if s.strip()]
1613
- if pending.get("multi") else val)
1614
- elif kind == "select":
1615
- parsed = raw.strip()
1616
- if not parsed:
1617
- note = "response_path was empty — write the chosen option's value"
1618
- elif kind == "author":
1619
- parsed = raw
1620
- if not raw.strip():
1621
- note = ("response_path was empty — write the complete "
1622
- "file content, then re-run the resume command")
1623
- elif kind == "hook":
1624
- parsed = True
1625
- else: # agent_generate / agent_review / agent_query / apply
1626
- parsed = _extract_json(raw)
1627
- if not parsed:
1628
- note = ("response_path had no parseable ```json fence — "
1629
- "rewrite it ending with the required fenced block, "
1630
- "then re-run the resume command")
1631
- if note:
1632
- pending["note"] = note
1633
- _print_suspended(pending, opts)
1634
- return 2
1635
- opts._cache[pending["step"]] = parsed
1636
- else:
1637
- # no response yet — re-emit the same pending request
1638
- _print_suspended(pending, opts)
1639
- return 2
1640
-
1641
- opts.profile = None
1642
- opts.profile_dir = ""
1643
- if opts.agent_profile:
1644
- with open(opts.agent_profile, encoding="utf-8") as fh:
1645
- opts.profile = json.load(fh)
1646
- # sdk adapters are declared relative to the profile file
1647
- opts.profile_dir = os.path.dirname(os.path.abspath(opts.agent_profile))
1648
- if opts.transport == "mock":
1649
- opts.transport = "real"
1650
-
1651
- # ---- strategy resolution -------------------------------------------------
1652
- # explicit --strategy wins; else legacy inference from the flags given.
1653
- if not opts.strategy:
1654
- if opts.profile:
1655
- opts.strategy = "spawn"
1656
- elif opts.checkpoint:
1657
- opts.strategy = "agent"
1658
- if opts.strategy and opts.transport == "mock":
1659
- opts.transport = "real"
1660
- if opts.strategy == "spawn" and not opts.profile:
1661
- print("ERROR: strategy 'spawn' requires --agent-profile <profile.json>")
1662
- return 2
1663
- if opts.strategy in ("sdk", "sdk-apply") and not opts.profile:
1664
- print(f"ERROR: strategy '{opts.strategy}' requires --agent-profile "
1665
- "<profile.json> — its sdk block declares the SDK module/package "
1666
- "and adapter (the engine has no default agent)")
1667
- return 2
1668
- if opts.strategy in ("agent", "sdk-apply") and not opts.checkpoint:
1669
- print(f"ERROR: strategy '{opts.strategy}' requires --checkpoint <statefile>")
1670
- return 2
1671
- if opts.transport == "real" and not opts.strategy:
1672
- print("ERROR: --transport real needs --strategy (or --agent-profile / --checkpoint)")
1673
- return 2
1674
-
1675
- with open(opts.workflow, encoding="utf-8") as fh:
1676
- wf = load_yaml(fh.read())
1677
-
1678
- orch = Orchestrator(wf, opts)
1679
- try:
1680
- result = orch.run()
1681
- _clear_checkpoint(opts)
1682
- except Suspend as e:
1683
- # persist state (vars seeds + strategy/model + replay cache + the
1684
- # pending request) and hand control back to the INVOKING agent.
1685
- # Exit 2 = "suspended".
1686
- with open(opts.checkpoint, "w", encoding="utf-8") as fh:
1687
- json.dump({"vars": opts.vars, "strategy": opts.strategy,
1688
- "model": opts.model, "input": opts.input,
1689
- "answers": opts.answers, "selects": opts.selects,
1690
- "agent_profile": opts.agent_profile,
1691
- "isolation": opts.isolation, "os": opts.os,
1692
- "cache": orch.cache, "pending": e.request}, fh, indent=2)
1693
- _print_suspended(e.request, opts)
1694
- return 2
1695
- except Stop as e:
1696
- result = {"status": "stopped", "reason": str(e), "vars": orch.vars,
1697
- "missing_required": list(orch.missing_required)}
1698
- _clear_checkpoint(opts)
1699
-
1700
- if opts.json:
1701
- print(json.dumps(result, indent=2))
1702
- else:
1703
- print(f"\n=== {result['status'].upper()} ===")
1704
- if result.get("reason"):
1705
- print(result["reason"])
1706
- # item H: a required system:/context: file that never made it into a
1707
- # payload is a silent quality hole — the model was told it had rules it
1708
- # never received. Repeat it at the end, where the operator will see it.
1709
- if result.get("missing_required"):
1710
- print(f"\nWARNING: {len(result['missing_required'])} required context "
1711
- "file(s) were declared but MISSING — every payload that named "
1712
- "them was assembled without them:")
1713
- for m in result["missing_required"]:
1714
- print(f" - {m}")
1715
- if result.get("outputs"):
1716
- print(json.dumps(result["outputs"], indent=2))
1717
- return 0 if result["status"] == "ok" else 1
1718
-
1719
-
1720
- if __name__ == "__main__":
1721
- sys.exit(main())