dsh-scout 0.9.0 → 0.9.2

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.
@@ -0,0 +1,1684 @@
1
+ /**
2
+ * dsh-scout 0.9.1 — independent acceptance suite (task-6).
3
+ *
4
+ * Owned by the independent verifier, not by any implementer. It deliberately
5
+ * does NOT re-use the assertions from `tests/tools.test.mjs` / `tests/model.test.mjs`;
6
+ * it drives the frozen build through its own Cordis-shaped harness and takes
7
+ * angles those suites do not cover:
8
+ *
9
+ * 1. `output.schema` is a *compiled native* JSON Schema, and the values of all
10
+ * twelve tools are validated against their own schema, with tamper controls
11
+ * proving the validator is live rather than vacuous;
12
+ * 2. per-class render budgets, including the adversarial inputs that used to
13
+ * break them (the whole-render UTF-8 cap is asserted, not just observed);
14
+ * 3. torn writes: the marker protocol (downgrade first, close last), what an
15
+ * interrupted write leaves on disk, and how `scout_import` and lazy
16
+ * rehydration each react — including the marker-vs-missing-file matrix;
17
+ * 4. the *content* of `export.complete.json` (`complete`, `eventCount`, file
18
+ * list, case id) versus `events.jsonl` and `report.md`, not its existence;
19
+ * 5. in-memory versus on-disk event counts under `autoPersist`, and three
20
+ * consecutive exports (complete → interrupted → complete);
21
+ * 6. call-site service binding, provider replacement, cwd/signal forwarding
22
+ * and disposal;
23
+ * 7. lazy rehydration from `<scoutDir>/<caseId>/`, including the case-id guard
24
+ * that stops an export of another case being adopted;
25
+ * 8. `language: 'en'` end to end (including the persisted `report.md`) and the
26
+ * unchanged `zh-CN` byte shape.
27
+ *
28
+ * Round 1 of this suite pinned six defects as *characterization* tests; round 2
29
+ * pinned a seventh as a *documented boundary* (`D10`). All seven were fixed and
30
+ * are now asserted as the positive contract; the names say which defect each one
31
+ * guards:
32
+ *
33
+ * - G1 (lazy rehydration ignored the completion marker) → marker is now required
34
+ * - G2 (the 1 KB render budget was not enforced) → per-field + whole-render caps
35
+ * - G3 (a failed overwrite left a stale `complete:true`) → marker is downgraded first
36
+ * - D1 (persisted report.md followed the case language) → export follows the render language
37
+ * - D7 (ingest drafts hard-coded Chinese notes) → both strings moved into i18n
38
+ * - D8 (stored factory prose kept the creation language) → untouched defaults align
39
+ * - D10 (a partially edited question set froze its untouched factory entries)
40
+ * → questions align entry by entry
41
+ *
42
+ * No test in this file asserts that a defect exists; §10b carries the seven-row
43
+ * behaviour matrix for the entry-by-entry rule. No real assertion is relaxed
44
+ * anywhere in this file.
45
+ */
46
+ import test from 'node:test'
47
+ import assert from 'node:assert/strict'
48
+ import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
49
+ import { Config, apply } from '../dist/index.js'
50
+ import { CLAIM_DIMENSIONS, EXPORT_FILE_NAMES, scoutStrings } from '../dist/model.js'
51
+
52
+ /* ------------------------------------------------------------------ *
53
+ * Harness
54
+ * ------------------------------------------------------------------ */
55
+
56
+ const ALL_TOOLS = [
57
+ 'scout_add_claim',
58
+ 'scout_add_source',
59
+ 'scout_compare',
60
+ 'scout_export',
61
+ 'scout_import',
62
+ 'scout_ingest',
63
+ 'scout_questions',
64
+ 'scout_report',
65
+ 'scout_search',
66
+ 'scout_start',
67
+ 'scout_verify_claim',
68
+ 'scout_verify_identity',
69
+ ]
70
+
71
+ /** Mutation-envelope tools: they share one `output.schema` and one renderer. */
72
+ const ENVELOPE_TOOLS = [
73
+ 'scout_add_claim',
74
+ 'scout_add_source',
75
+ 'scout_import',
76
+ 'scout_ingest',
77
+ 'scout_search',
78
+ 'scout_start',
79
+ 'scout_verify_claim',
80
+ 'scout_verify_identity',
81
+ ]
82
+
83
+ const EXPORT_MARKER_FILE = 'export.complete.json'
84
+ /** Model-facing byte budget the README states for a compact render. */
85
+ const RENDER_BUDGET_BYTES = 1024
86
+ const CJK = /[\u4e00-\u9fff]/
87
+ const countCjk = text => (text.match(/[\u4e00-\u9fff]/gu) ?? []).length
88
+
89
+ const execFor = (sessionId = 'verifier', cwd = '/session/cwd') => ({
90
+ agent: { id: sessionId, session: { header: { cwd } } },
91
+ signal: new AbortController().signal,
92
+ })
93
+
94
+ /**
95
+ * Mount the plugin on a minimal Cordis-shaped context.
96
+ *
97
+ * `effect()` mirrors Cordis' contract for the two shapes this plugin uses: the
98
+ * tool registration is a *generator* yielding disposers, while the optional
99
+ * service bindings are plain functions returning one disposer. `inject()`
100
+ * defers until every dependency is mounted, so a service attached after
101
+ * `apply()` still reaches its registration callback.
102
+ */
103
+ function createHarness({ config, services = {} } = {}) {
104
+ const tools = new Map()
105
+ const disposers = []
106
+ const injections = []
107
+
108
+ const ctx = {
109
+ get(serviceName) {
110
+ return this[serviceName]
111
+ },
112
+ tools: {
113
+ register(tool) {
114
+ tools.set(tool.name, tool)
115
+ return () => tools.delete(tool.name)
116
+ },
117
+ },
118
+ effect(callback) {
119
+ const produced = callback()
120
+ if (typeof produced === 'function') {
121
+ disposers.push(produced)
122
+ } else if (produced && typeof produced[Symbol.iterator] === 'function') {
123
+ for (const item of produced) if (typeof item === 'function') disposers.push(item)
124
+ }
125
+ return () => {}
126
+ },
127
+ inject(dependencies, callback) {
128
+ const entry = { dependencies: [...dependencies], callback, flushed: false }
129
+ injections.push(entry)
130
+ flushInjections()
131
+ },
132
+ /** Attach a host service, then re-run every deferred injection. */
133
+ mount(serviceName, service) {
134
+ this[serviceName] = service
135
+ flushInjections()
136
+ },
137
+ }
138
+
139
+ function flushInjections() {
140
+ for (const entry of injections) {
141
+ if (entry.flushed) continue
142
+ if (!entry.dependencies.every(name => ctx[name])) continue
143
+ entry.flushed = true
144
+ entry.callback(ctx)
145
+ }
146
+ }
147
+
148
+ Object.assign(ctx, services)
149
+ apply(ctx, config)
150
+ flushInjections()
151
+
152
+ return {
153
+ ctx,
154
+ tools,
155
+ injections,
156
+ get disposerCount() {
157
+ return disposers.length
158
+ },
159
+ dispose() {
160
+ while (disposers.length) disposers.pop()()
161
+ },
162
+ }
163
+ }
164
+
165
+ /** In-memory filesystem double that can fail on one named file. */
166
+ class MemoryFs {
167
+ constructor({ failOn = null } = {}) {
168
+ this.files = new Map()
169
+ this.calls = []
170
+ this.failOn = failOn
171
+ }
172
+
173
+ async resolve(path, options) {
174
+ this.calls.push({ op: 'resolve', path, options })
175
+ return { targetKey: `key:${path}`, displayPath: path }
176
+ }
177
+
178
+ async readText(target, signal) {
179
+ this.calls.push({ op: 'readText', path: target.displayPath, signal })
180
+ if (!this.files.has(target.displayPath)) {
181
+ throw new Error(`ENOENT: no such file: ${target.displayPath}`)
182
+ }
183
+ return this.files.get(target.displayPath)
184
+ }
185
+
186
+ async writeText(target, content, intent, signal) {
187
+ this.calls.push({ op: 'writeText', path: target.displayPath, content, intent, signal })
188
+ const baseName = target.displayPath.slice(target.displayPath.lastIndexOf('/') + 1)
189
+ if (this.failOn === baseName) {
190
+ throw new Error(`simulated write failure on ${baseName}`)
191
+ }
192
+ this.files.set(target.displayPath, content)
193
+ return { operation: 'create' }
194
+ }
195
+
196
+ read(path) {
197
+ return this.files.get(path)
198
+ }
199
+
200
+ has(path) {
201
+ return this.files.has(path)
202
+ }
203
+
204
+ list() {
205
+ return [...this.files.keys()]
206
+ }
207
+
208
+ /** Basenames written, in call order — the completion marker appears twice. */
209
+ get writeOrder() {
210
+ return this.calls
211
+ .filter(entry => entry.op === 'writeText')
212
+ .map(entry => entry.path.slice(entry.path.lastIndexOf('/') + 1))
213
+ }
214
+ }
215
+
216
+ function webStub(urls) {
217
+ return {
218
+ seen: [],
219
+ async search(request) {
220
+ this.seen.push(request)
221
+ return { sources: urls.map(url => ({ url, title: `Result ${url}` })), truncated: false }
222
+ },
223
+ }
224
+ }
225
+
226
+ const call = (harness, name, args, exec) => harness.tools.get(name).execute(args, exec)
227
+
228
+ const renderBlocks = (harness, name, value) => harness.tools.get(name).output.render({}, value)
229
+ const renderText = blocks =>
230
+ blocks.filter(block => block.type === 'text').map(block => block.text).join('\n')
231
+ const renderBytes = blocks => Buffer.byteLength(renderText(blocks), 'utf8')
232
+ const eventTypes = jsonl => (jsonl ?? '').trim()
233
+ ? jsonl.trim().split('\n').map(line => JSON.parse(line).type)
234
+ : []
235
+ const parseJsonl = jsonl => (jsonl ?? '').trim()
236
+ ? jsonl.trim().split('\n').map(line => JSON.parse(line))
237
+ : []
238
+
239
+ /** Seed a case with twelve sourced claims, the realistic "loaded" shape. */
240
+ async function seedLoadedCase(harness, caseId, exec) {
241
+ await call(harness, 'scout_start', {
242
+ caseId,
243
+ companyName: 'Example Co',
244
+ roleTitle: 'HR Head',
245
+ location: 'Shenzhen',
246
+ }, exec)
247
+ const items = []
248
+ for (let index = 1; index <= 12; index += 1) {
249
+ items.push({
250
+ url: `https://example.com/report-${index}`,
251
+ title: `Independent report ${index}`,
252
+ claim: {
253
+ text: `Finding ${index} about the company and the role.`,
254
+ dimension: index % 2 ? 'risk' : 'mandate',
255
+ impact: index % 3 ? 'material' : 'blocking',
256
+ },
257
+ })
258
+ }
259
+ await call(harness, 'scout_ingest', { caseId, itemsJson: JSON.stringify(items) }, exec)
260
+ return exec
261
+ }
262
+
263
+ /* ------------------------------------------------------------------ *
264
+ * 1. Output schema is a real, compiled, enforced contract (12/12)
265
+ * ------------------------------------------------------------------ */
266
+
267
+ test('all twelve tools publish a compiled JSON Schema that their own returned values satisfy', async () => {
268
+ const fs = new MemoryFs()
269
+ const harness = createHarness({ services: { fs, web: webStub(['https://example.com/found']) } })
270
+ assert.deepEqual([...harness.tools.keys()].sort(), ALL_TOOLS)
271
+
272
+ for (const [name, tool] of harness.tools) {
273
+ // `assertSupportedJsonSchema` rejects the *spec* form (`required: true`
274
+ // inside a property). Passing here means defineTool really compiled it.
275
+ assert.doesNotThrow(() => assertSupportedJsonSchema(tool.output.schema), `${name} output schema`)
276
+ assert.equal(typeof tool.output.render, 'function', `${name} must render`)
277
+ assert.ok(
278
+ !/"required":(true|false)/.test(JSON.stringify(tool.output.schema)),
279
+ `${name} still exposes the uncompiled spec form of \`required\``,
280
+ )
281
+ }
282
+
283
+ // One call per tool, in a sequence that is valid under the evidence policy.
284
+ const exec = execFor()
285
+ const values = new Map()
286
+ values.set('scout_start', await call(harness, 'scout_start', {
287
+ caseId: 'a', companyName: 'Alpha Co', roleTitle: 'HR Head', location: '',
288
+ }, exec))
289
+ assert.equal((await call(harness, 'scout_start', {
290
+ caseId: 'b', companyName: 'Beta Co', roleTitle: 'CTO', location: '',
291
+ }, exec)).ok, true)
292
+ values.set('scout_add_source', await call(harness, 'scout_add_source', {
293
+ caseId: 'a', sourceId: 'media', sourceType: 'independent_media',
294
+ title: 'Media', url: 'https://example.com/media', evidenceLevel: 'E2',
295
+ }, exec))
296
+ assert.equal((await call(harness, 'scout_add_source', {
297
+ caseId: 'a', sourceId: 'registry', sourceType: 'company_registry', title: 'Registry',
298
+ url: 'https://www.gsxt.gov.cn/co/1', evidenceLevel: 'E3',
299
+ }, exec)).ok, true)
300
+ values.set('scout_add_claim', await call(harness, 'scout_add_claim', {
301
+ caseId: 'a', claimId: 'c1', text: 'The role exists.', status: 'reported', evidenceLevel: 'E2',
302
+ dimension: 'role_existence', impact: 'blocking', sourceIds: 'media',
303
+ confidenceNote: 'Independent media.', nextAction: 'Confirm the mandate.',
304
+ }, exec))
305
+ values.set('scout_ingest', await call(harness, 'scout_ingest', {
306
+ caseId: 'a', itemsJson: JSON.stringify([{ url: 'https://example.com/extra', title: 'Extra' }]),
307
+ }, exec))
308
+ values.set('scout_search', await call(harness, 'scout_search', { caseId: 'a', query: 'alpha' }, exec))
309
+ values.set('scout_verify_identity', await call(harness, 'scout_verify_identity', {
310
+ caseId: 'a', legalEntity: 'Alpha Co Ltd', registrationNumber: '91440300X',
311
+ registeredRegion: 'Shenzhen', legalRepresentative: 'Zhang San',
312
+ brandRelationship: 'Operating entity', sourceIds: 'registry',
313
+ }, exec))
314
+ values.set('scout_verify_claim', await call(harness, 'scout_verify_claim', {
315
+ caseId: 'a', claimId: 'c1', evidenceLevel: 'E2', sourceIds: 'media,registry',
316
+ confidenceNote: 'Two sources.', nextAction: 'Keep.',
317
+ }, exec))
318
+ values.set('scout_report', await call(harness, 'scout_report', { caseId: 'a' }, exec))
319
+ values.set('scout_questions', await call(harness, 'scout_questions', { caseId: 'a' }, exec))
320
+ values.set('scout_compare', await call(harness, 'scout_compare', { caseIds: 'a,b' }, exec))
321
+ values.set('scout_export', await call(harness, 'scout_export', { caseId: 'a', targetDir: '/export/a' }, exec))
322
+ values.set('scout_import', await call(harness, 'scout_import', { caseId: 'a', sourceDir: '/export/a' }, exec))
323
+
324
+ assert.deepEqual([...values.keys()].sort(), ALL_TOOLS, 'every tool must be exercised exactly once')
325
+ for (const [name, value] of values) {
326
+ const schema = harness.tools.get(name).output.schema
327
+ assert.deepEqual(
328
+ validateJsonSchemaValue(schema, value),
329
+ [],
330
+ `${name} returned a value that violates its own declared output schema`,
331
+ )
332
+ }
333
+
334
+ // Tamper controls: without these, the block above would also pass on a
335
+ // validator that never reports anything.
336
+ for (const [name, value] of values) {
337
+ const schema = harness.tools.get(name).output.schema
338
+ assert.ok(
339
+ validateJsonSchemaValue(schema, {}).length > 0,
340
+ `${name} schema must require its properties (an empty object was accepted)`,
341
+ )
342
+ assert.ok(
343
+ validateJsonSchemaValue(schema, { ...value, __unexpected: true }).length > 0,
344
+ `${name} schema must reject an undeclared property`,
345
+ )
346
+ }
347
+ assert.ok(
348
+ validateJsonSchemaValue(harness.tools.get('scout_report').output.schema, {
349
+ ...values.get('scout_report'),
350
+ decision: 'MAYBE',
351
+ }).length > 0,
352
+ 'scout_report must reject a decision outside the closed enum',
353
+ )
354
+ })
355
+
356
+ /* ------------------------------------------------------------------ *
357
+ * 2. Render budgets
358
+ * ------------------------------------------------------------------ */
359
+
360
+ test('mutating tools render a compact summary while report and compare stay the full artifact', async () => {
361
+ const harness = createHarness({ services: { fs: new MemoryFs() } })
362
+ const exec = await seedLoadedCase(harness, 'budget', execFor())
363
+
364
+ const compact = new Map()
365
+ compact.set('scout_ingest', await call(harness, 'scout_ingest', {
366
+ caseId: 'budget', itemsJson: JSON.stringify([{ url: 'https://example.com/late', title: 'Late' }]),
367
+ }, exec))
368
+ compact.set('scout_add_source', await call(harness, 'scout_add_source', {
369
+ caseId: 'budget', sourceId: 'late-src', sourceType: 'other', title: 'Late',
370
+ url: 'https://example.com/late-2', evidenceLevel: 'E2',
371
+ }, exec))
372
+ compact.set('scout_add_claim', await call(harness, 'scout_add_claim', {
373
+ caseId: 'budget', claimId: 'late-claim', text: 'Late claim.', status: 'reported', evidenceLevel: 'E2',
374
+ dimension: 'risk', impact: 'material', sourceIds: 'late-src',
375
+ confidenceNote: 'Late.', nextAction: 'Check it.',
376
+ }, exec))
377
+ compact.set('scout_start', await call(harness, 'scout_start', {
378
+ caseId: 'budget-2', companyName: 'Second Co', roleTitle: 'CTO', location: '',
379
+ }, exec))
380
+ compact.set('scout_questions', await call(harness, 'scout_questions', { caseId: 'budget' }, exec))
381
+ compact.set('scout_export', await call(harness, 'scout_export', {
382
+ caseId: 'budget', targetDir: '/export/budget',
383
+ }, exec))
384
+ compact.set('scout_import', await call(harness, 'scout_import', {
385
+ caseId: 'budget', sourceDir: '/export/budget',
386
+ }, exec))
387
+
388
+ for (const [name, value] of compact) {
389
+ const blocks = renderBlocks(harness, name, value)
390
+ assert.ok(Array.isArray(blocks) && blocks.length > 0, `${name} render must emit content`)
391
+ assert.ok(blocks.every(block => block.type === 'text'), `${name} render blocks must be text`)
392
+ const bytes = renderBytes(blocks)
393
+ assert.ok(bytes < RENDER_BUDGET_BYTES, `${name} render was ${bytes} bytes (budget ${RENDER_BUDGET_BYTES})`)
394
+ assert.doesNotMatch(renderText(blocks), /"schemaVersion"/, `${name} must not dump the raw case JSON`)
395
+ }
396
+
397
+ // The two documented full artifacts are the only renders allowed past 1 KB.
398
+ const reportBlocks = renderBlocks(harness, 'scout_report', await call(harness, 'scout_report', { caseId: 'budget' }, exec))
399
+ assert.ok(renderBytes(reportBlocks) > RENDER_BUDGET_BYTES, 'scout_report carries the full report')
400
+ const reportText = renderText(reportBlocks)
401
+ assert.match(reportText, /^# Example Co — HR Head$/m)
402
+ for (const heading of [
403
+ '## Coverage',
404
+ '## Key supporting evidence (up to 3)',
405
+ '## Blocking or unresolved risks (up to 3)',
406
+ '## Role task and authority hypotheses',
407
+ '## Verification checklist',
408
+ '## Claim ledger',
409
+ '## Sources',
410
+ '## Interview questions',
411
+ ]) {
412
+ assert.ok(reportText.includes(heading), `report is missing ${heading}`)
413
+ }
414
+ assert.match(reportText, /https:\/\/example\.com\/report-1/, 'report must carry source URLs')
415
+ })
416
+
417
+ test('the 1 KB render budget holds even against adversarial caller-supplied text', async () => {
418
+ // Round 1 pinned the opposite: a 3000-character `nextAction` produced a
419
+ // 3121-byte render because only `changed.ids` was capped. Round 2 added
420
+ // per-field clipping plus a whole-render UTF-8 hard cap, so this test now
421
+ // asserts the budget itself — for ASCII, for multi-byte text, and for a
422
+ // synthetic envelope with every field at maximum size.
423
+ const harness = createHarness({ services: { fs: new MemoryFs() } })
424
+ const exec = execFor()
425
+ await call(harness, 'scout_start', { caseId: 'long', companyName: 'Long Co', roleTitle: 'HR Head', location: '' }, exec)
426
+ await call(harness, 'scout_add_source', {
427
+ caseId: 'long', sourceId: 'src', sourceType: 'other', title: 'Source',
428
+ url: 'https://example.com/src', evidenceLevel: 'E2',
429
+ }, exec)
430
+
431
+ const ascii = 'X'.repeat(3000)
432
+ const asciiValue = await call(harness, 'scout_add_claim', {
433
+ caseId: 'long', claimId: 'c1', text: 'Short text.', status: 'reported', evidenceLevel: 'E2',
434
+ dimension: 'risk', impact: 'blocking', sourceIds: 'src',
435
+ confidenceNote: 'Note.', nextAction: ascii,
436
+ }, exec)
437
+ const asciiBytes = renderBytes(renderBlocks(harness, 'scout_add_claim', asciiValue))
438
+ assert.ok(asciiBytes <= RENDER_BUDGET_BYTES, `ASCII render was ${asciiBytes} bytes`)
439
+ // Only the render is capped: the structured value still carries the caller's
440
+ // full text, so nothing is silently lost from the machine-readable field.
441
+ assert.equal(asciiValue.nextActions[0], ascii, 'the value keeps the full nextAction')
442
+
443
+ const cjk = '中'.repeat(3000)
444
+ const cjkValue = await call(harness, 'scout_add_claim', {
445
+ caseId: 'long', claimId: 'c2', text: '多字节文本。', status: 'reported', evidenceLevel: 'E2',
446
+ dimension: 'risk', impact: 'material', sourceIds: 'src',
447
+ confidenceNote: '备注。', nextAction: cjk,
448
+ }, exec)
449
+ const cjkBytes = renderBytes(renderBlocks(harness, 'scout_add_claim', cjkValue))
450
+ assert.ok(cjkBytes <= RENDER_BUDGET_BYTES, `multi-byte render was ${cjkBytes} bytes`)
451
+
452
+ // Three maximal multi-byte next actions: the per-field caps alone would still
453
+ // exceed 1 KB, so the whole-render cap has to fire.
454
+ for (let index = 3; index <= 4; index += 1) {
455
+ await call(harness, 'scout_add_claim', {
456
+ caseId: 'long', claimId: `c${index}`, text: `多字节 ${index}`, status: 'reported', evidenceLevel: 'E2',
457
+ dimension: 'risk', impact: 'blocking', sourceIds: 'src',
458
+ confidenceNote: '备注。', nextAction: '中'.repeat(500),
459
+ }, exec)
460
+ }
461
+ const synthetic = {
462
+ ok: true,
463
+ caseId: '案'.repeat(200),
464
+ decision: 'VERIFY',
465
+ identity: 'needs_verification',
466
+ counts: { sources: 1, claims: 4, verified: 0, open: 4 },
467
+ changed: { kind: 'case_imported', ids: ['案'.repeat(60)] },
468
+ nextActions: ['中'.repeat(500), '中'.repeat(500), '中'.repeat(500)],
469
+ sourceDir: '/目'.repeat(200),
470
+ complete: false,
471
+ }
472
+ const syntheticBlocks = renderBlocks(harness, 'scout_import', synthetic)
473
+ const syntheticBytes = renderBytes(syntheticBlocks)
474
+ assert.ok(syntheticBytes <= RENDER_BUDGET_BYTES, `worst-case render was ${syntheticBytes} bytes`)
475
+ assert.ok(
476
+ renderText(syntheticBlocks).endsWith('…'),
477
+ 'the whole-render cap must clip rather than silently drop trailing lines',
478
+ )
479
+
480
+ // F5: the clip budget is derived from the ellipsis it appends, so the clipped
481
+ // result — ellipsis included — lands *on* the budget rather than some magic
482
+ // number of bytes short of it. An ASCII-only envelope of the same shape is
483
+ // therefore exactly one budget wide, whichever way the clip falls.
484
+ const asciiSynthetic = {
485
+ ok: true,
486
+ caseId: 'A'.repeat(80),
487
+ decision: 'VERIFY',
488
+ identity: 'needs_verification',
489
+ counts: { sources: 1, claims: 4, verified: 0, open: 4 },
490
+ changed: { kind: 'case_imported', ids: ['B'.repeat(200)] },
491
+ nextActions: Array.from({ length: 6 }, () => 'C'.repeat(140)),
492
+ sourceDir: '/D'.repeat(160),
493
+ complete: false,
494
+ }
495
+ const asciiSyntheticBytes = renderBytes(renderBlocks(harness, 'scout_import', asciiSynthetic))
496
+ assert.equal(
497
+ asciiSyntheticBytes,
498
+ RENDER_BUDGET_BYTES,
499
+ 'an ASCII clip must fill the whole budget, ellipsis bytes included',
500
+ )
501
+ assert.ok(renderText(renderBlocks(harness, 'scout_import', asciiSynthetic)).endsWith('…'))
502
+
503
+ // Emoji are four UTF-8 bytes carried by a surrogate pair, and the per-field
504
+ // clip slices UTF-16 code units, so it can stop between the halves of a pair.
505
+ // Every one of those renders must still fit the budget.
506
+ for (const copies of [2, 3, 4, 5, 6, 7]) {
507
+ const emojiEnvelope = {
508
+ ...asciiSynthetic,
509
+ caseId: '🙂'.repeat(120),
510
+ changed: { kind: 'case_imported', ids: ['🚀'.repeat(120)] },
511
+ nextActions: Array.from({ length: copies }, () => '🧭'.repeat(300)),
512
+ sourceDir: `/🌍${'🙂'.repeat(200)}`,
513
+ }
514
+ const blocks = renderBlocks(harness, 'scout_import', emojiEnvelope)
515
+ const bytes = renderBytes(blocks)
516
+ assert.ok(bytes <= RENDER_BUDGET_BYTES, `${copies} emoji actions rendered ${bytes} bytes`)
517
+ assert.ok(renderText(blocks).endsWith('…'), `${copies} emoji actions must be clipped, not dropped`)
518
+ assert.ok(
519
+ bytes > RENDER_BUDGET_BYTES - 4,
520
+ `${copies} emoji actions rendered ${bytes} bytes: the clip must use the budget it has`,
521
+ )
522
+ }
523
+ })
524
+
525
+ /* ------------------------------------------------------------------ *
526
+ * 3. Declared parameters are enforced before execution
527
+ * ------------------------------------------------------------------ */
528
+
529
+ test('every tool rejects an empty argument object, so the declared parameters are not decorative', async () => {
530
+ const harness = createHarness({ services: { fs: new MemoryFs(), web: webStub([]) } })
531
+ assert.deepEqual([...harness.tools.keys()].sort(), ALL_TOOLS)
532
+ for (const name of ALL_TOOLS) {
533
+ await assert.rejects(
534
+ () => call(harness, name, {}, execFor()),
535
+ error => {
536
+ assert.equal(error.name, 'ToolArgsError', `${name} must fail argument validation, got ${error.name}`)
537
+ assert.match(error.message, /missing required property/, `${name} must name the missing property`)
538
+ return true
539
+ },
540
+ `${name} accepted an empty argument object`,
541
+ )
542
+ }
543
+ })
544
+
545
+ /* ------------------------------------------------------------------ *
546
+ * 4. autoPersist failure visibility
547
+ * ------------------------------------------------------------------ */
548
+
549
+ test('a failed autoPersist is visible in the return value and drops the case_exported event it could not write', async () => {
550
+ const fs = new MemoryFs()
551
+ const harness = createHarness({ config: { scoutDir: '/auto', autoPersist: true }, services: { fs } })
552
+ const exec = execFor()
553
+ await call(harness, 'scout_start', { caseId: 'auto', companyName: 'Auto Co', roleTitle: 'HR Head', location: '' }, exec)
554
+ assert.deepEqual(eventTypes(fs.read('/auto/auto/events.jsonl')), ['case_started', 'case_exported'])
555
+ assert.equal(JSON.parse(fs.read('/auto/auto/export.complete.json')).eventCount, 2)
556
+
557
+ // Fail half-way through the next write: the event stream itself fails.
558
+ fs.failOn = 'events.jsonl'
559
+ const failed = await call(harness, 'scout_add_source', {
560
+ caseId: 'auto', sourceId: 'src', sourceType: 'other', title: 'Source',
561
+ url: 'https://example.com/src', evidenceLevel: 'E2',
562
+ }, exec)
563
+
564
+ assert.equal(failed.persist.persisted, false)
565
+ assert.equal(failed.persist.targetDir, '/auto/auto')
566
+ assert.match(failed.persist.error, /events\.jsonl/)
567
+ assert.match(renderText(renderBlocks(harness, 'scout_add_source', failed)), /PERSIST FAILED/)
568
+
569
+ // The event the failed write would have recorded is absent from disk: the
570
+ // directory still holds the previous generation's event stream, so it never
571
+ // claims a case_exported that did not happen.
572
+ const onDisk = eventTypes(fs.read('/auto/auto/events.jsonl'))
573
+ assert.deepEqual(onDisk, ['case_started', 'case_exported'], 'the failed write must not append an event')
574
+ assert.ok(!onDisk.includes('source_added'))
575
+
576
+ // The failed attempt left the directory explicitly invalid rather than
577
+ // apparently complete.
578
+ assert.equal(JSON.parse(fs.read('/auto/auto/export.complete.json')).complete, false)
579
+
580
+ // The in-memory case must be just as honest: the next successful export
581
+ // records exactly one more case_exported, not one per attempt.
582
+ fs.failOn = null
583
+ const healed = await call(harness, 'scout_export', { caseId: 'auto', targetDir: '/auto/healed' }, exec)
584
+ assert.equal(healed.persisted, true)
585
+ assert.deepEqual(eventTypes(fs.read('/auto/healed/events.jsonl')), [
586
+ 'case_started', 'case_exported', 'source_added', 'case_exported',
587
+ ])
588
+ const healedMarker = JSON.parse(fs.read('/auto/healed/export.complete.json'))
589
+ assert.equal(healedMarker.complete, true)
590
+ assert.equal(healedMarker.eventCount, 4)
591
+ })
592
+
593
+ test('an interrupted overwrite downgrades the completion marker instead of leaving a stale complete:true', async () => {
594
+ // Round 1 pinned the opposite: the marker was written last, so an interrupted
595
+ // *overwrite* of an existing export left the previous `complete:true` marker
596
+ // beside mixed-generation files and `scout_import` reported `complete: true`.
597
+ // Round 2 writes the marker as `complete:false` before touching the payload
598
+ // and only re-asserts `complete:true` after every data file landed, so this
599
+ // test now asserts the invalidation contract.
600
+ const fs = new MemoryFs()
601
+ const harness = createHarness({ config: { scoutDir: '/auto', autoPersist: true }, services: { fs } })
602
+ const exec = execFor()
603
+ await call(harness, 'scout_start', { caseId: 'gen', companyName: 'Gen Co', roleTitle: 'HR Head', location: '' }, exec)
604
+
605
+ // Generation 1 is complete and self-consistent.
606
+ const firstMarker = JSON.parse(fs.read('/auto/gen/export.complete.json'))
607
+ assert.equal(firstMarker.complete, true)
608
+ assert.equal(firstMarker.eventCount, 2)
609
+ assert.equal(firstMarker.eventCount, eventTypes(fs.read('/auto/gen/events.jsonl')).length)
610
+
611
+ // Generation 2 fails on events.jsonl, after case.json/sources.json/claims.json
612
+ // were already overwritten.
613
+ fs.failOn = 'events.jsonl'
614
+ const failed = await call(harness, 'scout_add_claim', {
615
+ caseId: 'gen', claimId: 'c1', text: 'A claim.', status: 'needs_verification', evidenceLevel: 'E0',
616
+ dimension: 'risk', impact: 'material', sourceIds: '', confidenceNote: 'Note.', nextAction: 'Check.',
617
+ }, exec)
618
+ assert.equal(failed.persist.persisted, false)
619
+
620
+ // The marker is now explicitly invalid — that, and not its existence, is what
621
+ // a reader must trust. Its `eventCount` describes the attempt that failed, so
622
+ // it deliberately disagrees with the still-old `events.jsonl` on disk.
623
+ const tornMarker = JSON.parse(fs.read('/auto/gen/export.complete.json'))
624
+ const eventsOnDisk = eventTypes(fs.read('/auto/gen/events.jsonl'))
625
+ assert.equal(tornMarker.complete, false, 'an interrupted overwrite must downgrade the marker')
626
+ assert.equal(JSON.parse(fs.read('/auto/gen/claims.json')).length, 1, 'claims.json is from the new generation')
627
+ assert.ok(!eventsOnDisk.includes('claim_added'), 'events.jsonl still holds the previous generation')
628
+ assert.notEqual(tornMarker.eventCount, eventsOnDisk.length, 'the invalidated marker describes the failed attempt')
629
+
630
+ // A reader that respects the marker sees the directory as incomplete.
631
+ const imported = await call(harness, 'scout_import', { caseId: 'gen', sourceDir: '/auto/gen' }, exec)
632
+ assert.equal(imported.complete, false, 'a mixed-generation directory must not report complete:true')
633
+ const freshInstance = createHarness({ config: { scoutDir: '/auto' }, services: { fs } })
634
+ await assert.rejects(
635
+ () => call(freshInstance, 'scout_report', { caseId: 'gen' }, execFor()),
636
+ /Unknown case: gen/,
637
+ 'rehydration must refuse an invalidated export',
638
+ )
639
+
640
+ // A later successful write restores agreement between the data and the marker.
641
+ fs.failOn = null
642
+ await call(harness, 'scout_export', { caseId: 'gen', targetDir: '/auto/gen' }, exec)
643
+ const healedMarker = JSON.parse(fs.read('/auto/gen/export.complete.json'))
644
+ assert.equal(healedMarker.complete, true)
645
+ assert.equal(healedMarker.eventCount, eventTypes(fs.read('/auto/gen/events.jsonl')).length)
646
+ assert.equal(healedMarker.eventCount, 4)
647
+ })
648
+
649
+ /* ------------------------------------------------------------------ *
650
+ * 5. Export marker content agreement
651
+ * ------------------------------------------------------------------ */
652
+
653
+ test('the export marker, events.jsonl, report.md and case.json agree with the case that was exported', async () => {
654
+ const fs = new MemoryFs()
655
+ const harness = createHarness({ services: { fs } })
656
+ const exec = await seedLoadedCase(harness, 'agree', execFor())
657
+ await call(harness, 'scout_verify_claim', {
658
+ caseId: 'agree', claimId: 'claim-1', evidenceLevel: 'E2', sourceIds: 'src-1',
659
+ confidenceNote: 'Verifier note.', nextAction: 'Next step.',
660
+ }, exec)
661
+
662
+ const exported = await call(harness, 'scout_export', { caseId: 'agree', targetDir: '/export/agree' }, exec)
663
+ assert.equal(exported.persisted, true)
664
+ assert.equal(exported.complete, true)
665
+ assert.deepEqual(exported.files, [...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE])
666
+ for (const fileName of exported.files) {
667
+ assert.ok(fs.has(`/export/agree/${fileName}`), `export is missing ${fileName}`)
668
+ }
669
+
670
+ const events = parseJsonl(fs.read('/export/agree/events.jsonl'))
671
+ assert.equal(events.at(-1).type, 'case_exported', 'the event this export records must be the last line on disk')
672
+ assert.equal(events.at(-1).detail.targetDir, '/export/agree')
673
+ assert.equal(events.at(-1).detail.auto, false, 'an explicit export must be marked as not automatic')
674
+
675
+ const marker = JSON.parse(fs.read(`/export/agree/${EXPORT_MARKER_FILE}`))
676
+ assert.equal(marker.schemaVersion, 'dsh-scout.export.v1')
677
+ assert.equal(marker.caseId, 'agree')
678
+ assert.deepEqual(marker.files, [...EXPORT_FILE_NAMES], 'the marker lists the five data files, not itself')
679
+ assert.equal(marker.eventCount, events.length, 'the marker event count must match events.jsonl')
680
+
681
+ const caseJson = JSON.parse(fs.read('/export/agree/case.json'))
682
+ assert.equal(caseJson.caseId, 'agree')
683
+ assert.equal(caseJson.decision, 'VERIFY')
684
+ assert.ok(!('events' in caseJson), 'case.json must not duplicate the event stream')
685
+ const sourcesJson = JSON.parse(fs.read('/export/agree/sources.json'))
686
+ const claimsJson = JSON.parse(fs.read('/export/agree/claims.json'))
687
+ assert.equal(sourcesJson.length, 12)
688
+ assert.equal(claimsJson.length, 12)
689
+ // The files must describe the same case the envelope reported, not a stale one.
690
+ const envelopeCounts = (await call(harness, 'scout_import', {
691
+ caseId: 'agree', sourceDir: '/export/agree',
692
+ }, exec)).counts
693
+ assert.equal(envelopeCounts.sources, sourcesJson.length)
694
+ assert.equal(envelopeCounts.claims, claimsJson.length)
695
+
696
+ // The persisted report snapshot must be the same artifact the report tool
697
+ // returns — not a different rendering or a stale one.
698
+ const report = await call(harness, 'scout_report', { caseId: 'agree' }, exec)
699
+ assert.equal(fs.read('/export/agree/report.md'), report.report, 'report.md must equal the rendered report')
700
+ })
701
+
702
+ /* ------------------------------------------------------------------ *
703
+ * 6. Memory vs disk event counts, stable export/import/export sequence
704
+ * ------------------------------------------------------------------ */
705
+
706
+ test('autoPersist keeps the on-disk event stream equal to the in-memory event count', async () => {
707
+ const fs = new MemoryFs()
708
+ const harness = createHarness({ config: { scoutDir: '/auto', autoPersist: true }, services: { fs } })
709
+ const exec = execFor()
710
+ await call(harness, 'scout_start', { caseId: 'counts', companyName: 'Counts Co', roleTitle: 'HR Head', location: '' }, exec)
711
+ await call(harness, 'scout_add_source', {
712
+ caseId: 'counts', sourceId: 'src', sourceType: 'other', title: 'Source',
713
+ url: 'https://example.com/src', evidenceLevel: 'E2',
714
+ }, exec)
715
+ await call(harness, 'scout_add_claim', {
716
+ caseId: 'counts', claimId: 'c1', text: 'A claim.', status: 'reported', evidenceLevel: 'E2',
717
+ dimension: 'risk', impact: 'material', sourceIds: 'src', confidenceNote: 'Note.', nextAction: 'Check.',
718
+ }, exec)
719
+
720
+ const lines = eventTypes(fs.read('/auto/counts/events.jsonl'))
721
+ const marker = JSON.parse(fs.read('/auto/counts/export.complete.json'))
722
+ assert.deepEqual(lines, [
723
+ 'case_started', 'case_exported', 'source_added', 'case_exported', 'claim_added', 'case_exported',
724
+ ])
725
+ assert.equal(marker.eventCount, lines.length, 'in-memory event count and events.jsonl line count must agree')
726
+
727
+ // Export -> import -> export is append-only and does not renumber or repeat.
728
+ await call(harness, 'scout_import', { caseId: 'counts', sourceDir: '/auto/counts' }, exec)
729
+ await call(harness, 'scout_export', { caseId: 'counts', targetDir: '/auto/again' }, exec)
730
+ const after = eventTypes(fs.read('/auto/again/events.jsonl'))
731
+ assert.deepEqual(after.slice(0, lines.length), lines, 'the earlier stream must be preserved in order')
732
+ assert.deepEqual(after.slice(lines.length), ['case_imported', 'case_exported'])
733
+ })
734
+
735
+ /* ------------------------------------------------------------------ *
736
+ * 7. Torn exports
737
+ * ------------------------------------------------------------------ */
738
+
739
+ test('a write that fails part-way leaves a torn export that is rejected or honestly marked incomplete', async () => {
740
+ // (a) Interrupted on the third `writeText` call (case.json and sources.json
741
+ // have landed, claims.json fails): the directory is unusable and
742
+ // `scout_import` must say so instead of returning a partial case.
743
+ const earlyFs = new MemoryFs({ failOn: 'claims.json' })
744
+ const early = createHarness({ config: { scoutDir: '/scout' }, services: { fs: earlyFs } })
745
+ const exec = execFor()
746
+ await call(early, 'scout_start', { caseId: 'torn-early', companyName: 'Torn Co', roleTitle: 'HR Head', location: '' }, exec)
747
+ const earlyExport = await call(early, 'scout_export', { caseId: 'torn-early', targetDir: '/scout/torn-early' }, exec)
748
+ assert.equal(earlyExport.persisted, false)
749
+ assert.equal(earlyExport.complete, false)
750
+ assert.match(earlyExport.error, /claims\.json/)
751
+ // Round 2 writes the marker *first*, as `complete: false`, and only re-asserts
752
+ // it after the payload landed — so the interrupted directory now carries an
753
+ // explicitly invalid marker rather than no marker at all.
754
+ assert.deepEqual(earlyFs.list().sort(), [
755
+ '/scout/torn-early/case.json',
756
+ '/scout/torn-early/export.complete.json',
757
+ '/scout/torn-early/sources.json',
758
+ ])
759
+ const earlyMarker = JSON.parse(earlyFs.read('/scout/torn-early/export.complete.json'))
760
+ assert.equal(earlyMarker.complete, false)
761
+ assert.equal(earlyMarker.caseId, 'torn-early')
762
+ assert.ok(!earlyFs.has('/scout/torn-early/claims.json'))
763
+ // The write order itself is the guarantee: marker(false) first, marker(true) last.
764
+ assert.equal(earlyFs.writeOrder[0], EXPORT_MARKER_FILE)
765
+ assert.equal(earlyFs.writeOrder.at(-1), 'claims.json', 'the failed run never reached the closing marker write')
766
+ await assert.rejects(
767
+ () => call(early, 'scout_import', { caseId: 'torn-early', sourceDir: '/scout/torn-early' }, exec),
768
+ error => {
769
+ assert.match(error.message, /claims\.json/, 'the rejection must name the missing file')
770
+ return true
771
+ },
772
+ )
773
+ // Retrying into the same directory converges, and the failed attempt left no
774
+ // phantom export event behind.
775
+ earlyFs.failOn = null
776
+ const retried = await call(early, 'scout_export', { caseId: 'torn-early', targetDir: '/scout/torn-early' }, exec)
777
+ assert.equal(retried.persisted, true)
778
+ assert.deepEqual(eventTypes(earlyFs.read('/scout/torn-early/events.jsonl')), ['case_started', 'case_exported'])
779
+ assert.equal(JSON.parse(earlyFs.read('/scout/torn-early/export.complete.json')).complete, true)
780
+ // The retry brackets the payload with the marker: downgrade first, close last.
781
+ assert.deepEqual(
782
+ earlyFs.writeOrder.slice(-7),
783
+ [EXPORT_MARKER_FILE, ...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE],
784
+ 'the marker must be written both before and after the payload',
785
+ )
786
+
787
+ // (b) Interrupted on the closing marker write: all five data files exist, the
788
+ // marker still says `complete: false`, and that is the only thing that says so.
789
+ const lateFs = new MemoryFs()
790
+ const late = createHarness({ config: { scoutDir: '/scout' }, services: { fs: lateFs } })
791
+ await call(late, 'scout_start', { caseId: 'torn-late', companyName: 'Late Co', roleTitle: 'HR Head', location: '' }, exec)
792
+ lateFs.failOn = null
793
+ // Fail only the *second* marker write, i.e. the one that closes the export.
794
+ const originalWrite = lateFs.writeText.bind(lateFs)
795
+ let markerWrites = 0
796
+ lateFs.writeText = async (target, content, intent, signal) => {
797
+ const baseName = target.displayPath.slice(target.displayPath.lastIndexOf('/') + 1)
798
+ if (baseName === EXPORT_MARKER_FILE && ++markerWrites === 2) {
799
+ throw new Error(`simulated write failure on ${EXPORT_MARKER_FILE}`)
800
+ }
801
+ return originalWrite(target, content, intent, signal)
802
+ }
803
+ const lateExport = await call(late, 'scout_export', { caseId: 'torn-late', targetDir: '/scout/torn-late' }, exec)
804
+ assert.equal(lateExport.persisted, false)
805
+ assert.equal(lateExport.complete, false)
806
+ for (const fileName of EXPORT_FILE_NAMES) {
807
+ assert.ok(lateFs.has(`/scout/torn-late/${fileName}`), `${fileName} should have been written before the closing marker`)
808
+ }
809
+ assert.equal(
810
+ JSON.parse(lateFs.read(`/scout/torn-late/${EXPORT_MARKER_FILE}`)).complete,
811
+ false,
812
+ 'a run that never closed must leave the marker downgraded',
813
+ )
814
+ const imported = await call(late, 'scout_import', { caseId: 'torn-late', sourceDir: '/scout/torn-late' }, exec)
815
+ assert.equal(imported.caseId, 'torn-late')
816
+ assert.equal(imported.complete, false, 'a downgraded marker must report complete:false')
817
+
818
+ // (c) The same directory *with* a closing marker imports as complete, and
819
+ // deleting the marker afterwards downgrades it: the marker field is the only
820
+ // completeness signal.
821
+ lateFs.failOn = null
822
+ await call(late, 'scout_export', { caseId: 'torn-late', targetDir: '/scout/torn-late' }, exec)
823
+ assert.equal((await call(late, 'scout_import', {
824
+ caseId: 'torn-late', sourceDir: '/scout/torn-late',
825
+ }, exec)).complete, true)
826
+ lateFs.files.delete(`/scout/torn-late/${EXPORT_MARKER_FILE}`)
827
+ const withoutMarker = await call(late, 'scout_import', {
828
+ caseId: 'torn-late', sourceDir: '/scout/torn-late',
829
+ }, exec)
830
+ assert.equal(withoutMarker.complete, false, 'a deleted marker must downgrade the import to incomplete')
831
+ assert.equal(withoutMarker.counts.claims, 0)
832
+ })
833
+
834
+ test('three consecutive exports (complete, interrupted, complete) leave the marker and the event stream in agreement', async () => {
835
+ // The round-2 marker protocol has to survive repeated overwrites, not just one
836
+ // torn write: after a complete export, an interrupted one, and a final complete
837
+ // one, the data on disk and the marker must agree again.
838
+ const fs = new MemoryFs()
839
+ const harness = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
840
+ const exec = execFor()
841
+ const marker = () => JSON.parse(fs.read('/s/tri/export.complete.json'))
842
+ const lines = () => eventTypes(fs.read('/s/tri/events.jsonl'))
843
+
844
+ await call(harness, 'scout_start', { caseId: 'tri', companyName: 'Tri Co', roleTitle: 'HR Head', location: '' }, exec)
845
+ const first = await call(harness, 'scout_export', { caseId: 'tri', targetDir: '/s/tri' }, exec)
846
+ assert.equal(first.complete, true)
847
+ assert.equal(marker().complete, true)
848
+ assert.equal(marker().eventCount, lines().length)
849
+ assert.deepEqual(lines(), ['case_started', 'case_exported'])
850
+
851
+ await call(harness, 'scout_add_source', {
852
+ caseId: 'tri', sourceId: 'src', sourceType: 'other', title: 'Source',
853
+ url: 'https://example.com/src', evidenceLevel: 'E2',
854
+ }, exec)
855
+ fs.failOn = 'events.jsonl'
856
+ const second = await call(harness, 'scout_export', { caseId: 'tri', targetDir: '/s/tri' }, exec)
857
+ assert.equal(second.persisted, false)
858
+ assert.equal(second.complete, false)
859
+ assert.equal(marker().complete, false, 'the interrupted run leaves the marker invalid')
860
+ assert.deepEqual(lines(), ['case_started', 'case_exported'], 'and leaves the old event stream intact')
861
+
862
+ fs.failOn = null
863
+ const third = await call(harness, 'scout_export', { caseId: 'tri', targetDir: '/s/tri' }, exec)
864
+ assert.equal(third.complete, true)
865
+ assert.equal(marker().complete, true)
866
+ assert.deepEqual(lines(), ['case_started', 'case_exported', 'source_added', 'case_exported'])
867
+ assert.equal(marker().eventCount, lines().length, 'the marker and events.jsonl agree again')
868
+ assert.equal(JSON.parse(fs.read('/s/tri/sources.json')).length, 1)
869
+
870
+ const imported = await call(harness, 'scout_import', { caseId: 'tri', sourceDir: '/s/tri' }, exec)
871
+ assert.equal(imported.complete, true)
872
+ assert.equal(imported.counts.sources, 1)
873
+ })
874
+
875
+ test('scout_import tells "no completion marker" apart from "a data file is missing"', async () => {
876
+ // The completeness flag and the hard failure are two different signals: the
877
+ // marker decides `complete`, the presence of the five data files decides
878
+ // whether the import can happen at all.
879
+ const buildComplete = async () => {
880
+ const fs = new MemoryFs()
881
+ const writer = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
882
+ const exec = execFor()
883
+ await call(writer, 'scout_start', { caseId: 'mx', companyName: 'MX Co', roleTitle: 'HR Head', location: '' }, exec)
884
+ await call(writer, 'scout_add_source', {
885
+ caseId: 'mx', sourceId: 'src', sourceType: 'other', title: 'Source',
886
+ url: 'https://example.com/src', evidenceLevel: 'E2',
887
+ }, exec)
888
+ await call(writer, 'scout_export', { caseId: 'mx', targetDir: '/s/mx' }, exec)
889
+ return fs
890
+ }
891
+ const rewriteMarker = (fs, marker) => {
892
+ fs.files.set('/s/mx/export.complete.json', marker === null ? '' : `${JSON.stringify(marker)}\n`)
893
+ }
894
+ const importOnce = async fs => {
895
+ const reader = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
896
+ return call(reader, 'scout_import', { caseId: 'mx', sourceDir: '/s/mx' }, execFor())
897
+ .then(value => ({ outcome: 'imported', complete: value.complete, counts: value.counts }), error => ({ outcome: 'threw', message: error.message }))
898
+ }
899
+ const rehydrateOnce = async fs => {
900
+ const reader = createHarness({ config: { scoutDir: '/s' }, services: { fs } })
901
+ return call(reader, 'scout_report', { caseId: 'mx' }, execFor())
902
+ .then(() => 'rehydrated', () => 'refused')
903
+ }
904
+
905
+ const cases = []
906
+ {
907
+ const fs = await buildComplete()
908
+ cases.push(['closing marker complete:true', fs, { imported: true }])
909
+ }
910
+ {
911
+ const fs = await buildComplete()
912
+ fs.files.delete('/s/mx/export.complete.json')
913
+ cases.push(['marker file deleted', fs, { imported: false }])
914
+ }
915
+ {
916
+ const fs = await buildComplete()
917
+ rewriteMarker(fs, { schemaVersion: 'dsh-scout.export.v1', caseId: 'mx', complete: false, files: [...EXPORT_FILE_NAMES], eventCount: 2 })
918
+ cases.push(['marker says complete:false', fs, { imported: false }])
919
+ }
920
+ {
921
+ const fs = await buildComplete()
922
+ fs.files.set('/s/mx/export.complete.json', 'not json at all')
923
+ cases.push(['marker is unparsable', fs, { imported: false }])
924
+ }
925
+ {
926
+ const fs = await buildComplete()
927
+ fs.files.delete('/s/mx/claims.json')
928
+ cases.push(['marker complete:true but a data file is missing', fs, { throws: true }])
929
+ }
930
+ {
931
+ const fs = await buildComplete()
932
+ fs.files.delete('/s/mx/export.complete.json')
933
+ fs.files.delete('/s/mx/claims.json')
934
+ cases.push(['no marker and a data file missing', fs, { throws: true }])
935
+ }
936
+
937
+ for (const [label, fs, expected] of cases) {
938
+ const imported = await importOnce(fs)
939
+ if (expected.throws) {
940
+ assert.equal(imported.outcome, 'threw', `${label}: a missing data file must fail hard`)
941
+ assert.match(imported.message, /claims\.json/, `${label}: the failure must name the missing file`)
942
+ } else {
943
+ assert.equal(imported.outcome, 'imported', `${label}: import must succeed`)
944
+ assert.equal(imported.complete, expected.imported, `${label}: wrong complete flag`)
945
+ assert.equal(imported.counts.sources, 1)
946
+ }
947
+ assert.equal(
948
+ await rehydrateOnce(fs),
949
+ expected.imported ? 'rehydrated' : 'refused',
950
+ `${label}: rehydration follows the same rule`,
951
+ )
952
+ }
953
+ })
954
+
955
+ test('rehydration requires a completion marker asserting complete:true and refuses torn or legacy exports', async () => {
956
+ // Round 1 pinned the opposite: rehydration only required the four data files,
957
+ // so an export interrupted at `report.md` was adopted silently — including a
958
+ // `case_exported` event the tool had reported as failed. Round 2 makes the
959
+ // marker's `complete` field the gate, so this test now asserts the refusal.
960
+ const fs = new MemoryFs()
961
+ const writer = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
962
+ const exec = execFor()
963
+ await call(writer, 'scout_start', { caseId: 'silent', companyName: 'Silent Co', roleTitle: 'HR Head', location: '' }, exec)
964
+
965
+ // Interrupted after four of five data files: the marker is left downgraded.
966
+ fs.failOn = 'report.md'
967
+ const torn = await call(writer, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent' }, exec)
968
+ assert.equal(torn.persisted, false)
969
+ assert.equal(JSON.parse(fs.read('/scout/silent/export.complete.json')).complete, false)
970
+ assert.ok(!fs.has('/scout/silent/report.md'))
971
+
972
+ const reader = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
973
+ await assert.rejects(
974
+ () => call(reader, 'scout_report', { caseId: 'silent' }, execFor()),
975
+ error => {
976
+ assert.match(error.message, /Unknown case: silent/, 'an invalidated export must not be rehydrated')
977
+ assert.match(error.message, /scout_import/, 'the error must still point at the explicit restore path')
978
+ return true
979
+ },
980
+ )
981
+ // ... while the explicit import still works and reports the truth.
982
+ const imported = await call(reader, 'scout_import', { caseId: 'silent', sourceDir: '/scout/silent' }, exec)
983
+ assert.equal(imported.complete, false)
984
+ assert.equal(imported.counts.sources, 0)
985
+
986
+ // Healing the export makes it rehydratable again.
987
+ fs.failOn = null
988
+ await call(writer, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent' }, exec)
989
+ assert.equal(JSON.parse(fs.read('/scout/silent/export.complete.json')).complete, true)
990
+ const healed = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
991
+ const report = await call(healed, 'scout_report', { caseId: 'silent' }, execFor())
992
+ assert.match(report.report, /^# Silent Co — HR Head$/m)
993
+ await call(healed, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent-2' }, execFor())
994
+ assert.deepEqual(eventTypes(fs.read('/scout/silent-2/events.jsonl')), [
995
+ 'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
996
+ ], 'a completed export rehydrates and records exactly one case_rehydrated')
997
+
998
+ // A legacy 0.9.0 export has no marker at all: it must be restored explicitly
999
+ // with `scout_import`, not silently adopted on a cache miss.
1000
+ fs.files.delete('/scout/silent/export.complete.json')
1001
+ const legacy = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
1002
+ await assert.rejects(
1003
+ () => call(legacy, 'scout_report', { caseId: 'silent' }, execFor()),
1004
+ /Unknown case: silent/,
1005
+ 'a markerless legacy export must not be rehydrated',
1006
+ )
1007
+ const legacyImport = await call(legacy, 'scout_import', { caseId: 'silent', sourceDir: '/scout/silent' }, exec)
1008
+ assert.equal(legacyImport.complete, false, 'a legacy export still imports, honestly marked incomplete')
1009
+ assert.match((await call(legacy, 'scout_report', { caseId: 'silent' }, exec)).report, /Silent Co/)
1010
+ })
1011
+
1012
+ /* ------------------------------------------------------------------ *
1013
+ * 8. Call-site service binding, replacement, forwarding and disposal
1014
+ * ------------------------------------------------------------------ */
1015
+
1016
+ test('services mounted after apply() are used, a replaced provider wins, and disposal removes every registration', async () => {
1017
+ const harness = createHarness()
1018
+ assert.equal(harness.tools.size, 12, 'tools register even with no host service present')
1019
+ assert.deepEqual(harness.injections.map(entry => entry.dependencies), [['commands'], ['skills']])
1020
+
1021
+ const exec = execFor('late', '/session/cwd')
1022
+ await call(harness, 'scout_start', { caseId: 'late', companyName: 'Late Co', roleTitle: 'HR Head', location: '' }, exec)
1023
+
1024
+ // `web` appears only now; `scout_search` must still see it.
1025
+ harness.ctx.mount('web', webStub(['https://www.gsxt.gov.cn/co/1']))
1026
+ const found = await call(harness, 'scout_search', { caseId: 'late', query: 'late' }, exec)
1027
+ assert.equal(found.counts.sources, 1)
1028
+
1029
+ // `fs` appears only now; `scout_export` must still see it, and a *replaced*
1030
+ // provider must be the one that receives the writes.
1031
+ const firstFs = new MemoryFs()
1032
+ harness.ctx.mount('fs', firstFs)
1033
+ assert.equal((await call(harness, 'scout_export', { caseId: 'late', targetDir: 'relative/dir' }, exec)).persisted, true)
1034
+ assert.ok(firstFs.has('relative/dir/case.json'))
1035
+
1036
+ const secondFs = new MemoryFs()
1037
+ harness.ctx.mount('fs', secondFs)
1038
+ assert.equal((await call(harness, 'scout_export', { caseId: 'late', targetDir: 'relative/dir' }, exec)).persisted, true)
1039
+ assert.ok(secondFs.has('relative/dir/case.json'), 'the replacement provider must receive the write')
1040
+ assert.equal(firstFs.list().filter(path => path.includes('export.complete.json')).length, 1, 'the old provider must not be used again')
1041
+
1042
+ // The session cwd and the caller signal are forwarded to the write.
1043
+ const resolveCall = secondFs.calls.find(entry => entry.op === 'resolve')
1044
+ assert.equal(resolveCall.options.cwd, '/session/cwd', 'relative export paths resolve against the session cwd')
1045
+ assert.equal(resolveCall.options.signal, exec.signal)
1046
+ const writeCall = secondFs.calls.find(entry => entry.op === 'writeText')
1047
+ assert.equal(writeCall.signal, exec.signal, 'the write must forward exec.signal')
1048
+
1049
+ // Optional host services registered after apply() are reached through inject.
1050
+ const commands = []
1051
+ const skills = []
1052
+ harness.ctx.mount('commands', { register: definition => { commands.push(definition); return () => commands.pop() } })
1053
+ harness.ctx.mount('skills', { register: definition => { skills.push(definition); return () => skills.pop() } })
1054
+ assert.deepEqual(commands.map(definition => definition.name), ['scout'])
1055
+ assert.deepEqual(skills.map(definition => definition.name), ['scout'])
1056
+
1057
+ // Hold the tool object across disposal: the cache clearing is what must make
1058
+ // the call fail, not the registry lookup.
1059
+ const reportTool = harness.tools.get('scout_report')
1060
+ harness.dispose()
1061
+ assert.equal(harness.tools.size, 0, 'unload must remove every tool')
1062
+ assert.equal(commands.length, 0, 'unload must dispose the command registration')
1063
+ assert.equal(skills.length, 0, 'unload must dispose the skill registration')
1064
+ await assert.rejects(
1065
+ () => reportTool.execute({ caseId: 'late' }, exec),
1066
+ /Unknown case: late/,
1067
+ 'unload must clear the in-memory case cache',
1068
+ )
1069
+ })
1070
+
1071
+ /* ------------------------------------------------------------------ *
1072
+ * 9. Lazy rehydration
1073
+ * ------------------------------------------------------------------ */
1074
+
1075
+ test('a cache miss rehydrates from <scoutDir>/<caseId>/, records it, and refuses a mismatched case id', async () => {
1076
+ const fs = new MemoryFs()
1077
+ const writer = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
1078
+ const exec = execFor()
1079
+ await call(writer, 'scout_start', { caseId: 'restored', companyName: 'Restored Co', roleTitle: 'HR Head', location: '' }, exec)
1080
+ await call(writer, 'scout_export', { caseId: 'restored', targetDir: '/scout/restored' }, exec)
1081
+ assert.ok(fs.has('/scout/restored/export.complete.json'))
1082
+
1083
+ const reader = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
1084
+ const report = await call(reader, 'scout_report', { caseId: 'restored' }, exec)
1085
+ assert.match(report.report, /^# Restored Co — HR Head$/m)
1086
+ await call(reader, 'scout_export', { caseId: 'restored', targetDir: '/scout/restored-2' }, exec)
1087
+ assert.deepEqual(eventTypes(fs.read('/scout/restored-2/events.jsonl')), [
1088
+ 'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
1089
+ ])
1090
+
1091
+ // An export of a *different* case sitting in this case's directory must not be
1092
+ // adopted: rehydration is keyed by directory, and the id inside must match.
1093
+ const other = createHarness({ services: { fs } })
1094
+ await call(other, 'scout_start', { caseId: 'other', companyName: 'Other Co', roleTitle: 'HR Head', location: '' }, exec)
1095
+ await call(other, 'scout_export', { caseId: 'other', targetDir: '/scout/wanted' }, exec)
1096
+ const third = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
1097
+ await assert.rejects(
1098
+ () => call(third, 'scout_report', { caseId: 'wanted' }, execFor()),
1099
+ error => {
1100
+ assert.match(error.message, /Unknown case: wanted/)
1101
+ assert.match(error.message, /scout_import/, 'the error must suggest how to restore the case')
1102
+ return true
1103
+ },
1104
+ )
1105
+ })
1106
+
1107
+ /* ------------------------------------------------------------------ *
1108
+ * 10. Language
1109
+ * ------------------------------------------------------------------ */
1110
+
1111
+ test('language: en renders every human surface, including the persisted report.md, in English', async () => {
1112
+ // Scope note: this covers claims added through `scout_add_claim`, i.e. every
1113
+ // string in the result is either framework text or caller-supplied. The
1114
+ // `scout_ingest` draft path is covered separately by the DEFECT D7 test below,
1115
+ // which shows it is NOT language-neutral.
1116
+ const fs = new MemoryFs()
1117
+ const harness = createHarness({ config: { language: 'en' }, services: { fs } })
1118
+ const exec = execFor()
1119
+ await call(harness, 'scout_start', { caseId: 'en-a', companyName: 'Example Co', roleTitle: 'HR Head', location: '' }, exec)
1120
+ await call(harness, 'scout_start', { caseId: 'en-b', companyName: 'Second Co', roleTitle: 'CTO', location: '' }, exec)
1121
+
1122
+ const report = await call(harness, 'scout_report', { caseId: 'en-a' }, exec)
1123
+ assert.match(report.report, /- Decision: \*\*VERIFY\*\*/)
1124
+ assert.match(report.report, /- Reason: /)
1125
+ assert.match(report.report, /- Objective: /)
1126
+ assert.match(report.report, /- No sources yet/)
1127
+ assert.equal(CJK.test(report.report), false, 'an English report must not contain CJK characters')
1128
+
1129
+ const questions = await call(harness, 'scout_questions', { caseId: 'en-a' }, exec)
1130
+ assert.ok(questions.questions.length > 0)
1131
+ assert.equal(CJK.test(questions.questions.join('')), false, 'English questions must not contain CJK')
1132
+
1133
+ const comparison = await call(harness, 'scout_compare', { caseIds: 'en-a,en-b' }, exec)
1134
+ assert.match(comparison.report, /# Company \/ role comparison \(2 cases\)/)
1135
+ assert.equal(CJK.test(comparison.report), false, 'an English comparison must not contain CJK')
1136
+
1137
+ // The export writes report.md through `exportCaseFiles`, a different code path
1138
+ // from `scout_report`; both must be English.
1139
+ const exported = await call(harness, 'scout_export', { caseId: 'en-a', targetDir: '/export/en' }, exec)
1140
+ assert.equal(exported.persisted, true)
1141
+ const persisted = fs.read('/export/en/report.md')
1142
+ assert.equal(CJK.test(persisted), false, 'the persisted report.md must be English too')
1143
+ assert.equal(persisted, report.report, 'the persisted snapshot must equal the rendered report')
1144
+ const caseJson = JSON.parse(fs.read('/export/en/case.json'))
1145
+ assert.equal(caseJson.language, 'en')
1146
+ assert.equal(CJK.test(caseJson.decisionReason), false)
1147
+
1148
+ // The configured language must survive a round trip through an export.
1149
+ const reader = createHarness({ config: { language: 'en', scoutDir: '/export' }, services: { fs } })
1150
+ await call(reader, 'scout_import', { caseId: 'en-a', sourceDir: '/export/en' }, execFor())
1151
+ const reread = await call(reader, 'scout_report', { caseId: 'en-a' }, execFor())
1152
+ assert.match(reread.report, /- Decision: \*\*VERIFY\*\*/)
1153
+ })
1154
+
1155
+ test('scout_ingest drafts its claim notes in the deployment language', async () => {
1156
+ // Round 1 pinned the opposite: the ingest draft path hard-coded Chinese
1157
+ // `confidenceNote` / `nextAction`, so an `en` case built through the documented
1158
+ // start -> ingest workflow produced 78 CJK characters in the report and in the
1159
+ // persisted report.md. Round 2 moved both strings into `i18n.ts`; this test now
1160
+ // asserts both languages, including that `zh-CN` is unchanged.
1161
+ const ingestOne = async (language, caseId) => {
1162
+ const fs = new MemoryFs()
1163
+ const harness = createHarness({ config: { language }, services: { fs } })
1164
+ const exec = execFor()
1165
+ await call(harness, 'scout_start', { caseId, companyName: 'Example Co', roleTitle: 'HR Head', location: '' }, exec)
1166
+ await call(harness, 'scout_ingest', {
1167
+ caseId,
1168
+ itemsJson: JSON.stringify([{
1169
+ url: 'https://example.com/a',
1170
+ title: 'Report A',
1171
+ claim: { text: 'The role exists.', dimension: 'role_existence' },
1172
+ }]),
1173
+ }, exec)
1174
+ const report = await call(harness, 'scout_report', { caseId }, exec)
1175
+ const questions = await call(harness, 'scout_questions', { caseId }, exec)
1176
+ await call(harness, 'scout_export', { caseId, targetDir: `/export/${caseId}` }, exec)
1177
+ return { report: report.report, questions: questions.questions, persisted: fs.read(`/export/${caseId}/report.md`) }
1178
+ }
1179
+
1180
+ const english = await ingestOne('en', 'en-ingest')
1181
+ assert.equal(CJK.test(english.report), false, 'an English report must have no CJK on the ingest path either')
1182
+ assert.equal(CJK.test(english.persisted), false, 'and the persisted report.md must be English too')
1183
+ assert.equal(CJK.test(english.questions.join('')), false, 'and so must the derived interview questions')
1184
+ assert.match(
1185
+ english.report,
1186
+ /confidence: Auto-registered from an ingested item; source src-1; next action: Verify this claim and add an independent source/,
1187
+ )
1188
+ assert.equal(english.persisted, english.report, 'the persisted snapshot stays byte-equal to the report')
1189
+
1190
+ const chinese = await ingestOne('zh-CN', 'zh-ingest')
1191
+ assert.match(
1192
+ chinese.report,
1193
+ /置信说明:由采集项自动登记,来源 src-1;下一步:核验该主张并补充独立来源/,
1194
+ 'the zh-CN draft wording is unchanged, so the 0.9.0 byte-compat baseline still holds',
1195
+ )
1196
+ assert.equal(CJK.test(chinese.report), true)
1197
+ })
1198
+
1199
+ test('the default zh-CN report keeps its pre-0.9.1 bytes and only adds the Coverage block', async () => {
1200
+ const defaults = new Config({})
1201
+ assert.equal(defaults.language, 'zh-CN')
1202
+ assert.equal(defaults.scoutDir, 'dsh-scout')
1203
+ assert.equal(defaults.autoPersist, false)
1204
+
1205
+ const harness = createHarness({ services: { fs: new MemoryFs() } })
1206
+ const exec = execFor()
1207
+ await call(harness, 'scout_start', { caseId: 'zh', companyName: 'Example Co', roleTitle: 'HR Head', location: '' }, exec)
1208
+ const report = await call(harness, 'scout_report', { caseId: 'zh' }, exec)
1209
+
1210
+ // Section headings are English in both languages — that is the shipped 0.9.0
1211
+ // shape, verified against `git show HEAD:src/model.ts`, not a translation gap.
1212
+ assert.deepEqual(
1213
+ report.report.split('\n').filter(line => line.startsWith('## ')),
1214
+ [
1215
+ '## Coverage',
1216
+ '## Key supporting evidence (up to 3)',
1217
+ '## Blocking or unresolved risks (up to 3)',
1218
+ '## Role task and authority hypotheses',
1219
+ '## Verification checklist',
1220
+ '## Claim ledger',
1221
+ '## Sources',
1222
+ '## Interview questions',
1223
+ ],
1224
+ )
1225
+ assert.match(report.report, /^- 判断:\*\*VERIFY\*\*$/m)
1226
+ assert.match(report.report, /^- 理由:尚未完成公司主体和岗位授权核验。$/m)
1227
+ assert.match(report.report, /^- 目标:判断是否值得进入下一轮,并准备面试反问$/m)
1228
+ assert.match(report.report, /^- 暂无来源$/m)
1229
+ assert.equal(CJK.test(report.report), true)
1230
+
1231
+ // `## Coverage` reports all eleven dimensions, in CLAIM_DIMENSIONS order, and
1232
+ // the structured array must agree with the rendered block.
1233
+ assert.equal(report.coverage.length, CLAIM_DIMENSIONS.length)
1234
+ assert.deepEqual(report.coverage.map(entry => entry.dimension), [...CLAIM_DIMENSIONS])
1235
+ assert.ok(report.coverage.every(entry => entry.untouched === true))
1236
+ const coverageBlock = report.report.slice(
1237
+ report.report.indexOf('## Coverage'),
1238
+ report.report.indexOf('## Key supporting evidence'),
1239
+ )
1240
+ for (const dimension of CLAIM_DIMENSIONS) {
1241
+ assert.ok(coverageBlock.includes(`- ${dimension}(`), `coverage block is missing ${dimension}`)
1242
+ }
1243
+ assert.match(coverageBlock, /未调查维度(11):/)
1244
+ })
1245
+
1246
+ test('a case crossing a language boundary renders every surface, report.md included, in the deployment language', async () => {
1247
+ // Round 1 pinned the opposite: `exportCaseFiles` rendered with no language
1248
+ // override, so an `en` deployment importing a zh-CN case returned an English
1249
+ // `scout_report` but persisted a Chinese `report.md`. Round 2 threads the
1250
+ // deployment language through the export, so both are now the same artifact.
1251
+ const fs = new MemoryFs()
1252
+ const zh = createHarness({ services: { fs } })
1253
+ const exec = execFor()
1254
+ await call(zh, 'scout_start', { caseId: 'mixed', companyName: 'Mixed Co', roleTitle: 'HR Head', location: '' }, exec)
1255
+ await call(zh, 'scout_export', { caseId: 'mixed', targetDir: '/export/zh' }, exec)
1256
+
1257
+ const en = createHarness({ config: { language: 'en' }, services: { fs } })
1258
+ await call(en, 'scout_import', { caseId: 'mixed', sourceDir: '/export/zh' }, exec)
1259
+ const report = await call(en, 'scout_report', { caseId: 'mixed' }, exec)
1260
+ await call(en, 'scout_export', { caseId: 'mixed', targetDir: '/export/en-from-zh' }, exec)
1261
+
1262
+ assert.match(report.report, /^- Decision: \*\*VERIFY\*\*$/m, 'scout_report follows the configured language')
1263
+ assert.match(report.report, /^- Reason: /m)
1264
+ assert.match(report.report, /^- No sources yet$/m)
1265
+ const persisted = fs.read('/export/en-from-zh/report.md')
1266
+ assert.equal(persisted, report.report, 'the persisted snapshot and the rendered report are the same artifact')
1267
+ assert.doesNotMatch(persisted, /^- 判断:/, 'report.md no longer falls back to the case language')
1268
+
1269
+ const caseJson = JSON.parse(fs.read('/export/en-from-zh/case.json'))
1270
+ assert.equal(caseJson.language, 'en', 'the case label follows the deployment language')
1271
+ assert.equal(CJK.test(caseJson.decisionReason), false, 'and the decision reason is rendered in it')
1272
+
1273
+ // The mirror direction: a zh-CN deployment importing an en case.
1274
+ const fs2 = new MemoryFs()
1275
+ const enWriter = createHarness({ config: { language: 'en' }, services: { fs: fs2 } })
1276
+ await call(enWriter, 'scout_start', { caseId: 'rev', companyName: 'Rev Co', roleTitle: 'HR Head', location: '' }, execFor())
1277
+ await call(enWriter, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-en' }, execFor())
1278
+ const zhReader = createHarness({ services: { fs: fs2 } })
1279
+ await call(zhReader, 'scout_import', { caseId: 'rev', sourceDir: '/export/rev-en' }, execFor())
1280
+ const zhReport = await call(zhReader, 'scout_report', { caseId: 'rev' }, execFor())
1281
+ await call(zhReader, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-zh' }, execFor())
1282
+ assert.match(zhReport.report, /^- 判断:\*\*VERIFY\*\*$/m)
1283
+ assert.equal(fs2.read('/export/rev-zh/report.md'), zhReport.report)
1284
+ assert.equal(JSON.parse(fs2.read('/export/rev-zh/case.json')).language, 'zh-CN')
1285
+ })
1286
+
1287
+ test('a case crossing a language boundary is single-language in both directions, on the import and rehydrate paths', async () => {
1288
+ // Round 1 pinned the render/export mismatch (D1) and round 2 pinned the residue
1289
+ // it left behind (D8): the case-stored prose fields kept the creation language,
1290
+ // so an "English" report carried a Chinese objective and five Chinese curated
1291
+ // questions while `case.json` claimed `language: "en"`. Round 3 aligns the
1292
+ // *untouched factory* values with the deployment language, so this test now
1293
+ // asserts a fully single-language artifact.
1294
+ const zhFactory = scoutStrings('zh-CN').defaults
1295
+ const enFactory = scoutStrings('en').defaults
1296
+
1297
+ // --- zh-CN case into an `en` deployment, via the explicit import path -------
1298
+ const fs = new MemoryFs()
1299
+ const zh = createHarness({ services: { fs } })
1300
+ const exec = execFor()
1301
+ await call(zh, 'scout_start', { caseId: 'mixed', companyName: 'Mixed Co', roleTitle: 'HR Head', location: '' }, exec)
1302
+ await call(zh, 'scout_export', { caseId: 'mixed', targetDir: '/export/zh' }, exec)
1303
+ const originalZhCase = JSON.parse(fs.read('/export/zh/case.json'))
1304
+ assert.equal(originalZhCase.language, 'zh-CN')
1305
+ assert.deepEqual(originalZhCase.interviewQuestions, zhFactory.interviewQuestions)
1306
+
1307
+ const en = createHarness({ config: { language: 'en' }, services: { fs } })
1308
+ await call(en, 'scout_import', { caseId: 'mixed', sourceDir: '/export/zh' }, exec)
1309
+ const report = await call(en, 'scout_report', { caseId: 'mixed' }, exec)
1310
+ const questions = await call(en, 'scout_questions', { caseId: 'mixed' }, exec)
1311
+ await call(en, 'scout_export', { caseId: 'mixed', targetDir: '/export/en-from-zh' }, exec)
1312
+ const caseJson = JSON.parse(fs.read('/export/en-from-zh/case.json'))
1313
+
1314
+ assert.equal(CJK.test(report.report), false, 'the English report must contain no CJK at all')
1315
+ assert.equal(CJK.test(questions.questions.join('')), false, 'nor may the question list')
1316
+ assert.match(report.report, new RegExp(`^- Objective: ${enFactory.decisionObjective.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'))
1317
+ assert.equal(caseJson.language, 'en')
1318
+ assert.equal(caseJson.decisionObjective, enFactory.decisionObjective)
1319
+ assert.deepEqual(caseJson.interviewQuestions, enFactory.interviewQuestions)
1320
+ assert.equal(caseJson.role.fitQuestion, enFactory.fitQuestion)
1321
+ assert.deepEqual(questions.questions.slice(-5), enFactory.interviewQuestions, 'the curated tail is the English factory set')
1322
+
1323
+ // --- en case into a zh-CN deployment: the mirror image ---------------------
1324
+ const fs2 = new MemoryFs()
1325
+ const enWriter = createHarness({ config: { language: 'en' }, services: { fs: fs2 } })
1326
+ await call(enWriter, 'scout_start', { caseId: 'rev', companyName: 'Rev Co', roleTitle: 'HR Head', location: '' }, execFor())
1327
+ await call(enWriter, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-en' }, execFor())
1328
+ const zhReader = createHarness({ services: { fs: fs2 } })
1329
+ await call(zhReader, 'scout_import', { caseId: 'rev', sourceDir: '/export/rev-en' }, execFor())
1330
+ const zhReport = await call(zhReader, 'scout_report', { caseId: 'rev' }, execFor())
1331
+ const zhQuestions = await call(zhReader, 'scout_questions', { caseId: 'rev' }, execFor())
1332
+ await call(zhReader, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-zh' }, execFor())
1333
+ const zhCase = JSON.parse(fs2.read('/export/rev-zh/case.json'))
1334
+
1335
+ // No English factory prose may survive in a zh-CN deployment.
1336
+ for (const sentinel of [enFactory.decisionObjective, enFactory.fitQuestion, ...enFactory.interviewQuestions]) {
1337
+ assert.ok(!zhReport.report.includes(sentinel), `English factory prose leaked into the zh report: ${sentinel}`)
1338
+ assert.ok(!zhQuestions.questions.includes(sentinel), `English factory prose leaked into the zh questions: ${sentinel}`)
1339
+ }
1340
+ assert.equal(zhCase.language, 'zh-CN')
1341
+ assert.equal(zhCase.decisionObjective, zhFactory.decisionObjective)
1342
+ assert.deepEqual(zhCase.interviewQuestions, zhFactory.interviewQuestions)
1343
+ assert.equal(zhCase.role.fitQuestion, zhFactory.fitQuestion)
1344
+
1345
+ // --- the lazy-rehydration path must align as well, not only scout_import ---
1346
+ const fs3 = new MemoryFs()
1347
+ const zhWriter = createHarness({ scoutDir: '/scout', services: { fs: fs3 } })
1348
+ await call(zhWriter, 'scout_start', { caseId: 'rehy', companyName: 'Rehy Co', roleTitle: 'HR Head', location: '' }, execFor())
1349
+ await call(zhWriter, 'scout_export', { caseId: 'rehy', targetDir: '/scout/rehy' }, execFor())
1350
+ const enReader = createHarness({ config: { language: 'en', scoutDir: '/scout' }, services: { fs: fs3 } })
1351
+ const rehydrated = await call(enReader, 'scout_report', { caseId: 'rehy' }, execFor())
1352
+ assert.equal(CJK.test(rehydrated.report), false, 'a rehydrated zh case must be aligned before rendering')
1353
+ assert.match(rehydrated.report, /^- Objective: /m)
1354
+
1355
+ // --- round trip zh -> en -> zh restores the original factory prose ---------
1356
+ const fs4 = new MemoryFs()
1357
+ const first = createHarness({ scoutDir: '/rt', services: { fs: fs4 } })
1358
+ await call(first, 'scout_start', { caseId: 'rt', companyName: 'RT Co', roleTitle: 'HR Head', location: '' }, execFor())
1359
+ await call(first, 'scout_export', { caseId: 'rt', targetDir: '/rt/1' }, execFor())
1360
+ const middle = createHarness({ config: { language: 'en', scoutDir: '/rt' }, services: { fs: fs4 } })
1361
+ await call(middle, 'scout_import', { caseId: 'rt', sourceDir: '/rt/1' }, execFor())
1362
+ await call(middle, 'scout_export', { caseId: 'rt', targetDir: '/rt/2' }, execFor())
1363
+ const last = createHarness({ scoutDir: '/rt', services: { fs: fs4 } })
1364
+ await call(last, 'scout_import', { caseId: 'rt', sourceDir: '/rt/2' }, execFor())
1365
+ await call(last, 'scout_export', { caseId: 'rt', targetDir: '/rt/3' }, execFor())
1366
+ const restored = JSON.parse(fs4.read('/rt/3/case.json'))
1367
+ assert.equal(restored.language, 'zh-CN')
1368
+ assert.equal(restored.decisionObjective, zhFactory.decisionObjective)
1369
+ assert.deepEqual(restored.interviewQuestions, zhFactory.interviewQuestions)
1370
+ assert.equal(restored.role.fitQuestion, zhFactory.fitQuestion)
1371
+ })
1372
+
1373
+ test('user-edited case prose survives a language change while untouched fields still align', async () => {
1374
+ // The complement of the test above: alignment must never overwrite what the
1375
+ // user wrote. Each field is decided independently, so editing the objective
1376
+ // does not freeze the questions (and the other way round).
1377
+ const enFactory = scoutStrings('en').defaults
1378
+ const zhFactory = scoutStrings('zh-CN').defaults
1379
+ const editAndMove = async (edit, caseId) => {
1380
+ const fs = new MemoryFs()
1381
+ const zh = createHarness({ scoutDir: '/u', services: { fs } })
1382
+ const exec = execFor()
1383
+ await call(zh, 'scout_start', { caseId, companyName: 'User Co', roleTitle: 'HR Head', location: '' }, exec)
1384
+ await call(zh, 'scout_export', { caseId, targetDir: `/u/${caseId}` }, exec)
1385
+ const edited = JSON.parse(fs.read(`/u/${caseId}/case.json`))
1386
+ edit(edited)
1387
+ fs.files.set(`/u/${caseId}/case.json`, `${JSON.stringify(edited, null, 2)}\n`)
1388
+ const en = createHarness({ config: { language: 'en', scoutDir: '/u2' }, services: { fs } })
1389
+ await call(en, 'scout_import', { caseId, sourceDir: `/u/${caseId}` }, exec)
1390
+ const report = await call(en, 'scout_report', { caseId }, exec)
1391
+ await call(en, 'scout_export', { caseId, targetDir: `/u/${caseId}-en` }, exec)
1392
+ return { reportText: report.report, caseJson: JSON.parse(fs.read(`/u/${caseId}-en/case.json`)) }
1393
+ }
1394
+
1395
+ const customObjective = '用户自定义目标(勿改)'
1396
+ const objectiveEdited = await editAndMove(caseJson => { caseJson.decisionObjective = customObjective }, 'obj')
1397
+ assert.equal(objectiveEdited.caseJson.decisionObjective, customObjective, 'the edited objective is preserved verbatim')
1398
+ assert.match(objectiveEdited.reportText, /^- Objective: 用户自定义目标(勿改)$/m)
1399
+ assert.deepEqual(
1400
+ objectiveEdited.caseJson.interviewQuestions,
1401
+ enFactory.interviewQuestions,
1402
+ 'the untouched question set still aligns',
1403
+ )
1404
+ assert.equal(objectiveEdited.caseJson.role.fitQuestion, enFactory.fitQuestion)
1405
+
1406
+ const customFit = 'Custom fit question from the user'
1407
+ const fitEdited = await editAndMove(caseJson => { caseJson.role.fitQuestion = customFit }, 'fit')
1408
+ assert.equal(fitEdited.caseJson.role.fitQuestion, customFit)
1409
+ assert.equal(fitEdited.caseJson.decisionObjective, enFactory.decisionObjective, 'the objective still aligns')
1410
+ assert.deepEqual(fitEdited.caseJson.interviewQuestions, enFactory.interviewQuestions)
1411
+
1412
+ // A fully user-authored question set is preserved as authored — in whatever
1413
+ // language the user wrote it.
1414
+ const customQuestions = ['Q1?', 'Q2?', 'Q3?', 'Q4?', 'Q5?']
1415
+ const questionsEdited = await editAndMove(caseJson => { caseJson.interviewQuestions = customQuestions }, 'qs')
1416
+ assert.deepEqual(questionsEdited.caseJson.interviewQuestions, customQuestions)
1417
+ assert.match(questionsEdited.reportText, /- Q1\?/, 'and the report renders them')
1418
+ assert.ok(!questionsEdited.reportText.includes(zhFactory.interviewQuestions[0]))
1419
+
1420
+ // Sanity: nothing here is a factory string, so nothing may be swapped back.
1421
+ assert.notDeepEqual(questionsEdited.caseJson.interviewQuestions, enFactory.interviewQuestions)
1422
+ })
1423
+
1424
+ /* ------------------------------------------------------------------ *
1425
+ * 10b. F1 — curated questions align entry by entry (D10, closed)
1426
+ *
1427
+ * Round 2 replaced the all-or-nothing rule with an index-wise one: when the
1428
+ * curated set still has the factory length, every entry that equals its factory
1429
+ * slot is aligned while an edited entry is kept verbatim; a set of a different
1430
+ * length is preserved as a whole. All seven rows of the behaviour matrix are
1431
+ * asserted below as the positive contract — no test here pins a defect.
1432
+ * ------------------------------------------------------------------ */
1433
+
1434
+ /**
1435
+ * Factory defaults per language. Every assertion in this section reads the
1436
+ * shipped i18n table, so wording changes in `src/i18n.ts` can never make a test
1437
+ * pass or fail for the wrong reason.
1438
+ */
1439
+ const FACTORY = {
1440
+ 'zh-CN': scoutStrings('zh-CN').defaults,
1441
+ en: scoutStrings('en').defaults,
1442
+ }
1443
+
1444
+ /** `scout_import` recomputes the decision on the way in; no other key may move. */
1445
+ const IMPORT_RECOMPUTED_KEYS = ['decision', 'decisionReason', 'decisionReasonCode']
1446
+
1447
+ const withoutRecomputed = caseJson => Object.fromEntries(
1448
+ Object.entries(caseJson).filter(([key]) => !IMPORT_RECOMPUTED_KEYS.includes(key)),
1449
+ )
1450
+
1451
+ /**
1452
+ * Drive one case across a language boundary: create it in `from`, optionally
1453
+ * edit the exported `case.json`, then import that export into a deployment
1454
+ * rendering `to`. Returns the case.json that was imported, the case.json the
1455
+ * target deployment re-exported, and the report it rendered.
1456
+ */
1457
+ async function crossLanguageImport({ caseId, from, to, edit, fs = new MemoryFs() }) {
1458
+ const exec = execFor()
1459
+ const sourceDir = `/matrix/${caseId}/from`
1460
+ const targetDir = `/matrix/${caseId}/to`
1461
+ const scoutDir = `/matrix/${caseId}`
1462
+ const writer = createHarness({ config: { language: from, scoutDir }, services: { fs } })
1463
+ await call(writer, 'scout_start', { caseId, companyName: 'Matrix Co', roleTitle: 'HR Head', location: '' }, exec)
1464
+ await call(writer, 'scout_export', { caseId, targetDir: sourceDir }, exec)
1465
+ const imported = JSON.parse(fs.read(`${sourceDir}/case.json`))
1466
+ if (edit) {
1467
+ edit(imported)
1468
+ fs.files.set(`${sourceDir}/case.json`, `${JSON.stringify(imported, null, 2)}\n`)
1469
+ }
1470
+ const reader = createHarness({ config: { language: to, scoutDir }, services: { fs } })
1471
+ await call(reader, 'scout_import', { caseId, sourceDir }, exec)
1472
+ const report = await call(reader, 'scout_report', { caseId }, exec)
1473
+ await call(reader, 'scout_export', { caseId, targetDir }, exec)
1474
+ return { imported, exported: JSON.parse(fs.read(`${targetDir}/case.json`)), report: report.report, fs }
1475
+ }
1476
+
1477
+ test('F1 matrix 1/7 · an untouched factory question set aligns as a whole in an en deployment', async () => {
1478
+ const moved = await crossLanguageImport({ caseId: 'm1', from: 'zh-CN', to: 'en' })
1479
+ assert.deepEqual(
1480
+ moved.imported.interviewQuestions,
1481
+ FACTORY['zh-CN'].interviewQuestions,
1482
+ 'the source really carried the untouched zh factory set',
1483
+ )
1484
+ assert.deepEqual(moved.exported.interviewQuestions, FACTORY.en.interviewQuestions)
1485
+ assert.equal(moved.exported.language, 'en')
1486
+ assert.equal(countCjk(moved.report), 0, 'an aligned en report carries no CJK at all')
1487
+ })
1488
+
1489
+ test('F1 matrix 2/7 · D10 (closed): one edited entry is kept, the rest align, and the en report CJK equals that entry', async () => {
1490
+ const userQuestion = '我自己写的第三个问题?'
1491
+ const moved = await crossLanguageImport({
1492
+ caseId: 'm2',
1493
+ from: 'zh-CN',
1494
+ to: 'en',
1495
+ edit: caseJson => { caseJson.interviewQuestions[2] = userQuestion },
1496
+ })
1497
+ assert.equal(moved.exported.interviewQuestions[2], userQuestion, 'the edited entry is preserved verbatim')
1498
+ assert.deepEqual(
1499
+ moved.exported.interviewQuestions,
1500
+ FACTORY.en.interviewQuestions.map((question, index) => (index === 2 ? userQuestion : question)),
1501
+ 'every untouched entry aligns to this deployment’s factory default',
1502
+ )
1503
+ assert.ok(moved.report.includes(userQuestion), 'the report renders the user’s own question')
1504
+ assert.ok(countCjk(userQuestion) > 0, 'the count assertion below is only meaningful if the user wrote CJK')
1505
+ assert.equal(
1506
+ countCjk(moved.report),
1507
+ countCjk(userQuestion),
1508
+ 'the only CJK left in the en report is the user’s own question, not four frozen factory entries',
1509
+ )
1510
+ })
1511
+
1512
+ test('F1 matrix 3/7 · a question set of a different length is preserved as a whole', async () => {
1513
+ const custom = ['自定义问题一?', '自定义问题二?', '自定义问题三?']
1514
+ const moved = await crossLanguageImport({
1515
+ caseId: 'm3',
1516
+ from: 'zh-CN',
1517
+ to: 'en',
1518
+ edit: caseJson => { caseJson.interviewQuestions = custom },
1519
+ })
1520
+ assert.equal(custom.length === FACTORY['zh-CN'].interviewQuestions.length, false, 'this row needs a different length')
1521
+ assert.deepEqual(moved.exported.interviewQuestions, custom)
1522
+ assert.ok(moved.report.includes(custom[0]), 'the report renders them as authored')
1523
+ assert.equal(
1524
+ moved.report.includes(FACTORY.en.interviewQuestions[0]),
1525
+ false,
1526
+ 'no factory entry may be injected into a differently sized set',
1527
+ )
1528
+ assert.equal(
1529
+ countCjk(moved.report),
1530
+ custom.reduce((total, question) => total + countCjk(question), 0),
1531
+ 'a differently sized set is kept whole, so its CJK is exactly what the user wrote',
1532
+ )
1533
+ })
1534
+
1535
+ test('F1 matrix 4/7 · a same-length set with no factory entry left is equivalent to a whole-set replacement', async () => {
1536
+ const custom = ['A1?', 'A2?', 'A3?', 'A4?', 'A5?']
1537
+ const moved = await crossLanguageImport({
1538
+ caseId: 'm4',
1539
+ from: 'zh-CN',
1540
+ to: 'en',
1541
+ edit: caseJson => { caseJson.interviewQuestions = custom },
1542
+ })
1543
+ assert.equal(
1544
+ custom.length,
1545
+ FACTORY['zh-CN'].interviewQuestions.length,
1546
+ 'the set has the factory length, so the entry-by-entry branch runs',
1547
+ )
1548
+ assert.deepEqual(moved.exported.interviewQuestions, custom, 'no entry matches its slot, so nothing is swapped')
1549
+ for (const question of custom) {
1550
+ assert.ok(moved.report.includes(`- ${question}`), `the report must render the user’s ${question}`)
1551
+ }
1552
+ assert.equal(
1553
+ FACTORY.en.interviewQuestions.some(question => moved.report.includes(question)),
1554
+ false,
1555
+ 'no factory entry may be reintroduced into a fully custom set',
1556
+ )
1557
+ })
1558
+
1559
+ test('F1 matrix 5/7 · the rule is symmetric: an en case aligns entry by entry into zh-CN', async () => {
1560
+ const whole = await crossLanguageImport({ caseId: 'm5a', from: 'en', to: 'zh-CN' })
1561
+ assert.deepEqual(whole.imported.interviewQuestions, FACTORY.en.interviewQuestions)
1562
+ assert.deepEqual(whole.exported.interviewQuestions, FACTORY['zh-CN'].interviewQuestions)
1563
+ assert.equal(whole.exported.language, 'zh-CN')
1564
+
1565
+ const userQuestion = 'My own third question?'
1566
+ const partial = await crossLanguageImport({
1567
+ caseId: 'm5b',
1568
+ from: 'en',
1569
+ to: 'zh-CN',
1570
+ edit: caseJson => { caseJson.interviewQuestions[2] = userQuestion },
1571
+ })
1572
+ assert.deepEqual(
1573
+ partial.exported.interviewQuestions,
1574
+ FACTORY['zh-CN'].interviewQuestions.map((question, index) => (index === 2 ? userQuestion : question)),
1575
+ 'the mirror image of row 2 holds with en as the source language',
1576
+ )
1577
+ })
1578
+
1579
+ test('F1 matrix 6/7 · a zh → en → zh round trip restores every factory entry', async () => {
1580
+ const exec = execFor()
1581
+ const hop = async (fs, language, caseId, sourceDir, targetDir) => {
1582
+ const harness = createHarness({ config: { language, scoutDir: `/round/${caseId}` }, services: { fs } })
1583
+ if (sourceDir) {
1584
+ await call(harness, 'scout_import', { caseId, sourceDir }, exec)
1585
+ } else {
1586
+ await call(harness, 'scout_start', { caseId, companyName: 'Round Trip Co', roleTitle: 'HR Head', location: '' }, exec)
1587
+ }
1588
+ await call(harness, 'scout_export', { caseId, targetDir }, exec)
1589
+ return JSON.parse(fs.read(`${targetDir}/case.json`))
1590
+ }
1591
+
1592
+ const fs = new MemoryFs()
1593
+ await hop(fs, 'zh-CN', 'm6', null, '/round/m6/1')
1594
+ const middle = await hop(fs, 'en', 'm6', '/round/m6/1', '/round/m6/2')
1595
+ assert.equal(middle.language, 'en')
1596
+ assert.deepEqual(middle.interviewQuestions, FACTORY.en.interviewQuestions)
1597
+ const restored = await hop(fs, 'zh-CN', 'm6', '/round/m6/2', '/round/m6/3')
1598
+ assert.equal(restored.language, 'zh-CN')
1599
+ assert.deepEqual(restored.interviewQuestions, FACTORY['zh-CN'].interviewQuestions)
1600
+
1601
+ // The same trip with one entry edited: that entry survives both hops while
1602
+ // every other entry comes home to the zh factory value, slot by slot.
1603
+ const fs2 = new MemoryFs()
1604
+ const userQuestion = '只有这一条是用户改的?'
1605
+ await hop(fs2, 'zh-CN', 'm6b', null, '/round/m6b/1')
1606
+ const seeded = JSON.parse(fs2.read('/round/m6b/1/case.json'))
1607
+ seeded.interviewQuestions[2] = userQuestion
1608
+ fs2.files.set('/round/m6b/1/case.json', `${JSON.stringify(seeded, null, 2)}\n`)
1609
+ const middleB = await hop(fs2, 'en', 'm6b', '/round/m6b/1', '/round/m6b/2')
1610
+ assert.equal(middleB.interviewQuestions[2], userQuestion)
1611
+ assert.deepEqual(middleB.interviewQuestions.slice(0, 2), FACTORY.en.interviewQuestions.slice(0, 2))
1612
+ assert.deepEqual(middleB.interviewQuestions.slice(3), FACTORY.en.interviewQuestions.slice(3))
1613
+ const restoredB = await hop(fs2, 'zh-CN', 'm6b', '/round/m6b/2', '/round/m6b/3')
1614
+ assert.equal(restoredB.interviewQuestions[2], userQuestion, 'the user’s edit outlives both hops')
1615
+ assert.deepEqual(
1616
+ restoredB.interviewQuestions.filter((_, index) => index !== 2),
1617
+ FACTORY['zh-CN'].interviewQuestions.filter((_, index) => index !== 2),
1618
+ 'and every other slot returns to the zh factory value',
1619
+ )
1620
+ })
1621
+
1622
+ test('F1 matrix 7/7 · importing a case into its own language rewrites no case-authored field', async () => {
1623
+ const moved = await crossLanguageImport({ caseId: 'm7', from: 'zh-CN', to: 'zh-CN' })
1624
+ assert.equal(moved.imported.language, 'zh-CN')
1625
+ assert.deepEqual(
1626
+ withoutRecomputed(moved.exported),
1627
+ withoutRecomputed(moved.imported),
1628
+ 'apart from the decision scout_import recomputes, case.json is field-for-field unchanged',
1629
+ )
1630
+ for (const field of ['language', 'title', 'decisionObjective', 'subject', 'role', 'interviewQuestions']) {
1631
+ assert.deepEqual(moved.exported[field], moved.imported[field], `${field} must survive a same-language import verbatim`)
1632
+ }
1633
+ assert.deepEqual(moved.exported.interviewQuestions, FACTORY['zh-CN'].interviewQuestions)
1634
+ assert.equal(moved.exported.decision, moved.imported.decision, 'the recomputed decision agrees with the imported one')
1635
+ })
1636
+
1637
+ /* ------------------------------------------------------------------ *
1638
+ * 11. Explicit host entry points
1639
+ * ------------------------------------------------------------------ */
1640
+
1641
+ test('the /scout command exposes four working sub-actions and the scout Skill carries the evidence disciplines', async () => {
1642
+ const commands = []
1643
+ const skills = []
1644
+ const fs = new MemoryFs()
1645
+ const harness = createHarness()
1646
+
1647
+ // Both services arrive after apply(), exercising the deferred inject path.
1648
+ harness.ctx.mount('commands', { register: definition => { commands.push(definition); return () => {} } })
1649
+ harness.ctx.mount('skills', { register: definition => { skills.push(definition); return () => {} } })
1650
+ harness.ctx.mount('fs', fs)
1651
+
1652
+ assert.equal(commands.length, 1)
1653
+ assert.equal(commands[0].name, 'scout')
1654
+ assert.equal(skills[0].name, 'scout')
1655
+ assert.equal(skills[0].invocation.modelInvocable, true)
1656
+ assert.equal(skills[0].invocation.userInvocable, true)
1657
+ assert.match(skills[0].content, /Evidence discipline/)
1658
+ assert.match(skills[0].content, /scout_verify_identity/)
1659
+ assert.equal((skills[0].content.match(/^\d+\. /gm) ?? []).length >= 4, true)
1660
+
1661
+ const exec = execFor('cmd-session')
1662
+ await call(harness, 'scout_start', { caseId: 'cmd', companyName: 'Command Co', roleTitle: 'HR Head', location: '' }, exec)
1663
+ const invoke = rawInput => commands[0].handler({
1664
+ rawInput,
1665
+ agent: { id: 'cmd-session', session: { header: { cwd: '/session/cwd' } } },
1666
+ })
1667
+
1668
+ assert.match((await invoke('')).text, /^usage: \/scout status/, 'bare /scout prints usage')
1669
+ assert.match((await invoke('help')).text, /^usage: \/scout status/)
1670
+ assert.match((await invoke('status cmd')).text, /^\[cmd\] VERIFY · identity needs_verification/)
1671
+ assert.match((await invoke('questions cmd')).text, /^1\. /)
1672
+ assert.match((await invoke('report cmd')).text, /^# Command Co — HR Head$/m)
1673
+ const exported = await invoke('export cmd')
1674
+ assert.equal(exported.kind, 'success')
1675
+ assert.match(exported.text, /^exported to dsh-scout\/cmd$/)
1676
+ for (const fileName of [...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE]) {
1677
+ assert.ok(fs.has(`dsh-scout/cmd/${fileName}`), `/scout export is missing ${fileName}`)
1678
+ }
1679
+
1680
+ assert.equal((await invoke('bogus cmd')).kind, 'error')
1681
+ assert.match((await invoke('bogus cmd')).text, /Unknown \/scout action: bogus/)
1682
+ assert.equal((await invoke('status missing')).kind, 'error')
1683
+ assert.match((await invoke('status missing')).text, /Unknown case: missing/)
1684
+ })