@ngockhoale/ukit 3.1.9 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/cli/commands/memory.js +11 -87
- package/src/cli/commands/selfImprove.js +55 -0
- package/src/cli/commands/telemetry.js +45 -3
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +177 -0
- package/src/core/agentRuntime/contract.js +77 -0
- package/src/core/agentRuntime/diagnostics.js +343 -42
- package/src/core/agentRuntime/eventStore.js +139 -0
- package/src/core/agentRuntime/planCompiler.js +45 -6
- package/src/core/agentRuntime/planLibrary.js +43 -7
- package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
- package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
- package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
- package/src/core/agentRuntime/plans/release-check.json +2 -1
- package/src/core/agentRuntime/promotion.js +147 -9
- package/src/core/agentRuntime/runtimeSupport.js +18 -0
- package/src/core/agentRuntime/supervisor.js +44 -2
- package/src/core/agentRuntime/telemetry.js +123 -0
- package/src/core/agentRuntime/vmEngine.js +51 -10
- package/src/core/memory/episodes.js +168 -0
- package/src/core/metadata.js +21 -0
- package/src/core/runInstallPipeline.js +6 -1
- package/src/core/runtimeConfig.js +71 -40
- package/src/decision/client.js +4 -5
- package/src/decision/runtimeDecide.js +183 -5
- package/src/learning/selfImprove.js +205 -0
- package/src/learning/tunedOverlay.js +112 -0
- package/template_project/.claude/hooks/session-episode.sh +35 -15
- package/template_project/.claude/ukit/index/route-task.mjs +13 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +1 -2
- package/template_project/.claude/ukit/runtime/self-improve-trigger.mjs +98 -0
- package/template_project/ukit/storage/config.json +68 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.3.0 - 2026-09-27
|
|
6
|
+
|
|
7
|
+
**Zero-config self-improvement: every feature stage ships on, UKit collects and learns from its own data.** Fixes the gap where 2.6.8–3.2.0 built the machinery (flight recorder, memory v2, learning, decision plane) but nothing on the live hook path fed it — measured 1 telemetry record and 0 memory-v2 records after days of use.
|
|
8
|
+
|
|
9
|
+
- **`ukit self-improve` — the automatic collect → learn → apply pass** (`src/learning/selfImprove.js`, `src/cli/commands/selfImprove.js`). One bounded, rate-limited (10 min), lock-guarded pass that runs after every session: (1) backfills an episode for every idle exec-ledger — engine-agnostic, fixes omp session-id mismatch and Codex having no lifecycle hooks; (2) refreshes diagnostics (failure patterns, feedback, skill accuracy); (3) computes tuning suggestions and **auto-applies** them under `learning.tuning.applyMode: 'auto'` (new default) as clamped one-step moves into `.ukit/storage/learning/tuned.json` with a 24h per-key cooldown; (4) writes repeated failure patterns as pending pattern candidates (promotion still needs `ukit memory approve`); (5) ingests stored hook/ledger telemetry, flushes, refreshes the support view. Never throws; each step isolated.
|
|
10
|
+
- **`self-improve-trigger.mjs`** (`template_project/.claude/ukit/runtime/`): fire-and-forget launcher called from Claude `SessionEnd`/`session-episode.sh`, omp `session_stop` via the bridge, and Codex `route-task.mjs` (its only per-task entry). Detached child, returns in ms, silent no-op on any failure; opt out via `learning.selfImprove.enabled: false` or `UKIT_SELF_IMPROVE=0`.
|
|
11
|
+
- **CLI locator** (`.ukit/storage/cli.json`, `writeCliLocator` in `metadata.js`, written by `ukit install`): absolute node + bin paths so hooks spawning the CLI work on GUI-launched hosts whose PATH lacks nvm/volta. `resolveCliLocator` validates node basename, `bin/ukit` shape and `@ngockhoale/ukit` package identity before trusting it; PATH lookup stays the fallback.
|
|
12
|
+
- **All stage flags default on**: `routing.*` → `default`; `memoryV2.decision` → `default`; `learning.candidates`/`overlays` → `default`; `continuity.resumableRun` → `default`; `decisionRuntime.*` → `shadow` (diagnostics → `default`); `experiments.deliberation`/`dynamicWorkflow` → enabled; `observability.stage` added to code defaults (`default`). `learning.tuning.applyMode` accepts `'auto'`; tuned overlay merges via `inspectRuntimeConfig`. Rollback: per-key `stage: "off"`, `applyMode: 'manual'`, or delete `tuned.json`.
|
|
13
|
+
- **`ukit memory episode` refactored** onto shared `src/core/memory/episodes.js` (`resolveLedger`, `episodeText`, `writeEpisode`, `backfillEpisodes`) — one owner for the record shape between the single-session command and the self-improve backfill.
|
|
14
|
+
- Tests: 28 new (tunedOverlay clamp/merge/cooldown, episodes resolve/write/backfill dedupe, trigger locator validation + spawn contract); stale stage-default pins updated to the 3.3.0 contract.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## 3.2.0 - 2026-09-26
|
|
19
|
+
|
|
20
|
+
**Agent VM / Language-Compiled Runtime phase 2 (V-01..V-06)** — IR v2 flag-gated opcodes, decisionRuntime.vm stage-promotion machinery, plan library pins, VM decision nodes via unic-decision, host parity adapters, sanitized support bundle. All new surfaces ship `off`.
|
|
21
|
+
|
|
22
|
+
- **Host lane adapters for the owned runner (agent-vm-runtime V2).** `adapters.js` gains the frozen `HOST_ADAPTERS` registry plus `detectHost` (env-marker best-effort ambient engine resolution, ambiguous → `null`) and `probeHostAdapter` — honest per-host capability statuses `supported` / `unsupported-probe` / `unsupported` with typed reasons (`unknown_host`, `process_group_kill_unavailable`, `host_spawn_unavailable`, `live_host_e2e_unproven`); `supported` is reachable only via explicit `liveHostE2E` evidence, never inferred from primitive presence (G-V2). `buildHostCapabilityMap` in `runtimeSupport.js` exposes the map per lane; every lane reports `completionApi: 'eventStore-journal'` — no engine exposes a host-owned durable completion API. `createSupervisor` accepts `opts.host` and per-launch `spec.host` (precedence spec → opts → ambient detect → no lane); a lane probed `unsupported` refuses `start()` typed (`host_unknown`/`host_unsupported`, zero fs writes) while `unsupported-probe` lanes still run the identical spawn → journal → terminal contract. Live-E2E gap for claude-code/codex is documented in the task file — never claimed as supported.
|
|
23
|
+
- **Agent VM V-01 — IR v2 flag gate + versioned IR contract.** IR v2 ops (`PARALLEL`, `TIMEOUT`) now parse only behind an explicit opt-in — `ir.planVersion: 'v2'` on the artifact, `opts.irVersion: 'v2'` on `compilePlan`, a caller `allowedOps` list naming them, or a non-`off` `decisionRuntime.vm` stage forwarded by `planLibrary.loadPlan({config})`. Ungated v2 IR fails with `ir_v2_disabled`; unknown version literals fail `invalid_ir_version`. `contract.js` exports `IR_VERSIONS` (`['v1','v2']`) and `ValidatedPlan` carries `irVersion` derived from ops actually used — v1 plans compile byte-identical under every flag state (planVersion unchanged). RETRY lands as the bounded per-node `retry` policy (side-effect-class aware: non-auto-retryable classes can never claim `maxAttempts > 1`; a crashed non-idempotent node stays `recovery_required` and is never re-run blind). New fault coverage: TIMEOUT firing mid-node on deliver → deterministic `escalate` replay-identically; duplicate delivery at the join is idempotent across a crash (single `completed` fold in the wrapper journal); bounded fan-out honors `maxNodes`.
|
|
24
|
+
|
|
25
|
+
- **V4 plan library: release pinning + spec text.** `planLibrary.loadPlan` now returns `specText` (the human-authored spec each plan was compiled from) and `planVersion`, and enforces `PLAN_PINS` — a shipped plan whose compiled hash drifts from its pin fails with `plan_version_mismatch` instead of silently running a different workflow. Fourth reference plan `flag-promotion` (stage-promotion gate: baseline replay → shadow runs → promote/rollback branch, mirroring `promotion.js`) joins `bugfix-loop`, `handoff-review-batch`, `release-check`. Coverage: artifact-hash stability, vmEngine replay determinism per plan, malformed-artifact rejection, pin drift.
|
|
26
|
+
- **VM decision nodes via `unic-decision` (agent-vm-runtime V5).** `vmEngine` nodes may now carry a bounded `question` block (`{decisionKey?, instruction?, candidates?}`, validated by `validateNodeQuestion` in `contract.js` and carried through `planCompiler`). When `decisionRuntime.vm.stage` is promoted past `off` and no `classifyFn` is injected, unclassifiable events are routed by `createVmClassifyFn` (`src/decision/runtimeDecide.js`) through one bounded `runtime.node_route.v1` / `runtime.node_classify.v1` question — the only two keys a node may ask (registered `rolloutStage: 'off'`, owner `vmEngine`, deterministic escalate fallback). Model answers are data only (`tool_calls` never dispatched); `model_not_found`/timeout/invalid/adapter faults all resolve to the deterministic escalation lane with a `fallbackCode`, never a hard failure — a throwing `classifyFn` now escalates too. Stage `off` (default, absent, or malformed) installs no adapter: zero decision calls, byte-identical behaviour.
|
|
27
|
+
|
|
28
|
+
- **Agent VM V-06 — per-run support bundle** (`src/core/agentRuntime/diagnostics.js` `exportSupportBundle`, `eventStore.js` `readJournalExcerpt` + `summarizeEvent`, `telemetry.js` `buildTraceExcerpt`, `ukit telemetry export-run <operationId>`): one sanitized bundle per operation lands in the Data Foundation support layout — `Documents/UKit Support/proj-<sha256>/run-<sha256>/` with `journal.jsonl` (bounded code summaries only), `trace.jsonl` (double-gated span excerpt, deterministic pseudonyms), `SUMMARY.md` and a `ukit-support/1` manifest with sha256 checksums, importable via `ukit telemetry import`. Privacy: redaction before persist — secret/path/PII rules + `sanitizeForSupport` on trace records + a final assembled-bytes re-scan that blocks all writes on any residual secret; no raw prompt/diff/path content can appear. `decisionRuntime.diagnostics.stage: 'off'` → `skipped` with zero writes. Missing/corrupt journals still emit bundles with declared `gap` markers; a 256 KiB cap drops excerpt lines deterministically (`coverage.dropped_lines`).
|
|
29
|
+
|
|
5
30
|
## 3.1.9 - 2026-09-26
|
|
6
31
|
|
|
7
32
|
- **Checkpoint locked to `unic-decision` for all classes.** Owner decision: the single `unic-decision` model answers every decision (English, multilingual, unknown) — the provider swaps the backend (Lava/JEV) behind that name, so UKit no longer routes per-language. `checkpoints.{default,english,unknownLanguage}` all resolve to `unic-decision` in code + shipped config; `unic-decision-multilingual` removed from defaults (remains configurable via `decisionPlane.checkpoints` if ever needed). Endpoint config unchanged: `ukit decision` / `gatewayDecision.json` / `UKIT_DECISION_*` env.
|
|
8
33
|
|
|
34
|
+
- **Agent VM V-03 — stage-promotion machinery** (`src/core/agentRuntime/promotion.js`, `src/core/runtimeConfig.js`): new `resolveDecisionRuntimeStage(config, key)` resolves `decisionRuntime.<key>.stage` with `decisionPlane` semantics (absent/malformed → `off`); new pure `promote(config, evidence)` advances one family flag `off → shadow → canary → default` only when the frozen `PROMOTION_CRITERIA` hold on ≥ `minSampledRuns` sampled runs (quality delta ≥ `-QUALITY_SCORE_FLOOR`, wall-p95 and cost ratios ≤ baseline), and instant rollback (`evidence.rollback` or any forbidden failure) resolves `off` — deterministic owners authoritative, zero VM calls. `off` never self-promotes and one noisy run never flips a default. All `decisionRuntime.*` stages stay `off`: machinery only, no rollout flip.
|
|
35
|
+
|
|
9
36
|
## 3.1.8 - 2026-09-26
|
|
10
37
|
|
|
11
38
|
- **Agent VM IR v2**: `PARALLEL` (bounded fan-out, `join:'all'`) and `TIMEOUT` (deadline wrapper, `onTimeout: fail|escalate`, 30-minute ceiling) opcodes added to `planCompiler` + `vmEngine`; fault-injection coverage for crash-between-fan-out-and-join, non-idempotent child recovery, duplicate delivery. v2 plans hash under a `v2:`-prefixed serialization; v1 hashes unchanged.
|
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@ import { buildRuntimePaths } from '../../core/runtimePaths.js';
|
|
|
29
29
|
import { buildUserPaths } from '../../core/userPaths.js';
|
|
30
30
|
import { pathExists } from '../../core/fileOps.js';
|
|
31
31
|
import { detectProjectContext } from '../../context/detectProjectContext.js';
|
|
32
|
-
import {
|
|
32
|
+
import { episodeText, resolveLedger, writeEpisode } from '../../core/memory/episodes.js';
|
|
33
33
|
import fs from 'node:fs/promises';
|
|
34
34
|
import os from 'node:os';
|
|
35
35
|
import path from 'node:path';
|
|
@@ -520,53 +520,8 @@ async function runMemoryPromote(projectRoot, homeDir, args) {
|
|
|
520
520
|
}
|
|
521
521
|
|
|
522
522
|
// ---- `ukit memory episode` — session episode from exec-ledger (SPEC §7b) ----
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
// module, so the segment rule is duplicated deliberately.
|
|
526
|
-
function safeSegment(value) {
|
|
527
|
-
return String(value || 'default')
|
|
528
|
-
.trim()
|
|
529
|
-
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
530
|
-
.slice(0, 96) || 'default';
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
async function readJsonIfExists(filePath) {
|
|
534
|
-
try {
|
|
535
|
-
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
536
|
-
} catch {
|
|
537
|
-
return null;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Resolution order: --session <id> → UKIT_SESSION_ID env → most-recent ledger.
|
|
542
|
-
async function resolveLedger(projectRoot, sessionFlag) {
|
|
543
|
-
const dir = path.join(projectRoot, LEDGER_DIR_REL);
|
|
544
|
-
const candidate = sessionFlag ?? process.env.UKIT_SESSION_ID;
|
|
545
|
-
if (candidate) {
|
|
546
|
-
const file = `${safeSegment(candidate)}.json`;
|
|
547
|
-
const ledger = await readJsonIfExists(path.join(dir, file));
|
|
548
|
-
return ledger ? { ledger, ledgerKey: file } : { ledger: null, ledgerKey: file };
|
|
549
|
-
}
|
|
550
|
-
const [name] = await listLedgerFiles(dir, 1);
|
|
551
|
-
if (!name) return { ledger: null, ledgerKey: null };
|
|
552
|
-
const ledger = await readJsonIfExists(path.join(dir, name));
|
|
553
|
-
return ledger ? { ledger, ledgerKey: name } : { ledger: null, ledgerKey: name };
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function episodeText(ledger, sessionId) {
|
|
557
|
-
const write = ledger.writeSucceeded === true ? 'writeOk' : 'writeFail';
|
|
558
|
-
const verify = ledger.verificationAttempted !== true
|
|
559
|
-
? 'not-run'
|
|
560
|
-
: ledger.verificationSucceeded === true ? 'ok' : 'fail';
|
|
561
|
-
const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
|
|
562
|
-
const lastReceipt = receipts.length > 0 ? receipts[receipts.length - 1] : null;
|
|
563
|
-
const lastCommand = lastReceipt
|
|
564
|
-
? String(lastReceipt?.command ?? '').split('\n')[0].trim() || 'n/a'
|
|
565
|
-
: 'n/a';
|
|
566
|
-
const text = `Session ${sessionId}: ${write}, verify=${verify}, `
|
|
567
|
-
+ `${receipts.length} receipts, last command ${lastCommand}`;
|
|
568
|
-
return text.slice(0, 300);
|
|
569
|
-
}
|
|
523
|
+
// Record shape + dedupe live in core/memory/episodes.js (shared with the
|
|
524
|
+
// self-improve backfill).
|
|
570
525
|
|
|
571
526
|
async function runMemoryEpisode(projectRoot, homeDir, args) {
|
|
572
527
|
const dryRun = args.includes('--dry-run');
|
|
@@ -578,62 +533,31 @@ async function runMemoryEpisode(projectRoot, homeDir, args) {
|
|
|
578
533
|
return;
|
|
579
534
|
}
|
|
580
535
|
|
|
581
|
-
const { ledger, ledgerKey } = await resolveLedger(projectRoot, sessionFlag);
|
|
536
|
+
const { ledger, ledgerKey } = await resolveLedger(projectRoot, sessionFlag ?? process.env.UKIT_SESSION_ID);
|
|
582
537
|
if (!ledger) {
|
|
583
538
|
console.log('[UKit] episode: nothing to record');
|
|
584
539
|
return;
|
|
585
540
|
}
|
|
586
541
|
|
|
587
|
-
const sessionId = ledger.sessionId ?? ledger.sessionKey
|
|
588
|
-
?? (ledgerKey ? ledgerKey.replace(/\.json$/, '') : 'unknown');
|
|
589
|
-
const text = episodeText(ledger, sessionId);
|
|
590
|
-
|
|
591
542
|
if (dryRun) {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const existing = await loadRecords(projectRoot, { homeDir });
|
|
597
|
-
if (existing.some((r) => r.meta?.ledgerKey === ledgerKey)) {
|
|
598
|
-
console.log('[UKit] episode: already recorded');
|
|
543
|
+
const sessionId = ledger.sessionId ?? ledger.sessionKey ?? ledgerKey.replace(/\.json$/, '');
|
|
544
|
+
console.log(`[UKit] episode (dry-run): ${episodeText(ledger, sessionId)}`);
|
|
599
545
|
return;
|
|
600
546
|
}
|
|
601
547
|
|
|
602
|
-
const
|
|
603
|
-
const projectId = (await detectProjectContext(projectRoot, { homeDir })).project.name;
|
|
604
|
-
// FR-023: the add goes through mutateMemory with the ledger key as the
|
|
605
|
-
// idempotency key — a hook-invoked rerun is a journal 'duplicate' even if
|
|
606
|
-
// the meta.ledgerKey fast-path above is bypassed.
|
|
607
|
-
const res = await mutateMemory(
|
|
608
|
-
{ kind: 'project', projectRoot, homeDir },
|
|
609
|
-
{
|
|
610
|
-
op: 'add',
|
|
611
|
-
idempotencyKey: ledgerKey,
|
|
612
|
-
payload: {
|
|
613
|
-
type: 'episode',
|
|
614
|
-
scope: 'session',
|
|
615
|
-
text,
|
|
616
|
-
provenance: 'exec-ledger',
|
|
617
|
-
confidence: 0.6,
|
|
618
|
-
createdBy: 'episode-hook',
|
|
619
|
-
projectId,
|
|
620
|
-
validUntil: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
|
|
621
|
-
meta: { sessionId, ledgerKey },
|
|
622
|
-
},
|
|
623
|
-
},
|
|
624
|
-
);
|
|
548
|
+
const res = await writeEpisode(projectRoot, { ledger, ledgerKey, config, homeDir });
|
|
625
549
|
if (res.status === 'duplicate') {
|
|
626
550
|
console.log('[UKit] episode: already recorded');
|
|
627
551
|
return;
|
|
628
552
|
}
|
|
629
553
|
if (res.status === 'rejected') {
|
|
630
|
-
printGuardRejection(res);
|
|
554
|
+
printGuardRejection(res.result);
|
|
631
555
|
return;
|
|
632
556
|
}
|
|
633
|
-
if (res.status !== '
|
|
634
|
-
throw new Error(`episode write failed: ${res.
|
|
557
|
+
if (res.status !== 'recorded') {
|
|
558
|
+
throw new Error(`episode write failed: ${res.reason}`);
|
|
635
559
|
}
|
|
636
|
-
console.log(`[UKit] episode: recorded ${res.
|
|
560
|
+
console.log(`[UKit] episode: recorded ${res.id} for session ${res.sessionId}`);
|
|
637
561
|
}
|
|
638
562
|
|
|
639
563
|
// ---- `ukit memory backup|restore|doctor` — operator lane (SPEC §9, FR-011) ----
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `ukit self-improve` — run the automatic collect → learn → apply pass now.
|
|
2
|
+
// Hooks trigger it detached (self-improve-trigger.mjs); this is the manual
|
|
3
|
+
// and the trigger's own entry point. `--if-due` honors the rate-limit stamp
|
|
4
|
+
// (what triggers pass); without it the pass is forced.
|
|
5
|
+
|
|
6
|
+
import { runSelfImprove } from '../../learning/selfImprove.js';
|
|
7
|
+
|
|
8
|
+
const HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
9
|
+
const KNOWN_FLAGS = new Set(['--if-due', '--json', '--quiet']);
|
|
10
|
+
|
|
11
|
+
function printUsage() {
|
|
12
|
+
console.log('Usage: ukit self-improve [--if-due] [--json] [--quiet]');
|
|
13
|
+
console.log('');
|
|
14
|
+
console.log('Runs one self-improve pass: episode backfill, diagnostics, auto-tuning,');
|
|
15
|
+
console.log('pattern proposals, telemetry collect. Hooks run it automatically.');
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(' --if-due Skip when the last pass ran < 10 minutes ago (hook mode)');
|
|
18
|
+
console.log(' --json JSON output');
|
|
19
|
+
console.log(' --quiet No output (hook mode)');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function describeStep(stepResult) {
|
|
23
|
+
const { name, status, ...rest } = stepResult;
|
|
24
|
+
const details = Object.entries(rest)
|
|
25
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
26
|
+
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.length : value}`)
|
|
27
|
+
.join(' ');
|
|
28
|
+
return ` ${name}: ${status}${details ? ` ${details}` : ''}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runSelfImproveCommand({ projectRoot, argv = [] }) {
|
|
32
|
+
if (argv.some((flag) => HELP_FLAGS.has(flag))) {
|
|
33
|
+
printUsage();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const unknown = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
|
|
37
|
+
if (unknown.length > 0) {
|
|
38
|
+
console.error(`[UKit] Unknown self-improve argument(s): ${unknown.join(', ')}`);
|
|
39
|
+
printUsage();
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const result = await runSelfImprove(projectRoot, { force: !argv.includes('--if-due') });
|
|
44
|
+
if (argv.includes('--quiet')) return;
|
|
45
|
+
if (argv.includes('--json')) {
|
|
46
|
+
console.log(JSON.stringify(result, null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (result.status !== 'ran') {
|
|
50
|
+
console.log(`[UKit] self-improve: skipped (${result.reason})`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
console.log('[UKit] self-improve: ran');
|
|
54
|
+
for (const stepResult of result.steps) console.log(describeStep(stepResult));
|
|
55
|
+
}
|
|
@@ -42,7 +42,7 @@ import { validateSupportBundle } from '../../core/observability/support/import.j
|
|
|
42
42
|
import { runEvaluation } from '../../core/observability/evaluation/runner.js';
|
|
43
43
|
|
|
44
44
|
const HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
45
|
-
const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'import', 'evaluate']);
|
|
45
|
+
const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
|
|
46
46
|
const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
|
|
47
47
|
|
|
48
48
|
// The automatic support refresh's canary gate lives in schedule.js via
|
|
@@ -62,6 +62,7 @@ function printUsage() {
|
|
|
62
62
|
console.log(' status Show stage, segments, counters, support lag, crashes');
|
|
63
63
|
console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
|
|
64
64
|
console.log(' export-support Write the sanitized UKit Support bundle now');
|
|
65
|
+
console.log(' export-run <op> Write a sanitized per-run support bundle (V-06)');
|
|
65
66
|
console.log(' import <path> Validate a received support bundle (zip or directory)');
|
|
66
67
|
console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
|
|
67
68
|
console.log('');
|
|
@@ -370,6 +371,45 @@ async function exportSupportCommand({ projectRoot, config }) {
|
|
|
370
371
|
process.exitCode = 2;
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
// --- export-run (V-06) -------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* `export-run <operationId>` — stage-gated per-operation support bundle.
|
|
378
|
+
* Unlike export-support this is NOT lifted past the stage gate: the
|
|
379
|
+
* bundle lives under decisionRuntime.diagnostics, and 'off' means zero
|
|
380
|
+
* writes by contract (SPEC G7-FR04).
|
|
381
|
+
*/
|
|
382
|
+
async function exportRunCommand({ projectRoot, config, operationId }) {
|
|
383
|
+
const { exportSupportBundle } = await import('../../core/agentRuntime/diagnostics.js');
|
|
384
|
+
const runtimeDir = path.join(projectRoot, '.ukit', 'storage', 'agent-runtime');
|
|
385
|
+
let res;
|
|
386
|
+
try {
|
|
387
|
+
res = await exportSupportBundle(runtimeDir, operationId, {
|
|
388
|
+
config,
|
|
389
|
+
projectKey: projectRoot,
|
|
390
|
+
traceRoot: segmentsRoot(projectRoot),
|
|
391
|
+
});
|
|
392
|
+
} catch {
|
|
393
|
+
res = { status: 'degraded', reason: 'bundle_error', bytes: 0 };
|
|
394
|
+
}
|
|
395
|
+
if (res.status === 'written') {
|
|
396
|
+
// Pseudonymous location only — the absolute path is user-private.
|
|
397
|
+
const proj = path.basename(path.dirname(res.dir));
|
|
398
|
+
const run = path.basename(res.dir);
|
|
399
|
+
console.log(
|
|
400
|
+
`export-run: written bytes=${res.bytes ?? 0} ` +
|
|
401
|
+
`dir=${SUPPORT_DIR_NAME}/${proj}/${run}`,
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
console.log(
|
|
406
|
+
`export-run: ${res.status}` +
|
|
407
|
+
(res.reason ? ` reason=${res.reason}` : '') +
|
|
408
|
+
` bytes=${res.bytes ?? 0}`,
|
|
409
|
+
);
|
|
410
|
+
process.exitCode = 2;
|
|
411
|
+
}
|
|
412
|
+
|
|
373
413
|
async function importCommand({ bundlePath }) {
|
|
374
414
|
const res = await validateSupportBundle({ path: bundlePath });
|
|
375
415
|
if (!res.ok) {
|
|
@@ -434,7 +474,7 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
|
|
|
434
474
|
const flags = rest.filter((a) => a.startsWith('-'));
|
|
435
475
|
const allowed = JSON_SUBCOMMANDS.has(sub) ? new Set(['--json']) : new Set();
|
|
436
476
|
const unknownFlags = flags.filter((f) => !allowed.has(f));
|
|
437
|
-
const maxPositional = sub === 'import' ? 1 : 0;
|
|
477
|
+
const maxPositional = (sub === 'import' || sub === 'export-run') ? 1 : 0;
|
|
438
478
|
|
|
439
479
|
if (unknownFlags.length > 0) {
|
|
440
480
|
console.error(`[UKit] Unknown telemetry flag(s): ${unknownFlags.join(', ')}`);
|
|
@@ -442,7 +482,8 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
|
|
|
442
482
|
process.exitCode = 1;
|
|
443
483
|
return;
|
|
444
484
|
}
|
|
445
|
-
|
|
485
|
+
const wantsOperand = sub === 'import' || sub === 'export-run';
|
|
486
|
+
if (positional.length > maxPositional || (wantsOperand && positional.length === 0)) {
|
|
446
487
|
printUsage();
|
|
447
488
|
process.exitCode = 1;
|
|
448
489
|
return;
|
|
@@ -455,6 +496,7 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
|
|
|
455
496
|
if (sub === 'status') return statusCommand({ projectRoot, config, json });
|
|
456
497
|
if (sub === 'digest') return digestCommand({ projectRoot, json });
|
|
457
498
|
if (sub === 'export-support') return exportSupportCommand({ projectRoot, config });
|
|
499
|
+
if (sub === 'export-run') return exportRunCommand({ projectRoot, config, operationId: positional[0] });
|
|
458
500
|
if (sub === 'evaluate') return evaluateCommand({ projectRoot, config, json });
|
|
459
501
|
return importCommand({ bundlePath: positional[0] });
|
|
460
502
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { runTelemetry } from './commands/telemetry.js';
|
|
|
12
12
|
import { runFeedback } from './commands/feedback.js';
|
|
13
13
|
import { runPlaybook } from './commands/playbook.js';
|
|
14
14
|
import { runDecision } from './commands/decision.js';
|
|
15
|
+
import { runSelfImproveCommand } from './commands/selfImprove.js';
|
|
15
16
|
const GLOBAL_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
|
16
17
|
|
|
17
18
|
export async function runCli({ argv, packageRoot, projectRoot, packageVersion }) {
|
|
@@ -89,6 +90,11 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
if (command === 'self-improve') {
|
|
94
|
+
await runSelfImproveCommand({ projectRoot, argv: commandArgv });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
if (command === 'update') {
|
|
93
99
|
await runUpdate({ packageVersion, argv: commandArgv });
|
|
94
100
|
return;
|
|
@@ -131,6 +137,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
131
137
|
console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
|
|
132
138
|
console.log(' telemetry Flight recorder (collect/status/digest/export-support/import/evaluate)');
|
|
133
139
|
console.log(' feedback Record or list wrong-route feedback labels');
|
|
140
|
+
console.log(' self-improve Run the automatic learn/tune/collect pass now (hooks run it)');
|
|
134
141
|
console.log(' update Upgrade the global UKit CLI to the latest version');
|
|
135
142
|
console.log(' version Show UKit version');
|
|
136
143
|
console.log('');
|
|
@@ -27,12 +27,22 @@
|
|
|
27
27
|
* recorder via telemetry.js — synchronous, never-throw, stage-gated
|
|
28
28
|
* inside emit(). `now` and `artifactStore` are injectable for tests.
|
|
29
29
|
*
|
|
30
|
+
* Host lane adapters (V-02, AGENT_VM_RUNTIME_PLAN item V2) live at the
|
|
31
|
+
* end of this module: HOST_ADAPTERS is the frozen capability registry
|
|
32
|
+
* for the owned-runner host lanes (omp / claude-code / codex),
|
|
33
|
+
* detectHost best-effort resolves the ambient engine session from env
|
|
34
|
+
* markers, and probeHostAdapter evaluates one lane against the current
|
|
35
|
+
* environment + injectable primitives — honest statuses only
|
|
36
|
+
* ('supported' | 'unsupported-probe' | 'unsupported'), never a faked
|
|
37
|
+
* supported.
|
|
38
|
+
*
|
|
30
39
|
* `artifactStore` protocol (injected): `put(buffer)` →
|
|
31
40
|
* `Promise<string | { path: string }>`; the returned ref is recorded on
|
|
32
41
|
* `event.artifactRefs` and in the result `artifactRefs` list.
|
|
33
42
|
*/
|
|
34
43
|
|
|
35
44
|
import crypto from 'node:crypto';
|
|
45
|
+
import { spawn } from 'node:child_process';
|
|
36
46
|
|
|
37
47
|
import {
|
|
38
48
|
validateSemanticEvent,
|
|
@@ -333,3 +343,170 @@ export async function adaptOutput({
|
|
|
333
343
|
}
|
|
334
344
|
return { events: [event], artifactRefs, parseStatus };
|
|
335
345
|
}
|
|
346
|
+
|
|
347
|
+
// --- V-02: host lane adapters (AGENT_VM_RUNTIME_PLAN item V2) ---------
|
|
348
|
+
// The owned runner (supervisor.js) is primitive-level identical on every
|
|
349
|
+
// engine: Node child_process spawn, detached process-group kill, and
|
|
350
|
+
// kill(pid, 0) liveness — UKit-owned children only; no engine's own
|
|
351
|
+
// process lifecycle is hijacked. The lanes below describe that contract
|
|
352
|
+
// per host honestly: no engine exposes a host-owned durable completion
|
|
353
|
+
// API, so completionApi is UKit's own durable journal on every lane.
|
|
354
|
+
//
|
|
355
|
+
// Status ladder (G-V2 — never fake a completion signal):
|
|
356
|
+
// 'supported' — primitives present AND live host E2E evidence
|
|
357
|
+
// was explicitly declared (opts.liveHostE2E).
|
|
358
|
+
// Absent live evidence a probe never upgrades.
|
|
359
|
+
// 'unsupported-probe' — the lane contract is available at the
|
|
360
|
+
// primitive level but live host E2E has NOT
|
|
361
|
+
// been proven in this environment. The owned
|
|
362
|
+
// runner may launch; capability reports stay
|
|
363
|
+
// honest about the evidence gap.
|
|
364
|
+
// 'unsupported' — a required primitive is absent (win32 process-
|
|
365
|
+
// group kill, no spawnImpl, …) or the host is
|
|
366
|
+
// unknown. Never runnable.
|
|
367
|
+
|
|
368
|
+
export const HOST_NAMES = Object.freeze(['omp', 'claude-code', 'codex']);
|
|
369
|
+
|
|
370
|
+
export const HOST_SUPPORTED = 'supported';
|
|
371
|
+
export const HOST_UNSUPPORTED_PROBE = 'unsupported-probe';
|
|
372
|
+
export const HOST_UNSUPPORTED = 'unsupported';
|
|
373
|
+
|
|
374
|
+
// No engine exposes a host-owned durable completion API today; every
|
|
375
|
+
// lane's durable completion channel is the eventStore journal.
|
|
376
|
+
export const OWNED_COMPLETION_API = 'eventStore-journal';
|
|
377
|
+
|
|
378
|
+
// Frozen per-lane descriptor — static contract only; environment truth
|
|
379
|
+
// comes from probeHostAdapter, never from this table.
|
|
380
|
+
export const HOST_ADAPTERS = Object.freeze({
|
|
381
|
+
'omp': Object.freeze({
|
|
382
|
+
name: 'omp',
|
|
383
|
+
completionApi: OWNED_COMPLETION_API,
|
|
384
|
+
ownedProcessGroups: true,
|
|
385
|
+
}),
|
|
386
|
+
'claude-code': Object.freeze({
|
|
387
|
+
name: 'claude-code',
|
|
388
|
+
completionApi: OWNED_COMPLETION_API,
|
|
389
|
+
ownedProcessGroups: true,
|
|
390
|
+
}),
|
|
391
|
+
'codex': Object.freeze({
|
|
392
|
+
name: 'codex',
|
|
393
|
+
completionApi: OWNED_COMPLETION_API,
|
|
394
|
+
ownedProcessGroups: true,
|
|
395
|
+
}),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Documented session env markers for ambient engine detection. Explicit
|
|
399
|
+
// identity always wins: UKIT_HOST, then UKIT_AGENT_ID, then the marker
|
|
400
|
+
// sets — a hit in >1 marker set is ambiguous and resolves null (never a
|
|
401
|
+
// guessed lane).
|
|
402
|
+
const HOST_ENV_MARKERS = {
|
|
403
|
+
'omp': ['OMP_HOME', 'OMP_SESSION_ID', 'ORCA_OMP_SOURCE_AGENT_DIR'],
|
|
404
|
+
'claude-code': ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'],
|
|
405
|
+
'codex': ['CODEX_SANDBOX', 'CODEX_CI', 'ORCA_CODEX_HOME'],
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Best-effort ambient host resolution.
|
|
410
|
+
* @param {object} [env] default process.env
|
|
411
|
+
* @returns {string|null} a HOST_NAMES member, or null when absent/ambiguous.
|
|
412
|
+
*/
|
|
413
|
+
export function detectHost(env = process.env) {
|
|
414
|
+
const source = env && typeof env === 'object' ? env : {};
|
|
415
|
+
const explicit = source.UKIT_HOST ?? source.UKIT_AGENT_ID;
|
|
416
|
+
if (typeof explicit === 'string' && HOST_ADAPTERS[explicit]) return explicit;
|
|
417
|
+
const hits = HOST_NAMES.filter(
|
|
418
|
+
(name) => HOST_ENV_MARKERS[name].some((key) => source[key] != null),
|
|
419
|
+
);
|
|
420
|
+
return hits.length === 1 ? hits[0] : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Evaluate one host lane against the current environment.
|
|
425
|
+
*
|
|
426
|
+
* @param {string} name lane name (HOST_NAMES member or arbitrary string)
|
|
427
|
+
* @param {object} [opts]
|
|
428
|
+
* @param {boolean} [opts.liveHostE2E] declare live host E2E evidence —
|
|
429
|
+
* the ONLY path to 'supported'. Never derived from primitive presence.
|
|
430
|
+
* @param {Function} [opts.spawnImpl] required primitive (default Node spawn)
|
|
431
|
+
* @param {Function} [opts.killImpl] required primitive (default process.kill)
|
|
432
|
+
* @param {Function} [opts.probeImpl] required liveness primitive (default sig-0)
|
|
433
|
+
* @param {string} [opts.platform] default process.platform
|
|
434
|
+
* @returns {{name:string|null, supported:string, reason:string|null,
|
|
435
|
+
* completionApi:string|null, primitives:object}}
|
|
436
|
+
*/
|
|
437
|
+
export function probeHostAdapter(name, opts = {}) {
|
|
438
|
+
const descriptor = HOST_ADAPTERS[name];
|
|
439
|
+
if (!descriptor) {
|
|
440
|
+
return {
|
|
441
|
+
name: null,
|
|
442
|
+
supported: HOST_UNSUPPORTED,
|
|
443
|
+
reason: 'unknown_host',
|
|
444
|
+
completionApi: null,
|
|
445
|
+
primitives: {},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
|
|
449
|
+
// `undefined` picks the Node defaults; any other non-function value is a
|
|
450
|
+
// caller-supplied (possibly deliberately absent) primitive — probes must
|
|
451
|
+
// evaluate what was given, never silently fall back.
|
|
452
|
+
const spawnImpl = opts.spawnImpl === undefined
|
|
453
|
+
? (argv, spawnOpts) => spawn(argv[0], argv.slice(1), spawnOpts)
|
|
454
|
+
: opts.spawnImpl;
|
|
455
|
+
const killImpl = opts.killImpl === undefined
|
|
456
|
+
? (signal, target) => process.kill(target, signal)
|
|
457
|
+
: opts.killImpl;
|
|
458
|
+
const probeImpl = opts.probeImpl === undefined
|
|
459
|
+
? (pid) => {
|
|
460
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
461
|
+
}
|
|
462
|
+
: opts.probeImpl;
|
|
463
|
+
const primitives = {
|
|
464
|
+
spawn: typeof spawnImpl === 'function',
|
|
465
|
+
processGroupKill: platform !== 'win32' && typeof killImpl === 'function',
|
|
466
|
+
livenessProbe: typeof probeImpl === 'function',
|
|
467
|
+
};
|
|
468
|
+
if (platform === 'win32') {
|
|
469
|
+
return {
|
|
470
|
+
name,
|
|
471
|
+
supported: HOST_UNSUPPORTED,
|
|
472
|
+
reason: 'process_group_kill_unavailable',
|
|
473
|
+
completionApi: descriptor.completionApi,
|
|
474
|
+
primitives,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (!primitives.spawn || !primitives.livenessProbe || !primitives.processGroupKill) {
|
|
478
|
+
return {
|
|
479
|
+
name,
|
|
480
|
+
supported: HOST_UNSUPPORTED,
|
|
481
|
+
reason: 'host_spawn_unavailable',
|
|
482
|
+
completionApi: descriptor.completionApi,
|
|
483
|
+
primitives,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (opts.liveHostE2E === true) {
|
|
487
|
+
return {
|
|
488
|
+
name,
|
|
489
|
+
supported: HOST_SUPPORTED,
|
|
490
|
+
reason: null,
|
|
491
|
+
completionApi: descriptor.completionApi,
|
|
492
|
+
primitives,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
name,
|
|
497
|
+
supported: HOST_UNSUPPORTED_PROBE,
|
|
498
|
+
reason: 'live_host_e2e_unproven',
|
|
499
|
+
completionApi: descriptor.completionApi,
|
|
500
|
+
primitives,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Probe every registered lane. Returns `{hostName: probe}` — one entry
|
|
506
|
+
* per HOST_NAMES member, in registry order.
|
|
507
|
+
*/
|
|
508
|
+
export function listHostAdapters(opts = {}) {
|
|
509
|
+
const out = {};
|
|
510
|
+
for (const name of HOST_NAMES) out[name] = probeHostAdapter(name, opts);
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
@@ -52,6 +52,17 @@ export const SIDE_EFFECT_CLASSES = Object.freeze([
|
|
|
52
52
|
'destructive',
|
|
53
53
|
]);
|
|
54
54
|
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// V-01 — IR versioning (plan-language level, independent of CONTRACT_VERSION).
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Frozen IR versions understood by planCompiler. 'v1' is the original opcode
|
|
60
|
+
* set (RUN, WAIT_EVENT, BRANCH, COMPLETE, ESCALATE); 'v2' adds the wrapper
|
|
61
|
+
* ops PARALLEL and TIMEOUT. A plan opts into v2 via `ir.planVersion: 'v2'`
|
|
62
|
+
* or an enabled caller channel — v1 IR never parses v2 ops by default.
|
|
63
|
+
*/
|
|
64
|
+
export const IR_VERSIONS = Object.freeze(['v1', 'v2']);
|
|
65
|
+
|
|
55
66
|
/** Classes that MAY auto-retry under bounded attempts + backoff (SPEC §5). */
|
|
56
67
|
const AUTO_RETRYABLE = new Set(['pure', 'read_only', 'idempotent_write']);
|
|
57
68
|
|
|
@@ -245,3 +256,69 @@ export function validateRetry(sideEffectClass, attempt, policy = {}) {
|
|
|
245
256
|
|
|
246
257
|
return ok();
|
|
247
258
|
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// V-05 — VM decision-node question block + classify verdict shapes.
|
|
262
|
+
|
|
263
|
+
/** Verdict actions a classifyFn may return (agent-vm-runtime V5). */
|
|
264
|
+
export const CLASSIFY_ACTIONS = Object.freeze(['route', 'escalate']);
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Validate a classifyFn verdict — the bounded decision-node answer shape:
|
|
268
|
+
* `{action:'route', outcome:string}` routes the event into the node's
|
|
269
|
+
* deterministic transition table (which still gates the outcome); any other
|
|
270
|
+
* valid verdict escalates onto the deterministic lane.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} verdict
|
|
273
|
+
* @returns {{ok:true}|{ok:false, code:string}}
|
|
274
|
+
*/
|
|
275
|
+
export function validateClassifyVerdict(verdict) {
|
|
276
|
+
if (verdict === null || typeof verdict !== 'object' || Array.isArray(verdict)) {
|
|
277
|
+
return reject('malformed_verdict');
|
|
278
|
+
}
|
|
279
|
+
if (verdict.action === 'escalate') {
|
|
280
|
+
return ok();
|
|
281
|
+
}
|
|
282
|
+
if (verdict.action === 'route' && typeof verdict.outcome === 'string' && verdict.outcome.length > 0) {
|
|
283
|
+
return ok();
|
|
284
|
+
}
|
|
285
|
+
return reject('malformed_verdict');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validate a VM node's optional `question` block — the bounded
|
|
290
|
+
* classify/select question the decision plane may answer when the plan IR
|
|
291
|
+
* cannot route an event. Absent is valid (nodes need not ask); a malformed
|
|
292
|
+
* block is rejected wholesale, never partially honored.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} question `{decisionKey?, instruction?, candidates?}` —
|
|
295
|
+
* each declared field must be a non-empty bounded string / string list.
|
|
296
|
+
* @param {object} [bounds] `{maxText?, maxCandidates?}`
|
|
297
|
+
* @returns {{ok:true}|{ok:false, code:string}}
|
|
298
|
+
*/
|
|
299
|
+
export function validateNodeQuestion(question, bounds = {}) {
|
|
300
|
+
if (question === undefined) {
|
|
301
|
+
return ok();
|
|
302
|
+
}
|
|
303
|
+
if (question === null || typeof question !== 'object' || Array.isArray(question)) {
|
|
304
|
+
return reject('malformed_question');
|
|
305
|
+
}
|
|
306
|
+
const maxText = Number.isInteger(bounds.maxText) && bounds.maxText > 0 ? bounds.maxText : 240;
|
|
307
|
+
const maxCandidates = Number.isInteger(bounds.maxCandidates) && bounds.maxCandidates > 0 ? bounds.maxCandidates : 8;
|
|
308
|
+
const bounded = (v) => typeof v === 'string' && v.length > 0 && v.length <= maxText;
|
|
309
|
+
if (question.decisionKey !== undefined && !bounded(question.decisionKey)) {
|
|
310
|
+
return reject('malformed_question');
|
|
311
|
+
}
|
|
312
|
+
if (question.instruction !== undefined && !bounded(question.instruction)) {
|
|
313
|
+
return reject('malformed_question');
|
|
314
|
+
}
|
|
315
|
+
if (question.candidates !== undefined) {
|
|
316
|
+
if (!Array.isArray(question.candidates)
|
|
317
|
+
|| question.candidates.length === 0
|
|
318
|
+
|| question.candidates.length > maxCandidates
|
|
319
|
+
|| !question.candidates.every(bounded)) {
|
|
320
|
+
return reject('malformed_question');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return ok();
|
|
324
|
+
}
|