@takagaki/cortex-decisions-viewer 0.5.0 → 0.6.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/dist/render.js CHANGED
@@ -1107,6 +1107,18 @@ a:hover { text-decoration: underline; }
1107
1107
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px;
1108
1108
  }
1109
1109
  .ds-card-main { display: flex; align-items: center; gap: 10px; flex: 1 1 auto; min-width: 0; }
1110
+ /* 能力モーダルの中の「内部ソースのカード」だけ縦組みにする(2026-08-21)。
1111
+ 横並びのままだと、名前ブロックが左の細い列になり、その右に設定一式が積まれる
1112
+ (オーナー指摘「なんで左側に共有資料、資料というふうに出てるんだ?」)。
1113
+ **flex-wrap: nowrap を必ず含める。** .ds-card は flex-wrap: wrap で、
1114
+ direction を column にしても wrap は残る。このカードは max-height: 90vh の
1115
+ column flex(.modal)の縮みうる子なので、wrap したままだと自動最小サイズが
1116
+ 「最も高い子1つ分」まで落ち、縦に潰れて中身が複数列に折り返す=指摘された
1117
+ 「左に列がある」状態を、もっと壊れた形で作り直すことになる。
1118
+ **子結合子(>)で書く。** 子孫セレクタだと、中に入れ子にしたレコードの行の
1119
+ .ds-card-main にも当たり(特異度で勝つ)、行の名前ブロックが伸びなくなる。 */
1120
+ .ds-card.ds-capbody { flex-direction: column; align-items: stretch; flex-wrap: nowrap; gap: 10px; }
1121
+ .ds-card.ds-capbody > .ds-card-main { flex: 0 0 auto; }
1110
1122
  .ds-icon { font-size: 18px; line-height: 1; flex: 0 0 auto; }
1111
1123
  .ds-type { font-size: 12px; font-weight: 600; color: var(--muted); flex: 0 0 auto; }
