dsh-scout 0.8.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,44 +1,239 @@
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, createCase, decideCase, DEFAULT_AUTHORITY_HOST_SUFFIXES, EVIDENCE_LEVELS, EXPORT_FILE_NAMES, exportCaseFiles, 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, importCaseFromFiles, inferEvidenceLevel, inferSourceType, renderComparison, renderReport, SOURCE_TYPES, verifyClaim, verifyIdentity, } from './model.js';
4
+ import { formatScoutString, isScoutLanguage, normalizeScoutLanguage, scoutStrings, } from './i18n.js';
4
5
  export const name = 'dsh-scout';
5
6
  export const inject = ['tools'];
6
7
  export const Config = z.object({
7
8
  scoutDir: z.string().default('dsh-scout'),
8
9
  autoPersist: z.boolean().default(false),
9
10
  authorityHostSuffixes: z.array(z.string()).default([...DEFAULT_AUTHORITY_HOST_SUFFIXES]),
11
+ language: z.union([z.const('zh-CN'), z.const('en')]).default('zh-CN'),
12
+ });
13
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
14
+ const IMPACT_RANK = { blocking: 0, material: 1, informational: 2 };
15
+ /** Up to three smallest next actions for open blocking/material claims. */
16
+ const deriveNextActions = (scoutCase) => [...scoutCase.claims]
17
+ .filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')
18
+ .sort((left, right) => IMPACT_RANK[left.impact] - IMPACT_RANK[right.impact])
19
+ .map(claim => claim.nextAction.trim())
20
+ .filter((action, index, all) => Boolean(action) && all.indexOf(action) === index)
21
+ .slice(0, 3);
22
+ 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
+ /** Model-facing render budget for one mutation summary, in UTF-8 bytes. */
43
+ const MAX_RENDER_BYTES = 1024;
44
+ /** Marks a clipped render. Its own byte cost is paid out of the same budget. */
45
+ const ELLIPSIS = '…';
46
+ /** Byte length of a JS string in UTF-8, without relying on Node globals. */
47
+ const utf8Length = (text) => {
48
+ let bytes = 0;
49
+ for (const character of text) {
50
+ const code = character.codePointAt(0) ?? 0;
51
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
52
+ }
53
+ return bytes;
54
+ };
55
+ /**
56
+ * Hard cap so an oversized free-text field can never blow the render budget.
57
+ * The returned string — including the ellipsis, whose UTF-8 cost is derived
58
+ * rather than assumed — is never longer than `limit` bytes. Cutting stops on a
59
+ * code-unit boundary; a lone surrogate left by slicing a pair still encodes as
60
+ * 3 bytes, so the measured length stays the conservative one.
61
+ */
62
+ const clampUtf8 = (text, limit) => {
63
+ if (utf8Length(text) <= limit)
64
+ return text;
65
+ const suffixBytes = utf8Length(ELLIPSIS);
66
+ let clipped = text;
67
+ while (clipped.length > 0 && utf8Length(clipped) > limit - suffixBytes)
68
+ clipped = clipped.slice(0, -1);
69
+ return `${clipped}${ELLIPSIS}`;
70
+ };
71
+ /** Per-field cap applied before the whole-render cap. */
72
+ const clipField = (text, limit) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
73
+ const formatEnvelope = (value) => {
74
+ const { counts, changed } = value;
75
+ const lines = [
76
+ `[${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)})` : ''}`,
78
+ ];
79
+ if (value.nextActions.length) {
80
+ lines.push(`next: ${value.nextActions.map(action => clipField(action, 140)).join(' | ')}`);
81
+ }
82
+ if (value.persist) {
83
+ lines.push(value.persist.persisted
84
+ ? `persisted: ${clipField(value.persist.targetDir, 160)}`
85
+ : `PERSIST FAILED (${clipField(value.persist.targetDir, 160)}): ${clipField(value.persist.error ?? 'unknown error', 160)}`);
86
+ }
87
+ if (value.errors?.length) {
88
+ lines.push(`rejected: ${value.errors.length} item(s) — first: ${clipField(value.errors[0].error, 160)}`);
89
+ }
90
+ if (value.sourceDir) {
91
+ lines.push(`imported from ${clipField(value.sourceDir, 160)} (complete: ${value.complete === true})`);
92
+ }
93
+ // The per-field caps keep this from triggering in practice; the whole-render
94
+ // cap is the guarantee the gate asserts. `clampUtf8` already budgets for the
95
+ // ellipsis it appends, so the call passes the budget itself — no slack magic
96
+ // number that a later edit could shrink below the suffix cost.
97
+ return clampUtf8(lines.join('\n'), MAX_RENDER_BYTES);
98
+ };
99
+ /** Envelope schema shared by every mutating tool. */
100
+ const mutationSchema = () => ({
101
+ type: 'object',
102
+ additionalProperties: false,
103
+ properties: {
104
+ ok: { type: 'boolean', required: true },
105
+ caseId: { type: 'string', required: true },
106
+ decision: {
107
+ type: 'string',
108
+ enum: ['PROCEED', 'VERIFY', 'STOP'],
109
+ required: true,
110
+ },
111
+ identity: {
112
+ type: 'string',
113
+ enum: ['verified', 'needs_verification'],
114
+ required: true,
115
+ },
116
+ counts: {
117
+ type: 'object',
118
+ additionalProperties: false,
119
+ required: true,
120
+ properties: {
121
+ sources: { type: 'integer', required: true },
122
+ claims: { type: 'integer', required: true },
123
+ verified: { type: 'integer', required: true },
124
+ open: { type: 'integer', required: true },
125
+ },
126
+ },
127
+ changed: {
128
+ type: 'object',
129
+ additionalProperties: false,
130
+ required: true,
131
+ properties: {
132
+ kind: {
133
+ type: 'string',
134
+ enum: [
135
+ 'case_started',
136
+ 'source_added',
137
+ 'sources_added',
138
+ 'claim_added',
139
+ 'claims_added',
140
+ 'identity_verified',
141
+ 'claim_verified',
142
+ 'case_imported',
143
+ 'case_rehydrated',
144
+ ],
145
+ required: true,
146
+ },
147
+ ids: { type: 'array', items: { type: 'string' }, required: true },
148
+ },
149
+ },
150
+ nextActions: { type: 'array', items: { type: 'string' }, required: true },
151
+ persist: {
152
+ type: 'object',
153
+ additionalProperties: false,
154
+ properties: {
155
+ persisted: { type: 'boolean', required: true },
156
+ targetDir: { type: 'string', required: true },
157
+ error: { type: 'string' },
158
+ },
159
+ },
160
+ errors: {
161
+ type: 'array',
162
+ items: {
163
+ type: 'object',
164
+ additionalProperties: false,
165
+ properties: {
166
+ url: { type: 'string', required: true },
167
+ error: { type: 'string', required: true },
168
+ },
169
+ },
170
+ },
171
+ sourceDir: { type: 'string' },
172
+ complete: { type: 'boolean' },
173
+ },
174
+ });
175
+ const mutationOutput = () => ({
176
+ schema: mutationSchema(),
177
+ render: (_args, value) => [
178
+ { type: 'text', text: formatEnvelope(value) },
179
+ ],
10
180
  });
11
181
  export function apply(ctx, config = {}) {
12
182
  const cases = new Map();
13
183
  const caseKey = (caseId, agentId) => `${String(agentId ?? 'unscoped')}::${caseId}`;
14
- const requireCase = (key) => {
15
- const scoutCase = cases.get(key);
16
- if (!scoutCase)
17
- throw new Error(`Unknown case: ${key.split('::').at(-1)}`);
18
- return scoutCase;
19
- };
20
- // Optional host services: unavailable hosts degrade gracefully (tools report
21
- // the missing service instead of failing to register).
22
- const fs = ctx.get('fs');
23
- const web = ctx.get('web');
184
+ const keyCaseId = (key) => key.slice(key.indexOf('::') + 2 || 0);
24
185
  const authoritySuffixes = config.authorityHostSuffixes ?? DEFAULT_AUTHORITY_HOST_SUFFIXES;
25
186
  const defaultScoutDir = config.scoutDir?.replace(/\/+$/, '') || 'dsh-scout';
26
187
  const caseExportDir = (caseId) => `${defaultScoutDir}/${caseId}`;
27
- const persistFiles = async (scoutCase, targetDir) => {
28
- if (!fs)
29
- return { persisted: false, targetDir, error: 'Filesystem service is unavailable in this host' };
30
- try {
31
- const files = exportCaseFiles(scoutCase);
32
- for (const name of EXPORT_FILE_NAMES) {
33
- const target = await fs.resolve(`${targetDir}/${name}`);
34
- await fs.writeText(target, files[name]);
35
- }
36
- return { persisted: true, targetDir };
37
- }
38
- catch (error) {
39
- return { persisted: false, targetDir, error: error instanceof Error ? error.message : String(error) };
40
- }
188
+ /** Resolved once from validated config; every renderer and decision follows it. */
189
+ const language = normalizeScoutLanguage(config.language);
190
+ const strings = scoutStrings(language);
191
+ /**
192
+ * Case-authored copy (objective, fit question, curated interview questions) is
193
+ * written in the language the case was created in. When this deployment
194
+ * renders in another language, the still-untouched factory defaults are
195
+ * swapped for this deployment's own defaults, so `case.json` never claims one
196
+ * language while carrying another language's prose. A value the user edited
197
+ * no longer equals a factory default and is deliberately left alone. Curated
198
+ * interview questions follow the same rule entry by entry while the set still
199
+ * has the factory length; a differently sized set is kept as authored.
200
+ */
201
+ const alignCaseDefaults = (scoutCase) => {
202
+ const declared = isScoutLanguage(scoutCase.language) ? scoutCase.language : undefined;
203
+ const source = scoutStrings(declared);
204
+ if (source.language === strings.language)
205
+ return scoutCase;
206
+ const swap = (current, from, to) => (current === from ? to : current);
207
+ const curated = scoutCase.interviewQuestions ?? [];
208
+ const factory = source.defaults.interviewQuestions;
209
+ const target = strings.defaults.interviewQuestions;
210
+ // Entry-by-entry: a question the user edited no longer equals its factory
211
+ // slot and is kept verbatim, while every untouched slot aligns. A set whose
212
+ // length differs from the factory array is taken as fully user-authored and
213
+ // preserved as a whole (there is no index to compare it against).
214
+ const alignedQuestions = curated.length === factory.length
215
+ ? curated.map((question, index) => (question === factory[index] ? target[index] : question))
216
+ : curated;
217
+ return {
218
+ ...scoutCase,
219
+ decisionObjective: swap(scoutCase.decisionObjective, source.defaults.decisionObjective, strings.defaults.decisionObjective),
220
+ role: {
221
+ ...scoutCase.role,
222
+ fitQuestion: swap(scoutCase.role.fitQuestion, source.defaults.fitQuestion, strings.defaults.fitQuestion),
223
+ },
224
+ interviewQuestions: alignedQuestions,
225
+ };
41
226
  };
227
+ const decide = (scoutCase) => ({
228
+ ...decideCase(alignCaseDefaults(scoutCase), { language }),
229
+ // Keep the case's own label in sync with the language this deployment renders in.
230
+ language,
231
+ });
232
+ const renderReportOf = (scoutCase) => renderReport(scoutCase, { language });
233
+ // Resolved per call so a later-mounted (or replaced) provider is honored.
234
+ const fsService = () => ctx.get('fs');
235
+ const webService = () => ctx.get('web');
236
+ const execCwd = (exec) => exec?.agent?.session?.header?.cwd;
42
237
  const recordEvent = (scoutCase, type, detail) => {
43
238
  const events = scoutCase.events ?? [];
44
239
  let n = events.length + 1;
@@ -62,54 +257,188 @@ export function apply(ctx, config = {}) {
62
257
  evidenceLevel: source.evidenceLevel,
63
258
  ingested: true,
64
259
  });
65
- const maybePersist = async (scoutCase) => {
260
+ /* -------------------------------------------------------------- *
261
+ * Durable export
262
+ *
263
+ * Order is "record the event, then write": the persisted
264
+ * `events.jsonl` must contain the export event it belongs to.
265
+ * A completion marker is written last so a torn export is
266
+ * distinguishable from a corrupt one.
267
+ * -------------------------------------------------------------- */
268
+ const EXPORT_MARKER_FILE = 'export.complete.json';
269
+ const EXPORT_MARKER_SCHEMA = 'dsh-scout.export.v1';
270
+ const directoryOf = (displayPath) => {
271
+ const cut = displayPath.lastIndexOf('/');
272
+ return cut > 0 ? displayPath.slice(0, cut) : displayPath;
273
+ };
274
+ /**
275
+ * Write the five export files plus the completion marker.
276
+ *
277
+ * The marker is invalidated *before* the payload is touched and only
278
+ * re-asserted after every payload file landed, so an interrupted export can
279
+ * never leave a stale `complete: true` beside mixed-generation files.
280
+ */
281
+ const persistFiles = async (scoutCase, targetDir, fs, cwd, signal) => {
282
+ try {
283
+ const files = exportCaseFiles(scoutCase, { language });
284
+ const marker = (complete) => `${JSON.stringify({
285
+ schemaVersion: EXPORT_MARKER_SCHEMA,
286
+ caseId: scoutCase.caseId,
287
+ complete,
288
+ files: [...EXPORT_FILE_NAMES],
289
+ eventCount: (scoutCase.events ?? []).length,
290
+ }, null, 2)}\n`;
291
+ let absoluteDir;
292
+ const write = async (fileName, content) => {
293
+ const target = await fs.resolve(`${targetDir}/${fileName}`, { cwd, signal });
294
+ if (typeof target?.displayPath === 'string')
295
+ absoluteDir = directoryOf(target.displayPath);
296
+ await fs.writeText(target, content, undefined, signal);
297
+ };
298
+ await write(EXPORT_MARKER_FILE, marker(false));
299
+ for (const fileName of EXPORT_FILE_NAMES) {
300
+ await write(fileName, files[fileName]);
301
+ }
302
+ await write(EXPORT_MARKER_FILE, marker(true));
303
+ return { persisted: true, targetDir: absoluteDir ?? targetDir };
304
+ }
305
+ catch (error) {
306
+ return { persisted: false, targetDir, error: messageOf(error) };
307
+ }
308
+ };
309
+ /**
310
+ * Persist a case for one tool call. On success the returned case carries
311
+ * the `case_exported` event that the written `events.jsonl` also has; on
312
+ * failure the event is dropped and the error is surfaced to the caller.
313
+ */
314
+ const exportCase = async (scoutCase, targetDir, fs, cwd, signal, auto) => {
315
+ const withEvent = recordEvent(scoutCase, 'case_exported', { targetDir, auto });
316
+ const persist = await persistFiles(withEvent, targetDir, fs, cwd, signal);
317
+ return { scoutCase: persist.persisted ? withEvent : scoutCase, persist };
318
+ };
319
+ const maybePersist = async (scoutCase, exec) => {
66
320
  if (!config.autoPersist)
67
- return scoutCase;
68
- const targetDir = caseExportDir(scoutCase.caseId);
69
- const withEvent = recordEvent(scoutCase, 'case_exported', { targetDir, auto: true });
70
- const result = await persistFiles(withEvent, targetDir);
71
- if (!result.persisted)
72
- return scoutCase;
73
- return withEvent;
321
+ return { scoutCase };
322
+ const fs = fsService();
323
+ if (!fs) {
324
+ return {
325
+ scoutCase,
326
+ persist: {
327
+ persisted: false,
328
+ targetDir: caseExportDir(scoutCase.caseId),
329
+ error: 'Filesystem service is unavailable in this host',
330
+ },
331
+ };
332
+ }
333
+ return exportCase(scoutCase, caseExportDir(scoutCase.caseId), fs, execCwd(exec), exec?.signal, true);
334
+ };
335
+ /* -------------------------------------------------------------- *
336
+ * Case lookup with lazy rehydration
337
+ * -------------------------------------------------------------- */
338
+ const readExportFiles = async (sourceDir, fs, cwd, signal) => {
339
+ const files = {};
340
+ for (const fileName of ['case.json', 'sources.json', 'claims.json', 'events.jsonl']) {
341
+ const target = await fs.resolve(`${sourceDir}/${fileName}`, { cwd, signal });
342
+ files[fileName] = await fs.readText(target, signal);
343
+ }
344
+ return files;
345
+ };
346
+ /**
347
+ * Read the completion marker. Existence is not enough: the marker is
348
+ * written with `complete: false` before an export starts, so a stale marker
349
+ * left by an interrupted export must not be read as a finished one.
350
+ */
351
+ const readCompletionMarker = async (sourceDir, fs, cwd, signal) => {
352
+ try {
353
+ const raw = await fs.readText(await fs.resolve(`${sourceDir}/${EXPORT_MARKER_FILE}`, { cwd, signal }), signal);
354
+ const parsed = JSON.parse(raw);
355
+ return parsed?.complete === true;
356
+ }
357
+ catch {
358
+ return false;
359
+ }
360
+ };
361
+ /**
362
+ * Restore a case from `<scoutDir>/<caseId>/` when the cache misses.
363
+ *
364
+ * Only a completed export is rehydrated: an interrupted one is reported by
365
+ * `scout_import` (which returns `complete: false`) rather than silently
366
+ * becoming this session's case state. Exports written before 0.9.1 have no
367
+ * marker at all and must be restored explicitly with `scout_import`.
368
+ */
369
+ const rehydrate = async (caseId, key, fs, cwd, signal) => {
370
+ const sourceDir = caseExportDir(caseId);
371
+ try {
372
+ if (!(await readCompletionMarker(sourceDir, fs, cwd, signal)))
373
+ return undefined;
374
+ const restored = importCaseFromFiles(await readExportFiles(sourceDir, fs, cwd, signal));
375
+ if (restored.caseId !== caseId)
376
+ return undefined;
377
+ const nextCase = decide(recordEvent(restored, 'case_rehydrated', { sourceDir }));
378
+ cases.set(key, nextCase);
379
+ return nextCase;
380
+ }
381
+ catch {
382
+ return undefined;
383
+ }
384
+ };
385
+ const requireCase = async (key, exec) => {
386
+ const existing = cases.get(key);
387
+ if (existing)
388
+ return existing;
389
+ const caseId = keyCaseId(key);
390
+ const fs = fsService();
391
+ if (fs) {
392
+ const restored = await rehydrate(caseId, key, fs, execCwd(exec), exec?.signal);
393
+ if (restored)
394
+ return restored;
395
+ }
396
+ const known = [...cases.keys()]
397
+ .filter(entry => entry.startsWith(`${String(exec?.agent?.id ?? 'unscoped')}::`))
398
+ .map(entry => keyCaseId(entry));
399
+ throw new Error(`Unknown case: ${caseId}. No in-memory case and no restorable export at ${caseExportDir(caseId)}.`
400
+ + (known.length ? ` Known cases in this session: ${known.join(', ')}.` : '')
401
+ + ' Use scout_start to create it or scout_import to restore it from a directory.');
74
402
  };
403
+ /* -------------------------------------------------------------- *
404
+ * Tools
405
+ * -------------------------------------------------------------- */
75
406
  ctx.effect(function* registerScoutTools() {
76
407
  yield () => cases.clear();
77
408
  yield ctx.tools.register(defineTool({
78
409
  name: 'scout_start',
79
- description: 'Start a company and job due-diligence case. The initial decision is VERIFY until identity evidence is confirmed.',
410
+ 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.',
80
411
  parameters: {
81
412
  caseId: { type: 'string', required: true, description: 'Stable case identifier.' },
82
413
  companyName: { type: 'string', required: true, description: 'Company or brand name to investigate.' },
83
414
  roleTitle: { type: 'string', required: true, description: 'Role title to evaluate.' },
84
415
  location: { type: 'string', required: true, description: 'Job location, or an empty string if unknown.' },
85
416
  },
86
- output: {
87
- schema: { type: 'string' },
88
- render: (_args, value) => [{ type: 'text', text: value }],
89
- },
417
+ output: mutationOutput(),
90
418
  async execute(args, exec) {
91
419
  const key = caseKey(args.caseId, exec.agent?.id);
92
420
  if (cases.has(key))
93
421
  throw new Error(`Case already exists: ${args.caseId}`);
94
- const scoutCase = createCase({
422
+ let nextCase = recordEvent(createCase({
95
423
  caseId: args.caseId,
96
424
  companyName: args.companyName,
97
425
  roleTitle: args.roleTitle,
98
426
  location: args.location || undefined,
99
- });
100
- let nextCase = recordEvent(scoutCase, 'case_started', {
427
+ language,
428
+ }), 'case_started', {
101
429
  companyName: args.companyName,
102
430
  roleTitle: args.roleTitle,
103
431
  location: args.location || null,
104
432
  });
105
- nextCase = await maybePersist(nextCase);
433
+ const persisted = await maybePersist(nextCase, exec);
434
+ nextCase = persisted.scoutCase;
106
435
  cases.set(key, nextCase);
107
- return JSON.stringify(nextCase, null, 2);
436
+ return buildEnvelope(nextCase, { kind: 'case_started', ids: [nextCase.caseId] }, { persist: persisted.persist });
108
437
  },
109
438
  }));
110
439
  yield ctx.tools.register(defineTool({
111
440
  name: 'scout_add_claim',
112
- description: 'Add a sourced or explicitly unverified claim to an active dsh-scout case.',
441
+ description: 'Add a sourced or explicitly unverified claim to an active dsh-scout case. Returns a compact status summary; use scout_report for the full ledger.',
113
442
  parameters: {
114
443
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
115
444
  claimId: { type: 'string', required: true, description: 'Stable claim identifier.' },
@@ -122,14 +451,11 @@ export function apply(ctx, config = {}) {
122
451
  confidenceNote: { type: 'string', required: true, description: 'Why the current evidence supports this status and level.' },
123
452
  nextAction: { type: 'string', required: true, description: 'Smallest next action to verify or use the claim.' },
124
453
  },
125
- output: {
126
- schema: { type: 'string' },
127
- render: (_args, value) => [{ type: 'text', text: value }],
128
- },
454
+ output: mutationOutput(),
129
455
  async execute(args, exec) {
130
456
  const key = caseKey(args.caseId, exec.agent?.id);
131
- const scoutCase = requireCase(key);
132
- let nextCase = recordEvent(decideCase(addClaim(scoutCase, {
457
+ const scoutCase = await requireCase(key, exec);
458
+ let nextCase = recordEvent(decide(addClaim(scoutCase, {
133
459
  claimId: args.claimId,
134
460
  text: args.text,
135
461
  status: args.status,
@@ -146,25 +472,23 @@ export function apply(ctx, config = {}) {
146
472
  dimension: args.dimension,
147
473
  impact: args.impact,
148
474
  });
149
- nextCase = await maybePersist(nextCase);
475
+ const persisted = await maybePersist(nextCase, exec);
476
+ nextCase = persisted.scoutCase;
150
477
  cases.set(key, nextCase);
151
- return JSON.stringify(nextCase, null, 2);
478
+ return buildEnvelope(nextCase, { kind: 'claim_added', ids: [args.claimId] }, { persist: persisted.persist });
152
479
  },
153
480
  }));
154
481
  yield ctx.tools.register(defineTool({
155
482
  name: 'scout_ingest',
156
- 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). Individual invalid items or rejected claims are reported in errors without aborting the batch; a failed claim does not undo its already-registered source.',
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.',
157
484
  parameters: {
158
485
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
159
486
  itemsJson: { type: 'string', required: true, description: 'JSON array of collected source items.' },
160
487
  },
161
- output: {
162
- schema: { type: 'string' },
163
- render: (_args, value) => [{ type: 'text', text: value }],
164
- },
488
+ output: mutationOutput(),
165
489
  async execute(args, exec) {
166
490
  const key = caseKey(args.caseId, exec.agent?.id);
167
- let scoutCase = requireCase(key);
491
+ let scoutCase = await requireCase(key, exec);
168
492
  let items;
169
493
  try {
170
494
  const parsed = JSON.parse(args.itemsJson);
@@ -173,10 +497,10 @@ export function apply(ctx, config = {}) {
173
497
  items = parsed;
174
498
  }
175
499
  catch (error) {
176
- throw new Error(`Invalid itemsJson: ${error instanceof Error ? error.message : String(error)}`);
500
+ throw new Error(`Invalid itemsJson: ${messageOf(error)}`);
177
501
  }
178
502
  const added = [];
179
- const errors = [];
503
+ const failures = [];
180
504
  let nextId = (scoutCase.sources.length ?? 0) + 1;
181
505
  let nextClaimId = (scoutCase.claims.length ?? 0) + 1;
182
506
  for (const rawItem of items) {
@@ -185,13 +509,13 @@ export function apply(ctx, config = {}) {
185
509
  : String(rawItem?.url ?? rawItem ?? '');
186
510
  try {
187
511
  if (!rawItem || typeof rawItem !== 'object') {
188
- errors.push({ url: String(rawItem ?? ''), error: 'item must be an object' });
512
+ failures.push({ url: String(rawItem ?? ''), error: 'item must be an object' });
189
513
  continue;
190
514
  }
191
515
  const item = rawItem;
192
516
  const url = typeof item.url === 'string' ? item.url.trim() : '';
193
517
  if (!url) {
194
- errors.push({ url: String(item.url ?? ''), error: 'url is required' });
518
+ failures.push({ url: String(item.url ?? ''), error: 'url is required' });
195
519
  continue;
196
520
  }
197
521
  const explicitType = typeof item.sourceType === 'string' && SOURCE_TYPES.includes(item.sourceType)
@@ -201,44 +525,43 @@ export function apply(ctx, config = {}) {
201
525
  ? item.evidenceLevel
202
526
  : undefined;
203
527
  if (item.sourceType !== undefined && item.sourceType !== null && !explicitType) {
204
- errors.push({ url, error: `invalid sourceType: ${String(item.sourceType)}` });
528
+ failures.push({ url, error: `invalid sourceType: ${String(item.sourceType)}` });
205
529
  continue;
206
530
  }
207
531
  if (item.evidenceLevel !== undefined && item.evidenceLevel !== null && !explicitLevel) {
208
- errors.push({ url, error: `invalid evidenceLevel: ${String(item.evidenceLevel)}` });
532
+ failures.push({ url, error: `invalid evidenceLevel: ${String(item.evidenceLevel)}` });
209
533
  continue;
210
534
  }
211
535
  const typeGuess = inferSourceType(url, explicitType, authoritySuffixes);
212
536
  const levelGuess = inferEvidenceLevel(url, typeGuess.type, explicitLevel, authoritySuffixes);
213
- let sourceId = `src-${nextId}`;
214
- while (scoutCase.sources.some(source => source.sourceId === sourceId)) {
537
+ const existing = findSourceByUrl(scoutCase, url);
538
+ let source;
539
+ if (existing) {
540
+ source = existing;
541
+ }
542
+ else {
543
+ let sourceId = `src-${nextId}`;
544
+ while (scoutCase.sources.some(candidate => candidate.sourceId === sourceId)) {
545
+ nextId += 1;
546
+ sourceId = `src-${nextId}`;
547
+ }
215
548
  nextId += 1;
216
- sourceId = `src-${nextId}`;
549
+ source = {
550
+ sourceId,
551
+ type: typeGuess.type,
552
+ title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
553
+ url,
554
+ capturedAt: new Date().toISOString(),
555
+ evidenceLevel: levelGuess.evidenceLevel,
556
+ status: 'captured',
557
+ };
558
+ scoutCase = registerSource(scoutCase, source);
217
559
  }
218
- nextId += 1;
219
- const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
220
- const source = {
221
- sourceId,
222
- type: typeGuess.type,
223
- title,
224
- url,
225
- capturedAt: new Date().toISOString(),
226
- evidenceLevel: levelGuess.evidenceLevel,
227
- status: 'captured',
228
- };
229
- scoutCase = registerSource(scoutCase, source);
230
- const addedEntry = {
231
- sourceId,
232
- title,
233
- url,
234
- type: typeGuess.type,
235
- evidenceLevel: levelGuess.evidenceLevel,
236
- inferred: { type: typeGuess.inferred, evidenceLevel: levelGuess.inferred },
237
- };
560
+ added.push(source.sourceId);
238
561
  const rawClaim = item.claim;
239
562
  if (rawClaim !== undefined && rawClaim !== null) {
240
563
  if (typeof rawClaim !== 'object') {
241
- errors.push({ url, error: 'claim must be an object' });
564
+ failures.push({ url, error: 'claim must be an object' });
242
565
  }
243
566
  else {
244
567
  const claimDraft = rawClaim;
@@ -270,11 +593,11 @@ export function apply(ctx, config = {}) {
270
593
  if (!dimension)
271
594
  claimIssues.push(`invalid claim.dimension: ${String(claimDraft.dimension)}`);
272
595
  if (claimIssues.length) {
273
- errors.push({ url, error: `claim rejected: ${claimIssues.join('; ')}` });
596
+ failures.push({ url, error: `claim rejected: ${claimIssues.join('; ')}` });
274
597
  }
275
598
  else {
276
599
  let claimId = `claim-${nextClaimId}`;
277
- while (scoutCase.claims.some(claim => claim.claimId === claimId)) {
600
+ while (scoutCase.claims.some(candidate => candidate.claimId === claimId)) {
278
601
  nextClaimId += 1;
279
602
  claimId = `claim-${nextClaimId}`;
280
603
  }
@@ -288,8 +611,8 @@ export function apply(ctx, config = {}) {
288
611
  dimension: dimension,
289
612
  impact: impact ?? 'material',
290
613
  sourceIds: [source.sourceId],
291
- confidenceNote: `由采集项自动登记,来源 ${source.sourceId}`,
292
- nextAction: '核验该主张并补充独立来源',
614
+ confidenceNote: formatScoutString(strings.ingest.autoConfidenceNote, { sourceId: source.sourceId }),
615
+ nextAction: strings.ingest.autoNextAction,
293
616
  }), 'claim_added', {
294
617
  claimId,
295
618
  status: status ?? 'reported',
@@ -300,107 +623,95 @@ export function apply(ctx, config = {}) {
300
623
  sourceIds: [source.sourceId],
301
624
  ingested: true,
302
625
  });
303
- addedEntry.claims = [claimId];
626
+ added.push(claimId);
304
627
  }
305
628
  catch (error) {
306
- errors.push({ url, error: `claim rejected: ${error instanceof Error ? error.message : String(error)}` });
629
+ failures.push({ url, error: `claim rejected: ${messageOf(error)}` });
307
630
  }
308
631
  }
309
632
  }
310
633
  }
311
- added.push(addedEntry);
312
634
  }
313
635
  catch (error) {
314
- errors.push({ url: itemUrl(), error: error instanceof Error ? error.message : String(error) });
636
+ failures.push({ url: itemUrl(), error: messageOf(error) });
315
637
  }
316
638
  }
317
- scoutCase = decideCase(scoutCase);
318
- scoutCase = await maybePersist(scoutCase);
639
+ scoutCase = decide(scoutCase);
640
+ const persisted = await maybePersist(scoutCase, exec);
641
+ scoutCase = persisted.scoutCase;
319
642
  cases.set(key, scoutCase);
320
- return JSON.stringify({ added, errors }, null, 2);
643
+ return buildEnvelope(scoutCase, { kind: 'sources_added', ids: added }, { persist: persisted.persist, errors: failures });
321
644
  },
322
645
  }));
323
646
  yield ctx.tools.register(defineTool({
324
647
  name: 'scout_search',
325
- description: 'Run a web search through the DSH web provider and auto-register the result URLs as sources for the case (type and evidence level inferred per URL). Optionally limits how many results are registered.',
648
+ description: 'Run a web search through the DSH web provider and auto-register the result URLs as sources for the case (type and evidence level inferred per URL; a URL already registered for the case is reused). Optionally limits how many results are registered.',
326
649
  parameters: {
327
650
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
328
651
  query: { type: 'string', required: true, description: 'Search query to run through the web provider.' },
329
652
  limit: { type: 'number', description: 'Maximum number of search results to register (default 5, capped at 10).' },
330
653
  },
331
- output: {
332
- schema: { type: 'string' },
333
- render: (_args, value) => [{ type: 'text', text: value }],
334
- },
654
+ output: mutationOutput(),
335
655
  async execute(args, exec) {
656
+ const web = webService();
336
657
  if (!web)
337
658
  throw new Error('Web search service is unavailable in this host');
338
659
  const key = caseKey(args.caseId, exec.agent?.id);
339
- let scoutCase = requireCase(key);
660
+ let scoutCase = await requireCase(key, exec);
340
661
  const parsedLimit = Math.trunc(args.limit ?? 5);
341
662
  const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10) : 5;
342
663
  const result = await web.search({ query: args.query, maxResults: limit }, exec.signal);
343
664
  const added = [];
344
- const errors = [];
665
+ const failures = [];
345
666
  const items = Array.isArray(result.sources) ? result.sources : [];
346
667
  if (!Array.isArray(result.sources)) {
347
- errors.push({ url: '', error: 'search provider returned a non-array sources field' });
668
+ failures.push({ url: '', error: 'search provider returned a non-array sources field' });
348
669
  }
349
670
  let nextId = (scoutCase.sources.length ?? 0) + 1;
350
671
  items.forEach((item, index) => {
351
672
  if (!item || typeof item !== 'object') {
352
- errors.push({ url: '', error: `search result ${index} must be an object` });
673
+ failures.push({ url: '', error: `search result ${index} must be an object` });
353
674
  return;
354
675
  }
355
676
  const url = typeof item.url === 'string' ? item.url.trim() : '';
356
677
  if (!url) {
357
- errors.push({ url: '', error: `search result ${index} missing url` });
678
+ failures.push({ url: '', error: `search result ${index} missing url` });
358
679
  return;
359
680
  }
360
681
  try {
682
+ const existing = findSourceByUrl(scoutCase, url);
683
+ if (existing) {
684
+ added.push(existing.sourceId);
685
+ return;
686
+ }
361
687
  const typeGuess = inferSourceType(url, undefined, authoritySuffixes);
362
688
  const levelGuess = inferEvidenceLevel(url, typeGuess.type, undefined, authoritySuffixes);
363
689
  let sourceId = `src-${nextId}`;
364
- while (scoutCase.sources.some(source => source.sourceId === sourceId)) {
690
+ while (scoutCase.sources.some(candidate => candidate.sourceId === sourceId)) {
365
691
  nextId += 1;
366
692
  sourceId = `src-${nextId}`;
367
693
  }
368
694
  nextId += 1;
369
- const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
370
695
  scoutCase = registerSource(scoutCase, {
371
696
  sourceId,
372
697
  type: typeGuess.type,
373
- title,
698
+ title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
374
699
  url,
375
700
  capturedAt: new Date().toISOString(),
376
701
  evidenceLevel: levelGuess.evidenceLevel,
377
702
  status: 'captured',
378
703
  });
379
- added.push({
380
- sourceId,
381
- title,
382
- url,
383
- type: typeGuess.type,
384
- evidenceLevel: levelGuess.evidenceLevel,
385
- snippet: typeof item.snippet === 'string' ? item.snippet.slice(0, 300) : null,
386
- publishedAt: typeof item.publishedAt === 'string' ? item.publishedAt : null,
387
- inferred: { type: typeGuess.inferred, evidenceLevel: levelGuess.inferred },
388
- });
704
+ added.push(sourceId);
389
705
  }
390
706
  catch (error) {
391
- errors.push({ url, error: error instanceof Error ? error.message : String(error) });
707
+ failures.push({ url, error: messageOf(error) });
392
708
  }
393
709
  });
394
- scoutCase = decideCase(scoutCase);
395
- scoutCase = await maybePersist(scoutCase);
710
+ scoutCase = decide(scoutCase);
711
+ const persisted = await maybePersist(scoutCase, exec);
712
+ scoutCase = persisted.scoutCase;
396
713
  cases.set(key, scoutCase);
397
- return JSON.stringify({
398
- query: args.query,
399
- truncated: Boolean(result.truncated),
400
- added,
401
- errors,
402
- content: typeof result.content === 'string' && result.content ? result.content.slice(0, 500) : null,
403
- }, null, 2);
714
+ return buildEnvelope(scoutCase, { kind: 'sources_added', ids: added }, { persist: persisted.persist, errors: failures });
404
715
  },
405
716
  }));
406
717
  yield ctx.tools.register(defineTool({
@@ -410,8 +721,21 @@ export function apply(ctx, config = {}) {
410
721
  caseIds: { type: 'string', required: true, description: 'Comma-separated case identifiers; at least two, at most five.' },
411
722
  },
412
723
  output: {
413
- schema: { type: 'string' },
414
- render: (_args, value) => [{ type: 'text', text: value }],
724
+ schema: {
725
+ type: 'object',
726
+ additionalProperties: false,
727
+ properties: {
728
+ caseIds: {
729
+ type: 'array',
730
+ items: { type: 'string' },
731
+ required: true,
732
+ },
733
+ report: { type: 'string', required: true },
734
+ },
735
+ },
736
+ render: (_args, value) => [
737
+ { type: 'text', text: value.report },
738
+ ],
415
739
  },
416
740
  async execute(args, exec) {
417
741
  const agentId = exec.agent?.id;
@@ -421,60 +745,112 @@ export function apply(ctx, config = {}) {
421
745
  const caseIds = [...new Set(rawIds)];
422
746
  if (caseIds.length < 2)
423
747
  throw new Error('Comparison requires at least two distinct case ids');
424
- const compared = caseIds.map(caseId => {
425
- try {
426
- return requireCase(caseKey(caseId, agentId));
427
- }
428
- catch (error) {
429
- throw new Error(`Unknown case: ${caseId} (not found in this agent session)`);
430
- }
431
- });
432
- return renderComparison(compared);
748
+ const compared = [];
749
+ for (const caseId of caseIds) {
750
+ compared.push(await requireCase(caseKey(caseId, agentId), exec));
751
+ }
752
+ return { caseIds, report: renderComparison(compared, { language }) };
433
753
  },
434
754
  }));
435
755
  yield ctx.tools.register(defineTool({
436
756
  name: 'scout_report',
437
- description: 'Render the current evidence-bounded report for a dsh-scout case.',
757
+ description: 'Render the full evidence-bounded report for a dsh-scout case. This is the only tool that returns the complete artifact; mutation tools return compact summaries.',
438
758
  parameters: {
439
759
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
440
760
  },
441
761
  output: {
442
- schema: { type: 'string' },
443
- render: (_args, value) => [{ type: 'text', text: value }],
762
+ schema: {
763
+ type: 'object',
764
+ additionalProperties: false,
765
+ properties: {
766
+ caseId: { type: 'string', required: true },
767
+ decision: {
768
+ type: 'string',
769
+ enum: ['PROCEED', 'VERIFY', 'STOP'],
770
+ required: true,
771
+ },
772
+ report: { type: 'string', required: true },
773
+ coverage: {
774
+ type: 'array',
775
+ required: true,
776
+ items: {
777
+ type: 'object',
778
+ additionalProperties: false,
779
+ properties: {
780
+ dimension: {
781
+ type: 'string',
782
+ enum: [...CLAIM_DIMENSIONS],
783
+ required: true,
784
+ },
785
+ verified: { type: 'integer', required: true },
786
+ open: { type: 'integer', required: true },
787
+ untouched: { type: 'boolean', required: true },
788
+ },
789
+ },
790
+ },
791
+ },
792
+ },
793
+ render: (_args, value) => [
794
+ { type: 'text', text: value.report },
795
+ ],
444
796
  },
445
797
  async execute(args, exec) {
446
798
  const key = caseKey(args.caseId, exec.agent?.id);
447
- return renderReport(requireCase(key));
799
+ const scoutCase = await requireCase(key, exec);
800
+ return {
801
+ caseId: scoutCase.caseId,
802
+ decision: scoutCase.decision,
803
+ report: renderReportOf(scoutCase),
804
+ coverage: computeCoverage(scoutCase),
805
+ };
448
806
  },
449
807
  }));
450
808
  yield ctx.tools.register(defineTool({
451
809
  name: 'scout_export',
452
- description: 'Persist a dsh-scout case as the durable five-file export (case.json, sources.json, claims.json, events.jsonl, report.md). targetDir is optional and defaults to the configured scoutDir (or ./dsh-scout) plus the case id.',
810
+ description: 'Persist a dsh-scout case as the durable five-file export (case.json, sources.json, claims.json, events.jsonl, report.md) plus a completion marker. targetDir is optional and defaults to the configured scoutDir (or ./dsh-scout) plus the case id. The returned targetDir is the resolved absolute directory.',
453
811
  parameters: {
454
812
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
455
813
  targetDir: { type: 'string', description: 'Directory path to write the five export files into; defaults to <scoutDir>/<caseId>.' },
456
814
  },
457
815
  output: {
458
- schema: { type: 'string' },
459
- render: (_args, value) => [{ type: 'text', text: value }],
816
+ schema: {
817
+ type: 'object',
818
+ additionalProperties: false,
819
+ properties: {
820
+ caseId: { type: 'string', required: true },
821
+ targetDir: { type: 'string', required: true },
822
+ files: { type: 'array', items: { type: 'string' }, required: true },
823
+ persisted: { type: 'boolean', required: true },
824
+ complete: { type: 'boolean', required: true },
825
+ error: { type: 'string' },
826
+ },
827
+ },
828
+ render: (_args, value) => [
829
+ {
830
+ type: 'text',
831
+ text: value.persisted
832
+ ? `exported to ${value.targetDir} (complete: ${value.complete})`
833
+ : `EXPORT FAILED (${value.targetDir}): ${value.error ?? 'unknown error'}`,
834
+ },
835
+ ],
460
836
  },
461
837
  async execute(args, exec) {
838
+ const fs = fsService();
839
+ if (!fs)
840
+ throw new Error('Filesystem service is unavailable in this host');
462
841
  const key = caseKey(args.caseId, exec.agent?.id);
463
- const scoutCase = requireCase(key);
842
+ const scoutCase = await requireCase(key, exec);
464
843
  const targetDir = args.targetDir || caseExportDir(scoutCase.caseId);
465
- const result = await persistFiles(scoutCase, targetDir);
466
- let nextCase = scoutCase;
467
- if (result.persisted) {
468
- nextCase = recordEvent(scoutCase, 'case_exported', { targetDir: result.targetDir });
469
- cases.set(key, nextCase);
470
- }
471
- return JSON.stringify({
472
- caseId: args.caseId,
473
- targetDir: result.targetDir,
474
- files: EXPORT_FILE_NAMES,
475
- persisted: result.persisted,
476
- ...(result.error ? { error: result.error } : {}),
477
- }, null, 2);
844
+ const exported = await exportCase(scoutCase, targetDir, fs, execCwd(exec), exec.signal, false);
845
+ cases.set(key, exported.scoutCase);
846
+ return {
847
+ caseId: scoutCase.caseId,
848
+ targetDir: exported.persist.targetDir,
849
+ files: [...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE],
850
+ persisted: exported.persist.persisted,
851
+ complete: exported.persist.persisted,
852
+ ...(exported.persist.error ? { error: exported.persist.error } : {}),
853
+ };
478
854
  },
479
855
  }));
480
856
  yield ctx.tools.register(defineTool({
@@ -484,44 +860,54 @@ export function apply(ctx, config = {}) {
484
860
  caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
485
861
  },
486
862
  output: {
487
- schema: { type: 'string' },
488
- render: (_args, value) => [{ type: 'text', text: value }],
863
+ schema: {
864
+ type: 'object',
865
+ additionalProperties: false,
866
+ properties: {
867
+ caseId: { type: 'string', required: true },
868
+ questions: { type: 'array', items: { type: 'string' }, required: true },
869
+ },
870
+ },
871
+ render: (_args, value) => [
872
+ {
873
+ type: 'text',
874
+ text: value.questions.length
875
+ ? value.questions.map((question, index) => `${index + 1}. ${question}`).join('\n')
876
+ : 'No interview questions derived yet.',
877
+ },
878
+ ],
489
879
  },
490
880
  async execute(args, exec) {
491
881
  const key = caseKey(args.caseId, exec.agent?.id);
492
- return JSON.stringify(generateInterviewQuestions(requireCase(key)), null, 2);
882
+ const scoutCase = await requireCase(key, exec);
883
+ return { caseId: scoutCase.caseId, questions: generateInterviewQuestions(scoutCase, { language }) };
493
884
  },
494
885
  }));
495
886
  yield ctx.tools.register(defineTool({
496
887
  name: 'scout_import',
497
- description: 'Restore a dsh-scout case from a five-file export directory, then recompute its decision.',
888
+ description: 'Restore a dsh-scout case from a five-file export directory, then recompute its decision. Exports written before 0.9.1 have no completion marker: they still import, but `complete` reports false.',
498
889
  parameters: {
499
890
  caseId: { type: 'string', required: true, description: 'Case identifier to restore; must match the exported case.json.' },
500
891
  sourceDir: { type: 'string', required: true, description: 'Directory path containing the five export files.' },
501
892
  },
502
- output: {
503
- schema: { type: 'string' },
504
- render: (_args, value) => [{ type: 'text', text: value }],
505
- },
893
+ output: mutationOutput(),
506
894
  async execute(args, exec) {
895
+ const fs = fsService();
507
896
  if (!fs)
508
897
  throw new Error('Filesystem service is unavailable in this host');
509
898
  const key = caseKey(args.caseId, exec.agent?.id);
510
- const read = async (name) => {
511
- const target = await fs.resolve(`${args.sourceDir}/${name}`);
512
- return fs.readText(target);
513
- };
514
- const files = {};
515
- for (const name of ['case.json', 'sources.json', 'claims.json', 'events.jsonl']) {
516
- files[name] = await read(name);
517
- }
518
- const restored = importCaseFromFiles(files);
899
+ const cwd = execCwd(exec);
900
+ const restored = importCaseFromFiles(await readExportFiles(args.sourceDir, fs, cwd, exec.signal));
519
901
  if (restored.caseId !== args.caseId) {
520
902
  throw new Error(`Case id mismatch: export has ${restored.caseId}, requested ${args.caseId}`);
521
903
  }
522
- const nextCase = decideCase(recordEvent(restored, 'case_imported', { sourceDir: args.sourceDir }));
904
+ const nextCase = decide(recordEvent(restored, 'case_imported', { sourceDir: args.sourceDir }));
523
905
  cases.set(key, nextCase);
524
- return JSON.stringify(nextCase, null, 2);
906
+ return {
907
+ ...buildEnvelope(nextCase, { kind: 'case_imported', ids: [nextCase.caseId] }),
908
+ sourceDir: args.sourceDir,
909
+ complete: await readCompletionMarker(args.sourceDir, fs, cwd, exec.signal),
910
+ };
525
911
  },
526
912
  }));
527
913
  yield ctx.tools.register(defineTool({
@@ -535,13 +921,10 @@ export function apply(ctx, config = {}) {
535
921
  url: { type: 'string', required: true, description: 'Source URL, or an empty string for local/user-provided material.' },
536
922
  evidenceLevel: { type: 'string', enum: EVIDENCE_LEVELS, required: true, description: 'Source quality from E0 to E3.' },
537
923
  },
538
- output: {
539
- schema: { type: 'string' },
540
- render: (_args, value) => [{ type: 'text', text: value }],
541
- },
924
+ output: mutationOutput(),
542
925
  async execute(args, exec) {
543
926
  const key = caseKey(args.caseId, exec.agent?.id);
544
- const scoutCase = requireCase(key);
927
+ const scoutCase = await requireCase(key, exec);
545
928
  const source = {
546
929
  sourceId: args.sourceId,
547
930
  type: args.sourceType,
@@ -555,9 +938,10 @@ export function apply(ctx, config = {}) {
555
938
  sourceId: source.sourceId,
556
939
  evidenceLevel: source.evidenceLevel,
557
940
  });
558
- nextCase = await maybePersist(nextCase);
941
+ const persisted = await maybePersist(nextCase, exec);
942
+ nextCase = persisted.scoutCase;
559
943
  cases.set(key, nextCase);
560
- return JSON.stringify(nextCase, null, 2);
944
+ return buildEnvelope(nextCase, { kind: 'source_added', ids: [args.sourceId] }, { persist: persisted.persist });
561
945
  },
562
946
  }));
563
947
  yield ctx.tools.register(defineTool({
@@ -572,14 +956,11 @@ export function apply(ctx, config = {}) {
572
956
  brandRelationship: { type: 'string', required: true, description: 'How the investigated brand relates to the legal entity.' },
573
957
  sourceIds: { type: 'string', required: true, description: 'Comma-separated identity sources including a linked authoritative E3 source.' },
574
958
  },
575
- output: {
576
- schema: { type: 'string' },
577
- render: (_args, value) => [{ type: 'text', text: value }],
578
- },
959
+ output: mutationOutput(),
579
960
  async execute(args, exec) {
580
961
  const key = caseKey(args.caseId, exec.agent?.id);
581
- const scoutCase = requireCase(key);
582
- let nextCase = recordEvent(decideCase(verifyIdentity(scoutCase, {
962
+ const scoutCase = await requireCase(key, exec);
963
+ let nextCase = recordEvent(decide(verifyIdentity(scoutCase, {
583
964
  legalEntity: args.legalEntity,
584
965
  registrationNumber: args.registrationNumber,
585
966
  registeredRegion: args.registeredRegion,
@@ -590,9 +971,10 @@ export function apply(ctx, config = {}) {
590
971
  legalEntity: args.legalEntity,
591
972
  registrationNumber: args.registrationNumber,
592
973
  });
593
- nextCase = await maybePersist(nextCase);
974
+ const persisted = await maybePersist(nextCase, exec);
975
+ nextCase = persisted.scoutCase;
594
976
  cases.set(key, nextCase);
595
- return JSON.stringify(nextCase, null, 2);
977
+ return buildEnvelope(nextCase, { kind: 'identity_verified', ids: [args.legalEntity] }, { persist: persisted.persist });
596
978
  },
597
979
  }));
598
980
  yield ctx.tools.register(defineTool({
@@ -606,14 +988,11 @@ export function apply(ctx, config = {}) {
606
988
  confidenceNote: { type: 'string', required: true, description: 'Why the new sources justify verified status.' },
607
989
  nextAction: { type: 'string', required: true, description: 'Smallest next action after verification.' },
608
990
  },
609
- output: {
610
- schema: { type: 'string' },
611
- render: (_args, value) => [{ type: 'text', text: value }],
612
- },
991
+ output: mutationOutput(),
613
992
  async execute(args, exec) {
614
993
  const key = caseKey(args.caseId, exec.agent?.id);
615
- const scoutCase = requireCase(key);
616
- let nextCase = recordEvent(decideCase(verifyClaim(scoutCase, {
994
+ const scoutCase = await requireCase(key, exec);
995
+ let nextCase = recordEvent(decide(verifyClaim(scoutCase, {
617
996
  claimId: args.claimId,
618
997
  evidenceLevel: args.evidenceLevel,
619
998
  sourceIds: args.sourceIds.split(',').map(value => value.trim()).filter(Boolean),
@@ -623,11 +1002,108 @@ export function apply(ctx, config = {}) {
623
1002
  claimId: args.claimId,
624
1003
  evidenceLevel: args.evidenceLevel,
625
1004
  });
626
- nextCase = await maybePersist(nextCase);
1005
+ const persisted = await maybePersist(nextCase, exec);
1006
+ nextCase = persisted.scoutCase;
627
1007
  cases.set(key, nextCase);
628
- return JSON.stringify(nextCase, null, 2);
1008
+ return buildEnvelope(nextCase, { kind: 'claim_verified', ids: [args.claimId] }, { persist: persisted.persist });
629
1009
  },
630
1010
  }));
631
1011
  }, 'dsh-scout tools');
1012
+ /* -------------------------------------------------------------- *
1013
+ * Host entry points: a `/scout` command and a registered Skill.
1014
+ * Both are optional services bound through `ctx.inject`, so a host
1015
+ * without them simply skips the registration.
1016
+ * -------------------------------------------------------------- */
1017
+ const runScoutCommand = async (invocation) => {
1018
+ const raw = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
1019
+ const [action = 'help', caseId = ''] = raw.split(/\s+/).filter(Boolean);
1020
+ if (action === 'help' || !caseId) {
1021
+ return {
1022
+ kind: 'success',
1023
+ text: 'usage: /scout status <caseId> | /scout questions <caseId> | /scout report <caseId> | /scout export <caseId> [targetDir]',
1024
+ };
1025
+ }
1026
+ 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.` };
1030
+ }
1031
+ if (action === 'status') {
1032
+ const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
1033
+ return {
1034
+ kind: 'success',
1035
+ text: `[${scoutCase.caseId}] ${scoutCase.decision} · identity ${scoutCase.subject.identityStatus}`
1036
+ + ` · sources ${scoutCase.sources.length} / claims ${scoutCase.claims.length}`
1037
+ + ` (verified ${verified}, open ${scoutCase.claims.length - verified})`,
1038
+ };
1039
+ }
1040
+ if (action === 'questions') {
1041
+ return { kind: 'success', text: generateInterviewQuestions(scoutCase, { language }).map((q, i) => `${i + 1}. ${q}`).join('\n') };
1042
+ }
1043
+ if (action === 'report') {
1044
+ return { kind: 'success', text: renderReportOf(scoutCase) };
1045
+ }
1046
+ if (action === 'export') {
1047
+ const fs = fsService();
1048
+ if (!fs)
1049
+ 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);
1052
+ cases.set(key, exported.scoutCase);
1053
+ return exported.persist.persisted
1054
+ ? { kind: 'success', text: `exported to ${exported.persist.targetDir}` }
1055
+ : { kind: 'error', text: `export failed (${exported.persist.targetDir}): ${exported.persist.error ?? 'unknown error'}` };
1056
+ }
1057
+ return { kind: 'error', text: `Unknown /scout action: ${action}` };
1058
+ };
1059
+ const SCOUT_SKILL_CONTENT = [
1060
+ '# dsh-scout (司察) due-diligence workflow',
1061
+ '',
1062
+ 'Use the scout tools when the user wants to judge whether a company and a role are worth',
1063
+ 'pursuing, and what must be verified before deciding.',
1064
+ '',
1065
+ '## Order',
1066
+ '1. `scout_start` — create the case. The decision starts at VERIFY.',
1067
+ '2. `scout_search` / `scout_ingest` / `scout_add_source` — collect and register sources.',
1068
+ '3. `scout_add_claim` — attach evidence-bounded claims (batch drafts come from `scout_ingest`).',
1069
+ '4. `scout_verify_identity` — confirm the legal entity from an E3 registry/regulator source.',
1070
+ '5. `scout_verify_claim` — promote a claim once stronger sources exist.',
1071
+ '6. `scout_report` / `scout_questions` — the full report and the interview question list.',
1072
+ '7. `scout_export` — persist the five-file export; `scout_import` restores it later.',
1073
+ '',
1074
+ '## Evidence discipline (enforced by the tools, do not try to bypass)',
1075
+ '1. A claim may never cite a higher evidence level than its strongest source.',
1076
+ '2. A high-impact claim with no source must stay `unknown` or `needs_verification`.',
1077
+ '3. `scout_verify_identity` requires a linked E3 registry/regulator/official-filing source over HTTPS.',
1078
+ '4. The decision stays VERIFY while the identity is unverified or a blocking/material claim is open.',
1079
+ '',
1080
+ '## Reading tool output',
1081
+ 'Mutation tools return a compact status summary (decision, counts, what changed, next actions).',
1082
+ 'Call `scout_report` when the full ledger is actually needed instead of re-reading every mutation.',
1083
+ ].join('\n');
1084
+ const bindOptional = (serviceName, install) => {
1085
+ ctx.inject([serviceName], (child) => {
1086
+ const service = child[serviceName];
1087
+ if (!service)
1088
+ return;
1089
+ child.effect(() => install(service), `dsh-scout ${serviceName}`);
1090
+ });
1091
+ };
1092
+ bindOptional('commands', service => service.register({
1093
+ name: 'scout',
1094
+ description: 'Inspect a dsh-scout case: /scout status|questions|report|export <caseId>',
1095
+ handler: runScoutCommand,
1096
+ }));
1097
+ bindOptional('skills', service => service.register({
1098
+ name: 'scout',
1099
+ description: 'Evidence-driven company and job due diligence with the dsh-scout tools.',
1100
+ whenToUse: 'When the user asks whether a company/role is worth pursuing, or wants a background check with sources and interview questions.',
1101
+ content: SCOUT_SKILL_CONTENT,
1102
+ // `SkillRegistration` keeps `source` required (only invocation/provider are
1103
+ // optional); the registry defaults provider but never source, so omitting it
1104
+ // yields a summary whose declared non-optional `source` reads back undefined.
1105
+ source: 'runtime',
1106
+ invocation: { modelInvocable: true, userInvocable: true },
1107
+ }));
632
1108
  }
633
1109
  export * from './model.js';