@worca/app 1.2.0-rc.3 → 1.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3736 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1589 -212
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +447 -29
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1499 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1304 -194
@@ -277,6 +277,67 @@ export function contextMaxBytesTotal() {
277
277
  return readByteCap('contextMaxBytesTotal', DEFAULT_CONTEXT_MAX_BYTES_TOTAL);
278
278
  }
279
279
 
280
+ // ── Agent memory caps (agent-memory-design.md §2 / §12) ─────────────────────
281
+ export const DEFAULT_MEMORY_SOFT_BYTES_PER_FILE = 8192; // flagged in health above this
282
+ export const DEFAULT_MEMORY_HARD_BYTES_PER_FILE = 32768; // rejected at sync-back / write above this
283
+ export const DEFAULT_MEMORY_MAX_FILES_PER_SCOPE = 50;
284
+ export const DEFAULT_MEMORY_HOOK_MAX_CHARS = 160;
285
+
286
+ export const DEFAULT_MEMORY_DEFRAG_WRITES = 10; // writesSinceDefrag at which a scope is "due"
287
+ export const DEFAULT_MEMORY_DEFRAG_FILES = 30; // file count at which a scope is "due"
288
+ export const DEFAULT_MEMORY_DEFRAG_BYTES_PCT = 60; // % of (maxFilesPerScope × softBytesPerFile) at which a scope is "due"
289
+ export const DEFAULT_MEMORY_DEFRAG_ALWAYS_ON_BYTES = 16384; // bytes of path-less memory (loaded into EVERY agent's context) at which a scope is "due"
290
+
291
+ /** settings.json → { memory: { maxBytesPerFile, softBytesPerFile, maxFilesPerScope, hookMaxChars,
292
+ * defrag: { writes, files, bytesPct, alwaysOnBytes } } }. Every key optional; a bad value warns (naming the full key) and falls back. */
293
+ function memoryBlock() {
294
+ const block = readSettings().memory;
295
+ return block && typeof block === 'object' && !Array.isArray(block) ? block : {};
296
+ }
297
+ function readMemoryCap(key, fallback) {
298
+ const v = memoryBlock()[key];
299
+ if (v === undefined) return fallback;
300
+ if (isByteCap(v)) return v;
301
+ console.warn(`[worca] invalid memory.${key} ${JSON.stringify(v)} — using ${fallback}`);
302
+ return fallback;
303
+ }
304
+ let warnedDefragBlock = false; // module-level: the reader runs once per threshold key, the block is one mistake
305
+ /** `memory.defrag.<key>`: a positive integer; `bytesPct` additionally ≤ 100 (it is a share).
306
+ * A `defrag` that is not a plain object is one mistake, not three: warn ONCE and fall back. */
307
+ function readDefragThreshold(key, fallback, { pct = false } = {}) {
308
+ const d = memoryBlock().defrag;
309
+ const isBlock = Boolean(d) && typeof d === 'object' && !Array.isArray(d);
310
+ if (d !== undefined && !isBlock) {
311
+ if (!warnedDefragBlock) {
312
+ warnedDefragBlock = true;
313
+ console.warn(`[worca] invalid memory.defrag ${JSON.stringify(d)} — using the defaults`);
314
+ }
315
+ return fallback;
316
+ }
317
+ const v = isBlock ? d[key] : undefined;
318
+ if (v === undefined) return fallback;
319
+ if (isByteCap(v) && (!pct || v <= 100)) return v;
320
+ console.warn(`[worca] invalid memory.defrag.${key} ${JSON.stringify(v)} — using ${fallback}`);
321
+ return fallback;
322
+ }
323
+
324
+ /** The caps every memory reader/writer takes (memory-store.mjs, memory-sync.mjs). Read fresh per call.
325
+ * `defrag` is the health threshold block (agent-memory-design.md §8 / §12). */
326
+ export function memoryCaps() {
327
+ return {
328
+ softBytesPerFile: readMemoryCap('softBytesPerFile', DEFAULT_MEMORY_SOFT_BYTES_PER_FILE),
329
+ hardBytesPerFile: readMemoryCap('maxBytesPerFile', DEFAULT_MEMORY_HARD_BYTES_PER_FILE),
330
+ maxFilesPerScope: readMemoryCap('maxFilesPerScope', DEFAULT_MEMORY_MAX_FILES_PER_SCOPE),
331
+ hookMaxChars: readMemoryCap('hookMaxChars', DEFAULT_MEMORY_HOOK_MAX_CHARS),
332
+ defrag: {
333
+ writes: readDefragThreshold('writes', DEFAULT_MEMORY_DEFRAG_WRITES),
334
+ files: readDefragThreshold('files', DEFAULT_MEMORY_DEFRAG_FILES),
335
+ bytesPct: readDefragThreshold('bytesPct', DEFAULT_MEMORY_DEFRAG_BYTES_PCT, { pct: true }),
336
+ alwaysOnBytes: readDefragThreshold('alwaysOnBytes', DEFAULT_MEMORY_DEFRAG_ALWAYS_ON_BYTES),
337
+ },
338
+ };
339
+ }
340
+
280
341
  /** Skill delivery mechanism (§5.6): 'copy' (default, isolated) | 'symlink' (write-through). */
