dsh-scout 0.9.2 → 0.9.4

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.
@@ -5,6 +5,7 @@ import { apply, Config, inject, name } from '../dist/index.js'
5
5
  import {
6
6
  createCase,
7
7
  exportCaseFiles,
8
+ importCaseFromFiles,
8
9
  inferEvidenceLevel,
9
10
  inferSourceType,
10
11
  isTrustedAuthorityUrl,
@@ -141,9 +142,10 @@ test('isolates identical case ids by DSH agent session identity', async () => {
141
142
 
142
143
  const reportA = (await report.execute({ caseId: 'same-id' }, execFor('session-a'))).report
143
144
  const reportB = (await report.execute({ caseId: 'same-id' }, execFor('session-b'))).report
144
- assert.match(reportA, /Company A — Role A/)
145
+ assert.match(reportA, /^# Company A$/m)
146
+ assert.match(reportA, /^> 岗位:Role A/m)
145
147
  assert.doesNotMatch(reportA, /Company B/)
146
- assert.match(reportB, /Company B — Role B/)
148
+ assert.match(reportB, /^# Company B$/m)
147
149
  })
148
150
 
149
151
  /* ------------------------------------------------------------------ *
@@ -210,8 +212,8 @@ test('scout_report stays the only tool that returns the full artifact', async ()
210
212
  assertSchemaConforms(mounted, 'scout_report', value)
211
213
  assert.equal(value.caseId, 'full')
212
214
  assert.equal(value.decision, 'VERIFY')
213
- assert.match(value.report, /# Example Co — HR Head/)
214
- assert.match(value.report, /## Verification checklist/)
215
+ assert.match(value.report, /^# Example Co$/m)
216
+ assert.match(value.report, /## 6\. 距离下一步还差什么/)
215
217
  })
216
218
 
217
219
  /* ------------------------------------------------------------------ *
@@ -327,7 +329,7 @@ test('auto-persist failure is surfaced instead of silently swallowed', async ()
327
329
 
328
330
  // A failed auto-persist must not claim that an export happened.
329
331
  const report = await mounted.tools.get('scout_report').execute({ caseId: 'failing' }, execFor())
330
- assert.match(report.report, /# Example Co — HR Head/)
332
+ assert.match(report.report, /^# Example Co$/m)
331
333
  const exported = await mounted.tools.get('scout_export').execute({
332
334
  caseId: 'failing', targetDir: '/tmp/nope',
333
335
  }, execFor())
@@ -344,7 +346,7 @@ test('auto-persist without a filesystem service degrades gracefully', async () =
344
346
  assert.equal(started.persist.persisted, false)
345
347
  assert.match(started.persist.error, /Filesystem service is unavailable/)
346
348
  const report = await mounted.tools.get('scout_report').execute({ caseId: 'no-fs' }, execFor())
347
- assert.match(report.report, /# Example Co — HR Head/)
349
+ assert.match(report.report, /^# Example Co$/m)
348
350
  })
349
351
 
350
352
  /* ------------------------------------------------------------------ *
@@ -383,7 +385,7 @@ test('a cache miss rehydrates the case from the export directory and records it'
383
385
 
384
386
  const mounted = mountPlugin({ config: { scoutDir: '/tmp/scout-cases' }, services: { fs: mockFs(store) } })
385
387
  const report = await mounted.tools.get('scout_report').execute({ caseId: 'restored' }, execFor())
386
- assert.match(report.report, /# Restored Co — HR Head/)
388
+ assert.match(report.report, /^# Restored Co$/m)
387
389
 
388
390
  const exported = await mounted.tools.get('scout_export').execute({
389
391
  caseId: 'restored', targetDir: '/tmp/out',
@@ -449,9 +451,9 @@ test('scout_ingest batch-registers sources with inference and isolates errors',
449
451
 
450
452
  const report = (await mounted.tools.get('scout_report').execute({ caseId: 'ingest' }, execFor())).report
451
453
  assert.match(report, /gsxt\.gov\.cn/)
452
- assert.match(report, /company_registry \/ E3/)
453
- assert.match(report, /job_posting \/ E2/)
454
- assert.match(report, /other \/ E2/)
454
+ assert.match(report, /工商登记 \| 官方\/权威来源/)
455
+ assert.match(report, /招聘平台 \| 独立来源/)
456
+ assert.match(report, /其他 \| 独立来源/)
455
457
  })
456
458
 
457
459
  test('scout_ingest reuses an already-registered URL instead of duplicating the source', async () => {
@@ -702,7 +704,7 @@ test('scout_report returns per-dimension coverage alongside the rendered report'
702
704
  assert.ok(value.coverage.every(entry => entry.untouched === true))
703
705
  const identity = value.coverage.find(entry => entry.dimension === 'identity')
704
706
  assert.deepEqual(identity, { dimension: 'identity', verified: 0, open: 0, untouched: true })
705
- assert.match(value.report, /## Coverage/)
707
+ assert.match(value.report, /## 附录 C · 调查覆盖度/)
706
708
  })
707
709
 
708
710
  test('the configured language drives the report, the questions and the structured value', async () => {
@@ -712,8 +714,8 @@ test('the configured language drives the report, the questions and the structure
712
714
  }, execFor())
713
715
 
714
716
  const report = (await mounted.tools.get('scout_report').execute({ caseId: 'english' }, execFor())).report
715
- assert.match(report, /- Decision: \*\*VERIFY\*\*/)
716
- assert.match(report, /- Reason: /)
717
+ assert.match(report, /^> Role: [^|]+ \| Location: [^|]+ \| Decision: verify \(VERIFY\)$/m)
718
+ assert.match(report, /## 30-second verdict/)
717
719
  assert.doesNotMatch(report, /判断|理由/)
718
720
 
719
721
  const questions = await mounted.tools.get('scout_questions').execute({ caseId: 'english' }, execFor())
@@ -726,7 +728,7 @@ test('the configured language drives the report, the questions and the structure
726
728
  caseId: 'chinese', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
727
729
  }, execFor())
728
730
  const zhReport = (await chinese.tools.get('scout_report').execute({ caseId: 'chinese' }, execFor())).report
729
- assert.match(zhReport, /- 判断:\*\*VERIFY\*\*/)
731
+ assert.match(zhReport, /^> 岗位:/m)
730
732
 
731
733
  const defaults = new Config({})
732
734
  assert.equal(defaults.language, 'zh-CN')
@@ -886,7 +888,7 @@ test('a language change re-localizes untouched case-authored copy but keeps user
886
888
  assert.equal(imported.complete, true)
887
889
  const report = (await mounted.tools.get('scout_report').execute({ caseId: 'relabel' }, execFor())).report
888
890
  assert.doesNotMatch(report, cjk, 'an en deployment must not render mixed-language case prose')
889
- assert.match(report, /- Objective: Decide whether this role is worth pursuing/)
891
+ assert.match(report, /^> Role: /m)
890
892
  const questions = await mounted.tools.get('scout_questions').execute({ caseId: 'relabel' }, execFor())
891
893
  assert.ok(questions.questions.every(question => !cjk.test(question)), 'interview questions must follow the deployment language')
892
894
 
@@ -900,7 +902,7 @@ test('a language change re-localizes untouched case-authored copy but keeps user
900
902
  // A value the user wrote is not a factory default and must survive untouched.
901
903
  await mounted.tools.get('scout_import').execute({ caseId: 'edited', sourceDir: '/tmp/edited' }, execFor())
902
904
  const edited = (await mounted.tools.get('scout_report').execute({ caseId: 'edited' }, execFor())).report
903
- assert.match(edited, /Prüfen, ob diese Rolle passt\./)
905
+ assert.match(edited, /> (目标:|Objective: )Prüfen, ob diese Rolle passt\./)
904
906
  })
905
907
 
906
908
  /* ------------------------------------------------------------------ *
@@ -972,3 +974,367 @@ test('an absent optional service only skips that registration', async () => {
972
974
  // still loads (the real binding leaves that child fiber pending).
973
975
  assert.equal(skills.length, 1)
974
976
  })
977
+
978
+ // ---------------------------------------------------------------------------
979
+ // 0.9.4 regression coverage: host sandbox seam, source upgrades, ingest dedupe,
980
+ // identity tiers, start guard, and input validation.
981
+ // ---------------------------------------------------------------------------
982
+
983
+ const startArgs = (caseId, overrides = {}) => ({
984
+ caseId, companyName: 'Example Co', roleTitle: 'HR Head', location: '', ...overrides,
985
+ })
986
+ const sessionExec = (cwd = '/workspace/desk') => ({
987
+ agent: { id: 'session-a', session: { header: { cwd } } },
988
+ })
989
+
990
+ test('export forwards the session sandbox policy to a confined filesystem backend', async () => {
991
+ const workspace = '/workspace/desk'
992
+ const writes = []
993
+ const confinedFs = {
994
+ async resolve(path) {
995
+ return { targetKey: path, displayPath: path }
996
+ },
997
+ async readText() {
998
+ throw new Error('not found')
999
+ },
1000
+ async writeText(target, content, intent, signal, sandboxPolicy) {
1001
+ // Mirrors @deepseek-ai/dsh-fs-sandbox: a missing fifth argument means
1002
+ // "deployment-default root", which denies the session workspace.
1003
+ if (!sandboxPolicy) {
1004
+ throw new Error(`cannot write "${target.displayPath}": file access denied under workspace-write mode`)
1005
+ }
1006
+ if (!target.displayPath.startsWith(sandboxPolicy.workspaceRoot)) {
1007
+ throw new Error(`cannot write "${target.displayPath}": file access denied under workspace-write mode`)
1008
+ }
1009
+ writes.push({ path: target.displayPath, policy: sandboxPolicy })
1010
+ },
1011
+ }
1012
+ const sandboxPolicy = {
1013
+ resolve(request) {
1014
+ return {
1015
+ mode: 'workspace-write',
1016
+ workspaceRoot: request?.session?.header?.cwd ?? '/deployment-default',
1017
+ ...(request?.session ? { sessionId: 'session-a' } : {}),
1018
+ }
1019
+ },
1020
+ }
1021
+ const mounted = mountPlugin({ services: { fs: confinedFs, sandboxPolicy } })
1022
+ const exec = sessionExec(workspace)
1023
+ await mounted.tools.get('scout_start').execute(startArgs('sandbox'), exec)
1024
+ const exported = await mounted.tools.get('scout_export').execute({
1025
+ caseId: 'sandbox',
1026
+ targetDir: `${workspace}/cases/sandbox`,
1027
+ }, exec)
1028
+ assert.equal(exported.persisted, true)
1029
+ assert.equal(writes.length, 7, 'marker false + five payload files + marker true')
1030
+ assert.ok(writes.every(item => item.policy.workspaceRoot === workspace), 'the session workspace root is forwarded')
1031
+ })
1032
+
1033
+ test('a confinement refusal carries an actionable hint instead of the raw mode text only', async () => {
1034
+ const mounted = mountPlugin({
1035
+ services: {
1036
+ fs: {
1037
+ async resolve(path) {
1038
+ return { targetKey: path, displayPath: path }
1039
+ },
1040
+ async readText() {
1041
+ throw new Error('not found')
1042
+ },
1043
+ async writeText() {
1044
+ throw new Error('cannot write "/x": file access denied under workspace-write mode')
1045
+ },
1046
+ },
1047
+ },
1048
+ })
1049
+ const exec = sessionExec('/workspace/desk')
1050
+ await mounted.tools.get('scout_start').execute(startArgs('denied'), exec)
1051
+ const exported = await mounted.tools.get('scout_export').execute({
1052
+ caseId: 'denied',
1053
+ targetDir: '/workspace/desk/cases/denied',
1054
+ }, exec)
1055
+ assert.equal(exported.persisted, false)
1056
+ assert.match(exported.error, /sandbox policy/)
1057
+ assert.match(exported.error, /writable path/)
1058
+ })
1059
+
1060
+ test('re-ingesting a URL upgrades its metadata instead of silently dropping it', async () => {
1061
+ const mounted = mountPlugin({ services: { fs: mockFs() } })
1062
+ const exec = execFor()
1063
+ await mounted.tools.get('scout_start').execute(startArgs('upgrade'), exec)
1064
+ // First pass: the same official URL was classified as a generic page.
1065
+ await mounted.tools.get('scout_ingest').execute({
1066
+ caseId: 'upgrade',
1067
+ itemsJson: JSON.stringify([{
1068
+ url: 'https://www.gsxt.gov.cn/registry/upgrade-1',
1069
+ title: 'Registry record',
1070
+ sourceType: 'other',
1071
+ evidenceLevel: 'E2',
1072
+ }]),
1073
+ }, exec)
1074
+ // Second pass states the authoritative category + level for the same URL.
1075
+ const upgraded = await mounted.tools.get('scout_ingest').execute({
1076
+ caseId: 'upgrade',
1077
+ itemsJson: JSON.stringify([{
1078
+ url: 'https://www.gsxt.gov.cn/registry/upgrade-1',
1079
+ title: 'Registry record',
1080
+ sourceType: 'company_registry',
1081
+ evidenceLevel: 'E3',
1082
+ }]),
1083
+ }, exec)
1084
+ assert.equal(upgraded.counts.sources, 1, 'the URL is reused, not duplicated')
1085
+ const events = mounted.ctx.fs ? [] : []
1086
+ const report = await mounted.tools.get('scout_report').execute({ caseId: 'upgrade' }, exec)
1087
+ assert.match(report.report, /工商登记 \| 官方\/权威来源/)
1088
+ })
1089
+
1090
+ test('an E3 upgrade still has to satisfy the authority policy', async () => {
1091
+ const mounted = mountPlugin({ services: { fs: mockFs() } })
1092
+ const exec = execFor()
1093
+ await mounted.tools.get('scout_start').execute(startArgs('upgrade-denied'), exec)
1094
+ await mounted.tools.get('scout_ingest').execute({
1095
+ caseId: 'upgrade-denied',
1096
+ itemsJson: JSON.stringify([{ url: 'https://example.com/page', sourceType: 'other', evidenceLevel: 'E2' }]),
1097
+ }, exec)
1098
+ const refused = await mounted.tools.get('scout_ingest').execute({
1099
+ caseId: 'upgrade-denied',
1100
+ itemsJson: JSON.stringify([{
1101
+ url: 'https://example.com/page', sourceType: 'company_registry', evidenceLevel: 'E3',
1102
+ }]),
1103
+ }, exec)
1104
+ assert.match(refused.errors[0].error, /E3 sources require a supported registry/)
1105
+ })
1106
+
1107
+ test('re-running the same ingest batch does not duplicate claims', async () => {
1108
+ const mounted = mountPlugin({ services: { fs: mockFs() } })
1109
+ const exec = execFor()
1110
+ await mounted.tools.get('scout_start').execute(startArgs('dedupe'), exec)
1111
+ const batch = JSON.stringify([{
1112
+ url: 'https://www.qixin.com/brand/dedupe',
1113
+ title: 'Aggregator page',
1114
+ claim: { text: 'The company was founded in 2021.', dimension: 'company_and_founder' },
1115
+ }])
1116
+ const first = await mounted.tools.get('scout_ingest').execute({ caseId: 'dedupe', itemsJson: batch }, exec)
1117
+ const second = await mounted.tools.get('scout_ingest').execute({ caseId: 'dedupe', itemsJson: batch }, exec)
1118
+ assert.equal(first.counts.claims, 1)
1119
+ assert.equal(second.counts.claims, 1, 'the ledger does not double on a retried batch')
1120
+ })
1121
+
1122
+ test('a new case still tells the model what to do next', async () => {
1123
+ const mounted = mountPlugin({ services: { fs: mockFs() } })
1124
+ const started = await mounted.tools.get('scout_start').execute(startArgs('next-actions'), execFor())
1125
+ assert.ok(started.nextActions.length >= 1)
1126
+ assert.match(started.nextActions.join(' '), /scout_search|scout_ingest/)
1127
+ const rendered = renderedText(renderOutput(mounted, 'scout_start', started))
1128
+ assert.match(rendered, /^next: /m)
1129
+ })
1130
+
1131
+ test('start refuses to overwrite a completed export unless overwrite is explicit', async () => {
1132
+ const store = new Map()
1133
+ const first = mountPlugin({ services: { fs: mockFs(store) } })
1134
+ const exec = execFor()
1135
+ await first.tools.get('scout_start').execute(startArgs('guarded'), exec)
1136
+ await first.tools.get('scout_export').execute({ caseId: 'guarded' }, exec)
1137
+
1138
+ const second = mountPlugin({ services: { fs: mockFs(store) } })
1139
+ await assert.rejects(
1140
+ () => second.tools.get('scout_start').execute(startArgs('guarded'), exec),
1141
+ /completed export already exists/,
1142
+ )
1143
+ const replaced = await second.tools.get('scout_start').execute(startArgs('guarded', { overwrite: true }), exec)
1144
+ assert.equal(replaced.ok, true)
1145
+ })
1146
+
1147
+ test('start validates the case id and required labels', async () => {
1148
+ const mounted = mountPlugin({ services: { fs: mockFs() } })
1149
+ const exec = execFor()
1150
+ await assert.rejects(
1151
+ () => mounted.tools.get('scout_start').execute(startArgs('../escape/x'), exec),
1152
+ /Invalid caseId/,
1153
+ )
1154
+ await assert.rejects(
1155
+ () => mounted.tools.get('scout_start').execute(startArgs('blank-company', { companyName: ' ' }), exec),
1156
+ /companyName must not be blank/,
1157
+ )
1158
+ await assert.rejects(
1159
+ () => mounted.tools.get('scout_start').execute(startArgs('blank-role', { roleTitle: '' }), exec),
1160
+ /roleTitle must not be blank/,
1161
+ )
1162
+ })
1163
+
1164
+ test('identity verification reaches a labelled tier and records per-field provenance', async () => {
1165
+ const mounted = mountPlugin({ services: { fs: mockFs() } })
1166
+ const exec = execFor()
1167
+ await mounted.tools.get('scout_start').execute(startArgs('tier'), exec)
1168
+ await mounted.tools.get('scout_ingest').execute({
1169
+ caseId: 'tier',
1170
+ itemsJson: JSON.stringify([
1171
+ { url: 'https://www.qixin.com/brand/tier-1', title: 'Registry aggregator A', sourceType: 'public_database', evidenceLevel: 'E2' },
1172
+ { url: 'https://apps.apple.com/cn/app/tier/id1', title: 'App Store listing', sourceType: 'company_official', evidenceLevel: 'E2' },
1173
+ { url: 'https://www.szns.gov.cn/news/tier-1', title: 'Government event report', sourceType: 'regulator', evidenceLevel: 'E3' },
1174
+ ]),
1175
+ }, exec)
1176
+
1177
+ // A government page that only mentions the company cannot verify the
1178
+ // registration number: with field provenance the tier is corroborated.
1179
+ const corroborated = await mounted.tools.get('scout_verify_identity').execute({
1180
+ caseId: 'tier',
1181
+ legalEntity: 'Example Co Ltd',
1182
+ registrationNumber: '91440300TEST000001',
1183
+ registeredRegion: 'Shenzhen',
1184
+ legalRepresentative: 'Example Person',
1185
+ brandRelationship: 'Operating entity',
1186
+ sourceIds: 'src-1,src-2,src-3',
1187
+ fieldSources: JSON.stringify({
1188
+ legalEntity: 'src-1,src-3',
1189
+ registrationNumber: 'src-1',
1190
+ registeredRegion: 'src-1',
1191
+ legalRepresentative: 'src-1',
1192
+ brandRelationship: 'src-2',
1193
+ }),
1194
+ }, exec)
1195
+ assert.equal(corroborated.identity, 'corroborated')
1196
+ const report = await mounted.tools.get('scout_report').execute({ caseId: 'tier' }, exec)
1197
+ assert.match(report.report, /## 1\. 公司与主体/)
1198
+ assert.match(report.report, /已交叉核验/)
1199
+ assert.match(report.report, /统一社会信用代码=src-1/)
1200
+ assert.match(report.report, /已交叉核验:多个独立来源/)
1201
+
1202
+ // A second independent E2 source can support the registration number, but
1203
+ // only an E3 registration source upgrades the tier to verified.
1204
+ await mounted.tools.get('scout_ingest').execute({
1205
+ caseId: 'tier',
1206
+ itemsJson: JSON.stringify([{
1207
+ url: 'https://www.gsxt.gov.cn/registry/tier-2', title: 'Official registry record',
1208
+ sourceType: 'company_registry', evidenceLevel: 'E3',
1209
+ }]),
1210
+ }, exec)
1211
+ const verified = await mounted.tools.get('scout_verify_identity').execute({
1212
+ caseId: 'tier',
1213
+ legalEntity: 'Example Co Ltd',
1214
+ registrationNumber: '91440300TEST000001',
1215
+ registeredRegion: 'Shenzhen',
1216
+ legalRepresentative: 'Example Person',
1217
+ brandRelationship: 'Operating entity',
1218
+ sourceIds: 'src-1,src-2,src-3,src-4',
1219
+ fieldSources: JSON.stringify({
1220
+ legalEntity: 'src-1,src-3',
1221
+ registrationNumber: 'src-4',
1222
+ registeredRegion: 'src-4',
1223
+ legalRepresentative: 'src-1',
1224
+ brandRelationship: 'src-2',
1225
+ }),
1226
+ }, exec)
1227
+ assert.equal(verified.identity, 'verified')
1228
+ })
1229
+
1230
+ test('import rejects invalid evidence enums instead of casting them through', () => {
1231
+ const files = exportCaseFiles(createCase({ caseId: 'import-enum', companyName: 'Example Co', roleTitle: 'HR Head' }))
1232
+ const sources = JSON.parse(files['sources.json'])
1233
+ sources.push({ sourceId: 'bad', type: 'other', title: 'bad', url: null, capturedAt: null, evidenceLevel: 'E9', status: 'captured' })
1234
+ assert.throws(
1235
+ () => importCaseFromFiles({ ...files, 'sources.json': JSON.stringify(sources) }),
1236
+ /Invalid evidence level/,
1237
+ )
1238
+ })
1239
+
1240
+ test('scout_search summarises new results by title and reuses them silently on repeat', async () => {
1241
+ const web = {
1242
+ async search() {
1243
+ return {
1244
+ sources: [
1245
+ { url: 'https://www.qixin.com/brand/render-1', title: '启信宝页面' },
1246
+ { url: 'https://apps.apple.com/cn/app/render/id1', title: 'App Store 页面' },
1247
+ ],
1248
+ truncated: false,
1249
+ }
1250
+ },
1251
+ }
1252
+ const mounted = mountPlugin({ services: { fs: mockFs(), web } })
1253
+ const exec = execFor()
1254
+ await mounted.tools.get('scout_start').execute(startArgs('search-render'), exec)
1255
+ const first = await mounted.tools.get('scout_search').execute({
1256
+ caseId: 'search-render', query: 'render', limit: 5,
1257
+ }, exec)
1258
+ const firstText = renderedText(renderOutput(mounted, 'scout_search', first))
1259
+ assert.match(firstText, /new: 启信宝页面 · (www\.)?qixin\.com/)
1260
+ assert.match(firstText, /App Store 页面 · apps\.apple\.com/)
1261
+ assert.equal(first.counts.sources, 2)
1262
+
1263
+ const second = await mounted.tools.get('scout_search').execute({
1264
+ caseId: 'search-render', query: 'render', limit: 5,
1265
+ }, exec)
1266
+ assert.equal(second.counts.sources, 2, 'the same URLs are reused')
1267
+ assert.doesNotMatch(renderedText(renderOutput(mounted, 'scout_search', second)), /^new: /m)
1268
+ })
1269
+
1270
+ test('the /scout export command honours targetDir and cold-start commands rehydrate', async () => {
1271
+ const store = new Map()
1272
+ const registrations = []
1273
+ const mountWithCommands = () => mountPlugin({
1274
+ config: { scoutDir: '/tmp/cmd-scout' },
1275
+ services: {
1276
+ fs: mockFs(store),
1277
+ commands: {
1278
+ register(definition) {
1279
+ registrations.push(definition)
1280
+ return () => undefined
1281
+ },
1282
+ },
1283
+ },
1284
+ })
1285
+ const agent = { id: 'session-a', session: { header: { cwd: '/workspace' } } }
1286
+ const exec = { agent }
1287
+
1288
+ const first = mountWithCommands()
1289
+ await first.tools.get('scout_start').execute(startArgs('cmd-export'), exec)
1290
+ const handlerOf = () => registrations[registrations.length - 1].handler
1291
+ const exported = await handlerOf()({ rawInput: 'export cmd-export /tmp/cmd-out', agent })
1292
+ assert.equal(exported.kind, 'success')
1293
+ assert.match(exported.text, /exported to \/tmp\/cmd-out/)
1294
+ assert.ok(store.has('/tmp/cmd-out/export.complete.json'), 'targetDir token is honoured')
1295
+
1296
+ // A second case exported with the default scoutDir must come back from disk
1297
+ // on a cold start; the command path used to answer "Unknown case".
1298
+ await first.tools.get('scout_start').execute(startArgs('cmd-cold'), exec)
1299
+ const defaultExport = await handlerOf()({ rawInput: 'export cmd-cold', agent })
1300
+ assert.equal(defaultExport.kind, 'success')
1301
+ assert.match(defaultExport.text, /exported to \/tmp\/cmd-scout\/cmd-cold/)
1302
+
1303
+ registrations.length = 0
1304
+ mountWithCommands()
1305
+ const status = await handlerOf()({ rawInput: 'status cmd-cold', agent })
1306
+ assert.equal(status.kind, 'success')
1307
+ assert.match(status.text, /identity needs_verification/)
1308
+ assert.match(status.text, /· dsh-scout /)
1309
+ })
1310
+
1311
+ test('scout_add_source rejects a URL already registered under another id', async () => {
1312
+ const mounted = mountPlugin({ services: { fs: mockFs() } })
1313
+ const exec = execFor()
1314
+ await mounted.tools.get('scout_start').execute(startArgs('dupe-url'), exec)
1315
+ const addSource = mounted.tools.get('scout_add_source')
1316
+ await addSource.execute({
1317
+ caseId: 'dupe-url', sourceId: 'a', sourceType: 'other', title: 'A',
1318
+ url: 'https://example.com/x', evidenceLevel: 'E2',
1319
+ }, exec)
1320
+ await assert.rejects(
1321
+ () => addSource.execute({
1322
+ caseId: 'dupe-url', sourceId: 'b', sourceType: 'public_database', title: 'B',
1323
+ url: 'https://example.com/x?utm_source=somewhere', evidenceLevel: 'E2',
1324
+ }, exec),
1325
+ /URL already registered as a/,
1326
+ )
1327
+ })
1328
+
1329
+ test('import rejects invalid claim enums', () => {
1330
+ const files = exportCaseFiles(createCase({ caseId: 'import-claim-enum', companyName: 'Example Co', roleTitle: 'HR Head' }))
1331
+ const claims = JSON.parse(files['claims.json'])
1332
+ claims.push({
1333
+ claimId: 'bad', text: 'Bad claim', status: 'not-a-status', evidenceLevel: 'E2',
1334
+ dimension: 'risk', impact: 'material', sourceIds: [], confidenceNote: 'x', nextAction: 'y',
1335
+ })
1336
+ assert.throws(
1337
+ () => importCaseFromFiles({ ...files, 'claims.json': JSON.stringify(claims) }),
1338
+ /Invalid claim status/,
1339
+ )
1340
+ })