@shimatoworks/stw-agent 0.1.1 → 0.3.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/README.md CHANGED
@@ -35,8 +35,52 @@ set -a && source ~/.config/tamai/stw-agent.env && set +a
35
35
  stw-agent --help
36
36
  ```
37
37
 
38
- 動詞は `projects list` / `projects context` / `slack channels` / `slack messages` /
39
- `slack next` 5 つ。正確なオプションは常に `--help` を見る(ここに写経しない)。
38
+ 動詞は `doctor` / `projects list` / `projects context` / `projects resources` /
39
+ `projects next` / `slack channels` / `slack messages` / `slack next` の 8 つ。
40
+ 正確なオプションは常に `--help` を見る(ここに写経しない)。
41
+
42
+ ### Drive の資料を探す(`projects resources`)
43
+
44
+ ```bash
45
+ stw-agent projects resources yoshino
46
+ ```
47
+
48
+ 案件に紐付いた **Drive 入口フォルダ配下の索引**(名前・MIME・更新日・親子・
49
+ ショートカット先・「資料候補」の印)を返す。入口の URL だけでは
50
+ 「直下に 1 フォルダしかなく、主要資料はさらに下」という形に辿り着けないため。
51
+
52
+ **本文は返らない。** `fileId` を `gog` へ渡して読む(guide §4)。
53
+
54
+ 読み方で気をつけること:
55
+
56
+ - 索引は「**SA が読めるメタデータを、台帳で指定された受け手へ公開する**」もので、
57
+ 本人が Google で読める全ファイルではない。入口ごとに admin が有効化したものだけが出る
58
+ - `indexes[].complete` が false なら**まだ全部ではない**(`reason` に理由がある)。
59
+ **終了コード 0 でも起こる**ので、「これで全部」と書かない
60
+ - 続きは出力の「次:」行をそのまま実行する(`projects next '<query>' --slug <slug>`)。
61
+ cursor は不透明な値で、手で組み立てると 400 になる
62
+ - `409` は索引が作り直された合図。`--cursor` を外して取り直す
63
+ - `503 drive_unavailable` は一時的な確認失敗。**古い一覧を成功として返さない**ので、
64
+ 空ではなくエラーになる
65
+
66
+ ### まず `doctor`
67
+
68
+ ```bash
69
+ stw-agent doctor
70
+ ```
71
+
72
+ token・読み取り API への到達・token の権限・周辺ツール(`ntn` / `gog` / `hub` /
73
+ `gh` / `bun`)の有無と認証を一度に見る。各項目は
74
+ **OK / 未導入 / 未認証 / 権限なし / 到達不可 / 不明** のどれかで、
75
+ 要対応には直し方が 1 行付く。`--json` あり。
76
+
77
+ **`不明` は失敗ではない**(確かめられなかっただけ)。終了コードは
78
+ **要対応があるときだけ 1** になるので、`stw-agent doctor && 次の作業` と繋げられる。
79
+ たとえば `hub` は認証を検証する手立てが無いので、設定があっても `不明` のままになる。
80
+
81
+ 秘密は出さない。**token は値も長さも先頭数文字も出さない**(出すのは env から読んだのか
82
+ 設定ファイルから読んだのかだけ)。接続先は origin だけを出し、`STW_API_BASE` に
83
+ 資格情報(`user:password@`)が入っていれば**要求を投げる前に断る**。
40
84
 
41
85
  既定の出力は 1 行要約で、メッセージの本文は先頭だけを出す。
42
86
  会社の内部のやり取りが端末のログや transcript へ機械的に積み上がるのを避けるため。
@@ -53,6 +97,17 @@ stw-agent --help
53
97
 
54
98
  ## 変更履歴
55
99
 
100
+ - **0.3.0** — `projects resources`(Drive 入口配下の索引)と `projects next` を追加。
101
+ 資料候補をページ内で先に出し、`complete=false` のときは理由を必ず添える。
102
+ 409 / `503 drive_unavailable` に「取り直す」「一時的」の案内を付けた。
103
+ **本文は返さない**(`fileId` を `gog` へ渡す)
104
+ - **0.2.1** — `doctor` の bun の案内を「入れたあと PATH に `~/.bun/bin` を足す」まで書くようにした。
105
+ publish は GitHub Actions(npm trusted publishing・タグ push)へ移した
106
+ (repo が private なので provenance は付かない)
107
+ - **0.2.0** — `stw-agent doctor` を追加(token・API 到達・権限・周辺ツールの一括確認)。
108
+ `STW_API_BASE` に資格情報が入っていれば要求前に拒否し、表示は origin だけにする。
109
+ `slack channels` の既定出力に、サーバが返すようになれば保存範囲(最古〜最新・件数)と
110
+ 到達状態(上限到達/さかのぼり中/読み切り/未走査)・最後の失敗が出る
56
111
  - **0.1.1** — `npm i -g` した `stw-agent`(symlink)が無出力で終了する不具合を修正。
57
112
  実体パスでの直接実行は 0.1.0 でも動いていた
58
113
  - 0.1.0 — 最初の公開
package/bin/stw-agent.mjs CHANGED
@@ -31,11 +31,13 @@
31
31
  // 契約が 2 か所に増えて食い違う。**CLI が自分で断るのは「使い方」だけ**
32
32
  // (`--project` と `--channel` の排他など、要求を組み立てられない場合)。
33
33
 
34
- import { realpathSync } from 'node:fs';
34
+ import { execFile } from 'node:child_process';
35
+ import { accessSync, realpathSync } from 'node:fs';
35
36
  import { readFile } from 'node:fs/promises';
36
37
  import os from 'node:os';
37
38
  import path from 'node:path';
38
39
  import { pathToFileURL } from 'node:url';
40
+ import { promisify } from 'node:util';
39
41
 
40
42
  /** 接続先の既定。看板の仕様値。別環境は `STW_API_BASE` で上書きする。 */
41
43
  export const DEFAULT_API_BASE = 'https://api.shimatoworks.jp';
@@ -51,11 +53,15 @@ export const USAGE = `使い方:
51
53
  stw-agent projects context <slug> [--include-slack <true|false>]
52
54
  [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>]
53
55
  [--q <text>] [--limit <n>] [--json]
56
+ stw-agent projects resources <slug> [--system drive] [--resource-id <uuid>]
57
+ [--limit <n>] [--cursor <c>] [--json]
54
58
  stw-agent slack channels [--project <slug>] [--limit <n>] [--cursor <c>] [--json]
55
59
  stw-agent slack messages (--project <slug> | --channel <id>)
56
60
  [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--q <text>]
57
61
  [--limit <n>] [--thread <ts>] [--cursor <c>] [--json]
58
62
  stw-agent slack next <continuationQuery> [--json]
63
+ stw-agent projects next <continuationQuery> --slug <slug> [--json]
64
+ stw-agent doctor [--json]
59
65
 
60
66
  環境変数:
61
67
  STW_AGENT_TOKEN 読み取り token(必須)。~/.config/tamai/stw-agent.env から自動で読む
