Files
gstack/test/skill-e2e-skillify.test.ts
T
f30b7b788a v1.91.13.0 fix: severe bug wave (autoplan guard, land-and-deploy CI gate, project identity, gbrain, review gates) (#3013)
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995)

/land-and-deploy gated only on required checks, so a repo with none
(every GitHub Free private repo, any repo without branch protection)
skipped the CI wait and could merge over red CI. The verdict now comes
from bin/gstack-ci-gate, a tested helper bound to the head SHA: all
checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit
per-head approval, and both merge commands (including --auto) run only
after a PASS for the same SHA they pin with --match-head-commit.

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901)

gstack-slug kept only the last two remote path segments, so
customer-a/product/repo and customer-b/product/repo shared one bucket
and /context-restore could present another project's checkpoint.

- One canonical-remote rule (bash + TS, golden vectors) used by all six
  resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and
  Bitbucket Server normalized. 3+ segment remotes get
  <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged.
- Versioned sticky cache (v2:) keeps cache hits git-free.
- context-save stamps the credential-free remote and root;
  context-restore only treats this project's checkpoints as latest,
  reports foreign/unverified ones first, and treats a different root
  with the same remote (Conductor handoff) as information.
- gstack-slug --adopt-legacy copies only files the user confirms.
- question-preference-hook reads the same versioned cache, so never-ask
  preferences fire; unknown identity asks instead of auto-deciding.

* fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922)

- brain-context-load called 'gbrain list_pages' (an MCP tool name), so
  every skill's brain context was 0 bytes. It now uses 'gbrain list'
  with gbrain's real filter flags, and prints one line when queries fail.
- The code-stage sync always passes --no-pull (managed gbrain >= 0.51
  refuses without it); a gbrain that rejects the flag fails with an
  upgrade message and is never retried without it. stderr is streamed
  with a bounded tail and quoted in failure summaries.
- /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by
  default it skips types already synced through a federated curated
  source, so curated pages stop being re-duplicated.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011)

- Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what
  a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and
  never a false PASS; P2/P3-only output passes.
- import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's
  ROOTPATH (including git-URL targets) through explicit, captured
  mappings; declared bases must sit inside the source root or the
  run's checkout, URIs are percent-decoded, and rejected results are
  counted with rule, base and path instead of silently dropped.

* fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984)

- The pre-push redact hook exempts a root VERSION file that is only a
  version string and the version token in CHANGELOG release headings;
  the engine allowlist is untouched.
- gstack-update-check caches an unreadable remote as CHECK_FAILED with
  a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports
  'latest' after a failed check.
- SECURITY.md points reporters at GitHub private vulnerability
  reporting.
- pr-title-sync (pull_request_target, write token) pins actions/checkout
  to a reviewed SHA.

* fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006)

The guard required a session journal to start with a user message, but
Claude Code starts it with SessionStart hook attachments (and a forked
compacted session starts with a compact_boundary), so every phase was
denied for anyone with a SessionStart hook. Windows path spellings
(C:/, /c/, drive case) were rejected too.

- Reader accepts the captured startup, compact, clear and fork shapes
  (claude 2.1.284 fixture), computes root uniqueness over every
  candidate, and returns typed reason codes instead of one string.
- Hard denials (competing root, foreign cwd, sidechain, agent, cycle,
  malformed, identity) say what happened and how to run the reviews by
  hand; unflushed journals still retry.
- A well-formed, stable journal whose root shape is unrecognized now
  warns ('phase publication was NOT verified') and continues, without
  overriding Claude Code's own permission check; no bypass switch.
- Windows paths fold before validation, so '..' is still rejected.
- Periodic drift canary runs the reader on fresh journals from the
  pinned claude.

Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>

* v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs

