@takagaki/cortex-decisions-viewer 0.12.16 → 0.12.18

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.
Files changed (3) hide show
  1. package/dist/build.js +148 -5
  2. package/dist/render.js +1305 -85
  3. package/package.json +4 -2
package/dist/render.js CHANGED
@@ -3,9 +3,11 @@ 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;
6
7
  exports.sanitizeStateNow = sanitizeStateNow;
7
8
  exports.overlayStateOnFleet = overlayStateOnFleet;
8
9
  exports.chatRowsToAdd = chatRowsToAdd;
10
+ exports.gchatRowsToAdd = gchatRowsToAdd;
9
11
  exports.devRowsToAdd = devRowsToAdd;
10
12
  exports.designRowsToAdd = designRowsToAdd;
11
13
  exports.materialRowsToAdd = materialRowsToAdd;
@@ -96,7 +98,10 @@ function escapeHtml(s) {
96
98
  * 既存の GITHUB_ICON と同じ流儀。
97
99
  *
98
100
  * 出典: Slack/Figma/Google Meet/Google Drive は simple-icons(CC0)、
99
- * Backlog は ヌーラボ公式のロゴ配布(nulab.com/logo-guidelines/)。
101
+ * Backlog は ヌーラボ公式のロゴ配布(nulab.com/logo-guidelines/)、
102
+ * Google Chat は公式アイコン(2023-12 刷新版・Wikimedia Commons のSVG)。
103
+ * simple-icons の単色版は形は現行だが配色が公式6色と違い、他のフルカラー公式
104
+ * アイコンの並びから浮くため使わない(#153)。
100
105
  * 改変は「サイズ指定の付与」と「余分な属性の除去」のみ。配色と形は変えていない。
101
106
  */
102
107
  const ADAPTER_ICONS = {
@@ -106,6 +111,7 @@ const ADAPTER_ICONS = {
106
111
  "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
112
  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
113
  github: GITHUB_ICON,
114
+ "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
115
  };
110
116
  /**
111
117
  * **アダプタのカタログ(能力→この製品に存在するアダプタ)。#125②**
@@ -119,7 +125,7 @@ const ADAPTER_ICONS = {
119
125
  * アダプタが存在しないので、名前を出した瞬間「対応している」と読まれる。
120
126
  * 状態を出す場所すら無いのに名前だけ出るのが #124 の症状そのもの。
121
127
  *
122
- * **値は配列。** 今日はどの能力も1個だが、「能力=1アダプタ」を焼き込むと
128
+ * **値は配列。** チャットが2個になった(#153)。「能力=1アダプタ」を焼き込むと
123
129
  * #107 / #127(複数アダプタ・複数 Backlog)で読み手の書き換えが要る。
124
130
  * ロゴは上の ADAPTER_ICONS を、表示名は CM_TOOL_LABEL を引く(キーはここの値)。
125
131
  */
@@ -128,8 +134,14 @@ const ADAPTER_CATALOG = {
128
134
  "会議": ["google-meet"],
129
135
  "共有資料": ["google-drive"],
130
136
  "デザイン": ["figma"],
131
- "チャット": ["slack"],
137
+ // チャットは2アダプタ(#153)。**先頭が能力アンカー ds-チャット の主**になるので、
138
+ // Slack を先頭に保つ(入れ替えると接続マップの飛び先が Google Chat 側へ変わる)
139
+ "チャット": ["slack", "google-chat"],
132
140
  "開発": ["github"],
141
+ // **アダプタを持たない設定タイル。** 外部ツールとの接続ではなく Cortex 自身の設定なので、
142
+ // 繋ぐ相手が無い。値を読む相手も居ない(CAP_ORDER のループで special-case が先に立ち、
143
+ // renderAdapterTile へは行かない)——ここに載せているのは能力の並びを1箇所で決めるため
144
+ "ハーネス連携": [],
133
145
  };
134
146
  /**
135
147
  * 導出された状態 → 画面に出す文言。**マップとタイルで同じ語を使う。**
@@ -173,6 +185,7 @@ const CM_TOOL_LABEL = {
173
185
  figma: "Figma",
174
186
  slack: "Slack",
175
187
  github: "GitHub",
188
+ "google-chat": "Google Chat",
176
189
  };
177
190
  /**
178
191
  * 接続マップ(データソースとの接続を視覚化したSVG)をビルド時に生成する。
@@ -253,10 +266,116 @@ const CAP_OTHER = "その他";
253
266
  function externalCapability(type) {
254
267
  if (type === "slack")
255
268
  return "チャット";
269
+ if (type === "google-chat")
270
+ return "チャット";
256
271
  if (String(type).indexOf("github") === 0)
257
272
  return "開発";
258
273
  return CAP_OTHER;
259
274
  }
275
+ /**
276
+ * 外部ソースの種別 → どのアダプタの行か。チャットが2アダプタになった(#153)ので、
277
+ * 能力への対応(externalCapability)だけでは集計とモーダルの中身が混ざる——
278
+ * Slack のタイルが「3チャンネル」とスペース込みで数え、解除ボタンが
279
+ * **別アダプタの同名 ref を撃つ**。タイル・集計・一覧の絞り込みはこの1関数を使う。
280
+ *
281
+ * **知らない種別は空文字(=どのアダプタにも属さない)。** 捨てるのではなく
282
+ * 「絞らない側」に倒す——externalCapability が「その他」へ寄せたものを
283
+ * ここで消すと、読んでいるものが無言で消える。
284
+ *
285
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
286
+ */
287
+ function sourceAdapter(type) {
288
+ if (type === "slack")
289
+ return "slack";
290
+ if (type === "google-chat")
291
+ return "google-chat";
292
+ if (String(type).indexOf("github") === 0)
293
+ return "github";
294
+ return "";
295
+ }
296
+ /**
297
+ * 外部ソースの行が「この能力・このアダプタのタイル」に属するか。
298
+ *
299
+ * **この述語はここ1箇所に持つ**(状態判定 capabilityState と件数集計 tileSub が使う)。
300
+ * 写経すると、絞り方を変えたときに片方だけ直り、同じタイルの warn と件数が
301
+ * 別の行集合から計算される——このアダプタ分割が防ごうとした混線そのものが再発する。
302
+ * adapter 未指定(接続マップ・能力単位の呼び出し)とアダプタ不明の行は「絞らない側」に倒す。
303
+ *
304
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
305
+ */
306
+ function sourceMatchesAdapter(type, cap, adapter) {
307
+ if (externalCapability(type) !== cap)
308
+ return false;
309
+ if (!adapter)
310
+ return true;
311
+ var sa = sourceAdapter(type);
312
+ return !sa || sa === adapter;
313
+ }
314
+ /**
315
+ * capabilities[cap].adapters[](前方互換キー)から、このアダプタのエントリを引く。
316
+ *
317
+ * **走査はここ1箇所に持つ。** 状態判定・タイルの表示状態・detail の3箇所が別々に
318
+ * 書き写すと規則が割れる(実際に「state 必須」と「state 不問」に割れ、state を欠く
319
+ * エントリの detail だけが画面に出る不一致を作っていた)。規則はここで固定する:
320
+ * - `adapters` が**空配列なら「持っていない」と同じ**(null を返す)。build.ts は空を
321
+ * 落とすが、ライブ経路(op=state)は素通しなので、ここで守らないと engine が
322
+ * adapters:[] を出した瞬間に全タイルが「未接続」へ退行する
323
+ * - `adapter` か `state` を欠くエントリは存在しない扱い(どのタイルの何の状態か決まらない)
324
+ *
325
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
326
+ */
327
+ function adapterEntry(cap, adapter, caps) {
328
+ var list = adapterList(cap, caps);
329
+ var found = null;
330
+ list.forEach(function (x) { if (x.adapter === adapter)
331
+ found = x; });
332
+ return found;
333
+ }
334
+ /**
335
+ * アダプタの状態・detail の**解決規則の正本**(#153)。
336
+ *
337
+ * 規則: adapters[] のエントリが答えていればそれ/有効な一覧はあるがエントリが無ければ
338
+ * unset/一覧が無ければ、**アクティブなアダプタだけ**代表値(単数形)に従う(state: null で表す)。
339
+ * 非アクティブ側は unset——代表値を流用すると「Slack の on」を Google Chat が名乗る。
340
+ *
341
+ * 🔴 **この規則をここ以外に書き写さない。** 状態判定(capabilityState)・タイル表示
342
+ * (adapterDisplayState)・detail の取り出し(タイルとモーダルの引き直し)の4箇所が
343
+ * 別々に持っていた時期に、写し同士が実際にずれた(レビューで指摘)。
344
+ *
345
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
346
+ */
347
+ function adapterResolved(cap, adapter, caps) {
348
+ var e = adapterEntry(cap, adapter, caps);
349
+ if (e)
350
+ return { state: String(e.state), detail: e.detail || "" };
351
+ if (adapterList(cap, caps).length)
352
+ return { state: "unset", detail: "" };
353
+ if (adapter === capabilityAdapter(cap, caps))
354
+ return { state: null, detail: null }; // 代表値(単数形)に従う
355
+ return { state: "unset", detail: "" };
356
+ }
357
+ /**
358
+ * adapters[] の**有効なエントリだけ**の一覧(#153)。
359
+ *
360
+ * 🔴 **「一覧を持っているか」の判定もこの関数を通すこと。** 生の adapters.length で
361
+ * 見ると、壊れたエントリだけの一覧(state 欠け等)が「一覧あり・エントリ無し=unset」と
362
+ * 解釈され、**アクティブなアダプタのタイルまで未接続に化ける**。静的ビルドは壊れた
363
+ * エントリを落とすがライブ経路(op=state)は素通しなので、判定が生値を見ていると
364
+ * 60秒の取り直しの瞬間だけ答えが変わる——adapterEntry の規則(adapter か state を
365
+ * 欠くエントリは存在しない扱い)と同じ物差しをここに一本化する。
366
+ *
367
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
368
+ */
369
+ function adapterList(cap, caps) {
370
+ var c = caps ? caps[cap] : null;
371
+ var list = c && Array.isArray(c.adapters) ? c.adapters : null;
372
+ if (!list)
373
+ return [];
374
+ var out = [];
375
+ list.forEach(function (x) { if (x && x.adapter && x.state)
376
+ out.push(x); });
377
+ return out;
378
+ }
260
379
  /**
261
380
  * その能力をいま担っているアダプタ(#125②)。
262
381
  *
@@ -390,6 +509,9 @@ exports.LIVE_CONFIRM_LIMITS = { timeoutMs: LIVE_CONFIRM_TIMEOUT_MS };
390
509
  /** 応答のキー(ASCII)→ 画面の能力名。**対応表はここ1つだけ** */
391
510
  const REGISTERED_CAP_NAMES = {
392
511
  chat: "チャット",
512
+ // チャットの2本目のアダプタ(#153)。値が chat と重複するのは意図——能力は同じで、
513
+ // 読む登録ファイルと行の型が違う(要求は常に全能力なので、キーはここに載れば自動で要求される)
514
+ "google-chat": "チャット",
393
515
  dev: "開発",
394
516
  design: "デザイン",
395
517
  materials: "共有資料",
@@ -408,6 +530,7 @@ const REGISTERED_CAP_NAMES = {
408
530
  */
409
531
  const REGISTERED_FIELDS = {
410
532
  chat: ["ref", "name", "workspace", "orgUnknown", "origin", "goldState", "notify"],
533
+ "google-chat": ["ref", "name"],
411
534
  dev: ["ref", "type"],
412
535
  design: ["key", "name"],
413
536
  materials: ["id", "name"],
@@ -416,6 +539,7 @@ const REGISTERED_FIELDS = {
416
539
  /** 欠けていたら行として読めない欄(「cm1.backlog.jp / 」のような半端な行を出さない) */
417
540
  const REGISTERED_REQUIRED = {
418
541
  chat: ["ref"],
542
+ "google-chat": ["ref"],
419
543
  dev: ["ref"],
420
544
  design: ["key"],
421
545
  materials: ["id"],
@@ -481,9 +605,84 @@ function sanitizeRegisteredNow(v) {
481
605
  }
482
606
  return any ? out : null;
483
607
  }
484
- /** 接続状態ストアの語彙(サーバ側 connection-state.mjs と同じ。増やさない) */
608
+ // ---------------------------------------------------------------------------
609
+ // ハーネス連携(決まった時間に自動で動かす機能)— オン/オフの正本を通す篩
610
+ //
611
+ // 通り道は2本あり、**どちらも同じ関数を通す**:
612
+ // 1. ビルド時 … build.ts が `ハーネス連携/schedules.json` を読む
613
+ // 2. 画面側 … 保存の応答に添えられた entry でミラーを当て直す
614
+ // 写経して2枚にすると、片方だけ語彙を増やしたときに「静的では出ないものが
615
+ // 保存直後だけ出る」という一番気づきにくい食い違いになる(capabilities で踏んだ形)。
616
+ // ---------------------------------------------------------------------------
617
+ /** 動かす時間帯。**知らない値はキーごと落とす**(画面のラベルと送る値が決まらない) */
618
+ const HARNESS_SLOTS = ["morning", "noon", "evening"];
619
+ /** 毎週のときの曜日。同じく知らない値は落とす */
620
+ const HARNESS_WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
621
+ /**
622
+ * 時間帯のラベル。**「ごろ」を必ず含める**——実行はキューの都合で前後するので、
623
+ * 「朝9時」と書くと時刻の約束に読める。12時間表記で統一(発注書の例と一致)
624
+ */
625
+ const HARNESS_SLOT_LABEL = {
626
+ morning: "朝9時ごろ",
627
+ noon: "昼1時ごろ",
628
+ evening: "夜9時ごろ",
629
+ };
630
+ /** 曜日のラベル(「毎週」の後ろに置く) */
631
+ const HARNESS_WEEKDAY_LABEL = {
632
+ mon: "月曜", tue: "火曜", wed: "水曜", thu: "木曜", fri: "金曜", sat: "土曜", sun: "日曜",
633
+ };
634
+ /**
635
+ * オンになっている機能の一覧を、画面が扱ってよい形へ畳む。**通らなかったものは黙って落とす。**
636
+ *
637
+ * - `harness`(owner/repo)と `skill` は必須。欠ける行は落とす(どの機能の話か決まらない)
638
+ * - `slot` / `weekday` は語彙外なら**キーごと落として行は残す**。プランと同じ
639
+ * 「時間帯の無い行は実行されない」状態が、画面にも「時間帯未設定」として現れるのが正しい
640
+ * - `ref` などの余りは落とす。**葉は文字列だけ**(配列やオブジェクトを通すと
641
+ * 画面に [object Object] が出る)
642
+ *
643
+ * 🔴 **クロージャを持たない純関数に保つこと。** クライアントJSへ toString() で
644
+ * 流し込んでいるので、外の変数を掴むと直列化した先で undefined を参照して壊れる
645
+ *(参照してよいのは同じく流し込んである HARNESS_SLOTS / HARNESS_WEEKDAYS だけ)。
646
+ */
647
+ function sanitizeHarnessSchedules(v) {
648
+ if (!Array.isArray(v))
649
+ return [];
650
+ const out = [];
651
+ for (const r of v) {
652
+ if (r == null || typeof r !== "object" || Array.isArray(r))
653
+ continue;
654
+ if (out.length >= 200)
655
+ break;
656
+ const harness = r.harness;
657
+ const skill = r.skill;
658
+ if (typeof harness !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(harness))
659
+ continue;
660
+ if (typeof skill !== "string" || !/^[A-Za-z0-9_-]+$/.test(skill))
661
+ continue;
662
+ const row = {
663
+ harness: harness,
664
+ skill: skill,
665
+ };
666
+ if (typeof r.slot === "string" && HARNESS_SLOTS.indexOf(r.slot) >= 0)
667
+ row.slot = r.slot;
668
+ if (typeof r.weekday === "string" && HARNESS_WEEKDAYS.indexOf(r.weekday) >= 0)
669
+ row.weekday = r.weekday;
670
+ out.push(row);
671
+ }
672
+ return out;
673
+ }
674
+ /**
675
+ * 接続状態ストアの語彙(サーバ側 connection-state-keys.mjs の REACHABLE_STATUSES /
676
+ * INGESTED_STATUSES と同じ。**勝手に増やさない・減らさない**)。
677
+ *
678
+ * 🔴 **ここに無い語の行は、overlay に届く前に消える**(`sanitizeStateNow` が落とす)。
679
+ * サーバが書き始めた語をここへ写し忘れると、画面は「登録済み(接続は未確認です)」の
680
+ * まま永久に戻らない——**行が届いていないのか、届いて落とされたのかが画面から
681
+ * 区別できない**ので、原因に辿り着けない形の壊れ方になる。
682
+ * 写し忘れても赤くならなかったので、突き合わせは viewer/test/state-vocab-parity.test.mjs。
683
+ */
485
684
  const STATE_STATUSES = {
486
- reachable: ["ok", "not_in_channel", "missing_scope", "no_token", "unreachable", "unknown"],
685
+ reachable: ["ok", "not_in_channel", "missing_scope", "no_token", "unreachable", "unknown", "not_in_space", "no_credentials"],
487
686
  ingested: ["ok", "failed", "skipped"],
488
687
  };
489
688
  const STATE_ROW_FIELDS = ["cap", "ref", "workspace", "question", "status", "at", "by", "needed", "lastOkAt", "lastFailAt"];
@@ -530,7 +729,7 @@ function sanitizeStateNow(v) {
530
729
  * いることがあるが、push のたびに再生成される設計上それが残るのは休眠案件だけで、
531
730
  * 休眠案件ではストアの行が唯一かつ最新の観測なので実害は無い。
532
731
  *
533
- * 対象はチャットと開発の行(段B。夜間チェックがこの2つを書く)。
732
+ * 対象はチャット(Slack)・Google Chat・開発の行(夜間の Gold 昇格がこの3つを書く)。
534
733
  *
535
734
  * 🔴 **突き合わせの鍵は能力ごとに違う。**
536
735
  * - chat … ワークスペース + チャンネルID。チャンネルIDはワークスペースの中でしか
@@ -538,6 +737,10 @@ function sanitizeStateNow(v) {
538
737
  * - dev … owner/repo の生文字列(devRowsToAdd と同じ)。**ワークスペースは持たない。**
539
738
  * 同じリポジトリの github-issues と github-discussions は**同じ行**を指すので、
540
739
  * 2つの行に同じ判定が載るのが正しい(書く側が SK で dedupe している)
740
+ * - google-chat … `spaces/<ID>` の生文字列だけ(#153)。**ワークスペースは持たない**
741
+ * ——Google Chat のスペース ID はテナントをまたいで一意で、鍵に足すものが無い。
742
+ * **`chat` に寄せない**のも同じ理由の裏返しで、寄せると Slack のチャンネルと
743
+ * 同じ棚に並び、タイル・件数・実測が混ざる(直す場所が違う方を指す)
541
744
  *
542
745
  * **needed(足りないスコープ)も載せる。** 載せないと、夜間が missing_scope を
543
746
  * 書いた行だけスコープ名が消え、「権限が足りません」とだけ出て何を直せばよいか
@@ -554,6 +757,8 @@ function overlayStateOnFleet(view, rows) {
554
757
  continue;
555
758
  if (r.cap === "chat")
556
759
  byKey["chat\t" + String(r.workspace || "") + "\t" + String(r.ref)] = r;
760
+ else if (r.cap === "google-chat")
761
+ byKey["google-chat\t" + String(r.ref)] = r;
557
762
  else if (r.cap === "dev")
558
763
  byKey["dev\t" + String(r.ref)] = r;
559
764
  }
@@ -564,6 +769,8 @@ function overlayStateOnFleet(view, rows) {
564
769
  let hit = null;
565
770
  if (s.type === "slack")
566
771
  hit = byKey["chat\t" + String(s.workspace || "") + "\t" + String(s.ref)];
772
+ else if (s.type === "google-chat")
773
+ hit = byKey["google-chat\t" + String(s.ref)];
567
774
  else if (String(s.type || "").indexOf("github") === 0)
568
775
  hit = byKey["dev\t" + String(s.ref)];
569
776
  if (!hit)
@@ -629,6 +836,28 @@ function chatRowsToAdd(externals, rows) {
629
836
  }
630
837
  return out;
631
838
  }
839
+ /**
840
+ * Google Chat の行で、まとめ表がまだ知らないもの。**鍵は ref のみ**——スペースの ref は
841
+ * 全ワークスペースで一意(#153)。Slack の workspace+ref 鍵を写経しない。
842
+ * 同じ ref が両方に居るときは足さない(二重表示の dedupe はここが責務)。
843
+ */
844
+ function gchatRowsToAdd(externals, rows) {
845
+ const have = {};
846
+ for (const s of externals || []) {
847
+ if (!s || s.type !== "google-chat" || !s.ref)
848
+ continue;
849
+ have[String(s.ref)] = true;
850
+ }
851
+ const out = [];
852
+ for (const r of rows || []) {
853
+ if (!r || !r.ref)
854
+ continue;
855
+ if (have[String(r.ref)])
856
+ continue;
857
+ out.push({ type: "google-chat", name: r.name || r.ref, ref: r.ref });
858
+ }
859
+ return out;
860
+ }
632
861
  /**
633
862
  * 開発の行で、まとめ表がまだ知らないもの。**鍵は `owner/repo` の生文字列。**
634
863
  *
@@ -745,7 +974,15 @@ function unsettledRegisteredCount(view, reg) {
745
974
  if (!reg)
746
975
  return 0;
747
976
  const rows = registeredRowsToAdd(view, reg);
748
- return rows.externalSources.length + rows.design.length + rows.materials.length + rows.issues.length;
977
+ // 🔴 **google-chat の行は数えない(#153)。** engine Phase 2 の配備までまとめ表は
978
+ // google-chat を永遠に知らないので、数えると全閲覧セッションで15分×60秒間隔の
979
+ // 取り直しを焼き続け、打ち切り後はそのセッションの全能力で取り直しが張られなくなる。
980
+ // ここで数えるのは「取り直せば変わり得るか」——Phase 2 前の google-chat は変わらない。
981
+ // 行の表示(「登録状況を確認中です」)はマージ側に乗るので消えない。
982
+ // **Phase 2 配備後はこの除外を外す**——自己チェックが google-chat を書き始めれば
983
+ // 取り直しで確定するようになり、除外したままだと追加直後の追いつきだけが遅れる(設計⑭)
984
+ const ext = rows.externalSources.filter(function (s) { return s.type !== "google-chat"; });
985
+ return ext.length + rows.design.length + rows.materials.length + rows.issues.length;
749
986
  }
750
987
  /** 足すべき行を能力ごとに求める(マージと件数の唯一の出どころ) */
751
988
  function registeredRowsToAdd(view, reg) {
@@ -757,7 +994,9 @@ function registeredRowsToAdd(view, reg) {
757
994
  const bt = v.backlogTarget;
758
995
  const targets = bt && Array.isArray(bt.targets) ? bt.targets : bt ? [bt] : [];
759
996
  return {
760
- externalSources: chatRowsToAdd(v.externalSources, r.chat).concat(devRowsToAdd(v.externalSources, r.dev)),
997
+ externalSources: chatRowsToAdd(v.externalSources, r.chat)
998
+ .concat(gchatRowsToAdd(v.externalSources, r["google-chat"]))
999
+ .concat(devRowsToAdd(v.externalSources, r.dev)),
761
1000
  design: designRowsToAdd(design && design.figmaFiles, r.design),
762
1001
  materials: materialRowsToAdd(materials && materials.driveFolderIds, r.materials),
763
1002
  issues: issueRowsToAdd(targets, r.issues),
@@ -786,11 +1025,17 @@ function subtractRemovedRegistered(view, reg) {
786
1025
  return v;
787
1026
  let out = v;
788
1027
  const set = (patch) => { out = out === v ? Object.assign({}, v, patch) : Object.assign(out, patch); };
789
- if (Array.isArray(reg.chat) || Array.isArray(reg.dev)) {
1028
+ // 外側ガードにも google-chat を含める(#153)——chat / dev だけ見ていると、Slack 未導入
1029
+ // (channels.json 無し)の案件では google-chat の応答だけが届いても引き算に到達しない
1030
+ if (Array.isArray(reg.chat) || Array.isArray(reg.dev) || Array.isArray(reg["google-chat"])) {
790
1031
  const chatRefs = {};
791
1032
  for (const r of reg.chat || [])
792
1033
  if (r && r.ref)
793
1034
  chatRefs[String(r.ref)] = true;
1035
+ const gchatRefs = {};
1036
+ for (const r of reg["google-chat"] || [])
1037
+ if (r && r.ref)
1038
+ gchatRefs[String(r.ref)] = true;
794
1039
  const devRefs = {};
795
1040
  for (const r of reg.dev || [])
796
1041
  if (r && r.ref)
@@ -804,6 +1049,12 @@ function subtractRemovedRegistered(view, reg) {
804
1049
  return true;
805
1050
  return chatRefs[String(s.ref)] === true;
806
1051
  }
1052
+ // Google Chat(#153): 照合は ref のみ・type で絞る(Slack の行を巻き込まない)。
1053
+ // origin の守りは不要——登録元は名簿の一系統だけ(Slack の dev-sources 併記に相当する
1054
+ // 「他からも読まれる行」が存在しない)
1055
+ if (s.type === "google-chat" && Array.isArray(reg["google-chat"])) {
1056
+ return gchatRefs[String(s.ref)] === true;
1057
+ }
807
1058
  if (String(s.type || "").indexOf("github") === 0 && Array.isArray(reg.dev)) {
808
1059
  // 「読み取り停止中」(exclude)の行は登録簿の sources に居ないが、
809
1060
  // まとめ表はバッジを出すために保持している——引くと停止中の表示ごと消える
@@ -1139,6 +1390,12 @@ function sanitizeCapabilitiesNow(v) {
1139
1390
  for (const id in STATE_CAP_NAMES) {
1140
1391
  if (!Object.prototype.hasOwnProperty.call(STATE_CAP_NAMES, id))
1141
1392
  continue;
1393
+ // 🔴 **google-chat キーはここでは読まない(#153)。** chat と同じ能力名「チャット」に
1394
+ // 写像されるため、書くと**後勝ちで Slack の行(代表値)を上書きする**——サーバが将来
1395
+ // capabilitiesNow.google-chat を答え始めた日に、接続済み案件の Slack タイルが未接続に化ける。
1396
+ // 能力単位の代表は chat が持ち、アダプタ別の状態は adapters[](fleet 由来)が持つ
1397
+ if (id === "google-chat")
1398
+ continue;
1142
1399
  const c = v[id];
1143
1400
  if (c == null || typeof c !== "object" || Array.isArray(c))
1144
1401
  continue;
@@ -1256,6 +1513,12 @@ const TILE_CAPS = {
1256
1513
  "共有資料": { id: "materials", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1257
1514
  "デザイン": { id: "design", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "トークン" },
1258
1515
  "チャット": { id: "chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1516
+ // チャットの2枚目のアダプタ(#153・判断⑲)。**タイルの導出はアダプタで割る**——
1517
+ // 割らないと Google Chat タイルが Slack の登録件数・実測で判定される。
1518
+ // 鍵はタイル側(capDerivation)が「能力-アダプタ」で引く。実測ストアの
1519
+ // `cap: "google-chat"` の行は夜間の Gold 昇格が書く(#153 PR-E で結線済み)ので、
1520
+ // 夜間が一度走れば規則2/3(⚠️ / 接続OK)まで進む。走る前は規則4(実績なし→中立)
1521
+ "チャット-google-chat": { id: "google-chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1259
1522
  "開発": { id: "dev", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1260
1523
  };
1261
1524
  /**
@@ -1274,10 +1537,17 @@ function keyMissingText(cap) {
1274
1537
  /**
1275
1538
  * 実測が「悪い」と言える語。**`unknown` は入れない**——測れなかっただけで、
1276
1539
  * 悪いとは言えない(入れると、一度も測れていない案件が全部⚠️になる)。
1540
+ *
1541
+ * 🔴 **`reachable` は「`ok` と `unknown` 以外は全部悪い」。** 新しい語を足して
1542
+ * ここへ入れ忘れると、`deriveCapTile` が悪いと数えず、タイルが中立の
1543
+ * 「登録済み(接続は未確認です)」に落ちる——**夜間が答えを出しているのに画面が
1544
+ * 無音になる**(接続マップだけが⚠️で、タイルは静か、という食い違いにもなる)。
1545
+ * `not_in_space` / `no_credentials` はどちらも読めていない状態なので入れる
1546
+ * (文言の出し分けは DS_GATE が持つ)。突き合わせは state-vocab-parity.test.mjs。
1277
1547
  */
1278
1548
  const BAD_STATE_STATUSES = {
1279
1549
  ingested: ["failed", "skipped"],
1280
- reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable"],
1550
+ reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable", "not_in_space", "no_credentials"],
1281
1551
  };
1282
1552
  /**
1283
1553
  * タイル1行(と接続グラフの1ノード)の状態を、**正本から導出する**(0.12.0)。
@@ -1372,7 +1642,7 @@ function deriveCapTile(cap, ctx) {
1372
1642
  // ---- 規則4: 実績なし → 中立 ----
1373
1643
  return { rule: 4, state: "idle", text: meta.noFact, keyMissing: false };
1374
1644
  }
1375
- function capabilityState(cap, ctx) {
1645
+ function capabilityState(cap, ctx, adapter) {
1376
1646
  // **能力→パイプラインの対応はここに持つ。** 呼び出し側に渡させると、
1377
1647
  // クライアント側(この関数を .toString() で受け取る側)が同じ表を書き写すことになり、
1378
1648
  // 書き忘れると warn 判定が丸ごと死ぬ——実データで、東京ヴェルディの課題管理が
@@ -1386,7 +1656,10 @@ function capabilityState(cap, ctx) {
1386
1656
  // **ライブ参照かどうかは、能力名ではなく渡された行から決める。** 名前で決めていたとき、
1387
1657
  // 未知の種別を寄せた「その他」は行を2件持っているのに「未使用」と言い切っていた
1388
1658
  // (読み手は未使用を信じて押さないので、行が消えるより悪い)
1389
- const liveExts = ctx.externals.filter(function (s) { return externalCapability(s.type) === cap; });
1659
+ // **アダプタが指定されたら、その行だけで数える(#153)。** チャットが2アダプタになったので、
1660
+ // Slack のタイルの warn 判定に Google Chat の gate 落ちが混ざると、直す場所が違う方を指す。
1661
+ // 絞り方の正本は sourceMatchesAdapter(tileSub の件数集計と同じものを使う)
1662
+ const liveExts = ctx.externals.filter(function (s) { return sourceMatchesAdapter(s.type, cap, adapter); });
1390
1663
  const isLive = cap === "チャット" || cap === "開発" || cap === CAP_OTHER;
1391
1664
  const internal = ctx.internals.filter(function (s) { return s.kind === cap; })[0];
1392
1665
  const pipeRe = PIPE[cap];
@@ -1399,7 +1672,19 @@ function capabilityState(cap, ctx) {
1399
1672
  // **「問題あり(warn)」は別軸なので導出に混ぜない。** 設定は済んでいる(on)のに
1400
1673
  // 読めていない・同期が失敗している、は設定ファイルからは分からない。
1401
1674
  // broken(設定そのものが壊れている)だけは導出が答えを持っている。
1402
- const derived = capabilityRawState(cap, ctx.capabilities);
1675
+ let derived = capabilityRawState(cap, ctx.capabilities);
1676
+ // **アダプタ指定の呼び出しでは、そのアダプタの状態で判定する(#153)。**
1677
+ // - adapters[](前方互換キー)にエントリがあればそれが正本(引き方は adapterEntry の1箇所)
1678
+ // - adapters[] はあるがエントリが無い → engine がまだ知らない= unset 側へ倒す
1679
+ // - adapters[] を持たない旧データ → 単数形は broken > on > その他 の**代表値**なので、
1680
+ // アクティブなアダプタにだけ使う。非アクティブ側に流用すると「Slack の on」を
1681
+ // Google Chat が名乗る(レビュー指摘1の片割れ)
1682
+ if (adapter && ctx.capabilities && ctx.capabilities[cap]) {
1683
+ // 解決規則の正本は adapterResolved(state: null は「代表値=単数形に従う」)
1684
+ const r = adapterResolved(cap, adapter, ctx.capabilities);
1685
+ if (r.state !== null)
1686
+ derived = r.state;
1687
+ }
1403
1688
  if (derived !== null) {
1404
1689
  if (derived === "off")
1405
1690
  return "off";
@@ -1420,6 +1705,14 @@ function capabilityState(cap, ctx) {
1420
1705
  // ここは**生の状態**(capabilityRawState)で見ている。empty と unset は元々どちらも
1421
1706
  // idle に落ちるので、表示側の写像(capabilityDisplayState が empty を unset に畳む)と
1422
1707
  // 答えが割れることはない。**この2つを分けるなら、写像とここの両方を直すこと。**
1708
+ //
1709
+ // **ただし、ライブ参照の行が実在して読めていないなら⚠️を出す(#153 レビュー)。**
1710
+ // 行が fleet-status に載っている=夜間の読み取りが現に対象にしている、という観測。
1711
+ // 状態が unset(engine がこのアダプタをまだ知らない移行期間)だからと黙らせると、
1712
+ // 同じ行から描く接続マップだけが⚠️になり、「マップが⚠️・直下は静か」という
1713
+ // 既知の最悪事故の変形が移行期間のデフォルトになる
1714
+ if (liveExts.some(function (s) { return sourceNeedsAttention(s); }))
1715
+ return "warn";
1423
1716
  return "idle";
1424
1717
  }
1425
1718
  // ---- ここから下は capabilities を持たない fleet-status.json のための防御 ----
@@ -2525,6 +2818,32 @@ a.ds-name:hover { text-decoration: underline; }
2525
2818
  .ds-tile-sub { font-size: 12.5px; margin-top: 2px; }
2526
2819
  .ds-tile-chev { color: var(--muted); flex: 0 0 auto; }
2527
2820
  .ds-tile-note { font-size: 12.5px; color: #a15c00; margin-top: 2px; }
2821
+ /* 「オンにしたのに配信先が無い」(ハーネス連携)。**必ず .ds-tile-note より後ろに置く**
2822
+ ——同じ詳細度なので、前に書くと後ろの #a15c00 に負けて色が変わらない。
2823
+ 琥珀(設定の不足)より強い赤にするのは、この状態だと**動いているのに誰にも届かない**ため */
2824
+ .ds-tile-alert { color: #b91c1c; }
2825
+ /* ---- ハーネス連携のモーダル(機能ごとの1行)----
2826
+ 枠は .ds-card に揃える(同じモーダルの中で行の見え方を割らない)。
2827
+ 縦組みにするのは、1行に「名前+周期」「説明」「操作」の3段が入るため */
2828
+ .ds-harness-list { display: flex; flex-direction: column; gap: 10px; }
2829
+ .ds-harness-row {
2830
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
2831
+ padding: 14px 16px; box-shadow: var(--shadow);
2832
+ display: flex; flex-direction: column; gap: 4px;
2833
+ }
2834
+ .ds-harness-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; }
2835
+ .ds-harness-name { font-weight: 700; }
2836
+ .ds-harness-period { font-size: 12.5px; color: var(--muted); }
2837
+ .ds-harness-summary { font-size: 12.5px; color: var(--muted); }
2838
+ /* 時間帯・曜日の選択。トグルと同じ行に並べる(押した直後に調整できるのが要点) */
2839
+ .ds-harness-selects { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
2840
+ .ds-harness-selects select { font-size: 12px; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: #fff; color: var(--text); }
2841
+ .ds-harness-selects select:disabled { opacity: .6; cursor: wait; }
2842
+ /* 一覧の代わりに出す1行(読み込み中・0件・読み込めなかった)。**3つを見分けられる色にする** */
2843
+ .ds-harness-state { font-size: 12.5px; color: var(--muted); margin: 4px 0 10px; }
2844
+ .ds-harness-state.is-error { color: #b42318; }
2845
+ /* 一部の提供元から読めなかったとき。**行は出したうえで**「並んでいないものがある」と断る */
2846
+ .ds-harness-warn { font-size: 12.5px; color: #a15c00; margin: 4px 0 10px; }
2528
2847
  /* ---- 可変長の文字列は必ず折り返す(0.4.71) ----
2529
2848
  実機の受け入れテストで、状態の文言がタイル幅を超えて溢れた(スシローのデザインタイル)。
2530
2849
  ここに出るのは engine の detail・チェックの直し方・チャンネル名・ファイルパスなど
@@ -2538,7 +2857,9 @@ a.ds-name:hover { text-decoration: underline; }
2538
2857
  .ds-check-label, .ds-check-action, .ds-check-detail,
2539
2858
  .ds-pipe-label, .ds-pipe-todo,
2540
2859
  .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 {
2860
+ .ds-drive-note, .ds-inactive-note, .ds-legacy-chip, .ds-keys-code, .ds-gate-help,
2861
+ /* ハーネス連携の行。機能の表示名・説明・周期の文はどれも提供側が決めるので長さが読めない */
2862
+ .ds-harness-name, .ds-harness-period, .ds-harness-summary, .ds-harness-state, .ds-harness-warn {
2542
2863
  min-width: 0; white-space: normal; overflow-wrap: anywhere;
2543
2864
  }
2544
2865
  .ds-toggle { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
@@ -2710,6 +3031,15 @@ a.ds-name:hover { text-decoration: underline; }
2710
3031
  .mt-share-title { font-size: 12.5px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
2711
3032
  /* 共有すると何が起きるかの1行。**説明文より強く出す**(.ds-scope-note と同じ理由) */
2712
3033
  .mt-share-note { color: var(--text); margin-top: 6px; }
3034
+ /* 「全部見られてしまうのでは」への答え(2026-08-29 オーナー確定文)。
3035
+ **手順より前に置く。** 読む人が「共有しようか」と迷うのは手順を踏む前で、
3036
+ 手順(画像2枚ぶん長い)の後ろに置くと、迷ったまま読み進むことになる。
3037
+ 警告帯(.draft-note)と同じ系統の色で、注意書きだと一目で分かる形にする */
3038
+ .mt-privacy {
3039
+ margin: 8px 0 0; padding: 8px 10px;
3040
+ background: #fffbeb; border: 1px solid #fcd34d; border-radius: 6px;
3041
+ color: #92400e; line-height: 1.8;
3042
+ }
2713
3043
  /* フォルダを探す手間をなくすリンク。検索URLなので**誰のドライブでも1本で効く**
2714
3044
  (フォルダIDは人ごとに違うので、固定URLは作れない) */
2715
3045
  .mt-share-open { display: inline-block; margin: 6px 0 2px; font-weight: 700; }
@@ -2902,6 +3232,11 @@ var ADAPTER_CATALOG = ${JSON.stringify(ADAPTER_CATALOG)};
2902
3232
  var CAP_STATE_TEXT = ${JSON.stringify(CAP_STATE_TEXT)};
2903
3233
  var CAP_STATE_MARK = ${JSON.stringify(CAP_STATE_MARK)};
2904
3234
  ${externalCapability.toString()}
3235
+ ${sourceAdapter.toString()}
3236
+ ${sourceMatchesAdapter.toString()}
3237
+ ${adapterList.toString()}
3238
+ ${adapterEntry.toString()}
3239
+ ${adapterResolved.toString()}
2905
3240
  ${capabilityAdapter.toString()}
2906
3241
  ${capabilityRawState.toString()}
2907
3242
  ${capabilityDisplayState.toString()}
@@ -2921,7 +3256,15 @@ var REGISTERED_DEV_TYPES = ${JSON.stringify(REGISTERED_DEV_TYPES)};
2921
3256
  var UNSETTLED_TTL_MS = ${JSON.stringify(UNSETTLED_TTL_MS)};
2922
3257
  var UNSETTLED_POLL_MS = ${JSON.stringify(UNSETTLED_POLL_MS)};
2923
3258
  ${sanitizeRegisteredNow.toString()}
3259
+ // ハーネス連携(決まった時間に動かす機能)の語彙とラベル。**判定の実体はビルド時の定義**
3260
+ // ——画面側に書き写すと、許可リストが2枚になって語彙の追加漏れが静かに消える
3261
+ var HARNESS_SLOTS = ${JSON.stringify(HARNESS_SLOTS)};
3262
+ var HARNESS_WEEKDAYS = ${JSON.stringify(HARNESS_WEEKDAYS)};
3263
+ var HARNESS_SLOT_LABEL = ${JSON.stringify(HARNESS_SLOT_LABEL)};
3264
+ var HARNESS_WEEKDAY_LABEL = ${JSON.stringify(HARNESS_WEEKDAY_LABEL)};
3265
+ ${sanitizeHarnessSchedules.toString()}
2924
3266
  ${chatRowsToAdd.toString()}
3267
+ ${gchatRowsToAdd.toString()}
2925
3268
  ${devRowsToAdd.toString()}
2926
3269
  ${designRowsToAdd.toString()}
2927
3270
  ${materialRowsToAdd.toString()}
@@ -2968,6 +3311,13 @@ ${deriveCapTile.toString()}
2968
3311
 
2969
3312
  // 本文中のウィキリンク [[用語]](・[[用語|表示名]])を、その用語レコードへのSPA内リンクに変換する。
2970
3313
  // 解決先は term:用語。存在しない用語は素のテキストに落とす(角括弧を外す)。
3314
+ //
3315
+ // 🔴 **前提: ここに渡ってくるHTMLは、ビルド時に sanitizeBody() を通ったものだけ。**
3316
+ // エスケープしているのは生成する data-go 属性値の & と " だけで、**表示名(disp)は
3317
+ // 未エスケープのまま出力に混ぜている**。つまり [[用語|<img src=x onerror=1>]] は
3318
+ // それ自体としては注入経路で、生HTMLがビルド時に既に除去されていることだけが
3319
+ // これを無害にしている。サニタイズを通っていないHTMLをこの関数に渡さないこと
3320
+ // (bodies/*.html と bodyHtml 以外を流し込む経路を作らない)。
2971
3321
  function linkifyWiki(html) {
2972
3322
  if (!html) return html;
2973
3323
  return html.replace(/\\[\\[([^\\]|]+?)(?:\\|([^\\]]+?))?\\]\\]/g, function (_m, name, label) {
@@ -3106,6 +3456,11 @@ ${deriveCapTile.toString()}
3106
3456
  return el("div", { class: "rt-copy" }, [el("div", { class: "rt-url-row" }, [field, btn]), msg]);
3107
3457
  }
3108
3458
 
3459
+ // 要素を組み立てるヘルパ。
3460
+ // ⚠️ **el() 自体は無害化しない。** class/text 以外のキーはそのまま setAttribute へ渡すので
3461
+ // href に javascript: を置けるし、html: は innerHTML への抜け道(実使用は
3462
+ // homeGroundingSections の1箇所=ビルド時サニタイズ済みのHTMLのみ)。
3463
+ // 外部由来のURLを href に載せるときは、呼び出し側で isHttp() を通すこと。
3109
3464
  function el(tag, props, children) {
3110
3465
  var e = document.createElement(tag);
3111
3466
  if (props) Object.keys(props).forEach(function (k) {
@@ -3589,8 +3944,16 @@ ${deriveCapTile.toString()}
3589
3944
  fetch(rec.bodyRef).then(function (res) { if (!res.ok) throw new Error("http " + res.status); return res.text(); })
3590
3945
  .then(function (html) { var lk = linkifyWiki(html); bodyCache[rec.bodyRef] = lk; mount.innerHTML = lk; done(); })
3591
3946
  .catch(function () {
3592
- mount.innerHTML = '<div class="body-loading">本文を読み込めませんでした(静的サーバで配信してください)。' +
3593
- (rec.editUrl ? ' <a href="' + rec.editUrl + '" target="_blank" rel="noopener">GitHubで開く</a>' : '') + '</div>';
3947
+ // 🔴 文字列連結でHTMLを組み立てない(editUrl は frontmatter 由来の外部文字列)。
3948
+ // el() は class/text/html 以外のキーをそのまま setAttribute に渡すので、
3949
+ // href に javascript: を置けてしまう。**isHttp() が唯一の防御**(保険ではない)
3950
+ var box = el("div", { class: "body-loading", text: "本文を読み込めませんでした(静的サーバで配信してください)。" });
3951
+ if (rec.editUrl && isHttp(rec.editUrl)) {
3952
+ box.appendChild(document.createTextNode(" "));
3953
+ box.appendChild(el("a", { href: rec.editUrl, target: "_blank", rel: "noopener", text: "GitHubで開く" }));
3954
+ }
3955
+ mount.innerHTML = "";
3956
+ mount.appendChild(box);
3594
3957
  });
3595
3958
  }
3596
3959
  function getParam(name) {
@@ -3883,6 +4246,9 @@ ${deriveCapTile.toString()}
3883
4246
  // 顧客も読む面なので内部用語(cron・stub等)を避け「毎晩自動で〜」調にする。
3884
4247
  var DS_TYPE = {
3885
4248
  "slack": { icon: "💬", label: "Slack" },
4249
+ // 種別スロットに入るのはツール名(能力名や単位語ではない)——Slack 行が "Slack" を
4250
+ // 出しているのと同じ扱い(#170 の行の約束)。「スペース」は件数の単位としてだけ使う
4251
+ "google-chat": { icon: "💬", label: "Google Chat" },
3886
4252
  "github-issues": { icon: "🐙", label: "GitHub Issues" },
3887
4253
  "github-discussions": { icon: "💠", label: "GitHub Discussions" },
3888
4254
  };
@@ -3902,6 +4268,23 @@ ${deriveCapTile.toString()}
3902
4268
  // 残してあるのは「知らない種別のソースを黙って消さない」ため——新しいアダプタが
3903
4269
  // 増えた日に、行が状態を持たずに並ぶより、素の語でも出ているほうがよい
3904
4270
  "no_token": { text: "トークン未設定", cls: "ds-gate-muted" },
4271
+ // --- ここから Google Chat(#153)。値の正本は infra 側の語彙表(slack-gate.mjs)で、
4272
+ // 1対1の一致を infra のテストが見張る。**バッジは短語・次の一手は help に分ける**
4273
+ // (DS_GATE_NO_TOKEN と同じ作り。バッジに長文を入れると行が折り返す)。
4274
+ // 判定を返すのは engine の夜間実測(Phase 2)——画面は語彙と文面だけを先に持つ
4275
+ // not_in_channel の「招待」とは直す操作が別物なので寄せない
4276
+ "not_in_space": {
4277
+ text: "スペースにアプリ未追加",
4278
+ cls: "ds-gate-warn",
4279
+ help: "スペースの「アプリと統合」からアプリを追加してください。",
4280
+ },
4281
+ // no_token の「トークン未設定」は接続ボタンの無い Google Chat では打つ手を示せないので寄せない。
4282
+ // 内部語(サービスアカウント・鍵)は出さない——顧客側に打つ手が無い状態のため
4283
+ "no_credentials": {
4284
+ text: "接続の準備中",
4285
+ cls: "ds-gate-muted",
4286
+ help: "管理者の設定が済むと読み取りが始まります。",
4287
+ },
3905
4288
  "unknown": { text: "不明", cls: "ds-gate-muted" },
3906
4289
  };
3907
4290
  /* ---------------------------------------------------------------------------
@@ -4125,6 +4508,33 @@ ${deriveCapTile.toString()}
4125
4508
  return order;
4126
4509
  }
4127
4510
 
4511
+ /**
4512
+ * 会議名に入れる目印。**案件が宣言した合図が優先で、無ければ艦隊キー。**
4513
+ *
4514
+ * 人が打つのは案件が決めた合図(艦隊キーは長くてカレンダーに打てないので meetingKey で
4515
+ * 短くしている)。**自動取り込みが動いているときだけ**返す——手動運用の案件に
4516
+ * 会議名の条件を出すと、自動で拾われると誤解させる。
4517
+ *
4518
+ * ここに集めているのは、**同じ値を2箇所(目印の案内とプライバシーの注釈)で出す**ため。
4519
+ * 別々に導出すると、片方だけ艦隊キーのままになる事故が起きる
4520
+ */
4521
+ function meetingSignalOf(s) {
4522
+ if (!s || s.kind !== "会議" || s.ingestState !== "on") return null;
4523
+ return s.meetingKey || (SITE.intake && SITE.intake.projectKey) || null;
4524
+ }
4525
+
4526
+ /**
4527
+ * 経過措置で登録された会議名(別名)。**目印が無くてもこの名前の会議は取り込まれる。**
4528
+ *
4529
+ * 目印の導出と同じく1箇所にまとめる——別名の一覧(.ds-legacy)と、
4530
+ * プライバシーの注釈(.mt-privacy)が**同じ条件で出る**必要があるため。
4531
+ * 片方だけ出ると、注釈の「上に並ぶ登録済みの会議名」が指す先が画面に無くなる
4532
+ */
4533
+ function meetingAliasesOf(s) {
4534
+ if (!s || s.kind !== "会議" || s.ingestState !== "on") return [];
4535
+ return s.meetingAliases || [];
4536
+ }
4537
+
4128
4538
  /**
4129
4539
  * 会議の取り込みの案内。**操作ではなく条件を示す。**
4130
4540
  *
@@ -4134,7 +4544,7 @@ ${deriveCapTile.toString()}
4134
4544
  *
4135
4545
  * ②の材料を画面が出さないと、招待したのに届かない理由が分からない。
4136
4546
  */
4137
- function meetingHowTo() {
4547
+ function meetingHowTo(s) {
4138
4548
  // **2つの条件がANDであることを、読み飛ばせない形で出す。** 散文で並べていたときに
4139
4549
  // 「会議名は合っているのにBotを招待しておらず取り込まれなかった」事故が実際に起きた。
4140
4550
  // 会議名の条件は上の行(ds-keys)が出すので、ここでは「上の語」と参照するに留める。
@@ -4145,7 +4555,7 @@ ${deriveCapTile.toString()}
4145
4555
  ul.appendChild(el("li", { text: "会議名に上の目印が入っている" }));
4146
4556
  wrap.appendChild(ul);
4147
4557
  wrap.appendChild(el("p", { text: "どちらか一方だけでは取り込まれません。" }));
4148
- wrap.appendChild(meetingFolderShare());
4558
+ wrap.appendChild(meetingFolderShare(meetingSignalOf(s), meetingAliasesOf(s).length));
4149
4559
  return wrap;
4150
4560
  }
4151
4561
 
@@ -4165,12 +4575,45 @@ ${deriveCapTile.toString()}
4165
4575
  * 見えないことは別なので、両方を1行で正直に書く。
4166
4576
  */
4167
4577
  // 注: ここは埋め込みJSのテンプレートリテラル内。コメントでもバッククォートを使わない
4168
- function meetingFolderShare() {
4578
+ function meetingFolderShare(signal, aliasCount) {
4169
4579
  var box = el("div", { class: "mt-share" });
4170
4580
  box.appendChild(el("div", { class: "mt-share-title", text: "毎回の招待をやめる方法(会議の主催者向け・任意)" }));
4171
4581
  box.appendChild(el("p", { text: "自分の Google ドライブにある「Meet Recordings」フォルダを、下のアカウントに「閲覧者」で1回だけ共有すると、以後あなたが主催する会議は招待が要らなくなります。会議名の目印はこれまでどおり必要です。" }));
4172
4582
  box.appendChild(copyRow(DRIVE_INGEST_ACCOUNT));
4173
4583
 
4584
+ /*
4585
+ * 🔴 **「全部見られてしまうのでは」に答える**(2026-08-29 オーナー確定文・表記も含めて変えない)。
4586
+ *
4587
+ * すぐ下の注記(.mt-share-note)は「**取り込むか**」に答えていて、
4588
+ * 「**見るのか**」に答えていない。共有をためらう理由はそちらなので、専用に1行置く。
4589
+ *
4590
+ * **実装と一致していることを確かめてある**(apps-script/src/Main.gs:79-102・
4591
+ * Router.gs:65-91・Transcripts.gs の getMeetingName_):
4592
+ * - 振り分けに使うのは file.getName()(ファイル名)だけ
4593
+ * - 当たるのは**目印**(meetingNameHasKey_)**または別名の完全一致**
4594
+ * (meetingNameIsAlias_)の2通り。**別名は目印が要らない**
4595
+ * - どちらにも当たらないファイルは getTranscriptText_(Docs のエクスポート)を
4596
+ * 呼ぶ前に return する=**中身を開かない**
4597
+ * - どのリポジトリにも Slack にも書かない
4598
+ *
4599
+ * 🔴 **最初の版は「目印が入っていないメモは開かない」と書いて嘘になっていた**
4600
+ *(別名の経路を見落としていた)。しかも同じカードの .ds-legacy が
4601
+ * 「目印が無くても取り込まれます」と並んで出ていた——**カードが自分を否定していた**。
4602
+ * 文言を触るときは、必ず Router.gs の照合の**2通り**を見直すこと
4603
+ */
4604
+ var mark = signal ? "案件の目印([" + signal + "] など)" : "案件の目印";
4605
+ // **別名が1件も無い案件では「上に並ぶ登録済みの会議名」が画面に存在しない。**
4606
+ // 指す先が無いものを指すと、そこがまた嘘になるので、その句だけを機械的に落とす
4607
+ //(新しい言い回しは足さない)
4608
+ var scope = aliasCount
4609
+ ? mark + "と、上に並ぶ登録済みの会議名——このどちらにも当てはまらない"
4610
+ : mark + "に当てはまらない";
4611
+ box.appendChild(el("p", {
4612
+ class: "mt-privacy",
4613
+ text: "⚠ 共有を開始すると、bot はこのフォルダのファイル名だけを見ます。" + scope +
4614
+ "メモは、中身を開きません。読み取りも保存も行わないので、1on1や面談のメモがどこかに残ることはありません。",
4615
+ }));
4616
+
4174
4617
  // **フォルダを探すところから助ける。** フォルダIDは人ごとに違うので固定URLは作れないが、
4175
4618
  // 検索URLなら**1本で全員に効く**(開いた人のドライブの中で検索が走る)。
4176
4619
  // **共有ダイアログを直接開くURLは作れない**ので、リンクの先で1手かかることを
@@ -4194,7 +4637,10 @@ ${deriveCapTile.toString()}
4194
4637
  ]));
4195
4638
  box.appendChild(steps);
4196
4639
 
4640
+ // 権限の話(**bot から見える状態になる**)は、挙動の話(⚠ の注釈)と隣に置かない。
4641
+ // 並べると「見ない」→「見える」と続いて、正本が丁寧に分けている区別が消える
4197
4642
  box.appendChild(el("p", { class: "mt-share-note", text: "共有すると、そのフォルダに入る会議メモは bot から見える状態になります(実際に取り込まれるのは、会議名に目印が入っている会議だけです)。" }));
4643
+
4198
4644
  return box;
4199
4645
  }
4200
4646
 
@@ -4836,6 +5282,19 @@ ${deriveCapTile.toString()}
4836
5282
  */
4837
5283
  var LIVE_FLEET = null;
4838
5284
  var LIVE_FLEET_FAILED = false;
5285
+ /**
5286
+ * いまオンになっているハーネスの機能(ページ生存中の正本)。
5287
+ *
5288
+ * 🔴 **ここ(CLIENT_JS のトップレベル)に置くのが仕様。** buildDataSources の中に
5289
+ * 置くと、モーダルを閉じるたびに焼き値へ巻き戻る——close() は refreshDataSources() を
5290
+ * 呼んで節を作り直すので、**保存してモーダルを閉じた瞬間に、タイルの2行目と
5291
+ * 赤バッジが保存前の姿へ戻る**。loadLiveFleet の応答・失敗の経路でも再初期化しない
5292
+ *(op=state はこのキーを運ばないので、上書きできる材料がそもそも無い)。
5293
+ *
5294
+ * **寿命はこのページ内だけ。** 恒久化は保存コミット→次回ビルドの焼き直しで、
5295
+ * ここは「押した結果がその場で見える」ためのミラー(notifyToggle の s.notify と同型)。
5296
+ */
5297
+ var HARNESS_SCHEDULES = sanitizeHarnessSchedules(SITE.harnessSchedules);
4839
5298
  /**
4840
5299
  * リアルタイム同期の可否(op=state の realtime。#113)。取得前・取得失敗時は null。
4841
5300
  *
@@ -4944,7 +5403,22 @@ ${deriveCapTile.toString()}
4944
5403
  if (!LIVE_CAPS) return base;
4945
5404
  var out = {};
4946
5405
  for (var k in base) if (Object.prototype.hasOwnProperty.call(base, k)) out[k] = base[k];
4947
- for (var n in LIVE_CAPS) if (Object.prototype.hasOwnProperty.call(LIVE_CAPS, n)) out[n] = LIVE_CAPS[n];
5406
+ for (var n in LIVE_CAPS) {
5407
+ if (!Object.prototype.hasOwnProperty.call(LIVE_CAPS, n)) continue;
5408
+ var liveRow = LIVE_CAPS[n];
5409
+ // **アダプタ別の状態(adapters[]・#153)は live の丸ごと上書きで消さない。**
5410
+ // capabilitiesNow は能力単位の代表値しか運ばないので、fleet が持つ adapters[] を
5411
+ // そのまま落とすと、読み込みの数秒後に broken の⚠️と detail が静かに消える
5412
+ var baseRow = base[n];
5413
+ if (baseRow && Array.isArray(baseRow.adapters) && liveRow && !Array.isArray(liveRow.adapters)) {
5414
+ var merged = {};
5415
+ for (var f in liveRow) if (Object.prototype.hasOwnProperty.call(liveRow, f)) merged[f] = liveRow[f];
5416
+ merged.adapters = baseRow.adapters;
5417
+ out[n] = merged;
5418
+ } else {
5419
+ out[n] = liveRow;
5420
+ }
5421
+ }
4948
5422
  return out;
4949
5423
  }
4950
5424
 
@@ -5006,7 +5480,14 @@ ${deriveCapTile.toString()}
5006
5480
  if (!LIVE_REGISTERED) return {};
5007
5481
  var add = registeredRowsToAdd(rawView(), LIVE_REGISTERED);
5008
5482
  var out = {};
5009
- for (var i = 0; i < add.externalSources.length; i++) out[externalCapability(add.externalSources[i].type)] = 1;
5483
+ for (var i = 0; i < add.externalSources.length; i++) {
5484
+ var addType = add.externalSources[i].type;
5485
+ out[externalCapability(addType)] = 1;
5486
+ // **アダプタ別の鍵も立てる(#153)。** チャットは2タイルなので、能力単位だけだと
5487
+ // Slack の確認中が Google Chat タイルにも出る(逆も)。鍵の形は「能力\tアダプタ」
5488
+ var addAd = sourceAdapter(addType);
5489
+ if (addAd) out[externalCapability(addType) + "\t" + addAd] = 1;
5490
+ }
5010
5491
  if (add.design.length) out[REGISTERED_CAP_NAMES.design] = 1;
5011
5492
  if (add.materials.length) out[REGISTERED_CAP_NAMES.materials] = 1;
5012
5493
  if (add.issues.length) out[REGISTERED_CAP_NAMES.issues] = 1;
@@ -5021,8 +5502,16 @@ ${deriveCapTile.toString()}
5021
5502
  return n;
5022
5503
  };
5023
5504
  var isSlack = function (x) { return x.type === "slack"; };
5505
+ var isGchat = function (x) { return x.type === "google-chat"; };
5024
5506
  var isGh = function (x) { return String(x.type || "").indexOf("github") === 0; };
5025
- if (count(raw.externalSources, isSlack) > 0 && count(sub.externalSources, isSlack) === 0) out["チャット"] = 1;
5507
+ // 「0件になった能力」の判定も述語ごと=アダプタごとに(#153。能力単位だけだと
5508
+ // Google Chat の最後の1件を解除したとき Slack タイルまで「確認中」になる)
5509
+ if (count(raw.externalSources, isSlack) > 0 && count(sub.externalSources, isSlack) === 0) {
5510
+ out["チャット"] = 1; out["チャット\tslack"] = 1;
5511
+ }
5512
+ if (count(raw.externalSources, isGchat) > 0 && count(sub.externalSources, isGchat) === 0) {
5513
+ out["チャット"] = 1; out["チャット\tgoogle-chat"] = 1;
5514
+ }
5026
5515
  if (count(raw.externalSources, isGh) > 0 && count(sub.externalSources, isGh) === 0) out["開発"] = 1;
5027
5516
  var dRaw = internalOf(raw.internalSources || [], REGISTERED_CAP_NAMES.design);
5028
5517
  var dSub = internalOf(sub.internalSources || [], REGISTERED_CAP_NAMES.design);
@@ -5036,7 +5525,16 @@ ${deriveCapTile.toString()}
5036
5525
  return out;
5037
5526
  }
5038
5527
  /** その能力に、まとめ表がまだ知らない行があるか */
5039
- function capPending(cap) { return !!pendingCaps()[cap]; }
5528
+ /**
5529
+ * その能力(アダプタを渡せばそのタイル)に、まとめ表がまだ知らない行があるか。
5530
+ * **アダプタ別の鍵があるのはチャットだけ**(複数アダプタの能力)。アダプタ付きで
5531
+ * 呼ばれたらアダプタの鍵だけを見る——能力の鍵へ倒すと、絞った意味が消える
5532
+ */
5533
+ function capPending(cap, adapter) {
5534
+ var m = pendingCaps();
5535
+ if (adapter && (ADAPTER_CATALOG[cap] || []).length > 1) return !!m[cap + "\t" + adapter];
5536
+ return !!m[cap];
5537
+ }
5040
5538
 
5041
5539
  /**
5042
5540
  * 能力の状態を決めるときに渡す値。**足した行は混ぜない**(誰も測っていないので状態を持たない)。
@@ -5079,10 +5577,26 @@ ${deriveCapTile.toString()}
5079
5577
  * (自動取り込みが OFF)は 「capabilities」 の導出が答え、呼び出し側がこの関数より
5080
5578
  * 先に出す(deriveCapTile の項)。
5081
5579
  */
5082
- function registeredCountOf(cap) {
5580
+ function registeredCountOf(cap, adapter) {
5083
5581
  if (!LIVE_REGISTERED) return null;
5084
5582
  var meta = TILE_CAPS[cap];
5085
5583
  if (!meta) return null;
5584
+ // **チャットの合算は「アダプタ不明」(能力単位=接続グラフ・混載の1枚)のときだけ(#153)。**
5585
+ // chat キーだけ数えると Google Chat しか使わない案件が「登録0件→未接続」に誤判定される一方、
5586
+ // アダプタが分かるタイルにまで合算を掛けると、相手の名簿の答えが自分の件数に混ざる
5587
+ //(gc の名簿が空=0 が、まだ読めていない chat の null を上書きして Slack を未接続にする)。
5588
+ // 片方しか読めなかった回は読めた方だけで数える(両方 null のときだけ null)
5589
+ if (cap === "チャット" && !adapter) {
5590
+ var a = LIVE_REGISTERED["chat"];
5591
+ var b = LIVE_REGISTERED["google-chat"];
5592
+ var an = Array.isArray(a) ? a.length : null;
5593
+ var bn = Array.isArray(b) ? b.length : null;
5594
+ // **片方でも読めなかった(null)なら合算も null(#153 実装レビュー4)。** null を 0 に
5595
+ // 畳むと、channels.json の読み取りが失敗した回に「登録0件→未接続」と断定する——
5596
+ // サーバの契約(読めなかった能力のキーは出さない=0件と混ぜない)をここで壊さない
5597
+ if (an === null || bn === null) return null;
5598
+ return an + bn;
5599
+ }
5086
5600
  var list = LIVE_REGISTERED[meta.id];
5087
5601
  return Array.isArray(list) ? list.length : null;
5088
5602
  }
@@ -5093,9 +5607,26 @@ ${deriveCapTile.toString()}
5093
5607
  * 🔴 **呼び出し側で材料を組み立て直さないこと。** 同じ判定が2箇所に増えると、
5094
5608
  * 「図は接続OK・タイルは未接続」という #231 が殺したはずの食い違いが復活する。
5095
5609
  */
5096
- function capDerivation(cap) {
5097
- return deriveCapTile(cap, {
5098
- registered: registeredCountOf(cap),
5610
+ /**
5611
+ * unset(非アクティブなアダプタの既定値)でも導出へ進んでよいか(#153・判断⑲)。
5612
+ * **3条件が揃うときだけ**: 導出の変種を持つアダプタ・derived が unset・名簿の件数が
5613
+ * 数えられている(null でない)。この判定を呼び出し側に書き写さない——写しがずれると
5614
+ * タイルと引き直しで答えが割れる
5615
+ */
5616
+ function derivableUnset(cap, adapter, derived) {
5617
+ if (derived !== "unset" || !adapter) return false;
5618
+ var derCap = cap + "-" + adapter;
5619
+ if (!TILE_CAPS[derCap]) return false;
5620
+ return registeredCountOf(derCap, adapter) !== null;
5621
+ }
5622
+
5623
+ function capDerivation(cap, adapter) {
5624
+ // **アダプタが分かるタイルは、そのアダプタの名簿・実測だけで導出する(#153・判断⑲)。**
5625
+ // 割らないと、Slack の登録件数・実測で Google Chat タイルが「接続OK」を名乗る(逆も)。
5626
+ // 「能力-アダプタ」の変種は TILE_CAPS が持つ(無いアダプタは能力単位のまま)
5627
+ var derCap = adapter && TILE_CAPS[cap + "-" + adapter] ? cap + "-" + adapter : cap;
5628
+ return deriveCapTile(derCap, {
5629
+ registered: registeredCountOf(derCap, adapter),
5099
5630
  seen: LIVE_STATE_SEEN,
5100
5631
  rows: LIVE_STATE,
5101
5632
  });
@@ -5285,11 +5816,27 @@ ${deriveCapTile.toString()}
5285
5816
  return (ADAPTER_CATALOG[cap] || []).indexOf(tool) >= 0;
5286
5817
  }
5287
5818
 
5819
+ /**
5820
+ * そのアダプタのタイルが名乗る状態(#153)。
5821
+ *
5822
+ * adapters[](前方互換キー)があればそれが正本。単数形(adapter/state)は
5823
+ * broken > on > その他 の代表値なので、2アダプタ目のタイルに使うと
5824
+ * 「Slack の on」を Google Chat が名乗る。adapters[] にエントリが無いアダプタと、
5825
+ * adapters[] を持たない旧データの非アクティブ側は unset(知らないを繋がっているに寄せない)。
5826
+ * empty を unset に畳むのは capabilityDisplayState と同じ写像。
5827
+ */
5828
+ function adapterDisplayState(cap, adapter, caps) {
5829
+ // 解決規則の正本は adapterResolved(state: null は「代表値=単数形の表示写像に従う」)
5830
+ var r = adapterResolved(cap, adapter, caps);
5831
+ var s = r.state === null ? capabilityDisplayState(cap, caps) : r.state;
5832
+ return s === "empty" ? "unset" : s;
5833
+ }
5834
+
5288
5835
  /** 組み立てだけを行う(設置は呼び出し側)。ライブ差し替えで同じ節を作り直すため分けている */
5289
5836
  /** ツールid→表示名(クライアント側。接続マップの CM_TOOL_LABEL と同じ内容) */
5290
5837
  var CM_TOOL_LABEL_C = {
5291
5838
  backlog: "Backlog", "google-meet": "Google Meet", "google-drive": "Google Drive",
5292
- figma: "Figma", slack: "Slack", github: "GitHub",
5839
+ figma: "Figma", slack: "Slack", github: "GitHub", "google-chat": "Google Chat",
5293
5840
  };
5294
5841
 
5295
5842
  /**
@@ -5301,17 +5848,22 @@ ${deriveCapTile.toString()}
5301
5848
  *
5302
5849
  * 2026-08-21 のオーナー実機指摘「押してるのはそのツールなんだから、Backlog とか
5303
5850
  * Google ドライブとして出る必要がある」。出どころは ADAPTER_CATALOG(能力→アダプタ)で、
5304
- * **今日は能力あたり1アダプタなので一意に決まる**(#107 / #127 で複数になったら、
5305
- * ここが「どれを見出しにするか」を決める場所になる)。
5851
+ * チャットは2アダプタ(#153)——**開いたタイルのアダプタが分かるならそれで引く**。
5852
+ * 分からない呼び出し(能力ではない見出し・旧経路)は先頭アダプタへ倒す。
5306
5853
  *
5307
5854
  * ⚠ **カタログに無いものは、そのまま返す。** ここには能力名だけでなく
5308
5855
  * 「文字起こしを手動で追加」のような**能力ではない見出し**も渡ってくるし、
5309
5856
  * 未知の能力(engine が先に増やした場合)も来る。空にすると
5310
5857
  * **名前の無いダイアログ**になり、何を開いたのか分からなくなる——落とすなら能力名まで。
5311
5858
  */
5312
- function capToolLabel(cap) {
5859
+ function capToolLabel(cap, adapter) {
5313
5860
  var list = ADAPTER_CATALOG[cap] || [];
5314
- var tool = list.length ? list[0] : "";
5861
+ // **複数アダプタの能力で、どのタイルから開いたか分からないときは能力名。**
5862
+ // capabilities を持たないデータの1枚タイル(混載)がこれ——先頭アダプタの名を
5863
+ // 名乗ると、スペースの行が並ぶモーダルが「Slack」を名乗る(#153)
5864
+ if (!adapter && list.length > 1) return cap;
5865
+ // カタログ外のアダプタ名は使わない(顧客画面に未知の語を出さない・#124 と同じ原則)
5866
+ var tool = adapter && list.indexOf(adapter) >= 0 ? adapter : (list.length ? list[0] : "");
5315
5867
  return (tool && CM_TOOL_LABEL_C[tool]) || cap;
5316
5868
  }
5317
5869
 
@@ -5320,17 +5872,32 @@ ${deriveCapTile.toString()}
5320
5872
  * 🔴 **反映待ちで抑制してよいのはこれだけ。** この経路は「未接続」「OFFに設定されて
5321
5873
  * います」「**設定に異常があります**」を同じ枠で運んでいる。無条件に抑制すると、
5322
5874
  * 保存直後の15分間、異常が画面から消える——直したい誤読より悪い
5875
+ * @param adapter 開いたタイルのアダプタ(#153・チャットは2枚)。見出し・開示文の主語・
5876
+ * 説明の引き直しをアダプタで割るために使う。**第5引数**——第4は noteIsUnset が
5877
+ * 先に使っており、どちらも truthy 判定なので位置を取り違えると静かに壊れる
5323
5878
  */
5324
- function openCapabilityModal(cap, items, note, noteIsUnset) {
5879
+ function openCapabilityModal(cap, items, note, noteIsUnset, adapter) {
5325
5880
  // **開いた能力を覚えて、その登録ファイルを読みに行く(段階1)。** 全能力を毎回
5326
5881
  // 読ませない代わりに、見ている能力だけを取りに行く
5327
5882
  noteOpenCap(cap);
5328
5883
  // 見出しは押したタイルのツール名(Backlog・Google ドライブ…)。capToolLabel の説明を参照
5329
- var m = makeModal(capToolLabel(cap), true);
5884
+ var m = makeModal(capToolLabel(cap, adapter), true);
5330
5885
  // **何が読み取られるのかを必ず書く。** 一覧に並ぶものと読み取られるものの関係は、
5331
5886
  // 書いていないと分からない(チャットは「載っていれば全部」、開発はリポジトリ単位でOFFできる)
5332
5887
  if (cap === "チャット") {
5333
- m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいるチャンネルは、例外なく全て毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。読み取りを止めるには「登録を解除」してください。" }));
5888
+ // 開示文の主語もアダプタで割る(#153)。単位(チャンネル/スペース)を割った理由と同じで、
5889
+ // スペースの一覧の上に「チャンネルは例外なく…」と出すと、最も重要な開示文だけが混線する
5890
+ // 混載(capabilities 無しの1枚タイル・adapter 不明)は両方の名詞で言う——
5891
+ // スペースの行の上に「チャンネルは例外なく…」と出すと開示が嘘になる(#153)
5892
+ var chatNoun = adapter === "google-chat" ? "スペース"
5893
+ : adapter === "slack" ? "チャンネル" : "チャンネル・スペース";
5894
+ // Google Chat には利用範囲の開示を添える(#153)。アプリのスコープは送信能力を含むので
5895
+ // 「投稿しません」とは**約束しない**——通知送信の仕組み(engine 側)が既にあるため、
5896
+ // 読み取りと通知先への投稿だけ、という正直な形で書く。混載でも出す(スペースが並びうる)
5897
+ var chatScope = adapter === "slack"
5898
+ ? ""
5899
+ : " Cortex のアプリは、会話の読み取りと、通知先に設定したスペースへの確認・通知の投稿だけに使われます。";
5900
+ m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいる" + chatNoun + "は、例外なく全て毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。読み取りを止めるには「登録を解除」してください。" + chatScope }));
5334
5901
  } else if (cap === "開発") {
5335
5902
  m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並んでいるリポジトリの Issues を毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。読み取りをやめるには「登録から削除」してください。" }));
5336
5903
  }
@@ -5348,17 +5915,23 @@ ${deriveCapTile.toString()}
5348
5915
  // 「**設定に異常があります**」も同じ枠で運んでいるので、無条件に引き直すと
5349
5916
  // 異常が画面から消える——直したい誤読より悪い。
5350
5917
  //
5351
- // ⚠ 引き直しでは能力の代表アダプタの状態を使う(openCapabilityModal は
5352
- // どのアダプタのタイルから開かれたかを知らない。カタログは今日どの能力も1個)
5918
+ // ⚠ **引き直しは開いたタイルのアダプタで割る(#153)。** ここを代表アダプタ固定に
5919
+ // 戻すと、Google Chat のモーダルの説明が Slack の状態・detail から引き直される。
5920
+ // アダプタの分からない呼び出し(能力ではない見出し・混載の1枚タイル)だけ代表値。
5353
5921
  var noteText = note;
5354
5922
  if (noteIsUnset) {
5355
5923
  // 足した行があるなら、この一文(接続先が設定されていません)はもう事実ではない
5356
5924
  var capsNow = capsView();
5357
- var dNow = capabilityDisplayState(cap, capsNow);
5925
+ var dNow = adapter ? adapterDisplayState(cap, adapter, capsNow) : capabilityDisplayState(cap, capsNow);
5358
5926
  var infoNow = capsNow[cap];
5359
- noteText = capPending(cap) || !(dNow && dNow !== "on" && infoNow && infoNow.detail)
5927
+ // detail の解決はタイルと同じ正本(adapterResolved)。adapter 不明・null は代表値
5928
+ var rNow = adapter ? adapterResolved(cap, adapter, capsNow) : { state: null, detail: null };
5929
+ var detailNow = rNow.detail === null ? ((infoNow && infoNow.detail) || "") : rNow.detail;
5930
+ // 未知の state 語のフォールバックは**タイル側の modalNote と同じ式**(写しがずれると
5931
+ // 状態ラベル無しの「(説明文)」が出る)
5932
+ noteText = capPending(cap, adapter) || !(dNow && dNow !== "on" && detailNow)
5360
5933
  ? ""
5361
- : (CAP_STATE_TEXT[dNow] || "") + "(" + infoNow.detail + ")";
5934
+ : (CAP_STATE_TEXT[dNow] || "状態が判定できません") + "(" + detailNow + ")";
5362
5935
  }
5363
5936
  var noteEl = el("p", { class: "modal-desc", text: noteText });
5364
5937
  // **消さずに hidden で伏せる。** 節点を残しておかないと、この一文を探している
@@ -5381,6 +5954,440 @@ ${deriveCapTile.toString()}
5381
5954
  showModal(m.overlay, m.modal);
5382
5955
  }
5383
5956
 
5957
+ /* ---------------------------------------------------------------------------
5958
+ * ハーネス連携(7枚目の能力タイル)
5959
+ * ---------------------------------------------------------------------------
5960
+ * **既存のタイル(renderAdapterTile)には乗せない。** あちらは capabilityState を
5961
+ * 引くが、この能力は fleet-status.json にも capabilities にも無いので未知の能力の
5962
+ * 既定(off)へ落ち、2行目が「未設定」になる——タイルの状態表示が嘘になる。
5963
+ * 意味論も違う: ここで語るのは接続状態ではなく「何件オンか」と「配信先が無いか」。
5964
+ *
5965
+ * 一覧(何を選べるか)は提供側のリポジトリが持つので**ビルド時には焼けない**。
5966
+ * モーダルを開いたときに op=harness-catalog で取りに行く。
5967
+ * ------------------------------------------------------------------------- */
5968
+
5969
+ /**
5970
+ * 通知先になっているチャット行。**0件のままオンにすると、動いても誰にも届かない。**
5971
+ *
5972
+ * 読むのは fleetVal(登録の即時値がマージ済みの見え方)。写経すると
5973
+ * 「一覧には通知先の札が出ているのに、確認文は配信先なしと言う」が起きる。
5974
+ * **workspace を持つ行は除く**(読み取り専用の接続には通知が届かない・notifyToggle と同じ判定)。
5975
+ */
5976
+ function harnessNotifyTargets() {
5977
+ var srcs = fleetVal("externalSources") || [];
5978
+ return srcs.filter(function (s) { return s && s.notify === true && !s.workspace; });
5979
+ }
5980
+
5981
+ /** 配信先の呼び名。**# を付けるのは Slack だけ**(付け方の正本は slackDisplayName) */
5982
+ function harnessTargetLabel(s) {
5983
+ var name = s.name || s.ref || "";
5984
+ return s.type === "slack" ? slackDisplayName(name) : name;
5985
+ }
5986
+
5987
+ /**
5988
+ * 周期の説明(「毎日・朝9時ごろ」「毎週金曜・夜9時ごろ」)。
5989
+ *
5990
+ * **「ごろ」はラベル側が持つ**(ここで足すと二重になる)。時間帯が分からない行は
5991
+ * 既定へ倒さず「時間帯未設定」と言う——語彙外の値は落ちており、その行は実行されない
5992
+ * ので、「朝9時ごろに動きます」と書くと画面が嘘をつく。
5993
+ */
5994
+ function harnessPeriodText(slot, weekday) {
5995
+ var sl = HARNESS_SLOT_LABEL[slot];
5996
+ if (!sl) return "時間帯未設定";
5997
+ if (weekday && HARNESS_WEEKDAY_LABEL[weekday]) {
5998
+ return "毎週" + HARNESS_WEEKDAY_LABEL[weekday] + "・" + sl;
5999
+ }
6000
+ return "毎日・" + sl;
6001
+ }
6002
+
6003
+ /**
6004
+ * カタログの既定から言える周期(オフの行の表示と、オンにする確認文に使う)。
6005
+ *
6006
+ * 🔴 **既定が欠けていたら空文字=周期を言わない。** 既定の解決は intake が正本で、
6007
+ * ここで morning / fri を発明して「毎日・朝9時ごろ届き始めます」と言い切ると、
6008
+ * intake が別の値に解決したとき**確認文が嘘だったことになる**(契約裁定・レビュー Minor 5)。
6009
+ * H1 のカタログ CI は必須フィールドとして検証しているので、通常ここは空にならない。
6010
+ */
6011
+ function harnessDefaultPeriod(meta) {
6012
+ if (!meta || !meta.default_slot) return "";
6013
+ if (meta.cadence === "weekly" && !meta.default_weekday) return "";
6014
+ return harnessPeriodText(meta.default_slot, meta.cadence === "weekly" ? meta.default_weekday : null);
6015
+ }
6016
+
6017
+ /** いまオンになっているエントリ(無ければ null)。**一致鍵は harness + skill の2つだけ** */
6018
+ function harnessEntryOf(harness, skill) {
6019
+ for (var i = 0; i < HARNESS_SCHEDULES.length; i++) {
6020
+ var e = HARNESS_SCHEDULES[i];
6021
+ if (e.harness === harness && e.skill === skill) return e;
6022
+ }
6023
+ return null;
6024
+ }
6025
+
6026
+ function harnessMirrorRemove(harness, skill) {
6027
+ for (var i = HARNESS_SCHEDULES.length - 1; i >= 0; i--) {
6028
+ var e = HARNESS_SCHEDULES[i];
6029
+ if (e.harness === harness && e.skill === skill) HARNESS_SCHEDULES.splice(i, 1);
6030
+ }
6031
+ }
6032
+
6033
+ /**
6034
+ * 保存できたことをミラーへ反映する。
6035
+ *
6036
+ * 🔴 **正は応答の entry。** 既定(時間帯・曜日)を解決するのは intake なので、
6037
+ * UI が既定を写経して覚えると、既定を変えたときに二重帳簿になる。
6038
+ * entry を持たない応答(古い Lambda)のときだけ fallback を使う。
6039
+ *
6040
+ * **どちらも sanitizeHarnessSchedules を通す。** 壊れた entry(slot が配列など)を
6041
+ * そのまま持つと、周期の文に [object Object] が出る。
6042
+ */
6043
+ function harnessMirrorSet(harness, skill, entry, fallback) {
6044
+ var got = sanitizeHarnessSchedules([entry]);
6045
+ var row = got.length && got[0].harness === harness && got[0].skill === skill
6046
+ ? got[0]
6047
+ : sanitizeHarnessSchedules([fallback])[0] || { harness: harness, skill: skill };
6048
+ harnessMirrorRemove(harness, skill);
6049
+ HARNESS_SCHEDULES.push(row);
6050
+ }
6051
+
6052
+ /**
6053
+ * カタログの応答を、画面が扱ってよい形へ畳む。**読めなければ null(=異常表示)。**
6054
+ *
6055
+ * 🔴 **入口の判定は Array.isArray(j.harnesses)。** j.harnesses || [] と書くと、
6056
+ * harnesses の欄を持たない応答(古い Lambda・別 op のスタブ)が「正常の空」に化け、
6057
+ * 画面が「いま利用できる機能はありません」と、無いことを断言してしまう。
6058
+ *
6059
+ * **提供元の名前(owner/repo)も理由(HTTP コード等)も画面には出さない**(#124 の原則)。
6060
+ * errors はここでは件数としてしか使わない。
6061
+ */
6062
+ function harnessCatalogOf(j) {
6063
+ if (!j || typeof j !== "object" || Array.isArray(j)) return null;
6064
+ if (!Array.isArray(j.harnesses)) return null;
6065
+ var errors = Array.isArray(j.errors) ? j.errors : [];
6066
+ var providers = [];
6067
+ for (var i = 0; i < j.harnesses.length; i++) {
6068
+ var h = j.harnesses[i];
6069
+ if (h == null || typeof h !== "object" || Array.isArray(h)) continue;
6070
+ if (typeof h.harness !== "string" || !h.harness) continue;
6071
+ if (!Array.isArray(h.skills)) continue; // 形が違う提供元は行を出さない(0件と混ぜない)
6072
+ var skills = [];
6073
+ // 変数名を s にしない(fleet-passthrough.test.mjs の走査対象。ここはカタログ応答で
6074
+ // fleet-status 由来ではないため、番人に fleet の欄として数えさせない)
6075
+ for (var k = 0; k < h.skills.length; k++) {
6076
+ var sk = h.skills[k];
6077
+ if (sk == null || typeof sk !== "object" || Array.isArray(sk)) continue;
6078
+ // **表示名が無い行は落とす。** 代わりに識別子(内部語)を出すと、#124 と同じ
6079
+ // 「読み手に意味の分からない名前が並ぶ」画面になる
6080
+ if (typeof sk.display_name !== "string" || !sk.display_name) continue;
6081
+ // 🔴 **描ける行は必ずミラーにも通ることを、ここで保証する。** 名前の篩を写経せず
6082
+ // sanitizeHarnessSchedules へ通す——通らない名前の行を描くと、オンにした直後に
6083
+ // ミラーが落として行が「オフ」へ戻る(押しても何も起きないように見える)
6084
+ if (sanitizeHarnessSchedules([{ harness: h.harness, skill: sk.skill }]).length !== 1) continue;
6085
+ var weekly = sk.cadence === "weekly";
6086
+ skills.push({
6087
+ harness: h.harness,
6088
+ skill: sk.skill,
6089
+ display_name: sk.display_name,
6090
+ summary: typeof sk.summary === "string" ? sk.summary : "",
6091
+ cadence: weekly ? "weekly" : "daily",
6092
+ // 🔴 **既定が欠けていても発明しない(空文字=不明)。** 既定の解決は intake が
6093
+ // 正本で、ここで morning / fri を作ると確認文が「毎日・朝9時ごろ」と言い切り、
6094
+ // intake が別に解決したとき嘘になる。不明の扱いは harnessDefaultPeriod が持つ
6095
+ default_slot: HARNESS_SLOTS.indexOf(sk.default_slot) >= 0 ? sk.default_slot : "",
6096
+ default_weekday: weekly
6097
+ ? (HARNESS_WEEKDAYS.indexOf(sk.default_weekday) >= 0 ? sk.default_weekday : "")
6098
+ : null,
6099
+ });
6100
+ }
6101
+ providers.push({ harness: h.harness, skills: skills });
6102
+ }
6103
+ // **読めた提供元が1つも無く、読めなかった提供元がある → 異常。** ここで「0件」と
6104
+ // 言うと、実際には在るかもしれないものを「無い」と断言することになる
6105
+ if (!providers.length && errors.length) return null;
6106
+ return {
6107
+ providers: providers,
6108
+ errors: errors,
6109
+ partial: providers.length > 0 && errors.length > 0,
6110
+ };
6111
+ }
6112
+
6113
+ /**
6114
+ * 語彙から select を作る。**現在値が語彙外のときだけ**先頭に空の選択肢を足す
6115
+ * ——既定へ勝手に倒すと、実際には実行されない行を「朝9時ごろ」と説明することになる。
6116
+ */
6117
+ function harnessSelect(vocab, labels, current, blankLabel, ariaLabel) {
6118
+ var known = vocab.indexOf(current) >= 0;
6119
+ var sel = el("select", { "aria-label": ariaLabel });
6120
+ if (!known) sel.appendChild(el("option", { value: "", text: blankLabel }));
6121
+ for (var i = 0; i < vocab.length; i++) {
6122
+ sel.appendChild(el("option", { value: vocab[i], text: labels[vocab[i]] }));
6123
+ }
6124
+ sel.value = known ? current : "";
6125
+ return sel;
6126
+ }
6127
+
6128
+ /** オンにする前の確認。**何が・どこに・いつ届き始めるのかを書く** */
6129
+ function harnessOnConfirmLines(meta) {
6130
+ // **既定の周期が言えないときは、言い切らない**(harnessDefaultPeriod の項)。
6131
+ // 実際の時間帯は保存時に intake が決めるので、決まったあとに調整できることだけ伝える
6132
+ var dp = harnessDefaultPeriod(meta);
6133
+ var lines = [
6134
+ dp
6135
+ ? "オンにすると、この案件の通知先チャンネルに「" + meta.display_name + "」が届き始めます(" + dp + ")。"
6136
+ : "オンにすると、この案件の通知先チャンネルに「" + meta.display_name + "」が届き始めます。時間帯は、オンにしたあとこの画面で確認・調整できます。",
6137
+ ];
6138
+ var targets = harnessNotifyTargets();
6139
+ if (targets.length) {
6140
+ lines.push("配信先: " + harnessTargetLabel(targets[0])
6141
+ + (targets.length > 1 ? " ほか" + (targets.length - 1) + "件" : "")
6142
+ + "(「チャット」の通知先設定に従います)。");
6143
+ } else {
6144
+ // **ここで止めない。** 先にオンにして、あとで通知先を決める順番もありうる。
6145
+ // 届かないことだけを、はっきり伝える
6146
+ lines.push("⚠ この案件にはまだ配信先(通知先チャンネル)が設定されていません。オンにしても、「チャット」の通知先を設定するまで届きません。");
6147
+ }
6148
+ lines.push("よろしいですか?");
6149
+ return lines;
6150
+ }
6151
+
6152
+ /**
6153
+ * 機能1件の行。
6154
+ *
6155
+ * @param meta カタログの中身。**null はゴースト行**(提供元から消えた機能。出せる
6156
+ * 表示名が無いので、オフにする手段だけを残す——出さないと画面から止められなくなる)
6157
+ */
6158
+ function harnessRow(harness, skill, meta) {
6159
+ var row = el("div", { class: "ds-harness-row" });
6160
+ var name = el("div", {
6161
+ class: "ds-harness-name",
6162
+ text: meta ? meta.display_name : "(提供元にもう無い機能)",
6163
+ });
6164
+ var period = el("div", { class: "ds-harness-period" });
6165
+ row.appendChild(el("div", { class: "ds-harness-head" }, [name, period]));
6166
+ if (meta && meta.summary) row.appendChild(el("div", { class: "ds-harness-summary", text: meta.summary }));
6167
+ var btn = el("button", { class: "ds-toggle-btn", type: "button" });
6168
+ var selects = el("span", { class: "ds-harness-selects" });
6169
+ var msg = el("div", { class: "ds-toggle-msg" });
6170
+ row.appendChild(el("div", { class: "ds-toggle" }, [btn, selects, msg]));
6171
+
6172
+ /**
6173
+ * 時間帯(毎週なら曜日も)の調整。**確認モーダルは出さない**——同じ画面から
6174
+ * 即時に戻せる操作で、notifyToggle と同じ判断。
6175
+ */
6176
+ function buildSelects(e) {
6177
+ var weekly = meta.cadence === "weekly";
6178
+ var slotSel = harnessSelect(HARNESS_SLOTS, HARNESS_SLOT_LABEL, e.slot, "時間帯未設定", "時間帯");
6179
+ var daySel = weekly
6180
+ ? harnessSelect(HARNESS_WEEKDAYS, HARNESS_WEEKDAY_LABEL, e.weekday, "曜日未設定", "曜日")
6181
+ : null;
6182
+ var prevSlot = slotSel.value;
6183
+ var prevDay = daySel ? daySel.value : "";
6184
+ var onChange = async function () {
6185
+ var payload = { op: "harness-schedule-set", harness: harness, skill: skill };
6186
+ // 🔴 **時間帯と曜日は両方送る。** set は同じ harness+skill の置き換えで、
6187
+ // 省略したキーは既定へ解決されるので、片方だけ送るともう片方が既定へ巻き戻る。
6188
+ // 値が分からない(語彙外で落ちていた)ぶんだけは送らず、既定の解決に委ねる
6189
+ if (slotSel.value) payload.slot = slotSel.value;
6190
+ if (daySel && daySel.value) payload.weekday = daySel.value;
6191
+ slotSel.disabled = true;
6192
+ if (daySel) daySel.disabled = true;
6193
+ // sendConfig は例外を投げず戻り値も持たないので、成否は onDone で受ける
6194
+ var applied = false;
6195
+ await sendConfig(payload, msg, null, function (j) {
6196
+ applied = true;
6197
+ harnessMirrorSet(harness, skill, j && j.entry, {
6198
+ harness: harness, skill: skill, slot: payload.slot, weekday: payload.weekday,
6199
+ });
6200
+ var now = harnessEntryOf(harness, skill);
6201
+ if (now) {
6202
+ period.textContent = harnessPeriodText(now.slot, now.weekday);
6203
+ if (now.slot) slotSel.value = now.slot;
6204
+ if (daySel && now.weekday) daySel.value = now.weekday;
6205
+ }
6206
+ prevSlot = slotSel.value;
6207
+ prevDay = daySel ? daySel.value : "";
6208
+ });
6209
+ slotSel.disabled = false;
6210
+ if (daySel) daySel.disabled = false;
6211
+ // **失敗したら元の値へ戻す。** 画面だけが変わった状態を残すと、次に開いたとき
6212
+ //(焼き直しの後)に理由もなく値が戻る
6213
+ if (!applied) {
6214
+ slotSel.value = prevSlot;
6215
+ if (daySel) daySel.value = prevDay;
6216
+ }
6217
+ };
6218
+ slotSel.addEventListener("change", onChange);
6219
+ if (daySel) daySel.addEventListener("change", onChange);
6220
+ selects.appendChild(slotSel);
6221
+ if (daySel) selects.appendChild(daySel);
6222
+ }
6223
+
6224
+ /**
6225
+ * いまの向きに合わせて当て直す。**行ごと作り直さない**——作り直すと
6226
+ * sendConfig が書いた「✓ 保存しました。」ごと捨てることになる。
6227
+ */
6228
+ function paint() {
6229
+ var e = harnessEntryOf(harness, skill);
6230
+ btn.textContent = e ? "オフにする" : "オンにする";
6231
+ // **オフにしたゴースト行はボタンごと止める。** カタログに中身が無いのでオンには
6232
+ // 戻せない——押せる見た目のまま黙って何もしないボタンを残さない
6233
+ btn.disabled = !e && !meta;
6234
+ // **オフの行はカタログの既定で周期を出す**(オンの行は定義ファイルの具体値)。
6235
+ // 既定が欠けている行では空=周期を言わない(harnessDefaultPeriod の項。
6236
+ // 「時間帯未設定」はオンの行の語——オフの行に出すと、設定の抜けがあるように読める)
6237
+ period.textContent = e
6238
+ ? harnessPeriodText(e.slot, e.weekday)
6239
+ : harnessDefaultPeriod(meta);
6240
+ while (selects.firstChild) selects.removeChild(selects.firstChild);
6241
+ // ゴースト行には select を出さない(カタログが無いので周期の語彙が決まらない)
6242
+ if (e && meta) buildSelects(e);
6243
+ }
6244
+
6245
+ btn.addEventListener("click", async function () {
6246
+ if (harnessEntryOf(harness, skill)) {
6247
+ // **オフは確認を出さない。** 配信が止まる向きだが、同じ画面から即時に戻せる
6248
+ //(取り消せない「登録を解除」とは扱いを分ける・notifyToggle と同じ判断)
6249
+ await sendConfig({ op: "harness-schedule-remove", harness: harness, skill: skill }, msg, btn, function () {
6250
+ harnessMirrorRemove(harness, skill);
6251
+ paint();
6252
+ });
6253
+ return;
6254
+ }
6255
+ if (!meta) return; // ゴースト行はオンにできない(カタログに中身が無い)
6256
+ // **OK は赤くしない。** 危険な向きではなく「届き始める」向きの操作なので、
6257
+ // 削除と同じ赤で出すと、止めるべき操作に見える
6258
+ if (!(await openConfirm(harnessOnConfirmLines(meta), { okLabel: "オンにする", returnTo: btn, okDanger: false }))) {
6259
+ msg.textContent = "";
6260
+ return;
6261
+ }
6262
+ // 🔴 **初回のオンでは時間帯・曜日を送らない。** 既定の解決は intake が正本で、
6263
+ // UI が既定を写経して送ると、既定を変えたときに二重帳簿になる
6264
+ await sendConfig({ op: "harness-schedule-set", harness: harness, skill: skill }, msg, btn, function (j) {
6265
+ harnessMirrorSet(harness, skill, j && j.entry, {
6266
+ harness: harness,
6267
+ skill: skill,
6268
+ slot: meta.default_slot,
6269
+ weekday: meta.cadence === "weekly" ? meta.default_weekday : undefined,
6270
+ });
6271
+ paint();
6272
+ });
6273
+ });
6274
+
6275
+ paint();
6276
+ return row;
6277
+ }
6278
+
6279
+ /** 応答が読めたときの中身。行が1つも無ければ「0件」の文言に倒す */
6280
+ function harnessFillList(list, setState, warn, cat) {
6281
+ var errored = {};
6282
+ for (var i = 0; i < cat.errors.length; i++) {
6283
+ var er = cat.errors[i];
6284
+ if (er && typeof er === "object" && typeof er.harness === "string") errored[er.harness] = true;
6285
+ }
6286
+ var seen = {};
6287
+ var rows = 0;
6288
+ for (var p = 0; p < cat.providers.length; p++) {
6289
+ var prov = cat.providers[p];
6290
+ for (var k = 0; k < prov.skills.length; k++) {
6291
+ var meta = prov.skills[k];
6292
+ seen[meta.harness + "\\t" + meta.skill] = true;
6293
+ list.appendChild(harnessRow(meta.harness, meta.skill, meta));
6294
+ rows += 1;
6295
+ }
6296
+ }
6297
+ // **カタログに無いのにオンになっている行も出す(ゴースト行)。** 出さないと、
6298
+ // 提供側が機能を外したあとに画面からオフにする手段が消える(remove はカタログ検証不要)
6299
+ for (var s = 0; s < HARNESS_SCHEDULES.length; s++) {
6300
+ var ent = HARNESS_SCHEDULES[s];
6301
+ if (seen[ent.harness + "\\t" + ent.skill]) continue;
6302
+ // **一覧が読めなかっただけの提供元の行に「もう無い」と言わない。** 次に開けば
6303
+ // 普通の行として並ぶので、消えたと伝えるのは嘘になる(タイルの件数には残す)
6304
+ if (errored[ent.harness]) continue;
6305
+ list.appendChild(harnessRow(ent.harness, ent.skill, null));
6306
+ rows += 1;
6307
+ }
6308
+ // 行が並んだ/一部だけ読めた(警告が事情を説明する)ときは、状態の1行を伏せる
6309
+ if (rows || cat.partial) setState("");
6310
+ else setState("いま利用できる機能はありません。機能が追加されると、ここに並びます。");
6311
+ warn.hidden = !cat.partial;
6312
+ }
6313
+
6314
+ /**
6315
+ * ハーネス連携のモーダル。**開いたときに一覧を取りに行く**(カタログは提供側の
6316
+ * リポジトリが持つので、ビルド時には焼けない)。
6317
+ *
6318
+ * openCapabilityModal は使わない——items 機構にも noteOpenCap(能力ごとの登録の
6319
+ * 読み直し)にも乗らないため。閉じるボタンと showModal の作法だけ同じ形にしてある。
6320
+ */
6321
+ var harnessGen = 0;
6322
+ function openHarnessModal() {
6323
+ var intake = SITE.intake;
6324
+ if (!intake) return;
6325
+ // 世代トークン。**応答が届いたときに、まだこの一覧が生きているかを判定する**
6326
+ //(押し替え・閉じたあとに親前提の DOM 操作をしない)
6327
+ harnessGen += 1;
6328
+ var gen = harnessGen;
6329
+ var m = makeModal("ハーネス連携", true);
6330
+ m.modal.appendChild(el("p", { class: "modal-desc", text: "ここに並ぶ機能をオンにすると、決まった時間に自動で動き、結果がこの案件の通知先チャンネル(「チャット」の通知先設定)に届きます。オンにするまで、何も動きません。" }));
6331
+ // 一覧の代わりに出す1行(読み込み中 → 0件/読み込めなかった)。**3状態を必ず区別する**
6332
+ var stateEl = el("p", { class: "ds-harness-state", text: "読み込み中…" });
6333
+ var warn = el("p", { class: "ds-harness-warn", text: "一部の提供元から一覧を読み込めませんでした。ここに並んでいない機能があるかもしれません。時間をおいて開き直してください。" });
6334
+ warn.hidden = true;
6335
+ var list = el("div", { class: "ds-harness-list" });
6336
+ m.modal.appendChild(stateEl);
6337
+ m.modal.appendChild(warn);
6338
+ m.modal.appendChild(list);
6339
+ m.modal.appendChild(el("div", { class: "modal-actions" }, [
6340
+ el("button", { class: "ds-toggle-btn", type: "button", text: "閉じる" }),
6341
+ ]));
6342
+ m.modal.querySelector(".modal-actions button").addEventListener("click", function () { m.close(); });
6343
+ m.overlay.appendChild(m.modal);
6344
+ showModal(m.overlay, m.modal);
6345
+
6346
+ /** 生きている一覧か(閉じた・押し替えた後は何もしない) */
6347
+ var alive = function () { return gen === harnessGen && app.contains(list); };
6348
+ /**
6349
+ * 状態の1行を差し替える。**節点は残して文言を空にする**(消すと、この一文を
6350
+ * 探している検査が「文言が変わった」と「箱ごと無くなった」を区別できない)
6351
+ */
6352
+ var setState = function (text, isError) {
6353
+ stateEl.className = "ds-harness-state" + (isError ? " is-error" : "");
6354
+ stateEl.textContent = text;
6355
+ stateEl.hidden = text === "";
6356
+ };
6357
+ var fail = function () {
6358
+ if (!alive()) return;
6359
+ setState("利用できる機能の一覧を読み込めませんでした。時間をおいて開き直してください。", true);
6360
+ };
6361
+ // **fetch を持たない環境(JSDOM 素)では取りに行かない。** 素で呼ぶと
6362
+ // ReferenceError になり、タイルを押しただけで画面が落ちる(liveConfirmPossible と同形)
6363
+ if (typeof fetch !== "function") { fail(); return; }
6364
+ var ctrl = typeof AbortController === "function" ? new AbortController() : null;
6365
+ // 見切りは op=state と同じ10秒。**新しいリテラルを作らない**——値が割れると
6366
+ // 「10秒で戻る」検査が通ったまま実機では戻らなくなる
6367
+ var giveUp = ctrl ? setTimeout(function () { ctrl.abort(); }, LIVE_CONFIRM_TIMEOUT_MS) : null;
6368
+ // **成功・失敗の両方で外す。** 残すと、window を閉じないテストで node --test が終わらない
6369
+ var clearGiveUp = function () { if (giveUp != null) { clearTimeout(giveUp); giveUp = null; } };
6370
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
6371
+ // GET で取る。**カスタムヘッダは付けない**(preflight を1往復増やさないため)
6372
+ fetch(pfx + "op=harness-catalog&t=" + encodeURIComponent(intake.token), {
6373
+ signal: ctrl ? ctrl.signal : undefined,
6374
+ })
6375
+ .then(function (res) {
6376
+ clearGiveUp();
6377
+ if (!res.ok) throw new Error("http " + res.status);
6378
+ return res.json();
6379
+ })
6380
+ .then(function (j) {
6381
+ if (!alive()) return;
6382
+ var cat = harnessCatalogOf(j);
6383
+ if (!cat) { fail(); return; }
6384
+ harnessFillList(list, setState, warn, cat);
6385
+ })
6386
+ // **必ず末尾に置く。** 上の .then の中で投げた例外まで拾う——未処理の rejection は
6387
+ // node --test をテストごと落とす
6388
+ .catch(function () { clearGiveUp(); fail(); });
6389
+ }
6390
+
5384
6391
  /**
5385
6392
  * 鍵の欄の置き場(2026-08-21・オーナー指摘「APIキーのセクションが下にあって、
5386
6393
  * 変わってないように見えます」)。
@@ -5476,7 +6483,9 @@ ${deriveCapTile.toString()}
5476
6483
  // **どちらにも行が無い**。行の有無でセクションを作ると、その案件でチャットが丸ごと消え、
5477
6484
  // 「これから繋げる入口」も接続マップのアンカーの受け皿も無くなる。
5478
6485
  // 何を繋げるかは製品が決めていて、案件データでは変わらない(#125②)。
5479
- var CAP_ORDER = ["課題管理", "会議", "共有資料", "チャット", "デザイン", "開発"];
6486
+ // **ハーネス連携は末尾。** 前6つは「どこから情報が入ってくるか」で、これだけが
6487
+ // 「Cortex が何を自動で出すか」——読む順として、入口を見た後に出口が来る
6488
+ var CAP_ORDER = ["課題管理", "会議", "共有資料", "チャット", "デザイン", "開発", "ハーネス連携"];
5480
6489
  // internalSources に行が来る能力(チャットと開発は externalSources 側にしか来ない)。
5481
6490
  // **fleet-status.json がまだ無い案件のために、行が無くても設定を出す**判定に使う(#138)
5482
6491
  var CAP_INTERNAL = ["課題管理", "会議", "共有資料", "デザイン"];
@@ -5484,10 +6493,13 @@ ${deriveCapTile.toString()}
5484
6493
  var manualBox = {};
5485
6494
  // 対応表は externalCapability が持つ(状態判定・件数の集計と同じものを使う)
5486
6495
  var capOf = externalCapability;
5487
- function pushCap(cap, node) {
6496
+ // **adapter を付けて受け皿へ入れる(#153)。** チャットが2タイルに分かれたので、
6497
+ // どのアダプタのモーダルに出すかをここで決める。空(未指定)は「どのタイルにも出す」——
6498
+ // アダプタに属さない部品(内部ソースの操作等)を黙って消さないため
6499
+ function pushCap(cap, node, adapter) {
5488
6500
  if (!cap || !node) return;
5489
6501
  if (!capBox[cap]) capBox[cap] = [];
5490
- capBox[cap].push(node);
6502
+ capBox[cap].push({ node: node, adapter: adapter || "" });
5491
6503
  }
5492
6504
 
5493
6505
  if (internals.length) {
@@ -5526,13 +6538,10 @@ ${deriveCapTile.toString()}
5526
6538
  //
5527
6539
  // **括弧を付けた形で出す。** 語だけ見せると 【KC定例】 のように混ぜて書かれ、
5528
6540
  // どこも一致せず未仕分けへ落ちる(実際に起きた)。そのままコピーできる形にする。
5529
- var signal = null;
5530
6541
  // **自動取り込みが動いているときだけ出す。** Teams等で手動運用している案件に
5531
6542
  // 会議名の条件を出すと、自動で拾われると誤解させる(実際には拾われない)。
5532
- if (s.kind === "会議" && s.ingestState === "on") {
5533
- signal = s.meetingKey || (SITE.intake && SITE.intake.projectKey) || null;
5534
- }
5535
- var aliases = (s.kind === "会議" && s.ingestState === "on" && s.meetingAliases) || [];
6543
+ var signal = meetingSignalOf(s);
6544
+ var aliases = meetingAliasesOf(s);
5536
6545
  if (signal) {
5537
6546
  // **カードで一番強い要素にする(#117)。** 実機で見たとき、目印の案内が
5538
6547
  // 別名の一覧や注記と同じ細字で並んでいて、「まず会議名にこれを入れる」が
@@ -5663,8 +6672,10 @@ ${deriveCapTile.toString()}
5663
6672
  // 追加のときにワークスペースを選ばせるのをやめたので、
5664
6673
  // 「どの組織のものか」「その組織とつながっているか」を画面が言う。
5665
6674
  // 分け方は groupSlackByOrg(純関数)が全部持っている
5666
- // **一覧は下で1つの箱にまとめて出す**(開いたままでも差し替えられるように)
5667
- if (g.type === "slack" || g.type === "__github__") return;
6675
+ // **一覧は下で1つの箱にまとめて出す**(開いたままでも差し替えられるように)。
6676
+ // google-chat も同じ(#153)——汎用経路に落とすと4件以上で details に畳まれる。
6677
+ // 「畳まない」は不作為では達成できない(行の生成は下の liveList の充填関数が持つ)
6678
+ if (g.type === "slack" || g.type === "__github__" || g.type === "google-chat") return;
5668
6679
  var cards = el("div", { class: "ds-list" });
5669
6680
  g.items.forEach(function (s) { cards.appendChild(extSourceCard(s, meta)); });
5670
6681
  if (g.items.length >= 4) {
@@ -5681,13 +6692,13 @@ ${deriveCapTile.toString()}
5681
6692
  : el("span", { class: "badge ds-sum-warn", text: warn }),
5682
6693
  ]);
5683
6694
  var det = el("details", { class: "ds-group" }, [sum, cards]);
5684
- pushCap(capOf(g.type), det);
6695
+ pushCap(capOf(g.type), det, sourceAdapter(g.type));
5685
6696
  } else {
5686
6697
  // **appendChild と違い pushCap はノードを動かさない。** firstChild を条件にすると
5687
6698
  // 永久に真のままになる(実際に無限ループを作った)。先に配列へ取り出す。
5688
6699
  var kids = [];
5689
6700
  for (var ci = 0; ci < cards.childNodes.length; ci++) kids.push(cards.childNodes[ci]);
5690
- kids.forEach(function (n) { pushCap(capOf(g.type), n); });
6701
+ kids.forEach(function (n) { pushCap(capOf(g.type), n, sourceAdapter(g.type)); });
5691
6702
  }
5692
6703
  });
5693
6704
  }
@@ -5701,9 +6712,11 @@ ${deriveCapTile.toString()}
5701
6712
  // **Slack は組織ごと(slackRows)・GitHub も組織ごと(githubRows)。**
5702
6713
  // それまでは「行の一覧」と「組織ごとの接続状況」が別々に並んでいて、
5703
6714
  // どの行がどの組織の話なのか読めなかった(2026-08-20 に揃えた)。
6715
+ // **絞りの正本は sourceAdapter(#153)。** ここに独自の if 連鎖を書くと、次のアダプタや
6716
+ // 新しい github-* 種別を正本に足した日に、この箱だけ古い答えのまま行が別の箱に落ちる
5704
6717
  function liveExternals(kind) {
5705
6718
  return (fleetVal("externalSources") || []).filter(function (s) {
5706
- return kind === "slack" ? s.type === "slack" : String(s.type || "").indexOf("github") === 0;
6719
+ return sourceAdapter(s.type) === kind;
5707
6720
  });
5708
6721
  }
5709
6722
  /** 行の見た目を決める値だけを並べる(時刻など毎回変わる値は入れない) */
@@ -5715,6 +6728,8 @@ ${deriveCapTile.toString()}
5715
6728
  * (素の Decisions ビュー)で行も無い能力に空の箱を積むと、押しても何も無いモーダルになる。
5716
6729
  */
5717
6730
  function liveListWanted(kind) { return !!SITE.intake || liveExternals(kind).length > 0; }
6731
+ // **箱にはアダプタのタグを付ける(#153)。** pushCap の無タグは「どのタイルにも出す」
6732
+ // 既定なので、タグを忘れると Slack の一覧が Google Chat モーダルに丸ごと出る
5718
6733
  if (liveListWanted("slack")) pushCap("チャット", liveList(
5719
6734
  "チャット",
5720
6735
  function () { return externalsSig("slack", slackWorkspaces()); },
@@ -5724,7 +6739,19 @@ ${deriveCapTile.toString()}
5724
6739
  var meta = DS_TYPE["slack"] || { icon: "🔗", label: "Slack" };
5725
6740
  slackRows(items, meta).forEach(function (n) { box.appendChild(n); });
5726
6741
  },
5727
- ));
6742
+ ), "slack");
6743
+ // Google Chat の箱(#153)。行は平置き(組織情報が無いので組ごとの並べ替え・囲いは
6744
+ // 作れない。判断③)。4件以上でも畳まない——畳みは汎用経路の仕様で、ここは通らない
6745
+ if (liveListWanted("google-chat")) pushCap("チャット", liveList(
6746
+ "チャット",
6747
+ function () { return externalsSig("google-chat", null); },
6748
+ function (box) {
6749
+ var items = liveExternals("google-chat");
6750
+ if (!items.length) return;
6751
+ var meta = DS_TYPE["google-chat"] || { icon: "💬", label: "Google Chat" };
6752
+ items.forEach(function (s) { box.appendChild(extSourceCard(s, meta)); });
6753
+ },
6754
+ ), "google-chat");
5728
6755
  if (liveListWanted("github")) pushCap("開発", liveList(
5729
6756
  "開発",
5730
6757
  function () { return externalsSig("github", githubOwnersView() || null); },
@@ -5751,7 +6778,7 @@ ${deriveCapTile.toString()}
5751
6778
  // **新しい形が別の解き方をしている**——チャンネルを追加すれば
5752
6779
  // 読み取れていない組として現れ、そこに接続ボタンが出る。
5753
6780
  // 0件なら繋ぐ相手もまだ無いので、入口が要る場面にならない。
5754
- pushCap("チャット", adders.querySelector('[data-adder="slack"]'));
6781
+ pushCap("チャット", adders.querySelector('[data-adder="slack"]'), "slack");
5755
6782
  /* -------------------------------------------------------------------------
5756
6783
  * 🔴 接続の入口を常設する(0.12.2・実オンボーディングで踏んだ)
5757
6784
  * -------------------------------------------------------------------------
@@ -5782,10 +6809,17 @@ ${deriveCapTile.toString()}
5782
6809
  * 無い案件では押しても始まらない(renderExternalAdders も同じ条件で空を返す)。
5783
6810
  * ----------------------------------------------------------------------- */
5784
6811
  if (SITE.intake) {
6812
+ // "slack" 札は #153 の合流で付けた——無いと Google Chat の箱にも OAuth ボタンが出る
6813
+ //(Google Chat の承認は Admin install で、押す先の画面が無い)。常設の意図は Slack の箱の中で保つ
5785
6814
  pushCap("チャット", slackConnectButton(
5786
6815
  "お客様の Slack や、Slack コネクトで共有されたチャンネルを読むには、先にワークスペースの接続が要ります。",
5787
- ));
6816
+ ), "slack");
5788
6817
  }
6818
+ // Google Chat のフォーム(#153)。接続(OAuth)のボタンは存在しない——顧客の
6819
+ // Workspace 管理者による Admin install が承認そのもので、画面に押す先が無い。
6820
+ // 案内(アプリ追加の操作列)は独立の箱ではなくフォームの手順リストが持つ(#170/#174 の
6821
+ // 圧縮方針。「貼る前に読ませる」は手順が入力欄より前にあることで担保し、テストで固定)
6822
+ pushCap("チャット", adders.querySelector('[data-adder="google-chat"]'), "google-chat");
5789
6823
  // **組織ごとの接続状況は、行と一体にした(2026-08-20・Slack からの横展開)。**
5790
6824
  //
5791
6825
  // それまでは「リポジトリの一覧」と「組織ごとの接続状況」が**別々に並んで**おり、
@@ -5930,7 +6964,11 @@ ${deriveCapTile.toString()}
5930
6964
  "チャット": { icon: "💬", desc: "チャンネルの会話をGoldの材料に" },
5931
6965
  "デザイン": { icon: "🎨", desc: "画面インベントリの同期" },
5932
6966
  "開発": { icon: "🐙", desc: "IssuesをGoldの材料に" },
6967
+ "ハーネス連携": { icon: "⏰", desc: "ハーネスの機能を決まった時間に自動で動かし、結果を Slack に届けます" },
5933
6968
  };
6969
+ // 説明文がアダプタで割れるものだけ上書きする(#153)。チャットの単位は
6970
+ // Slack=チャンネル / Google Chat=スペース で、能力共通の一文だと後者に嘘が出る
6971
+ var ADAPTER_DESC = { "google-chat": "スペースの会話をGoldの材料に" };
5934
6972
  var STATE_LABEL = { ok: "● 接続OK", idle: "⦿ 未接続", off: "未使用", warn: "⚠️" };
5935
6973
  // **「開発」はここに無い。** GitHub の読み取りトークンは廃止したので、
5936
6974
  // 鍵の未設定を語る相手がいない(読めていないときは組織ごとの案内が拾う)
@@ -6008,7 +7046,10 @@ ${deriveCapTile.toString()}
6008
7046
  */
6009
7047
  function tileSub(cap, st, derived, adapter, ins, srcs, derive, confirming) {
6010
7048
  var internal = ins.filter(function (s) { return s.kind === cap; })[0];
6011
- var live = srcs.filter(function (s) { return externalCapability(s.type) === cap; });
7049
+ // **このタイルのアダプタの行だけを数える(#153)。** 絞らないと Slack のタイルが
7050
+ // スペース込みで「3チャンネル」と数える。絞り方の正本は sourceMatchesAdapter
7051
+ //(capabilityState の warn 集計と同じ関数——写経すると warn と件数が別の行集合になる)
7052
+ var live = srcs.filter(function (s) { return sourceMatchesAdapter(s.type, cap, adapter); });
6012
7053
  var parts = [];
6013
7054
  // **導出が on 以外なら、導出の語を優先する(#125②)。** broken は状態としては
6014
7055
  // 警告(⚠️)だが、原因は「設定そのものが壊れている」であって同期の失敗ではない。
@@ -6016,14 +7057,19 @@ ${deriveCapTile.toString()}
6016
7057
  // 直す場所が違うところを指す。
6017
7058
  //
6018
7059
  // 🔴 **ここが正本からの導出(下)より先。** 意図的に OFF にした能力に
6019
- // 「登録済み(接続は未確認です)」と出さないため(deriveCapTile の項)
7060
+ // 「登録済み(接続は未確認です)」と出さないため(deriveCapTile の項)。
7061
+ // **unset なのに warn は例外**(engine がこのアダプタを未導出でも、読めていない行が
7062
+ // 実在する移行期間・#153)——下の分岐へ流して件数と原因を出す。broken / off の優先は従来どおり。
7063
+ // **unset なのに導出が答えているのも例外(判断⑲)**——非アクティブなアダプタの unset は
7064
+ // 宣言ではなく既定値なので、導出(登録の実物)に道を譲る。塞ぐと「登録済み(接続は
7065
+ // 未確認です)」の代わりに「未接続」が出る(renderAdapterTile 側の同じ例外と対)
6020
7066
  if (confirming) {
6021
7067
  // 🔴 **確認中は状態を言わない(#256)。** 下の分岐はどれも焼いた値からの断言で、
6022
7068
  // ライブが届くと訂正される=瞬きの出どころ。**語は既存の中間状態と揃える**
6023
7069
  //(DS_GATE_PENDING「登録状況を確認中です」)——新しい言い方を増やすと、
6024
7070
  // 同じ画面で「確認中」を意味する語が2つ並ぶ
6025
7071
  parts.push(DS_GATE_PENDING.text);
6026
- } else if (derived && derived !== "on") {
7072
+ } else if (derived && derived !== "on" && !(derived === "unset" && st === "warn") && !(derived === "unset" && derive)) {
6027
7073
  parts.push((CAP_STATE_MARK[st] || "") + (CAP_STATE_TEXT[derived] || "状態が判定できません"));
6028
7074
  } else if (derive) {
6029
7075
  // 🔴 **正本(登録・実測)からの導出(0.12.0・#231 / #235 / #227)。**
@@ -6075,8 +7121,15 @@ ${deriveCapTile.toString()}
6075
7121
  // 単位は分かるものだけ。知らない種別に勝手な単位を付けない。
6076
7122
  // **「N件をGoldに使用」の内訳は出さない。** チャンネルは載っていれば全部が対象で、
6077
7123
  // github の除外は行のバッジ(「読み取り停止中」)が示す
6078
- //(件数の内訳は Gold トグルがあった頃の名残)
6079
- var unit = cap === "チャット" ? "チャンネル" : cap === "開発" ? "リポジトリ" : "件";
7124
+ //(件数の内訳は Gold トグルがあった頃の名残)。
7125
+ // チャットはアダプタで単位が割れる(Slack=チャンネル / Google Chat=スペース・#153)。
7126
+ // 混載(adapter 不明)でスペースが混ざっているときは「件」——「2チャンネル」と
7127
+ // 数えたうちの1つがスペース、という嘘を数字の単位で作らない
7128
+ var chatMixed = cap === "チャット" && !adapter &&
7129
+ live.some(function (s) { return s.type === "google-chat"; });
7130
+ var unit = adapter === "google-chat" ? "スペース"
7131
+ : chatMixed ? "件"
7132
+ : cap === "チャット" ? "チャンネル" : cap === "開発" ? "リポジトリ" : "件";
6080
7133
  parts.push(live.length + unit);
6081
7134
  } else if (cap === "共有資料" && adapter === "google-drive" && internal && internal.driveFolderIds) {
6082
7135
  // **そのタイルのアダプタを見る。** 宣言(tools)を見ていた頃は、local の案件
@@ -6108,6 +7161,20 @@ ${deriveCapTile.toString()}
6108
7161
  var extraCaps = Object.keys(capBox).filter(function (c) { return CAP_ORDER.indexOf(c) === -1; });
6109
7162
  var anyCap = false;
6110
7163
  CAP_ORDER.concat(extraCaps).forEach(function (cap) {
7164
+ // **ハーネス連携だけは専用の描画。** 接続ではなく Cortex 自身の設定なので、
7165
+ // 状態(capabilityState)・アダプタ・fleet の行のどれも持たない
7166
+ if (cap === "ハーネス連携") {
7167
+ // **設定も一覧の取得も intake Lambda が要る。** 無い構成では押しても始まらない
7168
+ // タイルになるので出さない(renderExternalAdders と同じゲート)
7169
+ if (!SITE.intake) return;
7170
+ sec.appendChild(el("div", { class: "ds-cap", text: cap }));
7171
+ // ここで anyCap を立てても「ハーネス連携だけで節が立つ」ことは無い——
7172
+ // 上の門(節を出すかの判定)が hasIntake を数えており、intake があれば
7173
+ // 手動投入のタイルも必ず並ぶ。立てるのは節末の注記を出すため
7174
+ anyCap = true;
7175
+ renderHarnessTile();
7176
+ return;
7177
+ }
6111
7178
  var items = capBox[cap] || [];
6112
7179
  // **カタログにある能力のタイルは常時出す(#125②)。** 使っているかどうかで出し分けない
6113
7180
  // ——未接続のタイルは「これから繋げる入口」で、消すと繋ぎ方を知る場所が無くなる。
@@ -6122,9 +7189,8 @@ ${deriveCapTile.toString()}
6122
7189
  // **カタログの全アダプタを並べる(今日は各1個)。** 能力=1アダプタを
6123
7190
  // 焼き込まないための形(#107 / #127)。導出が答えているアダプタにだけ
6124
7191
  // その状態を割り当て、残りは未接続——「知らない」を「繋がっている」に寄せない
6125
- var active = capabilityAdapter(cap, caps);
6126
7192
  adapters.forEach(function (a, ai) {
6127
- renderAdapterTile(cap, items, a, a === active ? capabilityDisplayState(cap, caps) : "unset", ai);
7193
+ renderAdapterTile(cap, items, a, adapterDisplayState(cap, a, caps), ai);
6128
7194
  });
6129
7195
  } else if (items.length) {
6130
7196
  // capabilities を持たないデータ: アダプタのタイルは行があるときだけ。手動投入だけが
@@ -6144,13 +7210,19 @@ ${deriveCapTile.toString()}
6144
7210
  */
6145
7211
  function renderAdapterTile(cap, items, adapter, derived, ai) {
6146
7212
  var meta = CAP_META[cap] || { icon: "🔗", desc: "" };
7213
+ // **このタイルに出す中身だけに絞る(#153)。** adapter 無し(旧データ・その他)は全部。
7214
+ // アダプタに属さない部品(adapter 空でpushされたもの)は両方のタイルに出す
7215
+ var mine = [];
7216
+ items.forEach(function (it) {
7217
+ if (!adapter || !it.adapter || it.adapter === adapter) mine.push(it.node);
7218
+ });
6147
7219
  // 🔴 **状態はまとめ表の素の値で決める(段階1)。** ここに足した行を混ぜると、
6148
7220
  // 登録直後の1行で「使っている」と判定され、誰も測っていないのに**接続OK**に落ちる
6149
- // (同じ画面の行は「登録状況を確認中です」)。足した行の扱いは pendingSub が持つ
6150
- var st = derived === "unset" && capabilityDisplayState(cap, caps) !== "unset"
6151
- // カタログにあるが導出が答えていないアダプタ(今日は発生しない。#107 の前方互換)
6152
- ? "idle"
6153
- : capabilityState(cap, stateCtx());
7221
+ // (同じ画面の行は「登録状況を確認中です」)。足した行の扱いは pendingSub が持つ。
7222
+ // **unset を idle に短絡しない(#153)。** 短絡すると gate 異常(読めていない行)の
7223
+ // warn 判定を一度も通らず、接続マップだけが⚠️になる。アダプタごとの状態の解決
7224
+ //(adapters[] / 旧データの代表値の扱い)は capabilityState 自身が持つ
7225
+ var st = capabilityState(cap, stateCtx(), adapter);
6154
7226
  // 🔴 **状態の正本は「登録・実測からの導出」(0.12.0・#231 / #235 / #227)。**
6155
7227
  // 設定が済んでいること(capabilities が on)と、実際に動いていることは別。
6156
7228
  // 設定だけを見て「● 接続OK」と出すと、**画面が動いていないものを動いていると言う**
@@ -6159,8 +7231,20 @@ ${deriveCapTile.toString()}
6159
7231
  // **導出が答えないとき(規則5・古い Lambda)だけ、従来の fleet 判定のまま。**
6160
7232
  // **設定が on 以外のときは、そちらが先に立つ**(tileSub の順序と揃える——
6161
7233
  // 揃えないと「色は接続OK・文字はOFF」が同じタイルの中で並ぶ)
6162
- var derive = derived && derived !== "on" ? null : capDerivation(cap);
6163
- if (derive) st = derive.state;
7234
+ // **導出の変種を持つアダプタ(google-chat)の unset は導出を止めない(#153・判断⑲)。**
7235
+ // 非アクティブなアダプタの unset は「エンジンが未導出」という既定値であって設定の宣言では
7236
+ // ないので、OFF/broken と同じ扱いで塞ぐと、名簿に登録があるのに「未接続」と言い続ける。
7237
+ // 判定は derivableUnset(1箇所)——**名簿が読めなかった回(件数 null)はバイパスしない**。
7238
+ // null は規則1(登録0件)をすり抜けて規則4に落ち、名簿の無い全案件で「登録済み」と嘘をつく
7239
+ var underived = derived && derived !== "on" && !derivableUnset(cap, adapter, derived);
7240
+ var derive = underived ? null : capDerivation(cap, adapter);
7241
+ // **導出が warn を降格しない(#153 実装レビュー3)。** 実測ストアは新しい gate 語
7242
+ //(not_in_space 等)を読めるようになった(PR-E)が、**焼いた行にしか判定が無い場面は
7243
+ // 残る**——夜間がまだ一度も走っていない案件・ストアが読めなかった回。そこで
7244
+ // fleet の行から出た warn を導出(規則4=idle)が上書きすると
7245
+ // 「接続グラフは⚠️・タイルは静か」——この仕組みが殺したはずの食い違いが再発する
7246
+ if (derive && !(st === "warn" && derive.state !== "warn")) st = derive.state;
7247
+ else if (derive && st === "warn") derive = null;
6164
7248
  // **ロゴはタイルの左に独立して置く。** 名前の行に入れると名前の高さに
6165
7249
  // 縛られて小さくなる。ロゴで能力を見分けられるのが一覧の主な用途
6166
7250
  var logo = ADAPTER_ICONS[adapter];
@@ -6179,8 +7263,9 @@ ${deriveCapTile.toString()}
6179
7263
  // あの語は「まとめ表しか材料が無く、登録の現在値が分からない」ときの折衷だった。
6180
7264
  // 正本(registeredNow)を読むようになった今は分かる——外した直後は「未接続」、
6181
7265
  // 足した直後は「登録済み(まだ取り込みがありません)」と、**何が起きたかを言える**。
6182
- // ここを逆にすると、まとめ表が追いつく翌朝まで「確認中です」が居座る
6183
- var pend = capPending(cap) && !derive;
7266
+ // ここを逆にすると、まとめ表が追いつく翌朝まで「確認中です」が居座る。
7267
+ // 確認中の判定はアダプタで絞る(#153・判断⑭)
7268
+ var pend = capPending(cap, adapter) && !derive;
6184
7269
  // 🔴 **初回のライブ確認が答えるまで、状態を断言しない(#256・設計v2 の5)。**
6185
7270
  // この2行目は焼いた capabilities 由来の状態を先に言っていたので、図と同じ瞬き
6186
7271
  // (旧状態を見せて1〜2秒後に訂正)をタイルでも起こしていた。**兄弟機能の同型の
@@ -6200,7 +7285,7 @@ ${deriveCapTile.toString()}
6200
7285
  });
6201
7286
  var body = el("div", { class: "ds-tile-body" }, [
6202
7287
  el("div", { class: "ds-tile-name", text: (rawTool && (CM_TOOL_LABEL_C[rawTool] || rawTool)) || cap }),
6203
- el("div", { class: "ds-tile-desc", text: meta.desc }),
7288
+ el("div", { class: "ds-tile-desc", text: ADAPTER_DESC[adapter] || meta.desc }),
6204
7289
  subEl,
6205
7290
  ]);
6206
7291
  // **なぜその状態なのかを添える(#125②)。** engine が書いた一文(detail)で、
@@ -6212,9 +7297,15 @@ ${deriveCapTile.toString()}
6212
7297
  // 実機で幅を超えて溢れた。off(何件残して止めたか)と broken(異常)は
6213
7298
  // 押さずに気づけないと困るので、表面に残す
6214
7299
  var capInfo = caps[cap];
7300
+ // detail もアダプタで引く(#153)。エントリの引き方の正本は adapterEntry——
7301
+ // 状態と別の走査を書くと「状態判定上は存在しないエントリの detail だけ出る」がつくれる。
7302
+ // adapters[] はあるがエントリが無いアダプタには、単数形(代表値)の detail を流用しない
7303
+ // detail の解決も adapterResolved(正本1箇所)。null は「代表値の detail に従う」
7304
+ var adRes = adapterResolved(cap, adapter, caps);
7305
+ var detailText = adRes.detail === null ? ((capInfo && capInfo.detail) || "") : adRes.detail;
6215
7306
  var detailOnTile = derived === "off" || derived === "broken";
6216
- if (detailOnTile && capInfo && capInfo.detail && adapter === capabilityAdapter(cap, caps)) {
6217
- body.appendChild(el("div", { class: "ds-tile-note ds-tile-todo", text: capInfo.detail }));
7307
+ if (detailOnTile && detailText) {
7308
+ body.appendChild(el("div", { class: "ds-tile-note ds-tile-todo", text: detailText }));
6218
7309
  }
6219
7310
  // トークン未設定は**状態とは別軸**。状態機械に混ぜると判定が1箇所でなくなる。
6220
7311
  // ただし「使わない」と**宣言した**能力では出さない——設定する必要が無いものに
@@ -6266,11 +7357,56 @@ ${deriveCapTile.toString()}
6266
7357
  ]);
6267
7358
  // ⚠ **ここで足した行(段階1)を見て抑制しない。** unset の一文は開くときに引き直される
6268
7359
  // (openCapabilityModal が capPending を見る)ので二重になるうえ、
6269
- // ここで抑えると off / broken まで消える——R4 で「消さない」と決めたもの
6270
- var modalNote = derived && derived !== "on" && capInfo && capInfo.detail
6271
- ? CAP_STATE_TEXT[derived] + "(" + capInfo.detail + ")"
7360
+ // ここで抑えると off / broken まで消える——R4 で「消さない」と決めたもの。
7361
+ // detail はアダプタで引いた detailText を使う(#153)。adapters[] は新しい状態語が
7362
+ // 最初に現れる場所なので、未知の state に必ず備える(無いと「undefined(…)」が顧客画面に出る)
7363
+ var modalNote = derived && derived !== "on" && detailText
7364
+ ? (CAP_STATE_TEXT[derived] || "状態が判定できません") + "(" + detailText + ")"
6272
7365
  : "";
6273
- tile.addEventListener("click", function () { openCapabilityModal(cap, items, modalNote, derived === "unset"); });
7366
+ // adapter は第5引数(第4は noteIsUnset・#184 の守り。判断⑰-2)
7367
+ tile.addEventListener("click", function () { openCapabilityModal(cap, mine, modalNote, derived === "unset", adapter); });
7368
+ sec.appendChild(tile);
7369
+ }
7370
+
7371
+ /**
7372
+ * ハーネス連携のタイル。**接続の語彙(未接続・接続OK)は一切使わない**
7373
+ * ——接続ではないので CAP_STATE_TEXT を引かない。ここで語るのは
7374
+ * (1) いま何件オンか
7375
+ * (2) オンなのに配信先が無い、という直すべき状態があるか
7376
+ * の2つだけで、どちらも押さずに分かる必要がある。
7377
+ */
7378
+ function renderHarnessTile() {
7379
+ var meta = CAP_META["ハーネス連携"];
7380
+ // **時間帯の無い行は数えない。** plan(実行系)は slot の無い行を除外して動かさない
7381
+ // ので、数えると「N件をオンにしています」が実行されない行を動いているように言う。
7382
+ // モーダルには行として出る(時間帯未設定・オフにできる)ので、消えはしない
7383
+ var n = 0;
7384
+ for (var hi = 0; hi < HARNESS_SCHEDULES.length; hi++) {
7385
+ if (HARNESS_SCHEDULES[hi].slot) n += 1;
7386
+ }
7387
+ var body = el("div", { class: "ds-tile-body" }, [
7388
+ el("div", { class: "ds-tile-name", text: "ハーネス連携" }),
7389
+ el("div", { class: "ds-tile-desc", text: meta.desc }),
7390
+ el("div", {
7391
+ class: "ds-tile-sub " + (n ? "ds-gate-ok" : "ds-gate-muted"),
7392
+ text: n ? "● " + n + "件をオンにしています" : "すべてオフ",
7393
+ }),
7394
+ ]);
7395
+ // 🔴 **オンなのに配信先が無い状態は、押さずに分かるようにする。** この状態だと
7396
+ // 自動実行は動くのに結果がどこにも届かず、画面上は「N件オン」だけが見えるので、
7397
+ // 何も起きていないことに気づけない
7398
+ if (n > 0 && !harnessNotifyTargets().length) {
7399
+ body.appendChild(el("div", {
7400
+ class: "ds-tile-note ds-tile-alert",
7401
+ text: "⚠ オンになっていますが、配信先が未設定です",
7402
+ }));
7403
+ }
7404
+ var tile = el("button", { class: "ds-tile", type: "button", id: "ds-ハーネス連携" }, [
7405
+ el("span", { class: "ds-tile-logo", text: meta.icon }),
7406
+ body,
7407
+ el("span", { class: "ds-tile-chev", text: "›" }),
7408
+ ]);
7409
+ tile.addEventListener("click", function () { openHarnessModal(); });
6274
7410
  sec.appendChild(tile);
6275
7411
  }
6276
7412
 
@@ -6298,7 +7434,7 @@ ${deriveCapTile.toString()}
6298
7434
 
6299
7435
  // 注記は全能力の後ろに1つ。**何が Gold の材料になるのかを1文で言う。**
6300
7436
  if (anyCap) {
6301
- sec.appendChild(el("p", { class: "ds-note", text: "登録されている Slack チャンネルと GitHub リポジトリは、毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。チャンネルは登録を解除すると、リポジトリは登録から削除すると、以後は読み取られません。" }));
7437
+ sec.appendChild(el("p", { class: "ds-note", text: "登録されている Slack チャンネル・Google Chat スペース・GitHub リポジトリは、毎晩の Gold 昇格が読み取り、Decision・用語集の材料になります。チャンネルとスペースは登録を解除すると、リポジトリは登録から削除すると、以後は読み取られません。" }));
6302
7438
  }
6303
7439
 
6304
7440
  if (pipes.length) {
@@ -6818,6 +7954,33 @@ ${deriveCapTile.toString()}
6818
7954
  return wrap;
6819
7955
  }
6820
7956
 
7957
+ /**
7958
+ * Google Chat スペースの登録解除(#153)。removeChannelButton と同型で、
7959
+ * 書き換わる先が google-chat-spaces.json・鍵が ref のみ(スペースの ref は
7960
+ * 全ワークスペースで一意なので workspace は送らない)という違いだけ。
7961
+ * #155 のモデル(載っている=読む・止める手段は登録解除)に Slack と揃える。
7962
+ */
7963
+ function removeSpaceButton(s, card) {
7964
+ return removeButton({
7965
+ label: "登録を解除",
7966
+ confirmLines: function () {
7967
+ return [
7968
+ "「" + (s.name || s.ref) + "」を、Gold 昇格の読み取り対象から外します。",
7969
+ "",
7970
+ "次回の Gold 昇格(毎晩22時ごろ)から、このスペースは読み取られなくなります。",
7971
+ "",
7972
+ "※ 既に Gold 層に入った内容は消えません(Decision・用語集はそのまま残ります)。",
7973
+ "",
7974
+ "解除しますか?",
7975
+ ];
7976
+ },
7977
+ build: function () {
7978
+ return { op: "remove-google-chat-space", ref: s.ref };
7979
+ },
7980
+ onDone: function () { card.classList.add("ds-removed"); },
7981
+ });
7982
+ }
7983
+
6821
7984
  /**
6822
7985
  * いま同期している Backlog の接続先の一覧(#127)。**1件でも配列で返す。**
6823
7986
  *
@@ -7441,7 +8604,7 @@ ${deriveCapTile.toString()}
7441
8604
  // つまり「tools: teams なのに enabled: true」なら**実際に取り込まれる**ので、
7442
8605
  // 宣言で隠すと、動いているのに説明が出ない状態になる。
7443
8606
  if (s.kind === "会議" && s.ingestState === "on") {
7444
- box.appendChild(meetingHowTo());
8607
+ box.appendChild(meetingHowTo(s));
7445
8608
  // **会議名の登録を画面から変える口は置かない(#117 第2弾)。**
7446
8609
  // 別名は括弧付きの合図運用へ移った際の経過措置で、既存案件のメンバーに定例の
7447
8610
  // 改名を強いないために置いたもの。第1弾では「既に別名がある案件にだけ追加口を出す」
@@ -7861,6 +9024,42 @@ ${deriveCapTile.toString()}
7861
9024
  adder: "slack",
7862
9025
  }));
7863
9026
 
9027
+ // Google Chat のスペース追加(#153)。**URL のみ・Figma/Drive と同形。**
9028
+ // 表示名の欄は置かない(名前はサーバが spaces.get で引く。引けなくても
9029
+ // 「名前未取得」で通し、後から実名に変わる——Slack と同じ約束)。
9030
+ // ワークスペースのプルダウンも無し(スペースの ref は全ワークスペースで一意)
9031
+ //
9032
+ // **接続(OAuth)のボタンは無い。** 顧客の Workspace 管理者による Admin install が
9033
+ // 承認そのもので、画面に押す先が無い(Slack の接続ボタンを写経しないこと——
9034
+ // 押しても何も起きないボタンが顧客の画面に出る)。
9035
+ // **追加時の確認モーダルも出さない**(#167 で「追加のたびの確認」は廃止済み。
9036
+ // 「追加=Gold 昇格の対象」という重さは、Slack と同じく手順の最終行が伝える)。
9037
+ // 案内(アプリ追加の操作列)は独立の箱にせず、この手順リストが持つ(#170/#174 の圧縮方針。
9038
+ // fleet-status にデータが無い案件でもフォームは常設なので、案内も常に出る)
9039
+ //
9040
+ // Google Chat アプリの表示名。**Marketplace 公開名として確定(2026-08-31 オーナー決定)。**
9041
+ // 検索ワードとして本文に組み込む(コピー行は置かない——コマンドではなく検索語のため)
9042
+ var GCHAT_APP_NAME = "Cortex Bot";
9043
+ box.appendChild(addForm({
9044
+ title: "+ Google Chat スペースを追加",
9045
+ placeholder: "https://chat.google.com/room/XXXXXXXXXXX",
9046
+ requiredNote: "スペースのURLを入力してください。",
9047
+ // 手順リスト(#170 の型)。アプリ追加の話が先——Slack の招待と同じで、入っていないと
9048
+ // 1件も読めない。操作列は Google 公式ヘルプの現行手順に合わせる
9049
+ // (support.google.com/chat/answer/7655820)。DM が対象外であることもここで言う——
9050
+ // 貼って 400 になってから知るより先に
9051
+ hint: [
9052
+ "読み取りたいスペースに Cortex のアプリを追加します。スペース名をクリック → 「アプリと統合」 → 「アプリを追加」 で、「" + GCHAT_APP_NAME + "」を検索して追加します。",
9053
+ "アプリが検索に出ないときは、貴社の Google Workspace 管理者が Marketplace からの導入をまだ承認していません。承認できるのは管理者だけで、反映まで最大24時間かかります(見つからない場合は担当者にご連絡ください)。",
9054
+ "下の欄に、ブラウザでスペースを開いたときのURL(chat.google.com/room/…)を貼って「追加」を押します。DM(1対1のチャット)は読み取りの対象外です。",
9055
+ "追加したスペースは毎晩の Gold 昇格の対象になり、Decision・用語集の材料になります。止めるときは、その行の「登録を解除」を押します(今夜の蒸留・22時ごろまでに解除すれば取り込まれません)。",
9056
+ ],
9057
+ build: function (url) {
9058
+ return { op: "add-google-chat-space", url: url };
9059
+ },
9060
+ adder: "google-chat",
9061
+ }));
9062
+
7864
9063
  if (usable("開発", "github")) {
7865
9064
  box.appendChild(addForm({
7866
9065
  title: "+ GitHubリポジトリを追加",
@@ -8044,7 +9243,12 @@ ${deriveCapTile.toString()}
8044
9243
  // **何も変わっていないなら「追加した」と見せない。** 既に同じものが入っていた場合
8045
9244
  // サーバは 200 + changed:false を返す。ここを res.ok だけで判定すると
8046
9245
  // 「変更はありません」と言いながら追加した行が出る
8047
- if (onDone && j.changed !== false) onDone();
9246
+ // 🔴 **応答そのものを渡す(j)。** サーバが保存後の値を添えてくる口
9247
+ //(ハーネスの entry: 既定を解決した後の時間帯・曜日)では、UI が既定を写経せずに
9248
+ // 正しく反映できる。**流れるのは res.ok かつ changed !== false のときだけ**で、
9249
+ // removeButton 経由の onDone(引数を取らない)には届かない——受け側は
9250
+ // j が undefined でも動く形に保つこと(既存の4呼び出しは引数を取らない)
9251
+ if (onDone && j.changed !== false) onDone(j);
8048
9252
  } catch (e) {
8049
9253
  msg.className = "ds-toggle-msg is-error";
8050
9254
  msg.textContent = "✗ 通信に失敗しました。";
@@ -8107,7 +9311,8 @@ ${deriveCapTile.toString()}
8107
9311
  * オーバーレイ要素だけにリスナを張るのでは足りない(フォーカスが body にあると経路を通らない)。
8108
9312
  *
8109
9313
  * @param lines 表示する行(1行1段落)。先頭行が主文=読み上げの名前になる
8110
- * @param opts { okLabel, returnTo }
9314
+ * @param opts { okLabel, returnTo, okDanger }
9315
+ * okDanger … OK ボタンを赤で出すか(既定 true)。false は「広げる向き」の確認用
8111
9316
  */
8112
9317
  function openConfirm(lines, opts) {
8113
9318
  return new Promise(function (resolve) {
@@ -8128,7 +9333,10 @@ ${deriveCapTile.toString()}
8128
9333
  box.appendChild(pnode);
8129
9334
  });
8130
9335
  var cancel = el("button", { class: "modal-cancel", type: "button", text: "キャンセル" });
8131
- var ok = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: o.okLabel || "はい" });
9336
+ // **既定は赤(ds-danger)。** ここに来る操作はほぼ全部が取り消せない向きなので、
9337
+ // 既定を変えない。**okDanger: false のときだけ通常の見た目**——ハーネスの
9338
+ // 「オンにする」は届き始める向きの操作で、削除と同じ赤で出すと止めるべき操作に見える
9339
+ var ok = el("button", { class: "ds-toggle-btn" + (o.okDanger === false ? "" : " ds-danger"), type: "button", text: o.okLabel || "はい" });
8132
9340
  box.appendChild(el("div", { class: "modal-actions" }, [cancel, ok]));
8133
9341
  overlay.appendChild(box);
8134
9342
 
@@ -8545,6 +9753,13 @@ ${deriveCapTile.toString()}
8545
9753
  // どちらも「チャット/channels.json に登録がある行」でしか効かない
8546
9754
  card.appendChild(notifyToggle(s, badges));
8547
9755
  }
9756
+ // Google Chat のスペースの登録解除(#153)。#155 のモデル(載っていること自体が
9757
+ // 読み取りの意思決定・止める手段は登録解除)に Slack と同型で揃える。
9758
+ // Gold トグルは Slack 同様持たない。登録ファイルは google-chat-spaces.json の一系統
9759
+ // だけなので、Slack のような origin の出し分けは不要
9760
+ if (SITE.intake && s.type === "google-chat" && s.ref) {
9761
+ card.appendChild(removeSpaceButton(s, card));
9762
+ }
8548
9763
  // GitHubリポの登録から削除。**ON/OFF のトグルは撤去した(2026-08-21)**
8549
9764
  //(stateToggle のコメント参照)。
8550
9765
  if (SITE.intake && s.type.indexOf("github") === 0 && s.ref) {
@@ -8864,7 +10079,12 @@ ${deriveCapTile.toString()}
8864
10079
  }
8865
10080
  return "";
8866
10081
  }
8867
- /** 画面の能力名 → op=state の識別子(対応表の実体は REGISTERED_CAP_NAMES) */
10082
+ /**
10083
+ * 画面の能力名 → op=state の識別子(対応表の実体は REGISTERED_CAP_NAMES)。
10084
+ * ⚠ チャットは chat と google-chat の2キーが同じ能力名に対応するが、**要求は常に全量**
10085
+ * (ALL_STATE_CAPS)になったので、ここは「初回に開いたか」の記録にしか使わない——
10086
+ * 先勝ちの1件で足りる(#153 の配列化は全量要求への移行で不要になった)
10087
+ */
8868
10088
  function stateCapIdOf(cap) {
8869
10089
  for (var id in REGISTERED_CAP_NAMES) {
8870
10090
  if (Object.prototype.hasOwnProperty.call(REGISTERED_CAP_NAMES, id) && REGISTERED_CAP_NAMES[id] === cap) return id;