281
342
  export function skillMount() {
282
343
  const v = readSettings().skillMount;
@@ -544,6 +605,8 @@ export const SETTINGS_POST_KEYS = Object.freeze([
544
605
  'askMaxTurns', 'askMaxBudgetUsd',
545
606
  'debugSpawnEnabled',
546
607
  'titleModel', 'hideBuiltinModels',
608
+ 'theme',
609
+ 'autoWorkflowModel', // auto-workflow spec D14
547
610
  ]);
548
611
 
549
612
  // ── Title-generation model + hidden built-ins (#422) ─────────────────────────
@@ -606,6 +669,74 @@ export async function setHideBuiltinModels(input) {
606
669
  return { hideBuiltinModels: hideBuiltinModels() };
607
670
  }
608
671
 
672
+ // ── Theme mode (2026-09-04 dark-mode design §6.1) ────────────────────────────
673
+ // One machine-wide preference: `system` follows the OS, `light`/`dark` force a
674
+ // scheme. The server writes it into the shell's <html data-theme> at serve time
675
+ // (ui/server.mjs sendIndex) and the client keeps it live; the value is read at
676
+ // use time like every other stored setting, so a save reaches the next request.
677
+ export const THEME_MODES = Object.freeze(['system', 'light', 'dark']);
678
+ export const DEFAULT_THEME = 'system';
679
+ const isThemeMode = (v) => THEME_MODES.includes(v);
680
+
681
+ /** STORED theme mode; an absent key is the default, an invalid value is the default (loudly). */
682
+ export function theme() {
683
+ const v = readSettings().theme;
684
+ if (v === undefined) return DEFAULT_THEME;
685
+ if (isThemeMode(v)) return v;
686
+ console.warn(`[worca] invalid theme ${JSON.stringify(v)} — using the default (${DEFAULT_THEME})`);
687
+ return DEFAULT_THEME;
688
+ }
689
+
690
+ /** @throws {Error} unless `input` is system|light|dark, or empty/null (a clear). */
691
+ export function assertThemeInput(input) {
692
+ if (isClearInput(input)) return;
693
+ if (!isThemeMode(input)) throw new Error('theme must be system, light or dark');
694
+ }
695
+
696
+ export async function setTheme(input) {
697
+ assertThemeInput(input);
698
+ const settings = readSettings();
699
+ if (isClearInput(input) || input === DEFAULT_THEME) delete settings.theme;
700
+ else settings.theme = input;
701
+ await persistSettings(settings);
702
+ return { theme: theme() };
703
+ }
704
+
705
+ // ── Auto workflow classifier model (auto-workflow spec D14) ─────────────────
706
+ // '' = unset: the runtime resolves a default from the catalog
707
+ // (src/core/auto/model.mjs). Read at use time like every other stored setting.
708
+
709
+ /** The configured classifier model id, or '' when unset. */
710
+ export function autoWorkflowModel() {
711
+ const v = readSettings().autoWorkflowModel;
712
+ return typeof v === 'string' ? v.trim() : '';
713
+ }
714
+
715
+ /**
716
+ * Validate a POST value. With `models` (the effective catalog) the id must name an
717
+ * entry and comes back in the catalog's casing; without it the id is returned as
718
+ * given (CLI, tests). Empty/null/undefined means "clear".
719
+ * @returns {string|null} the canonical id to store, null to clear
720
+ * @throws {Error} on a non-string or an id the catalog does not carry
721
+ */
722
+ export function assertAutoWorkflowModelInput(input, models = null) {
723
+ if (input === '' || input === null || input === undefined) return null;
724
+ if (typeof input !== 'string' || !input.trim()) throw new Error('autoWorkflowModel must be a catalog model id');
725
+ const id = input.trim();
726
+ if (!Array.isArray(models)) return id;
727
+ const hit = models.find((m) => m && typeof m.id === 'string' && m.id.toLowerCase() === id.toLowerCase());
728
+ if (!hit) throw new Error(`unknown model "${id}" — add it to the catalog first`);
729
+ return hit.id;
730
+ }
731
+
732
+ export async function setAutoWorkflowModel(input, { models = null } = {}) {
733
+ const id = assertAutoWorkflowModelInput(input, models);
734
+ const settings = readSettings();
735
+ if (id === null) delete settings.autoWorkflowModel; else settings.autoWorkflowModel = id;
736
+ await persistSettings(settings);
737
+ return { autoWorkflowModel: autoWorkflowModel() };
738
+ }
739
+
609
740
  // ── Spawn-debug diagnostics toggle (the stored side of WORCA_DEBUG_SPAWN) ────
610
741
  // Like every other stored setting (skillMount, the cost caps, the ask caps) this
611
742
  // is READ AT USE TIME: claude-runner.mjs#debugSpawnEnabled calls
@@ -912,3 +1043,34 @@ export async function removeGlobalModel(id) {
912
1043
  if (!settings.models.length) delete settings.models;
913
1044
  await persistSettings(settings);
914
1045
  }
1046
+
1047
+ // ── Getting started (onboarding) ─────────────────────────────────────────────
1048
+ // Two machine-wide flags, nothing more: which steps are DONE is derived from
1049
+ // product state by src/core/onboarding.mjs and never stored. `hidden` is the
1050
+ // checklist's Hide (Settings › General › Getting started shows it again);
1051
+ // `welcomeSeen` is the one-time welcome dialog. Both absent = both false.
1052
+ export function onboardingPrefs() {
1053
+ const o = readSettings().onboarding;
1054
+ const obj = o && typeof o === 'object' && !Array.isArray(o) ? o : {};
1055
+ return { hidden: obj.hidden === true, welcomeSeen: obj.welcomeSeen === true };
1056
+ }
1057
+
1058
+ /** @throws {Error} unless every present key is a boolean. Unknown keys are refused. */
1059
+ export function assertOnboardingPrefsInput(patch) {
1060
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('onboarding prefs must be an object');
1061
+ for (const [k, v] of Object.entries(patch)) {
1062
+ if (k !== 'hidden' && k !== 'welcomeSeen') throw new Error(`unknown onboarding key ${JSON.stringify(k)}`);
1063
+ if (typeof v !== 'boolean') throw new Error(`onboarding.${k} must be true or false`);
1064
+ }
1065
+ }
1066
+
1067
+ /** Merge `patch` over the stored flags; a store with both flags false drops the key. */
1068
+ export async function setOnboardingPrefs(patch = {}) {
1069
+ assertOnboardingPrefsInput(patch);
1070
+ const settings = readSettings();
1071
+ const next = { ...onboardingPrefs(), ...patch };
1072
+ if (!next.hidden && !next.welcomeSeen) delete settings.onboarding;
1073
+ else settings.onboarding = next;
1074
+ await persistSettings(settings);
1075
+ return onboardingPrefs();
1076
+ }
@@ -283,7 +283,10 @@ export async function retryWriteback(pipelineId) {
283
283
  try {
284
284
  const meta = readStoreMeta(row.project_key);
285
285
  if (meta?.path && (await hasGh())) {
286
- prUrl = (await findPrForBranch({ projectDir: meta.path, head: branch }))?.url || null;
286
+ // The persisted pr_url (SELECT * above) resolves a cross-repo PR by URL; the
287
+ // branch search stays the fallback for rows that never recorded one.
288
+ const persisted = row.pr_url || null;
289
+ prUrl = (await findPrForBranch({ projectDir: meta.path, head: branch, prUrl: persisted }))?.url || null;
287
290
  }
288
291
  } catch { prUrl = null; }
289
292
  }
@@ -32,6 +32,11 @@ export function canonicalProjectRoot(projectDir) {
32
32
  try { return realpathSync(dir); } catch { return dir; }
33
33
  }
34
34
 
35
+ /** The SHAPE projectKey() produces — `<slug>-<sha1[:8]>`, lowercase. One source of truth for the
36
+ * readers that validate a key before a lookup or a path join (ui/server.mjs' /api/memory routes),
37
+ * next to WORKSPACE_KEY_RE's role in workspaces.mjs. */
38
+ export const PROJECT_KEY_RE = /^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/;
39
+
35
40
  /** Stable key for a project. Memoized by resolved input path. */
36
41
  export function projectKey(projectDir) {
37
42
  const cacheKey = resolve(projectDir);
@@ -41,6 +41,7 @@ import { portsOf, findPort } from '../shared/graph/ports.mjs';
41
41
  /** Canonical leading-`---` YAML frontmatter matcher (was workflows.mjs FRONTMATTER_RE).
42
42
  * Group 1 is the inner YAML; the whole match (m[0]) is the fence block incl. its
43
43
  * trailing newline when present. */
44
+ // Readers use src/core/frontmatter.mjs; this stays a WRITER's fence matcher (m[0] bounds the stamp insertion — byte-pinned by the export tests).
44
45
  const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\n?/;
45
46
 
46
47
  /**
@@ -379,6 +380,7 @@ function deriveNodeIo(graph, tnode, nodeKey, { cycle = 1, channelDefs } = {}) {
379
380
  async function buildExportSet({ workflowId, destination, projectDir, slug, includeAgents = true, repoRoot }) {
380
381
  const tpl = await readWorkflow(workflowId);
381
382
  if (!tpl) throw err(`workflow not found: ${workflowId}`, 'NOT_FOUND');
383
+ if (tpl.auto) throw err('the Auto entry has no graph to export — it is decided per run', 'UNSUPPORTED');
382
384
 
383
385
  const dest = resolveDest({ destination, projectDir });
384
386
  const finalSlug = assertName(slug || slugify(tpl.name).slice(0, 48) || 'workflow', 'slug');
@@ -61,6 +61,7 @@ export function nodeDefaultsError(raw, models, where) {
61
61
  export async function exportGraphJson(id) {
62
62
  const tpl = await readWorkflow(id);
63
63
  if (!tpl) throw err(`workflow not found: ${id}`, 'NOT_FOUND');
64
+ if (tpl.auto) throw err('the Auto entry has no graph to export — it is decided per run', 'UNSUPPORTED');
64
65
  if (tpl.version !== 2) {
65
66
  throw err(`workflow ${id} is a v1 template — open and save it in the composer first`, 'UNSUPPORTED');
66
67
  }
@@ -25,9 +25,13 @@ import { DEFAULT_AGENTS_DIR, loadAgentRegistry } from './agent-registry.mjs'; //
25
25
  import { readPluginsLock } from './plugins-lock.mjs'; // a DISABLED plugin's rows are hidden
26
26
  import { validateGraph, formatIssue, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
27
27
  import { classifyLoops } from '../shared/graph/loops.mjs';
28
- import { GRAPH_DEFAULT_WORKFLOW } from './graph/builtin-workflows.mjs';
29
- export { GRAPH_DEFAULT_WORKFLOW };
28
+ import {
29
+ GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME, AUTO_WORKFLOW_STUB,
30
+ GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, MEMORY_DEFRAG_WORKFLOW_NAME, isReservedWorkflowId,
31
+ } from './graph/builtin-workflows.mjs';
32
+ export { GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, isReservedWorkflowId };
30
33
  import { registryPortsFn } from './graph/registry-ports.mjs';
34
+ import { parseFrontmatter } from './frontmatter.mjs';
31
35
 
32
36
  /**
33
37
  * Default feedback cycle count when run-config does not override it. Matches the
@@ -70,20 +74,7 @@ export async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
70
74
  } catch {
71
75
  return { prompt: '', tools: [] };
72
76
  }
73
- return { prompt: text, tools: parseFrontmatterTools(text) };
74
- }
75
-
76
- /** Extract a comma-separated `tools:` list from leading --- YAML frontmatter. */
77
- function parseFrontmatterTools(text) {
78
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
79
- if (!m) return [];
80
- const line = m[1].split(/\r?\n/).find((l) => /^tools\s*:/.test(l));
81
- if (!line) return [];
82
- return line
83
- .replace(/^tools\s*:/, '')
84
- .split(',')
85
- .map((s) => s.trim())
86
- .filter(Boolean);
77
+ return { prompt: text, tools: parseFrontmatter(text)?.tools ?? [] };
87
78
  }
88
79
 
89
80
  /**
@@ -310,16 +301,18 @@ export async function writeGraphWorkflow(tpl, opts = {}) {
310
301
  // The ONE reserved id is the built-in default's; a save may never claim it,
311
302
  // so it falls back to the slug.
312
303
  const asked = tpl && typeof tpl.id === 'string' ? tpl.id.trim() : '';
313
- const minted = !(asked && isSafeWorkflowId(asked) && asked !== GRAPH_DEFAULT_WORKFLOW.id);
304
+ const reserved = (id) => isReservedWorkflowId(id);
305
+ const minted = !(asked && isSafeWorkflowId(asked) && !reserved(asked));
314
306
  const id = minted ? `wf_${slugify(name)}` : asked;
315
307
  // C-3: the fallback re-mints the reserved id for ANY name slugging to
316
308
  // "default" ('Default', ' dEfAuLt ', 'default!!', 'Défault'…). That row is
317
309
  // filtered out of listWorkflows(), short-circuited past by readWorkflow() and
318
310
  // refused by DELETE — the user's pipeline would vanish behind a 201. Refuse
319
311
  // the WRITE instead; only the name is wrong, so the caller can rename.
320
- if (id === GRAPH_DEFAULT_WORKFLOW.id) {
312
+ if (reserved(id)) {
313
+ const which = id === AUTO_WORKFLOW_ID ? AUTO_WORKFLOW_NAME : id === MEMORY_DEFRAG_WORKFLOW_ID ? MEMORY_DEFRAG_WORKFLOW_NAME : GRAPH_DEFAULT_WORKFLOW.name;
321
314
  throw Object.assign(
322
- new Error(`the name "${GRAPH_DEFAULT_WORKFLOW.name}" is reserved — choose another name`),
315
+ new Error(`the name "${which}" is reserved — choose another name`),
323
316
  { code: 'RESERVED_NAME' });
324
317
  }
325
318
  const domain = normDomain(tpl && tpl.domain);
@@ -370,6 +363,8 @@ export async function writeGraphWorkflow(tpl, opts = {}) {
370
363
  export async function readWorkflow(id, opts = {}) {
371
364
  // `wf_default` IS the graph: the v1 default died with the v1 engine.
372
365
  if (id === GRAPH_DEFAULT_WORKFLOW.id) return GRAPH_DEFAULT_WORKFLOW;
366
+ if (id === AUTO_WORKFLOW_ID) return AUTO_WORKFLOW_STUB;
367
+ if (id === MEMORY_DEFRAG_WORKFLOW_ID) return GRAPH_MEMORY_DEFRAG_WORKFLOW;
373
368
  return readRaw(id, opts);
374
369
  }
375
370
 
@@ -400,7 +395,7 @@ export async function listWorkflows({ includeArchived = false, includeDisabled =
400
395
  const rows = prepare(`SELECT ${ROW_COLS} FROM workflows ${where} ORDER BY created_at DESC, id`).all();
401
396
  const lock = includeDisabled ? null : readPluginsLock();
402
397
  return rows
403
- .filter((r) => r.id !== GRAPH_DEFAULT_WORKFLOW.id)
398
+ .filter((r) => !isReservedWorkflowId(r.id))
404
399
  .filter((r) => includeDisabled || !pluginDisabled(r.origin, lock))
405
400
  .map(rowToTpl);
406
401
  }
@@ -437,6 +432,8 @@ function assertValidGraph(tpl, registry) {
437
432
  */
438
433
  export async function assertRunnableWorkflow(id, { registry, checkGraph = true } = {}) {
439
434
  const wanted = typeof id === 'string' && id.trim() ? id.trim() : GRAPH_DEFAULT_WORKFLOW.id;
435
+ // The Auto entry has no graph to check: the run decides one (spec §5.1).
436
+ if (wanted === AUTO_WORKFLOW_ID) return AUTO_WORKFLOW_STUB;
440
437
  const live = await readWorkflow(wanted);
441
438
  if (live) {
442
439
  // A disabled plugin's template is neither runnable nor openable: its agents
@@ -479,6 +476,9 @@ export async function setWorkflowNodeDefaults(id, map) {
479
476
  if (id === GRAPH_DEFAULT_WORKFLOW.id) {
480
477
  throw new Error('the built-in Default workflow cannot store defaults — save a copy in Composer first');
481
478
  }
479
+ if (id === MEMORY_DEFRAG_WORKFLOW_ID) {
480
+ throw new Error('the built-in Memory defragment workflow cannot store defaults — save a copy in Composer first');
481
+ }
482
482
  const tpl = readRaw(id);
483
483
  if (!tpl) throw new Error(`workflow not found: ${id}`);
484
484
  const patch = map && typeof map === 'object' ? map : {};
@@ -523,6 +523,8 @@ export async function setWorkflowNodeDefaults(id, map) {
523
523
  */
524
524
  export async function deleteWorkflow(id) {
525
525
  if (id === GRAPH_DEFAULT_WORKFLOW.id) return false; // built-in default is undeletable
526
+ if (id === AUTO_WORKFLOW_ID) return false; // never a row
527
+ if (id === MEMORY_DEFRAG_WORKFLOW_ID) return false; // a constant, never a row
526
528
  if (!isSafeWorkflowId(id)) return false; // SECURITY: reject unsafe ids
527
529
  getDb();
528
530
  let changed = 0;
@@ -548,7 +550,7 @@ export async function deleteWorkflow(id) {
548
550
  * @param {string} workflowId
549
551
  * @param {Record<string,object>} registry loadAgentRegistry() output
550
552
  * @param {string} [agentsDir] override for tests; defaults to ../../agents
551
- * @param {{ isWorkspace?: boolean }} [opts] workspace-mode resolve options
553
+ * @param {{ isWorkspace?: boolean, overlay?: {nodes?: object, wires?: object}, ignoreProjectOverrides?: boolean }} [opts] workspace-mode resolve options
552
554
  * @returns {Promise<object>} ExecutablePlan
553
555
  * @throws {Error} when the workflow id is unknown, or a node resolves the off-pipeline scanner
554
556
  */
@@ -591,6 +593,9 @@ export function workspaceVariants(registry) {
591
593
  * @throws {Error} unknown workflow, a v1 row, an unknown/un-ported/unplaceable agent
592
594
  */
593
595
  export async function resolveGraph(projectDir, workflowId, registry, agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
596
+ if (workflowId === AUTO_WORKFLOW_ID) {
597
+ throw new Error('the Auto workflow is decided per run — resolveGraph needs the adopted workflow id');
598
+ }
594
599
  const stored = await readWorkflow(workflowId);
595
600
  if (!stored) throw new Error(`unknown workflowId "${workflowId}"`);
596
601
  if (stored.version !== 2) throw new Error('template is not a graph — runs on the v1 engine');
@@ -600,12 +605,27 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
600
605
  const reg = registry && typeof registry === 'object' ? registry : {};
601
606
  const isWorkspace = !!opts.isWorkspace;
602
607
  const variants = isWorkspace ? workspaceVariants(reg) : {};
603
- const { nodes: nodeCfg, wires: wireCfg } = await resolveRunConfig(projectDir, workflowId);
608
+ // Per-project overlays. An Auto run owns its tuning (spec D7/D9): with
609
+ // `ignoreProjectOverrides` every per-project layer is skipped and `overlay`
610
+ // (the proposal the user accepted) is the ONLY overlay. Otherwise `overlay`
611
+ // merges PER NODE / PER WIRE over the project layer.
612
+ const ignore = !!opts.ignoreProjectOverrides;
613
+ const runCfg = ignore ? { nodes: {}, wires: {} } : await resolveRunConfig(projectDir, workflowId);
614
+ const over = opts.overlay && typeof opts.overlay === 'object' ? opts.overlay : {};
615
+ const mergeMaps = (base, extra) => {
616
+ const out = { ...base };
617
+ for (const [id, sel] of Object.entries(extra && typeof extra === 'object' ? extra : {})) {
618
+ if (sel && typeof sel === 'object') out[id] = { ...(out[id] || {}), ...sel };
619
+ }
620
+ return out;
621
+ };
622
+ const nodeCfg = mergeMaps(runCfg.nodes, over.nodes);
623
+ const wireCfg = mergeMaps(runCfg.wires, over.wires);
604
624
  // The legacy per-role layer is the Default workflow's storage only (saved rows
605
625
  // use nodeCfg); it is addressed by agent KEY, never by node id.
606
626
  // A defaults-only global export passes projectDir=null and must not touch the config
607
627
  // store (projectKey(null) throws); its legacy per-role layer is empty by definition.
608
- const stepsCfg = (workflowId === GRAPH_DEFAULT_WORKFLOW.id && projectDir) ? (await readConfig(projectDir)).steps : {};
628
+ const stepsCfg = (!ignore && workflowId === GRAPH_DEFAULT_WORKFLOW.id && projectDir) ? (await readConfig(projectDir)).steps : {};
609
629
  const firstDefined = (...vals) => vals.find((v) => v !== undefined);
610
630
 
611
631
  const nodes = {};
@@ -32,6 +32,27 @@ import { getDb, prepare, tx } from './db.mjs';
32
32
  /** Object-shaped error carrying a machine code (mirrors pipeline-delete.mjs). */
33
33
  function err(message, code) { return Object.assign(new Error(message), { code }); }
34
34
 
35
+ /**
36
+ * Validate a metricsProject candidate against a workspace's member paths.
37
+ * null/'' means "no home" and returns null. Anything else must resolve (by exact
38
+ * match or by canonical git root) to one of `paths`; the member's OWN stored path
39
+ * is returned (workspace_projects convention), not the normalized candidate.
40
+ * @param {string[]} paths
41
+ * @param {string|null|undefined} candidate
42
+ * @returns {string|null}
43
+ * @throws err(code: BAD_REQUEST)
44
+ */
45
+ function memberPathFor(paths, candidate) {
46
+ if (candidate == null || candidate === '') return null;
47
+ if (typeof candidate !== 'string') throw err('metricsProject must be a project path or null', 'BAD_REQUEST');
48
+ const want = normalizeProjectPath(candidate);
49
+ // normalizeProjectPath(' ') -> null; canonicalProjectRoot(null) would throw ERR_INVALID_ARG_TYPE -> 500.
50
+ if (!want) throw err('metricsProject must be a project path or null', 'BAD_REQUEST');
51
+ const hit = paths.find((p) => p === want || canonicalProjectRoot(p) === canonicalProjectRoot(want));
52
+ if (!hit) throw err('metricsProject must be one of the workspace projects', 'BAD_REQUEST');
53
+ return hit;
54
+ }
55
+
35
56
  /**
36
57
  * The workspace-key shape: "wks-<slug>-<sha1[:8]>". The server imports this as
37
58
  * its single source of truth (M2 route validation), so core + route agree on one
@@ -108,6 +129,7 @@ function annotate(entry) {
108
129
  projectPaths: pairs.map((x) => x.path),
109
130
  projectKeys: pairs.map((x) => x.key),
110
131
  exists: pairs.map((x) => isDir(x.path)),
132
+ metricsProject: entry.metricsProject ?? null,
111
133
  createdAt: entry.createdAt,
112
134
  updatedAt: entry.updatedAt,
113
135
  };
@@ -127,6 +149,7 @@ function rowToEntry(r) {
127
149
  name: r.name,
128
150
  description: typeof r.description === 'string' ? r.description : '',
129
151
  projectPaths: memberPaths(r.id),
152
+ metricsProject: r.metrics_project ?? null,
130
153
  createdAt: typeof r.created_at === 'string' ? r.created_at : '',
131
154
  updatedAt: typeof r.updated_at === 'string' ? r.updated_at : '',
132
155
  };
@@ -136,7 +159,7 @@ function rowToEntry(r) {
136
159
  function readEntry(id) {
137
160
  getDb();
138
161
  const r = prepare(
139
- 'SELECT id, name, description, created_at, updated_at FROM workspaces WHERE id = ?'
162
+ 'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces WHERE id = ?'
140
163
  ).get(id);
141
164
  return r ? rowToEntry(r) : null;
142
165
  }
@@ -148,7 +171,7 @@ function readEntry(id) {
148
171
  export async function listWorkspaces() {
149
172
  getDb();
150
173
  const rows = prepare(
151
- 'SELECT id, name, description, created_at, updated_at FROM workspaces ORDER BY created_at, name'
174
+ 'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces ORDER BY created_at, name'
152
175
  ).all();
153
176
  return rows.map(rowToEntry).map(annotate);
154
177
  }
@@ -218,6 +241,7 @@ export async function createWorkspace(input = {}) {
218
241
  if (!isGitRepo(p)) throw err(`member path is not a git repository: ${p}`, 'BAD_REQUEST');
219
242
  }
220
243
 
244
+ const metricsProject = memberPathFor(members, input.metricsProject ?? null);
221
245
  const id = workspaceKey({ name, projectPaths: members });
222
246
  const hash = rootsHash(members);
223
247
  const now = new Date().toISOString();
@@ -235,8 +259,8 @@ export async function createWorkspace(input = {}) {
235
259
  }
236
260
  }
237
261
  prepare(
238
- 'INSERT INTO workspaces (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
239
- ).run(id, name, description, now, now);
262
+ 'INSERT INTO workspaces (id, name, description, metrics_project, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
263
+ ).run(id, name, description, metricsProject, now, now);
240
264
  const insMember = prepare(
241
265
  'INSERT INTO workspace_projects (workspace_id, project_key, ordinal) VALUES (?, ?, ?)'
242
266
  );
@@ -245,7 +269,9 @@ export async function createWorkspace(input = {}) {
245
269
  });
246
270
 
247
271
  // Return the annotated entry (derived fields recomputed from the persisted paths).
248
- return annotate({ id, name, description, projectPaths: members, createdAt: now, updatedAt: now });
272
+ return annotate({
273
+ id, name, description, projectPaths: members, metricsProject, createdAt: now, updatedAt: now,
274
+ });
249
275
  }
250
276
 
251
277
  /**
@@ -270,6 +296,9 @@ export async function updateWorkspace(id, patch = {}) {
270
296
  if (patch && typeof patch.description === 'string') {
271
297
  description = patch.description; // cap-on-freeze, not cap-on-store: persisted whole
272
298
  }
299
+ const metricsProject = Object.prototype.hasOwnProperty.call(patch || {}, 'metricsProject')
300
+ ? memberPathFor(entry.projectPaths, patch.metricsProject)
301
+ : entry.metricsProject ?? null;
273
302
  const now = new Date().toISOString();
274
303
 
275
304
  tx(() => {
@@ -279,11 +308,11 @@ export async function updateWorkspace(id, patch = {}) {
279
308
  ).get(name, id);
280
309
  if (clash) throw err(`a workspace named "${name}" already exists`, 'DUPLICATE_NAME');
281
310
  prepare(
282
- 'UPDATE workspaces SET name = ?, description = ?, updated_at = ? WHERE id = ?'
283
- ).run(name, description, now, id);
311
+ 'UPDATE workspaces SET name = ?, description = ?, metrics_project = ?, updated_at = ? WHERE id = ?'
312
+ ).run(name, description, metricsProject, now, id);
284
313
  });
285
314
 
286
- return annotate({ ...entry, name, description, updatedAt: now });
315
+ return annotate({ ...entry, name, description, metricsProject, updatedAt: now });
287
316
  }
288
317
 
289
318
  /** Thin setter: edit only the description. */
@@ -16,6 +16,9 @@ const RESERVED_PORT_ID = 'await';
16
16
  const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
17
17
  const COLORS = new Set(['green', 'peach', 'red', 'blue', 'violet', 'amber']);
18
18
  const RUNNER_TYPES = new Set(['producer', 'verifier', 'clarifier']);
19
+ /** What an agent may write outside its output ports: 'code' (the worktree — implementer tool
20
+ * set) or 'memory' (worca's memory mount only — Read/Write/Edit/Glob/Grep, no Bash). */
21
+ const SIDE_EFFECTS = new Set(['code', 'memory']);
19
22
  const INPUT_AS = new Set(['file', 'answers', 'fix-review', 'worktree']);
20
23
  /** The port type each non-default `as` renderer requires. `file` is the default
21
24
  * and is materialized on NON-VOID inputs only, which makes `worktree` the only
@@ -112,7 +115,7 @@ export function normalizeAgentMeta(raw, opts = {}) {
112
115
  // Scope coercion mirrors color: anything but the explicit 'workspace-only'
113
116
  // marker is a normal project agent, so a typo fails safe to a VISIBLE agent.
114
117
  const scope = raw.scope === 'workspace-only' ? 'workspace-only' : 'project';
115
- if (raw.sideEffect !== undefined && raw.sideEffect !== 'code') err('sideEffect must be "code" when present');
118
+ if (raw.sideEffect !== undefined && !SIDE_EFFECTS.has(raw.sideEffect)) err(`sideEffect must be one of ${[...SIDE_EFFECTS].join(', ')}`);
116
119
  if (raw.workspaceStrategy !== undefined && !WORKSPACE_STRATEGIES.has(raw.workspaceStrategy)) {
117
120
  err(`workspaceStrategy must be one of ${[...WORKSPACE_STRATEGIES].join(', ')}`);
118
121
  }
@@ -164,7 +167,7 @@ export function normalizeAgentMeta(raw, opts = {}) {
164
167
  // an absent field means "the default", so a v2 entry stays diffable against
165
168
  // the sidecar that produced it.
166
169
  if (verdict) meta.verdict = verdict;
167
- if (raw.sideEffect === 'code') meta.sideEffect = 'code';
170
+ if (SIDE_EFFECTS.has(raw.sideEffect)) meta.sideEffect = raw.sideEffect;
168
171
  if (mockRole) meta.mockRole = mockRole;
169
172
  if (raw.wantsRequest) meta.wantsRequest = true;
170
173
  if (raw.workspaceFanOut) meta.workspaceFanOut = true;