@quolu/lattice 0.75.0 → 0.77.0

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/bin/lattice.mjs CHANGED
@@ -185,8 +185,9 @@ await scheduleRuntimeErrorReport();
185
185
  * 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。
186
186
  */
187
187
  async function scheduleRuntimeErrorReport() {
188
+ // `reporting verify`の直後は送らない。受け口は1分に1回まで受けるので、続けて送ると断られる。
188
189
  if (help !== null || args[0] === '--version' || args[0] === 'hooks'
189
- || (args[0] === 'runtime-errors' && args[1] === 'report')) return;
190
+ || (args[0] === 'runtime-errors' && (args[1] === 'report' || (args[1] === 'reporting' && args[2] === 'verify')))) return;
190
191
  try {
191
192
  const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs');
192
193
  if (!runtimeErrorReportingEnabled()) return;
@@ -202,11 +203,10 @@ async function scheduleRuntimeErrorReport() {
202
203
 
203
204
  // typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で
204
205
  // 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。
206
+ // 取消は記録せず、通信の失敗が漏れたものは内部故障と別の記録になる(ADR 0196)。
205
207
  function observeInternalFailure(error, commandKind) {
206
- pendingObservation = import('../src/runtime-errors.mjs').then(({ observeRuntimeError, runtimeErrorSafeContext }) => {
207
- observeRuntimeError('LATTICE.CLI_INTERNAL_FAILED', {
208
- version: packageJson.version, safeContext: runtimeErrorSafeContext({ commandKind, error }),
209
- });
208
+ pendingObservation = import('../src/runtime-errors.mjs').then(({ observeEscapedCliFailure }) => {
209
+ observeEscapedCliFailure({ error, commandKind, version: packageJson.version });
210
210
  }).catch(() => {});
211
211
  }
212
212
 
@@ -222,7 +222,7 @@ function workingDirectory() {
222
222
  async function runRuntimeErrorsCli(rest) {
223
223
  const runtimeErrors = await import('../src/runtime-errors.mjs');
224
224
  const usage = () => {
225
- process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact|report|reporting <status|enable|disable>> --json' })}\n`);
225
+ process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact|report|reporting <status|enable|disable|verify>> --json' })}\n`);
226
226
  return 2;
227
227
  };
228
228
  const options = { version: packageJson.version };
@@ -251,6 +251,12 @@ async function runRuntimeErrorsCli(rest) {
251
251
  result = runtimeErrors.setRuntimeErrorStatus(words[1], words[0] === 'resolve' ? 'resolved' : 'open', options);
252
252
  } else if (words[0] === 'compact' && words.length === 1) {
253
253
  result = runtimeErrors.compactRuntimeErrors(options);
254
+ } else if (words[0] === 'reporting' && words.length === 2 && words[1] === 'verify') {
255
+ // 空の報告を1通送って経路を確かめる。受領まで確かめられた時だけexit 0。
256
+ const reporting = await import('../src/runtime-error-reporting.mjs');
257
+ result = await reporting.verifyRuntimeErrorReporting(options);
258
+ process.stdout.write(`${JSON.stringify(result)}\n`);
259
+ return result.outcome === 'verified' ? 0 : 1;
254
260
  } else if (words[0] === 'reporting' && words.length === 2 && ['status', 'enable', 'disable'].includes(words[1])) {
255
261
  const reporting = await import('../src/runtime-error-reporting.mjs');
256
262
  result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.75.0",
3
+ "version": "0.77.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -12,6 +12,12 @@ const RUNTIME_SURFACES = Object.freeze({
12
12
  event: Object.freeze(['verify']),
13
13
  });
14
14
 
15
+ /**
16
+ * storeも端末の状態も書き換えないと確かめた面(ADR 0196)。通信の失敗が漏れた記録の重大度を決める時に使う
17
+ * ——ここに在る面は、落ちても失うものが無く、打ち直せば戻る。確かめていない面は載せない。
18
+ */
19
+ export const READ_ONLY_COMMAND_KINDS = new Set(['run.list', 'todo.status', 'todo.verify']);
20
+
15
21
  export function cliCommandKind(argv, surfaces = RUNTIME_SURFACES) {
16
22
  if (!Array.isArray(argv)) return 'other';
17
23
  const [surface, subcommand] = argv;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * typed契約の外へ漏れた例外を、取消・通信の失敗・それ以外に分ける(ADR 0196)。
3
+ *
4
+ * runtime error記録の入口が使う。取消は故障として記録しない。通信の失敗が漏れたものは、内部故障とは
5
+ * 別の記録にする——原因が通信でも、型つきの答えにできず落ちたのは製品の対処不良で、直すのは回線ではなく
6
+ * その面の通信失敗の受け方である。
7
+ */
8
+
9
+ // Nodeとundiciが通信の失敗に付けるcode。端末内のportの取り合い(EADDRINUSE)と、子processとのpipeでも
10
+ // 起きるEPIPEは、通信の失敗に数えない。
11
+ const TRANSPORT_CODES = new Set([
12
+ 'ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'EHOSTUNREACH', 'EHOSTDOWN', 'ENETUNREACH',
13
+ 'ENETDOWN', 'ENOTFOUND', 'EAI_AGAIN', 'ENOTCONN',
14
+ 'ERR_SOCKET_CONNECTION_TIMEOUT', 'ERR_SOCKET_CLOSED',
15
+ 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_CLOSED',
16
+ ]);
17
+ const CAUSE_DEPTH = 4;
18
+
19
+ /** 例外と、その`cause`の連なり。`fetch failed`(TypeError)は、通信のcodeを`cause`の側に持つ。 */
20
+ export function failureCauseChain(error) {
21
+ const chain = [];
22
+ for (let current = error; current !== null && typeof current === 'object' && chain.length < CAUSE_DEPTH;
23
+ current = current.cause) chain.push(current);
24
+ return chain;
25
+ }
26
+
27
+ /** `cancelled`(利用者や呼び出し元の取消)・`transport`(通信の失敗)・`internal`(それ以外)。 */
28
+ export function cliFailureClass(error) {
29
+ const chain = failureCauseChain(error);
30
+ if (chain.some((entry) => entry.name === 'AbortError' || entry.code === 'ABORT_ERR')) return 'cancelled';
31
+ // `AbortSignal.timeout`の時間切れは`TimeoutError`で届く。
32
+ if (chain.some((entry) => entry.name === 'TimeoutError' || TRANSPORT_CODES.has(entry.code))) return 'transport';
33
+ return 'internal';
34
+ }
package/src/cli-help.mjs CHANGED
@@ -198,6 +198,7 @@ Commands:
198
198
  compact
199
199
  report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ)
200
200
  reporting <status|enable|disable>
201
+ reporting verify 空の報告を1通送り、合鍵と受け口までの経路を確かめる(記録には触れない)
201
202
  `,
202
203
  bridge: `Usage: lattice bridge <command> [options] --json
203
204
 
@@ -315,7 +316,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
315
316
  'runtime-errors reopen': 'runtime-errors reopen <fingerprint> --json',
316
317
  'runtime-errors compact': 'runtime-errors compact --json',
317
318
  'runtime-errors report': 'runtime-errors report --json',
318
- 'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable> --json',
319
+ 'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable|verify> --json',
319
320
  'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
320
321
  'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
321
322
  'bridge status': 'bridge status --json',
@@ -31,6 +31,8 @@ import {
31
31
  * - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。
32
32
  * そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。
33
33
  * - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。
34
+ * - `reporting verify`は、記録を載せない空の報告を1通送って、合鍵・署名・受け口までの経路を確かめる。
35
+ * 受け口は受領を記録するだけで、issueも通知も動かない。storeの記録と受領の印には触れない。
34
36
  */
35
37
 
36
38
  const REPORT_SCHEMA_VERSION = '1.0';
@@ -275,6 +277,41 @@ function parseJson(text) {
275
277
  }
276
278
  }
277
279
 
280
+ /**
281
+ * 報告を1通、署名して送り、受領を確かめる。`accepted`を返すのは、200・`accepted: true`・`report_id`一致・
282
+ * 応答の署名一致がそろった時だけ。戻り値に秘密・key_id・宛先は入れない。
283
+ */
284
+ async function sendReport({ url, keyId, secret }, snapshot, options) {
285
+ const now = nowMs(options);
286
+ const ts = Math.floor(now / 1000);
287
+ // `observed_at`は、載せる記録のどの時刻よりも前にしない(BugHubは観測より後の記録を断る)。秒へ切り捨てると、
288
+ // 記録と同じ1秒の中で送った時に記録より前へずれる。`ts`とは同じ時刻から作り、差はBugHubの許す幅(10分)に収まる。
289
+ const observed = Math.max(now, ...snapshot.runtime_errors.map((record) => Date.parse(record.last_seen)),
290
+ ...snapshot.resolutions.map((resolution) => Date.parse(resolution.resolved_at)));
291
+ const report = buildRuntimeErrorReport({ snapshot, version: options.version ?? 'unknown',
292
+ reportId: options.reportId ?? randomUUID(), observedAt: new Date(observed).toISOString() });
293
+ const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
294
+ if (bodyBytes.length > REPORT_MAX_BYTES) return { outcome: 'too_large', reason: 'report_too_large', http_status: null };
295
+
296
+ const response = await post(url, {
297
+ 'Content-Type': 'application/json',
298
+ 'Content-Length': String(bodyBytes.length),
299
+ Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
300
+ }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
301
+
302
+ if (response.status === null) return { outcome: 'unconfirmed', reason: response.reason, http_status: null };
303
+ const body = parseJson(response.body);
304
+ if (response.status === 200) {
305
+ if (body === null || body.accepted !== true || body.report_id !== report.report_id
306
+ || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
307
+ return { outcome: 'unconfirmed', reason: 'receipt_not_verified', http_status: 200 };
308
+ }
309
+ return { outcome: 'accepted', reason: null, http_status: 200 };
310
+ }
311
+ const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
312
+ return { outcome: response.status >= 500 ? 'unconfirmed' : 'rejected', reason: code, http_status: response.status };
313
+ }
314
+
278
315
  /**
279
316
  * 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない
280
317
  * ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。
@@ -305,13 +342,6 @@ export async function reportRuntimeErrors(options = {}) {
305
342
  if (reason !== null) return result('throttled', { reason });
306
343
  }
307
344
 
308
- // `ts`と`observed_at`は同じ時刻から作る(BugHubは10分より離れた組を断る)。
309
- const ts = Math.floor(nowMs(options) / 1000);
310
- const report = buildRuntimeErrorReport({ snapshot: pending, version: options.version ?? 'unknown',
311
- reportId: options.reportId ?? randomUUID(), observedAt: new Date(ts * 1000).toISOString() });
312
- const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
313
- if (bodyBytes.length > REPORT_MAX_BYTES) return result('rejected', { reason: 'report_too_large', sent });
314
-
315
345
  const finish = (outcome, extra = {}) => {
316
346
  try {
317
347
  writeDelivery(options, { schema: DELIVERY_SCHEMA, last_attempt_at: new Date(nowMs(options)).toISOString(),
@@ -320,31 +350,33 @@ export async function reportRuntimeErrors(options = {}) {
320
350
  return result(outcome, { sent, acknowledged_through: pending.cursor.acknowledged_through, ...extra });
321
351
  };
322
352
 
323
- const { credential: { url, keyId, secret } } = credential;
324
- const response = await post(url, {
325
- 'Content-Type': 'application/json',
326
- 'Content-Length': String(bodyBytes.length),
327
- Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
328
- }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
329
-
330
- if (response.status === null) return finish('unconfirmed', { reason: response.reason });
331
- const body = parseJson(response.body);
332
- if (response.status === 200) {
333
- if (body === null || body.accepted !== true || body.report_id !== report.report_id
334
- || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
335
- return finish('unconfirmed', { reason: 'receipt_not_verified', http_status: 200 });
336
- }
337
- try {
338
- const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
339
- return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
340
- } catch {
341
- // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
342
- return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
343
- }
353
+ const answer = await sendReport(credential.credential, pending, options);
354
+ if (answer.outcome === 'too_large') return result('rejected', { reason: 'report_too_large', sent });
355
+ if (answer.outcome !== 'accepted') return finish(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
356
+ try {
357
+ const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
358
+ return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
359
+ } catch {
360
+ // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
361
+ return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
344
362
  }
345
- const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
346
- if (response.status >= 500) return finish('unconfirmed', { reason: code, http_status: response.status });
347
- return finish('rejected', { reason: code, http_status: response.status });
363
+ }
364
+
365
+ /**
366
+ * 記録を載せない空の報告を1通送り、合鍵・署名・受け口までの経路を確かめる(`reporting verify`)。
367
+ * 送る条件は`report`と同じで、送信を有効にしていない端末と合鍵の無い端末はnetworkへ触れない。
368
+ * storeの記録・受領の印・送る時機の記録には触れない。受け口は端末×製品ごとに1分に1回まで受けるので、
369
+ * 直前に送っていれば429で断られる。
370
+ */
371
+ export async function verifyRuntimeErrorReporting(options = {}) {
372
+ if (!runtimeErrorCollectionSupported(options)) return result('unsupported');
373
+ if (!runtimeErrorReportingEnabled(options)) return result('disabled');
374
+ const credential = readProductCredential(options);
375
+ if (credential.status === 'missing') return result('credential_missing');
376
+ if (credential.status !== 'ok') return result('credential_unsafe', { reason: credential.reason });
377
+ const answer = await sendReport(credential.credential, { runtime_errors: [], resolutions: [] }, options);
378
+ if (answer.outcome === 'accepted') return result('verified', { http_status: 200 });
379
+ return result(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
348
380
  }
349
381
 
350
382
  /**
@@ -5,6 +5,8 @@ import {
5
5
  import { homedir, platform as hostPlatform, arch as hostArch } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
 
8
+ import { READ_ONLY_COMMAND_KINDS } from './cli-command-kind.mjs';
9
+ import { cliFailureClass, failureCauseChain } from './cli-failure-class.mjs';
8
10
  import {
9
11
  daclIsOwnerOnly, daclIsProtected, isAclScratchName, readWindowsDacl, restrictWindowsDirToOwner, windowsSelfSid,
10
12
  } from './windows-owner-only.mjs';
@@ -40,6 +42,9 @@ const definitions = Object.freeze({
40
42
  'LATTICE.RUN_STORE_IO_FAILED': { component: 'run_store', severity: 'high', template: 'Lattice run store IO failed' },
41
43
  'LATTICE.EVENT_CHAIN_INTEGRITY_FAILED': { component: 'event_store', severity: 'high', template: 'Lattice run event chain integrity check failed' },
42
44
  'LATTICE.CLI_INTERNAL_FAILED': { component: 'cli', severity: 'high', template: 'Lattice CLI crashed outside the typed error contract' },
45
+ // 通信の失敗が型つき契約の外へ漏れた記録(ADR 0196)。直すのは回線ではなく、その面の通信失敗の受け方。
46
+ // 重大度は下の`severityOf`が、落ちた面で失うものがあるかで決める。
47
+ 'LATTICE.CLI_TRANSPORT_UNHANDLED': { component: 'cli', severity: 'high', template: 'Lattice CLI let a communication failure escape the typed error contract' },
43
48
  'LATTICE.MCP_SERVER_FAILED': { component: 'mcp', severity: 'high', template: 'Lattice MCP server failed' },
44
49
  });
45
50
 
@@ -169,13 +174,28 @@ const validSafeContext = (value) => plain(value) && exact(value, SAFE_CONTEXT_KE
169
174
  */
170
175
  export function runtimeErrorSafeContext({ commandKind, error } = {}) {
171
176
  const errorKind = error?.constructor?.name;
177
+ // `fetch failed`のように、codeを`cause`の側に持つ例外がある。連なりの中で最初に語彙へ合うcodeを載せる。
178
+ const causeCode = failureCauseChain(error).map((entry) => entry.code)
179
+ .find((code) => typeof code === 'string' && CAUSE_CODE.test(code));
172
180
  return {
173
181
  command_kind: validCommandKind(commandKind) ? commandKind : 'other',
174
182
  error_kind: ERROR_KINDS.has(errorKind) ? errorKind : 'other',
175
- cause_code: typeof error?.code === 'string' && CAUSE_CODE.test(error.code) ? error.code : 'none',
183
+ cause_code: causeCode ?? 'none',
176
184
  };
177
185
  }
178
186
 
187
+ /**
188
+ * 記録の重大度。error_codeだけでは決めない記録がある(ADR 0196): 通信の失敗が漏れた記録は、落ちた面が
189
+ * 何も書き換えないと確かめてある時だけ`warn`——その1回が止まっただけで、失うものが無く、打ち直せば戻る。
190
+ * 書き換える面と、確かめていない面は`high`のまま(結果が分からず、不整合や重複を否定できない)。
191
+ * 分類と面から決まる値なので、同じfingerprintの記録は必ず同じ重大度になる。
192
+ */
193
+ function severityOf(code, safeContext = null) {
194
+ if (code === 'LATTICE.CLI_TRANSPORT_UNHANDLED' && safeContext !== null
195
+ && READ_ONLY_COMMAND_KINDS.has(safeContext.command_kind)) return 'warn';
196
+ return definitions[code].severity;
197
+ }
198
+
179
199
  /**
180
200
  * 旧記録(safe_context無し)はerror_codeだけで決まる式のまま。新しい記録は3つの分類も含める
181
201
  * ——原因が違えば別の記録になる。式はdotagentsのadapterが再計算して照合する契約で、
@@ -269,7 +289,7 @@ function validate(store) {
269
289
  const definition = definitions[record.error_code];
270
290
  if (!definition || record.product !== PRODUCT || !validVersion(record.product_version)
271
291
  || record.component !== definition.component || record.message_template !== definition.template
272
- || record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
292
+ || record.severity !== severityOf(record.error_code, safeContext) || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
273
293
  || seen.has(record.fingerprint) || !Number.isSafeInteger(record.count) || record.count < 1
274
294
  || !validTime(record.first_seen) || !validTime(record.last_seen)
275
295
  || Date.parse(record.first_seen) > Date.parse(record.last_seen)
@@ -442,7 +462,7 @@ export function recordRuntimeError(code, options = {}) {
442
462
  if (store.records.length >= MAX_RECORDS) throw Error('store_overflow');
443
463
  store.records.push({
444
464
  product: PRODUCT, product_version: version, component: definition.component, error_code: code,
445
- message_template: definition.template, severity: definition.severity, fingerprint: key,
465
+ message_template: definition.template, severity: severityOf(code, safeContext), fingerprint: key,
446
466
  count: 1, first_seen: time, last_seen: time, state_schema_version: STATE_VERSION,
447
467
  os, arch, status: 'open', resolved_at: null, reason_code: null, sequence,
448
468
  safe_context: { ...safeContext },
@@ -462,6 +482,17 @@ export function observeRuntimeError(code, options = {}) {
462
482
  }
463
483
  }
464
484
 
485
+ /**
486
+ * CLIのtyped契約の外へ漏れた例外を記録する(process境界のbinが呼ぶ)。取消は故障ではないので記録しない。
487
+ * 通信の失敗が漏れたものは内部故障と別の記録にし、重大度を面で決める(ADR 0196)。
488
+ */
489
+ export function observeEscapedCliFailure({ error, commandKind, ...options }) {
490
+ const failureClass = cliFailureClass(error);
491
+ if (failureClass === 'cancelled') return;
492
+ observeRuntimeError(failureClass === 'transport' ? 'LATTICE.CLI_TRANSPORT_UNHANDLED' : 'LATTICE.CLI_INTERNAL_FAILED',
493
+ { ...options, safeContext: runtimeErrorSafeContext({ commandKind, error }) });
494
+ }
495
+
465
496
  export function acknowledgeRuntimeErrors(cursor, options = {}) {
466
497
  requireCursor(cursor);
467
498
  if (!collectionEnabled(options)) return snapshot({ ...options, afterCursor: cursor });
package/src/todo-cli.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { cliCommandKind } from './cli-command-kind.mjs';
2
+ import { cliFailureClass } from './cli-failure-class.mjs';
2
3
  import { gitSync } from './git-process.mjs';
3
4
  import { createHash } from 'node:crypto';
4
5
  import {
@@ -4253,7 +4254,8 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env,
4253
4254
  } catch (error) {
4254
4255
  if (error instanceof TodoStoreError || (typeof error?.code === 'string'
4255
4256
  && error.detail !== null && typeof error.detail === 'object')) return typedFailure(stderr, error);
4256
- if (error instanceof TypeError) {
4257
+ // 漏れた`fetch failed`もTypeErrorで届く。呼び出し側の契約違反と答えず、内部故障として観測口へ渡す。
4258
+ if (error instanceof TypeError && cliFailureClass(error) !== 'transport') {
4257
4259
  return typedFailure(stderr, {
4258
4260
  code: 'CONTRACT_VIOLATION',
4259
4261
  message: error.message,