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