@rune-kit/rune 2.13.0 → 2.15.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.
Files changed (96) hide show
  1. package/README.md +31 -12
  2. package/compiler/__tests__/adapter-model-mapping.test.js +152 -0
  3. package/compiler/__tests__/adr-scoring.test.js +91 -0
  4. package/compiler/__tests__/context-md-format.test.js +180 -0
  5. package/compiler/__tests__/context-pack-smell-tests.test.js +215 -0
  6. package/compiler/__tests__/context-preview-signal.test.js +166 -0
  7. package/compiler/__tests__/diversity-score.test.js +117 -0
  8. package/compiler/__tests__/improve-architecture.test.js +171 -0
  9. package/compiler/__tests__/openclaw-adapter.test.js +11 -6
  10. package/compiler/__tests__/oracle-bundle-format.test.js +210 -0
  11. package/compiler/__tests__/oracle-pending-schema.test.js +209 -0
  12. package/compiler/__tests__/out-of-scope-format.test.js +303 -0
  13. package/compiler/__tests__/skill-description-quality.test.js +147 -0
  14. package/compiler/__tests__/zoom-out-output.test.js +112 -0
  15. package/compiler/adapters/antigravity.js +17 -1
  16. package/compiler/adapters/claude.js +4 -0
  17. package/compiler/adapters/codex.js +16 -1
  18. package/compiler/adapters/cursor.js +4 -0
  19. package/compiler/adapters/generic.js +14 -1
  20. package/compiler/adapters/openclaw.js +15 -1
  21. package/compiler/adapters/opencode.js +17 -1
  22. package/compiler/adapters/windsurf.js +4 -0
  23. package/package.json +2 -2
  24. package/skills/adversary/SKILL.md +61 -1
  25. package/skills/adversary/evals.md +79 -0
  26. package/skills/adversary/references/context-bundle-format.md +122 -0
  27. package/skills/adversary/references/oracle-mode.md +116 -0
  28. package/skills/asset-creator/SKILL.md +1 -1
  29. package/skills/audit/SKILL.md +3 -1
  30. package/skills/autopsy/SKILL.md +1 -1
  31. package/skills/ba/SKILL.md +242 -83
  32. package/skills/ba/references/context-md-format.md +136 -0
  33. package/skills/ba/references/explore-first.md +107 -0
  34. package/skills/ba/references/out-of-scope-format.md +152 -0
  35. package/skills/brainstorm/SKILL.md +78 -2
  36. package/skills/brainstorm/references/design-it-twice.md +155 -0
  37. package/skills/browser-pilot/SKILL.md +1 -1
  38. package/skills/completion-gate/SKILL.md +1 -1
  39. package/skills/constraint-check/SKILL.md +1 -1
  40. package/skills/context-engine/SKILL.md +85 -1
  41. package/skills/context-engine/references/preview-gate.md +139 -0
  42. package/skills/context-pack/SKILL.md +68 -25
  43. package/skills/context-pack/evals.md +122 -0
  44. package/skills/context-pack/references/brief-template.md +121 -0
  45. package/skills/context-pack/references/durability-rules.md +109 -0
  46. package/skills/cook/SKILL.md +5 -4
  47. package/skills/db/SKILL.md +1 -1
  48. package/skills/debug/SKILL.md +7 -4
  49. package/skills/dependency-doctor/SKILL.md +1 -1
  50. package/skills/design/SKILL.md +1 -1
  51. package/skills/doc-processor/SKILL.md +1 -1
  52. package/skills/docs/SKILL.md +1 -1
  53. package/skills/docs-seeker/SKILL.md +1 -1
  54. package/skills/fix/SKILL.md +5 -3
  55. package/skills/git/SKILL.md +1 -1
  56. package/skills/hallucination-guard/SKILL.md +1 -1
  57. package/skills/improve-architecture/SKILL.md +275 -0
  58. package/skills/improve-architecture/evals.md +145 -0
  59. package/skills/improve-architecture/references/deepening.md +87 -0
  60. package/skills/improve-architecture/references/interface-design.md +68 -0
  61. package/skills/improve-architecture/references/language.md +81 -0
  62. package/skills/improve-architecture/references/scoring.md +122 -0
  63. package/skills/integrity-check/SKILL.md +1 -1
  64. package/skills/journal/SKILL.md +34 -7
  65. package/skills/journal/references/adr-criteria.md +109 -0
  66. package/skills/logic-guardian/SKILL.md +1 -1
  67. package/skills/marketing/SKILL.md +1 -1
  68. package/skills/mcp-builder/SKILL.md +1 -1
  69. package/skills/onboard/SKILL.md +1 -1
  70. package/skills/perf/SKILL.md +1 -1
  71. package/skills/plan/SKILL.md +1 -1
  72. package/skills/preflight/SKILL.md +1 -1
  73. package/skills/research/SKILL.md +1 -1
  74. package/skills/retro/SKILL.md +1 -1
  75. package/skills/review/SKILL.md +3 -2
  76. package/skills/review-intake/SKILL.md +26 -3
  77. package/skills/safeguard/SKILL.md +1 -1
  78. package/skills/scaffold/SKILL.md +1 -1
  79. package/skills/scout/SKILL.md +33 -1
  80. package/skills/sentinel/SKILL.md +1 -1
  81. package/skills/sentinel-env/SKILL.md +1 -1
  82. package/skills/session-bridge/SKILL.md +91 -4
  83. package/skills/session-bridge/references/detach-protocol.md +111 -0
  84. package/skills/skill-forge/SKILL.md +1 -1
  85. package/skills/slides/SKILL.md +1 -1
  86. package/skills/surgeon/SKILL.md +17 -2
  87. package/skills/team/SKILL.md +3 -0
  88. package/skills/test/SKILL.md +48 -1
  89. package/skills/test/evals.md +137 -0
  90. package/skills/test/references/mocking-policy.md +121 -0
  91. package/skills/test/references/test-quality.md +124 -0
  92. package/skills/test/references/vertical-tdd.md +110 -0
  93. package/skills/trend-scout/SKILL.md +1 -1
  94. package/skills/video-creator/SKILL.md +1 -1
  95. package/skills/watchdog/SKILL.md +1 -1
  96. package/skills/worktree/SKILL.md +1 -1
