@takagaki/cortex-decisions-viewer 0.12.17 → 0.12.19

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
@@ -3,9 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.UNSETTLED_LIMITS = exports.LIVE_CONFIRM_LIMITS = void 0;
4
4
  exports.renderSite = renderSite;
5
5
  exports.sanitizeRegisteredNow = sanitizeRegisteredNow;
6
+ exports.sanitizeHarnessSchedules = sanitizeHarnessSchedules;
7
+ exports.sanitizeHarnessShelf = sanitizeHarnessShelf;
6
8
  exports.sanitizeStateNow = sanitizeStateNow;
7
9
  exports.overlayStateOnFleet = overlayStateOnFleet;
8
10
  exports.chatRowsToAdd = chatRowsToAdd;
11
+ exports.gchatRowsToAdd = gchatRowsToAdd;
9
12
  exports.devRowsToAdd = devRowsToAdd;
10
13
  exports.designRowsToAdd = designRowsToAdd;
11
14
  exports.materialRowsToAdd = materialRowsToAdd;
@@ -96,7 +99,10 @@ function escapeHtml(s) {
96
99
  * 既存の GITHUB_ICON と同じ流儀。
97
100
  *
98
101
  * 出典: Slack/Figma/Google Meet/Google Drive は simple-icons(CC0)、
99
- * Backlog は ヌーラボ公式のロゴ配布(nulab.com/logo-guidelines/)。
102
+ * Backlog は ヌーラボ公式のロゴ配布(nulab.com/logo-guidelines/)、
103
+ * Google Chat は公式アイコン(2023-12 刷新版・Wikimedia Commons のSVG)。
104
+ * simple-icons の単色版は形は現行だが配色が公式6色と違い、他のフルカラー公式
105
+ * アイコンの並びから浮くため使わない(#153)。
100
106
  * 改変は「サイズ指定の付与」と「余分な属性の除去」のみ。配色と形は変えていない。
101
107
  */
102
108
  const ADAPTER_ICONS = {
@@ -106,6 +112,7 @@ const ADAPTER_ICONS = {
106
112
  "google-drive": `<svg width="36" height="36" aria-hidden="true" viewBox="0 0 256 229" xmlns="http://www.w3.org/2000/svg"><g> <path d="M19.3542312,196.033928 L30.644172,215.534816 C32.9900287,219.64014 36.3622164,222.86588 40.3210929,225.211737 C51.6602421,210.818376 59.5534225,199.772864 64.000634,192.075201 C68.5137119,184.263529 74.0609657,172.045039 80.6423954,155.41973 C62.9064315,153.085282 49.4659974,151.918058 40.3210929,151.918058 C31.545465,151.918058 18.1051007,153.085282 0,155.41973 C0,159.964996 1.17298825,164.510261 3.51893479,168.615586 L19.3542312,196.033928 Z" fill="#0066DA"></path> <path d="M215.681443,225.211737 C219.64032,222.86588 223.012507,219.64014 225.358364,215.534816 L230.050377,207.470615 L252.483511,168.615586 C254.829368,164.510261 256.002446,159.964996 256.002446,155.41973 C237.79254,153.085282 224.376613,151.918058 215.754667,151.918058 C206.488712,151.918058 193.072785,153.085282 175.506888,155.41973 C182.010479,172.136093 187.484394,184.354584 191.928633,192.075201 C196.412073,199.863919 204.329677,210.909431 215.681443,225.211737 Z" fill="#EA4335"></path> <path d="M128.001268,73.3111515 C141.121182,57.4655263 150.162898,45.2470011 155.126415,36.6555757 C159.123121,29.7376196 163.521739,18.6920726 168.322271,3.51893479 C164.363395,1.1729583 159.818129,0 155.126415,0 L100.876121,0 C96.1841079,0 91.638842,1.31958557 87.6799655,3.51893479 C93.7861943,20.9210065 98.9675428,33.3058067 103.224011,40.6733354 C107.927832,48.8151881 116.186918,59.6944602 128.001268,73.3111515 Z" fill="#00832D"></path> <path d="M175.360141,155.41973 L80.6420959,155.41973 L40.3210929,225.211737 C44.2799694,227.557893 48.8252352,228.730672 53.5172481,228.730672 L202.485288,228.730672 C207.177301,228.730672 211.722567,227.411146 215.681443,225.211737 L175.360141,155.41973 Z" fill="#2684FC"></path> <path d="M128.001268,73.3111515 L87.680265,3.51893479 C83.7213885,5.86488134 80.3489013,9.09044179 78.0030446,13.1960654 L3.51893479,142.223575 C1.17298825,146.329198 0,150.874464 0,155.41973 L80.6423954,155.41973 L128.001268,73.3111515 Z" fill="#00AC47"></path> <path d="M215.241501,77.7099697 L177.999492,13.1960654 C175.653635,9.09044179 172.281148,5.86488134 168.322271,3.51893479 L128.001268,73.3111515 L175.360141,155.41973 L255.855999,155.41973 C255.855999,150.874464 254.682921,146.329198 252.337064,142.223575 L215.241501,77.7099697 Z" fill="#FFBA00"></path> </g></svg>`,
107
113
  backlog: `<svg width="20" height="20" aria-hidden="true" viewBox="0 0 142 142" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Nulab-logos-" transform="translate(-1348.000000, -2619.000000)"> <g id="Group-7" transform="translate(1348.000000, 2619.000000)"> <path d="M112.518975,141.27641 L29.5477542,141.27641 C13.8226507,141.27641 0.955380933,128.389559 0.955380933,112.638806 L0.955380933,29.5308457 C0.955380933,13.7800933 13.8226507,0.893241768 29.5477542,0.893241768 L112.518975,0.893241768 C128.244079,0.893241768 141.109945,13.7800933 141.109945,29.5308457 L141.109945,112.638806 C141.109945,128.389559 128.244079,141.27641 112.518975,141.27641" id="Fill-16" fill="#42CE9F"></path> <path d="M60.1988625,61.3236616 C60.1441201,72.9746913 60.1146434,84.3121962 60.1441201,91.0958596 C70.2756738,90.5461368 75.2937266,88.482216 79.2632519,83.7132655 C82.0775724,80.3319784 82.686757,76.4586121 80.9799172,72.8073843 C78.7467083,68.0328101 72.1804284,62.9897012 60.1988625,61.3236616 M53.5357306,104.795345 L53.5357306,104.795345 C51.7348462,104.795345 50.0069517,104.075503 48.7380511,102.796097 L48.7352438,102.793285 C46.7434629,100.784196 46.7153899,98.8622754 46.6648584,95.3741368 C46.6395927,93.6504536 46.6241525,91.2083348 46.6171343,88.2896022 C46.6059051,83.1719784 46.6185379,76.0705725 46.6536292,67.1808101 C46.7153899,51.8855824 46.8262783,36.6381566 46.8262783,36.6381566 L60.3448422,36.7365725 C60.3195765,40.1150477 60.2957144,43.8295428 60.2718523,47.705721 C76.2678604,49.5531269 88.283114,56.4998794 93.2225623,67.065523 C97.1640146,75.4941368 95.8263351,84.9589289 89.6474629,92.3837012 C80.1166718,103.836493 66.5812641,104.795345 53.5357306,104.795345" id="Fill-18" fill="#FFFFFF"></path> </g> </g> </g> </svg>`,
108
114
  github: GITHUB_ICON,
115
+ "google-chat": `<svg width="36" height="36" aria-hidden="true" viewBox="0 0 311 320" xmlns="http://www.w3.org/2000/svg"><path fill="#0066da" d="M 76.37 0.51 L 76.38 76.98 L 0.00 76.96 L 0.00 20.77 Q 0.85 14.81 3.53 10.76 Q 10.14 0.74 22.75 0.67 Q 49.41 0.53 76.37 0.51 Z"/><path fill="#fbbc04" d="M 76.37 0.51 L 233.79 0.53 A 1.61 1.57 -26.7 0 1 234.71 0.82 L 235.08 1.09 Q 234.92 1.15 234.81 1.22 Q 234.64 1.31 234.64 1.50 L 234.62 77.01 Q 234.60 77.01 234.57 77.01 L 76.41 77.01 Q 76.40 77.00 76.38 76.98 L 76.37 0.51 Z"/><path fill="#ea4335" d="M 235.08 1.09 L 310.53 76.77 L 234.62 77.01 L 234.64 1.50 Q 234.64 1.31 234.81 1.22 Q 234.92 1.15 235.08 1.09 Z"/><path fill="#2684fc" d="M 0.00 76.96 L 76.38 76.98 Q 76.40 77.00 76.41 77.01 L 76.43 182.69 L 0.00 182.67 L 0.00 76.96 Z"/><path fill="#00ac47" d="M 310.53 76.77 L 311.00 77.11 L 311.00 239.01 Q 308.34 253.54 295.94 257.78 Q 291.52 259.30 282.91 259.28 Q 227.02 259.19 169.99 259.11 Q 161.71 259.10 153.19 259.23 Q 152.72 259.24 152.39 259.57 Q 124.49 287.34 96.39 315.59 C 93.52 318.48 90.27 320.09 86.15 319.48 Q 80.39 318.63 77.66 313.54 Q 76.51 311.38 76.49 305.66 Q 76.42 282.47 76.44 259.13 L 76.43 220.85 L 114.21 183.07 A 1.79 1.77 22.3 0 1 115.47 182.55 L 233.77 182.59 A 0.83 0.83 0.0 0 0 234.60 181.76 L 234.57 77.01 Q 234.60 77.01 234.62 77.01 L 310.53 76.77 Z"/><path fill="#00832d" d="M 76.43 182.69 L 76.43 220.85 L 76.44 259.13 Q 52.47 259.27 28.91 259.22 Q 19.09 259.20 14.76 257.68 Q 2.62 253.44 0.00 238.88 L 0.00 182.67 L 76.43 182.69 Z"/></svg>`,
109
116
  };
110
117
  /**
111
118
  * **アダプタのカタログ(能力→この製品に存在するアダプタ)。#125②**
@@ -119,7 +126,7 @@ const ADAPTER_ICONS = {
119
126
  * アダプタが存在しないので、名前を出した瞬間「対応している」と読まれる。
120
127
  * 状態を出す場所すら無いのに名前だけ出るのが #124 の症状そのもの。
121
128
  *
122
- * **値は配列。** 今日はどの能力も1個だが、「能力=1アダプタ」を焼き込むと
129
+ * **値は配列。** チャットが2個になった(#153)。「能力=1アダプタ」を焼き込むと
123
130
  * #107 / #127(複数アダプタ・複数 Backlog)で読み手の書き換えが要る。
124
131
  * ロゴは上の ADAPTER_ICONS を、表示名は CM_TOOL_LABEL を引く(キーはここの値)。
125
132
  */
@@ -128,8 +135,14 @@ const ADAPTER_CATALOG = {
128
135
  "会議": ["google-meet"],
129
136
  "共有資料": ["google-drive"],
130
137
  "デザイン": ["figma"],
131
- "チャット": ["slack"],
138
+ // チャットは2アダプタ(#153)。**先頭が能力アンカー ds-チャット の主**になるので、
139
+ // Slack を先頭に保つ(入れ替えると接続マップの飛び先が Google Chat 側へ変わる)
140
+ "チャット": ["slack", "google-chat"],
132
141
  "開発": ["github"],
142
+ // **アダプタを持たない設定タイル。** 外部ツールとの接続ではなく Cortex 自身の設定なので、
143
+ // 繋ぐ相手が無い。値を読む相手も居ない(CAP_ORDER のループで special-case が先に立ち、
144
+ // renderAdapterTile へは行かない)——ここに載せているのは能力の並びを1箇所で決めるため
145
+ "ハーネス連携": [],
133
146
  };
134
147
  /**
135
148
  * 導出された状態 → 画面に出す文言。**マップとタイルで同じ語を使う。**
@@ -173,6 +186,7 @@ const CM_TOOL_LABEL = {
173
186
  figma: "Figma",
174
187
  slack: "Slack",
175
188
  github: "GitHub",
189
+ "google-chat": "Google Chat",
176
190
  };
177
191
  /**
178
192
  * 接続マップ(データソースとの接続を視覚化したSVG)をビルド時に生成する。
@@ -253,10 +267,116 @@ const CAP_OTHER = "その他";
253
267
  function externalCapability(type) {
254
268
  if (type === "slack")
255
269
  return "チャット";
270
+ if (type === "google-chat")
271
+ return "チャット";
256
272
  if (String(type).indexOf("github") === 0)
257
273
  return "開発";
258
274
  return CAP_OTHER;
259
275
  }
276
+ /**
277
+ * 外部ソースの種別 → どのアダプタの行か。チャットが2アダプタになった(#153)ので、
278
+ * 能力への対応(externalCapability)だけでは集計とモーダルの中身が混ざる——
279
+ * Slack のタイルが「3チャンネル」とスペース込みで数え、解除ボタンが
280
+ * **別アダプタの同名 ref を撃つ**。タイル・集計・一覧の絞り込みはこの1関数を使う。
281
+ *
282
+ * **知らない種別は空文字(=どのアダプタにも属さない)。** 捨てるのではなく
283
+ * 「絞らない側」に倒す——externalCapability が「その他」へ寄せたものを
284
+ * ここで消すと、読んでいるものが無言で消える。
285
+ *
286
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
287
+ */
288
+ function sourceAdapter(type) {
289
+ if (type === "slack")
290
+ return "slack";
291
+ if (type === "google-chat")
292
+ return "google-chat";
293
+ if (String(type).indexOf("github") === 0)
294
+ return "github";
295
+ return "";
296
+ }
297
+ /**
298
+ * 外部ソースの行が「この能力・このアダプタのタイル」に属するか。
299
+ *
300
+ * **この述語はここ1箇所に持つ**(状態判定 capabilityState と件数集計 tileSub が使う)。
301
+ * 写経すると、絞り方を変えたときに片方だけ直り、同じタイルの warn と件数が
302
+ * 別の行集合から計算される——このアダプタ分割が防ごうとした混線そのものが再発する。
303
+ * adapter 未指定(接続マップ・能力単位の呼び出し)とアダプタ不明の行は「絞らない側」に倒す。
304
+ *
305
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
306
+ */
307
+ function sourceMatchesAdapter(type, cap, adapter) {
308
+ if (externalCapability(type) !== cap)
309
+ return false;
310
+ if (!adapter)
311
+ return true;
312
+ var sa = sourceAdapter(type);
313
+ return !sa || sa === adapter;
314
+ }
315
+ /**
316
+ * capabilities[cap].adapters[](前方互換キー)から、このアダプタのエントリを引く。
317
+ *
318
+ * **走査はここ1箇所に持つ。** 状態判定・タイルの表示状態・detail の3箇所が別々に
319
+ * 書き写すと規則が割れる(実際に「state 必須」と「state 不問」に割れ、state を欠く
320
+ * エントリの detail だけが画面に出る不一致を作っていた)。規則はここで固定する:
321
+ * - `adapters` が**空配列なら「持っていない」と同じ**(null を返す)。build.ts は空を
322
+ * 落とすが、ライブ経路(op=state)は素通しなので、ここで守らないと engine が
323
+ * adapters:[] を出した瞬間に全タイルが「未接続」へ退行する
324
+ * - `adapter` か `state` を欠くエントリは存在しない扱い(どのタイルの何の状態か決まらない)
325
+ *
326
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
327
+ */
328
+ function adapterEntry(cap, adapter, caps) {
329
+ var list = adapterList(cap, caps);
330
+ var found = null;
331
+ list.forEach(function (x) { if (x.adapter === adapter)
332
+ found = x; });
333
+ return found;
334
+ }
335
+ /**
336
+ * アダプタの状態・detail の**解決規則の正本**(#153)。
337
+ *
338
+ * 規則: adapters[] のエントリが答えていればそれ/有効な一覧はあるがエントリが無ければ
339
+ * unset/一覧が無ければ、**アクティブなアダプタだけ**代表値(単数形)に従う(state: null で表す)。
340
+ * 非アクティブ側は unset——代表値を流用すると「Slack の on」を Google Chat が名乗る。
341
+ *
342
+ * 🔴 **この規則をここ以外に書き写さない。** 状態判定(capabilityState)・タイル表示
343
+ * (adapterDisplayState)・detail の取り出し(タイルとモーダルの引き直し)の4箇所が
344
+ * 別々に持っていた時期に、写し同士が実際にずれた(レビューで指摘)。
345
+ *
346
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
347
+ */
348
+ function adapterResolved(cap, adapter, caps) {
349
+ var e = adapterEntry(cap, adapter, caps);
350
+ if (e)
351
+ return { state: String(e.state), detail: e.detail || "" };
352
+ if (adapterList(cap, caps).length)
353
+ return { state: "unset", detail: "" };
354
+ if (adapter === capabilityAdapter(cap, caps))
355
+ return { state: null, detail: null }; // 代表値(単数形)に従う
356
+ return { state: "unset", detail: "" };
357
+ }
358
+ /**
359
+ * adapters[] の**有効なエントリだけ**の一覧(#153)。
360
+ *
361
+ * 🔴 **「一覧を持っているか」の判定もこの関数を通すこと。** 生の adapters.length で
362
+ * 見ると、壊れたエントリだけの一覧(state 欠け等)が「一覧あり・エントリ無し=unset」と
363
+ * 解釈され、**アクティブなアダプタのタイルまで未接続に化ける**。静的ビルドは壊れた
364
+ * エントリを落とすがライブ経路(op=state)は素通しなので、判定が生値を見ていると
365
+ * 60秒の取り直しの瞬間だけ答えが変わる——adapterEntry の規則(adapter か state を
366
+ * 欠くエントリは存在しない扱い)と同じ物差しをここに一本化する。
367
+ *
368
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
369
+ */
370
+ function adapterList(cap, caps) {
371
+ var c = caps ? caps[cap] : null;
372
+ var list = c && Array.isArray(c.adapters) ? c.adapters : null;
373
+ if (!list)
374
+ return [];
375
+ var out = [];
376
+ list.forEach(function (x) { if (x && x.adapter && x.state)
377
+ out.push(x); });
378
+ return out;
379
+ }
260
380
  /**
261
381
  * その能力をいま担っているアダプタ(#125②)。
262
382
  *
@@ -390,6 +510,9 @@ exports.LIVE_CONFIRM_LIMITS = { timeoutMs: LIVE_CONFIRM_TIMEOUT_MS };
390
510
  /** 応答のキー(ASCII)→ 画面の能力名。**対応表はここ1つだけ** */
391
511
  const REGISTERED_CAP_NAMES = {
392
512
  chat: "チャット",
513
+ // チャットの2本目のアダプタ(#153)。値が chat と重複するのは意図——能力は同じで、
514
+ // 読む登録ファイルと行の型が違う(要求は常に全能力なので、キーはここに載れば自動で要求される)
515
+ "google-chat": "チャット",
393
516
  dev: "開発",
394
517
  design: "デザイン",
395
518
  materials: "共有資料",
@@ -408,6 +531,7 @@ const REGISTERED_CAP_NAMES = {
408
531
  */
409
532
  const REGISTERED_FIELDS = {
410
533
  chat: ["ref", "name", "workspace", "orgUnknown", "origin", "goldState", "notify"],
534
+ "google-chat": ["ref", "name"],
411
535
  dev: ["ref", "type"],
412
536
  design: ["key", "name"],
413
537
  materials: ["id", "name"],
@@ -416,6 +540,7 @@ const REGISTERED_FIELDS = {
416
540
  /** 欠けていたら行として読めない欄(「cm1.backlog.jp / 」のような半端な行を出さない) */
417
541
  const REGISTERED_REQUIRED = {
418
542
  chat: ["ref"],
543
+ "google-chat": ["ref"],
419
544
  dev: ["ref"],
420
545
  design: ["key"],
421
546
  materials: ["id"],
@@ -481,9 +606,112 @@ function sanitizeRegisteredNow(v) {
481
606
  }
482
607
  return any ? out : null;
483
608
  }
484
- /** 接続状態ストアの語彙(サーバ側 connection-state.mjs と同じ。増やさない) */
609
+ // ---------------------------------------------------------------------------
610
+ // ハーネス連携(決まった時間に自動で動かす機能)— オン/オフの正本を通す篩
611
+ //
612
+ // 通り道は2本あり、**どちらも同じ関数を通す**:
613
+ // 1. ビルド時 … build.ts が `ハーネス連携/schedules.json` を読む
614
+ // 2. 画面側 … 保存の応答に添えられた entry でミラーを当て直す
615
+ // 写経して2枚にすると、片方だけ語彙を増やしたときに「静的では出ないものが
616
+ // 保存直後だけ出る」という一番気づきにくい食い違いになる(capabilities で踏んだ形)。
617
+ // ---------------------------------------------------------------------------
618
+ /** 動かす時間帯。**知らない値はキーごと落とす**(画面のラベルと送る値が決まらない) */
619
+ const HARNESS_SLOTS = ["morning", "noon", "evening"];
620
+ /** 毎週のときの曜日。同じく知らない値は落とす */
621
+ const HARNESS_WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
622
+ /**
623
+ * 時間帯のラベル。**「ごろ」を必ず含める**——実行はキューの都合で前後するので、
624
+ * 「朝9時」と書くと時刻の約束に読める。12時間表記で統一(発注書の例と一致)
625
+ */
626
+ const HARNESS_SLOT_LABEL = {
627
+ morning: "朝9時ごろ",
628
+ noon: "昼1時ごろ",
629
+ evening: "夜9時ごろ",
630
+ };
631
+ /** 曜日のラベル(「毎週」の後ろに置く) */
632
+ const HARNESS_WEEKDAY_LABEL = {
633
+ mon: "月曜", tue: "火曜", wed: "水曜", thu: "木曜", fri: "金曜", sat: "土曜", sun: "日曜",
634
+ };
635
+ /**
636
+ * オンになっている機能の一覧を、画面が扱ってよい形へ畳む。**通らなかったものは黙って落とす。**
637
+ *
638
+ * - `harness`(owner/repo)と `skill` は必須。欠ける行は落とす(どの機能の話か決まらない)
639
+ * - `slot` / `weekday` は語彙外なら**キーごと落として行は残す**。プランと同じ
640
+ * 「時間帯の無い行は実行されない」状態が、画面にも「時間帯未設定」として現れるのが正しい
641
+ * - `ref` などの余りは落とす。**葉は文字列だけ**(配列やオブジェクトを通すと
642
+ * 画面に [object Object] が出る)
643
+ *
644
+ * 🔴 **クロージャを持たない純関数に保つこと。** クライアントJSへ toString() で
645
+ * 流し込んでいるので、外の変数を掴むと直列化した先で undefined を参照して壊れる
646
+ *(参照してよいのは同じく流し込んである HARNESS_SLOTS / HARNESS_WEEKDAYS だけ)。
647
+ */
648
+ function sanitizeHarnessSchedules(v) {
649
+ if (!Array.isArray(v))
650
+ return [];
651
+ const out = [];
652
+ for (const r of v) {
653
+ if (r == null || typeof r !== "object" || Array.isArray(r))
654
+ continue;
655
+ if (out.length >= 200)
656
+ break;
657
+ const harness = r.harness;
658
+ const skill = r.skill;
659
+ if (typeof harness !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(harness))
660
+ continue;
661
+ if (typeof skill !== "string" || !/^[A-Za-z0-9_-]+$/.test(skill))
662
+ continue;
663
+ const row = {
664
+ harness: harness,
665
+ skill: skill,
666
+ };
667
+ if (typeof r.slot === "string" && HARNESS_SLOTS.indexOf(r.slot) >= 0)
668
+ row.slot = r.slot;
669
+ if (typeof r.weekday === "string" && HARNESS_WEEKDAYS.indexOf(r.weekday) >= 0)
670
+ row.weekday = r.weekday;
671
+ out.push(row);
672
+ }
673
+ return out;
674
+ }
675
+ /**
676
+ * 棚(この案件で使う機能の候補)の一覧を、画面が扱ってよい形へ畳む。
677
+ *
678
+ * 🔴 **名前の篩は `sanitizeHarnessSchedules` に相乗りする(写経しない)。** 棚とオンは
679
+ * 別の状態だが、**同じ鍵(harness + skill)で突き合わせる**ので、通る名前の集合が
680
+ * 割れると「棚には置けるのにオンにできない機能」ができる。上限(200件)も向こうが持つ。
681
+ *
682
+ * 棚は**集合**なので、ここで重複を畳む(手編集や過去の並列書き込みで増えうる)。
683
+ * 描画順は名簿/カタログ順で決めるので、配列の並びそのものには意味を持たせない。
684
+ * **時間帯・曜日は落とす**——棚に周期を持たせると、intake の解決した値と食い違う
685
+ * 二重帳簿になる(棚は「いつ動くか」を決める場所ではない)。
686
+ *
687
+ * 🔴 **クロージャを持たない純関数に保つこと。** クライアントJSへ toString() で
688
+ * 流し込んでいる(参照してよいのは同じく流し込んである sanitizeHarnessSchedules だけ)。
689
+ */
690
+ function sanitizeHarnessShelf(v) {
691
+ const rows = sanitizeHarnessSchedules(v);
692
+ const out = [];
693
+ const seen = {};
694
+ for (const r of rows) {
695
+ const key = r.harness + "\t" + r.skill;
696
+ if (seen[key])
697
+ continue;
698
+ seen[key] = true;
699
+ out.push({ harness: r.harness, skill: r.skill });
700
+ }
701
+ return out;
702
+ }
703
+ /**
704
+ * 接続状態ストアの語彙(サーバ側 connection-state-keys.mjs の REACHABLE_STATUSES /
705
+ * INGESTED_STATUSES と同じ。**勝手に増やさない・減らさない**)。
706
+ *
707
+ * 🔴 **ここに無い語の行は、overlay に届く前に消える**(`sanitizeStateNow` が落とす)。
708
+ * サーバが書き始めた語をここへ写し忘れると、画面は「登録済み(接続は未確認です)」の
709
+ * まま永久に戻らない——**行が届いていないのか、届いて落とされたのかが画面から
710
+ * 区別できない**ので、原因に辿り着けない形の壊れ方になる。
711
+ * 写し忘れても赤くならなかったので、突き合わせは viewer/test/state-vocab-parity.test.mjs。
712
+ */
485
713
  const STATE_STATUSES = {
486
- reachable: ["ok", "not_in_channel", "missing_scope", "no_token", "unreachable", "unknown"],
714
+ reachable: ["ok", "not_in_channel", "missing_scope", "no_token", "unreachable", "unknown", "not_in_space", "no_credentials"],
487
715
  ingested: ["ok", "failed", "skipped"],
488
716
  };
489
717
  const STATE_ROW_FIELDS = ["cap", "ref", "workspace", "question", "status", "at", "by", "needed", "lastOkAt", "lastFailAt"];
@@ -530,7 +758,7 @@ function sanitizeStateNow(v) {
530
758
  * いることがあるが、push のたびに再生成される設計上それが残るのは休眠案件だけで、
531
759
  * 休眠案件ではストアの行が唯一かつ最新の観測なので実害は無い。
532
760
  *
533
- * 対象はチャットと開発の行(段B。夜間チェックがこの2つを書く)。
761
+ * 対象はチャット(Slack)・Google Chat・開発の行(夜間の Gold 昇格がこの3つを書く)。
534
762
  *
535
763
  * 🔴 **突き合わせの鍵は能力ごとに違う。**
536
764
  * - chat … ワークスペース + チャンネルID。チャンネルIDはワークスペースの中でしか
@@ -538,6 +766,10 @@ function sanitizeStateNow(v) {
538
766
  * - dev … owner/repo の生文字列(devRowsToAdd と同じ)。**ワークスペースは持たない。**
539
767
  * 同じリポジトリの github-issues と github-discussions は**同じ行**を指すので、
540
768
  * 2つの行に同じ判定が載るのが正しい(書く側が SK で dedupe している)
769
+ * - google-chat … `spaces/<ID>` の生文字列だけ(#153)。**ワークスペースは持たない**
770
+ * ——Google Chat のスペース ID はテナントをまたいで一意で、鍵に足すものが無い。
771
+ * **`chat` に寄せない**のも同じ理由の裏返しで、寄せると Slack のチャンネルと
772
+ * 同じ棚に並び、タイル・件数・実測が混ざる(直す場所が違う方を指す)
541
773
  *
542
774
  * **needed(足りないスコープ)も載せる。** 載せないと、夜間が missing_scope を
543
775
  * 書いた行だけスコープ名が消え、「権限が足りません」とだけ出て何を直せばよいか
@@ -554,6 +786,8 @@ function overlayStateOnFleet(view, rows) {
554
786
  continue;
555
787
  if (r.cap === "chat")
556
788
  byKey["chat\t" + String(r.workspace || "") + "\t" + String(r.ref)] = r;
789
+ else if (r.cap === "google-chat")
790
+ byKey["google-chat\t" + String(r.ref)] = r;
557
791
  else if (r.cap === "dev")
558
792
  byKey["dev\t" + String(r.ref)] = r;
559
793
  }
@@ -564,6 +798,8 @@ function overlayStateOnFleet(view, rows) {
564
798
  let hit = null;
565
799
  if (s.type === "slack")
566
800
  hit = byKey["chat\t" + String(s.workspace || "") + "\t" + String(s.ref)];
801
+ else if (s.type === "google-chat")
802
+ hit = byKey["google-chat\t" + String(s.ref)];
567
803
  else if (String(s.type || "").indexOf("github") === 0)
568
804
  hit = byKey["dev\t" + String(s.ref)];
569
805
  if (!hit)
@@ -629,6 +865,28 @@ function chatRowsToAdd(externals, rows) {
629
865
  }
630
866
  return out;
631
867
  }
868
+ /**
869
+ * Google Chat の行で、まとめ表がまだ知らないもの。**鍵は ref のみ**——スペースの ref は
870
+ * 全ワークスペースで一意(#153)。Slack の workspace+ref 鍵を写経しない。
871
+ * 同じ ref が両方に居るときは足さない(二重表示の dedupe はここが責務)。
872
+ */
873
+ function gchatRowsToAdd(externals, rows) {
874
+ const have = {};
875
+ for (const s of externals || []) {
876
+ if (!s || s.type !== "google-chat" || !s.ref)
877
+ continue;
878
+ have[String(s.ref)] = true;
879
+ }
880
+ const out = [];
881
+ for (const r of rows || []) {
882
+ if (!r || !r.ref)
883
+ continue;
884
+ if (have[String(r.ref)])
885
+ continue;
886
+ out.push({ type: "google-chat", name: r.name || r.ref, ref: r.ref });
887
+ }
888
+ return out;
889
+ }
632
890
  /**
633
891
  * 開発の行で、まとめ表がまだ知らないもの。**鍵は `owner/repo` の生文字列。**
634
892
  *
@@ -745,7 +1003,15 @@ function unsettledRegisteredCount(view, reg) {
745
1003
  if (!reg)
746
1004
  return 0;
747
1005
  const rows = registeredRowsToAdd(view, reg);
748
- return rows.externalSources.length + rows.design.length + rows.materials.length + rows.issues.length;
1006
+ // 🔴 **google-chat の行は数えない(#153)。** engine Phase 2 の配備までまとめ表は
1007
+ // google-chat を永遠に知らないので、数えると全閲覧セッションで15分×60秒間隔の
1008
+ // 取り直しを焼き続け、打ち切り後はそのセッションの全能力で取り直しが張られなくなる。
1009
+ // ここで数えるのは「取り直せば変わり得るか」——Phase 2 前の google-chat は変わらない。
1010
+ // 行の表示(「登録状況を確認中です」)はマージ側に乗るので消えない。
1011
+ // **Phase 2 配備後はこの除外を外す**——自己チェックが google-chat を書き始めれば
1012
+ // 取り直しで確定するようになり、除外したままだと追加直後の追いつきだけが遅れる(設計⑭)
1013
+ const ext = rows.externalSources.filter(function (s) { return s.type !== "google-chat"; });
1014
+ return ext.length + rows.design.length + rows.materials.length + rows.issues.length;
749
1015
  }
750
1016
  /** 足すべき行を能力ごとに求める(マージと件数の唯一の出どころ) */
751
1017
  function registeredRowsToAdd(view, reg) {
@@ -757,7 +1023,9 @@ function registeredRowsToAdd(view, reg) {
757
1023
  const bt = v.backlogTarget;
758
1024
  const targets = bt && Array.isArray(bt.targets) ? bt.targets : bt ? [bt] : [];
759
1025
  return {
760
- externalSources: chatRowsToAdd(v.externalSources, r.chat).concat(devRowsToAdd(v.externalSources, r.dev)),
1026
+ externalSources: chatRowsToAdd(v.externalSources, r.chat)
1027
+ .concat(gchatRowsToAdd(v.externalSources, r["google-chat"]))
1028
+ .concat(devRowsToAdd(v.externalSources, r.dev)),
761
1029
  design: designRowsToAdd(design && design.figmaFiles, r.design),
762
1030
  materials: materialRowsToAdd(materials && materials.driveFolderIds, r.materials),
763
1031
  issues: issueRowsToAdd(targets, r.issues),
@@ -786,11 +1054,17 @@ function subtractRemovedRegistered(view, reg) {
786
1054
  return v;
787
1055
  let out = v;
788
1056
  const set = (patch) => { out = out === v ? Object.assign({}, v, patch) : Object.assign(out, patch); };
789
- if (Array.isArray(reg.chat) || Array.isArray(reg.dev)) {
1057
+ // 外側ガードにも google-chat を含める(#153)——chat / dev だけ見ていると、Slack 未導入
1058
+ // (channels.json 無し)の案件では google-chat の応答だけが届いても引き算に到達しない
1059
+ if (Array.isArray(reg.chat) || Array.isArray(reg.dev) || Array.isArray(reg["google-chat"])) {
790
1060
  const chatRefs = {};
791
1061
  for (const r of reg.chat || [])
792
1062
  if (r && r.ref)
793
1063
  chatRefs[String(r.ref)] = true;
1064
+ const gchatRefs = {};
1065
+ for (const r of reg["google-chat"] || [])
1066
+ if (r && r.ref)
1067
+ gchatRefs[String(r.ref)] = true;
794
1068
  const devRefs = {};
795
1069
  for (const r of reg.dev || [])
796
1070
  if (r && r.ref)
@@ -804,6 +1078,12 @@ function subtractRemovedRegistered(view, reg) {
804
1078
  return true;
805
1079
  return chatRefs[String(s.ref)] === true;
806
1080
  }
1081
+ // Google Chat(#153): 照合は ref のみ・type で絞る(Slack の行を巻き込まない)。
1082
+ // origin の守りは不要——登録元は名簿の一系統だけ(Slack の dev-sources 併記に相当する
1083
+ // 「他からも読まれる行」が存在しない)
1084
+ if (s.type === "google-chat" && Array.isArray(reg["google-chat"])) {
1085
+ return gchatRefs[String(s.ref)] === true;
1086
+ }
807
1087
  if (String(s.type || "").indexOf("github") === 0 && Array.isArray(reg.dev)) {
808
1088
  // 「読み取り停止中」(exclude)の行は登録簿の sources に居ないが、
809
1089
  // まとめ表はバッジを出すために保持している——引くと停止中の表示ごと消える
@@ -1139,6 +1419,12 @@ function sanitizeCapabilitiesNow(v) {
1139
1419
  for (const id in STATE_CAP_NAMES) {
1140
1420
  if (!Object.prototype.hasOwnProperty.call(STATE_CAP_NAMES, id))
1141
1421
  continue;
1422
+ // 🔴 **google-chat キーはここでは読まない(#153)。** chat と同じ能力名「チャット」に
1423
+ // 写像されるため、書くと**後勝ちで Slack の行(代表値)を上書きする**——サーバが将来
1424
+ // capabilitiesNow.google-chat を答え始めた日に、接続済み案件の Slack タイルが未接続に化ける。
1425
+ // 能力単位の代表は chat が持ち、アダプタ別の状態は adapters[](fleet 由来)が持つ
1426
+ if (id === "google-chat")
1427
+ continue;
1142
1428
  const c = v[id];
1143
1429
  if (c == null || typeof c !== "object" || Array.isArray(c))
1144
1430
  continue;
@@ -1256,6 +1542,12 @@ const TILE_CAPS = {
1256
1542
  "共有資料": { id: "materials", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1257
1543
  "デザイン": { id: "design", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "トークン" },
1258
1544
  "チャット": { id: "chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1545
+ // チャットの2枚目のアダプタ(#153・判断⑲)。**タイルの導出はアダプタで割る**——
1546
+ // 割らないと Google Chat タイルが Slack の登録件数・実測で判定される。
1547
+ // 鍵はタイル側(capDerivation)が「能力-アダプタ」で引く。実測ストアの
1548
+ // `cap: "google-chat"` の行は夜間の Gold 昇格が書く(#153 PR-E で結線済み)ので、
1549
+ // 夜間が一度走れば規則2/3(⚠️ / 接続OK)まで進む。走る前は規則4(実績なし→中立)
1550
+ "チャット-google-chat": { id: "google-chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1259
1551
  "開発": { id: "dev", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1260
1552
  };
1261
1553
  /**
@@ -1274,10 +1566,17 @@ function keyMissingText(cap) {
1274
1566
  /**
1275
1567
  * 実測が「悪い」と言える語。**`unknown` は入れない**——測れなかっただけで、
1276
1568
  * 悪いとは言えない(入れると、一度も測れていない案件が全部⚠️になる)。
1569
+ *
1570
+ * 🔴 **`reachable` は「`ok` と `unknown` 以外は全部悪い」。** 新しい語を足して
1571
+ * ここへ入れ忘れると、`deriveCapTile` が悪いと数えず、タイルが中立の
1572
+ * 「登録済み(接続は未確認です)」に落ちる——**夜間が答えを出しているのに画面が
1573
+ * 無音になる**(接続マップだけが⚠️で、タイルは静か、という食い違いにもなる)。
1574
+ * `not_in_space` / `no_credentials` はどちらも読めていない状態なので入れる
1575
+ * (文言の出し分けは DS_GATE が持つ)。突き合わせは state-vocab-parity.test.mjs。
1277
1576
  */
1278
1577
  const BAD_STATE_STATUSES = {
1279
1578
  ingested: ["failed", "skipped"],
1280
- reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable"],
1579
+ reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable", "not_in_space", "no_credentials"],
1281
1580
  };
1282
1581
  /**
1283
1582
  * タイル1行(と接続グラフの1ノード)の状態を、**正本から導出する**(0.12.0)。
@@ -1372,7 +1671,7 @@ function deriveCapTile(cap, ctx) {
1372
1671
  // ---- 規則4: 実績なし → 中立 ----
1373
1672
  return { rule: 4, state: "idle", text: meta.noFact, keyMissing: false };
1374
1673
  }
1375
- function capabilityState(cap, ctx) {
1674
+ function capabilityState(cap, ctx, adapter) {
1376
1675
  // **能力→パイプラインの対応はここに持つ。** 呼び出し側に渡させると、
1377
1676
  // クライアント側(この関数を .toString() で受け取る側)が同じ表を書き写すことになり、
1378
1677
  // 書き忘れると warn 判定が丸ごと死ぬ——実データで、東京ヴェルディの課題管理が
@@ -1386,7 +1685,10 @@ function capabilityState(cap, ctx) {
1386
1685
  // **ライブ参照かどうかは、能力名ではなく渡された行から決める。** 名前で決めていたとき、
1387
1686
  // 未知の種別を寄せた「その他」は行を2件持っているのに「未使用」と言い切っていた
1388
1687
  // (読み手は未使用を信じて押さないので、行が消えるより悪い)
1389
- const liveExts = ctx.externals.filter(function (s) { return externalCapability(s.type) === cap; });
1688
+ // **アダプタが指定されたら、その行だけで数える(#153)。** チャットが2アダプタになったので、
1689
+ // Slack のタイルの warn 判定に Google Chat の gate 落ちが混ざると、直す場所が違う方を指す。
1690
+ // 絞り方の正本は sourceMatchesAdapter(tileSub の件数集計と同じものを使う)
1691
+ const liveExts = ctx.externals.filter(function (s) { return sourceMatchesAdapter(s.type, cap, adapter); });
1390
1692
  const isLive = cap === "チャット" || cap === "開発" || cap === CAP_OTHER;
1391
1693
  const internal = ctx.internals.filter(function (s) { return s.kind === cap; })[0];
1392
1694
  const pipeRe = PIPE[cap];
@@ -1399,7 +1701,19 @@ function capabilityState(cap, ctx) {
1399
1701
  // **「問題あり(warn)」は別軸なので導出に混ぜない。** 設定は済んでいる(on)のに
1400
1702
  // 読めていない・同期が失敗している、は設定ファイルからは分からない。
1401
1703
  // broken(設定そのものが壊れている)だけは導出が答えを持っている。
1402
- const derived = capabilityRawState(cap, ctx.capabilities);
1704
+ let derived = capabilityRawState(cap, ctx.capabilities);
1705
+ // **アダプタ指定の呼び出しでは、そのアダプタの状態で判定する(#153)。**
1706
+ // - adapters[](前方互換キー)にエントリがあればそれが正本(引き方は adapterEntry の1箇所)
1707
+ // - adapters[] はあるがエントリが無い → engine がまだ知らない= unset 側へ倒す
1708
+ // - adapters[] を持たない旧データ → 単数形は broken > on > その他 の**代表値**なので、
1709
+ // アクティブなアダプタにだけ使う。非アクティブ側に流用すると「Slack の on」を
1710
+ // Google Chat が名乗る(レビュー指摘1の片割れ)
1711
+ if (adapter && ctx.capabilities && ctx.capabilities[cap]) {
1712
+ // 解決規則の正本は adapterResolved(state: null は「代表値=単数形に従う」)
1713
+ const r = adapterResolved(cap, adapter, ctx.capabilities);
1714
+ if (r.state !== null)
1715
+ derived = r.state;
1716
+ }
1403
1717
  if (derived !== null) {
1404
1718
  if (derived === "off")
1405
1719
  return "off";
@@ -1420,6 +1734,14 @@ function capabilityState(cap, ctx) {
1420
1734
  // ここは**生の状態**(capabilityRawState)で見ている。empty と unset は元々どちらも
1421
1735
  // idle に落ちるので、表示側の写像(capabilityDisplayState が empty を unset に畳む)と
1422
1736
  // 答えが割れることはない。**この2つを分けるなら、写像とここの両方を直すこと。**
1737
+ //
1738
+ // **ただし、ライブ参照の行が実在して読めていないなら⚠️を出す(#153 レビュー)。**
1739
+ // 行が fleet-status に載っている=夜間の読み取りが現に対象にしている、という観測。
1740
+ // 状態が unset(engine がこのアダプタをまだ知らない移行期間)だからと黙らせると、
1741
+ // 同じ行から描く接続マップだけが⚠️になり、「マップが⚠️・直下は静か」という
1742
+ // 既知の最悪事故の変形が移行期間のデフォルトになる
1743
+ if (liveExts.some(function (s) { return sourceNeedsAttention(s); }))
1744
+ return "warn";
1423
1745
  return "idle";
1424
1746
  }
1425
1747
  // ---- ここから下は capabilities を持たない fleet-status.json のための防御 ----
@@ -2257,6 +2579,8 @@ a:hover { text-decoration: underline; }
2257
2579
  .detail-actions { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
2258
2580
  .detail-actions-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
2259
2581
  .detail-title { font-size: 24px; font-weight: 700; margin: 0 0 10px; line-height: 1.4; }
2582
+ /* Home タイトル横の鮮度のヒント。主張しない(タイトルの一部ではなく注記) */
2583
+ .home-updated { font-size: 12px; font-weight: 400; color: var(--muted); margin-left: 10px; white-space: nowrap; }
2260
2584
  .detail-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; color: var(--muted); font-size: 13px; margin-bottom: 16px; }
2261
2585
  /* GitHubへ飛ぶボタンだけ、ヘッダーの .gh-btn と同じダーク(GitHubカラー)+リキッドグラスで揃える */
2262
2586
  .edit-btn {
@@ -2525,6 +2849,52 @@ a.ds-name:hover { text-decoration: underline; }
2525
2849
  .ds-tile-sub { font-size: 12.5px; margin-top: 2px; }
2526
2850
  .ds-tile-chev { color: var(--muted); flex: 0 0 auto; }
2527
2851
  .ds-tile-note { font-size: 12.5px; color: #a15c00; margin-top: 2px; }
2852
+ /* 「オンにしたのに配信先が無い」(ハーネス連携)。**必ず .ds-tile-note より後ろに置く**
2853
+ ——同じ詳細度なので、前に書くと後ろの #a15c00 に負けて色が変わらない。
2854
+ 琥珀(設定の不足)より強い赤にするのは、この状態だと**動いているのに誰にも届かない**ため */
2855
+ .ds-tile-alert { color: #b91c1c; }
2856
+ /* ---- ハーネス連携のモーダル(機能ごとの1行)----
2857
+ 枠は .ds-card に揃える(同じモーダルの中で行の見え方を割らない)。
2858
+ 縦組みにするのは、1行に「名前+周期」「説明」「操作」の3段が入るため */
2859
+ .ds-harness-list { display: flex; flex-direction: column; gap: 10px; }
2860
+ .ds-harness-row {
2861
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
2862
+ padding: 14px 16px; box-shadow: var(--shadow);
2863
+ display: flex; flex-direction: column; gap: 4px;
2864
+ }
2865
+ .ds-harness-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; }
2866
+ .ds-harness-name { font-weight: 700; }
2867
+ .ds-harness-period { font-size: 12.5px; color: var(--muted); }
2868
+ .ds-harness-summary { font-size: 12.5px; color: var(--muted); }
2869
+ /* 時間帯・曜日の選択。トグルと同じ行に並べる(押した直後に調整できるのが要点) */
2870
+ .ds-harness-selects { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
2871
+ .ds-harness-selects select { font-size: 12px; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: #fff; color: var(--text); }
2872
+ .ds-harness-selects select:disabled { opacity: .6; cursor: wait; }
2873
+ /* 一覧の代わりに出す1行(読み込み中・0件・読み込めなかった)。**3つを見分けられる色にする** */
2874
+ .ds-harness-state { font-size: 12.5px; color: var(--muted); margin: 4px 0 10px; }
2875
+ .ds-harness-state.is-error { color: #b42318; }
2876
+ /* 一部の提供元から読めなかったとき。**行は出したうえで**「並んでいないものがある」と断る */
2877
+ .ds-harness-warn { font-size: 12.5px; color: #a15c00; margin: 4px 0 10px; }
2878
+ /* ---- 棚ビュー / ピッカーのセクション(部署 → ハーネス)----
2879
+ 見出しの階層を**大きさではなく余白で**作る。モーダルの中で h3/h2 相当の字を並べると、
2880
+ モーダルの見出し(機能名)と競り合って、どれが画面の主題か読めなくなる */
2881
+ .ds-harness-dep { margin: 0 0 18px; }
2882
+ .ds-harness-dep-title { font-size: 13px; font-weight: 700; color: var(--muted); margin: 0 0 8px; letter-spacing: .04em; }
2883
+ .ds-harness-prov-title { font-size: 13px; font-weight: 700; margin: 12px 0 6px; }
2884
+ .ds-harness-dep-body > .ds-harness-prov-title:first-child { margin-top: 0; }
2885
+ /* 提供元ごとの「いま読み込めませんでした」(一覧全体の断りとは別。どの提供元の話かが分かる) */
2886
+ .ds-harness-subnote { font-size: 12.5px; color: #a15c00; margin: 0 0 6px; }
2887
+ /* 「棚に無いのにオンになっている」行の注記。**行の中に置く**——一覧の上でまとめて
2888
+ 断ると、どの行のことなのかが読めない */
2889
+ .ds-harness-note { font-size: 12.5px; color: #a15c00; }
2890
+ /* ピッカーで「これはもう動いている」ことを示す印。棚の操作と混ぜないよう小さく添える */
2891
+ .ds-harness-onmark {
2892
+ font-size: 11.5px; color: var(--accent); border: 1px solid var(--accent);
2893
+ border-radius: 999px; padding: 0 8px; flex: 0 0 auto;
2894
+ }
2895
+ /* ビューを移る導線(機能をえらぶ/戻る)。行の操作と同じ高さに並べない(別の階層の操作) */
2896
+ .ds-harness-actions { display: flex; margin: 12px 0 4px; }
2897
+ .ds-harness-pick-state { font-size: 12.5px; color: var(--muted); margin: 4px 0 10px; }
2528
2898
  /* ---- 可変長の文字列は必ず折り返す(0.4.71) ----
2529
2899
  実機の受け入れテストで、状態の文言がタイル幅を超えて溢れた(スシローのデザインタイル)。
2530
2900
  ここに出るのは engine の detail・チェックの直し方・チャンネル名・ファイルパスなど
@@ -2538,7 +2908,10 @@ a.ds-name:hover { text-decoration: underline; }
2538
2908
  .ds-check-label, .ds-check-action, .ds-check-detail,
2539
2909
  .ds-pipe-label, .ds-pipe-todo,
2540
2910
  .ds-empty, .sec-how, .modal-desc, .ds-toggle-msg,
2541
- .ds-drive-note, .ds-inactive-note, .ds-legacy-chip, .ds-keys-code, .ds-gate-help {
2911
+ .ds-drive-note, .ds-inactive-note, .ds-legacy-chip, .ds-keys-code, .ds-gate-help,
2912
+ /* ハーネス連携の行。機能の表示名・説明・周期の文はどれも提供側が決めるので長さが読めない */
2913
+ .ds-harness-name, .ds-harness-period, .ds-harness-summary, .ds-harness-state, .ds-harness-warn,
2914
+ .ds-harness-dep-title, .ds-harness-prov-title, .ds-harness-subnote, .ds-harness-note, .ds-harness-pick-state {
2542
2915
  min-width: 0; white-space: normal; overflow-wrap: anywhere;
2543
2916
  }
2544
2917
  .ds-toggle { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
@@ -2827,6 +3200,11 @@ button.ops-card-btn:disabled { opacity: .6; cursor: default; }
2827
3200
  /* 貼ったURLを1行で出すので、はみ出させない(箱は420px・モバイルは実質335px) */
2828
3201
  .confirm-line { font-size: 14px; color: var(--text); line-height: 1.75; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
2829
3202
  .confirm-line:first-child { font-weight: 700; }
3203
+ /* 確認の中に挿す本文(ハーネスを「オンにする」ときの時間帯の選択)。箱の gap は
3204
+ .confirm-line の間隔なので、入れ子にすると効かない——同じ間隔をここでも持つ。
3205
+ 選択欄そのものは行側の .ds-harness-selects を流用する */
3206
+ .confirm-body { display: flex; flex-direction: column; gap: 10px; }
3207
+ .confirm-body-label { font-size: 13px; color: var(--muted); }
2830
3208
  .modal-ok { color: #047857; font-weight: 600; }
2831
3209
  .modal-status-error { color: #dc2626; font-weight: 600; }
2832
3210
  .modal-paths { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
@@ -2911,6 +3289,11 @@ var ADAPTER_CATALOG = ${JSON.stringify(ADAPTER_CATALOG)};
2911
3289
  var CAP_STATE_TEXT = ${JSON.stringify(CAP_STATE_TEXT)};
2912
3290
  var CAP_STATE_MARK = ${JSON.stringify(CAP_STATE_MARK)};
2913
3291
  ${externalCapability.toString()}
3292
+ ${sourceAdapter.toString()}
3293
+ ${sourceMatchesAdapter.toString()}
3294
+ ${adapterList.toString()}
3295
+ ${adapterEntry.toString()}
3296
+ ${adapterResolved.toString()}
2914
3297
  ${capabilityAdapter.toString()}
2915
3298
  ${capabilityRawState.toString()}
2916
3299
  ${capabilityDisplayState.toString()}
@@ -2930,7 +3313,16 @@ var REGISTERED_DEV_TYPES = ${JSON.stringify(REGISTERED_DEV_TYPES)};
2930
3313
  var UNSETTLED_TTL_MS = ${JSON.stringify(UNSETTLED_TTL_MS)};
2931
3314
  var UNSETTLED_POLL_MS = ${JSON.stringify(UNSETTLED_POLL_MS)};
2932
3315
  ${sanitizeRegisteredNow.toString()}
3316
+ // ハーネス連携(決まった時間に動かす機能)の語彙とラベル。**判定の実体はビルド時の定義**
3317
+ // ——画面側に書き写すと、許可リストが2枚になって語彙の追加漏れが静かに消える
3318
+ var HARNESS_SLOTS = ${JSON.stringify(HARNESS_SLOTS)};
3319
+ var HARNESS_WEEKDAYS = ${JSON.stringify(HARNESS_WEEKDAYS)};
3320
+ var HARNESS_SLOT_LABEL = ${JSON.stringify(HARNESS_SLOT_LABEL)};
3321
+ var HARNESS_WEEKDAY_LABEL = ${JSON.stringify(HARNESS_WEEKDAY_LABEL)};
3322
+ ${sanitizeHarnessSchedules.toString()}
3323
+ ${sanitizeHarnessShelf.toString()}
2933
3324
  ${chatRowsToAdd.toString()}
3325
+ ${gchatRowsToAdd.toString()}
2934
3326
  ${devRowsToAdd.toString()}
2935
3327
  ${designRowsToAdd.toString()}
2936
3328
  ${materialRowsToAdd.toString()}
@@ -2977,6 +3369,13 @@ ${deriveCapTile.toString()}
2977
3369
 
2978
3370
  // 本文中のウィキリンク [[用語]](・[[用語|表示名]])を、その用語レコードへのSPA内リンクに変換する。
2979
3371
  // 解決先は term:用語。存在しない用語は素のテキストに落とす(角括弧を外す)。
3372
+ //
3373
+ // 🔴 **前提: ここに渡ってくるHTMLは、ビルド時に sanitizeBody() を通ったものだけ。**
3374
+ // エスケープしているのは生成する data-go 属性値の & と " だけで、**表示名(disp)は
3375
+ // 未エスケープのまま出力に混ぜている**。つまり [[用語|<img src=x onerror=1>]] は
3376
+ // それ自体としては注入経路で、生HTMLがビルド時に既に除去されていることだけが
3377
+ // これを無害にしている。サニタイズを通っていないHTMLをこの関数に渡さないこと
3378
+ // (bodies/*.html と bodyHtml 以外を流し込む経路を作らない)。
2980
3379
  function linkifyWiki(html) {
2981
3380
  if (!html) return html;
2982
3381
  return html.replace(/\\[\\[([^\\]|]+?)(?:\\|([^\\]]+?))?\\]\\]/g, function (_m, name, label) {
@@ -3115,6 +3514,11 @@ ${deriveCapTile.toString()}
3115
3514
  return el("div", { class: "rt-copy" }, [el("div", { class: "rt-url-row" }, [field, btn]), msg]);
3116
3515
  }
3117
3516
 
3517
+ // 要素を組み立てるヘルパ。
3518
+ // ⚠️ **el() 自体は無害化しない。** class/text 以外のキーはそのまま setAttribute へ渡すので
3519
+ // href に javascript: を置けるし、html: は innerHTML への抜け道(実使用は
3520
+ // homeGroundingSections の1箇所=ビルド時サニタイズ済みのHTMLのみ)。
3521
+ // 外部由来のURLを href に載せるときは、呼び出し側で isHttp() を通すこと。
3118
3522
  function el(tag, props, children) {
3119
3523
  var e = document.createElement(tag);
3120
3524
  if (props) Object.keys(props).forEach(function (k) {
@@ -3598,8 +4002,16 @@ ${deriveCapTile.toString()}
3598
4002
  fetch(rec.bodyRef).then(function (res) { if (!res.ok) throw new Error("http " + res.status); return res.text(); })
3599
4003
  .then(function (html) { var lk = linkifyWiki(html); bodyCache[rec.bodyRef] = lk; mount.innerHTML = lk; done(); })
3600
4004
  .catch(function () {
3601
- mount.innerHTML = '<div class="body-loading">本文を読み込めませんでした(静的サーバで配信してください)。' +
3602
- (rec.editUrl ? ' <a href="' + rec.editUrl + '" target="_blank" rel="noopener">GitHubで開く</a>' : '') + '</div>';
4005
+ // 🔴 文字列連結でHTMLを組み立てない(editUrl は frontmatter 由来の外部文字列)。
4006
+ // el() は class/text/html 以外のキーをそのまま setAttribute に渡すので、
4007
+ // href に javascript: を置けてしまう。**isHttp() が唯一の防御**(保険ではない)
4008
+ var box = el("div", { class: "body-loading", text: "本文を読み込めませんでした(静的サーバで配信してください)。" });
4009
+ if (rec.editUrl && isHttp(rec.editUrl)) {
4010
+ box.appendChild(document.createTextNode(" "));
4011
+ box.appendChild(el("a", { href: rec.editUrl, target: "_blank", rel: "noopener", text: "GitHubで開く" }));
4012
+ }
4013
+ mount.innerHTML = "";
4014
+ mount.appendChild(box);
3603
4015
  });
3604
4016
  }
3605
4017
  function getParam(name) {
@@ -3792,7 +4204,19 @@ ${deriveCapTile.toString()}
3792
4204
  var h = SITE.home;
3793
4205
  countEl.textContent = "";
3794
4206
  var head = el("div", { class: "detail-head" });
3795
- var titleWrap = el("div", {}, [ el("h1", { class: "detail-title", text: h.title }) ]);
4207
+ var titleEl = el("h1", { class: "detail-title", text: h.title });
4208
+ // 鮮度のヒント。**h1 の中に入れる**——titleWrap 直下だと h1 が block なので
4209
+ // 次の行に落ちる。値はビルド時に git から導いた「実質コミット」の日付で、
4210
+ // フッタの generatedAt(サイトの再ビルド日時)とは別物(time の title で意味を補う)
4211
+ if (SITE.homeUpdatedAt) {
4212
+ titleEl.appendChild(el("time", {
4213
+ class: "home-updated",
4214
+ datetime: SITE.homeUpdatedAt,
4215
+ title: "Cortex/Home.md への最後の実質的な変更",
4216
+ text: "最終更新: " + SITE.homeUpdatedAt,
4217
+ }));
4218
+ }
4219
+ var titleWrap = el("div", {}, [ titleEl ]);
3796
4220
  var ai = h.fm ? adoptionInfo(h.fm.adoption) : null;
3797
4221
  if (ai) titleWrap.appendChild(el("span", { class: "chip adoption-" + String(h.fm.adoption), text: ai.label }));
3798
4222
  head.appendChild(titleWrap);
@@ -3892,6 +4316,9 @@ ${deriveCapTile.toString()}
3892
4316
  // 顧客も読む面なので内部用語(cron・stub等)を避け「毎晩自動で〜」調にする。
3893
4317
  var DS_TYPE = {
3894
4318
  "slack": { icon: "💬", label: "Slack" },
4319
+ // 種別スロットに入るのはツール名(能力名や単位語ではない)——Slack 行が "Slack" を
4320
+ // 出しているのと同じ扱い(#170 の行の約束)。「スペース」は件数の単位としてだけ使う
4321
+ "google-chat": { icon: "💬", label: "Google Chat" },
3895
4322
  "github-issues": { icon: "🐙", label: "GitHub Issues" },
3896
4323
  "github-discussions": { icon: "💠", label: "GitHub Discussions" },
3897
4324
  };
@@ -3911,6 +4338,23 @@ ${deriveCapTile.toString()}
3911
4338
  // 残してあるのは「知らない種別のソースを黙って消さない」ため——新しいアダプタが
3912
4339
  // 増えた日に、行が状態を持たずに並ぶより、素の語でも出ているほうがよい
3913
4340
  "no_token": { text: "トークン未設定", cls: "ds-gate-muted" },
4341
+ // --- ここから Google Chat(#153)。値の正本は infra 側の語彙表(slack-gate.mjs)で、
4342
+ // 1対1の一致を infra のテストが見張る。**バッジは短語・次の一手は help に分ける**
4343
+ // (DS_GATE_NO_TOKEN と同じ作り。バッジに長文を入れると行が折り返す)。
4344
+ // 判定を返すのは engine の夜間実測(Phase 2)——画面は語彙と文面だけを先に持つ
4345
+ // not_in_channel の「招待」とは直す操作が別物なので寄せない
4346
+ "not_in_space": {
4347
+ text: "スペースにアプリ未追加",
4348
+ cls: "ds-gate-warn",
4349
+ help: "スペースの「アプリと統合」からアプリを追加してください。",
4350
+ },
4351
+ // no_token の「トークン未設定」は接続ボタンの無い Google Chat では打つ手を示せないので寄せない。
4352
+ // 内部語(サービスアカウント・鍵)は出さない——顧客側に打つ手が無い状態のため
4353
+ "no_credentials": {
4354
+ text: "接続の準備中",
4355
+ cls: "ds-gate-muted",
4356
+ help: "管理者の設定が済むと読み取りが始まります。",
4357
+ },
3914
4358
  "unknown": { text: "不明", cls: "ds-gate-muted" },
3915
4359
  };
3916
4360
  /* ---------------------------------------------------------------------------
@@ -4908,6 +5352,32 @@ ${deriveCapTile.toString()}
4908
5352
  */
4909
5353
  var LIVE_FLEET = null;
4910
5354
  var LIVE_FLEET_FAILED = false;
5355
+ /**
5356
+ * いまオンになっているハーネスの機能(ページ生存中の正本)。
5357
+ *
5358
+ * 🔴 **ここ(CLIENT_JS のトップレベル)に置くのが仕様。** buildDataSources の中に
5359
+ * 置くと、モーダルを閉じるたびに焼き値へ巻き戻る——close() は refreshDataSources() を
5360
+ * 呼んで節を作り直すので、**保存してモーダルを閉じた瞬間に、タイルの2行目と
5361
+ * 赤バッジが保存前の姿へ戻る**。loadLiveFleet の応答・失敗の経路でも再初期化しない
5362
+ *(op=state はこのキーを運ばないので、上書きできる材料がそもそも無い)。
5363
+ *
5364
+ * **寿命はこのページ内だけ。** 恒久化は保存コミット→次回ビルドの焼き直しで、
5365
+ * ここは「押した結果がその場で見える」ためのミラー(notifyToggle の s.notify と同型)。
5366
+ */
5367
+ var HARNESS_SCHEDULES = sanitizeHarnessSchedules(SITE.harnessSchedules);
5368
+ /**
5369
+ * この案件で使う機能の棚(ページ生存中の正本)。**HARNESS_SCHEDULES と同じ置き場所**
5370
+ *(buildDataSources の中に置くと、モーダルを閉じるたびに焼き値へ巻き戻る)。
5371
+ *
5372
+ * 🔴 **null は「まだ棚を作っていない」で、空配列とは別の状態(設計 B-1)。**
5373
+ * null のときは棚の絞り込みそのものを効かせず、オン中の行を注記なしで並べる
5374
+ *——導入直後の既存案件が、いきなり「棚に無いがオン」の注記だらけになるのを避ける。
5375
+ * 空配列は「意図して空」で、空状態の文言に倒す。**null を [] へ丸めないこと。**
5376
+ *
5377
+ * **棚に置いても何も動かない。** 実行の正本は HARNESS_SCHEDULES のままで、
5378
+ * この配列は「この案件で使う候補」でしかない。
5379
+ */
5380
+ var HARNESS_SHELF = SITE.harnessShelf == null ? null : sanitizeHarnessShelf(SITE.harnessShelf);
4911
5381
  /**
4912
5382
  * リアルタイム同期の可否(op=state の realtime。#113)。取得前・取得失敗時は null。
4913
5383
  *
@@ -5016,7 +5486,22 @@ ${deriveCapTile.toString()}
5016
5486
  if (!LIVE_CAPS) return base;
5017
5487
  var out = {};
5018
5488
  for (var k in base) if (Object.prototype.hasOwnProperty.call(base, k)) out[k] = base[k];
5019
- for (var n in LIVE_CAPS) if (Object.prototype.hasOwnProperty.call(LIVE_CAPS, n)) out[n] = LIVE_CAPS[n];
5489
+ for (var n in LIVE_CAPS) {
5490
+ if (!Object.prototype.hasOwnProperty.call(LIVE_CAPS, n)) continue;
5491
+ var liveRow = LIVE_CAPS[n];
5492
+ // **アダプタ別の状態(adapters[]・#153)は live の丸ごと上書きで消さない。**
5493
+ // capabilitiesNow は能力単位の代表値しか運ばないので、fleet が持つ adapters[] を
5494
+ // そのまま落とすと、読み込みの数秒後に broken の⚠️と detail が静かに消える
5495
+ var baseRow = base[n];
5496
+ if (baseRow && Array.isArray(baseRow.adapters) && liveRow && !Array.isArray(liveRow.adapters)) {
5497
+ var merged = {};
5498
+ for (var f in liveRow) if (Object.prototype.hasOwnProperty.call(liveRow, f)) merged[f] = liveRow[f];
5499
+ merged.adapters = baseRow.adapters;
5500
+ out[n] = merged;
5501
+ } else {
5502
+ out[n] = liveRow;
5503
+ }
5504
+ }
5020
5505
  return out;
5021
5506
  }
5022
5507
 
@@ -5078,7 +5563,14 @@ ${deriveCapTile.toString()}
5078
5563
  if (!LIVE_REGISTERED) return {};
5079
5564
  var add = registeredRowsToAdd(rawView(), LIVE_REGISTERED);
5080
5565
  var out = {};
5081
- for (var i = 0; i < add.externalSources.length; i++) out[externalCapability(add.externalSources[i].type)] = 1;
5566
+ for (var i = 0; i < add.externalSources.length; i++) {
5567
+ var addType = add.externalSources[i].type;
5568
+ out[externalCapability(addType)] = 1;
5569
+ // **アダプタ別の鍵も立てる(#153)。** チャットは2タイルなので、能力単位だけだと
5570
+ // Slack の確認中が Google Chat タイルにも出る(逆も)。鍵の形は「能力\tアダプタ」
5571
+ var addAd = sourceAdapter(addType);
5572
+ if (addAd) out[externalCapability(addType) + "\t" + addAd] = 1;
5573
+ }
5082
5574
  if (add.design.length) out[REGISTERED_CAP_NAMES.design] = 1;
5083
5575
  if (add.materials.length) out[REGISTERED_CAP_NAMES.materials] = 1;
5084
5576
  if (add.issues.length) out[REGISTERED_CAP_NAMES.issues] = 1;
@@ -5093,8 +5585,16 @@ ${deriveCapTile.toString()}
5093
5585
  return n;
5094
5586
  };
5095
5587
  var isSlack = function (x) { return x.type === "slack"; };
5588
+ var isGchat = function (x) { return x.type === "google-chat"; };
5096
5589
  var isGh = function (x) { return String(x.type || "").indexOf("github") === 0; };
5097
- if (count(raw.externalSources, isSlack) > 0 && count(sub.externalSources, isSlack) === 0) out["チャット"] = 1;
5590
+ // 「0件になった能力」の判定も述語ごと=アダプタごとに(#153。能力単位だけだと
5591
+ // Google Chat の最後の1件を解除したとき Slack タイルまで「確認中」になる)
5592
+ if (count(raw.externalSources, isSlack) > 0 && count(sub.externalSources, isSlack) === 0) {
5593
+ out["チャット"] = 1; out["チャット\tslack"] = 1;
5594
+ }
5595
+ if (count(raw.externalSources, isGchat) > 0 && count(sub.externalSources, isGchat) === 0) {
5596
+ out["チャット"] = 1; out["チャット\tgoogle-chat"] = 1;
5597
+ }
5098
5598
  if (count(raw.externalSources, isGh) > 0 && count(sub.externalSources, isGh) === 0) out["開発"] = 1;
5099
5599
  var dRaw = internalOf(raw.internalSources || [], REGISTERED_CAP_NAMES.design);
5100
5600
  var dSub = internalOf(sub.internalSources || [], REGISTERED_CAP_NAMES.design);
@@ -5108,7 +5608,16 @@ ${deriveCapTile.toString()}
5108
5608
  return out;
5109
5609
  }
5110
5610
  /** その能力に、まとめ表がまだ知らない行があるか */
5111
- function capPending(cap) { return !!pendingCaps()[cap]; }
5611
+ /**
5612
+ * その能力(アダプタを渡せばそのタイル)に、まとめ表がまだ知らない行があるか。
5613
+ * **アダプタ別の鍵があるのはチャットだけ**(複数アダプタの能力)。アダプタ付きで
5614
+ * 呼ばれたらアダプタの鍵だけを見る——能力の鍵へ倒すと、絞った意味が消える
5615
+ */
5616
+ function capPending(cap, adapter) {
5617
+ var m = pendingCaps();
5618
+ if (adapter && (ADAPTER_CATALOG[cap] || []).length > 1) return !!m[cap + "\t" + adapter];
5619
+ return !!m[cap];
5620
+ }
5112
5621
 
5113
5622
  /**
5114
5623
  * 能力の状態を決めるときに渡す値。**足した行は混ぜない**(誰も測っていないので状態を持たない)。
@@ -5151,10 +5660,26 @@ ${deriveCapTile.toString()}
5151
5660
  * (自動取り込みが OFF)は 「capabilities」 の導出が答え、呼び出し側がこの関数より
5152
5661
  * 先に出す(deriveCapTile の項)。
5153
5662
  */
5154
- function registeredCountOf(cap) {
5663
+ function registeredCountOf(cap, adapter) {
5155
5664
  if (!LIVE_REGISTERED) return null;
5156
5665
  var meta = TILE_CAPS[cap];
5157
5666
  if (!meta) return null;
5667
+ // **チャットの合算は「アダプタ不明」(能力単位=接続グラフ・混載の1枚)のときだけ(#153)。**
5668
+ // chat キーだけ数えると Google Chat しか使わない案件が「登録0件→未接続」に誤判定される一方、
5669
+ // アダプタが分かるタイルにまで合算を掛けると、相手の名簿の答えが自分の件数に混ざる
5670
+ //(gc の名簿が空=0 が、まだ読めていない chat の null を上書きして Slack を未接続にする)。
5671
+ // 片方しか読めなかった回は読めた方だけで数える(両方 null のときだけ null)
5672
+ if (cap === "チャット" && !adapter) {
5673
+ var a = LIVE_REGISTERED["chat"];
5674
+ var b = LIVE_REGISTERED["google-chat"];
5675
+ var an = Array.isArray(a) ? a.length : null;
5676
+ var bn = Array.isArray(b) ? b.length : null;
5677
+ // **片方でも読めなかった(null)なら合算も null(#153 実装レビュー4)。** null を 0 に
5678
+ // 畳むと、channels.json の読み取りが失敗した回に「登録0件→未接続」と断定する——
5679
+ // サーバの契約(読めなかった能力のキーは出さない=0件と混ぜない)をここで壊さない
5680
+ if (an === null || bn === null) return null;
5681
+ return an + bn;
5682
+ }
5158
5683
  var list = LIVE_REGISTERED[meta.id];
5159
5684
  return Array.isArray(list) ? list.length : null;
5160
5685
  }
@@ -5165,9 +5690,26 @@ ${deriveCapTile.toString()}
5165
5690
  * 🔴 **呼び出し側で材料を組み立て直さないこと。** 同じ判定が2箇所に増えると、
5166
5691
  * 「図は接続OK・タイルは未接続」という #231 が殺したはずの食い違いが復活する。
5167
5692
  */
5168
- function capDerivation(cap) {
5169
- return deriveCapTile(cap, {
5170
- registered: registeredCountOf(cap),
5693
+ /**
5694
+ * unset(非アクティブなアダプタの既定値)でも導出へ進んでよいか(#153・判断⑲)。
5695
+ * **3条件が揃うときだけ**: 導出の変種を持つアダプタ・derived が unset・名簿の件数が
5696
+ * 数えられている(null でない)。この判定を呼び出し側に書き写さない——写しがずれると
5697
+ * タイルと引き直しで答えが割れる
5698
+ */
5699
+ function derivableUnset(cap, adapter, derived) {
5700
+ if (derived !== "unset" || !adapter) return false;
5701
+ var derCap = cap + "-" + adapter;
5702
+ if (!TILE_CAPS[derCap]) return false;
5703
+ return registeredCountOf(derCap, adapter) !== null;
5704
+ }
5705
+
5706
+ function capDerivation(cap, adapter) {
5707
+ // **アダプタが分かるタイルは、そのアダプタの名簿・実測だけで導出する(#153・判断⑲)。**
5708
+ // 割らないと、Slack の登録件数・実測で Google Chat タイルが「接続OK」を名乗る(逆も)。
5709
+ // 「能力-アダプタ」の変種は TILE_CAPS が持つ(無いアダプタは能力単位のまま)
5710
+ var derCap = adapter && TILE_CAPS[cap + "-" + adapter] ? cap + "-" + adapter : cap;
5711
+ return deriveCapTile(derCap, {
5712
+ registered: registeredCountOf(derCap, adapter),
5171
5713
  seen: LIVE_STATE_SEEN,
5172
5714
  rows: LIVE_STATE,
5173
5715
  });
@@ -5357,11 +5899,27 @@ ${deriveCapTile.toString()}
5357
5899
  return (ADAPTER_CATALOG[cap] || []).indexOf(tool) >= 0;
5358
5900
  }
5359
5901
 
5902
+ /**
5903
+ * そのアダプタのタイルが名乗る状態(#153)。
5904
+ *
5905
+ * adapters[](前方互換キー)があればそれが正本。単数形(adapter/state)は
5906
+ * broken > on > その他 の代表値なので、2アダプタ目のタイルに使うと
5907
+ * 「Slack の on」を Google Chat が名乗る。adapters[] にエントリが無いアダプタと、
5908
+ * adapters[] を持たない旧データの非アクティブ側は unset(知らないを繋がっているに寄せない)。
5909
+ * empty を unset に畳むのは capabilityDisplayState と同じ写像。
5910
+ */
5911
+ function adapterDisplayState(cap, adapter, caps) {
5912
+ // 解決規則の正本は adapterResolved(state: null は「代表値=単数形の表示写像に従う」)
5913
+ var r = adapterResolved(cap, adapter, caps);
5914
+ var s = r.state === null ? capabilityDisplayState(cap, caps) : r.state;
5915
+ return s === "empty" ? "unset" : s;
5916
+ }
5917
+
5360
5918
  /** 組み立てだけを行う(設置は呼び出し側)。ライブ差し替えで同じ節を作り直すため分けている */
5361
5919
  /** ツールid→表示名(クライアント側。接続マップの CM_TOOL_LABEL と同じ内容) */
5362
5920
  var CM_TOOL_LABEL_C = {
5363
5921
  backlog: "Backlog", "google-meet": "Google Meet", "google-drive": "Google Drive",
5364
- figma: "Figma", slack: "Slack", github: "GitHub",
5922
+ figma: "Figma", slack: "Slack", github: "GitHub", "google-chat": "Google Chat",
5365
5923
  };
5366
5924
 
5367
5925
  /**
@@ -5373,17 +5931,22 @@ ${deriveCapTile.toString()}
5373
5931
  *
5374
5932
  * 2026-08-21 のオーナー実機指摘「押してるのはそのツールなんだから、Backlog とか
5375
5933
  * Google ドライブとして出る必要がある」。出どころは ADAPTER_CATALOG(能力→アダプタ)で、
5376
- * **今日は能力あたり1アダプタなので一意に決まる**(#107 / #127 で複数になったら、
5377
- * ここが「どれを見出しにするか」を決める場所になる)。
5934
+ * チャットは2アダプタ(#153)——**開いたタイルのアダプタが分かるならそれで引く**。
5935
+ * 分からない呼び出し(能力ではない見出し・旧経路)は先頭アダプタへ倒す。
5378
5936
  *
5379
5937
  * ⚠ **カタログに無いものは、そのまま返す。** ここには能力名だけでなく
5380
5938
  * 「文字起こしを手動で追加」のような**能力ではない見出し**も渡ってくるし、
5381
5939
  * 未知の能力(engine が先に増やした場合)も来る。空にすると
5382
5940
  * **名前の無いダイアログ**になり、何を開いたのか分からなくなる——落とすなら能力名まで。
5383
5941
  */
5384
- function capToolLabel(cap) {
5942
+ function capToolLabel(cap, adapter) {
5385
5943
  var list = ADAPTER_CATALOG[cap] || [];
5386
- var tool = list.length ? list[0] : "";
5944
+ // **複数アダプタの能力で、どのタイルから開いたか分からないときは能力名。**
5945
+ // capabilities を持たないデータの1枚タイル(混載)がこれ——先頭アダプタの名を
5946
+ // 名乗ると、スペースの行が並ぶモーダルが「Slack」を名乗る(#153)
5947
+ if (!adapter && list.length > 1) return cap;
5948
+ // カタログ外のアダプタ名は使わない(顧客画面に未知の語を出さない・#124 と同じ原則)
5949
+ var tool = adapter && list.indexOf(adapter) >= 0 ? adapter : (list.length ? list[0] : "");
5387
5950
  return (tool && CM_TOOL_LABEL_C[tool]) || cap;
5388
5951
  }
5389
5952
 
@@ -5392,17 +5955,34 @@ ${deriveCapTile.toString()}
5392
5955
  * 🔴 **反映待ちで抑制してよいのはこれだけ。** この経路は「未接続」「OFFに設定されて
5393
5956
  * います」「**設定に異常があります**」を同じ枠で運んでいる。無条件に抑制すると、
5394
5957
  * 保存直後の15分間、異常が画面から消える——直したい誤読より悪い
5958
+ * @param adapter 開いたタイルのアダプタ(#153・チャットは2枚)。見出し・開示文の主語・
5959
+ * 説明の引き直しをアダプタで割るために使う。**第5引数**——第4は noteIsUnset が
5960
+ * 先に使っており、どちらも truthy 判定なので位置を取り違えると静かに壊れる
5395
5961
  */
5396
- function openCapabilityModal(cap, items, note, noteIsUnset) {
5962
+ function openCapabilityModal(cap, items, note, noteIsUnset, adapter) {
5397
5963
  // **開いた能力を覚えて、その登録ファイルを読みに行く(段階1)。** 全能力を毎回
5398
5964
  // 読ませない代わりに、見ている能力だけを取りに行く
5399
5965
  noteOpenCap(cap);
5400
5966
  // 見出しは押したタイルのツール名(Backlog・Google ドライブ…)。capToolLabel の説明を参照
5401
- var m = makeModal(capToolLabel(cap), true);
5967
+ var m = makeModal(capToolLabel(cap, adapter), true);
5402
5968
  // **何が読み取られるのかを必ず書く。** 一覧に並ぶものと読み取られるものの関係は、
5403
5969
  // 書いていないと分からない(チャットは「載っていれば全部」、開発はリポジトリ単位でOFFできる)
5404
5970
  if (cap === "チャット") {
5405
- m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいるチャンネルは、例外なく全て毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。読み取りを止めるには「登録を解除」してください。" }));
5971
+ // 開示文の主語もアダプタで割る(#153)。単位(チャンネル/スペース)を割った理由と同じで、
5972
+ // スペースの一覧の上に「チャンネルは例外なく…」と出すと、最も重要な開示文だけが混線する
5973
+ // 混載(capabilities 無しの1枚タイル・adapter 不明)は両方の名詞で言う——
5974
+ // スペースの行の上に「チャンネルは例外なく…」と出すと開示が嘘になる(#153)
5975
+ var chatNoun = adapter === "google-chat" ? "スペース"
5976
+ : adapter === "slack" ? "チャンネル" : "チャンネル・スペース";
5977
+ // Google Chat には利用範囲の開示を添える(#153)。アプリのスコープは送信能力を含むので
5978
+ // 「投稿しません」とは**約束しない**——通知送信の仕組み(engine 側)が既にあるため、
5979
+ // 読み取りと通知先への投稿だけ、という正直な形で書く。混載でも出す(スペースが並びうる)
5980
+ // 「本文を保存しない」の一文はポータルのプライバシーポリシーと対(2026-08-31 オーナー方針)。
5981
+ // 変えるときは依頼キット(docs/google-chat-onboarding.md の B)とポリシー側も同時に
5982
+ var chatScope = adapter === "slack"
5983
+ ? ""
5984
+ : " Cortex のアプリは、会話の読み取りと、通知先に設定したスペースへの確認・通知の投稿だけに使われます。読み取った会話は、本文そのものを保存するのではなく、決定事項・用語などの抽出結果として案件の記録に反映されます。";
5985
+ m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいる" + chatNoun + "は、例外なく全て毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。読み取りを止めるには「登録を解除」してください。" + chatScope }));
5406
5986
  } else if (cap === "開発") {
5407
5987
  m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいるリポジトリの Issues を毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。読み取りをやめるには「登録から削除」してください。" }));
5408
5988
  }
@@ -5420,17 +6000,23 @@ ${deriveCapTile.toString()}
5420
6000
  // 「**設定に異常があります**」も同じ枠で運んでいるので、無条件に引き直すと
5421
6001
  // 異常が画面から消える——直したい誤読より悪い。
5422
6002
  //
5423
- // ⚠ 引き直しでは能力の代表アダプタの状態を使う(openCapabilityModal は
5424
- // どのアダプタのタイルから開かれたかを知らない。カタログは今日どの能力も1個)
6003
+ // ⚠ **引き直しは開いたタイルのアダプタで割る(#153)。** ここを代表アダプタ固定に
6004
+ // 戻すと、Google Chat のモーダルの説明が Slack の状態・detail から引き直される。
6005
+ // アダプタの分からない呼び出し(能力ではない見出し・混載の1枚タイル)だけ代表値。
5425
6006
  var noteText = note;
5426
6007
  if (noteIsUnset) {
5427
6008
  // 足した行があるなら、この一文(接続先が設定されていません)はもう事実ではない
5428
6009
  var capsNow = capsView();
5429
- var dNow = capabilityDisplayState(cap, capsNow);
6010
+ var dNow = adapter ? adapterDisplayState(cap, adapter, capsNow) : capabilityDisplayState(cap, capsNow);
5430
6011
  var infoNow = capsNow[cap];
5431
- noteText = capPending(cap) || !(dNow && dNow !== "on" && infoNow && infoNow.detail)
6012
+ // detail の解決はタイルと同じ正本(adapterResolved)。adapter 不明・null は代表値
6013
+ var rNow = adapter ? adapterResolved(cap, adapter, capsNow) : { state: null, detail: null };
6014
+ var detailNow = rNow.detail === null ? ((infoNow && infoNow.detail) || "") : rNow.detail;
6015
+ // 未知の state 語のフォールバックは**タイル側の modalNote と同じ式**(写しがずれると
6016
+ // 状態ラベル無しの「(説明文)」が出る)
6017
+ noteText = capPending(cap, adapter) || !(dNow && dNow !== "on" && detailNow)
5432
6018
  ? ""
5433
- : (CAP_STATE_TEXT[dNow] || "") + "(" + infoNow.detail + ")";
6019
+ : (CAP_STATE_TEXT[dNow] || "状態が判定できません") + "(" + detailNow + ")";
5434
6020
  }
5435
6021
  var noteEl = el("p", { class: "modal-desc", text: noteText });
5436
6022
  // **消さずに hidden で伏せる。** 節点を残しておかないと、この一文を探している
@@ -5453,6 +6039,944 @@ ${deriveCapTile.toString()}
5453
6039
  showModal(m.overlay, m.modal);
5454
6040
  }
5455
6041
 
6042
+ /* ---------------------------------------------------------------------------
6043
+ * ハーネス連携(7枚目の能力タイル)
6044
+ * ---------------------------------------------------------------------------
6045
+ * **既存のタイル(renderAdapterTile)には乗せない。** あちらは capabilityState を
6046
+ * 引くが、この能力は fleet-status.json にも capabilities にも無いので未知の能力の
6047
+ * 既定(off)へ落ち、2行目が「未設定」になる——タイルの状態表示が嘘になる。
6048
+ * 意味論も違う: ここで語るのは接続状態ではなく「何件オンか」と「配信先が無いか」。
6049
+ *
6050
+ * 一覧(何を選べるか)は提供側のリポジトリが持つので**ビルド時には焼けない**。
6051
+ * モーダルを開いたときに op=harness-catalog で取りに行く。
6052
+ * ------------------------------------------------------------------------- */
6053
+
6054
+ /**
6055
+ * 通知先になっているチャット行。**0件のままオンにすると、動いても誰にも届かない。**
6056
+ *
6057
+ * 読むのは fleetVal(登録の即時値がマージ済みの見え方)。写経すると
6058
+ * 「一覧には通知先の札が出ているのに、確認文は配信先なしと言う」が起きる。
6059
+ * **workspace を持つ行は除く**(読み取り専用の接続には通知が届かない・notifyToggle と同じ判定)。
6060
+ */
6061
+ function harnessNotifyTargets() {
6062
+ var srcs = fleetVal("externalSources") || [];
6063
+ return srcs.filter(function (s) { return s && s.notify === true && !s.workspace; });
6064
+ }
6065
+
6066
+ /** 配信先の呼び名。**# を付けるのは Slack だけ**(付け方の正本は slackDisplayName) */
6067
+ function harnessTargetLabel(s) {
6068
+ var name = s.name || s.ref || "";
6069
+ return s.type === "slack" ? slackDisplayName(name) : name;
6070
+ }
6071
+
6072
+ /**
6073
+ * 周期の説明(「毎日・朝9時ごろ」「毎週金曜・夜9時ごろ」)。
6074
+ *
6075
+ * **「ごろ」はラベル側が持つ**(ここで足すと二重になる)。時間帯が分からない行は
6076
+ * 既定へ倒さず「時間帯未設定」と言う——語彙外の値は落ちており、その行は実行されない
6077
+ * ので、「朝9時ごろに動きます」と書くと画面が嘘をつく。
6078
+ */
6079
+ function harnessPeriodText(slot, weekday) {
6080
+ var sl = HARNESS_SLOT_LABEL[slot];
6081
+ if (!sl) return "時間帯未設定";
6082
+ if (weekday && HARNESS_WEEKDAY_LABEL[weekday]) {
6083
+ return "毎週" + HARNESS_WEEKDAY_LABEL[weekday] + "・" + sl;
6084
+ }
6085
+ return "毎日・" + sl;
6086
+ }
6087
+
6088
+ /**
6089
+ * カタログの既定から言える周期(オフの行の表示と、オンにする確認文に使う)。
6090
+ *
6091
+ * 🔴 **既定が欠けていたら空文字=周期を言わない。** 既定の解決は intake が正本で、
6092
+ * ここで morning / fri を発明して「毎日・朝9時ごろ届き始めます」と言い切ると、
6093
+ * intake が別の値に解決したとき**確認文が嘘だったことになる**(契約裁定・レビュー Minor 5)。
6094
+ * H1 のカタログ CI は必須フィールドとして検証しているので、通常ここは空にならない。
6095
+ */
6096
+ function harnessDefaultPeriod(meta) {
6097
+ if (!meta || !meta.default_slot) return "";
6098
+ if (meta.cadence === "weekly" && !meta.default_weekday) return "";
6099
+ return harnessPeriodText(meta.default_slot, meta.cadence === "weekly" ? meta.default_weekday : null);
6100
+ }
6101
+
6102
+ /** いまオンになっているエントリ(無ければ null)。**一致鍵は harness + skill の2つだけ** */
6103
+ function harnessEntryOf(harness, skill) {
6104
+ for (var i = 0; i < HARNESS_SCHEDULES.length; i++) {
6105
+ var e = HARNESS_SCHEDULES[i];
6106
+ if (e.harness === harness && e.skill === skill) return e;
6107
+ }
6108
+ return null;
6109
+ }
6110
+
6111
+ function harnessMirrorRemove(harness, skill) {
6112
+ for (var i = HARNESS_SCHEDULES.length - 1; i >= 0; i--) {
6113
+ var e = HARNESS_SCHEDULES[i];
6114
+ if (e.harness === harness && e.skill === skill) HARNESS_SCHEDULES.splice(i, 1);
6115
+ }
6116
+ }
6117
+
6118
+ /**
6119
+ * 保存できたことをミラーへ反映する。
6120
+ *
6121
+ * 🔴 **正は応答の entry。** 既定(時間帯・曜日)を解決するのは intake なので、
6122
+ * UI が既定を写経して覚えると、既定を変えたときに二重帳簿になる。
6123
+ * entry を持たない応答(古い Lambda)のときだけ fallback を使う。
6124
+ *
6125
+ * **どちらも sanitizeHarnessSchedules を通す。** 壊れた entry(slot が配列など)を
6126
+ * そのまま持つと、周期の文に [object Object] が出る。
6127
+ */
6128
+ function harnessMirrorSet(harness, skill, entry, fallback) {
6129
+ var got = sanitizeHarnessSchedules([entry]);
6130
+ var row = got.length && got[0].harness === harness && got[0].skill === skill
6131
+ ? got[0]
6132
+ : sanitizeHarnessSchedules([fallback])[0] || { harness: harness, skill: skill };
6133
+ harnessMirrorRemove(harness, skill);
6134
+ HARNESS_SCHEDULES.push(row);
6135
+ }
6136
+
6137
+ /** 棚に置いてあるか。**一致鍵は harness + skill**(オンの判定と同じ) */
6138
+ function harnessShelfHas(harness, skill) {
6139
+ if (!HARNESS_SHELF) return false;
6140
+ for (var i = 0; i < HARNESS_SHELF.length; i++) {
6141
+ var it = HARNESS_SHELF[i];
6142
+ if (it.harness === harness && it.skill === skill) return true;
6143
+ }
6144
+ return false;
6145
+ }
6146
+
6147
+ /**
6148
+ * 棚のミラーへ足す。**最初の1件で null から配列へ変わる**——「まだ棚を作っていない」
6149
+ * 案件でピッカーから1件置くと、そこから先は「棚が在る」案件として振る舞う
6150
+ *(サーバ側でも shelf.json がこの1件目で作られる)。
6151
+ */
6152
+ function harnessShelfMirrorAdd(harness, skill) {
6153
+ if (!HARNESS_SHELF) HARNESS_SHELF = [];
6154
+ if (harnessShelfHas(harness, skill)) return;
6155
+ HARNESS_SHELF.push({ harness: harness, skill: skill });
6156
+ }
6157
+
6158
+ function harnessShelfMirrorRemove(harness, skill) {
6159
+ if (!HARNESS_SHELF) return;
6160
+ for (var i = HARNESS_SHELF.length - 1; i >= 0; i--) {
6161
+ var it = HARNESS_SHELF[i];
6162
+ if (it.harness === harness && it.skill === skill) HARNESS_SHELF.splice(i, 1);
6163
+ }
6164
+ }
6165
+
6166
+ /**
6167
+ * 棚の保存を**1件ずつ直列に**送るための鎖。
6168
+ *
6169
+ * 🔴 **並列に投げてはいけない。** 棚の操作は1件=1コミットで、同じファイルへの
6170
+ * 同時書き込みはサーバ側の commitWithRetry の衝突になる(ピッカーは連打される場所
6171
+ * ——「あれもこれも置く」が自然な操作なので、二重送信は正常系として起きる)。
6172
+ *
6173
+ * **失敗しても鎖を切らない**(then の第2引数にも同じ実行を渡す)——1件の失敗で
6174
+ * 以後の操作が永久に待たされる状態を作らない。sendConfig 自身は例外を投げないが、
6175
+ * 鎖の前段が何であっても次が走ることを、ここで保証しておく。
6176
+ */
6177
+ var harnessShelfChain = null;
6178
+ function harnessShelfSend(payload, msg, btn, onOk) {
6179
+ // 🔴 **onDone ではなく hooks.onSaved で受ける。** 棚の追加は冪等で、
6180
+ // 「既に在る」は {ok:true, changed:false} の**正常な成功**。onDone は
6181
+ // changed !== false のときしか流れないので、そこに置くと
6182
+ // 「サーバには在るのに画面は棚が空」で取り残される(次のビルドまで直らない)
6183
+ var run = function () { return sendConfig(payload, msg, btn, null, { onSaved: onOk }); };
6184
+ var next = (harnessShelfChain || Promise.resolve()).then(run, run);
6185
+ harnessShelfChain = next;
6186
+ return next;
6187
+ }
6188
+
6189
+ /**
6190
+ * セクションの骨組み(部署 → ハーネス)。**応答のトップレベルの departments が正本で、
6191
+ * 画面は名簿の写しを持たない。** 読めなければ null=この応答は骨組みを持たない
6192
+ *(古い Lambda)ので、従来どおりのフラット表示へ落とす。
6193
+ *
6194
+ * 🔴 **提供元の識別子(owner/repo)も部署の語彙キーも画面には出さない**(#124 の原則)。
6195
+ * 表示名が解決できていない(=サーバが識別子やキーをそのまま返した)ときは、
6196
+ * **名前を出さない**方に倒す——部署は「その他」、ハーネスは見出しごと出さない。
6197
+ */
6198
+ function harnessDepartmentsOf(j) {
6199
+ if (!j || typeof j !== "object" || Array.isArray(j)) return null;
6200
+ if (!Array.isArray(j.departments)) return null;
6201
+ var out = [];
6202
+ for (var i = 0; i < j.departments.length; i++) {
6203
+ var d = j.departments[i];
6204
+ if (d == null || typeof d !== "object" || Array.isArray(d)) continue;
6205
+ if (!Array.isArray(d.harnesses)) continue;
6206
+ var key = typeof d.key === "string" ? d.key : "";
6207
+ var list = [];
6208
+ for (var k = 0; k < d.harnesses.length; k++) {
6209
+ var hn = d.harnesses[k];
6210
+ if (hn == null || typeof hn !== "object" || Array.isArray(hn)) continue;
6211
+ // 名前の篩は行の篩と同じものを通す(描ける提供元だけを骨組みに載せる)
6212
+ if (sanitizeHarnessSchedules([{ harness: hn.harness, skill: "x" }]).length !== 1) continue;
6213
+ var hlabel = typeof hn.label === "string" ? hn.label : "";
6214
+ list.push({
6215
+ harness: hn.harness,
6216
+ label: hlabel && hlabel !== hn.harness ? hlabel : "",
6217
+ });
6218
+ }
6219
+ var dlabel = typeof d.label === "string" ? d.label : "";
6220
+ out.push({ key: key, label: dlabel && dlabel !== key ? dlabel : "", harnesses: list });
6221
+ }
6222
+ return out;
6223
+ }
6224
+
6225
+ /**
6226
+ * カタログの応答を、画面が扱ってよい形へ畳む。**読めなければ null(=異常表示)。**
6227
+ *
6228
+ * 🔴 **入口の判定は Array.isArray(j.harnesses)。** j.harnesses || [] と書くと、
6229
+ * harnesses の欄を持たない応答(古い Lambda・別 op のスタブ)が「正常の空」に化け、
6230
+ * 画面が「いま利用できる機能はありません」と、無いことを断言してしまう。
6231
+ *
6232
+ * **提供元の名前(owner/repo)も理由(HTTP コード等)も画面には出さない**(#124 の原則)。
6233
+ * errors はここでは件数としてしか使わない。
6234
+ */
6235
+ function harnessCatalogOf(j) {
6236
+ if (!j || typeof j !== "object" || Array.isArray(j)) return null;
6237
+ if (!Array.isArray(j.harnesses)) return null;
6238
+ var errors = Array.isArray(j.errors) ? j.errors : [];
6239
+ var providers = [];
6240
+ for (var i = 0; i < j.harnesses.length; i++) {
6241
+ var h = j.harnesses[i];
6242
+ if (h == null || typeof h !== "object" || Array.isArray(h)) continue;
6243
+ if (typeof h.harness !== "string" || !h.harness) continue;
6244
+ if (!Array.isArray(h.skills)) continue; // 形が違う提供元は行を出さない(0件と混ぜない)
6245
+ var skills = [];
6246
+ // 変数名を s にしない(fleet-passthrough.test.mjs の走査対象。ここはカタログ応答で
6247
+ // fleet-status 由来ではないため、番人に fleet の欄として数えさせない)
6248
+ for (var k = 0; k < h.skills.length; k++) {
6249
+ var sk = h.skills[k];
6250
+ if (sk == null || typeof sk !== "object" || Array.isArray(sk)) continue;
6251
+ // **表示名が無い行は落とす。** 代わりに識別子(内部語)を出すと、#124 と同じ
6252
+ // 「読み手に意味の分からない名前が並ぶ」画面になる
6253
+ if (typeof sk.display_name !== "string" || !sk.display_name) continue;
6254
+ // 🔴 **描ける行は必ずミラーにも通ることを、ここで保証する。** 名前の篩を写経せず
6255
+ // sanitizeHarnessSchedules へ通す——通らない名前の行を描くと、オンにした直後に
6256
+ // ミラーが落として行が「オフ」へ戻る(押しても何も起きないように見える)
6257
+ if (sanitizeHarnessSchedules([{ harness: h.harness, skill: sk.skill }]).length !== 1) continue;
6258
+ var weekly = sk.cadence === "weekly";
6259
+ skills.push({
6260
+ harness: h.harness,
6261
+ skill: sk.skill,
6262
+ display_name: sk.display_name,
6263
+ summary: typeof sk.summary === "string" ? sk.summary : "",
6264
+ cadence: weekly ? "weekly" : "daily",
6265
+ // 🔴 **既定が欠けていても発明しない(空文字=不明)。** 既定の解決は intake が
6266
+ // 正本で、ここで morning / fri を作ると確認文が「毎日・朝9時ごろ」と言い切り、
6267
+ // intake が別に解決したとき嘘になる。不明の扱いは harnessDefaultPeriod が持つ
6268
+ default_slot: HARNESS_SLOTS.indexOf(sk.default_slot) >= 0 ? sk.default_slot : "",
6269
+ default_weekday: weekly
6270
+ ? (HARNESS_WEEKDAYS.indexOf(sk.default_weekday) >= 0 ? sk.default_weekday : "")
6271
+ : null,
6272
+ });
6273
+ }
6274
+ providers.push({ harness: h.harness, skills: skills });
6275
+ }
6276
+ // **読めた提供元が1つも無く、読めなかった提供元がある → 異常。** ここで「0件」と
6277
+ // 言うと、実際には在るかもしれないものを「無い」と断言することになる
6278
+ if (!providers.length && errors.length) return null;
6279
+ return {
6280
+ providers: providers,
6281
+ errors: errors,
6282
+ partial: providers.length > 0 && errors.length > 0,
6283
+ // 骨組みを持たない応答(古い Lambda)では null。**セクション表示ごと落とす**
6284
+ // ——名簿を画面側に写経して補うと、名簿を1行足した日に画面だけが古いまま残る
6285
+ departments: harnessDepartmentsOf(j),
6286
+ };
6287
+ }
6288
+
6289
+ /** カタログの中の1機能(無ければ null) */
6290
+ function harnessMetaOf(cat, harness, skill) {
6291
+ for (var i = 0; i < cat.providers.length; i++) {
6292
+ var prov = cat.providers[i];
6293
+ if (prov.harness !== harness) continue;
6294
+ for (var k = 0; k < prov.skills.length; k++) {
6295
+ if (prov.skills[k].skill === skill) return prov.skills[k];
6296
+ }
6297
+ }
6298
+ return null;
6299
+ }
6300
+
6301
+ /** 一覧を読み込めなかった提供元(harness → true) */
6302
+ function harnessErroredMap(cat) {
6303
+ var errored = {};
6304
+ for (var i = 0; i < cat.errors.length; i++) {
6305
+ var er = cat.errors[i];
6306
+ if (er && typeof er === "object" && typeof er.harness === "string") errored[er.harness] = true;
6307
+ }
6308
+ return errored;
6309
+ }
6310
+
6311
+ /**
6312
+ * 語彙から select を作る。**現在値が語彙外のときだけ**先頭に空の選択肢を足す
6313
+ * ——既定へ勝手に倒すと、実際には実行されない行を「朝9時ごろ」と説明することになる。
6314
+ */
6315
+ function harnessSelect(vocab, labels, current, blankLabel, ariaLabel) {
6316
+ var known = vocab.indexOf(current) >= 0;
6317
+ var sel = el("select", { "aria-label": ariaLabel });
6318
+ if (!known) sel.appendChild(el("option", { value: "", text: blankLabel }));
6319
+ for (var i = 0; i < vocab.length; i++) {
6320
+ sel.appendChild(el("option", { value: vocab[i], text: labels[vocab[i]] }));
6321
+ }
6322
+ sel.value = known ? current : "";
6323
+ return sel;
6324
+ }
6325
+
6326
+ /**
6327
+ * オンにする前の確認。**何が・どこに届き始めるのかを書く。**
6328
+ *
6329
+ * 🔴 **「いつ」は文で言わない。** 確認の中に時間帯の select を出すようになったので
6330
+ *(オンにするのと同じ流れで決められる)、括弧書きで周期も書くと、
6331
+ * **利用者が select を変えた瞬間に文のほうが嘘になる**。いつ届くかは select が示す。
6332
+ *
6333
+ * 「よろしいですか?」もここには置かない——select より後に問うため、
6334
+ * 呼び出し側が body の末尾へ積む(問いのあとに答えを左右する操作を出さない)。
6335
+ */
6336
+ function harnessOnConfirmLines(meta) {
6337
+ var lines = [
6338
+ "オンにすると、この案件の通知先チャンネルに「" + meta.display_name + "」が届き始めます。",
6339
+ ];
6340
+ var targets = harnessNotifyTargets();
6341
+ if (targets.length) {
6342
+ lines.push("配信先: " + harnessTargetLabel(targets[0])
6343
+ + (targets.length > 1 ? " ほか" + (targets.length - 1) + "件" : "")
6344
+ + "(「チャット」の通知先設定に従います)。");
6345
+ } else {
6346
+ // **ここで止めない。** 先にオンにして、あとで通知先を決める順番もありうる。
6347
+ // 届かないことだけを、はっきり伝える
6348
+ lines.push("⚠ この案件にはまだ配信先(通知先チャンネル)が設定されていません。オンにしても、「チャット」の通知先を設定するまで届きません。");
6349
+ }
6350
+ return lines;
6351
+ }
6352
+
6353
+ /**
6354
+ * 棚に置く / 棚から外す を送る(棚ビューとピッカーの共通処理)。
6355
+ *
6356
+ * 🔴 **オン中の機能を外すときは、止まらないことを操作の瞬間に言う(設計 B-2)。**
6357
+ * 「棚から外す」と「配信が止まる」は別のことで、取り違えられると
6358
+ * 「外したのに毎朝届く」になる。**オフの機能では確認を出さない**——即時に戻せる
6359
+ * 操作なので、確認を出すのは notifyToggle と同じ判断で過剰。
6360
+ *
6361
+ * 送信は harnessShelfSend を通す(1件ずつ直列。並列は同じファイルへの同時コミット)。
6362
+ *
6363
+ * @param label 画面に出ている名前(確認文に使う。識別子は使わない)
6364
+ */
6365
+ async function harnessShelfToggle(harness, skill, label, msg, btn, after) {
6366
+ var onShelf = harnessShelfHas(harness, skill);
6367
+ if (onShelf && harnessEntryOf(harness, skill)) {
6368
+ var lines = [
6369
+ "「" + label + "」を、この案件で使う機能から外します。",
6370
+ "この機能はいまオンなので、外しても配信は止まりません。止めるには「オフにする」を押してください。",
6371
+ "よろしいですか?",
6372
+ ];
6373
+ // **赤で出さない。** 何かが消える操作ではなく、候補の一覧から外すだけ
6374
+ if (!(await openConfirm(lines, { okLabel: "使う機能から外す", returnTo: btn, okDanger: false }))) {
6375
+ msg.textContent = "";
6376
+ return;
6377
+ }
6378
+ }
6379
+ // 🔴 **順番が回ってくる前に、押したボタンを止める。** 送信は直列なので、
6380
+ // beginBusy(sendConfig の中)が効くのは自分の番が来てから——それまでは
6381
+ // 押せる見た目のまま残り、①連打で同じ追加が2本飛ぶ ②押した感触が無いので
6382
+ // 何度も押す、の両方が起きる。**戻すのは sendConfig の endBusy**
6383
+ btn.disabled = true;
6384
+ await harnessShelfSend(
6385
+ {
6386
+ op: onShelf ? "harness-shelf-remove" : "harness-shelf-add",
6387
+ harness: harness,
6388
+ skill: skill,
6389
+ },
6390
+ msg,
6391
+ btn,
6392
+ function () {
6393
+ // 応答は棚の中身を運ばないので、送った向きでミラーを当てる
6394
+ //(add は冪等・remove は無い行でも成功なので、向きだけで確定する)
6395
+ if (onShelf) harnessShelfMirrorRemove(harness, skill);
6396
+ else harnessShelfMirrorAdd(harness, skill);
6397
+ if (after) after();
6398
+ },
6399
+ );
6400
+ }
6401
+
6402
+ /**
6403
+ * 機能1件の行。
6404
+ *
6405
+ * @param meta カタログの中身。**null はゴースト行**(提供元から消えた機能。出せる
6406
+ * 表示名が無いので、オフにする手段だけを残す——出さないと画面から止められなくなる)
6407
+ * @param opts 棚ビュー専用の追加(骨組みを持たない応答では渡さない=従来の見た目)
6408
+ * - shelf … 棚の導線(置く/外す)を出すか
6409
+ * - unreadable … 一覧を読めなかった提供元の行か。**「もう無い」と言わない**(B-4)
6410
+ * ——次に開けば普通に並ぶので、消えたと伝えるのは嘘になる
6411
+ */
6412
+ function harnessRow(harness, skill, meta, opts) {
6413
+ opts = opts || {};
6414
+ var row = el("div", { class: "ds-harness-row" });
6415
+ var label = meta
6416
+ ? meta.display_name
6417
+ : (opts.unreadable ? "(名前を読み込めなかった機能)" : "(提供元にもう無い機能)");
6418
+ var name = el("div", { class: "ds-harness-name", text: label });
6419
+ var period = el("div", { class: "ds-harness-period" });
6420
+ row.appendChild(el("div", { class: "ds-harness-head" }, [name, period]));
6421
+ if (meta && meta.summary) row.appendChild(el("div", { class: "ds-harness-summary", text: meta.summary }));
6422
+ // 「棚に無いのにオンになっている」ことの注記。**棚が在るときだけ出す**(B-1)
6423
+ // ——棚を作っていない案件で出すと、既存案件が導入直後に注記だらけになる
6424
+ var note = el("div", { class: "ds-harness-note" });
6425
+ note.hidden = true;
6426
+ row.appendChild(note);
6427
+ var btn = el("button", { class: "ds-toggle-btn", type: "button" });
6428
+ var selects = el("span", { class: "ds-harness-selects" });
6429
+ var msg = el("div", { class: "ds-toggle-msg" });
6430
+ var shelfBtn = opts.shelf
6431
+ ? el("button", { class: "ds-toggle-btn ds-harness-shelf-btn", type: "button" })
6432
+ : null;
6433
+ // **トグルは必ず先頭に置く**(行の先頭のボタン=オン/オフ、が既存の検査の前提)
6434
+ row.appendChild(el("div", { class: "ds-toggle" }, [btn, selects, shelfBtn, msg]));
6435
+ if (shelfBtn) {
6436
+ shelfBtn.addEventListener("click", function () {
6437
+ harnessShelfToggle(harness, skill, label, msg, shelfBtn, paint);
6438
+ });
6439
+ }
6440
+
6441
+ /**
6442
+ * 時間帯(毎週なら曜日も)の調整。**確認モーダルは出さない**——同じ画面から
6443
+ * 即時に戻せる操作で、notifyToggle と同じ判断。
6444
+ */
6445
+ function buildSelects(e) {
6446
+ var weekly = meta.cadence === "weekly";
6447
+ var slotSel = harnessSelect(HARNESS_SLOTS, HARNESS_SLOT_LABEL, e.slot, "時間帯未設定", "時間帯");
6448
+ var daySel = weekly
6449
+ ? harnessSelect(HARNESS_WEEKDAYS, HARNESS_WEEKDAY_LABEL, e.weekday, "曜日未設定", "曜日")
6450
+ : null;
6451
+ var prevSlot = slotSel.value;
6452
+ var prevDay = daySel ? daySel.value : "";
6453
+ var onChange = async function () {
6454
+ var payload = { op: "harness-schedule-set", harness: harness, skill: skill };
6455
+ // 🔴 **時間帯と曜日は両方送る。** set は同じ harness+skill の置き換えで、
6456
+ // 省略したキーは既定へ解決されるので、片方だけ送るともう片方が既定へ巻き戻る。
6457
+ // 値が分からない(語彙外で落ちていた)ぶんだけは送らず、既定の解決に委ねる
6458
+ if (slotSel.value) payload.slot = slotSel.value;
6459
+ if (daySel && daySel.value) payload.weekday = daySel.value;
6460
+ slotSel.disabled = true;
6461
+ if (daySel) daySel.disabled = true;
6462
+ // sendConfig は例外を投げず戻り値も持たないので、成否は onDone で受ける
6463
+ var applied = false;
6464
+ await sendConfig(payload, msg, null, function (j) {
6465
+ applied = true;
6466
+ harnessMirrorSet(harness, skill, j && j.entry, {
6467
+ harness: harness, skill: skill, slot: payload.slot, weekday: payload.weekday,
6468
+ });
6469
+ var now = harnessEntryOf(harness, skill);
6470
+ if (now) {
6471
+ period.textContent = harnessPeriodText(now.slot, now.weekday);
6472
+ if (now.slot) slotSel.value = now.slot;
6473
+ if (daySel && now.weekday) daySel.value = now.weekday;
6474
+ }
6475
+ prevSlot = slotSel.value;
6476
+ prevDay = daySel ? daySel.value : "";
6477
+ });
6478
+ slotSel.disabled = false;
6479
+ if (daySel) daySel.disabled = false;
6480
+ // **失敗したら元の値へ戻す。** 画面だけが変わった状態を残すと、次に開いたとき
6481
+ //(焼き直しの後)に理由もなく値が戻る
6482
+ if (!applied) {
6483
+ slotSel.value = prevSlot;
6484
+ if (daySel) daySel.value = prevDay;
6485
+ }
6486
+ };
6487
+ slotSel.addEventListener("change", onChange);
6488
+ if (daySel) daySel.addEventListener("change", onChange);
6489
+ selects.appendChild(slotSel);
6490
+ if (daySel) selects.appendChild(daySel);
6491
+ }
6492
+
6493
+ /**
6494
+ * いまの向きに合わせて当て直す。**行ごと作り直さない**——作り直すと
6495
+ * sendConfig が書いた「✓ 保存しました。」ごと捨てることになる。
6496
+ */
6497
+ function paint() {
6498
+ var e = harnessEntryOf(harness, skill);
6499
+ btn.textContent = e ? "オフにする" : "オンにする";
6500
+ // **オフにしたゴースト行はボタンごと止める。** カタログに中身が無いのでオンには
6501
+ // 戻せない——押せる見た目のまま黙って何もしないボタンを残さない
6502
+ btn.disabled = !e && !meta;
6503
+ if (opts.shelf) {
6504
+ var onShelf = harnessShelfHas(harness, skill);
6505
+ // 🔴 **画面に「棚」の語を出さない(分類B・リーダー承認)。** 内部では棚と呼ぶが、
6506
+ // 利用者に見えるのは「この案件で使う機能」という1つの言い方だけにする
6507
+ //(動詞もピッカーと揃える——同じ操作が2つの名前で呼ばれない)
6508
+ shelfBtn.textContent = onShelf ? "使う機能から外す" : "使う機能に追加";
6509
+ // 🔴 **棚にあるが提供元から消えた機能は「外す」だけの行にする(B-5)。**
6510
+ // オンにも戻せないボタンを並べると、腐った行を誰も片付けられないように見える
6511
+ btn.hidden = !meta && !e;
6512
+ // 棚にも無く提供元にも無い行(外した直後・オフにした直後)には、
6513
+ // 置き直す口を出さない——実在しない機能を棚へ戻せてしまう
6514
+ shelfBtn.hidden = !meta && !onShelf;
6515
+ // **「棚から外してもオン中は表示に残る」の実体**(B-2 の約束が守られていることが、
6516
+ // 外した直後の画面そのもので見える)。
6517
+ // 🔴 **棚を作っていない案件(HARNESS_SHELF が null)では出さない(B-1)。**
6518
+ // 出すと、この機能の導入直後に既存案件のオン中の行が全部この注記を持つ
6519
+ var stray = !!e && !onShelf && !!HARNESS_SHELF;
6520
+ note.hidden = !stray;
6521
+ if (stray) note.textContent = "この案件で使う機能には入っていませんが、オンになっています。";
6522
+ }
6523
+ // **オフの行はカタログの既定で周期を出す**(オンの行は定義ファイルの具体値)。
6524
+ // 既定が欠けている行では空=周期を言わない(harnessDefaultPeriod の項。
6525
+ // 「時間帯未設定」はオンの行の語——オフの行に出すと、設定の抜けがあるように読める)
6526
+ period.textContent = e
6527
+ ? harnessPeriodText(e.slot, e.weekday)
6528
+ : harnessDefaultPeriod(meta);
6529
+ while (selects.firstChild) selects.removeChild(selects.firstChild);
6530
+ // ゴースト行には select を出さない(カタログが無いので周期の語彙が決まらない)
6531
+ if (e && meta) buildSelects(e);
6532
+ }
6533
+
6534
+ btn.addEventListener("click", async function () {
6535
+ if (harnessEntryOf(harness, skill)) {
6536
+ // **オフは確認を出さない。** 配信が止まる向きだが、同じ画面から即時に戻せる
6537
+ //(取り消せない「登録を解除」とは扱いを分ける・notifyToggle と同じ判断)
6538
+ await sendConfig({ op: "harness-schedule-remove", harness: harness, skill: skill }, msg, btn, function () {
6539
+ harnessMirrorRemove(harness, skill);
6540
+ paint();
6541
+ });
6542
+ return;
6543
+ }
6544
+ if (!meta) return; // ゴースト行はオンにできない(カタログに中身が無い)
6545
+ var weekly = meta.cadence === "weekly";
6546
+ // **確認の中で時間帯(毎週なら曜日も)まで決める。** オンにしてから調整させると、
6547
+ // 「何を・いつ受け取るか」を決めるのに2手かかる(オーナー実機フィードバック)。
6548
+ // **開くたびに組み直す。** 使い回すと、キャンセルした選択が次に開いたときも残り、
6549
+ // 「既定でオンにする」つもりの人が前回いじった値でオンにしてしまう
6550
+ var slotSel = harnessSelect(HARNESS_SLOTS, HARNESS_SLOT_LABEL, meta.default_slot, "時間帯未設定", "時間帯");
6551
+ var daySel = weekly
6552
+ ? harnessSelect(HARNESS_WEEKDAYS, HARNESS_WEEKDAY_LABEL, meta.default_weekday, "曜日未設定", "曜日")
6553
+ : null;
6554
+ var picks = el("span", { class: "ds-harness-selects" }, [
6555
+ el("span", { class: "confirm-body-label", text: "届く時間帯:" }),
6556
+ slotSel,
6557
+ daySel,
6558
+ ]);
6559
+ var body = el("div", { class: "confirm-body" }, [picks]);
6560
+ // 🔴 **既定が欠けている(=選択が空の)ときだけ、保存時に決まることを言う。**
6561
+ // 空のまま OK すると intake が既定を**決定として**書くので、「未設定」の見た目の
6562
+ // まま黙っていると、画面と保存結果が食い違う。具体値が見えているときは言わない
6563
+ //(下の select が示していることを、文でもう一度言うことになる)
6564
+ if (!slotSel.value || (daySel && !daySel.value)) {
6565
+ body.appendChild(el("p", { class: "confirm-line", text: "時間帯は保存時に決まり、あとでこの画面で調整できます。" }));
6566
+ }
6567
+ // **問いは選択の後ろ。** lines に混ぜると「よろしいですか?→[選択]→[ボタン]」と
6568
+ // なり、問うたあとに答えを左右する操作が出る
6569
+ body.appendChild(el("p", { class: "confirm-line", text: "よろしいですか?" }));
6570
+ // **OK は赤くしない。** 危険な向きではなく「届き始める」向きの操作なので、
6571
+ // 削除と同じ赤で出すと、止めるべき操作に見える
6572
+ if (!(await openConfirm(harnessOnConfirmLines(meta), { okLabel: "オンにする", returnTo: btn, okDanger: false, body: body }))) {
6573
+ msg.textContent = "";
6574
+ return;
6575
+ }
6576
+ // 🔴 **既定と同じ値は送らない。** 既定の解決は intake が正本で、UI が写経して送ると
6577
+ // 既定を変えたときに二重帳簿になる。送るのは利用者が既定から**変えた**ぶんだけ
6578
+ //(set は置き換えだが、省いたキーは intake が既定へ解決する=見えていた初期選択に
6579
+ // なるので、画面と食い違わない)。
6580
+ // **select はもう DOM から外れている**(確認が閉じた)が、.value は読める
6581
+ var payload = { op: "harness-schedule-set", harness: harness, skill: skill };
6582
+ if (slotSel.value && slotSel.value !== (meta.default_slot || "")) payload.slot = slotSel.value;
6583
+ if (daySel && daySel.value && daySel.value !== (meta.default_weekday || "")) payload.weekday = daySel.value;
6584
+ await sendConfig(payload, msg, btn, function (j) {
6585
+ harnessMirrorSet(harness, skill, j && j.entry, {
6586
+ harness: harness,
6587
+ skill: skill,
6588
+ // 応答が entry を持たないときの当て先。**選んだ値で当てる**(既定固定にすると、
6589
+ // 変えてオンにした直後の行が既定の周期を出す)
6590
+ slot: payload.slot || meta.default_slot,
6591
+ weekday: weekly ? (payload.weekday || meta.default_weekday) : undefined,
6592
+ });
6593
+ paint();
6594
+ });
6595
+ });
6596
+
6597
+ paint();
6598
+ return row;
6599
+ }
6600
+
6601
+ /**
6602
+ * 骨組みを持たない応答(古い Lambda)のときの中身。**従来どおりのフラット表示。**
6603
+ * 行が1つも無ければ「0件」の文言に倒す。
6604
+ *
6605
+ * **ここに棚の絞り込みを持ち込まない。** 骨組みが無い=部署もハーネスの表示名も
6606
+ * 無い状態で棚だけ効かせると、「選んだはずの機能がどこにも見当たらない」画面になる。
6607
+ */
6608
+ function harnessFillList(list, setState, warn, cat) {
6609
+ var errored = harnessErroredMap(cat);
6610
+ var seen = {};
6611
+ var rows = 0;
6612
+ for (var p = 0; p < cat.providers.length; p++) {
6613
+ var prov = cat.providers[p];
6614
+ for (var k = 0; k < prov.skills.length; k++) {
6615
+ var meta = prov.skills[k];
6616
+ seen[meta.harness + "\\t" + meta.skill] = true;
6617
+ list.appendChild(harnessRow(meta.harness, meta.skill, meta));
6618
+ rows += 1;
6619
+ }
6620
+ }
6621
+ // **カタログに無いのにオンになっている行も出す(ゴースト行)。** 出さないと、
6622
+ // 提供側が機能を外したあとに画面からオフにする手段が消える(remove はカタログ検証不要)
6623
+ for (var s = 0; s < HARNESS_SCHEDULES.length; s++) {
6624
+ var ent = HARNESS_SCHEDULES[s];
6625
+ if (seen[ent.harness + "\\t" + ent.skill]) continue;
6626
+ // **一覧が読めなかっただけの提供元の行に「もう無い」と言わない。** 次に開けば
6627
+ // 普通の行として並ぶので、消えたと伝えるのは嘘になる(タイルの件数には残す)
6628
+ if (errored[ent.harness]) continue;
6629
+ list.appendChild(harnessRow(ent.harness, ent.skill, null));
6630
+ rows += 1;
6631
+ }
6632
+ // 行が並んだ/一部だけ読めた(警告が事情を説明する)ときは、状態の1行を伏せる
6633
+ if (rows || cat.partial) setState("");
6634
+ else setState("いま利用できる機能はありません。機能が追加されると、ここに並びます。");
6635
+ warn.hidden = !cat.partial;
6636
+ }
6637
+
6638
+ /**
6639
+ * 描く順番(部署 → ハーネス)を決める。
6640
+ *
6641
+ * 🔴 **骨組みに無い提供元を落とさない。** 名簿から外れた提供元の行が棚やオンに
6642
+ * 残っていることはある(名簿は艦隊側の都合で変わる)。骨組みだけで描くと、
6643
+ * その行は**画面から消えて誰も外せなくなる**——ゴースト行と同じ理由で「その他」へ回す。
6644
+ *
6645
+ * 同じ見出しは1つに畳む(部署の表示名が解決できない行と「その他」が2つに割れない)。
6646
+ */
6647
+ function harnessSectionPlan(cat) {
6648
+ var plan = [];
6649
+ var placed = {};
6650
+ var groupFor = function (label) {
6651
+ for (var g = 0; g < plan.length; g++) if (plan[g].label === label) return plan[g];
6652
+ var made = { label: label, harnesses: [] };
6653
+ plan.push(made);
6654
+ return made;
6655
+ };
6656
+ for (var i = 0; i < cat.departments.length; i++) {
6657
+ var dep = cat.departments[i];
6658
+ var group = groupFor(dep.label || "その他");
6659
+ for (var k = 0; k < dep.harnesses.length; k++) {
6660
+ var hn = dep.harnesses[k];
6661
+ if (placed[hn.harness]) continue;
6662
+ placed[hn.harness] = true;
6663
+ group.harnesses.push(hn);
6664
+ }
6665
+ }
6666
+ var addExtra = function (harness) {
6667
+ if (placed[harness]) return;
6668
+ placed[harness] = true;
6669
+ // 表示名が分からない提供元。**識別子は見出しに出さない**(小見出しごと出さない)
6670
+ groupFor("その他").harnesses.push({ harness: harness, label: "" });
6671
+ };
6672
+ for (var p = 0; p < cat.providers.length; p++) addExtra(cat.providers[p].harness);
6673
+ if (HARNESS_SHELF) {
6674
+ for (var s = 0; s < HARNESS_SHELF.length; s++) addExtra(HARNESS_SHELF[s].harness);
6675
+ }
6676
+ for (var e = 0; e < HARNESS_SCHEDULES.length; e++) addExtra(HARNESS_SCHEDULES[e].harness);
6677
+ return plan;
6678
+ }
6679
+
6680
+ /**
6681
+ * 1つの提供元について、棚ビューに出す機能を並べ順つきで決める。
6682
+ *
6683
+ * 出すのは **棚にあるもの ∪ オンになっているもの**。
6684
+ * 🔴 **オンの機能は棚に無くても必ず出す。** 棚で隠すと、その機能を画面からオフに
6685
+ * できなくなる(ゴースト行と同じ理由)。
6686
+ *
6687
+ * 並びは**カタログ順**(提供側が決めた順)→ カタログに無いもの。
6688
+ * **棚の配列順は使わない**——棚は集合で、置いた順に意味は無い。
6689
+ */
6690
+ function harnessShelfRowsFor(cat, harness) {
6691
+ var out = [];
6692
+ var seen = {};
6693
+ var take = function (skill) {
6694
+ if (seen[skill]) return;
6695
+ seen[skill] = true;
6696
+ out.push(skill);
6697
+ };
6698
+ for (var i = 0; i < cat.providers.length; i++) {
6699
+ var prov = cat.providers[i];
6700
+ if (prov.harness !== harness) continue;
6701
+ for (var k = 0; k < prov.skills.length; k++) {
6702
+ var sk = prov.skills[k].skill;
6703
+ if (harnessShelfHas(harness, sk) || harnessEntryOf(harness, sk)) take(sk);
6704
+ }
6705
+ }
6706
+ // カタログに無いもの(提供元から消えた・一覧を読めなかった)を後ろへ
6707
+ if (HARNESS_SHELF) {
6708
+ for (var s = 0; s < HARNESS_SHELF.length; s++) {
6709
+ if (HARNESS_SHELF[s].harness === harness) take(HARNESS_SHELF[s].skill);
6710
+ }
6711
+ }
6712
+ for (var e = 0; e < HARNESS_SCHEDULES.length; e++) {
6713
+ if (HARNESS_SCHEDULES[e].harness === harness) take(HARNESS_SCHEDULES[e].skill);
6714
+ }
6715
+ return out;
6716
+ }
6717
+
6718
+ /**
6719
+ * 棚ビュー(既定)を描く。**部署セクション → ハーネス小見出し → 機能の行。**
6720
+ *
6721
+ * **開き直しではなく描き直しで呼ばれる**(ピッカーから戻ったとき)ので、
6722
+ * 先に中身を空にする。中身が無いセクション・小見出しは立てない
6723
+ *(「中身の無い見出しを立てない」は能力タイル側と同じ約束)。
6724
+ */
6725
+ function harnessRenderShelf(list, setState, cat) {
6726
+ while (list.firstChild) list.removeChild(list.firstChild);
6727
+ var errored = harnessErroredMap(cat);
6728
+ var plan = harnessSectionPlan(cat);
6729
+ var rows = 0;
6730
+ for (var i = 0; i < plan.length; i++) {
6731
+ var group = plan[i];
6732
+ var inner = el("div", { class: "ds-harness-dep-body" });
6733
+ var groupRows = 0;
6734
+ for (var k = 0; k < group.harnesses.length; k++) {
6735
+ var hn = group.harnesses[k];
6736
+ var skills = harnessShelfRowsFor(cat, hn.harness);
6737
+ var box = el("div", { class: "ds-harness-list" });
6738
+ var boxRows = 0;
6739
+ for (var s = 0; s < skills.length; s++) {
6740
+ var meta = harnessMetaOf(cat, hn.harness, skills[s]);
6741
+ var on = !!harnessEntryOf(hn.harness, skills[s]);
6742
+ var bad = !!errored[hn.harness];
6743
+ // **読めなかった提供元の「棚にあるだけ」の行は出さない。** 消えたのか
6744
+ // 読めなかったのかを言えないので、どう書いても嘘になる(次に開けば並ぶ)
6745
+ if (!meta && bad && !on) continue;
6746
+ box.appendChild(harnessRow(hn.harness, skills[s], meta, {
6747
+ // 🔴 一覧を読めなかった提供元のオン中の行は、棚の導線を出さない(B-4)。
6748
+ // 棚に在るかどうかは分かるが、「もう無い」のか「読めなかった」のかが
6749
+ // 分からないまま外す口だけ出すと、直せる状態を誤って片付けさせる
6750
+ shelf: !meta && bad ? false : true,
6751
+ unreadable: !meta && bad,
6752
+ }));
6753
+ boxRows += 1;
6754
+ }
6755
+ if (!boxRows) continue;
6756
+ // 表示名の無い提供元では小見出しごと出さない(識別子は画面に出さない)
6757
+ if (hn.label) inner.appendChild(el("h4", { class: "ds-harness-prov-title", text: hn.label }));
6758
+ if (errored[hn.harness]) {
6759
+ inner.appendChild(el("p", {
6760
+ class: "ds-harness-subnote",
6761
+ text: "この提供元の一覧を、いま読み込めませんでした。ここに並んでいない機能があるかもしれません。",
6762
+ }));
6763
+ }
6764
+ inner.appendChild(box);
6765
+ groupRows += boxRows;
6766
+ }
6767
+ if (!groupRows) continue;
6768
+ list.appendChild(el("div", { class: "ds-harness-dep" }, [
6769
+ el("h3", { class: "ds-harness-dep-title", text: group.label }),
6770
+ inner,
6771
+ ]));
6772
+ rows += groupRows;
6773
+ }
6774
+ if (rows || cat.partial) { setState(""); return; }
6775
+ // 🔴 **「棚が無い」と「棚が空」を言い分ける(B-1)。** 前者はまだ何も選んでいない
6776
+ // 案件、後者は選んだうえで全部外した案件で、次にすることが違う
6777
+ setState(HARNESS_SHELF
6778
+ // **主語を先に置く**(何の話かを言ってから、その状態を言う)
6779
+ ? "この案件で使う機能は、いま1件もありません。「機能をえらぶ」から追加できます。"
6780
+ : "この案件で使う機能は、まだ選んでいません。「機能をえらぶ」から選べます。");
6781
+ }
6782
+
6783
+ /**
6784
+ * ピッカー(機能をえらぶ)を描く。**全カタログを部署 → ハーネスで並べ、
6785
+ * 各機能に「棚に置く / 置いてある(外す)」を出す。**
6786
+ *
6787
+ * 一覧を読めなかった提供元・カタログを持たない提供元は並べない(選べるものが無い)。
6788
+ * **オン中の機能には印を付ける**(B-2: 外しても止まらないことが読めるように)。
6789
+ */
6790
+ function harnessRenderPicker(box, setPickState, cat) {
6791
+ while (box.firstChild) box.removeChild(box.firstChild);
6792
+ var plan = harnessSectionPlan(cat);
6793
+ var rows = 0;
6794
+ for (var i = 0; i < plan.length; i++) {
6795
+ var group = plan[i];
6796
+ var inner = el("div", { class: "ds-harness-dep-body" });
6797
+ var groupRows = 0;
6798
+ for (var k = 0; k < group.harnesses.length; k++) {
6799
+ var hn = group.harnesses[k];
6800
+ var list = el("div", { class: "ds-harness-list" });
6801
+ var listRows = 0;
6802
+ for (var p = 0; p < cat.providers.length; p++) {
6803
+ var prov = cat.providers[p];
6804
+ if (prov.harness !== hn.harness) continue;
6805
+ for (var s = 0; s < prov.skills.length; s++) {
6806
+ list.appendChild(harnessPickRow(prov.skills[s]));
6807
+ listRows += 1;
6808
+ }
6809
+ }
6810
+ if (!listRows) continue;
6811
+ if (hn.label) inner.appendChild(el("h4", { class: "ds-harness-prov-title", text: hn.label }));
6812
+ inner.appendChild(list);
6813
+ groupRows += listRows;
6814
+ }
6815
+ if (!groupRows) continue;
6816
+ box.appendChild(el("div", { class: "ds-harness-dep" }, [
6817
+ el("h3", { class: "ds-harness-dep-title", text: group.label }),
6818
+ inner,
6819
+ ]));
6820
+ rows += groupRows;
6821
+ }
6822
+ setPickState(rows ? "" : "いま選べる機能はありません。機能が追加されると、ここに並びます。");
6823
+ }
6824
+
6825
+ /** ピッカーの1行。**オン/オフは触らせない**(ここで決めるのは棚に置くかどうかだけ) */
6826
+ function harnessPickRow(meta) {
6827
+ var row = el("div", { class: "ds-harness-row" });
6828
+ var head = el("div", { class: "ds-harness-head" }, [
6829
+ el("div", { class: "ds-harness-name", text: meta.display_name }),
6830
+ el("div", { class: "ds-harness-period", text: harnessDefaultPeriod(meta) }),
6831
+ ]);
6832
+ // **オン中であることは、この画面でも見えていないといけない。** 見えないまま
6833
+ // 「置いてある(外す)」を押すと、止まると受け取られる(B-2)
6834
+ if (harnessEntryOf(meta.harness, meta.skill)) {
6835
+ head.appendChild(el("span", { class: "ds-harness-onmark", text: "オン中" }));
6836
+ }
6837
+ row.appendChild(head);
6838
+ if (meta.summary) row.appendChild(el("div", { class: "ds-harness-summary", text: meta.summary }));
6839
+ var btn = el("button", { class: "ds-toggle-btn ds-harness-shelf-btn", type: "button" });
6840
+ var msg = el("div", { class: "ds-toggle-msg" });
6841
+ row.appendChild(el("div", { class: "ds-toggle" }, [btn, msg]));
6842
+ var paint = function () {
6843
+ // 動詞は棚ビューと揃える(追加/外す)。印は選択中であることを示すためだけに付ける
6844
+ btn.textContent = harnessShelfHas(meta.harness, meta.skill) ? "✓ 追加済み(外す)" : "+ 使う機能に追加";
6845
+ };
6846
+ btn.addEventListener("click", function () {
6847
+ harnessShelfToggle(meta.harness, meta.skill, meta.display_name, msg, btn, paint);
6848
+ });
6849
+ paint();
6850
+ return row;
6851
+ }
6852
+
6853
+ /**
6854
+ * ハーネス連携のモーダル。**開いたときに一覧を取りに行く**(カタログは提供側の
6855
+ * リポジトリが持つので、ビルド時には焼けない)。
6856
+ *
6857
+ * openCapabilityModal は使わない——items 機構にも noteOpenCap(能力ごとの登録の
6858
+ * 読み直し)にも乗らないため。閉じるボタンと showModal の作法だけ同じ形にしてある。
6859
+ */
6860
+ var harnessGen = 0;
6861
+ function openHarnessModal() {
6862
+ var intake = SITE.intake;
6863
+ if (!intake) return;
6864
+ // 世代トークン。**応答が届いたときに、まだこの一覧が生きているかを判定する**
6865
+ //(押し替え・閉じたあとに親前提の DOM 操作をしない)
6866
+ harnessGen += 1;
6867
+ var gen = harnessGen;
6868
+ var m = makeModal("ハーネス連携", true);
6869
+ m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並ぶ機能をオンにすると、決まった時間に自動で動き、結果がこの案件の通知先チャンネル(「チャット」の通知先設定)に届きます。オンにするまで、何も動きません。" }));
6870
+ // 一覧の代わりに出す1行(読み込み中 → 0件/読み込めなかった)。**3状態を必ず区別する**
6871
+ var stateEl = el("p", { class: "ds-harness-state", text: "読み込み中…" });
6872
+ var warn = el("p", { class: "ds-harness-warn", text: "一部の提供元から一覧を読み込めませんでした。ここに並んでいない機能があるかもしれません。時間をおいて開き直してください。" });
6873
+ warn.hidden = true;
6874
+ var list = el("div", { class: "ds-harness-list" });
6875
+ // 「機能をえらぶ」(ピッカーへの入口)。**骨組みが読めたときだけ出す**
6876
+ // ——部署もハーネスの表示名も無いままピッカーを出すと、ただの長い一覧になる
6877
+ var choose = el("button", { class: "ds-toggle-btn ds-harness-choose", type: "button", text: "機能をえらぶ" });
6878
+ choose.hidden = true;
6879
+ // 棚ビューは1つの箱にまとめる(ピッカーと**丸ごと**入れ替えるため)
6880
+ var shelfView = el("div", { class: "ds-harness-shelf" }, [
6881
+ stateEl,
6882
+ warn,
6883
+ list,
6884
+ el("div", { class: "ds-harness-actions" }, [choose]),
6885
+ ]);
6886
+ m.modal.appendChild(shelfView);
6887
+ // ピッカーは**同じモーダルの中のもう1つのビュー**(重ねない)。
6888
+ // 🔴 **hidden で切り替える。** 一覧を DOM から外すと alive() が false になり、
6889
+ // 遅れて届いた応答が捨てられて「読み込み中…」のまま固まる(設計 B-3)。
6890
+ var pickState = el("p", { class: "ds-harness-pick-state" });
6891
+ pickState.hidden = true;
6892
+ var pickBody = el("div", { class: "ds-harness-pick-list" });
6893
+ var back = el("button", { class: "ds-toggle-btn ds-harness-back", type: "button", text: "← 戻る" });
6894
+ var picker = el("div", { class: "ds-harness-picker" }, [
6895
+ el("div", { class: "ds-harness-actions" }, [back]),
6896
+ el("p", { class: "modal-desc", text: "この案件で使う機能を選びます。追加しただけでは動きません(動かすには、戻ってオンにしてください)。" }),
6897
+ pickState,
6898
+ pickBody,
6899
+ ]);
6900
+ picker.hidden = true;
6901
+ m.modal.appendChild(picker);
6902
+ m.modal.appendChild(el("div", { class: "modal-actions" }, [
6903
+ el("button", { class: "ds-toggle-btn", type: "button", text: "閉じる" }),
6904
+ ]));
6905
+ m.modal.querySelector(".modal-actions button").addEventListener("click", function () { m.close(); });
6906
+ m.overlay.appendChild(m.modal);
6907
+ showModal(m.overlay, m.modal);
6908
+
6909
+ /** 生きている一覧か(閉じた・押し替えた後は何もしない) */
6910
+ var alive = function () { return gen === harnessGen && app.contains(list); };
6911
+ /**
6912
+ * 状態の1行を差し替える。**節点は残して文言を空にする**(消すと、この一文を
6913
+ * 探している検査が「文言が変わった」と「箱ごと無くなった」を区別できない)
6914
+ */
6915
+ var setState = function (text, isError) {
6916
+ stateEl.className = "ds-harness-state" + (isError ? " is-error" : "");
6917
+ stateEl.textContent = text;
6918
+ stateEl.hidden = text === "";
6919
+ };
6920
+ var fail = function () {
6921
+ if (!alive()) return;
6922
+ setState("利用できる機能の一覧を読み込めませんでした。時間をおいて開き直してください。", true);
6923
+ };
6924
+ // **fetch を持たない環境(JSDOM 素)では取りに行かない。** 素で呼ぶと
6925
+ // ReferenceError になり、タイルを押しただけで画面が落ちる(liveConfirmPossible と同形)
6926
+ if (typeof fetch !== "function") { fail(); return; }
6927
+ var ctrl = typeof AbortController === "function" ? new AbortController() : null;
6928
+ // 見切りは op=state と同じ10秒。**新しいリテラルを作らない**——値が割れると
6929
+ // 「10秒で戻る」検査が通ったまま実機では戻らなくなる
6930
+ var giveUp = ctrl ? setTimeout(function () { ctrl.abort(); }, LIVE_CONFIRM_TIMEOUT_MS) : null;
6931
+ // **成功・失敗の両方で外す。** 残すと、window を閉じないテストで node --test が終わらない
6932
+ var clearGiveUp = function () { if (giveUp != null) { clearTimeout(giveUp); giveUp = null; } };
6933
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
6934
+ // GET で取る。**カスタムヘッダは付けない**(preflight を1往復増やさないため)
6935
+ fetch(pfx + "op=harness-catalog&t=" + encodeURIComponent(intake.token), {
6936
+ signal: ctrl ? ctrl.signal : undefined,
6937
+ })
6938
+ .then(function (res) {
6939
+ clearGiveUp();
6940
+ if (!res.ok) throw new Error("http " + res.status);
6941
+ return res.json();
6942
+ })
6943
+ .then(function (j) {
6944
+ if (!alive()) return;
6945
+ var cat = harnessCatalogOf(j);
6946
+ if (!cat) { fail(); return; }
6947
+ // 骨組みを持たない応答(古い Lambda)では、従来どおりのフラット表示に落とす。
6948
+ // **ピッカーの入口も出さない**——選んでも並べ替える先が無い
6949
+ if (!cat.departments) { harnessFillList(list, setState, warn, cat); return; }
6950
+ warn.hidden = !cat.partial;
6951
+ choose.hidden = false;
6952
+ var setPickState = function (text) {
6953
+ pickState.textContent = text;
6954
+ pickState.hidden = text === "";
6955
+ };
6956
+ /**
6957
+ * 棚ビューへ戻る。**そのつど描き直す**——ピッカーで置いた/外したぶんを
6958
+ * 反映するため(行ごと作り直すので、棚ビューに出ていた保存の印は消える。
6959
+ * ピッカーを一往復した後なので、消えて困る情報はもう画面に無い)
6960
+ */
6961
+ var showShelf = function () {
6962
+ harnessRenderShelf(list, setState, cat);
6963
+ picker.hidden = true;
6964
+ shelfView.hidden = false;
6965
+ };
6966
+ var showPicker = function () {
6967
+ harnessRenderPicker(pickBody, setPickState, cat);
6968
+ shelfView.hidden = true;
6969
+ picker.hidden = false;
6970
+ };
6971
+ choose.addEventListener("click", showPicker);
6972
+ back.addEventListener("click", showShelf);
6973
+ showShelf();
6974
+ })
6975
+ // **必ず末尾に置く。** 上の .then の中で投げた例外まで拾う——未処理の rejection は
6976
+ // node --test をテストごと落とす
6977
+ .catch(function () { clearGiveUp(); fail(); });
6978
+ }
6979
+
5456
6980
  /**
5457
6981
  * 鍵の欄の置き場(2026-08-21・オーナー指摘「APIキーのセクションが下にあって、
5458
6982
  * 変わってないように見えます」)。
@@ -5548,7 +7072,9 @@ ${deriveCapTile.toString()}
5548
7072
  // **どちらにも行が無い**。行の有無でセクションを作ると、その案件でチャットが丸ごと消え、
5549
7073
  // 「これから繋げる入口」も接続マップのアンカーの受け皿も無くなる。
5550
7074
  // 何を繋げるかは製品が決めていて、案件データでは変わらない(#125②)。
5551
- var CAP_ORDER = ["課題管理", "会議", "共有資料", "チャット", "デザイン", "開発"];
7075
+ // **ハーネス連携は末尾。** 前6つは「どこから情報が入ってくるか」で、これだけが
7076
+ // 「Cortex が何を自動で出すか」——読む順として、入口を見た後に出口が来る
7077
+ var CAP_ORDER = ["課題管理", "会議", "共有資料", "チャット", "デザイン", "開発", "ハーネス連携"];
5552
7078
  // internalSources に行が来る能力(チャットと開発は externalSources 側にしか来ない)。
5553
7079
  // **fleet-status.json がまだ無い案件のために、行が無くても設定を出す**判定に使う(#138)
5554
7080
  var CAP_INTERNAL = ["課題管理", "会議", "共有資料", "デザイン"];
@@ -5556,10 +7082,13 @@ ${deriveCapTile.toString()}
5556
7082
  var manualBox = {};
5557
7083
  // 対応表は externalCapability が持つ(状態判定・件数の集計と同じものを使う)
5558
7084
  var capOf = externalCapability;
5559
- function pushCap(cap, node) {
7085
+ // **adapter を付けて受け皿へ入れる(#153)。** チャットが2タイルに分かれたので、
7086
+ // どのアダプタのモーダルに出すかをここで決める。空(未指定)は「どのタイルにも出す」——
7087
+ // アダプタに属さない部品(内部ソースの操作等)を黙って消さないため
7088
+ function pushCap(cap, node, adapter) {
5560
7089
  if (!cap || !node) return;
5561
7090
  if (!capBox[cap]) capBox[cap] = [];
5562
- capBox[cap].push(node);
7091
+ capBox[cap].push({ node: node, adapter: adapter || "" });
5563
7092
  }
5564
7093
 
5565
7094
  if (internals.length) {
@@ -5732,8 +7261,10 @@ ${deriveCapTile.toString()}
5732
7261
  // 追加のときにワークスペースを選ばせるのをやめたので、
5733
7262
  // 「どの組織のものか」「その組織とつながっているか」を画面が言う。
5734
7263
  // 分け方は groupSlackByOrg(純関数)が全部持っている
5735
- // **一覧は下で1つの箱にまとめて出す**(開いたままでも差し替えられるように)
5736
- if (g.type === "slack" || g.type === "__github__") return;
7264
+ // **一覧は下で1つの箱にまとめて出す**(開いたままでも差し替えられるように)。
7265
+ // google-chat も同じ(#153)——汎用経路に落とすと4件以上で details に畳まれる。
7266
+ // 「畳まない」は不作為では達成できない(行の生成は下の liveList の充填関数が持つ)
7267
+ if (g.type === "slack" || g.type === "__github__" || g.type === "google-chat") return;
5737
7268
  var cards = el("div", { class: "ds-list" });
5738
7269
  g.items.forEach(function (s) { cards.appendChild(extSourceCard(s, meta)); });
5739
7270
  if (g.items.length >= 4) {
@@ -5750,13 +7281,13 @@ ${deriveCapTile.toString()}
5750
7281
  : el("span", { class: "badge ds-sum-warn", text: warn }),
5751
7282
  ]);
5752
7283
  var det = el("details", { class: "ds-group" }, [sum, cards]);
5753
- pushCap(capOf(g.type), det);
7284
+ pushCap(capOf(g.type), det, sourceAdapter(g.type));
5754
7285
  } else {
5755
7286
  // **appendChild と違い pushCap はノードを動かさない。** firstChild を条件にすると
5756
7287
  // 永久に真のままになる(実際に無限ループを作った)。先に配列へ取り出す。
5757
7288
  var kids = [];
5758
7289
  for (var ci = 0; ci < cards.childNodes.length; ci++) kids.push(cards.childNodes[ci]);
5759
- kids.forEach(function (n) { pushCap(capOf(g.type), n); });
7290
+ kids.forEach(function (n) { pushCap(capOf(g.type), n, sourceAdapter(g.type)); });
5760
7291
  }
5761
7292
  });
5762
7293
  }
@@ -5770,9 +7301,11 @@ ${deriveCapTile.toString()}
5770
7301
  // **Slack は組織ごと(slackRows)・GitHub も組織ごと(githubRows)。**
5771
7302
  // それまでは「行の一覧」と「組織ごとの接続状況」が別々に並んでいて、
5772
7303
  // どの行がどの組織の話なのか読めなかった(2026-08-20 に揃えた)。
7304
+ // **絞りの正本は sourceAdapter(#153)。** ここに独自の if 連鎖を書くと、次のアダプタや
7305
+ // 新しい github-* 種別を正本に足した日に、この箱だけ古い答えのまま行が別の箱に落ちる
5773
7306
  function liveExternals(kind) {
5774
7307
  return (fleetVal("externalSources") || []).filter(function (s) {
5775
- return kind === "slack" ? s.type === "slack" : String(s.type || "").indexOf("github") === 0;
7308
+ return sourceAdapter(s.type) === kind;
5776
7309
  });
5777
7310
  }
5778
7311
  /** 行の見た目を決める値だけを並べる(時刻など毎回変わる値は入れない) */
@@ -5784,6 +7317,8 @@ ${deriveCapTile.toString()}
5784
7317
  * (素の Decisions ビュー)で行も無い能力に空の箱を積むと、押しても何も無いモーダルになる。
5785
7318
  */
5786
7319
  function liveListWanted(kind) { return !!SITE.intake || liveExternals(kind).length > 0; }
7320
+ // **箱にはアダプタのタグを付ける(#153)。** pushCap の無タグは「どのタイルにも出す」
7321
+ // 既定なので、タグを忘れると Slack の一覧が Google Chat モーダルに丸ごと出る
5787
7322
  if (liveListWanted("slack")) pushCap("チャット", liveList(
5788
7323
  "チャット",
5789
7324
  function () { return externalsSig("slack", slackWorkspaces()); },
@@ -5793,7 +7328,19 @@ ${deriveCapTile.toString()}
5793
7328
  var meta = DS_TYPE["slack"] || { icon: "🔗", label: "Slack" };
5794
7329
  slackRows(items, meta).forEach(function (n) { box.appendChild(n); });
5795
7330
  },
5796
- ));
7331
+ ), "slack");
7332
+ // Google Chat の箱(#153)。行は平置き(組織情報が無いので組ごとの並べ替え・囲いは
7333
+ // 作れない。判断③)。4件以上でも畳まない——畳みは汎用経路の仕様で、ここは通らない
7334
+ if (liveListWanted("google-chat")) pushCap("チャット", liveList(
7335
+ "チャット",
7336
+ function () { return externalsSig("google-chat", null); },
7337
+ function (box) {
7338
+ var items = liveExternals("google-chat");
7339
+ if (!items.length) return;
7340
+ var meta = DS_TYPE["google-chat"] || { icon: "💬", label: "Google Chat" };
7341
+ items.forEach(function (s) { box.appendChild(extSourceCard(s, meta)); });
7342
+ },
7343
+ ), "google-chat");
5797
7344
  if (liveListWanted("github")) pushCap("開発", liveList(
5798
7345
  "開発",
5799
7346
  function () { return externalsSig("github", githubOwnersView() || null); },
@@ -5820,7 +7367,7 @@ ${deriveCapTile.toString()}
5820
7367
  // **新しい形が別の解き方をしている**——チャンネルを追加すれば
5821
7368
  // 読み取れていない組として現れ、そこに接続ボタンが出る。
5822
7369
  // 0件なら繋ぐ相手もまだ無いので、入口が要る場面にならない。
5823
- pushCap("チャット", adders.querySelector('[data-adder="slack"]'));
7370
+ pushCap("チャット", adders.querySelector('[data-adder="slack"]'), "slack");
5824
7371
  /* -------------------------------------------------------------------------
5825
7372
  * 🔴 接続の入口を常設する(0.12.2・実オンボーディングで踏んだ)
5826
7373
  * -------------------------------------------------------------------------
@@ -5851,10 +7398,17 @@ ${deriveCapTile.toString()}
5851
7398
  * 無い案件では押しても始まらない(renderExternalAdders も同じ条件で空を返す)。
5852
7399
  * ----------------------------------------------------------------------- */
5853
7400
  if (SITE.intake) {
7401
+ // "slack" 札は #153 の合流で付けた——無いと Google Chat の箱にも OAuth ボタンが出る
7402
+ //(Google Chat の承認は Admin install で、押す先の画面が無い)。常設の意図は Slack の箱の中で保つ
5854
7403
  pushCap("チャット", slackConnectButton(
5855
7404
  "お客様の Slack や、Slack コネクトで共有されたチャンネルを読むには、先にワークスペースの接続が要ります。",
5856
- ));
7405
+ ), "slack");
5857
7406
  }
7407
+ // Google Chat のフォーム(#153)。接続(OAuth)のボタンは存在しない——顧客の
7408
+ // Workspace 管理者による Admin install が承認そのもので、画面に押す先が無い。
7409
+ // 案内(アプリ追加の操作列)は独立の箱ではなくフォームの手順リストが持つ(#170/#174 の
7410
+ // 圧縮方針。「貼る前に読ませる」は手順が入力欄より前にあることで担保し、テストで固定)
7411
+ pushCap("チャット", adders.querySelector('[data-adder="google-chat"]'), "google-chat");
5858
7412
  // **組織ごとの接続状況は、行と一体にした(2026-08-20・Slack からの横展開)。**
5859
7413
  //
5860
7414
  // それまでは「リポジトリの一覧」と「組織ごとの接続状況」が**別々に並んで**おり、
@@ -5999,7 +7553,11 @@ ${deriveCapTile.toString()}
5999
7553
  "チャット": { icon: "💬", desc: "チャンネルの会話をGoldの材料に" },
6000
7554
  "デザイン": { icon: "🎨", desc: "画面インベントリの同期" },
6001
7555
  "開発": { icon: "🐙", desc: "IssuesをGoldの材料に" },
7556
+ "ハーネス連携": { icon: "⏰", desc: "ハーネスの機能を決まった時間に自動で動かし、結果を Slack に届けます" },
6002
7557
  };
7558
+ // 説明文がアダプタで割れるものだけ上書きする(#153)。チャットの単位は
7559
+ // Slack=チャンネル / Google Chat=スペース で、能力共通の一文だと後者に嘘が出る
7560
+ var ADAPTER_DESC = { "google-chat": "スペースの会話をGoldの材料に" };
6003
7561
  var STATE_LABEL = { ok: "● 接続OK", idle: "⦿ 未接続", off: "未使用", warn: "⚠️" };
6004
7562
  // **「開発」はここに無い。** GitHub の読み取りトークンは廃止したので、
6005
7563
  // 鍵の未設定を語る相手がいない(読めていないときは組織ごとの案内が拾う)
@@ -6077,7 +7635,10 @@ ${deriveCapTile.toString()}
6077
7635
  */
6078
7636
  function tileSub(cap, st, derived, adapter, ins, srcs, derive, confirming) {
6079
7637
  var internal = ins.filter(function (s) { return s.kind === cap; })[0];
6080
- var live = srcs.filter(function (s) { return externalCapability(s.type) === cap; });
7638
+ // **このタイルのアダプタの行だけを数える(#153)。** 絞らないと Slack のタイルが
7639
+ // スペース込みで「3チャンネル」と数える。絞り方の正本は sourceMatchesAdapter
7640
+ //(capabilityState の warn 集計と同じ関数——写経すると warn と件数が別の行集合になる)
7641
+ var live = srcs.filter(function (s) { return sourceMatchesAdapter(s.type, cap, adapter); });
6081
7642
  var parts = [];
6082
7643
  // **導出が on 以外なら、導出の語を優先する(#125②)。** broken は状態としては
6083
7644
  // 警告(⚠️)だが、原因は「設定そのものが壊れている」であって同期の失敗ではない。
@@ -6085,14 +7646,19 @@ ${deriveCapTile.toString()}
6085
7646
  // 直す場所が違うところを指す。
6086
7647
  //
6087
7648
  // 🔴 **ここが正本からの導出(下)より先。** 意図的に OFF にした能力に
6088
- // 「登録済み(接続は未確認です)」と出さないため(deriveCapTile の項)
7649
+ // 「登録済み(接続は未確認です)」と出さないため(deriveCapTile の項)。
7650
+ // **unset なのに warn は例外**(engine がこのアダプタを未導出でも、読めていない行が
7651
+ // 実在する移行期間・#153)——下の分岐へ流して件数と原因を出す。broken / off の優先は従来どおり。
7652
+ // **unset なのに導出が答えているのも例外(判断⑲)**——非アクティブなアダプタの unset は
7653
+ // 宣言ではなく既定値なので、導出(登録の実物)に道を譲る。塞ぐと「登録済み(接続は
7654
+ // 未確認です)」の代わりに「未接続」が出る(renderAdapterTile 側の同じ例外と対)
6089
7655
  if (confirming) {
6090
7656
  // 🔴 **確認中は状態を言わない(#256)。** 下の分岐はどれも焼いた値からの断言で、
6091
7657
  // ライブが届くと訂正される=瞬きの出どころ。**語は既存の中間状態と揃える**
6092
7658
  //(DS_GATE_PENDING「登録状況を確認中です」)——新しい言い方を増やすと、
6093
7659
  // 同じ画面で「確認中」を意味する語が2つ並ぶ
6094
7660
  parts.push(DS_GATE_PENDING.text);
6095
- } else if (derived && derived !== "on") {
7661
+ } else if (derived && derived !== "on" && !(derived === "unset" && st === "warn") && !(derived === "unset" && derive)) {
6096
7662
  parts.push((CAP_STATE_MARK[st] || "") + (CAP_STATE_TEXT[derived] || "状態が判定できません"));
6097
7663
  } else if (derive) {
6098
7664
  // 🔴 **正本(登録・実測)からの導出(0.12.0・#231 / #235 / #227)。**
@@ -6144,8 +7710,15 @@ ${deriveCapTile.toString()}
6144
7710
  // 単位は分かるものだけ。知らない種別に勝手な単位を付けない。
6145
7711
  // **「N件をGoldに使用」の内訳は出さない。** チャンネルは載っていれば全部が対象で、
6146
7712
  // github の除外は行のバッジ(「読み取り停止中」)が示す
6147
- //(件数の内訳は Gold トグルがあった頃の名残)
6148
- var unit = cap === "チャット" ? "チャンネル" : cap === "開発" ? "リポジトリ" : "件";
7713
+ //(件数の内訳は Gold トグルがあった頃の名残)。
7714
+ // チャットはアダプタで単位が割れる(Slack=チャンネル / Google Chat=スペース・#153)。
7715
+ // 混載(adapter 不明)でスペースが混ざっているときは「件」——「2チャンネル」と
7716
+ // 数えたうちの1つがスペース、という嘘を数字の単位で作らない
7717
+ var chatMixed = cap === "チャット" && !adapter &&
7718
+ live.some(function (s) { return s.type === "google-chat"; });
7719
+ var unit = adapter === "google-chat" ? "スペース"
7720
+ : chatMixed ? "件"
7721
+ : cap === "チャット" ? "チャンネル" : cap === "開発" ? "リポジトリ" : "件";
6149
7722
  parts.push(live.length + unit);
6150
7723
  } else if (cap === "共有資料" && adapter === "google-drive" && internal && internal.driveFolderIds) {
6151
7724
  // **そのタイルのアダプタを見る。** 宣言(tools)を見ていた頃は、local の案件
@@ -6177,6 +7750,20 @@ ${deriveCapTile.toString()}
6177
7750
  var extraCaps = Object.keys(capBox).filter(function (c) { return CAP_ORDER.indexOf(c) === -1; });
6178
7751
  var anyCap = false;
6179
7752
  CAP_ORDER.concat(extraCaps).forEach(function (cap) {
7753
+ // **ハーネス連携だけは専用の描画。** 接続ではなく Cortex 自身の設定なので、
7754
+ // 状態(capabilityState)・アダプタ・fleet の行のどれも持たない
7755
+ if (cap === "ハーネス連携") {
7756
+ // **設定も一覧の取得も intake Lambda が要る。** 無い構成では押しても始まらない
7757
+ // タイルになるので出さない(renderExternalAdders と同じゲート)
7758
+ if (!SITE.intake) return;
7759
+ sec.appendChild(el("div", { class: "ds-cap", text: cap }));
7760
+ // ここで anyCap を立てても「ハーネス連携だけで節が立つ」ことは無い——
7761
+ // 上の門(節を出すかの判定)が hasIntake を数えており、intake があれば
7762
+ // 手動投入のタイルも必ず並ぶ。立てるのは節末の注記を出すため
7763
+ anyCap = true;
7764
+ renderHarnessTile();
7765
+ return;
7766
+ }
6180
7767
  var items = capBox[cap] || [];
6181
7768
  // **カタログにある能力のタイルは常時出す(#125②)。** 使っているかどうかで出し分けない
6182
7769
  // ——未接続のタイルは「これから繋げる入口」で、消すと繋ぎ方を知る場所が無くなる。
@@ -6191,9 +7778,8 @@ ${deriveCapTile.toString()}
6191
7778
  // **カタログの全アダプタを並べる(今日は各1個)。** 能力=1アダプタを
6192
7779
  // 焼き込まないための形(#107 / #127)。導出が答えているアダプタにだけ
6193
7780
  // その状態を割り当て、残りは未接続——「知らない」を「繋がっている」に寄せない
6194
- var active = capabilityAdapter(cap, caps);
6195
7781
  adapters.forEach(function (a, ai) {
6196
- renderAdapterTile(cap, items, a, a === active ? capabilityDisplayState(cap, caps) : "unset", ai);
7782
+ renderAdapterTile(cap, items, a, adapterDisplayState(cap, a, caps), ai);
6197
7783
  });
6198
7784
  } else if (items.length) {
6199
7785
  // capabilities を持たないデータ: アダプタのタイルは行があるときだけ。手動投入だけが
@@ -6213,13 +7799,19 @@ ${deriveCapTile.toString()}
6213
7799
  */
6214
7800
  function renderAdapterTile(cap, items, adapter, derived, ai) {
6215
7801
  var meta = CAP_META[cap] || { icon: "🔗", desc: "" };
7802
+ // **このタイルに出す中身だけに絞る(#153)。** adapter 無し(旧データ・その他)は全部。
7803
+ // アダプタに属さない部品(adapter 空でpushされたもの)は両方のタイルに出す
7804
+ var mine = [];
7805
+ items.forEach(function (it) {
7806
+ if (!adapter || !it.adapter || it.adapter === adapter) mine.push(it.node);
7807
+ });
6216
7808
  // 🔴 **状態はまとめ表の素の値で決める(段階1)。** ここに足した行を混ぜると、
6217
7809
  // 登録直後の1行で「使っている」と判定され、誰も測っていないのに**接続OK**に落ちる
6218
- // (同じ画面の行は「登録状況を確認中です」)。足した行の扱いは pendingSub が持つ
6219
- var st = derived === "unset" && capabilityDisplayState(cap, caps) !== "unset"
6220
- // カタログにあるが導出が答えていないアダプタ(今日は発生しない。#107 の前方互換)
6221
- ? "idle"
6222
- : capabilityState(cap, stateCtx());
7810
+ // (同じ画面の行は「登録状況を確認中です」)。足した行の扱いは pendingSub が持つ。
7811
+ // **unset を idle に短絡しない(#153)。** 短絡すると gate 異常(読めていない行)の
7812
+ // warn 判定を一度も通らず、接続マップだけが⚠️になる。アダプタごとの状態の解決
7813
+ //(adapters[] / 旧データの代表値の扱い)は capabilityState 自身が持つ
7814
+ var st = capabilityState(cap, stateCtx(), adapter);
6223
7815
  // 🔴 **状態の正本は「登録・実測からの導出」(0.12.0・#231 / #235 / #227)。**
6224
7816
  // 設定が済んでいること(capabilities が on)と、実際に動いていることは別。
6225
7817
  // 設定だけを見て「● 接続OK」と出すと、**画面が動いていないものを動いていると言う**
@@ -6228,8 +7820,20 @@ ${deriveCapTile.toString()}
6228
7820
  // **導出が答えないとき(規則5・古い Lambda)だけ、従来の fleet 判定のまま。**
6229
7821
  // **設定が on 以外のときは、そちらが先に立つ**(tileSub の順序と揃える——
6230
7822
  // 揃えないと「色は接続OK・文字はOFF」が同じタイルの中で並ぶ)
6231
- var derive = derived && derived !== "on" ? null : capDerivation(cap);
6232
- if (derive) st = derive.state;
7823
+ // **導出の変種を持つアダプタ(google-chat)の unset は導出を止めない(#153・判断⑲)。**
7824
+ // 非アクティブなアダプタの unset は「エンジンが未導出」という既定値であって設定の宣言では
7825
+ // ないので、OFF/broken と同じ扱いで塞ぐと、名簿に登録があるのに「未接続」と言い続ける。
7826
+ // 判定は derivableUnset(1箇所)——**名簿が読めなかった回(件数 null)はバイパスしない**。
7827
+ // null は規則1(登録0件)をすり抜けて規則4に落ち、名簿の無い全案件で「登録済み」と嘘をつく
7828
+ var underived = derived && derived !== "on" && !derivableUnset(cap, adapter, derived);
7829
+ var derive = underived ? null : capDerivation(cap, adapter);
7830
+ // **導出が warn を降格しない(#153 実装レビュー3)。** 実測ストアは新しい gate 語
7831
+ //(not_in_space 等)を読めるようになった(PR-E)が、**焼いた行にしか判定が無い場面は
7832
+ // 残る**——夜間がまだ一度も走っていない案件・ストアが読めなかった回。そこで
7833
+ // fleet の行から出た warn を導出(規則4=idle)が上書きすると
7834
+ // 「接続グラフは⚠️・タイルは静か」——この仕組みが殺したはずの食い違いが再発する
7835
+ if (derive && !(st === "warn" && derive.state !== "warn")) st = derive.state;
7836
+ else if (derive && st === "warn") derive = null;
6233
7837
  // **ロゴはタイルの左に独立して置く。** 名前の行に入れると名前の高さに
6234
7838
  // 縛られて小さくなる。ロゴで能力を見分けられるのが一覧の主な用途
6235
7839
  var logo = ADAPTER_ICONS[adapter];
@@ -6248,8 +7852,9 @@ ${deriveCapTile.toString()}
6248
7852
  // あの語は「まとめ表しか材料が無く、登録の現在値が分からない」ときの折衷だった。
6249
7853
  // 正本(registeredNow)を読むようになった今は分かる——外した直後は「未接続」、
6250
7854
  // 足した直後は「登録済み(まだ取り込みがありません)」と、**何が起きたかを言える**。
6251
- // ここを逆にすると、まとめ表が追いつく翌朝まで「確認中です」が居座る
6252
- var pend = capPending(cap) && !derive;
7855
+ // ここを逆にすると、まとめ表が追いつく翌朝まで「確認中です」が居座る。
7856
+ // 確認中の判定はアダプタで絞る(#153・判断⑭)
7857
+ var pend = capPending(cap, adapter) && !derive;
6253
7858
  // 🔴 **初回のライブ確認が答えるまで、状態を断言しない(#256・設計v2 の5)。**
6254
7859
  // この2行目は焼いた capabilities 由来の状態を先に言っていたので、図と同じ瞬き
6255
7860
  // (旧状態を見せて1〜2秒後に訂正)をタイルでも起こしていた。**兄弟機能の同型の
@@ -6269,7 +7874,7 @@ ${deriveCapTile.toString()}
6269
7874
  });
6270
7875
  var body = el("div", { class: "ds-tile-body" }, [
6271
7876
  el("div", { class: "ds-tile-name", text: (rawTool && (CM_TOOL_LABEL_C[rawTool] || rawTool)) || cap }),
6272
- el("div", { class: "ds-tile-desc", text: meta.desc }),
7877
+ el("div", { class: "ds-tile-desc", text: ADAPTER_DESC[adapter] || meta.desc }),
6273
7878
  subEl,
6274
7879
  ]);
6275
7880
  // **なぜその状態なのかを添える(#125②)。** engine が書いた一文(detail)で、
@@ -6281,9 +7886,15 @@ ${deriveCapTile.toString()}
6281
7886
  // 実機で幅を超えて溢れた。off(何件残して止めたか)と broken(異常)は
6282
7887
  // 押さずに気づけないと困るので、表面に残す
6283
7888
  var capInfo = caps[cap];
7889
+ // detail もアダプタで引く(#153)。エントリの引き方の正本は adapterEntry——
7890
+ // 状態と別の走査を書くと「状態判定上は存在しないエントリの detail だけ出る」がつくれる。
7891
+ // adapters[] はあるがエントリが無いアダプタには、単数形(代表値)の detail を流用しない
7892
+ // detail の解決も adapterResolved(正本1箇所)。null は「代表値の detail に従う」
7893
+ var adRes = adapterResolved(cap, adapter, caps);
7894
+ var detailText = adRes.detail === null ? ((capInfo && capInfo.detail) || "") : adRes.detail;
6284
7895
  var detailOnTile = derived === "off" || derived === "broken";
6285
- if (detailOnTile && capInfo && capInfo.detail && adapter === capabilityAdapter(cap, caps)) {
6286
- body.appendChild(el("div", { class: "ds-tile-note ds-tile-todo", text: capInfo.detail }));
7896
+ if (detailOnTile && detailText) {
7897
+ body.appendChild(el("div", { class: "ds-tile-note ds-tile-todo", text: detailText }));
6287
7898
  }
6288
7899
  // トークン未設定は**状態とは別軸**。状態機械に混ぜると判定が1箇所でなくなる。
6289
7900
  // ただし「使わない」と**宣言した**能力では出さない——設定する必要が無いものに
@@ -6335,11 +7946,56 @@ ${deriveCapTile.toString()}
6335
7946
  ]);
6336
7947
  // ⚠ **ここで足した行(段階1)を見て抑制しない。** unset の一文は開くときに引き直される
6337
7948
  // (openCapabilityModal が capPending を見る)ので二重になるうえ、
6338
- // ここで抑えると off / broken まで消える——R4 で「消さない」と決めたもの
6339
- var modalNote = derived && derived !== "on" && capInfo && capInfo.detail
6340
- ? CAP_STATE_TEXT[derived] + "(" + capInfo.detail + ")"
7949
+ // ここで抑えると off / broken まで消える——R4 で「消さない」と決めたもの。
7950
+ // detail はアダプタで引いた detailText を使う(#153)。adapters[] は新しい状態語が
7951
+ // 最初に現れる場所なので、未知の state に必ず備える(無いと「undefined(…)」が顧客画面に出る)
7952
+ var modalNote = derived && derived !== "on" && detailText
7953
+ ? (CAP_STATE_TEXT[derived] || "状態が判定できません") + "(" + detailText + ")"
6341
7954
  : "";
6342
- tile.addEventListener("click", function () { openCapabilityModal(cap, items, modalNote, derived === "unset"); });
7955
+ // adapter は第5引数(第4は noteIsUnset・#184 の守り。判断⑰-2)
7956
+ tile.addEventListener("click", function () { openCapabilityModal(cap, mine, modalNote, derived === "unset", adapter); });
7957
+ sec.appendChild(tile);
7958
+ }
7959
+
7960
+ /**
7961
+ * ハーネス連携のタイル。**接続の語彙(未接続・接続OK)は一切使わない**
7962
+ * ——接続ではないので CAP_STATE_TEXT を引かない。ここで語るのは
7963
+ * (1) いま何件オンか
7964
+ * (2) オンなのに配信先が無い、という直すべき状態があるか
7965
+ * の2つだけで、どちらも押さずに分かる必要がある。
7966
+ */
7967
+ function renderHarnessTile() {
7968
+ var meta = CAP_META["ハーネス連携"];
7969
+ // **時間帯の無い行は数えない。** plan(実行系)は slot の無い行を除外して動かさない
7970
+ // ので、数えると「N件をオンにしています」が実行されない行を動いているように言う。
7971
+ // モーダルには行として出る(時間帯未設定・オフにできる)ので、消えはしない
7972
+ var n = 0;
7973
+ for (var hi = 0; hi < HARNESS_SCHEDULES.length; hi++) {
7974
+ if (HARNESS_SCHEDULES[hi].slot) n += 1;
7975
+ }
7976
+ var body = el("div", { class: "ds-tile-body" }, [
7977
+ el("div", { class: "ds-tile-name", text: "ハーネス連携" }),
7978
+ el("div", { class: "ds-tile-desc", text: meta.desc }),
7979
+ el("div", {
7980
+ class: "ds-tile-sub " + (n ? "ds-gate-ok" : "ds-gate-muted"),
7981
+ text: n ? "● " + n + "件をオンにしています" : "すべてオフ",
7982
+ }),
7983
+ ]);
7984
+ // 🔴 **オンなのに配信先が無い状態は、押さずに分かるようにする。** この状態だと
7985
+ // 自動実行は動くのに結果がどこにも届かず、画面上は「N件オン」だけが見えるので、
7986
+ // 何も起きていないことに気づけない
7987
+ if (n > 0 && !harnessNotifyTargets().length) {
7988
+ body.appendChild(el("div", {
7989
+ class: "ds-tile-note ds-tile-alert",
7990
+ text: "⚠ オンになっていますが、配信先が未設定です",
7991
+ }));
7992
+ }
7993
+ var tile = el("button", { class: "ds-tile", type: "button", id: "ds-ハーネス連携" }, [
7994
+ el("span", { class: "ds-tile-logo", text: meta.icon }),
7995
+ body,
7996
+ el("span", { class: "ds-tile-chev", text: "›" }),
7997
+ ]);
7998
+ tile.addEventListener("click", function () { openHarnessModal(); });
6343
7999
  sec.appendChild(tile);
6344
8000
  }
6345
8001
 
@@ -6367,7 +8023,7 @@ ${deriveCapTile.toString()}
6367
8023
 
6368
8024
  // 注記は全能力の後ろに1つ。**何が Gold の材料になるのかを1文で言う。**
6369
8025
  if (anyCap) {
6370
- sec.appendChild(el("p", { class: "ds-note", text: "登録されている Slack チャンネルと GitHub リポジトリは、毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。チャンネルは登録を解除すると、リポジトリは登録から削除すると、以後は読み取られません。" }));
8026
+ sec.appendChild(el("p", { class: "ds-note", text: "登録されている Slack チャンネル・Google Chat スペース・GitHub リポジトリは、毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。チャンネルとスペースは登録を解除すると、リポジトリは登録から削除すると、以後は読み取られません。" }));
6371
8027
  }
6372
8028
 
6373
8029
  if (pipes.length) {
@@ -6887,6 +8543,33 @@ ${deriveCapTile.toString()}
6887
8543
  return wrap;
6888
8544
  }
6889
8545
 
8546
+ /**
8547
+ * Google Chat スペースの登録解除(#153)。removeChannelButton と同型で、
8548
+ * 書き換わる先が google-chat-spaces.json・鍵が ref のみ(スペースの ref は
8549
+ * 全ワークスペースで一意なので workspace は送らない)という違いだけ。
8550
+ * #155 のモデル(載っている=読む・止める手段は登録解除)に Slack と揃える。
8551
+ */
8552
+ function removeSpaceButton(s, card) {
8553
+ return removeButton({
8554
+ label: "登録を解除",
8555
+ confirmLines: function () {
8556
+ return [
8557
+ "「" + (s.name || s.ref) + "」を、Gold 昇格の読み取り対象から外します。",
8558
+ "",
8559
+ "次回の Gold 昇格(毎晩22時ごろ)から、このスペースは読み取られなくなります。",
8560
+ "",
8561
+ "※ 既に Gold 層に入った内容は消えません(Decision・用語集はそのまま残ります)。",
8562
+ "",
8563
+ "解除しますか?",
8564
+ ];
8565
+ },
8566
+ build: function () {
8567
+ return { op: "remove-google-chat-space", ref: s.ref };
8568
+ },
8569
+ onDone: function () { card.classList.add("ds-removed"); },
8570
+ });
8571
+ }
8572
+
6890
8573
  /**
6891
8574
  * いま同期している Backlog の接続先の一覧(#127)。**1件でも配列で返す。**
6892
8575
  *
@@ -7930,6 +9613,42 @@ ${deriveCapTile.toString()}
7930
9613
  adder: "slack",
7931
9614
  }));
7932
9615
 
9616
+ // Google Chat のスペース追加(#153)。**URL のみ・Figma/Drive と同形。**
9617
+ // 表示名の欄は置かない(名前はサーバが spaces.get で引く。引けなくても
9618
+ // 「名前未取得」で通し、後から実名に変わる——Slack と同じ約束)。
9619
+ // ワークスペースのプルダウンも無し(スペースの ref は全ワークスペースで一意)
9620
+ //
9621
+ // **接続(OAuth)のボタンは無い。** 顧客の Workspace 管理者による Admin install が
9622
+ // 承認そのもので、画面に押す先が無い(Slack の接続ボタンを写経しないこと——
9623
+ // 押しても何も起きないボタンが顧客の画面に出る)。
9624
+ // **追加時の確認モーダルも出さない**(#167 で「追加のたびの確認」は廃止済み。
9625
+ // 「追加=Gold 昇格の対象」という重さは、Slack と同じく手順の最終行が伝える)。
9626
+ // 案内(アプリ追加の操作列)は独立の箱にせず、この手順リストが持つ(#170/#174 の圧縮方針。
9627
+ // fleet-status にデータが無い案件でもフォームは常設なので、案内も常に出る)
9628
+ //
9629
+ // Google Chat アプリの表示名。**Marketplace 公開名として確定(2026-08-31 オーナー決定)。**
9630
+ // 検索ワードとして本文に組み込む(コピー行は置かない——コマンドではなく検索語のため)
9631
+ var GCHAT_APP_NAME = "Cortex Bot";
9632
+ box.appendChild(addForm({
9633
+ title: "+ Google Chat スペースを追加",
9634
+ placeholder: "https://chat.google.com/room/XXXXXXXXXXX",
9635
+ requiredNote: "スペースのURLを入力してください。",
9636
+ // 手順リスト(#170 の型)。アプリ追加の話が先——Slack の招待と同じで、入っていないと
9637
+ // 1件も読めない。操作列は Google 公式ヘルプの現行手順に合わせる
9638
+ // (support.google.com/chat/answer/7655820)。DM が対象外であることもここで言う——
9639
+ // 貼って 400 になってから知るより先に
9640
+ hint: [
9641
+ "読み取りたいスペースに Cortex のアプリを追加します。スペース名をクリック → 「アプリと統合」 → 「アプリを追加」 で、「" + GCHAT_APP_NAME + "」を検索して追加します。",
9642
+ "アプリが検索に出ないときは、貴社の Google Workspace 管理者が Marketplace からの導入をまだ承認していません。承認できるのは管理者だけで、反映まで最大24時間かかります(見つからない場合は担当者にご連絡ください)。",
9643
+ "下の欄に、ブラウザでスペースを開いたときのURL(chat.google.com/room/…)を貼って「追加」を押します。DM(1対1のチャット)は読み取りの対象外です。",
9644
+ "追加したスペースは毎晩の Gold 昇格の対象になり、Decision・用語集の材料になります。止めるときは、その行の「登録を解除」を押します(今夜の蒸留・22時ごろまでに解除すれば取り込まれません)。",
9645
+ ],
9646
+ build: function (url) {
9647
+ return { op: "add-google-chat-space", url: url };
9648
+ },
9649
+ adder: "google-chat",
9650
+ }));
9651
+
7933
9652
  if (usable("開発", "github")) {
7934
9653
  box.appendChild(addForm({
7935
9654
  title: "+ GitHubリポジトリを追加",
@@ -8104,6 +9823,21 @@ ${deriveCapTile.toString()}
8104
9823
  // 🔴 **語を戻すのは onDone より先。** removeButton の onDone は押せない状態へ
8105
9824
  // 戻す(外した行のボタン)ので、後に置くと「処理中…」の語が居座る
8106
9825
  endBusy();
9826
+ // 🔴 **「サーバの状態が確定した」ことだけを伝える口(hooks.onSaved)。**
9827
+ //
9828
+ // 下の onDone は **changed !== false のときしか流れない**。これは「何も変わって
9829
+ // いないのに追加したと見せない」ための意図した仕様で、schedules(オン/オフ)では
9830
+ // 正しい——changed:false は「効いていない」なので行を動かしてはいけない。
9831
+ //
9832
+ // ところが**冪等な op では changed:false が正常な成功**になる(棚の追加は
9833
+ // 「既にあれば {ok:true, changed:false}」が intake の契約)。そこで onDone だけに
9834
+ // 頼ると、「サーバには既に在るのに画面は棚が空」のまま取り残され、
9835
+ // **次のビルドまで自己回復しない**。res.ok で確定した事実は、changed を問わず
9836
+ // 受け取れる必要がある。
9837
+ //
9838
+ // **既存4呼び出しの onDone 意味論には触れない。** 使い分けは呼び出し側の責任で、
9839
+ // 冪等な op(棚の2つ)だけがこちらを使う。
9840
+ if (hooks && typeof hooks.onSaved === "function") hooks.onSaved(j);
8107
9841
  // **保存が効いたら、いまの登録を読み直す(段階1)。** 一覧はまとめ表(夜間バッチ)から
8108
9842
  // 描かれるので、保存しただけでは行が出ない。ここで読み直すと、登録ファイルの中身が
8109
9843
  // 返ってきて**足りない行として一覧に出る**——さらに「まとめ表がまだ答えていない行」が
@@ -8113,7 +9847,12 @@ ${deriveCapTile.toString()}
8113
9847
  // **何も変わっていないなら「追加した」と見せない。** 既に同じものが入っていた場合
8114
9848
  // サーバは 200 + changed:false を返す。ここを res.ok だけで判定すると
8115
9849
  // 「変更はありません」と言いながら追加した行が出る
8116
- if (onDone && j.changed !== false) onDone();
9850
+ // 🔴 **応答そのものを渡す(j)。** サーバが保存後の値を添えてくる口
9851
+ //(ハーネスの entry: 既定を解決した後の時間帯・曜日)では、UI が既定を写経せずに
9852
+ // 正しく反映できる。**流れるのは res.ok かつ changed !== false のときだけ**で、
9853
+ // removeButton 経由の onDone(引数を取らない)には届かない——受け側は
9854
+ // j が undefined でも動く形に保つこと(既存の4呼び出しは引数を取らない)
9855
+ if (onDone && j.changed !== false) onDone(j);
8117
9856
  } catch (e) {
8118
9857
  msg.className = "ds-toggle-msg is-error";
8119
9858
  msg.textContent = "✗ 通信に失敗しました。";
@@ -8176,7 +9915,12 @@ ${deriveCapTile.toString()}
8176
9915
  * オーバーレイ要素だけにリスナを張るのでは足りない(フォーカスが body にあると経路を通らない)。
8177
9916
  *
8178
9917
  * @param lines 表示する行(1行1段落)。先頭行が主文=読み上げの名前になる
8179
- * @param opts { okLabel, returnTo }
9918
+ * @param opts { okLabel, returnTo, okDanger, body }
9919
+ * okDanger … OK ボタンを赤で出すか(既定 true)。false は「広げる向き」の確認用
9920
+ * body … lines の後・ボタンの前に挿す DOM 節(任意)。**押す前に決めることがある確認**
9921
+ * のための口で、ハーネスの「オンにする」は届く時間帯の選択をここに入れる。
9922
+ * 無指定なら従来と完全に同じ見た目・同じ挙動(節の中の操作は overlay の
9923
+ * 外側クリック判定=e.target === overlay に掛からないので、素通しでよい)
8180
9924
  */
8181
9925
  function openConfirm(lines, opts) {
8182
9926
  return new Promise(function (resolve) {
@@ -8196,8 +9940,12 @@ ${deriveCapTile.toString()}
8196
9940
  if (i === 0) { pnode.id = textId; box.setAttribute("aria-labelledby", textId); }
8197
9941
  box.appendChild(pnode);
8198
9942
  });
9943
+ if (o.body) box.appendChild(o.body);
8199
9944
  var cancel = el("button", { class: "modal-cancel", type: "button", text: "キャンセル" });
8200
- var ok = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: o.okLabel || "はい" });
9945
+ // **既定は赤(ds-danger)。** ここに来る操作はほぼ全部が取り消せない向きなので、
9946
+ // 既定を変えない。**okDanger: false のときだけ通常の見た目**——ハーネスの
9947
+ // 「オンにする」は届き始める向きの操作で、削除と同じ赤で出すと止めるべき操作に見える
9948
+ var ok = el("button", { class: "ds-toggle-btn" + (o.okDanger === false ? "" : " ds-danger"), type: "button", text: o.okLabel || "はい" });
8201
9949
  box.appendChild(el("div", { class: "modal-actions" }, [cancel, ok]));
8202
9950
  overlay.appendChild(box);
8203
9951
 
@@ -8614,6 +10362,13 @@ ${deriveCapTile.toString()}
8614
10362
  // どちらも「チャット/channels.json に登録がある行」でしか効かない
8615
10363
  card.appendChild(notifyToggle(s, badges));
8616
10364
  }
10365
+ // Google Chat のスペースの登録解除(#153)。#155 のモデル(載っていること自体が
10366
+ // 読み取りの意思決定・止める手段は登録解除)に Slack と同型で揃える。
10367
+ // Gold トグルは Slack 同様持たない。登録ファイルは google-chat-spaces.json の一系統
10368
+ // だけなので、Slack のような origin の出し分けは不要
10369
+ if (SITE.intake && s.type === "google-chat" && s.ref) {
10370
+ card.appendChild(removeSpaceButton(s, card));
10371
+ }
8617
10372
  // GitHubリポの登録から削除。**ON/OFF のトグルは撤去した(2026-08-21)**
8618
10373
  //(stateToggle のコメント参照)。
8619
10374
  if (SITE.intake && s.type.indexOf("github") === 0 && s.ref) {
@@ -8933,7 +10688,12 @@ ${deriveCapTile.toString()}
8933
10688
  }
8934
10689
  return "";
8935
10690
  }
8936
- /** 画面の能力名 → op=state の識別子(対応表の実体は REGISTERED_CAP_NAMES) */
10691
+ /**
10692
+ * 画面の能力名 → op=state の識別子(対応表の実体は REGISTERED_CAP_NAMES)。
10693
+ * ⚠ チャットは chat と google-chat の2キーが同じ能力名に対応するが、**要求は常に全量**
10694
+ * (ALL_STATE_CAPS)になったので、ここは「初回に開いたか」の記録にしか使わない——
10695
+ * 先勝ちの1件で足りる(#153 の配列化は全量要求への移行で不要になった)
10696
+ */
8937
10697
  function stateCapIdOf(cap) {
8938
10698
  for (var id in REGISTERED_CAP_NAMES) {
8939
10699
  if (Object.prototype.hasOwnProperty.call(REGISTERED_CAP_NAMES, id) && REGISTERED_CAP_NAMES[id] === cap) return id;