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,1158 +0,0 @@
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())