@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.
- package/dist/adapters/types.d.ts +6 -0
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +9 -1
- package/dist/grade.d.ts +12 -1
- package/dist/grade.js +48 -10
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +69 -0
- package/dist/lift.js +163 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +100 -3
- package/dist/regrade.d.ts +6 -0
- package/dist/regrade.js +25 -3
- package/dist/report.d.ts +14 -0
- package/dist/report.js +16 -1
- package/dist/rescore.d.ts +34 -0
- package/dist/rescore.js +66 -0
- package/dist/results.d.ts +9 -0
- package/dist/results.js +4 -1
- package/dist/run.d.ts +25 -1
- package/dist/run.js +126 -21
- package/dist/scaffold.d.ts +28 -0
- package/dist/scaffold.js +188 -0
- package/dist/score.d.ts +5 -1
- package/dist/seeded.js +2 -1
- package/dist/spec.d.ts +2 -0
- package/dist/spec.js +32 -0
- package/dist/util/env.d.ts +32 -0
- package/dist/util/env.js +67 -0
- package/dist/workspace.d.ts +5 -1
- package/dist/workspace.js +82 -6
- package/package.json +1 -1
package/dist/scaffold.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
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;
|
package/dist/util/env.js
ADDED
|
@@ -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
|
package/dist/workspace.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|