@takagaki/cortex-decisions-viewer 0.4.84 → 0.5.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/build.js CHANGED
@@ -152,6 +152,9 @@ async function readFleetSources(repoRoot) {
152
152
  // (このマッピングは素通しではなく明示の許可リスト)。落とすと、同じIDの
153
153
  // チャンネルを2つのワークスペースに持つ案件で「登録を解除」が別の行を消す
154
154
  workspace: s.workspace != null ? String(s.workspace) : undefined,
155
+ // **厳密比較。** サーバは true しか書かないので、
156
+ // 他の値("true" や 1)を印として扱わない
157
+ orgUnknown: s.orgUnknown === true ? true : undefined,
155
158
  }))
156
159
  : undefined;
157
160
  // 接続済み Slack ワークスペース(#104)。**空配列と undefined を区別して通す**——
package/dist/render.js CHANGED
@@ -1151,6 +1151,26 @@ a.ds-name:hover { text-decoration: underline; }
1151
1151
  .ds-group[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
1152
1152
  .ds-group > .ds-list { margin-top: 8px; padding-left: 12px; border-left: 2px solid var(--border); }
1153
1153
  .badge.ds-sum-ok { background: #e9f7ef; color: #1f7a45; }
1154
+ /* 「断定しない」——古い fleet-status では接続状態が分からない。OKとも警告とも言わない */
1155
+ .ds-sum-unknown { background: var(--bg); color: var(--muted); }
1156
+ /* 組織が分かっていない行のラベル。**警告色にする**(並びの中で見つかる必要がある) */
1157
+ .badge.ds-workspace-unknown { border-color: #f0b429; color: #8a5a00; background: #fff8e6; }
1158
+ /* 組織についての案内。行ではないので、行と見分けが付く形にする */
1159
+ .ds-orgnote {
1160
+ border: 1px solid #f0b429; background: #fffdf5; border-radius: 12px;
1161
+ padding: 12px 16px; display: flex; flex-direction: column; gap: 8px;
1162
+ }
1163
+ .ds-orgnote-title { font-weight: 700; font-size: 13.5px; color: #8a5a00; }
1164
+ /* 案内が指す行。**囲うことで「どのチャンネルの話か」を示す**(開閉はしない) */
1165
+ .ds-orgnote-rows { display: flex; flex-direction: column; gap: 8px; margin-top: 2px; }
1166
+ /* 手順のリスト。番号を出し、1手ずつ離す(一続きの文章にしない) */
1167
+ .sec-steps { margin: 0; padding-left: 1.35em; display: flex; flex-direction: column; gap: 8px; }
1168
+ .sec-steps li { line-height: 1.75; }
1169
+ .sec-steps li > span { display: block; }
1170
+ .ds-group-body { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 2px; }
1171
+ /* 組のまとまりを、行より弱い線で示す */
1172
+ .ds-group-note { font-size: 13px; color: var(--muted); line-height: 1.7; margin: 0; }
1173
+ .ds-group-note strong { color: var(--text); }
1154
1174
  .badge.ds-sum-warn { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
1155
1175
  /* ---- 接続マップ(操作タブ最上部のSVG図) ---- */
1156
1176
  .conn-map { overflow-x: auto; margin: 4px 0 20px; }
@@ -1298,7 +1318,13 @@ a.ds-name:hover { text-decoration: underline; }
1298
1318
  夜間のスナップショットの状態バッジと**同時に2つの判定を並べない**ため、押したあとは
1299
1319
  状態バッジをこの中の判定へ置き換える(同じ画面で「⚠️」と「接続OK」が並ぶのが、
1300
1320
  この節で3回直してきた壊れ方そのもの) */
1301
- .ds-probe { flex: 1 1 100%; margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border); }
1321
+ /* 押した結果の詳細。**押されるまでは何も出さない。**
1322
+ 以前は中身が空でも点線だけが全行に出ており、何も意味していなかった(2026-08-20) */
1323
+ .ds-probe { display: none; }
1324
+ .ds-probe.is-shown {
1325
+ display: block; flex: 1 1 100%;
1326
+ margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border);
1327
+ }
1302
1328
  .ds-probe-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
1303
1329
  /* いつ測った結果かを必ず添える。夜間のスナップショットと混ざると、どちらの話か読めない */
1304
1330
  .ds-probe-at { font-size: 11.5px; color: var(--muted); }
@@ -1402,7 +1428,7 @@ a.ds-name:hover { text-decoration: underline; }
1402
1428
  button.ops-card-btn { cursor: pointer; }
1403
1429
  button.ops-card-btn:disabled { opacity: .6; cursor: default; }
1404
1430
  /* ---- 投入モーダル ---- */
1405
- .modal-overlay {
1431
+ .modal-overlay, .confirm-overlay {
1406
1432
  position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center;
1407
1433
  background: rgba(16,24,40,0.45); padding: 20px;
1408
1434
  -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
@@ -1434,6 +1460,12 @@ button.ops-card-btn:disabled { opacity: .6; cursor: default; }
1434
1460
  background: var(--surface); color: var(--muted); font-size: 13px; font-weight: 600; cursor: pointer;
1435
1461
  }
1436
1462
  .modal-cancel:hover { background: var(--bg); }
1463
+ /* 確認モーダル。能力モーダルの上に重なるので z-index は上(下地は inert で塞ぐ) */
1464
+ .confirm-overlay { z-index: 110; }
1465
+ .confirm-box { max-width: 420px; gap: 10px; }
1466
+ /* 貼ったURLを1行で出すので、はみ出させない(箱は420px・モバイルは実質335px) */
1467
+ .confirm-line { font-size: 14px; color: var(--text); line-height: 1.75; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
1468
+ .confirm-line:first-child { font-weight: 700; }
1437
1469
  .modal-ok { color: #047857; font-weight: 600; }
1438
1470
  .modal-status-error { color: #dc2626; font-weight: 600; }
1439
1471
  .modal-paths { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
@@ -2050,7 +2082,7 @@ ${capabilityState.toString()}
2050
2082
  * Slack のチャンネルは、どのワークスペースにあるかで読み取りの鍵が別物になる:
2051
2083
  * - workspace 省略 … クラスメソッドの社内ワークスペース。中央 Bot の鍵(案件リポジトリの
2052
2084
  * 設定)で読む。**画面から設定できるものが1つも無い。**
2053
- * - workspace: "T…" … お客様のワークスペース。下の「お客様の Slack ワークスペースを接続」で
2085
+ * - workspace: "T…" … お客様のワークスペース。「Slack ワークスペースに接続する」で
2054
2086
  * 承認を貰って読む。**画面から自己解決できる。**
2055
2087
  *
2056
2088
  * どちらも no_token に潰して「トークン未設定」と出していたため、社内チャンネルを
@@ -2067,13 +2099,17 @@ ${capabilityState.toString()}
2067
2099
  internal: {
2068
2100
  text: "管理者の設定が必要です",
2069
2101
  cls: "ds-gate-muted",
2070
- help: "このチャンネルはクラスメソッドの社内ワークスペースにあります。読み取りを始めるには cortex 側(管理者)の設定が必要で、下の「お客様の Slack ワークスペースを接続」では設定できません。cortex の担当者にご連絡ください。",
2102
+ // **「この画面では設定できません」を必ず言う(2026-08-18 の不具合)。**
2103
+ // 状態だけを出していたので、設定する場所を探した人が
2104
+ // 顧客ワークスペースの接続へ吸い込まれ、押しても必ず失敗する画面を
2105
+ // 何度も試した。**次に誰が何をするか**まで書く
2106
+ help: "このチャンネルはクラスメソッドの社内ワークスペースにあります。読み取りを始めるには cortex 側(管理者)の設定が必要で、この画面では設定できません。cortex の担当者にご連絡ください。",
2071
2107
  },
2072
2108
  // お客様のワークスペース。従来どおり接続へ誘導する(自己解決できる唯一の経路)
2073
2109
  customer: {
2074
2110
  text: "トークン未設定",
2075
2111
  cls: "ds-gate-muted",
2076
- help: "このチャンネルがあるお客様の Slack ワークスペースが、まだ接続されていない(または接続が切れている)状態です。下の「お客様の Slack ワークスペースを接続」から接続してください。",
2112
+ help: "このチャンネルがあるお客様の Slack ワークスペースが、まだ接続されていない(または接続が切れている)状態です。「Slack ワークスペースに接続する」から接続してください。",
2077
2113
  },
2078
2114
  };
2079
2115
  /**
@@ -2127,6 +2163,331 @@ ${capabilityState.toString()}
2127
2163
  var allInternal = noToken.every(function (s) { return isInternalSlack(s); });
2128
2164
  return head + "が読めていません(" + (allInternal ? "管理者の設定が必要です" : "トークン未設定") + ")";
2129
2165
  }
2166
+ /**
2167
+ * Slack のチャンネルを、所属する組織ごとに分ける(2026-08-20)。
2168
+ *
2169
+ * ## なぜ純関数にするか
2170
+ *
2171
+ * ここが**判断の全部**。描画に混ぜると「どう分けたか」を実測できなくなる。
2172
+ * sourceGroup は受け取ったものを並べるだけで、意味を持たない
2173
+ * (GitHub は組織・Backlog はスペースで同じ形が要るため)。
2174
+ *
2175
+ * ## 分け方
2176
+ *
2177
+ * 1. workspace(team_id)があれば、その組織。表示名は台帳から引く
2178
+ * 2. orgUnknown の印が付いていれば「組織が分かりません」
2179
+ * 3. どちらも無ければ社内(クラスメソッド)
2180
+ *
2181
+ * **印を先に見ない。** 判定が済んで workspace が入った行にも、
2182
+ * 古いデータでは印が残っているかもしれない——**決まっている方を信じる**。
2183
+ *
2184
+ * ## 「分かりません」に入れる条件(ここが誤りやすい)
2185
+ *
2186
+ * **印が唯一の目印。** 夜間の実測(gate: "unreachable")で判断してはいけない——
2187
+ * unreachable はアーカイブ・削除・URLの打ち間違いでも起きるので、
2188
+ * それを「組織が分かりません」と出すと**誤った案内**になる。
2189
+ * 印はサーバが「全部の鍵で届かなかった」と実測した記録なので、意味が違う。
2190
+ *
2191
+ * @param items チャンネルの行(fleet-status の externalSources のうち type === "slack")
2192
+ * @param workspaces 台帳(slackWorkspaces() の返り値。null なら「知らない」)
2193
+ * @returns [{ key, title, items, unknown }] key は並びの安定のためだけに使う
2194
+ */
2195
+ function groupSlackByOrg(items, workspaces) {
2196
+ var known = {};
2197
+ (workspaces || []).forEach(function (w) { if (w && w.teamId) known[w.teamId] = w.name || w.teamId; });
2198
+ var order = [];
2199
+ var byKey = {};
2200
+ (items || []).forEach(function (s) {
2201
+ var key, title, unknown = false;
2202
+ if (s.workspace) {
2203
+ key = s.workspace;
2204
+ // **台帳に無ければ team_id を出す。** 空欄より、少なくとも突き合わせられる
2205
+ title = known[s.workspace] || s.workspace;
2206
+ } else if (s.orgUnknown === true) {
2207
+ key = "__unknown__";
2208
+ title = "組織不明";
2209
+ unknown = true;
2210
+ } else {
2211
+ key = "__internal__";
2212
+ title = "クラスメソッド";
2213
+ }
2214
+ var g = byKey[key];
2215
+ if (!g) { g = { key: key, title: title, items: [], unknown: unknown }; byKey[key] = g; order.push(g); }
2216
+ g.items.push(s);
2217
+ });
2218
+ // **社内を先、「分かりません」を最後にする。** 分かりませんが先頭に来ると、
2219
+ // 直すものが無い人にも毎回それが最初に見える
2220
+ order.sort(function (a, b) {
2221
+ var rank = function (g) { return g.key === "__internal__" ? 0 : g.unknown ? 2 : 1; };
2222
+ return rank(a) - rank(b);
2223
+ });
2224
+ return order;
2225
+ }
2226
+
2227
+ /**
2228
+ * 会議の取り込みの案内。**操作ではなく条件を示す。**
2229
+ *
2230
+ * 取り込まれるには2つとも要る:
2231
+ * ① cortex-notetaker Bot が会議に招待されている(=取り込む意思。これが同意のゲート)
2232
+ * ② 会議名が照合キーのどれかを含む(含まなければ、録れても案件に振り分けられない)
2233
+ *
2234
+ * ②の材料を画面が出さないと、招待したのに届かない理由が分からない。
2235
+ */
2236
+ function meetingHowTo() {
2237
+ // **2つの条件がANDであることを、読み飛ばせない形で出す。** 散文で並べていたときに
2238
+ // 「会議名は合っているのにBotを招待しておらず取り込まれなかった」事故が実際に起きた。
2239
+ // 会議名の条件は上の行(ds-keys)が出すので、ここでは「上の語」と参照するに留める。
2240
+ var wrap = el("div", { class: "sec-how" });
2241
+ wrap.appendChild(el("p", { text: "次の2つが揃った会議だけが取り込まれます。" }));
2242
+ var ul = el("ul", { class: "sec-how-list" });
2243
+ ul.appendChild(el("li", { text: "cortex-notetaker Bot が招待されている(定例はシリーズに1回でOK)" }));
2244
+ ul.appendChild(el("li", { text: "会議名に上の目印が入っている" }));
2245
+ wrap.appendChild(ul);
2246
+ wrap.appendChild(el("p", { text: "どちらか一方だけでは取り込まれません。" }));
2247
+ return wrap;
2248
+ }
2249
+
2250
+ /**
2251
+ * Slack の承認画面へ進むボタン(2026-08-20 に接続カードから切り出した)。
2252
+ *
2253
+ * ## 押す人についての注意を必ず添える
2254
+ *
2255
+ * **承認画面はワークスペースを指定できない**(開始の口に team を渡す口が無い)。
2256
+ * **承認する人がログインしているワークスペース**にボットが入る。だから
2257
+ * 「〈お客様の名前〉と接続する」とは書けない——保証できないことを書かない。
2258
+ *
2259
+ * お客様の Slack なら、クラスメソッドの人間は押せないことが多い
2260
+ * (その Slack の管理者にお願いすることになる)。それを書かないと、
2261
+ * 押しても進まない人が「壊れている」と読む。
2262
+ */
2263
+ function slackConnectButton() {
2264
+ var wrap = el("div", { class: "ds-toggle" });
2265
+ // **「この」を付けない(2026-08-20・オーナー指摘)。**
2266
+ // どのワークスペースを指すのか読み取れない——そもそも**指定できない**
2267
+ // (承認する人がログインしているワークスペースに入る)ので、
2268
+ // 特定のものを指すような書き方は嘘にもなる
2269
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "Slack ワークスペースに接続する" });
2270
+ var msg = el("div", { class: "ds-toggle-msg" });
2271
+ btn.addEventListener("click", async function () {
2272
+ var intake = SITE.intake;
2273
+ if (!intake) return;
2274
+ btn.disabled = true;
2275
+ msg.className = "ds-toggle-msg";
2276
+ msg.textContent = "承認画面を準備しています…";
2277
+ try {
2278
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
2279
+ var res = await fetch(pfx + "op=slack-oauth-start&t=" + encodeURIComponent(intake.token), {
2280
+ method: "POST",
2281
+ headers: { "content-type": "application/json" },
2282
+ body: "{}",
2283
+ });
2284
+ var j = await res.json().catch(function () { return {}; });
2285
+ if (!res.ok || !j.url) {
2286
+ // サーバの文言をそのまま出す(未設定のときは「準備中です」が返る)
2287
+ msg.className = "ds-toggle-msg is-error";
2288
+ msg.textContent = "✗ " + (j.message || "接続を開始できませんでした。");
2289
+ btn.disabled = false;
2290
+ return;
2291
+ }
2292
+ // **同じタブで遷移する。** 別タブだと、承認後の「接続しました」を見た人が
2293
+ // 元のタブに戻る動線を自分で見つけることになる
2294
+ location.href = j.url;
2295
+ } catch (e) {
2296
+ msg.className = "ds-toggle-msg is-error";
2297
+ msg.textContent = "✗ 接続を開始できませんでした。時間をおいて試してください。";
2298
+ btn.disabled = false;
2299
+ }
2300
+ });
2301
+ wrap.appendChild(el("p", {
2302
+ class: "ds-group-note",
2303
+ text: "ボタンを押すと Slack の承認画面が開きます。承認できるのは、その Slack の管理者だけです"
2304
+ + "(お客様の Slack なら、お客様の管理者にお願いしてください)。"
2305
+ + "お願いする権限は読み取りだけで、Cortex から投稿することはありません。",
2306
+ }));
2307
+ wrap.appendChild(btn);
2308
+ wrap.appendChild(msg);
2309
+ return wrap;
2310
+ }
2311
+
2312
+ /** 台帳から、そのワークスペースの接続状態を引く(無ければ null=断定しない) */
2313
+ function tokenStateOf(workspaces, teamId) {
2314
+ var list = workspaces || [];
2315
+ for (var i = 0; i < list.length; i++) {
2316
+ if (list[i] && list[i].teamId === teamId) return list[i].tokenState || null;
2317
+ }
2318
+ return null;
2319
+ }
2320
+
2321
+ /**
2322
+ * 「組織が分かりません」の行を、サーバに判定し直してもらう(2026-08-20)。
2323
+ *
2324
+ * **1回の表示につき1度だけ。** 組が複数あっても、呼ぶのは1回。
2325
+ *
2326
+ * ## 結果をその場で反映しない理由
2327
+ *
2328
+ * この画面が読んでいるのは**夜間に作られた実測ファイル**で、
2329
+ * 設定ファイルを直しても表示はすぐ変わらない。**嘘をつくよりは待たせる**——
2330
+ * 「反映しました」とだけ言い、表示が変わる時期を書く。
2331
+ * (設定を変える他の操作も同じ約束で「次回の Gold 昇格から反映されます」と言っている)
2332
+ */
2333
+ var orgResolveDone = false;
2334
+ function requestOrgResolve() {
2335
+ if (orgResolveDone) return;
2336
+ orgResolveDone = true;
2337
+ var intake = SITE.intake;
2338
+ if (!intake) return;
2339
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
2340
+ // **描画の途中で投げない。** ここは行を組み立てている最中に呼ばれるので、
2341
+ // 例外が出ると**画面そのものが描かれない**(fetch が無い環境で実際にそうなった)。
2342
+ // 判定し直しは「できたら直る」性質のもので、失敗しても行は
2343
+ // 「組織が分かりません」のまま残る——画面を落としてまでやることではない。
2344
+ //
2345
+ // **守りは二重(この行と下の try/catch)。** 片方だけ消しても
2346
+ // もう片方が受けるので変異検証では赤にならないが、**両方消すと6件赤**になる
2347
+ // (実際に当てて確かめた)。二重なのは、投げ方が2通りあるため——
2348
+ // fetch が存在しない(ReferenceError)と、呼び出しが投げる
2349
+ if (typeof fetch !== "function") return;
2350
+ try {
2351
+ fetch(pfx + "op=slack-resolve&t=" + encodeURIComponent(intake.token), {
2352
+ method: "POST",
2353
+ headers: { "content-type": "application/json" },
2354
+ body: "{}",
2355
+ })
2356
+ .then(function (res) { return res.ok ? res.json() : null; })
2357
+ .then(function (j) {
2358
+ if (!j || !j.resolved) return;
2359
+ // **表示は次の更新まで変わらない。** それを書かないと、
2360
+ // 「反映したのに何も変わらない」と読まれてもう一度押すことになる
2361
+ var note = document.querySelector(".ds-org-resolved");
2362
+ if (!note) return;
2363
+ note.textContent = "✓ " + j.resolved + "件の所属を反映しました(この一覧の表示は次回の更新・毎晩22時ごろから正しくなります)。";
2364
+ })
2365
+ .catch(function () {
2366
+ // **黙って諦める。** 直せなかっただけで、行は「組織が分かりません」のまま残る。
2367
+ // 利用者にできることは変わらないので、失敗を出しても迷わせるだけ
2368
+ });
2369
+ } catch (e) {
2370
+ // 同上。呼び出しそのものが投げても画面は描く
2371
+ }
2372
+ }
2373
+
2374
+ /**
2375
+ * Slack のチャンネルを**平らに**並べる(2026-08-20)。
2376
+ *
2377
+ * ## なぜ折りたたみをやめたか
2378
+ *
2379
+ * オーナー指摘:
2380
+ * > それぞれのワークスペースごとにセクション分けしてるけど、普通に並べて、
2381
+ * > 他の接続OKとかの横にどのワークスペースを出すだけでいい。
2382
+ * > 開いて閉じてみたいな感じで並ぶ必要があんまりわかんない。なんか複雑になっちゃってる。
2383
+ *
2384
+ * **並び順は組織ごと**(社内 → 顧客 → 分かりません)にするが、**箱で囲まない**。
2385
+ * どの組織かは**行のラベル**が示す(同じチャンネル名が2つの組織に並びうるので、
2386
+ * ラベル自体は必要)。
2387
+ *
2388
+ * ## 「組織が分かりません」の案内だけは別
2389
+ *
2390
+ * 3つの原因と、招待コマンド・接続ボタン・登録の解除が要る。**行ごとに繰り返すと
2391
+ * 一覧が案内で埋まる**ので、その並びの手前に1つだけ置く。
2392
+ */
2393
+ function slackRows(items, meta) {
2394
+ var ws = slackWorkspaces();
2395
+ var out = [];
2396
+ groupSlackByOrg(items, ws).forEach(function (g) {
2397
+ var cards = g.items.map(function (x) { return extSourceCard(x, meta); });
2398
+ if (g.unknown) {
2399
+ // **開いたときに1度だけ、判定し直しをサーバへ頼む。**
2400
+ // 承認が通っても、誰かが判定し直さなければ設定ファイルは古いまま。
2401
+ // OAuth の戻り先は固定ページ(この画面には戻らない)ので、
2402
+ // **利用者が操作タブを開き直したこの瞬間**が唯一の合図になる
2403
+ requestOrgResolve();
2404
+ // **案内が行を抱える(2026-08-20・オーナー指摘)。**
2405
+ // 「次のチャンネル」と書いても、囲われていないと**どれを指すのか分からない**。
2406
+ // 折りたたみは付けない——開閉させたいのではなく、**範囲を示したい**
2407
+ out.push(orgNoteBox(unknownOrgNodes(), cards));
2408
+ return;
2409
+ }
2410
+ if (g.key !== "__internal__") {
2411
+ var st = tokenStateOf(ws, g.key);
2412
+ // **接続が切れたときの導線を必ず残す。** それまでは接続カードの中の一覧に
2413
+ // 出していた。カードごと消したので、ここへ移す——移さないと
2414
+ // **接続が切れたことに気づけず、直す手段も画面から消える**
2415
+ if (st === "revoked" || st === "missing") {
2416
+ out.push(orgNoteBox(brokenOrgNodes(g.title, st), cards));
2417
+ return;
2418
+ }
2419
+ }
2420
+ cards.forEach(function (n) { out.push(n); });
2421
+ });
2422
+ return out;
2423
+ }
2424
+
2425
+ /**
2426
+ * 案内と、その案内が指す行を1つの箱にまとめる(2026-08-20)。
2427
+ *
2428
+ * **囲わないと「次のチャンネル」がどれを指すのか分からない**(オーナー指摘)。
2429
+ * **折りたたみは付けない**——開閉させたいのではなく、範囲を示したいだけ。
2430
+ */
2431
+ function orgNoteBox(noteNodes, cards) {
2432
+ var box = el("div", { class: "ds-orgnote" });
2433
+ noteNodes.forEach(function (n) { if (n) box.appendChild(n); });
2434
+ var list = el("div", { class: "ds-orgnote-rows" });
2435
+ cards.forEach(function (c) { list.appendChild(c); });
2436
+ box.appendChild(list);
2437
+ return box;
2438
+ }
2439
+
2440
+ /** 「組織不明」の案内。**原因を1つに絞らない** */
2441
+ function unknownOrgNodes() {
2442
+ // **接続だけを勧めると、招待すれば済む人に「直らない処方」を出す。**
2443
+ // 非公開チャンネルは、同じ Slack でも招待前は「存在しない」と返る
2444
+ var out = [];
2445
+ // **「読めませんでした」では、行の「Slackで招待してください」と区別が付かない**
2446
+ //(オーナー指摘・2026-08-20)。どちらも読めていないが、**分かっていることが違う**:
2447
+ // 行に「招待してください」… クラスメソッドのチャンネルだと**分かっている**。招待だけが足りない
2448
+ // この案内 … **どのSlackのものかすら分からない**(どの鍵でも届かなかった)
2449
+ // 状態の名前を「分かりません」に揃える
2450
+ out.push(el("div", { class: "ds-orgnote-title", text: "⚠️ 組織不明のチャンネル" }));
2451
+ out.push(el("p", {
2452
+ class: "ds-group-note",
2453
+ text: "Cortex が持っているどの鍵でも、このチャンネルに届きませんでした。次のどれかです。",
2454
+ }));
2455
+ var ul = el("ul", { class: "ds-group-note" });
2456
+ // **公開との違いをここで言い切る。** 「同じ未招待なのに、なぜ上の行は
2457
+ // 組織が分かっていて、こちらは分からないのか」が読み取れないと、
2458
+ // 利用者は同じ状態が2通りに見えることに戸惑う
2459
+ ul.appendChild(el("li", { text: "非公開チャンネルで、まだ Cortex を招待していない → 下のコマンドを Slack のそのチャンネルに貼ってください" }));
2460
+ ul.appendChild(el("li", { text: "お客様の Slack にあり、まだつながっていない → 下のボタンから接続します" }));
2461
+ ul.appendChild(el("li", { text: "チャンネルがアーカイブ/削除された、URLが違う → 「登録を解除」を押してください" }));
2462
+ out.push(ul);
2463
+ out.push(copyRow("/invite @Cortex"));
2464
+ out.push(slackConnectButton());
2465
+ // 判定し直しの結果を出す場所(何も起きなければ空のまま)
2466
+ out.push(el("p", { class: "ds-group-note ds-org-resolved" }));
2467
+ return out;
2468
+ }
2469
+
2470
+ /** 接続が切れた/未接続のワークスペースの案内 */
2471
+ function brokenOrgNodes(title, state) {
2472
+ var out = [];
2473
+ out.push(el("div", {
2474
+ class: "ds-orgnote-title",
2475
+ text: state === "revoked" ? "⚠️ " + title + " との接続が切れています" : "⚠️ " + title + " はまだ接続されていません",
2476
+ }));
2477
+ out.push(el("p", {
2478
+ class: "ds-group-note",
2479
+ // **revoked と missing を分ける。** 前者は「接続し直して」、後者は「まだ完了していない」で
2480
+ // 次の一手が違う。unknown には何も出さない——繋がっているのに
2481
+ // 「接続し直して」と言う方が、黙っているより悪い
2482
+ text: state === "revoked"
2483
+ ? "Slack 側で Cortex のアプリが削除されたか、接続が無効になっています。もう一度接続してください。"
2484
+ : "このワークスペースのチャンネルが登録されていますが、接続が見つかりません。もう一度接続してください。",
2485
+ }));
2486
+ out.push(slackConnectButton());
2487
+ return out;
2488
+ }
2489
+
2490
+
2130
2491
  var DS_KIND_ICON = {
2131
2492
  "課題管理": "📋",
2132
2493
  "会議": "🗒️",
@@ -2283,7 +2644,7 @@ ${capabilityState.toString()}
2283
2644
  // **何が読み取られるのかを必ず書く。** 一覧に並ぶものと読み取られるものの関係は、
2284
2645
  // 書いていないと分からない(チャットは「載っていれば全部」、開発はリポジトリ単位でOFFできる)
2285
2646
  if (cap === "チャット") {
2286
- m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいるチャンネルは、例外なく全て毎晩の Gold 昇格が読み取り、顧客も見る Decision・用語集の材料になります。読み取りを止めるには「登録を解除」してください。" }));
2647
+ m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいるチャンネルは、例外なく全て毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。読み取りを止めるには「登録を解除」してください。" }));
2287
2648
  } else if (cap === "開発") {
2288
2649
  m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいるリポジトリの Issues を毎晩の Gold 昇格が読み取ります。リポジトリ単位で読み取りを止められます(止めたものは読み取られません)。" }));
2289
2650
  }
@@ -2517,6 +2878,14 @@ ${capabilityState.toString()}
2517
2878
  var UNIT = { "slack": "チャンネル", "github-issues": "リポジトリ", "github-discussions": "リポジトリ" };
2518
2879
  groups.forEach(function (g) {
2519
2880
  var meta = DS_TYPE[g.type] || { icon: "🔗", label: g.type || "その他" };
2881
+ // **Slack は所属する組織ごとに分ける(2026-08-20)。**
2882
+ // 追加のときにワークスペースを選ばせるのをやめたので、
2883
+ // 「どの組織のものか」「その組織とつながっているか」を画面が言う。
2884
+ // 分け方は groupSlackByOrg(純関数)が全部持っている
2885
+ if (g.type === "slack") {
2886
+ slackRows(g.items, meta).forEach(function (n) { pushCap("チャット", n); });
2887
+ return;
2888
+ }
2520
2889
  var cards = el("div", { class: "ds-list" });
2521
2890
  g.items.forEach(function (s) { cards.appendChild(extSourceCard(s, meta)); });
2522
2891
  if (g.items.length >= 4) {
@@ -2547,9 +2916,18 @@ ${capabilityState.toString()}
2547
2916
  // **追加フォームはグループの外に置く。** グループは0件だと描かれず、4件以上だと
2548
2917
  // 畳まれる(既定は閉)。中に入れると**新規案件(0件)=一番追加したい場面**で消える。
2549
2918
  var adders = renderExternalAdders();
2550
- // **接続の入口はチャンネル追加より前に置く。** 顧客ワークスペースを繋いでいない状態で
2551
- // 先に追加フォームを見せると、貼れるのに読まれないチャンネルを登録することになる
2552
- pushCap("チャット", slackConnectBox());
2919
+ // **接続の入口は「読み取れていない」組にだけ出す(2026-08-20)。**
2920
+ //
2921
+ // それまでは「お客様の Slack ワークスペースを接続」という独立した節を
2922
+ // チャンネル追加より前に置いていた。オーナーの受け入れテストで
2923
+ // 「モーダルを開いても何から始めればいいか分からない」と指摘された——
2924
+ // 接続が先なのか追加が先なのか、そもそも自分に関係あるのかが読み取れない。
2925
+ //
2926
+ // **接続は「準備」ではなく、必要になった行から始まる。**
2927
+ // 当時の警告コメント(「箱そのものは消さない。0件の案件で入口が消える」)は
2928
+ // **新しい形が別の解き方をしている**——チャンネルを追加すれば
2929
+ // 読み取れていない組として現れ、そこに接続ボタンが出る。
2930
+ // 0件なら繋ぐ相手もまだ無いので、入口が要る場面にならない。
2553
2931
  pushCap("チャット", adders.querySelector('[data-adder="slack"]'));
2554
2932
  // **組織ごとの接続状況を、開発の一番上に置く。** トークンは組織ごとにしか
2555
2933
  // 効かない(Fine-grained PAT も GitHub App のインストールトークンも1組織分)ので、
@@ -2979,7 +3357,7 @@ ${capabilityState.toString()}
2979
3357
 
2980
3358
  // 注記は全能力の後ろに1つ。**何が Gold の材料になるのかを1文で言う。**
2981
3359
  if (anyCap) {
2982
- sec.appendChild(el("p", { class: "ds-note", text: "登録されている Slack チャンネルと GitHub リポジトリは、毎晩の Gold 昇格が読み取り、顧客も見る Decision・用語集の材料になります。チャンネルは登録を解除すると、リポジトリは読み取りを止めると、以後は読み取られません。" }));
3360
+ sec.appendChild(el("p", { class: "ds-note", text: "登録されている Slack チャンネルと GitHub リポジトリは、毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。チャンネルは登録を解除すると、リポジトリは読み取りを止めると、以後は読み取られません。" }));
2983
3361
  }
2984
3362
 
2985
3363
  if (pipes.length) {
@@ -3141,7 +3519,7 @@ ${capabilityState.toString()}
3141
3519
  // 長い説明は落とし、次の行動の1行だけ残す
3142
3520
  return [p(isInternalSlack(s)
3143
3521
  ? "cortex の担当者にご連絡ください。"
3144
- : "下の「お客様の Slack ワークスペースを接続」から接続してください。")];
3522
+ : "Slack ワークスペースに接続する」から接続してください。")];
3145
3523
  }
3146
3524
  if (r.gate === "unreachable") {
3147
3525
  return [p("チャンネルが見つからないか、アーカイブされています。不要なら「登録を解除」してください。")];
@@ -3210,14 +3588,24 @@ ${capabilityState.toString()}
3210
3588
  * (押すたびにコミットが増えるのを避ける)。表示名だけが例外で、そちらは
3211
3589
  * Slack の実名が正なので自動で揃える(autoAlignName)。
3212
3590
  */
3591
+ /**
3592
+ * 接続の確認。
3593
+ *
3594
+ * **ボタンは行の中(「登録を解除」の隣)に置く(2026-08-20)。**
3595
+ * それまでは行の下に独立した段を作っており、**1行が2段になって一覧が長かった**
3596
+ * (オーナー指摘「レコードをそれぞれもうちょっと短くできるよね」)。
3597
+ * 押した結果の詳細(測った時刻・次の一手)は下に出す——**押すまでは空**なので、
3598
+ * 一覧を眺めているだけの人には行が伸びない。
3599
+ *
3600
+ * @returns { btn, detail } btn は行に、detail は行の下に置く
3601
+ */
3213
3602
  function probeSection(s, card, gateEl) {
3603
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "接続を確認" });
3214
3604
  var wrap = el("div", { class: "ds-probe" });
3215
3605
  var head = el("div", { class: "ds-probe-head" });
3216
- var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "接続を確認" });
3217
3606
  var msg = el("span", { class: "ds-toggle-msg" });
3218
3607
  var at = el("span", { class: "ds-probe-at" });
3219
3608
  var body = el("div");
3220
- head.appendChild(btn);
3221
3609
  head.appendChild(msg);
3222
3610
  head.appendChild(at);
3223
3611
  wrap.appendChild(head);
@@ -3227,6 +3615,10 @@ ${capabilityState.toString()}
3227
3615
  var intake = SITE.intake;
3228
3616
  if (!intake) return;
3229
3617
  btn.disabled = true;
3618
+ // **押されて初めて枠を出す(2026-08-20・オーナー指摘)。**
3619
+ // それまでは中身が空でも点線だけが全行に出ており、**何も意味していなかった**。
3620
+ // 押した結果があるときだけ、行と結果を分ける線になる
3621
+ wrap.className = "ds-probe is-shown";
3230
3622
  msg.className = "ds-toggle-msg";
3231
3623
  msg.textContent = "確認中…";
3232
3624
  at.textContent = "";
@@ -3272,7 +3664,7 @@ ${capabilityState.toString()}
3272
3664
  }
3273
3665
  btn.disabled = false;
3274
3666
  });
3275
- return wrap;
3667
+ return { btn: btn, detail: wrap };
3276
3668
  }
3277
3669
 
3278
3670
  function removeChannelButton(s, card) {
@@ -3427,11 +3819,11 @@ ${capabilityState.toString()}
3427
3819
  ]);