@@ -63,10 +69,17 @@ export const USAGE = `使い方:
63
69
 
64
70
  set -a && source ~/.config/tamai/stw-agent.env && set +a
65
71
 
72
+ 困ったらまず stw-agent doctor(token・API 到達・ntn / gog / hub / gh / bun をまとめて見る)。
73
+
66
74
  読み方の約束・許可の範囲・エラーの意味は stw-manage の
67
75
  docs/guides/案件コンテキストの集め方.md を見る。
68
76
  既定の出力は 1 行要約(本文は先頭だけ)。全文が要るときだけ --json を付ける。
69
77
  期間を省略すると当日を含む直近 30 日。続きは continuationQuery を slack next へそのまま渡す。
78
+
79
+ projects resources は Drive 入口の**索引**(名前・MIME・更新日・親子・ショートカット先)を返す。
80
+ **本文は返さない** —— fileId を gog へ渡して読む(guide §4)。
81
+ 索引は「SA が読めるメタデータを台帳の受け手へ公開する」もので、本人が Google で読める
82
+ 全ファイルではない。complete=false なら**まだ全部ではない**(終了コード 0 でも起こる)。
70
83
  `;
71
84
 
72
85
  // ---------------------------------------------------------------------------
@@ -193,7 +206,55 @@ export async function configFromEnv(env, readFileImpl = readFile) {
193
206
  );
194
207
  }
195
208
 
196
- return { token, baseUrl: (baseUrl || DEFAULT_API_BASE).replace(/\/+$/, '') };
209
+ // **接続先を検証してから返す。** token より後に見るのは、doctor
210
+ // 「token が無い」と「接続先の設定が壊れている」を取り違えないため。
211
+ return { token, baseUrl: assertSafeBaseUrl(baseUrl || DEFAULT_API_BASE) };
212
+ }
213
+
214
+ /**
215
+ * 接続先 URL の検査。**資格情報入りの URL は要求を投げる前に断る**(R1 codex2 High 1)。
216
+ *
217
+ * `https://user:password@host` のような形は、`fetch` が拒否しなくても
218
+ * エラー経路や診断の表示に丸ごと出てしまう。受け取らないのが一番確実である。
219
+ * query と hash も接続先には要らないので落とす(値が載っていても運ばない)。
220
+ */
221
+ export function assertSafeBaseUrl(raw) {
222
+ const trimmed = String(raw).replace(/\/+$/, '');
223
+ let url;
224
+ try {
225
+ url = new URL(trimmed);
226
+ } catch {
227
+ throw cliError(
228
+ 'STW_API_BASE が URL として読めません(https://host の形で指定してください)。',
229
+ 'base_url_invalid',
230
+ );
231
+ }
232
+ if (url.username !== '' || url.password !== '') {
233
+ // **値そのものは出さない。** 「どこが悪いか」だけを言う。
234
+ throw cliError(
235
+ 'STW_API_BASE に資格情報(user:password@)を含めないでください。' +
236
+ 'token は STW_AGENT_TOKEN で渡します。',
237
+ 'base_url_credentials',
238
+ );
239
+ }
240
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
241
+ throw cliError('STW_API_BASE は https を使ってください。', 'base_url_insecure');
242
+ }
243
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
244
+ }
245
+
246
+ /**
247
+ * 表示してよい接続先の文字列。**origin だけ**(userinfo / query / hash は出さない)。
248
+ *
249
+ * `configFromEnv()` を通っていれば資格情報は入っていないが、表示側でも重ねて絞る
250
+ * ——「設定を読む経路が増えたときに、表示だけが古い前提のまま残る」を防ぐ。
251
+ */
252
+ export function safeBaseLabel(baseUrl) {
253
+ try {
254
+ return new URL(String(baseUrl)).origin;
255
+ } catch {
256
+ return '接続先設定が不正';
257
+ }
197
258
  }
198
259
 
199
260
  // ---------------------------------------------------------------------------
@@ -303,9 +364,17 @@ export function formatFailure(error) {
303
364
  if (error.status === 404) {
304
365
  lines.push(' 対象が無いか、この token の許可範囲外です。403 との違いは guide §3 を見る。');
305
366
  }
367
+ if (error.status === 409) {
368
+ // **cursor を組み立て直させない**(索引が作り直された後の続きは存在しない)。
369
+ lines.push(' 索引が更新されました。--cursor を外して取り直してください。');
370
+ }
306
371
  if (error.status === 429) {
307
372
  lines.push(' 枠(60 回/分・1,000 回/時)を超えました。token は他のワーカーと共有です。');
308
373
  }
374
+ if (error.status === 503 && code === 'drive_unavailable') {
375
+ lines.push(' Drive の再確認ができませんでした(一時的)。少し待って同じ要求を出す。');
376
+ lines.push(' 古い一覧を「成功」として返さないので、空ではなく 503 になります。');
377
+ }
309
378
  return lines.join('\n');
310
379
  }
311
380
 
