cli-five 0.2.15 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -21
- package/package.json +1 -1
- package/src/addons/codegraph-command.mjs +47 -0
- package/src/addons/codegraph.mjs +124 -0
- package/src/addons/jev.mjs +98 -0
- package/src/addons/registry.mjs +126 -0
- package/src/cli.mjs +30 -5
- package/src/commands/add.mjs +55 -0
- package/src/commands/init.mjs +105 -41
- package/src/commands/list-addons.mjs +49 -0
- package/src/steps/interview.mjs +59 -0
- package/src/steps/platform.mjs +46 -3
- package/src/steps/scaffold.mjs +11 -46
- package/src/util/merge.mjs +170 -0
- package/src/util/project.mjs +140 -0
- package/templates/AGENTS.md.tmpl +0 -1
- package/templates/opencode/plugin/jev-tier-router/index.js +187 -0
- package/templates/opencode/plugin/jev-tier-router/package.json +9 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const JSON_EXTENSIONS = new Set(['.json']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Surgically merge a named block into an existing file **without touching the
|
|
8
|
+
* rest of it**. Unlike init's blunt overwrite gate (which replaces whole files),
|
|
9
|
+
* `mergeBlock` is designed for `cli-five add` against repos that are already
|
|
10
|
+
* scaffolded.
|
|
11
|
+
*
|
|
12
|
+
* Two strategies, chosen by file extension:
|
|
13
|
+
*
|
|
14
|
+
* JSON (.json)
|
|
15
|
+
* `content` (a plain object or a JSON string) is deep-merged into the file.
|
|
16
|
+
* Existing keys are preserved; overlapping scalar/array keys are replaced.
|
|
17
|
+
* Passing `fenceKey` nests the patch under that top-level key instead of
|
|
18
|
+
* merging at the root (e.g. `{ fenceKey: 'mcp' }` for `opencode.json`).
|
|
19
|
+
*
|
|
20
|
+
* Markdown / other text
|
|
21
|
+
* `content` is wrapped in HTML-comment fences derived from `markerFence`:
|
|
22
|
+
* <!-- NAME_START -->
|
|
23
|
+
* ...content...
|
|
24
|
+
* <!-- NAME_END -->
|
|
25
|
+
* If the fences already exist the body between them is replaced in place;
|
|
26
|
+
* otherwise the block is appended. Re-running is idempotent.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} filePath Absolute path to the target file.
|
|
29
|
+
* @param {string|{name?:string,start?:string,end?:string}} markerFence
|
|
30
|
+
* Block name (e.g. "codegraph"), or explicit `{ start, end }` markers.
|
|
31
|
+
* @param {string|object} content Markdown body, or object / JSON string.
|
|
32
|
+
* @param {object} [options]
|
|
33
|
+
* @param {boolean} [options.dryRun] Compute but do not write.
|
|
34
|
+
* @param {string|null} [options.fenceKey] JSON only — nest the merge under this key.
|
|
35
|
+
* @param {boolean} [options.track] JSON only — record the block name under `$cliFive`.
|
|
36
|
+
* @param {string} [options.metaKey] JSON only — metadata key (default `$cliFive`).
|
|
37
|
+
* @returns {{path:string, block:string, action:'created'|'updated'|'unchanged', dryRun:boolean}}
|
|
38
|
+
*/
|
|
39
|
+
export function mergeBlock(filePath, markerFence, content, options = {}) {
|
|
40
|
+
const { dryRun = false, fenceKey = null, track = false, metaKey = '$cliFive' } = options;
|
|
41
|
+
const block = fenceName(markerFence);
|
|
42
|
+
|
|
43
|
+
const ext = extname(filePath).toLowerCase();
|
|
44
|
+
const result = JSON_EXTENSIONS.has(ext)
|
|
45
|
+
? mergeJson(filePath, block, content, { fenceKey, track, metaKey })
|
|
46
|
+
: mergeText(filePath, markerFence, content);
|
|
47
|
+
|
|
48
|
+
if (!dryRun && (result.action === 'created' || result.action === 'updated')) {
|
|
49
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
50
|
+
writeFileSync(filePath, result.contents);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { path: filePath, block, action: result.action, dryRun };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Markdown / text ───────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function mergeText(filePath, markerFence, content) {
|
|
59
|
+
const { start, end } = fenceMarkers(markerFence);
|
|
60
|
+
const body = String(content ?? '').replace(/\s+$/, '');
|
|
61
|
+
const core = `${start}\n${body}\n${end}`;
|
|
62
|
+
|
|
63
|
+
if (!existsSync(filePath)) {
|
|
64
|
+
return { contents: `${core}\n`, action: 'created' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const existing = readFileSync(filePath, 'utf8');
|
|
68
|
+
if (existing.trim() === '') {
|
|
69
|
+
return { contents: `${core}\n`, action: 'created' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`);
|
|
73
|
+
if (pattern.test(existing)) {
|
|
74
|
+
const next = existing.replace(pattern, core);
|
|
75
|
+
return { contents: next, action: next === existing ? 'unchanged' : 'updated' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const next = `${existing.replace(/\s+$/, '')}\n\n${core}\n`;
|
|
79
|
+
return { contents: next, action: 'updated' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fenceMarkers(markerFence) {
|
|
83
|
+
if (isPlainObject(markerFence) && markerFence.start && markerFence.end) {
|
|
84
|
+
return { start: markerFence.start, end: markerFence.end };
|
|
85
|
+
}
|
|
86
|
+
const name = fenceName(markerFence).toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
87
|
+
return { start: `<!-- ${name}_START -->`, end: `<!-- ${name}_END -->` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── JSON ──────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function mergeJson(filePath, block, content, { fenceKey, track, metaKey }) {
|
|
93
|
+
let existing = {};
|
|
94
|
+
if (existsSync(filePath)) {
|
|
95
|
+
const raw = readFileSync(filePath, 'utf8').trim();
|
|
96
|
+
if (raw) {
|
|
97
|
+
try {
|
|
98
|
+
existing = JSON.parse(raw);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
throw new Error(`mergeBlock: ${filePath} is not valid JSON: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isPlainObject(existing)) {
|
|
106
|
+
throw new Error(`mergeBlock: ${filePath} must contain a JSON object at the root`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let patch = content;
|
|
110
|
+
if (typeof patch === 'string') {
|
|
111
|
+
try {
|
|
112
|
+
patch = JSON.parse(patch);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new Error(`mergeBlock: content for ${filePath} is not valid JSON: ${err.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!isPlainObject(patch)) {
|
|
118
|
+
throw new Error(`mergeBlock: content for ${filePath} must be a JSON object`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const before = JSON.stringify(existing);
|
|
122
|
+
|
|
123
|
+
const target = fenceKey
|
|
124
|
+
? (isPlainObject(existing[fenceKey]) ? existing[fenceKey] : (existing[fenceKey] = {}))
|
|
125
|
+
: existing;
|
|
126
|
+
deepMerge(target, patch);
|
|
127
|
+
|
|
128
|
+
if (track) {
|
|
129
|
+
const meta = isPlainObject(existing[metaKey]) ? existing[metaKey] : (existing[metaKey] = {});
|
|
130
|
+
const blocks = Array.isArray(meta.blocks) ? meta.blocks : (meta.blocks = []);
|
|
131
|
+
if (!blocks.includes(block)) blocks.push(block);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const contents = `${JSON.stringify(existing, null, 2)}\n`;
|
|
135
|
+
const action = before === JSON.stringify(existing) && existsSync(filePath) ? 'unchanged' : (existsSync(filePath) ? 'updated' : 'created');
|
|
136
|
+
return { contents, action };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function deepMerge(target, patch) {
|
|
140
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
141
|
+
if (isPlainObject(value) && isPlainObject(target[key])) {
|
|
142
|
+
deepMerge(target[key], value);
|
|
143
|
+
} else if (isPlainObject(value)) {
|
|
144
|
+
target[key] = deepMerge({}, value);
|
|
145
|
+
} else if (Array.isArray(value)) {
|
|
146
|
+
target[key] = [...value];
|
|
147
|
+
} else {
|
|
148
|
+
target[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return target;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function fenceName(markerFence) {
|
|
157
|
+
if (typeof markerFence === 'string' && markerFence.trim()) return markerFence.trim();
|
|
158
|
+
if (isPlainObject(markerFence) && typeof markerFence.name === 'string' && markerFence.name.trim()) {
|
|
159
|
+
return markerFence.name.trim();
|
|
160
|
+
}
|
|
161
|
+
throw new Error('mergeBlock: markerFence must be a non-empty string or { name }');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isPlainObject(value) {
|
|
165
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function escapeRegExp(value) {
|
|
169
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
170
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// README variants checked in order. First match wins.
|
|
5
|
+
const README_CANDIDATES = [
|
|
6
|
+
'README.md',
|
|
7
|
+
'readme.md',
|
|
8
|
+
'Readme.md',
|
|
9
|
+
'README.MD',
|
|
10
|
+
'README.markdown',
|
|
11
|
+
'README.txt',
|
|
12
|
+
'README',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort auto-extraction of a project name and one-liner from the
|
|
17
|
+
* workspace itself (package.json and/or README), used by the minimal init
|
|
18
|
+
* interview so it only has to ask when the answer is genuinely missing or
|
|
19
|
+
* ambiguous.
|
|
20
|
+
*
|
|
21
|
+
* Returns:
|
|
22
|
+
* {
|
|
23
|
+
* name: { value, ambiguous, sources: [{ source, value }] },
|
|
24
|
+
* oneLiner: { value, ambiguous, sources: [{ source, value }] },
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* `value` is the first candidate (a safe fallback), `ambiguous` is true when
|
|
28
|
+
* two or more distinct candidates were found. Callers should ask the user
|
|
29
|
+
* whenever `ambiguous` is true or `value` is empty.
|
|
30
|
+
*/
|
|
31
|
+
export function autoProjectInfo(cwd) {
|
|
32
|
+
const nameSources = [];
|
|
33
|
+
const oneLinerSources = [];
|
|
34
|
+
|
|
35
|
+
const pkgPath = join(cwd, 'package.json');
|
|
36
|
+
if (existsSync(pkgPath)) {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
39
|
+
if (isNonEmptyString(pkg?.name)) {
|
|
40
|
+
nameSources.push({ source: 'package.json', value: pkg.name.trim() });
|
|
41
|
+
}
|
|
42
|
+
if (isNonEmptyString(pkg?.description)) {
|
|
43
|
+
oneLinerSources.push({ source: 'package.json', value: pkg.description.trim() });
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
/* malformed package.json — ignore */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const file of README_CANDIDATES) {
|
|
51
|
+
const filePath = join(cwd, file);
|
|
52
|
+
if (!existsSync(filePath)) continue;
|
|
53
|
+
|
|
54
|
+
let content;
|
|
55
|
+
try {
|
|
56
|
+
content = readFileSync(filePath, 'utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hints = extractReadmeHints(content);
|
|
62
|
+
if (hints.name) nameSources.push({ source: file, value: hints.name });
|
|
63
|
+
if (hints.oneLiner) oneLinerSources.push({ source: file, value: hints.oneLiner });
|
|
64
|
+
|
|
65
|
+
break; // first README found wins — don't blend multiple README variants
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: summarize(nameSources),
|
|
70
|
+
oneLiner: summarize(oneLinerSources),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pull a name (first H1) and one-liner (first prose line) out of a README. */
|
|
75
|
+
export function extractReadmeHints(content) {
|
|
76
|
+
const lines = String(content || '').split('\n');
|
|
77
|
+
let name = '';
|
|
78
|
+
let oneLiner = '';
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < lines.length; i++) {
|
|
81
|
+
const line = lines[i].trim();
|
|
82
|
+
if (!line) continue;
|
|
83
|
+
|
|
84
|
+
if (!name) {
|
|
85
|
+
const h1 = /^#\s+(.+?)\s*$/.exec(line);
|
|
86
|
+
if (h1) {
|
|
87
|
+
name = stripInlineMarkdown(h1[1]);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Wait for the first H1 before reading prose — otherwise the README may
|
|
93
|
+
// start with a logo/badge that is not a name.
|
|
94
|
+
if (!name || oneLiner) continue;
|
|
95
|
+
|
|
96
|
+
if (isProseLine(line)) {
|
|
97
|
+
oneLiner = line.length > 120 ? `${line.slice(0, 117)}...` : line;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { name, oneLiner };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function summarize(sources) {
|
|
105
|
+
if (sources.length === 0) {
|
|
106
|
+
return { value: '', ambiguous: false, sources: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const distinct = [];
|
|
110
|
+
for (const entry of sources) {
|
|
111
|
+
if (!distinct.includes(entry.value)) distinct.push(entry.value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
value: sources[0].value,
|
|
116
|
+
ambiguous: distinct.length > 1,
|
|
117
|
+
sources,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isProseLine(line) {
|
|
122
|
+
// Skip headings, badges/images, code fences, lists, tables, blockquotes, HTML.
|
|
123
|
+
if (/^[#>|`*\-_]/.test(line)) return false;
|
|
124
|
+
if (/^\[!\[/.test(line)) return false;
|
|
125
|
+
if (/^!\[/.test(line)) return false;
|
|
126
|
+
if (/^<[a-zA-Z!/]/.test(line)) return false;
|
|
127
|
+
if (/^\|/.test(line)) return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stripInlineMarkdown(value) {
|
|
132
|
+
return String(value)
|
|
133
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // [text](url) → text
|
|
134
|
+
.replace(/[*_`]/g, '')
|
|
135
|
+
.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isNonEmptyString(value) {
|
|
139
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
140
|
+
}
|
package/templates/AGENTS.md.tmpl
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// cli-five jev-tier-router plugin (OpenCode).
|
|
2
|
+
//
|
|
3
|
+
// Ships a `local_tier_heuristic` tool that classifies a task description into
|
|
4
|
+
// cli-five's tier vocabulary: trivial | minor | major.
|
|
5
|
+
//
|
|
6
|
+
// TRUTHFUL NAMING: this is a LOCAL heuristic. It does NOT call Jev. As of
|
|
7
|
+
// 2026-09-26, jev-harness's `route` subcommand exposes no custom-criteria
|
|
8
|
+
// interface (it emits its own fixed tier vocabulary: deterministic /
|
|
9
|
+
// lightweight_system2 / heavy_system2) and returns a constant confidence
|
|
10
|
+
// (0.88) under its offline/mock engine, so it cannot be thresholded on.
|
|
11
|
+
// The name `local_tier_heuristic` is deliberate — do not revive `jev_tier_route`
|
|
12
|
+
// unless/until real Jev wiring is verified. See JEVR_SWAP_POINT below.
|
|
13
|
+
//
|
|
14
|
+
// Fail-open: if anything goes wrong the tool reports `available: false` and
|
|
15
|
+
// tier "major" (the expensive tier), so the Planner falls back to its own
|
|
16
|
+
// judgment. It never throws in a way that would break the Planner's turn.
|
|
17
|
+
|
|
18
|
+
import { appendFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const CONFIDENCE_CUTOFF = 0.6;
|
|
22
|
+
const FALLBACK_TIER = 'major';
|
|
23
|
+
|
|
24
|
+
const TIERS = ['trivial', 'minor', 'major'];
|
|
25
|
+
|
|
26
|
+
// Weighted signals. `strong` matches dominate; `moderate` accumulate.
|
|
27
|
+
const SIGNALS = [
|
|
28
|
+
// trivial — mechanical, single-token, no reasoning
|
|
29
|
+
{ tier: 'trivial', weight: 3, re: /\b(typo|typos|whitespace|lint|linting|format|formatting|rename|renaming|comment|comments|docstring|spelling|indent(ation)?)\b/i },
|
|
30
|
+
{ tier: 'trivial', weight: 2, re: /\b(README|changelog|CHANGELOG|\.md\b|docs?)\b/i },
|
|
31
|
+
{ tier: 'trivial', weight: 2, re: /\b(one[- ]?line|single[- ]?(file|line)|small tweak|quick fix|minor tweak)\b/i },
|
|
32
|
+
{ tier: 'trivial', weight: 2, re: /\b(delete|remove)\b[\s\w]{0,20}\b(console\.log|stray|unused|dead code|tmp|temp file)\b/i },
|
|
33
|
+
|
|
34
|
+
// major — architectural, cross-cutting, ambiguous scope
|
|
35
|
+
{ tier: 'major', weight: 3, re: /\b(architect(ure|ural)?|redesign|rearchitect|rewrite|overhaul|migrat(e|ion)|replatform|distributed|scalab(le|ility)|multi[- ]?(tenant|region|service))\b/i },
|
|
36
|
+
{ tier: 'major', weight: 3, re: /\b(entire|whole|across (the )?(codebase|repo(sitory)?|project)|end[- ]to[- ]end|system[- ]wide)\b/i },
|
|
37
|
+
{ tier: 'major', weight: 2, re: /\b(concurren(cy|t)|race condition|deadlock|transaction(al)?|consistency|eventual consistency|saga|retry (architecture|strategy)|queue|scheduler|orchestrat(e|ion))\b/i },
|
|
38
|
+
{ tier: 'major', weight: 2, re: /\b(performance|latency|throughput|optimi[sz]e|profil(e|ing)|security|auth(entication|orization)?|encryption|compliance|HIPAA|SOC ?2|GDPR)\b/i },
|
|
39
|
+
{ tier: 'major', weight: 1, re: /\b(design|feature|implement|build|add support for|new (module|service|system))\b/i },
|
|
40
|
+
|
|
41
|
+
// minor — bounded, local, incremental
|
|
42
|
+
{ tier: 'minor', weight: 3, re: /\b(validation|validate|error handling|error message|edge case|bug ?fix|fix (a |the )?bug|patch|handle null|guard clause)\b/i },
|
|
43
|
+
{ tier: 'minor', weight: 2, re: /\b(component|function|method|handler|endpoint|form|button|modal|tooltip|dropdown)\b/i },
|
|
44
|
+
{ tier: 'minor', weight: 2, re: /\b(refactor|extract|rename (the )?(function|method|class|module)|tidy|clean ?up)\b/i },
|
|
45
|
+
{ tier: 'minor', weight: 1, re: /\b(add|update|adjust|tweak|improve|tidy)\b/i },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Classify a task description locally.
|
|
50
|
+
*
|
|
51
|
+
* Returns { tier, confidence, rationale, available, source }.
|
|
52
|
+
* Ambiguity fails toward the expensive tier (major), never the cheap one.
|
|
53
|
+
*/
|
|
54
|
+
export function classifyTask(description) {
|
|
55
|
+
const text = String(description ?? '').trim();
|
|
56
|
+
if (!text) {
|
|
57
|
+
return {
|
|
58
|
+
tier: FALLBACK_TIER,
|
|
59
|
+
confidence: 0,
|
|
60
|
+
rationale: 'Empty task description; defaulting to the expensive tier.',
|
|
61
|
+
available: true,
|
|
62
|
+
source: 'local_heuristic',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const scores = { trivial: 0, minor: 0, major: 0 };
|
|
67
|
+
const hits = { trivial: [], minor: [], major: [] };
|
|
68
|
+
|
|
69
|
+
for (const signal of SIGNALS) {
|
|
70
|
+
if (signal.re.test(text)) {
|
|
71
|
+
scores[signal.tier] += signal.weight;
|
|
72
|
+
hits[signal.tier].push(signal.re.source.slice(0, 40));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Length is a weak major signal: long, multi-clause prompts rarely stay local.
|
|
77
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
78
|
+
if (words > 25) scores.major += 1;
|
|
79
|
+
if (words > 60) scores.major += 1;
|
|
80
|
+
|
|
81
|
+
const ranked = TIERS.map((tier) => ({ tier, score: scores[tier] })).sort((a, b) => b.score - a.score);
|
|
82
|
+
const [top, second] = ranked;
|
|
83
|
+
|
|
84
|
+
if (top.score === 0) {
|
|
85
|
+
// Nothing matched — ambiguous. Fail toward the expensive tier.
|
|
86
|
+
return {
|
|
87
|
+
tier: FALLBACK_TIER,
|
|
88
|
+
confidence: 0.3,
|
|
89
|
+
rationale: 'No tier signals matched; ambiguous, so defaulting to the expensive tier.',
|
|
90
|
+
available: true,
|
|
91
|
+
source: 'local_heuristic',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const total = TIERS.reduce((sum, tier) => sum + scores[tier], 0);
|
|
96
|
+
const separation = (top.score - (second?.score ?? 0)) / top.score;
|
|
97
|
+
const share = top.score / total;
|
|
98
|
+
let confidence = 0.5 * share + 0.5 * separation;
|
|
99
|
+
|
|
100
|
+
// A single weak hit with no corroboration is not a confident call.
|
|
101
|
+
if (top.score <= 1) confidence = Math.min(confidence, 0.5);
|
|
102
|
+
|
|
103
|
+
confidence = Math.round(confidence * 100) / 100;
|
|
104
|
+
|
|
105
|
+
if (confidence < CONFIDENCE_CUTOFF) {
|
|
106
|
+
return {
|
|
107
|
+
tier: FALLBACK_TIER,
|
|
108
|
+
confidence,
|
|
109
|
+
rationale: `Low confidence (${confidence} < ${CONFIDENCE_CUTOFF}) between ${top.tier} and ${second?.tier ?? 'n/a'}; defaulting to the expensive tier.`,
|
|
110
|
+
available: true,
|
|
111
|
+
source: 'local_heuristic',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
tier: top.tier,
|
|
117
|
+
confidence,
|
|
118
|
+
rationale: `Matched ${hits[top.tier].length} ${top.tier} signal(s).`,
|
|
119
|
+
available: true,
|
|
120
|
+
source: 'local_heuristic',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── JEVR_SWAP_POINT ───────────────────────────────────────────────────
|
|
125
|
+
// Real-Jev wiring would replace classifyTask() above with a shell-out to
|
|
126
|
+
// jev-harness route --json --task "<description>"
|
|
127
|
+
// and map the returned `selected_tier` onto cli-five's tiers, thresholding on
|
|
128
|
+
// `confidence`. It is NOT wired because, as of 2026-09-26, `route` exposes no
|
|
129
|
+
// custom-criteria interface, emits a fixed vocabulary, and returns a constant
|
|
130
|
+
// confidence under the offline engine. Re-verify before swapping.
|
|
131
|
+
// Exact call site: the `execute` handler below that calls classifyTask().
|
|
132
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export const __testables = { classifyTask, CONFIDENCE_CUTOFF, FALLBACK_TIER };
|
|
135
|
+
|
|
136
|
+
export default {
|
|
137
|
+
id: 'cli-five-jev-tier-router',
|
|
138
|
+
setup: async (ctx) => {
|
|
139
|
+
if (!ctx || !ctx.tool || typeof ctx.tool.transform !== 'function') return;
|
|
140
|
+
|
|
141
|
+
await ctx.tool.transform((tools) => {
|
|
142
|
+
tools.add({
|
|
143
|
+
name: 'local_tier_heuristic',
|
|
144
|
+
description:
|
|
145
|
+
'Classify a task description into cli-five\'s tier vocabulary (trivial | minor | major) using a local heuristic. ' +
|
|
146
|
+
'Call this once per task, before planning. Trust the returned tier when confidence >= 0.6; otherwise fall back to "major". ' +
|
|
147
|
+
'This is a local heuristic, not a Jev call.',
|
|
148
|
+
input: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
description: {
|
|
152
|
+
type: 'string',
|
|
153
|
+
description: 'The task to classify, verbatim (the user request or planning prompt).',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
required: ['description'],
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
},
|
|
159
|
+
async execute(input) {
|
|
160
|
+
try {
|
|
161
|
+
const result = classifyTask(input?.description);
|
|
162
|
+
return { content: JSON.stringify(result) };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Fail-open: never break the caller's turn.
|
|
165
|
+
try {
|
|
166
|
+
appendFileSync(
|
|
167
|
+
process.env.CLI_FIVE_LOGFILE || join(process.cwd(), '.opencode', 'journals', 'jev-tier-router.log'),
|
|
168
|
+
`${new Date().toISOString()} local_tier_heuristic fail-open: ${err?.message || err}\n`,
|
|
169
|
+
);
|
|
170
|
+
} catch {
|
|
171
|
+
/* logging is best-effort */
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
content: JSON.stringify({
|
|
175
|
+
tier: FALLBACK_TIER,
|
|
176
|
+
confidence: 0,
|
|
177
|
+
rationale: 'Tier classifier unavailable; defaulting to the expensive tier.',
|
|
178
|
+
available: false,
|
|
179
|
+
source: 'local_heuristic',
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
};
|