dsh-scout 0.9.2 → 0.9.3

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/dist/index.js CHANGED
@@ -4,6 +4,23 @@ import { addClaim, addSource, appendEvent, CLAIM_DIMENSIONS, CLAIM_IMPACTS, CLAI
4
4
  import { formatScoutString, isScoutLanguage, normalizeScoutLanguage, scoutStrings, } from './i18n.js';
5
5
  export const name = 'dsh-scout';
6
6
  export const inject = ['tools'];
7
+ /**
8
+ * The version this source tree ships, stated as a literal.
9
+ *
10
+ * It is deliberately never read back from `package.json`. A release checklist
11
+ * anchor only works when the version is asserted in two independent places: the
12
+ * assertion compares this literal against the manifest, so a bump that updates
13
+ * one and forgets the other fails the suite instead of shipping silently.
14
+ * `tests/acceptance.test.mjs` pins this constant, `package.json` and the
15
+ * `/scout status` output to the same string.
16
+ *
17
+ * Where this reaches a user: `runScoutCommand` prints it in `/scout status`.
18
+ * It cannot be printed at load time — a plugin's log output is not visible in
19
+ * any DSH surface (cordis only buffers it in memory), and a plugin that failed
20
+ * to load never runs at all, so "is it loaded, and which version?" is answered
21
+ * by the settings plugin list instead.
22
+ */
23
+ export const PLUGIN_VERSION = '0.9.3';
7
24
  export const Config = z.object({
8
25
  scoutDir: z.string().default('dsh-scout'),
9
26
  autoPersist: z.boolean().default(false),
@@ -1034,7 +1051,11 @@ export function apply(ctx, config = {}) {
1034
1051
  kind: 'success',
1035
1052
  text: `[${scoutCase.caseId}] ${scoutCase.decision} · identity ${scoutCase.subject.identityStatus}`
1036
1053
  + ` · sources ${scoutCase.sources.length} / claims ${scoutCase.claims.length}`
1037
- + ` (verified ${verified}, open ${scoutCase.claims.length - verified})`,
1054
+ + ` (verified ${verified}, open ${scoutCase.claims.length - verified})`
1055
+ // Trailing, so the case facts stay the leading, greppable part of the
1056
+ // line. This is the one surface that can state the version: reaching
1057
+ // this branch already proves the plugin loaded.
1058
+ + ` · dsh-scout ${PLUGIN_VERSION}`,
1038
1059
  };
1039
1060
  }
1040
1061
  if (action === 'questions') {
@@ -16,7 +16,7 @@
16
16
 
17
17
  0.9.1 明确不做的事见 [CHANGELOG](../CHANGELOG.md):client 面板、自造 session event、`ctx.storageDomain` 持久化重写、评分与排名(0.9.2 沿用同一非目标)。
18
18
 
19
- **v0.9.2(当前版本)**是收口版本:把 0.9.1 验证报告点名的最大盲区(U11)补成真实宿主门禁,并清掉三轮独立验证留下的文档税。本版本**不新增工具、不新增命令、不新增 Skill 条目**:
19
+ **v0.9.2**是收口版本:把 0.9.1 验证报告点名的最大盲区(U11)补成真实宿主门禁,并清掉三轮独立验证留下的文档税。本版本**不新增工具、不新增命令、不新增 Skill 条目**:
20
20
 
21
21
  - **F2 · 真实宿主端到端门禁(关闭 U11)**:`scripts/profile-smoke.mjs` 扩展为六步真实宿主场景链——创建 → 导出 → 冷重启 → 惰性回填 → 跨语言导入 → 卸载,另加完成标记「缺失 / `complete:false`」两条负向对照;四版本矩阵在扩展后的门禁下 4/4 通过。**仍未覆盖**:真实 LLM / agent loop 与 approval 策略、相对路径、`autoPersist`、真实进程中断,以及其余 6 个工具(仅断言注册)。
22
22
  - **F1 · 面试问题按下标逐条对齐(关闭 D10)**:精选面试问题由「整组 all-or-nothing」改为按下标逐条——长度与出厂数组相同时逐条替换,用户改过的那条保持原样;长度不同则整组原样保留。默认行为对**未编辑**场景零变化。
@@ -24,6 +24,8 @@
24
24
  - **F4 · 依赖对齐**:`@deepseek-ai/schemastery` 由 `^3.18.2` 改为 `~3.18.4`,与宿主系列一致;`peerDependencies` 与 `engines.dsh` 范围不变。
25
25
  - **已知边界 G1(有意不修,推迟 0.10.0)**:冷启动惰性回填会把决策理由措辞由初始文案换成 `decisionReasons.identity`(并补 `decisionReasonCode`),`decision` 不变;详见 [0.9.2 门禁发现](dsh-scout-0.9.2-gate-findings.md)(仓库文档,不在 npm 包内)。
26
26
 
27
+ **v0.9.3(当前版本)**不改运行时契约,主题是**安装体验**:此前 README 的三条「已验证路径」全是 `dsh plugin --profile <name> add …`,而桌面 App 的 `desktop` profile 被 Electron 应用独占,桌面用户第一步就撞墙——0.9.3 把「App 内安装」写成主路径,并给安装补上可执行判据。安装模型、分发源取舍与兼容性豁免见 §5.3;桌面路径的手工验收见 [安装验收 checklist](install-verification.md)(仓库文档,不在 npm 包内);评审原件见 [安装评审](dsh-scout-install-ux-review.md)(同上)。本版本**不新增工具、命令或 Skill 条目**。
28
+
27
29
  ## 1. 产品承诺
28
30
 
29
31
  给定一个公司和一个岗位,`dsh-scout` 不承诺“找到所有信息”,而承诺在证据边界内回答:
@@ -123,6 +125,23 @@ scout-cases/<case-id>/
123
125
 
124
126
  > 一句话规则:**不要自造 session event。** 用户显式动作走 `command/run` + `command/done`,结构化 UI 载荷走 `output.presentationMeta` → `tool/result.meta`,持久状态走 `ctx.storageDomain`。
125
127
 
128
+ ### 5.3 安装与分发模型(0.9.3)
129
+
130
+ 两条入口,安装方式不同,不要混用:
131
+
132
+ | 你在哪运行 | 目标 profile | 安装方式 |
133
+ | --- | --- | --- |
134
+ | 桌面 App(DeepSeek Harness) | `desktop` | **侧边栏 → 插件页**(唯一路径) |
135
+ | CLI / headless / 自建 profile | 你自己的 profile 名 | `dsh plugin --profile <your-profile> add dsh-scout@<exact-version>` |
136
+
137
+ - **`desktop` 由应用独占**:CLI 对它的**所有** `dsh plugin` 子命令都在参数解析层按 profile 名拒绝(`rejectElectronProfile`,`error: profile "desktop" is managed exclusively by the Electron application`),连兼容性逃生门 `allow-version` 一起拒绝。所以桌面场景既没有 CLI 判据,也没有可脚本化的自动化门禁——这正是 0.9.2 及以前的门禁盲区。
138
+ - **spec 一律写精确版本**:pnpm ≥ 11 默认启用最小发布年龄(minimum release age)供应链保护,裸包名会把刚发布的版本静默跳过、回退到旧版;写精确版本后 pnpm 会把它记入 profile 的 `minimumReleaseAgeExclude`。
139
+ - **分发源与代价**:npm(自带 `dist/`,不跑安装脚本,**推荐**)、本地 tarball(离线 / 自测)、git(仓库不含 `dist/`,`prepare` 会跑 `tsc` 并安装 devDependencies——**不推荐**,只在需要未发布提交时使用,ref 写真实 tag 如 `#v0.9.3`)。git 是唯一会执行代码的路径。
140
+ - **Node 适用范围**:`engines.node >= 22.19` 只对 CLI / 门禁路径成立;桌面 App 自带运行时(实测 node 24.21.0 + pnpm 11.7.0),无需安装 Node。
141
+ - **成功判据与排障面(两个 GUI 面,别混)**:**侧边栏 → 插件页**是**管理面**,回答「装的是哪版」——`dsh-scout` 在「已安装」里,**详情页**标题旁带 `v{version}`(`BundleInfo.version`),安装 / 启用 / 停用 / 移除都在这里;**设置 → 内置插件 → 插件列表**是**只读检查面**,回答「装没装 / 为什么没生效」——该行**已启用 + 运行中**(不是**已停用** / **启动失败** / **条件启用**),按「会话插件 / 全局插件」分组,**不显示版本号**(wire 行只有 `{ entryId, moduleName, meta?, enabled, fiberPhase }`)。再加上新开一轮对话是否出现 12 个 `scout_*` 工具、`/scout status <caseId>` 是否命中且输出末尾带 `· dsh-scout <version>`。CLI 的 `--dump-config` 只证明「组合进去了」,**不等于**运行时挂上了——不匹配时插件行被置为 disabled,且**这一行只对 CLI 用户可见**(stderr 打 `dsh: disabling profile plugin <row>: <reason>`)。桌面 App 没有可见 stderr、没有运行日志(实测 Electron 主进程的 fd0/fd1/fd2 指向 `/dev/null`,日志目录为空),插件被 disabled 时它自己不可能输出任何东西——但**答案就在 GUI 里**:启动时被禁用的行会被投影成插件列表里的「已停用」(宿主侧 `enabled: !entry.disabled`,`dsh-host-plugin-inventory/lib/index.js:131`),所以插件列表是桌面唯一的排障入口。
142
+ - **版本豁免(桌面手动路径)**:桌面用户用不了 `allow-version`,需要在 `~/.dsh/profiles/desktop/compatibility.json` 手写豁免——平铺 JSON:精确 `package@version` → 允许的精确 DSH 版本数组,例如 `{ "dsh-scout@0.9.3": ["0.2.0-rc.2"] }`。与工具 / CLI 写入的是同一组边界:只接受精确版本、只授权「这一个包版本 + 这一个 DSH 版本」这一对、不随升级继承、不授权依赖安装脚本、下一次组合生效;手写绕过 `acceptRisk` 风险确认,写法不合规的记录会被忽略并告警(不会让 profile 不可用)。
143
+ - **验收**:桌面路径由发版前手工 checklist 覆盖(`docs/install-verification.md`:Lane D 桌面 App / Lane C CLI,含两条 lane 的差异与触发时机,仓库文档、不在 npm 包内);CLI 自动化仍是 `npm run test:profile` 的六步链与 `npm run check:release` 的四版本矩阵。
144
+
126
145
  ## 6. 明确非目标
127
146
 
128
147
  - 不重做通用搜索、浏览器或 MCP provider。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scout",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Evidence-driven company and job intelligence plugin for DeepSeek Harness.",
5
5
  "icon": "./assets/icon.svg",
6
6
  "license": "MIT",
@@ -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 = []
@@ -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
  /* ------------------------------------------------------------------ *
@@ -1682,3 +1687,58 @@ test('the /scout command exposes four working sub-actions and the scout Skill ca
1682
1687
  assert.equal((await invoke('status missing')).kind, 'error')
1683
1688
  assert.match((await invoke('status missing')).text, /Unknown case: missing/)
1684
1689
  })
1690
+
1691
+ /* ------------------------------------------------------------------ *
1692
+ * 11b. T-C · the loaded version, stated where a user can actually read it
1693
+ *
1694
+ * A load-time version banner was implemented, measured against a real
1695
+ * `dsh --profile` boot, and rejected: cordis' only built-in exporter writes to
1696
+ * an in-memory ring buffer, so `ctx.logger(name).info(...)` reaches no DSH
1697
+ * surface at all (stdout and stderr both stayed byte-empty while direct writes
1698
+ * from the same plugin appeared on both), nothing in DSH reads that buffer, and
1699
+ * in the desktop App the host's stdout is piped to `/dev/null` while its stderr
1700
+ * is kept only as a 64 KiB tail used when the host dies. A plugin that a peer
1701
+ * conflict disabled never runs at all, so it can log nothing either way.
1702
+ *
1703
+ * `/scout status` is therefore the one honest place: reaching that branch
1704
+ * already proves this exact version is the one that loaded.
1705
+ * ------------------------------------------------------------------ */
1706
+
1707
+ test('T-C · one release literal across the exported constant, package.json and /scout status', async () => {
1708
+ // Hardcoded on purpose, mirroring `RELEASE_VERSION` in tests/package.test.mjs:
1709
+ // reading the version back out of package.json and comparing it to itself
1710
+ // would assert nothing. This literal is the third, independent statement, so a
1711
+ // bump that updates the manifest but forgets the constant (or vice versa)
1712
+ // fails here instead of shipping a mislabelled build.
1713
+ const RELEASE_VERSION = '0.9.3'
1714
+
1715
+ assert.equal(PLUGIN_VERSION, RELEASE_VERSION, 'the exported constant must be the release literal')
1716
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
1717
+ assert.equal(manifest.version, RELEASE_VERSION, 'package.json must carry the same release literal')
1718
+
1719
+ const commands = []
1720
+ const harness = createHarness()
1721
+ harness.ctx.mount('commands', { register: definition => { commands.push(definition); return () => {} } })
1722
+ await call(
1723
+ harness,
1724
+ 'scout_start',
1725
+ { caseId: 'ver', companyName: 'Version Co', roleTitle: 'HR Head', location: '' },
1726
+ execFor('version-session'),
1727
+ )
1728
+
1729
+ const invoke = rawInput => commands[0].handler({ rawInput, agent: { id: 'version-session' } })
1730
+ const status = await invoke('status ver')
1731
+ assert.equal(status.kind, 'success')
1732
+ assert.ok(
1733
+ status.text.endsWith(` · dsh-scout ${RELEASE_VERSION}`),
1734
+ `status must end with the loaded version, got: ${status.text}`,
1735
+ )
1736
+ // Trailing, so the case facts remain the leading, greppable part of the line.
1737
+ assert.match(
1738
+ status.text,
1739
+ /^\[ver\] VERIFY · identity needs_verification · sources 0 \/ claims 0 \(verified 0, open 0\) /,
1740
+ )
1741
+ // The version belongs to the plugin, not to the case: no other sub-action
1742
+ // states it, and the usage line stays byte-identical.
1743
+ assert.equal((await invoke('')).text, 'usage: /scout status <caseId> | /scout questions <caseId> | /scout report <caseId> | /scout export <caseId> [targetDir]')
1744
+ })