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.
@@ -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({ callId, name, arguments, signal })`, in a SIX-STEP
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, not thrown errors (measured on
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 = error => ({
370
- name: error && error.name ? error.name : typeof error,
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 ctx.tools.execute({
389
- callId: callId,
390
- name: name,
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 — HR Head$/m)
406
+ assert.match(reportText, /^# Example Co$/m)
402
407
  for (const heading of [
403
- '## Coverage',
404
- '## Key supporting evidence (up to 3)',
405
- '## Blocking or unresolved risks (up to 3)',
406
- '## Role task and authority hypotheses',
407
- '## Verification checklist',
408
- '## Claim ledger',
409
- '## Sources',
410
- '## Interview questions',
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 — HR Head$/m)
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 — HR Head$/m)
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, /- Decision: \*\*VERIFY\*\*/)
1124
- assert.match(report.report, /- Reason: /)
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, /- Decision: \*\*VERIFY\*\*/)
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
- /confidence: Auto-registered from an ingested item; source src-1; next action: Verify this claim and add an independent source/,
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
- /置信说明:由采集项自动登记,来源 src-1;下一步:核验该主张并补充独立来源/,
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 pre-0.9.1 bytes and only adds the Coverage block', async () => {
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
- '## Coverage',
1216
- '## Key supporting evidence (up to 3)',
1217
- '## Blocking or unresolved risks (up to 3)',
1218
- '## Role task and authority hypotheses',
1219
- '## Verification checklist',
1220
- '## Claim ledger',
1221
- '## Sources',
1222
- '## Interview questions',
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, /^- 判断:\*\*VERIFY\*\*$/m)
1226
- assert.match(report.report, /^- 理由:尚未完成公司主体和岗位授权核验。$/m)
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('## Coverage'),
1238
- report.report.indexOf('## Key supporting evidence'),
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}(`), `coverage block is missing ${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, /^- Decision: \*\*VERIFY\*\*$/m, 'scout_report follows the configured language')
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, /^- 判断:\*\*VERIFY\*\*$/m)
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, new RegExp(`^- Objective: ${enFactory.decisionObjective.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'))
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, /^- Objective: /m)
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, /^- Objective: 用户自定义目标(勿改)$/m)
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 — HR Head$/m)
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
+ })