harness-alchemist 0.1.6 → 0.1.8

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "harness-alchemist",
4
4
  "displayName": "Harness Alchemist",
5
- "version": "0.1.6",
5
+ "version": "0.1.8",
6
6
  "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
7
7
  "author": {
8
8
  "name": "Haochuan Zhang"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-alchemist",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
5
5
  "author": {
6
6
  "name": "Haochuan Zhang",
package/README.md CHANGED
@@ -127,6 +127,29 @@ echo '{"request": "hello"}' | node skills/<name>/scripts/main.mjs
127
127
  - Non-zero exit with a stderr diagnostic on failure.
128
128
  - `scripts/main.py` is a stdlib-only behavioral twin of `scripts/main.mjs`.
129
129
 
130
+ ## Install-level verification
131
+
132
+ `install-check` drives your local harness CLIs against the project and asserts
133
+ each one can discover the plugin — the same checks a user's install would
134
+ perform, automated and cleaned up afterwards:
135
+
136
+ ```bash
137
+ npx harness-alchemist@latest install-check /path/to/project
138
+ npx harness-alchemist@latest install-check . --harness agy --json
139
+ ```
140
+
141
+ | Harness | Verified by | Isolation |
142
+ | --- | --- | --- |
143
+ | Claude Code | marketplace add → install → `plugin details` skill inventory | user scope, auto-removed |
144
+ | Codex | marketplace add → plugin add → `plugin list` enabled | plugin cache, auto-removed |
145
+ | Antigravity | `plugin validate` → install → `plugin list` | staged, auto-uninstalled |
146
+ | OpenCode | skills discovery via `debug skill` + plugin startup | temp `XDG_CONFIG_HOME` |
147
+ | DeepSeek | Cordis bundle composed into profile (`--dump-config`) | temp `DSH_HOME` |
148
+
149
+ Claude, Codex, and Antigravity run in both runtimes; the OpenCode plugin leg
150
+ and the DeepSeek Cordis check require npm mode with built adapters
151
+ (`npm run build` first). Missing CLIs are reported as skipped, not failures.
152
+
130
153
  ## Validation tiers
131
154
 
132
155
  `npm run validate` always enforces Agent Skills frontmatter compliance, SKILL.md reference resolution, and twin parity. With the optional `pyodide` devDependency installed, Python entrypoints are additionally compiled and smoke-executed inside a WebAssembly CPython sandbox — no native Python required.
@@ -3,6 +3,7 @@
3
3
  import { readFile } from "node:fs/promises"
4
4
 
5
5
  import { listTemplates, runCreate } from "../lib/create.mjs"
6
+ import { runInstallCheck } from "../lib/install-check.mjs"
6
7
  import { runValidate } from "../lib/validate.mjs"
7
8
 
8
9
  const packageJson = JSON.parse(
@@ -17,6 +18,8 @@ Usage: harness-alchemist <command> [options]
17
18
  Commands:
18
19
  create <directory> Create a universal coding-agent plugin repository.
19
20
  validate [directory] Validate a generated repository.
21
+ install-check Install the plugin into local harness CLIs and verify
22
+ discovery (claude, codex, agy, opencode, dsh).
20
23
  templates List bundled canonical templates.
21
24
  version Print the CLI version.
22
25
  help Show this help.
@@ -36,6 +39,8 @@ if (!command || command === "help" || command === "--help" || command === "-h")
36
39
  process.exitCode = await runCreate(args)
37
40
  } else if (command === "validate") {
38
41
  process.exitCode = await runValidate(args)
42
+ } else if (command === "install-check") {
43
+ process.exitCode = await runInstallCheck(args)
39
44
  } else {
40
45
  console.error(`Unknown command: ${command}\n`)
41
46
  console.error(usage())
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readdirSync } from "node:fs"
4
+ import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
5
+ import { tmpdir } from "node:os"
6
+ import { dirname, join, resolve } from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+ import { spawnSync } from "node:child_process"
9
+
10
+ const TIMEOUT_MS = 300_000
11
+
12
+ function findProjectRoot(start) {
13
+ let current = resolve(start)
14
+ let conventionalRoot
15
+ while (true) {
16
+ if (existsSync(join(current, "alchemy.json"))) return current
17
+ if (
18
+ !conventionalRoot &&
19
+ existsSync(join(current, "package.json")) &&
20
+ existsSync(join(current, ".claude-plugin")) &&
21
+ existsSync(join(current, ".codex-plugin"))
22
+ ) {
23
+ conventionalRoot = current
24
+ }
25
+ const parent = dirname(current)
26
+ if (parent === current) return conventionalRoot
27
+ current = parent
28
+ }
29
+ }
30
+
31
+ function run(command, args, options = {}) {
32
+ const result = spawnSync(command, args, {
33
+ encoding: "utf8",
34
+ timeout: TIMEOUT_MS,
35
+ ...options,
36
+ })
37
+ if (result.error?.code === "ENOENT") return { missing: true }
38
+ return {
39
+ ok: result.status === 0,
40
+ status: result.status,
41
+ stdout: result.stdout ?? "",
42
+ stderr: result.stderr ?? "",
43
+ }
44
+ }
45
+
46
+ function firstLine(text) {
47
+ return text.trim().split("\n")[0] ?? ""
48
+ }
49
+
50
+ function usage() {
51
+ return `Usage: harness-alchemist install-check [project-directory] [options]
52
+
53
+ Install-level verification: drives the local harness CLIs (claude, codex,
54
+ agy, opencode, dsh) against the project's plugin package and asserts each
55
+ harness can discover it. Static validation is a prerequisite; run
56
+ 'harness-alchemist validate' first.
57
+
58
+ Options:
59
+ --harness <id> Limit to one harness (claude, codex, agy, opencode, dsh).
60
+ Repeatable.
61
+ --keep Keep installed plugins and marketplaces after the check.
62
+ --json Print a machine-readable result.
63
+ --help Show this help.`
64
+ }
65
+
66
+ function parseArgs(argv) {
67
+ const options = { harnesses: [], keep: false, json: false }
68
+ for (let index = 0; index < argv.length; index += 1) {
69
+ const arg = argv[index]
70
+ if (arg === "--help" || arg === "-h") return { help: true }
71
+ if (arg === "--keep") options.keep = true
72
+ else if (arg === "--json") options.json = true
73
+ else if (arg === "--harness") {
74
+ const value = argv[index + 1]
75
+ if (!value || value.startsWith("--")) throw new Error("--harness requires a value")
76
+ if (!["claude", "codex", "agy", "opencode", "dsh"].includes(value)) {
77
+ throw new Error(`Unknown harness '${value}'. Choose from claude, codex, agy, opencode, dsh`)
78
+ }
79
+ options.harnesses.push(value)
80
+ index += 1
81
+ } else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`)
82
+ else if (options.project) throw new Error("Only one project directory may be supplied")
83
+ else options.project = arg
84
+ }
85
+ return options
86
+ }
87
+
88
+ async function readJson(path, problems) {
89
+ try {
90
+ return JSON.parse(await readFile(path, "utf8"))
91
+ } catch (error) {
92
+ problems.push(`${path}: invalid JSON (${error.message})`)
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ function skillNames(skillsDirectory) {
98
+ if (!existsSync(skillsDirectory)) return []
99
+ return readdirSync(skillsDirectory)
100
+ .filter((entry) => existsSync(join(skillsDirectory, entry, "SKILL.md")))
101
+ }
102
+
103
+ export async function runInstallCheck(argv) {
104
+ let options
105
+ try {
106
+ options = parseArgs(argv)
107
+ } catch (error) {
108
+ console.error(error.message)
109
+ console.error(usage())
110
+ return 2
111
+ }
112
+ if (options.help) {
113
+ console.log(usage())
114
+ return 0
115
+ }
116
+
117
+ const scriptDirectory = dirname(fileURLToPath(import.meta.url))
118
+ const root = options.project
119
+ ? resolve(options.project)
120
+ : findProjectRoot(process.cwd()) ?? findProjectRoot(scriptDirectory)
121
+ if (!root) {
122
+ console.error("Could not find a universal plugin project. Pass its directory explicitly.")
123
+ return 2
124
+ }
125
+
126
+ const problems = []
127
+ const layoutPath = join(root, "alchemy.json")
128
+ const layout = existsSync(layoutPath) ? (await readJson(layoutPath, problems)) ?? {} : {}
129
+ const pluginRoot = resolve(root, layout.pluginRoot ?? ".")
130
+ const runtime = layout.runtime ?? "npm"
131
+
132
+ const claudePlugin = await readJson(join(pluginRoot, ".claude-plugin/plugin.json"), problems)
133
+ const claudeMarketplace = await readJson(join(root, ".claude-plugin/marketplace.json"), problems)
134
+ if (problems.length > 0) {
135
+ for (const problem of problems) console.error(problem)
136
+ return 1
137
+ }
138
+
139
+ const pluginName = claudePlugin.name
140
+ const marketplaceName = claudeMarketplace.name
141
+ const skills = skillNames(join(pluginRoot, "skills"))
142
+ const selected = options.harnesses.length > 0
143
+ ? options.harnesses
144
+ : ["claude", "codex", "agy", "opencode", "dsh"]
145
+
146
+ const results = []
147
+ const record = (harness, status, details) => {
148
+ results.push({ harness, status, details })
149
+ if (options.json) return
150
+ const marker = status === "pass" ? "✔" : status === "skip" ? "○" : "✖"
151
+ console.log(`${marker} ${harness}: ${status}`)
152
+ for (const detail of details) console.log(` ${detail}`)
153
+ }
154
+
155
+ const adapterPath = join(pluginRoot, "dist/opencode.js")
156
+
157
+ for (const harness of selected) {
158
+ if (harness === "claude") {
159
+ const details = []
160
+ let status = "pass"
161
+ const steps = [
162
+ ["validate", pluginRoot, "--strict"],
163
+ ["marketplace", "add", root],
164
+ ["install", `${pluginName}@${marketplaceName}`],
165
+ ["details", pluginName],
166
+ ]
167
+ for (const args of steps) {
168
+ const result = run("claude", ["plugin", ...args])
169
+ if (result.missing) { status = "skip"; details.push("claude CLI not found"); break }
170
+ if (!result.ok) {
171
+ status = "fail"
172
+ details.push(`\`${command} ${args.join(" ")}\` failed: ${firstLine(result.stderr || result.stdout)}`)
173
+ break
174
+ }
175
+ if (args[0] === "details") {
176
+ for (const skill of skills) {
177
+ if (!result.stdout.includes(skill)) {
178
+ status = "fail"
179
+ details.push(`skill '${skill}' missing from plugin details`)
180
+ }
181
+ }
182
+ details.push(`${skills.length} skills registered`)
183
+ }
184
+ }
185
+ if (!options.keep) {
186
+ run("claude", ["plugin", "uninstall", `${pluginName}@${marketplaceName}`])
187
+ run("claude", ["plugin", "marketplace", "remove", marketplaceName])
188
+ }
189
+ record(harness, status, details)
190
+ } else if (harness === "codex") {
191
+ const details = []
192
+ let status = "pass"
193
+ const add = run("codex", ["plugin", "marketplace", "add", root])
194
+ if (add.missing) {
195
+ record(harness, "skip", ["codex CLI not found"])
196
+ continue
197
+ }
198
+ if (!add.ok) {
199
+ record(harness, "fail", [`marketplace add failed: ${firstLine(add.stderr || add.stdout)}`])
200
+ continue
201
+ }
202
+ const install = run("codex", ["plugin", "add", `${pluginName}@${marketplaceName}`])
203
+ if (!install.ok) {
204
+ status = "fail"
205
+ details.push(`plugin add failed: ${firstLine(install.stderr || install.stdout)}`)
206
+ } else {
207
+ const list = run("codex", ["plugin", "list"])
208
+ if (!list.ok || !list.stdout.includes(`${pluginName}@${marketplaceName}`)) {
209
+ status = "fail"
210
+ details.push("plugin not listed as installed")
211
+ } else if (!list.stdout.includes("installed, enabled")) {
212
+ status = "fail"
213
+ details.push("plugin installed but not enabled")
214
+ } else {
215
+ details.push("installed and enabled")
216
+ }
217
+ }
218
+ if (!options.keep) {
219
+ run("codex", ["plugin", "remove", `${pluginName}@${marketplaceName}`])
220
+ run("codex", ["plugin", "marketplace", "remove", marketplaceName])
221
+ }
222
+ record(harness, status, details)
223
+ } else if (harness === "agy") {
224
+ const details = []
225
+ const validate = run("agy", ["plugin", "validate", pluginRoot])
226
+ if (validate.missing) {
227
+ record(harness, "skip", ["agy CLI not found"])
228
+ continue
229
+ }
230
+ if (!validate.ok) {
231
+ record(harness, "fail", [`validate failed: ${firstLine(validate.stderr || validate.stdout)}`])
232
+ continue
233
+ }
234
+ const install = run("agy", ["plugin", "install", pluginRoot])
235
+ if (!install.ok) {
236
+ record(harness, "fail", [`install failed: ${firstLine(install.stderr || install.stdout)}`])
237
+ continue
238
+ }
239
+ const list = run("agy", ["plugin", "list"])
240
+ if (!list.ok || !list.stdout.includes(pluginName)) {
241
+ record(harness, "fail", ["plugin missing from agy plugin list"])
242
+ continue
243
+ }
244
+ details.push("validated, installed, and listed")
245
+ if (!options.keep) run("agy", ["plugin", "uninstall", pluginName])
246
+ record(harness, "pass", details)
247
+ } else if (harness === "opencode") {
248
+ if (runtime === "npm" && !existsSync(adapterPath)) {
249
+ record(harness, "fail", [
250
+ `npm runtime requires a built adapter; missing ${adapterPath}`,
251
+ "run `npm run build` in the plugin package first",
252
+ ])
253
+ continue
254
+ }
255
+ const isolation = await mkdtemp(join(tmpdir(), "ha-opencode-"))
256
+ const configDirectory = join(isolation, "opencode")
257
+ const skillsDirectory = join(configDirectory, "skills")
258
+ await cp(join(pluginRoot, "skills"), skillsDirectory, { recursive: true })
259
+ const config = { $schema: "https://opencode.ai/config.json" }
260
+ if (runtime === "npm") {
261
+ config.plugin = [`file://${adapterPath}`]
262
+ }
263
+ await writeFile(join(configDirectory, "opencode.json"), `${JSON.stringify(config, null, 2)}\n`)
264
+ const environment = {
265
+ ...process.env,
266
+ XDG_CONFIG_HOME: isolation,
267
+ HOME: isolation,
268
+ }
269
+ const skillCheck = run("opencode", ["debug", "skill"], { env: environment })
270
+ if (skillCheck.missing) {
271
+ await rm(isolation, { recursive: true, force: true })
272
+ record(harness, "skip", ["opencode CLI not found"])
273
+ continue
274
+ }
275
+ const details = []
276
+ let status = "pass"
277
+ const missing = skills.filter((skill) => !skillCheck.stdout.includes(`"name": "${skill}"`))
278
+ if (missing.length > 0) {
279
+ status = "fail"
280
+ details.push(`skills not discovered: ${missing.join(", ")}`)
281
+ } else {
282
+ details.push(`${skills.length} skills discovered`)
283
+ }
284
+ if (runtime === "npm" && status === "pass") {
285
+ const startup = run("opencode", ["debug", "startup"], { env: environment })
286
+ if (!startup.ok) {
287
+ status = "fail"
288
+ details.push(`startup failed: ${firstLine(startup.stderr || startup.stdout)}`)
289
+ } else {
290
+ details.push("plugin loaded at startup")
291
+ }
292
+ }
293
+ await rm(isolation, { recursive: true, force: true })
294
+ record(harness, status, details)
295
+ } else if (harness === "dsh") {
296
+ if (runtime !== "npm") {
297
+ record(harness, "skip", ["skills runtime has no Cordis bundle; install skills via filesystem roots instead"])
298
+ continue
299
+ }
300
+ if (!existsSync(adapterPath)) {
301
+ record(harness, "fail", [
302
+ `npm runtime requires built adapters; missing ${join(pluginRoot, "dist/deepseek.js")}`,
303
+ "run `npm run build` in the plugin package first",
304
+ ])
305
+ continue
306
+ }
307
+ const dshHome = await mkdtemp(join(tmpdir(), "ha-dsh-"))
308
+ const environment = { ...process.env, DSH_HOME: dshHome }
309
+ let dshCommand = "dsh"
310
+ let dshPrefix = []
311
+ if (spawnSync("dsh", ["--version"], { encoding: "utf8" }).error?.code === "ENOENT") {
312
+ dshCommand = "npx"
313
+ dshPrefix = ["-y", "@deepseek-ai/dsh"]
314
+ }
315
+ const add = run(dshCommand, [...dshPrefix, "plugin", "--profile", "install-check", "add", pluginRoot], { env: environment })
316
+ if (add.missing) {
317
+ await rm(dshHome, { recursive: true, force: true })
318
+ record(harness, "skip", ["dsh CLI not found and npx unavailable"])
319
+ continue
320
+ }
321
+ if (!add.ok) {
322
+ await rm(dshHome, { recursive: true, force: true })
323
+ record(harness, "fail", [`profile add failed: ${firstLine(add.stderr || add.stdout)}`])
324
+ continue
325
+ }
326
+ const dump = run(dshCommand, [...dshPrefix, "--profile", "install-check", "--dump-config"], { env: environment })
327
+ const details = []
328
+ let status = "pass"
329
+ if (!dump.ok || !dump.stdout.includes(`- id: ${pluginName}`)) {
330
+ status = "fail"
331
+ details.push("cordis insert entry missing from composed profile")
332
+ } else {
333
+ details.push("cordis bundle composed into isolated profile")
334
+ }
335
+ await rm(dshHome, { recursive: true, force: true })
336
+ record(harness, status, details)
337
+ }
338
+ }
339
+
340
+ const failed = results.filter((entry) => entry.status === "fail")
341
+ const skipped = results.filter((entry) => entry.status === "skip")
342
+ if (options.json) {
343
+ console.log(JSON.stringify({
344
+ project: root,
345
+ plugin: pluginName,
346
+ runtime,
347
+ results,
348
+ summary: {
349
+ pass: results.length - failed.length - skipped.length,
350
+ fail: failed.length,
351
+ skip: skipped.length,
352
+ },
353
+ }, null, 2))
354
+ } else {
355
+ console.log(
356
+ failed.length === 0
357
+ ? `Install check passed for ${pluginName} (${results.length - skipped.length} verified, ${skipped.length} skipped)`
358
+ : `Install check failed for ${pluginName}: ${failed.length} of ${results.length} harnesses failed`,
359
+ )
360
+ }
361
+
362
+ return failed.length > 0 ? 1 : 0
363
+ }
package/lib/validate.mjs CHANGED
@@ -9,7 +9,6 @@ import { spawnSync } from "node:child_process"
9
9
  const PROJECT_REQUIRED_FILES = [
10
10
  ".agents/plugins/marketplace.json",
11
11
  ".claude-plugin/marketplace.json",
12
- ".github/workflows/npm-publish.yml",
13
12
  ".gitignore",
14
13
  "AGENTS.md",
15
14
  "LICENSE",
@@ -263,7 +262,7 @@ except SystemExit as exit_code:
263
262
  _buffer.getvalue()
264
263
  `
265
264
 
266
- async function smokePythonScripts(scripts, errors, warnings) {
265
+ async function smokePythonScripts(scripts, errors, warnings, execSmoke = true) {
267
266
  let loadPyodide
268
267
  try {
269
268
  ;({ loadPyodide } = await import("pyodide"))
@@ -280,17 +279,26 @@ async function smokePythonScripts(scripts, errors, warnings) {
280
279
  return
281
280
  }
282
281
 
282
+ if (!execSmoke) {
283
+ warnings.push("skills runtime: Python entrypoints are syntax-checked without execution")
284
+ }
285
+
283
286
  for (const script of scripts) {
284
287
  const source = await readFile(script, "utf8")
285
288
  instance.globals.set("__ha_source__", source)
286
- instance.globals.set("__ha_payload__", "{}")
287
289
  let output
288
290
  try {
289
- output = instance.runPython(PYTHON_SMOKE_PROGRAM)
291
+ if (execSmoke) {
292
+ instance.globals.set("__ha_payload__", "{}")
293
+ output = instance.runPython(PYTHON_SMOKE_PROGRAM)
294
+ } else {
295
+ instance.runPython('compile(__ha_source__, "<skill>", "exec")\nNone')
296
+ }
290
297
  } catch (error) {
291
298
  errors.push(`${script}: WebAssembly Python check failed (${String(error).split("\n").at(-2) ?? String(error)})`)
292
299
  continue
293
300
  }
301
+ if (!execSmoke) continue
294
302
  try {
295
303
  const parsed = JSON.parse(output)
296
304
  if (parsed?.ok !== true || typeof parsed.plugin !== "string") {
@@ -301,8 +309,13 @@ async function smokePythonScripts(scripts, errors, warnings) {
301
309
  }
302
310
  }
303
311
 
304
- instance.globals.delete("__ha_source__")
305
- instance.globals.delete("__ha_payload__")
312
+ for (const key of ["__ha_source__", "__ha_payload__"]) {
313
+ try {
314
+ instance.globals.delete(key)
315
+ } catch {
316
+ // Key was never set in this session.
317
+ }
318
+ }
306
319
  }
307
320
 
308
321
  async function checkProductSkillRuntime(skillFile, content, frontmatter, errors, pythonScripts, runtime) {
@@ -434,6 +447,7 @@ export async function validateProject(projectRoot, options = {}) {
434
447
  if (errors.length > 0) return { root, errors, warnings }
435
448
 
436
449
  for (const file of PROJECT_REQUIRED_FILES) requirePath(root, file, errors)
450
+ if (runtime === "npm") requirePath(root, ".github/workflows/npm-publish.yml", errors)
437
451
  const pluginRequiredFiles = runtime === "npm"
438
452
  ? [...PLUGIN_REQUIRED_FILES, ...NPM_PLUGIN_REQUIRED_FILES]
439
453
  : PLUGIN_REQUIRED_FILES
@@ -605,7 +619,7 @@ export async function validateProject(projectRoot, options = {}) {
605
619
  }
606
620
 
607
621
  if (pythonScripts.size > 0) {
608
- await smokePythonScripts([...pythonScripts], errors, warnings)
622
+ await smokePythonScripts([...pythonScripts], errors, warnings, runtime === "npm")
609
623
  }
610
624
 
611
625
  for (const path of await scanForTokens(root)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-alchemist",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Before Publishing
4
4
 
5
+ Run the full verification ladder before distributing:
6
+
7
+ 1. `harness-alchemist validate <project>` — static contract for all harnesses.
8
+ 2. The project's own `verify` script — build plus runtime delegation tests.
9
+ 3. `harness-alchemist install-check <project>` — drives the local harness CLIs
10
+ (claude, codex, agy, opencode, dsh) and asserts real discovery. Claude,
11
+ Codex, and Antigravity verify in both runtimes; the OpenCode plugin leg and
12
+ DeepSeek Cordis check need npm mode with built adapters. Missing CLIs are
13
+ reported as skipped. Use `--keep` to leave installs in place for manual
14
+ inspection, and `--harness <id>` to target one harness.
15
+
5
16
  Run:
6
17
 
7
18
  ```bash