3428
3820
  if (!editable) return row;
3429
3821
  var btn = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: "この接続先を外す" });
3430
- btn.addEventListener("click", function () {
3822
+ btn.addEventListener("click", async function () {
3431
3823
  // **最後の1件かどうかは「押した時点」で数える。** 描画時の件数で決めると、
3432
3824
  // 2件を1件ずつ外したとき最後の1件で確認が出ない(Figma で踏んだのと同じ)
3433
3825
  var last = targets.length === 1;
3434
- if (!confirmDanger(removeTargetConfirmLines(t, last))) { msg.textContent = ""; return; }
3826
+ if (!(await confirmDanger(removeTargetConfirmLines(t, last), { returnTo: btn }))) { msg.textContent = ""; return; }
3435
3827
  var payload = {
3436
3828
  op: "backlog-target-remove",
3437
3829
  // **projectKey では送らない。** body の projectKey は案件キー(cortex の
@@ -3487,25 +3879,7 @@ ${capabilityState.toString()}
3487
3879
  title: "+ 接続先を追加",
3488
3880
  placeholder: "https://<スペース>.backlog.jp/projects/<プロジェクトキー>",
3489
3881
  requiredNote: "BacklogのURLを入力してください(プロジェクトキーが入った形。数字だけのURLは受け付けません)。",
3490
- hint: "Backlog でこの案件のプロジェクトを開き、ブラウザのURLをそのまま貼ってください(課題の画面のURLでも構いません)。APIキーは下の「Backlog のAPIキー」から別に登録します。",
3491
- confirmLines: function (v) {
3492
- // **解析した結果を見せる。** 貼ったURLをそのまま出すと、どのプロジェクトを
3493
- // 指すことになるかが確定前に分からない。実際 /projects/PJ-CORTEX は
3494
- // ハイフンで切れて PJ と解釈される(黙って別プロジェクトを指す)
3495
- var t = backlogTargetOf(v);
3496
- return [
3497
- "この案件が同期する Backlog プロジェクトを追加します。",
3498
- "",
3499
- t ? t.domain + " / " + t.projectKey : v,
3500
- "",
3501
- "このプロジェクトの課題・ドキュメント・Wiki が、次の同期から取り込まれます。",
3502
- "貼ったURLがこの案件のものか、もう一度確認してください。",
3503
- "",
3504
- "※ 増えるのは同期先だけです。ビューアからの起票先は変わりません。リアルタイム同期を使う場合は、追加したプロジェクトの Backlog 側にも案件専用URLの登録が要ります。",
3505
- "",
3506
- "追加しますか?",
3507
- ];
3508
- },
3882
+ hint: "Backlog でこの案件のプロジェクトを開き、ブラウザのURLをそのまま貼ってください(課題の画面のURLでも構いません)。APIキーは下の「Backlog のAPIキー」から別に登録します。増えるのは同期先だけで、ビューアからの起票先は変わりません。リアルタイム同期を使う場合は、追加したプロジェクトの Backlog 側にも案件専用URLの登録が要ります。",
3509
3883
  build: function (url) {
3510
3884
  return { op: "backlog-target-add", url: url, baseGeneration: targetsGenerationOf(targets) };
3511
3885
  },
@@ -3646,17 +4020,6 @@ ${capabilityState.toString()}
3646
4020
  namePlaceholder: "メモ(任意)",
3647
4021
  requiredNote: "フォルダのURLを入力してください。",
3648
4022
  hint: "このフォルダの中身が 共有資料 にコピーされ、Markdown 化されます。取り込み用アカウントをそのフォルダの閲覧者に招待しておいてください(招待が無いと読めません)。",
3649
- confirmLines: function (v) {
3650
- return [
3651
- "このフォルダの中身を、顧客も見るリポジトリにコピーします。",
3652
- "",
3653
- "貼ったURLがこの案件のものか、もう一度確認してください。",
3654
- "",
3655
- v,
3656
- "",
3657
- "追加しますか?",
3658
- ];
3659
- },
3660
4023
  build: function (url, name) { return { op: "add-drive-folder", url: url, name: name }; },
3661
4024
  }));
3662
4025
  // **ON/OFFのトグルは出さない。** フォルダを追加した時点で取り込みは始まる
@@ -3755,17 +4118,6 @@ ${capabilityState.toString()}
3755
4118
  namePlaceholder: "メモ(任意)",
3756
4119
  requiredNote: "FigmaのURLを入力してください。",
3757
4120
  hint: "このファイルの画面インベントリ(画面名・Figmaへのリンク)が毎晩同期されます。画像は取り込まれません。",
3758
- confirmLines: function (v) {
3759
- return [
3760
- "このFigmaファイルの画面名とFigmaへのリンクを、顧客も見るリポジトリに取り込みます(画像は取り込みません)。",
3761
- "",
3762
- "貼ったURLがこの案件のものか、もう一度確認してください。",
3763
- "",
3764
- v,
3765
- "",
3766
- "追加しますか?",
3767
- ];
3768
- },
3769
4121
  build: function (url, name) { return { op: "add-figma-file", url: url, name: name }; },
3770
4122
  }));
