@worca/app 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from './worktree.mjs';
|
|
34
34
|
import { runWorkspaceScan } from './phases.mjs';
|
|
35
35
|
import { fanoutCap, mapWithCap } from './fanout.mjs';
|
|
36
|
+
import { modelHasBaseUrlRouting } from './config.mjs';
|
|
36
37
|
|
|
37
38
|
// The scanning agent's name in the prompt-role/registry sense (drives the .md body
|
|
38
39
|
// it loads; the MOCK_ROLE marker differs — workspace-scan — and is set inside
|
|
@@ -298,6 +299,9 @@ class WorkspaceScan extends EventEmitter {
|
|
|
298
299
|
// ctxFanOut(ctx) -> ctx.fanOut (no node) -> grants Task/Agent so the agent
|
|
299
300
|
// can dispatch investigators (scan-fanout).
|
|
300
301
|
fanOut: true,
|
|
302
|
+
// No node on this ctx: ctxEndpointRouted reads the ctx-level flag, so the
|
|
303
|
+
// scanner's fan-out block degrades the same way a routed node's does.
|
|
304
|
+
endpointRouted: modelHasBaseUrlRouting(this.claude.model),
|
|
301
305
|
claudeOpts: {
|
|
302
306
|
permissionMode: this.claude.permissionMode || 'acceptEdits',
|
|
303
307
|
model: this.claude.model,
|
package/src/core/worktree.mjs
CHANGED
|
@@ -49,12 +49,17 @@ const BRANCH_STOPWORDS = new Set([
|
|
|
49
49
|
// 30 s on large repos — give them a longer leash before the SIGKILL deadline.
|
|
50
50
|
const SLOW_GIT_TIMEOUT_MS = 120_000;
|
|
51
51
|
|
|
52
|
-
/** Run git and resolve to { ok, stdout, stderr, code }. Never throws.
|
|
53
|
-
|
|
52
|
+
/** Run git and resolve to { ok, stdout, stderr, code }. Never throws.
|
|
53
|
+
* `env` is MERGED OVER process.env (never a replacement): the git binary and the
|
|
54
|
+
* credential helper need PATH/HOME. Only the Ask Worca callers pass it. */
|
|
55
|
+
function git(cwd, args, { signal, timeout = 30_000, env, maxBytes = 0 } = {}) {
|
|
54
56
|
return new Promise((res) => {
|
|
55
57
|
let child;
|
|
56
58
|
try {
|
|
57
|
-
child = spawn('git', args, {
|
|
59
|
+
child = spawn('git', args, {
|
|
60
|
+
cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
|
|
61
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
62
|
+
});
|
|
58
63
|
} catch (err) {
|
|
59
64
|
res({ ok: false, stdout: '', stderr: err.message, code: -1 });
|
|
60
65
|
return;
|
|
@@ -73,7 +78,24 @@ function git(cwd, args, { signal, timeout = 30_000 } = {}) {
|
|
|
73
78
|
done({ ok: false, stdout, stderr: stderr ? `git timed out: ${stderr}` : 'git timed out', code: -1 });
|
|
74
79
|
}, timeout)
|
|
75
80
|
: null;
|
|
76
|
-
|
|
81
|
+
// `maxBytes` caps the CAPTURE (review of PR #376): the Ask `git` tool re-runs a
|
|
82
|
+
// command per `offset` page, so `log -p --all` on a big repo accumulated the
|
|
83
|
+
// whole history in memory every page. Past the cap the child is killed and
|
|
84
|
+
// the result is `ok` with `truncated: true` — the caller says so and pages
|
|
85
|
+
// within what it got.
|
|
86
|
+
let captured = 0;
|
|
87
|
+
let truncated = false;
|
|
88
|
+
child.stdout?.on('data', (b) => {
|
|
89
|
+
if (truncated) return;
|
|
90
|
+
captured += b.length;
|
|
91
|
+
stdout += b.toString();
|
|
92
|
+
if (maxBytes > 0 && captured > maxBytes) {
|
|
93
|
+
truncated = true;
|
|
94
|
+
stdout = Buffer.from(stdout, 'utf8').subarray(0, maxBytes).toString('utf8');
|
|
95
|
+
try { child.kill('SIGKILL'); } catch { /* gone */ }
|
|
96
|
+
done({ ok: true, stdout, stderr, code: 0, truncated: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
77
99
|
child.stderr?.on('data', (b) => (stderr += b.toString()));
|
|
78
100
|
child.on('error', (err) => done({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
|
|
79
101
|
child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
|
|
@@ -185,7 +207,9 @@ export async function worktreePathForBranch(projectDir, branch) {
|
|
|
185
207
|
if (!r.ok) return null;
|
|
186
208
|
let curPath = null;
|
|
187
209
|
for (const line of r.stdout.split(/\r?\n/)) {
|
|
188
|
-
|
|
210
|
+
// git prints '/'-separated paths even on Windows; resolve() gives callers the
|
|
211
|
+
// native form they compare against (createWorktree's own worktreeDir).
|
|
212
|
+
if (line.startsWith('worktree ')) curPath = resolve(line.slice('worktree '.length).trim());
|
|
189
213
|
else if (line.startsWith('branch ')) {
|
|
190
214
|
const ref = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
191
215
|
if (ref === branch) return curPath;
|
|
@@ -302,8 +326,16 @@ export async function removeWorktree({ projectDir, worktreeDir, branch, force =
|
|
|
302
326
|
: ['worktree', 'remove', worktreeDir];
|
|
303
327
|
const r = await git(projectDir, args, { timeout: SLOW_GIT_TIMEOUT_MS });
|
|
304
328
|
steps.push({ step: 'worktree-remove', ok: r.ok, stderr: r.stderr.trim() });
|
|
305
|
-
|
|
306
|
-
|
|
329
|
+
// fs backstop whenever the checkout survives the git remove — not only on
|
|
330
|
+
// force. Git for Windows routinely leaves the directory behind (a read-only
|
|
331
|
+
// packed object, or a file a scanner/just-exited git still holds → EBUSY),
|
|
332
|
+
// which left detached run roots and worktrees on disk. Retry to ride out the
|
|
333
|
+
// transient lock; a no-op on POSIX where git already emptied the dir.
|
|
334
|
+
// ...but only when git REPORTED success and still left the dir behind (the
|
|
335
|
+
// Windows leftover). A non-force refusal on a dirty worktree (r.ok === false)
|
|
336
|
+
// must keep the checkout — deleting it would discard uncommitted agent work.
|
|
337
|
+
if (force || (r.ok && existsSync(worktreeDir))) {
|
|
338
|
+
const fsRes = await rm(worktreeDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
|
307
339
|
.then(() => null)
|
|
308
340
|
.catch((e) => e.message);
|
|
309
341
|
if (fsRes) steps.push({ step: 'rm-dir', ok: false, stderr: fsRes });
|
|
@@ -318,6 +350,65 @@ export async function removeWorktree({ projectDir, worktreeDir, branch, force =
|
|
|
318
350
|
return { ok: steps.every((s) => s.ok), steps };
|
|
319
351
|
}
|
|
320
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Create a DETACHED worktree at `worktreeDir` checking out `ref` — the Ask
|
|
355
|
+
* Worca inspection checkout (ask-worca-worktrees-design.md §3). Detached by
|
|
356
|
+
* construction: no branch is created, locked or deleted, so the pipeline
|
|
357
|
+
* branch-in-use check (M2) never sees these. The caller (ask/worktrees.mjs)
|
|
358
|
+
* owns path construction and containment; this validates only the ref (M1
|
|
359
|
+
* doctrine: reject option-injection before git parses argv) and throws on a
|
|
360
|
+
* fatal git failure with the same AbortError stamp createWorktree uses.
|
|
361
|
+
*/
|
|
362
|
+
export async function createDetachedWorktree({ projectDir, worktreeDir, ref, signal } = {}) {
|
|
363
|
+
if (!projectDir) throw new Error('projectDir required');
|
|
364
|
+
if (!worktreeDir) throw new Error('worktreeDir required');
|
|
365
|
+
if (!(await isValidSourceRef(projectDir, ref))) {
|
|
366
|
+
throw new Error(`ref is not a valid commit-ish: ${JSON.stringify(ref ?? null)}`);
|
|
367
|
+
}
|
|
368
|
+
await git(projectDir, ['worktree', 'prune']);
|
|
369
|
+
const r = await git(projectDir, ['worktree', 'add', '--detach', '--', worktreeDir, ref],
|
|
370
|
+
{ signal, timeout: SLOW_GIT_TIMEOUT_MS });
|
|
371
|
+
if (!r.ok) {
|
|
372
|
+
const err = new Error(`git worktree add --detach failed: ${r.stderr.trim() || `exit ${r.code}`}`);
|
|
373
|
+
if (signal?.aborted) err.name = 'AbortError';
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
const head = await git(worktreeDir, ['rev-parse', 'HEAD']);
|
|
377
|
+
return { worktreeDir, commit: head.ok ? head.stdout.trim() : null };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** HEAD commit sha of any checkout (worktree or repo), or null. */
|
|
381
|
+
export async function worktreeHead(dir) {
|
|
382
|
+
const r = await git(dir, ['rev-parse', 'HEAD']);
|
|
383
|
+
return r.ok ? r.stdout.trim() : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Env for every Ask Worca git spawn (spec §8). Pager OFF and — load-bearing —
|
|
388
|
+
* NEVER prompt: an https/ssh fetch with no cached credential would otherwise
|
|
389
|
+
* block on a terminal/askpass prompt until the SLOW_GIT_TIMEOUT_MS SIGKILL,
|
|
390
|
+
* holding the MCP call open for two minutes. GIT_ASKPASS/SSH_ASKPASS='' stop the
|
|
391
|
+
* GUI askpass fallback too. NOTE we deliberately do NOT set GIT_CONFIG_GLOBAL:
|
|
392
|
+
* the keychain credential helper for https fetch lives in the user's global
|
|
393
|
+
* config (E3), so nuking it would break authenticated fetch; the external-diff
|
|
394
|
+
* exec vector is closed per-invocation in the tool handler with
|
|
395
|
+
* `-c diff.external=` + `--no-ext-diff` instead.
|
|
396
|
+
*/
|
|
397
|
+
export const ASK_GIT_ENV = Object.freeze({
|
|
398
|
+
GIT_PAGER: 'cat', PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '',
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Raw capture runner for the Ask Worca `git` MCP tool. The ONLY gate between a
|
|
403
|
+
* model-authored argv and this spawn is ask/git-allowlist.mjs — callers pass
|
|
404
|
+
* exclusively its validated output. Spawn semantics identical to every other
|
|
405
|
+
* helper here ({ok, stdout, stderr, code}, never throws), plus the ASK_GIT_ENV
|
|
406
|
+
* hardening and the caller's abort signal.
|
|
407
|
+
*/
|
|
408
|
+
export function runGitCapture(cwd, args, { signal, timeoutMs = SLOW_GIT_TIMEOUT_MS, maxBytes = 0 } = {}) {
|
|
409
|
+
return git(cwd, args, { signal, timeout: timeoutMs, env: ASK_GIT_ENV, maxBytes });
|
|
410
|
+
}
|
|
411
|
+
|
|
321
412
|
/**
|
|
322
413
|
* Stage every remaining change and render a binary-capable patch against HEAD
|
|
323
414
|
* DIRECTLY INTO outFile (no in-memory patch string — agent-created artifacts can
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// src/shared/graph/agent-meta.mjs
|
|
2
|
+
// Agent metadata v2: ONE normalizer + validator for the registry loader (skip +
|
|
3
|
+
// warn), the agent store (hard 400), the Agents-view port editor (live hints)
|
|
4
|
+
// and agent-gen's read-back check. Pure — shared code cannot import
|
|
5
|
+
// claude-runner.mjs, so the mock-role vocabulary is INJECTED.
|
|
6
|
+
import { PORT_TYPES, MAX_PORTS_PER_SIDE, PORT_ID_RE } from './constants.mjs';
|
|
7
|
+
|
|
8
|
+
const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
9
|
+
// PORT_ID_RE is P1's, NOT a local copy: spec §3 puts it in constants.mjs
|
|
10
|
+
// precisely so the store's 400, P1's seed guard and the composer's editor hint
|
|
11
|
+
// can never disagree. It is the STRICT lowerCamel form `/^[a-z][A-Za-z0-9]{0,31}$/`
|
|
12
|
+
// — no `_`, no `-` (see the Q&A; `in_1` and `in-1` are INVALID port ids).
|
|
13
|
+
/** The engine synthesizes a type-agnostic `await` gate on every agent node, so
|
|
14
|
+
* the id is RESERVED on BOTH sides and no sidecar may declare it. */
|
|
15
|
+
const RESERVED_PORT_ID = 'await';
|
|
16
|
+
const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
17
|
+
const COLORS = new Set(['green', 'peach', 'red', 'blue', 'violet', 'amber']);
|
|
18
|
+
const RUNNER_TYPES = new Set(['producer', 'verifier', 'clarifier']);
|
|
19
|
+
const INPUT_AS = new Set(['file', 'answers', 'fix-review', 'worktree']);
|
|
20
|
+
/** The port type each non-default `as` renderer requires. `file` is the default
|
|
21
|
+
* and is materialized on NON-VOID inputs only, which makes `worktree` the only
|
|
22
|
+
* `as` a void input takes. */
|
|
23
|
+
const AS_REQUIRES_TYPE = { answers: 'json', 'fix-review': 'md', worktree: 'void' };
|
|
24
|
+
const OUTPUT_WHEN = new Set(['always', 'blocking', 'clean']);
|
|
25
|
+
const OUTPUT_STORES = new Set(['run', 'project']);
|
|
26
|
+
const WORKSPACE_STRATEGIES = new Set(['explore', 'task', 'review']);
|
|
27
|
+
const FILENAME_TOKENS = new Set(['cycle', 'vsuffix', 'base']);
|
|
28
|
+
/** Sort key a sidecar that omits `order` gets. Exported so the registry loader
|
|
29
|
+
* (agent-registry.mjs normalizeMeta) backfills the SAME value this normalizer
|
|
30
|
+
* does — a sidecar validateMetaV2 certifies must never vanish from the registry. */
|
|
31
|
+
export const DEFAULT_ORDER = 999;
|
|
32
|
+
const TYPES = [...PORT_TYPES].filter((t) => t !== 'any'); // `any` is engine-only, never declarable
|
|
33
|
+
|
|
34
|
+
/** `{key: meta}` from a registry LIST (the /api/agents payload shape). */
|
|
35
|
+
export function indexByKey(list) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const m of Array.isArray(list) ? list : []) {
|
|
38
|
+
if (m && typeof m === 'object' && typeof m.key === 'string' && m.key) out[m.key] = m;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The palette one-liner and the generic system-prompt fallback: the NON-VOID
|
|
44
|
+
* ids per side; an all-void side falls back to its declared ids so the sentence
|
|
45
|
+
* never degenerates to "produces .". */
|
|
46
|
+
export function derivePortSummary(meta) {
|
|
47
|
+
const ids = (ports) => {
|
|
48
|
+
const list = Array.isArray(ports) ? ports.filter(Boolean) : [];
|
|
49
|
+
const nonVoid = list.filter((p) => p.type !== 'void');
|
|
50
|
+
return (nonVoid.length ? nonVoid : list).map((p) => p.id);
|
|
51
|
+
};
|
|
52
|
+
const reads = ids(meta?.inputs);
|
|
53
|
+
const writes = ids(meta?.outputs);
|
|
54
|
+
if (!writes.length) return reads.length ? `Reads ${reads.join(', ')}.` : '';
|
|
55
|
+
if (!reads.length) return `Produces ${writes.join(', ')}.`;
|
|
56
|
+
return `Reads ${reads.join(', ')}; produces ${writes.join(', ')}.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pure validation for the store's 400 path. Silent by design — the load path warns. */
|
|
60
|
+
export function validateMetaV2(raw, opts = {}) {
|
|
61
|
+
return { errors: normalizeAgentMeta(raw, { ...opts, warn: () => {} }).errors };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} raw parsed sidecar
|
|
66
|
+
* @param {{mockWriterRoles?:Set<string>, warn?:(msg:string)=>void}} [opts]
|
|
67
|
+
* @returns {{meta:object|null, errors:string[]}} meta is meaningful only when errors is empty
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeAgentMeta(raw, opts = {}) {
|
|
70
|
+
const errors = [];
|
|
71
|
+
const err = (msg) => errors.push(msg);
|
|
72
|
+
const warn = typeof opts.warn === 'function' ? opts.warn : () => {};
|
|
73
|
+
if (!raw || typeof raw !== 'object') return { errors: ['meta must be an object'], meta: null };
|
|
74
|
+
|
|
75
|
+
const key = typeof raw.key === 'string' ? raw.key.trim() : '';
|
|
76
|
+
if (!key) err('key is required');
|
|
77
|
+
else if (!AGENT_KEY_RE.test(key)) err(`key "${key}" is not a valid agent key`);
|
|
78
|
+
if (raw.metaVersion !== 2) err('sidecar requires metaVersion 2');
|
|
79
|
+
if (!RUNNER_TYPES.has(raw.runnerType)) err(`runnerType must be one of ${[...RUNNER_TYPES].join(', ')}`);
|
|
80
|
+
const runnerType = RUNNER_TYPES.has(raw.runnerType) ? raw.runnerType : 'producer';
|
|
81
|
+
|
|
82
|
+
const order = raw.order === undefined ? DEFAULT_ORDER : Number(raw.order);
|
|
83
|
+
if (!Number.isFinite(order)) err('order must be a number');
|
|
84
|
+
|
|
85
|
+
// agentFile is a PATH: the registry joins it onto the layer's agents dir and
|
|
86
|
+
// reads it as the agent's system prompt AND for its `tools:` frontmatter, so
|
|
87
|
+
// it takes the SAME basename rule as verdict.filename / outputs[].filename.
|
|
88
|
+
// A non-<key>.md basename stays legal — every built-in uses worca-cc-<role>.md.
|
|
89
|
+
const agentFile = typeof raw.agentFile === 'string' && raw.agentFile.trim() ? raw.agentFile.trim() : null;
|
|
90
|
+
if (agentFile && (/[\\/]/.test(agentFile) || agentFile.includes('..'))) {
|
|
91
|
+
err(`agentFile "${agentFile}" must be a plain basename`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const verdict = readVerdict(raw.verdict, err);
|
|
95
|
+
if (runnerType === 'verifier' && !verdict) err('runnerType "verifier" requires verdict: { filename }');
|
|
96
|
+
|
|
97
|
+
const inputs = readInputs(raw.inputs, err, warn);
|
|
98
|
+
const outputs = readOutputs(raw.outputs, !!verdict, err);
|
|
99
|
+
if (runnerType === 'clarifier' && !outputs.some((p) => p.type === 'json')) {
|
|
100
|
+
err('runnerType "clarifier" requires at least one json output port');
|
|
101
|
+
}
|
|
102
|
+
// Two outputs may share one filename template only with an identical type (the
|
|
103
|
+
// refiner's clean/blocking plan arms); allocation then yields ONE path.
|
|
104
|
+
const typeByTemplate = new Map();
|
|
105
|
+
for (const p of outputs) {
|
|
106
|
+
if (!p.filename) continue;
|
|
107
|
+
const prev = typeByTemplate.get(p.filename);
|
|
108
|
+
if (prev === undefined) typeByTemplate.set(p.filename, p.type);
|
|
109
|
+
else if (prev !== p.type) err(`outputs: filename template "${p.filename}" is shared by ports of different types`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Scope coercion mirrors color: anything but the explicit 'workspace-only'
|
|
113
|
+
// marker is a normal project agent, so a typo fails safe to a VISIBLE agent.
|
|
114
|
+
const scope = raw.scope === 'workspace-only' ? 'workspace-only' : 'project';
|
|
115
|
+
if (raw.sideEffect !== undefined && raw.sideEffect !== 'code') err('sideEffect must be "code" when present');
|
|
116
|
+
if (raw.workspaceStrategy !== undefined && !WORKSPACE_STRATEGIES.has(raw.workspaceStrategy)) {
|
|
117
|
+
err(`workspaceStrategy must be one of ${[...WORKSPACE_STRATEGIES].join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
let workspaceVariantOf = null;
|
|
120
|
+
if (raw.workspaceVariantOf !== undefined) {
|
|
121
|
+
const target = typeof raw.workspaceVariantOf === 'string' ? raw.workspaceVariantOf.trim() : '';
|
|
122
|
+
if (!AGENT_KEY_RE.test(target)) err('workspaceVariantOf must be an agent key');
|
|
123
|
+
else if (target === key) err('workspaceVariantOf must not reference the agent itself');
|
|
124
|
+
else if (scope !== 'workspace-only') err('workspaceVariantOf requires scope "workspace-only"');
|
|
125
|
+
else workspaceVariantOf = target;
|
|
126
|
+
}
|
|
127
|
+
// An unknown mockRole is a WARNING, never a 400: the field is dropped and the
|
|
128
|
+
// generic mock-role fallback chain applies.
|
|
129
|
+
let mockRole = null;
|
|
130
|
+
if (raw.mockRole !== undefined) {
|
|
131
|
+
const role = typeof raw.mockRole === 'string' ? raw.mockRole.trim() : '';
|
|
132
|
+
const vocab = opts.mockWriterRoles instanceof Set ? opts.mockWriterRoles : null;
|
|
133
|
+
if (!vocab || vocab.has(role)) mockRole = role || null;
|
|
134
|
+
else warn(`[agent-registry] ${key || '<unkeyed>'}.mockRole: unknown mock role "${role}"; ignored (the generic mock chain applies)`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const asksQuestions = !!raw.asksQuestions;
|
|
138
|
+
const meta = {
|
|
139
|
+
metaVersion: 2,
|
|
140
|
+
key,
|
|
141
|
+
displayName: typeof raw.displayName === 'string' && raw.displayName.trim() ? raw.displayName.trim() : key,
|
|
142
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
143
|
+
color: COLORS.has(raw.color) ? raw.color : 'amber',
|
|
144
|
+
icon: typeof raw.icon === 'string' ? raw.icon : '',
|
|
145
|
+
agentFile,
|
|
146
|
+
runnerType,
|
|
147
|
+
scope,
|
|
148
|
+
domain: typeof raw.domain === 'string' && DOMAIN_RE.test(raw.domain) ? raw.domain : 'general',
|
|
149
|
+
fanOut: !!raw.fanOut,
|
|
150
|
+
asksQuestions,
|
|
151
|
+
questionsLocked: asksQuestions && !!raw.questionsLocked,
|
|
152
|
+
questionsDefault: asksQuestions && !!raw.questionsDefault,
|
|
153
|
+
order: Number.isFinite(order) ? order : DEFAULT_ORDER,
|
|
154
|
+
promptHints: typeof raw.promptHints === 'string' ? raw.promptHints : '',
|
|
155
|
+
requiresSkills: Array.isArray(raw.requiresSkills)
|
|
156
|
+
? raw.requiresSkills.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
157
|
+
: [],
|
|
158
|
+
inputs,
|
|
159
|
+
outputs,
|
|
160
|
+
portSummary: '',
|
|
161
|
+
};
|
|
162
|
+
meta.portSummary = derivePortSummary(meta);
|
|
163
|
+
// Capability defaults are applied at READ time, never written into the entry:
|
|
164
|
+
// an absent field means "the default", so a v2 entry stays diffable against
|
|
165
|
+
// the sidecar that produced it.
|
|
166
|
+
if (verdict) meta.verdict = verdict;
|
|
167
|
+
if (raw.sideEffect === 'code') meta.sideEffect = 'code';
|
|
168
|
+
if (mockRole) meta.mockRole = mockRole;
|
|
169
|
+
if (raw.wantsRequest) meta.wantsRequest = true;
|
|
170
|
+
if (raw.workspaceFanOut) meta.workspaceFanOut = true;
|
|
171
|
+
if (WORKSPACE_STRATEGIES.has(raw.workspaceStrategy)) meta.workspaceStrategy = raw.workspaceStrategy;
|
|
172
|
+
if (workspaceVariantOf) meta.workspaceVariantOf = workspaceVariantOf;
|
|
173
|
+
if (raw.placeable !== undefined && !raw.placeable) meta.placeable = false;
|
|
174
|
+
return { errors, meta };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readVerdict(raw, err) {
|
|
178
|
+
if (raw === undefined) return null;
|
|
179
|
+
const filename = raw && typeof raw === 'object' && typeof raw.filename === 'string' ? raw.filename.trim() : '';
|
|
180
|
+
if (!filename) { err('verdict must be an object with a filename'); return null; }
|
|
181
|
+
if (/[\\/]/.test(filename) || filename.includes('..')) {
|
|
182
|
+
err(`verdict filename "${filename}" must be a plain basename`);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return { filename };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readPortHead(raw, side, seen, err) {
|
|
189
|
+
if (!raw || typeof raw !== 'object') { err(`${side}: each port must be an object`); return null; }
|
|
190
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
191
|
+
if (id === RESERVED_PORT_ID) {
|
|
192
|
+
err(`${side}: port id "${RESERVED_PORT_ID}" is reserved — the engine synthesizes the await gate port on every agent node`);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (!PORT_ID_RE.test(id)) { err(`${side}: bad port id "${id}"`); return null; }
|
|
196
|
+
if (seen.has(id)) { err(`${side}: duplicate port id "${id}"`); return null; }
|
|
197
|
+
seen.add(id);
|
|
198
|
+
if (!TYPES.includes(raw.type)) { err(`${side}.${id}: type must be one of ${TYPES.join(', ')}`); return null; }
|
|
199
|
+
const port = { id, type: raw.type };
|
|
200
|
+
if (typeof raw.label === 'string' && raw.label.trim()) port.label = raw.label.trim();
|
|
201
|
+
if (typeof raw.description === 'string' && raw.description.trim()) port.description = raw.description.trim();
|
|
202
|
+
if (raw.type === 'void' && (raw.filename !== undefined || raw.store !== undefined)) {
|
|
203
|
+
err(`${side}.${id}: void ports carry no filename or store`);
|
|
204
|
+
}
|
|
205
|
+
return port;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readInputs(raw, err, warn) {
|
|
209
|
+
if (!Array.isArray(raw)) { err('inputs must be an array'); return []; }
|
|
210
|
+
if (raw.length > MAX_PORTS_PER_SIDE) err(`inputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const p of raw) {
|
|
214
|
+
const port = readPortHead(p, 'inputs', seen, err);
|
|
215
|
+
if (!port) continue;
|
|
216
|
+
// A loop receiver is excused from the first-execution barrier, so `loop` and
|
|
217
|
+
// `required` can never both hold. Coerced rather than rejected — but it is
|
|
218
|
+
// authoring intent quietly overruled, hence the warning.
|
|
219
|
+
const loop = !!p.loop;
|
|
220
|
+
let required = p.required === undefined ? true : !!p.required;
|
|
221
|
+
if (loop && required) {
|
|
222
|
+
warn(`[agent-registry] inputs.${port.id}: loop:true forces required:false (a loop receiver is never a barrier)`);
|
|
223
|
+
required = false;
|
|
224
|
+
}
|
|
225
|
+
port.required = required;
|
|
226
|
+
if (loop) port.loop = true;
|
|
227
|
+
if (p.expands) {
|
|
228
|
+
if (port.type !== 'json') err(`inputs.${port.id}: expands is only legal on json inputs`);
|
|
229
|
+
else port.expands = true;
|
|
230
|
+
}
|
|
231
|
+
if (p.as !== undefined) {
|
|
232
|
+
const need = Object.hasOwn(AS_REQUIRES_TYPE, p.as) ? AS_REQUIRES_TYPE[p.as] : null;
|
|
233
|
+
if (!INPUT_AS.has(p.as)) err(`inputs.${port.id}: as must be one of ${[...INPUT_AS].join(', ')}`);
|
|
234
|
+
else if (need ? port.type !== need : port.type === 'void') {
|
|
235
|
+
err(`inputs.${port.id}: as "${p.as}" requires a ${need || 'non-void'} port (got ${port.type})`);
|
|
236
|
+
} else port.as = p.as;
|
|
237
|
+
} else if (port.type !== 'void') port.as = 'file';
|
|
238
|
+
if (typeof p.directive === 'string' && p.directive.trim()) port.directive = p.directive;
|
|
239
|
+
out.push(port);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readOutputs(raw, hasVerdict, err) {
|
|
245
|
+
if (!Array.isArray(raw)) { err('outputs must be an array'); return []; }
|
|
246
|
+
if (raw.length === 0) err('at least one output port is required');
|
|
247
|
+
if (raw.length > MAX_PORTS_PER_SIDE) err(`outputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
|
|
248
|
+
const seen = new Set();
|
|
249
|
+
const out = [];
|
|
250
|
+
for (const p of raw) {
|
|
251
|
+
const port = readPortHead(p, 'outputs', seen, err);
|
|
252
|
+
if (!port) continue;
|
|
253
|
+
const when = p.when === undefined ? 'always' : p.when;
|
|
254
|
+
if (!OUTPUT_WHEN.has(when)) err(`outputs.${port.id}: when must be one of ${[...OUTPUT_WHEN].join(', ')}`);
|
|
255
|
+
else {
|
|
256
|
+
if (when !== 'always' && !hasVerdict) {
|
|
257
|
+
err(`outputs.${port.id}: when "${when}" requires the agent to declare verdict: { filename }`);
|
|
258
|
+
}
|
|
259
|
+
port.when = when;
|
|
260
|
+
}
|
|
261
|
+
if (port.type !== 'void') {
|
|
262
|
+
const filename = typeof p.filename === 'string' ? p.filename.trim() : '';
|
|
263
|
+
const tokens = [...filename.matchAll(/\{([^}]*)\}/g)].map((m) => m[1]).filter((t) => !FILENAME_TOKENS.has(t));
|
|
264
|
+
if (!filename) err(`outputs.${port.id}: ${port.type} outputs require a filename template`);
|
|
265
|
+
else if (/[\\/]/.test(filename) || filename.includes('..')) {
|
|
266
|
+
err(`outputs.${port.id}: filename "${filename}" must be a plain basename`);
|
|
267
|
+
} else if (tokens.length) {
|
|
268
|
+
err(`outputs.${port.id}: filename "${filename}" uses unknown token(s) ${tokens.map((t) => `{${t}}`).join(', ')}`);
|
|
269
|
+
} else port.filename = filename;
|
|
270
|
+
const store = p.store === undefined ? 'run' : p.store;
|
|
271
|
+
if (!OUTPUT_STORES.has(store)) err(`outputs.${port.id}: store must be one of ${[...OUTPUT_STORES].join(', ')}`);
|
|
272
|
+
else port.store = store;
|
|
273
|
+
port.artifactKind = typeof p.artifactKind === 'string' && p.artifactKind.trim() ? p.artifactKind.trim() : port.id;
|
|
274
|
+
}
|
|
275
|
+
out.push(port);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/shared/graph/constants.mjs
|
|
2
|
+
// The frozen vocabulary of the v2 template model: node kinds, port types, the
|
|
3
|
+
// synthesized await gate, the flow cards' port tables, id shapes and limits.
|
|
4
|
+
// Pure data + one pure function — imported unchanged by the engine, the
|
|
5
|
+
// server's 422 path, the tests and the browser (served at /src/shared).
|
|
6
|
+
|
|
7
|
+
/** Templates this model understands. v1 rows carry `version: 1`. */
|
|
8
|
+
export const TEMPLATE_VERSION = 2;
|
|
9
|
+
|
|
10
|
+
/** Every node kind a template may carry (palette order). */
|
|
11
|
+
export const KINDS = Object.freeze(['agent', 'task', 'end', 'and', 'or', 'combine']);
|
|
12
|
+
|
|
13
|
+
/** The flow cards: every kind that is not a spawned agent. They are pure engine
|
|
14
|
+
* executions — instant, $0, no spawn. */
|
|
15
|
+
export const FLOW_KINDS = Object.freeze(['task', 'end', 'and', 'or', 'combine']);
|
|
16
|
+
|
|
17
|
+
/** The flow cards' DISPLAY names — one label per FLOW_KIND. The single copy:
|
|
18
|
+
* the run manifest (`manifest.mjs` cell labels), the run monitor and the
|
|
19
|
+
* New-pipeline loop caption all read this table, so a flow card can never be
|
|
20
|
+
* called 'OR' on one surface and 'n_or' on another. */
|
|
21
|
+
export const FLOW_LABEL = Object.freeze({ task: 'Task', end: 'End', and: 'AND', or: 'OR', combine: 'Combine' });
|
|
22
|
+
|
|
23
|
+
/** Port payload types. 'any' is engine-internal: it lives only on AND inputs,
|
|
24
|
+
* OR ports before resolution, End's `result` and the synthesized `await` gate —
|
|
25
|
+
* never declarable in agent meta. */
|
|
26
|
+
export const PORT_TYPES = Object.freeze(['md', 'json', 'void', 'any']);
|
|
27
|
+
|
|
28
|
+
/** Max ports per side of one card (agent meta inputs/outputs, gate arity). */
|
|
29
|
+
export const MAX_PORTS_PER_SIDE = 8;
|
|
30
|
+
|
|
31
|
+
/** Per-wire loop budget when neither the overlay nor `wire.config` sets one. */
|
|
32
|
+
export const DEFAULT_MAX_CYCLES = 3;
|
|
33
|
+
|
|
34
|
+
/** The universal gate input every agent card gets: synthesized by portsFn and
|
|
35
|
+
* appended LAST to the agent's declared inputs. Never stored in a template and
|
|
36
|
+
* never declared in meta ('await' is a reserved port id on both sides). It
|
|
37
|
+
* accepts a wire from ANY output type and its payload is discarded — pure
|
|
38
|
+
* sequencing: no file, no renderer, no directive, no mode effect. */
|
|
39
|
+
export const AWAIT_PORT = Object.freeze({ id: 'await', type: 'any', required: false, synthetic: true });
|
|
40
|
+
|
|
41
|
+
/** Task card — the graph's single source: zero inputs, one always-firing md
|
|
42
|
+
* output carrying the rendered task md. */
|
|
43
|
+
export const TASK_PORTS = Object.freeze({
|
|
44
|
+
inputs: Object.freeze([]),
|
|
45
|
+
outputs: Object.freeze([Object.freeze({ id: 'task', type: 'md', when: 'always' })]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** End card — the graph's single sink: one single-wire input, zero outputs. The
|
|
49
|
+
* token arriving on its wire completes the run. */
|
|
50
|
+
export const END_PORTS = Object.freeze({
|
|
51
|
+
inputs: Object.freeze([Object.freeze({ id: 'result', type: 'any', required: true, loop: false, expands: false })]),
|
|
52
|
+
outputs: Object.freeze([]),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ports of a gate card at a given arity. AND is the pure synchronizer (`any`
|
|
57
|
+
* ins, ONE static `void` out, fires when all ins are fresh, payloads discarded);
|
|
58
|
+
* OR is the payload-forwarding valve (fires on any fresh in, re-emits that
|
|
59
|
+
* payload — its in/out types RESOLVE FROM WIRING, so `any` here is the
|
|
60
|
+
* pre-resolution placeholder); Combine is the md AND-join.
|
|
61
|
+
* @param {'and'|'or'|'combine'} kind
|
|
62
|
+
* @param {number} arity clamped to [2, MAX_PORTS_PER_SIDE]
|
|
63
|
+
* @returns {{inputs: Array<object>, outputs: Array<object>}}
|
|
64
|
+
*/
|
|
65
|
+
export function gatePorts(kind, arity) {
|
|
66
|
+
const n = Math.min(MAX_PORTS_PER_SIDE, Math.max(2, Math.floor(Number(arity)) || 2));
|
|
67
|
+
const inType = kind === 'combine' ? 'md' : 'any';
|
|
68
|
+
const inputs = [];
|
|
69
|
+
for (let i = 1; i <= n; i += 1) {
|
|
70
|
+
inputs.push(Object.freeze({ id: `in${i}`, type: inType, required: true, loop: false, expands: false }));
|
|
71
|
+
}
|
|
72
|
+
const outType = kind === 'and' ? 'void' : kind === 'combine' ? 'md' : 'any';
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
inputs: Object.freeze(inputs),
|
|
75
|
+
outputs: Object.freeze([Object.freeze({ id: 'out', type: outType, when: 'always' })]),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Minted node ids are `n_` + 8 base36; the seed graphs use readable `n_<word>`
|
|
80
|
+
* ids. Both shapes match. */
|
|
81
|
+
export const NODE_ID_RE = /^n_[a-z0-9]{1,32}$/;
|
|
82
|
+
|
|
83
|
+
/** Minted wire ids are `w_` + 8 base36; the seed graphs use `w1`..`w17`. Both
|
|
84
|
+
* shapes match — the underscore is optional. */
|
|
85
|
+
export const WIRE_ID_RE = /^w_?[a-z0-9]{1,32}$/;
|
|
86
|
+
|
|
87
|
+
/** Port ids are lowerCamel, at most 32 chars (`task`, `revise`, `in1`, `await`). */
|
|
88
|
+
export const PORT_ID_RE = /^[a-z][A-Za-z0-9]{0,31}$/;
|
|
89
|
+
|
|
90
|
+
/** The two ledger rows every run writes for its own bookends (P8 makes them
|
|
91
|
+
* `exec` rows keyed exactly so). Shared by the run monitor and the CLI so
|
|
92
|
+
* neither counts them as executions or progress. */
|
|
93
|
+
export const BOOKEND_EXECUTION_IDS = Object.freeze(['x:preflight:1', 'x:done:1']);
|
|
94
|
+
|
|
95
|
+
/** Structural ceilings the validator enforces (override per call with
|
|
96
|
+
* `validateGraph(tpl, portsFn, { limits })`). */
|
|
97
|
+
export const LIMITS = Object.freeze({
|
|
98
|
+
maxNodes: 80,
|
|
99
|
+
maxWires: 200,
|
|
100
|
+
maxPortsPerSide: MAX_PORTS_PER_SIDE,
|
|
101
|
+
minArity: 2,
|
|
102
|
+
maxArity: MAX_PORTS_PER_SIDE,
|
|
103
|
+
maxCycles: 20,
|
|
104
|
+
maxNameLen: 80,
|
|
105
|
+
});
|