@worca/app 1.2.0 → 1.3.0

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 +3715 -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 +1359 -214
  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 +423 -25
  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 +1450 -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 +1282 -193
@@ -11,13 +11,15 @@
11
11
  import { mkdir, writeFile, readFile, copyFile, readdir } from 'node:fs/promises';
12
12
  import { join, basename, resolve, isAbsolute } from 'node:path';
13
13
  import { randomBytes } from 'node:crypto';
14
- import { realpathSync, existsSync } from 'node:fs';
14
+ import { realpathSync, existsSync, statSync } from 'node:fs';
15
15
  import { hostname } from 'node:os';
16
16
  import { projectKey, projectStorePath, canonicalProjectRoot, workspaceStorePath } from './store.mjs';
17
17
  import { listProjects } from './projects.mjs';
18
18
  import { branchExists, diffShortstat, hasGh, findPrForBranch } from './git-info.mjs';
19
19
  import { getDb, tx } from './db.mjs';
20
20
  import { RUN_LOG_FILE } from './run-log.mjs';
21
+ import { readRunLedger } from './metrics/ledger.mjs';
22
+ import { memoryTotals } from './memory-sync.mjs';
21
23
 
22
24
  // ── DB row <-> state object mapping (Phase 3) ──────────────────────────────────
23
25
  // JSON columns are TEXT; (de)serialize at THIS boundary only. Reads are fail-safe:
@@ -82,17 +84,27 @@ export function deleteStoreMeta(key) {
82
84
  * in plans//reviews/, siblings of pipelines/). Idempotent (INSERT OR IGNORE on the
83
85
  * (pipeline_id, kind, rel_path) PK), best-effort: a logging failure never breaks a
84
86
  * run. A null/empty path is a no-op. The pipelines row must already exist (FK).
87
+ * Optional per-step attribution (step_key/node_id/cycle/created_at) is stamped on
88
+ * the FIRST insert only — the conflict behavior stays byte-for-byte INSERT OR
89
+ * IGNORE (first write wins), so a re-record never clobbers an existing row's
90
+ * attribution. The 3-arg form still works (attr defaults to {}, columns NULL).
85
91
  * @param {string} pipelineId
86
92
  * @param {string} kind
87
93
  * @param {string} relPath
94
+ * @param {{stepKey?:string, nodeId?:string, cycle?:number}} [attr]
88
95
  */
89
- export function recordArtifact(pipelineId, kind, relPath) {
96
+ export function recordArtifact(pipelineId, kind, relPath, attr = {}) {
90
97
  if (!pipelineId || !kind || !relPath) return;
98
+ const stepKey = attr.stepKey ?? null;
99
+ const nodeId = attr.nodeId ?? null;
100
+ const cycle = attr.cycle ?? null;
101
+ const createdAt = new Date().toISOString();
91
102
  try {
92
103
  tx(() => {
93
104
  getDb().prepare(
94
- 'INSERT OR IGNORE INTO artifacts (pipeline_id, kind, rel_path) VALUES (?, ?, ?)',
95
- ).run(pipelineId, kind, relPath);
105
+ 'INSERT OR IGNORE INTO artifacts (pipeline_id, kind, rel_path, step_key, node_id, cycle, created_at) '
106
+ + 'VALUES (?, ?, ?, ?, ?, ?, ?)',
107
+ ).run(pipelineId, kind, relPath, stepKey, nodeId, cycle, createdAt);
96
108
  });
97
109
  } catch { /* artifact indexing is best-effort; never break a run on it */ }
98
110
  }
@@ -111,6 +123,53 @@ export async function listArtifacts(pipelineId) {
111
123
  .all(pipelineId).map((r) => ({ kind: r.kind, relPath: r.rel_path }));
112
124
  }
113
125
 
