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
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
* `DSH_VERSIONS=0.2.0-rc.2 node scripts/check-release.mjs`)
|
|
25
25
|
* DSH_ALLOW_OUT_OF_RANGE `1` downgrades out-of-range failures to warnings
|
|
26
26
|
*
|
|
27
|
+
* README preflight (P2-4): the READMEs pin the released version in their install
|
|
28
|
+
* commands and artifact names, and nothing used to check them. The primary guard
|
|
29
|
+
* lives in `tests/package.test.mjs` (it runs on every `npm test` and in CI, in
|
|
30
|
+
* milliseconds); this gate repeats the check as an independent second
|
|
31
|
+
* implementation so a stale README fails in milliseconds instead of after the
|
|
32
|
+
* whole unit suite and the ~2 minute four-version profile matrix below.
|
|
33
|
+
*
|
|
27
34
|
* SemVer semantics (do NOT "fix" this matcher): this script has no runtime
|
|
28
35
|
* dependency, so it carries a small comparator-set matcher that mirrors
|
|
29
36
|
* node-semver's `{ includePrerelease: true }` mode — the exact mode DSH uses to
|
|
@@ -196,6 +203,227 @@ console.log(' range semantics: node-semver includePrerelease:true (mirrors dsh-
|
|
|
196
203
|
console.log(` profile matrix: ${versions.join(', ')} (${source})`)
|
|
197
204
|
console.log(` out-of-range failures: ${ALLOW_OUT_OF_RANGE ? 'warn only (DSH_ALLOW_OUT_OF_RANGE=1)' : 'fatal, reported as OUT OF DECLARED RANGE'}`)
|
|
198
205
|
|
|
206
|
+
// --- README release-reference preflight (see the module doc) ------------------
|
|
207
|
+
//
|
|
208
|
+
// Deliberately a second, simpler implementation of the guard in
|
|
209
|
+
// tests/package.test.mjs rather than a shared import: tests are not a library, and
|
|
210
|
+
// this one has to fail before the expensive work below. Same rule as that guard -
|
|
211
|
+
// only install/artifact references are checked: `dsh-scout-<v>.tgz`, the
|
|
212
|
+
// `dsh-scout@<v>` pin in its three live forms (install operand, alone on its
|
|
213
|
+
// line, or a quoted version key), the ` · dsh-scout <v>` status banner, a
|
|
214
|
+
// `#v<v>` git ref, and the phrase-anchored `本次发布创建 \`v<v>\`` /
|
|
215
|
+
// `this release creates \`v<v>\`` tag claim. Historical mentions in the
|
|
216
|
+
// verified-version matrix, the roadmap, and release prose are left alone.
|
|
217
|
+
// 0.9.3 freezes this rule set: a new version-reference style is evaluated in the
|
|
218
|
+
// change that introduces it, not by widening this list indefinitely.
|
|
219
|
+
const README_FILES = ['README.md', 'README.en.md']
|
|
220
|
+
const README_SEMVER = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`
|
|
221
|
+
/** An exact specifier: `1.2.3` or `v1.2.3`. */
|
|
222
|
+
const README_EXACT_SPECIFIER = new RegExp(`^v?(${README_SEMVER})$`)
|
|
223
|
+
/**
|
|
224
|
+
* A specifier naming a version *or a range* (`0.9`, `0.9.x`, `^0.9.2`, `>=0.9.2`,
|
|
225
|
+
* `=0.9.2`, `*`). Ranges are live references node-semver satisfies, but this
|
|
226
|
+
* preflight carries no range semantics, so it fails closed rather than waving
|
|
227
|
+
* them through (F-1). Dist-tags and placeholders name no version and stay valid.
|
|
228
|
+
*/
|
|
229
|
+
const README_VERSION_OR_RANGE = /^[\^~<>=|\s]*(?:v?\d|\*|x)/i
|
|
230
|
+
/** Exact version, unsupported specifier, or not a version claim at all. */
|
|
231
|
+
const classifySpecifier = token => {
|
|
232
|
+
const exact = README_EXACT_SPECIFIER.exec(token)
|
|
233
|
+
if (exact !== null) return { version: exact[1] }
|
|
234
|
+
if (README_VERSION_OR_RANGE.test(token)) {
|
|
235
|
+
return {
|
|
236
|
+
problem:
|
|
237
|
+
`uses the unsupported specifier "${token}"; a range or comparison cannot be compared to ` +
|
|
238
|
+
'the released version - write the exact version, or extend README_REFERENCE_PATTERNS ' +
|
|
239
|
+
'with the semantics of this form',
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
const README_REFERENCE_PATTERNS = [
|
|
245
|
+
{
|
|
246
|
+
kind: 'tarball name',
|
|
247
|
+
pattern: new RegExp(`dsh-scout-(${README_SEMVER})\\.tgz`, 'g'),
|
|
248
|
+
classify: match => ({ version: match[1] }),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
kind: 'npm install pin',
|
|
252
|
+
// Token charset excludes sentence punctuation, so `dsh-scout@0.9.3。` still
|
|
253
|
+
// reads as an exact pin while `dsh-scout@^0.9.2` is captured whole.
|
|
254
|
+
pattern: new RegExp('dsh-scout@([0-9A-Za-z.*^~<>=|+-]+)', 'g'),
|
|
255
|
+
classify: (match, line) => {
|
|
256
|
+
const before = line.slice(0, match.index)
|
|
257
|
+
const after = line.slice(match.index + match[0].length)
|
|
258
|
+
// the operand of an install verb ...
|
|
259
|
+
const livePosition =
|
|
260
|
+
/(?:^|[\s`'"(])(?:add|install|dlx|i)\s+(?:--?[\w-]+(?:=\S+)?\s+)*(?:file:)?$/.test(before.replace(/[`"')\]]+$/, '')) ||
|
|
261
|
+
// ... a specifier alone on its line (the desktop app's plugin field) ...
|
|
262
|
+
(before.replace(/^[\s>]*(?:[-*+]\s+)?/, '') === '' && after.replace(/[`"')\]]+$/, '').trim() === '') ||
|
|
263
|
+
// ... or a quoted `"package@version"` key (compatibility.json).
|
|
264
|
+
(/["']$/.test(before) && /^["']\s*:/.test(after))
|
|
265
|
+
return livePosition ? classifySpecifier(match[1]) : null
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
kind: 'git install ref',
|
|
270
|
+
pattern: new RegExp(`dsh-scout(?:\\.git)?#v?(${README_SEMVER})`, 'g'),
|
|
271
|
+
classify: match => ({ version: match[1] }),
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
// ` · dsh-scout <v>` - the /scout status banner (src/index.ts appends
|
|
275
|
+
// ` · dsh-scout ${PLUGIN_VERSION}`); the separator is required so that prose
|
|
276
|
+
// and report titles naming the package next to a version are not pins.
|
|
277
|
+
kind: 'status banner',
|
|
278
|
+
pattern: new RegExp(`·\\s*dsh-scout (${README_SEMVER})`, 'g'),
|
|
279
|
+
classify: match => ({ version: match[1] }),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
// `本次发布创建 \`v<v>\`` / `this release creates \`v<v>\`` - the tag this
|
|
283
|
+
// release creates. The phrase is what makes a bare `v<semver>` safe to guard
|
|
284
|
+
// here: the same line also lists historical tags (`v0.3.0` ... `v0.9.2`).
|
|
285
|
+
kind: 'release tag claim',
|
|
286
|
+
pattern: new RegExp(`(?:本次发布创建|this release creates)\\s*\`?v?(${README_SEMVER})\`?`, 'g'),
|
|
287
|
+
classify: match => ({ version: match[1] }),
|
|
288
|
+
},
|
|
289
|
+
]
|
|
290
|
+
const expectedVersion = packageJson.version
|
|
291
|
+
const staleReferences = []
|
|
292
|
+
const reportedLines = new Set()
|
|
293
|
+
const anchorFailures = []
|
|
294
|
+
const releaseTagClaimFiles = new Set()
|
|
295
|
+
let readmeReferenceCount = 0
|
|
296
|
+
for (const file of README_FILES) {
|
|
297
|
+
const text = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
|
|
298
|
+
text.split('\n').forEach((line, lineIndex) => {
|
|
299
|
+
for (const { kind, pattern, classify } of README_REFERENCE_PATTERNS) {
|
|
300
|
+
pattern.lastIndex = 0
|
|
301
|
+
let match
|
|
302
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
303
|
+
const verdict = classify(match, line)
|
|
304
|
+
if (verdict === null) continue
|
|
305
|
+
if (kind === 'release tag claim') releaseTagClaimFiles.add(file)
|
|
306
|
+
readmeReferenceCount += 1
|
|
307
|
+
if (verdict.problem !== undefined) {
|
|
308
|
+
staleReferences.push(`${file}:${lineIndex + 1} ${match[0]} ${verdict.problem} (${kind})`)
|
|
309
|
+
// Only *offending* lines are deduped, so the section scan below still
|
|
310
|
+
// sees a stale bare token on a line that also carries a correct pin
|
|
311
|
+
// (F-3): this matches guardFailures() in tests/package.test.mjs.
|
|
312
|
+
reportedLines.add(`${file}:${lineIndex + 1}`)
|
|
313
|
+
} else if (verdict.version !== expectedVersion) {
|
|
314
|
+
staleReferences.push(
|
|
315
|
+
`${file}:${lineIndex + 1} pins ${match[0]} but package.json is ${expectedVersion} (${kind})`,
|
|
316
|
+
)
|
|
317
|
+
reportedLines.add(`${file}:${lineIndex + 1}`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The release-tag claim is phrase-anchored, so rewording the sentence would stop
|
|
325
|
+
// guarding it silently. Asserted per file: a rule that can no longer match
|
|
326
|
+
// anything is the false green this preflight exists to prevent.
|
|
327
|
+
for (const file of README_FILES) {
|
|
328
|
+
if (!releaseTagClaimFiles.has(file)) {
|
|
329
|
+
anchorFailures.push(
|
|
330
|
+
`${file}: no "release tag claim" reference matched - either the sentence that creates the ` +
|
|
331
|
+
'release tag was reworded (update the phrase in README_REFERENCE_PATTERNS here and in ' +
|
|
332
|
+
'tests/package.test.mjs) or it was removed (retire this rule deliberately with the docs owner)',
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- success-criteria section scan (T-G) --------------------------------------
|
|
338
|
+
//
|
|
339
|
+
// The success-criteria section says how to confirm that *this* release is the one
|
|
340
|
+
// that loaded, so every version token inside it must be the released version -
|
|
341
|
+
// including the bare forms the pattern scan above cannot claim safely (the card's
|
|
342
|
+
// `v0.9.3` tag, `and version `0.9.3``), because a bare `v<semver>` elsewhere is
|
|
343
|
+
// legitimate history (the release-history line lists `v0.3.0` ... `v0.9.2`).
|
|
344
|
+
//
|
|
345
|
+
// The anchors are explicit and a miss is a FAILURE, never a skip: a silently
|
|
346
|
+
// skipped section would let a reworded heading disarm the guard, which is exactly
|
|
347
|
+
// the false green this preflight exists to prevent.
|
|
348
|
+
const SECTION_ANCHORS = [
|
|
349
|
+
{ file: 'README.md', heading: /^(#{2,4})\s*装上了吗(成功判据)$/, label: '装上了吗(成功判据)' },
|
|
350
|
+
{ file: 'README.en.md', heading: /^(#{2,4})\s*Did it install\? \(success criteria\)$/, label: 'Did it install? (success criteria)' },
|
|
351
|
+
]
|
|
352
|
+
// `@` is deliberately not excluded: a prose reference inside the section such as
|
|
353
|
+
// `` 安装 `dsh-scout@0.9.2` 后 `` names a version the pattern scan does not claim
|
|
354
|
+
// (it is not in an install position) and the section contract still covers it
|
|
355
|
+
// (F-2). `-` stays excluded so a tarball name is left to the pattern scan.
|
|
356
|
+
const SECTION_VERSION_TOKEN = /(?<![\w./-])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?![\w.-])/g
|
|
357
|
+
const SECTION_ANCHOR_REMEDY =
|
|
358
|
+
'the success-criteria heading was reworded or moved: update SECTION_ANCHORS here and ' +
|
|
359
|
+
'SUCCESS_CRITERIA_SECTIONS in tests/package.test.mjs. This preflight must never skip it silently.'
|
|
360
|
+
let sectionTokenCount = 0
|
|
361
|
+
/** Section body bounds for one heading anchor; null when the anchor no longer matches. */
|
|
362
|
+
const sectionBounds = (lines, heading) => {
|
|
363
|
+
let fenced = false
|
|
364
|
+
let start = -1
|
|
365
|
+
let level = 0
|
|
366
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
367
|
+
const trimmed = lines[index].trim()
|
|
368
|
+
// A `# ...` shell comment inside the section's fenced CLI example is body
|
|
369
|
+
// text, not a heading, so fences are tracked while looking for the end.
|
|
370
|
+
if (/^(?:```|~~~)/.test(trimmed)) {
|
|
371
|
+
fenced = !fenced
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
if (fenced) continue
|
|
375
|
+
const marker = /^(#{1,6})\s/.exec(trimmed)
|
|
376
|
+
if (start === -1) {
|
|
377
|
+
if (heading.test(trimmed)) {
|
|
378
|
+
start = index
|
|
379
|
+
level = /^#+/.exec(trimmed)[0].length
|
|
380
|
+
}
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
if (marker !== null && marker[1].length <= level) return { start, end: index }
|
|
384
|
+
}
|
|
385
|
+
return start === -1 ? null : { start, end: lines.length }
|
|
386
|
+
}
|
|
387
|
+
for (const { file, heading, label } of SECTION_ANCHORS) {
|
|
388
|
+
const lines = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').split('\n')
|
|
389
|
+
const section = sectionBounds(lines, heading)
|
|
390
|
+
if (section === null) {
|
|
391
|
+
anchorFailures.push(`${file}: no heading matches "${label}" - ${SECTION_ANCHOR_REMEDY}`)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
for (let index = section.start + 1; index < section.end; index += 1) {
|
|
395
|
+
const line = lines[index]
|
|
396
|
+
if (reportedLines.has(`${file}:${index + 1}`)) continue
|
|
397
|
+
SECTION_VERSION_TOKEN.lastIndex = 0
|
|
398
|
+
let match
|
|
399
|
+
while ((match = SECTION_VERSION_TOKEN.exec(line)) !== null) {
|
|
400
|
+
sectionTokenCount += 1
|
|
401
|
+
readmeReferenceCount += 1
|
|
402
|
+
if (match[1] !== expectedVersion) {
|
|
403
|
+
staleReferences.push(
|
|
404
|
+
`${file}:${index + 1} pins ${match[0]} in the success-criteria section but package.json is ${expectedVersion}`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (staleReferences.length > 0 || anchorFailures.length > 0) {
|
|
412
|
+
console.error('')
|
|
413
|
+
if (staleReferences.length > 0) {
|
|
414
|
+
console.error(`README release references are stale (package.json is ${expectedVersion}):`)
|
|
415
|
+
for (const reference of staleReferences) console.error(` ${reference}`)
|
|
416
|
+
}
|
|
417
|
+
if (anchorFailures.length > 0) {
|
|
418
|
+
console.error('README guard anchors/phrases no longer resolve:')
|
|
419
|
+
for (const failure of anchorFailures) console.error(` ${failure}`)
|
|
420
|
+
}
|
|
421
|
+
console.error(`dsh-scout release gate: FAIL - bump the install/artifact references before releasing`)
|
|
422
|
+
console.error('(the same rule is asserted on every `npm test` in tests/package.test.mjs)')
|
|
423
|
+
process.exit(1)
|
|
424
|
+
}
|
|
425
|
+
console.log(` readme release references: ${readmeReferenceCount} checked (${sectionTokenCount} inside the success-criteria section), all pinned to ${expectedVersion}`)
|
|
426
|
+
|
|
199
427
|
run('npm', ['test'])
|
|
200
428
|
|
|
201
429
|
const regressions = []
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* (launcher service) and the `fs` seam are available, and the plugin
|
|
13
13
|
* disposer runs on unload (`DSH_SCOUT_UNLOAD`);
|
|
14
14
|
* 3. REAL TOOL CALLS through the public runtime API
|
|
15
|
-
* `ctx.tools.execute(
|
|
15
|
+
* `ctx.tools.execute()` (legacy single call or executor batch), in a SIX-STEP
|
|
16
16
|
* scenario chain run across up to six real host processes:
|
|
17
17
|
* STEP 1 create: `scout_start` + `scout_add_source` (E3 registry origin)
|
|
18
18
|
* + `scout_add_claim` → each `isError === false`; `scout_start`
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
* exported `case.json` equals the decision reported before the restart and
|
|
58
58
|
* after rehydration and after the cross-language import.
|
|
59
59
|
*
|
|
60
|
-
* RUNTIME NOTE — failures arrive as RESULTS
|
|
60
|
+
* RUNTIME NOTE — tool failures arrive as RESULTS on the legacy API (measured on
|
|
61
61
|
* dsh 0.2.0-rc.2): a tool body that violates its declared `output.schema` does
|
|
62
62
|
* not reject `execute()`. The registry converts it to
|
|
63
63
|
* `{ isError: true, error: { message, info: { name: 'ToolOutputError',
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
* `toolErrorResult`, `lib/index.js:3541-3544` / `:3616-3622`). An unknown tool
|
|
66
66
|
* name behaves the same way with `ToolNotFoundError` / `UNKNOWN_TOOL`. Assertions
|
|
67
67
|
* must therefore inspect `result.isError` + `result.error.info`, and must not
|
|
68
|
-
* rely on `try`/`catch` around `execute()` to detect a rejected call.
|
|
68
|
+
* rely on `try`/`catch` around `execute()` to detect a rejected call. Batch
|
|
69
|
+
* infrastructure errors can reject; their bounded cause and partial call IDs are logged.
|
|
69
70
|
*
|
|
70
71
|
* WHAT THIS GATE DOES NOT COVER — read before trusting a green run:
|
|
71
72
|
* - no LLM and no agent loop: `execute()` runs with `agent` unset, so
|
|
@@ -125,6 +126,7 @@
|
|
|
125
126
|
* directory, and the temporary DSH home is removed in `finally`.
|
|
126
127
|
*/
|
|
127
128
|
import assert from 'node:assert/strict'
|
|
129
|
+
import { executeSmokeTool, describeSmokeError } from './smoke-tool-call.mjs'
|
|
128
130
|
import { spawnSync } from 'node:child_process'
|
|
129
131
|
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
|
130
132
|
import { tmpdir } from 'node:os'
|
|
@@ -287,6 +289,8 @@ function describeCall(entry, extra = []) {
|
|
|
287
289
|
if (entry?.skipped) lines.push(`skipped: ${entry.skipped}`)
|
|
288
290
|
if (result?.outcome === 'threw') {
|
|
289
291
|
lines.push(`threw: ${result.error?.name ?? 'Error'}: ${result.error?.message ?? '(no message)'}`)
|
|
292
|
+
if (result.error?.cause) lines.push(`cause: ${JSON.stringify(result.error.cause)}`)
|
|
293
|
+
if (result.error?.partial) lines.push(`partial: ${JSON.stringify(result.error.partial)}`)
|
|
290
294
|
if (result.error?.violations) lines.push(`violations: ${JSON.stringify(result.error.violations)}`)
|
|
291
295
|
if (result.error?.code) lines.push(`code: ${result.error.code}`)
|
|
292
296
|
} else if (result) {
|
|
@@ -366,12 +370,8 @@ const emit = (kind, payload) => {
|
|
|
366
370
|
process.stdout.write('${GATE_PREFIX}' + JSON.stringify(Object.assign({ kind: kind }, payload)) + '\\n')
|
|
367
371
|
}
|
|
368
372
|
|
|
369
|
-
const describeError =
|
|
370
|
-
|
|
371
|
-
message: error && error.message ? error.message : String(error),
|
|
372
|
-
code: error && error.code ? error.code : null,
|
|
373
|
-
violations: error && Array.isArray(error.violations) ? error.violations : null,
|
|
374
|
-
})
|
|
373
|
+
const describeError = ${describeSmokeError.toString()}
|
|
374
|
+
const executeSmokeTool = ${executeSmokeTool.toString()}
|
|
375
375
|
|
|
376
376
|
const waitForTool = async (ctx, name, deadline) => {
|
|
377
377
|
for (;;) {
|
|
@@ -385,12 +385,9 @@ const callTool = async (ctx, name, args) => {
|
|
|
385
385
|
callSequence += 1
|
|
386
386
|
const callId = 'smoke-' + name + '-' + callSequence
|
|
387
387
|
try {
|
|
388
|
-
const result = await
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
arguments: args,
|
|
392
|
-
signal: new AbortController().signal,
|
|
393
|
-
})
|
|
388
|
+
const result = await executeSmokeTool(
|
|
389
|
+
ctx.tools, callId, name, args, new AbortController().signal,
|
|
390
|
+
)
|
|
394
391
|
const content = result.content === undefined ? null : result.content
|
|
395
392
|
return {
|
|
396
393
|
outcome: 'returned',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Standalone helpers also embedded verbatim in the real-host verifier. */
|
|
2
|
+
export async function executeSmokeTool(tools, callId, name, args, signal) {
|
|
3
|
+
// setExecutor is the public provider seam in the batch API (0.2.2-alpha.2).
|
|
4
|
+
// Select before dispatch: retrying a failed mutation with another shape is unsafe.
|
|
5
|
+
const batchApi = typeof tools.setExecutor === 'function'
|
|
6
|
+
let result
|
|
7
|
+
if (batchApi) {
|
|
8
|
+
const batch = await tools.execute({
|
|
9
|
+
calls: [{ type: 'tool-call', id: callId, name, arguments: JSON.stringify(args) }],
|
|
10
|
+
signal,
|
|
11
|
+
})
|
|
12
|
+
if (!Array.isArray(batch?.results) || batch.results.length !== 1) {
|
|
13
|
+
throw new Error('Smoke batch must return exactly one result')
|
|
14
|
+
}
|
|
15
|
+
if (batch.results[0]?.callId !== callId) {
|
|
16
|
+
throw new Error('Smoke batch returned a mismatched callId')
|
|
17
|
+
}
|
|
18
|
+
result = batch.results[0].result
|
|
19
|
+
} else {
|
|
20
|
+
result = await tools.execute({ callId, name, arguments: args, signal })
|
|
21
|
+
}
|
|
22
|
+
if (!result || typeof result !== 'object' || typeof result.isError !== 'boolean') {
|
|
23
|
+
throw new Error('Smoke call must return a ToolExecutionResult with boolean isError')
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function describeSmokeError(error, depth = 0) {
|
|
29
|
+
const summary = {
|
|
30
|
+
name: error?.name ?? typeof error,
|
|
31
|
+
message: String(error?.message ?? error).slice(0, 500),
|
|
32
|
+
code: error?.code ?? null,
|
|
33
|
+
violations: Array.isArray(error?.violations) ? error.violations : null,
|
|
34
|
+
}
|
|
35
|
+
if (error?.cause !== undefined && depth < 2) {
|
|
36
|
+
summary.cause = describeSmokeError(error.cause, depth + 1)
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(error?.partial?.results)) {
|
|
39
|
+
// Preserve settlement identities, without dumping tool payloads or context.
|
|
40
|
+
summary.partial = { callIds: error.partial.results.map(item => item.callId) }
|
|
41
|
+
}
|
|
42
|
+
return summary
|
|
43
|
+
}
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
* 7. lazy rehydration from `<scoutDir>/<caseId>/`, including the case-id guard
|
|
24
24
|
* that stops an export of another case being adopted;
|
|
25
25
|
* 8. `language: 'en'` end to end (including the persisted `report.md`) and the
|
|
26
|
-
* unchanged `zh-CN` byte shape
|
|
26
|
+
* unchanged `zh-CN` byte shape;
|
|
27
|
+
* 9. the release literal, pinned in three independent places at once: the
|
|
28
|
+
* exported `PLUGIN_VERSION` constant, `package.json`, and the `/scout status`
|
|
29
|
+
* line — the one surface that can name the version, because reaching it
|
|
30
|
+
* already proves the plugin loaded.
|
|
27
31
|
*
|
|
28
32
|
* Round 1 of this suite pinned six defects as *characterization* tests; round 2
|
|
29
33
|
* pinned a seventh as a *documented boundary* (`D10`). All seven were fixed and
|
|
@@ -45,8 +49,9 @@
|
|
|
45
49
|
*/
|
|
46
50
|
import test from 'node:test'
|
|
47
51
|
import assert from 'node:assert/strict'
|
|
52
|
+
import { readFileSync } from 'node:fs'
|
|
48
53
|
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
|
|
49
|
-
import { Config, apply } from '../dist/index.js'
|
|
54
|
+
import { Config, PLUGIN_VERSION, apply } from '../dist/index.js'
|
|
50
55
|
import { CLAIM_DIMENSIONS, EXPORT_FILE_NAMES, scoutStrings } from '../dist/model.js'
|
|
51
56
|
|
|
52
57
|
/* ------------------------------------------------------------------ *
|
|
@@ -398,16 +403,19 @@ test('mutating tools render a compact summary while report and compare stay the
|
|
|
398
403
|
const reportBlocks = renderBlocks(harness, 'scout_report', await call(harness, 'scout_report', { caseId: 'budget' }, exec))
|
|
399
404
|
assert.ok(renderBytes(reportBlocks) > RENDER_BUDGET_BYTES, 'scout_report carries the full report')
|
|
400
405
|
const reportText = renderText(reportBlocks)
|
|
401
|
-
assert.match(reportText, /^# Example Co
|
|
406
|
+
assert.match(reportText, /^# Example Co$/m)
|
|
402
407
|
for (const heading of [
|
|
403
|
-
'##
|
|
404
|
-
'##
|
|
405
|
-
'##
|
|
406
|
-
'##
|
|
407
|
-
'##
|
|
408
|
-
'##
|
|
409
|
-
'##
|
|
410
|
-
'##
|
|
408
|
+
'## 30 秒结论',
|
|
409
|
+
'## 1. 公司与主体(已确认)',
|
|
410
|
+
'## 2. 已确认的事实(阶段:可直接使用)',
|
|
411
|
+
'## 3. 线索与待确认(阶段:先别当事实)',
|
|
412
|
+
'## 4. 风险与红旗(阶段:已识别)',
|
|
413
|
+
'## 5. 面试问题(按主题,最多 12 条)',
|
|
414
|
+
'## 6. 距离下一步还差什么',
|
|
415
|
+
'## 附录 A · 来源表',
|
|
416
|
+
'## 附录 B · 主张台账(技术可追溯)',
|
|
417
|
+
'## 附录 C · 调查覆盖度',
|
|
418
|
+
'## 附录 D · 术语表',
|
|
411
419
|
]) {
|
|
412
420
|
assert.ok(reportText.includes(heading), `report is missing ${heading}`)
|
|
413
421
|
}
|
|
@@ -989,7 +997,7 @@ test('rehydration requires a completion marker asserting complete:true and refus
|
|
|
989
997
|
assert.equal(JSON.parse(fs.read('/scout/silent/export.complete.json')).complete, true)
|
|
990
998
|
const healed = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
991
999
|
const report = await call(healed, 'scout_report', { caseId: 'silent' }, execFor())
|
|
992
|
-
assert.match(report.report, /^# Silent Co
|
|
1000
|
+
assert.match(report.report, /^# Silent Co$/m)
|
|
993
1001
|
await call(healed, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent-2' }, execFor())
|
|
994
1002
|
assert.deepEqual(eventTypes(fs.read('/scout/silent-2/events.jsonl')), [
|
|
995
1003
|
'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
|
|
@@ -1082,7 +1090,7 @@ test('a cache miss rehydrates from <scoutDir>/<caseId>/, records it, and refuses
|
|
|
1082
1090
|
|
|
1083
1091
|
const reader = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
|
|
1084
1092
|
const report = await call(reader, 'scout_report', { caseId: 'restored' }, exec)
|
|
1085
|
-
assert.match(report.report, /^# Restored Co
|
|
1093
|
+
assert.match(report.report, /^# Restored Co$/m)
|
|
1086
1094
|
await call(reader, 'scout_export', { caseId: 'restored', targetDir: '/scout/restored-2' }, exec)
|
|
1087
1095
|
assert.deepEqual(eventTypes(fs.read('/scout/restored-2/events.jsonl')), [
|
|
1088
1096
|
'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
|
|
@@ -1120,9 +1128,8 @@ test('language: en renders every human surface, including the persisted report.m
|
|
|
1120
1128
|
await call(harness, 'scout_start', { caseId: 'en-b', companyName: 'Second Co', roleTitle: 'CTO', location: '' }, exec)
|
|
1121
1129
|
|
|
1122
1130
|
const report = await call(harness, 'scout_report', { caseId: 'en-a' }, exec)
|
|
1123
|
-
assert.match(report.report,
|
|
1124
|
-
assert.match(report.report,
|
|
1125
|
-
assert.match(report.report, /- Objective: /)
|
|
1131
|
+
assert.match(report.report, /^> Role: HR Head \| Location: unspecified \| Decision: verify \(VERIFY\)$/m)
|
|
1132
|
+
assert.match(report.report, /## 30-second verdict/)
|
|
1126
1133
|
assert.match(report.report, /- No sources yet/)
|
|
1127
1134
|
assert.equal(CJK.test(report.report), false, 'an English report must not contain CJK characters')
|
|
1128
1135
|
|
|
@@ -1149,7 +1156,7 @@ test('language: en renders every human surface, including the persisted report.m
|
|
|
1149
1156
|
const reader = createHarness({ config: { language: 'en', scoutDir: '/export' }, services: { fs } })
|
|
1150
1157
|
await call(reader, 'scout_import', { caseId: 'en-a', sourceDir: '/export/en' }, execFor())
|
|
1151
1158
|
const reread = await call(reader, 'scout_report', { caseId: 'en-a' }, execFor())
|
|
1152
|
-
assert.match(reread.report,
|
|
1159
|
+
assert.match(reread.report, /^> Role: HR Head \| Location: unspecified \| Decision: verify \(VERIFY\)$/m)
|
|
1153
1160
|
})
|
|
1154
1161
|
|
|
1155
1162
|
test('scout_ingest drafts its claim notes in the deployment language', async () => {
|
|
@@ -1183,20 +1190,20 @@ test('scout_ingest drafts its claim notes in the deployment language', async ()
|
|
|
1183
1190
|
assert.equal(CJK.test(english.questions.join('')), false, 'and so must the derived interview questions')
|
|
1184
1191
|
assert.match(
|
|
1185
1192
|
english.report,
|
|
1186
|
-
/
|
|
1193
|
+
/next step: Verify this claim and add an independent source/,
|
|
1187
1194
|
)
|
|
1188
1195
|
assert.equal(english.persisted, english.report, 'the persisted snapshot stays byte-equal to the report')
|
|
1189
1196
|
|
|
1190
1197
|
const chinese = await ingestOne('zh-CN', 'zh-ingest')
|
|
1191
1198
|
assert.match(
|
|
1192
1199
|
chinese.report,
|
|
1193
|
-
|
|
1200
|
+
/下一步:核验该主张并补充独立来源/,
|
|
1194
1201
|
'the zh-CN draft wording is unchanged, so the 0.9.0 byte-compat baseline still holds',
|
|
1195
1202
|
)
|
|
1196
1203
|
assert.equal(CJK.test(chinese.report), true)
|
|
1197
1204
|
})
|
|
1198
1205
|
|
|
1199
|
-
test('the default zh-CN report keeps its
|
|
1206
|
+
test('the default zh-CN report keeps its shipped bytes and adds the 0.9.4 blocks', async () => {
|
|
1200
1207
|
const defaults = new Config({})
|
|
1201
1208
|
assert.equal(defaults.language, 'zh-CN')
|
|
1202
1209
|
assert.equal(defaults.scoutDir, 'dsh-scout')
|
|
@@ -1209,22 +1216,25 @@ test('the default zh-CN report keeps its pre-0.9.1 bytes and only adds the Cover
|
|
|
1209
1216
|
|
|
1210
1217
|
// Section headings are English in both languages — that is the shipped 0.9.0
|
|
1211
1218
|
// shape, verified against `git show HEAD:src/model.ts`, not a translation gap.
|
|
1219
|
+
// 0.9.4 inserts the verified-subject block before Coverage.
|
|
1212
1220
|
assert.deepEqual(
|
|
1213
1221
|
report.report.split('\n').filter(line => line.startsWith('## ')),
|
|
1214
1222
|
[
|
|
1215
|
-
'##
|
|
1216
|
-
'##
|
|
1217
|
-
'##
|
|
1218
|
-
'##
|
|
1219
|
-
'##
|
|
1220
|
-
'##
|
|
1221
|
-
'##
|
|
1222
|
-
'##
|
|
1223
|
+
'## 30 秒结论',
|
|
1224
|
+
'## 1. 公司与主体(已确认)',
|
|
1225
|
+
'## 2. 已确认的事实(阶段:可直接使用)',
|
|
1226
|
+
'## 3. 线索与待确认(阶段:先别当事实)',
|
|
1227
|
+
'## 4. 风险与红旗(阶段:已识别)',
|
|
1228
|
+
'## 5. 面试问题(按主题,最多 12 条)',
|
|
1229
|
+
'## 6. 距离下一步还差什么',
|
|
1230
|
+
'## 附录 A · 来源表',
|
|
1231
|
+
'## 附录 B · 主张台账(技术可追溯)',
|
|
1232
|
+
'## 附录 C · 调查覆盖度',
|
|
1233
|
+
'## 附录 D · 术语表',
|
|
1223
1234
|
],
|
|
1224
1235
|
)
|
|
1225
|
-
assert.match(report.report,
|
|
1226
|
-
assert.match(report.report,
|
|
1227
|
-
assert.match(report.report, /^- 目标:判断是否值得进入下一轮,并准备面试反问$/m)
|
|
1236
|
+
assert.match(report.report, /^> 岗位:HR Head | 地点:未指定 | 判断:继续核验(VERIFY)$/m)
|
|
1237
|
+
assert.match(report.report, /^> 目标:判断是否值得进入下一轮,并准备面试反问$/m)
|
|
1228
1238
|
assert.match(report.report, /^- 暂无来源$/m)
|
|
1229
1239
|
assert.equal(CJK.test(report.report), true)
|
|
1230
1240
|
|
|
@@ -1234,13 +1244,25 @@ test('the default zh-CN report keeps its pre-0.9.1 bytes and only adds the Cover
|
|
|
1234
1244
|
assert.deepEqual(report.coverage.map(entry => entry.dimension), [...CLAIM_DIMENSIONS])
|
|
1235
1245
|
assert.ok(report.coverage.every(entry => entry.untouched === true))
|
|
1236
1246
|
const coverageBlock = report.report.slice(
|
|
1237
|
-
report.report.indexOf('##
|
|
1238
|
-
report.report.indexOf('##
|
|
1247
|
+
report.report.indexOf('## 附录 C · 调查覆盖度'),
|
|
1248
|
+
report.report.indexOf('## 附录 D · 术语表'),
|
|
1239
1249
|
)
|
|
1250
|
+
const DIMENSION_ZH = {
|
|
1251
|
+
identity: '主体身份',
|
|
1252
|
+
role_existence: '岗位真实性',
|
|
1253
|
+
reporting_line: '汇报关系',
|
|
1254
|
+
mandate: '授权与职责',
|
|
1255
|
+
company_and_founder: '公司与创始人',
|
|
1256
|
+
product_and_technology: '产品与技术',
|
|
1257
|
+
market_and_competition: '市场与竞争',
|
|
1258
|
+
financing_and_commercialization: '融资与商业化',
|
|
1259
|
+
organization_and_hr: '组织与人力',
|
|
1260
|
+
risk: '风险',
|
|
1261
|
+
role_fit: '岗位匹配度',
|
|
1262
|
+
}
|
|
1240
1263
|
for (const dimension of CLAIM_DIMENSIONS) {
|
|
1241
|
-
assert.ok(coverageBlock.includes(`- ${dimension}
|
|
1264
|
+
assert.ok(coverageBlock.includes(`- ${DIMENSION_ZH[dimension]}`), `coverage block is missing ${dimension}`)
|
|
1242
1265
|
}
|
|
1243
|
-
assert.match(coverageBlock, /未调查维度(11):/)
|
|
1244
1266
|
})
|
|
1245
1267
|
|
|
1246
1268
|
test('a case crossing a language boundary renders every surface, report.md included, in the deployment language', async () => {
|
|
@@ -1259,8 +1281,7 @@ test('a case crossing a language boundary renders every surface, report.md inclu
|
|
|
1259
1281
|
const report = await call(en, 'scout_report', { caseId: 'mixed' }, exec)
|
|
1260
1282
|
await call(en, 'scout_export', { caseId: 'mixed', targetDir: '/export/en-from-zh' }, exec)
|
|
1261
1283
|
|
|
1262
|
-
assert.match(report.report,
|
|
1263
|
-
assert.match(report.report, /^- Reason: /m)
|
|
1284
|
+
assert.match(report.report, /^> Role: HR Head \| Location: unspecified \| Decision: verify \(VERIFY\)$/m, 'scout_report follows the configured language')
|
|
1264
1285
|
assert.match(report.report, /^- No sources yet$/m)
|
|
1265
1286
|
const persisted = fs.read('/export/en-from-zh/report.md')
|
|
1266
1287
|
assert.equal(persisted, report.report, 'the persisted snapshot and the rendered report are the same artifact')
|
|
@@ -1279,7 +1300,7 @@ test('a case crossing a language boundary renders every surface, report.md inclu
|
|
|
1279
1300
|
await call(zhReader, 'scout_import', { caseId: 'rev', sourceDir: '/export/rev-en' }, execFor())
|
|
1280
1301
|
const zhReport = await call(zhReader, 'scout_report', { caseId: 'rev' }, execFor())
|
|
1281
1302
|
await call(zhReader, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-zh' }, execFor())
|
|
1282
|
-
assert.match(zhReport.report,
|
|
1303
|
+
assert.match(zhReport.report, /^> 岗位:HR Head .*判断:继续核验(VERIFY)$/m)
|
|
1283
1304
|
assert.equal(fs2.read('/export/rev-zh/report.md'), zhReport.report)
|
|
1284
1305
|
assert.equal(JSON.parse(fs2.read('/export/rev-zh/case.json')).language, 'zh-CN')
|
|
1285
1306
|
})
|
|
@@ -1313,7 +1334,7 @@ test('a case crossing a language boundary is single-language in both directions,
|
|
|
1313
1334
|
|
|
1314
1335
|
assert.equal(CJK.test(report.report), false, 'the English report must contain no CJK at all')
|
|
1315
1336
|
assert.equal(CJK.test(questions.questions.join('')), false, 'nor may the question list')
|
|
1316
|
-
assert.match(report.report,
|
|
1337
|
+
assert.match(report.report, /^> Role: /m)
|
|
1317
1338
|
assert.equal(caseJson.language, 'en')
|
|
1318
1339
|
assert.equal(caseJson.decisionObjective, enFactory.decisionObjective)
|
|
1319
1340
|
assert.deepEqual(caseJson.interviewQuestions, enFactory.interviewQuestions)
|
|
@@ -1350,7 +1371,7 @@ test('a case crossing a language boundary is single-language in both directions,
|
|
|
1350
1371
|
const enReader = createHarness({ config: { language: 'en', scoutDir: '/scout' }, services: { fs: fs3 } })
|
|
1351
1372
|
const rehydrated = await call(enReader, 'scout_report', { caseId: 'rehy' }, execFor())
|
|
1352
1373
|
assert.equal(CJK.test(rehydrated.report), false, 'a rehydrated zh case must be aligned before rendering')
|
|
1353
|
-
assert.match(rehydrated.report,
|
|
1374
|
+
assert.match(rehydrated.report, /> (目标:|Objective: )/m)
|
|
1354
1375
|
|
|
1355
1376
|
// --- round trip zh -> en -> zh restores the original factory prose ---------
|
|
1356
1377
|
const fs4 = new MemoryFs()
|
|
@@ -1395,7 +1416,7 @@ test('user-edited case prose survives a language change while untouched fields s
|
|
|
1395
1416
|
const customObjective = '用户自定义目标(勿改)'
|
|
1396
1417
|
const objectiveEdited = await editAndMove(caseJson => { caseJson.decisionObjective = customObjective }, 'obj')
|
|
1397
1418
|
assert.equal(objectiveEdited.caseJson.decisionObjective, customObjective, 'the edited objective is preserved verbatim')
|
|
1398
|
-
assert.match(objectiveEdited.reportText,
|
|
1419
|
+
assert.match(objectiveEdited.reportText, /> (岗位:|Role: )HR Head/m)
|
|
1399
1420
|
assert.deepEqual(
|
|
1400
1421
|
objectiveEdited.caseJson.interviewQuestions,
|
|
1401
1422
|
enFactory.interviewQuestions,
|
|
@@ -1669,7 +1690,7 @@ test('the /scout command exposes four working sub-actions and the scout Skill ca
|
|
|
1669
1690
|
assert.match((await invoke('help')).text, /^usage: \/scout status/)
|
|
1670
1691
|
assert.match((await invoke('status cmd')).text, /^\[cmd\] VERIFY · identity needs_verification/)
|
|
1671
1692
|
assert.match((await invoke('questions cmd')).text, /^1\. /)
|
|
1672
|
-
assert.match((await invoke('report cmd')).text, /^# Command Co
|
|
1693
|
+
assert.match((await invoke('report cmd')).text, /^# Command Co$/m)
|
|
1673
1694
|
const exported = await invoke('export cmd')
|
|
1674
1695
|
assert.equal(exported.kind, 'success')
|
|
1675
1696
|
assert.match(exported.text, /^exported to dsh-scout\/cmd$/)
|
|
@@ -1682,3 +1703,58 @@ test('the /scout command exposes four working sub-actions and the scout Skill ca
|
|
|
1682
1703
|
assert.equal((await invoke('status missing')).kind, 'error')
|
|
1683
1704
|
assert.match((await invoke('status missing')).text, /Unknown case: missing/)
|
|
1684
1705
|
})
|
|
1706
|
+
|
|
1707
|
+
/* ------------------------------------------------------------------ *
|
|
1708
|
+
* 11b. T-C · the loaded version, stated where a user can actually read it
|
|
1709
|
+
*
|
|
1710
|
+
* A load-time version banner was implemented, measured against a real
|
|
1711
|
+
* `dsh --profile` boot, and rejected: cordis' only built-in exporter writes to
|
|
1712
|
+
* an in-memory ring buffer, so `ctx.logger(name).info(...)` reaches no DSH
|
|
1713
|
+
* surface at all (stdout and stderr both stayed byte-empty while direct writes
|
|
1714
|
+
* from the same plugin appeared on both), nothing in DSH reads that buffer, and
|
|
1715
|
+
* in the desktop App the host's stdout is piped to `/dev/null` while its stderr
|
|
1716
|
+
* is kept only as a 64 KiB tail used when the host dies. A plugin that a peer
|
|
1717
|
+
* conflict disabled never runs at all, so it can log nothing either way.
|
|
1718
|
+
*
|
|
1719
|
+
* `/scout status` is therefore the one honest place: reaching that branch
|
|
1720
|
+
* already proves this exact version is the one that loaded.
|
|
1721
|
+
* ------------------------------------------------------------------ */
|
|
1722
|
+
|
|
1723
|
+
test('T-C · one release literal across the exported constant, package.json and /scout status', async () => {
|
|
1724
|
+
// Hardcoded on purpose, mirroring `RELEASE_VERSION` in tests/package.test.mjs:
|
|
1725
|
+
// reading the version back out of package.json and comparing it to itself
|
|
1726
|
+
// would assert nothing. This literal is the third, independent statement, so a
|
|
1727
|
+
// bump that updates the manifest but forgets the constant (or vice versa)
|
|
1728
|
+
// fails here instead of shipping a mislabelled build.
|
|
1729
|
+
const RELEASE_VERSION = '0.9.4'
|
|
1730
|
+
|
|
1731
|
+
assert.equal(PLUGIN_VERSION, RELEASE_VERSION, 'the exported constant must be the release literal')
|
|
1732
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
1733
|
+
assert.equal(manifest.version, RELEASE_VERSION, 'package.json must carry the same release literal')
|
|
1734
|
+
|
|
1735
|
+
const commands = []
|
|
1736
|
+
const harness = createHarness()
|
|
1737
|
+
harness.ctx.mount('commands', { register: definition => { commands.push(definition); return () => {} } })
|
|
1738
|
+
await call(
|
|
1739
|
+
harness,
|
|
1740
|
+
'scout_start',
|
|
1741
|
+
{ caseId: 'ver', companyName: 'Version Co', roleTitle: 'HR Head', location: '' },
|
|
1742
|
+
execFor('version-session'),
|
|
1743
|
+
)
|
|
1744
|
+
|
|
1745
|
+
const invoke = rawInput => commands[0].handler({ rawInput, agent: { id: 'version-session' } })
|
|
1746
|
+
const status = await invoke('status ver')
|
|
1747
|
+
assert.equal(status.kind, 'success')
|
|
1748
|
+
assert.ok(
|
|
1749
|
+
status.text.endsWith(` · dsh-scout ${RELEASE_VERSION}`),
|
|
1750
|
+
`status must end with the loaded version, got: ${status.text}`,
|
|
1751
|
+
)
|
|
1752
|
+
// Trailing, so the case facts remain the leading, greppable part of the line.
|
|
1753
|
+
assert.match(
|
|
1754
|
+
status.text,
|
|
1755
|
+
/^\[ver\] VERIFY · identity needs_verification · sources 0 \/ claims 0 \(verified 0, open 0\) /,
|
|
1756
|
+
)
|
|
1757
|
+
// The version belongs to the plugin, not to the case: no other sub-action
|
|
1758
|
+
// states it, and the usage line stays byte-identical.
|
|
1759
|
+
assert.equal((await invoke('')).text, 'usage: /scout status <caseId> | /scout questions <caseId> | /scout report <caseId> | /scout export <caseId> [targetDir]')
|
|
1760
|
+
})
|