1112
1124
  .ds-name { font-size: 14.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1271,7 +1283,7 @@ a.ds-name:hover { text-decoration: underline; }
1271
1283
  min-width: 0 … flex の子が min-content より縮めるようにする(無いと折り返しても溢れる) */
1272
1284
  .ds-tile-name, .ds-tile-desc, .ds-tile-sub, .ds-tile-note,
1273
1285
  .ds-check-label, .ds-check-action, .ds-check-detail,
1274
- .ds-pipe-label, .ds-pipe-todo, .ds-owner-state,
1286
+ .ds-pipe-label, .ds-pipe-todo,
1275
1287
  .ds-empty, .sec-how, .modal-desc, .ds-toggle-msg,
1276
1288
  .ds-drive-note, .ds-inactive-note, .ds-legacy-chip, .ds-keys-code, .ds-gate-help {
1277
1289
  min-width: 0; white-space: normal; overflow-wrap: anywhere;
@@ -1282,8 +1294,10 @@ a.ds-name:hover { text-decoration: underline; }
1282
1294
  .ds-toggle-btn:disabled { opacity: .6; cursor: default; }
1283
1295
  .ds-toggle-btn.ds-danger { color: #b42318; border-color: #f3c6c1; }
1284
1296
  .ds-toggle-btn.ds-danger:hover:not(:disabled) { border-color: #b42318; color: #b42318; background: #fff5f4; }
1285
- .ds-subitems { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
1286
- .ds-subitem { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: #fff; }
1297
+ /* .ds-subitems / .ds-subitem は撤去した(2026-08-21)。共有資料のフォルダと Figma
1298
+ ファイルの行は、Slack / GitHub と同じ .ds-list > .ds-card になったので、
1299
+ 行間(gap)も .ds-list に揃う。**.ds-removed の規則は .ds-subitem に依存していない**
1300
+ ので、そのまま両方の行に効く。 */
1287
1301
  /* 削除できたことを行に残す。一覧そのものは翌朝まで更新されないので、消えたように見せない */
1288
1302
  .ds-removed .ds-name { text-decoration: line-through; color: var(--muted); }
1289
1303
  .ds-toggle-msg { font-size: 12px; color: var(--muted); }
@@ -1337,13 +1351,10 @@ a.ds-name:hover { text-decoration: underline; }
1337
1351
  とは別物で、同じ色にすると「押して繋ぎましょう」と読まれる */
1338
1352
  .ds-gate-off { color: #9ca3af; }
1339
1353
  .ds-pipes { display: flex; flex-direction: column; gap: 8px; }
1340
- /* 組織ごとの接続状況。トークンは組織ごとにしか効かないので、ここが判断の単位になる */
1341
- .ds-owners { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
1342
- .ds-owner { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px;
1343
- border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
1344
- .ds-owner-name { font-weight: 700; font-size: 13px; }
1345
- .ds-owner-state { font-size: 12.5px; }
1346
- .ds-owner-install { font-size: 12.5px; margin-left: auto; white-space: nowrap; }
1354
+ /* GitHub App のインストールへのリンク。**独立した「組織ごとの接続状況」の節は
1355
+ 2026-08-20 に廃止した**(行のラベル+必要な行だけ囲う形へ)が、
1356
+ このクラスだけは案内と手順の両方で使い続けている */
1357
+ .ds-owner-install { font-size: 12.5px; white-space: nowrap; }
1347
1358
  /* 実行状況の行に直し方を添える。同じ run の未達を別の節にも出すと画面に2回並ぶ */
1348
1359
  .ds-pipe-wrap { display: flex; flex-direction: column; gap: 3px; }
1349
1360
  .ds-pipe-todo { font-size: 12.5px; color: var(--muted); line-height: 1.7; padding-left: 4px; }
@@ -1368,17 +1379,10 @@ a.ds-name:hover { text-decoration: underline; }
1368
1379
  /* 「この接続は何のためのものでないか」(2026-08-18)。**説明文より強く出す**——
1369
1380
  ここを読み飛ばした人が、社内チャンネルのためにボタンを押し、必ず失敗する */
1370
1381
  .ds-scope-note { color: var(--text); }
1371
- /* いまの接続先(設定を変える口の直前に置く。値は本文色で、ラベルだけ弱める) */
1372
- .ds-current { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin: 0 0 8px; font-size: 12.5px; }
1373
- .ds-current-label { color: var(--muted); flex: 0 0 auto; }
1374
- .ds-current-value { color: var(--text); font-weight: 600; word-break: break-all; }
1375
- .ds-current-value.is-unset { color: var(--muted); font-weight: 400; }
1376
- /* 接続先の一覧(#127)。**1件のときは従来どおりラベルの右に1行**——単数の案件で
1377
- 見た目が変わると、複数対応と関係のない全案件に「何か変わった」と読ませる */
1378
- .ds-target-list { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
1379
- .ds-target-row { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
1380
- /* 行の中のボタンは行に揃える(.ds-toggle の縦マージンは節と節の間隔なので効かせない) */
1381
- .ds-target-row .ds-toggle-btn { flex: 0 0 auto; }
1382
+ /* .ds-current / .ds-current-label / .ds-current-value / .ds-target-list /
1383
+ .ds-target-row は撤去した(2026-08-21)。Backlog の接続先の行は、Slack / GitHub /
1384
+ Figma / Drive と同じ .ds-list > .ds-card になったので、行間も見た目も .ds-list
1385
+ .ds-card に揃う(それまでは同じモーダルの中でここだけ独自レイアウトだった)。 */
1382
1386
  /* 入力欄のすぐ下。貼る直前に読める位置に置く */
1383
1387
  .sec-note { color: var(--muted); font-size: 12px; line-height: 1.7; margin: 6px 0 0; }
1384
1388
  .sec-how p { margin: 0; }
@@ -1412,6 +1416,17 @@ a.ds-name:hover { text-decoration: underline; }
1412
1416
  /* まだ受信していない接続先(複数接続のとき混在する)。稼働中の行と見分けが付く濃さに落とす */
1413
1417
  .rt-live-line.is-waiting { color: var(--muted); font-weight: 500; }
1414
1418
  /* 手順は畳んで残す(張り替え・再確認用)。開いたときだけ間隔を空ける */
1419
+ /* 鍵カードの発行手順。**畳んであるだけで、中身は消していない**(2026-08-21)。
1420
+ **マージンは持たせない**——.ops-card は column flex の gap: 10px なので、
1421
+ 自前のマージンを足すとここだけ間隔が広くなる(短く見せたいカードで逆効果)。
1422
+ 閉じているときに最後の段落の下マージンが残らないよう、中の余白も畳む */
1423
+ .sec-fold { border: none; margin: 0; }
1424
+ .sec-fold > summary { cursor: pointer; color: var(--muted); font-size: 12px; }
1425
+ .sec-fold[open] > summary { margin-bottom: 8px; }
1426
+ .sec-fold > .sec-how:last-child { margin-bottom: 0; }
1427
+ /* 鍵の入力欄も同じ理由でマージンを持たせない(.ds-addform の中は従来どおり6px)。
1428
+ 子結合子で書くのは、リアルタイム同期カードの .rt-url(.rt-url-row の中)に当てないため */
1429
+ .ops-card > .sec-input { margin-bottom: 0; }
1415
1430
  .rt-steps-fold > summary { cursor: pointer; color: var(--muted); font-size: 12px; }
1416
1431
  .rt-steps-fold[open] > summary { margin-bottom: 10px; }
1417
1432
  .rt-steps-fold[open] > *:not(summary) { margin-top: 10px; }
@@ -1617,8 +1632,12 @@ ${capabilityState.toString()}
1617
1632
  var countEl = { textContent: "" }; // 件数表示は廃止(ヘッダーから削除済み)
1618
1633
  var footerEl = document.getElementById("footer-meta");
1619
1634
 
1635
+ // **画面の版も出す(2026-08-20)。** 新しい画面は案件ごとに順次切り替わるので、
1636
+ // **案件によって版が違う**。出していないと「直したはずなのに直っていない」の正体が
1637
+ // 「その案件だけ古い版」であることを切り分けられない
1620
1638
  footerEl.textContent = "生成: " + SITE.generatedAt +
1621
- (SITE.repoBaseUrl ? " / " + SITE.repoBaseUrl.split("://").pop() + " (" + SITE.branch + ")" : "");
1639
+ (SITE.repoBaseUrl ? " / " + SITE.repoBaseUrl.split("://").pop() + " (" + SITE.branch + ")" : "") +
1640
+ (SITE.viewerVersion ? " / 画面 v" + SITE.viewerVersion : "");
1622
1641
 
1623
1642
  /**
1624
1643
  * 「貼り付けて使う文字列」を、選べる形+コピーボタンで出す。
@@ -1628,6 +1647,19 @@ ${capabilityState.toString()}
1628
1647
  * clipboard API が使えない環境(http・古いブラウザ・権限拒否)では、**選択までしてやって**
1629
1648
  * 次の一手を伝える(何も起きないのが一番悪い)。
1630
1649
  */
1650
+ /**
1651
+ * 共有資料の取り込みに使う Google アカウント(2026-08-20)。
1652
+ *
1653
+ * **画面に出す。** それまでは「取り込み用アカウント」としか書いておらず、
1654
+ * **誰を招待すればいいのかが画面から分からなかった**——担当者に聞かないと
1655
+ * 手順が完了できなかった。
1656
+ *
1657
+ * **艦隊で1つ。** 案件ごとに違うものではないので定数で足りる
1658
+ * (案件ごとになったら fleet-status から運ぶ器に変える)。
1659
+ * 議事録の取り込みでも同じアカウントを使っている。
1660
+ */
1661
+ var DRIVE_INGEST_ACCOUNT = "cortex-notetaker@classmethod.jp";
1662
+
1631
1663
  function copyRow(text) {
1632
1664
  // **input と button を使わない。** 追加フォームの中に置くので、input を足すと
1633
1665
  // 「先頭がURL欄・末尾が名前欄」、button を足すと「最初のボタンが送信」という
@@ -2073,6 +2105,10 @@ ${capabilityState.toString()}
2073
2105
  // **状態の語で「招待」と言わない**——言うと、何度招待しても直らないものを繰り返させる
2074
2106
  "missing_scope": { text: "権限が足りません", cls: "ds-gate-warn" },
2075
2107
  "unreachable": { text: "アクセス不可", cls: "ds-gate-warn" },
2108
+ // **この素の語が出るのは、まだ対応していない種別のときだけ。**
2109
+ // slack / github は下の DS_GATE_NO_TOKEN で経路ごとに言い換える(dsGateOf を通す)。
2110
+ // 残してあるのは「知らない種別のソースを黙って消さない」ため——新しいアダプタが
2111
+ // 増えた日に、行が状態を持たずに並ぶより、素の語でも出ているほうがよい
2076
2112
  "no_token": { text: "トークン未設定", cls: "ds-gate-muted" },
2077
2113
  "unknown": { text: "不明", cls: "ds-gate-muted" },
2078
2114
  };
@@ -2111,6 +2147,27 @@ ${capabilityState.toString()}
2111
2147
  cls: "ds-gate-muted",
2112
2148
  help: "このチャンネルがあるお客様の Slack ワークスペースが、まだ接続されていない(または接続が切れている)状態です。「Slack ワークスペースに接続する」から接続してください。",
2113
2149
  },
2150
+ /* -------------------------------------------------------------------------
2151
+ * GitHub。**「トークン未設定」と言ってはいけない**(2026-08-21)
2152
+ * -------------------------------------------------------------------------
2153
+ * 読み取り用の個人アクセストークンは廃止し、GitHub App のインストールトークンに
2154
+ * 一本化した。**画面のどこにも、そのトークンを入れる欄はもう無い。**
2155
+ * それでも「トークン未設定」と出すと、上の DS_GATE_NO_TOKEN の項が書いている
2156
+ * 2026-08-18 の不具合と同じ形になる——しかも**探した先が存在しない**ぶん悪い。
2157
+ *
2158
+ * engine 側は、画面がこう言う前提で値を返している(scripts/fleet-status.mjs の
2159
+ * probeGithubGate): App のトークンを持たずに読もうとして 404/403 で落ちたなら、
2160
+ * 原因はスコープではなく**この組織に App が入っていない**。
2161
+ * **同じモーダルの中で、組織の案内が「App を入れてください」と言っているのに
2162
+ * 行と能力タイルが「トークン未設定」と言う**状態を作らないこと。
2163
+ *
2164
+ * **help は付けない。** 次の一手(インストール導線)は組織ごとの案内
2165
+ * (githubNotConnectedNodes)が1つだけ持つ。行ごとに繰り返すと一覧が案内で埋まる。
2166
+ * ----------------------------------------------------------------------- */
2167
+ github: {
2168
+ text: "App が未インストール",
2169
+ cls: "ds-gate-muted",
2170
+ },
2114
2171
  };
2115
2172
  /**
2116
2173
  * Slack のチャンネルが社内ワークスペースのものか。**workspace の有無だけで決まる**
@@ -2138,10 +2195,26 @@ ${capabilityState.toString()}
2138
2195
  if (!s || s.indexOf("(名前未取得") === 0) return s;
2139
2196
  return "#" + s.replace(/^#+/, "");
2140
2197
  }
2141
- /** 行に出す接続状態。no_token だけは経路(社内/顧客)で文面が分かれる */
2198
+ /**
2199
+ * 行に出す接続状態。**no_token だけは経路で文面が分かれる。**
2200
+ *
2201
+ * | 種別 | no_token の意味 | 語 |
2202
+ * |---|---|---|
2203
+ * | slack(社内) | 中央 Bot の鍵が無い。**画面から設定できない** | 管理者の設定が必要です |
2204
+ * | slack(顧客) | お客様のワークスペースが未接続。接続で直せる | トークン未設定 |
2205
+ * | github | 読む鍵が1本も無かった=**この組織に App が入っていない** | App が未インストール |
2206
+ *
2207
+ * **ここを通さずに語を書き写さないこと。** 行バッジ・能力タイルの要約・
2208
+ * 「接続を確認」の結果が同じ関数を通るので、片方だけ古くなることが構造的に起きない
2209
+ * (attentionSummary / probeVerdict の項に、実際に割れた経緯がある)。
2210
+ */
2142
2211
  function dsGateOf(s) {
2143
- if (s.gate === "no_token" && s.type === "slack") {
2144
- return isInternalSlack(s) ? DS_GATE_NO_TOKEN.internal : DS_GATE_NO_TOKEN.customer;
2212
+ if (s.gate === "no_token") {
2213
+ if (s.type === "slack") {
2214
+ return isInternalSlack(s) ? DS_GATE_NO_TOKEN.internal : DS_GATE_NO_TOKEN.customer;
2215
+ }
2216
+ // github-issues / github-discussions のどちらも同じ原因(組織単位の App)
2217
+ if (String(s.type || "").indexOf("github") === 0) return DS_GATE_NO_TOKEN.github;
2145
2218
  }
2146
2219
  return DS_GATE[s.gate] || DS_GATE["unknown"];
2147
2220
  }
@@ -2153,6 +2226,14 @@ ${capabilityState.toString()}
2153
2226
  * 場所が画面に無いので、探した人が顧客ワークスペースの接続へ吸い込まれる(DS_GATE_NO_TOKEN
2154
2227
  * の項の事故がここから始まっている)。1件でも顧客側が混ざるときは従来の文面のまま——
2155
2228
  * そちらは接続で直せるので、経路を名指しできない要約に倒す方が正しい。
2229
+ *
2230
+ * **GitHub も同じ理由で「トークン」と言わない(2026-08-21)。** 読み取り用の個人
2231
+ * アクセストークンは廃止済みで、**入力欄が画面のどこにも無い**。ここが古い語のまま
2232
+ * 残っていたので、同じモーダルの中で組織の案内は「App を入れてください」・
2233
+ * その直下の行と能力タイルは「トークン未設定」という食い違いが出ていた。
2234
+ *
2235
+ * **語は dsGateOf から引く(写経しない)。** 行バッジと同じ関数を通すので、
2236
+ * 片方だけ古くなることが構造的に起きない。
2156
2237
  */
2157
2238
  function attentionSummary(items) {
2158
2239
  var bad = items.filter(function (s) { return sourceNeedsAttention(s); });
@@ -2160,8 +2241,19 @@ ${capabilityState.toString()}
2160
2241
  var head = "⚠️ " + bad.length + "件";
2161
2242
  var noToken = bad.filter(function (s) { return s.gate === "no_token"; });
2162
2243
  if (noToken.length !== bad.length) return head + "に問題";
2163
- var allInternal = noToken.every(function (s) { return isInternalSlack(s); });
2164
- return head + "が読めていません(" + (allInternal ? "管理者の設定が必要です" : "トークン未設定") + ")";
2244
+ // 原因を名指しできるのは、行バッジの語が**1つに揃っている**ときだけ
2245
+ var texts = {};
2246
+ noToken.forEach(function (s) { texts[dsGateOf(s).text] = true; });
2247
+ var names = [];
2248
+ for (var t in texts) if (Object.prototype.hasOwnProperty.call(texts, t)) names.push(t);
2249
+ if (names.length === 1) return head + "が読めていません(" + names[0] + ")";
2250
+ // 語が割れた。**Slack の中だけで割れているなら従来どおり顧客側の語に倒す**——
2251
+ // そちらは接続で直せるので、「管理者の設定が必要です」と言い切ると直せる人を止める。
2252
+ // 種別をまたいで割れているとき(Slack と GitHub が同時に読めていない等)は、
2253
+ // 次にする人も操作も違うので1つの原因として書けない。件数だけにする
2254
+ var allSlack = noToken.every(function (s) { return s.type === "slack"; });
2255
+ if (allSlack) return head + "が読めていません(" + DS_GATE_NO_TOKEN.customer.text + ")";
2256
+ return head + "に問題";
2165
2257
  }
2166
2258
  /**
2167
2259
  * Slack のチャンネルを、所属する組織ごとに分ける(2026-08-20)。
@@ -2169,7 +2261,7 @@ ${capabilityState.toString()}
2169
2261
  * ## なぜ純関数にするか
2170
2262
  *
2171
2263
  * ここが**判断の全部**。描画に混ぜると「どう分けたか」を実測できなくなる。
2172
- * sourceGroup は受け取ったものを並べるだけで、意味を持たない
2264
+ * 並べる側は受け取ったものを出すだけで、意味を持たない
2173
2265
  * (GitHub は組織・Backlog はスペースで同じ形が要るため)。
2174
2266
  *
2175
2267
  * ## 分け方
@@ -2371,6 +2463,193 @@ ${capabilityState.toString()}
2371
2463
  }
2372
2464
  }
2373
2465
 
2466
+ /**
2467
+ * GitHub のリポジトリを、組織ごとに分ける(2026-08-20・Slack からの横展開)。
2468
+ *
2469
+ * ## Slack との違い
2470
+ *
2471
+ * **帰属を API に聞く必要が無い。** 組織は ref(owner/repo)に入っている。
2472
+ * だから判定は文字列を切るだけで、実測も印も要らない。
2473
+ *
2474
+ * ## 接続状態は台帳(githubOwners)から引く
2475
+ *
2476
+ * トークンは**組織ごとにしか効かない**(Fine-grained PAT も App の
2477
+ * インストールトークンも1組織分)ので、状態の単位も組織。
2478
+ *
2479
+ * @returns [{ key, title, items, state }] state は "ok" | "warn" | "unsure" | null
2480
+ */
2481
+ /** 台帳から、その組織の情報を引く(無ければ null=断定しない) */
2482
+ function githubOwnerMeta(owner) {
2483
+ var list = fleetVal("githubOwners") || [];
2484
+ for (var i = 0; i < list.length; i++) {
2485
+ if (list[i] && list[i].owner === owner) return list[i];
2486
+ }
2487
+ return null;
2488
+ }
2489
+
2490
+ function groupGithubByOwner(items, owners) {
2491
+ var known = {};
2492
+ (owners || []).forEach(function (o) { if (o && o.owner) known[o.owner] = o; });
2493
+ var order = [];
2494
+ var byKey = {};
2495
+ (items || []).forEach(function (s) {
2496
+ var owner = String(s.ref || "").split("/")[0] || "";
2497
+ var g = byKey[owner];
2498
+ if (!g) {
2499
+ var o = known[owner];
2500
+ // **台帳に無ければ断定しない。** 夜間バッチの成果物なので、
2501
+ // 立ち上げ直後の案件には無い(「0件」と「知らない」を同じ扱いにすると嘘になる)
2502
+ var state = null;
2503
+ // **「warn」 は else 側の総取り。** ここに載っていない値は全部「未接続」に落ちるので、
2504
+ // engine が 「source」 に新しい値を足したら**必ずここも足す**——載せ忘れると、
2505
+ // 読めている組織が顧客の画面で一斉に「まだ接続されていません」になる。
2506
+ //
2507
+ // 「workflow」 は「App は無いが、ワークフロー自身のトークンで全部読めた」=**接続OK**。
2508
+ // 「token」(廃止した個人アクセストークンで読めていた)も**当面 OK のまま受ける**——
2509
+ // 画面(Amplify で即配布)と engine(案件スタブが 「@v1」 を指しており、タグを
2510
+ // 進めるまで古い 「fleet-status.json」 のまま)は**別々に配られる**ので、
2511
+ // どちらを先に出しても片側だけ新しい期間ができる。その間に 「token」 を落とすと、
2512
+ // コミット済みの値を読んだ画面が偽の警告を出す。**外すのはフェーズ2**(engine の
2513
+ // v1 が全案件へ行き渡ったことを確かめてから)。
2514
+ if (o) {
2515
+ state = (o.source === "app" || o.source === "workflow" || o.source === "token") ? "ok"
2516
+ : o.source === "unknown" ? "unsure"
2517
+ : "warn";
2518
+ }
2519
+ g = { key: owner, title: owner || "(組織不明)", items: [], state: state, meta: o || null };
2520
+ byKey[owner] = g;
2521
+ order.push(g);
2522
+ }
2523
+ g.items.push(s);
2524
+ });
2525
+ // **繋がっている組織を先に、繋がっていない組織を後に。**
2526
+ // 直すものが無い人にも「未接続」が最初に見えるのは、優先順位が逆
2527
+ order.sort(function (a, b) {
2528
+ var rank = function (g) { return g.state === "warn" ? 2 : g.state === "unsure" ? 1 : 0; };
2529
+ return rank(a) - rank(b);
2530
+ });
2531
+ return order;
2532
+ }
2533
+
2534
+ /**
2535
+ * GitHub のリポジトリを**平らに**並べる(2026-08-20)。
2536
+ *
2537
+ * Slack と同じ形。並び順は組織ごとだが**箱で囲まない**。
2538
+ * どの組織かは**行のラベル**が示し、**接続が要る組だけ**案内が行を抱える。
2539
+ */
2540
+ function githubRows(items) {
2541
+ var owners = fleetVal("githubOwners");
2542
+ var out = [];
2543
+ groupGithubByOwner(items, owners).forEach(function (g) {
2544
+ // **行ごとに meta を引く。** issues と discussions でアイコンとラベルが違う
2545
+ var cards = g.items.map(function (x) {
2546
+ return extSourceCard(x, DS_TYPE[x.type] || { icon: "🔗", label: x.type || "その他" });
2547
+ });
2548
+ if (g.state === "warn") {
2549
+ out.push(orgNoteBox(githubNotConnectedNodes(g), cards));
2550
+ return;
2551
+ }
2552
+ if (g.state === "unsure") {
2553
+ // **確かめられなかった夜は、そう言う。** gh の不在・タイムアウト・
2554
+ // ネットワーク断で起きる。「接続OK」と出すと同じ画面の集計と食い違い、
2555
+ // 「未接続」と出すと繋がっている組織に偽の警告を出す——どちらも嘘。
2556
+ // **導線は出さない**(すでに入っているかもしれないので、押させる理由が無い)
2557
+ out.push(orgNoteBox(githubUnsureNodes(g), cards));
2558
+ return;
2559
+ }
2560
+ cards.forEach(function (n) { out.push(n); });
2561
+ });
2562
+ return out;
2563
+ }
2564
+
2565
+ /** 確かめられなかった組織の案内。**導線は出さない** */
2566
+ function githubUnsureNodes(g) {
2567
+ var o = g.meta || {};
2568
+ return [
2569
+ el("div", { class: "ds-orgnote-title", text: "⚠️ " + g.title + " の接続を確認できませんでした" }),
2570
+ el("p", {
2571
+ class: "ds-group-note",
2572
+ // **件数を出す。** 4リポのうち3つは確かめられている夜に
2573
+ // 「4リポジトリを確かめられていません」と読めるのは実態と違う
2574
+ text: (o.repos || g.items.length) + "リポジトリ中 "
2575
+ + (o.unchecked != null ? o.unchecked : (o.repos || g.items.length))
2576
+ + "件の接続を確かめられていません。翌朝の表示をご確認ください。",
2577
+ }),
2578
+ ];
2579
+ }
2580
+
2581
+ /**
2582
+ * 繋がっていない組織の案内。
2583
+ *
2584
+ * **「読めない」の原因は1つではない。** 以前はどの組織にも
2585
+ * 「この組織に Cortex の GitHub App を入れると読めるようになります」と出していたが、
2586
+ * **App は入っているのにそのリポジトリが選ばれていない**組織にはこれが効かない。
2587
+ * インストールは済んでいるので、押しても画面は変わらず、何度押しても直らない。
2588
+ *
2589
+ * 「appInstalled」 が原因を分ける(engine がインストールトークンを取れたかどうか):
2590
+ *
2591
+ * | 値 | 意味 | 案内 |
2592
+ * |---|---|---|
2593
+ * | 「false」 | App のトークンが取れなかった=未インストール | インストールへ |
2594
+ * | 「true」 | 取れたのに読めない=そのリポジトリが未選択 | **インストール設定**へ |
2595
+ * | 「null」 | その夜は確かめられなかった | **どちらも出さない**(押しても直るとは限らない) |
2596
+ *
2597
+ * **自社か顧客かで文言を分けない。** 組織名(「classmethod-*」)で分岐すると、
2598
+ * 新しい自社組織が増えたときに黙って間違った案内を出す。どちらにも通じる文にする。
2599
+ */
2600
+ function githubNotConnectedNodes(g) {
2601
+ var out = [];
2602
+ var o = g.meta || {};
2603
+ var repos = o.repos || g.items.length;
2604
+ var unread = o.unreadable != null ? o.unreadable : g.items.length;
2605
+ var installed = o.appInstalled;
2606
+ out.push(el("div", { class: "ds-orgnote-title", text: "⚠️ " + g.title + " はまだ接続されていません" }));
2607
+ // **読める件数を出す。** 総数だけ出すと、同じ画面の集計と食い違う(#101 で3度直した形)
2608
+ var head = repos + "リポジトリ中 " + unread + "件が読めていません。";
2609
+ if (installed === true) {
2610
+ // App は入っている。**インストールをもう一度促さないこと**——済んでいる操作を促すのは
2611
+ // 「設定する必要が無いものに設定を促す」偽の警告と同じで、しかも直らない
2612
+ //
2613
+ // **原因を1つに断定しない(2026-08-21)。** 「appInstalled」 が分けられるのは
2614
+ // 「①未インストール」と「②未選択/③消えた・改名」までで、**②と③は分かれない**。
2615
+ // 「対象に選ばれていません」と言い切ると、③の人は一覧に無いリポジトリを
2616
+ // 探し続けることになる(押しても直らない導線と同じ形)。逃げ道を1文で示す。
2617
+ //
2618
+ // **設定画面の宛先も断定しない。** リンク先は 「/settings/installations」=
2619
+ // **押した本人の**インストール一覧で(appSettingsLink の項に、組織の設定ページが
2620
+ // 管理者以外で 404 になる理由がある)、App を入れたのが別のメンバーや
2621
+ // 顧客側のオーナーなら**着いた先は空**。そこで詰まる人に次の宛先を渡す。
2622
+ out.push(el("p", {
2623
+ class: "ds-group-note",
2624
+ text: head + "GitHub App はこの組織に入っていますが、これらのリポジトリが読み取りの対象に選ばれていません。"
2625
+ + "インストール設定を開いて、対象のリポジトリを追加してください。"
2626
+ + "設定は App を入れた方の画面に出ます。見当たらない場合は、この組織で App を入れた方にご依頼ください。"
2627
+ + "リポジトリ自体が一覧に見当たらない場合は、移動・改名・削除されている可能性があるので、登録から外してください。"
2628
+ + "お客様の組織では、組織のオーナーに承認を依頼してください。",
2629
+ }));
2630
+ out.push(appSettingsLink("インストール設定を開く →"));
2631
+ return out;
2632
+ }
2633
+ if (installed === false) {
2634
+ out.push(el("p", {
2635
+ class: "ds-group-note",
2636
+ text: head + "この組織に Cortex の GitHub App を入れると読めるようになります(Issues の読み取りだけ)。"
2637
+ + "お客様の組織では、組織のオーナーに承認を依頼してください。",
2638
+ }));
2639
+ out.push(appInstallLink("この組織にインストール →"));
2640
+ return out;
2641
+ }
2642
+ // 「null」(確かめられなかった)と、この値を知らない古い fleet-status.json。
2643
+ // **導線を出さない。** どちらが原因か分からないので、押させると外れたほうを
2644
+ // 押させることになる(済んでいるインストールをもう一度、あるいは効かない設定画面へ)
2645
+ out.push(el("p", {
2646
+ class: "ds-group-note",
2647
+ text: head + "原因(GitHub App が入っていない/入っているがこれらのリポジトリが選ばれていない)は、"
2648
+ + "今回の実行では確かめられませんでした。翌朝の表示をご確認ください。",
2649
+ }));
2650
+ return out;
2651
+ }
2652
+
2374
2653
  /**
2375
2654
  * Slack のチャンネルを**平らに**並べる(2026-08-20)。
2376
2655
  *
@@ -2581,6 +2860,63 @@ ${capabilityState.toString()}
2581
2860
  */
2582
2861
  var GITHUB_APP_INSTALL_URL = "https://github.com/apps/classmethod-cortex-app/installations/new";
2583
2862
 
2863
+ /**
2864
+ * GitHub App のインストールへ行くリンク(2026-08-20 に部品化)。
2865
+ *
2866
+ * **同じものを2箇所に書かない。** 組織ごとの案内と、追加フォームの手順が
2867
+ * 同じ導線を持つ(#138 で「1箇所にしか無い」ことが事故になったので、
2868
+ * **むしろ複数あるのが正しい**)。写経すると、URLの張り替えで片方だけ古くなる。
2869
+ *
2870
+ * **正本は手順の側。** 組織ごとの案内は行が無いと出ないので、
2871
+ * 行0件の案件では手順側しか残らない。
2872
+ *
2873
+ * **⚠️ ただし手順は追加フォームの中にあり、追加フォームは
2874
+ * SITE.intake が無いと丸ごと消える**(renderExternalAdders の冒頭)。
2875
+ * つまり **intake 無し+行0件** の案件では App の導線が1本も無くなる。
2876
+ * いまは config.ts の intakeUrl が艦隊で1つ・全案件に渡っているので
2877
+ * その組み合わせは起きない(2026-08-20 に確認)。
2878
+ * **案件ごとに intake を出し分けるようになったら、ここを見直すこと。**
2879
+ */
2880
+ function appInstallLink(label) {
2881
+ return el("a", {
2882
+ class: "ds-owner-install",
2883
+ href: GITHUB_APP_INSTALL_URL,
2884
+ target: "_blank", rel: "noopener",
2885
+ text: label || "GitHub App を入れる →",
2886
+ });
2887
+ }
2888
+
2889
+ /**
2890
+ * すでに入っている App の**インストール設定**(対象リポジトリの追加)へ行くリンク。
2891
+ *
2892
+ * **インストールとは別の宛先。** 「installations/new」 は「入れる」ための画面で、
2893
+ * すでに入っている組織の人が押しても対象リポジトリは増えない。
2894
+ * リポジトリの追加は、その人が入れた先(個人 or 組織)の設定画面で行う。
2895
+ *
2896
+ * **組織名を URL に埋め込まない。** 組織ごとの設定ページは
2897
+ * 「github.com/organizations/<組織>/settings/installations」 だが、**その組織の
2898
+ * 管理者でないと 404 になる**。読み取り対象の組織のオーナーが Cortex の利用者とは
2899
+ * 限らない(お客様の組織がまさにそれ)ので、404 に飛ばすより、
2900
+ * 本人のインストール一覧から辿らせるほうが確実に着く。
2901
+ * ——「GITHUB_APP_INSTALL_URL」 の「確かめられないものを顧客に押させない」と同じ判断。
2902
+ *
2903
+ * ⚠ **ただし「押した本人が入れた App」しか出ない。** App を入れたのが別のメンバーや
2904
+ * 顧客側のオーナーなら、着いた先は空になる。だから**案内の本文の側が**
2905
+ * 「App を入れた方の画面に出ます/見当たらない場合はその方にご依頼ください」と断っている
2906
+ * (githubNotConnectedNodes)。**リンクだけ残して本文の断りを消さないこと**——
2907
+ * 消すと「開けば必ずそこにある」前提の案内に戻り、着いた先が空の人が詰む。
2908
+ */
2909
+ var GITHUB_APP_SETTINGS_URL = "https://github.com/settings/installations";
2910
+
2911
+ function appSettingsLink(label) {
2912
+ return el("a", {
2913
+ class: "ds-owner-install",
2914
+ href: GITHUB_APP_SETTINGS_URL,
2915
+ target: "_blank", rel: "noopener",
2916
+ text: label || "インストール設定を開く →",
2917
+ });
2918
+ }
2919
+
2584
2920
  /**
2585
2921
  * 接続状況のライブ値(op=state の応答)。取得前・取得失敗時は null。
2586
2922
  *
@@ -2826,7 +3162,14 @@ ${capabilityState.toString()}
2826
3162
  // いまはタイルが飛び先。両方に付けると同じ id が2つある不正なHTMLになり、
2827
3163
  // モーダルの中身がタイルより前に来る変更が入った瞬間、getElementById が
2828
3164
  // 「開いていないモーダルの中」を指して黙って無反応になる
2829
- var card = el("div", { class: "ds-card" + (inactive ? " ds-inactive" : "") }, [
3165
+ //
3166
+ // **ds-capbody を付けて縦組みにする(2026-08-21・オーナー指摘)。**
3167
+ // .ds-card は横並びの flex なので、名前ブロック(col)と設定一式(ctl)が
3168
+ // 左右に並び、「共有資料 資料」だけが左の細い列に見えていた。
3169
+ // Slack / GitHub の行と同じ「上にレコード・下にフォーム」に見せるため、
3170
+ // **このカードのときだけ** 縦に積む(.ds-card そのものの CSS は変えない
3171
+ // ——extSourceCard が使っており、変えると Slack と GitHub の行が壊れる)。
3172
+ var card = el("div", { class: "ds-card ds-capbody" + (inactive ? " ds-inactive" : "") }, [
2830
3173
  el("div", { class: "ds-card-main" }, [
2831
3174
  el("span", { class: "ds-icon", text: DS_KIND_ICON[s.kind] || "📁" }),
2832
3175
  el("span", { class: "ds-type", text: s.kind || "" }),
@@ -2871,8 +3214,13 @@ ${capabilityState.toString()}
2871
3214
  var groups = [];
2872
3215
  var byType = {};
2873
3216
  sources.forEach(function (s) {
2874
- var g = byType[s.type];
2875
- if (!g) { g = { type: s.type, items: [] }; byType[s.type] = g; groups.push(g); }
3217
+ // **GitHub は種別をまたいで1つにまとめる(2026-08-20)。**
3218
+ // issues discussions を別グループにすると、**同じ組織の案内が2箱出る**
3219
+ // (組織ごとの畳み直しが箱をまたげない。実測で再現した)。
3220
+ // 組織は type ではなく ref に入っているので、まとめて渡すのが正しい
3221
+ var key = String(s.type).indexOf("github") === 0 ? "__github__" : s.type;
3222
+ var g = byType[key];
3223
+ if (!g) { g = { type: key, items: [] }; byType[key] = g; groups.push(g); }
2876
3224
  g.items.push(s);
2877
3225
  });
2878
3226
  var UNIT = { "slack": "チャンネル", "github-issues": "リポジトリ", "github-discussions": "リポジトリ" };
@@ -2886,6 +3234,15 @@ ${capabilityState.toString()}
2886
3234
  slackRows(g.items, meta).forEach(function (n) { pushCap("チャット", n); });
2887
3235
  return;
2888
3236
  }
3237
+ // **GitHub も組織ごとに分ける(2026-08-20・Slack からの横展開)。**
3238
+ // それまでは「リポジトリの一覧」と「組織ごとの接続状況」が**別々に並んで**おり、
3239
+ // どの行がどの組織のものか、行を見ても分からなかった。
3240
+ if (g.type === "__github__") {
3241
+ // **meta は行ごとに引く。** issues と discussions が混ざるので、
3242
+ // グループの type から1つに決められない
3243
+ githubRows(g.items).forEach(function (n) { pushCap("開発", n); });
3244
+ return;
3245
+ }
2889
3246
  var cards = el("div", { class: "ds-list" });
2890
3247
  g.items.forEach(function (s) { cards.appendChild(extSourceCard(s, meta)); });
2891
3248
  if (g.items.length >= 4) {
@@ -2929,125 +3286,49 @@ ${capabilityState.toString()}
2929
3286
  // 読み取れていない組として現れ、そこに接続ボタンが出る。
2930
3287
  // 0件なら繋ぐ相手もまだ無いので、入口が要る場面にならない。
2931
3288
  pushCap("チャット", adders.querySelector('[data-adder="slack"]'));
2932
- // **組織ごとの接続状況を、開発の一番上に置く。** トークンは組織ごとにしか
2933
- // 効かない(Fine-grained PAT も GitHub App のインストールトークンも1組織分)ので、
2934
- // 「N件が読めていません」だけでは何をすればよいか決まらない。
2935
- // どの組織が繋がっていないかと、そこへの導線を出す
2936
- var owners = fleetVal("githubOwners") || [];
2937
- if (owners.length) {
2938
- var obox = el("div", { class: "ds-owners" });
2939
- obox.appendChild(el("div", { class: "ds-sub", text: "組織ごとの接続状況" }));
2940
- owners.forEach(function (o) {
2941
- var ok = o.source === "app" || o.source === "token";
2942
- // **確かめられなかった夜は、そう言う。** gh の不在・タイムアウト・ネットワーク断で
2943
- // 起きる。「接続OK」と出すと、同じ画面の集計行が「⚠️ 2件に問題」と言っている
2944
- // 横で食い違う。「未接続」と出すと、繋がっている組織に設定を促す偽の警告になる。
2945
- // どちらも嘘なので、確かめていないことをそのまま出す
2946
- var unsure = o.source === "unknown";
2947
- var row = el("div", { class: "ds-owner" }, [
2948
- el("span", { class: "ds-owner-name", text: o.owner }),
2949
- el("span", { class: "ds-owner-state ds-gate-" + (ok ? "ok" : "warn"),
2950
- // **読める件数を出す。** 総数だけ出すと、同じ画面の
2951
- // 「⚠️ 15件が読めていません」と食い違う(#101 で3度直した形)
2952
- // **確かめられなかった件数を出す。** 未接続側は「18リポジトリ中 15件が
2953
- // 読めていません」と出しているのに、こちらだけ総数だと同じ節の中で
2954
- // 表現が揃わない。4リポ中1つだけ揺れた夜に「4リポジトリを確かめられて
2955
- // いません」と読めるのも実態と違う
2956
- text: unsure
2957
- ? "⚠️ 確認できませんでした " + o.repos + "リポジトリ中 " +
2958
- (o.unchecked || o.repos) + "件の接続を確かめられていません"
2959
- : ok
2960
- ? "● 接続OK " + o.repos + "リポジトリ" + (o.source === "app" ? "(GitHub App)" : "")
2961
- : "⚠️ 未接続 " + o.repos + "リポジトリ中 " + o.unreadable + "件が読めていません" }),
2962
- ]);
2963
- // **未接続のときだけ導線を出す。** 繋がっている組織に「インストール」を
2964
- // 出すと、押す必要が無いものを押させることになる。
2965
- // 確かめられなかっただけの組織にも出さない——すでに入っているかもしれないので
2966
- if (!ok && !unsure) {
2967
- row.appendChild(el("a", {
2968
- class: "ds-owner-install",
2969
- // どの組織かはこの行に出ているので、選ばせる形で足りる(URLの形は
2970
- // GITHUB_APP_INSTALL_URL のコメントを参照)
2971
- href: GITHUB_APP_INSTALL_URL,
2972
- target: "_blank", rel: "noopener",
2973
- text: "この組織にインストール →",
2974
- }));
2975
- }
2976
- obox.appendChild(row);
2977
- });
2978
- pushCap("開発", obox);
2979
- } else {
2980
- // **組織が1件も無い案件にも、App の入口を置く(#138)。**
2981
- // githubOwners は夜間バッチの成果物なので、立ち上げ直後の案件には無い。
2982
- // インストールの導線を「未接続の組織の行」の中にしか置いていなかったため、
2983
- // **まだ何も繋いでいない案件=一番 App を入れてほしい案件**の画面に、
2984
- // App へ行く道が1本も無かった(実機で踏んだ)。
2985
- var obox0 = el("div", { class: "ds-owners" });
2986
- obox0.appendChild(el("div", { class: "ds-sub", text: "組織ごとの接続状況" }));
2987
- obox0.appendChild(el("div", { class: "ds-owner" }, [
2988
- el("span", { class: "ds-owner-state ds-gate-idle", text: "⦿ 未接続 読み取っている組織はまだありません" }),
2989
- el("a", {
2990
- class: "ds-owner-install",
2991
- href: GITHUB_APP_INSTALL_URL,
2992
- target: "_blank", rel: "noopener",
2993
- text: "組織に GitHub App をインストール →",
2994
- }),
2995
- ]));
2996
- pushCap("開発", obox0);
2997
- }
3289
+ // **組織ごとの接続状況は、行と一体にした(2026-08-20・Slack からの横展開)。**
3290
+ //
3291
+ // それまでは「リポジトリの一覧」と「組織ごとの接続状況」が**別々に並んで**おり、
3292
+ // 行を見てもどの組織のものか分からず、状況を見てもどの行の話か分からなかった。
3293
+ // いまは:
3294
+ // - どの組織かは**行のラベル**(extSourceCard)
3295
+ // - 繋がっていない組織は**案内が行を抱える**(githubRows)
3296
+ //
3297
+ // **⚠️ App の導線は、追加フォームの手順に常設してある(#138)。**
3298
+ // 組織ごとの案内は**行が無いと出ない**ので、
3299
+ // **行0件の案件=一番 App を入れてほしい案件**にはそちらしか無い。
3300
+ // 「案内側に導線があるから手順側は省く」とやると #138 が再発する。
3301
+ //
3302
+ // 立ち上げ直後(githubOwners が空)でも、追加フォームは必ず出る
3303
+ // (adders はグループの外に置いてある)。
2998
3304
 
2999
3305
  pushCap("開発", adders.querySelector('[data-adder="github"]'));
3000
3306
 
3001
- /**
3002
- * GitHub の読み取りトークンを入れる欄が要るか(#118)。
3003
- *
3004
- * トークンは **GitHub App が入っていない組織のフォールバック**でしかない。全組織が
3005
- * App(か登録済みトークン)で読めている案件に入力欄を出すと、実機で言われたとおり
3006
- * 「App に移行したのに、なぜトークンを求められるのか」になる。押す必要が無いものを
3007
- * 押させるのは、未設定の警告を出し続けるのと同じ種類の誤りなので畳む。
3008
- *
3009
- * **判断材料が無いときは出す。** githubOwners は fleet-status.json 由来で、
3010
- * 夜間バッチがまだ走っていない案件には無い。「情報が無い」を「接続済み」に寄せると、
3011
- * **一番繋ぎたい立ち上げ直後の案件**から唯一の入口が消える。
3012
- *
3013
- * **unknown(確かめられなかった組織)も「要る」側に倒す。** 実態は none かもしれず、
3014
- * 隠す方に倒すと直せなくなる(画面には組織ごとに「確認できませんでした」と出ている)。
3015
- *
3016
- * **「無い」と「空」は別物(#138・オーナー判断 2026-08-18)。**
3017
- * - 値そのものが来ていない(undefined)… fleet-status.json をまだ持っていない、または
3018
- * 取得に失敗した。**情報が無いので出す**(上の #118 の意図はここが担う)。
3019
- * - 空の配列([])… 夜間バッチは走ったが、読み取り対象の開発リポジトリが0件。
3020
- * 読むものが無いのだから鍵も要らない。**出さない**。
3021
- * 以前はどちらも空配列に丸めていたので、0件の案件にも欄が出続けていた。
3022
- * **この2つを混ぜると #118 を壊す**(立ち上げ直後の案件から唯一の入口が消える)。
3023
- */
3024
- function githubTokenNeeded() {
3025
- var list = fleetVal("githubOwners");
3026
- if (!Array.isArray(list)) return true;
3027
- if (!list.length) return false;
3028
- return list.some(function (o) { return !o || (o.source !== "app" && o.source !== "token"); });
3307
+ // **0件の案件にも1行だけ残す(#138 のオーナー判断・置き場所だけ移した)。**
3308
+ // 元は GitHub の読み取りトークンの鍵カードを畳んだ跡地に出していたが、
3309
+ // その鍵ごと廃止したので、GitHub の節の側で持つ。
3310
+ //
3311
+ // **消さないこと。** 立ち上げ直後の案件は、この節に行が1本も無い
3312
+ //(=組織ごとの案内も出ない)。何も言わないと、上のフォームが何のためにあるのか、
3313
+ // 登録したあと何が起きるのかが画面から読み取れない。
3314
+ //
3315
+ // **「[]」(夜間バッチは走ったが対象0件)と 「undefined」(まだ走っていない)は別物。**
3316
+ // 後者に「まだありません」と書くと、実際には登録済みかもしれない案件に嘘を言う
3317
+ var ownersNow = fleetVal("githubOwners");
3318
+ if (Array.isArray(ownersNow) && ownersNow.length === 0) {
3319
+ pushCap("開発", el("p", {
3320
+ class: "sec-how ds-all-connected",
3321
+ text: "読み取り対象の開発リポジトリがまだありません。上のフォームで登録すると、"
3322
+ + "読めているかどうかを翌朝からここに出します。",
3323
+ }));
3029
3324
  }
3030
3325
 
3031
3326
  // 連携の鍵は、その能力のところへ置く(画面下まで往復させない)
3032
- var SECRET_CAP = { figma: "デザイン", backlog: "課題管理", "external-sources": "開発" };
3327
+ // **GitHub(「external-sources」)はここに無い。** 読み取りトークンは廃止し、
3328
+ // 読み取りは GitHub App に一本化した。**この表に戻すと入力欄が復活する**
3329
+ var SECRET_CAP = { figma: "デザイン", backlog: "課題管理" };
3033
3330
  (SITE.secretKinds || []).forEach(function (k) {
3034
3331
  if (!SECRET_FORMS[k]) return;
3035
- // 全組織が繋がっているなら、入力欄ではなく**そう言う1行**に畳む(#118)。
3036
- // 何も出さないと「トークンはどこで入れるのか」が分からなくなる
3037
- if (k === "external-sources" && !githubTokenNeeded()) {
3038
- // **0件の案件に「すべての組織に接続済み」と言わない(#138)。** 組織が1つも無いのに
3039
- // 接続済みと書けば嘘になる。畳む理由が違うので、文も分ける——次の一歩
3040
- //(リポジトリを登録する)が同じモーダルの上にあることまで書く
3041
- var ownersNow = fleetVal("githubOwners");
3042
- var noRepos = Array.isArray(ownersNow) && ownersNow.length === 0;
3043
- pushCap(SECRET_CAP[k], el("p", {
3044
- class: "sec-how ds-all-connected",
3045
- text: noRepos
3046
- ? "読み取り対象の開発リポジトリがまだありません。このモーダルの上にあるリポジトリの追加フォームで登録すると、トークンが要るかどうかをここに出します。"
3047
- : "すべての組織に接続済みです(GitHub App / 登録済みトークン)。読み取りトークンの登録は要りません。",
3048
- }));
3049
- return;
3050
- }
3051
3332
  // **意図的にOFFにした能力にだけ、入力欄を出さない(0.4.71)。** 使わないと決めたものに
3052
3333
  // 鍵を求めるのは、設定する必要が無いものに設定を促す偽の警告と同じ。
3053
3334
  // 何も出さないと「トークンはどこで入れるのか」が分からなくなるので、理由を1行だけ残す
@@ -3065,6 +3346,13 @@ ${capabilityState.toString()}
3065
3346
  }));
3066
3347
  return;
3067
3348
  }
3349
+ // **Backlog は接続先のスペースごとにカードを出す(2026-08-20・#127)。**
3350
+ // キーはスペースごとに別なので、欄が1つだと**どちらの鍵を入れたのか
3351
+ // 誰にも分からない**まま片方の棚に入る(入れたのに動かない、が起きる)。
3352
+ if (k === "backlog") {
3353
+ backlogSecretCards().forEach(function (n) { pushCap(SECRET_CAP[k], n); });
3354
+ return;
3355
+ }
3068
3356
  pushCap(SECRET_CAP[k], secretCard(k, SECRET_FORMS[k]));
3069
3357
  });
3070
3358
 
@@ -3089,7 +3377,9 @@ ${capabilityState.toString()}
3089
3377
  "開発": { icon: "🐙", desc: "IssuesをGoldの材料に" },
3090
3378
  };
3091
3379
  var STATE_LABEL = { ok: "● 接続OK", idle: "⦿ 未接続", off: "未使用", warn: "⚠️" };
3092
- var SECRET_OF_CAP = { "デザイン": "figma", "課題管理": "backlog", "開発": "external-sources" };
3380
+ // **「開発」はここに無い。** GitHub の読み取りトークンは廃止したので、
3381
+ // 鍵の未設定を語る相手がいない(読めていないときは組織ごとの案内が拾う)
3382
+ var SECRET_OF_CAP = { "デザイン": "figma", "課題管理": "backlog" };
3093
3383
 
3094
3384
  // ---- セットアップの残り(checks)----
3095
3385
  //
@@ -3292,10 +3582,6 @@ ${capabilityState.toString()}
3292
3582
  // 新規案件——**まさに何を入れれば繋がるのかを知りたい場面**——から、
3293
3583
  // 鍵が未設定であることの手がかりまで消える
3294
3584
  var sk = SECRET_OF_CAP[cap];
3295
- // **GitHub は「トークンが無い」=「読めていない」ではない(#118)。** App で読めている
3296
- // 案件に未設定の警告を出すと、直す必要が無いものを直させることになる。
3297
- // 読めていない組織があるときは、この下の checks とタイルの状態(warn)が拾う
3298
- var tokenIsFallback = sk === "external-sources" && !githubTokenNeeded();
3299
3585
  // **#125②: 導出があるときは導出で判断する。** OFF(意図的に止めた)にだけ出さない
3300
3586
  // ——入れる必要が無いものについて設定を促す偽の警告になる。
3301
3587
  //
@@ -3304,7 +3590,7 @@ ${capabilityState.toString()}
3304
3590
  // 0.4.70 は unset で入力欄を畳んでいたので注記も抑えていたが、案A で欄を出すようにしたため揃えた
3305
3591
  // 導出が無いデータでは判断材料が無いので、注記を出す側(=入力欄と揃う側)に倒す
3306
3592
  var tokenRelevant = derived != null ? derived !== "off" : true;
3307
- if (!tokenIsFallback && tokenRelevant && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !((fleetVal("secretSources") || {})[sk])) {
3593
+ if (tokenRelevant && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !((fleetVal("secretSources") || {})[sk])) {
3308
3594
  body.appendChild(el("div", { class: "ds-tile-note", text: "⚠ トークンが未設定です" }));
3309
3595
  }
3310
3596
  // セットアップの残りのうち、この能力のもの。**鍵の注記と同じ位置**に出して
@@ -3780,6 +4066,42 @@ ${capabilityState.toString()}
3780
4066
  * **読み手が見に行くのと別の棚へ黙って入る**(入れたのに動かない、が一番起きてほしくない形)。
3781
4067
  * 名乗らなければサーバが従来どおり名簿の値を使う。
3782
4068
  */
4069
+ /**
4070
+ * Backlog のAPIキーの欄を、接続先のスペースごとに出す(2026-08-20・#127)。
4071
+ *
4072
+ * **キーはスペースごとに別。** 欄が1つだと、複数スペースの案件で
4073
+ * 「どちらの鍵を入れたのか」が誰にも分からないまま片方の棚に入る。
4074
+ *
4075
+ * ## 「現在:」は複数スペースのとき出さない
4076
+ *
4077
+ * 夜間の実測(secretSources.backlog)は**種別ごと**の1つの値で、
4078
+ * スペース別の充足を表せない。1枚目に帰属させると
4079
+ * **片方だけ入れた状態で両方に「登録済み」と出る**——画面が嘘をつく。
4080
+ * 器がスペース別になるまでは、複数のときは全カードで出さない。
4081
+ *
4082
+ * ## 接続先がまだ無いとき
4083
+ *
4084
+ * カードは1枚だけ出す(スペースは名乗らない)。サーバは名簿の値に倒す。
4085
+ */
4086
+ function backlogSecretCards() {
4087
+ var spaces = backlogSpacesNow();
4088
+ if (spaces.length <= 1) return [secretCard("backlog", SECRET_FORMS["backlog"])];
4089
+ return spaces.map(function (space) {
4090
+ return secretCard("backlog", SECRET_FORMS["backlog"], { space: space, hideCurrent: true });
4091
+ });
4092
+ }
4093
+
4094
+ /** 画面が見ている接続先のスペース一覧(重複なし) */
4095
+ function backlogSpacesNow() {
4096
+ var list = backlogTargetsNow(null);
4097
+ var domains = [];
4098
+ for (var i = 0; i < list.length; i++) {
4099
+ var d = String(list[i].domain || "").toLowerCase();
4100
+ if (d && domains.indexOf(d) === -1) domains.push(d);
4101
+ }
4102
+ return domains;
4103
+ }
4104
+
3783
4105
  function backlogSecretSpace() {
3784
4106
  var list = backlogTargetsNow(null);
3785
4107
  var domains = [];
@@ -3805,17 +4127,25 @@ ${capabilityState.toString()}
3805
4127
  var targets = backlogTargetsNow(s);
3806
4128
  var editable = backlogTargetsEditable();
3807
4129
 
3808
- var listEl = el("div", { class: "ds-target-list" });
3809
- var current = el("div", { class: "ds-current" }, [
3810
- el("span", { class: "ds-current-label", text: "現在の接続先" }),
3811
- listEl,
3812
- ]);
4130
+ // **Slack / GitHub / Figma / Drive と同じレコードの行にする(2026-08-21・オーナー指摘)。**
4131
+ // それまでは「現在の接続先」というラベル付きの独自レイアウト(.ds-current /
4132
+ // .ds-target-row)で、同じモーダルの中でもここだけ行間と見た目が違っていた。
4133
+ var listEl = el("div", { class: "ds-list" });
4134
+ // **0件の1行は一覧の外に置く**(Figma / Drive と同じ形)。中に .ds-card として
4135
+ // 置くと「行が1件ある」と読まれ、一覧を数えるテストにも行として数えられる
4136
+ var emptyEl = el("p", { class: "ds-empty", text: "同期する接続先はまだ登録されていません(未設定)。" });
3813
4137
  // 追加・削除の結果はここに1つだけ出す(行を描き直しても消えない位置)
3814
4138
  var msg = el("div", { class: "ds-toggle-msg" });
3815
4139
 
3816
4140
  function removeRow(t) {
3817
- var row = el("div", { class: "ds-target-row" }, [
3818
- el("span", { class: "ds-current-value", text: t.domain + " / " + t.projectKey }),
4141
+ // **ds-type はツール名**(能力名を入れると上の見出しと重複する)。
4142
+ // **名前はリンクにする**——Drive / Figma の行と同じく、正本を新しいタブで開ける
4143
+ var row = el("div", { class: "ds-card" }, [
4144
+ el("div", { class: "ds-card-main" }, [
4145
+ el("span", { class: "ds-icon", text: "📋" }),
4146
+ el("span", { class: "ds-type", text: "Backlog" }),
4147
+ dsNameEl(t.domain + " / " + t.projectKey, "https://" + t.domain + "/projects/" + t.projectKey),
4148
+ ]),
3819
4149
  ]);
3820
4150
  if (!editable) return row;
3821
4151
  var btn = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: "この接続先を外す" });
@@ -3842,12 +4172,13 @@ ${capabilityState.toString()}
3842
4172
 
3843
4173
  function paint() {
3844
4174
  while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
3845
- if (!targets.length) {
3846
- // **行ごと消さない。** 表示が無いと「まだ何も無い」のか「出し忘れ」なのか区別できない
3847
- listEl.appendChild(el("span", { class: "ds-current-value is-unset", text: "未設定" }));
3848
- return;
3849
- }
3850
4175
  for (var i = 0; i < targets.length; i++) listEl.appendChild(removeRow(targets[i]));
4176
+ // **0件を黙って見せない。** 表示が無いと「まだ何も無い」のか「出し忘れ」なのか
4177
+ // 区別できない。**一覧の外**(Figma / Drive と同じ)に、件数に応じて出し入れする
4178
+ // ——出しっぱなしにすると、設定済みの案件にも「未設定」と書いた行が残る
4179
+ var here = emptyEl.parentNode === box;
4180
+ if (!targets.length && !here) box.insertBefore(emptyEl, listEl.nextSibling);
4181
+ else if (targets.length && here) box.removeChild(emptyEl);
3851
4182
  }
3852
4183
 
3853
4184
  /**
@@ -3870,8 +4201,10 @@ ${capabilityState.toString()}
3870
4201
  return { onTargets: adoptTargets, staleNote: TARGETS_RACE_NOTE };
3871
4202
  }
3872
4203
 
4204
+ // **box を先に作る。** paint() は 0件の1行を box へ出し入れするので、
4205
+ // 箱が無いと最初の描画で「未設定」の行が出ない
4206
+ var box = el("div", {}, [listEl]);
3873
4207
  paint();
3874
- var box = el("div", {}, [current]);
3875
4208
  if (editable) {
3876
4209
  box.appendChild(addForm({
3877
4210
  // **「追加」に戻す(#127)。** 1件しか持てなかった頃は「保存」(置き換え)だったが、
@@ -3879,7 +4212,27 @@ ${capabilityState.toString()}
3879
4212
  title: "+ 接続先を追加",
3880
4213
  placeholder: "https://<スペース>.backlog.jp/projects/<プロジェクトキー>",
3881
4214
  requiredNote: "BacklogのURLを入力してください(プロジェクトキーが入った形。数字だけのURLは受け付けません)。",
3882
- hint: "Backlog でこの案件のプロジェクトを開き、ブラウザのURLをそのまま貼ってください(課題の画面のURLでも構いません)。APIキーは下の「Backlog のAPIキー」から別に登録します。増えるのは同期先だけで、ビューアからの起票先は変わりません。リアルタイム同期を使う場合は、追加したプロジェクトの Backlog 側にも案件専用URLの登録が要ります。",
4215
+ // **手順のリストにする(2026-08-20・Slack からの横展開)。**
4216
+ // **リスト(ol.sec-steps)そのものは維持する**——他の能力の手順と形を揃える。
4217
+ //
4218
+ // **鍵の発行手順は二重に持たない。** 正本は下の鍵カード
4219
+ //(SECRET_FORMS.backlog の how)で、ここからは参照するだけ。
4220
+ //
4221
+ // ## 4行 → 2行に圧縮した(2026-08-21・オーナー指摘「何も変わってない気がする」)
4222
+ //
4223
+ // **この欄は URL を貼るだけ**なので、4行の説明が読む負担に見合わない。
4224
+ // ただし**落としてよいのは1行目だけ**だった(欄を見れば分かる内容)。残り3つは
4225
+ // ここ以外に置き場が無いので、**消さずに2行へ畳んでいる**:
4226
+ // - 「起票先は変わりません」… 追加の確認をやめたとき(2026-08-20)に
4227
+ // ここへ移してきたもの。落とすと知る手段が無くなる
4228
+ // - 「スペースごとに別のキー」… 鍵カードからは読めない。1スペースの案件は
4229
+ // カードのタイトルにスペース名が出ないので、これから2つ目を足す人に届かない
4230
+ // - 「案件専用URLの登録」… 救出先の realtimeSyncCard() は稼働中の案件では
4231
+ // 手順一式を <details> に畳むので、**いちばん要る人に対して閉じている**
4232
+ hint: [
4233
+ "URL を貼ると同期先が増えます。ビューアからの起票先は変わりません(スペースごとに別のAPIキーが要ります)。",
4234
+ "リアルタイム同期を使うなら、追加したプロジェクトにも案件専用URLの登録が要ります。",
4235
+ ],
3883
4236
  build: function (url) {
3884
4237
  return { op: "backlog-target-add", url: url, baseGeneration: targetsGenerationOf(targets) };
3885
4238
  },
@@ -4013,15 +4366,11 @@ ${capabilityState.toString()}
4013
4366
  box.appendChild(el("p", { class: "sec-how", text: "共有資料の取り込み設定を読み取れないため、この画面からは変更できません。cortex の担当者にご連絡ください。" }));
4014
4367
  used = true;
4015
4368
  } else if (s.driveState) {
4016
- // フォルダが1件も無いのに ON/OFF だけ出しても意味が無いので、追加フォームを先に出す
4017
- box.appendChild(addForm({
4018
- title: "+ Google Drive のフォルダを追加",
4019
- placeholder: "https://drive.google.com/drive/folders/…",
4020
- namePlaceholder: "メモ(任意)",
4021
- requiredNote: "フォルダのURLを入力してください。",
4022
- hint: "このフォルダの中身が 共有資料 にコピーされ、Markdown 化されます。取り込み用アカウントをそのフォルダの閲覧者に招待しておいてください(招待が無いと読めません)。",
4023
- build: function (url, name) { return { op: "add-drive-folder", url: url, name: name }; },
4024
- }));
4369
+ // **レコードが先・追加フォームが後(2026-08-21)。** Slack / GitHub と同じ並びに揃える。
4370
+ // それまではフォームが先だった。理由は「フォルダが1件も無いのに ON/OFF だけ出しても
4371
+ // 意味が無いから」だったが、**その ON/OFF トグルは既に廃止されている**(下の注記)。
4372
+ // 理由が消えたのに順序だけ残っていた。デザインと課題管理は元から「レコード → フォーム」。
4373
+ //
4025
4374
  // **ON/OFFのトグルは出さない。** フォルダを追加した時点で取り込みは始まる
4026
4375
  // (applyAddDriveFolder が enabled を立てる)ので「始める」を押す機会が無い。
4027
4376
  // 止めたいならフォルダを外す——登録そのものが意思表示、という会議と同じ考え方。
@@ -4029,11 +4378,19 @@ ${capabilityState.toString()}
4029
4378
  // 名前は任意。**無ければIDを出す**(既存の案件は名前を持っていない)
4030
4379
  var fnames = s.driveFolderNames || {};
4031
4380
  if (folders.length) {
4032
- var dlist = el("div", { class: "ds-subitems" });
4381
+ var dlist = el("div", { class: "ds-list" });
4033
4382
  folders.forEach(function (id) {
4034
4383
  var fname = fnames[id];
4035
- var row = el("div", { class: "ds-subitem" }, [
4036
- dsNameEl(fname || id, "https://drive.google.com/drive/folders/" + id),
4384
+ // **Slack / GitHub の行と同じ構造にする(ds-card > ds-card-main >
4385
+ // ds-icon + ds-type + ds-name)。** extSourceCard は呼ばない——
4386
+ // あちらは type / gate / origin など外部ソース固有の判断を抱えている。
4387
+ // ds-type に入れるのは**ツール名**(能力名を入れると上の見出しと重複する)。
4388
+ var row = el("div", { class: "ds-card" }, [
4389
+ el("div", { class: "ds-card-main" }, [
4390
+ el("span", { class: "ds-icon", text: "📁" }),
4391
+ el("span", { class: "ds-type", text: "Google Drive" }),
4392
+ dsNameEl(fname || id, "https://drive.google.com/drive/folders/" + id),
4393
+ ]),
4037
4394
  ]);
4038
4395
  // **名前を出したときはIDも併記する。** 同名のフォルダを見分けられなくなるのと、
4039
4396
  // 「このIDを外す」という確認文と画面の表示が食い違うのを防ぐ
@@ -4054,12 +4411,41 @@ ${capabilityState.toString()}
4054
4411
  return lines;
4055
4412
  },
4056
4413
  build: function () { return { op: "remove-drive-folder", id: id }; },
4057
- onDone: function () { row.className = "ds-subitem ds-removed"; },
4414
+ // **クラスのハード代入をやめる**(片方だけ直し忘れる形を構造的に消す)
4415
+ onDone: function () { row.classList.add("ds-removed"); },
4058
4416
  }));
4059
4417
  dlist.appendChild(row);
4060
4418
  });
4061
4419
  box.appendChild(dlist);
4420
+ } else if (s.driveState === "empty") {
4421
+ // **0件を黙って見せない。** 追加フォームだけだと「まだ登録していない」のか
4422
+ // 「読めていない」のか区別できない。
4423
+ //
4424
+ // **判定は driveState === "empty" にする。** engine(fleet-status.mjs)は
4425
+ // driveFolderIds を「1件以上あるときだけ」出すので、キーの有無では
4426
+ // 未取得(unset / broken)と0件を見分けられない。unset / broken は
4427
+ // 上の分岐で先に返しているので、ここへ来る empty は「読めていて0件」だけ。
4428
+ box.appendChild(el("p", { class: "ds-empty", text: "取り込み対象のフォルダはまだ登録されていません。" }));
4062
4429
  }
4430
+ box.appendChild(addForm({
4431
+ title: "+ Google Drive のフォルダを追加",
4432
+ placeholder: "https://drive.google.com/drive/folders/…",
4433
+ namePlaceholder: "メモ(任意)",
4434
+ requiredNote: "フォルダのURLを入力してください。",
4435
+ // **手順のリストにする(2026-08-20・Slack からの横展開)。**
4436
+ //
4437
+ // **取り込み用アカウントのアドレスを出す(オーナー判断・分類A)。**
4438
+ // それまでは「取り込み用アカウントを招待してください」としか書いておらず、
4439
+ // **誰を招待すればいいのかが画面から分からなかった**——担当者に聞かないと
4440
+ // 完了できない手順になっていた。
4441
+ // 招待されない限り何も読めないアカウントなので、出す判断をした。
4442
+ hint: [
4443
+ { text: "読み取りたい Google Drive のフォルダを開き、下のアカウントを閲覧者として招待します(招待が無いと読めません)。", node: copyRow(DRIVE_INGEST_ACCOUNT) },
4444
+ "そのフォルダのURLを下の欄に貼って「追加」を押します。",
4445
+ "追加したフォルダの中身が 共有資料 にコピーされ、Markdown 化されます。止めるときは、その行の「このフォルダを外す」を押します。",
4446
+ ],
4447
+ build: function (url, name) { return { op: "add-drive-folder", url: url, name: name }; },
4448
+ }));
4063
4449
  used = true;
4064
4450
  }
4065
4451
  }
@@ -4073,10 +4459,18 @@ ${capabilityState.toString()}
4073
4459
  // 描画時点の件数で判定すると、3件を1件ずつ外していったとき最後の1件でも警告が出ない。
4074
4460
  var figsLeft = figs.length;
4075
4461
  if (figs.length) {
4076
- var flist = el("div", { class: "ds-subitems" });
4462
+ var flist = el("div", { class: "ds-list" });
4077
4463
  figs.forEach(function (f) {
4078
- var row = el("div", { class: "ds-subitem" }, [
4079
- dsNameEl(f.name || f.key, "https://www.figma.com/design/" + f.key),
4464
+ // **Slack / GitHub の行と同じ構造にする(ds-card > ds-card-main >
4465
+ // ds-icon + ds-type + ds-name)。** extSourceCard は呼ばない——
4466
+ // あちらは type / gate / origin など外部ソース固有の判断を抱えている。
4467
+ // ds-type に入れるのは**ツール名**(能力名を入れると上の見出しと重複する)。
4468
+ var row = el("div", { class: "ds-card" }, [
4469
+ el("div", { class: "ds-card-main" }, [
4470
+ el("span", { class: "ds-icon", text: "🎨" }),
4471
+ el("span", { class: "ds-type", text: "Figma" }),
4472
+ dsNameEl(f.name || f.key, "https://www.figma.com/design/" + f.key),
4473
+ ]),
4080
4474
  ]);
4081
4475
  row.appendChild(removeButton({
4082
4476
  label: "このファイルを外す",
@@ -4100,7 +4494,8 @@ ${capabilityState.toString()}
4100
4494
  },
4101
4495
  build: function () { return { op: "remove-figma-file", key: f.key }; },
4102
4496
  onDone: function () {
4103
- row.className = "ds-subitem ds-removed";
4497
+ // **クラスのハード代入をやめる**(片方だけ直し忘れる形を構造的に消す)
4498
+ row.classList.add("ds-removed");
4104
4499
  figsLeft -= 1;
4105
4500
  },
4106
4501
  }));
@@ -4117,7 +4512,18 @@ ${capabilityState.toString()}
4117
4512
  placeholder: "https://www.figma.com/design/…",
4118
4513
  namePlaceholder: "メモ(任意)",
4119
4514
  requiredNote: "FigmaのURLを入力してください。",
4120
- hint: "このファイルの画面インベントリ(画面名・Figmaへのリンク)が毎晩同期されます。画像は取り込まれません。",
4515
+ // **手順のリストにする(2026-08-20・Slack からの横展開)。**
4516
+ // 一続きの文章だと、読む人が「自分がいま何をすべきか」を自分で切り出すことになる。
4517
+ //
4518
+ // **手順1・2は要約にとどめ、正本は下の鍵カードに置く**(SECRET_FORMS.figma の
4519
+ // how / note)。全文をここに移すと二重になり、片方だけ直る形になる
4520
+ // ——カードは接続マップとタイルの注記と条件を揃えているので、動かすと壊れやすい。
4521
+ hint: [
4522
+ "Figma でトークンを発行します(読み取りだけ。スコープ2つ)。手順は下の「Figma のトークン」の欄にあります。",
4523
+ "発行したアカウントを、読み取りたい Figma ファイルに閲覧者として招待します(招待が無いと、トークンが正しくても読めません)。",
4524
+ "そのファイルのURLを下の欄に貼って「追加」を押します。",
4525
+ "追加したファイルは、画面名と Figma へのリンクが毎晩同期されます(画像は取り込まれません)。",
4526
+ ],
4121
4527
  build: function (url, name) { return { op: "add-figma-file", url: url, name: name }; },
4122
4528
  }));
4123
4529
  used = true;
@@ -4215,7 +4621,21 @@ ${capabilityState.toString()}
4215
4621
  placeholder: "owner/repo または https://github.com/owner/repo",
4216
4622
  namePlaceholder: "メモ(任意)",
4217
4623
  requiredNote: "リポジトリを入力してください。",
4218
- hint: "このリポジトリの Issues が、毎晩の Gold 昇格の材料になります。",
4624
+ // **手順のリストにする(2026-08-20・Slack からの横展開)。**
4625
+ //
4626
+ // **App の導線をここに常設する(#138 の再発防止・唯一の防波堤)。**
4627
+ // それまでは「組織ごとの接続状況」という独立した一覧の中にしか
4628
+ // 導線が無く、**まだ何も繋いでいない案件=一番 App を入れてほしい案件**の
4629
+ // 画面に App へ行く道が1本も無かった(実機で踏んだ)。
4630
+ //
4631
+ // **行が0件でも、この手順は必ず出る**(追加フォームはグループの外にある)。
4632
+ // 組織ごとの案内は行が無いと出ないので、**常設の正本はこちら**。
4633
+ // 「案内側に導線があるから手順側は省く」とやると即再発する。
4634
+ hint: [
4635
+ { text: "読み取りたいリポジトリがある組織に、Cortex の GitHub App を入れます(Issues の読み取りだけ)。", node: appInstallLink() },
4636
+ "そのリポジトリを下の欄に入れて「追加」を押します(owner/repo でも、GitHub のURLでも構いません)。",
4637
+ "追加したリポジトリの Issues が、毎晩の Gold 昇格の材料になります。止めるときは、その行の「読み取りを止める」を押します。",
4638
+ ],
4219
4639
  build: function (repo, name) { return { op: "add-github-source", repo: repo, name: name }; },
4220
4640
  adder: "github",
4221
4641
  }));
@@ -4747,6 +5167,30 @@ ${capabilityState.toString()}
4747
5167
  // ようになった(explicit には出さない)が、Slack で explicit なのは登録ファイルに
4748
5168
  // 直接書いた例外的な行だけで、バッジのために文面をもう一組増やすには釣り合わない。
4749
5169
  // #119 以降、新しいデータの github は必ず explicit(詳細は ORIGIN_LABEL)
5170
+ // **どの組織のリポジトリかを行に出す(2026-08-20)。**
5171
+ // 組織ごとの独立した一覧をやめたので、**組織を言うのは行のラベルだけ**になった。
5172
+ // トークンは組織ごとにしか効かないので、これが判断の単位になる
5173
+ if (String(s.type).indexOf("github") === 0 && s.ref) {
5174
+ var ghOwner = String(s.ref).split("/")[0];
5175
+ if (ghOwner) {
5176
+ // **何で読んでいるかを添える。** 台帳に無ければ何も言わない。
5177
+ //
5178
+ // **添えるのは App のときだけ(2026-08-21・読み取りトークンの廃止で判断し直した)。**
5179
+ // かつては「App/個人トークン」で直し方が違ったが、個人トークンは廃止した。
5180
+ // いま App が付かない行は 「workflow」(ワークフロー自身のトークンで読めた=
5181
+ // だいたい公開リポジトリ)で、**読めている以上その人に直す作業は無い**。
5182
+ // ここに「(ワークフローのトークン)」のような札を足すと、直しようのない
5183
+ // 内部事情を全行に並べることになる——押す必要が無いものを説明する側の混雑。
5184
+ // 一方 App の札は「この組織はもう繋いである」という**運用上の意味**を持つので残す。
5185
+ var ghMeta = githubOwnerMeta(ghOwner);
5186
+ var via = ghMeta && ghMeta.source === "app" ? "(GitHub App)" : "";
5187
+ badges.appendChild(el("span", {
5188
+ class: "badge ds-workspace" + (ghMeta && ghMeta.source === "unknown" ? " ds-workspace-unknown" : ""),
5189
+ text: ghOwner + via,
5190
+ title: "トークンは組織ごとにしか効きません(Fine-grained PAT も GitHub App のインストールトークンも1組織分)。",
5191
+ }));
5192
+ }
5193
+ }
4750
5194
  if (String(s.type).indexOf("github") === 0 && ORIGIN_LABEL[s.origin]) {
4751
5195
  badges.appendChild(el("span", {
4752
5196
  class: "badge ds-origin",
@@ -5136,12 +5580,25 @@ ${capabilityState.toString()}
5136
5580
  ].concat(steps));
5137
5581
  }
5138
5582
 
5139
- /** 鍵の入力欄に出す説明。kind は Lambda 側(resolveSecretTarget)と綴りを合わせること */
5583
+ /**
5584
+ * 鍵の入力欄に出す説明。kind は Lambda 側(resolveSecretTarget)と綴りを合わせること。
5585
+ *
5586
+ * **発行の手順(how / note / selfServe.note)は <details> に畳む**
5587
+ *(2026-08-21・オーナー指摘「カードが長すぎる」)。**文言は消していない**——
5588
+ * 畳んだ中に全部残っており、summary を開けば読める。
5589
+ * 畳まないのは .sec-state / .sec-preferred / .sec-input(secretCard 側の 🔴 コメント参照)。
5590
+ *
5591
+ * **「畳まない種別」の枝は置いていない。** 当初は fold: true の opt-in にしていたが、
5592
+ * 2026-08-21 に external-sources(GitHub の読み取りトークン)が廃止され、
5593
+ * **残る2種別が両方とも畳む=その枝が到達不能になった**。
5594
+ * 到達不能な枝は**壊しても何も起きない**ので、変異検証の網から静かに漏れる
5595
+ * ——このリポジトリが繰り返し踏んでいる形なので、枝ごと落とした。
5596
+ * **畳んではいけない種別を足すときに、テストと一緒に枝を戻すこと。**
5597
+ */
5140
5598
  var SECRET_FORMS = {
5141
5599
  "figma": {
5142
5600
  icon: "🎨",
5143
5601
  title: "Figma のトークン",
5144
- desc: "デザインの画面インベントリと、Figma に付いたコメント(指摘)を毎晩同期するために使います。",
5145
5602
  // **必要なスコープは実装が叩くAPIから決まる。** sync-designs は
5146
5603
  // GET /v1/files/{key} → file_content:read(画面インベントリ)
5147
5604
  // GET /v1/files/{key}/comments → file_comments:read(指摘の同期)
@@ -5158,7 +5615,6 @@ ${capabilityState.toString()}
5158
5615
  "backlog": {
5159
5616
  icon: "📋",
5160
5617
  title: "Backlog のAPIキー",
5161
- desc: "課題・Wiki・ドキュメントを毎晩同期するために使います。",
5162
5618
  how: "Backlog → 個人設定 → API で発行(この案件のプロジェクトが見えるアカウントで)",
5163
5619
  // **案件の担当者が自分で繋ぐ。** 以前は艦隊共通の読み取りアカウントを1つ運用していたが、
5164
5620
  // 案件が増えるたびに「その1人がプロジェクトへ参加追加してキーを配る」ことになり、
@@ -5175,21 +5631,6 @@ ${capabilityState.toString()}
5175
5631
  "(前の担当者のアカウントが無効になると同期が止まります)。",
5176
5632
  },
5177
5633
  },
5178
- "external-sources": {
5179
- icon: "🐙",
5180
- title: "GitHub の読み取りトークン",
5181
- desc: "開発リポジトリの Issues / Discussions を読み、Gold の原料にするために使います。",
5182
- // **第一の案内は GitHub App(#138)。** 実装の本線は App のインストールトークンで、
5183
- // 個人トークンは App を入れられない組織のフォールバックでしかない(既存案件は
5184
- // すでに App で読めている)。画面が個人トークンを勧め続けると、発行と入れ直しが
5185
- // 要る側に人が流れ続け、**入れ直しの要らない仕組みへ移れない**。
5186
- // 順序を入れ替えるだけで、どちらも残す——App を入れられない組織は実在する。
5187
- appFirst: {
5188
- text: "まず、読み取りたいリポジトリのある組織に Cortex の GitHub App を入れてください。トークンの発行も、期限が来たときの入れ直しも要りません。",
5189
- linkText: "組織に GitHub App をインストール →",
5190
- },
5191
- how: "App を入れられない組織では、代わりにトークンを使います。GitHub → Settings → Developer settings → Personal access tokens で、対象リポジトリに Contents / Issues の Read-only を付けて発行",
5192
- },
5193
5634
  };
5194
5635
 
5195
5636
  /**
@@ -5202,27 +5643,53 @@ ${capabilityState.toString()}
5202
5643
  * カードは #101 で能力モーダルの中へ移した(buildDataSources の pushCap 経由)。
5203
5644
  * まとめて並べる renderSecrets() は呼び出し元が無くなったので消してある。
5204
5645
  */
5205
- function secretPayload(intake, kind, value) {
5646
+ function secretPayload(intake, kind, value, cardSpace) {
5206
5647
  var body = { projectKey: intake.projectKey, kind: kind, value: value };
5207
5648
  if (kind === "backlog") {
5208
- var space = backlogSecretSpace();
5649
+ // **カードが担当するスペースを最優先する(2026-08-20)。**
5650
+ // 複数スペースのときは欄がスペースごとに分かれているので、
5651
+ // どの棚へ入れるかは**そのカードが知っている**。
5652
+ // 1枚だけのときは従来どおり(1つに定まるなら名乗る・定まらなければ名簿に倒す)
5653
+ var space = cardSpace || backlogSecretSpace();
5209
5654
  if (space) body.space = space;
5210
5655
  }
5211
5656
  return body;
5212
5657
  }
5213
5658
 
5214
- function secretCard(kind, meta) {
5659
+ /**
5660
+ * @param opts { space, hideCurrent }
5661
+ * space … このカードが担当する Backlog スペース(複数スペースのときだけ)
5662
+ * hideCurrent … 「現在:」を出さない(スペース別の充足を表せないため)
5663
+ */
5664
+ function secretCard(kind, meta, opts) {
5665
+ var cardSpace = (opts && opts.space) || "";
5666
+ var hideCurrent = !!(opts && opts.hideCurrent);
5215
5667
  // **いま何で動いているか**を先に出す。入れ直したのに反映されていない状態が
5216
5668
  // 画面で分かるようにする(接続状況は夜間バッチのスナップショットなので、
5217
5669
  // 保存の直後は前日の値のまま。時刻は上の節に出している)。
5670
+ // **複数スペースのときは出さない。** この値は種別ごとの1つで、
5671
+ // スペース別の充足を表せない——1枚目に帰属させると嘘になる
5218
5672
  var src = (fleetVal("secretSources") || {})[kind] || "";
5673
+ // **どのスペースの鍵かを見出しに出す。** 欄が2つ並ぶので、
5674
+ // 見分けが付かないと「どちらに入れたか」が分からなくなる
5675
+ var cardTitle = cardSpace ? meta.title + "(" + cardSpace + ")" : meta.title;
5219
5676
  // **前方一致で見る。** #105 の移行期は「Secrets Manager(旧名)」も来る。
5220
5677
  // 完全一致にすると、旧名で動いている案件だけが repo secret と同じ見た目になり、
5221
5678
  // 「管理下にあるのに未移行」という一番知りたい状態が画面から消える
5222
- var state = src
5223
- ? el("p", { class: "sec-state" + (src.indexOf("Secrets Manager") === 0 ? " is-managed" : ""),
5224
- text: "現在: " + src + " から取得中" })
5225
- : el("p", { class: "sec-state is-none", text: "現在: 未設定" });
5679
+ // **複数スペースのときは、何も出さない(2026-08-20)。**
5680
+ // この値は種別ごとの1つで、スペース別の充足を表せない。
5681
+ // 「未設定」に倒すのも嘘——**片方は入っているかもしれない**。
5682
+ // 出さない代わりに、どちらの鍵かは見出し(スペース名)で示す
5683
+ var state = hideCurrent
5684
+ // **消えた理由を書く(レビュー指摘)。** 1スペースの案件では出ていた行が
5685
+ // 黙って消えると、利用者は「入っているのか未設定なのか」も
5686
+ // 「なぜ表示が無いのか」も知る手段が無い
5687
+ ? el("p", { class: "sec-state is-none",
5688
+ text: "スペースごとの登録状況は、まだ表示できません(貼り直すと、このスペースの鍵として上書きされます)" })
5689
+ : src
5690
+ ? el("p", { class: "sec-state" + (src.indexOf("Secrets Manager") === 0 ? " is-managed" : ""),
5691
+ text: "現在: " + src + " から取得中" })
5692
+ : el("p", { class: "sec-state is-none", text: "現在: 未設定" });
5226
5693
 
5227
5694
  var input = el("input", { class: "sec-input", type: "password", placeholder: "トークンを貼り付け", autocomplete: "off" });
5228
5695
  var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "保存" });
@@ -5246,7 +5713,7 @@ ${capabilityState.toString()}
5246
5713
  // こちらが渡すのは「どのスペースの鍵か」という材料だけ。
5247
5714
  // **押した時点で読む。** 組み立て時に固めると、接続先を登録した直後に
5248
5715
  // 貼った鍵が「スペースを特定できません」で断られる
5249
- body: JSON.stringify(secretPayload(intake, kind, value)),
5716
+ body: JSON.stringify(secretPayload(intake, kind, value, cardSpace)),
5250
5717
  });
5251
5718
  var j = await res.json().catch(function () { return {}; });
5252
5719
  if (!res.ok) {
@@ -5272,38 +5739,50 @@ ${capabilityState.toString()}
5272
5739
  }
5273
5740
  });
5274
5741
 
5742
+ // 発行の手順ひとそろい。**<details> に畳む**(文言は消さない。畳んだ中に全部残る)
5743
+ var guidance = [
5744
+ el("p", { class: "sec-how", text: meta.how }),
5745
+ // **鍵の作り方に付随する注意(招待漏れ等)。** 発行手順(how)とは別に出す——
5746
+ // 「トークンは正しいのに読めない」は権限の問題に見えないので、切り分けに時間がかかる。
5747
+ meta.note ? el("p", { class: "sec-how", text: meta.note }) : null,
5748
+ meta.selfServe ? el("p", { class: "sec-how", text: meta.selfServe.note }) : null,
5749
+ ].filter(Boolean);
5750
+ // **「畳んである中に何があるか」を summary に書く。** ただの「詳細」だと、
5751
+ // 発行のしかたを探している人がここを開く手掛かりを持てない(rt カードと同じ流儀)
5752
+ var guidanceBox = el("details", { class: "sec-fold" }, [el("summary", { text: "発行のしかた" })]);
5753
+ for (var gi = 0; gi < guidance.length; gi++) guidanceBox.appendChild(guidance[gi]);
5754
+
5275
5755
  // ライブ取得後に「現在: …」だけを差し替えるための目印(カードごと作り直すと入力が消える)
5756
+ //
5757
+ // **並びは「状態 → 貼る → 畳んだ手順」。** カードを短く見せるために、
5758
+ // いちばんよく使う操作(貼って保存)を上へ出し、長い説明を下へ送っている。
5759
+ //
5760
+ // 🔴 **.sec-state / .sec-preferred / .sec-input は <details> の外に置くこと。**
5761
+ // - .sec-state … 保存したのに反映されていない状態が分かる唯一の手段。
5762
+ // 押すまで見えないと成立しない
5763
+ // - .sec-preferred … 「誰のアカウントで発行するのか」を**手順より先に**伝える行。
5764
+ // 未設定のときだけ出る=これから貼る人にだけ出るので、畳むと存在理由が消える
5765
+ // - .sec-input … 貼る欄そのもの。ライブ差し替えもこのノードを直接触る
5276
5766
  return el("div", { class: "ops-card", "data-secret-kind": kind }, [
5277
5767
  el("div", { class: "ops-card-head" }, [
5278
5768
  el("span", { class: "ops-card-icon", text: meta.icon }),
5279
- el("h3", { class: "ops-card-title", text: meta.title }),
5769
+ el("h3", { class: "ops-card-title", text: cardTitle }),
5280
5770
  ]),
5281
- el("p", { class: "ops-card-desc", text: meta.desc }),
5771
+ // **desc は無い種別がある(2026-08-21)。** el() は text をそのまま textContent へ
5772
+ // 代入するので、無条件に出すと画面に文字列 undefined が出る
5773
+ meta.desc ? el("p", { class: "ops-card-desc", text: meta.desc }) : null,
5282
5774
  state,
5283
5775
  // **未設定のときだけ、先に試すべき手を出す。** 既に動いているカードに
5284
5776
  // 「まずこれをやれ」と出すと、直す必要が無いものを触らせることになる
5285
5777
  !src && meta.selfServe ? el("p", { class: "sec-preferred", text: meta.selfServe.text }) : null,
5286
- // **本線(App)はトークンの手順より先に、いつでも出す(#138)。**
5287
- // トークンで動いている案件こそ App へ移してほしいので、selfServe と違って
5288
- // 「未設定のときだけ」にはしない
5289
- meta.appFirst
5290
- ? el("p", { class: "sec-preferred" }, [
5291
- document.createTextNode(meta.appFirst.text + " "),
5292
- el("a", { href: GITHUB_APP_INSTALL_URL, target: "_blank", rel: "noopener", text: meta.appFirst.linkText }),
5293
- ])
5294
- : null,
5295
- el("p", { class: "sec-how", text: meta.how }),
5296
- // **鍵の作り方に付随する注意(招待漏れ等)。** 発行手順(how)とは別に出す——
5297
- // 「トークンは正しいのに読めない」は権限の問題に見えないので、切り分けに時間がかかる。
5298
- meta.note ? el("p", { class: "sec-how", text: meta.note }) : null,
5299
- meta.selfServe ? el("p", { class: "sec-how", text: meta.selfServe.note }) : null,
5300
5778
  input,
5779
+ el("div", { class: "ds-toggle" }, [btn, msg]),
5780
+ guidanceBox,
5301
5781
  // **扱いの説明はカードに持たせる。** 節の見出しの下に置いていたときは、
5302
5782
  // #101 でカードだけがモーダルへ移り、説明だけが画面から消えた。
5303
5783
  // 顧客にトークンの貼り付けを求める画面で、貼った値がどう扱われるかの
5304
5784
  // 説明が落ちるのは、いちばん落としてはいけない種類の欠落
5305
5785
  el("p", { class: "sec-note", text: "入れた値はここには表示されません(保存だけできます)。保存先は案件ごとに分かれているので、他の案件には影響しません。" }),
5306
- el("div", { class: "ds-toggle" }, [btn, msg]),
5307
5786
  ]);
5308
5787
  }
5309
5788