@@ -350,15 +419,142 @@ export function formatProjectLines(response) {
350
419
  * (guide §5)。読み手が判断できる最小限として、最終同期の時刻・失敗の有無・
351
420
  * さかのぼりの途中かを出す。
352
421
  */
353
- export function formatSyncState(channel) {
422
+ export function formatSyncState(channel, { includeBackfill = true } = {}) {
354
423
  if (channel.syncHealth === 'never_synced') return '同期 まだ(未取り込み)';
355
424
  const at = channel.lastSyncAt ? formatJst(channel.lastSyncAt) : '不明';
356
425
  const failed = channel.syncHealth === 'error' ? '・失敗あり' : '';
357
426
  // 古い側を読み切っていない = 過去の発言がまだ揃っていない。
358
- const backfill = channel.backfillDone === false ? '・さかのぼり中' : '';
427
+ // **到達状態を別の列で出すときは重ねない**(`includeBackfill: false`)。
428
+ const backfill = includeBackfill && channel.backfillDone === false ? '・さかのぼり中' : '';
359
429
  return `同期 ${at}${failed}${backfill}`;
360
430
  }
361
431
 
432
+ /**
433
+ * 保存済みの範囲(最古〜最新・件数)。
434
+ *
435
+ * 値は `channel.coverage`(shared の `SlackSyncCoverage`)から読む。
436
+ * **サーバがこの塊を返さないうちは何も出さない。** CLI は新しい版でも古い
437
+ * デプロイ先を相手にするので、無い項目を「0 件」や「不明」と書くと、
438
+ * 「まだ返していない」と「本当に空」が混ざる。
439
+ */
440
+ export function formatStoredRange(channel) {
441
+ const coverage = channel?.coverage;
442
+ if (!coverage) return '';
443
+
444
+ const { oldestStoredAt, newestStoredAt, storedMessageCount, storedThreadCount } = coverage;
445
+ const count = typeof storedMessageCount === 'number' ? storedMessageCount : null;
446
+ if (count === 0 || (!oldestStoredAt && !newestStoredAt)) {
447
+ return count === null ? '保存 なし' : `保存 ${count} 件`;
448
+ }
449
+ const from = oldestStoredAt ? formatJst(oldestStoredAt) : '?';
450
+ const to = newestStoredAt ? formatJst(newestStoredAt) : '?';
451
+ const messages = count === null ? '' : ` ${count} 件`;
452
+ // スレッドは「返信のある親の数」。0 のときは黙る(無い方が普通なので)。
453
+ const threads = typeof storedThreadCount === 'number' && storedThreadCount > 0
454
+ ? `(スレッド ${storedThreadCount})`
455
+ : '';
456
+ return `保存 ${from}〜${to}${messages}${threads}`;
457
+ }
458
+
459
+ /**
460
+ * どこまで遡れたか。
461
+ *
462
+ * 「全部読めた」を 1 つの真偽値に畳まない(看板 01M1V8PNF9HE6W288PCE8VJXRE)。
463
+ * フリープランの上限に当たって**それ以上は取れない**のか、まだ遡っている途中なのか、
464
+ * そもそも始まっていないのかは別のことである。
465
+ *
466
+ * `backfillDone` は `coverage` ではなく**チャンネル直下**(shared の互換フィールド)。
467
+ */
468
+ export function formatReachState(channel) {
469
+ const coverage = channel?.coverage;
470
+ if (!coverage) return '';
471
+
472
+ const { backfillTargetReachedAt, backfillStartedAt, scannedThrough } = coverage;
473
+ if (backfillTargetReachedAt) {
474
+ // 上限に当たった = これ以上は Slack 側に無い。ここが「読み切った」の意味。
475
+ return `到達 上限まで(${formatJst(backfillTargetReachedAt)})`;
476
+ }
477
+ if (channel.backfillDone === true) return '到達 読み切り';
478
+ if (backfillStartedAt || scannedThrough) {
479
+ const through = scannedThrough ? `(${formatJst(scannedThrough)} まで走査)` : '';
480
+ return `到達 さかのぼり中${through}`;
481
+ }
482
+ // **始まっていないものを「さかのぼり中」と言わない。** 走査の記録が 1 つも無い。
483
+ return '到達 未走査';
484
+ }
485
+
486
+ /**
487
+ * いま抱えている失敗(`coverage.lastFailure`)。
488
+ *
489
+ * **code だけを出す。** shared 側が `SLACK_SYNC_FAILURE_CODE_RE` で
490
+ * `snake_case` の短い token に絞っているので、利用者の値は入らない。
491
+ */
492
+ export function formatLastFailure(channel) {
493
+ const failure = channel?.coverage?.lastFailure;
494
+ if (!failure?.code) return '';
495
+ const at = failure.at ? `・${formatJst(failure.at)}` : '';
496
+ return `失敗 ${failure.code}${at}`;
497
+ }
498
+
499
+ /**
500
+ * Drive の索引 1 行(`projects resources`)。
501
+ *
502
+ * **文書候補をページ内で先に出す**(§6)。`hints.candidate` が true のものを上へ持ってきて、
503
+ * 名前・MIME・更新日・fileId・入口からの相対位置を示す。並べ替えるのは**このページの中だけ**
504
+ * で、取得順(keyset)そのものは変えない —— 変えると続きのページで取りこぼす。
505
+ *
506
+ * 本文は返らないので、読むときは fileId を gog へ渡す(guide §4)。
507
+ */
508
+ export function formatDriveResourceLines(response) {
509
+ const lines = [];
510
+
511
+ for (const index of response.indexes ?? []) {
512
+ const parts = [`入口 ${index.resourceId}`];
513
+ parts.push(index.state === 'never' ? '索引なし' : `索引 ${index.state}`);
514
+ if (index.indexedAt) parts.push(`取得 ${formatJst(index.indexedAt)}`);
515
+ // **「全部ある」と読ませない。** complete でなければ理由を必ず添える。
516
+ parts.push(index.complete ? '読み切り' : `未完(${index.reason ?? '理由不明'})`);
517
+ if (index.stale) parts.push('鮮度切れ');
518
+ lines.push(parts.join(' '));
519
+ }
520
+ if ((response.indexes ?? []).length === 0) {
521
+ lines.push('(索引が有効な Drive 入口はありません)');
522
+ }
523
+
524
+ const items = [...(response.items ?? [])];
525
+ // 候補を先に(同じ候補の中では取得順のまま)。
526
+ const ordered = [
527
+ ...items.filter((item) => item.hints?.candidate === true),
528
+ ...items.filter((item) => item.hints?.candidate !== true),
529
+ ];
530
+ for (const item of ordered) {
531
+ const mark = item.hints?.candidate ? '*' : ' ';
532
+ const reasons = (item.hints?.reasons ?? []).join(',') || '-';
533
+ const modified = item.modifiedTime ? formatJst(item.modifiedTime) : '-';
534
+ const shortcut = item.shortcut
535
+ ? item.shortcut.target
536
+ ? ` → ${item.shortcut.target.fileId}`
537
+ : ' → (参照先非公開)'
538
+ : '';
539
+ lines.push(
540
+ `${mark} ${item.fileId} ${item.name} ${item.mimeType} 更新 ${modified} ` +
541
+ `深さ${item.depth} 親=${item.parentFileId} ${reasons}${shortcut}`,
542
+ );
543
+ }
544
+ if (ordered.length === 0) lines.push('(このページで確認できた資料はありません)');
545
+
546
+ if (response.continuationQuery) {
547
+ // **slug は query に入らない**(path 側)ので、次要求の形にして出す。
548
+ const slug = response.project?.slug ?? '<slug>';
549
+ lines.push(
550
+ `次: stw-agent projects next '${response.continuationQuery}' --slug ${slug}`,
551
+ );
552
+ }
553
+ // **終端は索引世代の終端**であって、Drive 全件の証明ではない(§6)。
554
+ lines.push('本文は返りません。読むときは fileId を gog へ渡す(guide §4)。');
555
+ return lines.join('\n');
556
+ }
557
+
362
558
  export function formatChannelLines(response) {
363
559
  const lines = (response.items ?? []).map((channel) => {
364
560
  const projects = channel.projectSlugs?.length ? channel.projectSlugs.join(',') : '-';
@@ -366,7 +562,12 @@ export function formatChannelLines(response) {
366
562
  const archived = channel.isArchived ? ' アーカイブ済み' : '';
367
563
  // bot が居ないチャンネルは本文が 1 件も取れない。件数 0 の理由を先に言う。
368
564
  const member = channel.isMember === false ? ' bot未参加' : '';
369
- return `${channel.channelId} #${channel.name} 案件=${projects}${shared}${archived}${member} ${formatSyncState(channel)}`;
565
+ // 保存範囲と到達状態はサーバが返したときだけ足す(古い API でも壊れない)。
566
+ const reach = formatReachState(channel);
567
+ const extra = [formatStoredRange(channel), reach, formatLastFailure(channel)].filter(Boolean);
568
+ const tail = extra.length ? ` ${extra.join(' ')}` : '';
569
+ const sync = formatSyncState(channel, { includeBackfill: reach === '' });
570
+ return `${channel.channelId} #${channel.name} 案件=${projects}${shared}${archived}${member} ${sync}${tail}`;
370
571
  });
371
572
  if (lines.length === 0) lines.push('(該当なし)');
372
573
  if (response.nextCursor) lines.push(`次: --cursor '${response.nextCursor}'`);
@@ -470,6 +671,539 @@ export function filterProjects(items, q) {
470
671
  );
471
672
  }
472
673
 
674
+ // ---------------------------------------------------------------------------
675
+ // doctor
676
+ // ---------------------------------------------------------------------------
677
+ //
678
+ // 「取得できない」理由が**未導入なのか・未認証なのか・権限が無いのか**を、
679
+ // 個別に確かめなくても一目で分かるようにする(Hermes の指摘・看板
680
+ // 01M1V8PNV2DVZ5EC7JBWE1Z19J)。
681
+ //
682
+ // ## 秘密を出さない
683
+ //
684
+ // token は値も長さも先頭数文字も出さない(先頭 4 文字は「どの token か」の特定に足りる)。
685
+ // 出すのは**どこから読んだか**(env か設定ファイルのパス)だけ。
686
+ // `gog` のアカウントは件数とドメインだけにする —— 「どの account 群で認証しているか」は
687
+ // 診断に要るが、メールアドレスそのものを doctor の出力へ並べる必要は無い。
688
+ //
689
+ // ## 判定できないことを OK にしない
690
+ //
691
+ // token が無ければ API の検査は**実行できない**ので `unknown`(不明)にする。
692
+ // 「未実施」を OK と書くと、doctor を通したのに動かない、が起きる。
693
+
694
+ /** 表示の語彙。看板の受け入れ条件どおり 6 つに固定する。 */
695
+ export const DOCTOR_LABELS = {
696
+ ok: 'OK',
697
+ missing: '未導入',
698
+ unauthenticated: '未認証',
699
+ forbidden: '権限なし',
700
+ unreachable: '到達不可',
701
+ unknown: '不明',
702
+ };
703
+
704
+ function check(id, label, status, detail, fix = '') {
705
+ return { id, label, status, detail, fix };
706
+ }
707
+
708
+ const execFileAsync = promisify(execFile);
709
+
710
+ /**
711
+ * 外部コマンドを 1 本走らせる。
712
+ *
713
+ * 返すのは `{ outcome, code, stdout }` だけで、**stderr は返さない**
714
+ * (認証エラーの本文に token やアカウントが載る CLI があるため)。
715
+ *
716
+ * `outcome` を 3 つに分けるのが要点(R1 codex2 Medium 2)。
717
+ *
718
+ * - `missing` … `ENOENT`。コマンドが無い
719
+ * - `exit` … **起動できて、終了コードが返った**。非 0 は「そのコマンドが断った」結果
720
+ * - `error` … タイムアウトや spawn の失敗。**何も判定できていない**
721
+ *
722
+ * これを混ぜていたので、`ETIMEDOUT` に「認証をやり直せ」と案内していた。
723
+ */
724
+ export async function probeCommand(runner, file, args, timeoutMs = 5000) {
725
+ try {
726
+ const { stdout } = await runner(file, args, { timeout: timeoutMs, encoding: 'utf8' });
727
+ return { outcome: 'exit', code: 0, stdout: String(stdout ?? '') };
728
+ } catch (error) {
729
+ if (error?.code === 'ENOENT') return { outcome: 'missing', code: null, stdout: '' };
730
+ // **数値の終了コード = 起動できて、そのコマンドが断った**(認証の判定に使える)。
731
+ // 文字列(`ETIMEDOUT` など)や killed = 実行できていない(何も判定できていない)。
732
+ if (typeof error?.code === 'number' && !error?.killed) {
733
+ return { outcome: 'exit', code: error.code, stdout: String(error?.stdout ?? '') };
734
+ }
735
+ return {
736
+ outcome: 'error',
737
+ code: error?.killed ? 'ETIMEDOUT' : (error?.code ?? 'unknown'),
738
+ stdout: '',
739
+ };
740
+ }
741
+ }
742
+
743
+ /**
744
+ * 「起動できなかった」ときの共通の見せ方。
745
+ *
746
+ * **未認証とは言わない。** 判定できていないので `到達不可` にして、
747
+ * 手で叩いて確かめる道を示す(R1 codex2 Medium 2)。
748
+ */
749
+ function unverifiedByError(id, label, command, probe) {
750
+ return check(
751
+ id,
752
+ label,
753
+ 'unreachable',
754
+ `${command} を実行できなかった(${probe.code})`,
755
+ `時間をおいて再実行する。続くなら手で ${command} を叩いて確かめる`,
756
+ );
757
+ }
758
+
759
+ /** `gog auth list` の出力 → 件数とドメイン(**アドレスそのものは持たない**)。 */
760
+ export function summarizeGogAccounts(stdout) {
761
+ const emails = String(stdout)
762
+ .split('\n')
763
+ .map((line) => line.split('\t')[0]?.trim())
764
+ .filter((value) => value && value.includes('@'));
765
+ const domains = [...new Set(emails.map((email) => email.split('@')[1]))];
766
+ return { count: emails.length, domains };
767
+ }
768
+
769
+ /** Node のメジャー版。`v22.23.2` → 22。 */
770
+ export function nodeMajor(version) {
771
+ const match = /^v?(\d+)\./.exec(String(version));
772
+ return match ? Number(match[1]) : null;
773
+ }
774
+
775
+ /** 実行環境(ホスト・ユーザー・OS・node)。 */
776
+ function checkEnvironment(env, platform) {
777
+ const user = env.USER || env.LOGNAME || '不明';
778
+ return check(
779
+ 'environment',
780
+ '実行環境',
781
+ 'ok',
782
+ `${platform.hostname} / ${user} / ${platform.type} ${platform.arch}`,
783
+ );
784
+ }
785
+
786
+ function checkNode(version) {
787
+ const major = nodeMajor(version);
788
+ if (major === null) return check('node', 'Node', 'unknown', String(version));
789
+ if (major < 20) {
790
+ return check(
791
+ 'node',
792
+ 'Node',
793
+ 'missing',
794
+ `${version}(20 以上が要る)`,
795
+ 'Node 20 以上へ上げる(mise なら mise use -g node@22)',
796
+ );
797
+ }
798
+ return check('node', 'Node', 'ok', String(version));
799
+ }
800
+
801
+ /**
802
+ * token の有無と**読み込み元**。値は出さない。
803
+ *
804
+ * 「env に入っている」と「設定ファイルから読んだ」を分けるのは、
805
+ * `source` を忘れているのか配布されていないのかで直し方が違うため。
806
+ */
807
+ function checkToken(config, envFile) {
808
+ if (!config) {
809
+ return check(
810
+ 'token',
811
+ 'STW_AGENT_TOKEN',
812
+ 'missing',
813
+ `env にも ${envFile} にも無い`,
814
+ `配布は たま(1Password の stw-manage / PJ開設 service token)。受け取ったら ${envFile} に置く`,
815
+ );
816
+ }
817
+ return check('token', 'STW_AGENT_TOKEN', 'ok', `読み込み元: ${config.source}`);
818
+ }
819
+
820
+ /** HTTP の結果 → 状態の語彙。 */
821
+ export function statusFromHttp(status) {
822
+ if (status === 200) return 'ok';
823
+ if (status === 401) return 'unauthenticated';
824
+ if (status === 403) return 'forbidden';
825
+ return 'unreachable';
826
+ }
827
+
828
+ /** API を 1 本叩いて `{ status, requestId }` を返す(doctor 用。例外にしない)。 */
829
+ async function probeApi(api, requestPath, query) {
830
+ try {
831
+ const payload = await api(requestPath, query);
832
+ // 応答本体も返す(Drive の検査に案件 slug が要る)。**保持するのは呼び出し側の判断**で、
833
+ // ここでは中身を log にも表示にも出さない。
834
+ return { status: 200, requestId: payload?.requestId ?? null, payload };
835
+ } catch (error) {
836
+ if (error instanceof ApiError) {
837
+ return { status: error.status, requestId: error.payload?.requestId ?? null, payload: null };
838
+ }
839
+ return { status: null, requestId: null, payload: null, transport: error?.message ?? 'unknown' };
840
+ }
841
+ }
842
+
843
+ /**
844
+ * `drive:read` の判定(R1 codex1 #10)。
845
+ *
846
+ * 0.3.0 で `projects resources` を足したのに doctor は `projects:read` と `slack:read` しか
847
+ * 見ておらず、**`drive:read` の無い token でも「権限 OK」**と表示していた。doctor を
848
+ * 通してから新コマンドで 403 になる。
849
+ *
850
+ * Drive の GET は案件 slug を要るので、`projects` の 1 件目を借りて叩く。
851
+ * **flag OFF の 404 と scope 不足の 403 を区別する** —— 404 は「Drive の索引がこの環境で
852
+ * 有効でない(か、この案件に索引が無い)」であって、権限が無いとは言えない。
853
+ */
854
+ async function probeDriveScope(api, projectsScope, slug) {
855
+ if (projectsScope.status !== 'ok') {
856
+ return { status: 'unknown', label: '不明', why: '(projects:read が未判定)' };
857
+ }
858
+ if (!slug) {
859
+ return { status: 'unknown', label: '不明', why: '(読める案件が無いので未実施)' };
860
+ }
861
+ const drive = await probeApi(api, `/api/agent/projects/${encodeURIComponent(slug)}/resources`, {
862
+ limit: 1,
863
+ });
864
+ if (drive.status === 200) return { status: 'ok', label: 'あり', why: '' };
865
+ if (drive.status === 403) return { status: 'forbidden', label: '権限なし', why: '' };
866
+ if (drive.status === 404) {
867
+ // **権限なしに丸めない。** Drive の索引が無効な環境でも 404 になる。
868
+ return { status: 'unknown', label: '不明', why: '(Drive 索引が無効か、この案件に入口が無い)' };
869
+ }
870
+ return {
871
+ status: 'unknown',
872
+ label: '不明',
873
+ why: drive.status === null ? '(接続できず)' : `(HTTP ${drive.status})`,
874
+ };
875
+ }
876
+
877
+ /**
878
+ * 検査を全部走らせて結果の配列を返す。
879
+ *
880
+ * 依存はすべて引数で受ける(テストから execFile と fetch を差し替えるため)。
881
+ */
882
+ export async function runDoctor({
883
+ env,
884
+ readFileImpl = readFile,
885
+ fetchImpl = globalThis.fetch,
886
+ sleepImpl = defaultSleep,
887
+ execFileImpl = execFileAsync,
888
+ platform = {
889
+ hostname: os.hostname(),
890
+ type: os.type(),
891
+ arch: os.arch(),
892
+ homedir: os.homedir(),
893
+ version: process.version,
894
+ },
895
+ fileExists = (target) => {
896
+ try {
897
+ accessSync(target);
898
+ return true;
899
+ } catch {
900
+ return false;
901
+ }
902
+ },
903
+ } = {}) {
904
+ const results = [];
905
+ const envFile = env.STW_AGENT_ENV_FILE || defaultEnvFilePath(platform.homedir);
906
+
907
+ results.push(checkEnvironment(env, platform));
908
+ results.push(checkNode(platform.version));
909
+
910
+ // token は configFromEnv と**同じ経路**で解決する(doctor だけ別の読み方をしない)。
911
+ // 失敗の理由は 2 通りあり、直し方が違うので区別する(R1 codex2 High 1)。
912
+ let config = null;
913
+ let configError = null;
914
+ try {
915
+ const resolved = await configFromEnv(env, readFileImpl);
916
+ config = {
917
+ ...resolved,
918
+ source: env.STW_AGENT_TOKEN?.trim() ? '環境変数 STW_AGENT_TOKEN' : envFile,
919
+ };
920
+ } catch (error) {
921
+ configError = error;
922
+ }
923
+
924
+ // 接続先の設定が悪いだけなら、token は解決できている(`configFromEnv` は
925
+ // token を先に見る)。**「token が無い」と混同しない。**
926
+ const baseUrlBroken = configError !== null && configError.code !== 'token_missing';
927
+ results.push(baseUrlBroken ? check('token', 'STW_AGENT_TOKEN', 'ok', '読み込み済み') : checkToken(config, envFile));
928
+
929
+ if (baseUrlBroken) {
930
+ // **例外 message をそのまま出す。** これは CLI が書いた固定文で、
931
+ // 設定値そのもの(=資格情報を含みうる)は入っていない。
932
+ const skip = 'STW_API_BASE を直してから doctor をやり直す';
933
+ results.push(check('api', '読み取り API', 'unreachable', configError.message.split('\n')[0], skip));
934
+ results.push(check('scope', 'token の権限', 'unknown', '接続先が不正なので未実施', skip));
935
+ } else if (!config) {
936
+ const skip = 'token を設定してから doctor をやり直す';
937
+ results.push(check('api', '読み取り API', 'unknown', 'token が無いので未実施', skip));
938
+ results.push(check('scope', 'token の権限', 'unknown', 'token が無いので未実施', skip));
939
+ } else {
940
+ const api = createAgentApi(config, fetchImpl, sleepImpl);
941
+ const projects = await probeApi(api, '/api/agent/projects', { limit: 1 });
942
+ // **origin だけを出す。** 設定に userinfo / query が付いていても運ばない。
943
+ const where = safeBaseLabel(config.baseUrl);
944
+ if (projects.status === null) {
945
+ results.push(
946
+ check('api', '読み取り API', 'unreachable', `${where} へ接続できない`, 'ネットワークと STW_API_BASE を確認する'),
947
+ );
948
+ results.push(check('scope', 'token の権限', 'unknown', 'API へ到達できないので未判定'));
949
+ } else {
950
+ const apiStatus = statusFromHttp(projects.status);
951
+ results.push(
952
+ check(
953
+ 'api',
954
+ '読み取り API',
955
+ apiStatus,
956
+ `HTTP ${projects.status} / requestId ${projects.requestId ?? '-'} / ${where}`,
957
+ apiStatus === 'ok' ? '' : doctorApiFix(projects.status),
958
+ ),
959
+ );
960
+
961
+ // scope は**応答から分かる範囲**で見る。200 なら持っている、403 なら無い。
962
+ // それ以外(401 や到達不可)は「不明」で、持っていないとは言い切らない。
963
+ const slack = await probeApi(api, '/api/agent/slack/channels', { limit: 1 });
964
+ const projectsScope = scopeVerdict(projects.status);
965
+ const slackScope = scopeVerdict(slack.status);
966
+ // Drive は案件 slug が要るので、`projects` の 1 件目を借りる(R1 codex1 #10)。
967
+ const firstSlug = projects.payload?.items?.[0]?.slug ?? null;
968
+ const driveScope = await probeDriveScope(api, projectsScope, firstSlug);
969
+ // **「不明」を「権限なし」に丸めない**(R1 codex2 Medium 3)。403 を見たときだけ
970
+ // 権限なし、全部 200 のときだけ OK、それ以外は不明。API 障害を権限の問題として
971
+ // 報告すると、直し方の案内が嘘になる。
972
+ const verdicts = [projectsScope.status, slackScope.status, driveScope.status];
973
+ const scopeStatus = verdicts.includes('forbidden')
974
+ ? 'forbidden'
975
+ : verdicts.every((value) => value === 'ok')
976
+ ? 'ok'
977
+ : 'unknown';
978
+ // 判定できなかった側は理由まで出す(HTTP か、そもそも届かなかったのか)。
979
+ const slackWhy =
980
+ slackScope.status === 'unknown'
981
+ ? slack.status === null
982
+ ? '(接続できず)'
983
+ : `(HTTP ${slack.status})`
984
+ : '';
985
+ const detail =
986
+ `方式 Bearer service token / projects:read=${projectsScope.label}` +
987
+ ` / slack:read=${slackScope.label}${slackWhy}` +
988
+ ` / drive:read=${driveScope.label}${driveScope.why}`;
989
+ results.push(
990
+ check(
991
+ 'scope',
992
+ 'token の権限',
993
+ scopeStatus,
994
+ detail,
995
+ slackScope.status === 'forbidden'
996
+ ? 'Slack を読む必要があるなら たま に slack:read と対象チャンネルの許可を頼む'
997
+ : driveScope.status === 'forbidden'
998
+ ? 'Drive の索引を読む必要があるなら たま に drive:read と対象入口の許可を頼む'
999
+ : scopeStatus === 'unknown'
1000
+ ? '判定できなかった側は API が復旧してから確かめる'
1001
+ : '',
1002
+ ),
1003
+ );
1004
+ }
1005
+ }
1006
+
1007
+ results.push(...(await checkTools(execFileImpl, env, platform, fileExists)));
1008
+ return results;
1009
+ }
1010
+
1011
+ function doctorApiFix(status) {
1012
+ if (status === 401) return 'token が失効しているか値が違う。たま に再発行を頼む';
1013
+ if (status === 403) return 'token に必要な scope が無い。たま に権限を頼む';
1014
+ if (status === 404) return '読み取り機能が無効か、STW_API_BASE が違う';
1015
+ return 'API 側の状態を確認する(時間をおいて再試行)';
1016
+ }
1017
+
1018
+ function scopeVerdict(status) {
1019
+ if (status === 200) return { status: 'ok', label: 'あり' };
1020
+ if (status === 403) return { status: 'forbidden', label: '権限なし' };
1021
+ return { status: 'unknown', label: '不明' };
1022
+ }
1023
+
1024
+ /** 周辺ツール(ntn / gog / hub / gh / bun)。 */
1025
+ async function checkTools(execFileImpl, env, platform, fileExists) {
1026
+ const results = [];
1027
+
1028
+ const ntn = await probeCommand(execFileImpl, 'ntn', ['doctor']);
1029
+ if (ntn.outcome === 'missing') {
1030
+ results.push(
1031
+ check('ntn', 'ntn(Notion CLI)', 'missing', 'コマンドが無い', 'npm i -g @notionhq/ntn か、Notion コネクタを使う'),
1032
+ );
1033
+ } else if (ntn.outcome === 'error') {
1034
+ results.push(unverifiedByError('ntn', 'ntn(Notion CLI)', 'ntn doctor', ntn));
1035
+ } else {
1036
+ results.push(
1037
+ ntn.code === 0
1038
+ ? check('ntn', 'ntn(Notion CLI)', 'ok', 'ntn doctor 正常')
1039
+ : check('ntn', 'ntn(Notion CLI)', 'unauthenticated', `ntn doctor が exit ${ntn.code}`, 'ntn の認証をやり直す(ntn doctor の指示に従う)'),
1040
+ );
1041
+ }
1042
+
1043
+ const gog = await probeCommand(execFileImpl, 'gog', ['auth', 'list']);
1044
+ if (gog.outcome === 'missing') {
1045
+ results.push(check('gog', 'gog(Google CLI)', 'missing', 'コマンドが無い', 'gog を入れる(brew install gog)'));
1046
+ } else if (gog.outcome === 'error') {
1047
+ results.push(unverifiedByError('gog', 'gog(Google CLI)', 'gog auth list', gog));
1048
+ } else if (gog.code !== 0) {
1049
+ results.push(check('gog', 'gog(Google CLI)', 'unauthenticated', `gog auth list が exit ${gog.code}`, 'gog auth login でアカウントを足す'));
1050
+ } else {
1051
+ const { count, domains } = summarizeGogAccounts(gog.stdout);
1052
+ // **列挙できた = 資格情報が保存されている**、まで。いま使えるかは確かめていない
1053
+ // (R1 codex2 Medium 2)。断定しない言い方にする。
1054
+ results.push(
1055
+ count === 0
1056
+ ? check('gog', 'gog(Google CLI)', 'unauthenticated', 'アカウントが 1 件も無い', 'gog auth login でアカウントを足す')
1057
+ : check('gog', 'gog(Google CLI)', 'ok', `登録 ${count} 件(${domains.join(', ')})/有効性は未検証`),
1058
+ );
1059
+ }
1060
+
1061
+ // **hub は `--version` を持たない**(未対応サブコマンドでも exit 0 になる)ので、
1062
+ // 終了コードで認証を判定できない。かといって**env の存在だけで「認証済み」とも
1063
+ // 言わない**(secret 欠落・失効・API 未到達を何も見ていない。R1 codex2 Medium 2)。
1064
+ // 設定の有無だけを事実として出し、状態は「未検証」にする。
1065
+ const hub = await probeCommand(execFileImpl, 'hub', []);
1066
+ if (hub.outcome === 'missing') {
1067
+ results.push(check('hub', 'hub(看板 CLI)', 'missing', 'コマンドが無い', 'hub を入れる(tools/hub.mjs か配布物)'));
1068
+ } else if (hub.outcome === 'error') {
1069
+ results.push(unverifiedByError('hub', 'hub(看板 CLI)', 'hub', hub));
1070
+ } else {
1071
+ const hubEnv = path.join(platform.homedir, '.config', 'tamai', 'hub-tasks.env');
1072
+ const verify = 'hub task list --project stw-manage で実際に確認する';
1073
+ if (env.TASKS_CLIENT_ID) {
1074
+ results.push(check('hub', 'hub(看板 CLI)', 'unknown', '認証 env 読込済み・認証未検証', verify));
1075
+ } else if (fileExists(hubEnv)) {
1076
+ results.push(
1077
+ check('hub', 'hub(看板 CLI)', 'unknown', `認証 env あり・未読込(${hubEnv})`, `set -a && source ${hubEnv} && set +a`),
1078
+ );
1079
+ } else {
1080
+ results.push(check('hub', 'hub(看板 CLI)', 'unauthenticated', '認証設定が見当たらない', `set -a && source ${hubEnv} && set +a`));
1081
+ }
1082
+ }
1083
+
1084
+ const gh = await probeCommand(execFileImpl, 'gh', ['auth', 'status']);
1085
+ if (gh.outcome === 'missing') {
1086
+ results.push(check('gh', 'gh(GitHub CLI)', 'missing', 'コマンドが無い', 'brew install gh'));
1087
+ } else if (gh.outcome === 'error') {
1088
+ results.push(unverifiedByError('gh', 'gh(GitHub CLI)', 'gh auth status', gh));
1089
+ } else {
1090
+ results.push(
1091
+ gh.code === 0
1092
+ ? check('gh', 'gh(GitHub CLI)', 'ok', '認証済み')
1093
+ : check('gh', 'gh(GitHub CLI)', 'unauthenticated', `gh auth status が exit ${gh.code}`, 'gh auth login'),
1094
+ );
1095
+ }
1096
+
1097
+ results.push(await checkBun(execFileImpl, platform, fileExists));
1098
+ return results;
1099
+ }
1100
+
1101
+ /**
1102
+ * bun の有無と PATH。
1103
+ *
1104
+ * **「入っているのに PATH に無い」を「未導入」と混同しない**(Hermes が踏んだ事故。
1105
+ * `~/.bun/bin` が PATH に無く、bun があるのに使えなかった)。
1106
+ */
1107
+ async function checkBun(execFileImpl, platform, fileExists) {
1108
+ const bun = await probeCommand(execFileImpl, 'bun', ['--version']);
1109
+ if (bun.outcome === 'exit' && bun.code === 0) {
1110
+ return check('bun', 'bun', 'ok', `v${bun.stdout.trim()}`);
1111
+ }
1112
+ if (bun.outcome === 'error') return unverifiedByError('bun', 'bun', 'bun --version', bun);
1113
+
1114
+ const homeBun = path.join(platform.homedir, '.bun', 'bin', 'bun');
1115
+ if (fileExists(homeBun)) {
1116
+ return check(
1117
+ 'bun',
1118
+ 'bun',
1119
+ 'missing',
1120
+ `${homeBun} はあるが PATH から呼べない`,
1121
+ `PATH に ${path.dirname(homeBun)} を足す(シェルの rc に export PATH="$HOME/.bun/bin:$PATH")`,
1122
+ );
1123
+ }
1124
+ if (bun.outcome === 'missing') {
1125
+ // **入れただけでは PATH に入らない。** installer は `~/.bun/bin` へ置いて
1126
+ // シェルの rc を書き換えるが、非対話シェルや別の rc を使っていると効かない
1127
+ // ——「入れたのに使えない」がまさに Hermes の踏んだ形なので、続きまで書く。
1128
+ return check(
1129
+ 'bun',
1130
+ 'bun',
1131
+ 'missing',
1132
+ 'コマンドが無い',
1133
+ `curl -fsSL https://bun.sh/install | bash で入れ、PATH に ${path.dirname(homeBun)} を足す`,
1134
+ );
1135
+ }
1136
+ return check('bun', 'bun', 'unknown', `bun --version が exit ${bun.code}`, 'bun の導入を確認する');
1137
+ }
1138
+
1139
+ /**
1140
+ * 端末での表示幅。**全角を 2 で数える**(日本語のラベルが混ざるので、
1141
+ * `String.length` で桁を揃えると列がずれる)。
1142
+ */
1143
+ export function displayWidth(text) {
1144
+ let width = 0;
1145
+ for (const char of String(text)) {
1146
+ const code = char.codePointAt(0);
1147
+ // CJK・かな・全角記号のざっくり判定。厳密な East Asian Width 表は持ち込まない。
1148
+ const wide =
1149
+ (code >= 0x1100 && code <= 0x115f) ||
1150
+ (code >= 0x2e80 && code <= 0xa4cf) ||
1151
+ (code >= 0xac00 && code <= 0xd7a3) ||
1152
+ (code >= 0xf900 && code <= 0xfaff) ||
1153
+ (code >= 0xfe30 && code <= 0xfe6f) ||
1154
+ (code >= 0xff00 && code <= 0xff60) ||
1155
+ (code >= 0xffe0 && code <= 0xffe6);
1156
+ width += wide ? 2 : 1;
1157
+ }
1158
+ return width;
1159
+ }
1160
+
1161
+ function padDisplay(text, width) {
1162
+ return `${text}${' '.repeat(Math.max(0, width - displayWidth(text)))}`;
1163
+ }
1164
+
1165
+ /**
1166
+ * 人が読む表。**1 項目 1 行 +(要対応なら)直し方 1 行**。
1167
+ *
1168
+ * 先頭の列は状態そのもの(OK / 未導入 / 未認証 / 権限なし / 到達不可 / 不明)にして、
1169
+ * 目で追う対象を 1 つにする。
1170
+ */
1171
+ export function formatDoctorLines(results) {
1172
+ const statusWidth = Math.max(...results.map((item) => displayWidth(DOCTOR_LABELS[item.status] ?? item.status)));
1173
+ const labelWidth = Math.max(...results.map((item) => displayWidth(item.label)));
1174
+ const lines = [];
1175
+ for (const item of results) {
1176
+ const status = DOCTOR_LABELS[item.status] ?? item.status;
1177
+ lines.push(`${padDisplay(status, statusWidth)} ${padDisplay(item.label, labelWidth)} ${item.detail}`);
1178
+ if (item.fix) lines.push(`${' '.repeat(statusWidth + 2)}${padDisplay('', labelWidth)} → ${item.fix}`);
1179
+ }
1180
+ const { blocking, unverified } = splitDoctorResults(results);
1181
+ lines.push('');
1182
+ if (blocking.length > 0) {
1183
+ lines.push(`要対応 ${blocking.length} 件: ${blocking.map((item) => item.label).join(' / ')}`);
1184
+ }
1185
+ if (unverified.length > 0) {
1186
+ lines.push(`未検証 ${unverified.length} 件: ${unverified.map((item) => item.label).join(' / ')}`);
1187
+ }
1188
+ if (blocking.length === 0 && unverified.length === 0) lines.push('すべて OK。');
1189
+ return lines.join('\n');
1190
+ }
1191
+
1192
+ /**
1193
+ * 「直すべきもの」と「確かめられなかったもの」を分ける。
1194
+ *
1195
+ * **`unknown` は失敗ではない。** 判定できなかっただけなので、終了コードにも
1196
+ * 「要対応」の数にも入れない —— これを混ぜると、正常な機械でも doctor が常に
1197
+ * 非 0 になり、`stw-agent doctor && 次の作業` が使えなくなる(R1 codex2 Medium 2 で
1198
+ * hub を「未検証」にした結果、この区別が要るようになった)。
1199
+ */
1200
+ export function splitDoctorResults(results) {
1201
+ return {
1202
+ blocking: results.filter((item) => item.status !== 'ok' && item.status !== 'unknown'),
1203
+ unverified: results.filter((item) => item.status === 'unknown'),
1204
+ };
1205
+ }
1206
+
473
1207
  const COMMANDS = {
474
1208
  'projects list': {
475
1209
  options: ['q', 'limit', 'cursor'],
@@ -506,6 +1240,31 @@ const COMMANDS = {
506
1240
  },
507
1241
  },
508
1242
 
1243
+ /**
1244
+ * Drive 入口配下の索引(2026-09-09 の設計 §6)。
1245
+ *
1246
+ * **`--resource-id` は登録済み入口の UUID** で、Drive の fileId や URL ではない。
1247
+ * 全件取得は `continuationQuery` を `projects next` へ渡して利用者が続ける。
1248
+ */
1249
+ 'projects resources': {
1250
+ options: ['system', 'resource-id', 'limit', 'cursor'],
1251
+ async run({ api, options, positional, json, stdout }) {
1252
+ const slug = positional[0];
1253
+ if (!slug) throw usageError('案件 slug を指定してください');
1254
+ const response = await api(`/api/agent/projects/${encodeURIComponent(slug)}/resources`, {
1255
+ system: options.system,
1256
+ resourceId: options['resource-id'],
1257
+ limit: options.limit,
1258
+ cursor: options.cursor,
1259
+ });
1260
+ stdout.write(
1261
+ json
1262
+ ? `${JSON.stringify(response, null, 2)}\n`
1263
+ : `${formatDriveResourceLines(response)}\n`,
1264
+ );
1265
+ },
1266
+ },
1267
+
509
1268
  'slack channels': {
510
1269
  options: ['project', 'limit', 'cursor'],
511
1270
  async run({ api, options, json, stdout }) {
@@ -550,6 +1309,35 @@ const COMMANDS = {
550
1309
  * **cursor を組み立て直さない。** cursor は版・route・並び順・query hash を含む
551
1310
  * 不透明な値で、手で作ると `400 invalid_cursor` になる(guide §3)。
552
1311
  */
1312
+ /**
1313
+ * 周辺ツールと認証・閲覧可否の一括確認。
1314
+ *
1315
+ * **token が無くても動く唯一のコマンド。** 「token が無い」こと自体を
1316
+ * 報告するのが仕事なので、`configFromEnv` の例外で止めない(`needsToken: false`)。
1317
+ */
1318
+ doctor: {
1319
+ options: [],
1320
+ needsToken: false,
1321
+ async run({ json, stdout, dependencies }) {
1322
+ const results = await runDoctor({
1323
+ env: dependencies.env,
1324
+ readFileImpl: dependencies.readFileImpl,
1325
+ fetchImpl: dependencies.fetchImpl,
1326
+ sleepImpl: dependencies.sleepImpl,
1327
+ execFileImpl: dependencies.execFileImpl,
1328
+ });
1329
+ stdout.write(
1330
+ json
1331
+ ? `${JSON.stringify({ checks: results }, null, 2)}\n`
1332
+ : `${formatDoctorLines(results)}\n`,
1333
+ );
1334
+ // **要対応があれば非 0 で終わる**(`brew doctor` / `npm doctor` と同じ流儀)。
1335
+ // `stw-agent doctor && 次の作業` と繋げられるようにするため。
1336
+ // **「不明」は数えない**(確かめられなかっただけで、壊れてはいない)。
1337
+ return splitDoctorResults(results).blocking.length > 0 ? 1 : 0;
1338
+ },
1339
+ },
1340
+
553
1341
  'slack next': {
554
1342
  options: [],
555
1343
  async run({ api, positional, json, stdout }) {
@@ -563,6 +1351,35 @@ const COMMANDS = {
563
1351
  );
564
1352
  },
565
1353
  },
1354
+
1355
+ /**
1356
+ * Drive 索引の続き。**cursor を組み立て直さない**(opaque な暗号文なので手で作れない)。
1357
+ *
1358
+ * `continuationQuery` は query だけで**案件 slug を含まない**(slug は path 側にある)。
1359
+ * cursor から復元することもしない —— cursor は認可の証明ではないので、
1360
+ * 「どの案件を読んでいたか」を cursor に語らせない。だから `--slug` を必須にする。
1361
+ */
1362
+ 'projects next': {
1363
+ options: ['slug'],
1364
+ async run({ api, options, positional, json, stdout }) {
1365
+ const query = positional[0];
1366
+ if (!query) {
1367
+ throw usageError('continuationQuery をそのまま渡してください(前回の出力の「次:」行)');
1368
+ }
1369
+ const slug = options.slug;
1370
+ if (!slug) throw usageError('--slug に前回と同じ案件 slug を指定してください');
1371
+ const response = await requestRawQuery(
1372
+ api,
1373
+ `/api/agent/projects/${encodeURIComponent(slug)}/resources`,
1374
+ query,
1375
+ );
1376
+ stdout.write(
1377
+ json
1378
+ ? `${JSON.stringify(response, null, 2)}\n`
1379
+ : `${formatDriveResourceLines(response)}\n`,
1380
+ );
1381
+ },
1382
+ },
566
1383
  };
567
1384
 
568
1385
  /**
@@ -593,25 +1410,40 @@ async function execute(argv, dependencies) {
593
1410
  group === '-h'
594
1411
  ) {
595
1412
  dependencies.stdout.write(USAGE);
596
- return;
1413
+ return 0;
597
1414
  }
598
1415
 
599
- const name = `${group} ${verb ?? ''}`.trim();
600
- const command = COMMANDS[name];
1416
+ // **1 語のコマンド(`doctor`)を先に見る。** `${group} ${verb}` だけで引くと、
1417
+ // `doctor --json` の `--json` が動詞として繋がって「未対応」になる。
1418
+ const single = COMMANDS[group];
1419
+ const name = single ? group : `${group} ${verb ?? ''}`.trim();
1420
+ const command = single ?? COMMANDS[name];
601
1421
  if (!command) throw usageError(`未対応のサブコマンドです: ${name}`);
602
1422
 
603
- const { options, positional, json } = parseArgs(rest, command.options);
604
- const config = await configFromEnv(dependencies.env, dependencies.readFileImpl);
605
- const api = createAgentApi(config, dependencies.fetchImpl, dependencies.sleepImpl);
606
-
607
- await command.run({
608
- api,
609
- options,
610
- positional,
611
- json,
612
- stdout: dependencies.stdout,
613
- stderr: dependencies.stderr,
614
- });
1423
+ const args = single ? [verb, ...rest].filter((value) => value !== undefined) : rest;
1424
+ const { options, positional, json } = parseArgs(args, command.options);
1425
+ // **doctor だけは token を要らない**(無いことを報告するのが仕事)。
1426
+ const api =
1427
+ command.needsToken === false
1428
+ ? null
1429
+ : createAgentApi(
1430
+ await configFromEnv(dependencies.env, dependencies.readFileImpl),
1431
+ dependencies.fetchImpl,
1432
+ dependencies.sleepImpl,
1433
+ );
1434
+
1435
+ // コマンドが終了コードを返したらそれを使う(既定は 0)。
1436
+ return (
1437
+ (await command.run({
1438
+ api,
1439
+ options,
1440
+ positional,
1441
+ json,
1442
+ stdout: dependencies.stdout,
1443
+ stderr: dependencies.stderr,
1444
+ dependencies,
1445
+ })) ?? 0
1446
+ );
615
1447
  }
616
1448
 
617
1449
  export async function runCli(argv, overrides = {}) {
@@ -620,13 +1452,13 @@ export async function runCli(argv, overrides = {}) {
620
1452
  fetchImpl: overrides.fetchImpl ?? globalThis.fetch,
621
1453
  readFileImpl: overrides.readFileImpl ?? readFile,
622
1454
  sleepImpl: overrides.sleepImpl ?? defaultSleep,
1455
+ execFileImpl: overrides.execFileImpl ?? execFileAsync,
623
1456
  stdout: overrides.stdout ?? process.stdout,
624
1457
  stderr: overrides.stderr ?? process.stderr,
625
1458
  };
626
1459
 
627
1460
  try {
628
- await execute(argv, dependencies);
629
- return 0;
1461
+ return await execute(argv, dependencies);
630
1462
  } catch (error) {
631
1463
  dependencies.stderr.write(`${formatFailure(error)}\n`);
632
1464
  return 1;
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@shimatoworks/stw-agent",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for the Shimatoworks project-context read API (project registry entries and Slack discussion).",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "bin": {
8
- "stw-agent": "./bin/stw-agent.mjs"
8
+ "stw-agent": "bin/stw-agent.mjs"
9
9
  },
10
10
  "files": [
11
11
  "bin/stw-agent.mjs",