dsh-scout 0.9.3 → 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.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import z from '@deepseek-ai/schemastery';
3
- import { addClaim, addSource, appendEvent, CLAIM_DIMENSIONS, CLAIM_IMPACTS, CLAIM_STATUSES, computeCoverage, createCase, decideCase, DEFAULT_AUTHORITY_HOST_SUFFIXES, EVIDENCE_LEVELS, EXPORT_FILE_NAMES, exportCaseFiles, findSourceByUrl, generateInterviewQuestions, importCaseFromFiles, inferEvidenceLevel, inferSourceType, renderComparison, renderReport, SOURCE_TYPES, verifyClaim, verifyIdentity, } from './model.js';
3
+ import { addClaim, addSource, appendEvent, CLAIM_DIMENSIONS, CLAIM_IMPACTS, CLAIM_STATUSES, computeCoverage, createCase, decideCase, DEFAULT_AUTHORITY_HOST_SUFFIXES, EVIDENCE_LEVELS, EXPORT_FILE_NAMES, exportCaseFiles, findSourceByUrl, generateInterviewQuestions, IDENTITY_FIELDS, importCaseFromFiles, inferEvidenceLevel, inferSourceType, isTrustedAuthorityUrl, renderComparison, renderReport, SOURCE_TYPES, verifyClaim, verifyIdentity, } from './model.js';
4
4
  import { formatScoutString, isScoutLanguage, normalizeScoutLanguage, scoutStrings, } from './i18n.js';
5
5
  export const name = 'dsh-scout';
6
6
  export const inject = ['tools'];
@@ -20,7 +20,7 @@ export const inject = ['tools'];
20
20
  * to load never runs at all, so "is it loaded, and which version?" is answered
21
21
  * by the settings plugin list instead.
22
22
  */
