@worca/app 1.0.0 → 1.1.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 (138) hide show
  1. package/README.md +22 -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 +319 -45
  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 +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. 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) };
@@ -42,7 +42,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
42
42
  import { join, resolve } from 'node:path';
43
43
  import { homedir } from 'node:os';
44
44
  import { randomBytes } from 'node:crypto';
45
- import { EFFORTS, isReservedModelEnvKey } from './model-env.mjs';
45
+ import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
46
46
 
47
47
  /**
48
48
  * The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
@@ -384,6 +384,74 @@ export function assertCostLimitInputs(inputs = {}) {
384
384
  if (has('costLimitResetPeriod')) assertResetPeriodInput(inputs.costLimitResetPeriod);
385
385
  }
386
386
 
387
+ // ── Ask Worca per-turn limits (ask-worca-design.md §6.9, D12) ────────────────
388
+ // Two keys, both read fresh on every chat turn. `askMaxTurns` is an integer cap
389
+ // on claude's agentic turns (--max-turns); `askMaxBudgetUsd` is the per-turn
390
+ // dollar cap (--max-budget-usd). For the budget key the literal `null` is a
391
+ // STORED value meaning "no cap" (the flag is omitted), while '' / undefined clear
392
+ // the key back to the default — the two semantics the design assigns to that key.
393
+ export const DEFAULT_ASK_MAX_TURNS = 40;
394
+ export const DEFAULT_ASK_MAX_BUDGET_USD = 2;
395
+
396
+ const isAskMaxTurns = (v) => Number.isSafeInteger(v) && v >= 1 && v <= 500;
397
+ const isAskMaxBudget = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0.1 && v <= 100;
398
+
399
+ /** --max-turns for one chat turn: integer 1..500; absent/invalid ⇒ the default (loudly). */
400
+ export function askMaxTurns() {
401
+ const v = readSettings().askMaxTurns;
402
+ if (v === undefined) return DEFAULT_ASK_MAX_TURNS;
403
+ if (isAskMaxTurns(v)) return v;
404
+ console.warn(`[worca] invalid askMaxTurns ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_TURNS})`);
405
+ return DEFAULT_ASK_MAX_TURNS;
406
+ }
407
+
408
+ /** --max-budget-usd for one chat turn: number 0.1..100, or null = no cap; absent/invalid ⇒ the default (loudly). */
409
+ export function askMaxBudgetUsd() {
410
+ const v = readSettings().askMaxBudgetUsd;
411
+ if (v === undefined) return DEFAULT_ASK_MAX_BUDGET_USD;
412
+ if (v === null) return null;
413
+ if (isAskMaxBudget(v)) return v;
414
+ console.warn(`[worca] invalid askMaxBudgetUsd ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_BUDGET_USD})`);
415
+ return DEFAULT_ASK_MAX_BUDGET_USD;
416
+ }
417
+
418
+ function assertAskMaxTurnsInput(input) {
419
+ if (!isClearInput(input) && !isAskMaxTurns(input)) {
420
+ throw new Error('askMaxTurns must be an integer between 1 and 500');
421
+ }
422
+ }
423
+ function assertAskMaxBudgetInput(input) {
424
+ if (input === '' || input === undefined || input === null) return; // clear, or stored no-cap
425
+ if (!isAskMaxBudget(input)) {
426
+ throw new Error('askMaxBudgetUsd must be null (no cap) or a number between 0.1 and 100');
427
+ }
428
+ }
429
+
430
+ /** Validate the ask keys PRESENT in `inputs` as a set, before any write (assertCostLimitInputs pattern). */
431
+ export function assertAskLimitInputs(inputs = {}) {
432
+ const has = (k) => Object.prototype.hasOwnProperty.call(inputs, k);
433
+ if (has('askMaxTurns')) assertAskMaxTurnsInput(inputs.askMaxTurns);
434
+ if (has('askMaxBudgetUsd')) assertAskMaxBudgetInput(inputs.askMaxBudgetUsd);
435
+ }
436
+
437
+ export async function setAskMaxTurns(input) {
438
+ assertAskMaxTurnsInput(input);
439
+ const settings = readSettings();
440
+ if (isClearInput(input)) delete settings.askMaxTurns;
441
+ else settings.askMaxTurns = input;
442
+ await persistSettings(settings);
443
+ return { askMaxTurns: askMaxTurns() };
444
+ }
445
+
446
+ export async function setAskMaxBudgetUsd(input) {
447
+ assertAskMaxBudgetInput(input);
448
+ const settings = readSettings();
449
+ if (input === '' || input === undefined) delete settings.askMaxBudgetUsd;
450
+ else settings.askMaxBudgetUsd = input; // a number, or the literal null (no cap)
451
+ await persistSettings(settings);
452
+ return { askMaxBudgetUsd: askMaxBudgetUsd() };
453
+ }
454
+
387
455
  /** Write (or clear) a USD cap key. @throws {Error} unless positive finite number (or empty). */
