aw-spec-cli 0.1.4 → 0.1.5

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 (254) hide show
  1. package/assets/.agents/agent.json +28 -0
  2. package/assets/.agents/skills/aw-spec-orch/SKILL.md +56 -0
  3. package/assets/.agents/skills/aw-spec-orch-demo/SKILL.md +59 -0
  4. package/assets/.aw-spec/DESIGN-FORMAT.md +151 -0
  5. package/assets/.aw-spec/ORCH-DIALECT.md +156 -0
  6. package/assets/.aw-spec/RULE-PARTITION.md +126 -0
  7. package/assets/.aw-spec/extensions/agent-context/extension.yml +12 -9
  8. package/assets/.aw-spec/extensions.yml +12 -19
  9. package/assets/.aw-spec/integrations/aw-spec.manifest.json +1 -9
  10. package/assets/.aw-spec/interpreter/run-skill.py +1158 -0
  11. package/assets/.aw-spec/orchestrator/AGENT-PROFILE.md +94 -0
  12. package/assets/.aw-spec/orchestrator/README.md +300 -0
  13. package/assets/.aw-spec/orchestrator/adapters/claude_agent_sdk.py +74 -0
  14. package/assets/.aw-spec/orchestrator/adapters/google_antigravity.py +74 -0
  15. package/assets/.aw-spec/orchestrator/modules/delta-design.orch.yaml +105 -0
  16. package/assets/.aw-spec/orchestrator/modules/delta-spec.orch.yaml +79 -0
  17. package/assets/.aw-spec/orchestrator/modules/domain-select.orch.yaml +81 -0
  18. package/assets/.aw-spec/orchestrator/modules/tech-select.orch.yaml +75 -0
  19. package/assets/.aw-spec/orchestrator/orchestrator.py +1721 -0
  20. package/assets/.aw-spec/orchestrator/rev-templates/ai-rules.reference.md +40 -0
  21. package/assets/.aw-spec/orchestrator/rev-templates/comandrulemap.skeleton.md +59 -0
  22. package/assets/.aw-spec/orchestrator/rev-templates/commandtechrulemap.skeleton.md +97 -0
  23. package/assets/.aw-spec/orchestrator/rev-templates/domain-constitution.skeleton.md +61 -0
  24. package/assets/.aw-spec/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
  25. package/assets/.aw-spec/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
  26. package/assets/.aw-spec/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
  27. package/assets/.aw-spec/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
  28. package/assets/.aw-spec/orchestrator/workflows/_smoke.orch.yaml +160 -0
  29. package/assets/.aw-spec/orchestrator/workflows/aw-spec-add-component.orch.yaml +244 -0
  30. package/assets/.aw-spec/orchestrator/workflows/aw-spec-adopt.orch.yaml +775 -0
  31. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +248 -0
  32. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +261 -0
  33. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +425 -0
  34. package/assets/.aw-spec/orchestrator/workflows/aw-spec-build.orch.yaml +188 -0
  35. package/assets/.aw-spec/orchestrator/workflows/aw-spec-characterize.orch.yaml +474 -0
  36. package/assets/.aw-spec/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
  37. package/assets/.aw-spec/orchestrator/workflows/aw-spec-constitution.orch.yaml +284 -0
  38. package/assets/.aw-spec/orchestrator/workflows/aw-spec-demo.orch.yaml +103 -0
  39. package/assets/.aw-spec/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
  40. package/assets/.aw-spec/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
  41. package/assets/.aw-spec/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
  42. package/assets/.aw-spec/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +382 -0
  43. package/assets/.aw-spec/orchestrator/workflows/aw-spec-foundation.orch.yaml +246 -0
  44. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +231 -0
  45. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement.orch.yaml +258 -0
  46. package/assets/.aw-spec/orchestrator/workflows/aw-spec-inventory.orch.yaml +236 -0
  47. package/assets/.aw-spec/orchestrator/workflows/aw-spec-onboard.orch.yaml +949 -0
  48. package/assets/.aw-spec/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
  49. package/assets/.aw-spec/orchestrator/workflows/aw-spec-run.orch.yaml +177 -0
  50. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-domain.orch.yaml +79 -0
  51. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
  52. package/assets/.aw-spec/orchestrator/workflows/aw-spec-specify.orch.yaml +412 -0
  53. package/assets/.aw-spec/scripts/bash/aw-inventory.sh +14 -0
  54. package/assets/.aw-spec/scripts/bash/aw-parity.sh +13 -0
  55. package/assets/.aw-spec/scripts/bash/aw-rev-constitution.sh +13 -0
  56. package/assets/.aw-spec/scripts/bash/aw-spec-actions.sh +1899 -0
  57. package/assets/.aw-spec/scripts/bash/aw-xref.sh +15 -0
  58. package/assets/.aw-spec/scripts/bash/common.sh +11 -60
  59. package/assets/.aw-spec/scripts/powershell/aw-inventory.ps1 +14 -0
  60. package/assets/.aw-spec/scripts/powershell/aw-parity.ps1 +13 -0
  61. package/assets/.aw-spec/scripts/powershell/aw-rev-constitution.ps1 +13 -0
  62. package/assets/.aw-spec/scripts/powershell/aw-spec-actions.ps1 +1563 -0
  63. package/assets/.aw-spec/scripts/powershell/aw-xref.ps1 +15 -0
  64. package/assets/.aw-spec/scripts/python/aw-inventory.py +683 -0
  65. package/assets/.aw-spec/scripts/python/aw-parity.py +380 -0
  66. package/assets/.aw-spec/scripts/python/aw-rev-constitution.py +312 -0
  67. package/assets/.aw-spec/scripts/python/aw-xref.py +1330 -0
  68. package/assets/.aw-spec/scripts/python/validate_okf.py +349 -0
  69. package/assets/.aw-spec/templates/constitution-domain-template.md +75 -0
  70. package/assets/.aw-spec/templates/constitution-technical-template.md +70 -0
  71. package/assets/.aw-spec/templates/delta-design-template.md +69 -0
  72. package/assets/.aw-spec/templates/delta-spec-template.md +60 -0
  73. package/assets/.aw-spec/templates/design-template.md +383 -0
  74. package/assets/.aw-spec/templates/okf-concept-template.md +103 -0
  75. package/assets/.aw-spec/templates/parity-manifest-template.json +49 -0
  76. package/assets/.aw-spec/templates/source-map-template.md +85 -0
  77. package/assets/.aw-spec/templates/spec-template.md +192 -71
  78. package/assets/.claude/agent.json +27 -0
  79. package/assets/.claude/skills/aw-spec-orch/SKILL.md +55 -0
  80. package/assets/.claude/skills/aw-spec-orch-demo/SKILL.md +59 -0
  81. package/assets/.kilocode/agent.json +28 -0
  82. package/assets/.kilocode/workflows/aw-spec-orch-demo.md +59 -0
  83. package/assets/.kilocode/workflows/aw-spec-orch.md +55 -0
  84. package/assets/docs/architecture/clean-architecture.md +1 -1
  85. package/assets/docs/architecture/cqrs.md +1 -1
  86. package/assets/docs/architecture/eventing.md +1 -1
  87. package/assets/docs/architecture/folder-structure.md +1 -1
  88. package/assets/docs/architecture/modular-monolith.md +1 -1
  89. package/assets/docs/architecture/persistence.md +1 -1
  90. package/assets/docs/architecture/testing.md +1 -1
  91. package/assets/docs/architecture/vertical-slices.md +1 -1
  92. package/assets/docs/components/Email/README.md +1 -1
  93. package/assets/docs/components/Jobs/README.md +1 -1
  94. package/assets/docs/components/QrGenerator/README.md +1 -1
  95. package/assets/docs/components/README.md +1 -1
  96. package/assets/docs/domain/domain-driven-design.md +1 -1
  97. package/assets/docs/domainspecs/healthcare/api-versioning.domain.md +1 -1
  98. package/assets/docs/domainspecs/healthcare/auditing.domain.md +86 -18
  99. package/assets/docs/domainspecs/healthcare/authorization.domain.md +1 -1
  100. package/assets/docs/domainspecs/healthcare/clinical-terms.md +1 -1
  101. package/assets/docs/domainspecs/healthcare/coding-standards.domain.md +1 -1
  102. package/assets/docs/domainspecs/healthcare/comandrulemap.md +58 -124
  103. package/assets/docs/domainspecs/healthcare/commandtechrulemap.md +119 -0
  104. package/assets/docs/domainspecs/healthcare/consent.md +1 -1
  105. package/assets/docs/domainspecs/healthcare/cqrs.domain.md +1 -1
  106. package/assets/docs/domainspecs/healthcare/data-classification.domain.md +1 -1
  107. package/assets/docs/domainspecs/healthcare/dependency-policy.domain.md +1 -1
  108. package/assets/docs/domainspecs/healthcare/dicom.md +1 -1
  109. package/assets/docs/domainspecs/healthcare/domain-driven-design.domain.md +1 -1
  110. package/assets/docs/domainspecs/healthcare/eventing.domain.md +1 -1
  111. package/assets/docs/domainspecs/healthcare/fhir.md +1 -1
  112. package/assets/docs/domainspecs/healthcare/folder-structure.domain.md +1 -1
  113. package/assets/docs/domainspecs/healthcare/hipaa.md +1 -1
  114. package/assets/docs/domainspecs/healthcare/hl7.md +1 -1
  115. package/assets/docs/domainspecs/healthcare/integration-terms.md +1 -1
  116. package/assets/docs/domainspecs/healthcare/persistence.domain.md +1 -1
  117. package/assets/docs/domainspecs/healthcare/terminology.md +1 -1
  118. package/assets/docs/domainspecs/healthcare/testing.domain.md +1 -1
  119. package/assets/docs/domainspecs/healthcare/vertical-slices.domain.md +1 -1
  120. package/assets/docs/domainspecs/healthcare/zero-trust.domain.md +1 -1
  121. package/assets/docs/domainspecs/pharma/api-versioning.domain.md +1 -1
  122. package/assets/docs/domainspecs/pharma/atc.md +1 -1
  123. package/assets/docs/domainspecs/pharma/auditing.domain.md +80 -16
  124. package/assets/docs/domainspecs/pharma/authorization.domain.md +1 -1
  125. package/assets/docs/domainspecs/pharma/coding-standards.domain.md +1 -1
  126. package/assets/docs/domainspecs/pharma/comandrulemap.md +64 -142
  127. package/assets/docs/domainspecs/pharma/commandtechrulemap.md +119 -0
  128. package/assets/docs/domainspecs/pharma/cqrs.domain.md +1 -1
  129. package/assets/docs/domainspecs/pharma/data-classification.domain.md +1 -1
  130. package/assets/docs/domainspecs/pharma/data-provenance.md +1 -1
  131. package/assets/docs/domainspecs/pharma/dependency-policy.domain.md +1 -1
  132. package/assets/docs/domainspecs/pharma/domain-driven-design.domain.md +1 -1
  133. package/assets/docs/domainspecs/pharma/eventing.domain.md +1 -1
  134. package/assets/docs/domainspecs/pharma/fhir.md +1 -1
  135. package/assets/docs/domainspecs/pharma/folder-structure.domain.md +1 -1
  136. package/assets/docs/domainspecs/pharma/hl7.md +1 -1
  137. package/assets/docs/domainspecs/pharma/idmp.md +1 -1
  138. package/assets/docs/domainspecs/pharma/integration-terms.md +1 -1
  139. package/assets/docs/domainspecs/pharma/ndc.md +1 -1
  140. package/assets/docs/domainspecs/pharma/persistence.domain.md +1 -1
  141. package/assets/docs/domainspecs/pharma/pharma-terms.md +1 -1
  142. package/assets/docs/domainspecs/pharma/product-lifecycle.domain.md +1 -1
  143. package/assets/docs/domainspecs/pharma/regulatory-compliance.md +1 -1
  144. package/assets/docs/domainspecs/pharma/terminology.md +1 -1
  145. package/assets/docs/domainspecs/pharma/testing.domain.md +1 -1
  146. package/assets/docs/domainspecs/pharma/vertical-slices.domain.md +1 -1
  147. package/assets/docs/domainspecs/pharma/zero-trust.domain.md +1 -1
  148. package/assets/docs/governance/api-versioning.md +1 -1
  149. package/assets/docs/governance/coding-standards.md +1 -1
  150. package/assets/docs/governance/dependency-policy.md +1 -1
  151. package/assets/docs/requirement-spec.txt +27 -0
  152. package/assets/docs/security/auditing.md +54 -11
  153. package/assets/docs/security/authorization.md +1 -1
  154. package/assets/docs/security/data-classification.md +1 -1
  155. package/assets/docs/security/zero-trust.md +1 -1
  156. package/dist/commands/init.d.ts.map +1 -1
  157. package/dist/commands/init.js +7 -54
  158. package/dist/commands/init.js.map +1 -1
  159. package/dist/index.js +0 -2
  160. package/dist/index.js.map +1 -1
  161. package/dist/utils/paths.js +2 -2
  162. package/dist/utils/paths.js.map +1 -1
  163. package/package.json +1 -1
  164. package/assets/.agents/skills/aw-spec-addcomponent/SKILL.md +0 -129
  165. package/assets/.agents/skills/aw-spec-agent-context-update/SKILL.md +0 -31
  166. package/assets/.agents/skills/aw-spec-analyze/SKILL.md +0 -296
  167. package/assets/.agents/skills/aw-spec-checklist/SKILL.md +0 -374
  168. package/assets/.agents/skills/aw-spec-clarify/SKILL.md +0 -320
  169. package/assets/.agents/skills/aw-spec-constitution/SKILL.md +0 -194
  170. package/assets/.agents/skills/aw-spec-fast-implement/SKILL.md +0 -268
  171. package/assets/.agents/skills/aw-spec-foundation/SKILL.md +0 -358
  172. package/assets/.agents/skills/aw-spec-implement/SKILL.md +0 -279
  173. package/assets/.agents/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
  174. package/assets/.agents/skills/aw-spec-plan/SKILL.md +0 -221
  175. package/assets/.agents/skills/aw-spec-select-domain/SKILL.md +0 -73
  176. package/assets/.agents/skills/aw-spec-specify/SKILL.md +0 -382
  177. package/assets/.agents/skills/aw-spec-tasks/SKILL.md +0 -259
  178. package/assets/.agents/skills/aw-spec-taskstoissues/SKILL.md +0 -107
  179. package/assets/.aw-spec/integrations/claude.manifest.json +0 -17
  180. package/assets/.aw-spec/scripts/bash/check-prerequisites.sh +0 -189
  181. package/assets/.aw-spec/scripts/bash/create-new-feature.sh +0 -299
  182. package/assets/.aw-spec/scripts/bash/setup-plan.sh +0 -84
  183. package/assets/.aw-spec/scripts/bash/setup-tasks.sh +0 -91
  184. package/assets/.aw-spec/templates/checklist-template.md +0 -40
  185. package/assets/.aw-spec/templates/constitution-template.md +0 -57
  186. package/assets/.aw-spec/templates/plan-template.md +0 -113
  187. package/assets/.aw-spec/templates/tasks-template.md +0 -252
  188. package/assets/.aw-spec/workflows/aw-spec/workflow.yml +0 -77
  189. package/assets/.aw-spec/workflows/workflow-registry.json +0 -13
  190. package/assets/.claude/skills/aw-spec-addcomponent/SKILL.md +0 -129
  191. package/assets/.claude/skills/aw-spec-agent-context-update/SKILL.md +0 -31
  192. package/assets/.claude/skills/aw-spec-analyze/SKILL.md +0 -296
  193. package/assets/.claude/skills/aw-spec-checklist/SKILL.md +0 -374
  194. package/assets/.claude/skills/aw-spec-clarify/SKILL.md +0 -320
  195. package/assets/.claude/skills/aw-spec-constitution/SKILL.md +0 -194
  196. package/assets/.claude/skills/aw-spec-fast-implement/SKILL.md +0 -268
  197. package/assets/.claude/skills/aw-spec-foundation/SKILL.md +0 -358
  198. package/assets/.claude/skills/aw-spec-implement/SKILL.md +0 -279
  199. package/assets/.claude/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
  200. package/assets/.claude/skills/aw-spec-plan/SKILL.md +0 -221
  201. package/assets/.claude/skills/aw-spec-select-domain/SKILL.md +0 -73
  202. package/assets/.claude/skills/aw-spec-specify/SKILL.md +0 -382
  203. package/assets/.claude/skills/aw-spec-tasks/SKILL.md +0 -259
  204. package/assets/.claude/skills/aw-spec-taskstoissues/SKILL.md +0 -107
  205. package/assets/.kilocode/rules/specify-rules.md +0 -4
  206. package/assets/.kilocode/workflows/aw-spec-addcomponent.md +0 -129
  207. package/assets/.kilocode/workflows/aw-spec-agent-context.update.md +0 -31
  208. package/assets/.kilocode/workflows/aw-spec-analyze.md +0 -296
  209. package/assets/.kilocode/workflows/aw-spec-checklist.md +0 -374
  210. package/assets/.kilocode/workflows/aw-spec-clarify.md +0 -320
  211. package/assets/.kilocode/workflows/aw-spec-constitution.md +0 -194
  212. package/assets/.kilocode/workflows/aw-spec-fast-implement.md +0 -263
  213. package/assets/.kilocode/workflows/aw-spec-foundation.md +0 -358
  214. package/assets/.kilocode/workflows/aw-spec-implement.md +0 -279
  215. package/assets/.kilocode/workflows/aw-spec-minimal-implementation.md +0 -204
  216. package/assets/.kilocode/workflows/aw-spec-plan.md +0 -221
  217. package/assets/.kilocode/workflows/aw-spec-select-domain.md +0 -73
  218. package/assets/.kilocode/workflows/aw-spec-specify.md +0 -382
  219. package/assets/.kilocode/workflows/aw-spec-tasks.md +0 -259
  220. package/assets/.kilocode/workflows/aw-spec-taskstoissues.md +0 -107
  221. package/assets/docs/UI.Visualization/PACKAGING.md +0 -191
  222. package/assets/docs/UI.Visualization/UI.Visualization.Reference.pack.txt +0 -11768
  223. package/assets/docs/UI.Visualization/UI.Visualization.pack.txt +0 -11214
  224. package/assets/docs/UI.Visualization/pack-foundation.ps1 +0 -165
  225. package/assets/docs/UI.Visualization/pack-foundation.sh +0 -206
  226. package/assets/docs/UI.Visualization/pack-reference-foundation.ps1 +0 -159
  227. package/assets/docs/UI.Visualization/pack-reference-foundation.sh +0 -205
  228. package/assets/docs/UI.Visualization/unpack-foundation.ps1 +0 -126
  229. package/assets/docs/UI.Visualization/unpack-foundation.sh +0 -217
  230. package/assets/docs/UI.Visualization/unpack-reference-foundation.ps1 +0 -129
  231. package/assets/docs/UI.Visualization/unpack-reference-foundation.sh +0 -218
  232. package/assets/docs/command.txt +0 -295
  233. package/assets/docs/domainspecs/UIVisualization/chart-bubble.md +0 -70
  234. package/assets/docs/domainspecs/UIVisualization/chart-dimred.md +0 -58
  235. package/assets/docs/domainspecs/UIVisualization/chart-heatmap.md +0 -62
  236. package/assets/docs/domainspecs/UIVisualization/chart-network.md +0 -69
  237. package/assets/docs/domainspecs/UIVisualization/chart-parallel.md +0 -75
  238. package/assets/docs/domainspecs/UIVisualization/chart-scurve.md +0 -66
  239. package/assets/docs/domainspecs/UIVisualization/chart-survival.md +0 -69
  240. package/assets/docs/domainspecs/UIVisualization/chart-trellis.md +0 -68
  241. package/assets/docs/domainspecs/UIVisualization/chart-violin.md +0 -61
  242. package/assets/docs/domainspecs/UIVisualization/chart-volcano.md +0 -63
  243. package/assets/docs/domainspecs/UIVisualization/charting-standards.domain.md +0 -80
  244. package/assets/docs/domainspecs/UIVisualization/clean-architecture.domain.md +0 -87
  245. package/assets/docs/domainspecs/UIVisualization/coding-standards.domain.md +0 -47
  246. package/assets/docs/domainspecs/UIVisualization/comandrulemap.md +0 -130
  247. package/assets/docs/domainspecs/UIVisualization/component-contracts.domain.md +0 -67
  248. package/assets/docs/domainspecs/UIVisualization/data-source.domain.md +0 -68
  249. package/assets/docs/domainspecs/UIVisualization/domain-requirements.md +0 -110
  250. package/assets/docs/domainspecs/UIVisualization/folder-structure.domain.md +0 -55
  251. package/assets/docs/domainspecs/UIVisualization/state-management.domain.md +0 -67
  252. package/assets/docs/domainspecs/UIVisualization/testing.domain.md +0 -52
  253. package/assets/docs/domainspecs/UIVisualization/ui-viz-terms.md +0 -39
  254. package/assets/docs/pharma-constitution.my-ref.md +0 -86
