@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.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,48 @@
1
+ // src/core/fanout.mjs
2
+ // Bounded-concurrency helper for the orchestrator's OWN per-project IO (worktree
3
+ // setup, graph builds, checkpoints, staging) on a workspace run. This is NOT the
4
+ // agent Task fan-out (that is agent-initiated inside a node, gated by node.fanOut);
5
+ // it is the deterministic, capped parallelism the orchestrator uses to apply the
6
+ // existing single-project machinery N times across member projects.
7
+ //
8
+ // The determinism guarantee: results come back in INPUT order, never completion
9
+ // order, so a workspace run over a sorted-projectKey member list always recombines
10
+ // the same way regardless of which project's git finished first.
11
+
12
+ /**
13
+ * The hard concurrency cap for orchestrator-owned per-project IO.
14
+ * WORCA_FANOUT_CAP overrides; a non-positive / non-numeric value falls back to 4.
15
+ * @returns {number}
16
+ */
17
+ export function fanoutCap() {
18
+ const n = Number(process.env.WORCA_FANOUT_CAP);
19
+ return Number.isFinite(n) && n > 0 ? n : 4;
20
+ }
21
+
22
+ /**
23
+ * Map `items` through async `fn` with at most `cap` running concurrently, returning
24
+ * the results in INPUT order (results[i] === await fn(items[i], i)). A worker pool
25
+ * pulls the next index off a shared cursor, so the cap is a true ceiling on in-flight
26
+ * work; the first rejection rejects the whole call (Promise.all semantics).
27
+ *
28
+ * @template T, R
29
+ * @param {T[]} items
30
+ * @param {number} cap max concurrent invocations (coerced to >= 1)
31
+ * @param {(item: T, index: number) => Promise<R>} fn
32
+ * @returns {Promise<R[]>} results index-aligned with `items`
33
+ */
34
+ export async function mapWithCap(items, cap, fn) {
35
+ const list = Array.isArray(items) ? items : [];
36
+ const results = new Array(list.length);
37
+ if (list.length === 0) return results;
38
+ const limit = Math.max(1, Math.min(Number(cap) || 1, list.length));
39
+ let cursor = 0;
40
+ const worker = async () => {
41
+ while (cursor < list.length) {
42
+ const i = cursor++;
43
+ results[i] = await fn(list[i], i);
44
+ }
45
+ };
46
+ await Promise.all(Array.from({ length: limit }, () => worker()));
47
+ return results;
48
+ }
@@ -0,0 +1,138 @@
1
+ // src/core/folder-dialog.mjs
2
+ // Server-side native OS "choose folder" dialog. A browser <input type=file>
3
+ // cannot reveal absolute directory paths, so the add-project Browse button asks
4
+ // the server (which runs on the user's own machine; worca-cc is localhost-only)
5
+ // to open the platform dialog and report the chosen path.
6
+ //
7
+ // darwin -> osascript `choose folder` (the System Events activate line keeps
8
+ // the dialog frontmost; on first use macOS may show a one-time
9
+ // Automation consent prompt — a denial surfaces as a non-cancel
10
+ // error and degrades to `unsupported`, i.e. the in-app fallback)
11
+ // win32 -> PowerShell System.Windows.Forms.FolderBrowserDialog (-STA)
12
+ // linux -> zenity --file-selection --directory, falling back to kdialog;
13
+ // requires DISPLAY/WAYLAND_DISPLAY (headless -> unsupported)
14
+ //
15
+ // Any failure that is not a recognized user-cancel degrades to
16
+ // { status: 'unsupported' } so the web UI can fall back to its in-app folder
17
+ // browser. WORCA_NO_NATIVE_DIALOG=1 forces that fallback. Like git-info.mjs,
18
+ // every command goes through an injectable runner (_testing) so tests never
19
+ // open a real dialog. Nothing here ever throws.
20
+
21
+ import { spawn } from 'node:child_process';
22
+
23
+ const PROMPT = 'Select a project folder';
24
+ // A dialog waits on a human; give it a long leash, then kill the process so a
25
+ // forgotten dialog cannot pin server resources forever.
26
+ const DIALOG_TIMEOUT_MS = 5 * 60 * 1000;
27
+
28
+ /** Default runner: spawn cmd, resolve { ok, stdout, stderr, code, timedOut }. */
29
+ function defaultRun(cmd, args, { timeout = DIALOG_TIMEOUT_MS } = {}) {
30
+ return new Promise((resolve) => {
31
+ let child;
32
+ try {
33
+ child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
34
+ } catch (err) {
35
+ resolve({ ok: false, stdout: '', stderr: err.message, code: -1, timedOut: false });
36
+ return;
37
+ }
38
+ let stdout = '';
39
+ let stderr = '';
40
+ let settled = false;
41
+ const done = (val) => {
42
+ if (settled) return;
43
+ settled = true;
44
+ clearTimeout(timer);
45
+ resolve(val);
46
+ };
47
+ const timer = setTimeout(() => {
48
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
49
+ done({ ok: false, stdout, stderr: 'dialog timed out', code: -1, timedOut: true });
50
+ }, timeout);
51
+ child.stdout?.on('data', (b) => (stdout += b.toString()));
52
+ child.stderr?.on('data', (b) => (stderr += b.toString()));
53
+ child.on('error', (err) => done({ ok: false, stdout, stderr: stderr || err.message, code: -1, timedOut: false }));
54
+ child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code: code ?? -1, timedOut: false }));
55
+ });
56
+ }
57
+
58
+ const _ov = { runner: null, platform: null, env: null };
59
+ let _inFlight = false;
60
+
61
+ export const _testing = {
62
+ set({ runner = _ov.runner, platform = _ov.platform, env = _ov.env } = {}) {
63
+ _ov.runner = runner;
64
+ _ov.platform = platform;
65
+ _ov.env = env;
66
+ },
67
+ reset() {
68
+ _ov.runner = null;
69
+ _ov.platform = null;
70
+ _ov.env = null;
71
+ _inFlight = false;
72
+ },
73
+ };
74
+
75
+ /**
76
+ * Open the platform's native folder picker and wait for the user.
77
+ * Serialized: while one dialog is open, further calls resolve { status:'busy' }.
78
+ * @returns {Promise<{status:'picked', path:string} | {status:'canceled'}
79
+ * | {status:'unsupported'} | {status:'busy'}>}
80
+ */
81
+ export async function pickFolderNative() {
82
+ if (_inFlight) return { status: 'busy' };
83
+ _inFlight = true;
84
+ try {
85
+ const platform = _ov.platform || process.platform;
86
+ const env = _ov.env || process.env;
87
+ const run = _ov.runner || defaultRun;
88
+ if ((env.WORCA_NO_NATIVE_DIALOG || '') === '1') return { status: 'unsupported' };
89
+ if (platform === 'darwin') return await pickMac(run);
90
+ if (platform === 'win32') return await pickWindows(run);
91
+ if (platform === 'linux') return await pickLinux(run, env);
92
+ return { status: 'unsupported' };
93
+ } finally {
94
+ _inFlight = false;
95
+ }
96
+ }
97
+
98
+ function pickedOrCanceled(stdoutRaw) {
99
+ const raw = stdoutRaw.trim();
100
+ const path = raw === '/' ? raw : raw.replace(/\/+$/, '');
101
+ return path ? { status: 'picked', path } : { status: 'canceled' };
102
+ }
103
+
104
+ async function pickMac(run) {
105
+ const r = await run('osascript', [
106
+ '-e', 'tell application "System Events" to activate',
107
+ '-e', `POSIX path of (choose folder with prompt "${PROMPT}")`,
108
+ ]);
109
+ if (r.ok) return pickedOrCanceled(r.stdout);
110
+ // `choose folder` cancel: exit 1 + "execution error: User canceled. (-128)"
111
+ if (/-128|User cancell?ed/i.test(r.stderr || '')) return { status: 'canceled' };
112
+ return { status: 'unsupported' }; // no GUI session, automation denied, ...
113
+ }
114
+
115
+ async function pickWindows(run) {
116
+ const script =
117
+ 'Add-Type -AssemblyName System.Windows.Forms | Out-Null; ' +
118
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog; ' +
119
+ `$d.Description = '${PROMPT}'; $d.ShowNewFolderButton = $true; ` +
120
+ "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.WriteLine($d.SelectedPath) }";
121
+ const r = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script]);
122
+ if (!r.ok) return { status: 'unsupported' };
123
+ // OK exit either way; empty stdout means the user canceled.
124
+ const path = r.stdout.trim();
125
+ return path ? { status: 'picked', path } : { status: 'canceled' };
126
+ }
127
+
128
+ async function pickLinux(run, env) {
129
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return { status: 'unsupported' };
130
+ const zen = await run('zenity', ['--file-selection', '--directory', `--title=${PROMPT}`]);
131
+ if (zen.ok) return pickedOrCanceled(zen.stdout);
132
+ if (zen.code === 1 && !zen.timedOut) return { status: 'canceled' }; // user closed it
133
+ // zenity missing (spawn error -> code -1) or broken: try kdialog.
134
+ const kd = await run('kdialog', ['--title', PROMPT, '--getexistingdirectory', env.HOME || '/']);
135
+ if (kd.ok) return pickedOrCanceled(kd.stdout);
136
+ if (kd.code === 1 && !kd.timedOut) return { status: 'canceled' };
137
+ return { status: 'unsupported' };
138
+ }
@@ -0,0 +1,49 @@
1
+ // src/core/fs-browse.mjs
2
+ // Read-only directory listing for the web UI's in-app folder browser (the
3
+ // fallback when the native OS dialog is unavailable). Lists ONLY directories —
4
+ // it is a folder picker, files are never shown — and hides dotfolders. Worca CC
5
+ // is a localhost-only single-user tool (isLocalRequest in ui/server.mjs), so
6
+ // this exposes exactly the same trust level as the manual path field it backs.
7
+
8
+ import { readdir, stat } from 'node:fs/promises';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { normalizeProjectPath } from './projects.mjs';
11
+ import { defaultRoot } from './settings.mjs';
12
+
13
+ function err(message, code) { return Object.assign(new Error(message), { code }); }
14
+
15
+ /**
16
+ * List the sub-directories of `input` (tilde-expanded, resolved). Empty input
17
+ * lists the OS home directory (normalizeProjectPath returns null for blank
18
+ * input, so this can never fall through to process.cwd()).
19
+ * @param {string} input
20
+ * @returns {Promise<{path:string, parent:string|null, home:string,
21
+ * dirs:Array<{name:string, path:string}>}>} parent is null at the fs root.
22
+ * @throws {Error & {code:'BAD_REQUEST'}} when the path does not exist, is not
23
+ * a directory, or cannot be read.
24
+ */
25
+ export async function listFolders(input) {
26
+ const home = resolve(defaultRoot());
27
+ const path = normalizeProjectPath(input) || home;
28
+ let entries;
29
+ try {
30
+ entries = await readdir(path, { withFileTypes: true });
31
+ } catch (e) {
32
+ if (e.code === 'ENOENT') throw err(`no such directory: ${path}`, 'BAD_REQUEST');
33
+ if (e.code === 'ENOTDIR') throw err(`not a directory: ${path}`, 'BAD_REQUEST');
34
+ if (e.code === 'EACCES' || e.code === 'EPERM') throw err(`permission denied: ${path}`, 'BAD_REQUEST');
35
+ throw err(`cannot read directory: ${e.message}`, 'BAD_REQUEST');
36
+ }
37
+ const dirs = [];
38
+ for (const d of entries) {
39
+ if (d.name.startsWith('.')) continue;
40
+ let isDir = d.isDirectory();
41
+ if (!isDir && d.isSymbolicLink()) {
42
+ try { isDir = (await stat(join(path, d.name))).isDirectory(); } catch { isDir = false; }
43
+ }
44
+ if (isDir) dirs.push({ name: d.name, path: join(path, d.name) });
45
+ }
46
+ dirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
47
+ const parent = dirname(path);
48
+ return { path, parent: parent === path ? null : parent, home, dirs };
49
+ }
@@ -0,0 +1,200 @@
1
+ // src/core/git-info.mjs
2
+ // Read-only git facts + gh (GitHub CLI) actions that the History UI needs.
3
+ // Leaf module: depends only on node:child_process so artifacts.mjs and the UI
4
+ // server can both import it without the worktree.mjs <-> artifacts.mjs cycle.
5
+ // Every command goes through an injectable runner (_testing.setRunner) so tests
6
+ // never shell out to real git/gh/GitHub. Nothing here ever throws.
7
+
8
+ import { spawn } from 'node:child_process';
9
+
10
+ /** Default runner: spawn `cmd args` in `cwd`, resolve { ok, stdout, stderr, code }. */
11
+ function defaultRun(cmd, args, { cwd } = {}) {
12
+ return new Promise((resolve) => {
13
+ let child;
14
+ try {
15
+ child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
16
+ } catch (err) {
17
+ resolve({ ok: false, stdout: '', stderr: err.message, code: -1 });
18
+ return;
19
+ }
20
+ let stdout = '', stderr = '';
21
+ child.stdout?.on('data', (b) => (stdout += b.toString()));
22
+ child.stderr?.on('data', (b) => (stderr += b.toString()));
23
+ child.on('error', (err) => resolve({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
24
+ child.on('close', (code) => resolve({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
25
+ });
26
+ }
27
+
28
+ let _run = defaultRun;
29
+ let _ghCache = null;
30
+
31
+ /** Parse `git diff --shortstat` output into { added, removed }. */
32
+ export function parseShortstat(out) {
33
+ const ins = /(\d+)\s+insertion/.exec(String(out || ''));
34
+ const del = /(\d+)\s+deletion/.exec(String(out || ''));
35
+ return { added: ins ? Number(ins[1]) : 0, removed: del ? Number(del[1]) : 0 };
36
+ }
37
+
38
+ /** Added/removed line counts for source...feature (merge-base/3-dot). 0/0 on any failure. */
39
+ export async function diffShortstat(projectDir, source, feature) {
40
+ if (!projectDir || !source || !feature) return { added: 0, removed: 0 };
41
+ const r = await _run('git', ['diff', '--shortstat', `${source}...${feature}`], { cwd: projectDir });
42
+ if (!r.ok) return { added: 0, removed: 0 };
43
+ return parseShortstat(r.stdout);
44
+ }
45
+
46
+ /**
47
+ * Parse `git diff --name-status -M` rows. `head` omitted -> diff base vs working tree.
48
+ * Rename/copy rows look like `R100\told\tnew`; status letter is the first char.
49
+ * `pathspecs` (optional) are appended AFTER the bare '--' so callers can restrict
50
+ * or, more usefully, EXCLUDE paths (`:(exclude)<path>` — exclude-only pathspecs are
51
+ * valid git). Passing nothing yields the byte-identical argv of before (§8.8).
52
+ * @returns {Promise<Array<{status:string, path:string, from?:string}>>}
53
+ */
54
+ export async function diffNameStatus(projectDir, base, head, pathspecs = []) {
55
+ if (!projectDir || !base) return [];
56
+ const args = ['diff', '--name-status', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
57
+ const r = await _run('git', args, { cwd: projectDir });
58
+ if (!r.ok) return [];
59
+ const out = [];
60
+ for (const line of r.stdout.split('\n')) {
61
+ if (!line.trim()) continue;
62
+ const parts = line.split('\t');
63
+ const status = parts[0][0]; // R100 -> R, C75 -> C
64
+ if (status === 'R' || status === 'C') {
65
+ out.push({ status, from: parts[1], path: parts[2] });
66
+ } else {
67
+ out.push({ status, path: parts[1] });
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Parse `git diff --numstat -M` into a Map keyed by path. Binary files report
75
+ * `-`/`-` and are flagged `binary:true` with zero counts. `pathspecs` (optional)
76
+ * are appended AFTER the bare '--' — see diffNameStatus.
77
+ * @returns {Promise<Map<string,{added:number, removed:number, binary:boolean}>>}
78
+ */
79
+ export async function diffNumstat(projectDir, base, head, pathspecs = []) {
80
+ const m = new Map();
81
+ if (!projectDir || !base) return m;
82
+ const args = ['diff', '--numstat', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
83
+ const r = await _run('git', args, { cwd: projectDir });
84
+ if (!r.ok) return m;
85
+ for (const line of r.stdout.split('\n')) {
86
+ if (!line.trim()) continue;
87
+ const [a, d, ...rest] = line.split('\t');
88
+ const path = rest[rest.length - 1]; // for renames the last col is the new path
89
+ const binary = a === '-' || d === '-';
90
+ m.set(path, { added: binary ? 0 : Number(a) || 0, removed: binary ? 0 : Number(d) || 0, binary });
91
+ }
92
+ return m;
93
+ }
94
+
95
+ /**
96
+ * Full unified diff (`git diff -M base [head]`). Empty string on failure.
97
+ * `pathspecs` (optional) are appended AFTER the bare '--' — see diffNameStatus.
98
+ * @returns {Promise<string>}
99
+ */
100
+ export async function diffPatch(projectDir, base, head, pathspecs = []) {
101
+ if (!projectDir || !base) return '';
102
+ const args = ['diff', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
103
+ const r = await _run('git', args, { cwd: projectDir });
104
+ return r.ok ? r.stdout : '';
105
+ }
106
+
107
+ /** True iff `branch` exists locally in `projectDir`. False on a missing repo/branch. */
108
+ export async function branchExists(projectDir, branch) {
109
+ if (!projectDir || !branch) return false;
110
+ const r = await _run('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: projectDir });
111
+ return r.ok && !!r.stdout.trim();
112
+ }
113
+
114
+ /** True iff the GitHub CLI is on PATH. Memoized (reset via _testing.reset()). */
115
+ export async function hasGh() {
116
+ if (_ghCache !== null) return _ghCache;
117
+ const r = await _run('gh', ['--version']);
118
+ _ghCache = r.ok;
119
+ return _ghCache;
120
+ }
121
+
122
+ /** Push the branch and set upstream. Idempotent; surfaces stderr on failure. */
123
+ export async function pushBranch(projectDir, branch) {
124
+ const r = await _run('git', ['push', '-u', 'origin', branch], { cwd: projectDir });
125
+ return { ok: r.ok, stderr: (r.stderr || '').trim() };
126
+ }
127
+
128
+ /**
129
+ * Open a PR with `gh pr create`. On "already exists", recover the open PR's URL
130
+ * via `gh pr view` so the button is still useful. Returns { ok, url, existed } |
131
+ * { ok:false, error }.
132
+ */
133
+ export async function createPr({ projectDir, base, head, title, body = '' }) {
134
+ const args = ['pr', 'create', '--base', base, '--head', head, '--title', title || head, '--body', body || title || head];
135
+ const r = await _run('gh', args, { cwd: projectDir });
136
+ if (r.ok) {
137
+ // gh prints the PR URL as the last stdout line.
138
+ const url = (r.stdout.trim().split(/\r?\n/).pop() || '').trim();
139
+ return { ok: true, url, existed: false };
140
+ }
141
+ if (/already exists/i.test(r.stderr || '')) {
142
+ const v = await _run('gh', ['pr', 'view', head, '--json', 'url', '-q', '.url'], { cwd: projectDir });
143
+ if (v.ok && v.stdout.trim()) return { ok: true, url: v.stdout.trim(), existed: true };
144
+ }
145
+ return { ok: false, error: (r.stderr || '').trim() || `gh exited ${r.code}` };
146
+ }
147
+
148
+ /** Normalize gh's `mergeable` / `mergeStateStatus` to MERGEABLE | CONFLICTING | UNKNOWN. */
149
+ export function normalizeMergeable(raw) {
150
+ const s = String(raw || '').toUpperCase();
151
+ if (s === 'MERGEABLE' || s === 'CLEAN') return 'MERGEABLE';
152
+ if (s === 'CONFLICTING' || s === 'DIRTY') return 'CONFLICTING';
153
+ return 'UNKNOWN';
154
+ }
155
+
156
+ /** Read mergeability for the PR whose head is `head`. UNKNOWN on any failure. */
157
+ export async function prMergeable({ projectDir, head }) {
158
+ const r = await _run('gh', ['pr', 'view', head, '--json', 'mergeable', '-q', '.mergeable'], { cwd: projectDir });
159
+ if (!r.ok) return 'UNKNOWN';
160
+ return normalizeMergeable(r.stdout.trim());
161
+ }
162
+
163
+ /**
164
+ * Look up an existing PR for `head` via `gh pr list`, so the History UI can hide
165
+ * the Create-PR button when a PR is already open or merged. Scans the matches and
166
+ * selects by priority OPEN > MERGED, so a newer closed PR never masks an older
167
+ * merged one; a closed-but-not-merged PR is ignored (treated as "no active PR").
168
+ * Returns { state, url, number } with state ∈ { OPEN, MERGED }, or null when there
169
+ * is no open/merged PR / on any gh failure. Never throws.
170
+ */
171
+ export async function findPrForBranch({ projectDir, head } = {}) {
172
+ if (!projectDir || !head) return null;
173
+ const r = await _run(
174
+ 'gh',
175
+ ['pr', 'list', '--head', head, '--state', 'all', '--json', 'number,state,url', '--limit', '30'],
176
+ { cwd: projectDir },
177
+ );
178
+ if (!r.ok) return null;
179
+ let arr;
180
+ try { arr = JSON.parse(r.stdout || '[]'); } catch { return null; }
181
+ if (!Array.isArray(arr) || arr.length === 0) return null;
182
+ // Keep only the states the UI acts on; closed/declined PRs are deliberately dropped.
183
+ const norm = arr
184
+ .map((pr) => ({
185
+ state: String(pr?.state || '').toUpperCase(),
186
+ url: String(pr?.url || ''),
187
+ number: Number(pr?.number) || null,
188
+ }))
189
+ .filter((pr) => pr.state === 'OPEN' || pr.state === 'MERGED');
190
+ if (norm.length === 0) return null;
191
+ // Requirement is binary: hide the button if any OPEN or MERGED PR exists. After
192
+ // the filter, norm[0] is necessarily a MERGED entry when there is no OPEN one.
193
+ return norm.find((p) => p.state === 'OPEN') || norm[0];
194
+ }
195
+
196
+ // Test seam: swap the command runner + clear the gh memo. Mirrors server.mjs#_testing.
197
+ export const _testing = {
198
+ setRunner(fn) { _run = typeof fn === 'function' ? fn : defaultRun; _ghCache = null; },
199
+ reset() { _run = defaultRun; _ghCache = null; },
200
+ };
@@ -0,0 +1,204 @@
1
+ // src/core/guardrail-store.mjs
2
+ // Global named guardrail-set store (table: guardrail_sets, SCHEMA_V14). The three
3
+ // built-ins — Permissive / Normal / Strict (wire ids permissive/normal/secure; the
4
+ // "Strict" label is display-only) — are VIRTUAL: derived from GUARDRAIL_PRESETS at
5
+ // read time, never persisted, undeletable, prepended by the server (the
6
+ // DEFAULT_WORKFLOW contract). User sets are rows; `settings` is the JSON 5-key
7
+ // blob, sanitized on read. Reads never throw: missing/unsafe-id => null/[];
8
+ // corrupt settings JSON degrades to the empty policy (the row stays a set).
9
+ // deleteGuardrailSet throws ReferencedError while any pipeline resume point pins
10
+ // the set (guardrails are selected PER RUN — resume_point.guardrailsId is the only
11
+ // reference kind; the historical pipelines.guardrails_id column never blocks).
12
+ import { getDb, prepare, tx } from './db.mjs';
13
+ import { slugify } from './artifacts.mjs';
14
+ import { GUARDRAIL_PRESETS, GUARDRAIL_LEVELS, sanitizeGuardrails } from './guardrails.mjs';
15
+ // Shared delete-guard error class (uninstall/delete blocked by live references).
16
+ // Reusing it is safe: plugin-workflows imports only db/artifacts/agent-registry/
17
+ // workflow-validator/plugins-lock (plugin-workflows.mjs:8-15) — a leaf subtree
18
+ // with no cycle back into this module. Its ctor is (message, references); the
19
+ // store stamps code:'REFERENCED' at the throw site, and the server ALSO matches
20
+ // structurally (err.name === 'ReferencedError' || err.code === 'REFERENCED').
21
+ import { ReferencedError } from './plugin-workflows.mjs';
22
+
23
+ export { ReferencedError };
24
+
25
+ const EPOCH = '1970-01-01T00:00:00.000Z';
26
+
27
+ // Wire id -> display name. `secure` renders as "Strict" (display-only rename).
28
+ const BUILTIN_META = Object.freeze({ permissive: 'Permissive', normal: 'Normal', secure: 'Strict' });
29
+
30
+ export const BUILTIN_GUARDRAIL_SET_IDS = Object.freeze(Object.keys(BUILTIN_META));
31
+
32
+ export function isBuiltinGuardrailSetId(id) {
33
+ return Object.prototype.hasOwnProperty.call(BUILTIN_META, id);
34
+ }
35
+
36
+ /** One virtual built-in, with FRESH settings copies (the frozen preset table never leaks). */
37
+ function builtinSet(id) {
38
+ return {
39
+ id,
40
+ name: BUILTIN_META[id],
41
+ origin: 'builtin',
42
+ settings: sanitizeGuardrails(GUARDRAIL_PRESETS[id]),
43
+ createdAt: EPOCH,
44
+ updatedAt: EPOCH,
45
+ };
46
+ }
47
+
48
+ /** All three virtual built-ins, list order Permissive -> Normal -> Strict. Sync: a pure code-table derivation. */
49
+ export function listBuiltinGuardrailSets() {
50
+ return BUILTIN_GUARDRAIL_SET_IDS.map(builtinSet);
51
+ }
52
+
53
+ /** A set id is a stem; reject anything that could escape (same rule as workflows). */
54
+ const SAFE_SET_ID = /^[A-Za-z0-9_-]+$/;
55
+ function isSafeSetId(id) { return typeof id === 'string' && SAFE_SET_ID.test(id); }
56
+
57
+ /** Map a guardrail_sets row to the set shape. Corrupt settings JSON degrades to the empty policy. */
58
+ function rowToSet(r) {
59
+ let settings;
60
+ try { settings = sanitizeGuardrails(JSON.parse(r.settings)); } catch { settings = sanitizeGuardrails(undefined); }
61
+ return { id: r.id, name: r.name, origin: r.origin || null, settings, createdAt: r.created_at, updatedAt: r.updated_at };
62
+ }
63
+
64
+ /** Read one stored row. Unsafe id / missing => null. */
65
+ function readRaw(id) {
66
+ if (!isSafeSetId(id)) return null; // SECURITY: reject path-traversal / unsafe ids
67
+ getDb();
68
+ const r = prepare(
69
+ 'SELECT id, name, settings, origin, created_at, updated_at FROM guardrail_sets WHERE id = ?'
70
+ ).get(id);
71
+ return r ? rowToSet(r) : null;
72
+ }
73
+
74
+ /**
75
+ * Read a set by id. Built-in ids resolve virtually (readGuardrailSet('secure')
76
+ * returns the Strict built-in); otherwise the stored row, or null.
77
+ * @param {string} id
78
+ * @returns {Promise<object|null>}
79
+ */
80
+ export async function readGuardrailSet(id) {
81
+ if (isBuiltinGuardrailSetId(id)) return builtinSet(id);
82
+ return readRaw(id);
83
+ }
84
+
85
+ /**
86
+ * List USER sets (NOT the built-ins — callers prepend listBuiltinGuardrailSets()),
87
+ * newest first by createdAt. Empty store => []. Never throws.
88
+ * @returns {Promise<object[]>}
89
+ */
90
+ export async function listGuardrailSets() {
91
+ getDb();
92
+ const rows = prepare(
93
+ 'SELECT id, name, settings, origin, created_at, updated_at FROM guardrail_sets ORDER BY created_at DESC, id'
94
+ ).all();
95
+ return rows.filter((r) => !isBuiltinGuardrailSetId(r.id)).map(rowToSet);
96
+ }
97
+
98
+ /**
99
+ * Persist a set. Mints gr_<slug> from the name when id is missing; sanitizes the
100
+ * settings blob; preserves created_at on re-save; upsert OMITS origin (a user
101
+ * re-save of a plugin row keeps its provenance; user rows stay NULL). Reserved
102
+ * ids (the GUARDRAIL_LEVELS words: the three built-ins + 'custom') and unsafe
103
+ * explicit ids return null — built-ins are code, not rows.
104
+ * @param {object} set { id?, name, settings, createdAt? }
105
+ * @returns {Promise<object|null>}
106
+ */
107
+ export async function writeGuardrailSet(set) {
108
+ const now = new Date().toISOString();
109
+ const name = (set && typeof set.name === 'string' && set.name.trim()) || 'Untitled';
110
+ const id = (set && typeof set.id === 'string' && set.id.trim()) || `gr_${slugify(name)}`;
111
+ if (!isSafeSetId(id) || GUARDRAIL_LEVELS.includes(id)) return null; // reserved: builtin ids + 'custom'
112
+ const settings = sanitizeGuardrails(set?.settings);
113
+
114
+ getDb();
115
+ // Preserve the original createdAt (and report provenance) if this id already exists.
116
+ const existing = prepare('SELECT created_at, origin FROM guardrail_sets WHERE id = ?').get(id);
117
+ const createdAt =
118
+ (set && typeof set.createdAt === 'string' && set.createdAt) ||
119
+ (existing && existing.created_at) ||
120
+ now;
121
+
122
+ const stored = { id, name, origin: (existing && existing.origin) || null, settings, createdAt, updatedAt: now };
123
+ tx(() => {
124
+ prepare(`
125
+ INSERT INTO guardrail_sets (id, name, settings, created_at, updated_at)
126
+ VALUES (?, ?, ?, ?, ?)
127
+ ON CONFLICT(id) DO UPDATE SET
128
+ name = excluded.name, settings = excluded.settings, updated_at = excluded.updated_at
129
+ `).run(id, name, JSON.stringify(settings), createdAt, now);
130
+ });
131
+ return stored;
132
+ }
133
+
134
+ /**
135
+ * SYNC reference scan: pipeline resume points whose rp.guardrailsId pins this id
136
+ * (Phase 2 writes the field; done/stopped runs null their resume point, so only
137
+ * recoverable runs pin — errored rows retain theirs and legitimately block, the
138
+ * plugin-workflows scope). The historical pipelines.guardrails_id COLUMN is
139
+ * deliberately NOT scanned: it records finished runs, and history never blocks.
140
+ * ARCHIVED rows are excluded for the same reason they are excluded from every
141
+ * list read: History's Archive soft-deletes (archived_at stamped, resume_point
142
+ * left in place), and an archived run is invisible, unresumable and un-re-
143
+ * archivable — so a kept pin would be permanent with no UI escape hatch. The
144
+ * hard DELETE this replaced released it.
145
+ * Raw prepare() reads only, so it is callable from inside deleteGuardrailSet's
146
+ * tx (tx() non-reentrancy bars nested tx() CALLS, not reads).
147
+ */
148
+ function scanReferences(id) {
149
+ const referencedBy = [];
150
+ for (const p of prepare(
151
+ 'SELECT id, resume_point FROM pipelines WHERE resume_point IS NOT NULL AND archived_at IS NULL',
152
+ ).all()) {
153
+ try {
154
+ const rp = JSON.parse(p.resume_point);
155
+ if (rp && rp.guardrailsId === id) referencedBy.push(`pipeline ${p.id}`);
156
+ } catch { /* corrupt resume point: not a reference */ }
157
+ }
158
+ return referencedBy.length ? [{ id, referencedBy }] : [];
159
+ }
160
+
161
+ /**
162
+ * Who still pins this set (the async API wrapper around scanReferences).
163
+ * @param {string} id
164
+ * @returns {Promise<Array<{id: string, referencedBy: string[]}>>}
165
+ */
166
+ export async function guardrailSetReferences(id) {
167
+ getDb();
168
+ return scanReferences(id);
169
+ }
170
+
171
+ /**
172
+ * Delete a saved set. Refuses built-ins and unsafe ids (false); throws
173
+ * ReferencedError (with the structured referencing list) while any resume-point
174
+ * pin exists — nothing deleted; false when no row exists; true on removal.
175
+ * The reference scan runs INSIDE the same tx as the DELETE (raw prepare()
176
+ * reads — tx() non-reentrancy bars nested tx() calls, not reads), so a pin
177
+ * written between scan and DELETE can never slip through and dangle. House
178
+ * precedent (plugin-workflows removePluginWorkflows) scans outside its tx; we
179
+ * deviate deliberately: a stranded workflow ref merely falls back to the
180
+ * default topology, a stranded guardrail pin silently downgrades a paused
181
+ * run's selected SECURITY policy to Permissive at resume.
182
+ * @param {string} id
183
+ * @returns {Promise<boolean>}
184
+ */
185
+ export async function deleteGuardrailSet(id) {
186
+ if (isBuiltinGuardrailSetId(id)) return false; // built-ins are undeletable
187
+ if (!isSafeSetId(id)) return false; // SECURITY: reject unsafe ids
188
+ getDb();
189
+ let changed = 0;
190
+ tx(() => {
191
+ const references = scanReferences(id);
192
+ if (references.length) {
193
+ const lines = references.map((r) => ` - ${r.id} (referenced by ${r.referencedBy.join(', ')})`);
194
+ const err = new ReferencedError(
195
+ `cannot delete guardrail set "${id}" — still referenced:\n${lines.join('\n')}`,
196
+ references,
197
+ );
198
+ err.code = 'REFERENCED'; // structural server match (the 409 mapping)
199
+ throw err; // tx() rolls back (nothing written) and rethrows
200
+ }
201
+ changed = prepare('DELETE FROM guardrail_sets WHERE id = ?').run(id).changes;
202
+ });
203
+ return changed > 0;
204
+ }