@worca/app 1.0.0 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -7,18 +7,34 @@
7
7
 
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { readFileSync } from 'node:fs';
10
- import { isAbsolute, join, resolve } from 'node:path';
10
+ import { join } from 'node:path';
11
11
  import { callSource } from './plugin-shim.mjs';
12
12
  import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
13
+ import { listProfiles, DEFAULT_PROFILE } from './plugin-config.mjs';
13
14
  import { normalizeManifest } from './plugin-manifest.mjs';
14
15
  import { getDb } from './db.mjs';
15
- import { runDirForRow, readStoreMeta } from './artifacts.mjs';
16
+ import { runDirForRow, readStoreMeta, readPromptFile } from './artifacts.mjs';
16
17
  import { RESULTS_FILE } from './results.mjs';
17
18
  import { hasGh, findPrForBranch } from './git-info.mjs';
18
19
 
19
- /** Same resolve-against-projectDir semantics as artifacts.mjs#resolveAgainst. */
20
- function resolveAgainst(base, p) {
21
- return isAbsolute(p) ? p : resolve(base, p);
20
+ /** Profile roster for a plugin; never throws — the pane must render even when a
21
+ * plugin's data dir is unreadable (it degrades to "no profiles yet"). */
22
+ function safeProfiles(name) {
23
+ try { return listProfiles(name); } catch { return []; }
24
+ }
25
+
26
+ /** Whether an installed source declares multiProfile; never throws (an
27
+ * uninstalled plugin or broken manifest answers false — the callers below
28
+ * degrade to pre-profiles behavior, which is the only thing they could do). */
29
+ function isMultiProfileSource(plugin, sourceId) {
30
+ try {
31
+ const dir = pluginCurrentDir(plugin);
32
+ const norm = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
33
+ if (!norm.ok) return false;
34
+ return (norm.manifest.taskSources || []).some((s) => s.id === sourceId && s.multiProfile === true);
35
+ } catch {
36
+ return false;
37
+ }
22
38
  }
23
39
 
24
40
  /**
@@ -50,6 +66,12 @@ export function listTaskSources() {
50
66
  sourceId: ts.id,
51
67
  displayName: ts.displayName || `${name}/${ts.id}`,
52
68
  inputs: ts.inputs || [],
69
+ // A multi-profile source cannot be used until the pane knows WHICH
70
+ // configuration to run against, so the roster ships with the listing —
71
+ // the New Pipeline pane resolves the project's binding against it
72
+ // without a second round trip.
73
+ multiProfile: ts.multiProfile === true,
74
+ profiles: ts.multiProfile === true ? safeProfiles(name) : [],
53
75
  });
54
76
  }
55
77
  }
@@ -68,9 +90,9 @@ function taskPromptText(task) {
68
90
  /**
69
91
  * Resolve a source descriptor to the pipeline's task input.
70
92
  * { type:'prompt', prompt } | { type:'markdown', promptText?, promptFile? }
71
- * | { type:'plugin', plugin, sourceId, taskId, inputs? }
93
+ * | { type:'plugin', plugin, sourceId, taskId, inputs?, profile? }
72
94
  * @returns {Promise<{promptText:string, promptFile:string|null,
73
- * sourceMeta:{plugin,sourceId,taskId,url,title}|null}>}
95
+ * sourceMeta:{plugin,sourceId,taskId,profile,url,title}|null}>}
74
96
  */
75
97
  export async function resolveTaskInput(source, { projectDir } = {}) {
76
98
  const src = source && typeof source === 'object' ? source : { type: 'prompt', prompt: '' };
@@ -82,19 +104,21 @@ export async function resolveTaskInput(source, { projectDir } = {}) {
82
104
 
83
105
  if (type === 'markdown') {
84
106
  if (src.promptFile) {
85
- let promptText = '';
86
- try {
87
- promptText = await readFile(resolveAgainst(projectDir, src.promptFile), 'utf8');
88
- } catch {
89
- promptText = ''; // exactly the legacy createPipeline catch{} degradation
90
- }
107
+ // A NAMED file that cannot be read is an ERROR, never an empty prompt: the
108
+ // old catch{} degradation here (and the identical one in createPipeline) ran
109
+ // a whole pipeline on "" and exited 0. Throws PROMPT_FILE_UNREADABLE — the
110
+ // CLI fail()s on it and ui/server.mjs answers 400.
111
+ const promptText = await readPromptFile(projectDir, src.promptFile);
91
112
  return { promptText, promptFile: src.promptFile, sourceMeta: null };
92
113
  }
93
114
  return { promptText: typeof src.promptText === 'string' ? src.promptText : '', promptFile: null, sourceMeta: null };
94
115
  }
95
116
 
96
117
  if (type === 'plugin') {
97
- const task = await callSource({ plugin: src.plugin, sourceId: src.sourceId, op: 'getTask', args: { id: src.taskId } });
118
+ const task = await callSource({
119
+ plugin: src.plugin, sourceId: src.sourceId, op: 'getTask',
120
+ args: { id: src.taskId }, profile: src.profile,
121
+ });
98
122
  if (!task) {
99
123
  throw new Error(`task-source ${src.plugin}/${src.sourceId}: task "${src.taskId}" not found`);
100
124
  }
@@ -105,6 +129,14 @@ export async function resolveTaskInput(source, { projectDir } = {}) {
105
129
  plugin: src.plugin,
106
130
  sourceId: src.sourceId,
107
131
  taskId: src.taskId,
132
+ // Pinned on the ROW, not re-derived at write-back time: the project's
133
+ // binding may have moved to another tracker by then, and a result must
134
+ // always be reported to the instance the task actually came from.
135
+ profile: src.profile || null,
136
+ // The source-panel inputs at fetch time, pinned for the same reason:
137
+ // a per-RUN choice (e.g. jira-source's "Write result back") must gate
138
+ // this run's report with what the user picked when they started it.
139
+ inputs: src.inputs && typeof src.inputs === 'object' ? src.inputs : null,
108
140
  url: task.url ?? null,
109
141
  title: task.title ?? null,
110
142
  },
@@ -164,31 +196,61 @@ export async function reportResultForPipeline(pipelineRow, resultsBundle) {
164
196
  try { ref = pipelineRow.source_ref ? JSON.parse(pipelineRow.source_ref) : null; } catch { ref = null; }
165
197
  if (!ref?.plugin || !ref?.sourceId || !ref?.taskId) return { ok: true, skipped: true };
166
198
 
167
- // Capability probe with a TOLERANT DEFAULT. capabilities() is optional in the
168
- // connector contract (§7.1: "defaults: writeBack true"): a connector without
169
- // the op makes the child answer { ok:false, error:{ kind:'plugin', message:
170
- // 'connector does not implement op "capabilities"' } } -> callSource throws
171
- // PluginOpError('plugin') -> we default to writeBack:true. Transport errors
172
- // (auth/network/timeout/protocol) ALSO default to true the reportResult
173
- // call below is the one that surfaces the real failure to the caller.
199
+ // Which profile to report against. Normally the one pinned on the row when
200
+ // the task was fetched (resolveTaskInput). A row that PREDATES profiles has
201
+ // none and if the plugin has since upgraded to multiProfile, refusing to
202
+ // report would strand the row forever (no code path can attach a profile to
203
+ // an existing source_ref, so the results-view retry would fail identically
204
+ // every time). Its task came from the instance whose flat config migrated
205
+ // into the DEFAULT_PROFILE bucket, so that is exactly where the report
206
+ // belongs — allowLegacyDefault is the shim's host-internal opt-in for it.
207
+ const profile = ref.profile || null;
208
+ const allowLegacyDefault = !profile && isMultiProfileSource(ref.plugin, ref.sourceId);
209
+ const callRef = {
210
+ plugin: ref.plugin,
211
+ sourceId: ref.sourceId,
212
+ profile: allowLegacyDefault ? DEFAULT_PROFILE : profile,
213
+ allowLegacyDefault,
214
+ };
215
+
216
+ // Capability probe. capabilities() is optional in the connector contract
217
+ // (§7.1: "defaults: writeBack true"): a connector without the op makes the
218
+ // child answer kind 'unimplemented' -> we default to writeBack:true.
219
+ // The run's pinned source-panel inputs travel to BOTH ops so a connector
220
+ // can make write-back a per-run choice: the opt-out rides the reserved
221
+ // input key `writeBack` (jira-source's "Write result back" input;
222
+ // capabilities({inputs}) answers for THIS run).
223
+ // Every OTHER probe error — an implemented capabilities() that crashed, a
224
+ // transport failure (auth/network/timeout/protocol/rate-limit) — means the
225
+ // connector could not answer, and what happens next depends on whether an
226
+ // answer could have mattered: a run that pinned no `writeBack` input fails
227
+ // OPEN (default writeBack:true — a transient rate-limit on the probe must
228
+ // not silently drop the ticket comment when the report itself would have
229
+ // succeeded), while a run WITH one fails CLOSED (the opt-out may be 'no'
230
+ // and we could not hear it; writing would violate it) — the caller
231
+ // surfaces the error and the results-view retry re-probes.
232
+ const inputs = ref.inputs && typeof ref.inputs === 'object' ? ref.inputs : {};
174
233
  let writeBack = true;
175
234
  try {
176
- const caps = await callSource({ plugin: ref.plugin, sourceId: ref.sourceId, op: 'capabilities', args: {} });
235
+ const caps = await callSource({ ...callRef, op: 'capabilities', args: { inputs } });
177
236
  if (caps && caps.writeBack === false) writeBack = false;
178
- } catch {
237
+ } catch (err) {
238
+ if ((err?.kind || 'plugin') !== 'unimplemented' && 'writeBack' in inputs) {
239
+ return { ok: false, error: `write-back capability probe failed: ${err?.message || String(err)}` };
240
+ }
179
241
  writeBack = true;
180
242
  }
181
243
  if (!writeBack) return { ok: true, skipped: true };
182
244
 
183
245
  await callSource({
184
- plugin: ref.plugin,
185
- sourceId: ref.sourceId,
246
+ ...callRef,
186
247
  op: 'reportResult',
187
248
  args: {
188
249
  id: ref.taskId,
189
250
  status: statusToResult(pipelineRow.status),
190
251
  summary: buildResultSummary(pipelineRow, resultsBundle),
191
252
  links: buildResultLinks(resultsBundle),
253
+ inputs,
192
254
  },
193
255
  });
194
256
  return { ok: true };
@@ -3,9 +3,9 @@
3
3
  // archive exists so these numbers survive history cleanup (spec §6.9). Pure
4
4
  // synchronous DB reads; no gh, no git, offline-correct.
5
5
  //
6
- // Attribution: money = cost_ledger by event timestamp (exact); runs, time,
7
- // and PRs = cohort by started_at (a run belongs to the bucket its start falls
8
- // in, with its CURRENT status). 'today' differs: its totals count runs whose
6
+ // Attribution: money = cost_ledger + ask_cost_ledger by event timestamp
7
+ // (exact); runs, time, and PRs = cohort by started_at (a run belongs to the
8
+ // bucket its start falls in, with its CURRENT status). 'today' differs: its totals count runs whose
9
9
  // lifespan [started_at, updated_at] overlaps the day ("active today"), and its
10
10
  // hourly bars attribute terminal outcomes to the hour of the terminal write
11
11
  // (updated_at). 'all' money/time use the fallback-aware pipelines sums so
@@ -14,6 +14,7 @@
14
14
  import { prepare } from './db.mjs';
15
15
  import {
16
16
  budgetStatus, costWindowStart, costWindowEnd, allTimeTotals, roundUsd,
17
+ askWindowedSpendUsd,
17
18
  } from './cost-budget.mjs';
18
19
 
19
20
  const RANGES = ['today', 'week', 'month', 'all'];
@@ -84,6 +85,18 @@ function ledgerSpend(fromMs, toMs) {
84
85
  return roundUsd(row?.s || 0);
85
86
  }
86
87
 
88
+ /** Ask Worca window aggregate (ask-cost-statistics-design.md D5/D6): spend,
89
+ * DISTINCT sessions, turns. A thread is a session in every window it has a
90
+ * costed turn in. */
91
+ function askTotals(fromMs, toMs) {
92
+ const row = prepare(`
93
+ SELECT COALESCE(SUM(amount_usd), 0) AS s,
94
+ COUNT(DISTINCT thread_id) AS sessions,
95
+ COUNT(*) AS turns
96
+ FROM ask_cost_ledger WHERE ts >= ? AND ts < ?`).get(fromMs, toMs);
97
+ return { spendUsd: roundUsd(row?.s || 0), sessions: row?.sessions || 0, turns: row?.turns || 0 };
98
+ }
99
+
87
100
  /** Build zero-filled buckets [{startMs, endMs}] from windowStart through `now`. */
88
101
  function buildBuckets(windowStart, now, bucket) {
89
102
  const out = [];
@@ -137,8 +150,11 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
137
150
  const windowTotalsFn = range === 'today' ? activeTotals : cohortTotals;
138
151
  const shapeTotals = (fromMs, toMs) => {
139
152
  const c = windowTotalsFn(fromMs, toMs);
153
+ const pipelineSpendUsd = ledgerSpend(fromMs, toMs);
154
+ const ask = askTotals(fromMs, toMs);
140
155
  return {
141
- spentUsd: ledgerSpend(fromMs, toMs),
156
+ spentUsd: roundUsd(pipelineSpendUsd + ask.spendUsd),
157
+ pipelineSpendUsd, ask,
142
158
  workedMs: c.workedMs,
143
159
  runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
144
160
  paused: c.paused, running: c.running,
@@ -150,8 +166,11 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
150
166
  if (range === 'all') {
151
167
  const at = allTimeTotals();
152
168
  const c = cohortTotals(0, windowEnd.getTime());
169
+ const ask = askTotals(0, windowEnd.getTime());
153
170
  totals = {
154
- spentUsd: at.spendUsd, workedMs: at.activeMs,
171
+ spentUsd: roundUsd(at.spendUsd + ask.spendUsd),
172
+ pipelineSpendUsd: at.spendUsd, ask,
173
+ workedMs: at.activeMs,
155
174
  runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
156
175
  paused: c.paused, running: c.running,
157
176
  prsOpened: c.prsOpened, prsMerged: c.prsMerged,
@@ -169,7 +188,7 @@ export function getStats({ range = 'month', now = new Date() } = {}) {
169
188
  const c = bucketTotalsFn(startMs, endMs);
170
189
  return {
171
190
  bucketStartMs: startMs,
172
- spentUsd: ledgerSpend(startMs, endMs),
191
+ spentUsd: roundUsd(ledgerSpend(startMs, endMs) + askWindowedSpendUsd(startMs, endMs)),
173
192
  finished: c.finished, stopped: c.stopped, failed: c.failed,
174
193
  };
175
194
  });
@@ -11,6 +11,8 @@ const SYSTEM = [
11
11
  'You write a SHORT, human-readable title for a software task.',
12
12
  'Rules: 3–8 words, Title Case-ish, no trailing period, no quotes, no markdown,',
13
13
  'no preamble. Output ONLY the title on a single line.',
14
+ 'Never ask questions, never ask for more context, never apologize or explain.',
15
+ 'If the task is vague or conversational, still output your best-guess topic title.',
14
16
  ].join(' ');
15
17
 
16
18
  /** Normalize raw model output into a safe single-line title (pure, exported for tests). */
@@ -21,14 +23,45 @@ export function sanitizeTitle(raw) {
21
23
  t = t.replace(/^(?:title|task)\s*[:\-]\s*/i, ''); // strip "Title:"/"Task:" label
22
24
  t = t.replace(/^["'“”`]+|["'“”`]+$/g, '').trim(); // strip wrapping quotes/backticks
23
25
  t = t.replace(/\s+/g, ' ').replace(/\.+$/, '').trim(); // collapse ws, drop trailing dots
24
- return t.slice(0, MAX_LEN).trim();
26
+ if (t.length > MAX_LEN) {
27
+ // Cut at a word boundary instead of mid-word ("…you'd li"). The window is
28
+ // MAX_LEN+1 so a word ending exactly at the cap survives; a boundary too
29
+ // close to the start (or none at all — one unbroken run) hard-slices.
30
+ const cut = t.slice(0, MAX_LEN + 1);
31
+ const sp = cut.lastIndexOf(' ');
32
+ t = (sp >= 30 ? cut.slice(0, sp) : t.slice(0, MAX_LEN)).replace(/[\s,;:–—-]+$/, '');
33
+ }
34
+ return t.trim();
35
+ }
36
+
37
+ // A title that is really a clarifying question / refusal ("I need more context
38
+ // to write a title…"). First-person or plea openers, a trailing question mark,
39
+ // or prose far beyond the 3–8-word instruction all mean the model did not
40
+ // title the task — the caller should keep its provisional title instead.
41
+ // Narrow on purpose (review of PR #376): bare `what`/`which`/`unable`/`please`
42
+ // openers dropped legitimate titles ("What's New Page Redesign", "Which Tab Is
43
+ // Active Indicator", "Unable To Login Error Fix", "Please Wait Spinner Timing").
44
+ // A question is caught by its trailing `?` above; `unable`/`please` count only
45
+ // with the verbs a refusal actually uses.
46
+ const REFUSAL_OPENER_RE =
47
+ /^(?:i(?:['’](?:m|d|ll|ve))?\s|sorry\b|apolog|unfortunately\b|unable to (?:determine|identify|generate|create|provide|write|title|summari[sz]e|infer|tell|help|assist)\b|please (?:provide|share|clarify|give|specify|tell|include|describe|let me know)\b|could you\b|can you\b|tell me\b|what(?:['’]s)? (?:is|are|was|were|do|does|did|would|should|task|work)\b|which (?:task|one|of)\b|help me\b)/i;
48
+ const MAX_WORDS = 12;
49
+
50
+ /** True when sanitized model output looks like a refusal/question, not a title (pure, exported for tests). */
51
+ export function isRefusalTitle(t) {
52
+ if (!t || typeof t !== 'string') return false;
53
+ if (/\?$/.test(t)) return true;
54
+ if (REFUSAL_OPENER_RE.test(t)) return true;
55
+ return t.split(' ').length > MAX_WORDS;
25
56
  }
26
57
 
27
58
  /**
28
59
  * Produce a concise LLM title for a prompt. Never throws — returns '' on any
29
60
  * failure/abort/empty input so the caller keeps the provisional title.
61
+ * `permissionMode` (default 'acceptEdits') exists for Ask Worca: its title call
62
+ * passes 'dontAsk' so the mock dispatcher can never reach a file-writing role.
30
63
  * @param {string} prompt
31
- * @param {{cwd:string, signal?:AbortSignal, model?:string, bin?:string, envScrub?:boolean, envAllowlist?:string[]}} opts
64
+ * @param {{cwd:string, signal?:AbortSignal, model?:string, bin?:string, mock?:boolean, envScrub?:boolean, envAllowlist?:string[], tools?:string[], strictMcpConfig?:boolean, settingSources?:string[], disableSlashCommands?:boolean, mcpConfigPath?:string, permissionMode?:string}} opts
32
65
  * @returns {Promise<string>}
33
66
  */
34
67
  export async function generateTitle(prompt, opts = {}) {
@@ -45,18 +78,32 @@ export async function generateTitle(prompt, opts = {}) {
45
78
  // env everywhere the id is used.
46
79
  modelEnv: resolveModelEnv(opts.model || DEFAULT_TITLE_MODEL),
47
80
  effort: 'low',
48
- permissionMode: 'acceptEdits',
81
+ permissionMode: opts.permissionMode || 'acceptEdits',
49
82
  allowedTools: [], // empty → no --allowedTools flag → claude defaults; pure text gen
50
83
  signal: opts.signal,
51
84
  // Title generation runs DURING a pipeline run, so it must honor the same
52
85
  // guardrails as the run itself. All three are undefined when absent, and
53
86
  // runClaude treats undefined as "not passed" — existing callers are unchanged.
54
87
  bin: opts.bin,
88
+ // A run configured with claude:{mock:true} (tests, `--mock`) must title
89
+ // through runMock too: runClaude decides mock-vs-real from THIS field or
90
+ // WORCA_MOCK, and the 29 test files that only pass the option spawned the
91
+ // developer's real binary 157x per `npm test` (2026-08-30).
92
+ mock: opts.mock,
55
93
  envScrub: opts.envScrub,
56
94
  envAllowlist: opts.envAllowlist,
95
+ // Ask Worca (ask-worca-design.md §6.8): sandbox hardening pass-through for the
96
+ // chat's background title call. All undefined for every existing caller, and
97
+ // runClaude emits nothing for undefined — pipeline title argv is unchanged.
98
+ tools: opts.tools,
99
+ strictMcpConfig: opts.strictMcpConfig,
100
+ settingSources: opts.settingSources,
101
+ disableSlashCommands: opts.disableSlashCommands,
102
+ mcpConfigPath: opts.mcpConfigPath,
57
103
  onEvent: () => {},
58
104
  });
59
- return sanitizeTitle(out);
105
+ const title = sanitizeTitle(out);
106
+ return isRefusalTitle(title) ? '' : title;
60
107
  } catch (err) {
61
108
  if (err && err.name === 'AbortError') return ''; // run was stopped — caller keeps provisional
62
109
  return '';