388
456
  async function setUsdCap(key, input) {
389
457
  assertUsdCapInput(key, input);
@@ -486,23 +554,42 @@ function sanitizeGlobalModel(raw) {
486
554
  const env = {};
487
555
  const rawEnv = raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env) ? raw.env : {};
488
556
  for (const [k, v] of Object.entries(rawEnv)) {
489
- if (isReservedModelEnvKey(k) || typeof v !== 'string' || !v) {
557
+ // Trim before storing: an ANTHROPIC_MODEL wire id (#374) reaches `--model`
558
+ // verbatim, so a pasted ' claude-opus-4-8' or whitespace-only value must not
559
+ // land on disk. Whitespace-only (truthy but empty after trim) is dropped.
560
+ const t = typeof v === 'string' ? v.trim() : '';
561
+ if (isReservedModelEnvKey(k) || typeof v !== 'string' || !t) {
490
562
  console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping env key ${JSON.stringify(k)} (reserved or not a non-empty string)`);
491
563
  continue;
492
564
  }
493
- env[k] = v;
565
+ env[k] = t;
494
566
  }
567
+ const cost = sanitizeModelCost(raw.cost, id);
495
568
  return {
496
569
  id,
497
570
  label,
498
571
  efforts: efforts.length ? efforts : [...EFFORTS],
499
572
  ...(Object.keys(env).length ? { env } : {}),
573
+ ...(cost ? { cost } : {}),
500
574
  };
501
575
  }
502
576
 
577
+ /** Lenient read-side counterpart of assertModelCost (model-env.mjs): drops an
578
+ * invalid `cost` loudly instead of throwing (a corrupt settings.json must never
579
+ * break reads). */
580
+ function sanitizeModelCost(raw, id) {
581
+ if (raw == null) return undefined;
582
+ try {
583
+ return assertModelCost(raw);
584
+ } catch (e) {
585
+ console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping invalid cost — ${e.message}`);
586
+ return undefined;
587
+ }
588
+ }
589
+
503
590
  /**
504
- * The sanitized global model catalog: [{id, label, efforts, env?}], effective
505
- * shape (label/efforts always present). Missing/corrupt -> []. Malformed and
591
+ * The sanitized global model catalog: [{id, label, efforts, env?, cost?}],
592
+ * effective shape (label/efforts always present). Missing/corrupt -> []. Malformed and
506
593
  * case-insensitively duplicate entries are dropped loudly (first wins). Never
507
594
  * throws.
508
595
  */
@@ -580,12 +667,13 @@ function assertTestSettingsAccess() {
580
667
  }
581
668
 
582
669
  /** The MINIMAL stored shape for validated parts (see section comment). */
583
- function storedModelShape(id, label, efforts, env) {
670
+ function storedModelShape(id, label, efforts, env, cost) {
584
671
  return {
585
672
  id,
586
673
  ...(label && label !== id ? { label } : {}),
587
674
  ...(efforts.length && efforts.length !== EFFORTS.length ? { efforts } : {}),
588
675
  ...(Object.keys(env).length ? { env } : {}),
676
+ ...(cost ? { cost } : {}),
589
677
  };
590
678
  }
591
679
 
@@ -603,21 +691,23 @@ function rawModels(settings) {
603
691
 
604
692
  /**
605
693
  * 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
694
+ * subset of EFFORTS (empty/absent = all); `env` keys must not be reserved;
695
+ * `cost` is an optional per-model override ({free} | {perMtok}, see assertModelCost).
696
+ * @returns {Promise<{id:string,label:string,efforts:string[],env?:object,cost?:object}>} the effective entry
608
697
  * @throws {Error} on invalid input or a case-insensitively duplicate id
609
698
  */
610
- export async function addGlobalModel({ id, label, efforts, env } = {}) {
699
+ export async function addGlobalModel({ id, label, efforts, env, cost } = {}) {
611
700
  assertTestSettingsAccess();
612
701
  const vid = assertModelId(id);
613
702
  if (!isClearInput(label) && typeof label !== 'string') throw new Error('label must be a string');
614
703
  const vefforts = assertEfforts(efforts);
615
704
  const venv = assertEnvPairs(env);
705
+ const vcost = assertModelCost(cost);
616
706
  const settings = readSettings();
617
707
  const models = rawModels(settings);
618
708
  if (findModelIndex(models, vid) !== -1) throw new Error(`a model with id ${JSON.stringify(vid)} already exists`);
619
709
  const vlabel = (typeof label === 'string' && label.trim()) || vid;
620
- settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv)];
710
+ settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv, vcost)];
621
711
  await persistSettings(settings);
622
712
  return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
623
713
  }
