@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29

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.
Files changed (79) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +56 -12
  4. package/data/yunnan-pack.json +30 -27
  5. package/dist/capabilities/anything.js +26 -0
  6. package/dist/capabilities/channel-registry.js +4 -4
  7. package/dist/capabilities/doctor.js +14 -7
  8. package/dist/capabilities/effect.js +1 -1
  9. package/dist/capabilities/ground-transfer.js +168 -1
  10. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  11. package/dist/capabilities/route-provider-conformance.js +341 -0
  12. package/dist/capabilities/session/extension-bridge.js +3 -3
  13. package/dist/capabilities/session-search.js +1 -1
  14. package/dist/scripts/anything-path-measure-tests.js +133 -0
  15. package/dist/scripts/anything-path-measure.js +150 -0
  16. package/dist/scripts/anything-tests.js +136 -2
  17. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  18. package/dist/scripts/bootstrap-tests.js +77 -7
  19. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  20. package/dist/scripts/doctor-tests.js +39 -3
  21. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  22. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  23. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  24. package/dist/scripts/dsh-llm-tests.js +437 -0
  25. package/dist/scripts/g5-guard-tests.js +20 -1
  26. package/dist/scripts/g5-guard.js +5 -1
  27. package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
  28. package/dist/scripts/m3-cohort-tests.js +714 -0
  29. package/dist/scripts/m3-cohort.js +310 -0
  30. package/dist/scripts/nightly-evidence-tests.js +44 -2
  31. package/dist/scripts/nightly-evidence.js +21 -4
  32. package/dist/scripts/outcome-projection-tests.js +427 -0
  33. package/dist/scripts/persona-sim-tests.js +621 -0
  34. package/dist/scripts/persona-sim.js +1081 -0
  35. package/dist/scripts/replay-async.js +2 -0
  36. package/dist/scripts/replay.js +2 -0
  37. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  38. package/dist/scripts/session-dida-live-e2e.js +1 -1
  39. package/dist/scripts/session-zone-crash.js +49 -0
  40. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  41. package/dist/scripts/session-zone-observation-tests.js +492 -0
  42. package/dist/scripts/session-zone-observe.js +135 -0
  43. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  44. package/dist/scripts/session-zones-tests.js +17 -1
  45. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  46. package/dist/scripts/state-cli.js +16 -0
  47. package/dist/scripts/time-eval-tests.js +2 -0
  48. package/dist/scripts/unified-tests.js +79 -2
  49. package/dist/scripts/z3-race-tests.js +6 -1
  50. package/dist/src/backend/modules/session-search.js +1 -1
  51. package/dist/src/city-scenario-tier.js +378 -0
  52. package/dist/src/dsh-llm.js +84 -14
  53. package/dist/src/index.js +166 -7
  54. package/dist/src/loop.js +16 -5
  55. package/dist/src/m3-cohort.js +1577 -0
  56. package/dist/src/outcome-projection.js +746 -0
  57. package/dist/src/session-zone-ledger.js +312 -0
  58. package/dist/src/session-zone-observation.js +354 -0
  59. package/dist/src/session-zone-wiring.js +658 -0
  60. package/dist/src/session-zones.js +7 -4
  61. package/dist/src/state-ledger.js +293 -40
  62. package/dist/src/unified.js +69 -5
  63. package/extension/README.md +1 -1
  64. package/extension/README.zh-CN.md +1 -1
  65. package/extension/background.js +1 -1
  66. package/extension/content-bridge.js +1 -1
  67. package/extension/content-main.js +1 -1
  68. package/extension/manifest.json +2 -2
  69. package/package.json +1 -1
  70. package/ts/capabilities/anything.ts +27 -0
  71. package/ts/capabilities/session/extension-bridge.ts +3 -3
  72. package/ts/capabilities/session-search.ts +1 -1
  73. package/ts/scripts/state-cli.ts +18 -0
  74. package/ts/src/contracts.ts +4 -0
  75. package/ts/src/dsh-llm.ts +111 -9
  76. package/ts/src/index.ts +181 -8
  77. package/ts/src/loop.ts +15 -5
  78. package/ts/src/state-ledger.ts +294 -40
  79. package/ts/src/unified.ts +91 -18
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Explicit opt-in M3 seed-cohort capture CLI (issue #22).
4
+ *
5
+ * Produces the records `scripts/product-metrics.ts` consumes. The scorer is the only
6
+ * component that divides anything; this CLI only records funnel facts and then hands the
7
+ * written evidence root straight back to the scorer for verification.
8
+ *
9
+ * The production CLI intentionally has no `--at` flag: timestamps come from the local
10
+ * clock. Tests inject clocks through `../src/m3-cohort.ts`.
11
+ *
12
+ * HMAC key custody: `GOTRY_M3_COHORT_HMAC_KEY` (>= 32 chars) lives outside the repository
13
+ * and is never printed by this CLI. Losing it makes the store unreadable by design, and
14
+ * `verify` needs it too — the export attestation is keyed, not a public digest.
15
+ *
16
+ * Argv exposure (accepted, documented): `--participant`, `--plan` and `--consent` carry raw
17
+ * labels on the command line, so they are visible to `ps` and land in shell history. Only
18
+ * their HMAC refs are ever persisted, but the invocation itself is not secret. For a real
19
+ * cohort, drive this CLI from a script whose history is not shared, or export the labels
20
+ * from a file the shell does not record; never pass anything you would not put in `ps`.
21
+ *
22
+ * npx tsx scripts/m3-cohort.ts init --state-root "$ROOT" --consent "$C" \
23
+ * --cohort m3-seed --evidence-kind real_seed_cohort \
24
+ * --window-start 2026-10-01T00:00:00.000Z --window-end 2026-12-31T23:59:59.000Z \
25
+ * --timezone Asia/Shanghai --attribution gotry_primary
26
+ * npx tsx scripts/m3-cohort.ts enroll ... --participant <handle> --invited --participant-consent
27
+ * npx tsx scripts/m3-cohort.ts deliver ... --participant <handle> --plan <handle> --attribution gotry_primary
28
+ * npx tsx scripts/m3-cohort.ts finalize ... --participant <handle> --plan <handle>
29
+ * npx tsx scripts/m3-cohort.ts nps ... --participant <handle> --plan <handle> --score 9
30
+ * npx tsx scripts/m3-cohort.ts poi-lock ... --participant <handle> --plan <handle> --locked-claims 12 --invalid-claims 0
31
+ * npx tsx scripts/m3-cohort.ts export ... --evidence-root <dir>
32
+ * npx tsx scripts/m3-cohort.ts verify --evidence-root <dir> # needs the HMAC key
33
+ * npx tsx scripts/m3-cohort.ts lock-status --state-root "$ROOT" # read-only
34
+ * npx tsx scripts/m3-cohort.ts unlock --state-root "$ROOT" # dead writer only
35
+ */
36
+ import { resolve } from 'node:path';
37
+ import { pathToFileURL } from 'node:url';
38
+ import { enrollParticipant, exportM3Cohort, initM3Cohort, inspectStoreLock, M3CohortError, M3_SIMULATION_PROVENANCE_SCHEMA, recordNps, recordPlanDelivered, recordPlanFinalized, recordPoiLock, unlockStore, verifyExportedEvidence, } from '../src/m3-cohort.js';
39
+ import { parseCohortRecord, parseManifest, scoreProductMetrics } from './product-metrics.js';
40
+ const ERROR_CODES = [
41
+ 'bad_args',
42
+ 'cohort_already_initialized',
43
+ 'cohort_consent_mismatch',
44
+ 'cohort_key_mismatch',
45
+ 'cohort_not_initialized',
46
+ 'evidence_kind_mismatch',
47
+ 'evidence_root_conflict',
48
+ 'export_validation_failed',
49
+ 'internal_error',
50
+ 'invalid_hmac_key',
51
+ 'invalid_input',
52
+ 'invalid_store',
53
+ 'invalid_transition',
54
+ 'lock_busy',
55
+ 'missing_consent',
56
+ 'missing_hmac_key',
57
+ 'missing_state_root',
58
+ 'output_exists',
59
+ 'simulation_forbidden_in_real_cohort',
60
+ 'simulation_label_required',
61
+ 'unsafe_state_root',
62
+ ];
63
+ function parseFlags(args) {
64
+ const [command, ...rest] = args;
65
+ if (!command)
66
+ throw new M3CohortError('bad_args');
67
+ const flags = {};
68
+ for (let index = 0; index < rest.length; index += 1) {
69
+ const token = rest[index];
70
+ if (!token.startsWith('--'))
71
+ throw new M3CohortError('bad_args');
72
+ const key = token.slice(2);
73
+ if (!key)
74
+ throw new M3CohortError('bad_args');
75
+ const next = rest[index + 1];
76
+ const value = next === undefined || next.startsWith('--') ? true : (index += 1, next);
77
+ if (key in flags) {
78
+ const current = flags[key];
79
+ flags[key] = Array.isArray(current) ? [...current, value] : [current, value];
80
+ }
81
+ else {
82
+ flags[key] = value;
83
+ }
84
+ }
85
+ return { command, flags };
86
+ }
87
+ function one(flags, key, required = true) {
88
+ const value = flags[key];
89
+ if (Array.isArray(value) || value === true)
90
+ throw new M3CohortError('bad_args');
91
+ if (value === undefined && required)
92
+ throw new M3CohortError(key === 'state-root' ? 'missing_state_root' : 'invalid_input');
93
+ return value;
94
+ }
95
+ function many(flags, key) {
96
+ const value = flags[key];
97
+ if (value === undefined)
98
+ return [];
99
+ if (value === true)
100
+ throw new M3CohortError('bad_args');
101
+ const list = Array.isArray(value) ? value : [value];
102
+ return list.map(item => {
103
+ if (item === true)
104
+ throw new M3CohortError('bad_args');
105
+ return item;
106
+ });
107
+ }
108
+ function boolFlag(flags, key) {
109
+ const value = flags[key];
110
+ if (value === undefined)
111
+ return false;
112
+ if (value !== true)
113
+ throw new M3CohortError('bad_args');
114
+ return true;
115
+ }
116
+ function exclusiveBool(flags, trueKey, falseKey) {
117
+ const positive = boolFlag(flags, trueKey);
118
+ const negative = boolFlag(flags, falseKey);
119
+ if (positive === negative)
120
+ throw new M3CohortError('invalid_input');
121
+ return positive;
122
+ }
123
+ function integerFlag(flags, key) {
124
+ const raw = one(flags, key);
125
+ if (!/^\d{1,9}$/.test(raw))
126
+ throw new M3CohortError('invalid_input');
127
+ return Number(raw);
128
+ }
129
+ function common(flags) {
130
+ const stateRoot = one(flags, 'state-root');
131
+ const consent = one(flags, 'consent', false);
132
+ if (!consent)
133
+ throw new M3CohortError('missing_consent');
134
+ return { stateRoot: stateRoot, consent, hmacKey: process.env['GOTRY_M3_COHORT_HMAC_KEY'] };
135
+ }
136
+ function ensureNoUnknownFlags(flags, allowed) {
137
+ const allowedSet = new Set(allowed);
138
+ for (const key of Object.keys(flags)) {
139
+ if (!allowedSet.has(key))
140
+ throw new M3CohortError('bad_args');
141
+ }
142
+ }
143
+ function attributionFlag(flags) {
144
+ const value = one(flags, 'attribution');
145
+ if (value !== 'gotry_primary' && value !== 'gotry_assisted')
146
+ throw new M3CohortError('invalid_input');
147
+ return value;
148
+ }
149
+ function attributionList(flags) {
150
+ const values = many(flags, 'attribution');
151
+ if (values.length === 0)
152
+ throw new M3CohortError('invalid_input');
153
+ return values.map(value => {
154
+ if (value !== 'gotry_primary' && value !== 'gotry_assisted')
155
+ throw new M3CohortError('invalid_input');
156
+ return value;
157
+ });
158
+ }
159
+ function simulationFlags(flags) {
160
+ const simulated = boolFlag(flags, 'simulated');
161
+ const personaId = one(flags, 'persona-id', false);
162
+ const productModel = one(flags, 'product-model', false);
163
+ const personaModel = one(flags, 'persona-model', false);
164
+ const promptDigest = one(flags, 'prompt-digest', false);
165
+ const provided = [personaId, productModel, personaModel, promptDigest];
166
+ if (!simulated) {
167
+ if (provided.some(value => value !== undefined))
168
+ throw new M3CohortError('invalid_input');
169
+ return undefined;
170
+ }
171
+ if (provided.some(value => value === undefined))
172
+ throw new M3CohortError('invalid_input');
173
+ return {
174
+ schema_version: M3_SIMULATION_PROVENANCE_SCHEMA,
175
+ kind: 'llm_persona',
176
+ persona_id: personaId,
177
+ product_model: productModel,
178
+ persona_model: personaModel,
179
+ prompt_digest: promptDigest,
180
+ };
181
+ }
182
+ function output(value) {
183
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
184
+ }
185
+ /** Consumer-side verification: the exact parsers and scorer the M3 gate uses. */
186
+ export function scorerValidator(payload) {
187
+ const manifest = parseManifest(JSON.parse(JSON.stringify(payload.manifest)));
188
+ const cohort = payload.cohort.map((record, index) => parseCohortRecord(JSON.parse(JSON.stringify(record)), index));
189
+ const summary = scoreProductMetrics(manifest, cohort, []);
190
+ if (manifest.evidence_kind === 'synthetic_fixture' && summary.business_pass) {
191
+ throw new M3CohortError('export_validation_failed');
192
+ }
193
+ }
194
+ function run(command, flags) {
195
+ switch (command) {
196
+ case 'init': {
197
+ ensureNoUnknownFlags(flags, ['state-root', 'consent', 'cohort', 'evidence-kind', 'window-start', 'window-end', 'timezone', 'attribution']);
198
+ const evidenceKind = one(flags, 'evidence-kind');
199
+ if (evidenceKind !== 'real_seed_cohort' && evidenceKind !== 'synthetic_fixture')
200
+ throw new M3CohortError('invalid_input');
201
+ output(initM3Cohort({
202
+ ...common(flags),
203
+ cohort: one(flags, 'cohort'),
204
+ evidenceKind,
205
+ windowStartAt: one(flags, 'window-start'),
206
+ windowEndAt: one(flags, 'window-end'),
207
+ timezone: one(flags, 'timezone'),
208
+ allowedAttribution: attributionList(flags),
209
+ }));
210
+ return;
211
+ }
212
+ case 'enroll': {
213
+ ensureNoUnknownFlags(flags, [
214
+ 'state-root', 'consent', 'participant', 'invited', 'not-invited', 'participant-consent',
215
+ 'participant-consent-withdrawn', 'test-or-staff', 'simulated', 'persona-id', 'product-model',
216
+ 'persona-model', 'prompt-digest',
217
+ ]);
218
+ output(enrollParticipant({
219
+ ...common(flags),
220
+ participant: one(flags, 'participant'),
221
+ invited: exclusiveBool(flags, 'invited', 'not-invited'),
222
+ participantConsent: exclusiveBool(flags, 'participant-consent', 'participant-consent-withdrawn'),
223
+ testOrStaff: boolFlag(flags, 'test-or-staff'),
224
+ simulation: simulationFlags(flags),
225
+ }));
226
+ return;
227
+ }
228
+ case 'deliver': {
229
+ ensureNoUnknownFlags(flags, ['state-root', 'consent', 'participant', 'plan', 'attribution']);
230
+ output(recordPlanDelivered({
231
+ ...common(flags),
232
+ participant: one(flags, 'participant'),
233
+ plan: one(flags, 'plan'),
234
+ attribution: attributionFlag(flags),
235
+ }));
236
+ return;
237
+ }
238
+ case 'finalize': {
239
+ ensureNoUnknownFlags(flags, ['state-root', 'consent', 'participant', 'plan']);
240
+ output(recordPlanFinalized({ ...common(flags), participant: one(flags, 'participant'), plan: one(flags, 'plan') }));
241
+ return;
242
+ }
243
+ case 'nps': {
244
+ ensureNoUnknownFlags(flags, ['state-root', 'consent', 'participant', 'plan', 'score']);
245
+ output(recordNps({
246
+ ...common(flags),
247
+ participant: one(flags, 'participant'),
248
+ plan: one(flags, 'plan'),
249
+ score: integerFlag(flags, 'score'),
250
+ }));
251
+ return;
252
+ }
253
+ case 'poi-lock': {
254
+ ensureNoUnknownFlags(flags, ['state-root', 'consent', 'participant', 'plan', 'locked-claims', 'invalid-claims']);
255
+ output(recordPoiLock({
256
+ ...common(flags),
257
+ participant: one(flags, 'participant'),
258
+ plan: one(flags, 'plan'),
259
+ lockedClaims: integerFlag(flags, 'locked-claims'),
260
+ invalidClaims: integerFlag(flags, 'invalid-claims'),
261
+ }));
262
+ return;
263
+ }
264
+ case 'export': {
265
+ ensureNoUnknownFlags(flags, ['state-root', 'consent', 'evidence-root']);
266
+ const evidenceRoot = one(flags, 'evidence-root');
267
+ const shared = common(flags);
268
+ const { payload, result } = exportM3Cohort({ ...shared, evidenceRoot, validate: scorerValidator });
269
+ // Verify the bytes that actually landed: re-read the evidence root through the real
270
+ // scorer parsers plus the keyed attestation check.
271
+ const manifest = parseManifest(JSON.parse(JSON.stringify(payload.manifest)));
272
+ const cohort = payload.cohort.map((record, index) => parseCohortRecord(JSON.parse(JSON.stringify(record)), index));
273
+ const summary = scoreProductMetrics(manifest, cohort, []);
274
+ const verification = verifyExportedEvidence(evidenceRoot, shared.hmacKey);
275
+ output({ ...result, verification, scorer_summary: summary });
276
+ return;
277
+ }
278
+ case 'verify': {
279
+ ensureNoUnknownFlags(flags, ['evidence-root']);
280
+ output(verifyExportedEvidence(one(flags, 'evidence-root'), process.env['GOTRY_M3_COHORT_HMAC_KEY']));
281
+ return;
282
+ }
283
+ case 'lock-status': {
284
+ ensureNoUnknownFlags(flags, ['state-root']);
285
+ output(inspectStoreLock(one(flags, 'state-root')));
286
+ return;
287
+ }
288
+ case 'unlock': {
289
+ ensureNoUnknownFlags(flags, ['state-root']);
290
+ output(unlockStore(one(flags, 'state-root')));
291
+ return;
292
+ }
293
+ default:
294
+ throw new M3CohortError('bad_args');
295
+ }
296
+ }
297
+ function main() {
298
+ try {
299
+ const { command, flags } = parseFlags(process.argv.slice(2));
300
+ run(command, flags);
301
+ }
302
+ catch (error) {
303
+ const code = error instanceof M3CohortError && ERROR_CODES.includes(error.code) ? error.code : 'internal_error';
304
+ process.stderr.write(`m3_cohort_error:${code}\n`);
305
+ process.exitCode = 2;
306
+ }
307
+ }
308
+ const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
309
+ if (import.meta.url === invokedPath)
310
+ main();
@@ -4,7 +4,9 @@
4
4
  * - 封存价表仅可审计换算(peak 保守上界),未知模型 fail-closed,usage 缺失 fail-closed;
5
5
  * - run_key 确定性 + 记录必须过消费方 parseNightlyRun(生产器写的就是评分器读的);
6
6
  * - 无凭证 = waiting_external_evidence,不写任何文件(issue #22 停机纪律);
7
- * - --dry-run mock 全链演练可跑通且绝不落盘。
7
+ * - --dry-run mock 全链演练可跑通且绝不落盘;
8
+ * - --dry-run 默认离线(issue #617):PATH 上的计数 fake hbcli 零次被调起(第三轮「我订了酒店:…」会触发 PoI 探针),
9
+ * operator 显式设置的 GOTRY_HBCLI_LIVE 不被覆盖(同时是 fake 可达的 positive control),演练后环境原样还原。
8
10
  * 真跑(花钱)不在 CI:heartbeat/founder 手动 `npx tsx scripts/nightly-evidence.ts`。
9
11
  *
10
12
  * 价表 schema 演进(issue #49):v1 (legacy DeepSeek only) / v2 (provider-aware;DeepSeek+MiniMax);
@@ -13,7 +15,7 @@
13
15
  * usage 缺失 fail-closed、run_key 确定性 + 消费方 parseNightlyRun、无凭证 waiting、dry-run 零落盘。
14
16
  */
15
17
  import assert from 'node:assert/strict';
16
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
19
  import { join } from 'node:path';
18
20
  import { tmpdir } from 'node:os';
19
21
  import { buildNightlyRunRecord, loadPriceTable, loadPromptSet, priceRunCost, runNightlyEvidence, } from './nightly-evidence.js';
@@ -158,4 +160,44 @@ finally {
158
160
  rmSync(dryParent, { recursive: true, force: true });
159
161
  }
160
162
  ok('dry-run mock 全链演练 + real_llm=false + 零落盘');
163
+ // ---- 7b. issue #617:dry-run 是离线「mock 全链演练」,不得经 runTurn→probePoi→anythingSearch 连真实 hbcli 后端 ----
164
+ // 受控 PATH 上放计数 fake(只用 shell 内建);HOME 指向空目录,已知安装位回退碰不到开发机真实 hbcli。
165
+ const hbcliProbeRoot = mkdtempSync(join(tmpdir(), 'nightly-hbcli-'));
166
+ const hbcliProbeBin = join(hbcliProbeRoot, 'bin');
167
+ const hbcliCallsLog = join(hbcliProbeRoot, 'calls.log');
168
+ mkdirSync(hbcliProbeBin);
169
+ mkdirSync(join(hbcliProbeRoot, 'home'));
170
+ writeFileSync(join(hbcliProbeBin, 'hbcli'), `#!/bin/sh\nprintf 'call\\n' >> '${hbcliCallsLog}'\nprintf '%s' '{"candidates":[]}'\n`, { mode: 0o755 });
171
+ const hbcliCalls = () => existsSync(hbcliCallsLog) ? readFileSync(hbcliCallsLog, 'utf8').split('\n').filter(Boolean).length : 0;
172
+ const savedEnv = { PATH: process.env['PATH'], HOME: process.env['HOME'], LIVE: process.env['GOTRY_HBCLI_LIVE'] };
173
+ const restoreEnv = (key, value) => {
174
+ if (value === undefined)
175
+ delete process.env[key];
176
+ else
177
+ process.env[key] = value;
178
+ };
179
+ try {
180
+ process.env['PATH'] = `${hbcliProbeBin}:${savedEnv.PATH ?? ''}`;
181
+ process.env['HOME'] = join(hbcliProbeRoot, 'home');
182
+ const dryRoot2 = join(hbcliProbeRoot, 'evidence');
183
+ // 缺省(operator 未设):演练期间 GOTRY_HBCLI_LIVE=0,fake 零次被调起,演练后环境原样还原(无泄漏)
184
+ delete process.env['GOTRY_HBCLI_LIVE'];
185
+ const offlineDry = await runNightlyEvidence({ evidenceRoot: dryRoot2, dryRun: true, clock: () => new Date(executeAt) });
186
+ assert.equal(offlineDry.state, 'dry_run');
187
+ assert.equal(hbcliCalls(), 0, 'dry-run 缺省离线:PATH 上的 fake hbcli 必须被调起 0 次');
188
+ assert.equal(process.env['GOTRY_HBCLI_LIVE'], undefined, 'dry-run 结束后必须还原 GOTRY_HBCLI_LIVE(不得泄漏进后续进程内套件)');
189
+ // operator 显式设置的值不被覆盖——同时是 positive control:证明上面的「零次」不是因为 fake 不可达
190
+ process.env['GOTRY_HBCLI_LIVE'] = '1';
191
+ const explicitDry = await runNightlyEvidence({ evidenceRoot: dryRoot2, dryRun: true, clock: () => new Date(executeAt) });
192
+ assert.equal(explicitDry.state, 'dry_run');
193
+ assert.ok(hbcliCalls() >= 1, `显式 GOTRY_HBCLI_LIVE=1 不得被 dry-run 覆盖,PoI 探针应调起 fake(实际 ${hbcliCalls()} 次)`);
194
+ assert.equal(process.env['GOTRY_HBCLI_LIVE'], '1', '显式值演练后原样保留');
195
+ }
196
+ finally {
197
+ restoreEnv('PATH', savedEnv.PATH);
198
+ restoreEnv('HOME', savedEnv.HOME);
199
+ restoreEnv('GOTRY_HBCLI_LIVE', savedEnv.LIVE);
200
+ rmSync(hbcliProbeRoot, { recursive: true, force: true });
201
+ }
202
+ ok('dry-run 缺省 GOTRY_HBCLI_LIVE=0:fake hbcli 零次调起 + 环境还原;显式值不覆盖(positive control)');
161
203
  console.log(`nightly-evidence tests: ${passed} 组断言全绿(offline,真跑不在 CI)`);
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 运行(在 ts/ 下):
8
8
  * npx tsx scripts/nightly-evidence.ts # 真跑:需 LLM_API_KEY,追加证据记录
9
- * npx tsx scripts/nightly-evidence.ts --dry-run # mock 全链演练,绝不写证据
9
+ * npx tsx scripts/nightly-evidence.ts --dry-run # mock 全链演练,绝不写证据(缺省 GOTRY_HBCLI_LIVE=0:不连真实 hbcli;显式设置则尊重)
10
10
  * npx tsx scripts/nightly-evidence.ts --format json # 机器可读状态(heartbeat 消费)
11
11
  * npx tsx scripts/nightly-evidence.ts --no-env-file # 不读仓根 .env(等待态测试用)
12
12
  *
@@ -190,6 +190,17 @@ function arg(name) {
190
190
  const index = process.argv.indexOf(name);
191
191
  return index >= 0 ? process.argv[index + 1] : undefined;
192
192
  }
193
+ /**
194
+ * issue #617:`--dry-run` 是离线「mock 全链演练」,但 runTurn 的 PoI 探针会 spawn 本机真实 hbcli
195
+ * (带凭据即连真实后端、耗配额)。演练期间把离线开关缺省置 0——operator 显式设过 GOTRY_HBCLI_LIVE
196
+ * (含空串)则一律不覆盖;返回还原函数(本函数在进程内被测试与 persona harness 复用,不得泄漏环境)。
197
+ */
198
+ function defaultHbcliLiveOff() {
199
+ if (process.env['GOTRY_HBCLI_LIVE'] !== undefined)
200
+ return () => { };
201
+ process.env['GOTRY_HBCLI_LIVE'] = '0';
202
+ return () => { delete process.env['GOTRY_HBCLI_LIVE']; };
203
+ }
193
204
  export async function runNightlyEvidence(options) {
194
205
  const clock = options.clock ?? (() => new Date());
195
206
  const llmKey = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY'];
@@ -203,9 +214,15 @@ export async function runNightlyEvidence(options) {
203
214
  const port = options.dryRun ? createMockLlm(packPath) : createOpenAICompatLlm(packPath);
204
215
  const state = newState();
205
216
  const history = [];
206
- for (const turn of promptSet.turns) {
207
- const { reply } = await runTurn(state, turn, port, [...history], solvePort);
208
- history.push({ role: 'user', text: turn }, { role: 'assistant', text: reply });
217
+ const restoreHbcliLive = options.dryRun ? defaultHbcliLiveOff() : () => { };
218
+ try {
219
+ for (const turn of promptSet.turns) {
220
+ const { reply } = await runTurn(state, turn, port, [...history], solvePort);
221
+ history.push({ role: 'user', text: turn }, { role: 'assistant', text: reply });
222
+ }
223
+ }
224
+ finally {
225
+ restoreHbcliLive();
209
226
  }
210
227
  const promptSetSha = sha256Of(promptSet);
211
228
  // history 是 user/assistant 交错序列;assistant 回复落在每个 user 之后(index*2+1)