@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
@@ -1,13 +1,13 @@
1
1
  // src/shared/graph/validate.mjs
2
- // THE authority on v2 graph legality (base spec §2, V1-V21; V22 is RETIRED —
3
- // subsumed by the restored V7 — and its number stays reserved so V-rule
4
- // references remain stable). Server save (422), plugin import, the composer's
2
+ // THE authority on v2 graph legality (base spec §2, V1-V22; V22 (script config)
3
+ // reuses the number the retired single-wire rule freed). Server save (422), plugin import, the composer's
5
4
  // live report, the run-time check and the seed drift guard all come through
6
5
  // here, so it is pure, never throws on a malformed template, and every lookup
7
6
  // guards: a dangling endpoint or an unknown key is an issue to COLLECT.
8
- import { KINDS, NODE_ID_RE, LIMITS } from './constants.mjs';
7
+ import { KINDS, KEYED_KINDS, NODE_ID_RE, LIMITS, PARAMS_PORT } from './constants.mjs';
9
8
  import { portsOf, findPort, resolveOrOutType } from './ports.mjs';
10
9
  import { classifyLoops } from './loops.mjs';
10
+ import { paramValueError, mockErrors, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, pythonMissingSentence, wirableParams, WIRABLE_PARAM_TYPES } from './script-meta.mjs';
11
11
 
12
12
  const ARITY_SET = new Set(['and', 'or', 'combine']);
13
13
  const IN_PORT_RE = /^in\d+$/;
@@ -19,6 +19,7 @@ const AWAIT_PORT_ID = 'await';
19
19
  * on a flow card. Unknown keys are PRESERVED and ignored, never stripped. */