@@ -0,0 +1,1158 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ run-skill.py — reference interpreter for aw-spec declarative skills
4
+ (dialect: aw-spec.declarative/v1).
5
+
6
+ ╔══════════════════════════════════════════════════════════════════════════════╗
7
+ ║ RETIRED AS AN ENTRY POINT — BUT DO NOT DELETE THIS FILE. ║
8
+ ║ ║
9
+ ║ The declarative tier it drove (the `aw-spec-decl-*` skills across .claude/, ║
10
+ ║ .agents/ and .kilocode/) was removed 2026-08-03; nothing launches this ║
11
+ ║ interpreter any more. It survives for ONE reason: ║
12
+ ║ ║
13
+ ║ .aw-spec/orchestrator/orchestrator.py imports this module at load time ║
14
+ ║ for its YAML-subset loader (`load_yaml`) — see orchestrator.py ~L112. ║
15
+ ║ ║
16
+ ║ Deleting this file makes EVERY orchestrator workflow fail with ║
17
+ ║ FileNotFoundError before a single step runs. Fold `load_yaml` into ║
18
+ ║ orchestrator.py first if you want this gone. ║
19
+ ╚══════════════════════════════════════════════════════════════════════════════╝
20
+
21
+ This was the "no-prompt" execution path: it PARSES a skill.yaml and EXECUTES the
22
+ `workflow:` itself. The model is invoked in exactly one place — the `llm_generate`
23
+ action — and only to author content. Everything else (loading context, verifying,
24
+ running the shared scripts, dispatching hooks, interactive selection) is done here.
25
+
26
+ Stdlib only — no PyYAML dependency. A compact YAML-subset loader (loader tuned to
27
+ the dialect) is bundled below and validated against a full YAML parser in tests.
28
+
29
+ Usage:
30
+ run-skill.py <skill.yaml> [--input "<user input>"] [--select name=value ...]
31
+ [--llm mock|command:<cmd>|agent] [--os posix|windows] [--dry-run] [--json]
32
+ run-skill.py <skill.yaml> --validate [--json] # lint without executing
33
+
34
+ LLM backends:
35
+ --llm mock (default) writes a deterministic placeholder file; no model call.
36
+ --llm command:<cmd> pipes the resolved prompt on stdin to <cmd>; stdout is the content.
37
+ --llm agent defers each llm_generate to the CALLING agent (e.g. Claude):
38
+ the interpreter records the request in `pending_generation`
39
+ and continues; the agent authors the content afterward.
40
+ """
41
+ import argparse
42
+ import json
43
+ import os
44
+ import re
45
+ import subprocess
46
+ import sys
47
+
48
+ # ─────────────────────────────────────────────────────────────────────────────
49
+ # Minimal YAML-subset loader
50
+ #
51
+ # Supports the constructs the dialect uses: nested block mappings, block
52
+ # sequences (items may sit at the parent key's indent), inline flow maps/lists,
53
+ # literal block scalars ("|"), quoted/plain scalars, comments, bool/int/null.
54
+ # Not a general YAML parser — intentionally small and predictable.
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ class YamlError(Exception):
57
+ pass
58
+
59
+
60
+ def _indent(line):
61
+ return len(line) - len(line.lstrip(" "))
62
+
63
+
64
+ def _strip_comment(s):
65
+ """Strip a trailing ' #...' comment that is outside quotes/flow."""
66
+ out, i, depth = [], 0, 0
67
+ quote = None
68
+ while i < len(s):
69
+ c = s[i]
70
+ if quote:
71
+ out.append(c)
72
+ if c == quote:
73
+ quote = None
74
+ elif c in ("'", '"'):
75
+ quote = c
76
+ out.append(c)
77
+ elif c in "[{":
78
+ depth += 1
79
+ out.append(c)
80
+ elif c in "]}":
81
+ depth = max(0, depth - 1)
82
+ out.append(c)
83
+ elif c == "#" and depth == 0 and (i == 0 or s[i - 1] == " "):
84
+ break
85
+ else:
86
+ out.append(c)
87
+ i += 1
88
+ return "".join(out).rstrip()
89
+
90
+
91
+ def _scalar(tok):
92
+ tok = tok.strip()
93
+ if tok == "" or tok == "~" or tok.lower() == "null":
94
+ return None
95
+ if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in "'\"":
96
+ return tok[1:-1]
97
+ low = tok.lower()
98
+ if low == "true":
99
+ return True
100
+ if low == "false":
101
+ return False
102
+ if re.fullmatch(r"-?\d+", tok):
103
+ return int(tok)
104
+ if re.fullmatch(r"-?\d+\.\d+", tok):
105
+ return float(tok)
106
+ return tok
107
+
108
+
109
+ def _parse_flow(s):
110
+ """Parse a JSON-ish flow scalar: {a: b, c: [x, y]} / [a, b] / plain."""
111
+ s = s.strip()
112
+ pos = [0]
113
+
114
+ def skip_ws():
115
+ while pos[0] < len(s) and s[pos[0]] in " \t":
116
+ pos[0] += 1
117
+
118
+ def parse_value():
119
+ skip_ws()
120
+ if pos[0] >= len(s):
121
+ return None
122
+ c = s[pos[0]]
123
+ if c == "{":
124
+ return parse_map()
125
+ if c == "[":
126
+ return parse_list()
127
+ return _scalar(read_until(",]}"))
128
+
129
+ def read_until(stops):
130
+ start = pos[0]
131
+ quote = None
132
+ while pos[0] < len(s):
133
+ ch = s[pos[0]]
134
+ if quote:
135
+ if ch == quote:
136
+ quote = None
137
+ elif ch in "'\"":
138
+ quote = ch
139
+ elif ch in stops:
140
+ break
141
+ pos[0] += 1
142
+ return s[start:pos[0]]
143
+
144
+ def parse_map():
145
+ obj = {}
146
+ pos[0] += 1 # {
147
+ skip_ws()
148
+ if pos[0] < len(s) and s[pos[0]] == "}":
149
+ pos[0] += 1
150
+ return obj
151
+ while True:
152
+ skip_ws()
153
+ key = read_until(":").strip()
154
+ if pos[0] < len(s) and s[pos[0]] == ":":
155
+ pos[0] += 1
156
+ val = parse_value()
157
+ obj[_scalar(key)] = val
158
+ skip_ws()
159
+ if pos[0] < len(s) and s[pos[0]] == ",":
160
+ pos[0] += 1
161
+ continue
162
+ if pos[0] < len(s) and s[pos[0]] == "}":
163
+ pos[0] += 1
164
+ break
165
+ return obj
166
+
167
+ def parse_list():
168
+ arr = []
169
+ pos[0] += 1 # [
170
+ skip_ws()
171
+ if pos[0] < len(s) and s[pos[0]] == "]":
172
+ pos[0] += 1
173
+ return arr
174
+ while True:
175
+ arr.append(parse_value())
176
+ skip_ws()
177
+ if pos[0] < len(s) and s[pos[0]] == ",":
178
+ pos[0] += 1
179
+ continue
180
+ if pos[0] < len(s) and s[pos[0]] == "]":
181
+ pos[0] += 1
182
+ break
183
+ return arr
184
+
185
+ if s and s[0] in "[{":
186
+ return parse_value()
187
+ return _scalar(s)
188
+
189
+
190
+ class _Lines:
191
+ def __init__(self, text):
192
+ self.raw = text.splitlines()
193
+ self.i = 0
194
+
195
+ def peek(self):
196
+ while self.i < len(self.raw):
197
+ line = self.raw[self.i]
198
+ stripped = line.strip()
199
+ if stripped == "" or stripped.startswith("#"):
200
+ self.i += 1
201
+ continue
202
+ return line
203
+ return None
204
+
205
+ def next(self):
206
+ line = self.peek()
207
+ if line is not None:
208
+ self.i += 1
209
+ return line
210
+
211
+
212
+ def _parse_block(lines, min_indent):
213
+ line = lines.peek()
214
+ if line is None or _indent(line) < min_indent:
215
+ return None
216
+ indent = _indent(line)
217
+ if line.strip().startswith("- "):
218
+ return _parse_seq(lines, indent)
219
+ return _parse_map(lines, indent)
220
+
221
+
222
+ def _parse_map(lines, indent):
223
+ obj = {}
224
+ while True:
225
+ line = lines.peek()
226
+ if line is None or _indent(line) != indent or line.strip().startswith("- "):
227
+ break
228
+ content = _strip_comment(line.strip())
229
+ m = re.match(r"^(.*?):(?:\s+(.*))?$", content)
230
+ if not m:
231
+ raise YamlError(f"bad mapping line: {line!r}")
232
+ key = _scalar(m.group(1))
233
+ rest = m.group(2)
234
+ lines.next()
235
+ if rest is None or rest == "":
236
+ child = _parse_block(lines, indent + 1)
237
+ obj[key] = child
238
+ elif rest in ("|", "|-", "|+", ">", ">-", ">+"):
239
+ obj[key] = _parse_block_scalar(lines, indent, folded=rest[0] == ">",
240
+ chomp=(rest[1:] or ""))
241
+ elif rest[0] in "[{":
242
+ obj[key] = _parse_flow(rest)
243
+ else:
244
+ obj[key] = _scalar(rest)
245
+ return obj
246
+
247
+
248
+ def _parse_seq(lines, indent):
249
+ arr = []
250
+ while True:
251
+ line = lines.peek()
252
+ if line is None or _indent(line) != indent or not line.strip().startswith("- "):
253
+ break
254
+ content = _strip_comment(line.strip()[2:])
255
+ lines.next()
256
+ if content == "":
257
+ arr.append(_parse_block(lines, indent + 1))
258
+ elif content[0] in "[{":
259
+ arr.append(_parse_flow(content))
260
+ elif re.match(r"^.*?:(?:\s+.*)?$", content) and ":" in content:
261
+ # inline map entry starting on the "- " line; parse it + following keys
262
+ m = re.match(r"^(.*?):(?:\s+(.*))?$", content)
263
+ item = {}
264
+ key = _scalar(m.group(1))
265
+ rest = m.group(2)
266
+ if rest is None or rest == "":
267
+ item[key] = _parse_block(lines, indent + 2)
268
+ elif rest[0] in "[{":
269
+ item[key] = _parse_flow(rest)
270
+ else:
271
+ item[key] = _scalar(rest)
272
+ # subsequent sibling keys at indent+2
273
+ sub = _parse_map(lines, indent + 2)
274
+ if sub:
275
+ item.update(sub)
276
+ arr.append(item)
277
+ else:
278
+ arr.append(_scalar(content))
279
+ return arr
280
+
281
+
282
+ def load_yaml(text):
283
+ lines = _Lines(text)
284
+ result = _parse_block(lines, 0)
285
+ return result if result is not None else {}
286
+
287
+
288
+ def _parse_block_scalar(lines, parent_indent, folded, chomp):
289
+ """Collect a block scalar ('|' literal or '>' folded) with chomping.
290
+
291
+ chomp: '' clip (single trailing \\n), '-' strip, '+' keep all trailing \\n.
292
+ """
293
+ block, block_indent = [], None
294
+ while lines.i < len(lines.raw):
295
+ raw = lines.raw[lines.i]
296
+ if raw.strip() == "":
297
+ block.append("")
298
+ lines.i += 1
299
+ continue
300
+ if _indent(raw) <= parent_indent:
301
+ break
302
+ if block_indent is None:
303
+ block_indent = _indent(raw)
304
+ block.append(raw[block_indent:])
305
+ lines.i += 1
306
+
307
+ trailing = 0
308
+ while block and block[-1] == "":
309
+ block.pop()
310
+ trailing += 1
311
+
312
+ if folded:
313
+ text = ""
314
+ for i, ln in enumerate(block):
315
+ if i == 0:
316
+ text = ln
317
+ else:
318
+ text += ("\n" if (block[i - 1] == "" or ln == "") else " ") + ln
319
+ else:
320
+ text = "\n".join(block)
321
+
322
+ if chomp == "-":
323
+ return text
324
+ if chomp == "+":
325
+ return text + "\n" * (trailing + 1 if block else trailing)
326
+ return text + "\n" # clip
327
+
328
+
329
+ # ─────────────────────────────────────────────────────────────────────────────
330
+ # Interpreter
331
+ # ─────────────────────────────────────────────────────────────────────────────
332
+ class Stop(Exception):
333
+ pass
334
+
335
+
336
+ class Suspend(Exception):
337
+ """Raised in checkpoint mode when the interpreter needs the agent (generate a
338
+ file / answer a query / ask a question). Carries the typed request."""
339
+ def __init__(self, request):
340
+ self.request = request
341
+
342
+
343
+ class Interp:
344
+ def __init__(self, skill, opts):
345
+ self.skill = skill
346
+ self.opts = opts
347
+ self.vars = {}
348
+ self.context = [] # ordered list of loaded file paths
349
+ self.log = []
350
+ self.pending = [] # deferred llm_generate requests (--llm agent)
351
+ # checkpoint mode: replay + response cache keyed by invocation path
352
+ self.checkpoint = getattr(opts, "checkpoint", None)
353
+ self.cache = getattr(opts, "_cache", {}) or {}
354
+ self.key_stack = [] # loop-aware invocation-key prefix
355
+ # session context ledger: files loaded by earlier commands this session
356
+ self.session_loaded = set(getattr(opts, "_session_loaded", None) or set())
357
+ self.ledger_path = getattr(opts, "_ledger_path", None)
358
+ self.session_id = getattr(opts, "session", None)
359
+
360
+ # -- helpers --------------------------------------------------------------
361
+ def emit(self, msg):
362
+ self.log.append(msg)
363
+ if not self.opts.json:
364
+ print(msg)
365
+
366
+ def _lookup(self, key):
367
+ """Dotted lookup into vars: 'q.text', 'items.0'. Returns (found, value)."""
368
+ parts = key.split(".")
369
+ if parts[0] not in self.vars:
370
+ return (False, None)
371
+ cur = self.vars[parts[0]]
372
+ for pth in parts[1:]:
373
+ if isinstance(cur, dict) and pth in cur:
374
+ cur = cur[pth]
375
+ elif isinstance(cur, list) and pth.isdigit() and int(pth) < len(cur):
376
+ cur = cur[int(pth)]
377
+ else:
378
+ return (False, None)
379
+ return (True, cur)
380
+
381
+ def resolve(self, val):
382
+ """Recursively substitute {{name}} / {{a.b}} in strings using self.vars."""
383
+ if isinstance(val, str):
384
+ def sub(m):
385
+ found, v = self._lookup(m.group(1).strip())
386
+ if not found:
387
+ return m.group(0)
388
+ return v if isinstance(v, str) else json.dumps(v)
389
+ return re.sub(r"\{\{\s*([^}]+?)\s*\}\}", sub, val)
390
+ if isinstance(val, list):
391
+ return [self.resolve(x) for x in val]
392
+ if isinstance(val, dict):
393
+ return {k: self.resolve(v) for k, v in val.items()}
394
+ return val
395
+
396
+ def resolve_value(self, x):
397
+ """Like resolve, but if x is exactly one {{ref}} return the raw object
398
+ (list/dict/bool), not a stringified form."""
399
+ if isinstance(x, str):
400
+ m = re.fullmatch(r"\{\{\s*([^}]+?)\s*\}\}", x.strip())
401
+ if m:
402
+ found, v = self._lookup(m.group(1).strip())
403
+ return v if found else x
404
+ return self.resolve(x)
405
+ return x
406
+
407
+ def truthy(self, v):
408
+ if isinstance(v, str):
409
+ return v.strip() not in ("", "false", "False", "None", "null", "[]")
410
+ return bool(v)
411
+
412
+ def eval_condition(self, expr):
413
+ expr = self.resolve(expr).strip()
414
+ for op in ("==", "!="):
415
+ if op in expr:
416
+ lhs, rhs = [s.strip().strip("'\"") for s in expr.split(op, 1)]
417
+ return (lhs == rhs) if op == "==" else (lhs != rhs)
418
+ return self.truthy(expr)
419
+
420
+ # -- checkpoint helpers ---------------------------------------------------
421
+ def _key(self, step):
422
+ """Stable, loop-aware invocation key for a step (for the replay cache)."""
423
+ return "/".join(self.key_stack + [step.get("id", "?")])
424
+
425
+ def _suspend(self, request):
426
+ """Persist a pending agent-request and raise Suspend."""
427
+ request["response_path"] = self.checkpoint + ".response"
428
+ raise Suspend(request)
429
+
430
+ def script_path(self, ref):
431
+ s = self.skill.get("scripts", {}).get(ref, {})
432
+ variant = "ps1" if self.opts.os == "windows" else "sh"
433
+ return s.get(variant)
434
+
435
+ def act_include(self, step):
436
+ """Run a reusable workflow module inline — shares vars, context, cache, and
437
+ the caller's `scripts:` map. `with:` seeds module inputs; the module's steps
438
+ are namespaced under the include id for stable checkpoint keys."""
439
+ module_path = self.resolve(step.get("module", ""))
440
+ for k, v in (step.get("with", {}) or {}).items():
441
+ self.vars[k] = self.resolve_value(v)
442
+ try:
443
+ with open(module_path, encoding="utf-8") as fh:
444
+ mod = load_yaml(fh.read())
445
+ except OSError as e:
446
+ raise YamlError(f"include: module not found: {module_path} ({e})")
447
+ self.emit(f"[include] {module_path}")
448
+ self.key_stack.append("inc:" + step.get("id", "include"))
449
+ try:
450
+ self._run_steps(mod.get("workflow", []) or [])
451
+ finally:
452
+ self.key_stack.pop()
453
+
454
+ def json_get(self, obj, path):
455
+ if not path.startswith("$."):
456
+ return obj.get(path) if isinstance(obj, dict) else None
457
+ cur = obj
458
+ for part in path[2:].split("."):
459
+ if isinstance(cur, dict):
460
+ cur = cur.get(part)
461
+ else:
462
+ return None
463
+ return cur
464
+
465
+ # -- actions --------------------------------------------------------------
466
+ def run(self):
467
+ self._run_steps(self.skill.get("workflow", []))
468
+ return 0
469
+
470
+ def _run_steps(self, steps):
471
+ """Run a list of steps. Returns 'break' if a break bubbled up, else None."""
472
+ for step in steps:
473
+ cond = step.get("condition")
474
+ if cond is not None and not self.eval_condition(cond):
475
+ self.emit(f"[skip] {step.get('id')} (condition false)")
476
+ continue
477
+ action = step.get("action")
478
+ if action == "break":
479
+ self.emit(f"[break] {step.get('id', '')}")
480
+ return "break"
481
+ if action == "loop":
482
+ self.act_loop(step) # a break inside is consumed by the loop itself
483
+ continue
484
+ fn = getattr(self, f"act_{action}", None)
485
+ if fn is None:
486
+ raise YamlError(f"unknown action: {action}")
487
+ fn(step)
488
+ return None
489
+
490
+ def act_loop(self, step):
491
+ """foreach (`over:` a list var) or while (`while:` + `max_iterations`)."""
492
+ as_name = step.get("as", "item")
493
+ max_it = step.get("max_iterations")
494
+ substeps = step.get("steps", []) or []
495
+ if "over" in step:
496
+ items = self.resolve_value(step["over"]) or []
497
+ if not isinstance(items, list):
498
+ raise YamlError(f"loop 'over' is not a list: {step.get('over')}")
499
+ loop_id = step.get("id", "loop")
500
+ for idx, item in enumerate(items):
501
+ if max_it is not None and idx >= max_it:
502
+ break
503
+ self.vars[as_name] = item
504
+ self.vars[as_name + "_index"] = idx
505
+ self.key_stack.append(f"{loop_id}#{idx}")
506
+ try:
507
+ brk = self._run_steps(substeps)
508
+ finally:
509
+ self.key_stack.pop()
510
+ if brk == "break":
511
+ break # break exits THIS loop (consumed here), not the caller
512
+ return None
513
+ # while variant
514
+ cond = step.get("while", "true")
515
+ loop_id = step.get("id", "loop")
516
+ i = 0
517
+ while self.eval_condition(cond):
518
+ if max_it is not None and i >= max_it:
519
+ break
520
+ self.vars[as_name + "_index"] = i
521
+ self.key_stack.append(f"{loop_id}#{i}")
522
+ try:
523
+ brk = self._run_steps(substeps)
524
+ finally:
525
+ self.key_stack.pop()
526
+ if brk == "break":
527
+ break # break exits THIS while-loop (consumed here)
528
+ i += 1
529
+ return None
530
+
531
+ def _bind_outputs(self, step, data):
532
+ for out in step.get("outputs", []) or []:
533
+ self.vars[out["name"]] = self.json_get(data, out["from"])
534
+
535
+ def act_load_context(self, step):
536
+ p = step.get("parameters", {})
537
+ files = p.get("files") or []
538
+ if p.get("files_from_variable"):
539
+ var = self.vars.get(p["files_from_variable"]) or []
540
+ files = [{"path": x} for x in var]
541
+ step_optional = p.get("optional", False)
542
+ for f in files:
543
+ path = self.resolve(f["path"] if isinstance(f, dict) else f)
544
+ optional = f.get("optional", step_optional) if isinstance(f, dict) else step_optional
545
+ if p.get("deduplicate") and path in self.context:
546
+ self.emit(f"[load_context] dedup skip {path}")
547
+ continue
548
+ # session ledger: a file already loaded by an EARLIER command in this
549
+ # agent session is already in the model's context — don't reload/re-attach.
550
+ if path in self.session_loaded:
551
+ self.emit(f"[load_context] session-dedup skip {path} (loaded earlier this session)")
552
+ continue
553
+ if os.path.exists(path):
554
+ self.context.append(path)
555
+ self.emit(f"[load_context] loaded {path}")
556
+ elif optional:
557
+ self.emit(f"[load_context] optional missing, skip {path}")
558
+ else:
559
+ raise YamlError(f"required context file missing: {path}")
560
+
561
+ def act_verify_context(self, step):
562
+ p = step.get("parameters", {})
563
+ if p.get("require_non_empty"):
564
+ var = p["require_non_empty"]
565
+ val = self.vars.get(var)
566
+ if not val:
567
+ self.emit(f"[verify_context] {var} is empty: {p.get('on_empty_message','')}")
568
+ if p.get("on_empty") == "stop":
569
+ raise Stop(p.get("on_empty_message", "stopped"))
570
+ else:
571
+ self.emit(f"[verify_context] {var} non-empty ({len(val)})")
572
+ if p.get("require_true"):
573
+ var = p["require_true"]
574
+ if not self.truthy(self.vars.get(var)):
575
+ msg = self.resolve(p.get("on_false_message", f"{var} is not satisfied"))
576
+ self.emit(f"[verify_context] gate failed ({var}): {msg}")
577
+ if p.get("on_false", "stop") == "stop":
578
+ raise Stop(msg)
579
+ else:
580
+ self.emit(f"[verify_context] gate ok ({var})")
581
+ if p.get("require_value"):
582
+ var = p["require_value"]
583
+ actual = str(self.vars.get(var, ""))
584
+ expected = str(self.resolve(p.get("equals", "")))
585
+ if actual != expected:
586
+ msg = self.resolve(p.get("on_mismatch_message", f"{var} != {expected}"))
587
+ self.emit(f"[verify_context] value gate failed ({var}={actual!r}): {msg}")
588
+ if p.get("on_mismatch", "stop") == "stop":
589
+ raise Stop(msg)
590
+ else:
591
+ self.emit(f"[verify_context] value gate ok ({var}=={expected})")
592
+ for rf in p.get("required_files", []) or []:
593
+ rf = self.resolve(rf)
594
+ present = rf in self.context or os.path.exists(rf)
595
+ if present:
596
+ self.emit(f"[verify_context] present {rf}")
597
+ elif p.get("allow_missing"):
598
+ self.emit(f"[verify_context] missing (allowed) {rf}")
599
+ else:
600
+ raise YamlError(f"required file not in context: {rf}")
601
+
602
+ def act_execute_script(self, step):
603
+ p = step.get("parameters", {})
604
+ ref = p["script"]["ref"]
605
+ script = self.script_path(ref)
606
+ if not script or not os.path.exists(script):
607
+ raise YamlError(f"script not found for ref '{ref}': {script}")
608
+ args = [self.resolve(a) for a in (p.get("args") or [])]
609
+ # checkpoint replay: reuse cached output, don't re-run the side effect
610
+ ck = self._key(step)
611
+ if self.checkpoint and ck in self.cache:
612
+ self._bind_outputs(step, self.cache[ck])
613
+ self.emit(f"[execute_script] {p['subcommand']} (cached)")
614
+ return
615
+ runner = ["pwsh", "-NoProfile", "-File", script] if self.opts.os == "windows" else ["bash", script]
616
+ cmd = runner + [p["subcommand"]] + args
617
+ self.emit(f"[execute_script] {p['subcommand']} {' '.join(args)}")
618
+ if self.opts.dry_run:
619
+ self._bind_outputs(step, {})
620
+ return
621
+ proc = subprocess.run(cmd, capture_output=True, text=True)
622
+ if proc.returncode != 0:
623
+ raise YamlError(f"script failed ({p['subcommand']}): {proc.stderr.strip()}")
624
+ try:
625
+ data = json.loads(proc.stdout.strip().splitlines()[-1])
626
+ except Exception as e:
627
+ raise YamlError(f"script did not return JSON ({p['subcommand']}): {proc.stdout!r} ({e})")
628
+ self._bind_outputs(step, data)
629
+ if self.checkpoint:
630
+ self.cache[ck] = data
631
+ self.emit(f"[execute_script] -> {json.dumps(data)}")
632
+
633
+ def act_dispatch_hooks(self, step):
634
+ p = step.get("parameters", {})
635
+ # checkpoint replay: don't re-dispatch hooks already handled
636
+ ck = self._key(step)
637
+ if self.checkpoint and ck in self.cache:
638
+ self.emit("[dispatch_hooks] (cached)")
639
+ return
640
+ hooks = self.vars.get(p.get("hooks_from_variable")) or []
641
+ if hooks:
642
+ for h in hooks:
643
+ kind = "mandatory" if not h.get("optional") else "optional"
644
+ self.emit(f"[dispatch_hooks] {kind}: {h.get('slash')} ({h.get('extension')})")
645
+ # An agent runs the slash command here; the interpreter records intent.
646
+ else:
647
+ self.emit("[dispatch_hooks] none")
648
+ if self.checkpoint:
649
+ self.cache[ck] = True
650
+
651
+ def act_user_select(self, step):
652
+ p = step.get("parameters", {})
653
+ options = self.vars.get(p.get("options_from_variable")) or []
654
+ # Direct-argument fast path
655
+ pre = p.get("preselect_from")
656
+ pre_val = self.vars.get(pre) if pre else None
657
+ chosen = None
658
+ if pre_val and pre_val in options:
659
+ chosen = pre_val
660
+ elif self.opts.selects.get(step.get("id")):
661
+ chosen = self.opts.selects[step["id"]]
662
+ elif len(options) == 1 and p.get("single_option_confirm"):
663
+ chosen = options[0]
664
+ elif self.opts.non_interactive:
665
+ chosen = options[0] if options else None
666
+ else:
667
+ chosen = self._prompt_choice(p.get("prompt", "Select:"), options)
668
+ if not chosen:
669
+ raise Stop("selection cancelled")
670
+ self.vars["selection"] = chosen
671
+ for out in step.get("outputs", []) or []:
672
+ src = out["from"]
673
+ self.vars[out["name"]] = chosen if src == "selection" else self.vars.get(src)
674
+ self.emit(f"[user_select] chosen: {chosen}")
675
+
676
+ def _prompt_choice(self, prompt, options):
677
+ print(prompt)
678
+ for i, o in enumerate(options, 1):
679
+ print(f" {i}) {o}")
680
+ try:
681
+ raw = input("> ").strip()
682
+ except EOFError:
683
+ return None
684
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
685
+ return options[int(raw) - 1]
686
+ return raw if raw in options else None
687
+
688
+ def act_ask(self, step):
689
+ """Single or multi-select question. Answer sources, in order:
690
+ --answer <key>=<value> (key = resolved `answer_key`, else step id)
691
+ interactive stdin (unless --non-interactive)
692
+ Binds the answer to `answer_var` (default 'answer')."""
693
+ p = step.get("parameters", {})
694
+ prompt = self.resolve(p.get("prompt", "?"))
695
+ options = []
696
+ if p.get("options_from"):
697
+ options = self.resolve_value(p["options_from"]) or []
698
+ elif p.get("options_from_variable"):
699
+ options = self.vars.get(p["options_from_variable"]) or []
700
+ elif p.get("options"):
701
+ options = [self.resolve(o) for o in p["options"]]
702
+ multi = bool(p.get("multi"))
703
+ key = self.resolve(p["answer_key"]) if p.get("answer_key") else step.get("id", "")
704
+ var = p.get("answer_var", "answer")
705
+
706
+ ck = self._key(step)
707
+ if self.checkpoint and ck in self.cache:
708
+ self.vars[var] = self.cache[ck]
709
+ self.emit(f"[ask] {key} -> {self.cache[ck]} (cached)")
710
+ return
711
+
712
+ raw = self.opts.answers.get(key)
713
+ if raw is None and self.checkpoint:
714
+ # need the agent/user — suspend with an ask request
715
+ self._suspend({"kind": "ask", "key": key, "step": ck,
716
+ "prompt": prompt, "options": options, "multi": multi})
717
+ if raw is None and self.opts.non_interactive:
718
+ raw = ",".join(str(o) for o in options) if multi else (options[0] if options else "")
719
+ if raw is None:
720
+ raw = self._prompt_choice(prompt, options) if options else \
721
+ (input(prompt + " ").strip() if not self.opts.non_interactive else "")
722
+ if raw is None:
723
+ raise Stop("question cancelled")
724
+ # multi-select: split on commas and DROP empties, so "" => [] (empty selection)
725
+ answer = [s.strip() for s in str(raw).split(",") if s.strip()] if multi else raw
726
+ self.vars[var] = answer
727
+ if self.checkpoint:
728
+ self.cache[ck] = answer
729
+ self.emit(f"[ask] {key} -> {answer}")
730
+
731
+ def act_set(self, step):
732
+ """Set variables from literals/expressions (parameters: {name: value})."""
733
+ for k, v in (step.get("parameters", {}) or {}).items():
734
+ self.vars[k] = self.resolve_value(v)
735
+ self.emit(f"[set] {list((step.get('parameters') or {}).keys())}")
736
+
737
+ def act_llm_query(self, step):
738
+ """In-run LLM call that returns STRUCTURED JSON bound to vars via outputs[].
739
+ Requires a synchronous backend (mock/command); not available with --llm agent."""
740
+ p = step.get("parameters", {})
741
+ ck = self._key(step)
742
+ if self.checkpoint and ck in self.cache:
743
+ self._bind_outputs(step, self.cache[ck])
744
+ self.emit(f"[llm_query] (cached) -> {json.dumps(self.cache[ck])[:120]}")
745
+ return
746
+ prompt = self.resolve(p.get("prompt", ""))
747
+ ctx_files = [self.resolve_value(c) for c in (p.get("context") or [])]
748
+ ctx_files = [c for c in ctx_files if isinstance(c, str)]
749
+ if p.get("include_loaded_context", True):
750
+ for c in self.context:
751
+ if c not in ctx_files:
752
+ ctx_files.append(c)
753
+ if self.checkpoint:
754
+ self._suspend({"kind": "llm_query", "step": ck,
755
+ "prompt": prompt, "context": ctx_files})
756
+ if self.opts.llm == "agent":
757
+ raise YamlError("llm_query needs a synchronous --llm backend "
758
+ "(mock/command:...) or --checkpoint, not --llm agent")
759
+ raw = self._invoke_llm(prompt, ctx_files, None)
760
+ try:
761
+ data = json.loads(raw.strip().splitlines()[-1] if raw.strip() else "{}")
762
+ except Exception as e:
763
+ raise YamlError(f"llm_query did not return JSON: {raw!r} ({e})")
764
+ self._bind_outputs(step, data)
765
+ self.emit(f"[llm_query] -> {json.dumps(data)[:200]}")
766
+
767
+ def act_llm_generate(self, step):
768
+ p = step.get("parameters", {})
769
+ prompt = self.resolve(p.get("prompt", ""))
770
+ ctx_files = [self.resolve(c) for c in (p.get("context") or [])]
771
+ # Fix #1: augment with everything pulled in by load_context so the standards
772
+ # and domain rules actually reach generation (dedup, order-preserving).
773
+ if p.get("include_loaded_context", True):
774
+ for c in self.context:
775
+ if c not in ctx_files:
776
+ ctx_files.append(c)
777
+ target = self.resolve(p["output_target"]["path"])
778
+ ck = self._key(step)
779
+ # checkpoint replay: rewrite the target from cached content (idempotent)
780
+ if self.checkpoint and ck in self.cache:
781
+ os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
782
+ with open(target, "w", encoding="utf-8") as fh:
783
+ fh.write(self.cache[ck])
784
+ self.emit(f"[llm_generate] (cached) rewrote {target}")
785
+ return
786
+ self.emit(f"[llm_generate] context={ctx_files} -> {target}")
787
+ if self.checkpoint:
788
+ self._suspend({"kind": "llm_generate", "step": ck, "target": target,
789
+ "prompt": prompt, "context": ctx_files})
790
+ if self.opts.llm == "agent":
791
+ # Defer generation to the calling agent (e.g. Claude). The interpreter
792
+ # does NOT write the file; it records the request and continues the
793
+ # remaining deterministic steps. The agent fulfills these afterward.
794
+ self.pending.append({"target": target, "context": ctx_files, "prompt": prompt})
795
+ self.emit("[llm_generate] deferred to agent (see pending_generation)")
796
+ return
797
+ content = self._invoke_llm(prompt, ctx_files, target)
798
+ if self.opts.dry_run:
799
+ self.emit("[llm_generate] dry-run: not writing")
800
+ return
801
+ os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
802
+ with open(target, "w", encoding="utf-8") as fh:
803
+ fh.write(content)
804
+ self.emit(f"[llm_generate] wrote {len(content)} chars")
805
+
806
+ def _invoke_llm(self, prompt, ctx_files, target):
807
+ backend = self.opts.llm
808
+ if backend.startswith("command:"):
809
+ cmd = backend[len("command:"):]
810
+ ctx_blob = ""
811
+ for c in ctx_files:
812
+ if os.path.exists(c):
813
+ ctx_blob += f"\n\n===== CONTEXT: {c} =====\n" + open(c, encoding="utf-8").read()
814
+ proc = subprocess.run(cmd, shell=True, input=prompt + ctx_blob,
815
+ capture_output=True, text=True)
816
+ if proc.returncode != 0:
817
+ raise YamlError(f"llm command failed: {proc.stderr.strip()}")
818
+ return proc.stdout
819
+ # mock backend — deterministic, no model call
820
+ return (
821
+ "<!-- generated by run-skill.py mock LLM backend -->\n"
822
+ f"<!-- target: {target} -->\n"
823
+ f"<!-- context files: {', '.join(ctx_files)} -->\n"
824
+ f"<!-- prompt chars: {len(prompt)} -->\n\n"
825
+ "# (mock content)\n\nReplace --llm mock with a real backend to author this file.\n"
826
+ )
827
+
828
+
829
+ # ─────────────────────────────────────────────────────────────────────────────
830
+ # Static validator (--validate): lints a skill.yaml without executing it.
831
+ # ─────────────────────────────────────────────────────────────────────────────
832
+ KNOWN_ACTIONS = {
833
+ "load_context", "verify_context", "execute_script",
834
+ "dispatch_hooks", "user_select", "llm_generate",
835
+ "loop", "ask", "llm_query", "set", "break", "include",
836
+ }
837
+ _SPECIAL_VARS = {"selection", "answer"}
838
+
839
+
840
+ def _refs_in(v):
841
+ found = set()
842
+ if isinstance(v, str):
843
+ for m in re.findall(r"\{\{\s*([^}]+?)\s*\}\}", v):
844
+ found.add(m.strip())
845
+ elif isinstance(v, list):
846
+ for x in v:
847
+ found |= _refs_in(x)
848
+ elif isinstance(v, dict):
849
+ for x in v.values():
850
+ found |= _refs_in(x)
851
+ return found
852
+
853
+
854
+ def validate_skill(skill, base_dir):
855
+ errors, warnings = [], []
856
+ if not isinstance(skill, dict):
857
+ return (["top-level YAML is not a mapping"], [])
858
+
859
+ wf = skill.get("workflow")
860
+ if not isinstance(wf, list) or not wf:
861
+ errors.append("missing or empty 'workflow' list")
862
+ wf = wf if isinstance(wf, list) else []
863
+ scripts = skill.get("scripts", {}) or {}
864
+
865
+ def walk(steps):
866
+ for s in steps or []:
867
+ if isinstance(s, dict):
868
+ yield s
869
+ if s.get("action") == "loop":
870
+ yield from walk(s.get("steps"))
871
+
872
+ produced = set(_SPECIAL_VARS)
873
+ for _, spec in (skill.get("inputs") or {}).items():
874
+ if isinstance(spec, dict) and spec.get("bind"):
875
+ produced.add(spec["bind"])
876
+ # pre-scan everything that produces variables (recursively) so ordering /
877
+ # forward references don't false-alarm
878
+ for step in walk(wf):
879
+ for o in step.get("outputs") or []:
880
+ if isinstance(o, dict) and o.get("name"):
881
+ produced.add(o["name"])
882
+ p = step.get("parameters", {}) or {}
883
+ act = step.get("action")
884
+ if act == "ask":
885
+ produced.add(p.get("answer_var", "answer"))
886
+ elif act == "set":
887
+ produced.update((step.get("parameters") or {}).keys())
888
+ elif act == "loop" and step.get("as"):
889
+ produced.add(step["as"])
890
+ produced.add(step["as"] + "_index")
891
+ elif act == "include":
892
+ # vars the module seeds + produces become available to the caller
893
+ produced.update((step.get("with") or {}).keys())
894
+ mpath = step.get("module", "")
895
+ for cand in (mpath, os.path.join(base_dir, mpath)):
896
+ if mpath and os.path.exists(cand):
897
+ try:
898
+ mwf = load_yaml(open(cand, encoding="utf-8").read()).get("workflow", [])
899
+ for ms in walk(mwf):
900
+ for o in ms.get("outputs") or []:
901
+ if isinstance(o, dict) and o.get("name"):
902
+ produced.add(o["name"])
903
+ mp = ms.get("parameters", {}) or {}
904
+ if ms.get("action") == "ask":
905
+ produced.add(mp.get("answer_var", "answer"))
906
+ elif ms.get("action") == "set":
907
+ produced.update((ms.get("parameters") or {}).keys())
908
+ except Exception:
909
+ pass
910
+ break
911
+
912
+ seen_ids = set()
913
+ for step in walk(wf):
914
+ loc = step.get("id") or "step"
915
+ sid = step.get("id")
916
+ if not sid:
917
+ warnings.append(f"{loc}: missing id")
918
+ elif sid in seen_ids:
919
+ errors.append(f"duplicate step id: {sid}")
920
+ else:
921
+ seen_ids.add(sid)
922
+
923
+ action = step.get("action")
924
+ if action not in KNOWN_ACTIONS:
925
+ errors.append(f"{loc}: unknown action '{action}' (known: {', '.join(sorted(KNOWN_ACTIONS))})")
926
+ p = step.get("parameters", {}) or {}
927
+
928
+ if action == "loop" and "over" not in step and "while" not in step:
929
+ errors.append(f"{loc}: loop needs 'over:' or 'while:'")
930
+ if action == "ask" and not (p.get("options_from") or p.get("options_from_variable") or p.get("options")):
931
+ warnings.append(f"{loc}: ask has no options source (free-text input)")
932
+ if action == "include":
933
+ m = step.get("module")
934
+ if not m:
935
+ errors.append(f"{loc}: include needs 'module'")
936
+ elif not (os.path.exists(m) or os.path.exists(os.path.join(base_dir, m))):
937
+ warnings.append(f"{loc}: include module not found on disk: {m}")
938
+ if action == "llm_query" and not (step.get("outputs")):
939
+ warnings.append(f"{loc}: llm_query has no outputs[] to bind")
940
+
941
+ if action == "execute_script":
942
+ ref = (p.get("script") or {}).get("ref")
943
+ if not ref:
944
+ errors.append(f"{loc}: execute_script needs parameters.script.ref")
945
+ elif ref not in scripts:
946
+ errors.append(f"{loc}: script ref '{ref}' not declared in top-level scripts:")
947
+ else:
948
+ variants = scripts.get(ref) or {}
949
+ found = False
950
+ for v in ("sh", "ps1"):
951
+ path = variants.get(v)
952
+ if path and (os.path.exists(path) or os.path.exists(os.path.join(base_dir, path))):
953
+ found = True
954
+ if not found:
955
+ warnings.append(f"{loc}: no script variant file found on disk for ref '{ref}'")
956
+ if not p.get("subcommand"):
957
+ errors.append(f"{loc}: execute_script needs parameters.subcommand")
958
+
959
+ if action == "user_select":
960
+ if not p.get("options_from_variable"):
961
+ errors.append(f"{loc}: user_select needs parameters.options_from_variable")
962
+
963
+ if action == "llm_generate":
964
+ if not p.get("prompt"):
965
+ warnings.append(f"{loc}: llm_generate has an empty prompt")
966
+ if not (p.get("output_target") or {}).get("path"):
967
+ errors.append(f"{loc}: llm_generate needs parameters.output_target.path")
968
+
969
+ used = _refs_in(wf) | _refs_in(skill.get("outputs", {}))
970
+ for name in sorted(used):
971
+ root = name.split(".")[0]
972
+ if root not in produced:
973
+ warnings.append(f"variable {{{{{name}}}}} referenced but never produced")
974
+
975
+ return errors, warnings
976
+
977
+
978
+ def main(argv=None):
979
+ ap = argparse.ArgumentParser(description="aw-spec declarative skill interpreter")
980
+ ap.add_argument("skill")
981
+ ap.add_argument("--input", default="")
982
+ ap.add_argument("--select", action="append", default=[], help="stepid=value")
983
+ ap.add_argument("--answer", action="append", default=[], help="key=value (for ask steps)")
984
+ ap.add_argument("--llm", default="mock")
985
+ ap.add_argument("--os", choices=["posix", "windows"],
986
+ default="windows" if os.name == "nt" else "posix")
987
+ ap.add_argument("--dry-run", action="store_true")
988
+ ap.add_argument("--non-interactive", action="store_true")
989
+ ap.add_argument("--validate", action="store_true",
990
+ help="lint the skill (parse + static checks) without executing")
991
+ ap.add_argument("--checkpoint", metavar="STATEFILE",
992
+ help="suspend/resume mode: persist state to STATEFILE and "
993
+ "suspend when the agent is needed (see docs)")
994
+ ap.add_argument("--session", default=os.environ.get("AW_SPEC_SESSION"),
995
+ help="agent session id (or $AW_SPEC_SESSION); enables cross-command "
996
+ "context dedup via .aw-spec/.context-ledger.json")
997
+ ap.add_argument("--session-reset", action="store_true",
998
+ help="clear the session context ledger and exit")
999
+ ap.add_argument("--json", action="store_true")
1000
+ opts = ap.parse_args(argv)
1001
+ opts.selects = dict(s.split("=", 1) for s in opts.select if "=" in s)
1002
+ opts.answers = dict(s.split("=", 1) for s in opts.answer if "=" in s)
1003
+
1004
+ # ---- session context ledger --------------------------------------------
1005
+ # Files loaded to context by an earlier command in the SAME agent session are
1006
+ # already in the model's window; later commands skip re-loading / re-attaching
1007
+ # them. Opt-in via --session / $AW_SPEC_SESSION (safe no-op when unset — e.g.
1008
+ # stateless headless runs where each model call has no memory).
1009
+ LEDGER = ".aw-spec/.context-ledger.json"
1010
+ if opts.session_reset:
1011
+ try:
1012
+ os.remove(LEDGER)
1013
+ msg = "context ledger reset"
1014
+ except FileNotFoundError:
1015
+ msg = "no context ledger to reset"
1016
+ print(json.dumps({"status": "ok", "message": msg}) if opts.json else msg)
1017
+ return 0
1018
+ opts._ledger_path = None
1019
+ opts._session_loaded = set()
1020
+ if opts.session:
1021
+ opts._ledger_path = LEDGER
1022
+ if os.path.exists(LEDGER):
1023
+ try:
1024
+ led = json.load(open(LEDGER, encoding="utf-8"))
1025
+ # only reuse the ledger when it belongs to THIS session id
1026
+ if led.get("session") == opts.session:
1027
+ opts._session_loaded = set(led.get("loaded", []))
1028
+ except Exception:
1029
+ pass
1030
+
1031
+ # --validate: parse + static checks only, never execute.
1032
+ if opts.validate:
1033
+ try:
1034
+ with open(opts.skill, encoding="utf-8") as fh:
1035
+ skill = load_yaml(fh.read())
1036
+ except (OSError, YamlError) as e:
1037
+ print(json.dumps({"status": "invalid", "errors": [f"parse: {e}"]})
1038
+ if opts.json else f"ERROR: parse: {e}")
1039
+ return 1
1040
+ errors, warnings = validate_skill(skill, os.path.dirname(os.path.abspath(opts.skill)))
1041
+ if opts.json:
1042
+ print(json.dumps({"status": "valid" if not errors else "invalid",
1043
+ "errors": errors, "warnings": warnings}, indent=2))
1044
+ else:
1045
+ for w in warnings:
1046
+ print("WARN: ", w)
1047
+ for e in errors:
1048
+ print("ERROR:", e)
1049
+ print(f"\n{opts.skill}: {len(errors)} error(s), {len(warnings)} warning(s)")
1050
+ return 1 if errors else 0
1051
+
1052
+ with open(opts.skill, encoding="utf-8") as fh:
1053
+ skill = load_yaml(fh.read())
1054
+
1055
+ # ---- checkpoint mode: load prior state + ingest the agent's response ------
1056
+ opts._cache = {}
1057
+ if opts.checkpoint and os.path.exists(opts.checkpoint):
1058
+ with open(opts.checkpoint, encoding="utf-8") as fh:
1059
+ state = json.load(fh)
1060
+ opts._cache = state.get("cache", {})
1061
+ # merge stored inputs; CLI-provided --answer/--input override/extend them
1062
+ if not opts.input:
1063
+ opts.input = state.get("input", "")
1064
+ merged = dict(state.get("answers", {}))
1065
+ merged.update(opts.answers)
1066
+ opts.answers = merged
1067
+ # ingest the response the agent wrote for the pending request
1068
+ pending = state.get("pending")
1069
+ if pending:
1070
+ rp = pending.get("response_path")
1071
+ if rp and os.path.exists(rp):
1072
+ raw = open(rp, encoding="utf-8").read()
1073
+ kind = pending["kind"]
1074
+ if kind == "llm_query":
1075
+ opts._cache[pending["step"]] = json.loads(
1076
+ raw.strip().splitlines()[-1] if raw.strip() else "{}")
1077
+ elif kind == "llm_generate":
1078
+ opts._cache[pending["step"]] = raw
1079
+ elif kind == "ask":
1080
+ val = raw.strip()
1081
+ opts._cache[pending["step"]] = (
1082
+ [s.strip() for s in val.split(",") if s.strip()] if pending.get("multi") else val)
1083
+ os.remove(rp)
1084
+ else:
1085
+ # no response yet — re-emit the same pending request
1086
+ _print_result({"status": "suspended", "pending": pending}, opts)
1087
+ return 2
1088
+
1089
+ interp = Interp(skill, opts)
1090
+ # bind declared inputs
1091
+ for _, spec in (skill.get("inputs") or {}).items():
1092
+ if isinstance(spec, dict) and spec.get("bind"):
1093
+ interp.vars[spec["bind"]] = opts.input
1094
+
1095
+ def _persist_ledger():
1096
+ # Record this run's loaded files into the session ledger — ONLY at a terminal
1097
+ # end (ok/stop), never on suspend (mid-run), so a checkpoint run's own resumes
1098
+ # keep seeing the pre-run snapshot and reload their context consistently.
1099
+ if not opts._ledger_path:
1100
+ return
1101
+ merged = sorted(set(opts._session_loaded) | set(interp.context))
1102
+ try:
1103
+ with open(opts._ledger_path, "w", encoding="utf-8") as fh:
1104
+ json.dump({"session": opts.session, "loaded": merged,
1105
+ "updated_at": __import__("time").strftime("%Y-%m-%dT%H:%M:%S")}, fh)
1106
+ except OSError:
1107
+ pass
1108
+
1109
+ rc = 0
1110
+ try:
1111
+ interp.run()
1112
+ result = {"status": "ok",
1113
+ "outputs": interp.resolve(skill.get("outputs", {})),
1114
+ "pending_generation": interp.pending,
1115
+ "vars": interp.vars}
1116
+ if opts.checkpoint and os.path.exists(opts.checkpoint):
1117
+ os.remove(opts.checkpoint) # finished — clear state
1118
+ _persist_ledger()
1119
+ except Suspend as e:
1120
+ # persist state and emit the request for the agent to fulfill (NOT the ledger)
1121
+ with open(opts.checkpoint, "w", encoding="utf-8") as fh:
1122
+ json.dump({"input": opts.input, "answers": opts.answers,
1123
+ "cache": interp.cache, "pending": e.request}, fh, indent=2)
1124
+ _print_result({"status": "suspended", "pending": e.request}, opts)
1125
+ return 2
1126
+ except Stop as e:
1127
+ result = {"status": "stopped", "reason": str(e), "vars": interp.vars}
1128
+ if opts.checkpoint and os.path.exists(opts.checkpoint):
1129
+ os.remove(opts.checkpoint)
1130
+ _persist_ledger()
1131
+ except YamlError as e:
1132
+ result = {"status": "error", "error": str(e), "vars": interp.vars}
1133
+ rc = 1
1134
+
1135
+ _print_result(result, opts)
1136
+ return rc
1137
+
1138
+
1139
+ def _print_result(result, opts):
1140
+ if opts.json:
1141
+ print(json.dumps(result, indent=2))
1142
+ return
1143
+ print(f"\n=== {result['status'].upper()} ===")
1144
+ if result.get("pending"):
1145
+ print("PENDING (agent action needed):")
1146
+ print(json.dumps(result["pending"], indent=2))
1147
+ if result.get("outputs"):
1148
+ print(json.dumps(result["outputs"], indent=2))
1149
+ if result.get("pending_generation"):
1150
+ print("PENDING_GENERATION:", json.dumps(result["pending_generation"], indent=2))
1151
+ if result.get("reason"):
1152
+ print(result["reason"])
1153
+ if result.get("error"):
1154
+ print("ERROR:", result["error"])
1155
+
1156
+
1157
+ if __name__ == "__main__":
1158
+ sys.exit(main())