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