mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
Merge branch 'steward/install-20261011' of /Users/hira/Documents/ECC/ecc-review-install-20261011 into integration/ecc-043-20261011
This commit is contained in:
17 files changed
+1732
-32
No files matched your search
@@ -0,0 +1,49 @@
|
||||
#!/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');
|
||||
|
||||
function portFromEnv(name, fallback) {
|
||||
const value = process.env[name] === undefined ? fallback : process.env[name];
|
||||
const port = Number(value);
|
||||
if (!/^\d+$/.test(String(value)) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`${name} must be an integer port between 1 and 65535`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
const listenPort = portFromEnv('ECC_RELAY_LISTEN', 1456);
|
||||
const targetPort = portFromEnv('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);
|
||||
client.on('close', drop);
|
||||
upstream.on('close', drop);
|
||||
client.setTimeout(30000, drop);
|
||||
upstream.setTimeout(30000, drop);
|
||||
}).listen(listenPort, '0.0.0.0', () => {
|
||||
console.log(`codex-auth-relay: 0.0.0.0:${listenPort} -> 127.0.0.1:${targetPort}`);
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -44,10 +44,138 @@ function hasSuggestionEvidence(candidate) {
|
||||
const MIN_ANCHOR_TERMS = 1;
|
||||
// v4: an explicit empty proposal (decline) is honored; the tier-2 fallback no
|
||||
// longer overrides declines at the launch/selection call sites.
|
||||
// v5: implicit admission requires a name or trigger anchor term.
|
||||
const ROUTING_POLICY_VERSION = 5;
|
||||
// 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.
|
||||
// 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.
|
||||
// v11: a contrastive current-task instruction ends the preceding example scope.
|
||||
// v12: preserve current-main evidence floors and anchor checks.
|
||||
const ROUTING_POLICY_VERSION = 12;
|
||||
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|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.
|
||||
*/
|
||||
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.
|
||||
* @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))];
|
||||
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.
|
||||
* @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, 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);
|
||||
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.
|
||||
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 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 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);
|
||||
// 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('?')));
|
||||
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';
|
||||
} else if (!instruction) {
|
||||
if (citation === 'none') citation = 'indirect';
|
||||
} else if (mention.exact && candidate.exact
|
||||
&& DIRECTIVE_NAME_PREFIX.test(scopes.at(-1))) {
|
||||
citation = 'directive';
|
||||
}
|
||||
}
|
||||
}
|
||||
return citation;
|
||||
}
|
||||
|
||||
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}`);
|
||||
@@ -196,7 +324,13 @@ function routingEntries({ repoRoot = DEFAULT_REPO_ROOT, profileId = 'lean@1', ta
|
||||
routingPolicyVersion: ROUTING_POLICY_VERSION, triggersDigest: digestObject(triggers), entries };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -229,28 +363,11 @@ 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 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) {
|
||||
if (exactAnchors.length === 1) {
|
||||
@@ -259,7 +376,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
|
||||
&& top.anchorTerms.length >= MIN_ANCHOR_TERMS
|
||||
&& (!second || top.bm25 >= AUTO_ADMIT_MARGIN * (second.bm25 || 0))) {
|
||||
autoSelection = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length, exact: false };
|
||||
@@ -271,7 +388,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 (meetsFallbackBar(top)
|
||||
if (citations.get(top.id) !== 'indirect' && meetsFallbackBar(top)
|
||||
&& top.anchorTerms.length >= MIN_ANCHOR_TERMS
|
||||
&& (!second || top.bm25 >= FALLBACK_MARGIN * (second.bm25 || 0))) {
|
||||
fallback = { id: top.id, bm25: top.bm25, matchedTerms: top.matchedTerms.length };
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -416,6 +416,35 @@ 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 &
|
||||
relay_pid=$!
|
||||
codex login
|
||||
kill "$relay_pid"
|
||||
```
|
||||
|
||||
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
|
||||
deletes that credential copy. Stop the relay after the login attempt. Only one container can hold host port 1455 at a time
|
||||
because the OAuth redirect targets a fixed port.
|
||||
|
||||
The host publish is restricted to loopback. Inside the container, the relay
|
||||
listens on all interfaces so Docker can forward to it; other containers on the
|
||||
same bridge network can reach it while it runs. Use a dedicated network without
|
||||
untrusted peers for this login, and remove the exact named container after use.
|
||||
Idle callback connections close after 30 seconds.
|
||||
|
||||
Run the same focused suite natively on the host:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const net = require('node:net');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const test = require('node:test');
|
||||
|
||||
const script = path.resolve(__dirname, '../../docker/plugin-setup/codex-auth-relay.js');
|
||||
|
||||
async function availablePort() {
|
||||
const server = net.createServer();
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const port = server.address().port;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
test('actual relay forwards callback bytes and closes its upstream when the client leaves', { timeout: 10000 }, async t => {
|
||||
const upstreamSockets = new Set();
|
||||
const upstream = net.createServer(socket => {
|
||||
upstreamSockets.add(socket);
|
||||
socket.on('close', () => upstreamSockets.delete(socket));
|
||||
socket.on('data', data => socket.write(data));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const relayPort = await availablePort();
|
||||
const child = spawn(process.execPath, [script], { env: { ...process.env,
|
||||
ECC_RELAY_LISTEN: String(relayPort), ECC_RELAY_TARGET: String(upstream.address().port) } });
|
||||
t.after(async () => {
|
||||
child.kill();
|
||||
for (const socket of upstreamSockets) socket.destroy();
|
||||
await new Promise(resolve => upstream.close(resolve));
|
||||
});
|
||||
const startup = await Promise.race([once(child.stdout, 'data'), once(child, 'exit')]);
|
||||
assert.ok(Buffer.isBuffer(startup[0]), 'Relay must start before exiting');
|
||||
const client = net.connect(relayPort, '127.0.0.1');
|
||||
t.after(() => client.destroy());
|
||||
await once(client, 'connect');
|
||||
client.write('GET /auth/callback?state=fixture&code=fixture HTTP/1.1\r\n\r\n');
|
||||
const [bytes] = await once(client, 'data');
|
||||
assert.equal(bytes.toString(), 'GET /auth/callback?state=fixture&code=fixture HTTP/1.1\r\n\r\n');
|
||||
const socket = [...upstreamSockets][0];
|
||||
const closed = once(socket, 'close');
|
||||
client.destroy();
|
||||
await closed;
|
||||
assert.equal(child.exitCode, null);
|
||||
});
|
||||
|
||||
for (const value of ['0', '-1', '65536', '1.5', 'not-a-port']) {
|
||||
test(`rejects invalid relay port ${value} before listening`, { timeout: 5000 }, async () => {
|
||||
const child = spawn(process.execPath, [script], { env: { ...process.env, ECC_RELAY_LISTEN: value } });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||
const [code] = await once(child, 'exit');
|
||||
assert.equal(code, 1);
|
||||
assert.match(stderr, /ECC_RELAY_LISTEN must be an integer port/);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -155,3 +155,183 @@ 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.', '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?',
|
||||
'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.',
|
||||
'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.',
|
||||
'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.',
|
||||
"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: [] },
|
||||
execute(command, args, options) {
|
||||
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']);
|
||||
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');
|
||||
}));
|
||||
}
|
||||
|
||||
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'],
|
||||
["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'],
|
||||
['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'],
|
||||
['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'],
|
||||
['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 = [];
|
||||
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,
|
||||
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']);
|
||||
}));
|
||||
|
||||
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.";
|
||||
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);
|
||||
});
|
||||
@@ -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, []);
|
||||
@@ -344,3 +358,372 @@ test('suggestion evidence requires an exact name or the absolute fallback floor'
|
||||
assert.equal(hasSuggestionEvidence(candidate({ exact: true, bm25: 1, matchedTerms: ['feature'] })), true);
|
||||
assert.equal(hasSuggestionEvidence(null), false);
|
||||
});
|
||||
|
||||
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.',
|
||||
'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 => {
|
||||
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']);
|
||||
}
|
||||
}));
|
||||
|
||||
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?',
|
||||
'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.',
|
||||
'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?',
|
||||
'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?',
|
||||
]) {
|
||||
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.',
|
||||
'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 [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 ['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.';
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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.',
|
||||
'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.',
|
||||
]) {
|
||||
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'],
|
||||
[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 });
|
||||
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.',
|
||||
'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.',
|
||||
`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.',
|
||||
'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.",
|
||||
'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 });
|
||||
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. 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.",
|
||||
'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 });
|
||||
assert.deepEqual(result.loadedIds, []);
|
||||
assert.equal(result.fallback, null);
|
||||
}));
|
||||
}
|
||||
|
||||
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.',
|
||||
'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);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
Reference in new issue
Block a user