From ac7812ab175062befe13359e8d0513d60be5c39a Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:37:22 -0400 Subject: [PATCH 01/11] feat(docker): support codex oauth login in networked harness codex login binds its callback server to container loopback, so the host browser redirect to localhost:1455 is refused. Publish 127.0.0.1:1455 on the opt-in networked service only and bridge it to loopback with a small node relay. Document the flow, fresh-state retry, and token lifetime in the hardened-harness skill. Follow-up to #2625. Co-Authored-By: Claude Fable 5 --- docker/plugin-setup/codex-auth-relay.js | 36 +++++++++++++++++++++++++ docker/plugin-setup/compose.yaml | 3 +++ skills/docker-patterns/SKILL.md | 21 +++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 docker/plugin-setup/codex-auth-relay.js diff --git a/docker/plugin-setup/codex-auth-relay.js b/docker/plugin-setup/codex-auth-relay.js new file mode 100644 index 000000000..5d5e466a3 --- /dev/null +++ b/docker/plugin-setup/codex-auth-relay.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Codex OAuth login relay for the hardened harness. + * + * `codex login` binds its callback server to 127.0.0.1:1455 inside the + * container. A Docker port publish forwards to the container interface, not + * loopback, so the host browser's redirect to http://localhost:1455 is + * refused. This bridges the published interface to loopback: + * + * host 127.0.0.1:1455 -> container 0.0.0.0:1456 (this relay) -> 127.0.0.1:1455 + * + * Run inside the networked session before `codex login`: + * node /ecc/docker/plugin-setup/codex-auth-relay.js & + * + * Env: ECC_RELAY_LISTEN (default 1456), ECC_RELAY_TARGET (default 1455). + */ + +'use strict'; + +const net = require('net'); + +const listenPort = Number(process.env.ECC_RELAY_LISTEN || 1456); +const targetPort = Number(process.env.ECC_RELAY_TARGET || 1455); + +net.createServer(client => { + const upstream = net.connect(targetPort, '127.0.0.1'); + client.pipe(upstream).pipe(client); + const drop = () => { + client.destroy(); + upstream.destroy(); + }; + client.on('error', drop); + upstream.on('error', drop); +}).listen(listenPort, '0.0.0.0', () => { + console.log(`codex-auth-relay: 0.0.0.0:${listenPort} -> 127.0.0.1:${targetPort}`); +}); diff --git a/docker/plugin-setup/compose.yaml b/docker/plugin-setup/compose.yaml index ef19064e5..15678eaa6 100644 --- a/docker/plugin-setup/compose.yaml +++ b/docker/plugin-setup/compose.yaml @@ -76,6 +76,9 @@ services: profiles: - networked network_mode: default + # Codex OAuth callback bridge: host browser -> codex-auth-relay.js -> loopback. + ports: + - "127.0.0.1:1455:1456" image: ecc-plugin-setup:debian real-cli-ubuntu: diff --git a/skills/docker-patterns/SKILL.md b/skills/docker-patterns/SKILL.md index e60c1d20f..53338725d 100644 --- a/skills/docker-patterns/SKILL.md +++ b/skills/docker-patterns/SKILL.md @@ -416,6 +416,27 @@ explicit Compose `--env NAME` flag, understand that the value is inspectable and can be exfiltrated for the container lifetime, and remove the exact named container immediately after. +### Authenticate Codex Inside the Networked Session + +`codex login` binds its OAuth callback server to `127.0.0.1:1455` inside the +container. A Docker port publish forwards to the container interface, not +loopback, so the host browser's redirect to `http://localhost:1455` is refused +without a bridge. The `real-cli-networked` service publishes host +`127.0.0.1:1455` to container port `1456`; bridge that port to loopback before +logging in: + +```bash +npm install --global @openai/codex # the harness image does not ship codex +node /ecc/docker/plugin-setup/codex-auth-relay.js & +codex login +``` + +Complete the sign-in in the host browser. Each attempt mints a fresh +`state`/`code`, so always follow the newest printed link. The token stays in +the disposable container's `~/.codex/auth.json`; removing the named container +revokes that copy. Only one container can hold host port 1455 at a time +because the OAuth redirect targets a fixed port. + Run the same focused suite natively on the host: ```bash From f302863d5804af317bd1f21538e2b48e41e3754d Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Thu, 1 Oct 2026 13:27:51 +0800 Subject: [PATCH 02/11] fix(context): respect indirect skill citations in auto routing --- scripts/lib/context-selection.js | 69 ++++++++++++-------- tests/lib/context-profile-launch.test.js | 18 ++++++ tests/lib/context-selection.test.js | 81 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 25 deletions(-) diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 85a496800..dba849cc7 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -28,9 +28,48 @@ const FALLBACK_MIN_TERMS = 2; const FALLBACK_MARGIN = 1.1; // v4: an explicit empty proposal (decline) is honored; the tier-2 fallback no // longer overrides declines at the launch/selection call sites. -const ROUTING_POLICY_VERSION = 4; +// v5: indirect name citations cannot bypass review through exact or scored admission. +const ROUTING_POLICY_VERSION = 5; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); +const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; +const normalizedQueryName = text => text.replace(/n['\u2019]t\b/gi, ' not') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') + .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + +// Classify each named skill locally: a rejected/quoted X must not suppress an +// affirmative Y in another clause. Ambiguous references stay available for the +// bounded proposal, but cannot admit themselves via either retrieval threshold. +function citationFor(candidate, query) { + if (!candidate.exact) return 'none'; + const aliases = [...new Set([candidate.exactAlias, candidate.id.slice('skill:'.length)] + .filter(Boolean).map(normalizedQueryName))]; + const quotedParts = query.match(/"[^"]*"|\u201c[^\u201d]*\u201d|(?:^|[\s(:])'[^']*'|\u2018[^\u2019]*\u2019|\x60[^\x60]*\x60/g) || []; + let directive = false; + let indirect = false; + for (const clause of query.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { + const text = normalizedQueryName(clause); + for (const name of aliases) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const directivePattern = DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?' + escaped + '\\b'; + if (quotedParts.some(part => new RegExp(directivePattern, 'i').test(normalizedQueryName(part)))) indirect = true; + for (const mention of text.matchAll(new RegExp('\\b' + escaped + '\\b', 'gi'))) { + const prefix = text.slice(0, mention.index); + const polite = /^(?:(?:please )?(?:can|could|would|will) you (?:please )?|do )(?:use|apply|invoke|run|follow|load)\b/.test(text); + const question = !polite && (clause.includes('?') + || /^(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/.test(text)); + const quoted = /^\s*["'\u201c\u2018\x60]/.test(clause); + if (question || quoted || /\b(do not|never|no|not|avoid|says|said|reads|told|mentions?|quoted?|document)\b/.test(prefix)) { + indirect = true; + } else if (new RegExp(DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?$', 'i').test(prefix)) { + directive = true; + } + } + } + } + return indirect ? 'indirect' : directive ? 'directive' : 'none'; +} + function validateTask(task) { if (!task || typeof task !== 'object' || Array.isArray(task)) throw new Error('Task must be an object'); for (const key of Object.keys(task)) if (!TASK_KEYS.has(key)) throw new Error(`Unknown task field: ${key}`); @@ -179,28 +218,8 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l // questions, negations, reported speech, multiple cited names — never admit // implicitly. Everything else keeps the bounded-proposal path so the // primary agent decides ambiguous cases during work it was already doing. - const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; - const normalizedQueryName = text => text.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') - .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); - const directiveCitation = candidate => { - if (!candidate || !candidate.exact) return false; - const text = normalizedQueryName(task.query || ''); - const aliases = [...new Set([candidate.exactAlias, - candidate.id.slice('skill:'.length).toLowerCase(), - candidate.id.slice('skill:'.length).toLowerCase().replace(/-/g, ' ')].filter(Boolean))]; - for (const name of aliases) { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`${DIRECTIVE_VERB.source}(skill\\s*:?\\s*)?${escaped}(\\s+(skill|workflow|guidance))?\\b`, 'i'); - const match = pattern.exec(text); - if (!match) continue; - const window = text.slice(Math.max(0, match.index - 28), match.index); - if (/\b(do not|don't|never|no)\b/.test(window)) return false; - if (/\b(says|said|reads|told|document)\b/i.test(task.query || '')) return false; - return true; - } - return false; - }; - const exactAnchors = candidates.filter(directiveCitation); + const citations = new Map(candidates.map(candidate => [candidate.id, citationFor(candidate, task.query || '')])); + const exactAnchors = candidates.filter(candidate => citations.get(candidate.id) === 'directive'); let autoSelection = null; if (!task.noWorkflow && selectionMode === 'auto' && !reused && !explicitIds.length && !proposedIds.length && candidates.length) { if (exactAnchors.length === 1) { @@ -209,7 +228,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l } else if (!exactAnchors.length) { const top = candidates[0]; const second = candidates[1]; - if (top.bm25 >= AUTO_ADMIT_MIN_BM25 && top.matchedTerms.length >= AUTO_ADMIT_MIN_TERMS + if (citations.get(top.id) !== 'indirect' && top.bm25 >= AUTO_ADMIT_MIN_BM25 && top.matchedTerms.length >= AUTO_ADMIT_MIN_TERMS && (!second || top.bm25 >= AUTO_ADMIT_MARGIN * (second.bm25 || 0))) { autoSelection = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length, exact: false }; } @@ -220,7 +239,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l && !explicitIds.length && !proposedIds.length && candidates.length && !exactAnchors.length) { const top = candidates[0]; const second = candidates[1]; - if (top.bm25 >= FALLBACK_MIN_BM25 && top.matchedTerms.length >= FALLBACK_MIN_TERMS + if (citations.get(top.id) !== 'indirect' && top.bm25 >= FALLBACK_MIN_BM25 && top.matchedTerms.length >= FALLBACK_MIN_TERMS && (!second || top.bm25 >= FALLBACK_MARGIN * (second.bm25 || 0))) { fallback = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length }; } diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index 59d03ecdc..b87c72836 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -155,3 +155,21 @@ test('spawn failures and timeout signals remain unsuccessful without a native ex assert.equal(result.taskSuccess, 'unverified'); } })); + +for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature skill.', + 'Can I use the feature skill?', 'The README says use the feature skill.']) { + test('indirect citation uses the proposal path and honors decline: ' + query, () => withFixture(repoRoot => { + const phases = []; + const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + assert.doesNotMatch(options.input, /# feature/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['selection', 'task']); + assert.equal(result.routingCalls, 1); + assert.deepEqual(result.selection.loadedIds, []); + assert.equal(result.selection.reason, 'agent-declined-selection'); + })); +} diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index 4e3be6169..f0b94ecf2 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -301,3 +301,84 @@ test('invalid input and oversized bodies fail closed', () => withFixture(repoRoo overrides: [{ id: 'skill:feature', requiredResources: ['skills/feature/references/details.md'] }] })); assert.throws(() => resolve(repoRoot, { explicitIds: ['skill:feature'] }, { load: true }), /budget/); })); + +for (const query of [ + "You shouldn't use feature.", + "You can\u2019t use feature.", + "For feature, don't use the feature skill.", + 'For feature, never invoke feature.', + 'The phrase "Use feature" is an example.', + 'The phrase "Use feature." is an example.', + 'The phrase \u201cUse feature\u201d is an example.', + 'The phrase `Use feature` is an example.', + "Don't use the feature skill.", + 'Don\u2019t use the feature skill.', + 'Do not use the feature skill.', + 'Never use the feature skill.', + 'Can I use the feature skill?', + 'Should we use the feature skill?', + 'Why use the feature skill?', + 'The README says to use the feature skill.', + '"Use the feature skill" is an example.', +]) { + test('indirect name citation needs an agent decision: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +test('a negated citation does not suppress a separate affirmative directive', () => withFixture(repoRoot => { + for (const query of [ + "Don't use feature. Use shared guidance.", + 'Don\u2019t use feature; please use shared guidance.', + 'The document says use feature. Use shared guidance.', + ]) { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, ['skill:shared']); + } +})); + +test('ordinary and polite directives preserve automatic and explicit selection', () => withFixture(repoRoot => { + for (const query of ['Please use the feature skill.', 'Can you use the feature skill?', + 'Use feature without changing the code.', 'Use feature to document the change.', + 'Use "feature".', 'Use `feature`.', 'Do use feature.', 'Please can you use feature?']) { + assert.deepEqual(resolve(repoRoot, { query }, { load: true }).loadedIds, ['skill:feature']); + } + for (const field of ['explicitIds', 'proposedIds']) { + assert.deepEqual(resolve(repoRoot, { query: 'Should we use feature?', [field]: ['skill:feature'] }, + { load: true }).loadedIds, ['skill:feature']); + } +})); + +for (const prefix of ["Don't use database-migrations.", 'Do not use database-migrations.', + 'Should we use database-migrations?', 'The README says use database-migrations.']) { + test('strong retrieval cannot bypass an indirect citation: ' + prefix, () => { + const query = prefix + ' Review a PostgreSQL migration that adds an indexed nullable column without downtime.'; + const result = resolveTaskContext({ task: task({ query }), load: true }); + const candidate = result.candidates.find(value => value.id === 'skill:database-migrations'); + assert.ok(candidate && candidate.bm25 >= 20, 'Exercise the BM25 auto-admission threshold'); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + }); +} + +test('routing policy changes invalidate receipts created by the old citation policy', () => withFixture(repoRoot => { + const query = "Don't use feature."; + const first = resolve(repoRoot, { query, explicitIds: ['skill:feature'] }); + const { digestObject } = require('../../scripts/lib/context-profile-support'); + const { compileContextProfile } = require('../../scripts/lib/context-profiles'); + const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); + const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 4, + triggersDigest: digestObject({}), queryDigest: digestObject(query) }); + const { receiptDigest: _receiptDigest, ...receipt } = first.receipt; + const oldReceipt = { ...receipt, bindingDigest, explicitIds: [], + selectionDigest: digestObject({ bindingDigest, selectedIds: ['skill:feature'], explicitIds: [] }) }; + const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; + const result = resolve(repoRoot, { query }, { previous, load: true }); + assert.equal(result.reused, false); + assert.deepEqual(result.loadedIds, []); +})); From 2d2712b899125d3cdd7a57eb01ddaa9d9ff886b5 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Thu, 1 Oct 2026 15:03:34 +0800 Subject: [PATCH 03/11] fix(context): honor directive order and singular rejections --- scripts/lib/context-selection.js | 78 +++++++++++++++--------- tests/lib/context-profile-launch.test.js | 32 +++++++++- tests/lib/context-selection.test.js | 64 ++++++++++++++++++- 3 files changed, 143 insertions(+), 31 deletions(-) diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index dba849cc7..0a1a8745e 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -29,7 +29,8 @@ const FALLBACK_MARGIN = 1.1; // v4: an explicit empty proposal (decline) is honored; the tier-2 fallback no // longer overrides declines at the launch/selection call sites. // v5: indirect name citations cannot bypass review through exact or scored admission. -const ROUTING_POLICY_VERSION = 5; +// v6: ordered directives and singular named references share the same admission guard. +const ROUTING_POLICY_VERSION = 6; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; @@ -37,37 +38,56 @@ const normalizedQueryName = text => text.replace(/n['\u2019]t\b/gi, ' not') .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); -// Classify each named skill locally: a rejected/quoted X must not suppress an -// affirmative Y in another clause. Ambiguous references stay available for the -// bounded proposal, but cannot admit themselves via either retrieval threshold. -function citationFor(candidate, query) { - if (!candidate.exact) return 'none'; - const aliases = [...new Set([candidate.exactAlias, candidate.id.slice('skill:'.length)] - .filter(Boolean).map(normalizedQueryName))]; - const quotedParts = query.match(/"[^"]*"|\u201c[^\u201d]*\u201d|(?:^|[\s(:])'[^']*'|\u2018[^\u2019]*\u2019|\x60[^\x60]*\x60/g) || []; - let directive = false; - let indirect = false; - for (const clause of query.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { +/** Return canonical/native aliases plus conservative singular forms. Singular + * multiword aliases only guard indirect references; they never create exact admission. */ +function citationAliases(candidate, nativeName) { + const names = [...new Set([candidate.exactAlias, candidate.id.slice('skill:'.length), nativeName] + .filter(Boolean).map(normalizedQueryName).filter(Boolean))]; + return names.flatMap(name => [{ name, exact: true }, + ...(/\s\w+[^s]s$/.test(name) ? [{ name: name.slice(0, -1), exact: false }] : [])]); +} + +/** Classify a retrieval candidate's references in query order. Genuine later + * directives/rejections replace earlier instructions; questions, quotations and + * reported speech do not withdraw a directive. Returns directive, indirect or none. + * @param {object} candidate Ranked skill candidate with its canonical ID and exact alias. + * @param {string} query Task text to classify without loading skill contents. + * @param {string} nativeName Registry name, including aliases absent from exact retrieval. + * @returns {'directive'|'indirect'|'none'} Admission evidence for this skill only. + */ +function citationFor(candidate, query, nativeName) { + const aliases = citationAliases(candidate, nativeName); + let citation = 'none'; + // Mask quoted instructions before splitting clauses so punctuation inside a + // quotation cannot look like a separate instruction. Quoted names remain usable. + const unquoted = query.replace(/"[^"]*"|\u201c[^\u201d]*\u201d|(?:^|[\s(:])'[^']*'|\u2018[^\u2019]*\u2019|\x60[^\x60]*\x60/g, part => { + const text = normalizedQueryName(part); + if (!DIRECTIVE_VERB.test(text)) return part; + if (aliases.some(({ name }) => new RegExp('\\b' + name + '\\b').test(text))) citation = 'indirect'; + return part.replace(/[^.!?;\n]/g, ' '); + }); + for (const clause of unquoted.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { const text = normalizedQueryName(clause); - for (const name of aliases) { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const directivePattern = DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?' + escaped + '\\b'; - if (quotedParts.some(part => new RegExp(directivePattern, 'i').test(normalizedQueryName(part)))) indirect = true; - for (const mention of text.matchAll(new RegExp('\\b' + escaped + '\\b', 'gi'))) { - const prefix = text.slice(0, mention.index); - const polite = /^(?:(?:please )?(?:can|could|would|will) you (?:please )?|do )(?:use|apply|invoke|run|follow|load)\b/.test(text); - const question = !polite && (clause.includes('?') - || /^(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/.test(text)); - const quoted = /^\s*["'\u201c\u2018\x60]/.test(clause); - if (question || quoted || /\b(do not|never|no|not|avoid|says|said|reads|told|mentions?|quoted?|document)\b/.test(prefix)) { - indirect = true; - } else if (new RegExp(DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?$', 'i').test(prefix)) { - directive = true; - } + const mentions = aliases.flatMap(({ name, exact }) => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] + .map(mention => ({ index: mention.index, exact }))).sort((a, b) => a.index - b.index); + const polite = /^(?:(?:please )?(?:can|could|would|will) you (?:please )?|do )(?:use|apply|invoke|run|follow|load)\b/.test(text); + const question = !polite && (clause.includes('?') + || (!/^do not\b/.test(text) + && /^(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/.test(text))); + for (const mention of mentions) { + const prefix = text.slice(0, mention.index); + if (question || /^\s*["'\u201c\u2018\x60]/.test(clause) + || /\b(say|says|said|reads|told|mentions?|quoted?|document)\b/.test(prefix)) { + if (citation === 'none') citation = 'indirect'; + } else if (/\b(do not|never|no|not|avoid)\b/.test(prefix)) { + citation = 'indirect'; + } else if (mention.exact && candidate.exact + && new RegExp(DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?$', 'i').test(prefix)) { + citation = 'directive'; } } } - return indirect ? 'indirect' : directive ? 'directive' : 'none'; + return citation; } function validateTask(task) { @@ -218,7 +238,7 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l // questions, negations, reported speech, multiple cited names — never admit // implicitly. Everything else keeps the bounded-proposal path so the // primary agent decides ambiguous cases during work it was already doing. - const citations = new Map(candidates.map(candidate => [candidate.id, citationFor(candidate, task.query || '')])); + const citations = new Map(candidates.map(candidate => [candidate.id, citationFor(candidate, task.query || '', byId.get(candidate.id).name)])); const exactAnchors = candidates.filter(candidate => citations.get(candidate.id) === 'directive'); let autoSelection = null; if (!task.noWorkflow && selectionMode === 'auto' && !reused && !explicitIds.length && !proposedIds.length && candidates.length) { diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index b87c72836..2edc088a1 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -157,7 +157,7 @@ test('spawn failures and timeout signals remain unsuccessful without a native ex })); for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature skill.', - 'Can I use the feature skill?', 'The README says use the feature skill.']) { + 'Can I use the feature skill?', 'The README says use the feature skill.', 'The docs say use feature.']) { test('indirect citation uses the proposal path and honors decline: ' + query, () => withFixture(repoRoot => { const phases = []; const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, @@ -173,3 +173,33 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature assert.equal(result.selection.reason, 'agent-declined-selection'); })); } + +test('a final directive after a question reaches the task with its context', () => withFixture(repoRoot => { + const phases = []; + const result = launchTaskContext({ repoRoot, + task: { ...input, query: 'Should we use feature? Use feature.', explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + assert.match(options.input, /# feature/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['task']); + assert.equal(result.routingCalls, 0); + assert.deepEqual(result.selection.loadedIds, ['skill:feature']); +})); + +test('a singular rejected name cannot inject scored context at launch', () => { + const phases = []; + const query = "Don't use the database migration skill. Review a PostgreSQL migration that adds an indexed nullable column without downtime."; + const result = launchTaskContext({ task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + assert.doesNotMatch(options.input, /# Database Migration Patterns/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['selection', 'task']); + assert.equal(result.routingCalls, 1); + assert.deepEqual(result.selection.loadedIds, []); + assert.equal(result.selection.fallback, null); +}); diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index f0b94ecf2..682615c81 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -319,6 +319,9 @@ for (const query of [ 'Should we use the feature skill?', 'Why use the feature skill?', 'The README says to use the feature skill.', + 'The docs say use feature.', + 'The docs say to use feature.', + 'The docs said use feature.', '"Use the feature skill" is an example.', ]) { test('indirect name citation needs an agent decision: ' + query, () => withFixture(repoRoot => { @@ -372,7 +375,7 @@ test('routing policy changes invalidate receipts created by the old citation pol const { compileContextProfile } = require('../../scripts/lib/context-profiles'); const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, - phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 4, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 5, triggersDigest: digestObject({}), queryDigest: digestObject(query) }); const { receiptDigest: _receiptDigest, ...receipt } = first.receipt; const oldReceipt = { ...receipt, bindingDigest, explicitIds: [], @@ -382,3 +385,62 @@ test('routing policy changes invalidate receipts created by the old citation pol assert.equal(result.reused, false); assert.deepEqual(result.loadedIds, []); })); + +for (const query of [ + 'Should we use feature? Use feature.', + 'Use feature. Should we use feature?', + 'The docs say use feature. Use feature.', + 'Use feature. The docs say use feature.', + 'The phrase "Use feature." is an example. Use feature.', + 'The docs say "Use feature." Use feature.', + 'The docs say "Should we use feature?" Use feature.', + `Use feature. The docs say "don’t use feature."`, + 'Use feature. The phrase "Use feature." is an example.', + "Don't use feature. Use feature.", + 'The docs say never use feature. Use feature.', + 'Use feature. The docs say never use feature.', + 'Use feature to inspect the essay.', +]) { + test('a genuine directive survives surrounding discussion: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, ['skill:feature']); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + +for (const query of [ + "Use feature. Don't use feature.", + 'Use feature. Never use feature.', + "Use feature. Don't use feature. Should we use feature?", +]) { + test('a later rejection withdraws an earlier directive: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + })); +} + +for (const prefix of [ + "Don't use the database migration skill.", + 'Don\u2019t use the database migration skill.', + 'Never use database migration.', + 'Should we use the database migration skill?', + 'The docs say use the database migration skill.', +]) { + test('singular named references cannot bypass citation review: ' + prefix, () => { + const query = prefix + ' Review a PostgreSQL migration that adds an indexed nullable column without downtime.'; + const result = resolveTaskContext({ task: task({ query }), load: true }); + const candidate = result.candidates.find(value => value.id === 'skill:database-migrations'); + assert.ok(candidate && candidate.bm25 >= 20, 'Exercise the BM25 auto-admission threshold'); + assert.equal(candidate.exact, false, 'Exercise the non-exact candidate path'); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + }); +} + +test('singular forms do not create exact directive admission', () => { + const result = resolveTaskContext({ task: task({ query: 'Use the database migration skill.' }), load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:database-migrations')); + assert.ok(!result.receipt.autoSelection || !result.receipt.autoSelection.exact); +}); From edeb7b9e9a6ce842c1471b06ee13e1dba83cea79 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Sat, 10 Oct 2026 11:23:53 +0800 Subject: [PATCH 04/11] fix(context): scope compound directives and preserve imperative requests Limit negation to each independent directive after comma, and, or but. Accept imperative requests with question punctuation while preserving true questions, reported speech, and ordered rejections. Bump routing policy to v7 so prior empty selections are reconsidered. Add resolver and launcher regressions for the individual cases and their combinations, including cached selections and actual context injection. --- scripts/lib/context-selection.js | 23 ++++-- tests/lib/context-profile-launch.test.js | 47 +++++++++++- tests/lib/context-selection.test.js | 97 +++++++++++++++++++++++- 3 files changed, 158 insertions(+), 9 deletions(-) diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 0a1a8745e..6a312f78a 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -30,10 +30,15 @@ const FALLBACK_MARGIN = 1.1; // longer overrides declines at the launch/selection call sites. // v5: indirect name citations cannot bypass review through exact or scored admission. // v6: ordered directives and singular named references share the same admission guard. -const ROUTING_POLICY_VERSION = 6; +// v7: independent directives scope negation and imperatives allow question punctuation. +const ROUTING_POLICY_VERSION = 7; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; +const DIRECTIVE_REQUEST = /^(?:please )?(?:(?:can|could|would|will) you (?:please )?|do )?(?:use|apply|invoke|run|follow|load)\b/; +const QUESTION_START = /^(?!do not\b)(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/; +const INSTRUCTION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=(?:please\s+)?(?:do\s+(?:not\s+)?|not\s+|never\s+)?(?:use|apply|invoke|run|follow|load)\b|(?:can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b)/i; +/** Normalize citation names while preserving contracted negation as a separate word. */ const normalizedQueryName = text => text.replace(/n['\u2019]t\b/gi, ' not') .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); @@ -67,19 +72,23 @@ function citationFor(candidate, query, nativeName) { return part.replace(/[^.!?;\n]/g, ' '); }); for (const clause of unquoted.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { - const text = normalizedQueryName(clause); + // Keep comma boundaries for directive scope without changing alias normalization. + const text = clause.split(',').map(normalizedQueryName).join(', '); const mentions = aliases.flatMap(({ name, exact }) => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] .map(mention => ({ index: mention.index, exact }))).sort((a, b) => a.index - b.index); - const polite = /^(?:(?:please )?(?:can|could|would|will) you (?:please )?|do )(?:use|apply|invoke|run|follow|load)\b/.test(text); - const question = !polite && (clause.includes('?') - || (!/^do not\b/.test(text) - && /^(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/.test(text))); + const request = DIRECTIVE_REQUEST.test(text); + const interrogative = !request && QUESTION_START.test(text); for (const mention of mentions) { const prefix = text.slice(0, mention.index); + // A directive or question after a connector starts its own scope. + // Lists without another verb and reported speech keep their guards. + const instructionPrefix = prefix.split(INSTRUCTION_BOUNDARY).at(-1); + const question = interrogative || (!DIRECTIVE_REQUEST.test(instructionPrefix) + && (QUESTION_START.test(instructionPrefix) || (!request && clause.includes('?')))); if (question || /^\s*["'\u201c\u2018\x60]/.test(clause) || /\b(say|says|said|reads|told|mentions?|quoted?|document)\b/.test(prefix)) { if (citation === 'none') citation = 'indirect'; - } else if (/\b(do not|never|no|not|avoid)\b/.test(prefix)) { + } else if (/\b(do not|never|no|not|avoid)\b/.test(instructionPrefix)) { citation = 'indirect'; } else if (mention.exact && candidate.exact && new RegExp(DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?$', 'i').test(prefix)) { diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index 2edc088a1..681da8fd0 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -157,7 +157,19 @@ test('spawn failures and timeout signals remain unsuccessful without a native ex })); for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature skill.', - 'Can I use the feature skill?', 'The README says use the feature skill.', 'The docs say use feature.']) { + 'Can I use the feature skill?', 'The README says use the feature skill.', 'The docs say use feature.', + 'The docs say do not use feature and use shared guidance.', + 'The docs say do not use feature, use shared guidance, okay?', + 'Should we avoid feature and use shared guidance?', + 'Should we avoid feature, use shared guidance?', + 'Use standard tools, should we use feature?', + 'Please use the existing code, can I use feature?', + 'Use standard tools and should we use feature?', + 'Use standard tools but should we use feature?', + 'Do not use feature and shared guidance.', + "Use feature, don't use feature.", + "Use feature and don't use feature.", + "Use feature but don't use feature."]) { test('indirect citation uses the proposal path and honors decline: ' + query, () => withFixture(repoRoot => { const phases = []; const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, @@ -165,6 +177,7 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature phases.push(options.phase); if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; assert.doesNotMatch(options.input, /# feature/); + assert.doesNotMatch(options.input, /# shared/); return { status: 0, stdout: 'ok' }; } }); assert.deepEqual(phases, ['selection', 'task']); @@ -174,6 +187,38 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature })); } +for (const [query, skill] of [ + ["Don't use feature, use shared guidance.", 'shared'], + ["Don't use feature and use shared guidance.", 'shared'], + ["Don't use feature but use shared guidance.", 'shared'], + ["Don't use feature, use shared guidance, okay?", 'shared'], + ["Don't use feature and use shared guidance, okay?", 'shared'], + ["Don't use feature but use shared guidance, okay?", 'shared'], + ['Do not use feature, please use shared guidance?', 'shared'], + ['Do not use feature and please use shared guidance?', 'shared'], + ['Do not use feature but please use shared guidance?', 'shared'], + ["Don't use feature, use feature.", 'feature'], + ['Use feature?', 'feature'], + ['Please use feature?', 'feature'], + ['Use feature to fix the bug, okay?', 'feature'], +]) { + test('an unambiguous final directive injects context without a proposal: ' + query, () => withFixture(repoRoot => { + const phases = []; + const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + assert.match(options.input, new RegExp('# ' + skill)); + assert.doesNotMatch(options.input, new RegExp('# ' + (skill === 'shared' ? 'feature' : 'shared'))); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, ['task']); + assert.equal(result.routingCalls, 0); + assert.deepEqual(result.selection.loadedIds, ['skill:' + skill]); + assert.equal(result.selection.reason, 'auto-selection'); + })); +} + test('a final directive after a question reaches the task with its context', () => withFixture(repoRoot => { const phases = []; const result = launchTaskContext({ repoRoot, diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index 682615c81..d55b50868 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -344,6 +344,74 @@ test('a negated citation does not suppress a separate affirmative directive', () } })); +for (const query of [ + "Don't use feature, use shared guidance.", + "Don't use feature and use shared guidance.", + "Don't use feature but use shared guidance.", + 'Do not use feature, please use shared guidance.', + 'Don\u2019t use feature, and please use shared guidance.', + "Don't use feature, do use shared guidance.", + "Don't use feature, use shared guidance, okay?", + "Don't use feature and use shared guidance, okay?", + "Don't use feature but use shared guidance, okay?", + 'Do not use feature, please use shared guidance?', + 'Do not use feature and please use shared guidance?', + 'Do not use feature but please use shared guidance?', +]) { + test('a new directive scopes an earlier rejection: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, ['skill:shared']); + assert.deepEqual(result.loadedIds, ['skill:shared']); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + +for (const query of [ + 'The docs say do not use feature and use shared guidance.', + 'The docs say do not use feature, use shared guidance.', + 'The docs say do not use feature, use shared guidance, okay?', + 'Should we avoid feature and use shared guidance?', + 'Should we avoid feature, use shared guidance?', + 'Do not use feature and shared guidance.', + 'Do not use feature, shared guidance.', +]) { + test('a connector preserves indirect or negated context: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:shared')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +for (const query of [ + 'Use standard tools, should we use feature?', + 'Please use the existing code, can I use feature?', + 'Use standard tools and should we use feature?', + 'Use standard tools but should we use feature?', +]) { + test('a generic directive does not authorize a later question: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +for (const query of ['Use feature?', 'Please use feature?', 'Use feature to fix the bug, okay?']) { + test('an imperative with question punctuation remains a directive: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, ['skill:feature']); + assert.deepEqual(result.loadedIds, ['skill:feature']); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + test('ordinary and polite directives preserve automatic and explicit selection', () => withFixture(repoRoot => { for (const query of ['Please use the feature skill.', 'Can you use the feature skill?', 'Use feature without changing the code.', 'Use feature to document the change.', @@ -375,7 +443,7 @@ test('routing policy changes invalidate receipts created by the old citation pol const { compileContextProfile } = require('../../scripts/lib/context-profiles'); const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, - phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 5, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 6, triggersDigest: digestObject({}), queryDigest: digestObject(query) }); const { receiptDigest: _receiptDigest, ...receipt } = first.receipt; const oldReceipt = { ...receipt, bindingDigest, explicitIds: [], @@ -386,9 +454,30 @@ test('routing policy changes invalidate receipts created by the old citation pol assert.deepEqual(result.loadedIds, []); })); +test('an old empty selection is reconsidered when directive policy changes', () => withFixture(repoRoot => { + const query = "Don't use feature, use shared guidance."; + const first = resolve(repoRoot, { query }); + const { digestObject } = require('../../scripts/lib/context-profile-support'); + const { compileContextProfile } = require('../../scripts/lib/context-profiles'); + const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); + const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 6, + triggersDigest: digestObject({}), queryDigest: digestObject(query) }); + const { receiptDigest: _receiptDigest, autoSelection: _autoSelection, ...receipt } = first.receipt; + const oldReceipt = { ...receipt, bindingDigest, decision: 'none', selectedIds: [], explicitIds: [], + loadedIds: [], resources: [], selectionDigest: digestObject({ bindingDigest, selectedIds: [], explicitIds: [] }) }; + const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; + const result = resolve(repoRoot, { query }, { previous, load: true }); + assert.equal(result.reused, false); + assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); + assert.deepEqual(result.loadedIds, ['skill:shared']); + assert.equal(result.reason, 'auto-selection'); +})); + for (const query of [ 'Should we use feature? Use feature.', 'Use feature. Should we use feature?', + 'Use feature, should we use feature?', 'The docs say use feature. Use feature.', 'Use feature. The docs say use feature.', 'The phrase "Use feature." is an example. Use feature.', @@ -397,6 +486,9 @@ for (const query of [ `Use feature. The docs say "don’t use feature."`, 'Use feature. The phrase "Use feature." is an example.', "Don't use feature. Use feature.", + "Don't use feature, use feature.", + "Don't use feature and use feature.", + "Don't use feature but use feature.", 'The docs say never use feature. Use feature.', 'Use feature. The docs say never use feature.', 'Use feature to inspect the essay.', @@ -411,6 +503,9 @@ for (const query of [ for (const query of [ "Use feature. Don't use feature.", + "Use feature, don't use feature.", + "Use feature and don't use feature.", + "Use feature but don't use feature.", 'Use feature. Never use feature.', "Use feature. Don't use feature. Should we use feature?", ]) { From 763acfbce018b734fe026fb22fc4cae194f7be99 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Sat, 10 Oct 2026 11:57:53 +0800 Subject: [PATCH 05/11] fix(context): require scoped evidence for automatic skill loading Keep follow-up questions and reported instructions on the proposal path by requiring recognized scopes and a direct request prefix for exact admission. Preserve grouped rejections, personal negation, and genuine directives around indirect discussion. Invalidate v7 receipts with routing policy v8. Cover both Greptile reproductions and neighboring resolver/launcher cases, including unlisted wording, nested reports, and grouped negative lists. --- scripts/lib/context-selection.js | 34 +++++++++++++------- tests/lib/context-profile-launch.test.js | 15 +++++++++ tests/lib/context-selection.test.js | 40 ++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 6a312f78a..402ffcf9d 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -31,13 +31,20 @@ const FALLBACK_MARGIN = 1.1; // v5: indirect name citations cannot bypass review through exact or scored admission. // v6: ordered directives and singular named references share the same admission guard. // v7: independent directives scope negation and imperatives allow question punctuation. -const ROUTING_POLICY_VERSION = 7; +// v8: only recognized request/rejection scopes can authorize coordinated directives. +const ROUTING_POLICY_VERSION = 8; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; const DIRECTIVE_REQUEST = /^(?:please )?(?:(?:can|could|would|will) you (?:please )?|do )?(?:use|apply|invoke|run|follow|load)\b/; -const QUESTION_START = /^(?!do not\b)(can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/; -const INSTRUCTION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=(?:please\s+)?(?:do\s+(?:not\s+)?|not\s+|never\s+)?(?:use|apply|invoke|run|follow|load)\b|(?:can|could|would|should|may|might|do|does|did|is|are|why|how|what|when|where|which)\b)/i; +const DIRECTIVE_REJECTION = /^(?:please )?(?:(?:(?:do )?not|never) (?:use|apply|invoke|run|follow|load)\b|avoid\b)/; +const DIRECTIVE_NAME_PREFIX = new RegExp(DIRECTIVE_REQUEST.source + '\\s+(?:the\\s+)?(?:skill\\s*)?$'); +const QUESTION_START = /^(?!do not\b)(can|could|would|should|shall|will|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/; +const REPORTED_INSTRUCTION = /\b(say|says|said|reads|told|mentions?|quoted?|document|states?|stated|recommends?|recommended|asserts?|asserted)\b/; +// Rejections keep their whole list until another instruction/question begins. +const INSTRUCTION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=(?:please\s+)?(?:do\s+(?:not\s+)?|not\s+|never\s+)?(?:use|apply|invoke|run|follow|load)\b|(?:can|could|would|should|shall|will|may|might|do|does|did|is|are|why|how|what|when|where|which)\b)/i; +// Admission additionally checks intervening scopes, including unknown wording. +const ADMISSION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=\S)/i; /** Normalize citation names while preserving contracted negation as a separate word. */ const normalizedQueryName = text => text.replace(/n['\u2019]t\b/gi, ' not') .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') @@ -76,22 +83,25 @@ function citationFor(candidate, query, nativeName) { const text = clause.split(',').map(normalizedQueryName).join(', '); const mentions = aliases.flatMap(({ name, exact }) => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] .map(mention => ({ index: mention.index, exact }))).sort((a, b) => a.index - b.index); - const request = DIRECTIVE_REQUEST.test(text); - const interrogative = !request && QUESTION_START.test(text); for (const mention of mentions) { const prefix = text.slice(0, mention.index); - // A directive or question after a connector starts its own scope. - // Lists without another verb and reported speech keep their guards. + const scopes = prefix.split(ADMISSION_BOUNDARY); const instructionPrefix = prefix.split(INSTRUCTION_BOUNDARY).at(-1); - const question = interrogative || (!DIRECTIVE_REQUEST.test(instructionPrefix) - && (QUESTION_START.test(instructionPrefix) || (!request && clause.includes('?')))); - if (question || /^\s*["'\u201c\u2018\x60]/.test(clause) - || /\b(say|says|said|reads|told|mentions?|quoted?|document)\b/.test(prefix)) { + // Unknown scopes cannot authorize a later "and use ...". Positive scope + // evidence is required for admission, while existing rejection forms remain + // usable and indirect discussion cannot withdraw an earlier directive. + const instruction = scopes.every(scope => DIRECTIVE_REQUEST.test(scope) || DIRECTIVE_REJECTION.test(scope)); + const question = (!DIRECTIVE_REQUEST.test(text) && QUESTION_START.test(text)) + || (!DIRECTIVE_REQUEST.test(instructionPrefix) + && (QUESTION_START.test(instructionPrefix) || clause.includes('?'))); + if (question || REPORTED_INSTRUCTION.test(prefix) || /^\s*["'\u201c\u2018\x60]/.test(clause)) { if (citation === 'none') citation = 'indirect'; } else if (/\b(do not|never|no|not|avoid)\b/.test(instructionPrefix)) { citation = 'indirect'; + } else if (!instruction) { + if (citation === 'none') citation = 'indirect'; } else if (mention.exact && candidate.exact - && new RegExp(DIRECTIVE_VERB.source + '(skill\\s*:?\\s*)?$', 'i').test(prefix)) { + && DIRECTIVE_NAME_PREFIX.test(scopes.at(-1))) { citation = 'directive'; } } diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index 681da8fd0..7193bf83e 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -160,9 +160,17 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature 'Can I use the feature skill?', 'The README says use the feature skill.', 'The docs say use feature.', 'The docs say do not use feature and use shared guidance.', 'The docs say do not use feature, use shared guidance, okay?', + 'The spec states do not use feature and use shared guidance.', + 'The spec stated do not use feature, use shared guidance.', + 'The spec recommends we do not use feature and use shared guidance.', + 'The spec asserts we should not use feature and use shared guidance.', + 'Use standard tools, the spec stipulates do not use feature and use shared guidance.', 'Should we avoid feature and use shared guidance?', 'Should we avoid feature, use shared guidance?', 'Use standard tools, should we use feature?', + 'Use standard tools, shall we use feature?', + 'Use standard tools, will we use feature?', + 'Use standard tools, ought we to use feature?', 'Please use the existing code, can I use feature?', 'Use standard tools and should we use feature?', 'Use standard tools but should we use feature?', @@ -182,7 +190,9 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature } }); assert.deepEqual(phases, ['selection', 'task']); assert.equal(result.routingCalls, 1); + assert.deepEqual(result.selection.selectedIds, []); assert.deepEqual(result.selection.loadedIds, []); + assert.equal(result.selection.fallback, null); assert.equal(result.selection.reason, 'agent-declined-selection'); })); } @@ -201,6 +211,11 @@ for (const [query, skill] of [ ['Use feature?', 'feature'], ['Please use feature?', 'feature'], ['Use feature to fix the bug, okay?', 'feature'], + ['Use feature. Shall we use feature?', 'feature'], + ['Use feature, shall we use shared guidance?', 'feature'], + ['Shall we use shared guidance? Use feature.', 'feature'], + ['Use feature. The spec states do not use feature and use shared guidance.', 'feature'], + ['The spec states do not use feature and use shared guidance. Use feature.', 'feature'], ]) { test('an unambiguous final directive injects context without a proposal: ' + query, () => withFixture(repoRoot => { const phases = []; diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index d55b50868..ef6e2c4af 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -371,6 +371,13 @@ for (const query of [ 'The docs say do not use feature and use shared guidance.', 'The docs say do not use feature, use shared guidance.', 'The docs say do not use feature, use shared guidance, okay?', + 'Use standard tools, the spec states do not use feature and use shared guidance.', + 'The spec states do not use feature and use shared guidance.', + 'The spec stated do not use feature, use shared guidance.', + 'The spec recommends we do not use feature and use shared guidance.', + 'The spec asserts we should not use feature and use shared guidance.', + 'Use standard tools, the spec stipulates do not use feature and use shared guidance.', + 'Use the documentation that says do not use feature and use shared guidance.', 'Should we avoid feature and use shared guidance?', 'Should we avoid feature, use shared guidance?', 'Do not use feature and shared guidance.', @@ -388,6 +395,9 @@ for (const query of [ for (const query of [ 'Use standard tools, should we use feature?', + 'Use standard tools, shall we use feature?', + 'Use standard tools, will we use feature?', + 'Use standard tools, ought we to use feature?', 'Please use the existing code, can I use feature?', 'Use standard tools and should we use feature?', 'Use standard tools but should we use feature?', @@ -437,13 +447,13 @@ for (const prefix of ["Don't use database-migrations.", 'Do not use database-mig } test('routing policy changes invalidate receipts created by the old citation policy', () => withFixture(repoRoot => { - const query = "Don't use feature."; + const query = 'Use standard tools, shall we use feature?'; const first = resolve(repoRoot, { query, explicitIds: ['skill:feature'] }); const { digestObject } = require('../../scripts/lib/context-profile-support'); const { compileContextProfile } = require('../../scripts/lib/context-profiles'); const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, - phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 6, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 7, triggersDigest: digestObject({}), queryDigest: digestObject(query) }); const { receiptDigest: _receiptDigest, ...receipt } = first.receipt; const oldReceipt = { ...receipt, bindingDigest, explicitIds: [], @@ -451,7 +461,10 @@ test('routing policy changes invalidate receipts created by the old citation pol const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; const result = resolve(repoRoot, { query }, { previous, load: true }); assert.equal(result.reused, false); + assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); + assert.deepEqual(result.selectedIds, []); assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); })); test('an old empty selection is reconsidered when directive policy changes', () => withFixture(repoRoot => { @@ -478,8 +491,14 @@ for (const query of [ 'Should we use feature? Use feature.', 'Use feature. Should we use feature?', 'Use feature, should we use feature?', + 'Use feature. Shall we use feature?', + 'Use feature, shall we use shared guidance?', + 'Shall we use shared guidance? Use feature.', 'The docs say use feature. Use feature.', 'Use feature. The docs say use feature.', + 'Use feature. The spec states do not use feature and use shared guidance.', + 'Use feature. Use the warning that says never use feature.', + 'The spec states do not use feature and use shared guidance. Use feature.', 'The phrase "Use feature." is an example. Use feature.', 'The docs say "Use feature." Use feature.', 'The docs say "Should we use feature?" Use feature.', @@ -503,6 +522,11 @@ for (const query of [ for (const query of [ "Use feature. Don't use feature.", + 'Use feature. Avoid feature.', + 'Use feature. You should not use feature.', + 'Use feature. You shouldn\u2019t use feature.', + 'Use feature, no feature.', + 'Use feature. For this task, do not use feature.', "Use feature, don't use feature.", "Use feature and don't use feature.", "Use feature but don't use feature.", @@ -516,6 +540,18 @@ for (const query of [ })); } +test('a later grouped rejection withdraws a previously requested skill', () => withFixture(repoRoot => { + for (const query of ["Use shared. Don't use feature and shared guidance.", + 'Use shared. Do not use feature and our shared guidance.', + 'Use shared. Do not use feature and the existing shared guidance.', + 'Use shared. Do not use feature, manual, and shared guidance.', + "Use shared. Don't use feature and the shared skill."]) { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.loadedIds, [], query); + assert.equal(result.fallback, null); + } +})); + for (const prefix of [ "Don't use the database migration skill.", 'Don\u2019t use the database migration skill.', From b5a8bf0603748ef9a379847c0d286f64bddfb7b7 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Sat, 10 Oct 2026 12:46:51 +0800 Subject: [PATCH 06/11] fix(context): preserve skill requests after task preambles Allow neutral imperative task preambles before an explicit skill request without admitting final bare mentions, questions, or subject-led reports. Keep rejection-list scoping intact and recognize must/ought questions. Bump routing policy to v9 so an empty v8 receipt is reconsidered. Cover resolver and launcher behavior and document the affected routing helpers. --- scripts/lib/context-selection.js | 39 ++++++++--- tests/lib/context-profile-launch.test.js | 16 +++++ tests/lib/context-selection.test.js | 82 ++++++++++++++++++------ 3 files changed, 108 insertions(+), 29 deletions(-) diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 402ffcf9d..535376c09 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -32,26 +32,36 @@ const FALLBACK_MARGIN = 1.1; // v6: ordered directives and singular named references share the same admission guard. // v7: independent directives scope negation and imperatives allow question punctuation. // v8: only recognized request/rejection scopes can authorize coordinated directives. -const ROUTING_POLICY_VERSION = 8; +// v9: neutral task preambles do not suppress a later explicit skill request. +const ROUTING_POLICY_VERSION = 9; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; const DIRECTIVE_REQUEST = /^(?:please )?(?:(?:can|could|would|will) you (?:please )?|do )?(?:use|apply|invoke|run|follow|load)\b/; const DIRECTIVE_REJECTION = /^(?:please )?(?:(?:(?:do )?not|never) (?:use|apply|invoke|run|follow|load)\b|avoid\b)/; const DIRECTIVE_NAME_PREFIX = new RegExp(DIRECTIVE_REQUEST.source + '\\s+(?:the\\s+)?(?:skill\\s*)?$'); -const QUESTION_START = /^(?!do not\b)(can|could|would|should|shall|will|may|might|do|does|did|is|are|why|how|what|when|where|which)\b/; +const QUESTION_START = /^(?!do not\b)(can|could|would|should|shall|will|may|might|must|ought|do|does|did|is|are|why|how|what|when|where|which)\b/; const REPORTED_INSTRUCTION = /\b(say|says|said|reads|told|mentions?|quoted?|document|states?|stated|recommends?|recommended|asserts?|asserted)\b/; +// Subject-led statements may describe someone else's instructions or intentions. +const SUBJECT_PREFIX = /^(?:the|a|an|i|you|he|she|it|we|they|my|your|his|her|its|our|their|this|that|these|those)\b/; // Rejections keep their whole list until another instruction/question begins. -const INSTRUCTION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=(?:please\s+)?(?:do\s+(?:not\s+)?|not\s+|never\s+)?(?:use|apply|invoke|run|follow|load)\b|(?:can|could|would|should|shall|will|may|might|do|does|did|is|are|why|how|what|when|where|which)\b)/i; +const INSTRUCTION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=(?:please\s+)?(?:do\s+(?:not\s+)?|not\s+|never\s+)?(?:use|apply|invoke|run|follow|load)\b|(?:can|could|would|should|shall|will|may|might|must|ought|do|does|did|is|are|why|how|what|when|where|which)\b)/i; // Admission additionally checks intervening scopes, including unknown wording. const ADMISSION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=\S)/i; -/** Normalize citation names while preserving contracted negation as a separate word. */ +/** Normalize citation names while preserving contracted negation as a separate word. + * @param {string} text Raw query text or a registry alias. + * @returns {string} Lowercase alphanumeric words with negation preserved. + */ const normalizedQueryName = text => text.replace(/n['\u2019]t\b/gi, ' not') .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); /** Return canonical/native aliases plus conservative singular forms. Singular - * multiword aliases only guard indirect references; they never create exact admission. */ + * multiword aliases only guard indirect references; they never create exact admission. + * @param {object} candidate Ranked skill candidate with its canonical ID and exact alias. + * @param {string} nativeName Registry name that may be absent from exact retrieval. + * @returns {Array<{name: string, exact: boolean}>} Normalized names and admission eligibility. + */ function citationAliases(candidate, nativeName) { const names = [...new Set([candidate.exactAlias, candidate.id.slice('skill:'.length), nativeName] .filter(Boolean).map(normalizedQueryName).filter(Boolean))]; @@ -87,10 +97,13 @@ function citationFor(candidate, query, nativeName) { const prefix = text.slice(0, mention.index); const scopes = prefix.split(ADMISSION_BOUNDARY); const instructionPrefix = prefix.split(INSTRUCTION_BOUNDARY).at(-1); - // Unknown scopes cannot authorize a later "and use ...". Positive scope - // evidence is required for admission, while existing rejection forms remain - // usable and indirect discussion cannot withdraw an earlier directive. - const instruction = scopes.every(scope => DIRECTIVE_REQUEST.test(scope) || DIRECTIVE_REJECTION.test(scope)); + // A task preamble such as "fix the bug" does not change a later request. + // Only preceding scopes may be neutral: an embedded instruction, question, + // or subject-led statement still needs review. The final scope must remain + // a direct request; neutral text never supplies admission evidence itself. + const instruction = scopes.every((scope, index) => DIRECTIVE_REQUEST.test(scope) || DIRECTIVE_REJECTION.test(scope) + || (index < scopes.length - 1 && !DIRECTIVE_VERB.test(scope) + && !QUESTION_START.test(scope) && !REPORTED_INSTRUCTION.test(scope) && !SUBJECT_PREFIX.test(scope))); const question = (!DIRECTIVE_REQUEST.test(text) && QUESTION_START.test(text)) || (!DIRECTIVE_REQUEST.test(instructionPrefix) && (QUESTION_START.test(instructionPrefix) || clause.includes('?'))); @@ -213,7 +226,13 @@ function validatePrevious(previous) { } } -/** Pure task-scoped resolver. Returned context never invokes a native skill or changes permissions. */ +/** Resolve task-scoped skill context without invoking native skills or changing permissions. + * @param {object} [options] Repository, task, profile, and selection constraints. + * @param {object} options.task Task identity, query, and explicit or proposed skill IDs. + * @param {boolean} [options.load=false] Include verified resource contents in the result. + * @param {object|null} [options.previous=null] Receipt reusable only when its binding matches. + * @returns {object} Selection decision, candidates, verified resources, and a bound receipt. + */ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'lean@1', target = 'codex', selectionMode = 'auto', include = [], exclude = [], load = false, previous = null, expectedDigest = null } = {}) { validateTask(task); diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index 7193bf83e..2e77cbd56 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -174,6 +174,15 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature 'Please use the existing code, can I use feature?', 'Use standard tools and should we use feature?', 'Use standard tools but should we use feature?', + 'Fix the bug, shall we use feature?', + 'Fix the bug, the spec states do not use feature and use shared guidance.', + 'The docs say fix the bug and use feature.', + 'Can we fix the bug and use feature?', + 'The spec stipulates: fix the bug and use feature.', + 'Our policy stipulates repair the bug and use feature.', + 'Fix the bug, ought we to repair it and use feature?', + 'Fix the bug, must we repair it and use feature?', + 'Fix the bug, feature.', 'Do not use feature and shared guidance.', "Use feature, don't use feature.", "Use feature and don't use feature.", @@ -198,6 +207,13 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature } for (const [query, skill] of [ + ['Fix the bug, use feature', 'feature'], + ['Fix the bug and use feature.', 'feature'], + ['Please fix the bug, please use feature.', 'feature'], + ['Write the regression test, use feature.', 'feature'], + ['Investigate the bug but use feature.', 'feature'], + ['For this task, use feature.', 'feature'], + ["Fix the bug, don't use feature and use shared guidance.", 'shared'], ["Don't use feature, use shared guidance.", 'shared'], ["Don't use feature and use shared guidance.", 'shared'], ["Don't use feature but use shared guidance.", 'shared'], diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index ef6e2c4af..7320c1f24 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -434,6 +434,45 @@ test('ordinary and polite directives preserve automatic and explicit selection', } })); +for (const [query, expectedId] of [ + ['Fix the bug, use feature', 'skill:feature'], + ['Fix the bug and use feature.', 'skill:feature'], + ['Please fix the bug, please use feature.', 'skill:feature'], + ['Write the regression test, use feature.', 'skill:feature'], + ['Investigate the bug but use feature.', 'skill:feature'], + ['For this task, use feature.', 'skill:feature'], + ["Fix the bug, don't use feature and use shared guidance.", 'skill:shared'], +]) { + test('a general task preamble preserves a later directive: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, [expectedId]); + assert.deepEqual(result.loadedIds, [expectedId]); + assert.equal(result.reason, 'auto-selection'); + assert.equal(result.receipt.autoSelection.exact, true); + })); +} + +for (const query of [ + 'Fix the bug, shall we use feature?', + 'Fix the bug, the spec states do not use feature and use shared guidance.', + 'The docs say fix the bug and use feature.', + 'Can we fix the bug and use feature?', + 'The spec stipulates: fix the bug and use feature.', + 'Our policy stipulates repair the bug and use feature.', + 'Fix the bug, ought we to repair it and use feature?', + 'Fix the bug, must we repair it and use feature?', + 'Fix the bug, feature.', +]) { + test('a general task preamble cannot promote an indirect citation: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + for (const prefix of ["Don't use database-migrations.", 'Do not use database-migrations.', 'Should we use database-migrations?', 'The README says use database-migrations.']) { test('strong retrieval cannot bypass an indirect citation: ' + prefix, () => { @@ -467,25 +506,30 @@ test('routing policy changes invalidate receipts created by the old citation pol assert.equal(result.fallback, null); })); -test('an old empty selection is reconsidered when directive policy changes', () => withFixture(repoRoot => { - const query = "Don't use feature, use shared guidance."; - const first = resolve(repoRoot, { query }); - const { digestObject } = require('../../scripts/lib/context-profile-support'); - const { compileContextProfile } = require('../../scripts/lib/context-profiles'); - const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); - const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, - phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 6, - triggersDigest: digestObject({}), queryDigest: digestObject(query) }); - const { receiptDigest: _receiptDigest, autoSelection: _autoSelection, ...receipt } = first.receipt; - const oldReceipt = { ...receipt, bindingDigest, decision: 'none', selectedIds: [], explicitIds: [], - loadedIds: [], resources: [], selectionDigest: digestObject({ bindingDigest, selectedIds: [], explicitIds: [] }) }; - const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; - const result = resolve(repoRoot, { query }, { previous, load: true }); - assert.equal(result.reused, false); - assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); - assert.deepEqual(result.loadedIds, ['skill:shared']); - assert.equal(result.reason, 'auto-selection'); -})); +for (const [routingPolicyVersion, query, expectedId] of [ + [6, "Don't use feature, use shared guidance.", 'skill:shared'], + [8, 'Fix the bug, use feature', 'skill:feature'], +]) { + test('an old policy ' + routingPolicyVersion + ' empty selection is reconsidered after policy changes', () => withFixture(repoRoot => { + const first = resolve(repoRoot, { query }); + const { digestObject } = require('../../scripts/lib/context-profile-support'); + const { compileContextProfile } = require('../../scripts/lib/context-profiles'); + const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); + const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion, + triggersDigest: digestObject({}), queryDigest: digestObject(query) }); + const { receiptDigest: _receiptDigest, autoSelection: _autoSelection, ...receipt } = first.receipt; + const oldReceipt = { ...receipt, bindingDigest, decision: 'none', selectedIds: [], explicitIds: [], + loadedIds: [], resources: [], selectionDigest: digestObject({ bindingDigest, selectedIds: [], explicitIds: [] }) }; + const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; + const result = resolve(repoRoot, { query }, { previous, load: true }); + assert.equal(result.reused, false); + assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); + assert.deepEqual(result.selectedIds, [expectedId]); + assert.deepEqual(result.loadedIds, [expectedId]); + assert.equal(result.reason, 'auto-selection'); + })); +} for (const query of [ 'Should we use feature? Use feature.', From a70b9795d13211c39b2ea34365b58eb51fd51cb7 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Sat, 10 Oct 2026 13:09:48 +0800 Subject: [PATCH 07/11] fix(context): keep illustrative skill requests on the proposal path Treat example-framing prefixes as indirect citations before checking rejections, preserving genuine requests around illustrative instructions. Keep e.g. together during clause splitting without rewriting e.g.js tokens. Bump routing policy to v10 to invalidate v9 selections that loaded example context. Cover resolver, launcher, instruction ordering, and receipt reuse. --- scripts/lib/context-selection.js | 11 +++- tests/lib/context-profile-launch.test.js | 15 +++++ tests/lib/context-selection.test.js | 71 +++++++++++++++++------- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 535376c09..5253b5be2 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -33,7 +33,8 @@ const FALLBACK_MARGIN = 1.1; // v7: independent directives scope negation and imperatives allow question punctuation. // v8: only recognized request/rejection scopes can authorize coordinated directives. // v9: neutral task preambles do not suppress a later explicit skill request. -const ROUTING_POLICY_VERSION = 9; +// v10: illustrative instructions do not supply or withdraw a direct request. +const ROUTING_POLICY_VERSION = 10; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; @@ -42,6 +43,7 @@ const DIRECTIVE_REJECTION = /^(?:please )?(?:(?:(?:do )?not|never) (?:use|apply| const DIRECTIVE_NAME_PREFIX = new RegExp(DIRECTIVE_REQUEST.source + '\\s+(?:the\\s+)?(?:skill\\s*)?$'); const QUESTION_START = /^(?!do not\b)(can|could|would|should|shall|will|may|might|must|ought|do|does|did|is|are|why|how|what|when|where|which)\b/; const REPORTED_INSTRUCTION = /\b(say|says|said|reads|told|mentions?|quoted?|document|states?|stated|recommends?|recommended|asserts?|asserted)\b/; +const EXAMPLE_PREFIX = /^(?:for (?:example|instance)|as an example|to illustrate)\b/; // Subject-led statements may describe someone else's instructions or intentions. const SUBJECT_PREFIX = /^(?:the|a|an|i|you|he|she|it|we|they|my|your|his|her|its|our|their|this|that|these|those)\b/; // Rejections keep their whole list until another instruction/question begins. @@ -88,7 +90,9 @@ function citationFor(candidate, query, nativeName) { if (aliases.some(({ name }) => new RegExp('\\b' + name + '\\b').test(text))) citation = 'indirect'; return part.replace(/[^.!?;\n]/g, ' '); }); - for (const clause of unquoted.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { + // Keep the illustrative abbreviation together when its periods would split clauses. + const expanded = unquoted.replace(/\be\.g\.(?=\s|[,;:!?]|$)/gi, 'for example'); + for (const clause of expanded.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { // Keep comma boundaries for directive scope without changing alias normalization. const text = clause.split(',').map(normalizedQueryName).join(', '); const mentions = aliases.flatMap(({ name, exact }) => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] @@ -97,6 +101,7 @@ function citationFor(candidate, query, nativeName) { const prefix = text.slice(0, mention.index); const scopes = prefix.split(ADMISSION_BOUNDARY); const instructionPrefix = prefix.split(INSTRUCTION_BOUNDARY).at(-1); + const example = scopes.some(scope => EXAMPLE_PREFIX.test(scope)); // A task preamble such as "fix the bug" does not change a later request. // Only preceding scopes may be neutral: an embedded instruction, question, // or subject-led statement still needs review. The final scope must remain @@ -107,7 +112,7 @@ function citationFor(candidate, query, nativeName) { const question = (!DIRECTIVE_REQUEST.test(text) && QUESTION_START.test(text)) || (!DIRECTIVE_REQUEST.test(instructionPrefix) && (QUESTION_START.test(instructionPrefix) || clause.includes('?'))); - if (question || REPORTED_INSTRUCTION.test(prefix) || /^\s*["'\u201c\u2018\x60]/.test(clause)) { + if (example || question || REPORTED_INSTRUCTION.test(prefix) || /^\s*["'\u201c\u2018\x60]/.test(clause)) { if (citation === 'none') citation = 'indirect'; } else if (/\b(do not|never|no|not|avoid)\b/.test(instructionPrefix)) { citation = 'indirect'; diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index 2e77cbd56..2d7610fe8 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -183,6 +183,14 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature 'Fix the bug, ought we to repair it and use feature?', 'Fix the bug, must we repair it and use feature?', 'Fix the bug, feature.', + 'For example, use feature', + 'For instance, use feature.', + 'As an example, use feature.', + 'To illustrate, use feature.', + 'Fix the bug, for example, use feature.', + 'For example, use feature and use feature.', + 'e.g., use feature.', + 'E.g. use feature.', 'Do not use feature and shared guidance.', "Use feature, don't use feature.", "Use feature and don't use feature.", @@ -232,6 +240,13 @@ for (const [query, skill] of [ ['Shall we use shared guidance? Use feature.', 'feature'], ['Use feature. The spec states do not use feature and use shared guidance.', 'feature'], ['The spec states do not use feature and use shared guidance. Use feature.', 'feature'], + ['Use feature. For example, use shared guidance.', 'feature'], + ['For example, use shared guidance. Use feature.', 'feature'], + ["Use feature. For example, don't use feature.", 'feature'], + ['Use feature to build an example.', 'feature'], + ['Fix the example, use feature.', 'feature'], + ['Fix e.g.js, use feature.', 'feature'], + ["Use feature. E.g., don't use feature.", 'feature'], ]) { test('an unambiguous final directive injects context without a proposal: ' + query, () => withFixture(repoRoot => { const phases = []; diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index 7320c1f24..2ec8c5529 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -485,26 +485,50 @@ for (const prefix of ["Don't use database-migrations.", 'Do not use database-mig }); } -test('routing policy changes invalidate receipts created by the old citation policy', () => withFixture(repoRoot => { - const query = 'Use standard tools, shall we use feature?'; - const first = resolve(repoRoot, { query, explicitIds: ['skill:feature'] }); - const { digestObject } = require('../../scripts/lib/context-profile-support'); - const { compileContextProfile } = require('../../scripts/lib/context-profiles'); - const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); - const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, - phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion: 7, - triggersDigest: digestObject({}), queryDigest: digestObject(query) }); - const { receiptDigest: _receiptDigest, ...receipt } = first.receipt; - const oldReceipt = { ...receipt, bindingDigest, explicitIds: [], - selectionDigest: digestObject({ bindingDigest, selectedIds: ['skill:feature'], explicitIds: [] }) }; - const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; - const result = resolve(repoRoot, { query }, { previous, load: true }); - assert.equal(result.reused, false); - assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); - assert.deepEqual(result.selectedIds, []); - assert.deepEqual(result.loadedIds, []); - assert.equal(result.fallback, null); -})); +for (const query of [ + 'For example, use feature', + 'For instance, use feature.', + 'As an example, use feature.', + 'To illustrate, use feature.', + 'Fix the bug, for example, use feature.', + 'For example, use feature and use feature.', + 'e.g., use feature.', + 'E.g. use feature.', +]) { + test('an illustrative citation needs an agent decision: ' + query, () => withFixture(repoRoot => { + const result = resolve(repoRoot, { query }, { load: true }); + assert.ok(result.candidates.some(candidate => candidate.id === 'skill:feature')); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); + })); +} + +for (const [routingPolicyVersion, query] of [ + [7, 'Use standard tools, shall we use feature?'], + [9, 'For example, use feature'], +]) { + test('an old policy ' + routingPolicyVersion + ' selected receipt is invalidated after policy changes', () => withFixture(repoRoot => { + const first = resolve(repoRoot, { query, explicitIds: ['skill:feature'] }); + const { digestObject } = require('../../scripts/lib/context-profile-support'); + const { compileContextProfile } = require('../../scripts/lib/context-profiles'); + const plan = compileContextProfile({ repoRoot, selectionMode: 'auto' }); + const bindingDigest = digestObject({ sessionId: 'session-1', taskId: 'task-1', revision: 1, + phase: 'implement', planDigest: plan.planDigest, routingPolicyVersion, + triggersDigest: digestObject({}), queryDigest: digestObject(query) }); + const { receiptDigest: _receiptDigest, ...receipt } = first.receipt; + const oldReceipt = { ...receipt, bindingDigest, explicitIds: [], + selectionDigest: digestObject({ bindingDigest, selectedIds: ['skill:feature'], explicitIds: [] }) }; + const previous = { ...oldReceipt, receiptDigest: digestObject(oldReceipt) }; + const result = resolve(repoRoot, { query }, { previous, load: true }); + assert.equal(result.reused, false); + assert.notEqual(result.receipt.bindingDigest, previous.bindingDigest); + assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); + assert.equal(result.fallback, null); + })); +} for (const [routingPolicyVersion, query, expectedId] of [ [6, "Don't use feature, use shared guidance.", 'skill:shared'], @@ -555,6 +579,13 @@ for (const query of [ 'The docs say never use feature. Use feature.', 'Use feature. The docs say never use feature.', 'Use feature to inspect the essay.', + 'Use feature. For example, use shared guidance.', + 'For example, use shared guidance. Use feature.', + "Use feature. For example, don't use feature.", + 'Use feature to build an example.', + 'Fix the example, use feature.', + 'Fix e.g.js, use feature.', + "Use feature. E.g., don't use feature.", ]) { test('a genuine directive survives surrounding discussion: ' + query, () => withFixture(repoRoot => { const result = resolve(repoRoot, { query }, { load: true }); From 310441dcc89b0cca66f9f65e16a3e002f2041fd4 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Sat, 10 Oct 2026 14:15:37 +0800 Subject: [PATCH 08/11] fix(context): honor current-task requests after examples Track illustrative scopes and retain their connectors so a contrastive request or rejection for the current task ends the preceding example. Preserve complete skill aliases, outer discussion guards, quoted task qualifiers, and indirect citation protection for scored admission. Invalidate older routing receipts and add resolver/launcher regressions for request ordering, scope boundaries, aliases, and the reported P1. --- scripts/lib/context-selection.js | 51 ++++++++++++++++++++---- tests/lib/context-profile-launch.test.js | 41 +++++++++++++++++++ tests/lib/context-selection.test.js | 38 +++++++++++++++++- 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/scripts/lib/context-selection.js b/scripts/lib/context-selection.js index 5253b5be2..66381519f 100644 --- a/scripts/lib/context-selection.js +++ b/scripts/lib/context-selection.js @@ -34,7 +34,8 @@ const FALLBACK_MARGIN = 1.1; // v8: only recognized request/rejection scopes can authorize coordinated directives. // v9: neutral task preambles do not suppress a later explicit skill request. // v10: illustrative instructions do not supply or withdraw a direct request. -const ROUTING_POLICY_VERSION = 10; +// v11: a contrastive current-task instruction ends the preceding example scope. +const ROUTING_POLICY_VERSION = 11; const TASK_KEYS = new Set(['sessionId', 'taskId', 'revision', 'phase', 'query', 'explicitIds', 'proposedIds', 'noWorkflow']); const DIRECTIVE_VERB = /\b(use|apply|invoke|run|follow|load)\s+(the\s+)?/i; @@ -44,12 +45,37 @@ const DIRECTIVE_NAME_PREFIX = new RegExp(DIRECTIVE_REQUEST.source + '\\s+(?:the\ const QUESTION_START = /^(?!do not\b)(can|could|would|should|shall|will|may|might|must|ought|do|does|did|is|are|why|how|what|when|where|which)\b/; const REPORTED_INSTRUCTION = /\b(say|says|said|reads|told|mentions?|quoted?|document|states?|stated|recommends?|recommended|asserts?|asserted)\b/; const EXAMPLE_PREFIX = /^(?:for (?:example|instance)|as an example|to illustrate)\b/; +const CURRENT_TASK_SUFFIX = /\bfor (?:this|the current) task$/; // Subject-led statements may describe someone else's instructions or intentions. const SUBJECT_PREFIX = /^(?:the|a|an|i|you|he|she|it|we|they|my|your|his|her|its|our|their|this|that|these|those)\b/; // Rejections keep their whole list until another instruction/question begins. const INSTRUCTION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=(?:please\s+)?(?:do\s+(?:not\s+)?|not\s+|never\s+)?(?:use|apply|invoke|run|follow|load)\b|(?:can|could|would|should|shall|will|may|might|must|ought|do|does|did|is|are|why|how|what|when|where|which)\b)/i; // Admission additionally checks intervening scopes, including unknown wording. const ADMISSION_BOUNDARY = /(?:,\s*(?:(?:and|but)\s+)?|\s+(?:and|but)\s+)(?=\S)/i; + +/** Track example spans without discarding the connectors between instructions. + * A contrast inside an example remains illustrative unless its instruction + * explicitly addresses the current task. Earlier non-example scopes stay intact. + * @param {string} text Normalized clause with comma boundaries preserved. + * @param {string[]} names Known candidate aliases whose internal connectors are names. + * @returns {Array<{start: number, text: string, example: boolean}>} Ordered scopes. + */ +function citationScopes(text, names) { + const mentions = names.flatMap(name => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] + .map(mention => ({ start: mention.index, end: mention.index + name.length }))); + const boundaries = [...text.matchAll(new RegExp(ADMISSION_BOUNDARY.source, 'gi'))] + .filter(boundary => !mentions.some(mention => boundary.index > mention.start && boundary.index < mention.end)); + let example = false; + return [0, ...boundaries.map(boundary => boundary.index + boundary[0].length)].map((start, index) => { + const scope = text.slice(start, boundaries[index]?.index ?? text.length); + const currentTask = CURRENT_TASK_SUFFIX.test(scope) + && (DIRECTIVE_REQUEST.test(scope) || DIRECTIVE_REJECTION.test(scope)); + if (currentTask && /\bbut\b/.test(boundaries[index - 1]?.[0] || '')) example = false; + if (EXAMPLE_PREFIX.test(scope)) example = true; + return { start, text: scope, example }; + }); +} + /** Normalize citation names while preserving contracted negation as a separate word. * @param {string} text Raw query text or a registry alias. * @returns {string} Lowercase alphanumeric words with negation preserved. @@ -77,17 +103,20 @@ function citationAliases(candidate, nativeName) { * @param {object} candidate Ranked skill candidate with its canonical ID and exact alias. * @param {string} query Task text to classify without loading skill contents. * @param {string} nativeName Registry name, including aliases absent from exact retrieval. + * @param {string[]} names All candidate aliases, used to preserve names across scopes. * @returns {'directive'|'indirect'|'none'} Admission evidence for this skill only. */ -function citationFor(candidate, query, nativeName) { +function citationFor(candidate, query, nativeName, names) { const aliases = citationAliases(candidate, nativeName); let citation = 'none'; // Mask quoted instructions before splitting clauses so punctuation inside a // quotation cannot look like a separate instruction. Quoted names remain usable. const unquoted = query.replace(/"[^"]*"|\u201c[^\u201d]*\u201d|(?:^|[\s(:])'[^']*'|\u2018[^\u2019]*\u2019|\x60[^\x60]*\x60/g, part => { const text = normalizedQueryName(part); - if (!DIRECTIVE_VERB.test(text)) return part; - if (aliases.some(({ name }) => new RegExp('\\b' + name + '\\b').test(text))) citation = 'indirect'; + const instruction = DIRECTIVE_VERB.test(text); + // Quoting a task qualifier cannot turn an illustrative instruction into a request. + if (!instruction && !CURRENT_TASK_SUFFIX.test(text)) return part; + if (instruction && aliases.some(({ name }) => new RegExp('\\b' + name + '\\b').test(text))) citation = 'indirect'; return part.replace(/[^.!?;\n]/g, ' '); }); // Keep the illustrative abbreviation together when its periods would split clauses. @@ -95,13 +124,16 @@ function citationFor(candidate, query, nativeName) { for (const clause of expanded.match(/[^.!?;\n]+[.!?;\n]*/g) || []) { // Keep comma boundaries for directive scope without changing alias normalization. const text = clause.split(',').map(normalizedQueryName).join(', '); + const clauseScopes = citationScopes(text, names); const mentions = aliases.flatMap(({ name, exact }) => [...text.matchAll(new RegExp('\\b' + name + '\\b', 'g'))] .map(mention => ({ index: mention.index, exact }))).sort((a, b) => a.index - b.index); for (const mention of mentions) { const prefix = text.slice(0, mention.index); - const scopes = prefix.split(ADMISSION_BOUNDARY); + const precedingScopes = clauseScopes.filter(scope => scope.start <= mention.index); + const example = precedingScopes.at(-1)?.example; + const scopes = precedingScopes.filter(scope => !scope.example) + .map(scope => scope.text.slice(0, mention.index - scope.start)); const instructionPrefix = prefix.split(INSTRUCTION_BOUNDARY).at(-1); - const example = scopes.some(scope => EXAMPLE_PREFIX.test(scope)); // A task preamble such as "fix the bug" does not change a later request. // Only preceding scopes may be neutral: an embedded instruction, question, // or subject-led statement still needs review. The final scope must remain @@ -112,7 +144,7 @@ function citationFor(candidate, query, nativeName) { const question = (!DIRECTIVE_REQUEST.test(text) && QUESTION_START.test(text)) || (!DIRECTIVE_REQUEST.test(instructionPrefix) && (QUESTION_START.test(instructionPrefix) || clause.includes('?'))); - if (example || question || REPORTED_INSTRUCTION.test(prefix) || /^\s*["'\u201c\u2018\x60]/.test(clause)) { + if (example || question || scopes.some(scope => REPORTED_INSTRUCTION.test(scope)) || /^\s*["'\u201c\u2018\x60]/.test(clause)) { if (citation === 'none') citation = 'indirect'; } else if (/\b(do not|never|no|not|avoid)\b/.test(instructionPrefix)) { citation = 'indirect'; @@ -281,7 +313,10 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l // questions, negations, reported speech, multiple cited names — never admit // implicitly. Everything else keeps the bounded-proposal path so the // primary agent decides ambiguous cases during work it was already doing. - const citations = new Map(candidates.map(candidate => [candidate.id, citationFor(candidate, task.query || '', byId.get(candidate.id).name)])); + const names = [...new Set(candidates.flatMap(candidate => citationAliases(candidate, byId.get(candidate.id).name) + .map(alias => alias.name)))]; + const citations = new Map(candidates.map(candidate => [candidate.id, + citationFor(candidate, task.query || '', byId.get(candidate.id).name, names)])); const exactAnchors = candidates.filter(candidate => citations.get(candidate.id) === 'directive'); let autoSelection = null; if (!task.noWorkflow && selectionMode === 'auto' && !reused && !explicitIds.length && !proposedIds.length && candidates.length) { diff --git a/tests/lib/context-profile-launch.test.js b/tests/lib/context-profile-launch.test.js index 2d7610fe8..cc2aac847 100644 --- a/tests/lib/context-profile-launch.test.js +++ b/tests/lib/context-profile-launch.test.js @@ -189,6 +189,18 @@ for (const query of ["Don't use the feature skill.", 'Don\u2019t use the feature 'To illustrate, use feature.', 'Fix the bug, for example, use feature.', 'For example, use feature and use feature.', + 'For example, use feature but use shared guidance.', + "For example, don't use feature but use shared guidance.", + 'For example, use feature and use feature for this task.', + 'For example, use feature, but should we use feature for this task?', + 'For example, use feature, but the docs say use feature for this task.', + 'For example, use feature, but "use feature for this task" is an example.', + 'The docs say for example, use feature, but use feature for this task.', + 'For example, use feature, but use feature "for this task"', + 'Should we, for example, use shared guidance, but use feature for this task?', + 'Our policy stipulates, for example, use shared guidance, but use feature for this task.', + 'Use feature. For example, use shared guidance, but do not use feature for this task.', + 'For example, use shared guidance, but use feature for this task and do not use feature.', 'e.g., use feature.', 'E.g. use feature.', 'Do not use feature and shared guidance.', @@ -247,6 +259,13 @@ for (const [query, skill] of [ ['Fix the example, use feature.', 'feature'], ['Fix e.g.js, use feature.', 'feature'], ["Use feature. E.g., don't use feature.", 'feature'], + ['For example, use feature, but use feature for this task', 'feature'], + ['For example, use shared guidance, but please use feature for this task.', 'feature'], + ['For instance, use shared guidance but use feature for this task.', 'feature'], + ['For example use shared guidance, but use feature for this task.', 'feature'], + ['For example, use shared guidance, but use feature for the current task.', 'feature'], + ['For example, use shared guidance, but use feature for this task, for instance, do not use feature.', 'feature'], + ['For example, the docs say use shared guidance, but use feature for this task.', 'feature'], ]) { test('an unambiguous final directive injects context without a proposal: ' + query, () => withFixture(repoRoot => { const phases = []; @@ -279,6 +298,28 @@ test('a final directive after a question reaches the task with its context', () assert.deepEqual(result.selection.loadedIds, ['skill:feature']); })); +for (const [suffix, expectedIds] of [['.', ['skill:feature']], [' and use shared guidance.', []]]) { + test('a conjunction inside a native alias preserves launch scope: ' + suffix, () => withFixture(repoRoot => { + fs.writeFileSync(path.join(repoRoot, 'skills/feature/SKILL.md'), + '---\nname: research-and-development\ndescription: Feature workflow\n---\n# feature'); + const query = 'For example, use shared guidance, but use research and development for this task' + suffix; + const phases = []; + const result = launchTaskContext({ repoRoot, task: { ...input, query, explicitIds: [] }, + execute(command, args, options) { + phases.push(options.phase); + if (options.phase === 'selection') return { status: 0, stdout: '{"selectedIds":[]}' }; + if (expectedIds.length) assert.match(options.input, /# feature/); + else assert.doesNotMatch(options.input, /# feature/); + assert.doesNotMatch(options.input, /# shared/); + return { status: 0, stdout: 'ok' }; + } }); + assert.deepEqual(phases, expectedIds.length ? ['task'] : ['selection', 'task']); + assert.deepEqual(result.selection.selectedIds, expectedIds); + assert.deepEqual(result.selection.loadedIds, expectedIds); + assert.equal(result.selection.reason, expectedIds.length ? 'auto-selection' : 'agent-declined-selection'); + })); +} + test('a singular rejected name cannot inject scored context at launch', () => { const phases = []; const query = "Don't use the database migration skill. Review a PostgreSQL migration that adds an indexed nullable column without downtime."; diff --git a/tests/lib/context-selection.test.js b/tests/lib/context-selection.test.js index 2ec8c5529..728887ad8 100644 --- a/tests/lib/context-selection.test.js +++ b/tests/lib/context-selection.test.js @@ -167,11 +167,13 @@ test('source changes invalidate reuse and source-bound load preview', () => with })); test('bounded search uses canonical IDs and deterministic order', () => withFixture(repoRoot => { - const result = resolve(repoRoot, { query: 'feature' }); + const result = resolve(repoRoot, { query: 'feature' }, { load: true }); assert.equal(result.candidates[0].id, 'skill:feature'); // A bare name mention ranks the skill but is not a directive citation. assert.deepEqual(result.selectedIds, []); + assert.deepEqual(result.loadedIds, []); assert.equal(result.reason, 'agent-selection-required'); + assert.equal(result.fallback, null); assert.ok(result.candidates.length <= 5); })); @@ -201,6 +203,18 @@ test('multiple directive citations defer to an explicit agent proposal', () => w assert.equal(result.reason, 'agent-selection-required'); })); +for (const [suffix, expectedIds] of [['.', ['skill:feature']], [' and use shared guidance.', []]]) { + test('a conjunction inside a native alias preserves task request scope: ' + suffix, () => withFixture(repoRoot => { + write(repoRoot, 'skills/feature/SKILL.md', '---\nname: research-and-development\ndescription: Feature workflow\n---\n# feature'); + const query = 'For example, use shared guidance, but use research and development for this task' + suffix; + const result = resolve(repoRoot, { query }, { load: true }); + assert.deepEqual(result.selectedIds, expectedIds); + assert.deepEqual(result.loadedIds, expectedIds); + assert.equal(result.reason, expectedIds.length ? 'auto-selection' : 'agent-selection-required'); + if (!expectedIds.length) assert.equal(result.fallback, null); + })); +} + test('name anchors require complete word boundaries', () => withFixture(repoRoot => { const result = resolve(repoRoot, { query: 'featurette sharedness' }, { load: true }); assert.deepEqual(result.loadedIds, []); @@ -473,7 +487,7 @@ for (const query of [ })); } -for (const prefix of ["Don't use database-migrations.", 'Do not use database-migrations.', +for (const prefix of ['database-migrations:', "Don't use database-migrations.", 'Do not use database-migrations.', 'Should we use database-migrations?', 'The README says use database-migrations.']) { test('strong retrieval cannot bypass an indirect citation: ' + prefix, () => { const query = prefix + ' Review a PostgreSQL migration that adds an indexed nullable column without downtime.'; @@ -492,6 +506,16 @@ for (const query of [ 'To illustrate, use feature.', 'Fix the bug, for example, use feature.', 'For example, use feature and use feature.', + 'For example, use feature but use shared guidance.', + "For example, don't use feature but use shared guidance.", + 'For example, use feature and use feature for this task.', + 'For example, use feature, but should we use feature for this task?', + 'For example, use feature, but the docs say use feature for this task.', + 'For example, use feature, but "use feature for this task" is an example.', + 'The docs say for example, use feature, but use feature for this task.', + 'For example, use feature, but use feature "for this task"', + 'Should we, for example, use shared guidance, but use feature for this task?', + 'Our policy stipulates, for example, use shared guidance, but use feature for this task.', 'e.g., use feature.', 'E.g. use feature.', ]) { @@ -533,6 +557,7 @@ for (const [routingPolicyVersion, query] of [ for (const [routingPolicyVersion, query, expectedId] of [ [6, "Don't use feature, use shared guidance.", 'skill:shared'], [8, 'Fix the bug, use feature', 'skill:feature'], + [10, 'For example, use feature, but use feature for this task', 'skill:feature'], ]) { test('an old policy ' + routingPolicyVersion + ' empty selection is reconsidered after policy changes', () => withFixture(repoRoot => { const first = resolve(repoRoot, { query }); @@ -586,6 +611,13 @@ for (const query of [ 'Fix the example, use feature.', 'Fix e.g.js, use feature.', "Use feature. E.g., don't use feature.", + 'For example, use feature, but use feature for this task', + 'For example, use shared guidance, but please use feature for this task.', + 'For instance, use shared guidance but use feature for this task.', + 'For example use shared guidance, but use feature for this task.', + 'For example, use shared guidance, but use feature for the current task.', + 'For example, use shared guidance, but use feature for this task, for instance, do not use feature.', + 'For example, the docs say use shared guidance, but use feature for this task.', ]) { test('a genuine directive survives surrounding discussion: ' + query, () => withFixture(repoRoot => { const result = resolve(repoRoot, { query }, { load: true }); @@ -607,6 +639,8 @@ for (const query of [ "Use feature but don't use feature.", 'Use feature. Never use feature.', "Use feature. Don't use feature. Should we use feature?", + 'Use feature. For example, use shared guidance, but do not use feature for this task.', + 'For example, use shared guidance, but use feature for this task and do not use feature.', ]) { test('a later rejection withdraws an earlier directive: ' + query, () => withFixture(repoRoot => { const result = resolve(repoRoot, { query }, { load: true }); From d9efa98bb60f609938d0c766eca86c233735e4be Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Sun, 11 Oct 2026 13:41:34 +0800 Subject: [PATCH 09/11] fix(install): preserve locale selections during auto-update --- scripts/lib/install/request.js | 7 ++- tests/lib/install-request.test.js | 54 +++++++++++++++++++++++ tests/scripts/auto-update.test.js | 73 +++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/scripts/lib/install/request.js b/scripts/lib/install/request.js index 28fab4acb..9dfbed7e2 100644 --- a/scripts/lib/install/request.js +++ b/scripts/lib/install/request.js @@ -4,6 +4,7 @@ const { validateInstallModuleIds, LOCALE_ALIAS_TO_COMPONENT_ID, listSupportedLoc const { resolveHookConsentFlags } = require('./hook-consent'); const LEGACY_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity']; +const LOCALE_COMPONENT_IDS = new Set(Object.values(LOCALE_ALIAS_TO_COMPONENT_ID)); function dedupeStrings(values) { return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))]; @@ -103,6 +104,7 @@ function normalizeInstallRequest(options = {}) { : null; const profileId = options.profileId || config?.profileId || null; const target = options.target || config?.target || 'claude'; + const supportsLocale = target === 'claude' || target === 'claude-project'; const moduleIds = validateInstallModuleIds( dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]) ); @@ -113,7 +115,7 @@ function normalizeInstallRequest(options = {}) { `Unsupported locale: "${locale}". Supported locales: ${listSupportedLocales().join(', ')}` ); } - if (locale && target !== 'claude' && target !== 'claude-project') { + if (locale && !supportsLocale) { throw new Error('--locale can only be used with --target claude or --target claude-project'); } const requestedIncludeComponentIds = dedupeStrings([ @@ -139,7 +141,8 @@ function normalizeInstallRequest(options = {}) { const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0; const hasNonLocaleManifestSelection = Boolean(profileId) || moduleIds.length > 0 - || requestedIncludeComponentIds.length > 0 + // Install-state persists --locale as a component; auto-update replays it via --with. + || requestedIncludeComponentIds.some(id => !supportsLocale || !LOCALE_COMPONENT_IDS.has(id)) || excludeComponentIds.length > 0; const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0; diff --git a/tests/lib/install-request.test.js b/tests/lib/install-request.test.js index 9e580918c..276591f68 100644 --- a/tests/lib/install-request.test.js +++ b/tests/lib/install-request.test.js @@ -143,6 +143,60 @@ function runTests() { assert.deepStrictEqual(request.includeComponentIds, ['locale:ja']); })) passed++; else failed++; + if (test('accepts canonical locale components with legacy languages on Claude targets', () => { + for (const target of ['claude', 'claude-project']) { + const request = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', '--target', target, + '--with', 'locale:zh-cn', '--with', 'locale:ja', + 'typescript', '--no-hooks', + ])); + assert.strictEqual(request.mode, 'legacy-compat'); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn', 'locale:ja']); + assert.deepStrictEqual(request.legacyLanguages, ['typescript']); + assert.strictEqual(request.hookConsent, 'declined'); + } + })) passed++; else failed++; + + if (test('accepts config-backed locale components with legacy languages', () => { + const request = normalizeInstallRequest({ + languages: ['typescript'], + config: { target: 'claude', includeComponentIds: ['locale:zh-cn'] }, + }); + assert.strictEqual(request.mode, 'legacy-compat'); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn']); + })) passed++; else failed++; + + if (test('keeps non-locale manifest selections incompatible with legacy languages', () => { + const invalidSelections = [ + { profileId: 'core' }, + { moduleIds: ['rules-core'] }, + { includeComponentIds: ['locale:zh-cn', 'skill:tdd-workflow'] }, + { includeComponentIds: ['locale:fr'] }, + { includeComponentIds: ['locale:zh-CN'] }, + { excludeComponentIds: ['skill:tdd-workflow'] }, + { config: { includeComponentIds: ['skill:tdd-workflow'] } }, + { target: 'cursor' }, + { target: 'antigravity' }, + ]; + for (const selection of invalidSelections) { + assert.throws(() => normalizeInstallRequest({ + target: 'claude', languages: ['typescript'], + includeComponentIds: ['locale:zh-cn'], ...selection, + }), /cannot be combined/, JSON.stringify(selection)); + } + })) passed++; else failed++; + + if (test('locale components without legacy languages retain manifest mode', () => { + for (const profileId of [null, 'core']) { + const request = normalizeInstallRequest({ + target: 'claude', profileId, includeComponentIds: ['locale:zh-cn'], + }); + assert.strictEqual(request.mode, 'manifest'); + assert.strictEqual(request.profileId, profileId); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn']); + } + })) passed++; else failed++; + if (test('rejects unsupported locale codes', () => { assert.throws( () => normalizeInstallRequest({ diff --git a/tests/scripts/auto-update.test.js b/tests/scripts/auto-update.test.js index 9533fd67b..98d678902 100644 --- a/tests/scripts/auto-update.test.js +++ b/tests/scripts/auto-update.test.js @@ -6,6 +6,9 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { execFileSync } = require('child_process'); +const { LOCALE_ALIAS_TO_COMPONENT_ID } = require('../../scripts/lib/install-manifests'); +const { normalizeInstallRequest, parseInstallArgs } = require('../../scripts/lib/install/request'); const { parseArgs, @@ -157,6 +160,76 @@ function runTests() { ]); })) passed += 1; else failed += 1; + if (test('replays every locale alias with legacy languages and hook consent', () => { + for (const target of ['claude', 'claude-project']) { + for (const [alias, componentId] of Object.entries(LOCALE_ALIAS_TO_COMPONENT_ID)) { + const original = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', '--target', target, + '--locale', alias, 'typescript', 'python', '--no-hooks', + ])); + const record = makeRecord({ + repoRoot: '/tmp/ecc', homeDir: '/tmp/home', projectRoot: '/tmp/project', + adapter: { id: target, target, kind: target === 'claude' ? 'home' : 'project' }, + request: { + profile: original.profileId, modules: original.moduleIds, + includeComponents: original.includeComponentIds, + excludeComponents: original.excludeComponentIds, + legacyLanguages: original.legacyLanguages, + legacyMode: true, hookConsent: original.hookConsent, + }, + resolution: { selectedModules: [], skippedModules: [] }, operations: [], + }); + const replay = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', ...buildInstallApplyArgs(record), + ])); + assert.deepStrictEqual(replay, original, `${target}: ${alias} -> ${componentId}`); + } + } + })) passed += 1; else failed += 1; + + for (const target of ['claude', 'claude-project']) { + if (test(`end-to-end: auto-update plans a recorded ${target} locale and language install`, () => { + const fixture = createTempDir('auto-update-locale-'); + const homeDir = path.join(fixture, 'home'); + const projectRoot = path.join(fixture, 'project'); + const repoRoot = path.resolve(__dirname, '..', '..'); + const env = { ...process.env, HOME: homeDir, USERPROFILE: homeDir, ECC_DRY_RUN: '0' }; + fs.mkdirSync(homeDir); + fs.mkdirSync(projectRoot); + try { + const installed = JSON.parse(execFileSync(process.execPath, [ + path.join(repoRoot, 'scripts', 'install-apply.js'), + '--target', target, '--locale', 'zh', 'typescript', '--no-hooks', '--json', + ], { cwd: projectRoot, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })); + assert.strictEqual(installed.result.applied, true); + const statePath = installed.result.installStatePath; + const stateBefore = fs.readFileSync(statePath, 'utf8'); + const state = JSON.parse(stateBefore); + assert.deepStrictEqual(state.request.includeComponents, ['locale:zh-cn']); + assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']); + + const updated = JSON.parse(execFileSync(process.execPath, [ + path.join(repoRoot, 'scripts', 'auto-update.js'), '--target', target, + '--repo-root', repoRoot, '--dry-run', '--json', + ], { cwd: projectRoot, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })); + assert.strictEqual(updated.summary.errorCount, 0); + assert.strictEqual(updated.summary.checkedCount, 1); + assert.strictEqual(updated.results[0].status, 'planned'); + const plan = updated.results[0].payload.plan; + assert.strictEqual(plan.mode, 'legacy-compat'); + assert.deepStrictEqual(plan.legacyLanguages, ['typescript']); + assert.deepStrictEqual(plan.includedComponentIds, ['locale:zh-cn']); + assert.ok(plan.selectedModuleIds.includes('docs-zh-cn')); + assert.ok(plan.selectedModuleIds.includes('framework-language')); + assert.ok(!plan.selectedModuleIds.includes('hooks-runtime')); + assert.strictEqual(plan.statePreview.request.hookConsent, 'declined'); + assert.strictEqual(fs.readFileSync(statePath, 'utf8'), stateBefore); + } finally { + cleanup(fixture); + } + })) passed += 1; else failed += 1; + } + if (test('buildInstallApplyArgs reconstructs manifest installs', () => { const record = { adapter: { target: 'cursor', kind: 'project' }, From da87114e057b0ff9b343578c54a12e16bea527bc Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:02:49 -0400 Subject: [PATCH 10/11] fix: honor global preview in every guided install entry Scoped adaptation from PR #3088. Keep the canonical atomic writer and dispatcher; exclude the broader provider/tool changes. Co-authored-by: Geronimo --- scripts/install-guided.js | 6 ++- .../install-guided-global-dry-run.test.js | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/scripts/install-guided-global-dry-run.test.js diff --git a/scripts/install-guided.js b/scripts/install-guided.js index 4fa27525d..08a1a15e6 100644 --- a/scripts/install-guided.js +++ b/scripts/install-guided.js @@ -106,7 +106,11 @@ function parseArgs(argv) { if (options.allHarnesses && options.harnesses.length > 0) { throw new Error('--all-harnesses and --harness are mutually exclusive.'); } - return options; + const dryRunEnv = process.env.ECC_DRY_RUN; + if (dryRunEnv !== undefined && dryRunEnv !== '0' && dryRunEnv !== '1') { + throw new Error('ECC_DRY_RUN must be "1" or "0" when set'); + } + return { ...options, dryRun: options.dryRun || dryRunEnv === '1' }; } function choicesText(values) { diff --git a/tests/scripts/install-guided-global-dry-run.test.js b/tests/scripts/install-guided-global-dry-run.test.js new file mode 100644 index 000000000..40e6ae8fa --- /dev/null +++ b/tests/scripts/install-guided-global-dry-run.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '../..'); + +for (const entry of ['install-guided.js', 'install-apply.js', 'ecc.js']) { + test(`global dry-run protects the actual ${entry} guided workflow`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-guided-global-preview-')); + try { + const home = path.join(root, 'home'); + const project = path.join(root, 'project'); + fs.mkdirSync(home); + fs.mkdirSync(project); + const args = entry === 'ecc.js' ? ['--dry-run', 'install', '--guided'] + : entry === 'install-apply.js' ? ['--guided'] : []; + const child = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', entry), + ...args, '--harness', 'kimi', '--profile', 'minimal', '--yes', '--json'], { + cwd: project, encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, HOME: home, USERPROFILE: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), ECC_DRY_RUN: '1' }, + }); + assert.equal(child.status, 0, child.stderr); + const payload = JSON.parse(child.stdout); + assert.equal(payload.dryRun, true); + assert.equal(payload.plan.request.dryRun, true); + assert.deepEqual(fs.readdirSync(home), []); + assert.deepEqual(fs.readdirSync(project), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('invalid global dry-run input is rejected before guided planning', () => { + const child = spawnSync(process.execPath, [path.join(repoRoot, 'scripts/install-guided.js'), + '--harness', 'kimi', '--yes', '--json'], { + encoding: 'utf8', env: { ...process.env, ECC_DRY_RUN: 'yes' }, + }); + assert.equal(child.status, 1); + assert.match(JSON.parse(child.stderr).error.message, /ECC_DRY_RUN must be "1" or "0"/); +}); From 3add1816ea2189d9c7375df05c16c85cd3ddc260 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:09:06 -0400 Subject: [PATCH 11/11] feat: offer an opt-in local Claude usage display Scoped adaptation of PR #2716: keep compact rendering as the default and require ECC_STATUSLINE_USAGE=1. Installer registration, managed aliases and the Codex background bar remain outside this slice. --- docs/STATUSLINE.md | 32 +++ scripts/hooks/ecc-statusline.js | 69 +++++- scripts/lib/statusline-render.js | 278 ++++++++++++++++++++++ tests/hooks/ecc-statusline-usage.test.js | 31 +++ tests/lib/statusline-render.test.js | 288 +++++++++++++++++++++++ 5 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 docs/STATUSLINE.md create mode 100644 scripts/lib/statusline-render.js create mode 100644 tests/hooks/ecc-statusline-usage.test.js create mode 100644 tests/lib/statusline-render.test.js diff --git a/docs/STATUSLINE.md b/docs/STATUSLINE.md new file mode 100644 index 000000000..86610904a --- /dev/null +++ b/docs/STATUSLINE.md @@ -0,0 +1,32 @@ +# Optional Claude usage display + +ECC's existing statusline can show rate-limit windows, prompt-cache usage, +session cost, and installed plugin information when explicitly enabled. It +reads Claude's statusline input and local ECC metrics; it makes no network +requests and does not register hooks or change shell aliases. + +To try it on macOS or Linux, adapt the existing statusline example in +`examples/statusline.json` and prefix its command with `ECC_STATUSLINE_USAGE=1`: + +```json +{ + "statusLine": { + "type": "command", + "command": "ECC_STATUSLINE_USAGE=1 /absolute/path/to/node /absolute/path/to/ecc/scripts/hooks/ecc-statusline.js" + } +} +``` + +Use the absolute Node executable that works on your machine, and quote paths +containing spaces. Keep any existing custom statusline unless you choose to +replace it. Windows users can set `ECC_STATUSLINE_USAGE=1` in their provider's +environment and use the same script command with quoted absolute paths. + +Removing the environment setting restores the existing compact output. Missing +rate-limit data produces no usage window. `ECC_BAR_GLYPHS=ascii` selects ASCII +symbols, and `ECC_STATUSLINE_COMPACT=1` requests the compact variant inside +the optional display. + +The rendering idea comes from PR #2716 and the MIT-licensed +[claude-code-usage-bar](https://github.com/leeguooooo/claude-code-usage-bar). +Automatic installation and a managed Codex terminal bar remain separate work. diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js index 413dbda49..11006a354 100644 --- a/scripts/hooks/ecc-statusline.js +++ b/scripts/hooks/ecc-statusline.js @@ -85,6 +85,68 @@ function readCurrentTask(sessionId) { } } +function getEccVersion(plugins) { + const ecc = plugins.find(p => p.name === 'ecc'); + if (ecc?.version) return ecc.version; + try { + return fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); + } catch { + return ''; + } +} + +/** + * Assemble statusline output lines from Claude Code stdin data. + * @param {object} data - Parsed statusLine stdin JSON + * @param {object|null} bridge - Session bridge metrics or null + * @param {string} task - Current in-progress task + * @returns {string[]} Non-empty output lines + */ +function buildUsageLines(data, bridge, task) { + const render = require('../lib/statusline-render'); + const remaining = data.context_window?.remaining_percentage; + const cost = data.cost || {}; + const dirname = path.basename(data.workspace?.current_dir || process.cwd()); + + if (process.env.ECC_STATUSLINE_COMPACT === '1') { + const segments = [`\x1b[2m${data.model?.display_name || 'Claude'}\x1b[0m`]; + if (task) segments.push(`\x1b[1;97m${task}\x1b[0m`); + segments.push(`\x1b[2m${dirname}\x1b[0m`); + return [segments.join(render.SEP) + buildContextBar(remaining)]; + } + + const usageLine = render.buildUsageLine( + data.rate_limits, + render.computeCacheStats(data.context_window?.current_usage) + ); + + const durationMs = bridge?.first_timestamp + ? Date.now() - new Date(bridge.first_timestamp).getTime() + : 0; + const sessionLine = render.buildSessionLine({ + model: data.model?.display_name, + effort: data.effort?.level, + fastMode: data.fast_mode === true, + ctxBar: buildContextBar(remaining), + ctxWindowSize: data.context_window?.context_window_size, + costUsd: cost.total_cost_usd ?? bridge?.total_cost_usd ?? 0, + linesAdded: cost.total_lines_added || 0, + linesRemoved: cost.total_lines_removed || 0, + durationMs, + task, + }); + + const plugins = render.readInstalledPlugins(); + const eccLine = render.buildEccLine({ + eccVersion: getEccVersion(plugins), + hooks: render.getHooksSummary(), + plugins, + dirname, + }); + + return [usageLine, sessionLine, eccLine].filter(Boolean); +} + function runStatusline() { let input = ''; const stdinTimeout = setTimeout(() => process.exit(0), 3000); @@ -119,6 +181,11 @@ function runStatusline() { // Current task const task = sessionId ? readCurrentTask(sessionId) : ''; + if (process.env.ECC_STATUSLINE_USAGE === '1') { + process.stdout.write(buildUsageLines(data, bridge, task).join('\n')); + return; + } + // Metrics from bridge let metricsStr = ''; if (bridge) { @@ -163,6 +230,6 @@ function runStatusline() { }); } -module.exports = { formatDuration, buildContextBar, readCurrentTask, MAX_STDIN }; +module.exports = { formatDuration, buildContextBar, readCurrentTask, buildUsageLines, MAX_STDIN }; if (require.main === module) runStatusline(); diff --git a/scripts/lib/statusline-render.js b/scripts/lib/statusline-render.js new file mode 100644 index 000000000..00abcde04 --- /dev/null +++ b/scripts/lib/statusline-render.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Shared rendering helpers for the ECC usage bar statusline. + * + * Inspired by leeguooooo/claude-code-usage-bar (MIT). ECC re-implementation + * with brand colors, hooks/plugin awareness, and no external dependencies. + * + * Colors use 256-color ANSI so macOS Terminal.app renders them correctly. + * Brand palette (from assets/ecc-icon.svg): amber #F59E0B ~ 214, + * terracotta #E07856 ~ 173. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { areHooksEnabled, getHookProfile, getDisabledHookIds } = require('./hook-flags'); + +const C = { + amber: '\x1b[38;5;214m', + terracotta: '\x1b[38;5;173m', + yellow: '\x1b[38;5;220m', + orange: '\x1b[38;5;208m', + red: '\x1b[1;38;5;196m', + green: '\x1b[38;5;114m', + dim: '\x1b[2m', + reset: '\x1b[0m', +}; + +const UNICODE_GLYPHS = { + full: '\u2588', empty: '\u2591', sep: '\u2502', bolt: '\u26a1', star: '\u2733', hex: '\u2b22', reset: '\u21bb', dot: '\u00b7', +}; +const ASCII_GLYPHS = { + full: '|', empty: '_', sep: '|', bolt: '*', star: '*', hex: '#', reset: '~', dot: '-', +}; + +/** + * Glyphs degrade to ASCII when the environment cannot be trusted to render + * block/box characters: tmux mangles them to underscores without a UTF-8 + * locale, and fonts such as Terminal.app's default lack \u2b22/\u2733. + * Force either set with ECC_BAR_GLYPHS=ascii|unicode. + */ +function glyphSet(env = process.env) { + const forced = String(env.ECC_BAR_GLYPHS || '').trim().toLowerCase(); + if (forced === 'ascii') return ASCII_GLYPHS; + if (forced === 'unicode') return UNICODE_GLYPHS; + const locale = `${env.LC_ALL || ''} ${env.LC_CTYPE || ''} ${env.LANG || ''}`.toLowerCase(); + return locale.includes('utf') ? UNICODE_GLYPHS : ASCII_GLYPHS; +} + +const G = glyphSet(); +const SEP = ` ${C.dim}${G.sep}${C.reset} `; + +/** + * Severity color for a usage percentage: brand color while healthy, + * warm ramp as the window fills up. + * @param {number} pct - 0..100 used + * @param {string} brandColor - color to use below 60% + */ +function pctColor(pct, brandColor = C.amber) { + if (pct >= 90) return C.red; + if (pct >= 80) return C.orange; + if (pct >= 60) return C.yellow; + return brandColor; +} + +/** + * Block bar for a percentage. Uses the same glyphs as the context bar. + * @param {number} pct - 0..100 used + * @param {number} width - bar width in cells + */ +function buildBar(pct, width = 8) { + const clamped = Math.max(0, Math.min(100, Math.round(pct))); + const filled = Math.round((clamped / 100) * width); + return G.full.repeat(filled) + G.empty.repeat(width - filled); +} + +/** + * Countdown until a unix-seconds timestamp: "42m", "1h42m", "3d". + * @param {number} epochSecs + * @returns {string} countdown or '' when invalid/past + */ +function formatCountdown(epochSecs) { + if (!epochSecs || typeof epochSecs !== 'number') return ''; + const secs = Math.floor(epochSecs - Date.now() / 1000); + if (secs <= 0) return ''; + const mins = Math.ceil(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) { + const rem = mins % 60; + return rem > 0 ? `${hours}h${rem}m` : `${hours}h`; + } + return `${Math.round(hours / 24)}d`; +} + +/** + * Wall-clock duration from milliseconds: "5s", "12m", "3h11m". + * @param {number} ms + */ +function formatMs(ms) { + if (!ms || ms < 0) return ''; + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + const rem = mins % 60; + return rem > 0 ? `${hours}h${rem}m` : `${hours}h`; +} + +/** + * Prompt-cache stats from context_window.current_usage. + * @returns {{hitPct: number}|null} null when there is no usage data yet + */ +function computeCacheStats(currentUsage) { + if (!currentUsage || typeof currentUsage !== 'object') return null; + const read = currentUsage.cache_read_input_tokens || 0; + const write = currentUsage.cache_creation_input_tokens || 0; + const fresh = currentUsage.input_tokens || 0; + const total = read + write + fresh; + if (total <= 0) return null; + return { hitPct: Math.round((read / total) * 100) }; +} + +function usageSegment(label, labelColor, window) { + const pct = Math.round(window?.used_percentage ?? -1); + if (pct < 0) return ''; + const color = pctColor(pct, labelColor); + const reset = formatCountdown(window.resets_at); + const resetStr = reset ? ` ${C.dim}${G.reset}${reset}${C.reset}` : ''; + return `${labelColor}${label}${C.reset} ${color}${buildBar(pct)} ${pct}%${C.reset}${resetStr}`; +} + +/** + * Line 1: rate-limit windows + prompt cache. + * Returns '' when no rate limit data is available (e.g. API-key billing). + */ +function buildUsageLine(rateLimits, cacheStats) { + const parts = []; + const fiveHour = usageSegment('5h', C.amber, rateLimits?.five_hour); + const sevenDay = usageSegment('7d', C.terracotta, rateLimits?.seven_day); + if (fiveHour) parts.push(fiveHour); + if (sevenDay) parts.push(sevenDay); + if (cacheStats && parts.length > 0) { + const cacheColor = cacheStats.hitPct >= 50 ? C.green : C.dim; + parts.push(`${C.dim}cache${C.reset} ${cacheColor}${cacheStats.hitPct}%${C.reset}`); + } + if (parts.length === 0) return ''; + return `${C.amber}${G.bolt}${C.reset} ${parts.join(SEP)}`; +} + +/** + * Line 2: model, mode, context bar, cost, diff, duration, current task. + */ +function buildSessionLine(info) { + const parts = []; + let model = `${C.amber}${G.star} ${info.model || 'Claude'}${C.reset}`; + const badges = []; + if (info.effort && info.effort !== 'high') badges.push(info.effort); + if (info.fastMode) badges.push('fast'); + if (badges.length > 0) model += ` ${C.dim}${badges.join(' ')}${C.reset}`; + parts.push(model); + + if (info.ctxBar) { + const size = info.ctxWindowSize >= 1000000 + ? '1M' + : info.ctxWindowSize ? `${Math.round(info.ctxWindowSize / 1000)}K` : ''; + const sizeStr = size ? ` ${C.dim}${size}${C.reset}` : ''; + parts.push(`${C.dim}ctx${C.reset}${info.ctxBar}${sizeStr}`); + } + + const metrics = []; + if (info.costUsd > 0) metrics.push(`$${info.costUsd.toFixed(2)}`); + if (info.linesAdded > 0 || info.linesRemoved > 0) { + metrics.push(`+${info.linesAdded || 0}/-${info.linesRemoved || 0}`); + } + const dur = formatMs(info.durationMs); + if (dur) metrics.push(dur); + if (metrics.length > 0) parts.push(`\x1b[38;5;117m${metrics.join(' ')}${C.reset}`); + + if (info.task) parts.push(`\x1b[1;97m${info.task}${C.reset}`); + return parts.join(SEP); +} + +/** + * Line 3: ECC version, hooks status, enabled plugins with versions, directory. + */ +function buildEccLine(info) { + const parts = []; + const version = info.eccVersion ? ` ${info.eccVersion}` : ''; + parts.push(`${C.terracotta}${G.hex} ECC${version}${C.reset}`); + + if (info.hooks) { + const h = info.hooks; + let hookStr; + if (!h.enabled) hookStr = `${C.red}hooks off${C.reset}`; + else { + const off = h.disabledCount > 0 ? ` ${C.dim}(${h.disabledCount} off)${C.reset}` : ''; + hookStr = `${C.green}hooks ${h.profile}${C.reset}${off}`; + } + parts.push(hookStr); + } + + if (Array.isArray(info.plugins) && info.plugins.length > 0) { + const shown = info.plugins.slice(0, 4) + .map(p => `${p.name}${p.version ? ` ${C.dim}${p.version}${C.reset}` : ''}`); + const more = info.plugins.length > 4 ? ` ${C.dim}+${info.plugins.length - 4}${C.reset}` : ''; + parts.push(`${C.dim}plugins${C.reset} ${shown.join(` ${G.dot} `)}${more}`); + } + + if (info.dirname) parts.push(`${C.dim}${info.dirname}${C.reset}`); + return parts.join(SEP); +} + +/** + * Enabled plugins with versions, ecc first. + * Reads installed_plugins.json (v2) and settings.json enabledPlugins. + * @param {string} [claudeDir] - defaults to CLAUDE_CONFIG_DIR or ~/.claude + * @returns {Array<{name: string, version: string}>} + */ +function readInstalledPlugins(claudeDir) { + try { + const dir = claudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const installed = JSON.parse( + fs.readFileSync(path.join(dir, 'plugins', 'installed_plugins.json'), 'utf8') + ); + let enabled = null; + try { + const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); + if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') { + enabled = settings.enabledPlugins; + } + } catch { /* no settings \u2014 show all installed */ } + + const plugins = []; + for (const [id, entries] of Object.entries(installed?.plugins || {})) { + if (enabled && enabled[id] === false) continue; + const entry = Array.isArray(entries) ? entries[0] : entries; + plugins.push({ name: id.split('@')[0], version: entry?.version || '' }); + } + plugins.sort((a, b) => (a.name === 'ecc' ? -1 : b.name === 'ecc' ? 1 : a.name.localeCompare(b.name))); + return plugins; + } catch { + return []; + } +} + +/** + * Hooks status summary from ECC hook flags (env + managed config). + */ +function getHooksSummary(env = process.env) { + return { + enabled: areHooksEnabled(env), + profile: getHookProfile(env), + disabledCount: getDisabledHookIds(env).size, + }; +} + +module.exports = { + C, + G, + SEP, + UNICODE_GLYPHS, + ASCII_GLYPHS, + glyphSet, + pctColor, + buildBar, + formatCountdown, + formatMs, + computeCacheStats, + buildUsageLine, + buildSessionLine, + buildEccLine, + readInstalledPlugins, + getHooksSummary, +}; diff --git a/tests/hooks/ecc-statusline-usage.test.js b/tests/hooks/ecc-statusline-usage.test.js new file mode 100644 index 000000000..5d045233c --- /dev/null +++ b/tests/hooks/ecc-statusline-usage.test.js @@ -0,0 +1,31 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +test('actual statusline usage display is opt-in and leaves provider settings alone', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-statusline-usage-')); + try { + const payload = { model: { display_name: 'Claude fixture' }, context_window: { remaining_percentage: 80 }, + rate_limits: { five_hour: { used_percentage: 20, resets_at: Math.floor(Date.now() / 1000) + 3600 } } }; + const render = value => { + const child = spawnSync(process.execPath, [path.resolve(__dirname, '../../scripts/hooks/ecc-statusline.js')], { + input: JSON.stringify(payload), encoding: 'utf8', timeout: 5000, + env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: home, + ECC_STATUSLINE_USAGE: value, ECC_BAR_GLYPHS: 'ascii' }, + }); + assert.equal(child.status, 0, child.stderr); + return child.stdout; + }; + assert.equal(render('0').split('\n').length, 1); + assert.ok(render('1').split('\n').length >= 2); + assert.match(render('1'), /5h/); + assert.deepEqual(fs.readdirSync(home), []); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/lib/statusline-render.test.js b/tests/lib/statusline-render.test.js new file mode 100644 index 000000000..a46fd96f4 --- /dev/null +++ b/tests/lib/statusline-render.test.js @@ -0,0 +1,288 @@ +/** + * Tests for scripts/lib/statusline-render.js + * + * Run with: node tests/lib/statusline-render.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + C, + G, + pctColor, + buildBar, + formatCountdown, + formatMs, + computeCacheStats, + buildUsageLine, + buildSessionLine, + buildEccLine, + readInstalledPlugins, + getHooksSummary, +} = require('../../scripts/lib/statusline-render'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing statusline-render.js ===\n'); + + let passed = 0; + let failed = 0; + const t = (name, fn) => (test(name, fn) ? passed++ : failed++); + + console.log('buildBar:'); + t('0% renders all empty blocks', () => { + assert.strictEqual(buildBar(0, 8), G.empty.repeat(8)); + }); + t('100% renders all full blocks', () => { + assert.strictEqual(buildBar(100, 8), G.full.repeat(8)); + }); + t('50% renders half full', () => { + assert.strictEqual(buildBar(50, 8), G.full.repeat(4) + G.empty.repeat(4)); + }); + t('clamps values beyond 100', () => { + assert.strictEqual(buildBar(250, 4), G.full.repeat(4)); + }); + t('clamps negative values', () => { + assert.strictEqual(buildBar(-10, 4), G.empty.repeat(4)); + }); + + console.log('\nglyphSet:'); + t('non-UTF-8 locale falls back to ASCII glyphs', () => { + const { glyphSet, ASCII_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'C' }), ASCII_GLYPHS); + assert.strictEqual(glyphSet({}), ASCII_GLYPHS); + }); + t('UTF-8 locale uses unicode glyphs', () => { + const { glyphSet, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8' }), UNICODE_GLYPHS); + assert.strictEqual(glyphSet({ LC_ALL: 'C.utf8' }), UNICODE_GLYPHS); + }); + t('ECC_BAR_GLYPHS forces either set', () => { + const { glyphSet, ASCII_GLYPHS, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8', ECC_BAR_GLYPHS: 'ascii' }), ASCII_GLYPHS); + assert.strictEqual(glyphSet({ LANG: 'C', ECC_BAR_GLYPHS: 'unicode' }), UNICODE_GLYPHS); + }); + + console.log('\npctColor:'); + t('below 60 uses brand color', () => { + assert.strictEqual(pctColor(59, C.amber), C.amber); + }); + t('60-79 is yellow', () => { + assert.strictEqual(pctColor(60), C.yellow); + }); + t('80-89 is orange', () => { + assert.strictEqual(pctColor(80), C.orange); + }); + t('90+ is red', () => { + assert.strictEqual(pctColor(95), C.red); + }); + + console.log('\nformatCountdown:'); + t('past timestamp returns empty', () => { + assert.strictEqual(formatCountdown(Date.now() / 1000 - 60), ''); + }); + t('invalid input returns empty', () => { + assert.strictEqual(formatCountdown(null), ''); + assert.strictEqual(formatCountdown('soon'), ''); + }); + t('minutes-scale countdown ends with m', () => { + const result = formatCountdown(Date.now() / 1000 + 42 * 60); + assert.ok(/^\d+m$/.test(result), `got: ${result}`); + }); + t('hours-scale countdown contains h', () => { + const result = formatCountdown(Date.now() / 1000 + 90 * 60); + assert.ok(result.includes('h'), `got: ${result}`); + }); + t('days-scale countdown ends with d', () => { + const result = formatCountdown(Date.now() / 1000 + 3 * 24 * 3600 + 60); + assert.ok(/^\d+d$/.test(result), `got: ${result}`); + }); + + console.log('\nformatMs:'); + t('zero returns empty', () => { + assert.strictEqual(formatMs(0), ''); + }); + t('seconds scale', () => { + assert.strictEqual(formatMs(30 * 1000), '30s'); + }); + t('minutes scale', () => { + assert.strictEqual(formatMs(5 * 60 * 1000), '5m'); + }); + t('hours scale with remainder', () => { + assert.strictEqual(formatMs((3 * 60 + 11) * 60 * 1000), '3h11m'); + }); + + console.log('\ncomputeCacheStats:'); + t('null usage returns null', () => { + assert.strictEqual(computeCacheStats(null), null); + }); + t('zero tokens returns null', () => { + assert.strictEqual(computeCacheStats({ input_tokens: 0 }), null); + }); + t('computes hit percentage from cache reads', () => { + const stats = computeCacheStats({ + input_tokens: 10, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 80, + }); + assert.strictEqual(stats.hitPct, 80); + }); + + console.log('\nbuildUsageLine:'); + t('no rate limits returns empty string', () => { + assert.strictEqual(buildUsageLine(undefined, { hitPct: 90 }), ''); + assert.strictEqual(buildUsageLine({}, null), ''); + }); + t('renders both windows with percentages', () => { + const line = buildUsageLine({ + five_hour: { used_percentage: 71, resets_at: Date.now() / 1000 + 3600 }, + seven_day: { used_percentage: 23, resets_at: Date.now() / 1000 + 86400 * 3 }, + }, null); + assert.ok(line.includes('5h'), 'expected 5h label'); + assert.ok(line.includes('7d'), 'expected 7d label'); + assert.ok(line.includes('71%'), 'expected 5h percentage'); + assert.ok(line.includes('23%'), 'expected 7d percentage'); + assert.ok(line.includes(G.reset), 'expected reset countdown'); + }); + t('renders a single window when only one exists', () => { + const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, null); + assert.ok(line.includes('5h'), 'expected 5h label'); + assert.ok(!line.includes('7d'), 'no 7d label expected'); + }); + t('appends cache hit rate when bars exist', () => { + const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, { hitPct: 98 }); + assert.ok(line.includes('cache'), 'expected cache segment'); + assert.ok(line.includes('98%'), 'expected cache percentage'); + }); + + console.log('\nbuildSessionLine:'); + t('shows model with effort badge', () => { + const line = buildSessionLine({ model: 'Opus 5', effort: 'xhigh' }); + assert.ok(line.includes('Opus 5'), 'expected model name'); + assert.ok(line.includes('xhigh'), 'expected effort badge'); + }); + t('default effort high shows no badge', () => { + const line = buildSessionLine({ model: 'Opus 5', effort: 'high' }); + assert.ok(!line.includes('high'), 'no badge expected for default effort'); + }); + t('shows cost, diff, and task', () => { + const line = buildSessionLine({ + model: 'Opus 5', + costUsd: 1.234, + linesAdded: 11, + linesRemoved: 4, + durationMs: 60 * 1000, + task: 'Fixing auth bug', + }); + assert.ok(line.includes('$1.23'), 'expected cost'); + assert.ok(line.includes('+11/-4'), 'expected diff stats'); + assert.ok(line.includes('1m'), 'expected duration'); + assert.ok(line.includes('Fixing auth bug'), 'expected task'); + }); + t('shows 1M context window size', () => { + const line = buildSessionLine({ model: 'Opus 5', ctxBar: ' bar 10%', ctxWindowSize: 1000000 }); + assert.ok(line.includes('1M'), 'expected 1M badge'); + }); + + console.log('\nbuildEccLine:'); + t('shows version, hooks profile, plugins, dirname', () => { + const line = buildEccLine({ + eccVersion: '2.2.0', + hooks: { enabled: true, profile: 'standard', disabledCount: 0 }, + plugins: [{ name: 'ecc', version: '2.2.0' }, { name: 'swift-lsp', version: '1.0.0' }], + dirname: 'myproject', + }); + assert.ok(line.includes('ECC 2.2.0'), 'expected ECC version'); + assert.ok(line.includes('hooks standard'), 'expected hooks profile'); + assert.ok(line.includes('swift-lsp'), 'expected plugin name'); + assert.ok(line.includes('1.0.0'), 'expected plugin version'); + assert.ok(line.includes('myproject'), 'expected dirname'); + }); + t('disabled hooks render hooks off', () => { + const line = buildEccLine({ hooks: { enabled: false, profile: 'standard', disabledCount: 0 } }); + assert.ok(line.includes('hooks off'), 'expected hooks off'); + }); + t('disabled hook count is shown', () => { + const line = buildEccLine({ hooks: { enabled: true, profile: 'minimal', disabledCount: 2 } }); + assert.ok(line.includes('(2 off)'), 'expected disabled count'); + }); + t('caps plugin list at 4 with overflow count', () => { + const plugins = ['a', 'b', 'c', 'd', 'e', 'f'].map(name => ({ name, version: '1.0.0' })); + const line = buildEccLine({ plugins }); + assert.ok(line.includes('+2'), 'expected +2 overflow'); + }); + + console.log('\nreadInstalledPlugins:'); + t('reads v2 plugin file and filters disabled plugins, ecc first', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); + try { + fs.mkdirSync(path.join(tmpDir, 'plugins'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'plugins', 'installed_plugins.json'), JSON.stringify({ + version: 2, + plugins: { + 'zeta@market': [{ version: '0.5.0', scope: 'user' }], + 'ecc@ecc': [{ version: '2.2.0', scope: 'user' }], + 'off-plugin@market': [{ version: '9.9.9', scope: 'user' }], + }, + })); + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify({ + enabledPlugins: { 'zeta@market': true, 'ecc@ecc': true, 'off-plugin@market': false }, + })); + + const plugins = readInstalledPlugins(tmpDir); + assert.strictEqual(plugins.length, 2); + assert.strictEqual(plugins[0].name, 'ecc'); + assert.strictEqual(plugins[0].version, '2.2.0'); + assert.strictEqual(plugins[1].name, 'zeta'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + t('missing plugin file returns empty array', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); + try { + assert.deepStrictEqual(readInstalledPlugins(tmpDir), []); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + console.log('\ngetHooksSummary:'); + t('defaults to enabled standard profile', () => { + const summary = getHooksSummary({}); + assert.strictEqual(summary.enabled, true); + assert.strictEqual(summary.profile, 'standard'); + assert.strictEqual(summary.disabledCount, 0); + }); + t('respects ECC_HOOKS_ENABLED=false', () => { + assert.strictEqual(getHooksSummary({ ECC_HOOKS_ENABLED: 'false' }).enabled, false); + }); + t('reports profile and disabled hook count', () => { + const summary = getHooksSummary({ + ECC_HOOK_PROFILE: 'minimal', + ECC_DISABLED_HOOKS: 'a,b', + }); + assert.strictEqual(summary.profile, 'minimal'); + assert.strictEqual(summary.disabledCount, 2); + }); + + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0);