20
20
  const KNOWN_CONFIG = {
21
21
  agent: new Set(['model', 'effort', 'fanOut', 'askQuestions', 'awaitAll', 'subagentModel']),
22
+ script: new Set(['params', 'ports', 'timeoutMs', 'awaitAll', 'mock', 'paramsPort']),
22
23
  task: new Set(['planStoreSeed']),
23
24
  and: new Set(['arity']),
24
25
  or: new Set(['arity']),
@@ -32,6 +33,8 @@ const KNOWN_WIRE_CONFIG = new Set(['maxCycles']);
32
33
  * (workflows.mjs#setWorkflowNodeDefaults, ui/server.mjs template save). A
33
34
  * strict subset of KNOWN_CONFIG.agent: awaitAll is TOPOLOGY, not a tunable. */
34
35
  export const AGENT_TUNABLES = ['model', 'effort', 'fanOut', 'askQuestions', 'subagentModel'];
36
+ /** The per-node SCRIPT tunables (P2's per-project overlays); unused by v1 writers. */
37
+ export const SCRIPT_TUNABLES = ['params', 'timeoutMs'];
35
38
 
36
39
  const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
37
40
 
@@ -184,8 +187,8 @@ export const RULES = [
184
187
  for (const n of nodes) {
185
188
  if (!KINDS.includes(n.kind)) { // KINDS is a frozen ARRAY (P1 constants)
186
189
  add(`node '${n.id}' has unknown kind ${JSON.stringify(n.kind)} — expected one of ${[...KINDS].join(', ')}`, { nodeId: n.id });
187
- } else if (n.kind === 'agent' && !n.key) add(`agent node '${n.id}' must declare a key`, { nodeId: n.id });
188
- else if (n.kind !== 'agent' && n.key !== undefined) {
190
+ } else if (KEYED_KINDS.includes(n.kind) && !n.key) add(`${n.kind} node '${n.id}' must declare a key`, { nodeId: n.id });
191
+ else if (!KEYED_KINDS.includes(n.kind) && n.key !== undefined) {
189
192
  add(`node '${n.id}' of kind '${n.kind}' must not declare a key`, { nodeId: n.id });
190
193
  }
191
194
  }
@@ -193,12 +196,25 @@ export const RULES = [
193
196
 
194
197
  { code: 'V4', level: 'E', check({ nodes, portsFor }, add) {
195
198
  for (const n of nodes) {
196
- if (n.kind !== 'agent' || !n.key) continue; // a missing key is V3's
199
+ if (!KEYED_KINDS.includes(n.kind) || !n.key) continue; // a missing key is V3's
197
200
  const p = portsFor(n.id);
198
- if (!p.known) add(`unknown agent "${n.key}" — no such key in the registry`, { nodeId: n.id });
199
- else if (!p.ported) add(`agent "${n.key}" has no v2 ports — port its sidecar to metaVersion 2`, { nodeId: n.id });
201
+ const what = n.kind;
202
+ if (!p.known) add(`unknown ${what} "${n.key}" — no such key in the registry`, { nodeId: n.id });
203
+ else if (!p.ported && p.meta?.configPortsMissing) {
204
+ add(`script "${n.key}" takes its ports per card — add them in the inspector`, { nodeId: n.id, incomplete: true });
205
+ } else if (!p.ported && p.meta?.configPortsInvalid) {
206
+ add(`script "${n.key}" has invalid ports in its config — fix them in the inspector`, { nodeId: n.id });
207
+ } else if (!p.ported) add(`agent "${n.key}" has no v2 ports — port its sidecar to metaVersion 2`, { nodeId: n.id });
200
208
  else if (p.meta?.placeable === false) {
201
- add(`agent "${n.key}" declares placeable: false and cannot be a graph node`, { nodeId: n.id });
209
+ add(`${what} "${n.key}" declares placeable: false and cannot be a graph node`, { nodeId: n.id });
210
+ } else if (p.meta?.runtime === 'python' && p.meta?.runtimeMissing) {
211
+ // Workbench §7 / W17: GET /api/scripts stamps `runtimeMissing` on a python
212
+ // meta when THIS host's probe failed, so the composer paints the card red
213
+ // the moment it is placed. The registry itself never carries the flag (the
214
+ // loader is synchronous, the probe is not), so a saved graph and a run are
215
+ // gated by the run preflight, which says the same sentence at the right
216
+ // moment. Listed always, unplaceable with a reason.
217
+ add(pythonMissingSentence(n.key), { nodeId: n.id });
202
218
  }
203
219
  }
204
220
  } },
@@ -263,10 +279,10 @@ export const RULES = [
263
279
 
264
280
  { code: 'V9', level: 'E', check({ nodes, metaOf, inputsOf, isWired, isLoopInput }, add) {
265
281
  for (const n of nodes) {
266
- // AGENT inputs only. P1 marks the gate `inK` ports and End's `result`
282
+ // KEYED inputs only. P1 marks the gate `inK` ports and End's `result`
267
283
  // `required: true`, and V12/V21 already own them with a card-specific
268
284
  // sentence — without this guard one unwired `n_and.in2` reports TWICE.
269
- if (n.kind !== 'agent' || !metaOf(n.id)) continue; // V3/V4 own an unresolved node
285
+ if (!KEYED_KINDS.includes(n.kind) || !metaOf(n.id)) continue; // V3/V4 own an unresolved node
270
286
  for (const inp of inputsOf(n.id)) {
271
287
  if (!inp?.required || inp.loop || isLoopInput(n.id, inp.id)) continue;
272
288
  // `incomplete`: the graph is UNFINISHED here, not wrong — the composer shows
@@ -403,7 +419,7 @@ export const RULES = [
403
419
 
404
420
  { code: 'V16', level: 'W', check({ nodes, metaOf, inputsOf, isWired, isLoopInput }, add) {
405
421
  for (const n of nodes) {
406
- if (n.kind !== 'agent' || !n.config?.awaitAll || !metaOf(n.id)) continue;
422
+ if (!KEYED_KINDS.includes(n.kind) || !n.config?.awaitAll || !metaOf(n.id)) continue;
407
423
  const wired = inputsOf(n.id).filter((inp) => !isLoopInput(n.id, inp.id) && isWired(n.id, inp.id));
408
424
  if (wired.length < 2) {
409
425
  add(`node '${n.id}' sets awaitAll but has ${wired.length} wired non-loop input(s) — the barrier is a no-op`,
@@ -434,7 +450,7 @@ export const RULES = [
434
450
  // always source, so without it every double-loop seed would warn permanently.
435
451
  { code: 'V18', level: 'W', check({ nodes, nodeById, metaOf, inputsOf, inboundOf, outputPort, isLoopInput }, add) {
436
452
  for (const n of nodes) {
437
- if (n.kind !== 'agent' || n.config?.awaitAll || !metaOf(n.id)) continue;
453
+ if (!KEYED_KINDS.includes(n.kind) || n.config?.awaitAll || !metaOf(n.id)) continue;
438
454
  let paired = 0;
439
455
  for (const inp of inputsOf(n.id)) {
440
456
  if (inp.id === AWAIT_PORT_ID || inp.synthetic) continue; // (c)
@@ -447,7 +463,7 @@ export const RULES = [
447
463
  paired += 1;
448
464
  }
449
465
  if (paired >= 2) {
450
- add(`agent node '${n.id}' has ${paired} always-sourced payload inputs without awaitAll — `
466
+ add(`${n.kind} node '${n.id}' has ${paired} always-sourced payload inputs without awaitAll — `
451
467
  + 'it may double-fire on re-runs (enable Await-all or insert an AND card)', { nodeId: n.id });
452
468
  }
453
469
  }
@@ -463,7 +479,7 @@ export const RULES = [
463
479
  const target = nodeById.get(w.to.node);
464
480
  if ((target.kind === 'and' || target.kind === 'or') && IN_PORT_RE.test(w.to.port)) continue;
465
481
  if (target.kind === 'end' && w.to.port === 'result') continue;
466
- if (target.kind === 'agent' && w.to.port === AWAIT_PORT_ID) continue;
482
+ if (KEYED_KINDS.includes(target.kind) && w.to.port === AWAIT_PORT_ID) continue;
467
483
  add(`blocking output '${w.from.node}.${w.from.port}' is wired into '${w.to.node}.${w.to.port}', `
468
484
  + 'which is not a loop input', { wireId: w.id });
469
485
  }
@@ -490,4 +506,61 @@ export const RULES = [
490
506
  if (!isWired(e.id, 'result')) add(`end node '${e.id}' input 'result' must be wired`, { nodeId: e.id, incomplete: true });
491
507
  }
492
508
  } },
509
+
510
+ // Script config (spec §9.1 V22): params against the sidecar's declarations,
511
+ // `ports` only where the sidecar says "config" (a MISSING or unreadable config
512
+ // is V4's), `mock` against the declared non-void outputs, `timeoutMs` an integer
513
+ // within [1 s, 24 h]. `incomplete` marks work-to-do (a required param nobody set yet).
514
+ // `paramsPort` only where it takes effect; a WIRED params port defers a required wirable param to the runner.
515
+ { code: 'V22', level: 'E', check({ nodes, portsFor, isWired }, add) {
516
+ for (const n of nodes) {
517
+ if (n.kind !== 'script' || !n.key) continue;
518
+ const p = portsFor(n.id);
519
+ if (!p.known) continue; // V4 owns it
520
+ const meta = p.meta || {};
521
+ const cfg = isObject(n.config) ? n.config : {};
522
+ const declared = Array.isArray(meta.params) ? meta.params : [];
523
+ const byId = new Map(declared.map((d) => [d.id, d]));
524
+ if (cfg.params !== undefined && !isObject(cfg.params)) add(`script node '${n.id}' config.params must be an object`, { nodeId: n.id });
525
+ const values = isObject(cfg.params) ? cfg.params : {};
526
+ for (const [id, value] of Object.entries(values)) {
527
+ const d = byId.get(id);
528
+ if (!d) {
529
+ add(`script node '${n.id}' sets unknown param '${id}' — script "${n.key}" declares ${declared.length ? declared.map((x) => x.id).join(', ') : 'no params'}`, { nodeId: n.id });
530
+ continue;
531
+ }
532
+ const bad = paramValueError(d, value);
533
+ if (bad) add(`script node '${n.id}' param '${id}': ${bad}`, { nodeId: n.id });
534
+ }
535
+ // `p.meta.inputs` are the RESOLVED inputs — the engine port is already among them, so it is
536
+ // looked up there instead of re-asking hasParamsPort (which would read it as a collision).
537
+ const enginePort = p.ported && p.inputs.some((x) => x && x.id === PARAMS_PORT.id && x.engine === 'params');
538
+ if (cfg.paramsPort !== undefined && typeof cfg.paramsPort !== 'boolean') {
539
+ add(`script node '${n.id}' config.paramsPort must be true or false`, { nodeId: n.id });
540
+ } else if (cfg.paramsPort === true && p.ported && !enginePort) {
541
+ add(wirableParams(meta).length
542
+ ? `script node '${n.id}' enables paramsPort but script "${n.key}" already declares an input named '${PARAMS_PORT.id}'`
543
+ : `script node '${n.id}' enables paramsPort but script "${n.key}" declares no param a wire can set`, { nodeId: n.id });
544
+ }
545
+ const wireDelivers = enginePort && isWired(n.id, PARAMS_PORT.id);
546
+ for (const d of declared) {
547
+ if (wireDelivers && WIRABLE_PARAM_TYPES.includes(d.type)) continue; // the wire may deliver it; the runner refuses the execution if it does not
548
+ if (d.required && values[d.id] === undefined && d.default === undefined) {
549
+ add(`script node '${n.id}' is missing required param '${d.id}'`, { nodeId: n.id, incomplete: true });
550
+ }
551
+ }
552
+ if (meta.ports !== 'config' && cfg.ports !== undefined) {
553
+ add(`script node '${n.id}' carries config.ports but script "${n.key}" declares its ports in its sidecar`, { nodeId: n.id });
554
+ }
555
+ if (cfg.mock !== undefined) {
556
+ for (const e of mockErrors(cfg.mock, p.ported ? p.outputs : [])) add(`script node '${n.id}' config.mock: ${e}`, { nodeId: n.id });
557
+ }
558
+ if (cfg.timeoutMs !== undefined && (!Number.isInteger(cfg.timeoutMs) || cfg.timeoutMs < MIN_TIMEOUT_MS)) {
559
+ add(`script node '${n.id}' timeoutMs must be an integer >= ${MIN_TIMEOUT_MS} ms (got ${JSON.stringify(cfg.timeoutMs)})`, { nodeId: n.id });
560
+ } else if (cfg.timeoutMs > MAX_TIMEOUT_MS) {
561
+ // v4 T3: a timer delay past 2^31-1 ms fires after ONE millisecond — the card would be killed at once.
562
+ add(`script node '${n.id}' timeoutMs must be at most ${MAX_TIMEOUT_MS} ms (24 h) (got ${cfg.timeoutMs})`, { nodeId: n.id });
563
+ }
564
+ }
565
+ } },
493
566
  ];
@@ -0,0 +1,100 @@
1
+ // Human-hours estimator (money-saved design §4). PURE: no I/O, no builtin imports — shared by
2
+ // the orchestrator (per step), the backfill (per run) and, later, the browser. Every rule
3
+ // reads EVIDENCE of what a step produced; no agent key is ever consulted.
4
+
5
+ export const HUMAN_ESTIMATE_DEFAULTS = Object.freeze({
6
+ codeBase: 0.5, codeFileH: 0.1, codeExp: 0.85, codeDiv: 25, // 0.5 + 0.1·files + lines^0.85/25
7
+ writeBase: 0.25, writeWph: 500, writeCapWords: 6000, // 0.25 + min(words,6000)/500
8
+ reviseFactor: 0.35, reviseDecay: 0.5, // write · 0.35 · 0.5^(cycle−1)
9
+ jsonBase: 0.25, jsonItemH: 0.05, // 0.25 + 0.05·items
10
+ readLph: 300, readWph: 3000, rereadFactor: 0.3, // (lines/300 + words/3000) · (cycle>1 ? 0.3 : 1)
11
+ });
12
+
13
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
14
+
15
+ /** Round hours to 2 dp (the unit the rate multiplies). */
16
+ export const roundHours = (h) => Math.round(((finite(h) ? h : 0) + Number.EPSILON) * 100) / 100;
17
+
18
+ /** Merge user overrides over the defaults: only finite, non-negative numbers for KNOWN keys apply. */
19
+ export function resolveConstants(overrides) {
20
+ if (!overrides || typeof overrides !== 'object') return HUMAN_ESTIMATE_DEFAULTS;
21
+ const out = { ...HUMAN_ESTIMATE_DEFAULTS };
22
+ for (const key of Object.keys(HUMAN_ESTIMATE_DEFAULTS)) {
23
+ const v = overrides[key];
24
+ if (finite(v) && v >= 0) out[key] = v;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ const FENCE_RE = /```[\s\S]*?```/g;
30
+
31
+ /** Words outside fenced code blocks. Plans embed whole files; a human would not have typed those. */
32
+ export function proseWords(text) {
33
+ if (text == null) return 0;
34
+ return String(text).replace(FENCE_RE, ' ').split(/\s+/).filter(Boolean).length;
35
+ }
36
+
37
+ /** Array length, else the first array-valued member's length, else the key count; 0 for scalars. */
38
+ export function jsonItems(value) {
39
+ if (Array.isArray(value)) return value.length;
40
+ if (!value || typeof value !== 'object') return 0;
41
+ const arr = Object.values(value).find(Array.isArray);
42
+ return arr ? arr.length : Object.keys(value).length;
43
+ }
44
+
45
+ /**
46
+ * @param {object} evidence see the module header in human-evidence.mjs for the collector
47
+ * @param {object} [k] resolved constants
48
+ * @returns {{hours:number, signals:{code:number,write:number,revise:number,json:number,read:number}, method:'heuristic'|'override'|'none'}}
49
+ */
50
+ export function estimateStepHours(evidence, k = HUMAN_ESTIMATE_DEFAULTS) {
51
+ const signals = { code: 0, write: 0, revise: 0, json: 0, read: 0 };
52
+ const e = evidence || {};
53
+ const effort = e.agent?.humanEffort;
54
+ if (effort && finite(effort.hours) && effort.hours >= 0) {
55
+ return { hours: roundHours(effort.hours), signals, method: 'override' };
56
+ }
57
+ if (e.nodeKind !== 'agent' || !e.agent) return { hours: 0, signals, method: 'none' };
58
+ const cycle = Number.isInteger(e.cycle) && e.cycle > 0 ? e.cycle : 1;
59
+
60
+ // 3. code delta
61
+ if (e.code) {
62
+ const lines = (e.code.insertions | 0) + (e.code.deletions | 0);
63
+ if (lines > 0) signals.code = k.codeBase + k.codeFileH * (e.code.files | 0) + Math.pow(lines, k.codeExp) / k.codeDiv;
64
+ }
65
+ // 4. prose: the LARGEST md output only (a plan plus its review note is one activity)
66
+ const mds = (e.outputs || []).filter((o) => o && o.type === 'md');
67
+ if (mds.length) {
68
+ const top = mds.reduce((a, b) => ((b.words | 0) > (a.words | 0) ? b : a));
69
+ const w = k.writeBase + Math.min(top.words | 0, k.writeCapWords) / k.writeWph;
70
+ if (top.revision) signals.revise = w * k.reviseFactor * Math.pow(k.reviseDecay, cycle - 1);
71
+ else signals.write = w;
72
+ }
73
+ // 5. structured outputs
74
+ for (const o of e.outputs || []) {
75
+ if (o && o.type === 'json') signals.json += k.jsonBase + k.jsonItemH * (o.items | 0);
76
+ }
77
+ // 6. reading (verifiers only)
78
+ if (e.agent.runnerType === 'verifier' && e.reads) {
79
+ const base = (e.reads.diffLines | 0) / k.readLph + (e.reads.words | 0) / k.readWph;
80
+ signals.read = base * (cycle > 1 ? k.rereadFactor : 1);
81
+ }
82
+ // 7. total, scaled
83
+ const factor = effort && finite(effort.factor) && effort.factor >= 0 ? effort.factor : 1;
84
+ for (const s of Object.keys(signals)) signals[s] = roundHours(signals[s] * factor);
85
+ const hours = roundHours(Object.values(signals).reduce((a, b) => a + b, 0));
86
+ return { hours, signals, method: 'heuristic' };
87
+ }
88
+
89
+ /** Σ finite step.humanHours, 2 dp. Mirrors run-harness sumStepCosts. */
90
+ export function sumStepHours(steps) {
91
+ let sum = 0;
92
+ for (const s of Array.isArray(steps) ? steps : []) if (finite(s?.humanHours)) sum += s.humanHours;
93
+ return roundHours(sum);
94
+ }
95
+
96
+ /** hours × rate − spent, 2 dp; negative is a real (honest) outcome. */
97
+ export function savedUsd(hours, rateUsd, spentUsd) {
98
+ const v = (finite(hours) ? hours : 0) * (finite(rateUsd) ? rateUsd : 0) - (finite(spentUsd) ? spentUsd : 0);
99
+ return Math.round((v + Number.EPSILON) * 100) / 100;
100
+ }
@@ -0,0 +1,353 @@
1
+ // src/shared/schedule/recurrence.mjs
2
+ // Recurrence rules for scheduled runs — ONE source for the server, the CLI and the
3
+ // browser (served under /src/shared, like the graph model). Pure: no node: imports,
4
+ // no Date.now() — every function takes its instant.
5
+ //
6
+ // A rule is wall-clock time in an IANA timezone, never a UTC instant: "02:00 every
7
+ // night" must stay 02:00 across daylight-saving changes.
8
+ //
9
+ // { freq: 'daily'|'weekly'|'monthly', interval: 1..,
10
+ // weekdays: ['mo',..] (weekly)
11
+ // monthDay: 1..31 | 'last' (monthly)
12
+ // time: 'HH:MM', tz: 'Europe/Berlin',
13
+ // anchor: 'YYYY-MM-DD' (local date the interval counts from)
14
+ // end: { type:'never' } | { type:'until', until:'YYYY-MM-DD' } | { type:'count', count:n } }
15
+
16
+ export const WEEKDAYS = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
17
+ const WD_LONG = { mo: 'Monday', tu: 'Tuesday', we: 'Wednesday', th: 'Thursday', fr: 'Friday', sa: 'Saturday', su: 'Sunday' };
18
+ const WD_SHORT = { mo: 'Mon', tu: 'Tue', we: 'Wed', th: 'Thu', fr: 'Fri', sa: 'Sat', su: 'Sun' };
19
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
20
+ export const OVERLAP_POLICIES = ['skip', 'start', 'queue'];
21
+ export const MISSED_POLICIES = ['run', 'skip'];
22
+ const TIME_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
23
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
24
+ const MAX_SCAN_DAYS = 366 * 12;
25
+
26
+ /** True when `tz` is a timezone Intl accepts. */
27
+ export function isValidTimeZone(tz) {
28
+ if (typeof tz !== 'string' || !tz) return false;
29
+ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
30
+ }
31
+
32
+ const _fmtCache = new Map();
33
+ function partsFormatter(tz) {
34
+ let f = _fmtCache.get(tz);
35
+ if (!f) {
36
+ f = new Intl.DateTimeFormat('en-US', {
37
+ timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
38
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
39
+ });
40
+ _fmtCache.set(tz, f);
41
+ }
42
+ return f;
43
+ }
44
+
45
+ /** Wall-clock parts of instant `ms` in `tz`: { y, m (1-12), d, hh, mm, ss }. */
46
+ export function zonedParts(ms, tz) {
47
+ const out = {};
48
+ for (const p of partsFormatter(tz).formatToParts(new Date(ms))) {
49
+ if (p.type !== 'literal') out[p.type] = Number(p.value);
50
+ }
51
+ return { y: out.year, m: out.month, d: out.day, hh: out.hour % 24, mm: out.minute, ss: out.second };
52
+ }
53
+
54
+ /** Offset (ms) of `tz` from UTC at instant `ms` (positive east of Greenwich). */
55
+ function tzOffsetMs(ms, tz) {
56
+ const p = zonedParts(ms, tz);
57
+ return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm, p.ss) - Math.floor(ms / 1000) * 1000;
58
+ }
59
+
60
+ /**
61
+ * The UTC instant of a wall-clock time in `tz`. A time that does not exist (spring
62
+ * forward) moves FORWARD past the gap; a time that occurs twice (autumn) resolves to
63
+ * the FIRST occurrence, so a rule fires once.
64
+ */
65
+ export function zonedToUtc({ y, m, d, hh, mm }, tz) {
66
+ const wall = Date.UTC(y, m - 1, d, hh, mm, 0);
67
+ // The offsets a day either side of the wall time cover every transition.
68
+ const offs = [...new Set([tzOffsetMs(wall - 86400000, tz), tzOffsetMs(wall + 86400000, tz), tzOffsetMs(wall, tz)])];
69
+ const hits = [];
70
+ for (const off of offs) {
71
+ const t = wall - off;
72
+ const p = zonedParts(t, tz);
73
+ if (p.y === y && p.m === m && p.d === d && p.hh === hh && p.mm === mm) hits.push(t);
74
+ }
75
+ if (hits.length) return Math.min(...hits);
76
+ // Non-existent local time: read it with the offset in force BEFORE the jump, which
77
+ // lands the same distance past the gap.
78
+ return wall - Math.min(...offs);
79
+ }
80
+
81
+ function daysInMonth(y, m) { return new Date(Date.UTC(y, m, 0)).getUTCDate(); }
82
+ function dowIndex(y, m, d) { return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7; } // Monday = 0
83
+ function weekdayKey(y, m, d) { return WEEKDAYS[dowIndex(y, m, d)]; }
84
+ function civilDayNumber(y, m, d) { return Math.floor(Date.UTC(y, m - 1, d) / 86400000); }
85
+ function parseDate(s) { const r = DATE_RE.exec(String(s || '')); return r ? { y: +r[1], m: +r[2], d: +r[3] } : null; }
86
+ function pad(n) { return String(n).padStart(2, '0'); }
87
+ /** 'YYYY-MM-DD' of civil parts. */
88
+ export function civilDate({ y, m, d }) { return `${y}-${pad(m)}-${pad(d)}`; }
89
+ /** Today's local date ('YYYY-MM-DD') in `tz` at instant `ms`. */
90
+ export function localDate(ms, tz) { return civilDate(zonedParts(ms, tz)); }
91
+
92
+ /**
93
+ * Validate + normalise a rule. Returns { ok:true, rule } or { ok:false, error }.
94
+ * `todayLocal` ('YYYY-MM-DD') seeds a missing anchor.
95
+ */
96
+ export function normalizeRule(input, { todayLocal = null } = {}) {
97
+ const r = input && typeof input === 'object' && !Array.isArray(input) ? input : null;
98
+ if (!r) return { ok: false, error: 'repeat.rule must be an object' };
99
+ const freq = String(r.freq || '').toLowerCase();
100
+ if (!['daily', 'weekly', 'monthly'].includes(freq)) return { ok: false, error: 'rule.freq must be daily, weekly or monthly' };
101
+ const interval = r.interval == null ? 1 : Number(r.interval);
102
+ if (!Number.isInteger(interval) || interval < 1 || interval > 365) return { ok: false, error: 'rule.interval must be a whole number from 1 to 365' };
103
+ const tm = TIME_RE.exec(String(r.time || ''));
104
+ if (!tm) return { ok: false, error: 'rule.time must be HH:MM (24-hour)' };
105
+ const time = `${pad(+tm[1])}:${tm[2]}`;
106
+ if (!isValidTimeZone(r.tz)) return { ok: false, error: `rule.tz is not a known timezone: ${r.tz ?? '(missing)'}` };
107
+ const out = { freq, interval, time, tz: r.tz };
108
+ if (freq === 'weekly') {
109
+ const days = Array.isArray(r.weekdays) ? [...new Set(r.weekdays.map((x) => String(x).toLowerCase().slice(0, 2)))] : [];
110
+ if (days.some((x) => !WEEKDAYS.includes(x))) return { ok: false, error: 'rule.weekdays must use mo tu we th fr sa su' };
111
+ if (!days.length) return { ok: false, error: 'rule.weekdays needs at least one day' };
112
+ out.weekdays = WEEKDAYS.filter((x) => days.includes(x));
113
+ }
114
+ if (freq === 'monthly') {
115
+ const md = r.monthDay === 'last' ? 'last' : Number(r.monthDay);
116
+ if (md !== 'last' && (!Number.isInteger(md) || md < 1 || md > 31)) return { ok: false, error: 'rule.monthDay must be 1-31 or "last"' };
117
+ out.monthDay = md;
118
+ }
119
+ const anchor = parseDate(r.anchor) ? r.anchor : todayLocal;
120
+ if (!parseDate(anchor)) return { ok: false, error: 'rule.anchor must be YYYY-MM-DD' };
121
+ out.anchor = anchor;
122
+ const end = r.end && typeof r.end === 'object' ? r.end : { type: 'never' };
123
+ const et = String(end.type || 'never');
124
+ if (et === 'never') out.end = { type: 'never' };
125
+ else if (et === 'until') {
126
+ if (!parseDate(end.until)) return { ok: false, error: 'rule.end.until must be YYYY-MM-DD' };
127
+ out.end = { type: 'until', until: end.until };
128
+ } else if (et === 'count') {
129
+ const c = Number(end.count);
130
+ if (!Number.isInteger(c) || c < 1 || c > 100000) return { ok: false, error: 'rule.end.count must be a whole number of 1 or more' };
131
+ out.end = { type: 'count', count: c };
132
+ } else return { ok: false, error: 'rule.end.type must be never, until or count' };
133
+ return { ok: true, rule: out };
134
+ }
135
+
136
+ function matchesDate(rule, y, m, d, anchor) {
137
+ if (rule.freq === 'daily') {
138
+ const diff = civilDayNumber(y, m, d) - civilDayNumber(anchor.y, anchor.m, anchor.d);
139
+ return diff >= 0 && diff % rule.interval === 0;
140
+ }
141
+ if (rule.freq === 'weekly') {
142
+ if (!rule.weekdays.includes(weekdayKey(y, m, d))) return false;
143
+ if (civilDayNumber(y, m, d) < civilDayNumber(anchor.y, anchor.m, anchor.d)) return false;
144
+ // Week index counted from the Monday of the anchor's week.
145
+ const monday = (yy, mm2, dd) => civilDayNumber(yy, mm2, dd) - dowIndex(yy, mm2, dd);
146
+ const diff = (monday(y, m, d) - monday(anchor.y, anchor.m, anchor.d)) / 7;
147
+ return diff % rule.interval === 0;
148
+ }
149
+ // monthly — a day past the month's end clamps to its last day.
150
+ const months = (y - anchor.y) * 12 + (m - anchor.m);
151
+ if (months < 0 || months % rule.interval !== 0) return false;
152
+ const dim = daysInMonth(y, m);
153
+ const want = rule.monthDay === 'last' ? dim : Math.min(rule.monthDay, dim);
154
+ return d === want;
155
+ }
156
+
157
+ /**
158
+ * The first occurrence strictly after `afterMs`, as a UTC ms instant, or null when
159
+ * the rule has ended (`firedCount` feeds end.type 'count').
160
+ */
161
+ export function nextOccurrence(rule, afterMs, { firedCount = 0 } = {}) {
162
+ if (rule.end?.type === 'count' && firedCount >= rule.end.count) return null;
163
+ const anchor = parseDate(rule.anchor);
164
+ const [hh, mm] = rule.time.split(':').map(Number);
165
+ const until = rule.end?.type === 'until' ? parseDate(rule.end.until) : null;
166
+ const start = zonedParts(afterMs, rule.tz);
167
+ let dayNo = civilDayNumber(start.y, start.m, start.d);
168
+ for (let i = 0; i < MAX_SCAN_DAYS; i++, dayNo++) {
169
+ const dt = new Date(dayNo * 86400000);
170
+ const y = dt.getUTCFullYear(), m = dt.getUTCMonth() + 1, d = dt.getUTCDate();
171
+ if (until && dayNo > civilDayNumber(until.y, until.m, until.d)) return null;
172
+ if (!matchesDate(rule, y, m, d, anchor)) continue;
173
+ const t = zonedToUtc({ y, m, d, hh, mm }, rule.tz);
174
+ if (t > afterMs) return t;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /** The next `n` occurrences after `afterMs` (for the editor preview). */
180
+ export function previewOccurrences(rule, afterMs, n = 3, { firedCount = 0 } = {}) {
181
+ const out = [];
182
+ let t = afterMs, fired = firedCount;
183
+ while (out.length < n) {
184
+ const next = nextOccurrence(rule, t, { firedCount: fired });
185
+ if (next == null) break;
186
+ out.push(next); t = next; fired++;
187
+ }
188
+ return out;
189
+ }
190
+
191
+ function listJoin(xs) { return xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`; }
192
+ function ordinal(n) { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
193
+
194
+ /** The plain-language sentence of a rule: "Every weekday at 02:00". */
195
+ export function describeRule(rule, { withTz = false } = {}) {
196
+ if (!rule) return '';
197
+ const n = rule.interval || 1;
198
+ let head;
199
+ if (rule.freq === 'daily') head = n === 1 ? 'Every day' : `Every ${n} days`;
200
+ else if (rule.freq === 'weekly') {
201
+ const days = rule.weekdays || [];
202
+ const key = days.join(',');
203
+ if (key === 'mo,tu,we,th,fr') head = n === 1 ? 'Every weekday' : `Every ${n} weeks on weekdays`;
204
+ else if (days.length === 7) head = n === 1 ? 'Every day' : `Every ${n} weeks, every day`;
205
+ else head = `${n === 1 ? 'Every' : `Every ${n} weeks on`} ${listJoin(days.map((d) => WD_LONG[d]))}`;
206
+ } else {
207
+ const day = rule.monthDay === 'last' ? 'the last day' : `the ${ordinal(rule.monthDay)}`;
208
+ head = n === 1 ? `Every month on ${day}` : `Every ${n} months on ${day}`;
209
+ }
210
+ let s = `${head} at ${rule.time}`;
211
+ if (rule.end?.type === 'until') s += `, until ${rule.end.until}`;
212
+ if (rule.end?.type === 'count') s += `, ${rule.end.count} time${rule.end.count === 1 ? '' : 's'}`;
213
+ if (withTz) s += ` (${rule.tz})`;
214
+ return s;
215
+ }
216
+
217
+ /** "Sat Sep 19, 02:00" in `tz` — the app's hand-rolled local format (app.js fmtResetAtLocal). */
218
+ export function formatInstant(ms, tz, { withYear = false } = {}) {
219
+ const p = zonedParts(ms, tz);
220
+ const wd = WD_SHORT[weekdayKey(p.y, p.m, p.d)];
221
+ return `${wd} ${MONTHS[p.m - 1]} ${p.d}${withYear ? ` ${p.y}` : ''}, ${pad(p.hh)}:${pad(p.mm)}`;
222
+ }
223
+
224
+ /** "7h 12m" / "3d 4h" / "45s" — the distance to a future instant ('' when past). */
225
+ export function formatCountdown(ms) {
226
+ if (!Number.isFinite(ms) || ms <= 0) return '';
227
+ const s = Math.floor(ms / 1000);
228
+ if (s < 60) return `${s}s`;
229
+ const m = Math.floor(s / 60);
230
+ if (m < 60) return `${m}m`;
231
+ const h = Math.floor(m / 60);
232
+ if (h < 48) return `${h}h ${m % 60}m`;
233
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
234
+ }
235
+
236
+ /**
237
+ * Parse the CLI's `--every` shorthand into a rule (tz/anchor filled by the caller):
238
+ * "day 03:30" · "3 days 02:00" · "weekdays 02:00" · "weekends 09:00"
239
+ * "mon,wed,fri 02:00" · "2 weeks mon 02:00" · "month 1 02:00" · "3 months last 02:00"
240
+ */
241
+ export function parseEvery(text) {
242
+ const toks = String(text || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
243
+ if (toks.length < 2) return { ok: false, error: '--every needs a pattern and a time, e.g. "weekdays 02:00"' };
244
+ const time = toks.pop();
245
+ if (!TIME_RE.test(time)) return { ok: false, error: `--every: "${time}" is not a HH:MM time` };
246
+ let interval = 1;
247
+ if (/^\d+$/.test(toks[0])) interval = Number(toks.shift());
248
+ if (!toks.length) return { ok: false, error: '--every: missing unit (day, week, month, weekdays, or day names)' };
249
+ const unit = toks.shift();
250
+ const days = (s) => s.split(',').map((x) => x.trim().slice(0, 2)).filter(Boolean);
251
+ if (['day', 'days', 'daily', 'night', 'nightly'].includes(unit) && !toks.length) return { ok: true, rule: { freq: 'daily', interval, time } };
252
+ if (unit === 'weekdays' && !toks.length) return { ok: true, rule: { freq: 'weekly', interval, weekdays: ['mo', 'tu', 'we', 'th', 'fr'], time } };
253
+ if (unit === 'weekends' && !toks.length) return { ok: true, rule: { freq: 'weekly', interval, weekdays: ['sa', 'su'], time } };
254
+ if (['week', 'weeks', 'weekly'].includes(unit)) {
255
+ if (toks.length !== 1) return { ok: false, error: '--every: name the day(s), e.g. "week mon 02:00" or "2 weeks mon,thu 02:00"' };
256
+ const d = days(toks[0]);
257
+ if (!d.length || d.some((x) => !WEEKDAYS.includes(x))) return { ok: false, error: `--every: unknown day in "${toks[0]}"` };
258
+ return { ok: true, rule: { freq: 'weekly', interval, weekdays: d, time } };
259
+ }
260
+ if (['month', 'months', 'monthly'].includes(unit)) {
261
+ if (toks.length !== 1) return { ok: false, error: '--every: name the day of the month, e.g. "month 1 02:00" or "month last 02:00"' };
262
+ const md = toks[0] === 'last' ? 'last' : Number(toks[0]);
263
+ if (md !== 'last' && (!Number.isInteger(md) || md < 1 || md > 31)) return { ok: false, error: `--every: "${toks[0]}" is not a day of the month` };
264
+ return { ok: true, rule: { freq: 'monthly', interval, monthDay: md, time } };
265
+ }
266
+ if (!toks.length) {
267
+ const d = days(unit);
268
+ if (d.length && d.every((x) => WEEKDAYS.includes(x))) return { ok: true, rule: { freq: 'weekly', interval, weekdays: d, time } };
269
+ }
270
+ return { ok: false, error: `--every: cannot read "${text}" — try "day 03:30", "weekdays 02:00", "mon,thu 02:00" or "month 1 02:00"` };
271
+ }
272
+
273
+ /**
274
+ * Translate a 5-field cron line when a rule can express it (fixed minute + hour, and
275
+ * either a day-of-week set or one day-of-month). Anything else is refused with a hint.
276
+ */
277
+ export function parseCron(text) {
278
+ const f = String(text || '').trim().split(/\s+/);
279
+ if (f.length !== 5) return { ok: false, error: '--cron needs five fields: minute hour day-of-month month day-of-week' };
280
+ const [min, hour, dom, mon, dow] = f;
281
+ const hint = ' — only a fixed time with day-of-week or one day-of-month is supported; use --every for the rest';
282
+ if (!/^\d{1,2}$/.test(min) || !/^\d{1,2}$/.test(hour) || +min > 59 || +hour > 23) return { ok: false, error: `--cron: minute and hour must be single numbers${hint}` };
283
+ if (mon !== '*') return { ok: false, error: `--cron: a month field is not supported${hint}` };
284
+ const time = `${pad(+hour)}:${pad(+min)}`;
285
+ if (dom === '*' && dow === '*') return { ok: true, rule: { freq: 'daily', interval: 1, time } };
286
+ if (dom === '*') {
287
+ const set = new Set();
288
+ for (const part of dow.split(',')) {
289
+ const r = /^(\d)(?:-(\d))?$/.exec(part);
290
+ if (!r) return { ok: false, error: `--cron: cannot read day-of-week "${dow}"${hint}` };
291
+ const a = +r[1], b = r[2] == null ? a : +r[2];
292
+ if (a > 7 || b > 7 || b < a) return { ok: false, error: `--cron: cannot read day-of-week "${dow}"${hint}` };
293
+ for (let i = a; i <= b; i++) set.add(WEEKDAYS[((i % 7) + 6) % 7]); // cron 0/7 = Sunday
294
+ }
295
+ return { ok: true, rule: { freq: 'weekly', interval: 1, weekdays: WEEKDAYS.filter((d) => set.has(d)), time } };
296
+ }
297
+ if (dow === '*' && /^\d{1,2}$/.test(dom) && +dom >= 1 && +dom <= 31) return { ok: true, rule: { freq: 'monthly', interval: 1, monthDay: +dom, time } };
298
+ return { ok: false, error: `--cron: this pattern cannot be expressed${hint}` };
299
+ }
300
+
301
+ /**
302
+ * Parse a one-shot `--at` value into a UTC ms instant. Local forms are read in `tz`:
303
+ * "02:00" (next occurrence) · "today 22:00" · "tomorrow 02:00" · "+90m" "+2h" "+1d"
304
+ * "2026-09-19 02:00" · "2026-09-19T02:00" · ISO 8601 with an offset or Z
305
+ */
306
+ export function parseAt(text, { nowMs, tz }) {
307
+ const s = String(text || '').trim();
308
+ if (!s) return { ok: false, error: '--at needs a time' };
309
+ const rel = /^\+(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/i.exec(s);
310
+ if (rel) {
311
+ const n = Number(rel[1]); const u = rel[2][0].toLowerCase();
312
+ return { ok: true, ms: nowMs + n * (u === 's' ? 1000 : u === 'm' ? 60000 : u === 'h' ? 3600000 : 86400000) };
313
+ }
314
+ if (/\d{4}-\d{2}-\d{2}T/.test(s) && /([zZ]|[+-]\d{2}:?\d{2})$/.test(s)) {
315
+ const ms = Date.parse(s);
316
+ return Number.isFinite(ms) ? { ok: true, ms } : { ok: false, error: `--at: cannot read "${s}"` };
317
+ }
318
+ const now = zonedParts(nowMs, tz);
319
+ let r = /^(?:(today|tomorrow)\s+)?(\d{1,2}:\d{2})$/i.exec(s);
320
+ if (r) {
321
+ const tm = TIME_RE.exec(r[2]);
322
+ if (!tm) return { ok: false, error: `--at: "${r[2]}" is not a HH:MM time` };
323
+ const base = civilDayNumber(now.y, now.m, now.d) + (String(r[1] || '').toLowerCase() === 'tomorrow' ? 1 : 0);
324
+ const mk = (dayNo) => {
325
+ const dt = new Date(dayNo * 86400000);
326
+ return zonedToUtc({ y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), hh: +tm[1], mm: +tm[2] }, tz);
327
+ };
328
+ let ms = mk(base);
329
+ if (!r[1] && ms <= nowMs) ms = mk(base + 1); // a bare time means its NEXT occurrence
330
+ return { ok: true, ms };
331
+ }
332
+ r = /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{1,2}):(\d{2})(?::\d{2})?$/.exec(s);
333
+ if (r) {
334
+ if (+r[4] > 23 || +r[5] > 59) return { ok: false, error: `--at: "${s}" is not a valid time` };
335
+ return { ok: true, ms: zonedToUtc({ y: +r[1], m: +r[2], d: +r[3], hh: +r[4], mm: +r[5] }, tz) };
336
+ }
337
+ return { ok: false, error: `--at: cannot read "${s}" — try "02:00", "tomorrow 02:00", "+90m" or "2026-09-19 02:00"` };
338
+ }
339
+
340
+ /**
341
+ * Parse an API `scheduledFor`: ISO 8601 WITH an offset or Z only — the server cannot
342
+ * know the caller's zone. Returns { ok, ms } or { ok:false, error }.
343
+ */
344
+ export function parseScheduledFor(value) {
345
+ if (typeof value !== 'string' || !value.trim()) return { ok: false, error: 'scheduledFor must be an ISO 8601 string' };
346
+ const s = value.trim();
347
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?([zZ]|[+-]\d{2}:?\d{2})$/.test(s)) {
348
+ return { ok: false, error: 'scheduledFor must be ISO 8601 with a UTC offset or Z, e.g. 2026-09-19T02:00:00+02:00' };
349
+ }
350
+ const ms = Date.parse(s);
351
+ if (!Number.isFinite(ms)) return { ok: false, error: 'scheduledFor is not a valid date' };
352
+ return { ok: true, ms };
353
+ }