3771
4123
  used = true;
@@ -3774,156 +4126,6 @@ ${capabilityState.toString()}
3774
4126
  return used ? box : null;
3775
4127
  }
3776
4128
 
3777
- /**
3778
- * 会議の取り込みの案内。**操作ではなく条件を示す。**
3779
- *
3780
- * 取り込まれるには2つとも要る:
3781
- * ① cortex-notetaker Bot が会議に招待されている(=取り込む意思。これが同意のゲート)
3782
- * ② 会議名が照合キーのどれかを含む(含まなければ、録れても案件に振り分けられない)
3783
- *
3784
- * ②の材料を画面が出さないと、招待したのに届かない理由が分からない。
3785
- */
3786
- function meetingHowTo() {
3787
- // **2つの条件がANDであることを、読み飛ばせない形で出す。** 散文で並べていたときに
3788
- // 「会議名は合っているのにBotを招待しておらず取り込まれなかった」事故が実際に起きた。
3789
- // 会議名の条件は上の行(ds-keys)が出すので、ここでは「上の語」と参照するに留める。
3790
- var wrap = el("div", { class: "sec-how" });
3791
- wrap.appendChild(el("p", { text: "次の2つが揃った会議だけが取り込まれます。" }));
3792
- var ul = el("ul", { class: "sec-how-list" });
3793
- ul.appendChild(el("li", { text: "cortex-notetaker Bot が招待されている(定例はシリーズに1回でOK)" }));
3794
- ul.appendChild(el("li", { text: "会議名に上の目印が入っている" }));
3795
- wrap.appendChild(ul);
3796
- wrap.appendChild(el("p", { text: "どちらか一方だけでは取り込まれません。" }));
3797
- return wrap;
3798
- }
3799
-
3800
- /* ---------------------------------------------------------------------------
3801
- * Slack ワークスペースの接続(#104)
3802
- *
3803
- * 顧客のワークスペースを読み始める日の入口。押す → サーバが署名つきの承認リンクを
3804
- * 作って返す → そこへ遷移 → 顧客が Slack で承認 → 別の Lambda が受け取って棚に鍵を入れる。
3805
- *
3806
- * **リンクを静的に焼き込まない。** 承認リンクには「この案件へ書き込んでよい」という
3807
- * 10分間有効な署名(state)が載っている。HTML に埋めると、配信物を読める人(=その案件の
3808
- * Basic 認証を通れる人)が期限のないリンクを手に入れることになるし、
3809
- * ビルド時点の state はどのみち期限切れで使えない。押した瞬間にサーバから貰う。
3810
- *
3811
- * **「トークン」という語を出さない。** 顧客に見せる画面なので、内部語ではなく
3812
- * 「接続する/読み取りたいチャンネルに招待する」で通す(内部語排除の原則の資格情報版)。
3813
- * ------------------------------------------------------------------------- */
3814
- function slackConnectBox() {
3815
- if (!SITE.intake) return null;
3816
- // **この項目を知らない古い fleet-status では何も出さない。** 「0件」と「知らない」を
3817
- // 同じ扱いにすると、engine が @v1 で止まっている案件に「まだ接続されていません」と
3818
- // 嘘を出すことになる(実際には社内ワークスペースで動いている)
3819
- var list = slackWorkspaces();
3820
- if (!list) return null;
3821
-
3822
- var box = el("div", { class: "ds-addform", "data-adder": "slack-workspace" }, [
3823
- el("div", { class: "ds-addform-title", text: "お客様の Slack ワークスペースを接続" }),
3824
- el("p", {
3825
- class: "sec-how",
3826
- text:
3827
- "お客様の Slack ワークスペースのチャンネルを読み取り対象にするための接続です。" +
3828
- "ボタンを押すと Slack の承認画面が開きます(承認するのはワークスペースの管理者です)。" +
3829
- "お願いする権限は読み取りだけで、Cortex から投稿することはありません。" +
3830
- "接続したあと、読み取りたいチャンネルにボットを招待し、この画面から追加してください。",
3831
- }),
3832
- // **「これは何のためのものでないか」を書く(2026-08-18 の不具合報告)。**
3833
- // 社内ワークスペースのチャンネルが読めていない人が、設定する場所を探して
3834
- // ここへ来る。押すと社内ワークスペースの承認画面が出て必ず失敗し、
3835
- // 「もう一度お試しください」に従ってまた押す——何度試しても直らない。
3836
- //
3837
- // **箱そのものは消さない。** 直上の renderExternalAdders のコメントが警告している
3838
- // のと同じ失敗で、「顧客チャンネルがある案件だけ出す」にすると
3839
- // **新規案件(0件)=一番繋ぎたい場面**でこの入口が消える。出したまま、
3840
- // 使う場面と使わない場面をここで言い切る
3841
- el("p", {
3842
- class: "sec-how ds-scope-note",
3843
- text:
3844
- "クラスメソッドの社内ワークスペース(Classmethod Group)のチャンネルには、この接続は使いません。" +
3845
- "社内のチャンネルは Cortex の中央ボットで読んでいるので、ここで接続しても読めるようにはなりません。" +
3846
- "社内のチャンネルが読めていない場合は、cortex の担当者にご連絡ください。",
3847
- }),
3848
- ]);
3849
-
3850
- // 接続済みの一覧。**0件でも「まだありません」と書く**——空白だと、
3851
- // 一覧が出ていないのか繋がっていないのかが読めない
3852
- var listBox = el("div", { class: "ds-ws-list" });
3853
- if (!list.length) {
3854
- listBox.appendChild(el("div", { class: "ds-ws-empty", text: "接続済みのワークスペースはまだありません。" }));
3855
- } else {
3856
- list.forEach(function (w) {
3857
- var row = el("div", { class: "ds-ws" }, [
3858
- el("span", { class: "ds-ws-name", text: w.name || w.teamId }),
3859
- el("span", {
3860
- class: "ds-ws-count",
3861
- text: (w.channels || 0) + "チャンネル",
3862
- }),
3863
- ]);
3864
- // **revoked と missing を分けて出す。** 前者は「接続し直してください」、
3865
- // 後者は「まだ接続が完了していない」で、次の一手が違う。
3866
- // unknown(確かめられなかった回)には何も出さない——繋がっているのに
3867
- // 「接続し直して」と言う方が、黙っているより悪い。
3868
- // 注: ここは埋め込みJSのテンプレートリテラル内。**コメントでもバッククォートを使わない**
3869
- if (w.tokenState === "revoked") {
3870
- row.appendChild(el("span", {
3871
- class: "ds-gate ds-gate-warn",
3872
- text: "⚠️ 接続が切れています",
3873
- title: "Slack 側で Cortex のアプリが削除されたか、接続が無効になっています。もう一度接続してください。",
3874
- }));
3875
- } else if (w.tokenState === "missing") {
3876
- row.appendChild(el("span", {
3877
- class: "ds-gate ds-gate-warn",
3878
- text: "⚠️ 未接続",
3879
- title: "このワークスペースのチャンネルが登録されていますが、接続が見つかりません。もう一度接続してください。",
3880
- }));
3881
- } else if (w.tokenState === "ok") {
3882
- row.appendChild(el("span", { class: "ds-gate ds-gate-ok", text: "● 接続OK" }));
3883
- }
3884
- listBox.appendChild(row);
3885
- });
3886
- }
3887
- box.appendChild(listBox);
3888
-
3889
- // **ボタン自身に「お客様の」と書く(2026-08-18 の不具合報告)。** 社内チャンネルが
3890
- // 読めていない人が押したのはこのボタンで、当時の文言は「Slack ワークスペースを接続」
3891
- // ——誰のワークスペースの話かが書かれておらず、唯一それらしいボタンに見えた
3892
- var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "お客様の Slack ワークスペースを接続" });
3893
- var msg = el("div", { class: "ds-toggle-msg" });
3894
- btn.addEventListener("click", async function () {
3895
- var intake = SITE.intake;
3896
- if (!intake) return;
3897
- btn.disabled = true;
3898
- msg.className = "ds-toggle-msg";
3899
- msg.textContent = "承認画面を準備しています…";
3900
- try {
3901
- var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
3902
- var res = await fetch(pfx + "op=slack-oauth-start&t=" + encodeURIComponent(intake.token), {
3903
- method: "POST",
3904
- headers: { "content-type": "application/json" },
3905
- body: "{}",
3906
- });
3907
- var j = await res.json().catch(function () { return {}; });
3908
- if (!res.ok || !j.url) {
3909
- // サーバの文言をそのまま出す(未設定のときは「準備中です」が返る)
3910
- msg.className = "ds-toggle-msg is-error";
3911
- msg.textContent = "✗ " + (j.message || "接続を開始できませんでした。");
3912
- btn.disabled = false;
3913
- return;
3914
- }
3915
- // **同じタブで遷移する。** 別タブだと、承認後の「接続しました」を見た人が
3916
- // 元のタブに戻る動線を自分で見つけることになる
3917
- location.href = j.url;
3918
- } catch (e) {
3919
- msg.className = "ds-toggle-msg is-error";
3920
- msg.textContent = "✗ 接続を開始できませんでした。時間をおいて試してください。";
3921
- btn.disabled = false;
3922
- }
3923
- });
3924
- box.appendChild(el("div", { class: "ds-toggle" }, [btn, msg]));
3925
- return box;
3926
- }
3927
4129
 