23
- export const PLUGIN_VERSION = '0.9.3';
23
+ export const PLUGIN_VERSION = '0.9.4';
24
24
  export const Config = z.object({
25
25
  scoutDir: z.string().default('dsh-scout'),
26
26
  autoPersist: z.boolean().default(false),
@@ -28,7 +28,16 @@ export const Config = z.object({
28
28
  language: z.union([z.const('zh-CN'), z.const('en')]).default('zh-CN'),
29
29
  });
30
30
  const messageOf = (error) => error instanceof Error ? error.message : String(error);
31
+ /**
32
+ * A confinement refusal is the one export failure whose cause is not the
33
+ * caller's input. Say what to change instead of echoing the sandbox mode text.
34
+ */
35
+ const WRITE_DENIED_HINT = ' — the confined filesystem rejected this write: keep scoutDir/targetDir under an absolute writable path'
36
+ + ' (session workspace or platform temp) and use a plugin build that forwards the per-call sandbox policy';
37
+ const withWriteHint = (message) => /FS_SANDBOX_DENIED|file access denied under/.test(message) ? `${message}${WRITE_DENIED_HINT}` : message;
31
38
  const IMPACT_RANK = { blocking: 0, material: 1, informational: 2 };
39
+ const EVIDENCE_RANK = { E0: 0, E1: 1, E2: 2, E3: 3 };
40
+ const IDENTITY_SOURCE_TYPE_SET = new Set(['company_registry', 'regulator', 'official_filing']);
32
41
  /** Up to three smallest next actions for open blocking/material claims. */
33
42
  const deriveNextActions = (scoutCase) => [...scoutCase.claims]
34
43
  .filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')
@@ -37,25 +46,6 @@ const deriveNextActions = (scoutCase) => [...scoutCase.claims]
37
46
  .filter((action, index, all) => Boolean(action) && all.indexOf(action) === index)
38
47
  .slice(0, 3);
39
48
  const capIds = (ids, limit = 20) => ids.length > limit ? [...ids.slice(0, limit), `+${ids.length - limit} more`] : ids;
40
- const buildEnvelope = (scoutCase, changed, extras = {}) => {
41
- const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
42
- return {
43
- ok: true,
44
- caseId: scoutCase.caseId,
45
- decision: scoutCase.decision,
46
- identity: scoutCase.subject.identityStatus,
47
- counts: {
48
- sources: scoutCase.sources.length,
49
- claims: scoutCase.claims.length,
50
- verified,
51
- open: scoutCase.claims.length - verified,
52
- },
53
- changed: { kind: changed.kind, ids: capIds(changed.ids) },
54
- nextActions: deriveNextActions(scoutCase),
55
- ...(extras.persist ? { persist: extras.persist } : {}),
56
- ...(extras.errors?.length ? { errors: extras.errors } : {}),
57
- };
58
- };
59
49
  /** Model-facing render budget for one mutation summary, in UTF-8 bytes. */
60
50
  const MAX_RENDER_BYTES = 1024;
61
51
  /** Marks a clipped render. Its own byte cost is paid out of the same budget. */
@@ -89,10 +79,27 @@ const clampUtf8 = (text, limit) => {
89
79
  const clipField = (text, limit) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
90
80
  const formatEnvelope = (value) => {
91
81
  const { counts, changed } = value;
82
+ const sourceIds = changed.ids.filter(id => id.startsWith('src-'));
83
+ const claimIds = changed.ids.filter(id => id.startsWith('claim-'));
84
+ const otherIds = changed.ids.filter(id => !id.startsWith('src-') && !id.startsWith('claim-'));
85
+ let changedLine;
86
+ if (sourceIds.length && claimIds.length) {
87
+ const listed = [...sourceIds, ...claimIds].slice(0, 6).join(', ');
88
+ changedLine = `changed: ${changed.kind} (+${sourceIds.length} sources, +${claimIds.length} claims: ${clipField(listed, 160)})`;
89
+ }
90
+ else {
91
+ changedLine = `changed: ${changed.kind}${changed.ids.length ? ` (${clipField(changed.ids.join(', '), 200)})` : ''}`;
92
+ }
92
93
  const lines = [
93
94
  `[${clipField(value.caseId, 80)}] ${value.decision} · identity ${value.identity} · sources ${counts.sources} / claims ${counts.claims} (verified ${counts.verified}, open ${counts.open})`,
94
- `changed: ${changed.kind}${changed.ids.length ? ` (${clipField(changed.ids.join(', '), 200)})` : ''}`,
95
+ changedLine,
95
96
  ];
97
+ if (changed.labels?.length) {
98
+ lines.push(`new: ${changed.labels.map(label => clipField(label, 80)).join(' · ')}`);
99
+ }
100
+ if (otherIds.length && (sourceIds.length || claimIds.length)) {
101
+ lines.push(`other: ${clipField(otherIds.join(', '), 120)}`);
102
+ }
96
103
  if (value.nextActions.length) {
97
104
  lines.push(`next: ${value.nextActions.map(action => clipField(action, 140)).join(' | ')}`);
98
105
  }
@@ -127,7 +134,7 @@ const mutationSchema = () => ({
127
134
  },
128
135
  identity: {
129
136
  type: 'string',
130
- enum: ['verified', 'needs_verification'],
137
+ enum: ['verified', 'corroborated', 'needs_verification'],
131
138
  required: true,
132
139
  },
133
140
  counts: {
@@ -151,6 +158,7 @@ const mutationSchema = () => ({
151
158
  enum: [
152
159
  'case_started',
153
160
  'source_added',
161
+ 'source_updated',
154
162
  'sources_added',
155
163
  'claim_added',
156
164
  'claims_added',
@@ -162,6 +170,7 @@ const mutationSchema = () => ({
162
170
  required: true,
163
171
  },
164
172
  ids: { type: 'array', items: { type: 'string' }, required: true },
173
+ labels: { type: 'array', items: { type: 'string' } },
165
174
  },
166
175
  },
167
176
  nextActions: { type: 'array', items: { type: 'string' }, required: true },
@@ -250,7 +259,106 @@ export function apply(ctx, config = {}) {
250
259
  // Resolved per call so a later-mounted (or replaced) provider is honored.
251
260
  const fsService = () => ctx.get('fs');
252
261
  const webService = () => ctx.get('web');
262
+ const sandboxPolicyService = () => ctx.get('sandboxPolicy');
253
263
  const execCwd = (exec) => exec?.agent?.session?.header?.cwd;
264
+ /**
265
+ * Resolve the caller session's sandbox policy for one filesystem write.
266
+ *
267
+ * A confined `fs` backend needs this as writeText's fifth argument; without
268
+ * it the write is checked against the deployment-default workspace root
269
+ * instead of the session's workspace, which denies every workspace export in
270
+ * the desktop app. Deployments without a sandbox policy (plain `fs-local`)
271
+ * simply pass `undefined` and behave as before.
272
+ */
273
+ const writePolicyFor = (exec) => {
274
+ const service = sandboxPolicyService();
275
+ if (!service)
276
+ return undefined;
277
+ try {
278
+ const session = exec?.agent?.session;
279
+ return service.resolve(session ? { session } : {});
280
+ }
281
+ catch {
282
+ return undefined;
283
+ }
284
+ };
285
+ const REQUIRED_DIMENSIONS_FOR_NEXT = ['role_existence', 'reporting_line', 'mandate'];
286
+ /** Parse the optional identity field→source-id provenance map. */
287
+ const parseIdentityFieldSources = (raw) => {
288
+ if (typeof raw !== 'string' || !raw.trim())
289
+ return undefined;
290
+ let parsed;
291
+ try {
292
+ parsed = JSON.parse(raw);
293
+ }
294
+ catch (error) {
295
+ throw new Error(`Invalid fieldSources JSON: ${messageOf(error)}`);
296
+ }
297
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
298
+ throw new Error('fieldSources must be a JSON object mapping identity fields to source ids');
299
+ }
300
+ const record = parsed;
301
+ const result = {};
302
+ for (const field of IDENTITY_FIELDS) {
303
+ const value = record[field];
304
+ if (value === undefined || value === null)
305
+ continue;
306
+ const values = Array.isArray(value)
307
+ ? value.filter((item) => typeof item === 'string')
308
+ : typeof value === 'string'
309
+ ? value.split(',')
310
+ : [];
311
+ const ids = values.map(item => item.trim()).filter(Boolean);
312
+ if (ids.length)
313
+ result[field] = ids;
314
+ }
315
+ return Object.keys(result).length ? result : undefined;
316
+ };
317
+ /**
318
+ * Next actions for the compact summary: claim-derived actions first, then a
319
+ * state-derived fallback so a brand-new or source-only case still tells the
320
+ * caller what to do next instead of printing no `next:` line at all.
321
+ */
322
+ const nextActionsFor = (scoutCase) => {
323
+ const fromClaims = deriveNextActions(scoutCase);
324
+ if (fromClaims.length)
325
+ return fromClaims;
326
+ if (scoutCase.subject.identityStatus === 'needs_verification') {
327
+ return [scoutCase.sources.length ? strings.nextActions.verifyIdentity : strings.nextActions.collectSources];
328
+ }
329
+ const missing = REQUIRED_DIMENSIONS_FOR_NEXT.find(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
330
+ if (missing) {
331
+ return [formatScoutString(strings.nextActions.missingDimension, {
332
+ dimension: strings.dimensions[missing],
333
+ })];
334
+ }
335
+ return scoutCase.claims.some(claim => claim.impact !== 'informational' && claim.status !== 'verified')
336
+ ? []
337
+ : [strings.nextActions.conclude];
338
+ };
339
+ const buildEnvelope = (scoutCase, changed, extras = {}) => {
340
+ const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
341
+ return {
342
+ ok: true,
343
+ caseId: scoutCase.caseId,
344
+ decision: scoutCase.decision,
345
+ identity: scoutCase.subject.identityStatus,
346
+ counts: {
347
+ sources: scoutCase.sources.length,
348
+ claims: scoutCase.claims.length,
349
+ verified,
350
+ open: scoutCase.claims.length - verified,
351
+ },
352
+ changed: {
353
+ kind: changed.kind,
354
+ ids: capIds(changed.ids),
355
+ ...(changed.labels?.length ? { labels: changed.labels.slice(0, 3) } : {}),
356
+ },
357
+ nextActions: nextActionsFor(scoutCase),
358
+ ...(extras.persist ? { persist: extras.persist } : {}),
359
+ ...(extras.errors?.length ? { errors: extras.errors } : {}),
360
+ };
361
+ };
254
362
  const recordEvent = (scoutCase, type, detail) => {
255
363
  const events = scoutCase.events ?? [];
256
364
  let n = events.length + 1;
@@ -274,6 +382,49 @@ export function apply(ctx, config = {}) {
274
382
  evidenceLevel: source.evidenceLevel,
275
383
  ingested: true,
276
384
  });
385
+ /**
386
+ * Upgrade an already-registered source when the caller now states stronger
387
+ * metadata. Re-registering the same URL used to silently drop the explicit
388
+ * sourceType/evidenceLevel, which made "start with E2 aggregators, later get
389
+ * the E3 official page" impossible. Only ever moves metadata upward unless an
390
+ * explicit different type is asserted; an E3 upgrade still has to satisfy the
391
+ * authority policy, so this cannot be used to bypass it.
392
+ */
393
+ const upgradeExistingSource = (scoutCase, existing, input) => {
394
+ const next = { ...existing };
395
+ let updated = false;
396
+ if (input.explicitLevel && EVIDENCE_RANK[input.evidenceLevel] > EVIDENCE_RANK[existing.evidenceLevel]) {
397
+ next.evidenceLevel = input.evidenceLevel;
398
+ updated = true;
399
+ }
400
+ if (input.explicitType && input.type !== existing.type) {
401
+ next.type = input.type;
402
+ updated = true;
403
+ }
404
+ if (input.explicitTitle && input.title && input.title !== existing.title) {
405
+ next.title = input.title;
406
+ updated = true;
407
+ }
408
+ if (!updated)
409
+ return { scoutCase, source: existing, updated: false };
410
+ if (next.evidenceLevel === 'E3'
411
+ && (!IDENTITY_SOURCE_TYPE_SET.has(next.type) || !isTrustedAuthorityUrl(next.url, authoritySuffixes))) {
412
+ throw new Error('E3 sources require a supported registry, regulator, or official filing HTTPS origin');
413
+ }
414
+ const withSource = {
415
+ ...scoutCase,
416
+ sources: scoutCase.sources.map(item => (item.sourceId === existing.sourceId ? next : item)),
417
+ };
418
+ return {
419
+ scoutCase: recordEvent(withSource, 'source_updated', {
420
+ sourceId: existing.sourceId,
421
+ from: { type: existing.type, evidenceLevel: existing.evidenceLevel, title: existing.title },
422
+ to: { type: next.type, evidenceLevel: next.evidenceLevel, title: next.title },
423
+ }),
424
+ source: next,
425
+ updated: true,
426
+ };
427
+ };
277
428
  /* -------------------------------------------------------------- *
278
429
  * Durable export
279
430
  *
@@ -295,7 +446,7 @@ export function apply(ctx, config = {}) {
295
446
  * re-asserted after every payload file landed, so an interrupted export can
296
447
  * never leave a stale `complete: true` beside mixed-generation files.
297
448
  */
298
- const persistFiles = async (scoutCase, targetDir, fs, cwd, signal) => {
449
+ const persistFiles = async (scoutCase, targetDir, fs, cwd, signal, sandboxPolicy = undefined) => {
299
450
  try {
300
451
  const files = exportCaseFiles(scoutCase, { language });
301
452
  const marker = (complete) => `${JSON.stringify({
@@ -310,7 +461,7 @@ export function apply(ctx, config = {}) {
310
461
  const target = await fs.resolve(`${targetDir}/${fileName}`, { cwd, signal });
311
462
  if (typeof target?.displayPath === 'string')
312
463
  absoluteDir = directoryOf(target.displayPath);
313
- await fs.writeText(target, content, undefined, signal);
464
+ await fs.writeText(target, content, undefined, signal, sandboxPolicy);
314
465
  };
315
466
  await write(EXPORT_MARKER_FILE, marker(false));
316
467
  for (const fileName of EXPORT_FILE_NAMES) {
@@ -320,7 +471,7 @@ export function apply(ctx, config = {}) {
320
471
  return { persisted: true, targetDir: absoluteDir ?? targetDir };
321
472
  }
322
473
  catch (error) {
323
- return { persisted: false, targetDir, error: messageOf(error) };
474
+ return { persisted: false, targetDir, error: withWriteHint(messageOf(error)) };
324
475
  }
325
476
  };
326
477
  /**
@@ -328,9 +479,9 @@ export function apply(ctx, config = {}) {
328
479
  * the `case_exported` event that the written `events.jsonl` also has; on
329
480
  * failure the event is dropped and the error is surfaced to the caller.
330
481
  */
331
- const exportCase = async (scoutCase, targetDir, fs, cwd, signal, auto) => {
482
+ const exportCase = async (scoutCase, targetDir, fs, cwd, signal, auto, sandboxPolicy = undefined) => {
332
483
  const withEvent = recordEvent(scoutCase, 'case_exported', { targetDir, auto });
333
- const persist = await persistFiles(withEvent, targetDir, fs, cwd, signal);
484
+ const persist = await persistFiles(withEvent, targetDir, fs, cwd, signal, sandboxPolicy);
334
485
  return { scoutCase: persist.persisted ? withEvent : scoutCase, persist };
335
486
  };
336
487
  const maybePersist = async (scoutCase, exec) => {
@@ -347,7 +498,7 @@ export function apply(ctx, config = {}) {
347
498
  },
348
499
  };
349
500
  }
350
- return exportCase(scoutCase, caseExportDir(scoutCase.caseId), fs, execCwd(exec), exec?.signal, true);
501
+ return exportCase(scoutCase, caseExportDir(scoutCase.caseId), fs, execCwd(exec), exec?.signal, true, writePolicyFor(exec));
351
502
  };
352
503
  /* -------------------------------------------------------------- *
353
504
  * Case lookup with lazy rehydration
@@ -426,26 +577,53 @@ export function apply(ctx, config = {}) {
426
577
  name: 'scout_start',
427
578
  description: 'Start a company and job due-diligence case. The initial decision is VERIFY until identity evidence is confirmed. Returns a compact status summary; use scout_report for the full report.',
428
579
  parameters: {
429
- caseId: { type: 'string', required: true, description: 'Stable case identifier.' },
430
- companyName: { type: 'string', required: true, description: 'Company or brand name to investigate.' },
580
+ caseId: { type: 'string', required: true, description: 'Stable case identifier (1-64 chars: letters, digits, dot, underscore, hyphen).' },
581
+ companyName: { type: 'string', required: true, description: 'Company or brand name to investigate. Prefer the full legal name when known - it removes same-name ambiguity.' },
431
582
  roleTitle: { type: 'string', required: true, description: 'Role title to evaluate.' },
432
583
  location: { type: 'string', required: true, description: 'Job location, or an empty string if unknown.' },
584
+ overwrite: { type: 'boolean', description: 'Allow starting a case whose completed export already exists on disk (default false refuses, to protect data).' },
433
585
  },
434
586
  output: mutationOutput(),
435
587
  async execute(args, exec) {
436
- const key = caseKey(args.caseId, exec.agent?.id);
588
+ const caseId = args.caseId.trim();
589
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(caseId)) {
590
+ throw new Error('Invalid caseId: use 1-64 characters from [A-Za-z0-9._-], starting with a letter or digit');
591
+ }
592
+ if (!args.companyName.trim())
593
+ throw new Error('companyName must not be blank');
594
+ if (!args.roleTitle.trim())
595
+ throw new Error('roleTitle must not be blank');
596
+ const key = caseKey(caseId, exec.agent?.id);
437
597
  if (cases.has(key))
438
- throw new Error(`Case already exists: ${args.caseId}`);
598
+ throw new Error(`Case already exists: ${caseId}`);
599
+ if (!args.overwrite) {
600
+ const fs = fsService();
601
+ if (fs) {
602
+ const sourceDir = caseExportDir(caseId);
603
+ let exists = false;
604
+ try {
605
+ exists = await readCompletionMarker(sourceDir, fs, execCwd(exec), exec.signal);
606
+ }
607
+ catch {
608
+ exists = false;
609
+ }
610
+ if (exists) {
611
+ throw new Error(`A completed export already exists at ${sourceDir}. Use scout_import to restore it, pick another caseId, `
612
+ + 'or pass overwrite:true to replace it');
613
+ }
614
+ }
615
+ }
439
616
  let nextCase = recordEvent(createCase({
440
- caseId: args.caseId,
441
- companyName: args.companyName,
442
- roleTitle: args.roleTitle,
443
- location: args.location || undefined,
617
+ caseId,
618
+ companyName: args.companyName.trim(),
619
+ roleTitle: args.roleTitle.trim(),
620
+ location: args.location?.trim() || undefined,
444
621
  language,
445
622
  }), 'case_started', {
446
- companyName: args.companyName,
447
- roleTitle: args.roleTitle,
448
- location: args.location || null,
623
+ companyName: args.companyName.trim(),
624
+ roleTitle: args.roleTitle.trim(),
625
+ location: args.location?.trim() || null,
626
+ overwrite: args.overwrite === true,
449
627
  });
450
628
  const persisted = await maybePersist(nextCase, exec);
451
629
  nextCase = persisted.scoutCase;
@@ -467,11 +645,13 @@ export function apply(ctx, config = {}) {
467
645
  sourceIds: { type: 'string', required: true, description: 'Comma-separated source identifiers; empty when there is no source.' },
468
646
  confidenceNote: { type: 'string', required: true, description: 'Why the current evidence supports this status and level.' },
469
647
  nextAction: { type: 'string', required: true, description: 'Smallest next action to verify or use the claim.' },
648
+ interviewQuestion: { type: 'string', description: 'Optional interview question this claim implies. Only these (plus curated questions) feed the interview question list; nextAction never does.' },
470
649
  },
471
650
  output: mutationOutput(),
472
651
  async execute(args, exec) {
473
652
  const key = caseKey(args.caseId, exec.agent?.id);
474
653
  const scoutCase = await requireCase(key, exec);
654
+ const interviewQuestion = typeof args.interviewQuestion === 'string' ? args.interviewQuestion.trim() : '';
475
655
  let nextCase = recordEvent(decide(addClaim(scoutCase, {
476
656
  claimId: args.claimId,
477
657
  text: args.text,
@@ -482,6 +662,7 @@ export function apply(ctx, config = {}) {
482
662
  sourceIds: args.sourceIds ? args.sourceIds.split(',').map(value => value.trim()).filter(Boolean) : [],
483
663
  confidenceNote: args.confidenceNote,
484
664
  nextAction: args.nextAction,
665
+ ...(interviewQuestion ? { interviewQuestion } : {}),
485
666
  })), 'claim_added', {
486
667
  claimId: args.claimId,
487
668
  status: args.status,
@@ -497,7 +678,7 @@ export function apply(ctx, config = {}) {
497
678
  }));
498
679
  yield ctx.tools.register(defineTool({
499
680
  name: 'scout_ingest',
500
- description: 'Batch-register collected research results as sources, optionally drafting claims from each item. itemsJson is a JSON array of { url, title?, sourceType?, evidenceLevel?, claim? }; claim is { text, dimension, impact?, status?, evidenceLevel? } with status defaulting to reported, impact to material, and evidence level to the source level. Source type and evidence level are inferred from the URL when omitted (official registry/regulator origins map to E3, job platforms to job_posting/E2, user-provided to E1, unknown to other/E2). A URL already registered for the case is reused instead of duplicated. Individual invalid items or rejected claims are reported in errors without aborting the batch; a failed claim does not undo its already-registered source.',
681
+ description: 'Batch-register collected research results as sources, optionally drafting claims from each item. itemsJson is a JSON array of { url, title?, sourceType?, evidenceLevel?, claim? }; claim is { text, dimension, impact?, status?, evidenceLevel?, interviewQuestion?, nextAction?, confidenceNote? } with status defaulting to reported, impact to material, evidence level to the source level, and nextAction/confidenceNote defaulting to the ingest placeholder when omitted. Source type and evidence level are inferred from the URL when omitted (official registry/regulator origins map to E3, app stores to company_official, job platforms to job_posting, Chinese registry aggregators to public_database; unknown to other). A URL already registered for the case is reused; when this item states a stronger evidenceLevel (or a different explicit sourceType/title) the existing source is upgraded and a source_updated event is recorded. Re-ingesting the same claim text (same dimension/impact) is a no-op instead of a duplicate. Individual invalid items or rejected claims are reported in errors without aborting the batch.',
501
682
  parameters: {
502
683
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
503
684
  itemsJson: { type: 'string', required: true, description: 'JSON array of collected source items.' },
@@ -517,6 +698,8 @@ export function apply(ctx, config = {}) {
517
698
  throw new Error(`Invalid itemsJson: ${messageOf(error)}`);
518
699
  }
519
700
  const added = [];
701
+ const updatedIds = [];
702
+ const sourceLabels = [];
520
703
  const failures = [];
521
704
  let nextId = (scoutCase.sources.length ?? 0) + 1;
522
705
  let nextClaimId = (scoutCase.claims.length ?? 0) + 1;
@@ -554,7 +737,19 @@ export function apply(ctx, config = {}) {
554
737
  const existing = findSourceByUrl(scoutCase, url);
555
738
  let source;
556
739
  if (existing) {
557
- source = existing;
740
+ const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : '';
741
+ const upgraded = upgradeExistingSource(scoutCase, existing, {
742
+ type: typeGuess.type,
743
+ evidenceLevel: levelGuess.evidenceLevel,
744
+ title,
745
+ explicitType: item.sourceType !== undefined && item.sourceType !== null,
746
+ explicitLevel: item.evidenceLevel !== undefined && item.evidenceLevel !== null,
747
+ explicitTitle: Boolean(title),
748
+ });
749
+ scoutCase = upgraded.scoutCase;
750
+ source = upgraded.source;
751
+ if (upgraded.updated)
752
+ updatedIds.push(source.sourceId);
558
753
  }
559
754
  else {
560
755
  let sourceId = `src-${nextId}`;
@@ -563,16 +758,18 @@ export function apply(ctx, config = {}) {
563
758
  sourceId = `src-${nextId}`;
564
759
  }
565
760
  nextId += 1;
761
+ const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
566
762
  source = {
567
763
  sourceId,
568
764
  type: typeGuess.type,
569
- title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
765
+ title,
570
766
  url,
571
767
  capturedAt: new Date().toISOString(),
572
768
  evidenceLevel: levelGuess.evidenceLevel,
573
769
  status: 'captured',
574
770
  };
575
771
  scoutCase = registerSource(scoutCase, source);
772
+ sourceLabels.push(title);
576
773
  }
577
774
  added.push(source.sourceId);
578
775
  const rawClaim = item.claim;
@@ -613,37 +810,56 @@ export function apply(ctx, config = {}) {
613
810
  failures.push({ url, error: `claim rejected: ${claimIssues.join('; ')}` });
614
811
  }
615
812
  else {
616
- let claimId = `claim-${nextClaimId}`;
617
- while (scoutCase.claims.some(candidate => candidate.claimId === claimId)) {
618
- nextClaimId += 1;
619
- claimId = `claim-${nextClaimId}`;
813
+ const impactValue = impact ?? 'material';
814
+ const interviewQuestion = typeof claimDraft.interviewQuestion === 'string' && claimDraft.interviewQuestion.trim()
815
+ ? claimDraft.interviewQuestion.trim()
816
+ : undefined;
817
+ const draftNextAction = typeof claimDraft.nextAction === 'string' && claimDraft.nextAction.trim()
818
+ ? claimDraft.nextAction.trim()
819
+ : undefined;
820
+ const draftConfidenceNote = typeof claimDraft.confidenceNote === 'string' && claimDraft.confidenceNote.trim()
821
+ ? claimDraft.confidenceNote.trim()
822
+ : undefined;
823
+ const duplicate = scoutCase.claims.find(candidate => candidate.text.trim() === text
824
+ && candidate.dimension === dimension
825
+ && candidate.impact === impactValue);
826
+ if (duplicate) {
827
+ // Re-running the same batch must not double the ledger.
620
828
  }
621
- nextClaimId += 1;
622
- try {
623
- scoutCase = recordEvent(addClaim(scoutCase, {
624
- claimId,
625
- text,
626
- status: status ?? 'reported',
627
- evidenceLevel: claimLevel ?? source.evidenceLevel,
628
- dimension: dimension,
629
- impact: impact ?? 'material',
630
- sourceIds: [source.sourceId],
631
- confidenceNote: formatScoutString(strings.ingest.autoConfidenceNote, { sourceId: source.sourceId }),
632
- nextAction: strings.ingest.autoNextAction,
633
- }), 'claim_added', {
634
- claimId,
635
- status: status ?? 'reported',
636
- evidenceLevel: claimLevel ?? source.evidenceLevel,
637
- dimension: dimension,
638
- impact: impact ?? 'material',
639
- text,
640
- sourceIds: [source.sourceId],
641
- ingested: true,
642
- });
643
- added.push(claimId);
644
- }
645
- catch (error) {
646
- failures.push({ url, error: `claim rejected: ${messageOf(error)}` });
829
+ else {
830
+ let claimId = `claim-${nextClaimId}`;
831
+ while (scoutCase.claims.some(candidate => candidate.claimId === claimId)) {
832
+ nextClaimId += 1;
833
+ claimId = `claim-${nextClaimId}`;
834
+ }
835
+ nextClaimId += 1;
836
+ try {
837
+ scoutCase = recordEvent(addClaim(scoutCase, {
838
+ claimId,
839
+ text,
840
+ status: status ?? 'reported',
841
+ evidenceLevel: claimLevel ?? source.evidenceLevel,
842
+ dimension: dimension,
843
+ impact: impactValue,
844
+ sourceIds: [source.sourceId],
845
+ confidenceNote: draftConfidenceNote ?? formatScoutString(strings.ingest.autoConfidenceNote, { sourceId: source.sourceId }),
846
+ nextAction: draftNextAction ?? strings.ingest.autoNextAction,
847
+ ...(interviewQuestion ? { interviewQuestion } : {}),
848
+ }), 'claim_added', {
849
+ claimId,
850
+ status: status ?? 'reported',
851
+ evidenceLevel: claimLevel ?? source.evidenceLevel,
852
+ dimension: dimension,
853
+ impact: impactValue,
854
+ text,
855
+ sourceIds: [source.sourceId],
856
+ ingested: true,
857
+ });
858
+ added.push(claimId);
859
+ }
860
+ catch (error) {
861
+ failures.push({ url, error: `claim rejected: ${messageOf(error)}` });
862
+ }
647
863
  }
648
864
  }
649
865
  }
@@ -657,7 +873,11 @@ export function apply(ctx, config = {}) {
657
873
  const persisted = await maybePersist(scoutCase, exec);
658
874
  scoutCase = persisted.scoutCase;
659
875
  cases.set(key, scoutCase);
660
- return buildEnvelope(scoutCase, { kind: 'sources_added', ids: added }, { persist: persisted.persist, errors: failures });
876
+ return buildEnvelope(scoutCase, {
877
+ kind: updatedIds.length && !added.some(id => id.startsWith('claim-')) ? 'source_updated' : 'sources_added',
878
+ ids: added,
879
+ ...(sourceLabels.length ? { labels: sourceLabels } : {}),
880
+ }, { persist: persisted.persist, errors: failures });
661
881
  },
662
882
  }));
663
883
  yield ctx.tools.register(defineTool({
@@ -679,11 +899,20 @@ export function apply(ctx, config = {}) {
679
899
  const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10) : 5;
680
900
  const result = await web.search({ query: args.query, maxResults: limit }, exec.signal);
681
901
  const added = [];
902
+ const labels = [];
682
903
  const failures = [];
683
904
  const items = Array.isArray(result.sources) ? result.sources : [];
684
905
  if (!Array.isArray(result.sources)) {
685
906
  failures.push({ url: '', error: 'search provider returned a non-array sources field' });
686
907
  }
908
+ const hostOf = (value) => {
909
+ try {
910
+ return new URL(value).hostname;
911
+ }
912
+ catch {
913
+ return '';
914
+ }
915
+ };
687
916
  let nextId = (scoutCase.sources.length ?? 0) + 1;
688
917
  items.forEach((item, index) => {
689
918
  if (!item || typeof item !== 'object') {
@@ -709,16 +938,18 @@ export function apply(ctx, config = {}) {
709
938
  sourceId = `src-${nextId}`;
710
939
  }
711
940
  nextId += 1;
941
+ const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
712
942
  scoutCase = registerSource(scoutCase, {
713
943
  sourceId,
714
944
  type: typeGuess.type,
715
- title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
945
+ title,
716
946
  url,
717
947
  capturedAt: new Date().toISOString(),
718
948
  evidenceLevel: levelGuess.evidenceLevel,
719
949
  status: 'captured',
720
950
  });
721
951
  added.push(sourceId);
952
+ labels.push(`${title} · ${hostOf(url)}`);
722
953
  }
723
954
  catch (error) {
724
955
  failures.push({ url, error: messageOf(error) });
@@ -728,7 +959,7 @@ export function apply(ctx, config = {}) {
728
959
  const persisted = await maybePersist(scoutCase, exec);
729
960
  scoutCase = persisted.scoutCase;
730
961
  cases.set(key, scoutCase);
731
- return buildEnvelope(scoutCase, { kind: 'sources_added', ids: added }, { persist: persisted.persist, errors: failures });
962
+ return buildEnvelope(scoutCase, { kind: 'sources_added', ids: added, ...(labels.length ? { labels } : {}) }, { persist: persisted.persist, errors: failures });
732
963
  },
733
964
  }));
734
965
  yield ctx.tools.register(defineTool({
@@ -858,7 +1089,7 @@ export function apply(ctx, config = {}) {
858
1089
  const key = caseKey(args.caseId, exec.agent?.id);
859
1090
  const scoutCase = await requireCase(key, exec);
860
1091
  const targetDir = args.targetDir || caseExportDir(scoutCase.caseId);
861
- const exported = await exportCase(scoutCase, targetDir, fs, execCwd(exec), exec.signal, false);
1092
+ const exported = await exportCase(scoutCase, targetDir, fs, execCwd(exec), exec.signal, false, writePolicyFor(exec));
862
1093
  cases.set(key, exported.scoutCase);
863
1094
  return {
864
1095
  caseId: scoutCase.caseId,
@@ -942,6 +1173,11 @@ export function apply(ctx, config = {}) {
942
1173
  async execute(args, exec) {
943
1174
  const key = caseKey(args.caseId, exec.agent?.id);
944
1175
  const scoutCase = await requireCase(key, exec);
1176
+ const duplicate = args.url ? findSourceByUrl(scoutCase, args.url) : undefined;
1177
+ if (duplicate && duplicate.sourceId !== args.sourceId) {
1178
+ throw new Error(`URL already registered as ${duplicate.sourceId}. Use scout_ingest to upgrade its metadata, `
1179
+ + 'or attach claims to the existing source id instead of creating a duplicate');
1180
+ }
945
1181
  const source = {
946
1182
  sourceId: args.sourceId,
947
1183
  type: args.sourceType,
@@ -958,12 +1194,16 @@ export function apply(ctx, config = {}) {
958
1194
  const persisted = await maybePersist(nextCase, exec);
959
1195
  nextCase = persisted.scoutCase;
960
1196
  cases.set(key, nextCase);
961
- return buildEnvelope(nextCase, { kind: 'source_added', ids: [args.sourceId] }, { persist: persisted.persist });
1197
+ return buildEnvelope(nextCase, {
1198
+ kind: 'source_added',
1199
+ ids: [args.sourceId],
1200
+ labels: [args.title],
1201
+ }, { persist: persisted.persist });
962
1202
  },
963
1203
  }));
964
1204
  yield ctx.tools.register(defineTool({
965
1205
  name: 'scout_verify_identity',
966
- description: 'Confirm the legal company identity using a previously registered E3 source.',
1206
+ description: 'Confirm the legal company identity from registered sources. With E3 registry evidence for the registration number the tier is `verified`; with two independent sources covering every field it is `corroborated` (decision-useful, never presented as official verification). Use fieldSources to record which source supports which field.',
967
1207
  parameters: {
968
1208
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
969
1209
  legalEntity: { type: 'string', required: true, description: 'Verified legal entity name.' },
@@ -971,22 +1211,28 @@ export function apply(ctx, config = {}) {
971
1211
  registeredRegion: { type: 'string', required: true, description: 'Verified registration region.' },
972
1212
  legalRepresentative: { type: 'string', required: true, description: 'Verified legal representative.' },
973
1213
  brandRelationship: { type: 'string', required: true, description: 'How the investigated brand relates to the legal entity.' },
974
- sourceIds: { type: 'string', required: true, description: 'Comma-separated identity sources including a linked authoritative E3 source.' },
1214
+ sourceIds: { type: 'string', required: true, description: 'Comma-separated identity sources (E3 authoritative source for `verified`, two independent sources for `corroborated`).' },
1215
+ fieldSources: { type: 'string', description: 'Optional JSON per-field provenance, e.g. {"legalEntity":"src-1,src-3","registrationNumber":"src-1","registeredRegion":"src-3","legalRepresentative":"src-1","brandRelationship":"src-2"}. When present every field must be covered.' },
975
1216
  },
976
1217
  output: mutationOutput(),
977
1218
  async execute(args, exec) {
978
1219
  const key = caseKey(args.caseId, exec.agent?.id);
979
1220
  const scoutCase = await requireCase(key, exec);
980
- let nextCase = recordEvent(decide(verifyIdentity(scoutCase, {
1221
+ const fieldSources = parseIdentityFieldSources(args.fieldSources);
1222
+ const verifiedCase = verifyIdentity(scoutCase, {
981
1223
  legalEntity: args.legalEntity,
982
1224
  registrationNumber: args.registrationNumber,
983
1225
  registeredRegion: args.registeredRegion,
984
1226
  legalRepresentative: args.legalRepresentative,
985
1227
  brandRelationship: args.brandRelationship,
986
1228
  sourceIds: args.sourceIds.split(',').map(value => value.trim()).filter(Boolean),
987
- }, authoritySuffixes)), 'identity_verified', {
1229
+ ...(fieldSources ? { fieldSources } : {}),
1230
+ }, authoritySuffixes);
1231
+ let nextCase = recordEvent(decide(verifiedCase), 'identity_verified', {
988
1232
  legalEntity: args.legalEntity,
989
1233
  registrationNumber: args.registrationNumber,
1234
+ identityStatus: verifiedCase.subject.identityStatus,
1235
+ ...(fieldSources ? { fieldSources } : {}),
990
1236
  });
991
1237
  const persisted = await maybePersist(nextCase, exec);
992
1238
  nextCase = persisted.scoutCase;
@@ -1033,7 +1279,7 @@ export function apply(ctx, config = {}) {
1033
1279
  * -------------------------------------------------------------- */
1034
1280
  const runScoutCommand = async (invocation) => {
1035
1281
  const raw = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
1036
- const [action = 'help', caseId = ''] = raw.split(/\s+/).filter(Boolean);
1282
+ const [action = 'help', caseId = '', commandTargetDir = ''] = raw.split(/\s+/).filter(Boolean);
1037
1283
  if (action === 'help' || !caseId) {
1038
1284
  return {
1039
1285
  kind: 'success',
@@ -1041,9 +1287,14 @@ export function apply(ctx, config = {}) {
1041
1287
  };
1042
1288
  }
1043
1289
  const key = caseKey(caseId, invocation?.agent?.id);
1044
- const scoutCase = cases.get(key);
1045
- if (!scoutCase) {
1046
- return { kind: 'error', text: `Unknown case: ${caseId}. Use scout_start or scout_import first.` };
1290
+ let scoutCase;
1291
+ try {
1292
+ // Same state machine as the tools: a cold process rehydrates from disk
1293
+ // instead of answering "Unknown case" for a case that plainly exists.
1294
+ scoutCase = await requireCase(key, invocation);
1295
+ }
1296
+ catch (error) {
1297
+ return { kind: 'error', text: messageOf(error) };
1047
1298
  }
1048
1299
  if (action === 'status') {
1049
1300
  const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
@@ -1068,8 +1319,8 @@ export function apply(ctx, config = {}) {
1068
1319
  const fs = fsService();
1069
1320
  if (!fs)
1070
1321
  return { kind: 'error', text: 'Filesystem service is unavailable in this host' };
1071
- const targetDir = caseExportDir(scoutCase.caseId);
1072
- const exported = await exportCase(scoutCase, targetDir, fs, invocation?.agent?.session?.header?.cwd, invocation?.signal, false);
1322
+ const targetDir = commandTargetDir || caseExportDir(scoutCase.caseId);
1323
+ const exported = await exportCase(scoutCase, targetDir, fs, invocation?.agent?.session?.header?.cwd, invocation?.signal, false, writePolicyFor(invocation));
1073
1324
  cases.set(key, exported.scoutCase);
1074
1325
  return exported.persist.persisted
1075
1326
  ? { kind: 'success', text: `exported to ${exported.persist.targetDir}` }