@@ -115,10 +115,9 @@ test('generateManifest defaults version when missing', () => {
115
115
  });
116
116
 
117
117
  test('generateManifest declares artifact convention for OpenClaw skills', () => {
118
- const manifest = openclaw.generateManifest(
119
- [{ name: 'cook', layer: 'L1', group: 'orchestrator' }],
120
- { version: '1.0.0' },
121
- );
118
+ const manifest = openclaw.generateManifest([{ name: 'cook', layer: 'L1', group: 'orchestrator' }], {
119
+ version: '1.0.0',
120
+ });
122
121
  assert.ok(manifest.artifactConvention, 'artifactConvention field exists');
123
122
  assert.ok(Array.isArray(manifest.artifactConvention.outputDirPriority), 'outputDirPriority is array');
124
123
  assert.ok(manifest.artifactConvention.outputDirPriority.length >= 4, 'at least 4 fallback tiers');
@@ -129,12 +128,18 @@ test('generateManifest declares artifact convention for OpenClaw skills', () =>
129
128
  assert.ok(manifest.artifactConvention.outputContract, 'outputContract documented');
130
129
  assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[0], 'success');
131
130
  assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[4], 'timeout with partial results (accept)');
132
- assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[124], 'timeout with zero results (retry or abort)');
131
+ assert.strictEqual(
132
+ manifest.artifactConvention.outputContract.exitCodes[124],
133
+ 'timeout with zero results (retry or abort)',
134
+ );
133
135
  });
134
136
 