126
+ /**
127
+ * List a run's artifacts with step attribution and on-disk byte size, ordered
128
+ * created_at (NULLs first, so legacy rows bucket ahead) then rel_path. `bytes` is
129
+ * stat-ed run dir first, store root second (mirroring resolveIndexedArtifactForRow's
130
+ * base order); a missing file reports bytes: 0. Optional { stepKey, kind } filter.
131
+ * An optional `limit` caps the SQL result so the per-row statSync only runs on
132
+ * rows the caller keeps (pass limit+1 to detect truncation); omit it to size
133
+ * every row.
134
+ * @param {string} pipelineId
135
+ * @param {{stepKey?:string, kind?:string, limit?:number}} [filter]
136
+ * @returns {Promise<Array<{kind:string, stepKey:string|null, nodeId:string|null, cycle:number|null, relPath:string, bytes:number, createdAt:string|null}>>}
137
+ */
138
+ export async function listRunArtifacts(pipelineId, filter = {}) {
139
+ const row = findPipelineRowById(pipelineId);
140
+ if (!row) return [];
141
+ // Query the RESOLVED id: findPipelineRowById accepts a run-dir basename/suffix
142
+ // (DIR_ID_RE), so `pipelineId` may not equal the stored `pipeline_id`.
143
+ const clauses = ['pipeline_id = ?'];
144
+ const args = [row.id];
145
+ if (filter.stepKey) { clauses.push('step_key = ?'); args.push(filter.stepKey); }
146
+ if (filter.kind) { clauses.push('kind = ?'); args.push(filter.kind); }
147
+ const hasLimit = Number.isInteger(filter.limit) && filter.limit > 0;
148
+ const raw = getDb().prepare(
149
+ `SELECT kind, rel_path, step_key, node_id, cycle, created_at FROM artifacts
150
+ WHERE ${clauses.join(' AND ')}
151
+ ORDER BY (created_at IS NULL) DESC, created_at ASC, rel_path ASC${hasLimit ? ' LIMIT ?' : ''}`,
152
+ ).all(...args, ...(hasLimit ? [filter.limit] : []));
153
+ const isWs = row.target === 'workspace' || !!row.workspace_key;
154
+ const storeRoot = isWs ? workspaceStorePath(row.workspace_key) : projectStorePath(row.project_key);
155
+ const runDir = await runDirForRow(row);
156
+ const sizeOf = (rel) => {
157
+ for (const base of [runDir, storeRoot]) {
158
+ try { return statSync(join(base, rel)).size; } catch { /* try next base */ }
159
+ }
160
+ return 0;
161
+ };
162
+ return raw.map((r) => ({
163
+ kind: r.kind,
164
+ stepKey: r.step_key ?? null,
165
+ nodeId: r.node_id ?? null,
166
+ cycle: r.cycle ?? null,
167
+ relPath: r.rel_path,
168
+ bytes: sizeOf(r.rel_path),
169
+ createdAt: r.created_at ?? null,
170
+ }));
171
+ }
172
+
114
173
  /**
115
174
  * Upsert the clarify row for a pipeline. Pass { questions } and/or { answers }. A
116
175
  * partial call updates only the provided column, preserving the other. JSON-encoded
@@ -538,6 +597,32 @@ export function updatePhaseStatus(pipelineId, ordinal, status, ts) {
538
597
  } catch { /* best-effort */ }
539
598
  }
540
599
 
600
+ /**
601
+ * Aggregate live run progress from the existing readers. Free-text fields
602
+ * (titles, review summaries, question/answer text) are redacted by the caller,
603
+ * not here — this is a pure data assembler. Returns null for an unknown run.
604
+ * @param {string} pipelineId
605
+ * @returns {Promise<null | {runId:string, phase:string|null, status:string|null, phases:Array, tasks:Array, clarify:object, reviews:Array, stepQuestions:Array}>}
606
+ */
607
+ export async function readRunProgress(pipelineId) {
608
+ const row = findPipelineRowById(pipelineId);
609
+ if (!row) return null;
610
+ // Read against the RESOLVED id — `pipelineId` may be a run-dir basename/suffix
611
+ // (findPipelineRowById's DIR_ID_RE) that no downstream table keys on.
612
+ const id = row.id;
613
+ const extras = readPipelineExtras(id);
614
+ return {
615
+ runId: row.id,
616
+ phase: row.phase ?? null,
617
+ status: row.status ?? null,
618
+ phases: listPhases(id),
619
+ tasks: listTasks(id),
620
+ clarify: extras.clarify,
621
+ reviews: extras.reviews,
622
+ stepQuestions: extras.stepQuestions,
623
+ };
624
+ }
625
+
541
626
  /**
542
627
  * Convert an arbitrary string to a safe kebab-case slug.
543
628
  * - Lowercases, replaces non-alphanumerics with hyphens, collapses repeats,
@@ -1138,6 +1223,22 @@ export function persistPrState(pipelineId, pr) {
1138
1223
  } catch { /* best-effort */ }
1139
1224
  }
1140
1225
 
