@worca/app 1.3.0 → 1.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -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 scripts/install.mjs), ui start|stop|restart|status
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 scripts/install.mjs, forwarding the target dir and any passthrough args. */
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, 'scripts', 'install.mjs');
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 validate <dir> [--strict] Lint a plugin dir (--strict: unknown fields error)
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>] Import a JSON export into your library ('-' = stdin)
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
- function pluginArgs(argv, valueFlags = [], boolFlags = []) {
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 (valueFlags.includes(a)) {
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': '>=3 <4' },
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 v = manifestMod.validatePluginDir(resolve(process.cwd(), dir), { strict: !!a.strict });
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
- const r = await share.importGraphWorkflow(obj, { name: a.name });
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
- if (budget.blocked) {
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
- const orch = await createOrchestratorFor({
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