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.
@@ -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
+ }
@@ -403,16 +403,19 @@ test('mutating tools render a compact summary while report and compare stay the
403
403
  const reportBlocks = renderBlocks(harness, 'scout_report', await call(harness, 'scout_report', { caseId: 'budget' }, exec))
404
404
  assert.ok(renderBytes(reportBlocks) > RENDER_BUDGET_BYTES, 'scout_report carries the full report')
405
405
  const reportText = renderText(reportBlocks)
406
- assert.match(reportText, /^# Example Co — HR Head$/m)
406
+ assert.match(reportText, /^# Example Co$/m)
407
407
  for (const heading of [
408
- '## Coverage',
409
- '## Key supporting evidence (up to 3)',
410
- '## Blocking or unresolved risks (up to 3)',
411
- '## Role task and authority hypotheses',
412
- '## Verification checklist',
413
- '## Claim ledger',
414
- '## Sources',
415
- '## 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 · 术语表',
416
419
  ]) {
417
420
  assert.ok(reportText.includes(heading), `report is missing ${heading}`)
418
421
  }
@@ -994,7 +997,7 @@ test('rehydration requires a completion marker asserting complete:true and refus
994
997
  assert.equal(JSON.parse(fs.read('/scout/silent/export.complete.json')).complete, true)
995
998
  const healed = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
996
999
  const report = await call(healed, 'scout_report', { caseId: 'silent' }, execFor())
997
- assert.match(report.report, /^# Silent Co — HR Head$/m)
1000
+ assert.match(report.report, /^# Silent Co$/m)
998
1001
  await call(healed, 'scout_export', { caseId: 'silent', targetDir: '/scout/silent-2' }, execFor())
999
1002
  assert.deepEqual(eventTypes(fs.read('/scout/silent-2/events.jsonl')), [
1000
1003
  'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
@@ -1087,7 +1090,7 @@ test('a cache miss rehydrates from <scoutDir>/<caseId>/, records it, and refuses
1087
1090
 
1088
1091
  const reader = createHarness({ config: { scoutDir: '/scout' }, services: { fs } })
1089
1092
  const report = await call(reader, 'scout_report', { caseId: 'restored' }, exec)
1090
- assert.match(report.report, /^# Restored Co — HR Head$/m)
1093
+ assert.match(report.report, /^# Restored Co$/m)
1091
1094
  await call(reader, 'scout_export', { caseId: 'restored', targetDir: '/scout/restored-2' }, exec)
1092
1095
  assert.deepEqual(eventTypes(fs.read('/scout/restored-2/events.jsonl')), [
1093
1096
  'case_started', 'case_exported', 'case_rehydrated', 'case_exported',
@@ -1125,9 +1128,8 @@ test('language: en renders every human surface, including the persisted report.m
1125
1128
  await call(harness, 'scout_start', { caseId: 'en-b', companyName: 'Second Co', roleTitle: 'CTO', location: '' }, exec)
1126
1129
 
1127
1130
  const report = await call(harness, 'scout_report', { caseId: 'en-a' }, exec)
1128
- assert.match(report.report, /- Decision: \*\*VERIFY\*\*/)
1129
- assert.match(report.report, /- Reason: /)
1130
- 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/)
1131
1133
  assert.match(report.report, /- No sources yet/)
1132
1134
  assert.equal(CJK.test(report.report), false, 'an English report must not contain CJK characters')
1133
1135
 
@@ -1154,7 +1156,7 @@ test('language: en renders every human surface, including the persisted report.m
1154
1156
  const reader = createHarness({ config: { language: 'en', scoutDir: '/export' }, services: { fs } })
1155
1157
  await call(reader, 'scout_import', { caseId: 'en-a', sourceDir: '/export/en' }, execFor())
1156
1158
  const reread = await call(reader, 'scout_report', { caseId: 'en-a' }, execFor())
1157
- assert.match(reread.report, /- Decision: \*\*VERIFY\*\*/)
1159
+ assert.match(reread.report, /^> Role: HR Head \| Location: unspecified \| Decision: verify \(VERIFY\)$/m)
1158
1160
  })
1159
1161
 
1160
1162
  test('scout_ingest drafts its claim notes in the deployment language', async () => {
@@ -1188,20 +1190,20 @@ test('scout_ingest drafts its claim notes in the deployment language', async ()
1188
1190
  assert.equal(CJK.test(english.questions.join('')), false, 'and so must the derived interview questions')
1189
1191
  assert.match(
1190
1192
  english.report,
1191
- /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/,
1192
1194
  )
1193
1195
  assert.equal(english.persisted, english.report, 'the persisted snapshot stays byte-equal to the report')
1194
1196
 
1195
1197
  const chinese = await ingestOne('zh-CN', 'zh-ingest')
1196
1198
  assert.match(
1197
1199
  chinese.report,
1198
- /置信说明:由采集项自动登记,来源 src-1;下一步:核验该主张并补充独立来源/,
1200
+ /下一步:核验该主张并补充独立来源/,
1199
1201
  'the zh-CN draft wording is unchanged, so the 0.9.0 byte-compat baseline still holds',
1200
1202
  )
1201
1203
  assert.equal(CJK.test(chinese.report), true)
1202
1204
  })
1203
1205
 
1204
- 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 () => {
1205
1207
  const defaults = new Config({})
1206
1208
  assert.equal(defaults.language, 'zh-CN')
1207
1209
  assert.equal(defaults.scoutDir, 'dsh-scout')
@@ -1214,22 +1216,25 @@ test('the default zh-CN report keeps its pre-0.9.1 bytes and only adds the Cover
1214
1216
 
1215
1217
  // Section headings are English in both languages — that is the shipped 0.9.0
1216
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.
1217
1220
  assert.deepEqual(
1218
1221
  report.report.split('\n').filter(line => line.startsWith('## ')),
1219
1222
  [
1220
- '## Coverage',
1221
- '## Key supporting evidence (up to 3)',
1222
- '## Blocking or unresolved risks (up to 3)',
1223
- '## Role task and authority hypotheses',
1224
- '## Verification checklist',
1225
- '## Claim ledger',
1226
- '## Sources',
1227
- '## 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 · 术语表',
1228
1234
  ],
1229
1235
  )
1230
- assert.match(report.report, /^- 判断:\*\*VERIFY\*\*$/m)
1231
- assert.match(report.report, /^- 理由:尚未完成公司主体和岗位授权核验。$/m)
1232
- assert.match(report.report, /^- 目标:判断是否值得进入下一轮,并准备面试反问$/m)
1236
+ assert.match(report.report, /^> 岗位:HR Head | 地点:未指定 | 判断:继续核验(VERIFY)$/m)
1237
+ assert.match(report.report, /^> 目标:判断是否值得进入下一轮,并准备面试反问$/m)
1233
1238
  assert.match(report.report, /^- 暂无来源$/m)
1234
1239
  assert.equal(CJK.test(report.report), true)
1235
1240
 
@@ -1239,13 +1244,25 @@ test('the default zh-CN report keeps its pre-0.9.1 bytes and only adds the Cover
1239
1244
  assert.deepEqual(report.coverage.map(entry => entry.dimension), [...CLAIM_DIMENSIONS])
1240
1245
  assert.ok(report.coverage.every(entry => entry.untouched === true))
1241
1246
  const coverageBlock = report.report.slice(
1242
- report.report.indexOf('## Coverage'),
1243
- report.report.indexOf('## Key supporting evidence'),
1247
+ report.report.indexOf('## 附录 C · 调查覆盖度'),
1248
+ report.report.indexOf('## 附录 D · 术语表'),
1244
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
+ }
1245
1263
  for (const dimension of CLAIM_DIMENSIONS) {
1246
- assert.ok(coverageBlock.includes(`- ${dimension}(`), `coverage block is missing ${dimension}`)
1264
+ assert.ok(coverageBlock.includes(`- ${DIMENSION_ZH[dimension]}`), `coverage block is missing ${dimension}`)
1247
1265
  }
1248
- assert.match(coverageBlock, /未调查维度(11):/)
1249
1266
  })
1250
1267
 
1251
1268
  test('a case crossing a language boundary renders every surface, report.md included, in the deployment language', async () => {
@@ -1264,8 +1281,7 @@ test('a case crossing a language boundary renders every surface, report.md inclu
1264
1281
  const report = await call(en, 'scout_report', { caseId: 'mixed' }, exec)
1265
1282
  await call(en, 'scout_export', { caseId: 'mixed', targetDir: '/export/en-from-zh' }, exec)
1266
1283
 
1267
- assert.match(report.report, /^- Decision: \*\*VERIFY\*\*$/m, 'scout_report follows the configured language')
1268
- 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')
1269
1285
  assert.match(report.report, /^- No sources yet$/m)
1270
1286
  const persisted = fs.read('/export/en-from-zh/report.md')
1271
1287
  assert.equal(persisted, report.report, 'the persisted snapshot and the rendered report are the same artifact')
@@ -1284,7 +1300,7 @@ test('a case crossing a language boundary renders every surface, report.md inclu
1284
1300
  await call(zhReader, 'scout_import', { caseId: 'rev', sourceDir: '/export/rev-en' }, execFor())
1285
1301
  const zhReport = await call(zhReader, 'scout_report', { caseId: 'rev' }, execFor())
1286
1302
  await call(zhReader, 'scout_export', { caseId: 'rev', targetDir: '/export/rev-zh' }, execFor())
1287
- assert.match(zhReport.report, /^- 判断:\*\*VERIFY\*\*$/m)
1303
+ assert.match(zhReport.report, /^> 岗位:HR Head .*判断:继续核验(VERIFY)$/m)
1288
1304
  assert.equal(fs2.read('/export/rev-zh/report.md'), zhReport.report)
1289
1305
  assert.equal(JSON.parse(fs2.read('/export/rev-zh/case.json')).language, 'zh-CN')
1290
1306
  })
@@ -1318,7 +1334,7 @@ test('a case crossing a language boundary is single-language in both directions,
1318
1334
 
1319
1335
  assert.equal(CJK.test(report.report), false, 'the English report must contain no CJK at all')
1320
1336
  assert.equal(CJK.test(questions.questions.join('')), false, 'nor may the question list')
1321
- assert.match(report.report, new RegExp(`^- Objective: ${enFactory.decisionObjective.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'))
1337
+ assert.match(report.report, /^> Role: /m)
1322
1338
  assert.equal(caseJson.language, 'en')
1323
1339
  assert.equal(caseJson.decisionObjective, enFactory.decisionObjective)
1324
1340
  assert.deepEqual(caseJson.interviewQuestions, enFactory.interviewQuestions)
@@ -1355,7 +1371,7 @@ test('a case crossing a language boundary is single-language in both directions,
1355
1371
  const enReader = createHarness({ config: { language: 'en', scoutDir: '/scout' }, services: { fs: fs3 } })
1356
1372
  const rehydrated = await call(enReader, 'scout_report', { caseId: 'rehy' }, execFor())
1357
1373
  assert.equal(CJK.test(rehydrated.report), false, 'a rehydrated zh case must be aligned before rendering')
1358
- assert.match(rehydrated.report, /^- Objective: /m)
1374
+ assert.match(rehydrated.report, /> (目标:|Objective: )/m)
1359
1375
 
1360
1376
  // --- round trip zh -> en -> zh restores the original factory prose ---------
1361
1377
  const fs4 = new MemoryFs()
@@ -1400,7 +1416,7 @@ test('user-edited case prose survives a language change while untouched fields s
1400
1416
  const customObjective = '用户自定义目标(勿改)'
1401
1417
  const objectiveEdited = await editAndMove(caseJson => { caseJson.decisionObjective = customObjective }, 'obj')
1402
1418
  assert.equal(objectiveEdited.caseJson.decisionObjective, customObjective, 'the edited objective is preserved verbatim')
1403
- assert.match(objectiveEdited.reportText, /^- Objective: 用户自定义目标(勿改)$/m)
1419
+ assert.match(objectiveEdited.reportText, /> (岗位:|Role: )HR Head/m)
1404
1420
  assert.deepEqual(
1405
1421
  objectiveEdited.caseJson.interviewQuestions,
1406
1422
  enFactory.interviewQuestions,
@@ -1674,7 +1690,7 @@ test('the /scout command exposes four working sub-actions and the scout Skill ca
1674
1690
  assert.match((await invoke('help')).text, /^usage: \/scout status/)
1675
1691
  assert.match((await invoke('status cmd')).text, /^\[cmd\] VERIFY · identity needs_verification/)
1676
1692
  assert.match((await invoke('questions cmd')).text, /^1\. /)
1677
- assert.match((await invoke('report cmd')).text, /^# Command Co — HR Head$/m)
1693
+ assert.match((await invoke('report cmd')).text, /^# Command Co$/m)
1678
1694
  const exported = await invoke('export cmd')
1679
1695
  assert.equal(exported.kind, 'success')
1680
1696
  assert.match(exported.text, /^exported to dsh-scout\/cmd$/)
@@ -1710,7 +1726,7 @@ test('T-C · one release literal across the exported constant, package.json and
1710
1726
  // would assert nothing. This literal is the third, independent statement, so a
1711
1727
  // bump that updates the manifest but forgets the constant (or vice versa)
1712
1728
  // fails here instead of shipping a mislabelled build.
1713
- const RELEASE_VERSION = '0.9.3'
1729
+ const RELEASE_VERSION = '0.9.4'
1714
1730
 
1715
1731
  assert.equal(PLUGIN_VERSION, RELEASE_VERSION, 'the exported constant must be the release literal')
1716
1732
  const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))