@@ -626,11 +716,12 @@ export async function addGlobalModel({ id, label, efforts, env } = {}) {
626
716
  * Patch a global catalog entry. Omitted fields are kept. `label`: ''/null
627
717
  * resets to the id. `efforts`: []/null resets to all. `env`: null clears the
628
718
  * 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).
719
+ * a string sets it (write-only PATCH semantics, design §4.10). `cost`: null/''
720
+ * removes the override; an object replaces it wholesale.
630
721
  * @returns {Promise<object>} the effective entry
631
722
  * @throws {Error} on an unknown id or invalid input
632
723
  */
633
- export async function updateGlobalModel(id, { label, efforts, env } = {}) {
724
+ export async function updateGlobalModel(id, { label, efforts, env, cost } = {}) {
634
725
  assertTestSettingsAccess();
635
726
  const vid = assertModelId(id);
636
727
  const settings = readSettings();
@@ -658,8 +749,13 @@ export async function updateGlobalModel(id, { label, efforts, env } = {}) {
658
749
  }
659
750
  }
660
751
 
752
+ // cost: omitted keeps the current override; null/'' removes it; an object
753
+ // replaces it wholesale (not a per-key merge — the table is small).
754
+ let nextCost = current.cost;
755
+ if (cost !== undefined) nextCost = isClearInput(cost) ? undefined : assertModelCost(cost);
756
+
661
757
  settings.models = models.slice();
662
- settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv);
758
+ settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv, nextCost);
663
759
  await persistSettings(settings);
664
760
  return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
665
761
  }
@@ -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
+ }
@@ -7,18 +7,34 @@
7
7
 
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { readFileSync } from 'node:fs';
10
- import { isAbsolute, join, resolve } from 'node:path';
10
+ import { join } from 'node:path';
11
11
  import { callSource } from './plugin-shim.mjs';
12
12
  import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
13
+ import { listProfiles, DEFAULT_PROFILE } from './plugin-config.mjs';
13
14
  import { normalizeManifest } from './plugin-manifest.mjs';
14
15
  import { getDb } from './db.mjs';
15
- import { runDirForRow, readStoreMeta } from './artifacts.mjs';
16
+ import { runDirForRow, readStoreMeta, readPromptFile } from './artifacts.mjs';
16
17
  import { RESULTS_FILE } from './results.mjs';
17
18
  import { hasGh, findPrForBranch } from './git-info.mjs';
18
19
 
