@worca/app 1.2.0 → 1.3.0-rc.2
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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/src/core/git-info.mjs
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// never shell out to real git/gh/GitHub. Nothing here ever throws.
|
|
7
7
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
9
12
|
|
|
10
13
|
/** Default runner: spawn `cmd args` in `cwd`, resolve { ok, stdout, stderr, code }. */
|
|
11
14
|
function defaultRun(cmd, args, { cwd, timeout = 0 } = {}) {
|
|
@@ -157,19 +160,28 @@ export async function hasGh() {
|
|
|
157
160
|
return _ghCache;
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
/** Push the branch and set upstream. Idempotent; surfaces stderr
|
|
161
|
-
export async function pushBranch(projectDir, branch) {
|
|
162
|
-
const r = await _run('git', ['push', '-u', 'origin', branch], { cwd: projectDir });
|
|
163
|
+
/** Push the branch to `remote` (default origin) and set upstream. Idempotent; surfaces stderr. */
|
|
164
|
+
export async function pushBranch(projectDir, branch, remote = 'origin') {
|
|
165
|
+
const r = await _run('git', ['push', '-u', remote || 'origin', branch], { cwd: projectDir });
|
|
163
166
|
return { ok: r.ok, stderr: (r.stderr || '').trim() };
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
/**
|
|
167
|
-
* Open a PR with `gh pr create`.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
+
* Open a PR with `gh pr create`. `repo` ([HOST/]OWNER/REPO) targets the base
|
|
171
|
+
* repository explicitly — gh's non-interactive default prefers a remote named
|
|
172
|
+
* `upstream` over `origin`, so an omitted --repo can land a PR in the wrong repo.
|
|
173
|
+
* `headOwner` (the push remote's owner) selects the cross-repo `owner:branch`
|
|
174
|
+
* head; leave it null when the branch lives in `repo` itself — gh matches PRs by
|
|
175
|
+
* head LABEL, so the form must agree with where the branch actually is.
|
|
176
|
+
* On "already exists", recover the open PR's URL via `gh pr view` with the same
|
|
177
|
+
* selector + repo, else from the URL gh prints on the last stderr line.
|
|
178
|
+
* Returns { ok, url, existed } | { ok:false, error }.
|
|
170
179
|
*/
|
|
171
|
-
export async function createPr({ projectDir, base, head, title, body = '' }) {
|
|
172
|
-
const
|
|
180
|
+
export async function createPr({ projectDir, base, head, title, body = '', repo = null, headOwner = null }) {
|
|
181
|
+
const headRef = prHeadRef(head, headOwner);
|
|
182
|
+
const repoArgs = repo ? ['--repo', repo] : [];
|
|
183
|
+
const args = ['pr', 'create', ...repoArgs, '--base', base, '--head', headRef,
|
|
184
|
+
'--title', title || head, '--body', body || title || head];
|
|
173
185
|
const r = await _run('gh', args, { cwd: projectDir });
|
|
174
186
|
if (r.ok) {
|
|
175
187
|
// gh prints the PR URL as the last stdout line.
|
|
@@ -177,12 +189,85 @@ export async function createPr({ projectDir, base, head, title, body = '' }) {
|
|
|
177
189
|
return { ok: true, url, existed: false };
|
|
178
190
|
}
|
|
179
191
|
if (/already exists/i.test(r.stderr || '')) {
|
|
180
|
-
const v = await _run('gh', ['pr', 'view',
|
|
192
|
+
const v = await _run('gh', ['pr', 'view', headRef, ...repoArgs, '--json', 'url', '-q', '.url'], { cwd: projectDir });
|
|
181
193
|
if (v.ok && v.stdout.trim()) return { ok: true, url: v.stdout.trim(), existed: true };
|
|
194
|
+
// gh's message ends with the existing PR's URL ("… already exists:\n<url>");
|
|
195
|
+
// use it when the view selector cannot resolve (e.g. a PR opened from another fork).
|
|
196
|
+
const m = /https?:\/\/\S+\/pull\/\d+/.exec(r.stderr || '');
|
|
197
|
+
if (m) return { ok: true, url: m[0], existed: true };
|
|
182
198
|
}
|
|
183
199
|
return { ok: false, error: (r.stderr || '').trim() || `gh exited ${r.code}` };
|
|
184
200
|
}
|
|
185
201
|
|
|
202
|
+
// ── gh issue create ───────────────────────────────────────────────────────────
|
|
203
|
+
// The run reporter's one write to GitHub. The body is a whole JSON report (40 KB on
|
|
204
|
+
// the largest run measured), so it rides a FILE: --body argv would be at the mercy of
|
|
205
|
+
// the platform's argument limit, and every backtick and newline in it would depend on
|
|
206
|
+
// spawn's quoting. --body-file has neither problem and is byte-exact.
|
|
207
|
+
|
|
208
|
+
/** Applied to every filed report. `bug` exists upstream; `ai` marks the filer. */
|
|
209
|
+
export const ISSUE_LABELS = Object.freeze(['bug', 'ai']);
|
|
210
|
+
|
|
211
|
+
// gh resolves label NAMES through the API and fails the whole create when one is
|
|
212
|
+
// missing — and a reporter with no triage permission on the target repo cannot add
|
|
213
|
+
// labels at all. Both read like this, and both are recoverable by dropping them.
|
|
214
|
+
const LABEL_REJECTED = /could not add label|label .*not found|must have (?:admin|push|triage)/i;
|
|
215
|
+
|
|
216
|
+
/** gh's own failures, split so the UI can say something actionable. */
|
|
217
|
+
function ghFailureKind(stderr) {
|
|
218
|
+
const s = String(stderr || '');
|
|
219
|
+
if (/gh auth login|not logged in|authentication|HTTP 401|bad credentials/i.test(s)) return 'auth';
|
|
220
|
+
return 'failed';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Open a GitHub issue with `gh issue create`. `repo` (OWNER/REPO) is REQUIRED and
|
|
225
|
+
* always explicit: gh would otherwise resolve the target from the cwd's remotes and
|
|
226
|
+
* file a worca bug report in whatever repository the user happens to be standing in.
|
|
227
|
+
*
|
|
228
|
+
* Labels are best effort — on a label rejection the issue is filed again without
|
|
229
|
+
* them rather than lost. Any OTHER failure is returned as-is and never retried: a
|
|
230
|
+
* retried create that actually succeeded the first time files the report twice.
|
|
231
|
+
*
|
|
232
|
+
* @returns {{ok:true, url:string, labeled:boolean} | {ok:false, kind:'no-repo'|'no-gh'|'auth'|'failed', error:string}}
|
|
233
|
+
*/
|
|
234
|
+
export async function createIssue({ repo, title, body = '', labels = ISSUE_LABELS }) {
|
|
235
|
+
if (!repo) return { ok: false, kind: 'no-repo', error: 'no GitHub repository is configured' };
|
|
236
|
+
if (!(await hasGh())) {
|
|
237
|
+
return { ok: false, kind: 'no-gh', error: 'the GitHub CLI (gh) is not installed' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let dir = null;
|
|
241
|
+
try {
|
|
242
|
+
dir = await mkdtemp(join(tmpdir(), 'worca-issue-'));
|
|
243
|
+
const file = join(dir, 'body.md');
|
|
244
|
+
await writeFile(file, String(body), 'utf8');
|
|
245
|
+
|
|
246
|
+
const base = ['issue', 'create', '--repo', repo, '--title', title || 'Run report',
|
|
247
|
+
'--body-file', file];
|
|
248
|
+
const withLabels = [...base, ...labels.flatMap((l) => ['--label', l])];
|
|
249
|
+
|
|
250
|
+
let labeled = labels.length > 0;
|
|
251
|
+
let r = labeled ? await _run('gh', withLabels) : await _run('gh', base);
|
|
252
|
+
if (!r.ok && labeled && LABEL_REJECTED.test(r.stderr || '')) {
|
|
253
|
+
labeled = false;
|
|
254
|
+
r = await _run('gh', base);
|
|
255
|
+
}
|
|
256
|
+
if (r.ok) {
|
|
257
|
+
// gh prints the issue URL as the last stdout line, after its progress chatter.
|
|
258
|
+
const url = ((r.stdout || '').trim().split(/\r?\n/).pop() || '').trim();
|
|
259
|
+
return { ok: true, url, labeled };
|
|
260
|
+
}
|
|
261
|
+
return { ok: false, kind: ghFailureKind(r.stderr),
|
|
262
|
+
error: (r.stderr || '').trim() || `gh exited ${r.code}` };
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// mkdtemp/writeFile only: a read-only temp dir must not throw through the route.
|
|
265
|
+
return { ok: false, kind: 'failed', error: err && err.message ? err.message : String(err) };
|
|
266
|
+
} finally {
|
|
267
|
+
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
186
271
|
/** Normalize gh's `mergeable` / `mergeStateStatus` to MERGEABLE | CONFLICTING | UNKNOWN. */
|
|
187
272
|
export function normalizeMergeable(raw) {
|
|
188
273
|
const s = String(raw || '').toUpperCase();
|
|
@@ -191,23 +276,57 @@ export function normalizeMergeable(raw) {
|
|
|
191
276
|
return 'UNKNOWN';
|
|
192
277
|
}
|
|
193
278
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Read mergeability. A `prUrl` is repo-agnostic (a fork PR lives in the BASE
|
|
281
|
+
* repo, which need not be the cwd's default) and wins; else the head selector
|
|
282
|
+
* (`owner:branch` when `headOwner`) scoped by `repo`. UNKNOWN on any failure.
|
|
283
|
+
*/
|
|
284
|
+
export async function prMergeable({ projectDir, head, repo = null, headOwner = null, prUrl = null }) {
|
|
285
|
+
const selector = prUrl || (head ? prHeadRef(head, headOwner) : '');
|
|
286
|
+
if (!selector) return 'UNKNOWN';
|
|
287
|
+
const repoArgs = !prUrl && repo ? ['--repo', repo] : [];
|
|
288
|
+
const r = await _run('gh', ['pr', 'view', selector, ...repoArgs, '--json', 'mergeable', '-q', '.mergeable'], { cwd: projectDir });
|
|
197
289
|
if (!r.ok) return 'UNKNOWN';
|
|
198
290
|
return normalizeMergeable(r.stdout.trim());
|
|
199
291
|
}
|
|
200
292
|
|
|
293
|
+
const normalizePr = (pr) => ({
|
|
294
|
+
state: String(pr?.state || '').toUpperCase(),
|
|
295
|
+
url: String(pr?.url || ''),
|
|
296
|
+
number: Number(pr?.number) || null,
|
|
297
|
+
});
|
|
298
|
+
|
|
201
299
|
/**
|
|
202
|
-
* Look up an existing PR for `head
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
300
|
+
* Look up an existing PR for `head`, so the History UI can hide the Create-PR
|
|
301
|
+
* button when a PR is already open or merged. Returns { state, url, number } with
|
|
302
|
+
* state ∈ { OPEN, MERGED }, or null when there is no open/merged PR / on any gh
|
|
303
|
+
* failure. Never throws.
|
|
304
|
+
*
|
|
305
|
+
* With a persisted `prUrl` (spec: later lookups use pr_url) the PR is read
|
|
306
|
+
* directly via `gh pr view <url>` — repo-agnostic, so a cross-repo PR is found
|
|
307
|
+
* even though `gh pr list` in the cwd would search the wrong repository. The
|
|
308
|
+
* view answers a JSON OBJECT (the list answers an array — parsed separately).
|
|
309
|
+
* The branch search runs for rows with no PR yet, when gh cannot read the URL
|
|
310
|
+
* (deleted PR, network, unparseable output), or when the PR behind the URL is
|
|
311
|
+
* CLOSED (unmerged) — a newer PR may exist for the branch. The list keeps the
|
|
312
|
+
* BARE branch: `gh pr list --head owner:branch` matches nothing. It scans the
|
|
313
|
+
* matches and selects by priority OPEN > MERGED, so a newer closed PR never
|
|
314
|
+
* masks an older merged one; a closed-but-not-merged PR is ignored.
|
|
208
315
|
*/
|
|
209
|
-
export async function findPrForBranch({ projectDir, head } = {}) {
|
|
316
|
+
export async function findPrForBranch({ projectDir, head, prUrl = null } = {}) {
|
|
210
317
|
if (!projectDir || !head) return null;
|
|
318
|
+
if (prUrl) {
|
|
319
|
+
const v = await _run('gh', ['pr', 'view', prUrl, '--json', 'number,state,url'], { cwd: projectDir });
|
|
320
|
+
if (v.ok) {
|
|
321
|
+
let obj = null;
|
|
322
|
+
try { obj = JSON.parse(v.stdout || 'null'); } catch { obj = null; }
|
|
323
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj) && obj.url) {
|
|
324
|
+
const pr = normalizePr(obj);
|
|
325
|
+
if (pr.state === 'OPEN' || pr.state === 'MERGED') return pr;
|
|
326
|
+
// CLOSED: fall through to the branch search below.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
211
330
|
const r = await _run(
|
|
212
331
|
'gh',
|
|
213
332
|
['pr', 'list', '--head', head, '--state', 'all', '--json', 'number,state,url', '--limit', '30'],
|
|
@@ -218,19 +337,107 @@ export async function findPrForBranch({ projectDir, head } = {}) {
|
|
|
218
337
|
try { arr = JSON.parse(r.stdout || '[]'); } catch { return null; }
|
|
219
338
|
if (!Array.isArray(arr) || arr.length === 0) return null;
|
|
220
339
|
// Keep only the states the UI acts on; closed/declined PRs are deliberately dropped.
|
|
221
|
-
const norm = arr
|
|
222
|
-
.map((pr) => ({
|
|
223
|
-
state: String(pr?.state || '').toUpperCase(),
|
|
224
|
-
url: String(pr?.url || ''),
|
|
225
|
-
number: Number(pr?.number) || null,
|
|
226
|
-
}))
|
|
227
|
-
.filter((pr) => pr.state === 'OPEN' || pr.state === 'MERGED');
|
|
340
|
+
const norm = arr.map(normalizePr).filter((pr) => pr.state === 'OPEN' || pr.state === 'MERGED');
|
|
228
341
|
if (norm.length === 0) return null;
|
|
229
342
|
// Requirement is binary: hide the button if any OPEN or MERGED PR exists. After
|
|
230
343
|
// the filter, norm[0] is necessarily a MERGED entry when there is no OPEN one.
|
|
231
344
|
return norm.find((p) => p.state === 'OPEN') || norm[0];
|
|
232
345
|
}
|
|
233
346
|
|
|
347
|
+
// ── Remotes (fork support) ──────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Parse a git remote URL into { host, owner, repo } or null when it is not a
|
|
351
|
+
* hosted owner/repo URL (local paths, file://, bare hosts). Accepts
|
|
352
|
+
* https://github.com/owner/repo.git https://user@host/owner/repo
|
|
353
|
+
* ssh://git@github.com/owner/repo.git ssh://git@host:2222/owner/repo
|
|
354
|
+
* git@github.com:owner/repo.git (scp-style, cf. marketplaces.mjs:31)
|
|
355
|
+
* git@github.com:/owner/repo.git host:owner/repo
|
|
356
|
+
* git://host/owner/repo.git
|
|
357
|
+
* Trailing `.git` / `/` are dropped; owner/repo are the LAST two path segments.
|
|
358
|
+
* GitHub's SSH-over-443 alias host (`ssh.github.com`) is folded into `github.com`:
|
|
359
|
+
* it names the same repository, and gh's --repo form only knows the real host.
|
|
360
|
+
* Pure; never throws.
|
|
361
|
+
*/
|
|
362
|
+
const HOST_ALIASES = { 'ssh.github.com': 'github.com' };
|
|
363
|
+
|
|
364
|
+
export function parseRemoteUrl(url) {
|
|
365
|
+
const s = String(url || '').trim();
|
|
366
|
+
if (!s) return null;
|
|
367
|
+
let host = '';
|
|
368
|
+
let pathPart = '';
|
|
369
|
+
let m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(s);
|
|
370
|
+
if (m) {
|
|
371
|
+
host = m[1]; pathPart = m[2];
|
|
372
|
+
} else if ((m = /^(?:([^@/\s]+)@)?([^:/\s]+):(.+)$/.exec(s))) {
|
|
373
|
+
// scp-style [user@]host:path. Without a user@ prefix a leading `/` is
|
|
374
|
+
// indistinguishable from a Windows drive path (C:/repos/x) → not hosted.
|
|
375
|
+
if (!m[1] && m[3].startsWith('/')) return null;
|
|
376
|
+
host = m[2]; pathPart = m[3];
|
|
377
|
+
} else {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const segs = pathPart.replace(/\/+$/, '').replace(/\.git$/i, '').split('/').filter(Boolean);
|
|
381
|
+
if (segs.length < 2) return null;
|
|
382
|
+
const owner = segs[segs.length - 2];
|
|
383
|
+
const repo = segs[segs.length - 1];
|
|
384
|
+
if (!owner || !repo) return null;
|
|
385
|
+
const h = host.toLowerCase();
|
|
386
|
+
return { host: HOST_ALIASES[h] || h, owner, repo };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** gh's `[HOST/]OWNER/REPO` form for --repo; the host is omitted for github.com. */
|
|
390
|
+
export function remoteRepoSlug(parsed) {
|
|
391
|
+
if (!parsed || !parsed.owner || !parsed.repo) return null;
|
|
392
|
+
const base = `${parsed.owner}/${parsed.repo}`;
|
|
393
|
+
return parsed.host && parsed.host !== 'github.com' ? `${parsed.host}/${base}` : base;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** True when two parsed remotes name the same repository (GitHub is case-insensitive). */
|
|
397
|
+
export function sameRepo(a, b) {
|
|
398
|
+
if (!a || !b || !a.owner || !b.owner || !a.repo || !b.repo) return false;
|
|
399
|
+
return String(a.host || '').toLowerCase() === String(b.host || '').toLowerCase()
|
|
400
|
+
&& a.owner.toLowerCase() === b.owner.toLowerCase()
|
|
401
|
+
&& a.repo.toLowerCase() === b.repo.toLowerCase();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** gh's PR selector for a head branch: `owner:branch` for a cross-repo head, else bare. */
|
|
405
|
+
export function prHeadRef(head, headOwner) {
|
|
406
|
+
return headOwner ? `${headOwner}:${head}` : head;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The repo's git remotes from `git remote -v`, in git's (alphabetical) order.
|
|
411
|
+
* Each entry is { name, fetchUrl, pushUrl, host, owner, repo, slug } with
|
|
412
|
+
* host/owner/repo/slug null when the URL is not a hosted owner/repo URL. The
|
|
413
|
+
* push URL is what the branch lands on, so it is parsed first; the fetch URL is
|
|
414
|
+
* the fallback. Never throws: { ok:true, remotes } | { ok:false, remotes:[], error }.
|
|
415
|
+
* Lives here (not in worktree.mjs) so it shares the `_run` seam the tests stub.
|
|
416
|
+
*/
|
|
417
|
+
export async function listRemotes(projectDir) {
|
|
418
|
+
if (!projectDir) return { ok: false, remotes: [], error: 'projectDir is required' };
|
|
419
|
+
const r = await _run('git', ['remote', '-v'], { cwd: projectDir });
|
|
420
|
+
if (!r.ok) return { ok: false, remotes: [], error: (r.stderr || '').trim() || `git exited ${r.code}` };
|
|
421
|
+
const byName = new Map();
|
|
422
|
+
for (const raw of (r.stdout || '').split(/\r?\n/)) {
|
|
423
|
+
const m = /^(\S+)\t(.+?)\s+\((fetch|push)\)$/.exec(raw.trim());
|
|
424
|
+
if (!m) continue;
|
|
425
|
+
const [, name, url, kind] = m;
|
|
426
|
+
const e = byName.get(name) || { name, fetchUrl: null, pushUrl: null };
|
|
427
|
+
if (kind === 'fetch') e.fetchUrl = url; else e.pushUrl = url;
|
|
428
|
+
byName.set(name, e);
|
|
429
|
+
}
|
|
430
|
+
const remotes = [...byName.values()].map((e) => {
|
|
431
|
+
const parsed = parseRemoteUrl(e.pushUrl || e.fetchUrl);
|
|
432
|
+
return {
|
|
433
|
+
...e,
|
|
434
|
+
host: parsed?.host ?? null, owner: parsed?.owner ?? null, repo: parsed?.repo ?? null,
|
|
435
|
+
slug: remoteRepoSlug(parsed),
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
return { ok: true, remotes };
|
|
439
|
+
}
|
|
440
|
+
|
|
234
441
|
// Test seam: swap the command runner + clear the gh memo. Mirrors server.mjs#_testing.
|
|
235
442
|
export const _testing = {
|
|
236
443
|
defaultRun,
|
|
@@ -49,3 +49,53 @@ export const GRAPH_DEFAULT_WORKFLOW = deepFreeze({
|
|
|
49
49
|
{ id: 'w10', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
50
50
|
],
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
/** The Auto entry's reserved id. Like wf_default it is NEVER a row: the picker
|
|
54
|
+
* lists it as a client-side constant, POST /api/run + the CLI accept it, and the
|
|
55
|
+
* orchestrator decides the real graph per run (auto-workflow spec §5). */
|
|
56
|
+
export const AUTO_WORKFLOW_ID = 'wf_auto';
|
|
57
|
+
export const AUTO_WORKFLOW_NAME = 'Auto';
|
|
58
|
+
|
|
59
|
+
/** What readWorkflow('wf_auto') answers: an id + name and NO graph, so nothing
|
|
60
|
+
* can validate or run it as a template. `auto: true` is the discriminator. */
|
|
61
|
+
export const AUTO_WORKFLOW_STUB = deepFreeze({
|
|
62
|
+
id: AUTO_WORKFLOW_ID,
|
|
63
|
+
name: AUTO_WORKFLOW_NAME,
|
|
64
|
+
version: 2,
|
|
65
|
+
domain: 'coding',
|
|
66
|
+
auto: true,
|
|
67
|
+
createdAt: '1970-01-01T00:00:00.000Z',
|
|
68
|
+
updatedAt: '1970-01-01T00:00:00.000Z',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** The Memory defragment workflow (agent-memory-design.md §7.2): one agent node between the
|
|
72
|
+
* task and End. Reserved like wf_default — never a row, read through readWorkflow, hidden
|
|
73
|
+
* from listWorkflows, undeletable, opened read-only in the composer. A run of it needs the
|
|
74
|
+
* `memoryScope` option (memory-sync.mjs validateMemoryScope). */
|
|
75
|
+
export const MEMORY_DEFRAG_WORKFLOW_ID = 'wf_memory_defrag';
|
|
76
|
+
export const MEMORY_DEFRAG_WORKFLOW_NAME = 'Memory defragment';
|
|
77
|
+
export const GRAPH_MEMORY_DEFRAG_WORKFLOW = deepFreeze({
|
|
78
|
+
id: MEMORY_DEFRAG_WORKFLOW_ID,
|
|
79
|
+
name: MEMORY_DEFRAG_WORKFLOW_NAME,
|
|
80
|
+
version: 2,
|
|
81
|
+
domain: 'shared',
|
|
82
|
+
createdAt: '1970-01-01T00:00:00.000Z',
|
|
83
|
+
updatedAt: '1970-01-01T00:00:00.000Z',
|
|
84
|
+
nodes: [
|
|
85
|
+
{ id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
|
|
86
|
+
{ id: 'n_defrag', kind: 'agent', key: 'memoryDefragmenter', x: 320, y: 200, config: {} },
|
|
87
|
+
{ id: 'n_end', kind: 'end', x: 600, y: 200, config: {} },
|
|
88
|
+
],
|
|
89
|
+
wires: [
|
|
90
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_defrag', port: 'task' } },
|
|
91
|
+
{ id: 'w2', from: { node: 'n_defrag', port: 'report' }, to: { node: 'n_end', port: 'result' } },
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** The ids no saved row may claim: writeGraphWorkflow re-mints them, listWorkflows hides them,
|
|
96
|
+
* DELETE refuses them. Order is NOT significant — GET /api/workflows and the Ask catalog list
|
|
97
|
+
* the graph built-ins in their own fixed order (Default, then Memory defragment).
|
|
98
|
+
* NOTE: `ui/public/graph/composer.mjs` keeps a twin of this list as a `Set` (`.has`), not an
|
|
99
|
+
* Array (`.includes`) — the two are not interchangeable. */
|
|
100
|
+
export const RESERVED_WORKFLOW_IDS = Object.freeze([GRAPH_DEFAULT_WORKFLOW.id, AUTO_WORKFLOW_ID, MEMORY_DEFRAG_WORKFLOW_ID]);
|
|
101
|
+
export function isReservedWorkflowId(id) { return RESERVED_WORKFLOW_IDS.includes(id); }
|
|
@@ -44,7 +44,7 @@ import { readReview, normalizeClarify, normalizeReview, safeParseJson } from '..
|
|
|
44
44
|
import {
|
|
45
45
|
taskHeader, buildSystemPrompt, resolveAgentBody, mockMarkers, runOpts,
|
|
46
46
|
fanOutDirective, ctxFanOut, ctxSubagentModel, ctxEndpointRouted, workspaceFanOutDirective, workspaceDiffInstruction,
|
|
47
|
-
renderAnswers, siblingsBlock, diffInstruction, READ_WRITE_TOOLS, IMPLEMENTER_TOOLS,
|
|
47
|
+
renderAnswers, siblingsBlock, diffInstruction, READ_WRITE_TOOLS, IMPLEMENTER_TOOLS, MEMORY_TOOLS,
|
|
48
48
|
} from '../phases.mjs';
|
|
49
49
|
import { SUBAGENT_MODELS } from '../model-env.mjs';
|
|
50
50
|
import { AWAIT_PORT } from '../../shared/graph/constants.mjs';
|
|
@@ -578,6 +578,13 @@ async function readPriorAnswers(ports, bindings = {}) {
|
|
|
578
578
|
return Array.isArray(json?.answers) ? json.answers : [];
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
/** The role's base allow-list by side effect (effectiveAllowedTools unions the .md frontmatter on top). */
|
|
582
|
+
export function toolsForMeta(meta) {
|
|
583
|
+
if (meta?.sideEffect === 'code') return IMPLEMENTER_TOOLS;
|
|
584
|
+
if (meta?.sideEffect === 'memory') return MEMORY_TOOLS;
|
|
585
|
+
return READ_WRITE_TOOLS;
|
|
586
|
+
}
|
|
587
|
+
|
|
581
588
|
/**
|
|
582
589
|
* Prepare an agent execution: allocate whatever the caller did not, resolve the mock
|
|
583
590
|
* role and the prior answers, and assemble both prompts. Shared by the agent and
|
|
@@ -605,10 +612,10 @@ async function prepare(ctx) {
|
|
|
605
612
|
if (!String(body || '').trim()) {
|
|
606
613
|
console.warn(`[executor] node "${node?.id}": no agent .md body resolved — running with an empty system prompt`);
|
|
607
614
|
}
|
|
608
|
-
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, body, role, ctx.workspace);
|
|
615
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, body, role, ctx.workspace, ctx.memoryBlock);
|
|
609
616
|
const full = { ...ctx, ports, meta, outputs, verdict, expandsPort, mockRole, priorAnswers };
|
|
610
617
|
const prompt = buildAgentPrompt(full);
|
|
611
|
-
const allowedTools = meta
|
|
618
|
+
const allowedTools = toolsForMeta(meta);
|
|
612
619
|
// D3: an EXPLICIT alias pin on an endpoint-routed node is a stored promise the
|
|
613
620
|
// run cannot keep — degrade it (the prompt already carries the same-endpoint
|
|
614
621
|
// block) and say so on the result, which the scheduler folds into
|
|
@@ -670,6 +677,7 @@ export async function runAgentExecution(ctx) {
|
|
|
670
677
|
],
|
|
671
678
|
sessionId,
|
|
672
679
|
prompt,
|
|
680
|
+
systemPrompt,
|
|
673
681
|
};
|
|
674
682
|
}
|
|
675
683
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// src/core/index-html.mjs — the one server-side render of the web shell: the
|
|
2
|
+
// stored theme mode goes into <html data-theme="…"> so the first paint is
|
|
3
|
+
// already right (dark-mode design §5.2). Pure: no fs, no settings — the server
|
|
4
|
+
// hands in the file text and the mode. `theme` is always one of THEME_MODES
|
|
5
|
+
// (settings.mjs#theme guarantees it), so no escaping question arises.
|
|
6
|
+
export const INDEX_THEME_ANCHOR = '<html lang="en" data-theme="system">';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} html the on-disk ui/public/index.html
|
|
10
|
+
* @param {'system'|'light'|'dark'} theme
|
|
11
|
+
* @returns {string}
|
|
12
|
+
* @throws {Error} when the anchor is not present (index.html drifted)
|
|
13
|
+
*/
|
|
14
|
+
export function renderIndexHtml(html, theme) {
|
|
15
|
+
if (!html.includes(INDEX_THEME_ANCHOR)) throw new Error('index.html theme anchor missing');
|
|
16
|
+
return html.replace(INDEX_THEME_ANCHOR, `<html lang="en" data-theme="${theme}">`);
|
|
17
|
+
}
|