135
137
  test('generateManifest description scales with skill count', () => {
136
138
  const fewSkills = openclaw.generateManifest(
137
- [{ name: 'cook', layer: 'L1' }, { name: 'plan', layer: 'L2' }],
139
+ [
140
+ { name: 'cook', layer: 'L1' },
141
+ { name: 'plan', layer: 'L2' },
142
+ ],
138
143
  { version: '1.0.0' },
139
144
  );
140
145
  assert.ok(fewSkills.description.includes('2-skill'), 'description reflects actual skill count');
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Adversary oracle-mode context bundle format — verify the regex-validated
3
+ * structure that adversary emits when dispatching to a second model.
4
+ *
5
+ * The bundle has 3 mandatory sections:
6
+ * 1. [SYSTEM] line with exact role-priming text
7
+ * 2. [USER] line with template-driven problem statement
8
+ * 3. ### File N: <path> blocks with normalized content
9
+ *
10
+ * Validates:
11
+ * - System prompt is invariant (must match exact text)
12
+ * - User section non-empty, within length bounds
13
+ * - File headers numbered sequentially
14
+ * - Hard caps enforced (12 files, 4k chars per file, 100k bundle)
15
+ * - Forbidden content stripped (secrets redacted)
16
+ * - Citation validation (reply must cite files in bundle)
17
+ */
18
+
19
+ import assert from 'node:assert';
20
+ import { describe, test } from 'node:test';
21
+
22
+ const SYSTEM_LINE_REGEX =
23
+ /^\[SYSTEM\] You are Oracle, a focused one-shot problem solver\. You have NO prior context — assume zero project knowledge\. Cite file:line for every claim\. Reject any claim you cannot ground in the provided files\.$/;
24
+
25
+ const USER_LINE_REGEX = /^\[USER\] .{20,2000}$/;
26
+
27
+ const FILE_HEADER_REGEX = /^### File \d+: [^\n]+$/;
28
+
29
+ const HARD_CAPS = {
30
+ bundleChars: 400_000,
31
+ perFileChars: 4_000,
32
+ fileCount: 12,
33
+ userMaxChars: 2_000,
34
+ };
35
+
36
+ function buildSystemLine() {
37
+ return '[SYSTEM] You are Oracle, a focused one-shot problem solver. You have NO prior context — assume zero project knowledge. Cite file:line for every claim. Reject any claim you cannot ground in the provided files.';
38
+ }
39
+
40
+ function buildUserLine(source, payload) {
41
+ if (source === 'debug') {
42
+ return `[USER] Agent stuck after ${payload.cycles} hypothesis cycles. Disproved: ${payload.disproved}. Error: ${payload.error}. What is the most likely root cause not yet considered?`;
43
+ }
44
+ if (source === 'fix') {
45
+ return `[USER] Agent stuck after ${payload.attempts} failed fix attempts on ${payload.file}. Each attempt's tests failed with: ${payload.testFailure}. What is the most likely cause of the test failure?`;
46
+ }
47
+ return `[USER] ${payload}`;
48
+ }
49
+
50
+ function buildBundle({ files = [], userLine, systemLine = buildSystemLine() }) {
51
+ const sections = [systemLine, '', userLine, ''];
52
+ files.forEach((f, i) => {
53
+ sections.push(`### File ${i + 1}: ${f.path}`);
54
+ sections.push(f.content);
55
+ sections.push('');
56
+ });
57
+ return sections.join('\n');
58
+ }
59
+
60
+ function validateBundle(bundle) {
61
+ const errors = [];
62
+ if (bundle.length > HARD_CAPS.bundleChars) errors.push('bundle_too_large');
63
+ const lines = bundle.split('\n');
64
+ const sysLine = lines.find((l) => l.startsWith('[SYSTEM]'));
65
+ if (!sysLine || !SYSTEM_LINE_REGEX.test(sysLine)) errors.push('invalid_system_line');
66
+ const userLine = lines.find((l) => l.startsWith('[USER]'));
67
+ if (!userLine || !USER_LINE_REGEX.test(userLine)) errors.push('invalid_user_line');
68
+ const fileHeaders = lines.filter((l) => l.startsWith('### File '));
69
+ if (fileHeaders.length > HARD_CAPS.fileCount) errors.push('too_many_files');
70
+ for (let i = 0; i < fileHeaders.length; i++) {
71
+ if (!FILE_HEADER_REGEX.test(fileHeaders[i])) errors.push(`invalid_file_header_${i}`);
72
+ const expectedNum = i + 1;
73
+ if (!fileHeaders[i].startsWith(`### File ${expectedNum}:`)) errors.push(`out_of_order_file_${i}`);
74
+ }
75
+ return { valid: errors.length === 0, errors };
76
+ }
77
+
78
+ function redactSecrets(content) {
79
+ return content
80
+ .replace(/(api[_-]?key|secret|password|token)\s*[:=]\s*["'][^"']{8,}["']/gi, '$1=<REDACTED>')
81
+ .replace(/Authorization:\s*Bearer\s+\S{20,}/g, 'Authorization: Bearer <REDACTED>')
82
+ .replace(/process\.env\.[A-Z_]+\s*=\s*["'][^"']+["']/g, (match) => match.replace(/["'][^"']+["']/, '"<REDACTED>"'));
83
+ }
84
+
85
+ describe('System prompt invariance', () => {
86
+ test('exact system line matches regex', () => {
87
+ assert.match(buildSystemLine(), SYSTEM_LINE_REGEX);
88
+ });
89
+
90
+ test('rejects modified system prompt (no improvising)', () => {
91
+ const tampered = '[SYSTEM] You are Oracle, an improved problem solver with extra context.';
92
+ assert.doesNotMatch(tampered, SYSTEM_LINE_REGEX);
93
+ });
94
+
95
+ test('rejects empty system line', () => {
96
+ assert.doesNotMatch('[SYSTEM]', SYSTEM_LINE_REGEX);
97
+ });
98
+ });
99
+
100
+ describe('User section template', () => {
101
+ test('debug-source user line matches regex', () => {
102
+ const line = buildUserLine('debug', { cycles: 3, disproved: 'H1; H2; H3', error: '401 intermittent' });
103
+ assert.match(line, USER_LINE_REGEX);
104
+ });
105
+
106
+ test('fix-source user line matches regex', () => {
107
+ const line = buildUserLine('fix', {
108
+ attempts: 2,
109
+ file: 'src/auth.ts',
110
+ testFailure: 'expected 200, got 401',
111
+ });
112
+ assert.match(line, USER_LINE_REGEX);
113
+ });
114
+
115
+ test('rejects user line under 20 chars', () => {
116
+ assert.doesNotMatch('[USER] short', USER_LINE_REGEX);
117
+ });
118
+
119
+ test('manual mode accepts free-form within bounds', () => {
120
+ const line = `[USER] ${'x'.repeat(100)}`;
121
+ assert.match(line, USER_LINE_REGEX);
122
+ });
123
+ });
124
+
125
+ describe('File header structure', () => {
126
+ test('valid file header passes regex', () => {
127
+ assert.match('### File 1: src/auth/login.ts', FILE_HEADER_REGEX);
128
+ assert.match('### File 12: tests/e2e/checkout.spec.ts', FILE_HEADER_REGEX);
129
+ });
130
+
131
+ test('rejects malformed file headers', () => {
132
+ assert.doesNotMatch('### File: src/auth.ts', FILE_HEADER_REGEX);
133
+ assert.doesNotMatch('### file 1: src/auth.ts', FILE_HEADER_REGEX);
134
+ assert.doesNotMatch('## File 1: src/auth.ts', FILE_HEADER_REGEX);
135
+ });
136
+
137
+ test('files numbered sequentially from 1', () => {
138
+ const bundle = buildBundle({
139
+ systemLine: buildSystemLine(),
140
+ userLine: buildUserLine('manual', 'Why does the test fail intermittently?'),
141
+ files: [
142
+ { path: 'a.ts', content: 'a' },
143
+ { path: 'b.ts', content: 'b' },
144
+ { path: 'c.ts', content: 'c' },
145
+ ],
146
+ });
147
+ const result = validateBundle(bundle);
148
+ assert.strictEqual(result.valid, true, `unexpected errors: ${result.errors.join(', ')}`);
149
+ });
150
+ });
151
+
152
+ describe('Hard caps', () => {
153
+ test('rejects bundle with 13+ files', () => {
154
+ const files = Array.from({ length: 13 }, (_, i) => ({ path: `f${i}.ts`, content: 'x' }));
155
+ const bundle = buildBundle({ userLine: buildUserLine('manual', 'Why does this break under load?'), files });
156
+ const result = validateBundle(bundle);
157
+ assert.ok(result.errors.includes('too_many_files'));
158
+ });
159
+
160
+ test('accepts bundle with exactly 12 files', () => {
161
+ const files = Array.from({ length: 12 }, (_, i) => ({ path: `f${i}.ts`, content: 'x' }));
162
+ const bundle = buildBundle({ userLine: buildUserLine('manual', 'Why does this break under load?'), files });
163
+ const result = validateBundle(bundle);
164
+ assert.strictEqual(result.valid, true);
165
+ });
166
+
167
+ test('rejects bundle exceeding 400k chars total', () => {
168
+ const oversize = 'x'.repeat(50_000);
169
+ const files = Array.from({ length: 10 }, () => ({ path: 'big.ts', content: oversize }));
170
+ const bundle = buildBundle({ userLine: buildUserLine('manual', 'Why does this break under load?'), files });
171
+ const result = validateBundle(bundle);
172
+ assert.ok(result.errors.includes('bundle_too_large'));
173
+ });
174
+ });
175
+
176
+ describe('Secret redaction', () => {
177
+ test('redacts hardcoded api keys', () => {
178
+ const input = 'const apiKey = "sk-1234567890abcdef"';
179
+ const out = redactSecrets(input);
180
+ assert.match(out, /<REDACTED>/);
181
+ assert.doesNotMatch(out, /sk-1234567890abcdef/);
182
+ });
183
+
184
+ test('redacts Bearer tokens', () => {
185
+ const input = 'Authorization: Bearer abc123def456ghi789jkl000mno111';
186
+ const out = redactSecrets(input);
187
+ assert.match(out, /Bearer <REDACTED>/);
188
+ });
189
+
190
+ test('preserves non-secret content', () => {
191
+ const input = 'function login(user) { return user.id; }';
192
+ assert.strictEqual(redactSecrets(input), input);
193
+ });
194
+ });
195
+
196
+ describe('Signal naming compliance', () => {
197
+ const SIGNAL_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
198
+
199
+ test('oracle.dispatched conforms to signal naming', () => {
200
+ assert.match('oracle.dispatched', SIGNAL_PATTERN);
201
+ });
202
+
203
+ test('oracle.response conforms to signal naming', () => {
204
+ assert.match('oracle.response', SIGNAL_PATTERN);
205
+ });
206
+
207
+ test('oracle.failed conforms to signal naming', () => {
208
+ assert.match('oracle.failed', SIGNAL_PATTERN);
209
+ });
210
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Oracle-pending record schema (v2.15+) — verify the JSON schema written by
3
+ * session-bridge detach mode. The pending file in .rune/oracle-pending/<id>.json
4
+ * is the rendezvous point between adversary (dispatcher) and primary agent
5
+ * (reattach poller).
6
+ *
7
+ * Validates:
8
+ * - Required fields present + correct type
9
+ * - sessionId pattern: oracle-<unix-ms>-<rand>
10
+ * - bundleHash pattern: sha256:<hex>
11
+ * - status enum: pending | complete | failed
12
+ * - sourceSkill enum: debug | fix | manual
13
+ * - Idempotency: same bundleHash returns same sessionId
14
+ * - Timeout transition: pending → failed when now > timeoutAt
15
+ * - Cleanup: records older than 24h dropped
16
+ */
17
+
18
+ import assert from 'node:assert';
19
+ import { describe, test } from 'node:test';
20
+
21
+ const SESSION_ID_PATTERN = /^oracle-\d+-[a-z0-9]+$/;
22
+ const BUNDLE_HASH_PATTERN = /^sha256:[a-f0-9]{8,64}$/;
23
+ const STATUS_VALUES = new Set(['pending', 'complete', 'failed']);
24
+ const SOURCE_SKILL_VALUES = new Set(['debug', 'fix', 'manual']);
25
+
26
+ function isIso8601(s) {
27
+ if (typeof s !== 'string') return false;
28
+ const d = new Date(s);
29
+ return !Number.isNaN(d.getTime()) && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(s);
30
+ }
31
+
32
+ function validatePendingRecord(rec) {
33
+ const errors = [];
34
+ if (typeof rec.sessionId !== 'string' || !SESSION_ID_PATTERN.test(rec.sessionId)) errors.push('invalid_sessionId');
35
+ if (!isIso8601(rec.dispatchedAt)) errors.push('invalid_dispatchedAt');
36
+ if (typeof rec.triggerSignal !== 'string') errors.push('invalid_triggerSignal');
37
+ if (!SOURCE_SKILL_VALUES.has(rec.sourceSkill)) errors.push('invalid_sourceSkill');
38
+ if (typeof rec.targetModel !== 'string') errors.push('invalid_targetModel');
39
+ if (typeof rec.bundleHash !== 'string' || !BUNDLE_HASH_PATTERN.test(rec.bundleHash))
40
+ errors.push('invalid_bundleHash');
41
+ if (!STATUS_VALUES.has(rec.status)) errors.push('invalid_status');
42
+ if (!isIso8601(rec.timeoutAt)) errors.push('invalid_timeoutAt');
43
+
44
+ // Conditional fields
45
+ if (rec.status === 'complete') {
46
+ if (rec.responseId === null || typeof rec.responseId !== 'string') errors.push('missing_responseId_when_complete');
47
+ if (rec.responseExcerpt === null || typeof rec.responseExcerpt !== 'string')
48
+ errors.push('missing_responseExcerpt_when_complete');
49
+ if (typeof rec.responseExcerpt === 'string' && rec.responseExcerpt.length > 500)
50
+ errors.push('responseExcerpt_too_long');
51
+ }
52
+ if (rec.status === 'pending') {
53
+ if (rec.responseId !== null) errors.push('responseId_must_be_null_when_pending');
54
+ if (rec.responseExcerpt !== null) errors.push('responseExcerpt_must_be_null_when_pending');
55
+ }
56
+ return { valid: errors.length === 0, errors };
57
+ }
58
+
59
+ function timeoutCheck(record, now) {
60
+ if (record.status !== 'pending') return record;
61
+ if (new Date(now).getTime() >= new Date(record.timeoutAt).getTime()) {
62
+ return { ...record, status: 'failed' };
63
+ }
64
+ return record;
65
+ }
66
+
67
+ function cleanupExpired(records, now, ttlMs = 24 * 60 * 60 * 1000) {
68
+ return records.filter((r) => new Date(now).getTime() - new Date(r.dispatchedAt).getTime() <= ttlMs);
69
+ }
70
+
71
+ function buildPending(overrides = {}) {
72
+ return {
73
+ sessionId: 'oracle-1714234500-abc123',
74
+ dispatchedAt: '2026-04-27T12:34:56Z',
75
+ triggerSignal: 'agent.stuck',
76
+ sourceSkill: 'debug',
77
+ targetModel: 'claude-opus-4-7',
78
+ bundleHash: 'sha256:9f3a4b5c6d7e8f90',
79
+ status: 'pending',
80
+ timeoutAt: '2026-04-27T12:44:56Z',
81
+ responseId: null,
82
+ responseExcerpt: null,
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ describe('Pending record schema', () => {
88
+ test('valid pending record passes', () => {
89
+ const result = validatePendingRecord(buildPending());
90
+ assert.strictEqual(result.valid, true, `unexpected errors: ${result.errors.join(', ')}`);
91
+ });
92
+
93
+ test('valid complete record passes', () => {
94
+ const result = validatePendingRecord(
95
+ buildPending({
96
+ status: 'complete',
97
+ responseId: 'resp_xyz789',
98
+ responseExcerpt: 'Root cause: missing await on session refresh in middleware/auth.ts:47',
99
+ }),
100
+ );
101
+ assert.strictEqual(result.valid, true, `unexpected errors: ${result.errors.join(', ')}`);
102
+ });
103
+
104
+ test('valid failed record passes', () => {
105
+ const result = validatePendingRecord(buildPending({ status: 'failed' }));
106
+ assert.strictEqual(result.valid, true);
107
+ });
108
+
109
+ test('rejects invalid sessionId pattern', () => {
110
+ const result = validatePendingRecord(buildPending({ sessionId: 'NotAnOracleId' }));
111
+ assert.ok(result.errors.includes('invalid_sessionId'));
112
+ });
113
+
114
+ test('rejects invalid bundleHash pattern', () => {
115
+ const result = validatePendingRecord(buildPending({ bundleHash: 'md5:abc123' }));
116
+ assert.ok(result.errors.includes('invalid_bundleHash'));
117
+ });
118
+
119
+ test('rejects unknown status', () => {
120
+ const result = validatePendingRecord(buildPending({ status: 'in_flight' }));
121
+ assert.ok(result.errors.includes('invalid_status'));
122
+ });
123
+
124
+ test('rejects unknown sourceSkill', () => {
125
+ const result = validatePendingRecord(buildPending({ sourceSkill: 'review' }));
126
+ assert.ok(result.errors.includes('invalid_sourceSkill'));
127
+ });
128
+
129
+ test('complete record without responseId is invalid', () => {
130
+ const result = validatePendingRecord(buildPending({ status: 'complete' }));
131
+ assert.ok(result.errors.includes('missing_responseId_when_complete'));
132
+ assert.ok(result.errors.includes('missing_responseExcerpt_when_complete'));
133
+ });
134
+
135
+ test('responseExcerpt > 500 chars rejected', () => {
136
+ const result = validatePendingRecord(
137
+ buildPending({ status: 'complete', responseId: 'r', responseExcerpt: 'x'.repeat(501) }),
138
+ );
139
+ assert.ok(result.errors.includes('responseExcerpt_too_long'));
140
+ });
141
+
142
+ test('pending record with responseId set is invalid', () => {
143
+ const result = validatePendingRecord(buildPending({ responseId: 'r1' }));
144
+ assert.ok(result.errors.includes('responseId_must_be_null_when_pending'));
145
+ });
146
+ });
147
+
148
+ describe('Idempotency', () => {
149
+ test('same bundleHash returns same sessionId on duplicate dispatch', () => {
150
+ const existing = buildPending({ sessionId: 'oracle-1714234500-abc' });
151
+ const inbox = [existing];
152
+
153
+ const dispatch = (bundleHash) => {
154
+ const match = inbox.find((r) => r.bundleHash === bundleHash && r.status === 'pending');
155
+ if (match) return match.sessionId;
156
+ const newId = `oracle-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
157
+ inbox.push(buildPending({ sessionId: newId, bundleHash }));
158
+ return newId;
159
+ };
160
+
161
+ const id1 = dispatch(existing.bundleHash);
162
+ const id2 = dispatch(existing.bundleHash);
163
+ assert.strictEqual(id1, id2);
164
+ assert.strictEqual(id1, existing.sessionId);
165
+ });
166
+ });
167
+
168
+ describe('Timeout transition', () => {
169
+ test('pending record becomes failed when now > timeoutAt', () => {
170
+ const record = buildPending({ timeoutAt: '2026-04-27T12:00:00Z' });
171
+ const after = timeoutCheck(record, '2026-04-27T13:00:00Z');
172
+ assert.strictEqual(after.status, 'failed');
173
+ });
174
+
175
+ test('pending stays pending when now < timeoutAt', () => {
176
+ const record = buildPending({ timeoutAt: '2026-04-27T13:00:00Z' });
177
+ const after = timeoutCheck(record, '2026-04-27T12:30:00Z');
178
+ assert.strictEqual(after.status, 'pending');
179
+ });
180
+
181
+ test('complete records not affected by timeout check', () => {
182
+ const record = buildPending({
183
+ status: 'complete',
184
+ responseId: 'r',
185
+ responseExcerpt: 'done',
186
+ timeoutAt: '2026-04-27T12:00:00Z',
187
+ });
188
+ const after = timeoutCheck(record, '2026-04-27T20:00:00Z');
189
+ assert.strictEqual(after.status, 'complete');
190
+ });
191
+ });
192
+
193
+ describe('Cleanup orphaned records', () => {
194
+ test('records >24h old are dropped', () => {
195
+ const records = [
196
+ buildPending({ sessionId: 'oracle-1-fresh', dispatchedAt: '2026-04-27T11:00:00Z' }),
197
+ buildPending({ sessionId: 'oracle-2-stale', dispatchedAt: '2026-04-25T11:00:00Z' }),
198
+ ];
199
+ const remaining = cleanupExpired(records, '2026-04-27T12:00:00Z');
200
+ assert.strictEqual(remaining.length, 1);
201
+ assert.strictEqual(remaining[0].sessionId, 'oracle-1-fresh');
202
+ });
203
+
204
+ test('records <24h old are kept', () => {
205
+ const records = [buildPending({ dispatchedAt: '2026-04-26T13:00:00Z' })];
206
+ const remaining = cleanupExpired(records, '2026-04-27T12:00:00Z');
207
+ assert.strictEqual(remaining.length, 1);
208
+ });
209
+ });