19
- /** Same resolve-against-projectDir semantics as artifacts.mjs#resolveAgainst. */
20
- function resolveAgainst(base, p) {
21
- return isAbsolute(p) ? p : resolve(base, p);
20
+ /** Profile roster for a plugin; never throws — the pane must render even when a
21
+ * plugin's data dir is unreadable (it degrades to "no profiles yet"). */
22
+ function safeProfiles(name) {
23
+ try { return listProfiles(name); } catch { return []; }
24
+ }
25
+
26
+ /** Whether an installed source declares multiProfile; never throws (an
27
+ * uninstalled plugin or broken manifest answers false — the callers below
28
+ * degrade to pre-profiles behavior, which is the only thing they could do). */
29
+ function isMultiProfileSource(plugin, sourceId) {
30
+ try {
31
+ const dir = pluginCurrentDir(plugin);
32
+ const norm = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
33
+ if (!norm.ok) return false;
34
+ return (norm.manifest.taskSources || []).some((s) => s.id === sourceId && s.multiProfile === true);
35
+ } catch {
36
+ return false;
37
+ }
22
38
  }
23
39
 
24
40
  /**
@@ -50,6 +66,12 @@ export function listTaskSources() {
50
66
  sourceId: ts.id,
51
67
  displayName: ts.displayName || `${name}/${ts.id}`,
52
68
  inputs: ts.inputs || [],
69
+ // A multi-profile source cannot be used until the pane knows WHICH
70
+ // configuration to run against, so the roster ships with the listing —
71
+ // the New Pipeline pane resolves the project's binding against it
72
+ // without a second round trip.
73
+ multiProfile: ts.multiProfile === true,
74
+ profiles: ts.multiProfile === true ? safeProfiles(name) : [],
53
75
  });
54
76
  }
55
77
  }
@@ -68,9 +90,9 @@ function taskPromptText(task) {
68
90
  /**
69
91
  * Resolve a source descriptor to the pipeline's task input.
70
92
  * { type:'prompt', prompt } | { type:'markdown', promptText?, promptFile? }
71
- * | { type:'plugin', plugin, sourceId, taskId, inputs? }
93
+ * | { type:'plugin', plugin, sourceId, taskId, inputs?, profile? }
72
94
  * @returns {Promise<{promptText:string, promptFile:string|null,
73
- * sourceMeta:{plugin,sourceId,taskId,url,title}|null}>}
95
+ * sourceMeta:{plugin,sourceId,taskId,profile,url,title}|null}>}
74
96
  */
75
97
  export async function resolveTaskInput(source, { projectDir } = {}) {
76
98
  const src = source && typeof source === 'object' ? source : { type: 'prompt', prompt: '' };
@@ -82,19 +104,21 @@ export async function resolveTaskInput(source, { projectDir } = {}) {
82
104
 
83
105
  if (type === 'markdown') {
84
106
  if (src.promptFile) {
85
- let promptText = '';
86
- try {
87
- promptText = await readFile(resolveAgainst(projectDir, src.promptFile), 'utf8');
88
- } catch {
89
- promptText = ''; // exactly the legacy createPipeline catch{} degradation
90
- }
107
+ // A NAMED file that cannot be read is an ERROR, never an empty prompt: the
108
+ // old catch{} degradation here (and the identical one in createPipeline) ran
109
+ // a whole pipeline on "" and exited 0. Throws PROMPT_FILE_UNREADABLE — the
110
+ // CLI fail()s on it and ui/server.mjs answers 400.
111
+ const promptText = await readPromptFile(projectDir, src.promptFile);
91
112
  return { promptText, promptFile: src.promptFile, sourceMeta: null };
92
113
  }
93
114
  return { promptText: typeof src.promptText === 'string' ? src.promptText : '', promptFile: null, sourceMeta: null };
94
115
  }
95
116
 
96
117
  if (type === 'plugin') {
97
- const task = await callSource({ plugin: src.plugin, sourceId: src.sourceId, op: 'getTask', args: { id: src.taskId } });
118
+ const task = await callSource({
119
+ plugin: src.plugin, sourceId: src.sourceId, op: 'getTask',
120
+ args: { id: src.taskId }, profile: src.profile,
121
+ });
98
122
  if (!task) {
99
123
  throw new Error(`task-source ${src.plugin}/${src.sourceId}: task "${src.taskId}" not found`);
100
124
  }
@@ -105,6 +129,14 @@ export async function resolveTaskInput(source, { projectDir } = {}) {
105
129
  plugin: src.plugin,
106
130
  sourceId: src.sourceId,
107
131
  taskId: src.taskId,
132
+ // Pinned on the ROW, not re-derived at write-back time: the project's
133
+ // binding may have moved to another tracker by then, and a result must
134
+ // always be reported to the instance the task actually came from.
135
+ profile: src.profile || null,
136
+ // The source-panel inputs at fetch time, pinned for the same reason:
137
+ // a per-RUN choice (e.g. jira-source's "Write result back") must gate
138
+ // this run's report with what the user picked when they started it.
139
+ inputs: src.inputs && typeof src.inputs === 'object' ? src.inputs : null,
108
140
  url: task.url ?? null,
109
141
  title: task.title ?? null,
110
142
  },
@@ -164,31 +196,61 @@ export async function reportResultForPipeline(pipelineRow, resultsBundle) {
164
196
  try { ref = pipelineRow.source_ref ? JSON.parse(pipelineRow.source_ref) : null; } catch { ref = null; }
165
197
  if (!ref?.plugin || !ref?.sourceId || !ref?.taskId) return { ok: true, skipped: true };
166
198
 
167
- // Capability probe with a TOLERANT DEFAULT. capabilities() is optional in the
168
- // connector contract (§7.1: "defaults: writeBack true"): a connector without
169
- // the op makes the child answer { ok:false, error:{ kind:'plugin', message:
170
- // 'connector does not implement op "capabilities"' } } -> callSource throws
171
- // PluginOpError('plugin') -> we default to writeBack:true. Transport errors
172
- // (auth/network/timeout/protocol) ALSO default to true the reportResult
173
- // call below is the one that surfaces the real failure to the caller.
199
+ // Which profile to report against. Normally the one pinned on the row when
200
+ // the task was fetched (resolveTaskInput). A row that PREDATES profiles has
201
+ // none and if the plugin has since upgraded to multiProfile, refusing to
202
+ // report would strand the row forever (no code path can attach a profile to
203
+ // an existing source_ref, so the results-view retry would fail identically
204
+ // every time). Its task came from the instance whose flat config migrated
205
+ // into the DEFAULT_PROFILE bucket, so that is exactly where the report
206
+ // belongs — allowLegacyDefault is the shim's host-internal opt-in for it.
207
+ const profile = ref.profile || null;
208
+ const allowLegacyDefault = !profile && isMultiProfileSource(ref.plugin, ref.sourceId);
209
+ const callRef = {
210
+ plugin: ref.plugin,
211
+ sourceId: ref.sourceId,
212
+ profile: allowLegacyDefault ? DEFAULT_PROFILE : profile,
213
+ allowLegacyDefault,
214
+ };
215
+
216
+ // Capability probe. capabilities() is optional in the connector contract
217
+ // (§7.1: "defaults: writeBack true"): a connector without the op makes the
218
+ // child answer kind 'unimplemented' -> we default to writeBack:true.
219
+ // The run's pinned source-panel inputs travel to BOTH ops so a connector
220
+ // can make write-back a per-run choice: the opt-out rides the reserved
221
+ // input key `writeBack` (jira-source's "Write result back" input;
222
+ // capabilities({inputs}) answers for THIS run).
223
+ // Every OTHER probe error — an implemented capabilities() that crashed, a
224
+ // transport failure (auth/network/timeout/protocol/rate-limit) — means the
225
+ // connector could not answer, and what happens next depends on whether an
226
+ // answer could have mattered: a run that pinned no `writeBack` input fails
227
+ // OPEN (default writeBack:true — a transient rate-limit on the probe must
228
+ // not silently drop the ticket comment when the report itself would have
229
+ // succeeded), while a run WITH one fails CLOSED (the opt-out may be 'no'
230
+ // and we could not hear it; writing would violate it) — the caller
231
+ // surfaces the error and the results-view retry re-probes.
232
+ const inputs = ref.inputs && typeof ref.inputs === 'object' ? ref.inputs : {};
174
233
  let writeBack = true;
175
234
  try {
176
- const caps = await callSource({ plugin: ref.plugin, sourceId: ref.sourceId, op: 'capabilities', args: {} });
235
+ const caps = await callSource({ ...callRef, op: 'capabilities', args: { inputs } });
177
236
  if (caps && caps.writeBack === false) writeBack = false;
178
- } catch {
237
+ } catch (err) {
238
+ if ((err?.kind || 'plugin') !== 'unimplemented' && 'writeBack' in inputs) {
239
+ return { ok: false, error: `write-back capability probe failed: ${err?.message || String(err)}` };
240
+ }
179
241
  writeBack = true;
180
242
  }
181
243
  if (!writeBack) return { ok: true, skipped: true };
182
244
 
183
245
  await callSource({
184
- plugin: ref.plugin,
185
- sourceId: ref.sourceId,
246
+ ...callRef,
186
247
  op: 'reportResult',
187
248
  args: {
188
249
  id: ref.taskId,
189
250
  status: statusToResult(pipelineRow.status),
190
251
  summary: buildResultSummary(pipelineRow, resultsBundle),
191
252
  links: buildResultLinks(resultsBundle),
253
+ inputs,
192
254
  },
193
255
  });
194
256
  return { ok: true };