@worca/app 1.0.0 → 1.2.0-rc.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 +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -108,7 +108,11 @@ export async function rmGuarded(runRoot, { worcaHome, pipelineId } = {}) {
|
|
|
108
108
|
return { ok: false, removed: false, reason: `refusing to remove ${target}: basename !== pipelineId ${pipelineId}` };
|
|
109
109
|
}
|
|
110
110
|
try {
|
|
111
|
-
|
|
111
|
+
// maxRetries: the run root holds git worktree checkouts; on Windows a dir
|
|
112
|
+
// cannot be removed while git's index/packed-refs handle (or a virus scanner)
|
|
113
|
+
// is still open, so a single rm EBUSY/EPERMs under load. Node backs off across
|
|
114
|
+
// attempts (~cumulative seconds); POSIX takes the first try.
|
|
115
|
+
await rm(target, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
|
|
112
116
|
return { ok: true, removed: true };
|
|
113
117
|
} catch (err) {
|
|
114
118
|
return { ok: false, removed: false, reason: err?.message || String(err) };
|
package/src/core/settings.mjs
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
// contextMaxBytesPerFile — §5.4 per-source-file inlining cap.
|
|
12
12
|
// contextMaxBytesTotal — §5.4 total memory budget.
|
|
13
13
|
// skillMount — §5.6 'copy' (default) | 'symlink' (opt-in).
|
|
14
|
+
// debugSpawnEnabled — the stored spawn-diagnostics preference (a UI checkbox).
|
|
15
|
+
// claude-runner.mjs reads it fresh on every spawn through
|
|
16
|
+
// effectiveDebugSpawn(), so it applies to the UI server AND
|
|
17
|
+
// to CLI runs with no restart. A NON-EMPTY WORCA_DEBUG_SPAWN
|
|
18
|
+
// in the process environment overrides it (power-user
|
|
19
|
+
// override); this module never writes process.env.
|
|
14
20
|
// pipelineCostLimitUsd — per-pipeline lifetime USD spend cap; unset = no limit.
|
|
15
21
|
// totalCostLimitUsd — windowed all-pipelines USD spend cap; unset = no limit.
|
|
16
22
|
// costLimitResetPeriod — total-budget window, 'weekly' | 'monthly' (default).
|
|
@@ -42,7 +48,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
|
42
48
|
import { join, resolve } from 'node:path';
|
|
43
49
|
import { homedir } from 'node:os';
|
|
44
50
|
import { randomBytes } from 'node:crypto';
|
|
45
|
-
import { EFFORTS, isReservedModelEnvKey } from './model-env.mjs';
|
|
51
|
+
import { EFFORTS, isReservedModelEnvKey, assertModelCost, envFlag } from './model-env.mjs';
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
|
|
@@ -384,6 +390,74 @@ export function assertCostLimitInputs(inputs = {}) {
|
|
|
384
390
|
if (has('costLimitResetPeriod')) assertResetPeriodInput(inputs.costLimitResetPeriod);
|
|
385
391
|
}
|
|
386
392
|
|
|
393
|
+
// ── Ask Worca per-turn limits (ask-worca-design.md §6.9, D12) ────────────────
|
|
394
|
+
// Two keys, both read fresh on every chat turn. `askMaxTurns` is an integer cap
|
|
395
|
+
// on claude's agentic turns (--max-turns); `askMaxBudgetUsd` is the per-turn
|
|
396
|
+
// dollar cap (--max-budget-usd). For the budget key the literal `null` is a
|
|
397
|
+
// STORED value meaning "no cap" (the flag is omitted), while '' / undefined clear
|
|
398
|
+
// the key back to the default — the two semantics the design assigns to that key.
|
|
399
|
+
export const DEFAULT_ASK_MAX_TURNS = 40;
|
|
400
|
+
export const DEFAULT_ASK_MAX_BUDGET_USD = 2;
|
|
401
|
+
|
|
402
|
+
const isAskMaxTurns = (v) => Number.isSafeInteger(v) && v >= 1 && v <= 500;
|
|
403
|
+
const isAskMaxBudget = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0.1 && v <= 100;
|
|
404
|
+
|
|
405
|
+
/** --max-turns for one chat turn: integer 1..500; absent/invalid ⇒ the default (loudly). */
|
|
406
|
+
export function askMaxTurns() {
|
|
407
|
+
const v = readSettings().askMaxTurns;
|
|
408
|
+
if (v === undefined) return DEFAULT_ASK_MAX_TURNS;
|
|
409
|
+
if (isAskMaxTurns(v)) return v;
|
|
410
|
+
console.warn(`[worca] invalid askMaxTurns ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_TURNS})`);
|
|
411
|
+
return DEFAULT_ASK_MAX_TURNS;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** --max-budget-usd for one chat turn: number 0.1..100, or null = no cap; absent/invalid ⇒ the default (loudly). */
|
|
415
|
+
export function askMaxBudgetUsd() {
|
|
416
|
+
const v = readSettings().askMaxBudgetUsd;
|
|
417
|
+
if (v === undefined) return DEFAULT_ASK_MAX_BUDGET_USD;
|
|
418
|
+
if (v === null) return null;
|
|
419
|
+
if (isAskMaxBudget(v)) return v;
|
|
420
|
+
console.warn(`[worca] invalid askMaxBudgetUsd ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_BUDGET_USD})`);
|
|
421
|
+
return DEFAULT_ASK_MAX_BUDGET_USD;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function assertAskMaxTurnsInput(input) {
|
|
425
|
+
if (!isClearInput(input) && !isAskMaxTurns(input)) {
|
|
426
|
+
throw new Error('askMaxTurns must be an integer between 1 and 500');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function assertAskMaxBudgetInput(input) {
|
|
430
|
+
if (input === '' || input === undefined || input === null) return; // clear, or stored no-cap
|
|
431
|
+
if (!isAskMaxBudget(input)) {
|
|
432
|
+
throw new Error('askMaxBudgetUsd must be null (no cap) or a number between 0.1 and 100');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Validate the ask keys PRESENT in `inputs` as a set, before any write (assertCostLimitInputs pattern). */
|
|
437
|
+
export function assertAskLimitInputs(inputs = {}) {
|
|
438
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(inputs, k);
|
|
439
|
+
if (has('askMaxTurns')) assertAskMaxTurnsInput(inputs.askMaxTurns);
|
|
440
|
+
if (has('askMaxBudgetUsd')) assertAskMaxBudgetInput(inputs.askMaxBudgetUsd);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function setAskMaxTurns(input) {
|
|
444
|
+
assertAskMaxTurnsInput(input);
|
|
445
|
+
const settings = readSettings();
|
|
446
|
+
if (isClearInput(input)) delete settings.askMaxTurns;
|
|
447
|
+
else settings.askMaxTurns = input;
|
|
448
|
+
await persistSettings(settings);
|
|
449
|
+
return { askMaxTurns: askMaxTurns() };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function setAskMaxBudgetUsd(input) {
|
|
453
|
+
assertAskMaxBudgetInput(input);
|
|
454
|
+
const settings = readSettings();
|
|
455
|
+
if (input === '' || input === undefined) delete settings.askMaxBudgetUsd;
|
|
456
|
+
else settings.askMaxBudgetUsd = input; // a number, or the literal null (no cap)
|
|
457
|
+
await persistSettings(settings);
|
|
458
|
+
return { askMaxBudgetUsd: askMaxBudgetUsd() };
|
|
459
|
+
}
|
|
460
|
+
|
|
387
461
|
/** Write (or clear) a USD cap key. @throws {Error} unless positive finite number (or empty). */
|
|
388
462
|
async function setUsdCap(key, input) {
|
|
389
463
|
assertUsdCapInput(key, input);
|
|
@@ -458,6 +532,75 @@ export async function setCostLimitResetPeriod(input) {
|
|
|
458
532
|
return { costLimitResetPeriod: costLimitResetPeriod() };
|
|
459
533
|
}
|
|
460
534
|
|
|
535
|
+
// ── The keys POST /api/settings understands ──────────────────────────────────
|
|
536
|
+
// The route keeps a legacy contract: a body naming NONE of these clears root
|
|
537
|
+
// (test/settings-projects-root.test.mjs "a bodyless POST resets root"). Every
|
|
538
|
+
// setter's key is listed HERE, beside the setters, so a new key cannot forget
|
|
539
|
+
// to join a hand-maintained exclusion list in the route and wipe the root on
|
|
540
|
+
// its first save.
|
|
541
|
+
export const SETTINGS_POST_KEYS = Object.freeze([
|
|
542
|
+
'root', 'projectsRoot', 'chat',
|
|
543
|
+
'pipelineCostLimitUsd', 'totalCostLimitUsd', 'costLimitResetPeriod',
|
|
544
|
+
'askMaxTurns', 'askMaxBudgetUsd',
|
|
545
|
+
'debugSpawnEnabled',
|
|
546
|
+
]);
|
|
547
|
+
|
|
548
|
+
// ── Spawn-debug diagnostics toggle (the stored side of WORCA_DEBUG_SPAWN) ────
|
|
549
|
+
// Like every other stored setting (skillMount, the cost caps, the ask caps) this
|
|
550
|
+
// is READ AT USE TIME: claude-runner.mjs#debugSpawnEnabled calls
|
|
551
|
+
// effectiveDebugSpawn() on every spawn, so a UI save reaches the very next spawn
|
|
552
|
+
// in this process and in any CLI process with no restart, and nothing here ever
|
|
553
|
+
// mutates process.env (a runtime env write would leak an explicit
|
|
554
|
+
// WORCA_DEBUG_SPAWN=0 into every inherited child env and break the runner's
|
|
555
|
+
// "OFF ⇒ byte-identical spawn env" invariant).
|
|
556
|
+
export const DEFAULT_DEBUG_SPAWN_ENABLED = false;
|
|
557
|
+
|
|
558
|
+
const isBool = (v) => typeof v === 'boolean';
|
|
559
|
+
|
|
560
|
+
/** @throws {Error} unless `input` is a boolean (the route and the setter share this). */
|
|
561
|
+
export function assertDebugSpawnInput(input) {
|
|
562
|
+
if (!isBool(input)) throw new Error('debugSpawnEnabled must be true or false');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** STORED spawn-debug preference: boolean, default OFF. Invalid stored value ⇒ OFF (loudly). */
|
|
566
|
+
export function debugSpawnEnabled() {
|
|
567
|
+
const v = readSettings().debugSpawnEnabled;
|
|
568
|
+
if (v === undefined) return DEFAULT_DEBUG_SPAWN_ENABLED;
|
|
569
|
+
if (isBool(v)) return v;
|
|
570
|
+
console.warn(`[worca] invalid debugSpawnEnabled ${JSON.stringify(v)} — using the default (${DEFAULT_DEBUG_SPAWN_ENABLED})`);
|
|
571
|
+
return DEFAULT_DEBUG_SPAWN_ENABLED;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* What the runner will actually do on the next spawn, and why. ONE precedence
|
|
576
|
+
* rule, shared by the runner gate and the settings API: a NON-EMPTY
|
|
577
|
+
* WORCA_DEBUG_SPAWN in the environment wins (parsed with the envFlag rule, so an
|
|
578
|
+
* exported "0"/"false" is an explicit OFF override), otherwise the stored
|
|
579
|
+
* preference applies. An empty export (`export WORCA_DEBUG_SPAWN=` in a profile
|
|
580
|
+
* or a dotenv template) is NOT an override — the runner would read it as OFF
|
|
581
|
+
* while the UI showed the stored value checked, with nothing explaining why.
|
|
582
|
+
* @returns {{enabled: boolean, source: 'env'|'settings'}}
|
|
583
|
+
*/
|
|
584
|
+
export function effectiveDebugSpawn() {
|
|
585
|
+
const v = process.env.WORCA_DEBUG_SPAWN;
|
|
586
|
+
if (v !== undefined && v !== '') return { enabled: envFlag('WORCA_DEBUG_SPAWN'), source: 'env' };
|
|
587
|
+
return { enabled: debugSpawnEnabled(), source: 'settings' };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Persist the preference. Nothing else: the runner reads it back per spawn, so
|
|
592
|
+
* the change is live everywhere without touching this process's environment.
|
|
593
|
+
* @throws {Error} unless `input` is a boolean.
|
|
594
|
+
*/
|
|
595
|
+
export async function setDebugSpawnEnabled(input) {
|
|
596
|
+
assertDebugSpawnInput(input);
|
|
597
|
+
const settings = readSettings();
|
|
598
|
+
if (input === DEFAULT_DEBUG_SPAWN_ENABLED) delete settings.debugSpawnEnabled;
|
|
599
|
+
else settings.debugSpawnEnabled = input;
|
|
600
|
+
await persistSettings(settings);
|
|
601
|
+
return { debugSpawnEnabled: debugSpawnEnabled() };
|
|
602
|
+
}
|
|
603
|
+
|
|
461
604
|
// ---------------------------------------------------------------------------
|
|
462
605
|
// Global model catalog (configurable-models-design.md §4.1). Stored entries are
|
|
463
606
|
// MINIMAL — label only when it differs from id, efforts only when a proper
|
|
@@ -486,23 +629,42 @@ function sanitizeGlobalModel(raw) {
|
|
|
486
629
|
const env = {};
|
|
487
630
|
const rawEnv = raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env) ? raw.env : {};
|
|
488
631
|
for (const [k, v] of Object.entries(rawEnv)) {
|
|
489
|
-
|
|
632
|
+
// Trim before storing: an ANTHROPIC_MODEL wire id (#374) reaches `--model`
|
|
633
|
+
// verbatim, so a pasted ' claude-opus-4-8' or whitespace-only value must not
|
|
634
|
+
// land on disk. Whitespace-only (truthy but empty after trim) is dropped.
|
|
635
|
+
const t = typeof v === 'string' ? v.trim() : '';
|
|
636
|
+
if (isReservedModelEnvKey(k) || typeof v !== 'string' || !t) {
|
|
490
637
|
console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping env key ${JSON.stringify(k)} (reserved or not a non-empty string)`);
|
|
491
638
|
continue;
|
|
492
639
|
}
|
|
493
|
-
env[k] =
|
|
640
|
+
env[k] = t;
|
|
494
641
|
}
|
|
642
|
+
const cost = sanitizeModelCost(raw.cost, id);
|
|
495
643
|
return {
|
|
496
644
|
id,
|
|
497
645
|
label,
|
|
498
646
|
efforts: efforts.length ? efforts : [...EFFORTS],
|
|
499
647
|
...(Object.keys(env).length ? { env } : {}),
|
|
648
|
+
...(cost ? { cost } : {}),
|
|
500
649
|
};
|
|
501
650
|
}
|
|
502
651
|
|
|
652
|
+
/** Lenient read-side counterpart of assertModelCost (model-env.mjs): drops an
|
|
653
|
+
* invalid `cost` loudly instead of throwing (a corrupt settings.json must never
|
|
654
|
+
* break reads). */
|
|
655
|
+
function sanitizeModelCost(raw, id) {
|
|
656
|
+
if (raw == null) return undefined;
|
|
657
|
+
try {
|
|
658
|
+
return assertModelCost(raw);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping invalid cost — ${e.message}`);
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
503
665
|
/**
|
|
504
|
-
* The sanitized global model catalog: [{id, label, efforts, env?}],
|
|
505
|
-
* shape (label/efforts always present). Missing/corrupt -> []. Malformed and
|
|
666
|
+
* The sanitized global model catalog: [{id, label, efforts, env?, cost?}],
|
|
667
|
+
* effective shape (label/efforts always present). Missing/corrupt -> []. Malformed and
|
|
506
668
|
* case-insensitively duplicate entries are dropped loudly (first wins). Never
|
|
507
669
|
* throws.
|
|
508
670
|
*/
|
|
@@ -580,12 +742,13 @@ function assertTestSettingsAccess() {
|
|
|
580
742
|
}
|
|
581
743
|
|
|
582
744
|
/** The MINIMAL stored shape for validated parts (see section comment). */
|
|
583
|
-
function storedModelShape(id, label, efforts, env) {
|
|
745
|
+
function storedModelShape(id, label, efforts, env, cost) {
|
|
584
746
|
return {
|
|
585
747
|
id,
|
|
586
748
|
...(label && label !== id ? { label } : {}),
|
|
587
749
|
...(efforts.length && efforts.length !== EFFORTS.length ? { efforts } : {}),
|
|
588
750
|
...(Object.keys(env).length ? { env } : {}),
|
|
751
|
+
...(cost ? { cost } : {}),
|
|
589
752
|
};
|
|
590
753
|
}
|
|
591
754
|
|
|
@@ -603,21 +766,23 @@ function rawModels(settings) {
|
|
|
603
766
|
|
|
604
767
|
/**
|
|
605
768
|
* Add a global catalog entry. `label` defaults to the id; `efforts` must be a
|
|
606
|
-
* subset of EFFORTS (empty/absent = all); `env` keys must not be reserved
|
|
607
|
-
*
|
|
769
|
+
* subset of EFFORTS (empty/absent = all); `env` keys must not be reserved;
|
|
770
|
+
* `cost` is an optional per-model override ({free} | {perMtok}, see assertModelCost).
|
|
771
|
+
* @returns {Promise<{id:string,label:string,efforts:string[],env?:object,cost?:object}>} the effective entry
|
|
608
772
|
* @throws {Error} on invalid input or a case-insensitively duplicate id
|
|
609
773
|
*/
|
|
610
|
-
export async function addGlobalModel({ id, label, efforts, env } = {}) {
|
|
774
|
+
export async function addGlobalModel({ id, label, efforts, env, cost } = {}) {
|
|
611
775
|
assertTestSettingsAccess();
|
|
612
776
|
const vid = assertModelId(id);
|
|
613
777
|
if (!isClearInput(label) && typeof label !== 'string') throw new Error('label must be a string');
|
|
614
778
|
const vefforts = assertEfforts(efforts);
|
|
615
779
|
const venv = assertEnvPairs(env);
|
|
780
|
+
const vcost = assertModelCost(cost);
|
|
616
781
|
const settings = readSettings();
|
|
617
782
|
const models = rawModels(settings);
|
|
618
783
|
if (findModelIndex(models, vid) !== -1) throw new Error(`a model with id ${JSON.stringify(vid)} already exists`);
|
|
619
784
|
const vlabel = (typeof label === 'string' && label.trim()) || vid;
|
|
620
|
-
settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv)];
|
|
785
|
+
settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv, vcost)];
|
|
621
786
|
await persistSettings(settings);
|
|
622
787
|
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
623
788
|
}
|
|
@@ -626,11 +791,12 @@ export async function addGlobalModel({ id, label, efforts, env } = {}) {
|
|
|
626
791
|
* Patch a global catalog entry. Omitted fields are kept. `label`: ''/null
|
|
627
792
|
* resets to the id. `efforts`: []/null resets to all. `env`: null clears the
|
|
628
793
|
* whole map; an object merges per key, where a null value DELETES that key and
|
|
629
|
-
* a string sets it (write-only PATCH semantics, design §4.10).
|
|
794
|
+
* a string sets it (write-only PATCH semantics, design §4.10). `cost`: null/''
|
|
795
|
+
* removes the override; an object replaces it wholesale.
|
|
630
796
|
* @returns {Promise<object>} the effective entry
|
|
631
797
|
* @throws {Error} on an unknown id or invalid input
|
|
632
798
|
*/
|
|
633
|
-
export async function updateGlobalModel(id, { label, efforts, env } = {}) {
|
|
799
|
+
export async function updateGlobalModel(id, { label, efforts, env, cost } = {}) {
|
|
634
800
|
assertTestSettingsAccess();
|
|
635
801
|
const vid = assertModelId(id);
|
|
636
802
|
const settings = readSettings();
|
|
@@ -658,8 +824,13 @@ export async function updateGlobalModel(id, { label, efforts, env } = {}) {
|
|
|
658
824
|
}
|
|
659
825
|
}
|
|
660
826
|
|
|
827
|
+
// cost: omitted keeps the current override; null/'' removes it; an object
|
|
828
|
+
// replaces it wholesale (not a per-key merge — the table is small).
|
|
829
|
+
let nextCost = current.cost;
|
|
830
|
+
if (cost !== undefined) nextCost = isClearInput(cost) ? undefined : assertModelCost(cost);
|
|
831
|
+
|
|
661
832
|
settings.models = models.slice();
|
|
662
|
-
settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv);
|
|
833
|
+
settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv, nextCost);
|
|
663
834
|
await persistSettings(settings);
|
|
664
835
|
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
665
836
|
}
|
package/src/core/skills.mjs
CHANGED
|
@@ -105,12 +105,19 @@ export function resolveSkill(name, { repoRoot, projectDir, homeDir = homedir(),
|
|
|
105
105
|
* `registry` is the plain object returned by loadAgentRegistry (keyed by agent
|
|
106
106
|
* key); `plan.steps` is Array<Array<node>> and each node has `.key`.
|
|
107
107
|
* @param {Record<string, {requiresSkills?: string[]}>} registry
|
|
108
|
-
* @param {{steps?: Array<Array<{key:string}>>}} plan
|
|
108
|
+
* @param {{steps?: Array<Array<{key:string}>>}|Iterable<string>} planOrKeys v1 plan, or agent keys
|
|
109
109
|
* @returns {Array<{skill:string, requiredBy:string[]}>}
|
|
110
110
|
*/
|
|
111
|
-
export function collectRequiredSkills(registry,
|
|
111
|
+
export function collectRequiredSkills(registry, planOrKeys) {
|
|
112
|
+
// Two entry shapes: the v1 ExecutablePlan ({steps:[[{key}]]}) and a plain
|
|
113
|
+
// iterable of agent keys (what the engine-agnostic run harness has — it never
|
|
114
|
+
// sees a v1 plan). A plan object is not iterable, so the test is unambiguous.
|
|
112
115
|
const nodeKeys = new Set();
|
|
113
|
-
|
|
116
|
+
if (planOrKeys && typeof planOrKeys !== 'string' && typeof planOrKeys[Symbol.iterator] === 'function') {
|
|
117
|
+
for (const key of planOrKeys) nodeKeys.add(key);
|
|
118
|
+
} else {
|
|
119
|
+
for (const group of planOrKeys?.steps || []) for (const node of group) nodeKeys.add(node.key);
|
|
120
|
+
}
|
|
114
121
|
/** @type {Map<string, Set<string>>} */
|
|
115
122
|
const bySkill = new Map();
|
|
116
123
|
for (const key of nodeKeys) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// src/core/source-bindings.mjs
|
|
2
|
+
// Which PROFILE of a plugin task source a project (or workspace) pulls from.
|
|
3
|
+
//
|
|
4
|
+
// The problem this solves: one worca install drives several projects, and each
|
|
5
|
+
// may belong to a different tracker instance — repo A files tickets in one Jira,
|
|
6
|
+
// repo B in another. Making that a per-run dropdown is how you start a pipeline
|
|
7
|
+
// against the wrong tracker: the mistake is silent (the wrong ticket text simply
|
|
8
|
+
// lands in the prompt) and only visible once the run is underway. So the choice
|
|
9
|
+
// is made ONCE per project and remembered here.
|
|
10
|
+
//
|
|
11
|
+
// Rows are (scope_type, scope_key, plugin, source_id) -> profile. A project that
|
|
12
|
+
// pulls from two different sources binds each independently.
|
|
13
|
+
//
|
|
14
|
+
// Deliberately tolerant on read: an unbound scope, an uninstalled plugin and a
|
|
15
|
+
// profile that was since deleted all collapse to the same answer (null), because
|
|
16
|
+
// every caller's next move is identical — ask the user. But "never bound" and
|
|
17
|
+
// "was bound, then the profile went away" are NOT the same for the single-
|
|
18
|
+
// profile convenience fallback: a scope that once chose a tracker must be ASKED
|
|
19
|
+
// again, never silently re-pointed at whichever profile happens to survive. So
|
|
20
|
+
// deleting a profile TOMBSTONES its bindings (profile = '') instead of dropping
|
|
21
|
+
// the rows; a tombstone reads as unbound everywhere except resolveProfile's
|
|
22
|
+
// 'only' fallback, which it suppresses.
|
|
23
|
+
|
|
24
|
+
import { getDb, prepare } from './db.mjs';
|
|
25
|
+
|
|
26
|
+
/** Deleted-profile marker. Reads as "unbound" (getBinding -> null) but records
|
|
27
|
+
* that this scope HAD chosen — resolveProfile must ask, not guess. */
|
|
28
|
+
const TOMBSTONE = '';
|
|
29
|
+
|
|
30
|
+
/** The two scopes a binding can hang off. */
|
|
31
|
+
const SCOPES = new Set(['project', 'workspace']);
|
|
32
|
+
|
|
33
|
+
function checkScope(scopeType) {
|
|
34
|
+
if (!SCOPES.has(scopeType)) throw new Error(`invalid binding scope "${scopeType}"`);
|
|
35
|
+
return scopeType;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = (o = {}) => ({
|
|
39
|
+
scopeType: checkScope(o.scopeType),
|
|
40
|
+
scopeKey: String(o.scopeKey || ''),
|
|
41
|
+
plugin: String(o.plugin || ''),
|
|
42
|
+
sourceId: String(o.sourceId || ''),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Raw row value: null (no row) | '' (tombstone) | profile id. Internal —
|
|
46
|
+
* resolveProfile needs the three-way answer; everyone else wants getBinding. */
|
|
47
|
+
function rawBinding(ref) {
|
|
48
|
+
const k = key(ref);
|
|
49
|
+
if (!k.scopeKey || !k.plugin || !k.sourceId) return null;
|
|
50
|
+
getDb();
|
|
51
|
+
const row = prepare(
|
|
52
|
+
'SELECT profile FROM source_bindings WHERE scope_type = ? AND scope_key = ? AND plugin = ? AND source_id = ?'
|
|
53
|
+
).get(k.scopeType, k.scopeKey, k.plugin, k.sourceId);
|
|
54
|
+
return row ? String(row.profile) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The profile explicitly bound to this scope, or null (a tombstoned binding
|
|
59
|
+
* reads as unbound). No fallback logic — use resolveProfile() for that.
|
|
60
|
+
* @returns {string|null}
|
|
61
|
+
*/
|
|
62
|
+
export function getBinding(ref) {
|
|
63
|
+
const p = rawBinding(ref);
|
|
64
|
+
return p === TOMBSTONE ? null : p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Bind (or re-bind) a scope to a profile. Returns the stored profile. */
|
|
68
|
+
export function setBinding(ref, profile) {
|
|
69
|
+
const k = key(ref);
|
|
70
|
+
const p = String(profile || '').trim();
|
|
71
|
+
if (!k.scopeKey || !k.plugin || !k.sourceId) throw new Error('binding needs scopeKey, plugin and sourceId');
|
|
72
|
+
if (!p) throw new Error('binding needs a profile');
|
|
73
|
+
getDb();
|
|
74
|
+
prepare(`
|
|
75
|
+
INSERT INTO source_bindings (scope_type, scope_key, plugin, source_id, profile, updated_at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
77
|
+
ON CONFLICT (scope_type, scope_key, plugin, source_id)
|
|
78
|
+
DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at
|
|
79
|
+
`).run(k.scopeType, k.scopeKey, k.plugin, k.sourceId, p, new Date().toISOString());
|
|
80
|
+
return p;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Remove a binding. Silent when there was none. */
|
|
84
|
+
export function clearBinding(ref) {
|
|
85
|
+
const k = key(ref);
|
|
86
|
+
getDb();
|
|
87
|
+
prepare(
|
|
88
|
+
'DELETE FROM source_bindings WHERE scope_type = ? AND scope_key = ? AND plugin = ? AND source_id = ?'
|
|
89
|
+
).run(k.scopeType, k.scopeKey, k.plugin, k.sourceId);
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every LIVE binding for one scope: [{ plugin, sourceId, profile }].
|
|
94
|
+
* Tombstones are bookkeeping for resolveProfile, not bindings — excluded. */
|
|
95
|
+
export function listBindingsForScope(scopeType, scopeKey) {
|
|
96
|
+
getDb();
|
|
97
|
+
return prepare(
|
|
98
|
+
"SELECT plugin, source_id, profile FROM source_bindings WHERE scope_type = ? AND scope_key = ? AND profile != '' ORDER BY plugin, source_id"
|
|
99
|
+
).all(checkScope(scopeType), String(scopeKey || ''))
|
|
100
|
+
.map((r) => ({ plugin: r.plugin, sourceId: r.source_id, profile: r.profile }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Tombstone every binding naming a profile — called when that profile is
|
|
104
|
+
* deleted, so a project never points at a profile that is gone, and never
|
|
105
|
+
* silently re-points at a survivor either (the tombstone suppresses the
|
|
106
|
+
* single-profile fallback until the user chooses again). Plugin-wide (no
|
|
107
|
+
* sourceId): profiles and their buckets are per-PLUGIN, so deleting one
|
|
108
|
+
* dangles every source's binding to it, not just the source the delete came
|
|
109
|
+
* in through. Returns the row count. */
|
|
110
|
+
export function clearBindingsForProfile(plugin, profile) {
|
|
111
|
+
getDb();
|
|
112
|
+
const p = String(profile || '');
|
|
113
|
+
if (!p) return 0; // '' is the tombstone itself, never a deletable profile
|
|
114
|
+
const r = prepare(
|
|
115
|
+
'UPDATE source_bindings SET profile = ?, updated_at = ? WHERE plugin = ? AND profile = ?'
|
|
116
|
+
).run(TOMBSTONE, new Date().toISOString(), String(plugin || ''), p);
|
|
117
|
+
return Number(r?.changes ?? 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Drop every binding for a plugin — called on uninstall. */
|
|
121
|
+
export function clearBindingsForPlugin(plugin) {
|
|
122
|
+
getDb();
|
|
123
|
+
prepare('DELETE FROM source_bindings WHERE plugin = ?').run(String(plugin || ''));
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The profile a run should use, with the fallbacks that make the common case
|
|
129
|
+
* invisible:
|
|
130
|
+
*
|
|
131
|
+
* 1. an explicit binding on this scope wins;
|
|
132
|
+
* 2. for a WORKSPACE with no binding of its own, the member projects' bindings
|
|
133
|
+
* decide — but only if they AGREE. Members split across two trackers is a
|
|
134
|
+
* real ambiguity, and guessing one would be the silent-wrong-tracker bug
|
|
135
|
+
* this module exists to prevent, so it stays unresolved;
|
|
136
|
+
* 3. a source with exactly one profile needs no ceremony — use it, UNLESS
|
|
137
|
+
* this scope once chose a profile that has since gone away (a tombstoned
|
|
138
|
+
* or stale binding): its next run against a tracker it never picked is
|
|
139
|
+
* the exact silent-wrong-tracker bug again, so it degrades to "ask";
|
|
140
|
+
* 4. otherwise null: the caller must ask.
|
|
141
|
+
*
|
|
142
|
+
* `available` is the source's profile id list (plugin-config#listProfiles). A
|
|
143
|
+
* binding naming a profile that is no longer in it is treated as absent, so
|
|
144
|
+
* deleting a profile degrades to "ask" rather than to a broken run.
|
|
145
|
+
*
|
|
146
|
+
* @param {{scopeType:string, scopeKey:string, plugin:string, sourceId:string,
|
|
147
|
+
* memberKeys?:string[], available?:string[]}} ref
|
|
148
|
+
* @returns {{profile:string|null, via:'binding'|'members'|'only'|'none'|'conflict',
|
|
149
|
+
* candidates?:string[]}}
|
|
150
|
+
*/
|
|
151
|
+
export function resolveProfile(ref = {}) {
|
|
152
|
+
const available = Array.isArray(ref.available) ? ref.available.map(String) : null;
|
|
153
|
+
const known = (p) => !!p && (!available || available.includes(p));
|
|
154
|
+
|
|
155
|
+
const own = rawBinding(ref);
|
|
156
|
+
if (known(own)) return { profile: own, via: 'binding' };
|
|
157
|
+
// A row that no longer names a live profile — tombstoned by a delete, or
|
|
158
|
+
// stale (its profile left the roster while we weren't looking) — means this
|
|
159
|
+
// scope HAD chosen. It must choose again; no fallback may choose for it.
|
|
160
|
+
let hadBinding = own !== null;
|
|
161
|
+
|
|
162
|
+
if (ref.scopeType === 'workspace' && Array.isArray(ref.memberKeys) && ref.memberKeys.length) {
|
|
163
|
+
const found = new Set();
|
|
164
|
+
for (const k of ref.memberKeys) {
|
|
165
|
+
const p = rawBinding({ scopeType: 'project', scopeKey: k, plugin: ref.plugin, sourceId: ref.sourceId });
|
|
166
|
+
if (known(p)) found.add(p);
|
|
167
|
+
else if (p !== null) hadBinding = true; // a member's lapsed choice suppresses the fallback too
|
|
168
|
+
}
|
|
169
|
+
if (found.size === 1) return { profile: [...found][0], via: 'members' };
|
|
170
|
+
if (found.size > 1) return { profile: null, via: 'conflict', candidates: [...found] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!hadBinding && available && available.length === 1) return { profile: available[0], via: 'only' };
|
|
174
|
+
return { profile: null, via: 'none' };
|
|
175
|
+
}
|