@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
@@ -30,6 +30,7 @@ import {
30
30
  } from './run-manifest.mjs';
31
31
  import { branchExists, hasGh, findPrForBranch } from './git-info.mjs';
32
32
  import { retainedWorkPatchName } from './results.mjs';
33
+ import { deleteCommentsForRun } from './diff-comments.mjs';
33
34
 
34
35
  // Statuses for which deletion is refused (the entry is or may be live).
35
36
  const ACTIVE = new Set(['running', 'starting', 'created', 'pausing']);
@@ -60,8 +61,12 @@ function lookupRow(storeKey, id) {
60
61
  */
61
62
  function artifactAbsPath(relPath, pipelineDir, storeRootDir) {
62
63
  if (isAbsolute(relPath)) return relPath;
63
- if (relPath.startsWith('plans/') || relPath.startsWith('reviews/')) return join(storeRootDir, relPath);
64
- return join(pipelineDir, relPath);
64
+ // Rows are indexed with '/' (see _recordArtifact); rows written by earlier
65
+ // Windows builds carry '\\' — normalise before the layout check so those
66
+ // shared plan/review files are still re-rooted (and unlinked) correctly.
67
+ const rel = relPath.replace(/\\/g, '/');
68
+ if (rel.startsWith('plans/') || rel.startsWith('reviews/')) return join(storeRootDir, rel);
69
+ return join(pipelineDir, rel);
65
70
  }
66
71
 
67
72
  /**
@@ -252,6 +257,12 @@ export async function archivePipeline({ projectDir = null, key = null, workspace
252
257
  // pipeline_steps and the other children stay: stats fallback sums need them.
253
258
  tx(() => {
254
259
  getDb().prepare('DELETE FROM artifacts WHERE pipeline_id = ?').run(row.id);
260
+ // The run dir (and with it diff-patch.patch) was just removed, so nothing could
261
+ // re-anchor these comments and creation is refused from here on. They die with
262
+ // the artifacts index rather than lingering as unreachable rows — the declared
263
+ // pipelines cascade cannot do it, because the pipelines row is never DELETEd
264
+ // (see this file's header).
265
+ deleteCommentsForRun(row.id);
255
266
  getDb().prepare('UPDATE pipelines SET archived_at = ? WHERE id = ?')
256
267
  .run(new Date().toISOString(), row.id);
257
268
  });
@@ -8,6 +8,11 @@
8
8
  // core graph.
9
9
  //
10
10
  // API 2 adds the chatChannels contribution + persistent channel worker
11
- // protocol; the task-source connector contract is unchanged between 1 and 2.
12
- export const WORCA_PLUGIN_API = 2;
13
- export const WORCA_PLUGIN_APIS = [1, 2];
11
+ // protocol; API 3 changes the DATA contract: agent sidecars are meta v2 (typed
12
+ // ports) and workflows/*.json are v2 graphs. The task-source connector and the
13
+ // channel-worker protocols are unchanged across 1 -> 2 -> 3, so a connector-only
14
+ // ">=1 <2" plugin and a chat plugin's ">=2 <3" keep negotiating 1 and 2 and keep
15
+ // working untouched. The set is what makes that possible: never collapse it to a
16
+ // single integer.
17
+ export const WORCA_PLUGIN_API = 3;
18
+ export const WORCA_PLUGIN_APIS = [1, 2, 3];
@@ -1,8 +1,19 @@
1
1
  // src/core/plugin-config.mjs
2
2
  // Per-plugin settings/secrets/state under <pluginDir>/data (spec §5, §7.6).
3
- // Secrets: data/secrets.json, mode 0600, atomic temp+rename (settings.mjs:89-92
4
- // idiom), {"$env":"VAR"} indirection resolved at READ time only stored
5
- // verbatim so the value never touches disk. Explicitly NOT in worca-cc.db.
3
+ //
4
+ // PROFILES: every file is keyed by profile id, so one plugin can hold several
5
+ // complete, independent setups two Jira servers, two GitHub orgs — instead of
6
+ // one that gets overwritten each time you switch. A source that does not declare
7
+ // multiProfile simply never uses more than the DEFAULT_PROFILE bucket, which is
8
+ // created implicitly, so single-profile plugins need no special handling here.
9
+ //
10
+ // config.json { "$format": "profiles/1", "profiles": { "<id>": { key: value } } }
11
+ // secrets.json { "$format": "profiles/1", "profiles": { "<id>": { … } } } mode 0600
12
+ // state.json { "$format": "profiles/1", "profiles": { "<id>": { … } } }
13
+ //
14
+ // Secrets: mode 0600, atomic temp+rename (settings.mjs:89-92 idiom),
15
+ // {"$env":"VAR"} indirection resolved at READ time only — stored verbatim so the
16
+ // value never touches disk. Explicitly NOT in worca-cc.db.
6
17
  // All functions are sync (contract; callers are the shim + server routes).
7
18
 
8
19
  import { readFileSync, writeFileSync, renameSync, mkdirSync, chmodSync } from 'node:fs';
@@ -10,6 +21,24 @@ import { join, dirname } from 'node:path';
10
21
  import { randomBytes } from 'node:crypto';
11
22
  import { pluginDataDir } from './plugins-lock.mjs';
12
23
 
24
+ /** The bucket every source gets for free; the only one a single-profile source uses. */
25
+ export const DEFAULT_PROFILE = 'default';
26
+
27
+ /** Profile ids are directory-safe and URL-safe: they become path segments in a
28
+ * connector's own storage (jira-source hangs a $JTR_CONFIG_DIR off one) and
29
+ * route params on /api/plugins/:name/profiles/:id. */
30
+ export const PROFILE_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
31
+
32
+ export function isValidProfileId(id) {
33
+ return PROFILE_ID_RE.test(String(id ?? ''));
34
+ }
35
+
36
+ function profileId(id) {
37
+ const p = id == null || id === '' ? DEFAULT_PROFILE : String(id);
38
+ if (!isValidProfileId(p)) throw new Error(`invalid profile id "${p}" (lowercase letters, digits and dashes)`);
39
+ return p;
40
+ }
41
+
13
42
  function readJson(file) {
14
43
  try {
15
44
  const v = JSON.parse(readFileSync(file, 'utf8'));
@@ -19,6 +48,36 @@ function readJson(file) {
19
48
  }
20
49
  }
21
50
 
51
+ /** In-band format marker for the profile envelope. No shape heuristic can be
52
+ * exact here: config keys come from configSchema, but STATE keys are whatever
53
+ * a connector passed to ctx.state.set(), so a legacy flat file may legally be
54
+ * { "profiles": { <id>: {...} } } byte-for-byte — data, not envelope. Only the
55
+ * marker distinguishes the two, and only writeBucket/deleteProfile emit it. */
56
+ const ENVELOPE_FORMAT = 'profiles/1';
57
+
58
+ const isBag = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
59
+
60
+ /** The marked { $format, profiles: {...} } envelope, tolerant of a
61
+ * missing/garbage file. A file WITHOUT the marker is a pre-profiles flat bag
62
+ * ({ key: value } at the top level) — read it, whole and unjudged, as the
63
+ * implicit DEFAULT_PROFILE bucket so an upgrade never orphans stored
64
+ * config/secrets/state; the next writeBucket persists the marked envelope. */
65
+ function readBuckets(file) {
66
+ const raw = readJson(file);
67
+ if (raw.$format === ENVELOPE_FORMAT && isBag(raw.profiles)) {
68
+ return Object.fromEntries(
69
+ Object.entries(raw.profiles).filter(([id, bag]) => isValidProfileId(id) && isBag(bag)),
70
+ );
71
+ }
72
+ return Object.keys(raw).length ? { [DEFAULT_PROFILE]: raw } : {};
73
+ }
74
+
75
+ /** One profile's bag from a file. */
76
+ function readBucket(file, profile) {
77
+ const b = readBuckets(file)[profile];
78
+ return b && typeof b === 'object' && !Array.isArray(b) ? b : {};
79
+ }
80
+
22
81
  function writeJsonAtomic(file, obj, { mode } = {}) {
23
82
  mkdirSync(dirname(file), { recursive: true });
24
83
  const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
@@ -27,20 +86,101 @@ function writeJsonAtomic(file, obj, { mode } = {}) {
27
86
  renameSync(tmp, file);
28
87
  }
29
88
 
89
+ /** Replace one profile's bag inside a file, leaving every other profile alone. */
90
+ function writeBucket(file, profile, bucket, opts) {
91
+ const all = readBuckets(file);
92
+ all[profile] = bucket;
93
+ writeJsonAtomic(file, { $format: ENVELOPE_FORMAT, profiles: all }, opts);
94
+ }
95
+
30
96
  const isEnvRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.$env === 'string';
31
97
  /** The exact redaction marker redactedConfig emits — must never be persisted. */
32
98
  const isSetMarker = (v) => !!v && typeof v === 'object' && v.set === true && Object.keys(v).length === 1;
33
99
 
34
100
  function files(name) {
35
101
  const dir = pluginDataDir(name);
36
- return { config: join(dir, 'config.json'), secrets: join(dir, 'secrets.json'), state: join(dir, 'state.json') };
102
+ return {
103
+ config: join(dir, 'config.json'),
104
+ secrets: join(dir, 'secrets.json'),
105
+ state: join(dir, 'state.json'),
106
+ profiles: join(dir, 'profiles.json'),
107
+ };
37
108
  }
38
109
 
39
- /** Merged config.json + secrets.json (secrets win), schema defaults applied,
40
- * {"$env":"VAR"} resolved (unset env -> null). */
41
- export function readPluginConfig(name, configSchema = []) {
110
+ // ── profiles ──────────────────────────────────────────────────────────────────
111
+ // The roster lives in its own file rather than being inferred from config.json's
112
+ // keys: a profile is real the moment it is created, BEFORE anything is saved into
113
+ // it, and it keeps a human label the id cannot carry.
114
+
115
+ /**
116
+ * Every profile of a plugin, in creation order.
117
+ * @returns {Array<{id:string, label:string}>}
118
+ */
119
+ export function listProfiles(name) {
120
+ const raw = readJson(files(name).profiles);
121
+ const list = Array.isArray(raw.profiles) ? raw.profiles : [];
122
+ return list
123
+ .filter((p) => p && isValidProfileId(p.id))
124
+ .map((p) => ({ id: String(p.id), label: String(p.label || p.id) }));
125
+ }
126
+
127
+ /** Just the ids — the shape resolveProfile()/the UI gate want. */
128
+ export function listProfileIds(name) {
129
+ return listProfiles(name).map((p) => p.id);
130
+ }
131
+
132
+ /**
133
+ * Create a profile. Idempotent on the id (a repeat call updates the label only),
134
+ * so a caller that ensures DEFAULT_PROFILE on every save cannot duplicate it.
135
+ * @returns {{id:string, label:string}}
136
+ */
137
+ export function createProfile(name, id, label) {
138
+ const pid = profileId(id);
139
+ // The default bucket is shared plumbing, not a profile: chat channels, model
140
+ // secrets and migrated pre-profiles config all live in it via profile-less
141
+ // reads. Enrolling "default" in the roster would make that shared bucket
142
+ // deletable like any member — one Remove click away from wiping them all.
143
+ if (pid === DEFAULT_PROFILE) throw new Error(`profile id "${DEFAULT_PROFILE}" is reserved`);
144
+ const list = listProfiles(name);
145
+ const at = list.findIndex((p) => p.id === pid);
146
+ const entry = { id: pid, label: String(label || (at >= 0 ? list[at].label : pid)) };
147
+ if (at >= 0) list[at] = entry;
148
+ else list.push(entry);
149
+ writeJsonAtomic(files(name).profiles, { profiles: list });
150
+ return entry;
151
+ }
152
+
153
+ /**
154
+ * Delete a profile and everything stored under it (config, secrets, state).
155
+ * Callers are responsible for the bindings that named it — see
156
+ * source-bindings.mjs#clearBindingsForProfile.
157
+ */
158
+ export function deleteProfile(name, id) {
159
+ const pid = profileId(id);
160
+ // Same reservation as createProfile — and a backstop for a roster that
161
+ // enrolled "default" before the reservation existed: deleting it would strip
162
+ // the shared bucket (chat-channel config, model secrets, migrated legacy
163
+ // data) out of all three files.
164
+ if (pid === DEFAULT_PROFILE) throw new Error(`profile id "${DEFAULT_PROFILE}" is reserved — its bucket backs every profile-less read`);
42
165
  const f = files(name);
43
- const raw = { ...readJson(f.config), ...readJson(f.secrets) };
166
+ writeJsonAtomic(f.profiles, { profiles: listProfiles(name).filter((p) => p.id !== pid) });
167
+ for (const [file, opts] of [[f.config, undefined], [f.secrets, { mode: 0o600 }], [f.state, undefined]]) {
168
+ const all = readBuckets(file);
169
+ if (!(pid in all)) continue;
170
+ delete all[pid];
171
+ writeJsonAtomic(file, { $format: ENVELOPE_FORMAT, profiles: all }, opts);
172
+ }
173
+ return { ok: true };
174
+ }
175
+
176
+ // ── config ────────────────────────────────────────────────────────────────────
177
+
178
+ /** Merged config.json + secrets.json for ONE profile (secrets win), schema
179
+ * defaults applied, {"$env":"VAR"} resolved (unset env -> null). */
180
+ export function readPluginConfig(name, configSchema = [], profile) {
181
+ const pid = profileId(profile);
182
+ const f = files(name);
183
+ const raw = { ...readBucket(f.config, pid), ...readBucket(f.secrets, pid) };
44
184
  const out = {};
45
185
  for (const field of configSchema) {
46
186
  let v = raw[field.key];
@@ -51,13 +191,15 @@ export function readPluginConfig(name, configSchema = []) {
51
191
  return out;
52
192
  }
53
193
 
54
- /** Route values by schema: secret:true -> secrets.json (0600), else config.json.
55
- * undefined / redaction-marker values keep the prior stored value; null clears.
56
- * $env refs are stored verbatim. Both writes are temp+rename atomic. */
57
- export function writePluginConfig(name, configSchema = [], values = {}) {
194
+ /** Route values by schema: secret:true -> secrets.json (0600), else config.json,
195
+ * both inside `profile`'s bucket. undefined / redaction-marker values keep the
196
+ * prior stored value; null clears. $env refs are stored verbatim. Both writes
197
+ * are temp+rename atomic. */
198
+ export function writePluginConfig(name, configSchema = [], values = {}, profile) {
199
+ const pid = profileId(profile);
58
200
  const f = files(name);
59
- const config = readJson(f.config);
60
- const secrets = readJson(f.secrets);
201
+ const config = readBucket(f.config, pid);
202
+ const secrets = readBucket(f.secrets, pid);
61
203
  const secretKeys = new Set(configSchema.filter((x) => x && x.secret).map((x) => x.key));
62
204
  for (const [k, v] of Object.entries(values && typeof values === 'object' ? values : {})) {
63
205
  if (v === undefined || isSetMarker(v)) continue; // absent / echoed marker -> keep prior
@@ -67,17 +209,19 @@ export function writePluginConfig(name, configSchema = [], values = {}) {
67
209
  if (v === null) delete bucket[k];
68
210
  else bucket[k] = v;
69
211
  }
70
- writeJsonAtomic(f.config, config);
71
- writeJsonAtomic(f.secrets, secrets, { mode: 0o600 });
212
+ writeBucket(f.config, pid, config);
213
+ writeBucket(f.secrets, pid, secrets, { mode: 0o600 });
72
214
  return { ok: true };
73
215
  }
74
216
 
75
- /** UI echo shape: secrets -> { set: true|false } markers, non-secrets verbatim
76
- * (with defaults). Secret VALUES never reach the browser after save (§7.6). */
77
- export function redactedConfig(name, configSchema = []) {
217
+ /** UI echo shape for ONE profile: secrets -> { set: true|false } markers,
218
+ * non-secrets verbatim (with defaults). Secret VALUES never reach the browser
219
+ * after save (§7.6). */
220
+ export function redactedConfig(name, configSchema = [], profile) {
221
+ const pid = profileId(profile);
78
222
  const f = files(name);
79
- const config = readJson(f.config);
80
- const secrets = readJson(f.secrets);
223
+ const config = readBucket(f.config, pid);
224
+ const secrets = readBucket(f.secrets, pid);
81
225
  const out = {};
82
226
  for (const field of configSchema) {
83
227
  if (field.secret) out[field.key] = { set: secrets[field.key] !== undefined };
@@ -86,15 +230,21 @@ export function redactedConfig(name, configSchema = []) {
86
230
  return out;
87
231
  }
88
232
 
89
- /** Connector KV (cursors, etags) — host-persisted ctx.state backing (§7.1). */
90
- export function readPluginState(name) {
91
- return readJson(files(name).state);
233
+ // ── connector state ───────────────────────────────────────────────────────────
234
+
235
+ /** Connector KV for ONE profile (cursors, etags) — host-persisted ctx.state
236
+ * backing (§7.1). Per-profile so two instances of the same source cannot read
237
+ * back each other's cached cursors. */
238
+ export function readPluginState(name, profile) {
239
+ return readBucket(files(name).state, profileId(profile));
92
240
  }
93
241
 
94
- /** Shallow-merge patch into state.json, atomic write. Returns the new state. */
95
- export function writePluginState(name, patch = {}) {
242
+ /** Shallow-merge patch into one profile's state, atomic write. Returns the new
243
+ * bag for that profile. */
244
+ export function writePluginState(name, patch = {}, profile) {
245
+ const pid = profileId(profile);
96
246
  const f = files(name);
97
- const next = { ...readJson(f.state), ...(patch && typeof patch === 'object' ? patch : {}) };
98
- writeJsonAtomic(f.state, next);
247
+ const next = { ...readBucket(f.state, pid), ...(patch && typeof patch === 'object' ? patch : {}) };
248
+ writeBucket(f.state, pid, next);
99
249
  return next;
100
250
  }
@@ -29,8 +29,12 @@ export async function inventoryFromCache(repoUrl, sha, subdir, { exec = defaultE
29
29
  // be parsed as a git option (defense layer 2; the parser rejects it too).
30
30
  await exec('git', ['--git-dir', repoCacheDir(repoUrl), 'archive', '--format=tar', '-o', tar,
31
31
  ...(subdir ? [sha, '--', subdir] : [sha])]);
32
- await exec('tar', ['-xf', tar, '-C', tmp,
33
- ...(subdir ? ['--strip-components', String(subdir.split('/').length)] : [])]);
32
+ // Extract with cwd = tmp (the destination) so tar never receives a native
33
+ // Windows path: Git-for-Windows GNU tar reads a colon in `-f` as a `host:path`
34
+ // remote spec ("cannot connect to C:") and cannot parse backslash separators.
35
+ // A relative `-f` and no `-C` sidesteps both; the archive lands in cwd.
36
+ await exec('tar', ['-xf', 'x.tar',
37
+ ...(subdir ? ['--strip-components', String(subdir.split('/').length)] : [])], { cwd: tmp });
34
38
  await rm(tar, { force: true });
35
39
  // Defense-in-depth for the consent display: strip any symlink that escapes the
36
40
  // export root before inventorying (the tmp dir is deleted in finally regardless).
@@ -5,8 +5,11 @@
5
5
 
6
6
  import { readFileSync, readdirSync, readlinkSync, existsSync } from 'node:fs';
7
7
  import { join, resolve, dirname, sep, isAbsolute } from 'node:path';
8
- import { WORCA_PLUGIN_APIS } from './plugin-api.mjs';
9
- import { EFFORTS, isReservedModelEnvKey } from './model-env.mjs';
8
+ import { WORCA_PLUGIN_API, WORCA_PLUGIN_APIS } from './plugin-api.mjs';
9
+ import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
10
+ import { validateMetaV2, normalizeAgentMeta, indexByKey } from '../shared/graph/agent-meta.mjs';
11
+ import { portsFnFor } from '../shared/graph/ports.mjs';
12
+ import { validateGraph } from '../shared/graph/validate.mjs';
10
13
 
11
14
  /** Plugin names are kebab-case, machine-unique, dir-name safe (spec §4.1). */
12
15
  export const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -17,12 +20,12 @@ const SOURCE_ID_RE = /^[a-z][a-z0-9-]{0,63}$/;
17
20
  const FIELD_TYPES = new Set(['text', 'select']);
18
21
  const INPUT_TYPES = new Set(['text', 'select', 'remote-select', 'task-browser']);
19
22
  const KNOWN_TOP = new Set(['name', 'version', 'description', 'author', 'homepage', 'license', 'engines', 'taskSources', 'chatChannels', 'setup', 'models', 'modelSecrets']);
20
- const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs']);
23
+ const KNOWN_SOURCE = new Set(['id', 'displayName', 'module', 'configSchema', 'inputs', 'multiProfile']);
21
24
  const KNOWN_CHANNEL = new Set(['id', 'displayName', 'platform', 'module', 'ingress', 'capabilities', 'configSchema']);
22
25
  const CHANNEL_INGRESS = new Set(['connect', 'webhook']);
23
26
  const KNOWN_FIELD = new Set(['key', 'type', 'label', 'secret', 'required', 'default', 'help', 'options']);
24
27
  const KNOWN_INPUT = new Set(['key', 'type', 'label', 'default', 'optionsFrom', 'options']);
25
- const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env']);
28
+ const KNOWN_MODEL = new Set(['id', 'label', 'efforts', 'env', 'cost']);
26
29
  const KNOWN_MODEL_SECRET = new Set(['key', 'label']);
27
30
  /** A manifest env value that defers to the plugin's secrets store (design §9.1). */
28
31
  const isSecretRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
@@ -68,6 +71,86 @@ export function negotiatedApi(range, apis = WORCA_PLUGIN_APIS) {
68
71
  return best;
69
72
  }
70
73
 
74
+ /** The ONE per-file sentence for each half of the data contract. `validatePluginDir`
75
+ * prefixes `agents/<f>: ` / `workflows/<f>: `; `agent-registry.scanLayer` prefixes the
76
+ * layer and appends ` — ignored`; `plugin-workflows` uses NOT_GRAPH_V2 verbatim as a
77
+ * skip reason. Exported so those three sites can never drift apart — never re-word
78
+ * either string in a second place. */
79
+ export const NOT_META_V2 = 'not a meta v2 sidecar (declare "metaVersion": 2 with typed inputs/outputs) — plugin API 3 no longer reads channel sidecars';
80
+ export const NOT_GRAPH_V2 = 'not a version-2 graph template (nodes/wires) — port the "steps" pipeline';
81
+
82
+ /** The host API a range was BUILT FOR: the lowest integer it accepts. ">=1 <2"
83
+ * and "1" both answer 1; an unconstrained range answers 0; null when nothing
84
+ * satisfies it (an unparseable range fails closed in apiSatisfies too).
85
+ * @param {string} range @returns {number|null} */
86
+ export function declaredApi(range) {
87
+ for (let n = 0; n <= 99; n += 1) if (apiSatisfies(range, n)) return n;
88
+ return null;
89
+ }
90
+
91
+ /** Which files in a plugin dir are still on the API-2 data contract: sidecars
92
+ * without metaVersion 2, templates without version 2. Pure fs read; an absent
93
+ * or unreadable dir/file contributes nothing (validatePluginDir reports those
94
+ * separately as parse errors). Basenames only — the caller prefixes them.
95
+ * @param {string} absDir
96
+ * @returns {{agentsV1: string[], workflowsV1: string[]}} */
97
+ export function dataContractIssues(absDir) {
98
+ const agentsV1 = [];
99
+ const workflowsV1 = [];
100
+ const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
101
+ const agentsDir = join(absDir, 'agents');
102
+ if (existsSync(agentsDir)) {
103
+ for (const f of readdirSync(agentsDir).filter((x) => x.endsWith('.meta.json')).sort()) {
104
+ const raw = read(join(agentsDir, f));
105
+ if (raw && Number(raw.metaVersion) !== 2) agentsV1.push(f);
106
+ }
107
+ }
108
+ const wfDir = join(absDir, 'workflows');
109
+ if (existsSync(wfDir)) {
110
+ for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json')).sort()) {
111
+ const raw = read(join(wfDir, f));
112
+ if (raw && Number(raw.version) !== 2) workflowsV1.push(f);
113
+ }
114
+ }
115
+ return { agentsV1, workflowsV1 };
116
+ }
117
+
118
+ /**
119
+ * The Plugins-view / doctor payload for a plugin whose DATA is still on the old
120
+ * contract, or null when it has nothing the host would ignore. The bump is
121
+ * gated by CONTENT, not by the range: a connector-only plugin declaring
122
+ * ">=1 <2" ships no agents and no templates, so it is never "incompatible" —
123
+ * it simply keeps negotiating API 1.
124
+ * @param {string} range engines['worca-cc-api']
125
+ * @param {{agentsV1: string[], workflowsV1: string[]}} issues
126
+ * @returns {{builtFor: number|null, host: number, agents: number, workflows: number, message: string}|null}
127
+ */
128
+ export function apiMismatch(range, issues) {
129
+ const agents = (issues && issues.agentsV1 ? issues.agentsV1 : []).length;
130
+ const workflows = (issues && issues.workflowsV1 ? issues.workflowsV1 : []).length;
131
+ if (!agents && !workflows) return null;
132
+ // declaredApi('') is 0 (an unconstrained range accepts everything); report that
133
+ // as null so the message reads "built for an older version", never "API 0".
134
+ const mismatch = { builtFor: declaredApi(range) || null, host: WORCA_PLUGIN_API, agents, workflows };
135
+ mismatch.message = apiMismatchMessage(mismatch);
136
+ return mismatch;
137
+ }
138
+
139
+ /**
140
+ * THE per-plugin sentence (spec §9 wording, "worca" is the product name) —
141
+ * rendered verbatim by the Plugins view (`p.apiMismatch.message`), the doctor's
142
+ * `agents-api` check and `worca plugin list`. An API-outdated plugin is NOT
143
+ * corrupt: it installed fine and its connector or chat channel still works —
144
+ * worca simply ignores the agents and pipeline templates it ships.
145
+ */
146
+ export function apiMismatchMessage(mismatch) {
147
+ if (!mismatch) return '';
148
+ const { builtFor, agents, workflows } = mismatch;
149
+ return `built for plugin API ${builtFor ?? 'an older version'}; this version of worca requires `
150
+ + `plugin API ${WORCA_PLUGIN_API} for agents and pipeline templates — update or reinstall the plugin `
151
+ + `(${agents} agent(s), ${workflows} template(s) ignored)`;
152
+ }
153
+
71
154
  const str = (v, d = '') => (typeof v === 'string' ? v.trim() : d);
72
155
 
73
156
  function normOptions(raw) {
@@ -117,6 +200,31 @@ function badModulePath(mod) {
117
200
  return null;
118
201
  }
119
202
 
203
+ /**
204
+ * `agentFile` is a PATH, not a label: agent-registry.mjs stamps
205
+ * `agentPath = join(<layer>/agents, meta.agentFile)` and workflows.mjs reads
206
+ * THAT file as the agent's system prompt AND for its `tools:` frontmatter. So
207
+ * it is gated exactly like `module` — relative, no "..", and it must still
208
+ * resolve inside the agents dir. Absent/empty is legal (the agent then has no
209
+ * prompt file). Returns a reason clause or null, like badModulePath.
210
+ */
211
+ function badAgentFile(agentFile, agentsDir) {
212
+ if (agentFile === undefined || agentFile === null || agentFile === '') return null;
213
+ if (typeof agentFile !== 'string') return 'must be a string';
214
+ const af = agentFile.trim(); // the normalizer trims: judge the string the runtime will use
215
+ if (!af) return null;
216
+ if (isAbsolute(af) || /\\/.test(af)) return 'must be a relative path inside agents/';
217
+ if (af.split('/').includes('..')) return 'must not contain ".."';
218
+ const root = resolve(agentsDir);
219
+ if (!resolve(root, af).startsWith(root + sep)) return 'must be a relative path inside agents/';
220
+ // A contained agentFile must also EXIST: consent and the runtime both read it,
221
+ // so a name with no file behind it says one thing at consent time ("none
222
+ // declared") and another at run time (workflows.loadAgentFile used to fall
223
+ // back into the BUILT-IN agents dir for the same basename).
224
+ if (!existsSync(join(root, af))) return `${af} not found in agents/`;
225
+ return null;
226
+ }
227
+
120
228
  /**
121
229
  * Normalize a parsed worca-cc-plugin.json (spec §4.1). Only `name` is required.
122
230
  * Unknown fields are ignored and collected as warnings (validatePluginDir
@@ -195,7 +303,14 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
195
303
  if (browsers !== 1) {
196
304
  errors.push(`${at} ("${id}"): must declare exactly ONE input of type "task-browser" (found ${browsers}) — it is what produces the task (spec §7.4)`);
197
305
  }
198
- taskSources.push({ id, displayName: str(s.displayName) || id, module, configSchema, inputs });
306
+ // multiProfile: the source can hold several independent configurations
307
+ // (two Jira servers, two GitHub orgs), each bound to a project/workspace.
308
+ // Opt-in, because it changes the settings UI from one form to a roster —
309
+ // a single-instance source should not pay that.
310
+ taskSources.push({
311
+ id, displayName: str(s.displayName) || id, module, configSchema, inputs,
312
+ multiProfile: s.multiProfile === true,
313
+ });
199
314
  });
200
315
  }
201
316
  const ids = taskSources.map((s) => s.id);
@@ -293,7 +408,13 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
293
408
  return;
294
409
  }
295
410
  for (const [k, v] of Object.entries(envRaw)) {
296
- if (isReservedModelEnvKey(k)) { errors.push(`${at} ("${id}"): env key ${JSON.stringify(k)} is reserved and cannot be set on a model`); continue; }
411
+ // Reserved keys degrade, never reject: a published manifest is a
412
+ // third-party artifact its user cannot edit, and the spawn-time filter
413
+ // (model-env.mjs prepareModelEnv) drops these defensively anyway — so a
414
+ // host that grows the reserved list (e.g. CLAUDE_CODE_SUBAGENT_MODEL)
415
+ // must not retroactively brick installed/marketplace plugins. The
416
+ // user's own catalog (settings.mjs) still hard-rejects: that author CAN fix it.
417
+ if (isReservedModelEnvKey(k)) { warnings.push(`${at} ("${id}"): env key ${JSON.stringify(k)} is reserved — ignored`); continue; }
297
418
  if (isSecretRef(v)) {
298
419
  if (!secretKeys.has(v.secret)) { errors.push(`${at} ("${id}"): env ${k} references undeclared modelSecrets key ${JSON.stringify(v.secret)}`); continue; }
299
420
  env[k] = { secret: v.secret };
@@ -303,10 +424,23 @@ export function normalizeManifest(raw, { dir = '' } = {}) {
303
424
  errors.push(`${at} ("${id}"): env value for ${JSON.stringify(k)} must be a non-empty string or {"secret": "<key>"}`);
304
425
  }
305
426
  }
427
+ // A plugin that ships a model routed at its OWN endpoint is exactly the
428
+ // case that needs a price pinned (the CLI would price it by NAME), so a
429
+ // manifest may carry `cost` — same shape, same validator as a global
430
+ // catalog entry (model-env.mjs). A user's global entry for the same id
431
+ // still wins, as it does for label/efforts/env (§9.3).
432
+ let cost;
433
+ try {
434
+ cost = assertModelCost(m.cost);
435
+ } catch (e) {
436
+ errors.push(`${at} ("${id}"): ${e.message}`);
437
+ return;
438
+ }
306
439
  models.push({
307
440
  id, label: str(m.label) || id,
308
441
  efforts: efforts.length ? efforts : [...EFFORTS],
309
442
  ...(Object.keys(env).length ? { env } : {}),
443
+ ...(cost ? { cost } : {}),
310
444
  });
311
445
  });
312
446
  const mids = models.map((m) => m.id.toLowerCase());
@@ -392,8 +526,26 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
392
526
  }
393
527
  }
394
528
 
395
- // agents/: <key>.md + <key>.meta.json pairs, existing dual-file format (§4.2)
529
+ // agents/: <key>.md + <key>.meta.json pairs, existing dual-file format (§4.2).
530
+ // API 3: the sidecar must pass the SAME meta v2 gate the agent-store save path
531
+ // applies, every failed rule named. ALL capability fields are open to plugins
532
+ // (verdict, sideEffect, mockRole, workspace*, placeable, …) — the gate is the
533
+ // schema, not an allow-list.
396
534
  const agentKeys = new Set();
535
+ // Keys whose sidecar EXISTS but was rejected by a gate below. They are not
536
+ // shipped (no ports), yet they are not foreign either — the workflows block
537
+ // owes a template that references one a single accurate cause rather than
538
+ // "this plugin does not ship it" or the derived V4/V5/V20/V21 cascade.
539
+ const ungatedKeys = new Set();
540
+ const ownMetas = [];
541
+ // A range that admits the CURRENT API (or no engines at all) claims to be an
542
+ // API-3 plugin, so v1-shaped data is a hard error; --strict promotes it for
543
+ // everyone else, because that flag is the plugin AUTHOR's gate. A manifest
544
+ // that did not PARSE fails SOFT: its declared API is unknowable, and the JSON
545
+ // error above is already the only actionable line.
546
+ const hardData = raw !== null
547
+ && (strict || negotiatedApi(raw && raw.engines ? raw.engines['worca-cc-api'] : '') === WORCA_PLUGIN_API);
548
+ const dataLevel = hardData ? 'error' : 'warn';
397
549
  const agentsDir = join(absDir, 'agents');
398
550
  if (existsSync(agentsDir)) {
399
551
  const files = readdirSync(agentsDir);
@@ -406,7 +558,19 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
406
558
  if (!KEY_RE.test(key)) { push('error', `agents/${f}: "${key}" must be a valid agent key (letters/digits/_-)`); continue; }
407
559
  if (key !== stem) push('error', `agents/${f}: key "${key}" must match the filename stem "${stem}"`);
408
560
  if (!files.includes(`${stem}.md`)) push('error', `agents/${f}: missing sibling ${stem}.md`);
561
+ // Path traversal is never softened by dataLevel: an escaping agentFile is
562
+ // a security defect in a v1 sidecar exactly as in a v2 one, and it is
563
+ // checked BEFORE the meta gate so exactly one cause is reported.
564
+ const afErr = badAgentFile(meta.agentFile, agentsDir);
565
+ if (afErr) { push('error', `agents/${f}: "agentFile" ${afErr}`); ungatedKeys.add(key); continue; }
566
+ if (Number(meta.metaVersion) !== 2) { push(dataLevel, `agents/${f}: ${NOT_META_V2}`); ungatedKeys.add(key); continue; }
567
+ const { errors } = validateMetaV2(meta);
568
+ for (const e of errors) push('error', `agents/${f}: ${e}`);
569
+ if (errors.length) { ungatedKeys.add(key); continue; }
570
+ // BELOW every gate: a rejected sidecar ships no ports, so counting its key
571
+ // as shipped is what let a ports-less node reach validateGraph.
409
572
  agentKeys.add(key);
573
+ ownMetas.push(normalizeAgentMeta(meta).meta);
410
574
  }
411
575
  for (const f of files.filter((x) => x.endsWith('.md'))) {
412
576
  const stem = f.slice(0, -3);
@@ -426,18 +590,42 @@ export function validatePluginDir(absDir, { strict = false } = {}) {
426
590
  }
427
591
  }
428
592
 
429
- // workflows/*.json may reference ONLY the plugin's own agent keys (§9.3)
593
+ // workflows/*.json are v2 GRAPHS (API 3), validated by the SAME shared
594
+ // validator the composer and POST /api/workflows use, over a ports function
595
+ // built from this plugin's OWN sidecars plus the engine's flow-card ports.
596
+ // The isolation rule stays: a template may reference only keys this plugin
597
+ // ships, so a host rename or a deleted user agent can never break it.
430
598
  const wfDir = join(absDir, 'workflows');
431
599
  if (existsSync(wfDir)) {
600
+ const portsFn = portsFnFor(indexByKey(ownMetas));
432
601
  for (const f of readdirSync(wfDir).filter((x) => x.endsWith('.json'))) {
433
602
  let tpl = null;
434
603
  try { tpl = JSON.parse(readFileSync(join(wfDir, f), 'utf8')); }
435
604
  catch { push('error', `workflows/${f}: invalid JSON`); continue; }
436
- if (!Array.isArray(tpl?.steps)) { push('error', `workflows/${f}: "steps" must be an array`); continue; }
437
- const keys = tpl.steps.flat().map((n) => n?.key).filter(Boolean);
605
+ if (Number(tpl?.version) !== 2) { push(dataLevel, `workflows/${f}: ${NOT_GRAPH_V2}`); continue; }
606
+ const nodes = Array.isArray(tpl.nodes) ? tpl.nodes : [];
607
+ const keys = nodes.filter((n) => n && n.kind === 'agent').map((n) => n.key).filter(Boolean);
608
+ let unresolved = false;
438
609
  for (const k of new Set(keys)) {
439
- if (!agentKeys.has(k)) push('error', `workflows/${f}: references agent key "${k}" which this plugin does not ship`);
610
+ if (agentKeys.has(k)) continue;
611
+ if (ungatedKeys.has(k)) {
612
+ // The sidecar is this plugin's, it just did not pass. Report at the
613
+ // DATA level so an API-1 plugin keeps installing (spec §9) instead of
614
+ // being refused for a template that is fine.
615
+ push(dataLevel, `workflows/${f}: references agent key "${k}" whose sidecar is not a valid meta v2 sidecar`);
616
+ } else {
617
+ push('error', `workflows/${f}: references agent key "${k}" which this plugin does not ship`);
618
+ }
619
+ unresolved = true;
440
620
  }
621
+ // An unresolved key has no ports, so every wire touching it would also
622
+ // fire V4/V5 — one clear cause beats five derived ones.
623
+ if (unresolved) continue;
624
+ const { errors, warnings } = validateGraph(
625
+ { ...tpl, nodes, wires: Array.isArray(tpl.wires) ? tpl.wires : [] }, portsFn,
626
+ );
627
+ for (const e of errors) push('error', `workflows/${f}: ${e.code}: ${e.message}`);
628
+ for (const w of warnings) push('warn', `workflows/${f}: ${w.code}: ${w.message}`);
441
629
  }
442
630
  }
443
631
 
@@ -49,6 +49,7 @@ export function allPluginModels() {
49
49
  out.push({
50
50
  plugin: name, id: m.id, label: m.label, efforts: [...m.efforts],
51
51
  ...(m.env ? { env: m.env } : {}),
52
+ ...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing (config.mjs modelCostConfig)
52
53
  secrets: [...new Set(secrets)],
53
54
  });
54
55
  }