1226
+ /**
1227
+ * Last persisted PR facts for a pipeline (spec §6.8), or null when none were
1228
+ * ever observed. Read by the later PR lookups so a cross-repo PR (which lives in
1229
+ * the base repo, not the cwd's default) is resolved by URL instead of by branch.
1230
+ * The resolved `state` (rowToState) deliberately omits pr_* — use this instead.
1231
+ * @param {string} pipelineId
1232
+ * @returns {{ url:string, number:(number|null), state:string }|null}
1233
+ */
1234
+ export function readPrState(pipelineId) {
1235
+ if (!pipelineId) return null;
1236
+ try {
1237
+ const row = getDb().prepare('SELECT pr_url, pr_number, pr_state FROM pipelines WHERE id = ?').get(pipelineId);
1238
+ return row && row.pr_url ? { url: row.pr_url, number: row.pr_number ?? null, state: row.pr_state ?? 'OPEN' } : null;
1239
+ } catch { return null; }
1240
+ }
1241
+
1141
1242
  /**
1142
1243
  * The status a stale (crashed/killed) run is reconciled to. Distinct from a user
1143
1244
  * 'stopped' and a real 'error': the owning process died before Orchestrator.run()'s
@@ -1532,7 +1633,10 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
1532
1633
  // unavailable we still set pr:null (the field is present whenever requested), so
1533
1634
  // callers can distinguish "looked, none" from "did not look".
1534
1635
  if (opts.withPr && repoDir && feature) {
1535
- entry.pr = (await hasGh()) ? await findPrForBranch({ projectDir: repoDir, head: feature }) : null;
1636
+ // pr_url first (repo-agnostic view); the branch search only for rows with no PR yet.
1637
+ entry.pr = (await hasGh())
1638
+ ? await findPrForBranch({ projectDir: repoDir, head: feature, prUrl: row.pr_url || null })
1639
+ : null;
1536
1640
  }
1537
1641
  return entry;
1538
1642
  }
@@ -1574,7 +1678,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
1574
1678
  const dirById = await runDirIndex(pipelinesDir);
1575
1679
  const rows = getDb().prepare(`
1576
1680
  SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
1577
- branch, workspace_meta, guardrails_id,
1681
+ branch, workspace_meta, guardrails_id, pr_url,
1578
1682
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1579
1683
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1580
1684
  FROM pipelines
@@ -1603,7 +1707,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
1603
1707
  export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
1604
1708
  const rows = getDb().prepare(`
1605
1709
  SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
1606
- total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id,
1710
+ total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id, pr_url,
1607
1711
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1608
1712
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1609
1713
  FROM pipelines
@@ -1700,7 +1804,8 @@ export async function enrichPipelinesPr(onBatch, { batchSize = 16 } = {}) {
1700
1804
  for (let i = 0; i < targets.length; i += batchSize) {
1701
1805
  const slice = targets.slice(i, i + batchSize);
1702
1806
  const items = await Promise.all(slice.map(async (r) => {
1703
- const pr = (await findPrForBranch({ projectDir: r.projectDir, head: r.branch })) || null;
1807
+ const prUrl = readPrState(r.id)?.url || null;
1808
+ const pr = (await findPrForBranch({ projectDir: r.projectDir, head: r.branch, prUrl })) || null;
1704
1809
  if (pr) persistPrState(r.id, pr); // positive observations only (null never clears)
1705
1810
  return { projectKey: r.projectKey, id: r.id, pr };
1706
1811
  }));
@@ -1889,6 +1994,13 @@ export function findPipelineRowById(id) {
1889
1994
  return row || null;
1890
1995
  }
1891
1996
 
1997
+ /** The detail `state` of ONE pipeline by id alone — any store key, archived included (the Ask Worca progress
1998
+ * card's hydration read). The keyed History routes keep their full payload; this is the bare rowToState. */
1999
+ export function readPipelineStateById(id) {
2000
+ const row = findPipelineRowById(id);
2001
+ return row ? rowToState(row) : null;
2002
+ }
2003
+
1892
2004
  /**
1893
2005
  * The DB-backed lookups `sweepRunRoots` (worktree.mjs) needs, in ONE place shared by
1894
2006
  * both callers — `ui/server.mjs`'s boot sweep and the `worca doctor` subcommand.
@@ -2048,10 +2160,22 @@ export async function readPipelineByKey(key, id) {
2048
2160
  artifacts: await listArtifacts(row.id), // [{kind, relPath}] — drives the Live-logs dropdown (project + workspace)
2049
2161
  results,
2050
2162
  overview,
2163
+ teamMetrics: readRunLedger(row.id),
2164
+ memory: await readMemoryLedger(dir),
2051
2165
  ...readPipelineExtras(row.id),
2052
2166
  };
2053
2167
  }
2054
2168
 
2169
+ /** A run's memory ledger (<runDir>/memory.json, agent-memory P1 amendment A2) as
2170
+ * { mount, changes, totals }, or null when the run wrote none. The ONE reader: the History
2171
+ * detail above serves it whole, ask/tool-deps.mjs' readRunMemory serves get_run the
2172
+ * changes + totals (never the mount path). Read-only, null on any failure. */
2173
+ export async function readMemoryLedger(dir) {
2174
+ const ledger = await readJsonFile(join(dir, 'memory.json'));
2175
+ if (!ledger || !Array.isArray(ledger.changes)) return null;
2176
+ return { mount: ledger.mount || null, changes: ledger.changes, totals: memoryTotals(ledger.changes) };
2177
+ }
2178
+
2055
2179
  /** Local helper: read + JSON-parse a file, null on any failure. */
