Compare commits

...
Author SHA1 Message Date
Abdul WahabandCursor 7ddcd533a4 Test: pin 1D grid-background pass cases in the fixture suite
The unit suite already covered dashed rules; this adds an isolated HTML fixture so the page-level one-finding cap cannot hide a regression.

Prepared with AI assistance (Cursor agent), directed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:58:36 +05:00
Abdul WahabandCursor a236137bc6 Fix: stop flagging 1D dashed rules as grid backgrounds (#615)
codex-grid-background treated any 2D px background-size as a grid, so a single hairline tiled as a dash or rail false-positived. A finding now requires two hairline gradients plus a px tile.

Prepared with AI assistance (Cursor agent), directed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:51:25 +05:00
Abdul WahabandGitHub 56f44523f7 Merge pull request #627 from pbakaus/codex/issue-624-claude-agents
Fix Claude agent installation
2026-08-22 04:59:10 +05:00
github-actions[bot] 5d4418e2dc Sync generated provider output 2026-08-21 23:56:25 +00:00
Abdul WahabandGitHub abba4012ff Merge pull request #622 from pbakaus/codex/issue-620-tune-no-params
Fix stalled Tune state without params
2026-08-22 04:55:56 +05:00
github-actions[bot] e0a9d8e7d9 Sync generated provider output 2026-08-21 23:53:35 +00:00
Abdul WahabandGitHub fccd91c6ac Merge pull request #617 from pbakaus/codex/remove-dead-design-parser-paths-20260819
Remove dead DESIGN.md parser paths
2026-08-22 04:52:47 +05:00
github-actions[bot] e5abceedc4 Sync generated provider output 2026-08-21 23:50:29 +00:00
Abdul WahabandGitHub c29f30fa34 Merge pull request #616 from pbakaus/codex/issue-614-remove-multiedit
Remove stale Claude MultiEdit matcher
2026-08-22 04:49:56 +05:00
github-actions[bot] 6360b27823 Sync generated provider output 2026-08-21 23:33:14 +00:00
Abdul WahabandGitHub a66aefba80 Merge pull request #613 from pbakaus/codex/simplify-manual-apply-failures-20260818
Simplify manual Apply failure orchestration
2026-08-22 04:32:43 +05:00
Abdul WahabandGitHub 77dd327080 Merge pull request #606 from pbakaus/codex/centralize-provider-smoke-runtime-20260817
Simplify provider hook smoke orchestration
2026-08-22 04:31:41 +05:00
Abdul WahabandGitHub ff1f15c7ad Merge pull request #605 from pbakaus/codex/issue-604-claude-hook-migration
Fix Windows Claude hook migration dedupe
2026-08-22 04:30:41 +05:00
Paul Bakaus d2a9efb90f Preserve inferred agent update scope
Prepared with AI assistance from Codex under explicit maintainer authorization.
2026-08-21 09:37:53 -07:00
Paul Bakaus 16a218e632 Fix home-scoped agent freshness
Prepared with AI assistance from Codex under explicit maintainer authorization.
2026-08-21 09:23:41 -07:00
Paul Bakaus 7b94585653 Fix Claude agent installation
Prepared with AI assistance from Codex under explicit maintainer authorization.
2026-08-21 09:10:31 -07:00
Paul Bakaus 478325a2dd Fix stalled Tune state without params
Resolve pending Tune controls when the completed variant set contains no tunable parameters, while preserving deferred parameter publications.

AI assistance: implemented and validated by OpenAI Codex under maintainer authorization.
2026-08-20 09:18:33 -07:00
Paul Bakaus 8d62b135fe Remove dead design parser paths
Delete unreachable inline color parsing helpers and unused regular expressions without changing the DESIGN.md parser contract.

AI-assisted: prepared by Codex under pbakaus’s scheduled architecture-refactor authorization.
2026-08-19 11:29:00 -07:00
Paul Bakaus 1f2c3f9d6b Simplify manual Apply rollback flow
Centralize repeated rollback result construction, repair context, and entry verification without changing the live Apply contract.

AI-assisted: prepared by Codex under pbakaus's scheduled architecture-refactor authorization.
2026-08-18 11:33:07 -07:00
Paul Bakaus cf8f295dc3 Simplify provider hook smoke orchestration
Centralize provider fixture, hook, and agent-launch contracts while preserving provider-specific verification behavior. Reuse the shared CLI argument parser and characterize the public usage contract.

AI-assisted: prepared by Codex under pbakaus's scheduled architecture-refactor authorization.
2026-08-17 08:45:12 -07:00
Paul Bakaus 665c51b903 Fix Windows hook migration dedupe
Normalize hook command separators before matching Impeccable-owned entries so updates replace legacy Windows guards instead of duplicating them.\n\nAI assistance: Codex implemented and validated this change under maintainer authorization.
2026-08-17 06:10:16 -07:00
91 changed files with 1780 additions and 2746 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `$impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
+2 -2
View File
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
+11 -3
View File
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+38 -8
View File
@@ -699,7 +699,7 @@ function deduplicateProviders(root, providers, scope) {
* SKILL.md, so script-only fixes and removed files are detected.
* Returns true if every bundle skill matches the local copy.
*/
function isUpToDate(root, providers, bundleDir, scope) {
function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
const unique = deduplicateProviders(root, providers, scope);
if (unique.length === 0) return false;
@@ -724,6 +724,8 @@ function isUpToDate(root, providers, bundleDir, scope) {
if (bundleHash !== localHash) return false;
}
}
if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false;
}
return true;
}
@@ -745,7 +747,8 @@ async function check() {
console.log('Checking for updates...\n');
try {
const bundleDir = await downloadAndExtractBundle();
const upToDate = isUpToDate(root, providers, bundleDir);
const agentScope = isHomeDir(root) ? 'user' : undefined;
const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope);
rmSync(bundleDir, { recursive: true, force: true });
if (upToDate) {
@@ -1251,7 +1254,9 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
}
// Native subagent definitions that ship in the bundle next to a provider's
// skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`:
// skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project
// agents take precedence over user agents. GitHub Copilot's live at
// `.github/agents/impeccable-*.agent.md`:
// project installs commit them at `<repo>/.github/agents/`, user-level
// installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT
// `~/.github/`). On a name conflict Copilot lets the user-level file shadow
@@ -1261,6 +1266,11 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
// `~/.cursor/agents/`; project agents take precedence there, so no shadow
// warning is needed.
const PROVIDER_AGENT_ARTIFACTS = {
'.claude': {
ext: '.md',
userDir: home => join(home, '.claude', 'agents'),
userShadowsProject: false,
},
'.github': {
ext: '.agent.md',
userDir: home => join(home, '.copilot', 'agents'),
@@ -1273,6 +1283,23 @@ const PROVIDER_AGENT_ARTIFACTS = {
},
};
function providerAgentsUpToDate(bundleDir, root, provider, scope) {
const artifact = PROVIDER_AGENT_ARTIFACTS[provider];
if (!artifact) return true;
const srcDir = join(bundleDir, provider, 'agents');
if (!existsSync(srcDir)) return true;
const destDir = scope === 'user'
? artifact.userDir(root)
: join(root, provider, 'agents');
const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext));
return agentFiles.every(name => {
const localPath = join(destDir, name);
return existsSync(localPath)
&& hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath);
});
}
function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) {
const targets = Array.isArray(providers) ? providers : [providers];
const results = [];
@@ -1534,7 +1561,8 @@ function hookInstalledForProvider(root, provider) {
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => value.includes(marker));
const normalized = value.replace(/\\/g, '/');
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => normalized.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') {
@@ -2072,7 +2100,9 @@ function resolveUpdateTarget({ projectRoot, home, explicitScope }) {
const homeRooted = isHomeDir(projectRoot);
if (homeRooted && !explicitScope) {
const providers = findInstalledProviders(home);
return providers.length ? { root: home, scope: undefined, providers, scopeLabel: 'user level' } : null;
return providers.length
? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' }
: null;
}
const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project');
@@ -2203,7 +2233,7 @@ async function update(flags = []) {
: { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' };
}
const { root, scope } = target;
const { root, scope, agentScope = scope } = target;
console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`);
const providers = target.providers;
const linkedProviders = findLinkedProviders(root, providers, scope);
@@ -2227,7 +2257,7 @@ async function update(flags = []) {
}
// Compare local vs remote -- skip if already up to date
if (isUpToDate(root, copyProviders, tmpDir, scope)) {
if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) {
try {
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
@@ -2263,7 +2293,7 @@ async function update(flags = []) {
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope }));
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope }));
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
+6 -10
View File
@@ -1955,20 +1955,19 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative grid or line-field backgrounds drawn with hairline
// Decorative two-axis grid backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1985,13 +1984,10 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
if (hairlineCount >= 2 && hasPxCell) {
return [{
index: blk.index,
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
snippet: 'two-axis grid-line gradient background',
}];
}
}
+6 -10
View File
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative grid or line-field backgrounds drawn with hairline
// Decorative two-axis grid backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
if (hairlineCount >= 2 && hasPxCell) {
return [{
index: blk.index,
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
snippet: 'two-axis grid-line gradient background',
}];
}
}
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+107 -151
View File
@@ -12,6 +12,8 @@ import { homedir, tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from './lib/cli-args.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const prRoot = resolve(__dirname, '..');
const defaultBundle = join(prRoot, 'dist', 'universal.zip');
@@ -32,31 +34,60 @@ if (args.help || args.h || !args.repo) {
process.exit(1);
}
const targetRepo = resolve(args.repo);
const bundlePath = resolve(args.bundle || defaultBundle);
const selectedProviders = (args.providers || defaultProviders.join(','))
const targetRepo = resolve(legacySmokeArg(args.repo));
const bundlePath = resolve(legacySmokeArg(args.bundle) || defaultBundle);
const selectedProviders = (legacySmokeArg(args.providers) || defaultProviders.join(','))
.split(',')
.map((provider) => provider.trim().toLowerCase())
.filter(Boolean);
const smokeDir = join(targetRepo, '.impeccable', 'provider-smoke');
const summaryPath = join(smokeDir, 'summary.json');
const smokeFiles = {
direct: 'src/__impeccable_provider_smoke_direct.html',
claude: 'src/__impeccable_provider_smoke_claude.html',
codex: 'src/__impeccable_provider_smoke_codex.html',
cursor: 'src/__impeccable_provider_smoke_cursor.html',
confirmedClaude: 'src/__impeccable_provider_smoke_confirmed_claude.html',
confirmedCodex: 'src/__impeccable_provider_smoke_confirmed_codex.html',
confirmedCursor: 'src/__impeccable_provider_smoke_confirmed_cursor.html',
agentChoiceClaude: 'src/__impeccable_provider_smoke_font_choice_claude.html',
agentChoiceCodex: 'src/__impeccable_provider_smoke_font_choice_codex.html',
agentChoiceCursor: 'src/__impeccable_provider_smoke_font_choice_cursor.html',
const directSmokeFile = 'src/__impeccable_provider_smoke_direct.html';
const providerSmoke = {
claude: {
fixture: 'src/__impeccable_provider_smoke_claude.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_claude.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_claude.html',
admin: '.claude/skills/impeccable/scripts/hook-admin.mjs',
hook: '.claude/skills/impeccable/scripts/hook.mjs',
event: (file) => postToolUseEvent('confirmed-claude', file, 'Edit'),
},
codex: {
fixture: 'src/__impeccable_provider_smoke_codex.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_codex.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_codex.html',
admin: '.agents/skills/impeccable/scripts/hook-admin.mjs',
hook: '.agents/skills/impeccable/scripts/hook.mjs',
event: (file) => postToolUseEvent('confirmed-codex', file, 'apply_patch'),
},
cursor: {
fixture: 'src/__impeccable_provider_smoke_cursor.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_cursor.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_cursor.html',
admin: '.cursor/skills/impeccable/scripts/hook-admin.mjs',
hook: '.cursor/skills/impeccable/scripts/hook-before-edit.mjs',
event: (file) => ({
hook_event_name: 'preToolUse',
cwd: targetRepo,
tool_name: 'Write',
tool_input: {
file_path: file,
content: readFileSync(file, 'utf8'),
},
}),
},
};
const results = [];
const hookConfigFiles = ['.impeccable/config.json', '.impeccable/config.local.json'];
const originalHookConfigFiles = new Map();
function legacySmokeArg(value) {
// The retired local parser represented a bare flag as the string "true".
// Preserve that CLI/error behavior while sharing the repository parser.
return value === true ? 'true' : value;
}
main().catch((error) => {
restoreHookConfigFiles();
if (!results.some((result) => !result.pass)) {
@@ -109,21 +140,6 @@ async function checked(name, classification, fn) {
}
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
out[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
out[arg.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
}
}
return out;
}
function assertPath(path, label) {
if (!existsSync(path)) throw new Error(`${label} does not exist: ${path}`);
}
@@ -466,7 +482,7 @@ function assertNoPluginInstall() {
function runDirectContractChecks() {
clearRuntimeState();
const file = writeBadFixture(smokeFiles.direct);
const file = writeBadFixture(directSmokeFile);
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'direct.ndjson') };
const claude = run('node', ['.claude/skills/impeccable/scripts/hook.mjs'], {
cwd: targetRepo,
@@ -495,7 +511,7 @@ function runDirectContractChecks() {
cwd: targetRepo,
tool_name: 'Write',
tool_input: {
file_path: join(targetRepo, smokeFiles.direct),
file_path: join(targetRepo, directSmokeFile),
content: badFixtureContent(),
},
}),
@@ -515,7 +531,7 @@ function runConfirmedExceptionPersistenceChecks() {
function runConfirmedExceptionForProvider(provider) {
clearRuntimeState();
const rel = confirmedSmokeFile(provider);
const rel = providerSmoke[provider].confirmedFixture;
const file = writeConfirmedFixture(rel);
const beforeLog = `${provider}-confirmed-before.ndjson`;
const afterLog = `${provider}-confirmed-after.ndjson`;
@@ -528,7 +544,7 @@ function runConfirmedExceptionForProvider(provider) {
assertNoSpecificFontIgnoreConfig(provider);
run('node', [
providerAdminScript(provider),
providerSmoke[provider].admin,
'ignore-value',
'overused-font',
'Roboto',
@@ -580,7 +596,7 @@ function runAgentChosenFontExceptionChecks() {
function runAgentChosenFontExceptionForProvider(provider) {
clearRuntimeState();
const rel = agentChoiceSmokeFile(provider);
const rel = providerSmoke[provider].agentChoiceFixture;
const file = writeConfirmedFixture(rel);
const beforeLog = `${provider}-agent-choice-before.ndjson`;
const afterLog = `${provider}-agent-choice-after.ndjson`;
@@ -612,27 +628,40 @@ function runAgentChosenFontExceptionForProvider(provider) {
}
function runProviderAgentFontException(provider, rel) {
const prompt = fontExceptionPrompt(provider, rel);
runProviderAgent(provider, fontExceptionPrompt(provider, rel), {
logName: `${provider}-agent-choice.log`,
claudeDebugLog: 'claude-agent-choice-debug.log',
});
}
function runProviderAgent(provider, prompt, {
logName,
env = {},
claudeDebugLog,
claudeTools = 'Read,Bash',
claudeAllowedTools = 'Read Bash',
cursorReady = false,
} = {}) {
if (provider === 'claude') {
run('claude', [
return run('claude', [
'-p',
'--setting-sources', 'project',
'--permission-mode', 'acceptEdits',
'--tools', 'Read,Bash',
'--allowedTools', 'Read Bash',
'--tools', claudeTools,
'--allowedTools', claudeAllowedTools,
'--debug', 'hooks',
'--debug-file', join(smokeDir, 'claude-agent-choice-debug.log'),
'--debug-file', join(smokeDir, claudeDebugLog),
prompt,
], {
cwd: targetRepo,
logName: 'claude-agent-choice.log',
env,
logName,
timeoutMs: 10 * 60 * 1000,
});
return;
}
if (provider === 'codex') {
run('codex', [
return run('codex', [
'exec',
'-C', targetRepo,
'--dangerously-bypass-hook-trust',
@@ -641,14 +670,14 @@ function runProviderAgentFontException(provider, rel) {
prompt,
], {
cwd: targetRepo,
logName: 'codex-agent-choice.log',
env,
logName,
timeoutMs: 10 * 60 * 1000,
});
return;
}
if (provider === 'cursor') {
ensureCursorAgent();
if (!cursorReady) ensureCursorAgent();
const res = run('agent', [
'-p',
'--force',
@@ -658,7 +687,8 @@ function runProviderAgentFontException(provider, rel) {
prompt,
], {
cwd: targetRepo,
logName: 'cursor-agent-choice.log',
env,
logName,
timeoutMs: 10 * 60 * 1000,
allowFailure: true,
});
@@ -671,10 +701,10 @@ function runProviderAgentFontException(provider, rel) {
}
throw new Error(res.error ? `agent failed: ${res.error.message}` : `agent exited ${res.status}`);
}
return;
return res;
}
throw new Error(`Unsupported agent-choice provider: ${provider}`);
throw new Error(`Unsupported provider agent: ${provider}`);
}
function assertSpecificFontIgnoreConfig(provider, config) {
@@ -713,84 +743,29 @@ function readSharedHookConfig() {
}
function runInstalledProviderHook(provider, file, logName) {
const smoke = providerSmoke[provider];
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, logName) };
if (provider === 'claude') {
return run('node', ['.claude/skills/impeccable/scripts/hook.mjs'], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify(postToolUseEvent(`confirmed-${provider}`, file, 'Edit')),
});
}
if (provider === 'codex') {
return run('node', ['.agents/skills/impeccable/scripts/hook.mjs'], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify(postToolUseEvent(`confirmed-${provider}`, file, 'apply_patch')),
});
}
if (provider === 'cursor') {
return run('node', ['.cursor/skills/impeccable/scripts/hook-before-edit.mjs'], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify({
hook_event_name: 'preToolUse',
cwd: targetRepo,
tool_name: 'Write',
tool_input: {
file_path: file,
content: readFileSync(file, 'utf8'),
},
}),
});
}
throw new Error(`Unsupported confirmed exception provider: ${provider}`);
}
function confirmedSmokeFile(provider) {
if (provider === 'claude') return smokeFiles.confirmedClaude;
if (provider === 'codex') return smokeFiles.confirmedCodex;
if (provider === 'cursor') return smokeFiles.confirmedCursor;
throw new Error(`Unsupported confirmed exception provider: ${provider}`);
}
function agentChoiceSmokeFile(provider) {
if (provider === 'claude') return smokeFiles.agentChoiceClaude;
if (provider === 'codex') return smokeFiles.agentChoiceCodex;
if (provider === 'cursor') return smokeFiles.agentChoiceCursor;
throw new Error(`Unsupported agent-choice provider: ${provider}`);
}
function providerAdminScript(provider) {
if (provider === 'claude') return '.claude/skills/impeccable/scripts/hook-admin.mjs';
if (provider === 'codex') return '.agents/skills/impeccable/scripts/hook-admin.mjs';
if (provider === 'cursor') return '.cursor/skills/impeccable/scripts/hook-admin.mjs';
throw new Error(`Unsupported admin provider: ${provider}`);
return run('node', [smoke.hook], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify(smoke.event(file)),
});
}
function runClaudeProviderSmoke() {
clearRuntimeState();
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'claude.ndjson') };
const prompt = providerPrompt(smokeFiles.claude);
const res = run('claude', [
'-p',
'--setting-sources', 'project',
'--permission-mode', 'acceptEdits',
'--tools', 'Read,Write,Edit',
'--allowedTools', 'Read Write Edit',
'--debug', 'hooks',
'--debug-file', join(smokeDir, 'claude-debug.log'),
prompt,
], {
cwd: targetRepo,
const prompt = providerPrompt(providerSmoke.claude.fixture);
const res = runProviderAgent('claude', prompt, {
env,
logName: 'claude-provider.log',
timeoutMs: 10 * 60 * 1000,
claudeDebugLog: 'claude-debug.log',
claudeTools: 'Read,Write,Edit',
claudeAllowedTools: 'Read Write Edit',
});
const evidence = `${res.stdout}\n${res.stderr}\n${readMaybe(join(smokeDir, 'claude.ndjson'))}\n${readMaybe(join(smokeDir, 'claude-debug.log'))}`;
requireFile(smokeFiles.claude, 'Claude provider fixture');
requireFile(providerSmoke.claude.fixture, 'Claude provider fixture');
requireFinding('Claude provider hook', evidence);
if (!/PostToolUse|hook/i.test(evidence)) throw new Error('Claude provider evidence lacks hook/PostToolUse marker');
record('claude provider', true, 'Claude edit triggered PostToolUse hook and side-tab detection');
@@ -799,23 +774,14 @@ function runClaudeProviderSmoke() {
function runCodexProviderSmoke() {
clearRuntimeState();
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'codex.ndjson') };
const prompt = `Use apply_patch to ${providerPrompt(smokeFiles.codex)}`;
const res = run('codex', [
'exec',
'-C', targetRepo,
'--dangerously-bypass-hook-trust',
'--dangerously-bypass-approvals-and-sandbox',
'--json',
prompt,
], {
cwd: targetRepo,
const prompt = `Use apply_patch to ${providerPrompt(providerSmoke.codex.fixture)}`;
const res = runProviderAgent('codex', prompt, {
env,
logName: 'codex-provider.log',
timeoutMs: 10 * 60 * 1000,
});
const evidence = `${res.stdout}\n${res.stderr}\n${readMaybe(join(smokeDir, 'codex.ndjson'))}`;
const cacheEvidence = `${readMaybe(join(targetRepo, '.impeccable', 'hook.cache.json'))}\n${readMaybe(join(targetRepo, '.impeccable', 'hook.pending.json'))}`;
requireFile(smokeFiles.codex, 'Codex provider fixture');
requireFile(providerSmoke.codex.fixture, 'Codex provider fixture');
requireFinding('Codex provider hook', `${evidence}\n${cacheEvidence}`);
record('codex provider', true, 'Codex apply_patch triggered project hook and side-tab detection');
}
@@ -824,37 +790,19 @@ function runCursorProviderSmoke() {
ensureCursorAgent();
clearRuntimeState();
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'cursor.ndjson') };
const prompt = providerPrompt(smokeFiles.cursor);
const res = run('agent', [
'-p',
'--force',
'--trust',
'--workspace', targetRepo,
'--output-format', 'stream-json',
prompt,
], {
cwd: targetRepo,
const prompt = providerPrompt(providerSmoke.cursor.fixture);
const res = runProviderAgent('cursor', prompt, {
env,
logName: 'cursor-provider.log',
timeoutMs: 10 * 60 * 1000,
allowFailure: true,
cursorReady: true,
});
if (res.error || res.status !== 0) {
const output = `${res.stdout}\n${res.stderr}\n${res.error?.message || ''}`;
if (/Authentication required|agent login|CURSOR_API_KEY/i.test(output)) {
const err = new Error('Cursor CLI authentication required. Run `agent login` or set CURSOR_API_KEY, then rerun `bun run smoke:hooks -- --providers=cursor`.');
err.classification = 'cursor auth required';
throw err;
}
throw new Error(res.error ? `agent failed: ${res.error.message}` : `agent exited ${res.status}`);
}
const evidence = `${res.stdout}\n${res.stderr}\n${readMaybe(join(smokeDir, 'cursor.ndjson'))}\n${readMaybe(join(targetRepo, '.impeccable', 'hook.pending.json'))}\n${readMaybe(join(targetRepo, '.impeccable', 'hook.cache.json'))}`;
requireFinding('Cursor provider hook', evidence);
const auditEvents = readAuditEvents(join(smokeDir, 'cursor.ndjson'));
if (!auditEvents.some((event) => event.event === 'preToolUse' && event.blocked === true)) {
throw new Error('Cursor provider evidence lacks a preToolUse audit entry with blocked=true');
}
const fixturePath = join(targetRepo, smokeFiles.cursor);
const fixturePath = join(targetRepo, providerSmoke.cursor.fixture);
const intentionalIgnore = auditEvents.some((event) =>
event.event === 'preToolUse'
&& event.file === fixturePath
@@ -1017,7 +965,15 @@ function cleanSmokeArtifacts() {
}
function cleanSmokeFiles() {
for (const rel of Object.values(smokeFiles)) {
const files = [
directSmokeFile,
...Object.values(providerSmoke).flatMap(({ fixture, confirmedFixture, agentChoiceFixture }) => [
fixture,
confirmedFixture,
agentChoiceFixture,
]),
];
for (const rel of files) {
rmSync(join(targetRepo, rel), { force: true });
}
}
-33
View File
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
+11 -3
View File
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
+68 -112
View File
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+48
View File
@@ -6,9 +6,16 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { boolFlag, parseArgs, positiveIntFlag, resolveEnum, toCamel } from '../scripts/lib/cli-args.mjs';
const PROVIDER_SMOKE_SCRIPT = fileURLToPath(new URL('../scripts/smoke-provider-hooks.mjs', import.meta.url));
describe('parseArgs', () => {
it('reads space-separated values', () => {
// The regression: without the argv[i+1] lookahead this yielded
@@ -125,3 +132,44 @@ describe('resolveEnum', () => {
);
});
});
describe('provider hook smoke CLI', () => {
it('prints help without requiring a target repository', () => {
const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT, '--help'], { encoding: 'utf8' });
assert.equal(result.status, 0);
assert.match(result.stdout, /^Usage: bun run smoke:hooks/);
assert.match(result.stdout, /target repo must be explicit/);
assert.equal(result.stderr, '');
});
it('fails with the same usage guidance when the target repository is omitted', () => {
const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT], { encoding: 'utf8' });
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(result.stderr, /^Usage: bun run smoke:hooks/);
assert.match(result.stderr, /target repo must be explicit/);
});
it('preserves the legacy string sentinel for value-less options', () => {
const cases = [
{ args: ['--repo'], error: /target repo does not exist: .*\/true/ },
{ args: ['--repo', '.', '--bundle'], error: /universal bundle does not exist: .*\/true/ },
{ args: ['--repo', '.', '--bundle', './missing.zip', '--providers'], error: /universal bundle does not exist: .*\/missing\.zip/ },
];
for (const { args, error } of cases) {
const cwd = mkdtempSync(join(tmpdir(), 'impeccable-provider-smoke-cli-'));
try {
const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT, ...args], { cwd, encoding: 'utf8' });
assert.equal(result.status, 1);
assert.doesNotMatch(result.stderr, /TypeError/);
assert.match(result.stderr, error);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}
});
});
@@ -1278,6 +1278,14 @@ describe('detectHtml — generated-UI tells', () => {
}
});
it('codex-grid-background: 1D dashed rules and px-pair line-fields stay legal', async () => {
const f = await detectHtml(path.join(FIXTURES, 'codex-grid-1d-pass.html'));
assert.equal(
f.filter(r => r.antipattern === 'codex-grid-background').length, 0,
`1D tiled hairlines must not flag, got: ${f.filter(r => r.antipattern === 'codex-grid-background').map(r => r.snippet).join('; ')}`,
);
});
it('gemini-tells: both flag cases surface by default and pass cases stay legal', async () => {
const findings = await detectHtml(path.join(FIXTURES, 'gemini-tells.html'));
// Two flag cases: a CSS img:hover{transform} rule and a Tailwind hover:scale on <img>.
+32 -4
View File
@@ -1806,11 +1806,9 @@ describe('codex-grid-background variants', () => {
expect(grids(css)).toHaveLength(1);
});
test('flags single-axis hairline tiled by a px pair cell', () => {
test('keeps single-axis hairline tiled by a px pair cell legal', () => {
const css = `body { background: linear-gradient(90deg, rgba(23,25,24,.035) 1px, transparent 1px) 0 0 / 40px 40px, #f4f1ea; }`;
const f = grids(css);
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('line-field');
expect(grids(css)).toHaveLength(0);
});
test('keeps percent-tiled single hairlines (data-viz track rules) legal', () => {
@@ -1818,6 +1816,36 @@ describe('codex-grid-background variants', () => {
expect(grids(css)).toHaveLength(0);
});
test('keeps 1D dashed dot rules legal', () => {
const css = `.dot-rule {
height: 5px;
background-image: linear-gradient(90deg, rgba(255,255,255,.75) 5px, transparent 5px);
background-size: 10px 5px;
background-repeat: repeat-x;
}`;
expect(grids(css)).toHaveLength(0);
});
test('keeps 1D progress rails with dash-period px pair tiles legal', () => {
const css = `.progress-rail {
background-image: linear-gradient(90deg, #eee 1px, transparent 1px);
background-size: 8px 4px;
background-repeat: repeat-x;
}`;
expect(grids(css)).toHaveLength(0);
});
test('regex source engine keeps 1D dot rules legal', () => {
const css = `.dot-rule {
height: 5px;
background-image: linear-gradient(90deg, rgba(255,255,255,.75) 5px, transparent 5px);
background-size: 10px 5px;
background-repeat: repeat-x;
}`;
const findings = detectText(css, 'dot-rule.css');
expect(findings.filter(f => f.antipattern === 'codex-grid-background')).toHaveLength(0);
});
test('classic two-axis background-size form still flags', () => {
const css = `.hero { background-image:
linear-gradient(#eee 1px, transparent 1px),
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>codex-grid-background 1D pass cases</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
.dot-rule { height: 5px; background-image: linear-gradient(90deg, rgba(255,255,255,.75) 5px, transparent 5px); background-size: 10px 5px; background-repeat: repeat-x; }
.progress-rail { height: 4px; background-image: linear-gradient(90deg, #eee 1px, transparent 1px); background-size: 8px 4px; background-repeat: repeat-x; }
.line-field { height: 80px; background: linear-gradient(90deg, rgba(23,25,24,.035) 1px, transparent 1px) 0 0 / 40px 40px, #f4f1ea; }
</style>
</head>
<body>
<h2>Dotted horizontal rule</h2>
<div class="dot-rule"></div>
<h2>Progress rail</h2>
<div class="progress-rail"></div>
<h2>Single-axis px-pair line field</h2>
<div class="line-field"></div>
</body>
</html>
+9
View File
@@ -1032,6 +1032,15 @@ describe('live-browser.js regression guards', () => {
/case 'variant_progress':[\s\S]{0,120}?if \(msg\.publicationKind === 'params'\) parameterGenerationState = 'loading';/,
'a params-only publication must mark Tune controls loading even though the variant count is unchanged',
);
assert.match(
SOURCE,
/function completeParameterGenerationIfReady\(\) \{[\s\S]{0,240}?arrivedVariants < expectedVariants[\s\S]{0,160}?parameterGenerationState === 'pending'[\s\S]{0,120}?completeParameterPublication\(\);/,
'the completed variants publication must resolve pending Tune controls even when no params publication follows',
);
assert.ok(
(SOURCE.match(/completeParameterGenerationIfReady\(\);/g) || []).length >= 4,
'every DOM, source, and component-preview completion path must resolve pending Tune controls',
);
assert.match(SOURCE, /revisionDomain: 'browser'/, 'browser checkpoints must use their own revision domain');
});
+117 -2
View File
@@ -84,11 +84,13 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("local bundle context");\n');
}
if (providers.includes('.claude')) {
mkdirSync(join(bundleRoot, '.claude'), { recursive: true });
mkdirSync(join(bundleRoot, '.claude', 'agents'), { recursive: true });
writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({
description: 'fresh claude hook',
hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }] },
}, null, 2));
writeFileSync(join(bundleRoot, '.claude', 'agents', 'impeccable-finish-reviewer.md'),
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\n---\nClaude reviewer body.\n');
}
if (providers.includes('.cursor')) {
mkdirSync(join(bundleRoot, '.cursor'), { recursive: true });
@@ -228,7 +230,51 @@ describe('copyProviderSkills: symlink handling', () => {
});
});
describe('copyProviderAgents: Copilot and Cursor subagents', () => {
describe('copyProviderAgents: Claude, Copilot, and Cursor subagents', () => {
test('Claude project and user scopes use .claude/agents, with project copies taking precedence', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-home-'));
const bundle = createFakeUniversalBundle(tmp, ['.claude']);
mkdirSync(join(home, '.claude', 'agents'), { recursive: true });
writeFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'stale copy\n');
const projectResults = copyProviderAgents(bundle, tmp, ['.claude'], { scope: 'project', home });
const userResults = copyProviderAgents(bundle, home, ['.claude'], { scope: 'user' });
expect(projectResults).toHaveLength(1);
expect(projectResults[0].shadowed).toEqual([]);
expect(userResults).toHaveLength(1);
expect(readFileSync(join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8'))
.toContain('Claude reviewer body.');
expect(readFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8'))
.toContain('Claude reviewer body.');
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
test('Claude install and update backfill bundled agents beside an unchanged skill', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-home-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
const agentPath = join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md');
const installOutput = run('skills install -y --no-hooks --providers=claude', { cwd: tmp, env });
expect(installOutput).toContain('Installed Claude Code agents into:');
expect(existsSync(agentPath)).toBe(true);
rmSync(agentPath);
const updateOutput = run('skills update -y --no-hooks', { cwd: tmp, env });
expect(updateOutput).toContain('Updated');
expect(updateOutput).toContain('Installed Claude Code agents into:');
expect(existsSync(agentPath)).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('project scope places agents at .github/agents/ and .cursor/agents/', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-project-'));
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
@@ -262,6 +308,46 @@ describe('copyProviderAgents: Copilot and Cursor subagents', () => {
rmSync(home, { recursive: true, force: true });
});
test('skills check accepts current Copilot user agents in a home-rooted checkout', () => {
const home = mkdtempSync(join(tmpdir(), 'imp-agents-check-home-'));
execSync('git init', { cwd: home });
const bundleRoot = createFakeUniversalBundle(home, ['.github']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env });
expect(existsSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
expect(existsSync(join(home, '.github', 'agents'))).toBe(false);
const output = run('skills check', { cwd: home, env });
expect(output).toContain('Skills are up to date');
expect(output).not.toContain('Updates available');
rmSync(home, { recursive: true, force: true });
}, 15000);
test('inferred home-rooted updates refresh stale or missing Copilot user agents', () => {
const home = mkdtempSync(join(tmpdir(), 'imp-agents-update-home-'));
execSync('git init', { cwd: home });
const bundleRoot = createFakeUniversalBundle(home, ['.github']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
const userAgent = join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md');
const projectAgent = join(home, '.github', 'agents', 'impeccable-finish-reviewer.agent.md');
run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env });
writeFileSync(userAgent, 'stale copy\n');
run('skills update -y --no-hooks', { cwd: home, env });
expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.');
expect(existsSync(projectAgent)).toBe(false);
rmSync(userAgent);
run('skills update -y --no-hooks', { cwd: home, env });
expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.');
expect(existsSync(projectAgent)).toBe(false);
rmSync(home, { recursive: true, force: true });
}, 20000);
test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-'));
@@ -1649,6 +1735,35 @@ describe('hook manifest merge helpers', () => {
'node .cursor/skills/impeccable/scripts/hook-before-edit.mjs',
]);
});
test('mergeHookManifests replaces legacy Windows-path Claude hooks (#604)', () => {
const legacyPath = 'C:\\Users\\alice\\.claude\\skills\\impeccable\\scripts\\hook.mjs';
const legacyCommand = `[ ! -f "${legacyPath}" ] || node "${legacyPath}"`;
const freshCommand = `node -e "guard" "${legacyPath}"`;
const merged = mergeHookManifests(
{
hooks: {
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
{ type: 'command', command: legacyCommand },
] }],
Stop: [{ hooks: [{ type: 'command', command: legacyCommand }] }],
},
},
{
hooks: {
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
{ type: 'command', command: freshCommand },
] }],
Stop: [{ hooks: [{ type: 'command', command: freshCommand }] }],
},
},
);
expect(merged.hooks.PostToolUse).toHaveLength(1);
expect(merged.hooks.Stop).toHaveLength(1);
expect(merged.hooks.PostToolUse[0].hooks[0].command).toBe(freshCommand);
expect(merged.hooks.Stop[0].hooks[0].command).toBe(freshCommand);
});
});
// ─── Hook command path resolution (issue #399, part 1) ───────────────────────