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.
package/dist/index.js CHANGED
@@ -1,9 +1,26 @@
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'];
7
+ /**
8
+ * The version this source tree ships, stated as a literal.
9
+ *
10
+ * It is deliberately never read back from `package.json`. A release checklist
11
+ * anchor only works when the version is asserted in two independent places: the
12
+ * assertion compares this literal against the manifest, so a bump that updates
13
+ * one and forgets the other fails the suite instead of shipping silently.
14
+ * `tests/acceptance.test.mjs` pins this constant, `package.json` and the
15
+ * `/scout status` output to the same string.
16
+ *
17
+ * Where this reaches a user: `runScoutCommand` prints it in `/scout status`.
18
+ * It cannot be printed at load time — a plugin's log output is not visible in
19
+ * any DSH surface (cordis only buffers it in memory), and a plugin that failed
20
+ * to load never runs at all, so "is it loaded, and which version?" is answered
21
+ * by the settings plugin list instead.
22
+ */
23
+ export const PLUGIN_VERSION = '0.9.4';
7
24
  export const Config = z.object({
8
25
  scoutDir: z.string().default('dsh-scout'),
9
26
  autoPersist: z.boolean().default(false),
@@ -11,7 +28,16 @@ export const Config = z.object({
11
28
  language: z.union([z.const('zh-CN'), z.const('en')]).default('zh-CN'),
12
29
  });
13
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;
14
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']);
15
41
  /** Up to three smallest next actions for open blocking/material claims. */
16
42
  const deriveNextActions = (scoutCase) => [...scoutCase.claims]
17
43
  .filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')
@@ -20,25 +46,6 @@ const deriveNextActions = (scoutCase) => [...scoutCase.claims]
20
46
  .filter((action, index, all) => Boolean(action) && all.indexOf(action) === index)
21
47
  .slice(0, 3);
22
48
  const capIds = (ids, limit = 20) => ids.length > limit ? [...ids.slice(0, limit), `+${ids.length - limit} more`] : ids;
23
- const buildEnvelope = (scoutCase, changed, extras = {}) => {
24
- const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
25
- return {
26
- ok: true,
27
- caseId: scoutCase.caseId,
28
- decision: scoutCase.decision,
29
- identity: scoutCase.subject.identityStatus,
30
- counts: {
31
- sources: scoutCase.sources.length,
32
- claims: scoutCase.claims.length,
33
- verified,
34
- open: scoutCase.claims.length - verified,
35
- },
36
- changed: { kind: changed.kind, ids: capIds(changed.ids) },
37
- nextActions: deriveNextActions(scoutCase),
38
- ...(extras.persist ? { persist: extras.persist } : {}),
39
- ...(extras.errors?.length ? { errors: extras.errors } : {}),
40
- };
41
- };
42
49
  /** Model-facing render budget for one mutation summary, in UTF-8 bytes. */
43
50
  const MAX_RENDER_BYTES = 1024;
44
51
  /** Marks a clipped render. Its own byte cost is paid out of the same budget. */
@@ -72,10 +79,27 @@ const clampUtf8 = (text, limit) => {
72
79
  const clipField = (text, limit) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
73
80
  const formatEnvelope = (value) => {
74
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
+ }
75
93
  const lines = [
76
94
  `[${clipField(value.caseId, 80)}] ${value.decision} · identity ${value.identity} · sources ${counts.sources} / claims ${counts.claims} (verified ${counts.verified}, open ${counts.open})`,
77
- `changed: ${changed.kind}${changed.ids.length ? ` (${clipField(changed.ids.join(', '), 200)})` : ''}`,
95
+ changedLine,
78
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
+ }
79
103
  if (value.nextActions.length) {
80
104
  lines.push(`next: ${value.nextActions.map(action => clipField(action, 140)).join(' | ')}`);
81
105
  }
@@ -110,7 +134,7 @@ const mutationSchema = () => ({
110
134
  },
111
135
  identity: {
112
136
  type: 'string',
113
- enum: ['verified', 'needs_verification'],
137
+ enum: ['verified', 'corroborated', 'needs_verification'],
114
138
  required: true,
115
139
  },
116
140
  counts: {
@@ -134,6 +158,7 @@ const mutationSchema = () => ({
134
158
  enum: [
135
159
  'case_started',
136
160
  'source_added',
161
+ 'source_updated',
137
162
  'sources_added',
138
163
  'claim_added',
139
164
  'claims_added',
@@ -145,6 +170,7 @@ const mutationSchema = () => ({
145
170
  required: true,
146
171
  },
147
172
  ids: { type: 'array', items: { type: 'string' }, required: true },
173
+ labels: { type: 'array', items: { type: 'string' } },
148
174
  },
149
175
  },
150
176
  nextActions: { type: 'array', items: { type: 'string' }, required: true },
@@ -233,7 +259,106 @@ export function apply(ctx, config = {}) {
233
259
  // Resolved per call so a later-mounted (or replaced) provider is honored.
234
260
  const fsService = () => ctx.get('fs');
235
261
  const webService = () => ctx.get('web');
262
+ const sandboxPolicyService = () => ctx.get('sandboxPolicy');
236
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
+ };
237
362
  const recordEvent = (scoutCase, type, detail) => {
238
363
  const events = scoutCase.events ?? [];
239
364
  let n = events.length + 1;
@@ -257,6 +382,49 @@ export function apply(ctx, config = {}) {
257
382
  evidenceLevel: source.evidenceLevel,
258
383
  ingested: true,
259
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
+ };
260
428
  /* -------------------------------------------------------------- *
261
429
  * Durable export
262
430
  *
@@ -278,7 +446,7 @@ export function apply(ctx, config = {}) {
278
446
  * re-asserted after every payload file landed, so an interrupted export can
279
447
  * never leave a stale `complete: true` beside mixed-generation files.
280
448
  */
281
- const persistFiles = async (scoutCase, targetDir, fs, cwd, signal) => {
449
+ const persistFiles = async (scoutCase, targetDir, fs, cwd, signal, sandboxPolicy = undefined) => {
282
450
  try {
283
451
  const files = exportCaseFiles(scoutCase, { language });
284
452
  const marker = (complete) => `${JSON.stringify({
@@ -293,7 +461,7 @@ export function apply(ctx, config = {}) {
293
461
  const target = await fs.resolve(`${targetDir}/${fileName}`, { cwd, signal });
294
462
  if (typeof target?.displayPath === 'string')
295
463
  absoluteDir = directoryOf(target.displayPath);
296
- await fs.writeText(target, content, undefined, signal);
464
+ await fs.writeText(target, content, undefined, signal, sandboxPolicy);
297
465
  };
298
466
  await write(EXPORT_MARKER_FILE, marker(false));
299
467
  for (const fileName of EXPORT_FILE_NAMES) {
@@ -303,7 +471,7 @@ export function apply(ctx, config = {}) {
303
471
  return { persisted: true, targetDir: absoluteDir ?? targetDir };
304
472
  }
305
473
  catch (error) {
306
- return { persisted: false, targetDir, error: messageOf(error) };
474
+ return { persisted: false, targetDir, error: withWriteHint(messageOf(error)) };
307
475
  }
308
476
  };
309
477
  /**
@@ -311,9 +479,9 @@ export function apply(ctx, config = {}) {
311
479
  * the `case_exported` event that the written `events.jsonl` also has; on
312
480
  * failure the event is dropped and the error is surfaced to the caller.
313
481
  */
314
- const exportCase = async (scoutCase, targetDir, fs, cwd, signal, auto) => {
482
+ const exportCase = async (scoutCase, targetDir, fs, cwd, signal, auto, sandboxPolicy = undefined) => {
315
483
  const withEvent = recordEvent(scoutCase, 'case_exported', { targetDir, auto });
316
- const persist = await persistFiles(withEvent, targetDir, fs, cwd, signal);
484
+ const persist = await persistFiles(withEvent, targetDir, fs, cwd, signal, sandboxPolicy);
317
485
  return { scoutCase: persist.persisted ? withEvent : scoutCase, persist };
318
486
  };
319
487
  const maybePersist = async (scoutCase, exec) => {
@@ -330,7 +498,7 @@ export function apply(ctx, config = {}) {
330
498
  },
331
499
  };
332
500
  }
333
- 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));
334
502
  };
335
503
  /* -------------------------------------------------------------- *
336
504
  * Case lookup with lazy rehydration
@@ -409,26 +577,53 @@ export function apply(ctx, config = {}) {
409
577
  name: 'scout_start',
410
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.',
411
579
  parameters: {
412
- caseId: { type: 'string', required: true, description: 'Stable case identifier.' },
413
- 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.' },
414
582
  roleTitle: { type: 'string', required: true, description: 'Role title to evaluate.' },
415
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).' },
416
585
  },
417
586
  output: mutationOutput(),
418
587
  async execute(args, exec) {
419
- 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);
420
597
  if (cases.has(key))
421
- 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
+ }
422
616
  let nextCase = recordEvent(createCase({
423
- caseId: args.caseId,
424
- companyName: args.companyName,
425
- roleTitle: args.roleTitle,
426
- location: args.location || undefined,
617
+ caseId,
618
+ companyName: args.companyName.trim(),
619
+ roleTitle: args.roleTitle.trim(),
620
+ location: args.location?.trim() || undefined,
427
621
  language,
428
622
  }), 'case_started', {
429
- companyName: args.companyName,
430
- roleTitle: args.roleTitle,
431
- 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,
432
627
  });
433
628
  const persisted = await maybePersist(nextCase, exec);
434
629
  nextCase = persisted.scoutCase;
@@ -450,11 +645,13 @@ export function apply(ctx, config = {}) {
450
645
  sourceIds: { type: 'string', required: true, description: 'Comma-separated source identifiers; empty when there is no source.' },
451
646
  confidenceNote: { type: 'string', required: true, description: 'Why the current evidence supports this status and level.' },
452
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.' },
453
649
  },
454
650
  output: mutationOutput(),
455
651
  async execute(args, exec) {
456
652
  const key = caseKey(args.caseId, exec.agent?.id);
457
653
  const scoutCase = await requireCase(key, exec);
654
+ const interviewQuestion = typeof args.interviewQuestion === 'string' ? args.interviewQuestion.trim() : '';
458
655
  let nextCase = recordEvent(decide(addClaim(scoutCase, {
459
656
  claimId: args.claimId,
460
657
  text: args.text,
@@ -465,6 +662,7 @@ export function apply(ctx, config = {}) {
465
662
  sourceIds: args.sourceIds ? args.sourceIds.split(',').map(value => value.trim()).filter(Boolean) : [],
466
663
  confidenceNote: args.confidenceNote,
467
664
  nextAction: args.nextAction,
665
+ ...(interviewQuestion ? { interviewQuestion } : {}),
468
666
  })), 'claim_added', {
469
667
  claimId: args.claimId,
470
668
  status: args.status,
@@ -480,7 +678,7 @@ export function apply(ctx, config = {}) {
480
678
  }));
481
679
  yield ctx.tools.register(defineTool({
482
680
  name: 'scout_ingest',
483
- 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.',
484
682
  parameters: {
485
683
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
486
684
  itemsJson: { type: 'string', required: true, description: 'JSON array of collected source items.' },
@@ -500,6 +698,8 @@ export function apply(ctx, config = {}) {
500
698
  throw new Error(`Invalid itemsJson: ${messageOf(error)}`);
501
699
  }
502
700
  const added = [];
701
+ const updatedIds = [];
702
+ const sourceLabels = [];
503
703
  const failures = [];
504
704
  let nextId = (scoutCase.sources.length ?? 0) + 1;
505
705
  let nextClaimId = (scoutCase.claims.length ?? 0) + 1;
@@ -537,7 +737,19 @@ export function apply(ctx, config = {}) {
537
737
  const existing = findSourceByUrl(scoutCase, url);
538
738
  let source;
539
739
  if (existing) {
540
- 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);
541
753
  }
542
754
  else {
543
755
  let sourceId = `src-${nextId}`;
@@ -546,16 +758,18 @@ export function apply(ctx, config = {}) {
546
758
  sourceId = `src-${nextId}`;
547
759
  }
548
760
  nextId += 1;
761
+ const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
549
762
  source = {
550
763
  sourceId,
551
764
  type: typeGuess.type,
552
- title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
765
+ title,
553
766
  url,
554
767
  capturedAt: new Date().toISOString(),
555
768
  evidenceLevel: levelGuess.evidenceLevel,
556
769
  status: 'captured',
557
770
  };
558
771
  scoutCase = registerSource(scoutCase, source);
772
+ sourceLabels.push(title);
559
773
  }
560
774
  added.push(source.sourceId);
561
775
  const rawClaim = item.claim;
@@ -596,37 +810,56 @@ export function apply(ctx, config = {}) {
596
810
  failures.push({ url, error: `claim rejected: ${claimIssues.join('; ')}` });
597
811
  }
598
812
  else {
599
- let claimId = `claim-${nextClaimId}`;
600
- while (scoutCase.claims.some(candidate => candidate.claimId === claimId)) {
601
- nextClaimId += 1;
602
- 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.
603
828
  }
604
- nextClaimId += 1;
605
- try {
606
- scoutCase = recordEvent(addClaim(scoutCase, {
607
- claimId,
608
- text,
609
- status: status ?? 'reported',
610
- evidenceLevel: claimLevel ?? source.evidenceLevel,
611
- dimension: dimension,
612
- impact: impact ?? 'material',
613
- sourceIds: [source.sourceId],
614
- confidenceNote: formatScoutString(strings.ingest.autoConfidenceNote, { sourceId: source.sourceId }),
615
- nextAction: strings.ingest.autoNextAction,
616
- }), 'claim_added', {
617
- claimId,
618
- status: status ?? 'reported',
619
- evidenceLevel: claimLevel ?? source.evidenceLevel,
620
- dimension: dimension,
621
- impact: impact ?? 'material',
622
- text,
623
- sourceIds: [source.sourceId],
624
- ingested: true,
625
- });
626
- added.push(claimId);
627
- }
628
- catch (error) {
629
- 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
+ }
630
863
  }
631
864
  }
632
865
  }
@@ -640,7 +873,11 @@ export function apply(ctx, config = {}) {
640
873
  const persisted = await maybePersist(scoutCase, exec);
641
874
  scoutCase = persisted.scoutCase;
642
875
  cases.set(key, scoutCase);
643
- 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 });
644
881
  },
645
882
  }));
646
883
  yield ctx.tools.register(defineTool({
@@ -662,11 +899,20 @@ export function apply(ctx, config = {}) {
662
899
  const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10) : 5;
663
900
  const result = await web.search({ query: args.query, maxResults: limit }, exec.signal);
664
901
  const added = [];
902
+ const labels = [];
665
903
  const failures = [];
666
904
  const items = Array.isArray(result.sources) ? result.sources : [];
667
905
  if (!Array.isArray(result.sources)) {
668
906
  failures.push({ url: '', error: 'search provider returned a non-array sources field' });
669
907
  }
908
+ const hostOf = (value) => {
909
+ try {
910
+ return new URL(value).hostname;
911
+ }
912
+ catch {
913
+ return '';
914
+ }
915
+ };
670
916
  let nextId = (scoutCase.sources.length ?? 0) + 1;
671
917
  items.forEach((item, index) => {
672
918
  if (!item || typeof item !== 'object') {
@@ -692,16 +938,18 @@ export function apply(ctx, config = {}) {
692
938
  sourceId = `src-${nextId}`;
693
939
  }
694
940
  nextId += 1;
941
+ const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
695
942
  scoutCase = registerSource(scoutCase, {
696
943
  sourceId,
697
944
  type: typeGuess.type,
698
- title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
945
+ title,
699
946
  url,
700
947
  capturedAt: new Date().toISOString(),
701
948
  evidenceLevel: levelGuess.evidenceLevel,
702
949
  status: 'captured',
703
950
  });
704
951
  added.push(sourceId);
952
+ labels.push(`${title} · ${hostOf(url)}`);
705
953
  }
706
954
  catch (error) {
707
955
  failures.push({ url, error: messageOf(error) });
@@ -711,7 +959,7 @@ export function apply(ctx, config = {}) {
711
959
  const persisted = await maybePersist(scoutCase, exec);
712
960
  scoutCase = persisted.scoutCase;
713
961
  cases.set(key, scoutCase);
714
- 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 });
715
963
  },
716
964
  }));
717
965
  yield ctx.tools.register(defineTool({
@@ -841,7 +1089,7 @@ export function apply(ctx, config = {}) {
841
1089
  const key = caseKey(args.caseId, exec.agent?.id);
842
1090
  const scoutCase = await requireCase(key, exec);
843
1091
  const targetDir = args.targetDir || caseExportDir(scoutCase.caseId);
844
- 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));
845
1093
  cases.set(key, exported.scoutCase);
846
1094
  return {
847
1095
  caseId: scoutCase.caseId,
@@ -925,6 +1173,11 @@ export function apply(ctx, config = {}) {
925
1173
  async execute(args, exec) {
926
1174
  const key = caseKey(args.caseId, exec.agent?.id);
927
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
+ }
928
1181
  const source = {
929
1182
  sourceId: args.sourceId,
930
1183
  type: args.sourceType,
@@ -941,12 +1194,16 @@ export function apply(ctx, config = {}) {
941
1194
  const persisted = await maybePersist(nextCase, exec);
942
1195
  nextCase = persisted.scoutCase;
943
1196
  cases.set(key, nextCase);
944
- 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 });
945
1202
  },
946
1203
  }));
947
1204
  yield ctx.tools.register(defineTool({
948
1205
  name: 'scout_verify_identity',
949
- 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.',
950
1207
  parameters: {
951
1208
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
952
1209
  legalEntity: { type: 'string', required: true, description: 'Verified legal entity name.' },
@@ -954,22 +1211,28 @@ export function apply(ctx, config = {}) {
954
1211
  registeredRegion: { type: 'string', required: true, description: 'Verified registration region.' },
955
1212
  legalRepresentative: { type: 'string', required: true, description: 'Verified legal representative.' },
956
1213
  brandRelationship: { type: 'string', required: true, description: 'How the investigated brand relates to the legal entity.' },
957
- 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.' },
958
1216
  },
959
1217
  output: mutationOutput(),
960
1218
  async execute(args, exec) {
961
1219
  const key = caseKey(args.caseId, exec.agent?.id);
962
1220
  const scoutCase = await requireCase(key, exec);
963
- let nextCase = recordEvent(decide(verifyIdentity(scoutCase, {
1221
+ const fieldSources = parseIdentityFieldSources(args.fieldSources);
1222
+ const verifiedCase = verifyIdentity(scoutCase, {
964
1223
  legalEntity: args.legalEntity,
965
1224
  registrationNumber: args.registrationNumber,
966
1225
  registeredRegion: args.registeredRegion,
967
1226
  legalRepresentative: args.legalRepresentative,
968
1227
  brandRelationship: args.brandRelationship,
969
1228
  sourceIds: args.sourceIds.split(',').map(value => value.trim()).filter(Boolean),
970
- }, authoritySuffixes)), 'identity_verified', {
1229
+ ...(fieldSources ? { fieldSources } : {}),
1230
+ }, authoritySuffixes);
1231
+ let nextCase = recordEvent(decide(verifiedCase), 'identity_verified', {
971
1232
  legalEntity: args.legalEntity,
972
1233
  registrationNumber: args.registrationNumber,
1234
+ identityStatus: verifiedCase.subject.identityStatus,
1235
+ ...(fieldSources ? { fieldSources } : {}),
973
1236
  });
974
1237
  const persisted = await maybePersist(nextCase, exec);
975
1238
  nextCase = persisted.scoutCase;
@@ -1016,7 +1279,7 @@ export function apply(ctx, config = {}) {
1016
1279
  * -------------------------------------------------------------- */
1017
1280
  const runScoutCommand = async (invocation) => {
1018
1281
  const raw = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
1019
- const [action = 'help', caseId = ''] = raw.split(/\s+/).filter(Boolean);
1282
+ const [action = 'help', caseId = '', commandTargetDir = ''] = raw.split(/\s+/).filter(Boolean);
1020
1283
  if (action === 'help' || !caseId) {
1021
1284
  return {
1022
1285
  kind: 'success',
@@ -1024,9 +1287,14 @@ export function apply(ctx, config = {}) {
1024
1287
  };
1025
1288
  }
1026
1289
  const key = caseKey(caseId, invocation?.agent?.id);
1027
- const scoutCase = cases.get(key);
1028
- if (!scoutCase) {
1029
- 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) };
1030
1298
  }
1031
1299
  if (action === 'status') {
1032
1300
  const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
@@ -1034,7 +1302,11 @@ export function apply(ctx, config = {}) {
1034
1302
  kind: 'success',
1035
1303
  text: `[${scoutCase.caseId}] ${scoutCase.decision} · identity ${scoutCase.subject.identityStatus}`
1036
1304
  + ` · sources ${scoutCase.sources.length} / claims ${scoutCase.claims.length}`
1037
- + ` (verified ${verified}, open ${scoutCase.claims.length - verified})`,
1305
+ + ` (verified ${verified}, open ${scoutCase.claims.length - verified})`
1306
+ // Trailing, so the case facts stay the leading, greppable part of the
1307
+ // line. This is the one surface that can state the version: reaching
1308
+ // this branch already proves the plugin loaded.
1309
+ + ` · dsh-scout ${PLUGIN_VERSION}`,
1038
1310
  };
1039
1311
  }
1040
1312
  if (action === 'questions') {
@@ -1047,8 +1319,8 @@ export function apply(ctx, config = {}) {
1047
1319
  const fs = fsService();
1048
1320
  if (!fs)
1049
1321
  return { kind: 'error', text: 'Filesystem service is unavailable in this host' };
1050
- const targetDir = caseExportDir(scoutCase.caseId);
1051
- 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));
1052
1324
  cases.set(key, exported.scoutCase);
1053
1325
  return exported.persist.persisted
1054
1326
  ? { kind: 'success', text: `exported to ${exported.persist.targetDir}` }