* test: seed the versioned slug cache in the memory-injection deny case (#2901)

The preference hook now resolves the project bucket from gstack-slug's
v2 slug-cache entry instead of basename(cwd); this fixture still wrote
preferences under the basename bucket without the entry the skill
preamble always writes first.

* fix: full-suite follow-ups for the fix wave

- gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50
  pipe-deadlock guard like the other bins.
- Refresh the codex/factory ship goldens for the plan-gates slug change.
- The periodic lane gains the autoplan journal drift canary (107 paid
  files); raise the eval-slices cap to 25 so every slice still starts
  at once until its recorded duration lets the planner pack it.

* fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs

The workflow judge scored plan-eng-review's instructions at
actionability 3.67 (bar 4) on text unchanged from main. Resolve the
ambiguities it named together, without changing any gate or output:
Step 0 names the Read's order and keeps one STOP; the pending-call,
read-only, forbidden-write and failed-save rules become one Write
routes block; header/label limits and grid checks are concrete;
Performance gets a checklist; each section has a stated output shape;
Lake Score is defined. Same total length (parity cap).

---------

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
2026-10-02 11:11:00 -07:00

528 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Browser-skills Phase 2a — gate-tier E2E for /scrape and /skillify.
*
* Five scenarios cover the productivity loop and the contracts locked
* during the v1.19.0.0 plan review:
*
* D1 — /skillify provenance guard (scenario 4)
* D2 — synthesis input slice (covered indirectly by scenario 3 — the
* committed SKILL.md must not contain conversation prose)
* D3 — atomic write discipline (scenarios 3 and 5)
*
* 1. scrape-match-path — /scrape with intent matching bundled
* hackernews-frontpage routes via $B skill run, no prototype.
* 2. scrape-prototype-path — /scrape against a local file:// fixture
* (no matching skill) drives $B primitives, returns JSON, suggests
* /skillify.
* 3. skillify-happy-path — /scrape then /skillify in one session.
* Skill written to ~/.gstack/browser-skills/<name>/ with full
* file tree, $B skill test passes.
* 4. skillify-provenance-refusal — cold /skillify with no prior
* /scrape refuses with the D1 message; nothing on disk.
* 5. skillify-approval-reject — /scrape then /skillify but reject in
* the approval gate; temp dir is removed, nothing at final path.
*
* The three skillify keys run gate-tier; the two scrape keys are periodic
* (/scrape is Aside-first and its fallback no longer prescribes the match +
* prototype flow they assert — see E2E_TIERS). ~$0.50–$1.50 each.
* Set EVALS=1 to enable. Set EVALS_MODEL to override (default frontier Claude).
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { JUDGE_MS, CAPTURE_MS, CAPTURE_LONG_MS } from './helpers/eval-budgets';
import { runSkillTest } from './helpers/session-runner';
import {
ROOT, browseBin, runId,
describeIfSelected, testConcurrentIfSelected,
setupBrowseShims, copyDirSync, logCost, recordE2E,
createEvalCollector, finalizeEvalCollector,
} from './helpers/e2e-helpers';
import { extractSkillBody } from './helpers/skill-fixture';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const evalCollector = createEvalCollector('e2e-skillify');
// ─── Shared workdir setup ───────────────────────────────────────
interface Workdir {
workDir: string;
gstackHome: string;
skillsDir: string;
}
/**
* Build a working directory that has:
* - The /scrape and /skillify skills installed under .claude/skills/
* - The browse binary symlinked + find-browse shim (via setupBrowseShims)
* - bin/ scripts referenced by the preamble
* - A scoped GSTACK_HOME under the workdir so on-disk artifacts are
* contained and assertable
* - A CLAUDE.md routing block instructing Skill-tool invocation
*
* `installSkills` lets each test pick the minimum surface (e.g., the
* provenance-refusal scenario doesn't need /scrape).
*/
function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape', 'skillify']): Workdir {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), `skill-e2e-skillify-${suffix}-`));
const gstackHome = path.join(workDir, '.gstack-home');
fs.mkdirSync(gstackHome, { recursive: true });
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: workDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(workDir, 'README.md'), '# test\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
setupBrowseShims(workDir);
// Install requested skills. The tests exercise the full /scrape + /skillify
// flows (all 11 skillify steps, D1-D3 contracts), so keep the whole
// skill-specific body — but drop the ~780-line shared preamble the tests
// never touch (CLAUDE.md: "E2E test fixtures: extract, don't copy").
const skillsDir = path.join(workDir, '.claude', 'skills');
for (const skill of installSkills) {
const destDir = path.join(skillsDir, skill);
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, 'SKILL.md'), extractSkillBody(path.join(ROOT, skill)));
}
// bin/ scripts — preamble references several of these.
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-slug', 'gstack-config', 'gstack-state-root.sh', 'gstack-remote-identity.sh',
'gstack-update-check', 'gstack-repo-mode',
'gstack-learnings-log', 'gstack-learnings-search',
]) {
const src = path.join(ROOT, 'bin', script);
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(binDir, script));
fs.chmodSync(path.join(binDir, script), 0o755);
}
}
fs.writeFileSync(path.join(workDir, 'CLAUDE.md'), `# Project Instructions
## Skill routing
When the user's request matches an available skill, ALWAYS invoke it via
the Skill tool as your FIRST action.
Key routing rules:
- /scrape, "scrape", "get data from", "extract from" → invoke scrape
- /skillify, "skillify", "codify this scrape" → invoke skillify
Environment:
- GSTACK_HOME="${gstackHome}" for all gstack bin scripts.
- bin scripts are at ./bin/ relative to this directory.
- Browse binary is at ${browseBin} — assign to $B (e.g., \`B=${browseBin}\`).
`);
return { workDir, gstackHome, skillsDir };
}
/**
* Install the bundled hackernews-frontpage browser-skill into the workdir's
* project-tier (so $B skill list finds it for match-path tests). The skill
* has to live under <workdir>/.gstack/browser-skills/ for the project-tier
* lookup to find it (gstack's bundled tier resolves from the install dir,
* which the test workdir doesn't have).
*/
function installBundledHackernewsSkill(workDir: string) {
const src = path.join(ROOT, 'browser-skills', 'hackernews-frontpage');
const dst = path.join(workDir, '.gstack', 'browser-skills', 'hackernews-frontpage');
copyDirSync(src, dst);
}
/** Helper: every Bash invocation's command string from the agent. */
function bashCommands(result: { toolCalls: Array<{ tool: string; input: any }> }): string[] {
return result.toolCalls
.filter((tc) => tc.tool === 'Bash')
.map((tc) => String(tc.input?.command ?? ''))
.filter(Boolean);
}
/** Helper: the union of agent text + every tool input/output for matching. */
function fullSurface(result: any): string {
const parts: string[] = [];
if (result.output) parts.push(String(result.output));
for (const tc of result.toolCalls || []) {
parts.push(JSON.stringify(tc.input || {}));
if (tc.output) parts.push(String(tc.output));
}
for (const entry of result.transcript || []) {
try { parts.push(JSON.stringify(entry)); } catch { /* skip */ }
}
return parts.join('\n');
}
// ─── Test fixtures ──────────────────────────────────────────────
/**
* Tiny HTML fixture for the prototype-path test. Stable structure with three
* "items" the agent should be able to extract via $B html + parse.
*/
const PROTOTYPE_FIXTURE_HTML = `<!doctype html>
<html><body>
<h1>Test Items</h1>
<ul id="items">
<li class="item"><a href="/a">First Title</a><span class="score">42</span></li>
<li class="item"><a href="/b">Second Title</a><span class="score">17</span></li>
<li class="item"><a href="/c">Third Title</a><span class="score">8</span></li>
</ul>
</body></html>
`;
// ─── Live-fire suite ────────────────────────────────────────────
describeIfSelected('Browser-skills Phase 2a E2E (/scrape + /skillify)', [
'scrape-match-path',
'scrape-prototype-path',
'skillify-happy-path',
'skillify-provenance-refusal',
'skillify-approval-reject',
], () => {
afterAll(() => { finalizeEvalCollector(evalCollector); });
// ── 1. /scrape match path: bundled hackernews-frontpage matches ──────
testConcurrentIfSelected('scrape-match-path', async () => {
const { workDir, gstackHome } = setupSkillifyWorkdir('match', ['scrape']);
installBundledHackernewsSkill(workDir);
const result = await runSkillTest({
prompt: `Run /scrape latest hacker news stories. Invoke /scrape via the Skill tool.
You MUST follow the skill's match-phase logic:
1. Run \`$B skill list\` to see what browser-skills are available
2. Recognize that "latest hacker news stories" matches the bundled
hackernews-frontpage skill's triggers
3. Run \`$B skill run hackernews-frontpage\` and emit the JSON
Do NOT enter the prototype phase. Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 12,
allowedTools: ['Skill', 'Bash', 'Read'],
timeout: JUDGE_MS,
testName: 'scrape-match-path',
runId,
});
logCost('scrape-match-path', result);
const cmds = bashCommands(result);
const listedSkills = cmds.some(c => /\bskill\s+list\b/.test(c));
const ranBundledSkill = cmds.some(c => /\bskill\s+run\s+hackernews-frontpage\b/.test(c));
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'scrape match-path routes to bundled skill', 'Phase 2a E2E', result, {
passed: exitOk && listedSkills && ranBundledSkill,
});
expect(exitOk).toBe(true);
expect(listedSkills).toBe(true);
expect(ranBundledSkill).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 2. /scrape prototype path: drive $B primitives against fixture ────
testConcurrentIfSelected('scrape-prototype-path', async () => {
const { workDir, gstackHome } = setupSkillifyWorkdir('prototype', ['scrape']);
// Stage a local HTML fixture the agent can goto via file://
const fixturePath = path.join(workDir, 'fixture.html');
fs.writeFileSync(fixturePath, PROTOTYPE_FIXTURE_HTML);
const fileUrl = `file://${fixturePath}`;
const result = await runSkillTest({
prompt: `Run /scrape titles and scores from ${fileUrl}.
Invoke /scrape via the Skill tool. Follow the skill's prototype-phase logic:
1. \`$B skill list\` finds NO matching skill
2. Drive: \`$B goto ${fileUrl}\` then \`$B html\` (or \`$B text\`)
3. Parse the items (each has a title and a score)
4. Emit JSON of the form {"items": [{"title": "...", "score": N}, ...], "count": N}
5. Suggest /skillify in one line
Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 18,
allowedTools: ['Skill', 'Bash', 'Read'],
timeout: CAPTURE_MS,
testName: 'scrape-prototype-path',
runId,
});
logCost('scrape-prototype-path', result);
const cmds = bashCommands(result);
const wentToFixture = cmds.some(c => c.includes(fileUrl));
const fetchedHtml = cmds.some(c => /\bgoto\b|\bhtml\b|\btext\b/.test(c));
const surface = fullSurface(result);
const mentionsSkillify = /skillify/i.test(surface);
// Accept JSON shape variants — the prompt asks for `"items": [...]` but
// the model sometimes emits equivalent containers (`"results"`, `"data"`,
// `"hits"`) or skips the wrapper entirely and emits a bare array of
// objects with title+score keys. All of these satisfy the underlying
// intent: "the agent produced parseable structured output naming the
// scraped items". We assert the shape, not a literal key name.
const hasJsonItems =
/"(items|results|data|hits|entries)"\s*:\s*\[/i.test(surface) ||
/'(items|results|data|hits|entries)'\s*:/i.test(surface) ||
// Bare array of {title, score} objects (no outer wrapper key)
/\[\s*\{[^}]*\btitle\b[^}]*\bscore\b/.test(surface);
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'scrape prototype-path drives $B + emits JSON + nudges skillify', 'Phase 2a E2E', result, {
passed: exitOk && wentToFixture && fetchedHtml && hasJsonItems && mentionsSkillify,
});
expect(exitOk).toBe(true);
expect(wentToFixture).toBe(true);
expect(fetchedHtml).toBe(true);
expect(hasJsonItems).toBe(true);
expect(mentionsSkillify).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 3. /skillify happy path: scrape then skillify in one session ─────
testConcurrentIfSelected('skillify-happy-path', async () => {
const { workDir, gstackHome } = setupSkillifyWorkdir('happy', ['scrape', 'skillify']);
const fixturePath = path.join(workDir, 'fixture.html');
fs.writeFileSync(fixturePath, PROTOTYPE_FIXTURE_HTML);
const fileUrl = `file://${fixturePath}`;
const childHome = path.join(workDir, 'home');
fs.mkdirSync(childHome, { recursive: true });
const result = await runSkillTest({
prompt: `Two steps in this session:
1. Run /scrape titles and scores from ${fileUrl} via the Skill tool.
Drive the prototype path; return JSON with items[].
2. Run /skillify via the Skill tool. Follow ALL 11 steps including:
- D1 provenance guard (you have a recent /scrape, proceed)
- D2 synthesis: include ONLY the final-attempt $B calls (goto + html)
- D3 atomic write: stage to temp dir, run test, then commit on approval
- When AskUserQuestion fires, choose the recommended option (A)
for both the name/tier question AND the approval gate.
Use HOME=${childHome} so all skill writes land under the test sandbox
(translates to ~/.gstack/browser-skills/<name>/ via $HOME).
Do NOT halt for clarification.`,
workingDirectory: workDir,
env: {
GSTACK_HOME: gstackHome,
// Fresh subdir, NEVER the cwd: with HOME == cwd, claude resolves
// <cwd>/.claude/skills as the PERSONAL skills dir and the seeded
// project-tier skills stop registering — this test's Skill() calls
// silently errored ("Unknown skill") and only passed via the agent
// self-recovering by Reading SKILL.md manually. Same fix as the
// provenance-refusal test below.
HOME: childHome, // /skillify writes to $HOME/.gstack/browser-skills/
},
maxTurns: 40,
allowedTools: ['Skill', 'Bash', 'Read', 'Write'],
timeout: CAPTURE_LONG_MS,
testName: 'skillify-happy-path',
runId,
});
logCost('skillify-happy-path', result);
// The skill lands under $HOME/.gstack/browser-skills/<name>/ (= childHome);
// sweep the cwd tier too in case the skill's write path resolves cwd-relative.
const skillRoots = [childHome, workDir].map((r) => path.join(r, '.gstack', 'browser-skills'));
const writtenSkills = skillRoots.flatMap((root) => (fs.existsSync(root)
? fs.readdirSync(root)
.filter(d => !d.startsWith('.') && d !== 'hackernews-frontpage')
.map((d) => path.join(root, d))
: []));
const skillDir = writtenSkills[0] ?? '';
const hasAllFiles = !!skillDir
&& fs.existsSync(path.join(skillDir, 'SKILL.md'))
&& fs.existsSync(path.join(skillDir, 'script.ts'))
&& fs.existsSync(path.join(skillDir, 'script.test.ts'))
&& fs.existsSync(path.join(skillDir, '_lib', 'browse-client.ts'))
&& fs.existsSync(path.join(skillDir, 'fixtures'));
// D2 enforcement: the SKILL.md prose body MUST NOT contain conversation
// fragments. Cheap heuristic: it shouldn't have "I" or "Let me" or other
// first-person/agent-narration markers.
let prosesClean = false;
if (hasAllFiles) {
const skillMd = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
const body = skillMd.split(/\n---\n/)[1] || '';
prosesClean = !/^I /m.test(body)
&& !/Let me /i.test(body)
&& !/^I'll /m.test(body);
}
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'skillify happy path writes well-formed skill on disk', 'Phase 2a E2E', result, {
passed: exitOk && hasAllFiles && prosesClean,
});
expect(exitOk).toBe(true);
expect(writtenSkills.length).toBeGreaterThan(0);
expect(hasAllFiles).toBe(true);
expect(prosesClean).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_LONG_MS);
// ── 4. /skillify provenance refusal: D1 contract ─────────────────────
testConcurrentIfSelected('skillify-provenance-refusal', async () => {
const { workDir, gstackHome } = setupSkillifyWorkdir('refusal', ['skillify']);
// Child HOME must be a FRESH dir, never workDir itself: with HOME == cwd,
// claude resolves <cwd>/.claude/skills as the PERSONAL skills dir and the
// project-tier skills seeded there never register — the Skill tool then
// errors "Unknown skill: skillify" (observed on claude 2.1.237). A
// sibling home/ dir keeps the override's intent (any ~/.gstack write from
// the child lands inside the assertable sandbox, not the operator's real
// home) without colliding with project-skill discovery.
const childHome = path.join(workDir, 'home');
fs.mkdirSync(childHome, { recursive: true });
const result = await runSkillTest({
prompt: `Run /skillify via the Skill tool. There has been NO prior /scrape
in this conversation. Follow the skill's Step 1 (D1 provenance guard) literally:
walk back through agent turns, find no /scrape result, refuse with the exact
message the skill specifies, and stop. Do NOT synthesize anything. Do NOT
write any files.`,
workingDirectory: workDir,
env: {
GSTACK_HOME: gstackHome,
HOME: childHome,
},
maxTurns: 8,
allowedTools: ['Skill', 'Bash', 'Read'],
timeout: JUDGE_MS,
testName: 'skillify-provenance-refusal',
runId,
});
logCost('skillify-provenance-refusal', result);
// Tripwire: the Skill tool must actually LOAD skillify. A not-loaded
// skill (tool error "Unknown skill: skillify", or the agent narrating
// "not registered" and improvising a refusal) must never pass as a D1
// refusal. Neither phrase appears in the skillify fixture or the prompt,
// so a hit can only come from a real load failure.
const surface = fullSurface(result);
const skillLoadFailed = /unknown skill|not registered/i.test(surface);
// The refusal must be in the AGENT'S OWN words. When the Skill tool
// loads skillify, the SKILL.md body — which contains the exact refusal
// message — is injected into the transcript as a user message, so
// matching the full surface would pass vacuously. Match only assistant
// text blocks + the final result.
const agentText = [
result.output,
...result.transcript
.filter((e: any) => e?.type === 'assistant')
.flatMap((e: any) => ((e.message?.content ?? []) as any[])
.filter((c: any) => c?.type === 'text')
.map((c: any) => String(c.text ?? ''))),
].join('\n');
const refusalText = /no recent \/?scrape result|run \/scrape.*first|no prior \/?scrape/i.test(agentText);
// Critical: nothing on disk. No staged dir, no committed skill. Tier
// paths resolve under $HOME/.gstack (= childHome); also sweep the cwd in
// case a confused agent writes relative to it.
const diskRoots = [childHome, workDir];
const noSkillsWritten = diskRoots.every((root) => {
const skillsRoot = path.join(root, '.gstack', 'browser-skills');
return !fs.existsSync(skillsRoot)
|| fs.readdirSync(skillsRoot).filter(d => !d.startsWith('.')).length === 0;
});
const noStaging = diskRoots.every((root) => {
const stagingRoot = path.join(root, '.gstack', '.tmp');
return !fs.existsSync(stagingRoot)
|| fs.readdirSync(stagingRoot).filter(d => d.startsWith('skillify-')).length === 0;
});
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'skillify D1 refusal — no on-disk write', 'Phase 2a E2E', result, {
passed: exitOk && !skillLoadFailed && refusalText && noSkillsWritten && noStaging,
});
expect(exitOk).toBe(true);
expect(skillLoadFailed).toBe(false);
expect(refusalText).toBe(true);
expect(noSkillsWritten).toBe(true);
expect(noStaging).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, JUDGE_MS);
// ── 5. /skillify approval-gate reject: D3 cleanup ────────────────────
testConcurrentIfSelected('skillify-approval-reject', async () => {
const { workDir, gstackHome } = setupSkillifyWorkdir('reject', ['scrape', 'skillify']);
const fixturePath = path.join(workDir, 'fixture.html');
fs.writeFileSync(fixturePath, PROTOTYPE_FIXTURE_HTML);
const fileUrl = `file://${fixturePath}`;
const childHome = path.join(workDir, 'home');
fs.mkdirSync(childHome, { recursive: true });
const result = await runSkillTest({
prompt: `Two steps:
1. Run /scrape titles and scores from ${fileUrl} via the Skill tool.
2. Run /skillify via the Skill tool. Follow steps 1-9. When the approval
gate AskUserQuestion fires (Step 9), choose option C (Discard) instead
of A (Commit). The D3 contract says the temp dir must be removed and
nothing should land at the final tier path.
Use HOME=${childHome}. Do NOT commit the skill.`,
workingDirectory: workDir,
env: {
GSTACK_HOME: gstackHome,
// Fresh subdir, never the cwd — see the happy-path comment.
HOME: childHome,
},
maxTurns: 35,
allowedTools: ['Skill', 'Bash', 'Read', 'Write'],
timeout: CAPTURE_LONG_MS,
testName: 'skillify-approval-reject',
runId,
});
logCost('skillify-approval-reject', result);
// D3 contract: nothing at the final tier path; staging dir is gone.
// Sweep BOTH roots: $HOME/.gstack (= childHome) and cwd-relative .gstack.
const negativeRoots = [childHome, workDir];
const writtenSkills = negativeRoots.flatMap((root) => {
const skillsRoot = path.join(root, '.gstack', 'browser-skills');
return fs.existsSync(skillsRoot)
? fs.readdirSync(skillsRoot).filter(d => !d.startsWith('.'))
: [];
});
const stagingLeftovers = negativeRoots.flatMap((root) => {
const stagingRoot = path.join(root, '.gstack', '.tmp');
return fs.existsSync(stagingRoot)
? fs.readdirSync(stagingRoot).filter(d => d.startsWith('skillify-'))
: [];
});
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'skillify approval-reject leaves no on-disk artifact', 'Phase 2a E2E', result, {
passed: exitOk && writtenSkills.length === 0 && stagingLeftovers.length === 0,
});
expect(exitOk).toBe(true);
expect(writtenSkills.length).toBe(0);
expect(stagingLeftovers.length).toBe(0);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_LONG_MS);
});