skilld-harness 3.1.0 → 3.3.0

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/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_OUTPUT_POLICY, isSkillName, loadSkilldMaintainedSkill, normalizeOutputPath, resolveWithin } from "./_chunks/skills.mjs";
2
2
  import { lstat, mkdir, open, opendir, realpath, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
3
- import { dirname, join, posix, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
4
4
  import { HarnessAgent } from "@ai-sdk/harness/agent";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { parseDocument } from "yaml";
@@ -275,14 +275,16 @@ const promoteSkill = async (rootDir, name, files, attempts) => {
275
275
  if (backupCleanup instanceof Error) warnings.push(`Previous Skill cleanup failed at ${backup}: ${backupCleanup.message}`);
276
276
  }
277
277
  return ok({
278
- _tag: "GeneratedSkill",
279
- name,
280
- outputDir: target,
281
- files: files.map((file) => ({
282
- path: file.path,
283
- bytes: file.content.byteLength
284
- })),
285
- sourceAttempts: attempts,
278
+ skill: {
279
+ _tag: "GeneratedSkill",
280
+ name,
281
+ outputDir: target,
282
+ files: files.map((file) => ({
283
+ path: file.path,
284
+ bytes: file.content.byteLength
285
+ })),
286
+ sourceAttempts: attempts
287
+ },
286
288
  warnings
287
289
  });
288
290
  }
@@ -301,14 +303,65 @@ const promoteSkill = async (rootDir, name, files, attempts) => {
301
303
  cause
302
304
  });
303
305
  };