3928
4130
  /**
3929
4131
  * 外部ソースの追加フォーム(Slack・GitHub)。
@@ -3959,30 +4161,21 @@ ${capabilityState.toString()}
3959
4161
  // **招待の話を先頭に置く。** Slack の仕様で、ボットは**公開チャンネルでも**招待されるまで
3960
4162
  // 中身を1件も読めない(conversations.history が not_in_channel を返す・2026-08-16 実測+公式ドキュメント)。
3961
4163
  // そのあとに「追加=Gold 昇格の対象」を置く(手順と、その手順の重さ)。
3962
- hint: "先に Slack でそのチャンネルを開き、下のコマンドを貼って Cortex を招待してください。公開チャンネルでも招待が必要で、招待していないと中身を読めません。そのあと、チャンネル名を右クリック →「リンクをコピー」で出るURLを下に貼ります。追加したチャンネルは毎晩の Gold 昇格の対象になり、顧客も見る Decision・用語集の材料になります(読み取りを止めるには、追加した行の「登録を解除」を押します)。招待前に登録すると名前は「名前未取得」になるので、招待してから追加するのがおすすめです。",
3963
- copyText: "/invite @Cortex",
3964
- // 撤去した Gold トグルの確認文の趣旨をここへ移した。**追加が昇格の意思決定そのもの**
3965
- // なので、社内限定の会話が顧客可視の層に出てしまう話はここで伝えるしかない
3966
- // (実際にその事故が起きている)。あわせて他案件の取り違えも確認させる。
3967
- confirmLines: function (v) {
3968
- return [
3969
- "このチャンネルの内容を、顧客も見る Decision・用語集に蒸留するようになります。",
3970
- "",
3971
- v,
3972
- "",
3973
- "社内限定の会話が含まれるチャンネルでは、顧客に見せられない内容が出てしまう可能性があります。",
3974
- "顧客がこのチャンネルにいる(=すでに会話を見ている)場合にだけ追加してください。",
3975
- "",
3976
- "貼ったURLがこの案件のものか、もう一度確認してください。",
3977
- "",
3978
- // Slack の仕様(公開チャンネルでも招待が要る)を、最後にもう一度置く。
3979
- // 「追加したのに何も取り込まれない」の原因のほとんどがこれ。
3980
- "※ Slack でこのチャンネルに Cortex を招待していないと、中身は読めません。",
3981
- "※ 今夜の蒸留(22時ごろ)までに登録を解除すれば取り込まれません。一度生成された記録は自動では消えません。",
3982
- "",
3983
- "追加しますか?",
3984
- ];
3985
- },
4164
+ // **手順のリストにする(2026-08-20・オーナー指摘)。**
4165
+ // 一続きの文章だと、読む人が「自分がいま何をすべきか」を自分で切り出すことになる。
4166
+ //
4167
+ // **接続(お客様の Slack への導入)の説明もここに置く。** 独立した節を
4168
+ // 廃止したので、**繋ぎ方を知る場所はここしか無い**——
4169
+ // 「0件の案件で入口が消える」という過去の事故(renderExternalAdders
4170
+ // 直上コメント)を、この手順が受け止めている。
4171
+ hint: [
4172
+ // **コマンドは手順1の中に置く。** フォームの下に離すと、どの手順のものか
4173
+ // 対応が読めない(手で打たせない理由は copyRow のコメント)
4174
+ { text: "読み取りたいチャンネルで、下のコマンドを貼って Cortex を招待します。公開チャンネルでも招待が必要です。", node: copyRow("/invite @Cortex") },
4175
+ "下の欄に、そのチャンネルのリンクを貼って「追加」を押します(チャンネル名を右クリック →「リンクをコピー」)。",
4176
+ "「組織不明」と出たら、そこにある「Slack ワークスペースに接続する」から承認をもらいます。お客様の Slack のチャンネルは、この承認が済むまで読めません(承認できるのはその Slack の管理者です)。",
4177
+ "追加したチャンネルは毎晩の Gold 昇格の対象になり、Decision・用語集の材料になります。止めるときは、その行の「登録を解除」を押します。",
4178
+ ],
3986
4179
  // **選んだワークスペースを送る(#104)。** 空文字=社内ワークスペースなのでキーごと落とす
3987
4180
  // (サーバ側は「未宣言・null・空」を区別しない約束で、無いことで既定を表す)
3988
4181
  build: function (url, name, workspace) {
@@ -3990,7 +4183,29 @@ ${capabilityState.toString()}
3990
4183
  if (workspace) payload.workspace = workspace;
3991
4184
  return payload;
3992
4185
  },
3993
- workspacePicker: true,
4186
+ // **別案件でも使われていたときに出す確認(2026-08-20)。**
4187
+ //
4188
+ // **追加のときに出る唯一のモーダル。** 同日に「追加のたびの確認」を廃止した
4189
+ // (全部取り込む方針にし、顧客に見せるかは別途フラグで判定するため)ので、
4190
+ // ここで止まるのは「他の案件と重複したとき」だけ。
4191
+ // だから**何が起きるかもここで言う**(どちらの案件の顧客にも出ること)。
4192
+ //
4193
+ // **他案件の名前は出さない。** この画面の操作はその案件の Basic 認証を通れる人
4194
+ // (顧客を含む)なら誰でもできるので、案件名の列挙に使わせない。
4195
+ // **貼ったURLを本文に出す。** 「URLを確かめてください」と言うのに出ていなかった
4196
+ duplicateConfirmLines: function (value) {
4197
+ return [
4198
+ "このチャンネルは、別の案件でも追加されています。",
4199
+ "",
4200
+ "このまま追加すると、同じ会話が両方の案件の Decision・用語集に出ます。",
4201
+ "",
4202
+ "全体定例のように複数の案件に関係するチャンネルなら、これは正しい状態です。",
4203
+ "別のチャンネルと取り違えていないか、貼ったURLを確かめてください:",
4204
+ String(value || ""),
4205
+ "",
4206
+ "追加しますか?",
4207
+ ];
4208
+ },
3994
4209
  adder: "slack",
3995
4210
  }));
3996
4211
 
@@ -4001,18 +4216,6 @@ ${capabilityState.toString()}
4001
4216
  namePlaceholder: "メモ(任意)",
4002
4217
  requiredNote: "リポジトリを入力してください。",
4003
4218
  hint: "このリポジトリの Issues が、毎晩の Gold 昇格の材料になります。",
4004
- confirmLines: function (v) {
4005
- return [
4006
- "このリポジトリの Issues を、Gold 昇格の材料に追加します。",
4007
- "",
4008
- "追加した時点で読み取りの対象になります。",
4009
- "Issues の内容から、顧客も見る Decision・用語集が作られます。",
4010
- "",
4011
- v,
4012
- "",
4013
- "追加しますか?",
4014
- ];
4015
- },
4016
4219
  build: function (repo, name) { return { op: "add-github-source", repo: repo, name: name }; },
4017
4220
  adder: "github",
4018
4221
  }));
@@ -4054,6 +4257,49 @@ ${capabilityState.toString()}
4054
4257
  // 一覧が添えられていれば、断られていても先に画面を追いつかせる(#127)
4055
4258
  var raced = hooks && hooks.onTargets && Array.isArray(j.targets) ? hooks.onTargets(j.targets) : false;
4056
4259
  if (!res.ok) {
4260
+ // **確認すれば覆せる断り方(2026-08-20・Slack の重複だけ)。**
4261
+ //
4262
+ // 全体定例のように複数案件に関係するチャンネルは実在するのに、
4263
+ // 「取り違えていないか確認してください」で終わっていて**確認しても進めなかった**。
4264
+ //
4265
+ // **完全一致で見る。** j.confirmable を truthy で見ると、次に触る人が他の能力にも
4266
+ // 印を足したときに Drive でも確認が出て押せてしまう(サーバは通さないので実害は
4267
+ // 「押しても409」だが、「確認すれば通る」というUIの約束が壊れる)。
4268
+ //
4269
+ // **一度「確認済み」で送って、それでも断られたら二度目は出さない。**
4270
+ //
4271
+ // **理由を正確に書く(実装レビューの指摘)。** 「画面が先に新しくなった時間帯に
4272
+ // 押し続けることになる」は成立しない——古いサーバは印を返さないので、そもそも
4273
+ // 確認が出ない(canConfirm が最初から false)。このガードが本当に守るのは
4274
+ // **サーバを切り戻したとき**と**デプロイ中の版ズレ**。どちらも確認済みの再送だけが
4275
+ // 断られる状態で、そこで出し続けると「はい」を押し続けることになる。
4276
+ // 配る順番はサーバ→画面(これは順序の話とは別に必要)。
4277
+ var canConfirm =
4278
+ res.status === 409 &&
4279
+ j.confirmable === "duplicate" &&
4280
+ j.confirmableKind === "slack" &&
4281
+ hooks &&
4282
+ typeof hooks.onDuplicate === "function" &&
4283
+ payload.confirmDuplicate !== true;
4284
+ if (canConfirm) {
4285
+ // **ボタンは必ず先に戻す。** 確認の前に戻しておかないと、「いいえ」を押した人の
4286
+ // フォームが押せないまま残り、再読み込みするまで追加できなくなる
4287
+ btn.disabled = false;
4288
+ msg.className = "ds-toggle-msg";
4289
+ msg.textContent = "";
4290
+ if (await hooks.onDuplicate()) {
4291
+ // 「はい」——同じ内容に確認の印を付けて送り直す
4292
+ var again = {};
4293
+ for (var k in payload) if (Object.prototype.hasOwnProperty.call(payload, k)) again[k] = payload[k];
4294
+ again.confirmDuplicate = true;
4295
+ await sendConfig(again, msg, btn, onDone, hooks);
4296
+ return;
4297
+ }
4298
+ // 「いいえ」——サーバの文言を出して終わる(ボタンは戻してある)
4299
+ msg.className = "ds-toggle-msg is-error";
4300
+ msg.textContent = "✗ " + (j.message || "保存できませんでした。");
4301
+ return;
4302
+ }
4057
4303
  // サーバの文言をそのまま出す(貼り方の案内・取り違えの警告を含んでいる)。
4058
4304
  // **例外は「他の人が先に変えていた」だけ**——サーバは再読み込みを案内するが、
4059
4305
  // 画面は既に追いついているので、済んでいることをやらせる案内になる(#127)
@@ -4099,9 +4345,116 @@ ${capabilityState.toString()}
4099
4345
  * 書いていたが、取り違えはサーバ側が409で実際に弾く(findRefInOtherProjects)。
4100
4346
  * 実在する守りがあるのに脅し文を重ねると、操作者が萎縮するだけで安全は増えない。
4101
4347
  * 「貼ったURLがこの案件のものか確認してください」で足りる。
4348
+ *
4349
+ * **ただし Slack のチャンネルだけは、この前提が成り立たなくなった(2026-08-20)。**
4350
+ * 重複は 409 で断られたあと**確認すれば覆せる**ので、「サーバが実際に弾く」とは
4351
+ * 言えない。だから2枚目の確認(duplicateConfirmLines)では、重複そのものの事実と
4352
+ * 「両方の案件の顧客が見る場所に出る」ことをはっきり書いている——**そこは脅し文ではなく、
4353
+ * 押す前に知っておくべき事実**。1枚目(confirmLines)は変えていない。
4354
+ * ※ この region はテンプレート文字列の中なので、コメントにもバッククォートを書かない。
4102
4355
  */
