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.
@@ -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
+ }
@@ -24,4 +24,3 @@ and Copilot all read `AGENTS.md` per the [agents.md](https://agents.md) conventi
24
24
  4. Per-agent memory lives in `histories/<agent>.md`.
25
25
  5. Append a session summary to `agent-diary.md` when work completes.
26
26
 
27
- {{CODEGRAPH_BLOCK}}
@@ -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
+ };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "cli-five-jev-tier-router",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ }
9
+ }