cli-five 0.2.16 → 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 +41 -3
- 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 +38 -8
- package/src/commands/add.mjs +1 -1
- package/src/commands/list-addons.mjs +16 -7
- package/src/steps/scaffold.mjs +11 -46
- 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
package/README.md
CHANGED
|
@@ -156,13 +156,51 @@ Optional integrations live outside the default scaffold and are installed with `
|
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
158
|
npx cli-five add # list known targets
|
|
159
|
-
npx cli-five add codegraph #
|
|
159
|
+
npx cli-five add codegraph # CodeGraph MCP registration + AGENTS.md instructions
|
|
160
|
+
npx cli-five add jev # tier-routing tool for the Planner (OpenCode only)
|
|
160
161
|
npx cli-five list-addons # installed vs. available status
|
|
161
162
|
```
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
Add-ons use `mergeBlock(file, markerFence, content)` to layer a fenced block into an existing JSON or Markdown file without touching the rest of it (distinct from init's blunt overwrite gate). Markdown gets `<!-- NAME_START -->` / `<!-- NAME_END -->` fences; JSON is deep-merged with existing keys preserved. Re-running an add-on is idempotent — no duplicate MCP entries or AGENTS.md sections.
|
|
165
|
+
|
|
166
|
+
### CodeGraph (`add codegraph`)
|
|
167
|
+
|
|
168
|
+
`add codegraph` registers the CodeGraph MCP server and adds the CodeGraph section to `AGENTS.md`. It works on **both platforms**:
|
|
169
|
+
|
|
170
|
+
| Target | MCP registration | Instructions |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| Copilot | `.vscode/mcp.json` → `servers.codegraph` | `AGENTS.md` block |
|
|
173
|
+
| OpenCode | `opencode.json` → `mcp.codegraph` | `AGENTS.md` block |
|
|
174
|
+
|
|
175
|
+
**The `init` flags are now thin wrappers.** `init --codegraph` and `init --no-codegraph` still behave exactly as before, but they call the *same* underlying `add codegraph` logic — one implementation, two entry points. CodeGraph remains off by default in minimal `init` and on with `--full-interview`; pass `--codegraph` to force it on, `--no-codegraph` to force it off.
|
|
176
|
+
|
|
177
|
+
cli-five still does **not** run `codegraph init` itself — it only registers the server and reminds you to index the project:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npm i -g @colbymchenry/codegraph
|
|
181
|
+
codegraph init
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Jev (`add jev`) — tier-routing only, OpenCode only
|
|
185
|
+
|
|
186
|
+
`add jev` scaffolds an OpenCode plugin that adds a `local_tier_heuristic` tool. The Planner calls it once per task to classify the work as `trivial` / `minor` / `major`, then scales planning depth accordingly.
|
|
187
|
+
|
|
188
|
+
**Two things it deliberately does *not* do, stated plainly:**
|
|
189
|
+
|
|
190
|
+
1. **It does not call Jev.** `jev-harness` 0.2.0's `route` subcommand exposes no custom-criteria interface — it emits its own fixed tier vocabulary (`deterministic` / `lightweight_system2` / `heavy_system2`) and returns a constant confidence (`0.88`) under its offline/mock engine, so it cannot be thresholded on. The shipped tool is therefore a **local heuristic**, truthfully named `local_tier_heuristic`. The swap point for real Jev wiring is marked in `templates/opencode/plugin/jev-tier-router/index.js` (`JEVR_SWAP_POINT`).
|
|
191
|
+
2. **The test-gate is parked.** Gating Reviewer spawns via plugin interception (`tool.execute.before` / `permission.ask`) does not work: OpenCode plugin hooks do not fire under OpenChamber's embedded-server routing. Do not expect `add jev` to gate anything.
|
|
192
|
+
|
|
193
|
+
`list-addons` reports each add-on's honest capability rather than a bare "installed":
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
codegraph installed (MCP registration + AGENTS.md instructions) available MCP registration + AGENTS.md instructions
|
|
197
|
+
jev installed (tier-routing only) available local heuristic — …; test-gate parked — <issue link>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Fail-open is non-negotiable.** If the classifier is unavailable, errors, or returns malformed output, the tool returns `available: false` with tier `major` (the expensive tier) and never throws. The Planner falls back to its own judgment. cli-five and the scaffolded agents behave identically whether the plugin works, is missing, or is broken.
|
|
201
|
+
|
|
202
|
+
**Copilot has no equivalent.** `add jev` refuses cleanly on a Copilot target (exit 1, no files written) — there is no `tools.add`-style surface there.
|
|
164
203
|
|
|
165
|
-
Add-ons use `mergeBlock(file, markerFence, content)` to layer a fenced block into an existing JSON or Markdown file without touching the rest of it (distinct from init's blunt overwrite gate). Markdown gets `<!-- NAME_START -->` / `<!-- NAME_END -->` fences; JSON is deep-merged with existing keys preserved.
|
|
166
204
|
|
|
167
205
|
## Model providers
|
|
168
206
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { log } from '../util/log.mjs';
|
|
5
|
+
import { PLATFORM_COPILOT, PLATFORM_OPENCODE, platformLabel } from '../util/platforms.mjs';
|
|
6
|
+
import { addCodegraphTo, CODEGRAPH_INIT_REMINDER, mcpTargetFor } from './codegraph.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `add codegraph` — register the CodeGraph MCP server + AGENTS.md section.
|
|
10
|
+
*
|
|
11
|
+
* Works on both platforms. Same content and behavior as the previous inline
|
|
12
|
+
* init implementation; only the invocation path and write mechanism changed
|
|
13
|
+
* (mergeBlock instead of blunt overwrite).
|
|
14
|
+
*/
|
|
15
|
+
export async function runCodegraph({ cwd, args = {} }) {
|
|
16
|
+
const platform = detectPlatform(cwd);
|
|
17
|
+
|
|
18
|
+
if (platform === 'unknown') {
|
|
19
|
+
log.err('No cli-five scaffold detected (neither .opencode/agents nor .github/agents).');
|
|
20
|
+
log.dim('Run `npx cli-five init` first, then `npx cli-five add codegraph`.');
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dryRun = Boolean(args.dryRun);
|
|
26
|
+
const touched = addCodegraphTo({ cwd, platform, dryRun, track: true });
|
|
27
|
+
|
|
28
|
+
for (const t of touched) {
|
|
29
|
+
const rel = t.path.replace(cwd + '/', '');
|
|
30
|
+
const symbol = t.action === 'created' || t.action === 'updated' ? '+' : '~';
|
|
31
|
+
log.raw(` ${symbol} ${rel} ${kleur.dim(`(${t.action})`)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
log.ok(`CodeGraph registered for ${platformLabel(platform)}`);
|
|
35
|
+
log.dim(`MCP: ${mcpTargetFor(platform)} · instructions: AGENTS.md`);
|
|
36
|
+
log.dim(CODEGRAPH_INIT_REMINDER.replace('CodeGraph is configured. ', ''));
|
|
37
|
+
|
|
38
|
+
return touched;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function detectPlatform(cwd) {
|
|
42
|
+
if (existsSync(join(cwd, '.opencode', 'agents'))) return PLATFORM_OPENCODE;
|
|
43
|
+
if (existsSync(join(cwd, '.github', 'agents', 'orchestrator.agent.md'))) return PLATFORM_COPILOT;
|
|
44
|
+
return 'unknown';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const __testables = { detectPlatform };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mergeBlock } from '../util/merge.mjs';
|
|
4
|
+
import { PLATFORM_COPILOT, PLATFORM_OPENCODE } from '../util/platforms.mjs';
|
|
5
|
+
|
|
6
|
+
export const CODEGRAPH_STATUS = 'MCP registration + AGENTS.md instructions';
|
|
7
|
+
export const CODEGRAPH_BLOCK_NAME = 'codegraph';
|
|
8
|
+
export const CODEGRAPH_INIT_REMINDER =
|
|
9
|
+
'CodeGraph is configured. Remember to run `codegraph init` before asking agents to explore the codebase.';
|
|
10
|
+
|
|
11
|
+
// The AGENTS.md section is byte-identical to the pre-migration inline constant
|
|
12
|
+
// in scaffold.mjs. Do not change its content — only the write path changed.
|
|
13
|
+
export const CODEGRAPH_BLOCK = `## CodeGraph
|
|
14
|
+
|
|
15
|
+
This project is configured to use [CodeGraph](https://codegraph.ru) for graph-backed codebase context.
|
|
16
|
+
When you need to understand relationships, call paths, or impacts, use:
|
|
17
|
+
|
|
18
|
+
\`\`\`
|
|
19
|
+
codegraph explore "<your question>"
|
|
20
|
+
\`\`\`
|
|
21
|
+
|
|
22
|
+
The CodeGraph MCP server is registered in the project config. Run \`codegraph init\` in this directory
|
|
23
|
+
if the project has not been indexed yet.`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The CodeGraph MCP server entries, per platform. Unchanged from the previous
|
|
27
|
+
* inline implementation — same server, same command, same shape.
|
|
28
|
+
*/
|
|
29
|
+
export function codegraphOpencodeConfig() {
|
|
30
|
+
return {
|
|
31
|
+
codegraph: {
|
|
32
|
+
type: 'local',
|
|
33
|
+
command: ['codegraph', 'serve', '--mcp'],
|
|
34
|
+
enabled: true,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function codegraphCopilotMcpJson() {
|
|
40
|
+
return {
|
|
41
|
+
inputs: [],
|
|
42
|
+
servers: {
|
|
43
|
+
codegraph: {
|
|
44
|
+
command: 'codegraph',
|
|
45
|
+
args: ['serve', '--mcp'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Which MCP file a given platform owns. Preserves the old per-platform split. */
|
|
52
|
+
export function mcpTargetFor(platform) {
|
|
53
|
+
return platform === PLATFORM_OPENCODE ? 'opencode.json' : join('.vscode', 'mcp.json');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register CodeGraph in a workspace, using mergeBlock() for every write.
|
|
58
|
+
*
|
|
59
|
+
* Shared by `add codegraph` and init's legacy --codegraph flag — one
|
|
60
|
+
* implementation, two entry points.
|
|
61
|
+
*
|
|
62
|
+
* @returns {Array<{path:string, action:string}>} the files touched.
|
|
63
|
+
*/
|
|
64
|
+
export function addCodegraphTo({ cwd, platform, dryRun = false, track = false }) {
|
|
65
|
+
const touched = [];
|
|
66
|
+
const isOpenCode = platform === PLATFORM_OPENCODE;
|
|
67
|
+
|
|
68
|
+
// 1. MCP registration.
|
|
69
|
+
if (isOpenCode) {
|
|
70
|
+
const opencodePath = join(cwd, 'opencode.json');
|
|
71
|
+
// Preserve a pre-existing `plugins[]` (e.g. jev): deepMerge replaces arrays,
|
|
72
|
+
// so carry the existing value through untouched at the root.
|
|
73
|
+
const existing = readJson(opencodePath) || {};
|
|
74
|
+
const patch = { mcp: codegraphOpencodeConfig() };
|
|
75
|
+
if (Array.isArray(existing.plugins)) patch.plugins = existing.plugins;
|
|
76
|
+
touched.push(mergeBlock(opencodePath, 'codegraph', patch, { dryRun, track }));
|
|
77
|
+
} else {
|
|
78
|
+
const mcpPath = join(cwd, '.vscode', 'mcp.json');
|
|
79
|
+
const existing = readJson(mcpPath) || {};
|
|
80
|
+
// Preserve the old key order (`inputs` first) so output matches the
|
|
81
|
+
// pre-migration inline implementation byte-for-byte. `inputs` is an array;
|
|
82
|
+
// carry it through so deepMerge's array-replace is a no-op.
|
|
83
|
+
const patch = { inputs: Array.isArray(existing.inputs) ? existing.inputs : [] };
|
|
84
|
+
patch.servers = { codegraph: codegraphCopilotMcpJson().servers.codegraph };
|
|
85
|
+
touched.push(mergeBlock(mcpPath, 'codegraph', patch, { dryRun, track }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. AGENTS.md section (fenced block; same marker name as the old template).
|
|
89
|
+
const agentsPath = join(cwd, 'AGENTS.md');
|
|
90
|
+
touched.push(mergeBlock(agentsPath, CODEGRAPH_BLOCK_NAME, CODEGRAPH_BLOCK, { dryRun }));
|
|
91
|
+
|
|
92
|
+
return touched;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Idempotent remove-of-duplicates is intentionally NOT implemented: mergeBlock
|
|
97
|
+
* already replaces an existing fenced block in place and deep-merges JSON, so
|
|
98
|
+
* re-running add codegraph does not duplicate anything.
|
|
99
|
+
*/
|
|
100
|
+
export function isCodegraphPresent(cwd, platform) {
|
|
101
|
+
const mcpPath = join(cwd, mcpTargetFor(platform));
|
|
102
|
+
const cfg = readJson(mcpPath);
|
|
103
|
+
if (platform === PLATFORM_OPENCODE) return Boolean(cfg?.mcp?.codegraph);
|
|
104
|
+
return Boolean(cfg?.servers?.codegraph);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readJson(filePath) {
|
|
108
|
+
if (!existsSync(filePath)) return null;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Ensure a file exists with given content (used for a created-not-merged file). */
|
|
117
|
+
export function ensureFile(filePath, contents) {
|
|
118
|
+
if (existsSync(filePath)) return false;
|
|
119
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
120
|
+
writeFileSync(filePath, contents);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const __testables = { readJson, mcpTargetFor };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync, readFileSync, cpSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { log } from '../util/log.mjs';
|
|
5
|
+
import { mergeBlock } from '../util/merge.mjs';
|
|
6
|
+
import { templatePath } from '../util/fs.mjs';
|
|
7
|
+
|
|
8
|
+
export const JEVR_STATUS =
|
|
9
|
+
'local heuristic — jev-harness lacks a custom-criteria interface as of 2026-09-26';
|
|
10
|
+
export const JEVR_TOOL = 'local_tier_heuristic';
|
|
11
|
+
export const TEST_GATE_ISSUE = 'https://github.com/idusortus/cli-five/issues';
|
|
12
|
+
|
|
13
|
+
const PLUGIN_REL = join('.opencode', 'plugin', 'jev-tier-router');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `add jev` — tier-routing only.
|
|
17
|
+
*
|
|
18
|
+
* Ships a local tier-classification tool for the Planner. Does NOT wire the
|
|
19
|
+
* test-gate (parked: OpenCode plugin hooks don't fire under OpenChamber's
|
|
20
|
+
* embedded-server routing) and does NOT call Jev (no custom-criteria
|
|
21
|
+
* interface in jev-harness as of this release).
|
|
22
|
+
*/
|
|
23
|
+
export async function runJev({ cwd, args }) {
|
|
24
|
+
const platform = detectPlatform(cwd);
|
|
25
|
+
|
|
26
|
+
if (platform !== 'opencode') {
|
|
27
|
+
log.err('jev is OpenCode-only. No tools.add-equivalent surface exists for Copilot.');
|
|
28
|
+
log.dim('Re-run init with --target opencode, then `npx cli-five add jev`.');
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const written = [];
|
|
34
|
+
|
|
35
|
+
// 1. Plugin directory (package.json + index.js), confirmed convention.
|
|
36
|
+
const pluginDir = join(cwd, PLUGIN_REL);
|
|
37
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
38
|
+
for (const file of ['package.json', 'index.js']) {
|
|
39
|
+
const src = templatePath('opencode', 'plugin', 'jev-tier-router', file);
|
|
40
|
+
const dest = join(pluginDir, file);
|
|
41
|
+
cpSync(src, dest);
|
|
42
|
+
written.push({ path: dest, written: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. Register the plugin path in opencode.json's `plugins` array.
|
|
46
|
+
// mergeBlock deep-merges JSON but replaces arrays, so read the existing
|
|
47
|
+
// array, union it, and hand the unioned value to mergeBlock (single
|
|
48
|
+
// write path — no second merge approach).
|
|
49
|
+
const opencodePath = join(cwd, 'opencode.json');
|
|
50
|
+
const plugins = readPlugins(opencodePath);
|
|
51
|
+
const pluginRef = toPosix(PLUGIN_REL);
|
|
52
|
+
if (!plugins.includes(pluginRef)) plugins.push(pluginRef);
|
|
53
|
+
mergeBlock(opencodePath, 'jev', { plugins }, { track: true, metaKey: '$cliFive' });
|
|
54
|
+
|
|
55
|
+
// 3. Planner instruction in AGENTS.md (fenced block, idempotent).
|
|
56
|
+
const agentsPath = join(cwd, 'AGENTS.md');
|
|
57
|
+
mergeBlock(agentsPath, 'jev-tier-routing', plannerInstruction());
|
|
58
|
+
|
|
59
|
+
log.ok(`Plugin written to ${kleur.bold(toPosix(PLUGIN_REL))}`);
|
|
60
|
+
log.ok(`Registered in opencode.json (plugins[])`);
|
|
61
|
+
log.ok('Planner instruction added to AGENTS.md');
|
|
62
|
+
log.raw('');
|
|
63
|
+
log.info(`Tool: ${kleur.bold(JEVR_TOOL)} (tier-routing only)`);
|
|
64
|
+
log.warn('Status: ' + JEVR_STATUS);
|
|
65
|
+
log.dim(`Test-gate is parked — see ${TEST_GATE_ISSUE}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function plannerInstruction() {
|
|
69
|
+
return `## Tier routing (local heuristic)
|
|
70
|
+
|
|
71
|
+
Before planning, call the \`${JEVR_TOOL}\` tool once with the task description.
|
|
72
|
+
|
|
73
|
+
- If it returns \`confidence\` >= 0.6, use its \`tier\` (trivial | minor | major) as your planning depth.
|
|
74
|
+
- If \`confidence\` < 0.6, or the tool is unavailable, use your own judgment and default to \`major\`.
|
|
75
|
+
- This is a local heuristic, not a Jev call. Never block or fail a turn because the tool is unavailable.`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readPlugins(opencodePath) {
|
|
79
|
+
if (!existsSync(opencodePath)) return [];
|
|
80
|
+
try {
|
|
81
|
+
const cfg = JSON.parse(readFileSync(opencodePath, 'utf8'));
|
|
82
|
+
return Array.isArray(cfg.plugins) ? [...cfg.plugins] : [];
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function detectPlatform(cwd) {
|
|
89
|
+
if (existsSync(join(cwd, '.opencode', 'agents'))) return 'opencode';
|
|
90
|
+
if (existsSync(join(cwd, '.github', 'agents', 'orchestrator.agent.md'))) return 'copilot';
|
|
91
|
+
return 'unknown';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toPosix(p) {
|
|
95
|
+
return p.split('\\').join('/');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const __testables = { plannerInstruction, readPlugins, detectPlatform };
|
package/src/addons/registry.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { runJev, JEVR_STATUS, TEST_GATE_ISSUE } from './jev.mjs';
|
|
4
|
+
import { runCodegraph } from './codegraph-command.mjs';
|
|
5
|
+
import { CODEGRAPH_STATUS } from './codegraph.mjs';
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Add-on registry for `cli-five add <name>`.
|
|
@@ -16,19 +19,27 @@ export const ADDONS = {
|
|
|
16
19
|
name: 'codegraph',
|
|
17
20
|
label: 'CodeGraph',
|
|
18
21
|
description: 'Graph-backed codebase context MCP server and agent instructions.',
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
// Matches jev's status-object pattern: explicit capability + status string,
|
|
23
|
+
// not a bare boolean.
|
|
24
|
+
available: true,
|
|
25
|
+
capability: 'MCP registration + AGENTS.md instructions',
|
|
26
|
+
status: CODEGRAPH_STATUS,
|
|
27
|
+
platforms: ['copilot', 'opencode'],
|
|
21
28
|
detect: detectCodeGraph,
|
|
22
|
-
run:
|
|
29
|
+
run: runCodegraph,
|
|
23
30
|
},
|
|
24
31
|
jev: {
|
|
25
32
|
name: 'jev',
|
|
26
33
|
label: 'Jev',
|
|
27
|
-
description: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
description: 'Tier-routing tool for the Planner (local heuristic; test-gate parked).',
|
|
35
|
+
// Not a bare boolean: jev ships tier-routing ONLY. The test-gate half is
|
|
36
|
+
// parked because OpenCode plugin hooks don't fire under OpenChamber routing.
|
|
37
|
+
available: true,
|
|
38
|
+
capability: 'tier-routing only',
|
|
39
|
+
status: `${JEVR_STATUS}; test-gate parked — ${TEST_GATE_ISSUE}`,
|
|
40
|
+
platforms: ['opencode'],
|
|
41
|
+
detect: detectJev,
|
|
42
|
+
run: runJev,
|
|
32
43
|
},
|
|
33
44
|
};
|
|
34
45
|
|
|
@@ -94,3 +105,22 @@ function readJson(filePath) {
|
|
|
94
105
|
return null;
|
|
95
106
|
}
|
|
96
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect the jev tier-router plugin: its directory on disk and/or its entry in
|
|
111
|
+
* opencode.json's `plugins` array. Read-only.
|
|
112
|
+
*/
|
|
113
|
+
export function detectJev(cwd) {
|
|
114
|
+
const signals = [];
|
|
115
|
+
|
|
116
|
+
if (existsSync(join(cwd, '.opencode', 'plugin', 'jev-tier-router', 'index.js'))) {
|
|
117
|
+
signals.push('.opencode/plugin/jev-tier-router');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cfg = readJson(join(cwd, 'opencode.json'));
|
|
121
|
+
if (Array.isArray(cfg?.plugins) && cfg.plugins.some((p) => String(p).includes('jev-tier-router'))) {
|
|
122
|
+
signals.push('opencode.json plugins[]');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return signals;
|
|
126
|
+
}
|
package/src/commands/add.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export async function add(args) {
|
|
|
28
28
|
if (typeof addon.run !== 'function') {
|
|
29
29
|
log.warn(`${addon.label} is registered but not implemented yet.`);
|
|
30
30
|
log.info('The add dispatcher works — this target is reserved for a future release.');
|
|
31
|
-
if (addon.note) log.dim(addon.note);
|
|
31
|
+
if (addon.note || addon.status) log.dim(addon.note || addon.status);
|
|
32
32
|
log.dim('Nothing was written to your project.');
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
@@ -5,24 +5,33 @@ import { detectAddon, listAddons } from '../addons/registry.mjs';
|
|
|
5
5
|
/**
|
|
6
6
|
* `cli-five list-addons` — show what is installed vs. available.
|
|
7
7
|
*
|
|
8
|
-
* "Installed" is detected read-only from workspace artifacts.
|
|
9
|
-
*
|
|
8
|
+
* "Installed" is detected read-only from workspace artifacts. Status text is
|
|
9
|
+
* deliberately honest: an add-on that ships partial capability (e.g. jev's
|
|
10
|
+
* tier-routing without the parked test-gate) must not read as fully installed.
|
|
10
11
|
*/
|
|
11
12
|
export function listAddonsCommand(args) {
|
|
12
13
|
const cwd = args.cwd;
|
|
13
14
|
log.raw(kleur.bold().magenta('\ncli-five list-addons') + kleur.gray(` ${cwd}`));
|
|
14
15
|
log.raw('');
|
|
15
16
|
|
|
16
|
-
log.raw(` ${kleur.gray(pad('ADD-ON', 12))} ${kleur.gray(pad('STATUS',
|
|
17
|
-
log.raw(` ${'─'.repeat(12)} ${'─'.repeat(
|
|
17
|
+
log.raw(` ${kleur.gray(pad('ADD-ON', 12))} ${kleur.gray(pad('STATUS', 14))} ${kleur.gray(pad('ADD', 10))} ${kleur.gray('DETAIL')}`);
|
|
18
|
+
log.raw(` ${'─'.repeat(12)} ${'─'.repeat(14)} ${'─'.repeat(10)} ${'─'.repeat(30)}`);
|
|
18
19
|
|
|
19
20
|
for (const addon of listAddons()) {
|
|
20
21
|
const signals = detectAddon(addon, cwd);
|
|
21
22
|
const installed = signals.length > 0;
|
|
22
23
|
const addable = typeof addon.run === 'function';
|
|
23
|
-
const detail = installed ? signals.join(', ') : addon.note || '';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// An installed add-on with a `capability` note is partial — say so.
|
|
26
|
+
const statusLabel = installed
|
|
27
|
+
? (addon.capability ? `installed (${addon.capability})` : 'installed')
|
|
28
|
+
: 'not found';
|
|
29
|
+
|
|
30
|
+
const detail = installed
|
|
31
|
+
? [addon.status || signals.join(', ')].filter(Boolean).join(' — ')
|
|
32
|
+
: addon.note || addon.status || '';
|
|
33
|
+
|
|
34
|
+
const statusText = pad(statusLabel, 14);
|
|
26
35
|
const status = installed ? kleur.green(statusText) : kleur.gray(statusText);
|
|
27
36
|
const addableText = pad(addable ? 'available' : 'planned', 10);
|
|
28
37
|
const addableColored = addable ? kleur.green(addableText) : kleur.yellow(addableText);
|
|
@@ -31,7 +40,7 @@ export function listAddonsCommand(args) {
|
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
log.raw('');
|
|
34
|
-
log.dim('Install with `npx cli-five add <name
|
|
43
|
+
log.dim('Install with `npx cli-five add <name>`.');
|
|
35
44
|
log.raw('');
|
|
36
45
|
}
|
|
37
46
|
|
package/src/steps/scaffold.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { log } from '../util/log.mjs';
|
|
|
3
3
|
import { readTemplate, render, writeFile, listFilesRecursive, relTo, templatePath } from '../util/fs.mjs';
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { PLATFORM_COPILOT, PLATFORM_OPENCODE, agentDirFor, agentFileFor } from '../util/platforms.mjs';
|
|
6
|
+
import { addCodegraphTo } from '../addons/codegraph.mjs';
|
|
6
7
|
|
|
7
8
|
const AGENT_NAMES = ['orchestrator', 'planner', 'coder', 'designer', 'reviewer'];
|
|
8
9
|
const HISTORY_FILES = ['orchestrator.md', 'planner.md', 'coder.md', 'designer.md', 'reviewer.md'];
|
|
@@ -44,6 +45,9 @@ export function scaffold({ cwd, answers, args }) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// Shared memory primitives
|
|
48
|
+
// NOTE: AGENTS.md is written WITHOUT the CodeGraph block here. When CodeGraph
|
|
49
|
+
// is enabled, the block is merged in afterward by addCodegraphTo() — the same
|
|
50
|
+
// code path `add codegraph` uses (one implementation, two entry points).
|
|
47
51
|
for (const tmpl of [
|
|
48
52
|
'AGENTS.md.tmpl',
|
|
49
53
|
'PROJECT.md.tmpl',
|
|
@@ -61,6 +65,13 @@ export function scaffold({ cwd, answers, args }) {
|
|
|
61
65
|
written.push(writeFile(join(cwd, 'histories', file), readTemplate('histories', file), args));
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
// CodeGraph — delegated to the shared add codegraph implementation.
|
|
69
|
+
if (answers.codegraph) {
|
|
70
|
+
for (const t of addCodegraphTo({ cwd, platform, dryRun: args.dryRun })) {
|
|
71
|
+
written.push({ path: t.path, written: !args.dryRun && t.action !== 'unchanged' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
return written;
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -93,11 +104,6 @@ function scaffoldCopilot({ cwd, answers, args, vars }) {
|
|
|
93
104
|
writeFile(join(cwd, '.github', 'skills', 'README.md'), readTemplate('.github', 'skills', 'README.md'), args),
|
|
94
105
|
);
|
|
95
106
|
|
|
96
|
-
// CodeGraph MCP for VS Code Copilot
|
|
97
|
-
if (answers.codegraph) {
|
|
98
|
-
written.push(writeFile(join(cwd, '.vscode', 'mcp.json'), JSON.stringify(codegraphMcpJson(), null, 2), args));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
107
|
return written;
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -144,34 +150,10 @@ function buildOpencodeConfig({ answers, orchestratorModel }) {
|
|
|
144
150
|
subagent_depth: 2,
|
|
145
151
|
};
|
|
146
152
|
|
|
147
|
-
if (answers.codegraph) {
|
|
148
|
-
config.mcp = {
|
|
149
|
-
codegraph: {
|
|
150
|
-
type: 'local',
|
|
151
|
-
command: ['codegraph', 'serve', '--mcp'],
|
|
152
|
-
enabled: true,
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
153
|
return config;
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
function codegraphMcpJson() {
|
|
161
|
-
return {
|
|
162
|
-
inputs: [],
|
|
163
|
-
servers: {
|
|
164
|
-
codegraph: {
|
|
165
|
-
command: 'codegraph',
|
|
166
|
-
args: ['serve', '--mcp'],
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
156
|
function buildVars(a) {
|
|
173
|
-
const codegraphBlock = a.codegraph ? CODEGRAPH_BLOCK : '';
|
|
174
|
-
|
|
175
157
|
return {
|
|
176
158
|
PROJECT_NAME: a.projectName,
|
|
177
159
|
ONE_LINER: a.oneLiner || 'TODO — write a one-line vision statement.',
|
|
@@ -190,7 +172,6 @@ ${a.docs}
|
|
|
190
172
|
` : '',
|
|
191
173
|
DATE: new Date().toISOString().slice(0, 10),
|
|
192
174
|
PERSONA_BLOCK: a.snark ? PERSONA_BLOCK : '',
|
|
193
|
-
CODEGRAPH_BLOCK: codegraphBlock,
|
|
194
175
|
};
|
|
195
176
|
}
|
|
196
177
|
|
|
@@ -221,22 +202,6 @@ const PERSONA_BLOCK = `# Persona
|
|
|
221
202
|
|
|
222
203
|
`;
|
|
223
204
|
|
|
224
|
-
const CODEGRAPH_BLOCK = `
|
|
225
|
-
<!-- CODEGRAPH_START -->
|
|
226
|
-
## CodeGraph
|
|
227
|
-
|
|
228
|
-
This project is configured to use [CodeGraph](https://codegraph.ru) for graph-backed codebase context.
|
|
229
|
-
When you need to understand relationships, call paths, or impacts, use:
|
|
230
|
-
|
|
231
|
-
\`\`\`
|
|
232
|
-
codegraph explore "<your question>"
|
|
233
|
-
\`\`\`
|
|
234
|
-
|
|
235
|
-
The CodeGraph MCP server is registered in the project config. Run \`codegraph init\` in this directory
|
|
236
|
-
if the project has not been indexed yet.
|
|
237
|
-
<!-- CODEGRAPH_END -->
|
|
238
|
-
`;
|
|
239
|
-
|
|
240
205
|
export function summarize(written, cwd) {
|
|
241
206
|
const lines = [];
|
|
242
207
|
for (const w of written) {
|
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
|
+
};
|