4103
- function confirmDanger(lines) {
4104
- return window.confirm(lines.join(String.fromCharCode(10)));
4356
+ function confirmDanger(lines, opts) {
4357
+ return openConfirm(lines, opts);
4358
+ }
4359
+
4360
+ var confirmSeq = 0;
4361
+
4362
+ /**
4363
+ * 画面内の確認モーダル。**window.confirm の置き換え(2026-08-20)。**
4364
+ * 返すのは Promise で、中身は「進めてよいか」。
4365
+ *
4366
+ * ## なぜ makeModal を使わないのか(一番大事)
4367
+ *
4368
+ * **makeModal は開いているモーダルを畳む**(existing.__close(true))。
4369
+ * ところが確認が要る5か所は**すべて能力モーダルの中にいる**——
4370
+ * openCapabilityModal の items.forEach(m.modal.appendChild(n)) は
4371
+ * **ノードの移動**なので、利用者が「追加」を押した時点でフォームはモーダルの子。
4372
+ * makeModal で確認を開くと**親ごと消え、フォームが無くなる**。
4373
+ * しかも sendConfig の再送は掴んだままの msg/btn に書くので、
4374
+ * **追加は成功するのに画面には何も起きない**(利用者はもう一度押す)。
4375
+ *
4376
+ * makeModal の close() は refreshDataSources と能力モーダル専用の戻り先処理に
4377
+ * 絡んでいるため、フラグで分岐させると**あちらを壊すリスクをこちらの都合で背負う**。
4378
+ * 確認は「短い文と2つのボタン」しかないので、小さく別に持つ。
4379
+ *
4380
+ * ## なぜクラスを .modal-overlay にしないのか
4381
+ *
4382
+ * isModalOpen()・expandAllCaps・openCapModal がいずれも .modal-overlay を
4383
+ * 見ている。名前を共有すると「モーダルが開いている間は一覧を差し替えない」等の
4384
+ * 判定に確認が混ざる。見た目だけ CSS のカンマセレクタで揃える。
4385
+ *
4386
+ * ## なぜ Esc を capture で受けて止めるのか
4387
+ *
4388
+ * **makeModal は開いている間ずっと document に keydown を張ったままにする。**
4389
+ * inert は document レベルの keydown を止めない。素直に足すと
4390
+ * **親のリスナが先に登録済みなので先に発火し、Esc 一発で親モーダルが閉じる**
4391
+ * (=上と同じ破壊が Esc 経由で起きる)。capture で受けて stopPropagation すれば
4392
+ * 同一ノードの bubble リスナ=親の onKey には届かない。
4393
+ * オーバーレイ要素だけにリスナを張るのでは足りない(フォーカスが body にあると経路を通らない)。
4394
+ *
4395
+ * @param lines 表示する行(1行1段落)。先頭行が主文=読み上げの名前になる
4396
+ * @param opts { okLabel, returnTo }
4397
+ */
4398
+ function openConfirm(lines, opts) {
4399
+ return new Promise(function (resolve) {
4400
+ // **多重オープンはキャンセル扱いで無音。** 下地を inert にするので通常は起きない。
4401
+ // ここで警告を出しても操作者にできることが無いし、2枚重なって
4402
+ // 「下の1枚が residual で操作を受け付けない」状態のほうが害が大きい
4403
+ if (app.querySelector(".confirm-overlay")) { resolve(false); return; }
4404
+ var o = opts || {};
4405
+ var overlay = el("div", { class: "confirm-overlay" });
4406
+ // 確認は dialog ではなく alertdialog(「注意を促して答えを待つ」もの)
4407
+ var box = el("div", { class: "modal confirm-box", role: "alertdialog", "aria-modal": "true" });
4408
+ confirmSeq += 1;
4409
+ var textId = "confirm-line-" + confirmSeq;
4410
+ (lines || []).forEach(function (line, i) {
4411
+ var pnode = el("p", { class: "confirm-line", text: line });
4412
+ // 先頭行を読み上げの名前にする。見出しは付けない(主文を2回言うことになる)
4413
+ if (i === 0) { pnode.id = textId; box.setAttribute("aria-labelledby", textId); }
4414
+ box.appendChild(pnode);
4415
+ });
4416
+ var cancel = el("button", { class: "modal-cancel", type: "button", text: "キャンセル" });
4417
+ var ok = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: o.okLabel || "はい" });
4418
+ box.appendChild(el("div", { class: "modal-actions" }, [cancel, ok]));
4419
+ overlay.appendChild(box);
4420
+
4421
+ function finish(answer) {
4422
+ document.removeEventListener("keydown", onKey, true);
4423
+ // **順序が仕様。** ①自分をDOMから外す → ②inertを全部外す →
4424
+ // ③親が残っていれば掛け直す → ④それからフォーカスを戻す
4425
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
4426
+ setBackdropInert(overlay, false);
4427
+ // **親モーダルはまだ開いている。** 全部外したままにすると、その後ろの画面が
4428
+ // 触れるようになり Tab でタブ行へ抜けられる(別タブに移ると close() を
4429
+ // 通らないので keydown が1本残る)
4430
+ var rest = app.querySelector(".modal-overlay");
4431
+ if (rest) setBackdropInert(rest, true);
4432
+ // **戻し先は呼び出し側からもらう。** activeElement を記録する方式は破綻する——
4433
+ // sendConfig は冒頭で btn.disabled = true にしており、実ブラウザでは
4434
+ // フォーカス中の要素を disabled にした時点でフォーカスが body に落ちる。
4435
+ // disabled を戻してもフォーカスは戻らない。**jsdom は落とさないのでテストから見えない。**
4436
+ // Safari はクリックでボタンにフォーカスを当てないので、なおさら明示が要る
4437
+ var back = o.returnTo;
4438
+ if (back && typeof back.focus === "function" && app.contains(back)) back.focus();
4439
+ resolve(answer);
4440
+ }
4441
+ function onKey(e) {
4442
+ if (e.key !== "Escape") return;
4443
+ e.stopPropagation();
4444
+ e.preventDefault();
4445
+ finish(false);
4446
+ }
4447
+ document.addEventListener("keydown", onKey, true);
4448
+ // 外側クリックとキャンセルは「いいえ」。**確認は安全側に倒す**
4449
+ overlay.addEventListener("click", function (e) { if (e.target === overlay) finish(false); });
4450
+ cancel.addEventListener("click", function () { finish(false); });
4451
+ ok.addEventListener("click", function () { finish(true); });
4452
+
4453
+ app.appendChild(overlay);
4454
+ setBackdropInert(overlay, true);
4455
+ // **キャンセルに載せる**(showModal と同じ「押しても何も壊れないものへ」の原則)
4456
+ cancel.focus();
4457
+ });
4105
4458
  }
