@worca/app 0.0.1
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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/core/plugins-lock.mjs
|
|
2
|
+
// Plugin on-disk layout roots + plugins.lock.json (plugin spec §5). The lock is
|
|
3
|
+
// a diffable FILE, deliberately NOT a worca-cc.db table: the shared-DB
|
|
4
|
+
// cross-branch user_version stamping is a recorded hazard (db.mjs
|
|
5
|
+
// reconcileSchema), and the plugin layer is machine-global like ~/.worca-cc/agents.
|
|
6
|
+
// Lock shape: { [name]: { repo, subdir, pinnedSha, version, enabled,
|
|
7
|
+
// installedAt, linked?: true, ...unknown keys preserved verbatim } }.
|
|
8
|
+
// Sync IO mirrors settings.mjs: reads never throw; write is temp+rename atomic
|
|
9
|
+
// (the settings.mjs:89-92 idiom, sync variant).
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { worcaHome } from './projects.mjs';
|
|
15
|
+
|
|
16
|
+
/** Anti-traversal guard: a plugin name is a bare kebab dir stem, never a path. */
|
|
17
|
+
export const DIR_NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
18
|
+
function safeName(name) {
|
|
19
|
+
const n = String(name ?? '');
|
|
20
|
+
if (!DIR_NAME_RE.test(n)) throw new Error(`invalid plugin name "${n}"`);
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pluginsRoot() { return join(worcaHome(), 'plugins'); }
|
|
25
|
+
export function pluginDir(name) { return join(pluginsRoot(), safeName(name)); }
|
|
26
|
+
export function pluginCurrentDir(name) { return join(pluginDir(name), 'current'); }
|
|
27
|
+
export function pluginDataDir(name) { return join(pluginDir(name), 'data'); }
|
|
28
|
+
export function pluginsLockFile() { return join(pluginsRoot(), 'plugins.lock.json'); }
|
|
29
|
+
|
|
30
|
+
/** Read the lock; missing/corrupt/non-object -> {}. Entries are NOT normalized:
|
|
31
|
+
* unknown keys written by newer worca-cc versions survive read-modify-write cycles. */
|
|
32
|
+
export function readPluginsLock() {
|
|
33
|
+
try {
|
|
34
|
+
const v = JSON.parse(readFileSync(pluginsLockFile(), 'utf8'));
|
|
35
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Atomic write (temp+rename). Creates the plugins root on first use. */
|
|
42
|
+
export function writePluginsLock(lock) {
|
|
43
|
+
const file = pluginsLockFile();
|
|
44
|
+
mkdirSync(pluginsRoot(), { recursive: true });
|
|
45
|
+
const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
46
|
+
writeFileSync(tmp, JSON.stringify(lock ?? {}, null, 2) + '\n', 'utf8');
|
|
47
|
+
renameSync(tmp, file);
|
|
48
|
+
return lock ?? {};
|
|
49
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// src/core/preflight-node.mjs (FINAL)
|
|
2
|
+
// Runtime preflight for the node:sqlite backend. Called once at each entry point
|
|
3
|
+
// (src/cli/worca-cc.mjs, ui/server.mjs) BEFORE any DB is opened. Guarantees the
|
|
4
|
+
// process is on a Node with a FLAGLESS node:sqlite (>= 22.13.0) and that the module
|
|
5
|
+
// actually imports, then fails fast with an actionable message instead of letting a
|
|
6
|
+
// cryptic error surface deep inside db.mjs.
|
|
7
|
+
//
|
|
8
|
+
// Pure, dependency-free (semver-ish compare is hand-rolled — no new dependency,
|
|
9
|
+
// matching the project's "Node built-ins + express + ws" rule). Deliberately does
|
|
10
|
+
// NOT import db.mjs: the version check must not open the DB.
|
|
11
|
+
//
|
|
12
|
+
// NOTE: distinct from src/core/preflight.mjs (optional-TOOL detection, detectTools()).
|
|
13
|
+
// This is the Node-RUNTIME preflight.
|
|
14
|
+
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
/** Minimum Node that ships a flagless node:sqlite (v22 LTS 'Jod' backport / v23.4+). */
|
|
19
|
+
export const MIN_NODE = '22.13.0';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a version string into [major, minor, patch] integers. Tolerant: ignores a
|
|
25
|
+
* leading "v", ignores any pre-release/build suffix, treats missing/non-numeric
|
|
26
|
+
* components as 0.
|
|
27
|
+
* @param {string} v
|
|
28
|
+
* @returns {[number, number, number]}
|
|
29
|
+
*/
|
|
30
|
+
function parse(v) {
|
|
31
|
+
const core = String(v).trim().replace(/^v/i, '').split(/[-+]/, 1)[0];
|
|
32
|
+
const [maj = '0', min = '0', pat = '0'] = core.split('.');
|
|
33
|
+
const n = (s) => {
|
|
34
|
+
const x = parseInt(s, 10);
|
|
35
|
+
return Number.isFinite(x) ? x : 0;
|
|
36
|
+
};
|
|
37
|
+
return [n(maj), n(min), n(pat)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compare two version strings numerically by major.minor.patch.
|
|
42
|
+
* @param {string} a
|
|
43
|
+
* @param {string} b
|
|
44
|
+
* @returns {-1 | 0 | 1}
|
|
45
|
+
*/
|
|
46
|
+
export function cmpVersions(a, b) {
|
|
47
|
+
const pa = parse(a);
|
|
48
|
+
const pb = parse(b);
|
|
49
|
+
for (let i = 0; i < 3; i++) {
|
|
50
|
+
if (pa[i] > pb[i]) return 1;
|
|
51
|
+
if (pa[i] < pb[i]) return -1;
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is `actual` (default: the running process's Node) >= MIN_NODE?
|
|
58
|
+
* @param {string} [actual=process.versions.node]
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function meetsMinNode(actual = process.versions.node) {
|
|
62
|
+
return cmpVersions(actual, MIN_NODE) >= 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Confirm node:sqlite imports and exposes DatabaseSync. Returns null on success or a
|
|
67
|
+
* short error string on failure (never throws). node:sqlite is a builtin, so require
|
|
68
|
+
* resolves synchronously even in this ESM module — keeping preflight a plain sync
|
|
69
|
+
* call that matches the synchronous data layer.
|
|
70
|
+
* @returns {string | null}
|
|
71
|
+
*/
|
|
72
|
+
export function probeSqlite() {
|
|
73
|
+
try {
|
|
74
|
+
const mod = require('node:sqlite');
|
|
75
|
+
if (!mod || typeof mod.DatabaseSync !== 'function') {
|
|
76
|
+
return 'node:sqlite imported but DatabaseSync is missing';
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return err && err.message ? err.message : String(err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Full runtime preflight. If the Node version is too old OR node:sqlite cannot be
|
|
86
|
+
* loaded, print a clear, actionable message to stderr and exit non-zero (code 1).
|
|
87
|
+
* Otherwise return normally. Called once per entry point before opening the DB.
|
|
88
|
+
*
|
|
89
|
+
* Kept side-effecting (writes stderr, calls process.exit) on purpose: it is the
|
|
90
|
+
* single fail-fast gate. The pure helpers above are what the unit tests exercise.
|
|
91
|
+
*
|
|
92
|
+
* @param {{ exit?: (code:number)=>never, err?: (s:string)=>void }} [io] injectable
|
|
93
|
+
* for tests; defaults to process.exit / process.stderr.write.
|
|
94
|
+
*/
|
|
95
|
+
export function preflightNode(io = {}) {
|
|
96
|
+
const exit = io.exit || ((c) => process.exit(c));
|
|
97
|
+
const err = io.err || ((s) => process.stderr.write(s));
|
|
98
|
+
const actual = process.versions.node;
|
|
99
|
+
|
|
100
|
+
if (!meetsMinNode(actual)) {
|
|
101
|
+
err(
|
|
102
|
+
`\nworca: Node ${actual} is too old.\n` +
|
|
103
|
+
` Worca CC stores its state in SQLite via the built-in node:sqlite module,\n` +
|
|
104
|
+
` which is only available (flag-free) on Node >= ${MIN_NODE}.\n` +
|
|
105
|
+
` Please upgrade Node (e.g. \`nvm install --lts\` or use the bundled .nvmrc:\n` +
|
|
106
|
+
` \`nvm use\`), then re-run.\n\n`
|
|
107
|
+
);
|
|
108
|
+
return exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sqliteErr = probeSqlite();
|
|
112
|
+
if (sqliteErr) {
|
|
113
|
+
err(
|
|
114
|
+
`\nworca: the built-in node:sqlite module could not be loaded.\n` +
|
|
115
|
+
` (${sqliteErr})\n` +
|
|
116
|
+
` Worca CC requires Node >= ${MIN_NODE} with node:sqlite available.\n` +
|
|
117
|
+
` You are on Node ${actual}. Upgrade Node (\`nvm use\` with the bundled\n` +
|
|
118
|
+
` .nvmrc) and re-run.\n\n`
|
|
119
|
+
);
|
|
120
|
+
return exit(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// src/core/preflight.mjs
|
|
2
|
+
// Detect optional knowledge-graph tooling in the user's environment so agents
|
|
3
|
+
// can be told to use it. Two tools are supported:
|
|
4
|
+
// - graphify (github.com/safishamsi/graphify)
|
|
5
|
+
// - code-review-graph (github.com/tirth8205/code-review-graph)
|
|
6
|
+
//
|
|
7
|
+
// Rule: if BOTH are present, prefer graphify.
|
|
8
|
+
//
|
|
9
|
+
// Every probe is wrapped so that a missing binary, missing file, or failing
|
|
10
|
+
// subprocess resolves to `false` and NEVER throws.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { access } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { constants as FS } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run a command and resolve to its trimmed stdout, or null on any failure.
|
|
20
|
+
* Times out defensively so a hung probe can't block preflight.
|
|
21
|
+
*/
|
|
22
|
+
function execSafe(cmd, args, { timeout = 4000 } = {}) {
|
|
23
|
+
return new Promise((resolveP) => {
|
|
24
|
+
let child;
|
|
25
|
+
try {
|
|
26
|
+
child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
27
|
+
} catch {
|
|
28
|
+
resolveP(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let out = '';
|
|
32
|
+
let settled = false;
|
|
33
|
+
const done = (val) => {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
resolveP(val);
|
|
38
|
+
};
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
try {
|
|
41
|
+
child.kill('SIGKILL');
|
|
42
|
+
} catch {
|
|
43
|
+
/* ignore */
|
|
44
|
+
}
|
|
45
|
+
done(null);
|
|
46
|
+
}, timeout);
|
|
47
|
+
|
|
48
|
+
child.stdout?.on('data', (d) => {
|
|
49
|
+
out += d.toString();
|
|
50
|
+
});
|
|
51
|
+
// Drain stderr so the child can't block on a full pipe.
|
|
52
|
+
child.stderr?.on('data', () => {});
|
|
53
|
+
child.on('error', () => done(null));
|
|
54
|
+
child.on('close', (code) => done(code === 0 ? out.trim() : null));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True if `which <name>` resolves to a path. */
|
|
59
|
+
async function whichOk(name) {
|
|
60
|
+
const out = await execSafe('which', [name]);
|
|
61
|
+
return typeof out === 'string' && out.length > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True if a filesystem path is accessible. */
|
|
65
|
+
async function pathExists(p) {
|
|
66
|
+
try {
|
|
67
|
+
await access(p, FS.F_OK);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True if `pipx list` output mentions `needle` (case-insensitive). */
|
|
75
|
+
async function pipxMentions(needle) {
|
|
76
|
+
const out = await execSafe('pipx', ['list']);
|
|
77
|
+
return !!out && out.toLowerCase().includes(needle.toLowerCase());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True if `pip show <pkg>` (or pip3) reports an installed package. */
|
|
81
|
+
async function pipShows(pkg) {
|
|
82
|
+
for (const pip of ['pip', 'pip3']) {
|
|
83
|
+
const out = await execSafe(pip, ['show', pkg]);
|
|
84
|
+
if (out && out.toLowerCase().includes('name:')) return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detect graphify and HOW it is installed. Returns:
|
|
91
|
+
* { found: boolean, kind: 'cli'|'skill'|'output-cached'|null }
|
|
92
|
+
*
|
|
93
|
+
* The `kind` controls the instruction wording so the agent picks the right
|
|
94
|
+
* dispatch mechanism (Bash CLI vs Skill tool vs read cached output). Priority:
|
|
95
|
+
* 1. `which graphify` → 'cli' (executable on PATH)
|
|
96
|
+
* 2. pipx / pip shows graphify → 'cli' (importable / on PATH soon)
|
|
97
|
+
* 3. ~/.claude/skills/graphify/ → 'skill' (Claude Code skill, no binary)
|
|
98
|
+
* 4. <projectDir>/graphify-out → 'output-cached' (graph exists from prior run)
|
|
99
|
+
*
|
|
100
|
+
* Ordering matters: a host with both a CLI and a skill prefers the CLI because
|
|
101
|
+
* an agent can drive it directly. An `output-cached` win is the weakest — it
|
|
102
|
+
* means a graph exists but we don't know how it was built.
|
|
103
|
+
*/
|
|
104
|
+
async function detectGraphify(projectDir) {
|
|
105
|
+
if (await whichOk('graphify')) return { found: true, kind: 'cli' };
|
|
106
|
+
if (await pipxMentions('graphify')) return { found: true, kind: 'cli' };
|
|
107
|
+
if (await pipShows('graphify')) return { found: true, kind: 'cli' };
|
|
108
|
+
if (await pathExists(join(homedir(), '.claude', 'skills', 'graphify', 'SKILL.md'))) {
|
|
109
|
+
return { found: true, kind: 'skill' };
|
|
110
|
+
}
|
|
111
|
+
if (await pathExists(join(projectDir, 'graphify-out'))) {
|
|
112
|
+
return { found: true, kind: 'output-cached' };
|
|
113
|
+
}
|
|
114
|
+
return { found: false, kind: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Detect code-review-graph. ANY of:
|
|
119
|
+
* - `which code-review-graph`
|
|
120
|
+
* - pipx list / pip show code-review-graph mentions it
|
|
121
|
+
* - a cloned dir named code-review-graph reachable (cwd or home)
|
|
122
|
+
*/
|
|
123
|
+
async function detectCodeReviewGraph(projectDir) {
|
|
124
|
+
const checks = await Promise.all([
|
|
125
|
+
whichOk('code-review-graph'),
|
|
126
|
+
pipxMentions('code-review-graph'),
|
|
127
|
+
pipShows('code-review-graph'),
|
|
128
|
+
pathExists(join(projectDir, 'code-review-graph')),
|
|
129
|
+
pathExists(join(homedir(), 'code-review-graph')),
|
|
130
|
+
]);
|
|
131
|
+
return checks.some(Boolean);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build the human-readable instruction injected into agent system prompts.
|
|
136
|
+
* The wording is branched by `kind` so the agent uses the right dispatch
|
|
137
|
+
* mechanism (Bash CLI, Skill tool, or simply reading cached output).
|
|
138
|
+
*/
|
|
139
|
+
export function buildInstruction(tool, kind) {
|
|
140
|
+
if (tool === 'graphify') {
|
|
141
|
+
if (kind === 'skill') {
|
|
142
|
+
return (
|
|
143
|
+
'A code knowledge-graph SKILL named "graphify" is available. It is a ' +
|
|
144
|
+
'Claude Code skill, NOT a shell command — do NOT try to run it via Bash. ' +
|
|
145
|
+
'BEFORE analyzing or planning, invoke it via the `Skill` tool, e.g. ' +
|
|
146
|
+
'`Skill(skill: "graphify", args: "<your question about the code>")`. ' +
|
|
147
|
+
'Use its output to ground your work in real codebase structure rather ' +
|
|
148
|
+
'than assumptions. A cached graph may already exist at ' +
|
|
149
|
+
'graphify-out/ — consult it if present.'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (kind === 'output-cached') {
|
|
153
|
+
return (
|
|
154
|
+
'A graphify knowledge graph has ALREADY been built for this project at ' +
|
|
155
|
+
'graphify-out/. No graphify binary or Skill was detected, so ' +
|
|
156
|
+
'do NOT try to invoke or rebuild it — just READ the cached output. BEFORE ' +
|
|
157
|
+
'analyzing or planning, read graphify-out/GRAPH_REPORT.md for the overview, ' +
|
|
158
|
+
'then open graphify-out/graph.json to trace specific symbols and their ' +
|
|
159
|
+
'edges, so your understanding is grounded in real structure rather than ' +
|
|
160
|
+
'assumptions.'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
// 'cli' (or unspecified, treated as CLI for safety).
|
|
164
|
+
// `graphify query` does literal token-matching to pick BFS start nodes, so a
|
|
165
|
+
// natural-language PHRASE matches almost nothing (only stray tokens, often in
|
|
166
|
+
// test files) and yields noise — which makes agents give up and fall back to
|
|
167
|
+
// grep. The instruction therefore teaches one-concept-at-a-time querying and
|
|
168
|
+
// points at the already-built graph instead of a nonexistent build command.
|
|
169
|
+
return (
|
|
170
|
+
'A code knowledge-graph CLI named "graphify" is available on PATH, and a ' +
|
|
171
|
+
'graph has ALREADY been built at graphify-out/ (do NOT rebuild). ' +
|
|
172
|
+
'BEFORE analyzing or planning, ground yourself in the real codebase: first ' +
|
|
173
|
+
'read graphify-out/GRAPH_REPORT.md for the overview, then query the graph ' +
|
|
174
|
+
'via Bash. Query ONE concept at a time — a single symbol or term, NOT a ' +
|
|
175
|
+
'natural-language phrase (phrases match almost nothing and return noise). ' +
|
|
176
|
+
'Useful commands:\n' +
|
|
177
|
+
' graphify query "<concept>" # BFS neighborhood of one term, e.g. "effort"\n' +
|
|
178
|
+
' graphify explain "<symbol>" # one node plus its direct connections\n' +
|
|
179
|
+
' graphify path "<A>" "<B>" # how two symbols are connected\n' +
|
|
180
|
+
'Run several single-concept queries rather than one long one. Use ' +
|
|
181
|
+
'Glob/Grep/Read only for what the graph cannot answer.'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (tool === 'code-review-graph') {
|
|
185
|
+
return (
|
|
186
|
+
'A code-analysis CLI named "code-review-graph" is available in this ' +
|
|
187
|
+
'environment. Run it via Bash to build a graph of the codebase and inform ' +
|
|
188
|
+
'your analysis, planning, and review with its output rather than relying ' +
|
|
189
|
+
'on assumptions about code structure.'
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Instruction for agents when a fresh AST graph has been built INSIDE the
|
|
197
|
+
* current worktree at ./graphify-out/. Paths are cwd-relative (agents run with
|
|
198
|
+
* cwd=worktree); the AST-only nature is called out so agents calibrate.
|
|
199
|
+
*/
|
|
200
|
+
export function worktreeGraphInstruction() {
|
|
201
|
+
return (
|
|
202
|
+
'A code knowledge-graph CLI named "graphify" is available, and a fresh graph ' +
|
|
203
|
+
'for THIS worktree has been built at graphify-out/ (relative to your working ' +
|
|
204
|
+
'directory). It is an AST-only structural graph (symbols, files, and their ' +
|
|
205
|
+
'structural relationships) with NO semantic/inferred edges. BEFORE analyzing ' +
|
|
206
|
+
'or planning, ground yourself in the real code: first read ' +
|
|
207
|
+
'graphify-out/GRAPH_REPORT.md for the overview, then query the graph via Bash. ' +
|
|
208
|
+
'Query ONE concept at a time — a single symbol or term, NOT a natural-language ' +
|
|
209
|
+
'phrase (phrases match almost nothing and return noise). Useful commands:\n' +
|
|
210
|
+
' graphify query "<concept>" # BFS neighborhood of one term\n' +
|
|
211
|
+
' graphify explain "<symbol>" # one node plus its direct connections\n' +
|
|
212
|
+
' graphify path "<A>" "<B>" # how two symbols are connected\n' +
|
|
213
|
+
'Run several single-concept queries. Use Glob/Grep/Read for anything the ' +
|
|
214
|
+
'graph cannot answer.'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Run `graphify update <dir>` headlessly to build/refresh an AST graph at
|
|
220
|
+
* <dir>/graphify-out/. Spawned with cwd=cwd so graphify's stray cwd-relative
|
|
221
|
+
* manifest write lands inside the same worktree, never the main repo. Bounded
|
|
222
|
+
* by timeoutMs (macOS has no timeout(1)); on overrun the child is SIGKILLed.
|
|
223
|
+
* Never throws. Resolves { ok, code, timedOut, stderr }.
|
|
224
|
+
*/
|
|
225
|
+
export function runGraphifyUpdate({ dir, cwd, timeoutMs = 120000 } = {}) {
|
|
226
|
+
return new Promise((resolveP) => {
|
|
227
|
+
let child;
|
|
228
|
+
try {
|
|
229
|
+
child = spawn('graphify', ['update', dir], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
resolveP({ ok: false, code: -1, timedOut: false, stderr: err.message });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
let stderr = '';
|
|
235
|
+
let settled = false;
|
|
236
|
+
let timedOut = false;
|
|
237
|
+
const done = (val) => {
|
|
238
|
+
if (settled) return;
|
|
239
|
+
settled = true;
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
resolveP(val);
|
|
242
|
+
};
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
timedOut = true;
|
|
245
|
+
try {
|
|
246
|
+
child.kill('SIGKILL');
|
|
247
|
+
} catch {
|
|
248
|
+
/* ignore */
|
|
249
|
+
}
|
|
250
|
+
done({ ok: false, code: -1, timedOut: true, stderr: 'graphify update timed out' });
|
|
251
|
+
}, timeoutMs);
|
|
252
|
+
child.stdout?.on('data', () => {});
|
|
253
|
+
child.stderr?.on('data', (d) => {
|
|
254
|
+
stderr += d.toString();
|
|
255
|
+
});
|
|
256
|
+
child.on('error', (err) => done({ ok: false, code: -1, timedOut, stderr: stderr || err.message }));
|
|
257
|
+
child.on('close', (code) => done({ ok: code === 0, code: code ?? -1, timedOut, stderr }));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Detect optional tooling for a project directory.
|
|
263
|
+
* @param {string} projectDir
|
|
264
|
+
* @returns {Promise<{
|
|
265
|
+
* graphify:boolean,
|
|
266
|
+
* codeReviewGraph:boolean,
|
|
267
|
+
* tool:('graphify'|'code-review-graph'|null),
|
|
268
|
+
* kind:('cli'|'skill'|'output-cached'|null),
|
|
269
|
+
* instruction:string,
|
|
270
|
+
* }>}
|
|
271
|
+
*/
|
|
272
|
+
export async function detectTools(projectDir) {
|
|
273
|
+
const dir = projectDir || process.cwd();
|
|
274
|
+
let graphifyInfo = { found: false, kind: null };
|
|
275
|
+
let codeReviewGraph = false;
|
|
276
|
+
try {
|
|
277
|
+
[graphifyInfo, codeReviewGraph] = await Promise.all([
|
|
278
|
+
detectGraphify(dir),
|
|
279
|
+
detectCodeReviewGraph(dir),
|
|
280
|
+
]);
|
|
281
|
+
} catch {
|
|
282
|
+
// Absolute belt-and-suspenders: detection must never throw.
|
|
283
|
+
graphifyInfo = { found: false, kind: null };
|
|
284
|
+
codeReviewGraph = false;
|
|
285
|
+
}
|
|
286
|
+
// BOTH installed => prefer graphify.
|
|
287
|
+
const tool = graphifyInfo.found ? 'graphify' : codeReviewGraph ? 'code-review-graph' : null;
|
|
288
|
+
const kind = tool === 'graphify' ? graphifyInfo.kind : tool === 'code-review-graph' ? 'cli' : null;
|
|
289
|
+
return {
|
|
290
|
+
graphify: graphifyInfo.found,
|
|
291
|
+
codeReviewGraph,
|
|
292
|
+
tool,
|
|
293
|
+
kind,
|
|
294
|
+
instruction: buildInstruction(tool, kind),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Detect tooling for EACH project in a workspace, in parallel. A trivial
|
|
300
|
+
* Promise.all over detectTools, returning a Map keyed by projectDir so the
|
|
301
|
+
* orchestrator can grant each member its own per-project graph instruction.
|
|
302
|
+
* detectTools never throws, so this never throws. Member order is irrelevant
|
|
303
|
+
* (the Map is keyed by dir); the caller iterates by sorted projectKey.
|
|
304
|
+
* @param {string[]} projectDirs
|
|
305
|
+
* @returns {Promise<Map<string,{tool,kind,instruction}>>}
|
|
306
|
+
*/
|
|
307
|
+
export async function detectToolsPerProject(projectDirs) {
|
|
308
|
+
const dirs = Array.isArray(projectDirs) ? projectDirs : [];
|
|
309
|
+
const infos = await Promise.all(dirs.map((dir) => detectTools(dir)));
|
|
310
|
+
const map = new Map();
|
|
311
|
+
dirs.forEach((dir, i) => map.set(dir, infos[i]));
|
|
312
|
+
return map;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* §8.18 / Phase-0 gate V5: parse `claude --help` ONCE per run and assert that this
|
|
317
|
+
* build advertises `--mcp-config`. Version drift, not a CLI unknown — V5 PASSED on
|
|
318
|
+
* the development machine (`claude --help` line 113,
|
|
319
|
+
* `docs/run-root-verification.md`), so this ships as insurance for OTHER machines.
|
|
320
|
+
*
|
|
321
|
+
* On absence the caller degrades gracefully (skip the flag, warn loudly naming the
|
|
322
|
+
* required version >= 2.1.220) rather than failing the run: R1(a)/(c) still hold via
|
|
323
|
+
* the cwd and ancestor mechanisms, and R1(b) is reported as DEGRADED.
|
|
324
|
+
*
|
|
325
|
+
* Deliberately NO `addDir` field: `--add-dir` is not probed because no shipped
|
|
326
|
+
* feature uses it (§5.3 — it needs `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1`
|
|
327
|
+
* to carry memory at all, E2), and a probe field no consumer reads is dead weight.
|
|
328
|
+
*
|
|
329
|
+
* Never throws: a missing binary / hung process resolves to
|
|
330
|
+
* `{ mcpConfig: false, version: null }`, which the caller treats the same as an old
|
|
331
|
+
* CLI. (A machine with no `claude` on PATH fails loudly at the first node anyway.)
|
|
332
|
+
* @param {string} [bin] the claude binary (defaults to `claude`)
|
|
333
|
+
* @returns {Promise<{mcpConfig: boolean, version: string|null}>}
|
|
334
|
+
*/
|
|
335
|
+
export async function probeClaudeCapabilities(bin = 'claude') {
|
|
336
|
+
const exe = bin && String(bin).trim() ? String(bin).trim() : 'claude';
|
|
337
|
+
const help = await execSafe(exe, ['--help'], { timeout: 8000 });
|
|
338
|
+
const raw = await execSafe(exe, ['--version'], { timeout: 8000 });
|
|
339
|
+
const version = raw ? (/(\d+\.\d+\.\d+)/.exec(raw)?.[1] ?? raw.split(/\s+/)[0] ?? null) : null;
|
|
340
|
+
return { mcpConfig: !!help && help.includes('--mcp-config'), version };
|
|
341
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/core/projects.mjs
|
|
2
|
+
// Named project registry: a small persistent list of { name, path } entries the
|
|
3
|
+
// web UI uses to populate its project dropdown.
|
|
4
|
+
//
|
|
5
|
+
// node:sqlite migration: now persisted in the `projects` table; path helpers vestigial.
|
|
6
|
+
//
|
|
7
|
+
// Reads never throw: a fresh/empty DB yields an empty list. Writes validate then
|
|
8
|
+
// persist inside a single db.mjs tx(). Each row is keyed by projectKey(path)
|
|
9
|
+
// (store.mjs), so every worktree of a repo maps to one row.
|
|
10
|
+
|
|
11
|
+
import { existsSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { getWorcaRoot, defaultRoot } from './settings.mjs';
|
|
14
|
+
import { getDb, prepare, tx } from './db.mjs';
|
|
15
|
+
import { projectKey } from './store.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Absolute path to the .worca-cc data directory. Base resolution precedence:
|
|
19
|
+
* 1. WORCA_HOME env (non-empty) — tests/smoke isolation + CLI override
|
|
20
|
+
* 2. persisted Settings root — the user-chosen "Worca CC root folder"
|
|
21
|
+
* 3. defaultRoot() — the OS home
|
|
22
|
+
* Read fresh every call, so a saved root applies to new operations w/o restart.
|
|
23
|
+
*/
|
|
24
|
+
export function worcaHome() {
|
|
25
|
+
const env = process.env.WORCA_HOME;
|
|
26
|
+
if (!(env && env.trim()) && process.env.NODE_TEST_CONTEXT &&
|
|
27
|
+
!process.env.WORCA_TEST_ALLOW_HOME_FALLBACK) {
|
|
28
|
+
// Under the node:test runner the real ~/.worca-cc must be unreachable: a test
|
|
29
|
+
// (or a fire-and-forget write outliving its teardown) that resolves the home
|
|
30
|
+
// with no WORCA_HOME set would silently pollute the user's real store.
|
|
31
|
+
// WORCA_TEST_ALLOW_HOME_FALLBACK opts out for tests that exercise the
|
|
32
|
+
// settings/home fallback tiers and sandbox HOME/USERPROFILE themselves.
|
|
33
|
+
throw new Error(
|
|
34
|
+
'worcaHome(): WORCA_HOME is unset under the node:test runner — ' +
|
|
35
|
+
'tests must never touch the real ~/.worca-cc (use test/helpers/temp-home.mjs#useTempHome)'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const base = env && env.trim() ? env : (getWorcaRoot() || defaultRoot());
|
|
39
|
+
return join(resolve(base), '.worca-cc');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Absolute path to the (legacy) registry file.
|
|
44
|
+
*
|
|
45
|
+
* VESTIGIAL (node:sqlite migration §0.6): the registry now lives in the `projects`
|
|
46
|
+
* table, not this JSON file. This export is retained only for backward
|
|
47
|
+
* import-compatibility (test imports); it no longer describes where data lives.
|
|
48
|
+
*/
|
|
49
|
+
export function projectsFile() {
|
|
50
|
+
return join(worcaHome(), 'projects.json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Expand a leading ~ and resolve to an absolute path. Mirrors the web server's
|
|
55
|
+
* historical resolveProjectDir so the registry and runs agree on a path.
|
|
56
|
+
* @param {string} input
|
|
57
|
+
* @returns {string|null} absolute path, or null for empty/non-string input
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeProjectPath(input) {
|
|
60
|
+
if (!input || typeof input !== 'string' || !input.trim()) return null;
|
|
61
|
+
let p = input.trim();
|
|
62
|
+
if (p.startsWith('~')) p = join(process.env.HOME || process.env.USERPROFILE || '', p.slice(1));
|
|
63
|
+
return resolve(p);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True when the path exists and is a directory. */
|
|
67
|
+
function isDir(p) {
|
|
68
|
+
try {
|
|
69
|
+
return statSync(p).isDirectory();
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the raw registry rows from the DB, ordered by creation then name for a
|
|
77
|
+
* stable list. Never throws (a fresh DB simply has no rows). The DB call is
|
|
78
|
+
* synchronous (node:sqlite); we return the plain array.
|
|
79
|
+
* @returns {Array<{key:string, name:string, path:string}>}
|
|
80
|
+
*/
|
|
81
|
+
function readRows() {
|
|
82
|
+
getDb(); // ensure the singleton is open + migrated before preparing
|
|
83
|
+
return prepare(
|
|
84
|
+
'SELECT key, name, path FROM projects ORDER BY created_at, name'
|
|
85
|
+
).all();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List saved projects, each annotated with a runtime `exists` flag (true when the
|
|
90
|
+
* path is an existing directory). The flag is computed, never persisted. Reads
|
|
91
|
+
* from the projects table; never throws.
|
|
92
|
+
* @returns {Promise<Array<{name:string, path:string, exists:boolean}>>}
|
|
93
|
+
*/
|
|
94
|
+
export async function listProjects() {
|
|
95
|
+
return readRows().map((e) => ({ name: e.name, path: e.path, exists: isDir(e.path) }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Number of registered projects (missing-on-disk rows included, matching the
|
|
100
|
+
* Projects list + its sidebar count). Cheap COUNT(*); never throws.
|
|
101
|
+
* Uses the bare `prepare` already imported at projects.mjs:14.
|
|
102
|
+
* @returns {number}
|
|
103
|
+
*/
|
|
104
|
+
export function countProjects() {
|
|
105
|
+
getDb(); // ensure the singleton is open + migrated before preparing
|
|
106
|
+
const row = prepare('SELECT COUNT(*) AS n FROM projects').get();
|
|
107
|
+
return row ? Number(row.n) : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add a project. Validates and persists to the projects table. Returns the
|
|
112
|
+
* updated annotated list. Keyed by projectKey(path) (store.mjs), so every worktree
|
|
113
|
+
* of a repo maps to one row. Name uniqueness is case-insensitive (checked here AND
|
|
114
|
+
* backed by the NOCASE unique index).
|
|
115
|
+
* @param {{name:string, path:string}} input
|
|
116
|
+
* @throws {Error} on empty name/path, a path that exists but is not a directory,
|
|
117
|
+
* a duplicate name (case-insensitive), or a duplicate path/key.
|
|
118
|
+
*/
|
|
119
|
+
export async function addProject(input) {
|
|
120
|
+
const name = (input && typeof input.name === 'string' ? input.name : '').trim();
|
|
121
|
+
if (!name) throw new Error('project name is required');
|
|
122
|
+
const path = normalizeProjectPath(input && input.path);
|
|
123
|
+
if (!path) throw new Error('project path is required');
|
|
124
|
+
// A path that exists must be a directory; a non-existent path is allowed (the run
|
|
125
|
+
// creates it), matching the orchestrator's mkdir-on-run behavior.
|
|
126
|
+
if (existsSync(path) && !isDir(path)) throw new Error('path is not a directory');
|
|
127
|
+
|
|
128
|
+
const key = projectKey(path);
|
|
129
|
+
const createdAt = new Date().toISOString();
|
|
130
|
+
tx(() => {
|
|
131
|
+
// Case-insensitive duplicate-name guard (matches the legacy check + the index).
|
|
132
|
+
const clash = prepare('SELECT 1 FROM projects WHERE name = ? COLLATE NOCASE').get(name);
|
|
133
|
+
if (clash) throw new Error(`a project named "${name}" already exists`);
|
|
134
|
+
// Same path -> same key -> PK collision; report it cleanly rather than crashing.
|
|
135
|
+
const samePath = prepare('SELECT 1 FROM projects WHERE key = ?').get(key);
|
|
136
|
+
if (samePath) throw new Error('this project path is already registered');
|
|
137
|
+
prepare(
|
|
138
|
+
'INSERT INTO projects (key, name, path, created_at) VALUES (?, ?, ?, ?)'
|
|
139
|
+
).run(key, name, path, createdAt);
|
|
140
|
+
});
|
|
141
|
+
return listProjects();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove a project by name (case-insensitive). Absent name is a no-op.
|
|
146
|
+
* @param {string} name
|
|
147
|
+
* @returns {Promise<Array<{name:string, path:string, exists:boolean}>>}
|
|
148
|
+
*/
|
|
149
|
+
export async function removeProject(name) {
|
|
150
|
+
const key = (typeof name === 'string' ? name : '').trim();
|
|
151
|
+
if (key) {
|
|
152
|
+
tx(() => {
|
|
153
|
+
prepare('DELETE FROM projects WHERE name = ? COLLATE NOCASE').run(key);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return listProjects();
|
|
157
|
+
}
|