@worca/app 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
package/src/cli/worca-cc.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// CLI entry point. Parses flags, creates a core orchestrator, subscribes to its events,
|
|
5
5
|
// renders a phase tracker + streamed agent logs to the terminal, and drives interactive
|
|
6
6
|
// Q&A (clarify) and loop gates via node:readline. Supports --yes (auto), --mock,
|
|
7
|
-
// --install <dir> (delegates to
|
|
7
|
+
// --install <dir> (delegates to tools/install.mjs), ui start|stop|restart|status
|
|
8
8
|
// (--ui is an alias of `ui start`; see cmdUi),
|
|
9
9
|
// and -v/-V/--version (also the bare word `version`).
|
|
10
10
|
//
|
|
@@ -28,8 +28,17 @@ import {
|
|
|
28
28
|
} from '../core/projects.mjs';
|
|
29
29
|
import { projectKey } from '../core/store.mjs';
|
|
30
30
|
import { formatExecLine, formatGateHeader, formatRunSummary, formatWorkflowProposal } from './render.mjs';
|
|
31
|
+
// Ask forms (spec §8): the prompt FORMATTING lives in render.mjs too. A second import
|
|
32
|
+
// statement, not a longer first one — test/cli-exec-render.test.mjs pins the line above.
|
|
33
|
+
import { formatFormField, formatCoerceError, formatFormErrors, FORM_REPROMPT_MAX } from './render.mjs';
|
|
34
|
+
import { promptFields, projectForm, coerceInput } from '../shared/forms/project.mjs';
|
|
35
|
+
import { whenOk } from '../shared/forms/layout.mjs';
|
|
36
|
+
import { validate } from '../shared/forms/schema.mjs';
|
|
37
|
+
import { collectAnswer } from '../shared/forms/answer.mjs';
|
|
31
38
|
import { pauseExitCode, describePauseReason, promptOptions, REASON } from '../core/failure-policy.mjs';
|
|
32
39
|
import { effectiveDebugSpawn } from '../core/settings.mjs';
|
|
40
|
+
import { SCHEDULE_VALUE_FLAGS, wantsSchedule, readScheduleFlags, createFromFlags, waitAndRun, cmdSchedule } from './schedule.mjs';
|
|
41
|
+
import { cmdModels } from './models.mjs';
|
|
33
42
|
import {
|
|
34
43
|
DEFAULT_UI_HOST, DEFAULT_UI_PORT, probeUi, stopUi, readUiInstance, uiUrl, waitForUiState,
|
|
35
44
|
} from '../core/ui-instance.mjs';
|
|
@@ -94,10 +103,15 @@ function parseArgs(argv) {
|
|
|
94
103
|
workflow: undefined,
|
|
95
104
|
mock: false,
|
|
96
105
|
auto: false,
|
|
106
|
+
pastTeamCap: false, // team policy (design §12): start with the total-cap acknowledgement recorded
|
|
107
|
+
reason: undefined, // …and the reason the team sees for it
|
|
97
108
|
install: null,
|
|
98
109
|
sourceBranch: undefined,
|
|
99
110
|
featureBranch: undefined,
|
|
100
111
|
memoryScope: undefined,
|
|
112
|
+
after: undefined,
|
|
113
|
+
afterAny: false,
|
|
114
|
+
sourceFromPrevious: false,
|
|
101
115
|
help: false,
|
|
102
116
|
_: [],
|
|
103
117
|
};
|
|
@@ -114,8 +128,11 @@ function parseArgs(argv) {
|
|
|
114
128
|
'--source-branch',
|
|
115
129
|
'--branch',
|
|
116
130
|
'--memory-scope',
|
|
131
|
+
'--reason',
|
|
132
|
+
...Object.keys(SCHEDULE_VALUE_FLAGS),
|
|
117
133
|
]);
|
|
118
134
|
const map = {
|
|
135
|
+
...SCHEDULE_VALUE_FLAGS,
|
|
119
136
|
'--project': 'project',
|
|
120
137
|
'--prompt': 'prompt',
|
|
121
138
|
'--file': 'file',
|
|
@@ -128,6 +145,7 @@ function parseArgs(argv) {
|
|
|
128
145
|
'--source-branch': 'sourceBranch',
|
|
129
146
|
'--branch': 'featureBranch',
|
|
130
147
|
'--memory-scope': 'memoryScope',
|
|
148
|
+
'--reason': 'reason',
|
|
131
149
|
};
|
|
132
150
|
|
|
133
151
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -148,10 +166,20 @@ function parseArgs(argv) {
|
|
|
148
166
|
out.humanInLoop = false;
|
|
149
167
|
continue;
|
|
150
168
|
}
|
|
169
|
+
if (arg === '--past-team-cap') {
|
|
170
|
+
out.pastTeamCap = true;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
151
173
|
if (arg === '--ui') {
|
|
152
174
|
out.ui = true;
|
|
153
175
|
continue;
|
|
154
176
|
}
|
|
177
|
+
if (arg === '--wait') {
|
|
178
|
+
out.wait = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (arg === '--after-any') { out.afterAny = true; continue; }
|
|
182
|
+
if (arg === '--source-from-previous') { out.sourceFromPrevious = true; continue; }
|
|
155
183
|
|
|
156
184
|
let inlineValue;
|
|
157
185
|
const eq = arg.indexOf('=');
|
|
@@ -227,15 +255,22 @@ Subcommands:
|
|
|
227
255
|
remove <name> Remove a registered project by name (case-insensitive).
|
|
228
256
|
resume <pipelineId> Continue a paused pipeline (re-attaches Claude sessions).
|
|
229
257
|
[--ignore-cost-cap] Resume past this pipeline's cost cap (persists on the run).
|
|
258
|
+
[--past-team-cap] Continue past a TEAM cap (soft; recorded to team metrics). Add --reason "<why>".
|
|
230
259
|
doctor Reconcile crashed runs and sweep leftover run roots.
|
|
231
260
|
plugin <cmd> [...] Manage plugins: add|install|list|update|remove|purge|enable|
|
|
232
261
|
disable|doctor|link|reimport|init|validate|exec. See: worca plugin help
|
|
233
262
|
marketplace <cmd> [...] Manage plugin marketplaces: add|list|refresh|remove. See: worca marketplace help
|
|
263
|
+
script <cmd> [...] Manage and test scripts: list|show|new|rm|test. See: worca script help
|
|
234
264
|
config [get|set|unset] Budget & cost-limit settings
|
|
235
265
|
ui [start|stop|restart|status]
|
|
236
266
|
Run the web UI (default http://localhost:4317). See: worca ui help
|
|
237
267
|
workflow <cmd> [...] Export a workflow (Claude Code skill, JSON, or plugin) / import JSON: list|export|import. See: worca workflow help
|
|
238
268
|
metrics push [--project <path>] Push pending team-metrics run records (headless flush)
|
|
269
|
+
policy <cmd> [...] Team policy from the worca-policy branch: show|pull|init|setup. See: worca policy help
|
|
270
|
+
schedule <cmd> [...] Manage scheduled runs: list|show|run-now|move|cancel|skip|pause|resume|log.
|
|
271
|
+
See: worca schedule help
|
|
272
|
+
models <cmd> [...] Model catalog + providers: list|providers|login|logout|import|test|set.
|
|
273
|
+
See: worca models help
|
|
239
274
|
help Print this help (same as --help).
|
|
240
275
|
version Print the version (same as --version).
|
|
241
276
|
|
|
@@ -257,6 +292,16 @@ Options:
|
|
|
257
292
|
--source-branch <name> Branch to fork the per-run worktree from (default: current HEAD)
|
|
258
293
|
--branch <name> Feature branch name (default: claude proposes one)
|
|
259
294
|
--mock Offline mock mode (no claude, no tokens)
|
|
295
|
+
--at <when> Run ONCE, later: "02:00", "tomorrow 02:00", "+90m", "2026-09-19 02:00",
|
|
296
|
+
or ISO 8601 with an offset. Needs a Worca server up at that time — or --wait
|
|
297
|
+
--wait With --at: hold this terminal and start the run here when it is due
|
|
298
|
+
--every <pattern> Repeat: "day 03:30", "weekdays 02:00", "mon,thu 02:00", "month 1 02:00"
|
|
299
|
+
--cron "<m h dom mon dow>" Repeat (cron subset: fixed time + days of week or one day of month)
|
|
300
|
+
More schedule options (--until, --count, --overlap, --max-failures,
|
|
301
|
+
--if-missed, --grace, --tz): worca schedule help
|
|
302
|
+
--after <id> Start when another run ends: a run id or a scheduled run id (any unique prefix)
|
|
303
|
+
--after-any …even if that run fails or is stopped
|
|
304
|
+
--source-from-previous Start on that run's feature branch (with --after)
|
|
260
305
|
--yes, --non-interactive Auto-answer clarify (first option) and gates (continue)
|
|
261
306
|
--ui Same as "worca ui start" (accepts --port, --open, --mock)
|
|
262
307
|
--install <targetDir> Copy agents + /worca skill into <targetDir>/.claude
|
|
@@ -420,6 +465,113 @@ async function askWorkflow(rl, workflow) {
|
|
|
420
465
|
}
|
|
421
466
|
}
|
|
422
467
|
|
|
468
|
+
/** The answer field a P1 error path names: `notes`, `steps[1].verdict` -> `steps`. */
|
|
469
|
+
function fieldOfPath(path) {
|
|
470
|
+
return String(path || '').split(/[.[]/)[0] || '';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The answer schema for ONE field, or null (a stale layout, or a display widget). */
|
|
474
|
+
function fieldSchemaOf(ask, name) {
|
|
475
|
+
const props = ask && ask.answerSchema && ask.answerSchema.properties;
|
|
476
|
+
return props && Object.hasOwn(props, name) ? props[name] : null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read ONE entry for `f` until it coerces and validates. Returns the value, or
|
|
481
|
+
* `undefined` for an optional field the user left empty. Coercion is P1's
|
|
482
|
+
* coerceInput (ruling X7) — the CLI only prints and decides requiredness.
|
|
483
|
+
*/
|
|
484
|
+
async function readFormEntry(rl, f, prompt, indent = '') {
|
|
485
|
+
for (;;) {
|
|
486
|
+
const got = coerceInput(f, await question(rl, c('cyan', `${indent}${prompt}`)));
|
|
487
|
+
if (!got.ok) { out(c('red', `${indent}${formatCoerceError(f, got)}`)); continue; }
|
|
488
|
+
// coerceInput returns `undefined` for an empty entry meaning "use the default";
|
|
489
|
+
// applying it is the caller's job, and so is requiredness.
|
|
490
|
+
if (got.value === undefined) {
|
|
491
|
+
if (f.default !== undefined) return f.default;
|
|
492
|
+
if (f.required) { out(c('red', `${indent} ${f.label || f.field} is required`)); continue; }
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
return got.value;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Prompt ONE field and write it into `values`. */
|
|
500
|
+
async function askFormField(rl, ask, f, values) {
|
|
501
|
+
const { lines, prompt } = formatFormField(f);
|
|
502
|
+
for (const line of lines) out(line);
|
|
503
|
+
for (;;) {
|
|
504
|
+
const value = await readFormEntry(rl, f, prompt);
|
|
505
|
+
if (value === undefined) { delete values[f.field]; return; }
|
|
506
|
+
const schema = fieldSchemaOf(ask, f.field);
|
|
507
|
+
if (schema) {
|
|
508
|
+
const v = validate(schema, value);
|
|
509
|
+
if (!v.ok) {
|
|
510
|
+
for (const line of formatFormErrors(v.errors.map((e) => ({ ...e, path: e.path || f.field })))) out(c('red', line));
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
values[f.field] = value;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** A review-list: one row per bound item, each row prompting the field's itemFields. */
|
|
520
|
+
async function askReviewList(rl, f, values) {
|
|
521
|
+
const { lines } = formatFormField(f);
|
|
522
|
+
for (const line of lines) out(line);
|
|
523
|
+
const rows = [];
|
|
524
|
+
for (const item of (Array.isArray(f.items) ? f.items : [])) {
|
|
525
|
+
out(` ${item.label || item.id}`);
|
|
526
|
+
const row = { id: item.id };
|
|
527
|
+
for (const sub of (Array.isArray(f.itemFields) ? f.itemFields : [])) {
|
|
528
|
+
const { lines: subLines, prompt } = formatFormField(sub);
|
|
529
|
+
for (const line of subLines) out(` ${line}`);
|
|
530
|
+
const value = await readFormEntry(rl, sub, prompt, ' ');
|
|
531
|
+
if (value !== undefined) row[sub.field] = value;
|
|
532
|
+
}
|
|
533
|
+
rows.push(row);
|
|
534
|
+
}
|
|
535
|
+
values[f.field] = rows;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Ask ONE kind:'form' question interactively (spec §8). Prints P1's text projection
|
|
540
|
+
* — display widgets as text, files as `rel (mime, size)` — then prompts field by
|
|
541
|
+
* field in LAYOUT order, honouring `when` as answers accumulate (a field that
|
|
542
|
+
* becomes hidden loses its value and is not required). Each entry goes through P1's
|
|
543
|
+
* coerceInput + validate; the whole set through collectAnswer, which drops hidden
|
|
544
|
+
* fields, strips unknown keys and treats "" as missing. Returns { values }.
|
|
545
|
+
* Re-offers from the first offending field, at most FORM_REPROMPT_MAX times.
|
|
546
|
+
*/
|
|
547
|
+
async function askForm(rl, ask) {
|
|
548
|
+
out('');
|
|
549
|
+
const projected = projectForm(ask).split('\n');
|
|
550
|
+
out(c('bold', `? ${projected[0]}`));
|
|
551
|
+
for (const line of projected.slice(1)) out(line);
|
|
552
|
+
const fields = promptFields(ask);
|
|
553
|
+
const values = {};
|
|
554
|
+
let from = 0;
|
|
555
|
+
for (let pass = 1; ; pass++) {
|
|
556
|
+
for (let i = from; i < fields.length; i++) {
|
|
557
|
+
const f = fields[i];
|
|
558
|
+
if (!whenOk(f.when, values)) { delete values[f.field]; continue; }
|
|
559
|
+
if (f.widget === 'review-list') await askReviewList(rl, f, values);
|
|
560
|
+
else await askFormField(rl, ask, f, values);
|
|
561
|
+
}
|
|
562
|
+
const collected = collectAnswer(ask, ask.answerSchema, values);
|
|
563
|
+
if (!collected.errors.length) return { values: collected.values };
|
|
564
|
+
for (const line of formatFormErrors(collected.errors)) out(c('red', line));
|
|
565
|
+
if (pass >= FORM_REPROMPT_MAX) {
|
|
566
|
+
throw new Error(`form "${ask.form}" is still invalid after ${FORM_REPROMPT_MAX} attempts`);
|
|
567
|
+
}
|
|
568
|
+
const bad = new Set(collected.errors.map((e) => fieldOfPath(e.path)));
|
|
569
|
+
const first = fields.findIndex((f) => bad.has(f.field) && whenOk(f.when, values));
|
|
570
|
+
from = first >= 0 ? first : 0;
|
|
571
|
+
for (let i = from; i < fields.length; i++) delete values[fields[i].field];
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
423
575
|
// ── shared drive loop ────────────────────────────────────────────────────────────
|
|
424
576
|
|
|
425
577
|
/**
|
|
@@ -584,6 +736,37 @@ async function attachAndDrive(orch, flags, start) {
|
|
|
584
736
|
out(c('yellow', c('bold', `${agent || 'Agent'} has questions:`)));
|
|
585
737
|
const payload = await askClarify(rl, questions || []);
|
|
586
738
|
orch.answer(id, payload);
|
|
739
|
+
} else if (kind === 'form') {
|
|
740
|
+
if (payload.surface === 'web') {
|
|
741
|
+
// Spec §8 (as corrected by ruling X11) lets a form declare that a text
|
|
742
|
+
// answer is meaningless. Chat prints it and keeps waiting — a chat run
|
|
743
|
+
// lives in ui/server.mjs's runs Map and a browser can answer it. A CLI
|
|
744
|
+
// run owns its orchestrator in-process, and `question-resolved` is a
|
|
745
|
+
// browser-only WebSocket broadcast, so NOTHING here can ever answer it.
|
|
746
|
+
// Waiting would hang forever with the pipelines row left `running`
|
|
747
|
+
// (MAJ-7). Print what was asked, say where it is answered, abandon.
|
|
748
|
+
out('');
|
|
749
|
+
const projected = projectForm(payload).split('\n');
|
|
750
|
+
out(c('bold', `? ${projected[0]}`));
|
|
751
|
+
for (const line of projected.slice(1)) out(line);
|
|
752
|
+
out(c('yellow', 'This form is answered in the worca web UI.'));
|
|
753
|
+
abandonAnswer(new Error(`form "${payload.form}" is web-only`));
|
|
754
|
+
} else {
|
|
755
|
+
out(c('yellow', c('bold', `${agent || 'Agent'} needs a form answered:`)));
|
|
756
|
+
for (let attempt = 1; ; attempt++) {
|
|
757
|
+
const answer = await askForm(rl, payload);
|
|
758
|
+
try {
|
|
759
|
+
orch.answer(id, answer);
|
|
760
|
+
break;
|
|
761
|
+
} catch (err) {
|
|
762
|
+
if (!err || err.code !== 'INVALID_ANSWER') throw err;
|
|
763
|
+
for (const line of formatFormErrors(err.errors)) out(c('red', line));
|
|
764
|
+
if (attempt >= FORM_REPROMPT_MAX) {
|
|
765
|
+
throw new Error(`form "${payload.form}" was rejected ${attempt} times`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
587
770
|
}
|
|
588
771
|
} catch (err) {
|
|
589
772
|
process.stderr.write(`Failed to read answer: ${err?.message || err}\n`);
|
|
@@ -850,9 +1033,9 @@ async function cmdUi(argv) {
|
|
|
850
1033
|
return uiStart(a);
|
|
851
1034
|
}
|
|
852
1035
|
|
|
853
|
-
/** Delegate to
|
|
1036
|
+
/** Delegate to tools/install.mjs, forwarding the target dir and any passthrough args. */
|
|
854
1037
|
function runInstall(targetDir, passthrough) {
|
|
855
|
-
const script = join(REPO_ROOT, '
|
|
1038
|
+
const script = join(REPO_ROOT, 'tools', 'install.mjs');
|
|
856
1039
|
const args = [script, targetDir, ...passthrough];
|
|
857
1040
|
const child = spawn(process.execPath, args, { stdio: 'inherit' });
|
|
858
1041
|
return new Promise((res) => {
|
|
@@ -1108,12 +1291,15 @@ async function cmdConfig(argv) {
|
|
|
1108
1291
|
async function cmdResume(argv) {
|
|
1109
1292
|
const id = (argv.find((a) => !a.startsWith('--')) || '').trim();
|
|
1110
1293
|
if (!id) {
|
|
1111
|
-
process.stderr.write('usage: worca resume <pipelineId> [--mock] [--yes] [--ignore-cost-cap]\n');
|
|
1294
|
+
process.stderr.write('usage: worca resume <pipelineId> [--mock] [--yes] [--ignore-cost-cap] [--past-team-cap [--reason "<why>"]]\n');
|
|
1112
1295
|
return 1;
|
|
1113
1296
|
}
|
|
1114
1297
|
const mock = argv.includes('--mock');
|
|
1115
1298
|
const auto = argv.includes('--yes') || argv.includes('--non-interactive');
|
|
1116
1299
|
const ignoreCap = argv.includes('--ignore-cost-cap');
|
|
1300
|
+
const pastTeamCap = argv.includes('--past-team-cap');
|
|
1301
|
+
const reasonAt = argv.indexOf('--reason');
|
|
1302
|
+
const policyReason = reasonAt !== -1 ? argv[reasonAt + 1] ?? null : null;
|
|
1117
1303
|
if (mock) process.env.WORCA_MOCK = '1';
|
|
1118
1304
|
|
|
1119
1305
|
const { readPipelineForResume, reconcileStaleRunning } = await import('../core/artifacts.mjs');
|
|
@@ -1203,6 +1389,23 @@ async function cmdResume(argv) {
|
|
|
1203
1389
|
return 1;
|
|
1204
1390
|
}
|
|
1205
1391
|
|
|
1392
|
+
// Team policy gates (design §7, §12): soft. --past-team-cap records the choice (once per
|
|
1393
|
+
// window for the total cap, per run for the pipeline cap); under --yes the harness warns instead.
|
|
1394
|
+
{
|
|
1395
|
+
const { checkTeamTotalGate, checkTeamPipelineGate } = await import('../core/policy/gate.mjs');
|
|
1396
|
+
const target = workspace ? { workspaceId: workspace.id } : { projectDir };
|
|
1397
|
+
const totalGate = await checkTeamTotalGate(target, { pastTeamCap, reason: policyReason, unattended: auto });
|
|
1398
|
+
if (totalGate.blocked) {
|
|
1399
|
+
process.stderr.write(`worca: ${totalGate.error}. ${totalGate.code === 'reason_required' ? 'Add --reason "<why>".' : `Continue: worca resume ${id} --past-team-cap [--reason "<why>"]`}\n`);
|
|
1400
|
+
return 1;
|
|
1401
|
+
}
|
|
1402
|
+
const pipeGate = checkTeamPipelineGate(totalGate.caps, { pipelineId: id, spentSoFar, pastTeamCap, reason: policyReason, unattended: auto });
|
|
1403
|
+
if (pipeGate.blocked) {
|
|
1404
|
+
process.stderr.write(`worca: ${pipeGate.error}. ${pipeGate.code === 'reason_required' ? 'Add --reason "<why>".' : `Continue: worca resume ${id} --past-team-cap [--reason "<why>"]`}\n`);
|
|
1405
|
+
return 1;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1206
1409
|
const orch = await createOrchestratorFor({
|
|
1207
1410
|
projectDir,
|
|
1208
1411
|
...(workspace ? { workspace } : {}),
|
|
@@ -1234,7 +1437,11 @@ Usage:
|
|
|
1234
1437
|
worca plugin link <dir> Dev mode: use a local dir as "current"
|
|
1235
1438
|
worca plugin reimport <name> Re-read the plugin's pipeline templates (a linked dir is live-edited)
|
|
1236
1439
|
worca plugin init <name> [--dir <D>] [--with task-source,agents,skills,workflows]
|
|
1237
|
-
worca plugin
|
|
1440
|
+
worca plugin new-script <key> [--runtime <runtime>] [--dir <plugin dir>]
|
|
1441
|
+
Scaffold scripts/<key>: sidecar, source and one
|
|
1442
|
+
sample case (runtimes: worca script help)
|
|
1443
|
+
worca plugin validate <dir> [--strict] [--run-cases] Lint a plugin dir (--strict: unknown fields error;
|
|
1444
|
+
--run-cases: run every shipped script case)
|
|
1238
1445
|
worca plugin exec <name> <sourceId> <op> [--args '<json>'] [--profile <id>] [--inspect] Debug one connector op
|
|
1239
1446
|
worca plugin channel <name> <channelId> [--check] [--inspect] Run a chat channel worker in the
|
|
1240
1447
|
foreground (typed lines = simulated inbound); --check runs
|
|
@@ -1262,7 +1469,7 @@ shareable JSON, or as a Worca plugin) and import one shared as JSON
|
|
|
1262
1469
|
Usage:
|
|
1263
1470
|
worca workflow list List workflows (id, name, domain)
|
|
1264
1471
|
worca workflow export <id> [options] Export a workflow (see --format)
|
|
1265
|
-
worca workflow import <file> [--name <name>]
|
|
1472
|
+
worca workflow import <file> [--name <name>] [--accept-scripts] Import a JSON export into your library ('-' = stdin); --accept-scripts confirms script commands
|
|
1266
1473
|
|
|
1267
1474
|
Export formats (--format):
|
|
1268
1475
|
claude (default) A runnable Claude Code skill tree under <dest>/.claude/
|
|
@@ -1293,8 +1500,9 @@ Export always prints the plan first, then applies (unless --dry-run). A re-expor
|
|
|
1293
1500
|
of an unchanged workflow is an all-no-op. Exit codes: 0 ok, 1 failure, 2 usage/validation errors.
|
|
1294
1501
|
`;
|
|
1295
1502
|
|
|
1296
|
-
/** Tiny per-verb arg parser: positionals plus declared --value / --bool flags.
|
|
1297
|
-
|
|
1503
|
+
/** Tiny per-verb arg parser: positionals plus declared --value / --bool flags.
|
|
1504
|
+
* A `repeatFlags` entry keeps EVERY occurrence, as an array (`--param`, `--input`). */
|
|
1505
|
+
function pluginArgs(argv, valueFlags = [], boolFlags = [], repeatFlags = []) {
|
|
1298
1506
|
const out = { _: [] };
|
|
1299
1507
|
for (let i = 0; i < argv.length; i++) {
|
|
1300
1508
|
let a = argv[i];
|
|
@@ -1304,7 +1512,12 @@ function pluginArgs(argv, valueFlags = [], boolFlags = []) {
|
|
|
1304
1512
|
inline = a.slice(eq + 1);
|
|
1305
1513
|
a = a.slice(0, eq);
|
|
1306
1514
|
}
|
|
1307
|
-
if (
|
|
1515
|
+
if (repeatFlags.includes(a)) {
|
|
1516
|
+
const v = inline !== undefined ? inline : argv[++i];
|
|
1517
|
+
if (v === undefined) fail(`Flag ${a} requires a value.`);
|
|
1518
|
+
const name = a.slice(2);
|
|
1519
|
+
(out[name] || (out[name] = [])).push(v);
|
|
1520
|
+
} else if (valueFlags.includes(a)) {
|
|
1308
1521
|
const v = inline !== undefined ? inline : argv[++i];
|
|
1309
1522
|
if (v === undefined) fail(`Flag ${a} requires a value.`);
|
|
1310
1523
|
out[a.slice(2)] = v;
|
|
@@ -1341,6 +1554,7 @@ function contribSummary(x) {
|
|
|
1341
1554
|
[n(b.taskSources), 'source', 'sources'],
|
|
1342
1555
|
[n(b.chatChannels), 'chat channel', 'chat channels'],
|
|
1343
1556
|
[n(b.agents), 'agent', 'agents'],
|
|
1557
|
+
[n(b.scripts), 'script', 'scripts'],
|
|
1344
1558
|
[n(b.skills), 'skill', 'skills'],
|
|
1345
1559
|
[n(b.workflows), 'workflow', 'workflows'],
|
|
1346
1560
|
]
|
|
@@ -1350,7 +1564,7 @@ function contribSummary(x) {
|
|
|
1350
1564
|
}
|
|
1351
1565
|
|
|
1352
1566
|
/** Print the post-export install inventory (spec §6.1 consent items). */
|
|
1353
|
-
function printInventory(inv) {
|
|
1567
|
+
async function printInventory(inv) {
|
|
1354
1568
|
const i = inv || {};
|
|
1355
1569
|
for (const s of i.taskSources || []) {
|
|
1356
1570
|
out(` task source: ${s.id} (${s.displayName})${s.secrets?.length ? ` — secrets: ${s.secrets.join(', ')}` : ''}`);
|
|
@@ -1362,7 +1576,22 @@ function printInventory(inv) {
|
|
|
1362
1576
|
}
|
|
1363
1577
|
for (const a of i.agents || []) {
|
|
1364
1578
|
out(` agent: ${a.key}${a.tools?.length ? ` (tools: ${a.tools.join(', ')})` : ''}`);
|
|
1579
|
+
const forms = Array.isArray(a.forms) ? a.forms : [];
|
|
1580
|
+
if (forms.length) {
|
|
1581
|
+
const types = Array.isArray(a.fileTypes) ? a.fileTypes : [];
|
|
1582
|
+
out(` ${forms.length} form${forms.length === 1 ? '' : 's'}: ${forms.join(', ')}`
|
|
1583
|
+
+ (types.length ? ` — may display ${types.join(', ')} from the run folder` : ''));
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
for (const s of i.scripts || []) {
|
|
1587
|
+
out(` script: ${s.key} (${s.runtime}${s.command ? `, ${s.command}` : s.file ? `, ${s.file}` : ''})`);
|
|
1365
1588
|
}
|
|
1589
|
+
// What ships in one line, plus the host-fact notice when it applies (§8.1).
|
|
1590
|
+
const { scriptsSummary, pythonNoticeFor } = await import('../core/plugin-store.mjs');
|
|
1591
|
+
const summary = scriptsSummary(i.scripts);
|
|
1592
|
+
if (summary) out(` ${summary}`);
|
|
1593
|
+
const notice = await pythonNoticeFor(i.scripts);
|
|
1594
|
+
if (notice) out(c('yellow', ` ${notice}`));
|
|
1366
1595
|
for (const s of i.skills || []) out(` skill: ${s}`);
|
|
1367
1596
|
for (const w of i.workflows || []) out(` workflow: ${w}`);
|
|
1368
1597
|
if (i.depCount != null) out(` npm dependencies: ${i.depCount}`);
|
|
@@ -1413,7 +1642,7 @@ async function pluginInit(rest) {
|
|
|
1413
1642
|
name,
|
|
1414
1643
|
version: '0.1.0',
|
|
1415
1644
|
description: 'Scaffolded worca plugin — edit me',
|
|
1416
|
-
engines: { 'worca-cc-api': '>=
|
|
1645
|
+
engines: { 'worca-cc-api': '>=4 <5' },
|
|
1417
1646
|
};
|
|
1418
1647
|
if (withParts.includes('task-source')) {
|
|
1419
1648
|
manifestObj.taskSources = [{
|
|
@@ -1551,6 +1780,104 @@ async function pluginInit(rest) {
|
|
|
1551
1780
|
return 0;
|
|
1552
1781
|
}
|
|
1553
1782
|
|
|
1783
|
+
/** `worca plugin new-script <key>` — the script half of the scaffold (spec §6):
|
|
1784
|
+
* sidecar + source + one sample case, all three in the shape `worca plugin
|
|
1785
|
+
* validate` accepts, so a plugin author never hand-rolls a meta v2 file. */
|
|
1786
|
+
async function pluginNewScript(rest) {
|
|
1787
|
+
const { SCRIPT_RUNTIMES, validateScriptMetaV2, normalizeScriptMeta } = await import('../shared/graph/script-meta.mjs');
|
|
1788
|
+
const a = pluginArgs(rest, ['--runtime', '--dir'], []);
|
|
1789
|
+
const key = a._[0];
|
|
1790
|
+
if (!key) fail(`Usage: worca plugin new-script <key> [--runtime ${SCRIPT_RUNTIMES.join('|')}] [--dir <plugin dir>]`);
|
|
1791
|
+
const runtime = a.runtime || 'node';
|
|
1792
|
+
if (!SCRIPT_RUNTIMES.includes(runtime)) fail(`--runtime must be one of ${SCRIPT_RUNTIMES.join(', ')} (got ${runtime})`);
|
|
1793
|
+
|
|
1794
|
+
const target = resolve(process.cwd(), a.dir || '.');
|
|
1795
|
+
const { existsSync } = await import('node:fs');
|
|
1796
|
+
if (!existsSync(join(target, 'worca-cc-plugin.json'))) {
|
|
1797
|
+
process.stderr.write(`worca plugin new-script: ${target} is not a plugin folder (no worca-cc-plugin.json) `
|
|
1798
|
+
+ '— scaffold one with: worca plugin init <name>\n');
|
|
1799
|
+
return 2;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const tpl = await import('../shared/graph/script-templates.mjs');
|
|
1803
|
+
// Line endings belong to the writer: the store's own rule (CRLF for a .cmd, LF
|
|
1804
|
+
// for a .sh), so this scaffold and `worca script new` put the same bytes on disk.
|
|
1805
|
+
const { programText } = await import('../core/script-store.mjs');
|
|
1806
|
+
const meta = tpl.scriptMetaTemplate(key, runtime);
|
|
1807
|
+
// The key gate is the normalizer's, not a second regex: one source of truth.
|
|
1808
|
+
const { errors } = validateScriptMetaV2(meta);
|
|
1809
|
+
if (errors.length) {
|
|
1810
|
+
for (const e of errors) process.stderr.write(`worca plugin new-script: ${e}\n`);
|
|
1811
|
+
return 2;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// A key the HOST would never load is refused at the scaffold, not discovered
|
|
1815
|
+
// after install: the reserved route segments and the Windows device stems (the
|
|
1816
|
+
// store's own gate), and a key a built-in script or a built-in agent already
|
|
1817
|
+
// holds — the registry drops the plugin's copy on every host (D16, builtin > plugin).
|
|
1818
|
+
// Only the BUILT-IN layers are read: the author's user layer says nothing about
|
|
1819
|
+
// the recipient's machine.
|
|
1820
|
+
const { assertKeyAllowed } = await import('../core/script-store.mjs');
|
|
1821
|
+
try { assertKeyAllowed(key); } catch (e) {
|
|
1822
|
+
process.stderr.write(`worca plugin new-script: ${e.message}\n`);
|
|
1823
|
+
return 2;
|
|
1824
|
+
}
|
|
1825
|
+
const { loadScriptRegistry } = await import('../core/script-registry.mjs');
|
|
1826
|
+
const { loadAgentRegistry } = await import('../core/agent-registry.mjs');
|
|
1827
|
+
const lower = key.toLowerCase();
|
|
1828
|
+
const heldBy = (reg) => Object.keys(reg).find((k) => k.toLowerCase() === lower);
|
|
1829
|
+
const builtinScript = heldBy(loadScriptRegistry({ userScriptsDir: null, includePlugins: false, agentKeys: null }));
|
|
1830
|
+
const builtinAgent = heldBy(loadAgentRegistry(undefined, { userAgentsDir: null, includePlugins: false }));
|
|
1831
|
+
if (builtinScript || builtinAgent) {
|
|
1832
|
+
process.stderr.write(`worca plugin new-script: "${key}" is taken by the built-in ${builtinScript ? 'script' : 'agent'} `
|
|
1833
|
+
+ `"${builtinScript || builtinAgent}" — a plugin script with that key is never loaded; pick another key\n`);
|
|
1834
|
+
return 2;
|
|
1835
|
+
}
|
|
1836
|
+
if (existsSync(join(target, 'agents', `${key}.meta.json`))) {
|
|
1837
|
+
process.stderr.write(`worca plugin new-script: this plugin already ships an agent "${key}" `
|
|
1838
|
+
+ '— scripts and agents share one namespace, so pick another key\n');
|
|
1839
|
+
return 2;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
const files = new Map();
|
|
1843
|
+
files.set(`${key}.meta.json`, JSON.stringify(meta, null, 2) + '\n');
|
|
1844
|
+
if (runtime === 'shell') {
|
|
1845
|
+
// BOTH halves: on a Windows host the runner hands the platform's entry to
|
|
1846
|
+
// cmd.exe, which cannot run a .sh (§10) — and a shared plugin must run on every OS.
|
|
1847
|
+
files.set(`${key}.sh`, programText('shell', tpl.scriptSourceTemplate(runtime)));
|
|
1848
|
+
files.set(`${key}.cmd`, programText('shell', tpl.scriptSourceTemplate(runtime, { win32: true }), { win32: true }));
|
|
1849
|
+
} else {
|
|
1850
|
+
files.set(`${key}.${runtime === 'python' ? 'py' : 'mjs'}`, tpl.scriptSourceTemplate(runtime));
|
|
1851
|
+
}
|
|
1852
|
+
// The sample is built from the NORMALIZED meta, so the case that ships is the
|
|
1853
|
+
// one `worca plugin validate --run-cases` will run.
|
|
1854
|
+
files.set(`${key}.tests.json`, JSON.stringify(tpl.sampleCasesTemplate(normalizeScriptMeta(meta).meta), null, 2) + '\n');
|
|
1855
|
+
|
|
1856
|
+
const dir = join(target, 'scripts');
|
|
1857
|
+
for (const name of files.keys()) {
|
|
1858
|
+
if (existsSync(join(dir, name))) {
|
|
1859
|
+
process.stderr.write(`worca plugin new-script: scripts/${name} already exists in ${target}\n`);
|
|
1860
|
+
return 1; // nothing written: every target is checked first
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
const { mkdir, writeFile, chmod } = await import('node:fs/promises');
|
|
1864
|
+
await mkdir(dir, { recursive: true });
|
|
1865
|
+
for (const [name, text] of files) {
|
|
1866
|
+
await writeFile(join(dir, name), text, 'utf8');
|
|
1867
|
+
out(`created\t${join(dir, name)}`);
|
|
1868
|
+
}
|
|
1869
|
+
if (runtime === 'shell') {
|
|
1870
|
+
try { await chmod(join(dir, `${key}.sh`), 0o755); } catch { /* Windows has no mode bits */ }
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const { validatePluginDir } = await import('../core/plugin-manifest.mjs');
|
|
1874
|
+
const v = validatePluginDir(target);
|
|
1875
|
+
for (const p of v.problems) process.stderr.write(`${p.level}: ${p.message}\n`);
|
|
1876
|
+
if (!v.ok) return 1;
|
|
1877
|
+
out(`next: worca plugin validate ${target} --run-cases`);
|
|
1878
|
+
return 0;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1554
1881
|
/** `worca plugin <verb> …` — dispatch. */
|
|
1555
1882
|
async function cmdPlugin(argv) {
|
|
1556
1883
|
const verb = argv[0];
|
|
@@ -1631,7 +1958,7 @@ async function cmdPlugin(argv) {
|
|
|
1631
1958
|
}
|
|
1632
1959
|
const res = await store.installPlugin({ repoUrl, subdir: entry.subdir, name, sha, ...(marketplace ? { marketplace } : {}) });
|
|
1633
1960
|
out('installed:');
|
|
1634
|
-
printInventory(res.inventory);
|
|
1961
|
+
await printInventory(res.inventory);
|
|
1635
1962
|
printIgnored(res.ignored);
|
|
1636
1963
|
return 0;
|
|
1637
1964
|
}
|
|
@@ -1774,18 +2101,57 @@ async function cmdPlugin(argv) {
|
|
|
1774
2101
|
case 'init':
|
|
1775
2102
|
return await pluginInit(rest);
|
|
1776
2103
|
|
|
2104
|
+
case 'new-script':
|
|
2105
|
+
return await pluginNewScript(rest);
|
|
2106
|
+
|
|
1777
2107
|
case 'validate': {
|
|
1778
|
-
const a = pluginArgs(rest, [], ['--strict']);
|
|
2108
|
+
const a = pluginArgs(rest, [], ['--strict', '--run-cases']);
|
|
1779
2109
|
const dir = a._[0];
|
|
1780
|
-
if (!dir) fail('Usage: worca plugin validate <dir> [--strict]');
|
|
1781
|
-
const
|
|
2110
|
+
if (!dir) fail('Usage: worca plugin validate <dir> [--strict] [--run-cases]');
|
|
2111
|
+
const abs = resolve(process.cwd(), dir);
|
|
2112
|
+
const v = manifestMod.validatePluginDir(abs, { strict: !!a.strict });
|
|
1782
2113
|
for (const p of v.problems) {
|
|
1783
2114
|
out(`${p.level === 'error' ? c('red', 'error') : c('yellow', 'warn ')}: ${p.message}`);
|
|
1784
2115
|
}
|
|
1785
|
-
if (!v.ok) return 2;
|
|
2116
|
+
if (!v.ok) return 2; // a dir that does not lint never runs its cases
|
|
1786
2117
|
const warns = v.problems.length;
|
|
1787
2118
|
out(`OK: ${v.manifest.name}${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}`);
|
|
1788
|
-
return 0;
|
|
2119
|
+
if (!a['run-cases']) return 0;
|
|
2120
|
+
// --run-cases: every SHIPPED case through the real bench, scratch cwd (§8.1),
|
|
2121
|
+
// under the two rules `worca script test` runs by: a signal STOPS the child tree
|
|
2122
|
+
// instead of orphaning it (benchStopper), and the streamed lines can pass the
|
|
2123
|
+
// 64 KiB pipe buffer — a CI log IS a pipe — so the exit code returns flushed.
|
|
2124
|
+
const { runPluginScriptCases } = await import('../core/plugin-script-cases.mjs');
|
|
2125
|
+
return await flushed((async () => {
|
|
2126
|
+
const stopper = benchStopper();
|
|
2127
|
+
let r;
|
|
2128
|
+
try {
|
|
2129
|
+
r = await runPluginScriptCases(abs, {
|
|
2130
|
+
onBench: stopper.onBench,
|
|
2131
|
+
stopRequested: stopper.requested,
|
|
2132
|
+
// What the script printed is the evidence when a case goes red in CI.
|
|
2133
|
+
onLine: (ev) => process.stderr.write(`[${ev.key}/${ev.caseId}] ${String(ev.text || '').replace(/\n$/, '')}\n`),
|
|
2134
|
+
});
|
|
2135
|
+
} finally {
|
|
2136
|
+
stopper.release();
|
|
2137
|
+
}
|
|
2138
|
+
for (const p of r.problems) out(`${c('yellow', 'warn ')}: ${p}`);
|
|
2139
|
+
for (const s of r.scripts) {
|
|
2140
|
+
for (const k of s.cases) {
|
|
2141
|
+
out(`${k.pass ? c('green', '✓') : c('red', '✗')} ${s.key}/${k.caseId}`
|
|
2142
|
+
+ `\t${k.status}\t${(k.durationMs / 1000).toFixed(1)}s${k.checked ? '' : '\tno expectation'}`);
|
|
2143
|
+
for (const d of k.diffs) out(` ${d}`);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
const total = r.passed + r.failed + r.unchecked;
|
|
2147
|
+
out(total ? `${r.passed} passed, ${r.failed} failed, ${r.unchecked} unchecked` : 'no shipped cases');
|
|
2148
|
+
if (r.stopped) {
|
|
2149
|
+
// Nothing past the stopped case ran: 2, like a stopped `worca script test`.
|
|
2150
|
+
process.stderr.write('worca plugin validate: stopped — the remaining cases did not run\n');
|
|
2151
|
+
return 2;
|
|
2152
|
+
}
|
|
2153
|
+
return r.failed ? 1 : 0;
|
|
2154
|
+
})());
|
|
1789
2155
|
}
|
|
1790
2156
|
|
|
1791
2157
|
case 'exec': {
|
|
@@ -1950,7 +2316,7 @@ async function cmdWorkflow(argv) {
|
|
|
1950
2316
|
return 0;
|
|
1951
2317
|
}
|
|
1952
2318
|
case 'import': {
|
|
1953
|
-
const a = pluginArgs(rest, ['--name'], []);
|
|
2319
|
+
const a = pluginArgs(rest, ['--name'], ['--accept-scripts']);
|
|
1954
2320
|
const file = a._[0];
|
|
1955
2321
|
if (!file) fail('Usage: worca workflow import <file> [--name <name>]');
|
|
1956
2322
|
const { readFile } = await import('node:fs/promises');
|
|
@@ -1963,7 +2329,15 @@ async function cmdWorkflow(argv) {
|
|
|
1963
2329
|
let obj;
|
|
1964
2330
|
try { obj = JSON.parse(text); } catch (e) { process.stderr.write(`worca workflow import: ${file} is not valid JSON (${e.message})\n`); return 2; }
|
|
1965
2331
|
try {
|
|
1966
|
-
|
|
2332
|
+
// D18: a shared workflow may carry commands that run with worca's privileges — show them once.
|
|
2333
|
+
const dry = await share.importGraphWorkflow(obj, { name: a.name, dryRun: true });
|
|
2334
|
+
if (dry.scriptNodes.length && !a['accept-scripts']) {
|
|
2335
|
+
process.stderr.write(share.formatScriptNodes(dry.scriptNodes));
|
|
2336
|
+
process.stderr.write('worca workflow import: re-run with --accept-scripts to import a workflow that runs these commands\n');
|
|
2337
|
+
return 2;
|
|
2338
|
+
}
|
|
2339
|
+
// Reaching here means: no script commands, or the user passed the flag after seeing them above.
|
|
2340
|
+
const r = await share.importGraphWorkflow(obj, { name: a.name, acceptScripts: a['accept-scripts'] === true });
|
|
1967
2341
|
out(`imported\t${r.workflow.id}\t${r.workflow.name}`);
|
|
1968
2342
|
if (r.renamed) out(c('yellow', `renamed: "${r.requestedName}" was already taken — saved as "${r.workflow.name}"`));
|
|
1969
2343
|
for (const w of r.warnings || []) out(`${c('yellow', 'warn')}\t${formatIssue(w)}`);
|
|
@@ -2010,6 +2384,7 @@ async function cmdWorkflow(argv) {
|
|
|
2010
2384
|
for (const p of r.updated) out(`${c('cyan', 'update')}\t${p}`);
|
|
2011
2385
|
for (const p of r.noop) out(`${c('gray', 'no-op')}\t${p}`);
|
|
2012
2386
|
for (const s of r.skipped) out(`${c('gray', 'skip')}\t${s.path}\t(${s.reason})`);
|
|
2387
|
+
for (const s of r.scripts || []) out(`${c('cyan', 'script')}\t${s.key}\t${s.runtime}`);
|
|
2013
2388
|
for (const w of r.warnings || []) out(`${c('yellow', 'warn')}\t${w}`);
|
|
2014
2389
|
for (const p of r.written) out(`${c('green', 'wrote')}\t${p}`);
|
|
2015
2390
|
if (r.validation && !r.validation.ok) {
|
|
@@ -2064,6 +2439,439 @@ async function cmdWorkflow(argv) {
|
|
|
2064
2439
|
}
|
|
2065
2440
|
}
|
|
2066
2441
|
|
|
2442
|
+
// ── script subcommands ─────────────────────────────────────────────────────────
|
|
2443
|
+
// `worca script …` (spec §6). Core only: the store and the bench run in THIS
|
|
2444
|
+
// process, so a script is listed, written and tested with no server running.
|
|
2445
|
+
// Imports stay lazy (mirrors cmdPlugin) so a pipeline run never loads them.
|
|
2446
|
+
// Exit codes: 0 ok, 1 failure, 2 usage/validation errors — except `test`, whose
|
|
2447
|
+
// codes are the RUN's verdict (scriptTest, Task 3).
|
|
2448
|
+
|
|
2449
|
+
function scriptHelp(runtimes) {
|
|
2450
|
+
return `worca script — registered scripts (script cards): list, author, test
|
|
2451
|
+
|
|
2452
|
+
Usage:
|
|
2453
|
+
worca script list [--json] Every registered script (key, name, origin, runtime, cases)
|
|
2454
|
+
worca script show <key> [--json] One script: meta, resolved file or command, source
|
|
2455
|
+
worca script new <key> [--runtime ${runtimes.join('|')}] [--from <key>]
|
|
2456
|
+
A user script from the template, or a copy of another
|
|
2457
|
+
worca script rm <key> Delete a user script
|
|
2458
|
+
worca script test <key> [options] Run it once in a bench folder
|
|
2459
|
+
|
|
2460
|
+
worca script test options:
|
|
2461
|
+
--case <id> Run one saved case (not combinable with the value flags below)
|
|
2462
|
+
--all Run every saved case, in order
|
|
2463
|
+
--param <id>=<value> Param value; repeatable
|
|
2464
|
+
--input <port>=<value> Bound input; repeatable. <value> is text, @<file>, or
|
|
2465
|
+
fired for a void port; @@ starts a literal @
|
|
2466
|
+
--cwd <dir> Working dir for this run (default: a scratch folder)
|
|
2467
|
+
--project <key> Working dir = a registered project's checkout
|
|
2468
|
+
--timeout <s> Timeout in seconds, 1 to 86400 (default: the script's own)
|
|
2469
|
+
--json The full result as JSON on stdout
|
|
2470
|
+
|
|
2471
|
+
Lines stream to stderr; the result goes to stdout.
|
|
2472
|
+
worca script test exit codes: with an expectation 0 all passed, 1 any failed; without
|
|
2473
|
+
one 0 clean, 1 blocking; 2 an execution error, a timeout, bad arguments or an unknown
|
|
2474
|
+
key. Every other verb: 0 ok, 1 failure, 2 usage/validation errors.
|
|
2475
|
+
`;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/** The ports line for `show`: a config-ported sidecar has none of its own. */
|
|
2479
|
+
const scriptPortLine = (m) => (m.ports === 'config' ? 'ports per card' : (m.portSummary || ''));
|
|
2480
|
+
|
|
2481
|
+
/** `--param <id>=<value>` / `--input <port>=<value>` -> [name, value]; the
|
|
2482
|
+
* value keeps every `=` after the first. */
|
|
2483
|
+
function splitAssign(flag, spec) {
|
|
2484
|
+
const eq = String(spec).indexOf('=');
|
|
2485
|
+
if (eq <= 0) fail(`${flag} must be <name>=<value> (got "${spec}")`);
|
|
2486
|
+
return [String(spec).slice(0, eq), String(spec).slice(eq + 1)];
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/** argv is all strings; V22 checks the sidecar's declared type, so coerce here. */
|
|
2490
|
+
function coerceParamValue(def, raw) {
|
|
2491
|
+
if (def.type === 'number') {
|
|
2492
|
+
// Number('') and Number(' ') are a finite 0: a blank is not a number.
|
|
2493
|
+
const n = String(raw).trim() === '' ? NaN : Number(raw);
|
|
2494
|
+
if (!Number.isFinite(n)) return { error: `--param ${def.id}: "${raw}" is not a number` };
|
|
2495
|
+
return { value: n };
|
|
2496
|
+
}
|
|
2497
|
+
if (def.type === 'boolean') {
|
|
2498
|
+
if (/^(1|true|yes|on)$/i.test(raw)) return { value: true };
|
|
2499
|
+
if (/^(0|false|no|off)$/i.test(raw)) return { value: false };
|
|
2500
|
+
return { error: `--param ${def.id}: "${raw}" is not true or false` };
|
|
2501
|
+
}
|
|
2502
|
+
return { value: String(raw) };
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
/** `--input <port>=<value>` by the port's declared TYPE (spec §6): a void port
|
|
2506
|
+
* takes `fired`; a non-void port takes @<file>, @@<literal @…> or plain text.
|
|
2507
|
+
* A path resolves against the SHELL's cwd with both separators (Windows). */
|
|
2508
|
+
async function readInputSpec(port, raw) {
|
|
2509
|
+
if (port.type === 'void') {
|
|
2510
|
+
if (raw !== 'fired') return { error: `--input ${port.id}: a void port takes "fired" (got "${raw}")` };
|
|
2511
|
+
return { value: { fired: true } };
|
|
2512
|
+
}
|
|
2513
|
+
if (raw.startsWith('@@')) return { value: { text: raw.slice(1) } };
|
|
2514
|
+
if (raw.startsWith('@')) {
|
|
2515
|
+
const path = resolve(process.cwd(), raw.slice(1));
|
|
2516
|
+
const { readFile, stat } = await import('node:fs/promises');
|
|
2517
|
+
const { MAX_CASE_INPUT_BYTES } = await import('../shared/graph/script-cases.mjs');
|
|
2518
|
+
try {
|
|
2519
|
+
// The bench refuses an input over its cap anyway; size it BEFORE reading, so
|
|
2520
|
+
// a mistyped path to a huge file is a sentence and not a whole-file read.
|
|
2521
|
+
if ((await stat(path)).size > MAX_CASE_INPUT_BYTES) {
|
|
2522
|
+
return { error: `--input ${port.id}: ${path} is over ${MAX_CASE_INPUT_BYTES} bytes` };
|
|
2523
|
+
}
|
|
2524
|
+
return { value: { text: await readFile(path, 'utf8') } };
|
|
2525
|
+
} catch (e) { return { error: `--input ${port.id}: cannot read ${path} (${e.message})` }; }
|
|
2526
|
+
}
|
|
2527
|
+
return { value: { text: raw } };
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/** spec §6: the RUN's verdict is the exit code. A STOPPED run verified nothing:
|
|
2531
|
+
* it is 2 even under an expectation, which would otherwise read it as a failed
|
|
2532
|
+
* check (1) — a CI cancel is not a red test. */
|
|
2533
|
+
function benchExitCode(r) {
|
|
2534
|
+
if (r.status === 'stopped') return 2;
|
|
2535
|
+
if (r.expect) return r.expect.pass ? 0 : 1;
|
|
2536
|
+
if (r.status === 'clean') return 0;
|
|
2537
|
+
if (r.status === 'blocking') return 1;
|
|
2538
|
+
return 2; // error, timeout, stopped
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/** One run: the status line, then what it fired, expected, wrote and warned. */
|
|
2542
|
+
function printBenchResult(key, r) {
|
|
2543
|
+
const dur = `${((r.durationMs || 0) / 1000).toFixed(1)}s`;
|
|
2544
|
+
const exit = r.exitCode == null ? '' : `\texit ${r.exitCode}`;
|
|
2545
|
+
out(`${key}\t${r.runtime}\t${r.status}${exit}\t${dur}${r.draft ? '\tdraft' : ''}`);
|
|
2546
|
+
if (r.summary) out(` ${r.summary}`);
|
|
2547
|
+
if ((r.fired || []).length) out(` fired: ${r.fired.join(', ')}`);
|
|
2548
|
+
// A STOPPED run verified nothing: an expectation it happens to satisfy (one that
|
|
2549
|
+
// names no verdict) must not print as a pass beside exit code 2.
|
|
2550
|
+
if (r.expect && r.status !== 'stopped') {
|
|
2551
|
+
out(` expect: ${r.expect.pass ? c('green', 'pass') : c('red', 'fail')}`);
|
|
2552
|
+
for (const d of r.expect.diffs || []) out(` ${d}`);
|
|
2553
|
+
}
|
|
2554
|
+
// Only what FIRED: two conditional outputs may share one filename (the built-in
|
|
2555
|
+
// shell's `log` and `fail`), so listing a port that did not fire would show the
|
|
2556
|
+
// other port's bytes under its name. A failed run fires nothing — there, whatever
|
|
2557
|
+
// was written is the evidence.
|
|
2558
|
+
const fired = new Set(r.fired || []);
|
|
2559
|
+
for (const [port, o] of Object.entries(r.outputs || {})) {
|
|
2560
|
+
if (!(fired.has(port) || (r.error && o.bytes))) continue;
|
|
2561
|
+
out(o.type === 'void' ? ` ${port}\tvoid` : ` ${port}\t${o.path}\t${o.bytes} bytes${o.truncated ? '\ttruncated' : ''}`);
|
|
2562
|
+
}
|
|
2563
|
+
if (r.envelopePath) out(` envelope\t${r.envelopePath}`);
|
|
2564
|
+
if (r.error) {
|
|
2565
|
+
out(` ${c('red', 'error')}: ${r.error.message}`);
|
|
2566
|
+
for (const l of r.error.tail || []) out(` ${l}`);
|
|
2567
|
+
}
|
|
2568
|
+
for (const w of r.warnings || []) out(` ${c('yellow', 'warn')}\t${w}`);
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/** --all: one line per case, then the tally. */
|
|
2572
|
+
function printCaseRun(key, r) {
|
|
2573
|
+
for (const row of r.cases || []) {
|
|
2574
|
+
const ok = benchExitCode(row.result) === 0;
|
|
2575
|
+
out(`${ok ? c('green', '✓') : c('red', '✗')} ${key}/${row.caseId}\t${row.result.status}`
|
|
2576
|
+
+ `\t${((row.result.durationMs || 0) / 1000).toFixed(1)}s`);
|
|
2577
|
+
for (const d of (row.result.expect && row.result.expect.diffs) || []) out(` ${d}`);
|
|
2578
|
+
if (row.result.error) out(` ${row.result.error.message}`);
|
|
2579
|
+
}
|
|
2580
|
+
// The bench tallies by the expectation alone. A STOPPED case verified nothing, so
|
|
2581
|
+
// on the CLI it counts as failed whatever its expectation said — the tally has to
|
|
2582
|
+
// agree with the ✗ above and with exit code 2.
|
|
2583
|
+
let { passed, failed, unchecked } = r;
|
|
2584
|
+
for (const row of r.cases || []) {
|
|
2585
|
+
if (row.result.status !== 'stopped') continue;
|
|
2586
|
+
if (!row.result.expect) unchecked -= 1;
|
|
2587
|
+
else if (row.result.expect.pass) passed -= 1;
|
|
2588
|
+
else continue; // already counted as failed
|
|
2589
|
+
failed += 1;
|
|
2590
|
+
}
|
|
2591
|
+
out(`${passed} passed, ${failed} failed, ${unchecked} unchecked`);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// Ctrl+C, a CI cancel (SIGTERM) and a closed terminal (SIGHUP) all STOP a bench
|
|
2595
|
+
// run: the bench kills the child tree and resolves `stopped`. Left to the default
|
|
2596
|
+
// action the CLI dies and the script — spawned in its own process group, so the
|
|
2597
|
+
// terminal's Ctrl+C never reaches it — runs on until its own timeout.
|
|
2598
|
+
const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
2599
|
+
|
|
2600
|
+
/**
|
|
2601
|
+
* Hold the live bench for as long as a verb runs one, and stop it on a signal.
|
|
2602
|
+
* ONE helper for every verb on the CLI that runs a bench: a consumer without it
|
|
2603
|
+
* orphans the script on Ctrl+C.
|
|
2604
|
+
* @returns {{onBench: Function, requested: () => boolean, release: Function}}
|
|
2605
|
+
*/
|
|
2606
|
+
function benchStopper() {
|
|
2607
|
+
let live = null;
|
|
2608
|
+
let requested = false;
|
|
2609
|
+
const onSignal = () => { requested = true; if (live) live.stop(); };
|
|
2610
|
+
for (const sig of STOP_SIGNALS) process.on(sig, onSignal);
|
|
2611
|
+
return {
|
|
2612
|
+
// A signal that landed before the bench existed is honoured the moment it does.
|
|
2613
|
+
onBench: (bench) => { live = bench; if (requested) bench.stop(); },
|
|
2614
|
+
requested: () => requested,
|
|
2615
|
+
release: () => { for (const sig of STOP_SIGNALS) process.off(sig, onSignal); },
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
/** `worca script test <key> …` — one bench run in THIS process (spec §6). */
|
|
2620
|
+
async function scriptTest(rest, store) {
|
|
2621
|
+
const a = pluginArgs(rest, ['--case', '--cwd', '--project', '--timeout'], ['--all', '--json'], ['--param', '--input']);
|
|
2622
|
+
const key = a._[0];
|
|
2623
|
+
if (!key) {
|
|
2624
|
+
fail('Usage: worca script test <key> [--case <id> | --all] [--param id=value]… '
|
|
2625
|
+
+ '[--input port=value]… [--cwd <dir> | --project <key>] [--timeout <s>] [--json]');
|
|
2626
|
+
}
|
|
2627
|
+
if (a.case !== undefined && a.all) fail('--case and --all are mutually exclusive');
|
|
2628
|
+
if (a.cwd !== undefined && a.project !== undefined) fail('--cwd and --project are mutually exclusive');
|
|
2629
|
+
// A saved case carries its own setup (spec §4.2). The bench IGNORES the other
|
|
2630
|
+
// fields; a CI gate that passes because a typed flag was dropped is worse.
|
|
2631
|
+
const byCase = a.case !== undefined || a.all === true;
|
|
2632
|
+
const typed = [['--param', a.param], ['--input', a.input], ['--cwd', a.cwd], ['--project', a.project], ['--timeout', a.timeout]]
|
|
2633
|
+
.filter(([, v]) => v !== undefined).map(([f]) => f);
|
|
2634
|
+
if (byCase && typed.length) {
|
|
2635
|
+
fail(`${typed.join(', ')} cannot be combined with ${a.all ? '--all' : '--case'}: a saved case carries its own setup`);
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
const data = await store.readScript(key);
|
|
2639
|
+
if (!data) {
|
|
2640
|
+
process.stderr.write(`worca script test: unknown script "${key}"\n`);
|
|
2641
|
+
return 2;
|
|
2642
|
+
}
|
|
2643
|
+
const meta = data.meta;
|
|
2644
|
+
|
|
2645
|
+
let request;
|
|
2646
|
+
if (a.all) {
|
|
2647
|
+
// The bench REFUSES a Run all with nothing to run (the page never offers the
|
|
2648
|
+
// button then). On the CLI "no cases" is a green no-op, so `worca script test
|
|
2649
|
+
// --all` and `worca plugin validate --run-cases` agree on an empty set (E8).
|
|
2650
|
+
if (!((data.cases || []).length + (data.userCases || []).length)) {
|
|
2651
|
+
process.stderr.write(`no saved cases for "${key}"\n`);
|
|
2652
|
+
if (a.json) process.stdout.write(JSON.stringify({ cases: [], passed: 0, failed: 0, unchecked: 0 }, null, 2) + '\n');
|
|
2653
|
+
return 0;
|
|
2654
|
+
}
|
|
2655
|
+
request = { key, all: true };
|
|
2656
|
+
} else if (a.case !== undefined) {
|
|
2657
|
+
request = { key, caseId: a.case };
|
|
2658
|
+
} else {
|
|
2659
|
+
// A config-ported sidecar (the built-in shell / js) has no ports of its own:
|
|
2660
|
+
// the CLI runs it on the defaults a freshly placed card would carry (E5).
|
|
2661
|
+
const config = meta.ports === 'config';
|
|
2662
|
+
const ports = config
|
|
2663
|
+
? (meta.defaultPorts || { inputs: [], outputs: [] })
|
|
2664
|
+
: { inputs: meta.inputs || [], outputs: meta.outputs || [] };
|
|
2665
|
+
const params = {};
|
|
2666
|
+
for (const spec of a.param || []) {
|
|
2667
|
+
const [id, raw] = splitAssign('--param', spec);
|
|
2668
|
+
const def = (meta.params || []).find((p) => p.id === id);
|
|
2669
|
+
if (!def) {
|
|
2670
|
+
process.stderr.write(`worca script test: unknown param "${id}" for script "${key}"\n`);
|
|
2671
|
+
return 2;
|
|
2672
|
+
}
|
|
2673
|
+
const r = coerceParamValue(def, raw);
|
|
2674
|
+
if (r.error) { process.stderr.write(`worca script test: ${r.error}\n`); return 2; }
|
|
2675
|
+
params[id] = r.value;
|
|
2676
|
+
}
|
|
2677
|
+
const inputs = {};
|
|
2678
|
+
for (const spec of a.input || []) {
|
|
2679
|
+
const [id, raw] = splitAssign('--input', spec);
|
|
2680
|
+
const port = (ports.inputs || []).find((p) => p.id === id);
|
|
2681
|
+
if (!port) {
|
|
2682
|
+
process.stderr.write(`worca script test: unknown input port "${id}" for script "${key}"\n`);
|
|
2683
|
+
return 2;
|
|
2684
|
+
}
|
|
2685
|
+
const r = await readInputSpec(port, raw);
|
|
2686
|
+
if (r.error) { process.stderr.write(`worca script test: ${r.error}\n`); return 2; }
|
|
2687
|
+
inputs[id] = r.value;
|
|
2688
|
+
}
|
|
2689
|
+
let timeoutMs;
|
|
2690
|
+
if (a.timeout !== undefined) {
|
|
2691
|
+
// The bench IGNORES a timeout under its floor and clamps one over its cap
|
|
2692
|
+
// (script-meta MIN_TIMEOUT_MS / MAX_TIMEOUT_MS) — a typed flag must not vanish.
|
|
2693
|
+
const s = Number(a.timeout);
|
|
2694
|
+
if (!Number.isFinite(s) || s < 1 || s > 86400) fail(`--timeout must be between 1 and 86400 seconds (got ${a.timeout})`);
|
|
2695
|
+
timeoutMs = Math.round(s * 1000);
|
|
2696
|
+
}
|
|
2697
|
+
const cwd = a.cwd !== undefined ? { kind: 'dir', dir: resolve(process.cwd(), a.cwd) }
|
|
2698
|
+
: a.project !== undefined ? { kind: 'project', projectKey: a.project }
|
|
2699
|
+
: { kind: 'scratch' };
|
|
2700
|
+
request = { key, params, inputs, cwd, ...(config ? { ports } : {}), ...(timeoutMs ? { timeoutMs } : {}) };
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
const { runBenchOnce } = await import('../core/script-bench.mjs');
|
|
2704
|
+
const stopper = benchStopper(); // a signal STOPS the run: `stopped`, exit 2, no orphan
|
|
2705
|
+
let result;
|
|
2706
|
+
try {
|
|
2707
|
+
result = await runBenchOnce(request, {
|
|
2708
|
+
allowDirCwd: true, // --cwd <dir> is the CLI's alone; the server never sets this
|
|
2709
|
+
// P1c's own hooks: onLine per streamed line, onBench for the live bench so
|
|
2710
|
+
// Ctrl+C kills the child TREE instead of orphaning it (base spec §6.3).
|
|
2711
|
+
onLine: (ev) => {
|
|
2712
|
+
const tag = ev && ev.caseId ? `[${ev.caseId}] ` : '';
|
|
2713
|
+
process.stderr.write(`${tag}${String((ev && ev.text) || '').replace(/\n$/, '')}\n`);
|
|
2714
|
+
},
|
|
2715
|
+
onBench: stopper.onBench,
|
|
2716
|
+
});
|
|
2717
|
+
} catch (e) {
|
|
2718
|
+
// NOT_FOUND / BAD_REQUEST / BUSY: nothing ran (spec §6 — exit 2).
|
|
2719
|
+
process.stderr.write(`worca script test: ${e && e.message ? e.message : e}\n`);
|
|
2720
|
+
return 2;
|
|
2721
|
+
} finally {
|
|
2722
|
+
stopper.release();
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
const multi = Array.isArray(result.cases);
|
|
2726
|
+
const code = multi
|
|
2727
|
+
? (result.cases || []).reduce((worst, row) => Math.max(worst, benchExitCode(row.result)), 0)
|
|
2728
|
+
: benchExitCode(result);
|
|
2729
|
+
if (a.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
2730
|
+
else if (multi) printCaseRun(key, result);
|
|
2731
|
+
else printBenchResult(key, result);
|
|
2732
|
+
return code;
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
/** `worca script <verb> …` — dispatch. Store calls are awaited whether or not
|
|
2736
|
+
* the store returns promises, so the arm survives it going async. */
|
|
2737
|
+
async function cmdScript(argv) {
|
|
2738
|
+
const verb = argv[0];
|
|
2739
|
+
const rest = argv.slice(1);
|
|
2740
|
+
const { SCRIPT_RUNTIMES } = await import('../shared/graph/script-meta.mjs');
|
|
2741
|
+
if (!verb || verb === 'help') {
|
|
2742
|
+
process.stdout.write(scriptHelp(SCRIPT_RUNTIMES));
|
|
2743
|
+
return 0;
|
|
2744
|
+
}
|
|
2745
|
+
const store = await import('../core/script-store.mjs');
|
|
2746
|
+
try {
|
|
2747
|
+
switch (verb) {
|
|
2748
|
+
case 'list': {
|
|
2749
|
+
const a = pluginArgs(rest, [], ['--json']);
|
|
2750
|
+
const list = await store.listScripts();
|
|
2751
|
+
if (a.json) {
|
|
2752
|
+
process.stdout.write(JSON.stringify(list, null, 2) + '\n');
|
|
2753
|
+
return 0;
|
|
2754
|
+
}
|
|
2755
|
+
for (const m of list) {
|
|
2756
|
+
out(`${m.key}\t${m.displayName || m.key}\t${m.origin}\t${m.runtime}\t${m.caseCount || 0}`);
|
|
2757
|
+
}
|
|
2758
|
+
return 0;
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
case 'show': {
|
|
2762
|
+
const a = pluginArgs(rest, [], ['--json']);
|
|
2763
|
+
const key = a._[0];
|
|
2764
|
+
if (!key) fail('Usage: worca script show <key> [--json]');
|
|
2765
|
+
const data = await store.readScript(key);
|
|
2766
|
+
if (!data) {
|
|
2767
|
+
process.stderr.write(`worca script show: unknown script "${key}"\n`);
|
|
2768
|
+
return 2;
|
|
2769
|
+
}
|
|
2770
|
+
if (a.json) {
|
|
2771
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
2772
|
+
return 0;
|
|
2773
|
+
}
|
|
2774
|
+
const m = data.meta;
|
|
2775
|
+
// 13 = the longest label (`description`) + two spaces, so every value starts in one column.
|
|
2776
|
+
const row = (label, value) => { if (value !== '' && value != null) out(`${label.padEnd(13)}${value}`); };
|
|
2777
|
+
row('key', m.key);
|
|
2778
|
+
row('name', m.displayName || m.key);
|
|
2779
|
+
row('description', m.description);
|
|
2780
|
+
row('origin', m.origin);
|
|
2781
|
+
row('runtime', m.runtime);
|
|
2782
|
+
row('file', data.sourcePath || m.commandResolved || '');
|
|
2783
|
+
row('ports', scriptPortLine(m));
|
|
2784
|
+
row('params', (m.params || []).map((p) => `${p.id}: ${p.type}${p.required ? ' *' : ''}`).join(', '));
|
|
2785
|
+
row('timeout', `${Math.round((m.timeoutMs || 0) / 1000)}s`);
|
|
2786
|
+
row('cases', `${(data.cases || []).length} shipped, ${(data.userCases || []).length} yours`);
|
|
2787
|
+
if (data.source) {
|
|
2788
|
+
out('');
|
|
2789
|
+
process.stdout.write(data.source.endsWith('\n') ? data.source : `${data.source}\n`);
|
|
2790
|
+
}
|
|
2791
|
+
if (data.sourceTruncated) out(c('yellow', `(source truncated — read ${data.sourcePath})`));
|
|
2792
|
+
return 0;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
case 'new': {
|
|
2796
|
+
const a = pluginArgs(rest, ['--runtime', '--from'], []);
|
|
2797
|
+
const key = a._[0];
|
|
2798
|
+
if (!key) fail(`Usage: worca script new <key> [--runtime ${SCRIPT_RUNTIMES.join('|')}] [--from <key>]`);
|
|
2799
|
+
if (a.from) {
|
|
2800
|
+
// A copy keeps its source's runtime; a typed flag is refused, never dropped (E6).
|
|
2801
|
+
if (a.runtime !== undefined) fail(`--runtime cannot be combined with --from: a copy keeps the runtime of "${a.from}"`);
|
|
2802
|
+
const copy = await store.duplicateScript(a.from, key, 'cli');
|
|
2803
|
+
out(`created\t${copy.meta.key}\t(copy of ${a.from})`);
|
|
2804
|
+
return 0;
|
|
2805
|
+
}
|
|
2806
|
+
const runtime = a.runtime || 'node';
|
|
2807
|
+
if (!SCRIPT_RUNTIMES.includes(runtime)) {
|
|
2808
|
+
fail(`--runtime must be one of ${SCRIPT_RUNTIMES.join(', ')} (got ${runtime})`);
|
|
2809
|
+
}
|
|
2810
|
+
const tpl = await import('../shared/graph/script-templates.mjs');
|
|
2811
|
+
const written = await store.createScript({
|
|
2812
|
+
meta: tpl.scriptMetaTemplate(key, runtime),
|
|
2813
|
+
source: tpl.scriptSourceTemplate(runtime),
|
|
2814
|
+
// A shell script scaffolds BOTH halves: on Windows the runner hands the
|
|
2815
|
+
// platform's entry to cmd.exe, and cmd.exe cannot run a .sh (§10).
|
|
2816
|
+
sourceWin32: runtime === 'shell' ? tpl.scriptSourceTemplate(runtime, { win32: true }) : undefined,
|
|
2817
|
+
by: 'cli',
|
|
2818
|
+
});
|
|
2819
|
+
const { userScriptsDir } = await import('../core/script-registry.mjs');
|
|
2820
|
+
const dir = userScriptsDir();
|
|
2821
|
+
out(`created\t${join(dir, `${key}.meta.json`)}`);
|
|
2822
|
+
const files = typeof written.meta.file === 'string'
|
|
2823
|
+
? [written.meta.file]
|
|
2824
|
+
: Object.values(written.meta.file || {});
|
|
2825
|
+
for (const f of files) out(`created\t${join(dir, f)}`);
|
|
2826
|
+
return 0;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
case 'rm': {
|
|
2830
|
+
const a = pluginArgs(rest);
|
|
2831
|
+
const key = a._[0];
|
|
2832
|
+
if (!key) fail('Usage: worca script rm <key>');
|
|
2833
|
+
await store.deleteScript(key);
|
|
2834
|
+
out(`removed\t${key}`);
|
|
2835
|
+
return 0;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
case 'test':
|
|
2839
|
+
return await scriptTest(rest, store);
|
|
2840
|
+
|
|
2841
|
+
default:
|
|
2842
|
+
fail(`unknown script verb "${verb}" — see: worca script help`);
|
|
2843
|
+
}
|
|
2844
|
+
} catch (err) {
|
|
2845
|
+
process.stderr.write(`worca script ${verb}: ${err && err.message ? err.message : err}\n`);
|
|
2846
|
+
// NOT_FOUND / BAD_REQUEST are what the user typed; BUILTIN, PLUGIN,
|
|
2847
|
+
// DUPLICATE and REFERENCED are refusals about the state of the store.
|
|
2848
|
+
return err && (err.code === 'NOT_FOUND' || err.code === 'BAD_REQUEST') ? 2 : 1;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/**
|
|
2853
|
+
* stdout and stderr over a PIPE are asynchronous on POSIX, and main() ends in
|
|
2854
|
+
* process.exit(): whatever is still queued past the 64 KiB pipe buffer is cut.
|
|
2855
|
+
* `worca script test --json | jq` got invalid JSON, `worca script show` half a
|
|
2856
|
+
* source. The verb's exit code therefore resolves only once both streams have
|
|
2857
|
+
* flushed (an empty write's callback runs after everything queued before it).
|
|
2858
|
+
* A reader that went away (`| head -1`) is not the verb's failure: its EPIPE
|
|
2859
|
+
* is swallowed and the exit code stays the verb's own.
|
|
2860
|
+
* @param {Promise<number>} codePromise the verb, already running
|
|
2861
|
+
* @returns {Promise<number>}
|
|
2862
|
+
*/
|
|
2863
|
+
async function flushed(codePromise) {
|
|
2864
|
+
const streams = [process.stdout, process.stderr];
|
|
2865
|
+
const gone = new Set();
|
|
2866
|
+
for (const s of streams) s.on('error', () => gone.add(s));
|
|
2867
|
+
const code = await codePromise;
|
|
2868
|
+
await Promise.all(streams.map((s) => (gone.has(s) ? null : new Promise((res) => {
|
|
2869
|
+
s.once('error', () => res());
|
|
2870
|
+
s.write('', () => res());
|
|
2871
|
+
}))));
|
|
2872
|
+
return code;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2067
2875
|
// ── metrics subcommand ───────────────────────────────────────────────────────────
|
|
2068
2876
|
|
|
2069
2877
|
const METRICS_HELP = `worca metrics — team metrics (git-backed, team-wide run records)
|
|
@@ -2113,6 +2921,121 @@ async function cmdMetrics(argv) {
|
|
|
2113
2921
|
}
|
|
2114
2922
|
}
|
|
2115
2923
|
|
|
2924
|
+
// ── policy subcommand ─────────────────────────────────────────────────────────────
|
|
2925
|
+
// Team policy (team-policy design §12). Lazy imports like cmdMetrics; the effective
|
|
2926
|
+
// table is the same fold the Team policy page shows.
|
|
2927
|
+
|
|
2928
|
+
const POLICY_HELP = `worca policy — team policy (git-backed, read from the worca-policy branch)
|
|
2929
|
+
|
|
2930
|
+
Usage:
|
|
2931
|
+
worca policy show [--project <path>] [--json] The effective policy for a project: team value, yours, what applies.
|
|
2932
|
+
worca policy pull [--project <path>] Fetch the worca-policy branch now (a CLI-only machine has no hourly loop).
|
|
2933
|
+
worca policy init --here | --follow <slug> [--project <path>] [--title <text>]
|
|
2934
|
+
Create the branch with an empty policy, or a marker that follows another project.
|
|
2935
|
+
worca policy setup [--project <path>] [--install] The setup checklist: marketplaces to add, plugins to install or update.
|
|
2936
|
+
--install runs it; each install names its source first. Never automatic otherwise.
|
|
2937
|
+
worca policy help
|
|
2938
|
+
|
|
2939
|
+
Run flags: worca ... --past-team-cap [--reason "<why>"] Continue past a soft team cap; the team sees it in Team metrics.
|
|
2940
|
+
|
|
2941
|
+
Exit codes: 0 ok · 1 failure · 2 usage.
|
|
2942
|
+
`;
|
|
2943
|
+
|
|
2944
|
+
async function cmdPolicy(argv) {
|
|
2945
|
+
const verb = argv[0];
|
|
2946
|
+
const rest = argv.slice(1);
|
|
2947
|
+
if (!verb || verb === 'help') { process.stdout.write(POLICY_HELP); return 0; }
|
|
2948
|
+
const sync = await import('../core/policy/sync.mjs');
|
|
2949
|
+
const { effectiveRows } = await import('../core/policy/effective.mjs');
|
|
2950
|
+
const { localSnapshot, pluginRequirements, marketplaceSeedCandidates, seedPolicyMarketplaces } = await import('../core/policy/local.mjs');
|
|
2951
|
+
try {
|
|
2952
|
+
switch (verb) {
|
|
2953
|
+
case 'show': {
|
|
2954
|
+
const a = pluginArgs(rest, ['--project'], ['--json']);
|
|
2955
|
+
const projectDir = resolve(a.project || process.cwd());
|
|
2956
|
+
const r = await sync.resolveProjectPolicy(projectDir);
|
|
2957
|
+
if (!r.ok) {
|
|
2958
|
+
if (a.json) out(JSON.stringify({ policy: null, reason: r.reason, detail: r.detail ?? null }));
|
|
2959
|
+
else out(`no team policy for ${projectDir}: ${r.detail || r.reason}`);
|
|
2960
|
+
return r.reason === 'not-enabled' || r.reason === 'no-origin' ? 0 : 1;
|
|
2961
|
+
}
|
|
2962
|
+
const rows = effectiveRows({ doc: r.doc, workspaceRun: false, local: localSnapshot(projectDir) });
|
|
2963
|
+
if (a.json) { out(JSON.stringify({ home: r.home, sha: r.sha, delegated: r.delegated, from: r.from, doc: r.doc, rows }, null, 2)); return 0; }
|
|
2964
|
+
out(c('bold', `team policy ${r.home}${r.sha ? ` @ ${String(r.sha).slice(0, 7)}` : ''}${r.delegated ? ` (followed by ${r.from})` : ''}`));
|
|
2965
|
+
if (r.doc.title) out(` ${r.doc.title}${r.doc.updatedBy ? ` · updated by ${r.doc.updatedBy}` : ''}${r.doc.updatedAt ? ` · ${r.doc.updatedAt}` : ''}`);
|
|
2966
|
+
for (const w of r.warnings) out(c('yellow', ` ! ${w}`));
|
|
2967
|
+
const shown = rows.filter((x) => x.shown);
|
|
2968
|
+
if (!shown.length) out(' (the policy sets no fields yet)');
|
|
2969
|
+
for (const row of shown) {
|
|
2970
|
+
out(` ${row.label.padEnd(30)} team ${row.team.display} (${row.team.kind})${row.local && row.local.set ? ` · yours ${row.local.display}` : ''} → ${row.effective.display} [${row.effective.source}]${row.note ? ` — ${row.note}` : ''}`);
|
|
2971
|
+
}
|
|
2972
|
+
for (const q of pluginRequirements([{ slug: r.home, doc: r.doc }]).filter((x) => x.state !== 'ok')) {
|
|
2973
|
+
out(c('yellow', ` plugin ${q.name}: ${q.state}${q.minVersion ? ` (expects ≥ ${q.minVersion})` : ''} — see: worca policy setup`));
|
|
2974
|
+
}
|
|
2975
|
+
return 0;
|
|
2976
|
+
}
|
|
2977
|
+
case 'pull': {
|
|
2978
|
+
const a = pluginArgs(rest, ['--project'], []);
|
|
2979
|
+
const projectDir = resolve(a.project || process.cwd());
|
|
2980
|
+
const prefs = await sync.discoverPolicy(projectDir, { force: true });
|
|
2981
|
+
if (!prefs) { out('could not read this repository'); return 1; }
|
|
2982
|
+
if (prefs.lastDiscoveryError) { out(`${c('red', '✗')} ${prefs.slug}: ${prefs.lastDiscoveryError}`); return 1; }
|
|
2983
|
+
out(`${c('green', '✓')} ${prefs.slug}: ${prefs.present ? (prefs.delegateTo ? `follows ${prefs.delegateTo}` : `policy @ ${String(prefs.headSha || '').slice(0, 7)}`) : `no ${sync.POLICY_BRANCH} branch`}`);
|
|
2984
|
+
return 0;
|
|
2985
|
+
}
|
|
2986
|
+
case 'init': {
|
|
2987
|
+
const a = pluginArgs(rest, ['--project', '--follow', '--title'], ['--here']);
|
|
2988
|
+
const projectDir = resolve(a.project || process.cwd());
|
|
2989
|
+
if (!a.here && !a.follow) fail('init needs --here or --follow <slug> — see: worca policy help');
|
|
2990
|
+
const r = await sync.enableTeamPolicy(projectDir, a.follow ? { mode: 'follow', delegateTo: a.follow } : { mode: 'here', title: a.title || '' });
|
|
2991
|
+
out(`${c('green', '✓')} ${r.slug}: ${r.action}${a.follow ? ` (follows ${a.follow})` : ''}`);
|
|
2992
|
+
if (r.action === 'created' && !a.follow) out(` protect the ${sync.POLICY_BRANCH} branch on your git host so only maintainers can push; edit it from the Team policy page`);
|
|
2993
|
+
return 0;
|
|
2994
|
+
}
|
|
2995
|
+
case 'setup': {
|
|
2996
|
+
const a = pluginArgs(rest, ['--project'], ['--install']);
|
|
2997
|
+
const projectDir = resolve(a.project || process.cwd());
|
|
2998
|
+
const r = await sync.resolveProjectPolicy(projectDir);
|
|
2999
|
+
if (!r.ok) { out(`no team policy for ${projectDir}: ${r.detail || r.reason}`); return 0; }
|
|
3000
|
+
const homes = [{ slug: r.home, doc: r.doc }];
|
|
3001
|
+
const seeds = marketplaceSeedCandidates(homes);
|
|
3002
|
+
for (const s of seeds) out(`marketplace ${s.url}: to add`);
|
|
3003
|
+
const reqs = pluginRequirements(homes);
|
|
3004
|
+
for (const q of reqs) out(`plugin ${q.name}${q.minVersion ? ` ≥ ${q.minVersion}` : ''}: ${q.state}${q.installed?.version ? ` (installed ${q.installed.version})` : ''}`);
|
|
3005
|
+
if (!seeds.length && reqs.every((q) => q.state === 'ok')) { out(`${c('green', '✓')} nothing to do — this machine meets ${r.home}'s policy`); return 0; }
|
|
3006
|
+
if (!a.install) { out('run again with --install to add the marketplaces and install or update the plugins above'); return 0; }
|
|
3007
|
+
const seeded = await seedPolicyMarketplaces(homes);
|
|
3008
|
+
for (const s of seeded) out(`${s.added ? c('green', '✓') : c('red', '✗')} marketplace ${s.url}${s.error ? `: ${s.error}` : ''}`);
|
|
3009
|
+
const { resolveInstallSource } = await import('../core/marketplaces.mjs');
|
|
3010
|
+
const { installPlugin, updatePlugin } = await import('../core/plugin-store.mjs');
|
|
3011
|
+
let failed = 0;
|
|
3012
|
+
for (const q of reqs) {
|
|
3013
|
+
if (q.state === 'ok' || q.state === 'disabled') continue;
|
|
3014
|
+
try {
|
|
3015
|
+
if (q.state === 'missing') {
|
|
3016
|
+
const src = resolveInstallSource(q.name, {});
|
|
3017
|
+
if (!src || src.candidates) { out(`${c('red', '✗')} ${q.name}: ${src?.candidates ? 'found in several marketplaces — install it by hand: worca plugin install ' + q.name + ' --repo <url>' : 'not found in any marketplace'}`); failed++; continue; }
|
|
3018
|
+
out(`installing ${q.name} from ${src.repoUrl}${src.sha ? ` @ ${String(src.sha).slice(0, 7)}` : ''}`);
|
|
3019
|
+
await installPlugin({ repoUrl: src.repoUrl, subdir: src.subdir || '', name: q.name, sha: src.sha || undefined, marketplace: src.marketplace || undefined });
|
|
3020
|
+
out(`${c('green', '✓')} ${q.name} installed`);
|
|
3021
|
+
} else if (q.state === 'outdated') {
|
|
3022
|
+
out(`updating ${q.name} (installed ${q.installed?.version}, expects ≥ ${q.minVersion})`);
|
|
3023
|
+
await updatePlugin(q.name);
|
|
3024
|
+
out(`${c('green', '✓')} ${q.name} updated`);
|
|
3025
|
+
}
|
|
3026
|
+
} catch (err) { failed++; out(`${c('red', '✗')} ${q.name}: ${err?.message || err}`); }
|
|
3027
|
+
}
|
|
3028
|
+
return failed ? 1 : 0;
|
|
3029
|
+
}
|
|
3030
|
+
default:
|
|
3031
|
+
fail(`unknown policy verb "${verb}" — see: worca policy help`);
|
|
3032
|
+
}
|
|
3033
|
+
} catch (err) {
|
|
3034
|
+
process.stderr.write(`worca policy ${verb}: ${err?.message || err}${err?.stderr ? `\n${String(err.stderr).trim()}` : ''}${err?.hint ? `\nhint: ${err.hint}` : ''}\n`);
|
|
3035
|
+
return 1;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
|
|
2116
3039
|
/** Await in-flight metrics pushes before the CLI exits (decision 24). Never blocks past its
|
|
2117
3040
|
* own budget and never changes the run's exit code — metrics must not gate the CLI. */
|
|
2118
3041
|
async function drainMetricsFlushes() {
|
|
@@ -2124,7 +3047,7 @@ async function drainMetricsFlushes() {
|
|
|
2124
3047
|
|
|
2125
3048
|
// ── main ──────────────────────────────────────────────────────────────────────────
|
|
2126
3049
|
|
|
2127
|
-
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics']);
|
|
3050
|
+
const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics', 'script', 'policy', 'schedule', 'models']);
|
|
2128
3051
|
|
|
2129
3052
|
/** Levenshtein distance, two-row. Only ever called on short argv tokens. */
|
|
2130
3053
|
function editDistance(a, b) {
|
|
@@ -2184,7 +3107,11 @@ async function main() {
|
|
|
2184
3107
|
if (sub === 'config') return cmdConfig(rest);
|
|
2185
3108
|
if (sub === 'ui') return cmdUi(rest);
|
|
2186
3109
|
if (sub === 'workflow') return cmdWorkflow(rest);
|
|
3110
|
+
if (sub === 'script') return flushed(cmdScript(rest));
|
|
2187
3111
|
if (sub === 'metrics') return cmdMetrics(rest);
|
|
3112
|
+
if (sub === 'policy') return cmdPolicy(rest);
|
|
3113
|
+
if (sub === 'schedule') return cmdSchedule(rest, { out, c, fail });
|
|
3114
|
+
if (sub === 'models') return cmdModels(rest, { out, c, fail });
|
|
2188
3115
|
}
|
|
2189
3116
|
// `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
|
|
2190
3117
|
// remaining tokens to the ui parser so --port/--open/--mock work with either.
|
|
@@ -2257,12 +3184,33 @@ async function main() {
|
|
|
2257
3184
|
// budget: refuse up front (mock runs included — WORCA_MOCK is already set above).
|
|
2258
3185
|
const { budgetStatus } = await import('../core/cost-budget.mjs');
|
|
2259
3186
|
const budget = budgetStatus();
|
|
2260
|
-
|
|
3187
|
+
// A SCHEDULE only warns: the budget window may reset before the run starts, and the
|
|
3188
|
+
// start path checks it again then.
|
|
3189
|
+
const scheduling = wantsSchedule(flags);
|
|
3190
|
+
if (!scheduling && flags.wait) fail('--wait needs --at "<when>"');
|
|
3191
|
+
if (!scheduling && (flags.sourceFromPrevious || flags.afterAny)) fail('--source-from-previous / --after-any need --after');
|
|
3192
|
+
const spec = scheduling ? readScheduleFlags(flags, { fail, projectDir }) : null;
|
|
3193
|
+
if (budget.blocked && scheduling) {
|
|
3194
|
+
out(c('yellow', `Note: the total cost limit is reached right now (${budgetRefusalDetail(budget)}). The run only starts if the budget allows it then.`));
|
|
3195
|
+
}
|
|
3196
|
+
if (budget.blocked && !scheduling) {
|
|
2261
3197
|
process.stderr.write(`worca: total cost limit reached: ${budgetRefusalDetail(budget)}. `
|
|
2262
3198
|
+ 'Raise it: worca config set totalCostLimitUsd <usd>\n');
|
|
2263
3199
|
return 1;
|
|
2264
3200
|
}
|
|
2265
3201
|
|
|
3202
|
+
// Team total cap (team-policy design §7, §12): soft. --past-team-cap acknowledges it once per
|
|
3203
|
+
// window; under --yes nobody can click, so the harness warns instead and nothing is refused here.
|
|
3204
|
+
{
|
|
3205
|
+
const { checkTeamTotalGate } = await import('../core/policy/gate.mjs');
|
|
3206
|
+
const gate = await checkTeamTotalGate({ projectDir }, { pastTeamCap: flags.pastTeamCap, reason: flags.reason, unattended: flags.auto });
|
|
3207
|
+
if (gate.blocked) {
|
|
3208
|
+
process.stderr.write(`worca: ${gate.error}. `
|
|
3209
|
+
+ (gate.code === 'reason_required' ? 'Add --reason "<why>" with --past-team-cap.\n' : 'Continue: add --past-team-cap [--reason "<why>"]; the team sees it in Team metrics.\n'));
|
|
3210
|
+
return 1;
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
|
|
2266
3214
|
// Validate --workflow before spawning anything: an unknown or archived template
|
|
2267
3215
|
// must fail with one line, not a stack trace half-way through a run. The read row
|
|
2268
3216
|
// doubles as createOrchestratorFor's routing hint (it skips a second row read).
|
|
@@ -2283,7 +3231,20 @@ async function main() {
|
|
|
2283
3231
|
if (reason) fail(reason);
|
|
2284
3232
|
}
|
|
2285
3233
|
|
|
2286
|
-
|
|
3234
|
+
// Scheduled runs: write the ticket (or the repeating schedule) and exit — unless --wait
|
|
3235
|
+
// holds this terminal, in which case the run starts HERE, through the path below.
|
|
3236
|
+
let waitTicketId = null;
|
|
3237
|
+
if (scheduling) {
|
|
3238
|
+
let promptText = null;
|
|
3239
|
+
if (flags.file) {
|
|
3240
|
+
const { readPromptFile } = await import('../core/artifacts.mjs');
|
|
3241
|
+
promptText = await readPromptFile(projectDir, flags.file);
|
|
3242
|
+
}
|
|
3243
|
+
const made = await createFromFlags(flags, { projectDir, extras, promptText, spec, out, c });
|
|
3244
|
+
if (!flags.wait) return 0;
|
|
3245
|
+
waitTicketId = made.ticket.id;
|
|
3246
|
+
}
|
|
3247
|
+
const buildOrch = () => createOrchestratorFor({
|
|
2287
3248
|
projectDir,
|
|
2288
3249
|
prompt: flags.prompt || undefined,
|
|
2289
3250
|
promptFile: flags.file || undefined,
|
|
@@ -2302,6 +3263,24 @@ async function main() {
|
|
|
2302
3263
|
humanInLoop: flags.humanInLoop === false ? false : undefined,
|
|
2303
3264
|
});
|
|
2304
3265
|
|
|
3266
|
+
if (waitTicketId) {
|
|
3267
|
+
const code = await waitAndRun({
|
|
3268
|
+
ticketId: waitTicketId, tz: spec.tz, out, c,
|
|
3269
|
+
drive: async (onPipelineId) => {
|
|
3270
|
+
const o = await buildOrch();
|
|
3271
|
+
o.on('state', (st) => { if (st && typeof st.id === 'string' && st.id) onPipelineId(st.id); });
|
|
3272
|
+
out(c('bold', `orchestrator — project: ${projectDir}`));
|
|
3273
|
+
if (flags.mock) out(c('yellow', 'mock mode: no claude will be spawned'));
|
|
3274
|
+
const exit = await attachAndDrive(o, flags, () => o.run());
|
|
3275
|
+
const st = o.state || {};
|
|
3276
|
+
return { code: exit, status: st.status || (exit === 0 ? 'done' : 'error'), pipelineId: st.id || null, reason: st.status === 'paused' ? (st.pauseReason || 'paused') : null };
|
|
3277
|
+
},
|
|
3278
|
+
});
|
|
3279
|
+
await drainMetricsFlushes();
|
|
3280
|
+
return code;
|
|
3281
|
+
}
|
|
3282
|
+
const orch = await buildOrch();
|
|
3283
|
+
|
|
2305
3284
|
out(c('bold', `orchestrator — project: ${projectDir}`));
|
|
2306
3285
|
if (flags.mock) out(c('yellow', 'mock mode: no claude will be spawned'));
|
|
2307
3286
|
|