4106
4459
 
4107
4460
  /**
@@ -4119,10 +4472,10 @@ ${capabilityState.toString()}
4119
4472
  };
4120
4473
  paint();
4121
4474
 
4122
- btn.addEventListener("click", function () {
4475
+ btn.addEventListener("click", async function () {
4123
4476
  var next = !on;
4124
4477
  // 読み始める方向(危険)だけ確認する。止める方向は素通し
4125
- if (next && opts.confirmOn && !confirmDanger(opts.confirmOn)) { msg.textContent = ""; return; }
4478
+ if (next && opts.confirmOn && !(await confirmDanger(opts.confirmOn, { returnTo: btn }))) { msg.textContent = ""; return; }
4126
4479
  sendConfig(opts.build(next), msg, btn, function () {
4127
4480
  // **押した内容をボタンに反映し、逆向きの操作を残す。**
4128
4481
  // **カードごと作り直さない。** 上の msg に出した結果表示が消えてしまう
@@ -4149,8 +4502,8 @@ ${capabilityState.toString()}
4149
4502
  var btn = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: opts.label || "削除" });
4150
4503
  var msg = el("div", { class: "ds-toggle-msg" });
4151
4504
 
4152
- btn.addEventListener("click", function () {
4153
- if (!confirmDanger(opts.confirmLines())) { msg.textContent = ""; return; }
4505
+ btn.addEventListener("click", async function () {
4506
+ if (!(await confirmDanger(opts.confirmLines(), { returnTo: btn }))) { msg.textContent = ""; return; }
4154
4507
  sendConfig(opts.build(), msg, btn, function () {
4155
4508
  btn.disabled = true;
4156
4509
  if (opts.onDone) opts.onDone();
@@ -4169,11 +4522,36 @@ ${capabilityState.toString()}
4169
4522
  * 2つ目を足せると読めるため、そういう口だけ submitLabel で語を変える。押した結果の
4170
4523
  * 表示(+/追加済み)も同じ理由で語を揃える。
4171
4524
  *
4172
- * @param opts { title, placeholder, hint, needsName, confirmLines, build(values), submitLabel, hooks }
4525
+ * @param opts { title, placeholder, hint, needsName, build(values), submitLabel, hooks }
4173
4526
  * hooks … sendConfig へ渡す追い付き処理を返す関数(接続先の一覧・#127)。
4174
4527
  * **これがある口では、その場の控え(「追加済み・反映中」)を出さない**——
4175
4528
  * 応答で本物の一覧が増えるので、控えを併記すると同じものが2回出る
4176
4529
  */
