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