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