Compare commits

..
Author SHA1 Message Date
Abdul WahabandCursor c8f93c55db Fix: omit allowed-tools from Claude output only (#736)
Keep allowed-tools in shared source for other providers; drop it from the
Claude Code transformer and plugin rewrite so claude -p can activate the
skill. Verify gate now checks parsed frontmatter, not body text.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:11:50 +05:00
Abdul WahabandCursor 681569712f Fix: drop allowed-tools so skill activates in claude -p (#736)
Claude Code requires consent to activate skills that declare allowed-tools;
non-interactive hosts cannot provide it and the skill silently degrades.
Remove the frontmatter block from source and strip it in the plugin rewrite.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:07:27 +05:00
16 changed files with 120 additions and 176 deletions
Generated
+16 -16
View File
@@ -509,7 +509,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable" name = "impeccable"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"impeccable-browser", "impeccable-browser",
@@ -528,7 +528,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-browser" name = "impeccable-browser"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"impeccable-core", "impeccable-core",
@@ -542,7 +542,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-bundle" name = "impeccable-bundle"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"impeccable-core", "impeccable-core",
@@ -551,14 +551,14 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-common" name = "impeccable-common"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"libc", "libc",
] ]
[[package]] [[package]]
name = "impeccable-comp" name = "impeccable-comp"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"image", "image",
"once_cell", "once_cell",
@@ -570,7 +570,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-comp-verbs" name = "impeccable-comp-verbs"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"impeccable-common", "impeccable-common",
"impeccable-comp", "impeccable-comp",
@@ -583,7 +583,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-context" name = "impeccable-context"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"flate2", "flate2",
"impeccable-common", "impeccable-common",
@@ -600,7 +600,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-core" name = "impeccable-core"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"impeccable-core", "impeccable-core",
"impeccable-foundation", "impeccable-foundation",
@@ -612,7 +612,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-detect" name = "impeccable-detect"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"impeccable-common", "impeccable-common",
"impeccable-core", "impeccable-core",
@@ -624,7 +624,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-foundation" name = "impeccable-foundation"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"cssparser", "cssparser",
"once_cell", "once_cell",
@@ -637,7 +637,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-hook" name = "impeccable-hook"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"impeccable-common", "impeccable-common",
"impeccable-context", "impeccable-context",
@@ -651,7 +651,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-html" name = "impeccable-html"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"cssparser", "cssparser",
"ego-tree", "ego-tree",
@@ -672,7 +672,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-live" name = "impeccable-live"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"impeccable-common", "impeccable-common",
@@ -690,7 +690,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-skills" name = "impeccable-skills"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"impeccable-common", "impeccable-common",
"impeccable-context", "impeccable-context",
@@ -709,7 +709,7 @@ dependencies = [
[[package]] [[package]]
name = "impeccable-wasm" name = "impeccable-wasm"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"impeccable-core", "impeccable-core",
"impeccable-detect", "impeccable-detect",
@@ -1681,7 +1681,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"impeccable-bundle", "impeccable-bundle",
] ]
+1 -1
View File
@@ -6,7 +6,7 @@ resolver = "2"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.1.1" version = "0.1.0"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
publish = false publish = false
+1 -1
View File
@@ -1 +1 @@
0.1.1 0.1.0
+5 -15
View File
@@ -19,11 +19,11 @@
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
"optionalDependencies": { "optionalDependencies": {
"@impeccable/cli-darwin-arm64": "0.1.1", "@impeccable/cli-darwin-arm64": "0.1.0",
"@impeccable/cli-darwin-x64": "0.1.1", "@impeccable/cli-darwin-x64": "0.1.0",
"@impeccable/cli-linux-arm64": "0.1.1", "@impeccable/cli-linux-arm64": "0.1.0",
"@impeccable/cli-linux-x64": "0.1.1", "@impeccable/cli-linux-x64": "0.1.0",
"@impeccable/cli-windows-x64": "0.1.1", "@impeccable/cli-windows-x64": "0.1.0",
}, },
}, },
}, },
@@ -72,16 +72,6 @@
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@impeccable/cli-darwin-arm64": ["@impeccable/cli-darwin-arm64@0.1.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "impeccable-darwin-arm64": "bin/impeccable" } }, "sha512-1/DZYaiZqDoNwpXpyoG4gRQpgLZ4YDCMADGzA5wYmNiG279b3KPt2RLYUww6NF2hxoMxdVQUAGzdc5062KZKHg=="],
"@impeccable/cli-darwin-x64": ["@impeccable/cli-darwin-x64@0.1.1", "", { "os": "darwin", "cpu": "x64", "bin": { "impeccable-darwin-x64": "bin/impeccable" } }, "sha512-/itjFZEHPcz1RQDBx3+2aeTebQ4pCD30VKAJ3Zst5KxHteJQOW3hoiTFE7jAOXg3vG2D/qAaEh/7Eo+xzEawew=="],
"@impeccable/cli-linux-arm64": ["@impeccable/cli-linux-arm64@0.1.1", "", { "os": "linux", "cpu": "arm64", "bin": { "impeccable-linux-arm64": "bin/impeccable" } }, "sha512-uGJ2DNVq3NzH8+RlTlyn1XWpAsNS1bv6kix7PsAnzCa0aBiOHaYSUkPXbqDVUTDy9X4oEOsZYuzbgoY+otkoMQ=="],
"@impeccable/cli-linux-x64": ["@impeccable/cli-linux-x64@0.1.1", "", { "os": "linux", "cpu": "x64", "bin": { "impeccable-linux-x64": "bin/impeccable" } }, "sha512-wPul+V7w9g0MZAgFmJJvEOXpy8AYt4htmLQJIx7XvakSp8CS+PYBjEFqmWSW41NlrNAaifzDKdUe6rzeRli97A=="],
"@impeccable/cli-windows-x64": ["@impeccable/cli-windows-x64@0.1.1", "", { "os": "win32", "cpu": "x64", "bin": { "impeccable-windows-x64": "bin/impeccable.exe" } }, "sha512-dqcQ8VQFschjA1iFzhKvO44UEzsbQQGHAZW3MmsxJbqo/uSDENIL8WA5l4K8Q9wqjf4Kbc/H1hqo6MjOuQw85A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "impeccable", "name": "impeccable",
"version": "4.0.2", "version": "4.0.1",
"author": "Paul Bakaus", "author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents", "description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [ "keywords": [
@@ -71,11 +71,11 @@
"check:engine-release": "node scripts/check-engine-release.mjs" "check:engine-release": "node scripts/check-engine-release.mjs"
}, },
"optionalDependencies": { "optionalDependencies": {
"@impeccable/cli-darwin-arm64": "0.1.1", "@impeccable/cli-darwin-arm64": "0.1.0",
"@impeccable/cli-darwin-x64": "0.1.1", "@impeccable/cli-darwin-x64": "0.1.0",
"@impeccable/cli-linux-x64": "0.1.1", "@impeccable/cli-linux-x64": "0.1.0",
"@impeccable/cli-linux-arm64": "0.1.1", "@impeccable/cli-linux-arm64": "0.1.0",
"@impeccable/cli-windows-x64": "0.1.1" "@impeccable/cli-windows-x64": "0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@ai-sdk/anthropic": "^4.0.7", "@ai-sdk/anthropic": "^4.0.7",
+21 -12
View File
@@ -1,5 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { generateYamlFrontmatter, parseFrontmatter } from './utils.js';
/** /**
* Rewrite project-relative script paths for the plugin subtree (issue #523). * Rewrite project-relative script paths for the plugin subtree (issue #523).
@@ -26,15 +27,19 @@ export const CLAUDE_PROJECT_SCRIPTS_PATH = '.claude/skills/impeccable/scripts';
export const PLUGIN_SCRIPTS_PATH = '<skill-base-dir>/scripts'; export const PLUGIN_SCRIPTS_PATH = '<skill-base-dir>/scripts';
// The project-path rule pre-approves a path inside the user's project, the // Claude Code requires user consent to activate a skill whose frontmatter
// one place the plugin must NOT run scripts from. No replacement rule // declares allowed-tools; non-interactive hosts (`claude -p`) cannot provide
// exists: a wildcard pattern such as `node */skills/impeccable/scripts/*` // it and the skill silently degrades (issue #736). The plugin copy drops the
// auto-approves any same-shaped path anywhere on disk, and frontmatter has // key via structured frontmatter rewrite, not regex.
// no variable bound to the loaded plugin root (CLAUDE_PLUGIN_ROOT is
// hook-only). The plugin copy drops the rule and script runs go through
// the normal Bash confirmation.
export const PROJECT_ALLOWED_TOOLS_LINE = ` - Bash(${CLAUDE_PROJECT_SCRIPTS_PATH}/impeccable *)\n`; export const PROJECT_ALLOWED_TOOLS_LINE = ` - Bash(${CLAUDE_PROJECT_SCRIPTS_PATH}/impeccable *)\n`;
function stripAllowedToolsFrontmatter(content) {
const { frontmatter, body } = parseFrontmatter(content);
if (frontmatter['allowed-tools'] === undefined) return content;
delete frontmatter['allowed-tools'];
return `${generateYamlFrontmatter(frontmatter)}\n${body}`;
}
// Setup step 1's second sentence names the project path as the fallback // Setup step 1's second sentence names the project path as the fallback
// when the runtime reports no base directory. A plugin install has no // when the runtime reports no base directory. A plugin install has no
// working project fallback (that path is the bug this rewrite exists to // working project fallback (that path is the bug this rewrite exists to
@@ -72,10 +77,7 @@ export const AGENT_EMBED_FALLBACK =
* unit suite can pin every rewrite without a build. * unit suite can pin every rewrite without a build.
*/ */
export function rewritePluginMarkdown(content) { export function rewritePluginMarkdown(content) {
return content return stripAllowedToolsFrontmatter(content)
// Order matters: the allowed-tools line contains the project path, so
// remove it before the generic path replacement rewrites it into a
// line the removal no longer matches.
.replaceAll(PROJECT_ALLOWED_TOOLS_LINE, '') .replaceAll(PROJECT_ALLOWED_TOOLS_LINE, '')
.replaceAll(SETUP_FALLBACK_TEXT, SETUP_PLUGIN_TEXT) .replaceAll(SETUP_FALLBACK_TEXT, SETUP_PLUGIN_TEXT)
.replaceAll(CLAUDE_PROJECT_SCRIPTS_PATH, PLUGIN_SCRIPTS_PATH) .replaceAll(CLAUDE_PROJECT_SCRIPTS_PATH, PLUGIN_SCRIPTS_PATH)
@@ -154,10 +156,17 @@ export function verifyPluginSkillRewrite(skillMdPath) {
'scripts/lib/plugin-paths.js (issue #523); update SETUP_FALLBACK_TEXT to the new wording.', 'scripts/lib/plugin-paths.js (issue #523); update SETUP_FALLBACK_TEXT to the new wording.',
); );
} }
if (parseFrontmatter(content).frontmatter['allowed-tools'] !== undefined) {
throw new Error(
`Plugin rewrite drift: ${skillMdPath} still declares allowed-tools in frontmatter. ` +
'The plugin copy must drop the entire block so non-interactive sessions can activate ' +
'the skill (issue #736); update the removal in scripts/lib/plugin-paths.js.',
);
}
if (/Bash\((?:node |[^)]*scripts\/impeccable)/.test(content)) { if (/Bash\((?:node |[^)]*scripts\/impeccable)/.test(content)) {
throw new Error( throw new Error(
`Plugin rewrite drift: ${skillMdPath} still pre-approves an engine launcher or node script path. ` + `Plugin rewrite drift: ${skillMdPath} still pre-approves an engine launcher or node script path. ` +
"SKILL.src.md's allowed-tools entry no longer matches the removal in " + 'A stray Bash(...) entry survived the allowed-tools removal in ' +
'scripts/lib/plugin-paths.js (issue #523); the plugin ships no launcher pre-approval.', 'scripts/lib/plugin-paths.js (issue #523); the plugin ships no launcher pre-approval.',
); );
} }
+3 -1
View File
@@ -29,7 +29,9 @@ export const PROVIDERS = {
providerTags: ['claude-code', 'claude'], providerTags: ['claude-code', 'claude'],
configDir: '.claude', configDir: '.claude',
displayName: 'Claude Code', displayName: 'Claude Code',
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'], frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata'],
// allowed-tools omitted: Claude Code blocks skill activation in non-interactive
// sessions when the field is present (issue #736). Other providers keep it.
agentFormat: 'claude-md', agentFormat: 'claude-md',
emitHooks: 'claude', emitHooks: 'claude',
// Project-local Claude Code hooks live in `.claude/settings.json`. // Project-local Claude Code hooks live in `.claude/settings.json`.
+1 -1
View File
@@ -1 +1 @@
0.1.1 0.1.0
+4 -6
View File
@@ -7834,6 +7834,7 @@
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled'); if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
showBar('generating'); showBar('generating');
saveSession(); saveSession();
sendCheckpoint('generate_started');
writeScrollY(window.scrollY); writeScrollY(window.scrollY);
if (variantObserver) variantObserver.disconnect(); if (variantObserver) variantObserver.disconnect();
variantObserver = startVariantObserver(currentSessionId); variantObserver = startVariantObserver(currentSessionId);
@@ -7915,6 +7916,7 @@
showBar('generating'); showBar('generating');
startScrollTracking(); startScrollTracking();
saveSession(); saveSession();
sendCheckpoint('generate_started');
writeScrollY(window.scrollY); writeScrollY(window.scrollY);
if (variantObserver) variantObserver.disconnect(); if (variantObserver) variantObserver.disconnect();
variantObserver = startVariantObserver(currentSessionId); variantObserver = startVariantObserver(currentSessionId);
@@ -8236,8 +8238,7 @@
// rasterization from delaying the fetch itself. // rasterization from delaying the fetch itself.
if (!hasAnnotations) { if (!hasAnnotations) {
basePayload.clientSentAt = Date.now(); basePayload.clientSentAt = Date.now();
const created = await sendEvent(basePayload); await sendEvent(basePayload);
if (created?.ok && currentSessionId === basePayload.id) sendCheckpoint('generate_started');
} }
let screenshotPath; let screenshotPath;
@@ -8278,10 +8279,7 @@
// is semantic input. Plain requests were already dispatched above. // is semantic input. Plain requests were already dispatched above.
if (hasAnnotations) { if (hasAnnotations) {
basePayload.clientSentAt = Date.now(); basePayload.clientSentAt = Date.now();
const created = await sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
// Capture/upload can take seconds. Progress before this acknowledgment
// refers to an unknown session and would clear our own active work.
if (created?.ok && currentSessionId === basePayload.id) sendCheckpoint('generate_started');
} }
} }
@@ -57,12 +57,6 @@
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
"readyTimeoutMs": 120000, "readyTimeoutMs": 120000,
"steer": false, "steer": false,
"liveChrome": {
"annotations": {
"selector": "h1.hero-title",
"uploadDelayMs": 300
}
},
"pickSelector": "ul.expense-list", "pickSelector": "ul.expense-list",
"pickPosition": { "pickPosition": {
"x": 10, "x": 10,
+1 -42
View File
@@ -2,53 +2,12 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8'); const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8');
const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || ''; const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || '';
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || ''; const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
describe('live-browser source contracts', () => { describe('live-browser source contracts', () => {
it('does not checkpoint a generation before captureAndEmit creates its session', () => {
for (const name of ['handleGo', 'handleInsertCreate']) {
const body = SOURCE.match(new RegExp(`function ${name}\\(\\) \\{[\\s\\S]*?\\n \\}`))?.[0];
assert.ok(body);
assert.doesNotMatch(body, /sendCheckpoint\('generate_started'\)/);
}
});
for (const annotated of [false, true]) {
for (const outcome of ['created', 'failed', 'superseded']) {
it(`${annotated ? 'annotated' : 'plain'} generation checkpoints only its acknowledged current session (${outcome})`, async () => {
const capture = Promise.withResolvers();
const creation = Promise.withResolvers();
const events = [];
const context = {
currentSessionId: 'session-a', state: 'GENERATING', PORT: 1234, TOKEN: 'test',
console, Date,
captureElementToBlob: () => capture.promise,
showShaderOverlay() {},
fetch: async () => ({ ok: true, json: async () => ({ path: '/annotation.png' }) }),
sendEvent: async (payload) => { events.push(payload.type); return creation.promise; },
sendCheckpoint: (reason) => events.push(reason),
};
const emit = runInNewContext(`(${CAPTURE_AND_EMIT_SOURCE})`, context);
const pending = emit({}, { type: 'generate', id: 'session-a' }, {
comments: annotated ? [{ text: 'change title' }] : [], strokes: [],
}, {});
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(events, annotated ? [] : ['generate']);
capture.resolve({ blob: {}, paper: 'white' });
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(events, ['generate'], 'capture/upload must not checkpoint before creation is acknowledged');
if (outcome === 'superseded') context.currentSessionId = 'session-b';
creation.resolve(outcome === 'failed' ? null : { ok: true });
await pending;
assert.deepEqual(events, outcome === 'created' ? ['generate', 'generate_started'] : ['generate']);
});
}
}
it('reports foreground poll connectivity without a background worker dependency', () => { it('reports foreground poll connectivity without a background worker dependency', () => {
assert.match( assert.match(
SOURCE, SOURCE,
@@ -70,7 +29,7 @@ describe('live-browser source contracts', () => {
); );
assert.match( assert.match(
CAPTURE_AND_EMIT_SOURCE, CAPTURE_AND_EMIT_SOURCE,
/if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*const created = await sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);/, /if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
'annotated generation should dispatch exactly after capture and upload resolve', 'annotated generation should dispatch exactly after capture and upload resolve',
); );
}); });
-14
View File
@@ -7,7 +7,6 @@ import {
MANUAL_EDIT_SYSTEM_INSTRUCTIONS, MANUAL_EDIT_SYSTEM_INSTRUCTIONS,
VARIANT_SYSTEM_INSTRUCTIONS, VARIANT_SYSTEM_INSTRUCTIONS,
createLlmAgent, createLlmAgent,
llmRequestSettings,
parseManualEditResponse, parseManualEditResponse,
parseVariantResponse, parseVariantResponse,
progressiveVariantGuidance, progressiveVariantGuidance,
@@ -20,19 +19,6 @@ import {
validateVariantVisibleCopy, validateVariantVisibleCopy,
} from './live-e2e/agents/llm-agent.mjs'; } from './live-e2e/agents/llm-agent.mjs';
describe('live-e2e LLM request settings', () => {
it('explicitly selects low-effort DeepSeek thinking for bounded JSON edit requests', () => {
assert.deepEqual(llmRequestSettings('deepseek'), {
thinking: { type: 'enabled' }, output_config: { effort: 'low' },
});
});
it('leaves other providers unchanged', () => {
assert.deepEqual(llmRequestSettings('anthropic'), {});
assert.deepEqual(llmRequestSettings('openai'), {});
});
});
describe('live-e2e LLM agent provider config', () => { describe('live-e2e LLM agent provider config', () => {
it('defaults to OpenAI gpt-5.6-terra at medium reasoning effort', () => { it('defaults to OpenAI gpt-5.6-terra at medium reasoning effort', () => {
const config = resolveLlmAgentConfig({}, {}); const config = resolveLlmAgentConfig({}, {});
+6 -10
View File
@@ -1283,21 +1283,17 @@ for (const { name, fixture } of fixtures) {
const pickSelector = annotation.selector || fixture.runtime.pickSelector || 'h1.hero-title'; const pickSelector = annotation.selector || fixture.runtime.pickSelector || 'h1.hero-title';
try { try {
await waitForHandshake(page); await waitForHandshake(page);
if (annotation.uploadDelayMs) {
await page.route('**/annotation?*', async (route) => {
await new Promise(resolve => setTimeout(resolve, annotation.uploadDelayMs));
await route.continue();
});
}
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions); if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
await pickElement(page, pickSelector, { resetPickMode: true }); await pickElement(page, pickSelector, { resetPickMode: true });
await drawAnnotationPinAndStroke(page, { await drawAnnotationPinAndStroke(page, {
comment: annotation.comment || 'Make this selected element easier to scan', comment: annotation.comment || 'Make this selected element easier to scan',
}); });
await clickGo(page); await clickGo(page);
// A reload would mask a checkpoint-before-creation race by adopting await waitForCyclingRobust(page, 3, {
// the session again. Annotated generation must complete in this tab. agentMode,
await waitForCycling(page, 3, { timeout: agentMode === 'llm' ? 180_000 : 30_000 }); preActions: fixture.runtime.preActions,
log: (m) => t.diagnostic(m),
});
const generateEvent = recordedGenerateEvents.at(-1); const generateEvent = recordedGenerateEvents.at(-1);
await assertAnnotationUploadEvent(generateEvent); await assertAnnotationUploadEvent(generateEvent);
@@ -1306,7 +1302,7 @@ for (const { name, fixture } of fixtures) {
const sourceFile = await locateSessionFile(session.appRoot); const sourceFile = await locateSessionFile(session.appRoot);
const svelteComponentTarget = svelteComponentTargetFor(sourceFile); const svelteComponentTarget = svelteComponentTargetFor(sourceFile);
await cycleToVariant(page, 2, 3); await clickNext(page);
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate'); assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate');
await clickAccept(page, { expectedVariant: 2 }); await clickAccept(page, { expectedVariant: 2 });
await waitForBarHidden(page); await waitForBarHidden(page);
+1 -18
View File
@@ -248,17 +248,6 @@ function resolveProvider(opts, env) {
return 'openai'; return 'openai';
} }
export function llmRequestSettings(provider) {
// DeepSeek defaults to high-effort thinking, which can consume the entire
// bounded response before emitting the JSON these edit tests exercise.
// Low effort retains planning for the full live spec without inheriting
// the provider's high-effort default.
// https://api-docs.deepseek.com/guides/thinking_mode/
return provider === 'deepseek'
? { thinking: { type: 'enabled' }, output_config: { effort: 'low' } }
: {};
}
/** /**
* Anthropic-SDK-shaped shim over the `ai` SDK for OpenAI models, so the * Anthropic-SDK-shaped shim over the `ai` SDK for OpenAI models, so the
* three text-only call sites in this file stay provider-agnostic. system * three text-only call sites in this file stay provider-agnostic. system
@@ -342,7 +331,6 @@ export async function createLlmAgent(opts = {}) {
try { try {
response = await client.messages.create( response = await client.messages.create(
{ {
...llmRequestSettings(provider),
model, model,
temperature: 0, temperature: 0,
max_tokens: 16000, max_tokens: 16000,
@@ -488,7 +476,6 @@ export async function createLlmAgent(opts = {}) {
try { try {
response = await client.messages.create( response = await client.messages.create(
{ {
...llmRequestSettings(provider),
model, model,
temperature: 0, temperature: 0,
max_tokens: 16000, max_tokens: 16000,
@@ -618,15 +605,11 @@ export async function createLlmAgent(opts = {}) {
].join('\n'); ].join('\n');
const response = await client.messages.create({ const response = await client.messages.create({
...llmRequestSettings(provider),
model, model,
max_tokens: 4096, max_tokens: 4096,
system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS), system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS),
messages: [{ role: 'user', content: userMessage }], messages: [{ role: 'user', content: userMessage }],
}, provider === 'deepseek' ? { });
maxRetries: LLM_REQUEST_MAX_RETRIES,
timeout: MANUAL_EDIT_REQUEST_TIMEOUT_MS,
} : {});
const cacheRead = response.usage?.cache_read_input_tokens ?? 0; const cacheRead = response.usage?.cache_read_input_tokens ?? 0;
const inputTokens = response.usage?.input_tokens ?? 0; const inputTokens = response.usage?.input_tokens ?? 0;
+11 -7
View File
@@ -26,7 +26,8 @@ import {
clickEditCopy, clickEditCopy,
clickExitLiveMode, clickExitLiveMode,
clickGo, clickGo,
cycleToVariant, clickNext,
clickPrev,
clickSaveEdit, clickSaveEdit,
drawAnnotationPinAndStroke, drawAnnotationPinAndStroke,
editTextLeaf, editTextLeaf,
@@ -39,7 +40,6 @@ import {
waitForBarHidden, waitForBarHidden,
waitForCycling, waitForCycling,
waitForHandshake, waitForHandshake,
waitForVariantSettled,
} from './live-e2e/ui.mjs'; } from './live-e2e/ui.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -281,7 +281,7 @@ async function runAnnotationGenerateFlow({ page, tmp, evidence }) {
const generateEvent = latestJournalEvent(tmp, (event) => event.type === 'generate' && event.screenshotPath); const generateEvent = latestJournalEvent(tmp, (event) => event.type === 'generate' && event.screenshotPath);
await assertAnnotationUploadEvent(generateEvent); await assertAnnotationUploadEvent(generateEvent);
assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists'); assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists');
await cycleTo(page, 2); await clickNext(page);
await assertVariantCounter(page, 2, 3); await assertVariantCounter(page, 2, 3);
await evidence.capture('annotation-cycle'); await evidence.capture('annotation-cycle');
await clickDiscard(page); await clickDiscard(page);
@@ -514,10 +514,14 @@ async function clickPendingTrash(page) {
} }
async function cycleTo(page, target) { async function cycleTo(page, target) {
// Component imports finish after the counter changes. Do not send the next for (let i = 0; i < 6; i++) {
// click (or reload) while the previous variant is still mounting. const visible = await getVisibleVariant(page);
await cycleToVariant(page, target, 3); if (visible === target) return;
await waitForVariantSettled(page, target, 3); if (visible == null) await page.waitForTimeout(250);
else if (visible < target) await clickNext(page);
else await clickPrev(page);
}
assert.equal(await getVisibleVariant(page), target, `variant ${target} visible`);
} }
async function waitForVisibleCycling(page, count, { timeout }) { async function waitForVisibleCycling(page, count, { timeout }) {
+43 -20
View File
@@ -15,6 +15,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { parseFrontmatter } from '../scripts/lib/utils.js';
import { import {
rewritePluginMarkdown, rewritePluginMarkdown,
rewritePluginAgentMarkdown, rewritePluginAgentMarkdown,
@@ -66,20 +67,24 @@ describe('rewritePluginMarkdown', () => {
); );
}); });
test('removes the node pre-approval instead of widening it', () => { test('removes the entire allowed-tools frontmatter block', () => {
const frontmatter = [ const input = [
'---',
'name: impeccable',
'allowed-tools:', 'allowed-tools:',
' - Bash(npx impeccable *)', ' - Bash(npx impeccable *)',
' - Bash(.claude/skills/impeccable/scripts/impeccable *)', ' - Bash(.claude/skills/impeccable/scripts/impeccable *)',
'license: Apache 2.0',
'---', '---',
'', '',
'Body text.',
].join('\n'); ].join('\n');
const output = rewritePluginMarkdown(frontmatter); const output = rewritePluginMarkdown(input);
// The generic path rewrite alone would leave Bash(<skill-base-dir>/scripts/impeccable *), expect(output).not.toMatch(/^allowed-tools:/m);
// a dead literal, and any wildcard replacement would auto-approve
// same-shaped paths outside the plugin. The line must go entirely.
expect(output).not.toContain('scripts/impeccable *'); expect(output).not.toContain('scripts/impeccable *');
expect(output).toContain(' - Bash(npx impeccable *)\n---'); expect(output).not.toContain('npx impeccable');
expect(output).toContain('license: Apache 2.0');
expect(output).toContain('Body text.');
}); });
test('drops the project-path fallback clause from Setup step 1', () => { test('drops the project-path fallback clause from Setup step 1', () => {
@@ -256,9 +261,6 @@ describe('verifyPluginSkillRewrite', () => {
}; };
const goodSkill = [ const goodSkill = [
'allowed-tools:',
' - Bash(.claude/skills/impeccable/scripts/impeccable *)',
'',
'1. Run `<skill-base-dir>/scripts/impeccable context` once per session, where `<skill-base-dir>` is the ' + '1. Run `<skill-base-dir>/scripts/impeccable context` once per session, where `<skill-base-dir>` is the ' +
"loaded base directory the runtime reports for this skill; keep cwd at the user's project. " + "loaded base directory the runtime reports for this skill; keep cwd at the user's project. " +
'That base directory resolves every `.claude/skills/impeccable/scripts/impeccable <verb>` command in this skill ' + 'That base directory resolves every `.claude/skills/impeccable/scripts/impeccable <verb>` command in this skill ' +
@@ -269,6 +271,7 @@ describe('verifyPluginSkillRewrite', () => {
test('accepts a correctly rewritten SKILL.md', () => { test('accepts a correctly rewritten SKILL.md', () => {
const p = writeSkill(rewritePluginMarkdown(goodSkill)); const p = writeSkill(rewritePluginMarkdown(goodSkill));
expect(() => verifyPluginSkillRewrite(p)).not.toThrow(); expect(() => verifyPluginSkillRewrite(p)).not.toThrow();
expect(fs.readFileSync(p, 'utf-8')).not.toMatch(/^allowed-tools:/m);
}); });
test('fails the build when the Setup fallback sentence no longer matched', () => { test('fails the build when the Setup fallback sentence no longer matched', () => {
@@ -280,22 +283,31 @@ describe('verifyPluginSkillRewrite', () => {
}); });
test('fails the build when a launcher pre-approval survives the removal', () => { test('fails the build when a launcher pre-approval survives the removal', () => {
const reworded = goodSkill.replace( const p = writeSkill(
'Bash(.claude/skills/impeccable/scripts/impeccable *)', rewritePluginMarkdown(goodSkill) + '\n - Bash(<skill-base-dir>/scripts/impeccable.cmd *)\n',
'Bash(.claude/skills/impeccable/scripts/impeccable.cmd *)',
); );
const p = writeSkill(rewritePluginMarkdown(reworded));
expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher/); expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher/);
}); });
test('fails the build when allowed-tools frontmatter survives the removal', () => {
const p = writeSkill([
'---',
'name: impeccable',
'allowed-tools:',
' - Bash(npx impeccable *)',
'license: Apache 2.0',
'---',
'',
rewritePluginMarkdown(goodSkill),
].join('\n'));
expect(() => verifyPluginSkillRewrite(p)).toThrow(/allowed-tools/);
});
test('fails the build when a legacy node pre-approval survives', () => { test('fails the build when a legacy node pre-approval survives', () => {
// The Node-era line is gone from SKILL.src.md, but a copy that still // A copy that still carries a node pre-approval must fail the same way as
// carries one must fail the same way as a surviving launcher line. // a surviving launcher line, even outside an allowed-tools block.
const p = writeSkill( const p = writeSkill(
rewritePluginMarkdown(goodSkill).replace( rewritePluginMarkdown(goodSkill) + '\n - Bash(node <skill-base-dir>/scripts/*)\n',
'allowed-tools:\n',
'allowed-tools:\n - Bash(node <skill-base-dir>/scripts/*)\n',
),
); );
expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher or node script path/); expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher or node script path/);
}); });
@@ -310,3 +322,14 @@ describe('verifyPluginSkillRewrite', () => {
expect(() => verifyPluginSkillRewrite(p)).toThrow(/still contains the project-relative scripts path/); expect(() => verifyPluginSkillRewrite(p)).toThrow(/still contains the project-relative scripts path/);
}); });
}); });
describe('SKILL.src.md frontmatter', () => {
test('keeps allowed-tools in source for non-Claude providers (issue #736)', () => {
const src = fs.readFileSync(
path.join(import.meta.dirname, '../skill/SKILL.src.md'),
'utf-8',
);
const { frontmatter } = parseFrontmatter(src);
expect(frontmatter['allowed-tools']).toBeDefined();
});
});