@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.
Files changed (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. 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
- await rm(target, { recursive: true, force: true });
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) };
@@ -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
- if (isReservedModelEnvKey(k) || typeof v !== 'string' || !v) {
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] = v;
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?}], effective
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
- * @returns {Promise<{id:string,label:string,efforts:string[],env?:object}>} the effective entry
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
  }
@@ -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, plan) {
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
- for (const group of plan?.steps || []) for (const node of group) nodeKeys.add(node.key);
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
+ }