@skill-harness/core 0.1.2 → 0.2.1

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.
@@ -0,0 +1,188 @@
1
+ /** Marker written into an `init` template's first comment. Its presence tells
2
+ * `suggest` the file is an unadopted template it may overwrite without --force. */
3
+ export const TEMPLATE_SENTINEL = "skill-harness: generated template";
4
+ /** Render a commented, empty-but-valid specification.yaml for a skill. */
5
+ export function renderTemplateSpec(skillName) {
6
+ return `# ${TEMPLATE_SENTINEL} — \`suggest\` will overwrite this file while
7
+ # this line is present; delete it once you start editing by hand.
8
+ skill: ${skillName}
9
+
10
+ # How the LLM judge should role-play when grading transcripts.
11
+ judge_persona: >
12
+ a careful, fair reviewer.
13
+
14
+ # The ship bar: what it takes to SHIP.
15
+ # total = scenarios counted toward the bar
16
+ # min_pass = minimum passes required
17
+ # no_critical_fail = a critical-id fail blocks SHIP even if min_pass is met
18
+ ship_bar:
19
+ total: 1
20
+ min_pass: 1
21
+ no_critical_fail: true
22
+
23
+ # Scenario ids that block the ship if they fail (or set \`critical: true\` per scenario).
24
+ critical: []
25
+
26
+ scenarios:
27
+ # A* = baseline capability · B* = under-pressure / adversarial
28
+ - id: A1
29
+ title: describe what this scenario checks
30
+ # critical: true # uncomment to gate the ship on this scenario
31
+ turns:
32
+ - "the user's first message"
33
+ # - "a follow-up message for a multi-turn scenario"
34
+ checklist:
35
+ - "an observable thing the response must do"
36
+ `;
37
+ }
38
+ /** True if the text still carries the template sentinel (i.e. an unadopted template). */
39
+ export function isTemplateSpec(text) {
40
+ return text.includes(TEMPLATE_SENTINEL);
41
+ }
42
+ /** Render a populated spec from an LLM draft. Strings are JSON-encoded (valid YAML
43
+ * flow scalars) so colons/quotes never break the file. Carries no sentinel. */
44
+ export function renderDraftSpec(skillName, draft) {
45
+ const scenarioBlocks = draft.scenarios
46
+ .map((s) => {
47
+ const turns = s.turns.map((t) => ` - ${JSON.stringify(t)}`).join("\n");
48
+ const checks = s.checklist.map((c) => ` - ${JSON.stringify(c)}`).join("\n");
49
+ return ` - id: ${s.id}\n title: ${JSON.stringify(s.title)}\n turns:\n${turns}\n checklist:\n${checks}`;
50
+ })
51
+ .join("\n");
52
+ const proposed = draft.proposed_critical.length
53
+ ? `# proposed critical: [${draft.proposed_critical.join(", ")}] — move ids into \`critical: []\` below after review.`
54
+ : `# proposed critical: (none) — mark any ship-gating scenarios in \`critical: []\` below.`;
55
+ return `skill: ${skillName}
56
+
57
+ # REVIEW: does this judge persona fit the skill? Edit freely.
58
+ judge_persona: ${JSON.stringify(draft.judge_persona)}
59
+
60
+ # REVIEW: tune the ship bar before your first run.
61
+ ship_bar:
62
+ total: ${draft.ship_bar.total}
63
+ min_pass: ${draft.ship_bar.min_pass}
64
+ no_critical_fail: ${draft.ship_bar.no_critical_fail}
65
+
66
+ ${proposed}
67
+ critical: []
68
+
69
+ scenarios:
70
+ ${scenarioBlocks}
71
+ `;
72
+ }
73
+ export function buildSuggestPrompt(skillName, skillMd) {
74
+ return `You are drafting a test specification for an agent skill named "${skillName}".
75
+ Below is its SKILL.md. Propose scenarios that check whether an agent following this
76
+ skill behaves correctly, including at least one adversarial / under-pressure case.
77
+
78
+ Return ONLY a JSON object (no prose, no markdown fences) with exactly this shape:
79
+ {
80
+ "judge_persona": "<how a judge should role-play when grading transcripts>",
81
+ "ship_bar": { "total": <int>, "min_pass": <int>, "no_critical_fail": true },
82
+ "proposed_critical": ["<scenario id you think should gate the ship>", ...],
83
+ "scenarios": [
84
+ { "id": "A1", "title": "<short title>",
85
+ "turns": ["<the user's message>", "<optional follow-up turns>"],
86
+ "checklist": ["<an observable thing the response must do>", ...] }
87
+ ]
88
+ }
89
+ Use ids A1, A2, ... for baseline scenarios and B1, B2, ... for adversarial ones.
90
+ Every scenario needs at least one turn and one checklist item.
91
+
92
+ --- SKILL.md ---
93
+ ${skillMd}`;
94
+ }
95
+ /** Ids are interpolated raw into YAML (see renderDraftSpec); restrict the character
96
+ * set so a crafted id can never inject extra YAML keys (e.g. `critical: true`). */
97
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
98
+ function asStringArray(v, ctx) {
99
+ if (!Array.isArray(v) || v.length === 0 || v.some((x) => typeof x !== "string")) {
100
+ throw new Error(`${ctx} must be a non-empty array of strings`);
101
+ }
102
+ return v;
103
+ }
104
+ /** Extract the first complete top-level JSON object from a model reply, tolerating
105
+ * surrounding prose or ```json fences — including trailing text that itself
106
+ * contains braces (e.g. "…} Does {this} work?"). Scans brace depth while skipping
107
+ * string contents, so the object ends at its own matching `}`, not the last `}`
108
+ * anywhere in the reply. */
109
+ function extractJsonObject(raw) {
110
+ const start = raw.indexOf("{");
111
+ if (start < 0)
112
+ throw new Error("no JSON object in model output");
113
+ let depth = 0;
114
+ let inStr = false;
115
+ let escaped = false;
116
+ for (let i = start; i < raw.length; i++) {
117
+ const ch = raw[i];
118
+ if (inStr) {
119
+ if (escaped)
120
+ escaped = false;
121
+ else if (ch === "\\")
122
+ escaped = true;
123
+ else if (ch === '"')
124
+ inStr = false;
125
+ continue;
126
+ }
127
+ if (ch === '"')
128
+ inStr = true;
129
+ else if (ch === "{")
130
+ depth++;
131
+ else if (ch === "}" && --depth === 0)
132
+ return raw.slice(start, i + 1);
133
+ }
134
+ throw new Error("no complete JSON object in model output");
135
+ }
136
+ export function parseSuggestDraft(raw) {
137
+ let obj;
138
+ try {
139
+ obj = JSON.parse(extractJsonObject(raw));
140
+ }
141
+ catch (e) {
142
+ if (e instanceof Error && e.message.includes("JSON object in model output"))
143
+ throw e;
144
+ throw new Error(`model output is not valid JSON — ${e.message}`);
145
+ }
146
+ if (typeof obj.judge_persona !== "string" || !obj.judge_persona.trim()) {
147
+ throw new Error("judge_persona must be a non-empty string");
148
+ }
149
+ const sb = obj.ship_bar;
150
+ if (!sb || typeof sb.total !== "number" || typeof sb.min_pass !== "number") {
151
+ throw new Error("ship_bar must have numeric total and min_pass");
152
+ }
153
+ if (sb.min_pass > sb.total) {
154
+ throw new Error(`ship_bar.min_pass (${sb.min_pass}) cannot exceed total (${sb.total})`);
155
+ }
156
+ const proposed = Array.isArray(obj.proposed_critical)
157
+ ? obj.proposed_critical.filter((x) => typeof x === "string" && SAFE_ID.test(x))
158
+ : [];
159
+ if (!Array.isArray(obj.scenarios) || obj.scenarios.length === 0) {
160
+ throw new Error("scenarios must be a non-empty array");
161
+ }
162
+ const seen = new Set();
163
+ const scenarios = obj.scenarios.map((raw2, i) => {
164
+ const s = raw2;
165
+ if (typeof s.id !== "string" || !s.id.trim())
166
+ throw new Error(`scenario #${i + 1} needs a string id`);
167
+ if (!SAFE_ID.test(s.id))
168
+ throw new Error(`scenario id \`${s.id}\` must be alphanumeric (A-Z a-z 0-9 _ -)`);
169
+ if (seen.has(s.id))
170
+ throw new Error(`duplicate scenario id \`${s.id}\``);
171
+ seen.add(s.id);
172
+ if (typeof s.title !== "string" || !s.title.trim())
173
+ throw new Error(`scenario ${s.id} needs a title`);
174
+ return {
175
+ id: s.id,
176
+ title: s.title,
177
+ turns: asStringArray(s.turns, `scenario ${s.id} turns`),
178
+ checklist: asStringArray(s.checklist, `scenario ${s.id} checklist`),
179
+ };
180
+ });
181
+ return {
182
+ judge_persona: obj.judge_persona,
183
+ ship_bar: { total: sb.total, min_pass: sb.min_pass, no_critical_fail: sb.no_critical_fail !== false },
184
+ proposed_critical: proposed,
185
+ scenarios,
186
+ };
187
+ }
188
+ //# sourceMappingURL=scaffold.js.map
package/dist/score.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ShipBar } from "./spec.js";
2
- export type Verdict = "PASS" | "FAIL" | "ERROR";
2
+ /**
3
+ * "JUDGE-AMBIGUOUS": the judge emitted conflicting verdicts for one transcript. It is
4
+ * never a pass and never silently resolved — it marks the run for a rejudge.
5
+ */
6
+ export type Verdict = "PASS" | "FAIL" | "ERROR" | "JUDGE-AMBIGUOUS";
3
7
  export interface ScenarioVerdict {
4
8
  id: string;
5
9
  verdict: Verdict;
package/dist/seeded.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { exec } from "./util/exec.js";
2
- const VITEST_TIMEOUT_MS = Number(process.env.SKILL_CHECK_VITEST_TIMEOUT_MS ?? 120_000);
2
+ import { envNum } from "./util/env.js";
3
+ const VITEST_TIMEOUT_MS = envNum("VITEST_TIMEOUT_MS", 120_000);
3
4
  /**
4
5
  * Run a seeded scenario inside a caller-prepared workspace: let the harness edit
5
6
  * the repo, then evaluate objective gates (staged-diff contains + optional vitest
package/dist/spec.d.ts CHANGED
@@ -14,6 +14,8 @@ export interface Scenario {
14
14
  fixture?: string;
15
15
  assert?: SeededAssert;
16
16
  workspace: WorkspaceKind;
17
+ remote: boolean;
18
+ systemPromptFile?: string;
17
19
  reps?: number;
18
20
  passThreshold?: number;
19
21
  }
package/dist/spec.js CHANGED
@@ -54,6 +54,24 @@ function resolveWorkspace(env, mode, fixture, id, file) {
54
54
  }
55
55
  throw new SpecError(`scenario \`${id}\` env.workspace must be none | empty-git | fixture:<path>`, file);
56
56
  }
57
+ /**
58
+ * Resolve `env.remote`. A remote needs a repo to attach to, so it is only meaningful
59
+ * with empty-git or a fixture — asking for one on a bare cwd is an authoring mistake,
60
+ * not something to silently ignore.
61
+ */
62
+ function resolveRemote(env, workspace, id, file) {
63
+ const raw = env && typeof env === "object" ? env.remote : undefined;
64
+ if (raw === undefined)
65
+ return false;
66
+ if (typeof raw !== "boolean") {
67
+ throw new SpecError(`scenario \`${id}\` env.remote must be true or false`, file);
68
+ }
69
+ if (raw && workspace === "none") {
70
+ throw new SpecError(`scenario \`${id}\` sets env.remote but has no repo to attach it to — ` +
71
+ `use env.workspace: empty-git or fixture:<path>`, file);
72
+ }
73
+ return raw;
74
+ }
57
75
  /** Parse + validate a specification.yaml from its raw text. `file` is used in error messages. */
58
76
  export function parseSpec(text, file) {
59
77
  let doc;
@@ -124,6 +142,7 @@ export function parseSpec(text, file) {
124
142
  turns: s.turns,
125
143
  checklist: s.checklist,
126
144
  workspace: "none",
145
+ remote: false,
127
146
  };
128
147
  if (mode === "seeded") {
129
148
  if (typeof s.fixture !== "string" || s.fixture.length === 0) {
@@ -145,6 +164,19 @@ export function parseSpec(text, file) {
145
164
  }
146
165
  }
147
166
  scenario.workspace = resolveWorkspace(s.env, mode, scenario.fixture, id, file);
167
+ scenario.remote = resolveRemote(s.env, scenario.workspace, id, file);
168
+ if (s.system_prompt_file !== undefined) {
169
+ if (typeof s.system_prompt_file !== "string" || !s.system_prompt_file.trim()) {
170
+ throw new SpecError(`scenario \`${id}\` \`system_prompt_file\` must be a non-empty string`, file);
171
+ }
172
+ // A subagent has no turn two. Testing an agent file across multiple turns would
173
+ // measure conversation armor the single-shot contract deliberately drops.
174
+ if (scenario.turns.length !== 1) {
175
+ throw new SpecError(`scenario \`${id}\` uses system_prompt_file, so it must have exactly one turn ` +
176
+ `(got ${scenario.turns.length}) — an agent definition is single-shot by contract`, file);
177
+ }
178
+ scenario.systemPromptFile = s.system_prompt_file.trim();
179
+ }
148
180
  if (s.reps !== undefined) {
149
181
  if (typeof s.reps !== "number" || !Number.isInteger(s.reps) || s.reps < 1) {
150
182
  throw new SpecError(`scenario \`${id}\` \`reps\` must be a positive integer`, file);
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Environment-variable resolution for the `SKILL_HARNESS_*` namespace.
3
+ *
4
+ * The tuning vars predate the skill-check → skill-harness rename and shipped in
5
+ * 0.1.x under a `SKILL_CHECK_` prefix. Anyone who set one of those in a shell
6
+ * profile or CI config must keep working, so every name is resolved new-first
7
+ * with a legacy fallback and a one-time notice pointing at the new spelling.
8
+ *
9
+ * Call these with the *suffix* only (`"PI_TIMEOUT_MS"`), never a full name — the
10
+ * prefixes are this module's business, which is what keeps ROADMAP rule 5 (no
11
+ * new `SKILL_CHECK_*` names) enforceable by grepping for the prefix.
12
+ */
13
+ /** Test seam: clears the once-per-suffix warning memo. */
14
+ export declare function __resetEnvWarnings(): void;
15
+ /**
16
+ * Resolve `SKILL_HARNESS_<suffix>`, falling back to `SKILL_CHECK_<suffix>`.
17
+ *
18
+ * An empty value counts as unset: exporting a var as `""` is a common way to
19
+ * neutralize it in CI, and honoring it as "set" would make the legacy fallback
20
+ * unreachable for exactly those users.
21
+ */
22
+ export declare function readEnv(suffix: string): string | undefined;
23
+ /**
24
+ * Resolve a positive-integer var, e.g. a timeout in ms.
25
+ *
26
+ * A set-but-unparseable value warns and yields `fallback` rather than passing
27
+ * `NaN` down: the pre-rename `Number(process.env.X ?? default)` turned a typo
28
+ * into a NaN timeout, which silently disabled the timeout it was feeding.
29
+ */
30
+ export declare function envNum(suffix: string, fallback: number): number;
31
+ /** Resolve a boolean var: any non-empty value is on. */
32
+ export declare function envFlag(suffix: string): boolean;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Environment-variable resolution for the `SKILL_HARNESS_*` namespace.
3
+ *
4
+ * The tuning vars predate the skill-check → skill-harness rename and shipped in
5
+ * 0.1.x under a `SKILL_CHECK_` prefix. Anyone who set one of those in a shell
6
+ * profile or CI config must keep working, so every name is resolved new-first
7
+ * with a legacy fallback and a one-time notice pointing at the new spelling.
8
+ *
9
+ * Call these with the *suffix* only (`"PI_TIMEOUT_MS"`), never a full name — the
10
+ * prefixes are this module's business, which is what keeps ROADMAP rule 5 (no
11
+ * new `SKILL_CHECK_*` names) enforceable by grepping for the prefix.
12
+ */
13
+ const NEW_PREFIX = "SKILL_HARNESS_";
14
+ const LEGACY_PREFIX = "SKILL_CHECK_";
15
+ /** Suffixes already warned about, so a module-scope read doesn't nag per call. */
16
+ const warned = new Set();
17
+ /** Test seam: clears the once-per-suffix warning memo. */
18
+ export function __resetEnvWarnings() {
19
+ warned.clear();
20
+ }
21
+ function warnOnce(key, message) {
22
+ if (warned.has(key))
23
+ return;
24
+ warned.add(key);
25
+ process.stderr.write(`skill-harness: ${message}\n`);
26
+ }
27
+ /**
28
+ * Resolve `SKILL_HARNESS_<suffix>`, falling back to `SKILL_CHECK_<suffix>`.
29
+ *
30
+ * An empty value counts as unset: exporting a var as `""` is a common way to
31
+ * neutralize it in CI, and honoring it as "set" would make the legacy fallback
32
+ * unreachable for exactly those users.
33
+ */
34
+ export function readEnv(suffix) {
35
+ const fresh = process.env[NEW_PREFIX + suffix];
36
+ if (fresh)
37
+ return fresh;
38
+ const legacy = process.env[LEGACY_PREFIX + suffix];
39
+ if (legacy) {
40
+ warnOnce(`legacy:${suffix}`, `${LEGACY_PREFIX}${suffix} is the pre-rename name and still honored; rename it to ${NEW_PREFIX}${suffix}.`);
41
+ return legacy;
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * Resolve a positive-integer var, e.g. a timeout in ms.
47
+ *
48
+ * A set-but-unparseable value warns and yields `fallback` rather than passing
49
+ * `NaN` down: the pre-rename `Number(process.env.X ?? default)` turned a typo
50
+ * into a NaN timeout, which silently disabled the timeout it was feeding.
51
+ */
52
+ export function envNum(suffix, fallback) {
53
+ const raw = readEnv(suffix);
54
+ if (raw === undefined)
55
+ return fallback;
56
+ const n = Number(raw);
57
+ if (!Number.isFinite(n) || n <= 0) {
58
+ warnOnce(`malformed:${suffix}`, `${NEW_PREFIX}${suffix}=${JSON.stringify(raw)} is not a positive number; using ${fallback}.`);
59
+ return fallback;
60
+ }
61
+ return n;
62
+ }
63
+ /** Resolve a boolean var: any non-empty value is on. */
64
+ export function envFlag(suffix) {
65
+ return readEnv(suffix) !== undefined;
66
+ }
67
+ //# sourceMappingURL=env.js.map
@@ -10,8 +10,12 @@ export interface Workspace {
10
10
  * Create an isolated temp-dir working directory for one scenario. `none` is an
11
11
  * empty dir (no git); `empty-git` initialises a clean repo; `{ fixture }` copies
12
12
  * the fixture (relative paths resolve against `specDir`) then initialises a repo
13
- * with a baseline commit. Child processes run here, never in the user's home.
13
+ * with a baseline commit. A fixture may carry top-level `_staged/` and
14
+ * `_uncommitted/` subdirectories, applied after that commit to start the scenario
15
+ * from a dirty tree. `opts.remote` additionally wires a local bare `origin`.
16
+ * Child processes run here, never in the user's home.
14
17
  */
15
18
  export declare function createWorkspace(kind: WorkspaceKind, opts: {
16
19
  specDir: string;
20
+ remote?: boolean;
17
21
  }): Workspace;
package/dist/workspace.js CHANGED
@@ -1,36 +1,112 @@
1
- import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { tmpdir } from "node:os";
4
4
  import { isAbsolute, join, resolve } from "node:path";
5
5
  const GIT_TIMEOUT_MS = 30_000;
6
- /** git init + a baseline commit, so a later `git diff --cached` shows only edits. */
6
+ /**
7
+ * Fixture subdirectories copied over the workspace AFTER the baseline commit, so their
8
+ * files land as pending changes rather than history. Lets a scenario start from a dirty
9
+ * tree — "I fixed the typo, commit it" needs the fix present but not yet committed, and
10
+ * "commit my staged changes" needs a populated index. `_staged/` contents are added to
11
+ * the index, `_uncommitted/` contents are left unstaged; a fixture may use either or
12
+ * both. Neither marker directory ever appears in the workspace.
13
+ */
14
+ const UNCOMMITTED_DIR = "_uncommitted";
15
+ const STAGED_DIR = "_staged";
16
+ const MARKERS = [STAGED_DIR, UNCOMMITTED_DIR];
17
+ /**
18
+ * A misspelled marker (`_uncommited/`) used to be copied into the baseline commit as a
19
+ * literal directory: the tree came up clean, the scenario silently measured the opposite
20
+ * of its intent, and nothing reported it. Any top-level `_name/` is therefore treated as
21
+ * a marker claim and must be a real one.
22
+ *
23
+ * Only a SINGLE leading underscore counts, so `__tests__` and `__pycache__` — ordinary
24
+ * directories a fixture may legitimately contain — are not marker claims. Nested
25
+ * `pkg/_staged/` is likewise ordinary content: markers are top-level only.
26
+ */
27
+ function assertKnownMarkers(src) {
28
+ const suspects = readdirSync(src, { withFileTypes: true })
29
+ .filter((e) => e.isDirectory() && /^_[A-Za-z]/.test(e.name) && !MARKERS.includes(e.name))
30
+ .map((e) => e.name);
31
+ if (suspects.length > 0) {
32
+ throw new Error(`fixture ${src}: unknown marker director${suspects.length > 1 ? "ies" : "y"} ` +
33
+ `${suspects.map((s) => `\`${s}/\``).join(", ")} — known markers are ` +
34
+ `${MARKERS.map((m) => `\`${m}/\``).join(" and ")}. Rename it, or move it deeper if it is ordinary content.`);
35
+ }
36
+ }
37
+ /**
38
+ * git init + a baseline commit, so a later `git diff --cached` shows only edits.
39
+ * Pinned to `main`: the host's init.defaultBranch is not ours to depend on, and
40
+ * scenarios say things like "I'm on the main branch".
41
+ */
7
42
  function gitBaseline(cwd) {
8
- execFileSync("git", ["init", "-q"], { cwd, timeout: GIT_TIMEOUT_MS });
43
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
9
44
  execFileSync("git", ["add", "-A"], { cwd, timeout: GIT_TIMEOUT_MS });
10
45
  execFileSync("git", ["-c", "user.email=sh@local", "-c", "user.name=skill-harness", "commit", "-q", "--allow-empty", "-m", "baseline"], { cwd, timeout: GIT_TIMEOUT_MS });
11
46
  }
47
+ /**
48
+ * Wire a bare repo in its own temp dir as `origin` and push the baseline, so `main`
49
+ * tracks `origin/main` and the baseline commit genuinely exists upstream.
50
+ *
51
+ * Without this, a git fixture has no remote — and a model reasonably reads a missing
52
+ * upstream as "solo throwaway repo", which is how git-ops A4 passes or fails depending
53
+ * on the run. A real remote makes "shared work" a fact of the fixture rather than a
54
+ * claim in the prompt, and needs no network.
55
+ */
56
+ function addLocalRemote(cwd) {
57
+ const bare = mkdtempSync(join(tmpdir(), "sc-remote-")) + ".git";
58
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", bare], { timeout: GIT_TIMEOUT_MS });
59
+ execFileSync("git", ["remote", "add", "origin", bare], { cwd, timeout: GIT_TIMEOUT_MS });
60
+ execFileSync("git", ["push", "-q", "-u", "origin", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
61
+ return bare;
62
+ }
12
63
  /**
13
64
  * Create an isolated temp-dir working directory for one scenario. `none` is an
14
65
  * empty dir (no git); `empty-git` initialises a clean repo; `{ fixture }` copies
15
66
  * the fixture (relative paths resolve against `specDir`) then initialises a repo
16
- * with a baseline commit. Child processes run here, never in the user's home.
67
+ * with a baseline commit. A fixture may carry top-level `_staged/` and
68
+ * `_uncommitted/` subdirectories, applied after that commit to start the scenario
69
+ * from a dirty tree. `opts.remote` additionally wires a local bare `origin`.
70
+ * Child processes run here, never in the user's home.
17
71
  */
18
72
  export function createWorkspace(kind, opts) {
19
73
  const cwd = mkdtempSync(join(tmpdir(), "sc-ws-"));
20
- const cleanup = () => rmSync(cwd, { recursive: true, force: true });
74
+ let bare = null;
75
+ const cleanup = () => {
76
+ rmSync(cwd, { recursive: true, force: true });
77
+ if (bare)
78
+ rmSync(bare, { recursive: true, force: true }); // the remote is ours too
79
+ };
21
80
  try {
22
81
  if (kind === "none") {
23
82
  // empty isolated dir; nothing to set up
24
83
  }
25
84
  else if (kind === "empty-git") {
26
85
  gitBaseline(cwd);
86
+ if (opts.remote)
87
+ bare = addLocalRemote(cwd);
27
88
  }
28
89
  else {
29
90
  const src = isAbsolute(kind.fixture) ? kind.fixture : resolve(opts.specDir, kind.fixture);
30
91
  if (!existsSync(src))
31
92
  throw new Error(`fixture not found: ${src}`);
32
- cpSync(src, cwd, { recursive: true });
93
+ assertKnownMarkers(src);
94
+ const pending = [STAGED_DIR, UNCOMMITTED_DIR].map((d) => join(src, d));
95
+ cpSync(src, cwd, {
96
+ recursive: true,
97
+ filter: (from) => !pending.includes(from), // committed baseline only
98
+ });
33
99
  gitBaseline(cwd);
100
+ // Before the pending changes land, so the remote holds the baseline only.
101
+ if (opts.remote)
102
+ bare = addLocalRemote(cwd);
103
+ const [staged, uncommitted] = pending;
104
+ if (existsSync(staged)) {
105
+ cpSync(staged, cwd, { recursive: true });
106
+ execFileSync("git", ["add", "-A"], { cwd, timeout: GIT_TIMEOUT_MS });
107
+ }
108
+ if (existsSync(uncommitted))
109
+ cpSync(uncommitted, cwd, { recursive: true });
34
110
  }
35
111
  }
36
112
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",