4530
+ /**
4531
+ * フォームの説明。**配列なら手順のリストにする(2026-08-20)。**
4532
+ *
4533
+ * オーナー指摘: 「もっと手続き的に、結局ユーザー自身が何をすればいいのかを
4534
+ * わかるようにした方がいい。リスト形式でちゃんと並べてあげるとわかりやすい」。
4535
+ * 一続きの文章は、読む人が「自分がいま何をすべきか」を自分で切り出すことになる。
4536
+ *
4537
+ * **文字列の口は今までどおり。** 他の4フォーム(共有資料・Figma・Backlog・GitHub)は
4538
+ * まだ文章なので、そちらを壊さない。横展開でそれぞれ配列に変えていく。
4539
+ *
4540
+ * 各要素は文字列か、{ text, node } (手順の中に部品を挟むとき。招待コマンドなど)。
4541
+ */
4542
+ function hintNode(hint) {
4543
+ if (!hint) return null;
4544
+ if (!Array.isArray(hint)) return el("p", { class: "sec-how", text: hint });
4545
+ var ol = el("ol", { class: "sec-how sec-steps" });
4546
+ hint.forEach(function (step) {
4547
+ var text = typeof step === "string" ? step : (step && step.text) || "";
4548
+ var li = el("li", {}, [el("span", { text: text })]);
4549
+ if (step && step.node) li.appendChild(step.node);
4550
+ ol.appendChild(li);
4551
+ });
4552
+ return ol;
4553
+ }
4554
+
4177
4555
  function addForm(opts) {
4178
4556
  var main = el("input", { class: "sec-input", type: "text", placeholder: opts.placeholder, autocomplete: "off" });
4179
4557
  var name = opts.needsName
@@ -4188,20 +4566,18 @@ ${capabilityState.toString()}
4188
4566
  // ワークスペースの改名で変わるし、共有チャンネルでは貼った人のホストになる。
4189
4567
  // 推測して間違えると「追加できたのに読まれない」チャンネルができ、
4190
4568
  // 翌朝の接続状況で初めて分かる。人に選ばせる。
4191
- var wsList = opts.workspacePicker ? slackWorkspaces() || [] : [];
4569
+ // **ワークスペースの選択は廃止した(2026-08-20)。**
4570
+ // オーナーの受け入れテストで「意味が分からない」。実際、チャンネルIDを見ても
4571
+ // どのワークスペースのものかは分からないし、**間違えると追加はできるのに
4572
+ // 永久に読まれない**(気づくのは翌朝の接続状況)。
4573
+ // いまはサーバが、持っている鍵を順に Slack へ投げて決める。
4574
+ // **URLのホスト名から推測しない**という下の判断は今も正しい——推測ではなく実測にした。
4192
4575
  var wsSelect = null;
4193
- if (wsList.length) {
4194
- wsSelect = el("select", { class: "sec-input" });
4195
- wsSelect.appendChild(el("option", { value: "", text: "社内ワークスペース(クラスメソッド)" }));
4196
- wsList.forEach(function (w) {
4197
- wsSelect.appendChild(el("option", { value: w.teamId, text: w.name || w.teamId }));
4198
- });
4199
- }
4200
4576
  var submitLabel = opts.submitLabel || "追加";
4201
4577
  var btn = el("button", { class: "ds-toggle-btn", type: "button", text: submitLabel });
4202
4578
  var msg = el("div", { class: "ds-toggle-msg" });
4203
4579
 
4204
- btn.addEventListener("click", function () {
4580
+ btn.addEventListener("click", async function () {
4205
4581
  if (!main.value.trim()) {
4206
4582
  msg.className = "ds-toggle-msg is-error";
4207
4583
  msg.textContent = "⚠ " + (opts.requiredNote || "入力してください。");
@@ -4212,7 +4588,6 @@ ${capabilityState.toString()}
4212
4588
  msg.textContent = "⚠ 表示名を入力してください。";
4213
4589
  return;
4214
4590
  }
4215
- if (opts.confirmLines && !confirmDanger(opts.confirmLines(main.value.trim()))) { msg.textContent = ""; return; }
4216
4591
  var added = main.value.trim();
4217
4592
  var addedName = name ? name.value.trim() : "";
4218
4593
  var payload = opts.build(added, addedName, wsSelect ? wsSelect.value : "");
@@ -4233,7 +4608,28 @@ ${capabilityState.toString()}
4233
4608
  el("span", { class: "ds-pending-note", text: submitLabel + "済み・反映中" }),
4234
4609
  ]),
4235
4610
  );
4236
- }, opts.hooks ? opts.hooks() : null);
4611
+ }, (function () {
4612
+ // **既存の hooks(接続先の一覧が持つ onTargets)と、重複の確認を1つにまとめる。**
4613
+ // Slack の追加フォームは onTargets を持たないが、重複の確認は要る。
4614
+ // 逆に接続先の一覧(Backlog)は onTargets を持つが確認は要らない——
4615
+ // 共通に足すと、あちらの「他の人が先に変えていた」の判定と混ざる。
4616
+ var h = opts.hooks ? opts.hooks() : null;
4617
+ if (!opts.duplicateConfirmLines) return h;
4618
+ h = h || {};
4619
+ // **返り値が「進めてよいか」(Promise)。** 呼び出し側は必ず await すること——
4620
+ // await を落とすと Promise は truthy なので**確認なしで「はい」扱いになる**。
4621
+ // 2026-08-20 に window.confirm から画面内のモーダルに変えた(openConfirm)。
4622
+ // **戻し先に btn を渡す。** sendConfig が冒頭で btn.disabled = true にしており、
4623
+ // 実ブラウザではその時点でフォーカスが body に落ちるので、
4624
+ // activeElement を記録する方式では戻し先が消える
4625
+ h.onDuplicate = function () {
4626
+ return confirmDanger(opts.duplicateConfirmLines(main.value.trim()), {
4627
+ okLabel: "追加する",
4628
+ returnTo: btn,
4629
+ });
4630
+ };
4631
+ return h;
4632
+ })());
4237
4633
  });
