@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
@@ -0,0 +1,226 @@
1
+ // src/core/ask/git-allowlist.mjs
2
+ // The single gate between the Ask Worca `git` tool and a spawned git argv
3
+ // (ask-worca-worktrees-design.md §8). Pure, no imports: worktree-deps.mjs
4
+ // spawns ONLY what this returns. Allowlist-shaped throughout — unknown
5
+ // subcommands, creation/mutation forms and the known exec/config/write/
6
+ // transport vectors are rejected with a hint the model can act on.
7
+
8
+ // `cat-file` is DELIBERATELY absent: it is a pure raw-content read (`cat-file -p
9
+ // HEAD:.env` / `cat-file -p <blob-sha>`) with no diff structure for the §8
10
+ // protected-path filter to act on, and it completes the `ls-tree -> blob-sha ->
11
+ // cat-file -p <sha>` leak that no path inspection can catch (a sha carries no
12
+ // path). Files are read by checking out the ref and inspecting them with the
13
+ // other read subcommands — the git tool serves diffs/logs/history, not raw dumps.
14
+ const READ_SUBCOMMANDS = new Set([
15
+ 'diff', 'log', 'show', 'status', 'blame', 'rev-parse', 'merge-base',
16
+ 'grep', 'shortlog', 'describe', 'ls-files', 'ls-tree',
17
+ ]);
18
+ const LIST_ONLY = new Set(['branch', 'tag']);
19
+ const NAV = new Set(['checkout', 'switch']);
20
+
21
+ // ANY position, exact token or `=`-form prefix. Closes config injection
22
+ // (`-c`/`--config-env` → hooks/pager/textconv = arbitrary exec), repo
23
+ // redirection (`--git-dir`/`--work-tree`/`-C`), exec paths (`--exec-path`/
24
+ // `--ext-diff`/`--textconv`), file writes (`--output`/`-o`) and transport
25
+ // command overrides (`--upload-pack`/`--receive-pack`).
26
+ //
27
+ // The second row are worktree-scope ESCAPES demonstrated during planning (spec
28
+ // D3 — the model reads ONLY inside its checkout): `diff --no-index <a> <b>` and
29
+ // `blame --contents <file>` read ARBITRARY filesystem paths and print them,
30
+ // bypassing every §7 deny; `grep -f/--file <path>` reads a pattern file from
31
+ // anywhere; `--filters` runs a repo-configured smudge filter (arbitrary exec —
32
+ // cat-file is already removed from the read tier, but blocking the flag is
33
+ // defence-in-depth); `--color`/`--color=*` injects SGR escapes that make the
34
+ // `diff --git ` header undetectable, defeating the protected-path section filter
35
+ // (verified: `git diff --color=always` leaked a whole `.env`). `-f`/`--file` are
36
+ // already in BRANCH_TAG_MUTATING/NAV_BLOCKED, so promoting them here changes no
37
+ // existing behaviour. The short `-O` form of `--open-files-in-pager` (arbitrary
38
+ // exec) needs a PREFIX guard, not a Set entry — `key('-Ocurl…')` returns the
39
+ // whole token, so no exact match can catch it (handled in validateGitArgs below).
40
+ const BLOCKED_ANYWHERE = new Set([
41
+ '-c', '--config-env', '--exec-path', '--git-dir', '--work-tree', '-C',
42
+ '--ext-diff', '--textconv', '--output', '-o', '--upload-pack', '--receive-pack',
43
+ '--open-files-in-pager',
44
+ '--no-index', '--contents', '-f', '--file', '--filters', '--color',
45
+ ]);
46
+
47
+ // Output-SHAPE flags. Each one was verified (code review of PR #376) to defeat
48
+ // the protected-path filter in tools.mjs against a real repo with a committed
49
+ // `.env`: the SECTION filter engages only on a `diff --git ` header at column 0,
50
+ // and the LINE filter only sees a path when git prints it as its own token.
51
+ // --graph / --line-prefix prefix EVERY line → no header at column 0 → the
52
+ // fallback line filter kept `| +DB_PASSWORD=…` verbatim
53
+ // --src-prefix/--dst-prefix relabel `a/.env` as `x.env` → no pattern matches
54
+ // --no-prefix/--default-prefix change the header shape the parser is pinned to
55
+ // --relative[=<dir>] strips the directory a slash-anchored pattern needs
56
+ // --submodule[=diff] inlines a nested repo's patch under its own headers
57
+ // --color-words/--color-moved(-ws) colour switches `--color` does not spell
58
+ // Keyed by `key()` form (exact token or `=`-prefix), like the rest of the set;
59
+ // the value is the hint the model gets back.
60
+ const SHAPE_BLOCKED = {
61
+ '--graph': 'it prefixes every output line, which defeats the protected-path filter — use --oneline without --graph',
62
+ '--line-prefix': 'it prefixes every output line, which defeats the protected-path filter',
63
+ '--src-prefix': 'it relabels file paths, which defeats the protected-path filter',
64
+ '--dst-prefix': 'it relabels file paths, which defeats the protected-path filter',
65
+ '--no-prefix': 'it changes the diff header shape the protected-path filter reads',
66
+ '--default-prefix': 'it changes the diff header shape the protected-path filter reads',
67
+ '--relative': 'it strips directories from paths, which defeats slash-anchored protected-path patterns',
68
+ '--submodule': 'submodule patches are not filtered here — open the submodule as its own worktree',
69
+ '--color-words': 'colour output defeats the protected-path filter',
70
+ '--color-moved': 'colour output defeats the protected-path filter',
71
+ '--color-moved-ws': 'colour output defeats the protected-path filter',
72
+ };
73
+ // `--format`/`--pretty` on the PATH-LIST subcommands can glue the object name to
74
+ // the path (`%(objectname)%(path)`) so the line filter's token split never sees a
75
+ // protected basename. `log --format` is commit metadata and stays allowed.
76
+ const LIST_FORMAT_FLAGS = new Set(['--format', '--pretty']);
77
+ const LIST_FORMAT_SUBS = new Set(['ls-tree', 'ls-files']);
78
+ // Short options whose value may be ATTACHED on the patch/log subcommands
79
+ // (`-Sfoo`, `-Gconfig`, `-L1,5:file`): everything after them in a cluster is data,
80
+ // so the `-f`/`-o`/`-O` letter scan stops there. Only letters that take a value on
81
+ // EVERY allowed subcommand belong here — a letter that is boolean somewhere would
82
+ // let `-<letter>f` smuggle `--file` past the scan on that subcommand.
83
+ const PATCH_VALUE_SHORTS = new Set(['S', 'G', 'L']);
84
+
85
+ const BRANCH_TAG_MUTATING = new Set([
86
+ '-d', '-D', '--delete', '-m', '-M', '--move', '-c', '-C', '--copy', '-f', '--force',
87
+ '--edit-description', '--set-upstream-to', '-u', '--unset-upstream', '--create-reflog',
88
+ '-s', '--sign', '-F', '--file', '-e', '--edit', '-a', // tag -a creates; branch -a lists — resolved below
89
+ ]);
90
+ const LISTY_FLAGS = new Set(['--list', '-l', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at']);
91
+ // `-c`/`-C`/`--guess` create a branch on `switch`; without them a `git switch -c
92
+ // evil` is blocked only by git's own `--detach`+`-c` mutual-exclusion error — a
93
+ // version-dependent accident, not the validator. Blocking them keeps D4 ("no
94
+ // branch ever created") enforced by us, not by git's argument parser.
95
+ const NAV_BLOCKED = new Set(['-b', '-B', '-c', '-C', '--guess', '--orphan', '--track', '-t', '-f', '--force',
96
+ '--ours', '--theirs', '-p', '--patch', '--pathspec-from-file', '--merge', '-m']);
97
+ const FETCH_FLAGS = new Set(['--all', '--prune', '-p']);
98
+
99
+ // `grep` is the one read subcommand whose output the §8 protected-path filter reads
100
+ // LINE by LINE (a path list, not a patch), and it drops a line only when a delimited
101
+ // token on that line is a protected basename. These forms take the path OFF the
102
+ // line — `-h`/`--no-filename` and `--heading` print bare match lines, `-z`/`--null`
103
+ // glues the path to the content with a NUL — so a protected file's CONTENTS come
104
+ // back verbatim (verified against real git: `git grep -h <pat>` dumped a whole
105
+ // .env). Forcing `-H` downstream is not enough: the last flag wins, and `--heading`
106
+ // overrides `-H` outright, so they are refused here.
107
+ const GREP_PATH_SUPPRESSING = new Set(['-h', '--no-filename', '--heading', '-z', '--null']);
108
+ // Short options whose value may be ATTACHED (`-ehunter`, `-C3`, `-m5`): everything
109
+ // after them inside a cluster is data, so the cluster scan stops there.
110
+ const GREP_VALUE_SHORTS = new Set(['e', 'f', 'm', 'A', 'B', 'C', 'O']);
111
+
112
+ const key = (a) => (a.includes('=') ? a.slice(0, a.indexOf('=')) : a);
113
+
114
+ const pathSuppressingError = (tok, flag) =>
115
+ `git grep ${flag} (in ${JSON.stringify(tok)}) hides the filename from the output and is not allowed — the protected-path filter needs the path on every line`;
116
+
117
+ function validateGrep(args) {
118
+ for (const a of args.slice(1)) {
119
+ if (GREP_PATH_SUPPRESSING.has(key(a))) return { ok: false, error: pathSuppressingError(a, key(a)) };
120
+ if (a.startsWith('--') || !a.startsWith('-')) continue;
121
+ for (const ch of a.slice(1)) { // bundles: `git grep -nh` == `-n -h`
122
+ if (GREP_VALUE_SHORTS.has(ch)) break; // the rest of the token is this option's value
123
+ if (ch === 'h' || ch === 'z') return { ok: false, error: pathSuppressingError(a, `-${ch}`) };
124
+ }
125
+ }
126
+ return { ok: true, args, nav: false, fetch: false };
127
+ }
128
+
129
+ function validateListOnly(sub, args) {
130
+ const rest = args.slice(1);
131
+ for (const a of rest) {
132
+ const k = key(a);
133
+ if (k === '-a' && sub === 'branch') continue; // branch -a = list all; tag -a = create
134
+ if (BRANCH_TAG_MUTATING.has(k)) return { ok: false, error: `git ${sub} ${a} mutates branches/tags and is not allowed` };
135
+ }
136
+ const positionals = rest.filter((a) => !a.startsWith('-'));
137
+ const listy = rest.some((a) => LISTY_FLAGS.has(key(a)));
138
+ if (positionals.length && !listy) {
139
+ return { ok: false, error: `git ${sub} with a positional name creates a ${sub}; use --list <pattern> to filter` };
140
+ }
141
+ return { ok: true, args, nav: false, fetch: false };
142
+ }
143
+
144
+ function validateNav(sub, args) {
145
+ const rest = args.slice(1);
146
+ if (rest.includes('--')) return { ok: false, error: `git ${sub} -- <paths> (file restore) is not allowed` };
147
+ for (const a of rest) {
148
+ if (NAV_BLOCKED.has(key(a))) return { ok: false, error: `git ${sub} ${a} is not allowed (worktrees stay detached, files stay pristine)` };
149
+ }
150
+ const positionals = rest.filter((a) => !a.startsWith('-'));
151
+ if (positionals.length !== 1) return { ok: false, error: `git ${sub} needs exactly one ref` };
152
+ const out = rest.includes('--detach') ? args : [sub, '--detach', ...rest];
153
+ return { ok: true, args: out, nav: true, fetch: false };
154
+ }
155
+
156
+ function validateFetch(args) {
157
+ const rest = args.slice(1);
158
+ for (const a of rest.filter((x) => x.startsWith('-'))) {
159
+ if (!FETCH_FLAGS.has(a)) return { ok: false, error: `git fetch ${a} is not allowed` };
160
+ }
161
+ const positionals = rest.filter((a) => !a.startsWith('-'));
162
+ if (positionals.length > 1) return { ok: false, error: 'git fetch takes at most a remote name — refspecs are not allowed (they can rewrite local branches)' };
163
+ if (positionals.length === 1) {
164
+ const p = positionals[0];
165
+ // Remote NAME only. Reject URLs/refspecs (`:`), path traversal (`.`/`..` or a
166
+ // leading `.`), and a leading `-` (option-shaped). The runtime `git remote`
167
+ // membership check in the tool handler is the second gate, but shape-reject here.
168
+ if (!/^[A-Za-z0-9._-]+$/.test(p) || p.includes(':') || p === '.' || p === '..' || /^[.-]/.test(p)) {
169
+ return { ok: false, error: 'git fetch accepts a configured remote NAME only, never a URL, refspec or path' };
170
+ }
171
+ }
172
+ return { ok: true, args, nav: false, fetch: true };
173
+ }
174
+
175
+ /**
176
+ * @param {unknown} rawArgs the model-supplied argv (without the leading "git")
177
+ * @returns {{ok:true, args:string[], nav:boolean, fetch:boolean} | {ok:false, error:string}}
178
+ */
179
+ export function validateGitArgs(rawArgs) {
180
+ if (!Array.isArray(rawArgs) || !rawArgs.length || !rawArgs.every((a) => typeof a === 'string')) {
181
+ return { ok: false, error: 'args must be a non-empty array of strings' };
182
+ }
183
+ const args = rawArgs.map((a) => a.trim()).filter((a) => a.length);
184
+ if (!args.length) return { ok: false, error: 'args must be a non-empty array of strings' };
185
+ const sub = args[0];
186
+ // Which short letters swallow the rest of a cluster as their VALUE depends on the
187
+ // subcommand: grep's `-e<pat>`/`-C<n>`…, the patch/log family's `-S<str>`/`-G<re>`/
188
+ // `-L<range>`. Anywhere else no letter is trusted to take a value.
189
+ const valueShorts = sub === 'grep' ? GREP_VALUE_SHORTS : (READ_SUBCOMMANDS.has(sub) ? PATCH_VALUE_SHORTS : new Set());
190
+ for (const a of args) {
191
+ // Cluster guard for ATTACHED short-option values, which key() cannot normalise
192
+ // (it strips `=value`, never `-f<value>` — git's standard attached short form):
193
+ // `-O<cmd>` (open-files-in-pager = arbitrary exec, `diff -O<orderfile>` = read),
194
+ // `-f<path>` (grep's pattern FILE = arbitrary absolute-path read OUTSIDE the
195
+ // worktree, breaking the D3 confinement invariant) and `-o<path>` (--output =
196
+ // file write). All three also hide inside a bundle — `git grep -nf/etc/passwd`
197
+ // is `-n -f /etc/passwd`, verified against real git — so the scan walks the
198
+ // cluster letter by letter and stops only at a letter known to take a value
199
+ // (`-Sfoo` is the pickaxe string "foo", not `-S -f oo`). The bare `-f`/`-o`/`-O`
200
+ // tokens were already refused by BLOCKED_ANYWHERE, so this only widens to the
201
+ // forms that carry a value.
202
+ if (/^-[A-Za-z]/.test(a)) {
203
+ for (const ch of a.slice(1)) {
204
+ if (ch === 'O' || ch === 'f' || ch === 'o') return { ok: false, error: `argument ${JSON.stringify(a)} is not allowed` };
205
+ if (valueShorts.has(ch)) break; // the rest of the token is this option's value
206
+ if (!/[A-Za-z]/.test(ch)) break; // a digit/punctuation ends the flag cluster
207
+ }
208
+ }
209
+ const k = key(a);
210
+ if (BLOCKED_ANYWHERE.has(k)) return { ok: false, error: `argument ${JSON.stringify(a)} is not allowed` };
211
+ if (Object.prototype.hasOwnProperty.call(SHAPE_BLOCKED, k)) {
212
+ return { ok: false, error: `argument ${JSON.stringify(a)} is not allowed: ${SHAPE_BLOCKED[k]}` };
213
+ }
214
+ if (LIST_FORMAT_SUBS.has(sub) && LIST_FORMAT_FLAGS.has(k)) {
215
+ return { ok: false, error: `git ${sub} ${k} is not allowed — a custom format can hide the path from the protected-path filter; use the default listing` };
216
+ }
217
+ }
218
+ if (sub === 'pull') return { ok: false, error: 'pull is not available (detached worktrees have nothing to merge into) — fetch, then diff/checkout origin/<branch>' };
219
+ if (sub === 'push' || sub === 'remote') return { ok: false, error: `${sub} is not available: the chat cannot publish anything — propose a pipeline instead` };
220
+ if (sub === 'grep') return validateGrep(args); // read tier, but the path must stay on every line
221
+ if (READ_SUBCOMMANDS.has(sub)) return { ok: true, args, nav: false, fetch: false };
222
+ if (LIST_ONLY.has(sub)) return validateListOnly(sub, args);
223
+ if (NAV.has(sub)) return validateNav(sub, args);
224
+ if (sub === 'fetch') return validateFetch(args);
225
+ return { ok: false, error: `git ${sub} is not in the allowlist` };
226
+ }
@@ -0,0 +1,57 @@
1
+ // src/core/ask/limits.mjs
2
+ // Fixed limits of the Ask Worca chat (ask-worca-design.md §6.9) plus the two
3
+ // operator-configurable per-turn guards, read fresh on every turn (D12). Pure
4
+ // apart from the settings readers, which are injectable for tests.
5
+ import { askMaxTurns as readAskMaxTurns, askMaxBudgetUsd as readAskMaxBudgetUsd } from '../settings.mjs';
6
+ import { TEXT_EXTENSIONS, BINARY_EXTENSIONS } from './attachment-kind.mjs';
7
+
8
+ export const ASK_LIMITS = Object.freeze({
9
+ turnsPerThread: 1, // one running turn per thread (409)
10
+ turnsGlobal: 3, // running turns across all threads (429)
11
+ turnTimeoutMs: 30 * 60 * 1000, // wall clock per turn (the runner has none)
12
+ jobGraceMs: 30_000, // finished job kept for WS replay
13
+ emptyThreadSweepMs: 24 * 60 * 60 * 1000, // empty threads older than this are swept at boot
14
+ attachment: Object.freeze({
15
+ maxFiles: 8, // per message
16
+ maxBytesPerFile: 512 * 1024, // text kinds — they are inlined/paged into prompts
17
+ maxBytesPerBinaryFile: 5 * 1024 * 1024, // image/pdf kinds — read from disk, never inlined (#398)
18
+ maxBytesPerThread: 25 * 1024 * 1024, // enforced ACROSS kinds (was 4 MB text-only pre-#398)
19
+ extensions: TEXT_EXTENSIONS, // attachment-kind.mjs owns both tables
20
+ binaryExtensions: BINARY_EXTENSIONS,
21
+ }),
22
+ contextHeaderMaxChars: 1024, // [worca context] block
23
+ inlineAttachmentsMaxBytes: 24 * 1024, // inlined into the turn prompt
24
+ restoredMaxChars: 30_000, // DB-replay fallback prompt
25
+ blockIoMaxChars: 2048, // persisted tool input / error per block
26
+ agentLogMaxLines: 50,
27
+ listRunsDefaultLimit: 20,
28
+ listRunsMaxLimit: 100,
29
+ runsScanLimit: 200, // listAllPipelines({limit}) before JS filtering
30
+ diffDefaultBytes: 60_000,
31
+ diffMaxBytes: 200_000,
32
+ gitOutputMaxBytes: 200_000, // per `git` tool call (P4 §8), sliceBytes window
33
+ gitCaptureMaxBytes: 8_000_000, // stdout CAPTURE cap per spawn — past it the child is killed and the output marked capped
34
+ worktreesPerThread: 5, // P4 D9
35
+ worktreesGlobal: 15, // P4 D9
36
+ attachmentReadDefaultBytes: 32_000,
37
+ attachmentReadMaxBytes: 200_000,
38
+ briefMaxChars: 8000,
39
+ commentBodyMaxChars: 4000, // diff_comments.body cap (pinned equal to COMMENT_BODY_MAX)
40
+ titleMaxChars: 120,
41
+ headerRuns: 5,
42
+ headerCards: 5,
43
+ headerAttachments: 5,
44
+ deltaBatchMs: 50,
45
+ deltaBatchChars: 256,
46
+ defaultModel: 'claude-opus-5', // D8
47
+ defaultEffort: 'high',
48
+ });
49
+
50
+ /**
51
+ * The two configurable per-turn guards. Read fresh every call — a Settings change
52
+ * applies to the next turn without a restart.
53
+ * @returns {{maxTurns:number, maxBudgetUsd:number|null}}
54
+ */
55
+ export function askLimits({ readMaxTurns = readAskMaxTurns, readMaxBudgetUsd = readAskMaxBudgetUsd } = {}) {
56
+ return { maxTurns: readMaxTurns(), maxBudgetUsd: readMaxBudgetUsd() };
57
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // src/core/ask/mcp-stdio.mjs
3
+ // The worca MCP server of the Ask Worca sandbox (ask-worca-design.md §6.4, D11):
4
+ // a hand-rolled JSON-RPC 2.0 server over stdio — newline-delimited JSON, one
5
+ // message per line (the MCP stdio transport rule), stdout carrying ONLY protocol
6
+ // messages, diagnostics on stderr. claude spawns it once per process through the
7
+ // per-turn --mcp-config and closes its stdin on shutdown; the server then exits 0.
8
+ //
9
+ // Probed on claude 2.1.239: request ids start at 0 (so a notification is
10
+ // id === undefined || id === null); the client sends initialize (protocolVersion
11
+ // '2025-11-25') → notifications/initialized → tools/list → tools/call{name,
12
+ // arguments, _meta}; with only capabilities.tools advertised it never sends
13
+ // resources/prompts/roots/ping. Tool-execution failures are returned INSIDE the
14
+ // result as isError:true text so the model can self-correct; unknown tools and
15
+ // non-object arguments are -32602; unknown methods -32601; parse errors -32700.
16
+ //
17
+ // WORCA_HOME / WORCA_ASK_THREAD_ID come from the env (mcpServers.env) or from
18
+ // `--home <base> --thread <id>` (argv wins). The DB opens lazily on the first
19
+ // tool call through db.mjs (WAL, busy_timeout, open-retry — second-process
20
+ // access is designed for).
21
+ import { createInterface } from 'node:readline';
22
+ import { createRequire } from 'node:module';
23
+ import { pathToFileURL } from 'node:url';
24
+ import { createAskTools, AskToolError } from './tools.mjs';
25
+ import { defaultToolDeps } from './tool-deps.mjs';
26
+ import { defaultWorktreeDeps } from './worktree-deps.mjs';
27
+ import { defaultCommentDeps } from './comment-deps.mjs';
28
+
29
+ const SUPPORTED_PROTOCOLS = Object.freeze(['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']);
30
+ const DEFAULT_PROTOCOL = '2025-06-18';
31
+ const PKG_VERSION = createRequire(import.meta.url)('../../../package.json').version;
32
+
33
+ /** `--home <base> --thread <id>`; a flag without a value is ignored. */
34
+ export function parseArgv(argv) {
35
+ const out = { home: null, thread: null };
36
+ for (let i = 0; i < argv.length; i++) {
37
+ if (argv[i] === '--home' && argv[i + 1] !== undefined) out.home = argv[++i];
38
+ else if (argv[i] === '--thread' && argv[i + 1] !== undefined) out.thread = argv[++i];
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * @param {{tools:{list:Function, call:Function}, write:(s:string)=>void, log?:(s:string)=>void, serverVersion?:string}} opts
45
+ * @returns {{feed:(line:string)=>Promise<void>, idle:()=>Promise<void>}}
46
+ */
47
+ export function createRpcServer({ tools, write, log = (s) => process.stderr.write(`${s}\n`), serverVersion = PKG_VERSION }) {
48
+ const send = (msg) => write(`${JSON.stringify(msg)}\n`);
49
+ const result = (id, res) => send({ jsonrpc: '2.0', id, result: res });
50
+ const error = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
51
+ const toolNames = () => new Set(tools.list().map((t) => t.name));
52
+
53
+ async function handle(msg) {
54
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return error(null, -32600, 'Invalid Request');
55
+ const { id, method, params } = msg;
56
+ const isNotification = id === undefined || id === null;
57
+ if (typeof method !== 'string') return isNotification ? undefined : error(id, -32600, 'Invalid Request');
58
+ if (isNotification) return undefined; // notifications/* — never answered
59
+ switch (method) {
60
+ case 'initialize': {
61
+ const requested = params && typeof params.protocolVersion === 'string' ? params.protocolVersion : '';
62
+ return result(id, {
63
+ protocolVersion: SUPPORTED_PROTOCOLS.includes(requested) ? requested : DEFAULT_PROTOCOL,
64
+ capabilities: { tools: {} },
65
+ serverInfo: { name: 'worca', version: serverVersion },
66
+ });
67
+ }
68
+ case 'ping':
69
+ return result(id, {});
70
+ case 'tools/list':
71
+ return result(id, { tools: tools.list() });
72
+ case 'tools/call': {
73
+ const name = params && typeof params.name === 'string' ? params.name : '';
74
+ const args = params && params.arguments !== undefined ? params.arguments : {};
75
+ if (!name || !toolNames().has(name)) return error(id, -32602, `Invalid params: unknown tool ${JSON.stringify(name)}`);
76
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) return error(id, -32602, 'Invalid params: arguments must be an object');
77
+ try {
78
+ const out = await tools.call(name, args);
79
+ return result(id, { content: [{ type: 'text', text: JSON.stringify(out ?? null) }] }); // never `undefined` → invalid JSON text
80
+ } catch (err) {
81
+ const message = err && err.message ? err.message : String(err);
82
+ if (!(err instanceof AskToolError)) log(`[ask-mcp] ${name} failed: ${err && err.stack ? err.stack : message}`);
83
+ return result(id, { content: [{ type: 'text', text: `error: ${message}` }], isError: true });
84
+ }
85
+ }
86
+ default:
87
+ return error(id, -32601, `Method not found: ${method}`);
88
+ }
89
+ }
90
+
91
+ // One sequential chain: responses leave in request order, a slow tool never reorders them.
92
+ let chain = Promise.resolve();
93
+ return {
94
+ feed(line) {
95
+ const trimmed = String(line).trim();
96
+ if (!trimmed) return chain;
97
+ let msg;
98
+ try { msg = JSON.parse(trimmed); } catch {
99
+ chain = chain.then(() => error(null, -32700, 'Parse error')); // through the chain: order preserved
100
+ return chain;
101
+ }
102
+ for (const m of Array.isArray(msg) ? msg : [msg]) {
103
+ chain = chain.then(() => handle(m)).catch((e) => log(`[ask-mcp] handler crashed: ${e && e.stack ? e.stack : e}`));
104
+ }
105
+ return chain;
106
+ },
107
+ idle: () => chain,
108
+ };
109
+ }
110
+
111
+ export async function main({ argv = process.argv.slice(2), env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
112
+ const { home, thread } = parseArgv(argv);
113
+ if (home) env.WORCA_HOME = home; // argv wins; worcaHome() reads the env at call time
114
+ const threadId = thread || env.WORCA_ASK_THREAD_ID || null;
115
+ const server = createRpcServer({
116
+ tools: createAskTools({
117
+ ...defaultToolDeps({ threadId }),
118
+ ...defaultWorktreeDeps({ threadId }),
119
+ ...defaultCommentDeps(),
120
+ }),
121
+ write: (s) => stdout.write(s),
122
+ });
123
+ const rl = createInterface({ input: stdin });
124
+ rl.on('line', (line) => { server.feed(line); });
125
+ await new Promise((resolve) => rl.on('close', resolve));
126
+ await server.idle();
127
+ await new Promise((resolve) => stdout.write('', resolve)); // macOS pipes are async: drain before exit
128
+ }
129
+
130
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
131
+ main().then(
132
+ () => process.exit(0),
133
+ (err) => { process.stderr.write(`[ask-mcp] fatal: ${err && err.stack ? err.stack : err}\n`); process.exit(1); },
134
+ );
135
+ }
@@ -0,0 +1,125 @@
1
+ // src/core/ask/models.mjs
2
+ // The Ask Worca model picker catalog (D8, ask-worca-design.md §6.9): the COMPOSED
3
+ // catalog from config.mjs — built-ins ⊕ plugin models ⊕ the user's GLOBAL models,
4
+ // with composeCatalog's precedence (global > plugin > built-in) already applied and
5
+ // one entry per id. The only thing dropped here is `custom:'project'`.
6
+ import { listModels as realListModels, EFFORTS } from '../config.mjs';
7
+ import { listPluginModels as realPluginModels, pluginModelSecretStatus as realSecretStatus } from '../plugin-models.mjs';
8
+ import { ASK_LIMITS } from './limits.mjs';
9
+
10
+ /**
11
+ * @param {{
12
+ * listModels?: (projectDir:string)=>Promise<Array<object>>,
13
+ * pluginModels?: ()=>Array<{plugin:string,id:string,secrets?:string[]}>,
14
+ * secretStatus?: (plugin:string)=>Array<{key:string,set:boolean}>,
15
+ * defaults?: {defaultModel:string, defaultEffort:string},
16
+ * }} [deps]
17
+ */
18
+ export function createAskModels({
19
+ listModels = realListModels,
20
+ pluginModels = realPluginModels,
21
+ secretStatus = realSecretStatus,
22
+ defaults = ASK_LIMITS,
23
+ } = {}) {
24
+ /**
25
+ * lc id -> the modelSecrets keys that model needs but that are NOT set.
26
+ * Mirrors pluginModelsPayload() in ui/server.mjs (which ships the full
27
+ * [{key,label,set}] for the editor); the chat only needs the missing keys.
28
+ * Keying by id alone is safe because listPluginModels() already dedupes to one
29
+ * entry per id (plugin-models.mjs:64-77). Like the server, this only sees keys
30
+ * the manifest DECLARES in modelSecrets — an env {secret:…} naming an
31
+ * undeclared key is invisible here exactly as it is in the Models view.
32
+ * Built lazily: an install with no plugin models does no extra disk reads.
33
+ */
34
+ function missingSecretsByIdLc() {
35
+ const byPlugin = new Map(); // pluginModelSecretStatus hits disk per call — memoize
36
+ const out = new Map();
37
+ for (const m of pluginModels()) {
38
+ const needed = Array.isArray(m.secrets) ? m.secrets : [];
39
+ if (!needed.length) continue;
40
+ if (!byPlugin.has(m.plugin)) byPlugin.set(m.plugin, secretStatus(m.plugin) || []);
41
+ const missing = byPlugin.get(m.plugin).filter((s) => needed.includes(s.key) && !s.set).map((s) => s.key);
42
+ if (missing.length) out.set(m.id.toLowerCase(), missing);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** The D8 initial pick, validated against the live catalog (D5). */
48
+ function pickDefault(models) {
49
+ const want = String(defaults.defaultModel || '').toLowerCase();
50
+ const hit = models.find((m) => m.id.toLowerCase() === want) || models[0] || null;
51
+ if (!hit) return null;
52
+ const efforts = hit.efforts.length ? hit.efforts : [...EFFORTS];
53
+ const effort = efforts.includes(defaults.defaultEffort)
54
+ ? defaults.defaultEffort
55
+ : (efforts.includes('high') ? 'high' : efforts[0]);
56
+ return { model: hit.id, effort };
57
+ }
58
+
59
+ /**
60
+ * @param {{withSecrets?:boolean}} [opts] `withSecrets:false` skips the per-model
61
+ * secret probe — the extra listPluginModels() + one manifest/config read per
62
+ * plugin, all synchronous. Only validateModelEffort passes it: that path keeps
63
+ * id/efforts and throws the rest away, and it runs on every message POST.
64
+ * @returns {Promise<{models:Array<object>, efforts:string[], default:{model:string,effort:string}|null}>}
65
+ */
66
+ async function askCatalog({ withSecrets = true } = {}) {
67
+ const all = await listModels('');
68
+ const models = [];
69
+ let missing = null; // lazily built on the first plugin entry
70
+ for (const m of all) {
71
+ if (!m || typeof m.id !== 'string') continue;
72
+ // Legacy per-project models stay out: the chat is project-less (listModels('')
73
+ // never composes them anyway — config.mjs:296), and offering them needs a
74
+ // project-selection design first. Everything else — built-in, global,
75
+ // plugin — is offered.
76
+ if (m.custom === 'project') continue;
77
+ const custom = m.custom === 'global' || m.custom === 'plugin' ? m.custom : false;
78
+ const entry = {
79
+ id: m.id,
80
+ label: typeof m.label === 'string' && m.label ? m.label : m.id,
81
+ efforts: Array.isArray(m.efforts) ? [...m.efforts] : [...EFFORTS],
82
+ custom,
83
+ hasEnv: m.hasEnv === true,
84
+ };
85
+ if (custom === 'plugin' && typeof m.plugin === 'string' && m.plugin) entry.plugin = m.plugin;
86
+ // Only globals and plugin entries can arrive flagged: composeCatalog emits an
87
+ // UNSHADOWED built-in as {...m, custom:false, hasEnv:false} with no
88
+ // ...unreliable(lc) (src/core/config.mjs:200), so a built-in in model_cost_flags
89
+ // shows no ⚠cost here. Pre-existing gap, shared with the pipeline dropdown and
90
+ // /api/config; fixing it means editing composeCatalog and its three other consumers.
91
+ if (m.costUnreliable === true) entry.costUnreliable = true;
92
+ if (custom === 'plugin' && withSecrets) {
93
+ if (!missing) missing = missingSecretsByIdLc();
94
+ const keys = missing.get(m.id.toLowerCase());
95
+ if (keys && keys.length) entry.secretsMissing = [...keys];
96
+ }
97
+ models.push(entry);
98
+ }
99
+ return { models, efforts: [...EFFORTS], default: pickDefault(models) };
100
+ }
101
+
102
+ /**
103
+ * @param {unknown} model
104
+ * @param {unknown} effort
105
+ * @returns {Promise<{ok:true, model:string, effort:string}|{ok:false, error:string}>}
106
+ */
107
+ async function validateModelEffort(model, effort) {
108
+ if (typeof model !== 'string' || !model.trim()) return { ok: false, error: 'model is required' };
109
+ if (typeof effort !== 'string' || !effort.trim()) return { ok: false, error: 'effort is required' };
110
+ const id = model.trim();
111
+ const { models } = await askCatalog({ withSecrets: false }); // id/efforts only — a missing secret never blocks (D9)
112
+ const entry = models.find((m) => m.id.toLowerCase() === id.toLowerCase());
113
+ if (!entry) return { ok: false, error: `unknown model "${id}"` };
114
+ const e = effort.trim();
115
+ if (!entry.efforts.includes(e)) return { ok: false, error: `effort "${e}" is not available for model "${entry.id}"` };
116
+ return { ok: true, model: entry.id, effort: e };
117
+ }
118
+
119
+ return { askCatalog, validateModelEffort };
120
+ }
121
+
122
+ const bound = createAskModels();
123
+ /** Bound to the real catalog — what ui/server.mjs uses for GET /api/ask/models and the message POST. */
124
+ export const askCatalog = bound.askCatalog;
125
+ export const validateModelEffort = bound.validateModelEffort;