2056
2180
  async function readJsonFile(p) {
2057
2181
  try { return JSON.parse(await readFile(p, 'utf8')); } catch { return null; }
@@ -6,7 +6,7 @@
6
6
  // Readers are injected so unit tests run without a DB.
7
7
  import { listProjects as realListProjects } from '../projects.mjs';
8
8
  import { listWorkspaces as realListWorkspaces } from '../workspaces.mjs';
9
- import { listWorkflows as realListWorkflows, GRAPH_DEFAULT_WORKFLOW } from '../workflows.mjs';
9
+ import { listWorkflows as realListWorkflows, GRAPH_DEFAULT_WORKFLOW, GRAPH_MEMORY_DEFRAG_WORKFLOW } from '../workflows.mjs';
10
10
  import { classifyLoops } from '../../shared/graph/loops.mjs';
11
11
  import { rankNodes } from '../../shared/graph/layout.mjs';
12
12
  import { registryPortsFn } from '../graph/registry-ports.mjs';
@@ -27,6 +27,27 @@ function graphSteps(tpl, portsFn) {
27
27
  return [...byRank.keys()].sort((a, b) => a - b).map((r) => byRank.get(r));
28
28
  }
29
29
  import { loadAgentRegistry as realLoadAgentRegistry } from '../agent-registry.mjs';
30
+ import { agentVocabulary } from '../auto/classify.mjs';
31
+
32
+ /** The agents a hand-authored shape may place (spec §8.5): the classifier's vocabulary, one compact record each.
33
+ * selfLoop mirrors the assembler's BAD_SELF_LOOP rule EXACTLY (assemble.mjs:349,:363-367): the agent's FIRST
34
+ * `when:'blocking'` output exists and one of its `loop` inputs accepts that type (equal, or the input is `any`).
35
+ * Read from the registry's port objects, not the card's summary strings. */
36
+ export function shapeAgents(registry) {
37
+ const loops = (m) => {
38
+ const outs = Array.isArray(m?.outputs) ? m.outputs : [];
39
+ const blocking = outs.find((o) => o && o.when === 'blocking') || null;
40
+ if (!blocking) return false;
41
+ const ins = Array.isArray(m?.inputs) ? m.inputs.filter((p) => p && p.loop) : [];
42
+ return ins.some((i) => i.type === 'any' || i.type === blocking.type);
43
+ };
44
+ return agentVocabulary(registry, { domain: 'coding' })
45
+ .map((c) => ({
46
+ key: c.key, displayName: c.displayName, purpose: c.purpose, inputs: c.inputs, outputs: c.outputs,
47
+ verifier: c.verifier, clarifier: c.clarifier, selfLoop: loops(registry[c.key]), fanOut: c.fanOut, asksQuestions: c.asksQuestions,
48
+ }))
49
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
50
+ }
30
51
 
31
52
  /**
32
53
  * Pure: a stored workflow template → the catalog shape. `tpl.steps` is already
@@ -78,30 +99,34 @@ export function shapeWorkflow(tpl, registry = {}) {
78
99
  }
79
100
 
80
101
  /**
81
- * @param {{listProjects?:Function, listWorkspaces?:Function, listWorkflows?:Function, defaultWorkflow?:object, loadAgentRegistry?:Function}} [deps]
102
+ * @param {{listProjects?:Function, listWorkspaces?:Function, listWorkflows?:Function, defaultWorkflow?:object, builtinWorkflows?:object[], loadAgentRegistry?:Function}} [deps]
82
103
  */
83
104
  export function createCatalog({
84
105
  listProjects = realListProjects,
85
106
  listWorkspaces = realListWorkspaces,
86
107
  listWorkflows = realListWorkflows,
87
108
  defaultWorkflow = GRAPH_DEFAULT_WORKFLOW,
109
+ builtinWorkflows = [GRAPH_MEMORY_DEFRAG_WORKFLOW],
88
110
  loadAgentRegistry = realLoadAgentRegistry,
89
111
  } = {}) {
90
112
  async function buildCatalog() {
91
113
  const [projects, workspaces, workflows] = await Promise.all([listProjects(), listWorkspaces(), listWorkflows()]);
92
114
  let registry = {};
93
115
  try { registry = loadAgentRegistry() || {}; } catch { registry = {}; }
94
- // Same order as GET /api/workflows: the graph default, then saved rows.
95
- // shapeWorkflow already derives `steps` (condensation-topo ranks) +
96
- // `feedbacks` (loop wires) for a v2 template, so the LLM-facing shape is unchanged.
116
+ // Same order as GET /api/workflows: the graph default, the other built-ins (Memory
117
+ // defragment — spec §7.2 wants the assistant to see it), then saved rows. wf_auto
118
+ // stays out: it has no graph. shapeWorkflow already derives `steps` + `feedbacks`.
119
+ const builtins = [defaultWorkflow, ...builtinWorkflows];
120
+ const builtinIds = new Set(builtins.map((t) => t.id));
97
121
  const templates = [
98
- defaultWorkflow,
99
- ...workflows.filter((t) => t && t.id !== defaultWorkflow.id),
122
+ ...builtins,
123
+ ...workflows.filter((t) => t && !builtinIds.has(t.id)),
100
124
  ];
101
125
  return {
102
126
  projects: projects.map((p) => ({ key: p.key, name: p.name, path: p.path })),
103
127
  workspaces: workspaces.map((w) => ({ id: w.id, name: w.name, projectKeys: [...(w.projectKeys || [])] })),
104
128
  workflows: templates.map((t) => shapeWorkflow(t, registry)),
129
+ agents: shapeAgents(registry),
105
130
  };
106
131
  }
107
132
  return { buildCatalog };
@@ -12,7 +12,7 @@
12
12
  // imports at all); tools.mjs owns the protected-path filter and the redaction, so
13
13
  // the fail-closed read rules live next to get_run_diff's.
14
14
  import {
15
- addDiffComment, listDiffComments, getDiffComment, setDiffCommentResolved, deleteDiffComment,
15
+ addDiffComment, addDiffCommentReply, listDiffComments, getDiffComment, setDiffCommentResolved, deleteDiffComment,
16
16
  } from '../diff-comments.mjs';
17
17
  import { hunkContext } from '../diff-anchor.mjs';
18
18
 
@@ -40,13 +40,16 @@ export function defaultCommentDeps() {
40
40
  list: (storeKey, pipelineId, { status = 'all', path = null, patchText = null, keep = null } = {}) => {
41
41
  const rows = listDiffComments(storeKey, pipelineId, { status, path });
42
42
  const kept = typeof keep === 'function' ? rows.filter(keep) : rows;
43
- return kept.map((c) => (patchText == null ? c : {
43
+ // Context for ROOTS only: a reply shares its root's anchor, and parsing the
44
+ // whole patch once more per reply would buy the model nothing (D7).
45
+ return kept.map((c) => ((patchText == null || c.parentId) ? c : {
44
46
  ...c,
45
47
  context: hunkContext(patchText, { project: c.projectKey, path: c.path, side: c.side, line: c.line },
46
48
  COMMENT_CONTEXT_RADIUS),
47
49
  }));
48
50
  },
49
51
  add: (input) => addDiffComment({ ...input, author: 'ask' }),
52
+ reply: (input) => addDiffCommentReply({ ...input, author: 'ask' }),
50
53
  get: (id) => getDiffComment(id),
51
54
  setResolved: (id, resolved) => setDiffCommentResolved(id, resolved),
52
55
  remove: (id) => deleteDiffComment(id),
@@ -32,11 +32,12 @@ const weight = (u) => u.input + 1.25 * u.cacheCreation + 0.1 * u.cacheRead + 5 *
32
32
  const clone = (v) => JSON.parse(JSON.stringify(v));
33
33
  const short = (name) => String(name ?? '').replace(/^mcp__worca__/, '');
34
34
  const isAgentTool = (name) => name === 'Task' || name === 'Agent';
35
- // The three write tools whose success must reach the browser. The reducer runs in
35
+ // The four write tools whose success must reach the browser. The reducer runs in
36
36
  // the PARENT process, so this is the only place a child-process write becomes a
37
37
  // broadcast (the MCP server cannot call broadcast()).
38
38
  const COMMENT_WRITE_TOOLS = new Set([
39
- 'mcp__worca__add_diff_comment', 'mcp__worca__resolve_diff_comment', 'mcp__worca__delete_diff_comment',
39
+ 'mcp__worca__add_diff_comment', 'mcp__worca__reply_to_diff_comment',
40
+ 'mcp__worca__resolve_diff_comment', 'mcp__worca__delete_diff_comment',
40
41
  ]);
41
42
  // The worktree-mutating tools (P4): the MCP child opens/removes checkouts and
42
43
  // moves HEAD (checkout/switch/fetch → tools.mjs noteNav) — invisible to this
@@ -45,6 +46,10 @@ const COMMENT_WRITE_TOOLS = new Set([
45
46
  // only when its subcommand is one noteNav acts on; a `log`/`status` never pokes.
46
47
  const WORKTREE_TOOLS = new Set(['mcp__worca__open_worktree', 'mcp__worca__remove_worktree', 'mcp__worca__git']);
47
48
  const GIT_NAV_SUBCOMMANDS = new Set(['checkout', 'switch', 'fetch']);
49
+ // The memory writers (agent-memory-design.md §9.1): a successful remember/forget in the CHILD
50
+ // becomes the same `memory-changed` broadcast the REST routes emit (ui/server.mjs emitMemoryChanged).
51
+ // The scope key rides the tool RESULT (`scopeKey`), like pokeCommentWrite reads `comment.runId`.
52
+ const MEMORY_WRITE_TOOLS = new Set(['mcp__worca__remember', 'mcp__worca__forget']);
48
53
  /** True when a SUCCESSFUL call of `name` with `input` changed this thread's worktree rows. */
49
54
  export function worktreeMutatingCall(name, input) {
50
55
  if (!WORKTREE_TOOLS.has(name)) return false;
@@ -118,11 +123,22 @@ export function labelForTool(name, input = {}, attachmentNames = {}) {
118
123
  case 'list_workflows': return 'Looking at workflows';
119
124
  case 'list_projects': return 'Looking at projects';
120
125
  case 'propose_run': return 'Preparing a run';
126
+ case 'propose_workflow': return 'Building a workflow';
127
+ case 'propose_metrics_change': return 'Proposing a metrics change';
128
+ case 'get_team_metrics': return 'Reading team metrics';
129
+ case 'list_team_metrics_runs': return 'Listing team runs';
130
+ case 'push_team_metrics': return 'Pushing team metrics';
131
+ case 'track_run': return 'Tracking a run';
121
132
  case 'read_attachment': return `Reading ${(attachmentNames && attachmentNames[id]) || 'attachment'}`;
122
133
  case 'list_diff_comments': return id ? `Reading comments on ${id.slice(0, 12)}` : 'Reading diff comments';
123
134
  case 'add_diff_comment': return 'Writing a diff comment';
135
+ case 'reply_to_diff_comment': return 'Replying to a diff comment';
124
136
  case 'resolve_diff_comment': return 'Updating a diff comment';
125
137
  case 'delete_diff_comment': return 'Deleting a diff comment';
138
+ case 'list_memory': return 'Reading memory';
139
+ case 'read_memory': return input?.name ? `Reading memory: ${input.name}` : 'Reading memory';
140
+ case 'remember': return input?.name ? `Saving memory: ${input.name}` : 'Saving memory';
141
+ case 'forget': return input?.name ? `Removing memory: ${input.name}` : 'Removing memory';
126
142
  default: return `Using ${n}`;
127
143
  }
128
144
  }
@@ -159,8 +175,13 @@ export function createTurnReducer({
159
175
  setTimeout: setT = globalThis.setTimeout,
160
176
  clearTimeout: clearT = globalThis.clearTimeout,
161
177
  onProposal = null,
178
+ onWorkflowStart = null,
179
+ onWorkflowResult = null,
180
+ onMetricsProposal = null, // propose_metrics_change RESULT (team metrics card; the parent re-validates the input)
181
+ onTrackRun = null,
162
182
  onCommentMutation = null,
163
183
  onWorktreeMutation = null,
184
+ onMemoryMutation = null,
164
185
  estimateLiveCost = null,
165
186
  attachmentNames = {},
166
187
  resolveCost = null,
@@ -348,6 +369,11 @@ export function createTurnReducer({
348
369
  fullInputs.set(c.id, input);
349
370
  label(labelForTool(c.name, input, attachmentNames));
350
371
  upsertBlock({ kind: 'tool', id: c.id, name: c.name, input: clipJson(input, limits.blockIoMaxChars), status: 'running', durationMs: null });
372
+ // P3: the workflow card exists from the tool_use on (state 'building' — the four-step trace), so the
373
+ // START is a hook too. Sync: the block must precede any frame the tool result produces.
374
+ if (c.name === 'mcp__worca__propose_workflow' && typeof onWorkflowStart === 'function') {
375
+ try { onWorkflowStart({ toolUseId: c.id, input }); } catch { reducerErrors += 1; }
376
+ }
351
377
  }
352
378
  } else {
353
379
  const agent = byId.get(ptu);
@@ -387,6 +413,18 @@ export function createTurnReducer({
387
413
  try { onWorktreeMutation({ tool: short(name) }); } catch { /* a broken sink never breaks the stream */ }
388
414
  }
389
415
 
416
+ // And for memory: a remember/forget succeeded in the CHILD, so the parent broadcasts the same
417
+ // `memory-changed` frame the REST writes emit. Note the asymmetry with the worktree poke — that
418
+ // one reads the call INPUT, this one the result TEXT, because the scope key rides the result.
419
+ function pokeMemoryWrite(name, text, isError) {
420
+ if (isError || !MEMORY_WRITE_TOOLS.has(name) || typeof onMemoryMutation !== 'function') return;
421
+ try {
422
+ const parsed = JSON.parse(text);
423
+ const scope = typeof parsed?.scopeKey === 'string' ? parsed.scopeKey : null;
424
+ if (scope) onMemoryMutation({ scope, tool: short(name) });
425
+ } catch { /* unparseable result — no poke; the next open refetches anyway */ }
426
+ }
427
+
390
428
  function onUser(raw, ptu, isMain) {
391
429
  const content = Array.isArray(raw.message?.content) ? raw.message.content : [];
392
430
  for (const c of content) {
@@ -400,6 +438,7 @@ export function createTurnReducer({
400
438
  if (agent) appendLog(agent, c.is_error ? `← error: ${clipStr(text, 120)}` : `← ok ${((now() - ct.t0) / 1000).toFixed(1)}s`);
401
439
  pokeCommentWrite(ct.name, text, c.is_error);
402
440
  pokeWorktreeMutation(ct.name, ct.input, c.is_error);
441
+ pokeMemoryWrite(ct.name, text, c.is_error);
403
442
  continue;
404
443
  }
405
444
  const b = byId.get(c.tool_use_id);
@@ -435,8 +474,32 @@ export function createTurnReducer({
435
474
  if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
436
475
  } catch { reducerErrors += 1; }
437
476
  }
477
+ if (b.name === 'mcp__worca__propose_workflow' && typeof onWorkflowResult === 'function') {
478
+ // The RAW result text: the parent re-validates from the returned shape (spec §8.2, PD1); an isError result
479
+ // carries "error: <message>" and flips the card to failed.
480
+ try {
481
+ const ret = onWorkflowResult({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
482
+ if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
483
+ } catch { reducerErrors += 1; }
484
+ }
485
+ if (b.name === 'mcp__worca__propose_metrics_change' && typeof onMetricsProposal === 'function') {
486
+ // The parent re-validates from the tool INPUT (metrics-proposal.mjs is pure over the real readers);
487
+ // the raw result text only says whether the child accepted it.
488
+ try {
489
+ const ret = onMetricsProposal({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
490
+ if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
491
+ } catch { reducerErrors += 1; }
492
+ }
493
+ if (b.name === 'mcp__worca__track_run' && typeof onTrackRun === 'function') {
494
+ // The parent owns the runs Map, the link rows and the followers: it re-resolves the id itself (D4).
495
+ try {
496
+ const ret = onTrackRun({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
497
+ if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
498
+ } catch { reducerErrors += 1; }
499
+ }
438
500
  pokeCommentWrite(b.name, text, c.is_error);
439
501
  pokeWorktreeMutation(b.name, fullInputs.get(b.id), c.is_error);
502
+ pokeMemoryWrite(b.name, text, c.is_error);
440
503
  }
441
504
  }
442
505
 
@@ -35,7 +35,16 @@ export const ASK_LIMITS = Object.freeze({
35
35
  worktreesGlobal: 15, // P4 D9
36
36
  attachmentReadDefaultBytes: 32_000,
37
37
  attachmentReadMaxBytes: 200_000,
38
+ artifactsListMaxLimit: 200,
39
+ artifactReadDefaultBytes: 60_000,
40
+ artifactReadMaxBytes: 200_000,
38
41
  briefMaxChars: 8000,
42
+ metricsRunsDefaultLimit: 20, // list_team_metrics_runs page (= listRunsDefaultLimit)
43
+ metricsRunsMaxLimit: 100, // list_team_metrics_runs page cap (= listRunsMaxLimit)
44
+ metricsBreakdownMaxRows: 20, // get_team_metrics rows per breakdown dimension
45
+ workflowTaskMaxChars: 32_000, // propose_workflow task text (= classify.mjs TASK_TEXT_CAP)
46
+ workflowNoteMaxChars: 200, // propose_workflow note shown on the card
47
+ proposalNoteMaxChars: 200, // propose_run note ("why this shape") shown on the run card
39
48
  commentBodyMaxChars: 4000, // diff_comments.body cap (pinned equal to COMMENT_BODY_MAX)
40
49
  titleMaxChars: 120,
41
50
  headerRuns: 5,
@@ -24,7 +24,10 @@ import { pathToFileURL } from 'node:url';
24
24
  import { createAskTools, AskToolError } from './tools.mjs';
25
25
  import { defaultToolDeps } from './tool-deps.mjs';
26
26
  import { defaultWorktreeDeps } from './worktree-deps.mjs';
27
+ import { defaultMemoryDeps } from './memory-deps.mjs';
27
28
  import { defaultCommentDeps } from './comment-deps.mjs';
29
+ import { defaultWorkflowDeps } from './workflow-deps.mjs';
30
+ import { defaultMetricsDeps } from './metrics-deps.mjs';
28
31
 
29
32
  const SUPPORTED_PROTOCOLS = Object.freeze(['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']);
30
33
  const DEFAULT_PROTOCOL = '2025-06-18';
@@ -112,17 +115,24 @@ export async function main({ argv = process.argv.slice(2), env = process.env, st
112
115
  const { home, thread } = parseArgv(argv);
113
116
  if (home) env.WORCA_HOME = home; // argv wins; worcaHome() reads the env at call time
114
117
  const threadId = thread || env.WORCA_ASK_THREAD_ID || null;
118
+ // P3 (v7): stdin closing == the chat turn ended or was stopped — abort whatever propose_workflow is still classifying
119
+ // (its result could never be delivered), so the drain below returns promptly instead of after the classifier's timeout.
120
+ const life = new AbortController();
115
121
  const server = createRpcServer({
116
122
  tools: createAskTools({
117
123
  ...defaultToolDeps({ threadId }),
118
124
  ...defaultWorktreeDeps({ threadId }),
125
+ ...defaultMemoryDeps({ threadId }),
119
126
  ...defaultCommentDeps(),
127
+ ...defaultWorkflowDeps({ threadId, signal: life.signal }),
128
+ ...defaultMetricsDeps({ threadId }),
120
129
  }),
121
130
  write: (s) => stdout.write(s),
122
131
  });
123
132
  const rl = createInterface({ input: stdin });
124
133
  rl.on('line', (line) => { server.feed(line); });
125
134
  await new Promise((resolve) => rl.on('close', resolve));
135
+ life.abort();
126
136
  await server.idle();
127
137
  await new Promise((resolve) => stdout.write('', resolve)); // macOS pipes are async: drain before exit
128
138
  }
@@ -0,0 +1,107 @@
1
+ // src/core/ask/memory-deps.mjs
2
+ // The dep bundle of the memory tools (agent-memory-design.md §9.1; P2 amendment B3) — reads AND
3
+ // writes under ONE namespaced sub-object, exactly like worktree-deps.mjs / comment-deps.mjs.
4
+ // Deliberately separate from tool-deps.mjs, whose source is pinned read-only. Everything goes
5
+ // through src/core/memory-store.mjs (validation, frontmatter repair, caps, snapshots, counters),
6
+ // so the MCP path and the REST path share one writer. Also the chat's --add-dir memory mount.
7
+ import { join } from 'node:path';
8
+ import {
9
+ memoryRoot, listMemory, readMemory, writeMemory, removeMemory, renderMemoryFile, MEMORY_NAME_HELP,
10
+ } from '../memory-store.mjs';
11
+ import { mountDirs, withStoreLock, refreshMount } from '../memory-sync.mjs';
12
+ import { memoryCaps } from '../settings.mjs';
13
+ import { listProjects, worcaHome } from '../projects.mjs';
14
+ import { PROJECT_KEY_RE } from '../store.mjs';
15
+ import { getThread } from './store.mjs';
16
+
17
+ /** {key, name, path} for a registered project key, or null. */
18
+ async function projectByKey(key) {
19
+ if (typeof key !== 'string' || !key) return null;
20
+ const p = (await listProjects()).find((x) => x && x.key === key);
21
+ return p ? { key: p.key, name: p.name || '', path: p.path } : null;
22
+ }
23
+
24
+ export function defaultMemoryDeps({ threadId }) {
25
+ const source = `ask:${threadId || 'unknown'}`;
26
+ return {
27
+ memory: {
28
+ projectByKey,
29
+ /** The page-following (unpinned) project of this thread, or null — tool-deps' pinnedScope
30
+ * covers the pinned case. B30: the context the panel sends carries `projectDir` on the New,
31
+ * Running and Projects pages and `projectKey` only on History detail, so this mirrors
32
+ * resolveAskContext (ui/server.mjs) and resolves either through the registry. */
33
+ contextProjectKey: async () => {
34
+ if (!threadId) return null;
35
+ let c = null;
36
+ try { c = getThread(threadId)?.context ?? null; } catch { return null; }
37
+ if (!c || c.pinned === true) return null;
38
+ if (typeof c.projectKey === 'string' && c.projectKey) return c.projectKey;
39
+ if (typeof c.projectDir === 'string' && c.projectDir) {
40
+ try {
41
+ const p = (await listProjects()).find((x) => x && x.path === c.projectDir); // the match resolveAskContext uses
42
+ return p ? p.key : null;
43
+ } catch { return null; }
44
+ }
45
+ return null;
46
+ },
47
+ list: (scope) => listMemory(memoryRoot(), scope),
48
+ read: (scope, name) => readMemory(memoryRoot(), scope, name),
49
+ /** Compose + write one file. Omitted description/paths keep the existing file's values (an
50
+ * omission never erases); `append` joins the bodies with one blank line (replace on a new
51
+ * file). Read-compose-write under the store lock (I2-#13) so two turns of THIS process
52
+ * cannot lose an append; a concurrent MCP child or a run's sync-back is not covered — that
53
+ * race is a Non-goal, and the pre-write snapshot keeps the losing version. */
54
+ remember: (scope, { name, body, description, paths, mode = 'replace' }) => withStoreLock(memoryRoot(), async () => {
55
+ const existing = await readMemory(memoryRoot(), scope, name);
56
+ const meta = {
57
+ name,
58
+ description: description == null ? (existing?.meta.description || '') : String(description),
59
+ paths: paths == null ? (existing?.meta.paths || []) : paths,
60
+ extra: existing?.meta.extra || {},
61
+ };
62
+ const nextBody = mode === 'append' && existing ? `${existing.body.trimEnd()}\n\n${String(body).trim()}\n` : String(body);
63
+ const r = await writeMemory(memoryRoot(), scope, name, renderMemoryFile(meta, nextBody), { source, caps: memoryCaps() });
64
+ return { created: r.created, bytes: r.bytes };
65
+ }),
66
+ forget: (scope, name) => removeMemory(memoryRoot(), scope, name, { source }),
67
+ },
68
+ };
69
+ }
70
+
71
+ /** `<home>/ask/memory/<projectKey|global>` — the --add-dir base of one scope set. Never the cwd (one
72
+ * Claude Code project slug for every thread) and never under tmp/ (Read-denied there). `global` is a
73
+ * safe sentinel: a registry key is `<slug>-<8 hex>` (PROJECT_KEY_RE), so it can never be that word. */
74
+ export function askMemoryMountBase(projectKey) { return join(worcaHome(), 'ask', 'memory', projectKey || 'global'); }
75
+
76
+ /**
77
+ * Refresh the chat's rules mount for one scope set (native-rules revision, D16): global + the
78
+ * given project, written under `<base>/.claude/rules/worca/{global,project}/` — the layout the
79
+ * CLI loads through `--add-dir <base>` + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 (probes J/J2).
80
+ * The writing is memory-sync's refreshMount (NON-destructive: files written atomically by name,
81
+ * stale ones unlinked, dirs never removed — a turn already spawning on the same mount must not
82
+ * read an empty dir; a file that cannot be written keeps its previous copy and is reported).
83
+ * Serialised under the store lock so it never interleaves with an in-process sync or remember
84
+ * (the MCP child writes from another process; whole-file atomic writes make that harmless).
85
+ * `async` on purpose: the projectKey guard REJECTS rather than throwing at the call site.
86
+ * @returns {Promise<string|null>} the base to pass as --add-dir, or null when the scope set holds
87
+ * no file (B33: nothing to load ⇒ the spawn stays byte-identical); the mount is emptied then.
88
+ */
89
+ export async function refreshAskMemoryMount({ projectKey = null, projectName = null } = {}) {
90
+ // A path segment: refuse anything but a registry-shaped key before a single mkdir (defence in
91
+ // depth — today's only caller passes a resolved key; the shape store.mjs#projectKey produces and
92
+ // the server's memory routes test). The rejection lands in the turn's catch.
93
+ if (projectKey != null && !PROJECT_KEY_RE.test(projectKey)) throw new Error(`refreshAskMemoryMount: invalid projectKey ${JSON.stringify(projectKey)}`);
94
+ const base = askMemoryMountBase(projectKey);
95
+ const dirs = mountDirs({ members: projectKey ? [{ projectKey, projectName }] : [], isWorkspace: false });
96
+ return withStoreLock(memoryRoot(), async () => {
97
+ // Two different failures reach ONE callback: a junk name / unreadable file in the STORE (the
98
+ // listing, `listMemoryDir` → ENAME) and a mount target that could not be written (the rename).
99
+ // Only the second has a "previous copy"; reporting a store name that way would promise a copy
100
+ // that never existed and name a path the user cannot fix by retrying.
101
+ const onError = (p, err) => console.warn(err?.code === 'ENAME'
102
+ ? `[worca-ask] memory: ignoring ${JSON.stringify(p)} — ${MEMORY_NAME_HELP}`
103
+ : `[worca-ask] memory mount: ${p} could not be refreshed (${err?.message || err}); the previous copy (if any) is served this turn`);
104
+ const { files } = await refreshMount({ root: memoryRoot(), mount: join(base, '.claude', 'rules', 'worca'), dirs, onError });
105
+ return files ? base : null;
106
+ });
107
+ }