304
- const allowedFrontmatter = /* @__PURE__ */ new Set([
305
- "name",
306
- "description",
307
- "license",
308
- "compatibility",
309
- "metadata",
310
- "allowed-tools"
311
- ]);
306
+ const searchCommand = /\b(?:git grep|grep|rg)\b/;
307
+ const inlineCode = /`([^`\n]+)`/g;
308
+ const fencedBlock = /```[^\n]*\n[\s\S]*?```/g;
309
+ const denial = /\b(no|not|none|never|absent|missing|lacks|without|instead|rather|empty|undeclared|unavailable|nothing)\b|\bdoes ?n[o']t\b/i;
310
+ const creation = /\b(?:copy|create|write|generate)\b/i;
311
+ function fencedBlocks(markdown) {
312
+ return markdown.match(fencedBlock) ?? [];
313
+ }
314
+ function proseParagraphs(markdown) {
315
+ return markdown.replace(fencedBlock, "").split(/\n[ \t]*\n/).map((paragraph) => paragraph.replace(/\n/g, " ").trim()).filter((paragraph) => paragraph.length > 0);
316
+ }
317
+ const pathExtension = /\.(?:md|mdx|json|jsonc|ts|tsx|mts|cts|js|mjs|cjs|jsx|vue|svelte|txt|yml|yaml|toml|html|css|scss|rs|py|go|sh|lock|env|csv|xml)$/i;
318
+ const versionShape = /^v?\d+(?:\.\d+)*$/i;
319
+ const productName = /^[A-Za-z][A-Za-z0-9_]*\.[A-Za-z0-9]+$/;
320
+ function pathCandidate(token) {
321
+ if (token.length === 0 || /\s/.test(token)) return null;
322
+ if (/^[-$#<{[]/.test(token)) return null;
323
+ if (token.includes("://") || /[()|;&"'@:]/.test(token)) return null;
324
+ const trimmed = token.replace(/^\.\//, "").replace(/\/+$/, "");
325
+ if (trimmed.length === 0 || trimmed.startsWith("..")) return null;
326
+ if (versionShape.test(trimmed) || productName.test(trimmed) || /\/\d+$/.test(trimmed)) return null;
327
+ if (!trimmed.includes("/") && !pathExtension.test(trimmed)) return null;
328
+ return trimmed;
329
+ }
330
+ function literalPrefix(candidate) {
331
+ const wildcard = candidate.search(/[*?]/);
332
+ if (wildcard === -1) return candidate;
333
+ return candidate.slice(0, wildcard).replace(/[^/]*$/, "").replace(/\/+$/, "");
334
+ }
335
+ function resolves(candidate, known) {
336
+ if (known.has(candidate)) return true;
337
+ const prefix = literalPrefix(candidate);
338
+ if (prefix.length === 0) return true;
339
+ for (const path of known) {
340
+ if (path.startsWith(`${candidate}.`)) return true;
341
+ if (path === prefix || path.startsWith(`${prefix}/`)) return true;
342
+ }
343
+ return false;
344
+ }
345
+ function checkProjectSkill(input) {
346
+ const issues = [];
347
+ const known = /* @__PURE__ */ new Set([...input.projectPaths, ...input.outputPaths]);
348
+ const unknown = /* @__PURE__ */ new Set();
349
+ for (const paragraph of proseParagraphs(input.markdown)) {
350
+ if (denial.test(paragraph) || creation.test(paragraph)) continue;
351
+ for (const match of paragraph.matchAll(inlineCode)) {
352
+ const candidate = pathCandidate(match[1].trim());
353
+ if (candidate !== null && !resolves(candidate, known)) unknown.add(candidate);
354
+ }
355
+ }
356
+ for (const path of [...unknown].sort()) issues.push(`SKILL.md points at a path the project does not contain: ${path}.`);
357
+ const inline = [...input.markdown.matchAll(inlineCode)].map((match) => match[1]);
358
+ const commands = [...fencedBlocks(input.markdown), ...inline].join("\n");
359
+ if (!searchCommand.test(commands)) issues.push("SKILL.md must give the Agent at least one search command it can repeat.");
360
+ return issues;
361
+ }
362
+ const allowedFrontmatter = /* @__PURE__ */ new Set(["name", "description"]);
363
+ const packageSkillLineLimit = 500;
364
+ const packageSkillReferenceLimit = 8;
312
365
  const invalid = (issues) => err({
313
366
  _tag: "InvalidSkill",
314
367
  message: "Skill output failed deterministic checks.",
@@ -321,13 +374,44 @@ function decodeText(content) {
321
374
  return null;
322
375
  }
323
376
  }
324
- const isStringMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
325
- const validateGeneratedSkill = (name, files) => {
377
+ function linkTargets(markdown) {
378
+ const targets = /* @__PURE__ */ new Set();
379
+ const add = (raw) => {
380
+ const target = raw.trim().replace(/^<|>$/g, "").split(/\s/)[0].replace(/[#?].*$/, "").replace(/^\.\//, "");
381
+ if (target.length > 0) targets.add(target);
382
+ };
383
+ for (const match of markdown.matchAll(/\]\(([^)\n]+)\)/g)) add(match[1]);
384
+ for (const match of markdown.matchAll(/^\s*\[[^\]\n]+\]:\s*(\S+)/gm)) add(match[1]);
385
+ for (const match of markdown.matchAll(/`([^`\n]+)`/g)) add(match[1]);
386
+ return targets;
387
+ }
388
+ function lineCount(text) {
389
+ const lines = text.split("\n");
390
+ return lines.at(-1) === "" ? lines.length - 1 : lines.length;
391
+ }
392
+ function structureIssues(markdown, files, contract) {
393
+ const issues = [];
394
+ const referencePaths = files.map((file) => file.path).filter((path) => path.startsWith("references/")).sort();
395
+ if (contract._tag === "PackageSkill") {
396
+ const lines = lineCount(markdown);
397
+ if (lines >= packageSkillLineLimit) issues.push(`SKILL.md must stay under ${packageSkillLineLimit} lines; it has ${lines}.`);
398
+ if (referencePaths.length > packageSkillReferenceLimit) issues.push(`A package Skill may write at most ${packageSkillReferenceLimit} reference files; it wrote ${referencePaths.length}.`);
399
+ }
400
+ const linked = linkTargets(markdown);
401
+ for (const path of referencePaths) if (!linked.has(path)) issues.push(`SKILL.md must link ${path}.`);
402
+ return issues;
403
+ }
404
+ const validateGeneratedSkill = (name, files, contract) => {
326
405
  const issues = [];
327
406
  const skillFiles = files.filter((file) => file.path === "SKILL.md");
328
407
  if (skillFiles.length !== 1) return invalid(["Output must contain exactly one SKILL.md file."]);
329
408
  const source = decodeText(skillFiles[0].content);
330
409
  if (source === null) return invalid(["SKILL.md must contain valid UTF-8 text."]);
410
+ if (contract._tag === "ProjectSkill") issues.push(...checkProjectSkill({
411
+ markdown: source,
412
+ projectPaths: contract.projectPaths,
413
+ outputPaths: files.map((file) => file.path)
414
+ }));
331
415
  const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
332
416
  if (!match) return invalid(["SKILL.md must start with YAML frontmatter."]);
333
417
  if (match[2]?.trim().length === 0) issues.push("SKILL.md instructions must not be empty.");
@@ -344,10 +428,7 @@ const validateGeneratedSkill = (name, files) => {
344
428
  if (frontmatter.name !== name) issues.push("Frontmatter name must match the Skill directory name.");
345
429
  if (typeof frontmatter.name !== "string" || !isSkillName(frontmatter.name)) issues.push("Frontmatter name is invalid.");
346
430
  if (typeof frontmatter.description !== "string" || frontmatter.description.trim().length === 0 || frontmatter.description.length > 1024) issues.push("Frontmatter description must contain 1 to 1024 characters.");
347
- if (frontmatter.license !== void 0 && typeof frontmatter.license !== "string") issues.push("Frontmatter license must be a string.");
348
- if (frontmatter.compatibility !== void 0 && (typeof frontmatter.compatibility !== "string" || frontmatter.compatibility.length > 500)) issues.push("Frontmatter compatibility must be a string of at most 500 characters.");
349
- if (frontmatter.metadata !== void 0 && !isStringMap(frontmatter.metadata)) issues.push("Frontmatter metadata must map strings to strings.");
350
- if (frontmatter["allowed-tools"] !== void 0 && typeof frontmatter["allowed-tools"] !== "string") issues.push("Frontmatter allowed-tools must be a string.");
431
+ issues.push(...structureIssues(source, files, contract));
351
432
  return issues.length === 0 ? ok(void 0) : invalid(issues);
352
433
  };
353
434
  const findingLevels = /* @__PURE__ */ new Set([
@@ -391,6 +472,9 @@ const validateSkillReview = (files) => {
391
472
  findings
392
473
  });
393
474
  };
475
+ function skippedFileWarnings(skipped, policy) {
476
+ return skipped.map((file) => `Source file ${file.path} was left out: ${file.bytes} bytes exceeds the ${policy.maxSourceFileBytes} byte file limit.`);
477
+ }
394
478
  const ignoredNames = /* @__PURE__ */ new Set([
395
479
  ".git",
396
480
  ".hg",
@@ -428,7 +512,10 @@ async function collectHostDirectory(directory, policy, sourceLabel = directory,
428
512
  if (canonicalRoot instanceof Error) return unavailable("Source directory cannot be resolved.", canonicalRoot);
429
513
  if (canonicalRoot !== root) return unavailable("Source path must not pass through a symbolic link.");
430
514
  const files = [];
515
+ const skippedFiles = [];
431
516
  let totalBytes = 0;
517
+ const relativePath = (absolute) => relative(root, absolute).split(sep).join("/");
518
+ const entryUnavailable = (absolute, message, cause, fix) => unavailable(`${message}: ${relativePath(absolute)}.${fix === void 0 ? "" : ` ${fix}`}`, cause);
432
519
  const walk = async (current) => {
433
520
  if (signal?.aborted) return cancelled();
434
521
  const canonicalCurrent = await realpath(current).catch((error) => error);
@@ -440,28 +527,32 @@ async function collectHostDirectory(directory, policy, sourceLabel = directory,
440
527
  if (ignoredNames.has(entry.name)) continue;
441
528
  const absolute = join(current, entry.name);
442
529
  const stat = await lstat(absolute).catch((error) => error);
443
- if (stat instanceof Error) return unavailable("Source entry cannot be read.", stat);
444
- if (stat.isSymbolicLink()) return unavailable("Source contains a symbolic link.");
530
+ if (stat instanceof Error) return entryUnavailable(absolute, "Source entry cannot be read", stat);
531
+ if (stat.isSymbolicLink()) return entryUnavailable(absolute, "Source contains a symbolic link", void 0, "Remove the link, or pass a clean export such as a `git archive` of the directory.");
445
532
  if (stat.isDirectory()) {
446
533
  const nested = await walk(absolute);
447
534
  if (nested._tag === "Err") return nested;
448
535
  continue;
449
536
  }
450
- if (!stat.isFile()) return unavailable("Source contains a special file.");
451
- const path = relative(root, absolute).split(sep).join("/");
537
+ if (!stat.isFile()) return entryUnavailable(absolute, "Source contains a special file");
538
+ const path = relativePath(absolute);
452
539
  const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => error);
453
- if (handle instanceof Error) return unavailable("Source file cannot be opened without following links.", handle);
540
+ if (handle instanceof Error) return entryUnavailable(absolute, "Source file cannot be opened without following links", handle);
454
541
  const openedStat = await handle.stat().catch((error) => error);
455
542
  if (openedStat instanceof Error) {
456
543
  await handle.close();
457
- return unavailable("Source file cannot be inspected.", openedStat);
544
+ return entryUnavailable(absolute, "Source file cannot be inspected", openedStat);
458
545
  }
459
546
  if (!openedStat.isFile() || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) {
460
547
  await handle.close();
461
- return unavailable("Source file changed during collection.");
548
+ return entryUnavailable(absolute, "Source file changed during collection");
462
549
  }
463
550
  if (openedStat.size > policy.maxSourceFileBytes) {
464
551
  await handle.close();
552
+ skippedFiles.push({
553
+ path,
554
+ bytes: openedStat.size
555
+ });
465
556
  continue;
466
557
  }
467
558
  if (files.length >= policy.maxSourceFiles) {
@@ -474,8 +565,8 @@ async function collectHostDirectory(directory, policy, sourceLabel = directory,
474
565
  }
475
566
  const content = await handle.readFile().catch((error) => error);
476
567
  await handle.close();
477
- if (content instanceof Error) return unavailable("Source file cannot be read.", content);
478
- if (content.byteLength !== openedStat.size) return unavailable("Source file changed during collection.");
568
+ if (content instanceof Error) return entryUnavailable(absolute, "Source file cannot be read", content);
569
+ if (content.byteLength !== openedStat.size) return entryUnavailable(absolute, "Source file changed during collection");
479
570
  if (signal?.aborted) return cancelled();
480
571
  files.push({
481
572
  path,
@@ -489,8 +580,10 @@ async function collectHostDirectory(directory, policy, sourceLabel = directory,
489
580
  if (walked._tag === "Err") return walked;
490
581
  if (files.length === 0) return unavailable("Source directory has no usable files.");
491
582
  files.sort((left, right) => left.path.localeCompare(right.path));
583
+ skippedFiles.sort((left, right) => left.path.localeCompare(right.path));
492
584
  return ok({
493
585
  files,
586
+ skippedFiles,
494
587
  attempts: [{
495
588
  source: sourceLabel,
496
589
  status: "used"
@@ -558,6 +651,7 @@ async function extractArchive(compressed, policy, layout) {
558
651
  source.on("error", (error) => gunzip.destroy(error));
559
652
  source.pipe(gunzip);
560
653
  const files = [];
654
+ const skippedFiles = [];
561
655
  const seen = /* @__PURE__ */ new Set();
562
656
  const maxArchiveBytes = policy.maxSourceBytes + (policy.maxSourceFiles + 2) * blockBytes * 2;
563
657
  let archiveBytes = 0;
@@ -645,7 +739,7 @@ async function extractArchive(compressed, policy, layout) {
645
739
  };
646
740
  continue;
647
741
  }
648
- if (type !== "0" && type !== "\0") return unavailable$1("Source archive contains a linked or special entry.");
742
+ if (type !== "0" && type !== "\0") return unavailable$1(`Source archive contains a linked or special entry: ${nextPath ?? headerPath}.`);
649
743
  const path = archivePath(nextPath ?? headerPath, layout);
650
744
  nextPath = null;
651
745
  if (path === null) return unavailable$1("Source archive contains an invalid path.");
@@ -654,6 +748,10 @@ async function extractArchive(compressed, policy, layout) {
654
748
  fileEntries += 1;
655
749
  if (fileEntries > policy.maxSourceFiles) return unavailable$1("Source archive contains too many files.");
656
750
  if (size <= policy.maxSourceFileBytes && totalBytes + size > policy.maxSourceBytes) return unavailable$1("Source archive exceeds the total byte limit.");
751
+ if (size > policy.maxSourceFileBytes) skippedFiles.push({
752
+ path,
753
+ bytes: size
754
+ });
657
755
  entry = {
658
756
  kind: size > policy.maxSourceFileBytes ? "skip" : "file",
659
757
  path,
@@ -671,7 +769,11 @@ async function extractArchive(compressed, policy, layout) {
671
769
  if (entry !== null || pending.some((byte) => byte !== 0) || !ended) return unavailable$1("Source archive ended before its final entry.");
672
770
  if (files.length === 0) return unavailable$1("Source archive has no usable files.");
673
771
  files.sort((left, right) => left.path.localeCompare(right.path));
674
- return ok(files);
772
+ skippedFiles.sort((left, right) => left.path.localeCompare(right.path));
773
+ return ok({
774
+ files,
775
+ skippedFiles
776
+ });
675
777
  }
676
778
  const packageSpecPattern = /^(@[^/@]+\/[^/@]+|[^/@]+)(?:@(\S+))?$/;
677
779
  const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?$/i;
@@ -949,7 +1051,8 @@ async function prepareNpmPackage(spec, policy, fetchClient, signal) {
949
1051
  return unavailable(message, [...attempts, skipped(version.dist.tarball, message)]);
950
1052
  }
951
1053
  attempts.push(used(version.dist.tarball));
952
- let files = [...extracted.value];
1054
+ let files = [...extracted.value.files];
1055
+ let skippedFiles = [...extracted.value.skippedFiles];
953
1056
  const repositoryArchive = githubArchiveUrl(version.repository ?? repository, version.gitHead);
954
1057
  const available = remainingPolicy(policy, files);
955
1058
  if (repositoryArchive !== null && available === null) attempts.push(skipped(repositoryArchive, "Prepared package files reached the source limit."));
@@ -964,7 +1067,11 @@ async function prepareNpmPackage(spec, policy, fetchClient, signal) {
964
1067
  const repositoryFiles = await extractResponse(fetchedRepository.value, available, "github");
965
1068
  if (repositoryFiles._tag === "Err") attempts.push(skipped(repositoryArchive, repositoryFiles.error.message));
966
1069
  else {
967
- files = [...files, ...repositoryFiles.value.map((file) => ({
1070
+ files = [...files, ...repositoryFiles.value.files.map((file) => ({
1071
+ ...file,
1072
+ path: posix.join("repository", file.path)
1073
+ }))];
1074
+ skippedFiles = [...skippedFiles, ...repositoryFiles.value.skippedFiles.map((file) => ({
968
1075
  ...file,
969
1076
  path: posix.join("repository", file.path)
970
1077
  }))];
@@ -973,8 +1080,10 @@ async function prepareNpmPackage(spec, policy, fetchClient, signal) {
973
1080
  }
974
1081
  }
975
1082
  files.sort((left, right) => left.path.localeCompare(right.path));
1083
+ skippedFiles.sort((left, right) => left.path.localeCompare(right.path));
976
1084
  return ok({
977
1085
  files,
1086
+ skippedFiles,
978
1087
  attempts,
979
1088
  npmResolution: {
980
1089
  package: parsed.name,
@@ -982,6 +1091,61 @@ async function prepareNpmPackage(spec, policy, fetchClient, signal) {
982
1091
  }
983
1092
  });
984
1093
  }
1094
+ function addCount(total, count) {
1095
+ return total === void 0 ? count : count === void 0 ? total : total + count;
1096
+ }
1097
+ function isPromiseLike(value) {
1098
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
1099
+ }
1100
+ function createRunLog(onEvent) {
1101
+ const warnings = [];
1102
+ const eventFailures = /* @__PURE__ */ new Set();
1103
+ let usage = {
1104
+ inputTokens: void 0,
1105
+ cachedInputTokens: void 0,
1106
+ outputTokens: void 0
1107
+ };
1108
+ let steps = 0;
1109
+ const fail = (cause) => {
1110
+ eventFailures.add(`onEvent failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1111
+ };
1112
+ return {
1113
+ emit: (event) => {
1114
+ if (event._tag === "StepFinish") {
1115
+ steps += 1;
1116
+ usage = {
1117
+ inputTokens: addCount(usage.inputTokens, event.usage.inputTokens),
1118
+ cachedInputTokens: addCount(usage.cachedInputTokens, event.usage.cachedInputTokens),
1119
+ outputTokens: addCount(usage.outputTokens, event.usage.outputTokens)
1120
+ };
1121
+ }
1122
+ if (!onEvent) return;
1123
+ try {
1124
+ const returned = onEvent(event);
1125
+ if (isPromiseLike(returned)) Promise.resolve(returned).catch(fail);
1126
+ } catch (cause) {
1127
+ fail(cause);
1128
+ }
1129
+ },
1130
+ warn: (warning) => warnings.push(warning),
1131
+ settle: (total, count) => {
1132
+ usage = total;
1133
+ steps = count;
1134
+ },
1135
+ report: () => ({
1136
+ usage,
1137
+ steps,
1138
+ warnings: [...warnings, ...eventFailures]
1139
+ })
1140
+ };
1141
+ }
1142
+ function toUsage(usage) {
1143
+ return {
1144
+ inputTokens: usage.inputTokens,
1145
+ cachedInputTokens: usage.inputTokenDetails.cacheReadTokens,
1146
+ outputTokens: usage.outputTokens
1147
+ };
1148
+ }
985
1149
  function cancelled() {
986
1150
  return err({
987
1151
  _tag: "Cancelled",
@@ -1040,6 +1204,7 @@ async function prepareRun(input, policy, fetchClient, signal) {
1040
1204
  skillName: "generate-project-skill",
1041
1205
  outputName: input.destination.name,
1042
1206
  source: source.value,
1207
+ sourceDir: "",
1043
1208
  current: current.value,
1044
1209
  destination: input.destination
1045
1210
  });
@@ -1049,7 +1214,8 @@ async function prepareRun(input, policy, fetchClient, signal) {
1049
1214
  return source._tag === "Err" ? source : ok({
1050
1215
  skillName: "review-skill",
1051
1216
  outputName: "review",
1052
- source: source.value
1217
+ source: source.value,
1218
+ sourceDir: basename(input.skillDir)
1053
1219
  });
1054
1220
  }
1055
1221
  if (input.source._tag === "NpmPackage") {
@@ -1062,6 +1228,7 @@ async function prepareRun(input, policy, fetchClient, signal) {
1062
1228
  skillName: "generate-package-skill",
1063
1229
  outputName: input.destination.name,
1064
1230
  source: source.value,
1231
+ sourceDir: "",
1065
1232
  current: current.value,
1066
1233
  destination: input.destination
1067
1234
  });
@@ -1086,6 +1253,7 @@ async function prepareRun(input, policy, fetchClient, signal) {
1086
1253
  skillName: "generate-package-skill",
1087
1254
  outputName: input.destination.name,
1088
1255
  source: source.value,
1256
+ sourceDir: "",
1089
1257
  current: current.value,
1090
1258
  destination: input.destination
1091
1259
  });
@@ -1098,8 +1266,11 @@ function requestContent(skill) {
1098
1266
  function renderRequest(template, sourcePath, currentSkillPath, outputPath, skillName) {
1099
1267
  return template.replaceAll("{{SOURCE_PATH}}", sourcePath).replaceAll("{{CURRENT_SKILL_PATH}}", currentSkillPath).replaceAll("{{OUTPUT_PATH}}", outputPath).replaceAll("{{SKILL_NAME}}", skillName);
1100
1268
  }
1269
+ function sourcePathOf(active, prepared) {
1270
+ return posix.join(active.workDir, "input/source", prepared.sourceDir);
1271
+ }
1101
1272
  async function writePreparedSource(active, prepared, signal) {
1102
- const sourcePath = posix.join(active.workDir, "input/source");
1273
+ const sourcePath = sourcePathOf(active, prepared);
1103
1274
  const reset = await active.sandbox.run({
1104
1275
  command: "rm -rf -- \"$SKILLD_INPUT\" \"$SKILLD_OUTPUT\" && mkdir -p -- \"$SKILLD_INPUT\"",
1105
1276
  env: {
@@ -1123,6 +1294,7 @@ async function writePreparedSource(active, prepared, signal) {
1123
1294
  path: posix.join(active.workDir, "input/source-manifest.json"),
1124
1295
  content: `${JSON.stringify({
1125
1296
  sourceAttempts: prepared.source.attempts,
1297
+ skippedFiles: prepared.source.skippedFiles,
1126
1298
  npmResolution: prepared.source.npmResolution,
1127
1299
  hasCurrentSkill: prepared.current !== void 0
1128
1300
  }, null, 2)}\n`,
@@ -1145,17 +1317,40 @@ function createSkillHarness(options) {
1145
1317
  const sandboxConfig = { ...options.sandboxConfig };
1146
1318
  const harness = options.harness;
1147
1319
  const sandbox = options.sandbox;
1148
- return { run: async (input, runOptions = {}) => {
1320
+ return { run: (input, runOptions = {}) => runSkill(input, runOptions) };
1321
+ async function runSkill(input, runOptions) {
1322
+ const log = createRunLog(runOptions.onEvent);
1323
+ const outcome = await runLogged(input, runOptions, log);
1324
+ const report = log.report();
1325
+ return outcome._tag === "Ok" ? {
1326
+ _tag: "Ok",
1327
+ value: outcome.value,
1328
+ report
1329
+ } : {
1330
+ _tag: "Err",
1331
+ error: outcome.error,
1332
+ report
1333
+ };
1334
+ }
1335
+ async function runLogged(input, runOptions, log) {
1149
1336
  const parsed = parseSkillRun(input);
1150
1337
  if (parsed._tag === "Err") return parsed;
1151
1338
  const prepared = await prepareRun(parsed.value, policy, fetchClient, runOptions.signal);
1152
1339
  if (prepared._tag === "Err") return prepared;
1340
+ for (const warning of skippedFileWarnings(prepared.value.source.skippedFiles, policy)) log.warn(warning);
1153
1341
  const skill = await loadSkilldMaintainedSkill(prepared.value.skillName);
1342
+ const sandboxSession = await sandbox.createSession({ abortSignal: runOptions.signal }).then(ok, (cause) => err(cause));
1343
+ if (sandboxSession._tag === "Err") return toAgentError(sandboxSession.error, runOptions.signal);
1344
+ const result = await runAgent(prepared.value, skill, sandboxSession.value, log, runOptions.signal);
1345
+ const destroyed = await Promise.resolve(sandboxSession.value.destroy()).then(() => void 0, (cause) => cause);
1346
+ if (destroyed !== void 0) log.warn(`Sandbox session cleanup failed: ${destroyed instanceof Error ? destroyed.message : String(destroyed)}`);
1347
+ return result;
1348
+ }
1349
+ async function runAgent(prepared, skill, sandboxSession, log, signal) {
1154
1350
  let active;
1155
1351
  const userOnSession = sandboxConfig.onSession;
1156
1352
  const agent = new HarnessAgent({
1157
1353
  harness,
1158
- sandbox,
1159
1354
  skills: [skill],
1160
1355
  permissionMode: "allow-all",
1161
1356
  sandboxConfig: {
@@ -1165,44 +1360,79 @@ function createSkillHarness(options) {
1165
1360
  sandbox: sessionOptions.session,
1166
1361
  workDir: sessionOptions.sessionWorkDir
1167
1362
  };
1168
- await writePreparedSource(active, prepared.value, runOptions.signal);
1363
+ await writePreparedSource(active, prepared, signal);
1169
1364
  await userOnSession?.(sessionOptions);
1170
1365
  }
1171
1366
  }
1172
1367
  });
1173
- const sessionResult = await agent.createSession({ abortSignal: runOptions.signal }).then(ok, (cause) => err(cause));
1174
- if (sessionResult._tag === "Err") return toAgentError(sessionResult.error, runOptions.signal);
1368
+ const sessionResult = await agent.createSession({
1369
+ sandboxSession,
1370
+ abortSignal: signal
1371
+ }).then(ok, (cause) => err(cause));
1372
+ if (sessionResult._tag === "Err") return toAgentError(sessionResult.error, signal);
1175
1373
  const session = sessionResult.value;
1176
1374
  try {
1177
1375
  if (!active) return err({
1178
1376
  _tag: "AgentFailed",
1179
1377
  message: "Harness did not provide its sandbox session."
1180
1378
  });
1181
- const sourcePath = posix.join(active.workDir, "input/source");
1379
+ const sourcePath = sourcePathOf(active, prepared);
1182
1380
  const currentSkillPath = posix.join(active.workDir, "input/current-skill");
1183
- const outputPath = posix.join(active.workDir, "skilld-output", prepared.value.outputName);
1184
- const prompt = renderRequest(requestContent(skill), sourcePath, currentSkillPath, outputPath, prepared.value.outputName);
1381
+ const outputPath = posix.join(active.workDir, "skilld-output", prepared.outputName);
1382
+ const prompt = renderRequest(requestContent(skill), sourcePath, currentSkillPath, outputPath, prepared.outputName);
1383
+ let step = 0;
1185
1384
  const generated = await agent.generate({
1186
1385
  session,
1187
1386
  prompt,
1188
- abortSignal: runOptions.signal
1387
+ abortSignal: signal,
1388
+ onStepStart: (event) => {
1389
+ step = event.stepNumber;
1390
+ log.emit({
1391
+ _tag: "StepStart",
1392
+ step
1393
+ });
1394
+ },
1395
+ onToolExecutionStart: ({ toolCall }) => {
1396
+ log.emit({
1397
+ _tag: "ToolCall",
1398
+ step,
1399
+ toolName: toolCall.toolName,
1400
+ toolCallId: toolCall.toolCallId,
1401
+ input: toolCall.input
1402
+ });
1403
+ },
1404
+ onStepEnd: (result) => {
1405
+ log.emit({
1406
+ _tag: "StepFinish",
1407
+ step: result.stepNumber,
1408
+ finishReason: result.finishReason,
1409
+ usage: toUsage(result.usage)
1410
+ });
1411
+ }
1189
1412
  }).then(ok, (cause) => err(cause));
1190
- if (generated._tag === "Err") return toAgentError(generated.error, runOptions.signal);
1191
- if (runOptions.signal?.aborted) return cancelled();
1192
- const collected = await collectSandboxOutput(active.sandbox, outputPath, policy, runOptions.signal);
1413
+ if (generated._tag === "Err") return toAgentError(generated.error, signal);
1414
+ log.settle(toUsage(generated.value.totalUsage), generated.value.steps.length);
1415
+ if (signal?.aborted) return cancelled();
1416
+ const collected = await collectSandboxOutput(active.sandbox, outputPath, policy, signal);
1193
1417
  if (collected._tag === "Err") return collected;
1194
- if (prepared.value.skillName === "review-skill") return validateSkillReview(collected.value);
1195
- const validated = validateGeneratedSkill(prepared.value.outputName, collected.value);
1418
+ if (prepared.skillName === "review-skill") return validateSkillReview(collected.value);
1419
+ const validated = validateGeneratedSkill(prepared.outputName, collected.value, prepared.skillName === "generate-project-skill" ? {
1420
+ _tag: "ProjectSkill",
1421
+ projectPaths: prepared.source.files.map((file) => file.path)
1422
+ } : { _tag: "PackageSkill" });
1196
1423
  if (validated._tag === "Err") return validated;
1197
- if (!prepared.value.destination) return err({
1424
+ if (!prepared.destination) return err({
1198
1425
  _tag: "InvalidInput",
1199
1426
  message: "Skill destination is required."
1200
1427
  });
1201
- return promoteSkill(prepared.value.destination.rootDir, prepared.value.destination.name, collected.value, prepared.value.source.attempts);
1428
+ const promoted = await promoteSkill(prepared.destination.rootDir, prepared.destination.name, collected.value, prepared.source.attempts);
1429
+ if (promoted._tag === "Err") return promoted;
1430
+ for (const warning of promoted.value.warnings) log.warn(warning);
1431
+ return ok(promoted.value.skill);
1202
1432
  } finally {
1203
1433
  await session.destroy();
1204
1434
  }
1205
- } };
1435
+ }
1206
1436
  }
1207
1437
  export { createSkillHarness };
1208
1438