4238
4634
 
4239
4635
  // このセッションで追加したものを並べる場所(リロードすると消える=正式な一覧は上に出る)
@@ -4241,7 +4637,7 @@ ${capabilityState.toString()}
4241
4637
 
4242
4638
  var box = el("div", opts.adder ? { class: "ds-addform", "data-adder": opts.adder } : { class: "ds-addform" }, [
4243
4639
  el("div", { class: "ds-addform-title", text: opts.title }),
4244
- opts.hint ? el("p", { class: "sec-how", text: opts.hint }) : null,
4640
+ hintNode(opts.hint),
4245
4641
  // 手順の中に「どこかへ貼る文字列」があるときは、**手で打たせない**。
4246
4642
  // 「/invite @Cortex」のような短い文字列でも、打ち間違えると黙って失敗する
4247
4643
  opts.copyText ? copyRow(opts.copyText) : null,
@@ -4313,15 +4709,35 @@ ${capabilityState.toString()}
4313
4709
  // 行ごとに「対象です」と書いても情報が増えない。github の除外は行のトグル
4314
4710
  // (「読み取りを始める/止める」)が向きで示す。
4315
4711
  if (s.notify === true) badges.appendChild(el("span", { class: "badge ds-notify", text: "通知先" }));
4316
- // **どのワークスペースのチャンネルかを出す(#104)。** 同じチャンネル名が
4317
- // 2つのワークスペースに並びうるので、これが無いと「どっちの #general か」が読めない。
4318
- // 宣言なし(社内ワークスペース)には何も出さない——これまでどおりの見た目を保つ
4319
- if (s.type === "slack" && s.workspace) {
4320
- badges.appendChild(el("span", {
4321
- class: "badge ds-workspace",
4322
- text: workspaceLabel(s.workspace),
4323
- title: "接続した Slack ワークスペースのチャンネルです(読み取り専用の接続なので、通知は届きません)。",
4324
- }));
4712
+ // **どのワークスペースのチャンネルかを、必ず行に出す(#104・2026-08-20 に社内も)。**
4713
+ //
4714
+ // 折りたたみの見出しでまとめるのをやめた(オーナー指摘「複雑になっちゃってる」)ので、
4715
+ // **どの組織かを言うのは行のラベルだけ**になった。社内に何も出さないと、
4716
+ // ラベルの無い行が「社内」なのか「まだ分かっていない」のかを読み分けられない。
4717
+ if (s.type === "slack") {
4718
+ // **順序は groupSlackByOrg と揃える(workspace が先)。**
4719
+ // 逆にすると、判定が済んで workspace が入った行に古い印が残っているとき、
4720
+ // **並びは「Hapio AI」なのにラベルだけ「組織が分かりません」**になる
4721
+ // ——同じ画面で2つの判定が食い違う、このプロダクトが何度も直した形
4722
+ if (s.workspace) {
4723
+ badges.appendChild(el("span", {
4724
+ class: "badge ds-workspace",
4725
+ text: workspaceLabel(s.workspace),
4726
+ title: "接続した Slack ワークスペースのチャンネルです(読み取り専用の接続なので、通知は届きません)。",
4727
+ }));
4728
+ } else if (s.orgUnknown === true) {
4729
+ badges.appendChild(el("span", {
4730
+ class: "badge ds-workspace ds-workspace-unknown",
4731
+ text: "組織不明",
4732
+ title: "どの Slack のチャンネルか、まだ分かっていません。上の案内をご覧ください。",
4733
+ }));
4734
+ } else {
4735
+ badges.appendChild(el("span", {
4736
+ class: "badge ds-workspace",
4737
+ text: "クラスメソッド",
4738
+ title: "クラスメソッドの社内 Slack のチャンネルです。",
4739
+ }));
4740
+ }
4325
4741
  }
4326
4742
  // 登録の由来(#118)。**「登録から削除」が出る/出ないの理由がこれ。** 導出されたリポは
4327
4743
  // 登録ファイルから消しても翌日また現れるので削除を出していないが、画面には
@@ -4372,7 +4788,7 @@ ${capabilityState.toString()}
4372
4788
  confirmOn: [
4373
4789
  "このリポジトリの Issues を、Gold 昇格の材料に戻します。",
4374
4790
  "",
4375
- "Issues の内容から、顧客も見る Decision・用語集が作られます。",
4791
+ "Issues の内容から、Decision・用語集が作られます。",
4376
4792
  "",
4377
4793
  s.ref,
4378
4794
  "",
@@ -4427,7 +4843,11 @@ ${capabilityState.toString()}
4427
4843
  // **origin === "explicit" でも出す。** 確認は読むだけで、どの登録ファイル由来かに
4428
4844
  // 関係なく効く(書き込みになる「表示名を更新する」だけが由来で出し分かれる)
4429
4845
  if (SITE.intake && s.type === "slack" && s.ref) {
4430
- card.appendChild(probeSection(s, card, gateEl));
4846
+ var probe = probeSection(s, card, gateEl);
4847
+ // **ボタンは行の中(「登録を解除」の隣)。** 独立した段を作ると1行が2段になる
4848
+ card.appendChild(probe.btn);
4849
+ // 押した結果の詳細だけを下に。**押すまでは空**なので行は伸びない
4850
+ card.appendChild(probe.detail);
4431
4851
  }
4432
4852
  return card;
4433
4853
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.84",
3
+ "version": "0.5.0",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,