@takagaki/cortex-decisions-viewer 0.12.47 → 0.12.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.js CHANGED
@@ -430,6 +430,9 @@ async function readFleetSources(repoRoot) {
430
430
  label: String(s.label ?? ""),
431
431
  url: s.url != null ? String(s.url) : undefined,
432
432
  lastSync: s.lastSync != null ? String(s.lastSync) : undefined,
433
+ // 🔴 **時刻と一緒に「誰が測ったか」も通す(#523 決定1)。** これを落とすと、
434
+ // 画面は run の成功時刻(空振りの run を含む)を「最終同期」として出し続ける
435
+ lastSyncSource: s.lastSyncSource != null ? String(s.lastSyncSource) : undefined,
433
436
  // 取り込み済みの件数。**「同期が走った」と「実際に入った」は別物。**
434
437
  // これが無いと、初回全量同期をしていない案件が「接続OK」に見える
435
438
  itemCount: typeof s.itemCount === "number" ? s.itemCount : undefined,
package/dist/render.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.UNSETTLED_LIMITS = exports.ALREADY_REGISTERED = exports.LIVE_CONFIRM_LIMITS = exports.CLAUDE_CODE_LINK = void 0;
3
+ exports.STATE_CONFIRMING = exports.UNSETTLED_LIMITS = exports.ALREADY_REGISTERED = exports.LIVE_CONFIRM_LIMITS = exports.CLAUDE_CODE_LINK = void 0;
4
4
  exports.renderSite = renderSite;
5
5
  exports.renderSiteWithAssets = renderSiteWithAssets;
6
6
  exports.viewerAssetSources = viewerAssetSources;
@@ -36,6 +36,9 @@ exports.sanitizeCapabilitiesNow = sanitizeCapabilitiesNow;
36
36
  exports.sanitizeGithubOwnersNow = sanitizeGithubOwnersNow;
37
37
  exports.splitNeededParts = splitNeededParts;
38
38
  exports.ingestedAtOf = ingestedAtOf;
39
+ exports.fleetLastSyncOf = fleetLastSyncOf;
40
+ exports.stateRefKey = stateRefKey;
41
+ exports.registeredRefKeys = registeredRefKeys;
39
42
  exports.keyMissingText = keyMissingText;
40
43
  exports.isCalendarId = isCalendarId;
41
44
  exports.calendarStateRow = calendarStateRow;
@@ -966,7 +969,7 @@ function sanitizeRegisteredNow(v) {
966
969
  * 写し忘れても赤くならなかったので、突き合わせは viewer/test/state-vocab-parity.test.mjs。
967
970
  */
968
971
  const STATE_STATUSES = {
969
- reachable: ["ok", "not_in_channel", "missing_scope", "no_token", "unreachable", "unknown", "not_in_space", "no_credentials", "no_consent", "transcripts_api_disabled", "no_access_policy", "speaker_attribution_off", "folder_not_granted"],
972
+ reachable: ["ok", "not_in_channel", "missing_scope", "no_token", "unreachable", "unknown", "not_in_space", "no_credentials", "admin_approval_pending", "no_consent", "transcripts_api_disabled", "no_access_policy", "speaker_attribution_off", "folder_not_granted"],
970
973
  ingested: ["ok", "failed", "skipped"],
971
974
  // 案件カレンダーは作れたか(tools #427)。行の描画は #428。ここに無いと、中央が報告した
972
975
  // 「作成済み」の行が overlay に届く前に落ちて、画面は永久に「作成中」のまま
@@ -1860,20 +1863,124 @@ function splitNeededParts(text) {
1860
1863
  *
1861
1864
  * @param capId 応答側の能力名(issues / design / materials / meetings)
1862
1865
  * @returns ISO文字列。1件も無ければ ""
1866
+ * @param refs 登録簿にある ref の鍵(`registeredRefKeys`)。**渡されたときだけ篩う**
1867
+ * ——孤児行(外した接続先の残骸)の `lastOkAt` を「最終同期」として出さないため。
1868
+ * 分からない(null / undefined)ときは篩わない(#523 S1・deriveCapTile と同じ規律)
1863
1869
  */
1864
- function ingestedAtOf(rows, capId) {
1870
+ function ingestedAtOf(rows, capId, refs) {
1865
1871
  if (!Array.isArray(rows) || !capId)
1866
1872
  return "";
1867
1873
  let best = "";
1868
1874
  for (const r of rows) {
1869
1875
  if (!r || r.cap !== capId || r.question !== "ingested")
1870
1876
  continue;
1877
+ if (refs && !refs[stateRefKey(capId, r.ref, r.workspace)])
1878
+ continue;
1871
1879
  const at = r.lastOkAt || (r.status === "ok" ? r.at : "");
1872
1880
  if (typeof at === "string" && at > best)
1873
1881
  best = at;
1874
1882
  }
1875
1883
  return best;
1876
1884
  }
1885
+ /**
1886
+ * まとめ表(fleet-status.json)の「最終同期」を、画面に出してよい形で取る(#523 決定1)。
1887
+ *
1888
+ * 🔴 **run 由来の時刻は捨てる。** `lastSync` には2つの意味が入っている——
1889
+ * 接続状態ストアの `lastOkAt`(実際に取り込めた時刻・`lastSyncSource === "ingested"`)と、
1890
+ * ワークフローが成功した時刻(`"run"`。鍵が無くて1件も取り込めていない run も
1891
+ * 「成功」で終わる)。後者を「最終同期」として出すと、**登録したのに何も取り込めて
1892
+ * いない案件が「昨日同期できている」と読める**(engine #314 の「最終同期 9/8 18:34」)。
1893
+ *
1894
+ * 🔴 **欄が無い生成物も run 扱い(捨てる)。** この欄より前の engine が作った
1895
+ * fleet-status.json には `lastSyncSource` が無い。「どちらか分からない時刻」を出すのは
1896
+ * 嘘をつきうる側なので、出さない方へ倒す(決定1の趣旨)。
1897
+ *
1898
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
1899
+ */
1900
+ function fleetLastSyncOf(internal) {
1901
+ if (!internal || typeof internal !== "object")
1902
+ return "";
1903
+ if (internal.lastSyncSource !== "ingested")
1904
+ return "";
1905
+ return typeof internal.lastSync === "string" ? internal.lastSync : "";
1906
+ }
1907
+ /**
1908
+ * 接続状態ストアの行の `ref` を、**登録簿との突き合わせ鍵**に畳む(#523 S1・設計v2 §2 の表)。
1909
+ *
1910
+ * 🔴 **能力ごとに鍵が違う。** `registeredRefKeys` と**必ず同じ畳み方**にすること
1911
+ * ——片方だけ変えると、登録してある接続先の行が「登録簿に無い」と判定され、
1912
+ * **実測が届いているのに画面が黙る**(いま直そうとしている嘘の裏返し)。
1913
+ * - issues … `issuesRefOf`(ドメイン小文字 / キー大文字)。設定ファイルの値は人が
1914
+ * 書いたもので大小が揃っていない
1915
+ * - materials / design … folderId / fileKey の**生値**(どちらも大小を区別する不透明ID)
1916
+ * - chat … ワークスペース + チャンネルID(IDはワークスペースの中でしか一意でない。
1917
+ * `overlayStateOnFleet` と同じ鍵)
1918
+ * - google-chat / dev … ref の生値(ワークスペースを持たない)
1919
+ *
1920
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
1921
+ * `issuesRefOf` はこれより**前**に流し込んである)。
1922
+ */
1923
+ function stateRefKey(capId, ref, workspace) {
1924
+ var s = String(ref == null ? "" : ref);
1925
+ if (!s)
1926
+ return "";
1927
+ if (capId === "issues") {
1928
+ var at = s.indexOf("/");
1929
+ if (at < 0)
1930
+ return "";
1931
+ return issuesRefOf(s.slice(0, at), s.slice(at + 1));
1932
+ }
1933
+ if (capId === "chat")
1934
+ return String(workspace == null ? "" : workspace) + "\t" + s;
1935
+ return s;
1936
+ }
1937
+ /**
1938
+ * 登録簿(`registeredNow` の1能力分)から、突き合わせ鍵の集合を作る(#523 S1)。
1939
+ *
1940
+ * 🔴 **チャットは鍵を2本入れる。** 正式な鍵は `ワークスペース + ref` だが、ストアの行が
1941
+ * ワークスペースを持たない場合は厳密な鍵を組めない。そこで `"" + \t + ref` も入れて
1942
+ * **「分からないものは落とさない」**側へ倒す(篩い過ぎ=画面が黙る、が一番重い事故)。
1943
+ * 逆に、登録されていないワークスペースの同IDは落ちる(設計どおり)。
1944
+ *
1945
+ * @param regKey `registeredNow` のキー(chat / google-chat / dev / design / materials / issues)
1946
+ * @param rows その能力の登録行。配列でなければ null(=分からない。篩わない)
1947
+ *
1948
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
1949
+ */
1950
+ function registeredRefKeys(regKey, rows) {
1951
+ if (!Array.isArray(rows))
1952
+ return null;
1953
+ var out = {};
1954
+ for (var i = 0; i < rows.length; i++) {
1955
+ var r = rows[i];
1956
+ if (!r || typeof r !== "object")
1957
+ continue;
1958
+ if (regKey === "issues") {
1959
+ if (r.domain && r.projectKey)
1960
+ out[issuesRefOf(r.domain, r.projectKey)] = true;
1961
+ continue;
1962
+ }
1963
+ if (regKey === "design") {
1964
+ if (r.key)
1965
+ out[String(r.key)] = true;
1966
+ continue;
1967
+ }
1968
+ if (regKey === "materials") {
1969
+ if (r.id)
1970
+ out[String(r.id)] = true;
1971
+ continue;
1972
+ }
1973
+ if (!r.ref)
1974
+ continue;
1975
+ if (regKey === "chat") {
1976
+ out[String(r.workspace == null ? "" : r.workspace) + "\t" + String(r.ref)] = true;
1977
+ out["\t" + String(r.ref)] = true;
1978
+ continue;
1979
+ }
1980
+ out[String(r.ref)] = true;
1981
+ }
1982
+ return out;
1983
+ }
1877
1984
  /**
1878
1985
  * タイルと接続グラフの導出に要る、能力ごとの材料(0.12.0・#231 / #235 / #227)。
1879
1986
  *
@@ -1884,24 +1991,51 @@ function ingestedAtOf(rows, capId) {
1884
1991
  * - `noFact` … 規則4(登録はあるが実績が無い)の文言。**能力ごとに待っているものが違う**
1885
1992
  * ので1文に丸めない。会議・共有資料は中央 Apps Script の取り込み待ち(=まだ使っていない)、
1886
1993
  * 課題管理・デザインは夜間同期待ち、チャット・開発は実測待ち
1994
+ * - `alsoBad` … **実績の問いとは別に、悪ければ⚠️にする問い**(#523 S1・決定3)。
1995
+ * 取り込む4能力は `reachable`(届くか)も見る——Drive のフォルダの共有が外れると
1996
+ * `reachable=unreachable` になるが `ingested` の行は**書かれない**(測れていないので)ので、
1997
+ * `ingested` だけを見ていると「接続OK(最終同期 ◯日前)」と言い続ける。
1998
+ * **「悪い方」で判定する**(どちらかが悪ければ⚠️)。実績(規則3)は `question` の側だけで数える
1999
+ * ——reachable が ok でも、取り込みが1件も無ければ「まだ取り込みがありません」が事実
2000
+ * - `adminOnNoToken` … `reachable=no_token` を「読めていません」と言わない能力(決定3)。
2001
+ * 鍵の呼び名があれば「◯が未登録です」(画面の鍵カードと同じ語)、無ければ
2002
+ * 「管理者の設定が必要です」——Drive の資格情報は艦隊側にあり、案件の人には打つ手が無い。
2003
+ * **チャット・開発には付けない**(あちらの `no_token` は行バッジ側が
2004
+ * `DS_GATE_NO_TOKEN` で経路ごとに言い換えており、タイルの語を変えると二重管理になる)
2005
+ * - `refKeyed` … 登録簿(`registeredNow`)との突き合わせで孤児行を落とせる能力(#523 S1・決定2)。
2006
+ * 会議は接続先の登録簿を持たない(ref は固定の `transcripts`)ので篩わない
1887
2007
  *
1888
2008
  * **6能力すべてを載せること。** ここに無い能力は導出そのものが働かず、規則5
1889
2009
  * (fleet へのフォールバック)に落ちる=正本化の対象から静かに外れる。
1890
2010
  */
1891
2011
  const TILE_CAPS = {
1892
- "課題管理": { id: "issues", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "APIキー" },
1893
- "会議": { id: "meetings", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1894
- "共有資料": { id: "materials", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "" },
1895
- "デザイン": { id: "design", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "トークン" },
1896
- "チャット": { id: "chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
2012
+ "課題管理": { id: "issues", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "APIキー", alsoBad: ["reachable"], adminOnNoToken: true, refKeyed: true },
2013
+ "会議": { id: "meetings", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "", alsoBad: ["reachable"], adminOnNoToken: true },
2014
+ "共有資料": { id: "materials", question: "ingested", noFact: "未使用(まだ取り込みがありません)", keyLabel: "", alsoBad: ["reachable"], adminOnNoToken: true, refKeyed: true },
2015
+ "デザイン": { id: "design", question: "ingested", noFact: "登録済み(まだ取り込みがありません)", keyLabel: "トークン", alsoBad: ["reachable"], adminOnNoToken: true, refKeyed: true },
2016
+ "チャット": { id: "chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "", refKeyed: true },
1897
2017
  // チャットの2枚目のアダプタ(#153・判断⑲)。**タイルの導出はアダプタで割る**——
1898
2018
  // 割らないと Google Chat タイルが Slack の登録件数・実測で判定される。
1899
2019
  // 鍵はタイル側(capDerivation)が「能力-アダプタ」で引く。実測ストアの
1900
2020
  // `cap: "google-chat"` の行は夜間の Gold 昇格が書く(#153 PR-E で結線済み)ので、
1901
2021
  // 夜間が一度走れば規則2/3(⚠️ / 接続OK)まで進む。走る前は規則4(実績なし→中立)
1902
- "チャット-google-chat": { id: "google-chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
1903
- "開発": { id: "dev", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "" },
2022
+ "チャット-google-chat": { id: "google-chat", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "", refKeyed: true },
2023
+ "開発": { id: "dev", question: "reachable", noFact: "登録済み(接続は未確認です)", keyLabel: "", refKeyed: true },
1904
2024
  };
2025
+ /**
2026
+ * 登録したばかりで、まだ実測の行が1件も届いていない接続先があるときの語(#523 決定6)。
2027
+ *
2028
+ * 🔴 **`DS_GATE_PENDING`(「登録状況を確認中です」)と混ぜない。** あちらは
2029
+ * 「登録簿が読めず、何件あるのかが分からない」という別の事実で、こちらは
2030
+ * **登録は見えている・実測の到着を待っている**。上限は `UNSETTLED_TTL_MS`(15分)で、
2031
+ * 過ぎたら規則4の中立文(「まだ取り込みがありません」)に戻す——待てば出るとは
2032
+ * 言い続けない。
2033
+ *
2034
+ * ⚠ **定数そのものを export しない**(CommonJS で `exports.X` に化け、流し込んだ先で落ちる)。
2035
+ */
2036
+ const STATE_CONFIRMING_TEXT = "確認しています…";
2037
+ /** 語を**テストから固定するための入れ物**(UNSETTLED_LIMITS と同じ作法) */
2038
+ exports.STATE_CONFIRMING = { text: STATE_CONFIRMING_TEXT };
1905
2039
  /**
1906
2040
  * 「鍵が無くて飛ばした」の一文(#225)。**能力ごとに鍵の呼び名が違う。**
1907
2041
  *
@@ -1931,7 +2065,7 @@ const BAD_STATE_STATUSES = {
1931
2065
  // 案件カレンダー(tools #427)。**`requested` は中立**(依頼を受けた・作成中)で悪くない。
1932
2066
  // 入れると、押した直後の 15 分が全部⚠️になる
1933
2067
  calendar: ["failed"],
1934
- reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable", "not_in_space", "no_credentials", "no_consent", "transcripts_api_disabled", "no_access_policy", "speaker_attribution_off", "folder_not_granted"],
2068
+ reachable: ["not_in_channel", "missing_scope", "no_token", "unreachable", "not_in_space", "no_credentials", "admin_approval_pending", "no_consent", "transcripts_api_disabled", "no_access_policy", "speaker_attribution_off", "folder_not_granted"],
1935
2069
  };
1936
2070
  /**
1937
2071
  * 案件カレンダーの ID の形か(#428)。
@@ -2254,10 +2388,16 @@ function calendarAddUrl(id) {
2254
2388
  * `sanitizeStateNow` は0件を `null` に畳むので、`rows` だけを見ると
2255
2389
  * 「読めて空(=まだ何も起きていない事実)」が「分からない」に化ける
2256
2390
  * @param ctx.rows `stateNow` の行
2391
+ * @param ctx.refs 登録簿にある ref の鍵(`registeredRefKeys` の答え)。**分からなければ
2392
+ * 渡さない**——null を「登録が無い」として篩うと、登録簿が読めなかった回に
2393
+ * 実測が丸ごと無視され、画面が黙る(`registered: null` を 0 と混ぜないのと同じ理由)
2394
+ * @param ctx.confirming 登録したばかりで実測の到着を待っている接続先があるか(決定6)。
2395
+ * 規則4の文言だけを差し替える(状態は中立のまま)
2257
2396
  * @returns 導出結果。`null` なら規則5(従来表示へフォールバック)
2258
2397
  *
2259
2398
  * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す。
2260
- * `TILE_CAPS` / `BAD_STATE_STATUSES` は CLIENT_JS 側にも `var` で流し込んである)。
2399
+ * `TILE_CAPS` / `BAD_STATE_STATUSES` / `STATE_CONFIRMING_TEXT` は CLIENT_JS 側にも
2400
+ * `var` で流し込んである。`stateRefKey` もこれより前に流し込む)。
2261
2401
  */
2262
2402
  function deriveCapTile(cap, ctx) {
2263
2403
  var meta = TILE_CAPS[cap];
@@ -2271,25 +2411,43 @@ function deriveCapTile(cap, ctx) {
2271
2411
  if (!ctx || !ctx.seen)
2272
2412
  return null;
2273
2413
  var rows = Array.isArray(ctx.rows) ? ctx.rows : [];
2274
- var bad = BAD_STATE_STATUSES[meta.question] || [];
2414
+ // **見る問いは「実績の問い」+ alsoBad(#523 決定3)。** 悪い方で判定する
2415
+ var questions = [meta.question].concat(meta.alsoBad || []);
2416
+ // 登録簿にある ref の鍵。**無い(null / undefined)なら篩わない**(規則1が null を
2417
+ // 0 と混ぜないのと同じ理由——読めなかったことを「登録が無い」と断定しない)
2418
+ var refs = ctx.refs || null;
2275
2419
  var failed = 0;
2276
2420
  var skipped = 0;
2421
+ var noToken = 0;
2277
2422
  var unreachable = 0;
2278
2423
  var okSeen = false;
2279
2424
  for (var i = 0; i < rows.length; i++) {
2280
2425
  var r = rows[i];
2281
- if (!r || r.cap !== meta.id || r.question !== meta.question)
2426
+ if (!r || r.cap !== meta.id)
2427
+ continue;
2428
+ if (questions.indexOf(r.question) < 0)
2429
+ continue;
2430
+ // 🔴 **登録簿に無い ref の行は数えない(#523 決定2)。** 接続先を外してもストアの行は
2431
+ // 残る(TTL が無い・既知の問題)ので、登録が1件以上ある能力では規則1が守ってくれない
2432
+ // ——外したフォルダの `skipped` が残っているだけで「⚠ 取り込みを飛ばしました」が
2433
+ // 出続ける(engine #314 の症状の候補3)。**篩い方の正本は stateRefKey 1箇所**
2434
+ if (refs && meta.refKeyed && !refs[stateRefKey(meta.id, r.ref, r.workspace)])
2282
2435
  continue;
2283
2436
  // **一度でも成功していれば実績あり。** `lastOkAt` を見るのは、いま失敗している行でも
2284
- // 「繋がったことがある」のは事実だから(規則2が先に立つので表示は⚠️のまま)
2285
- if (r.status === "ok" || r.lastOkAt)
2437
+ // 「繋がったことがある」のは事実だから(規則2が先に立つので表示は⚠️のまま)。
2438
+ // **実績は `question`(その能力が「実際に動いた」と言える問い)の側だけで数える**
2439
+ // ——Drive に届いていても取り込みが0件なら「まだ取り込みがありません」が事実
2440
+ if (r.question === meta.question && (r.status === "ok" || r.lastOkAt))
2286
2441
  okSeen = true;
2442
+ var bad = BAD_STATE_STATUSES[r.question] || [];
2287
2443
  if (bad.indexOf(r.status) < 0)
2288
2444
  continue;
2289
2445
  if (r.status === "failed")
2290
2446
  failed++;
2291
2447
  else if (r.status === "skipped")
2292
2448
  skipped++;
2449
+ else if (r.status === "no_token" && meta.adminOnNoToken)
2450
+ noToken++;
2293
2451
  else
2294
2452
  unreachable++;
2295
2453
  }
@@ -2303,6 +2461,13 @@ function deriveCapTile(cap, ctx) {
2303
2461
  // 設定が済んでいないだけで、利用者が取るべき行動が違う(鍵を入れる/原因を調べる)
2304
2462
  if (skipped)
2305
2463
  return { rule: 2, state: "warn", text: keyMissingText(cap), keyMissing: !!meta.keyLabel };
2464
+ // 鍵が無くて「届くか」すら測れなかった(#523 決定3)。**「読めていません」と言わない**
2465
+ // ——読もうとする前に止まっているので、直すのは鍵の登録(または管理者の設定)
2466
+ if (noToken) {
2467
+ return meta.keyLabel
2468
+ ? { rule: 2, state: "warn", text: keyMissingText(cap), keyMissing: true }
2469
+ : { rule: 2, state: "warn", text: "管理者の設定が必要です", keyMissing: false };
2470
+ }
2306
2471
  if (unreachable) {
2307
2472
  return { rule: 2, state: "warn", text: unreachable + "件が読めていません", keyMissing: false };
2308
2473
  }
@@ -2310,6 +2475,13 @@ function deriveCapTile(cap, ctx) {
2310
2475
  if (okSeen)
2311
2476
  return { rule: 3, state: "ok", text: "接続OK", keyMissing: false };
2312
2477
  // ---- 規則4: 実績なし → 中立 ----
2478
+ //
2479
+ // **登録したばかりなら「確認しています…」(#523 決定6)。** 登録した瞬間から
2480
+ // 「まだ取り込みがありません」と言うのは事実だが、利用者には「登録が効いていない」とも
2481
+ // 読める。実測が届くまでの短い間(`UNSETTLED_TTL_MS`)だけ、待っていることを言う。
2482
+ // **判定の材料は呼び出し側**(仮置き=pending-ops の add・capConfirming)
2483
+ if (ctx.confirming)
2484
+ return { rule: 4, state: "idle", text: STATE_CONFIRMING_TEXT, keyMissing: false };
2313
2485
  return { rule: 4, state: "idle", text: meta.noFact, keyMissing: false };
2314
2486
  }
2315
2487
  function capabilityState(cap, ctx, adapter) {
@@ -2560,8 +2732,12 @@ function renderConnectionMap(site) {
2560
2732
  else if (isLive) {
2561
2733
  statusText = "ライブ参照";
2562
2734
  }
2563
- else if (internal?.lastSync) {
2564
- statusTs = internal.lastSync;
2735
+ else if (fleetLastSyncOf(internal)) {
2736
+ // 🔴 **run 由来の時刻は焼かない(#523 決定1)。** ここは図の3行目(「最終同期 …」)で、
2737
+ // ライブが届くまでそのまま見えている。run の成功時刻(空振りを含む)を焼くと、
2738
+ // 何も取り込めていない案件の図が「昨日同期できている」と語る——
2739
+ // **ライブで訂正されるまでの数秒だけ嘘をつく**形も作らない
2740
+ statusTs = fleetLastSyncOf(internal);
2565
2741
  }
2566
2742
  // 時刻はクライアントでローカルタイム整形するため data-ts で渡す。
2567
2743
  //
@@ -3739,6 +3915,12 @@ a.ds-tile:hover { text-decoration: none; }
3739
3915
  (flex: 1 1 100%)——状態バッジの横に押し込むと、いちばん読ませたい一文が
3740
3916
  カード幅に負けて省略される。状態の語より弱く、しかし読める大きさで出す */
3741
3917
  .ds-gate-help { flex: 1 1 100%; margin: 4px 0 0; font-size: 12px; line-height: 1.7; color: #6b7280; }
3918
+ /* 管理者への依頼文(#521)。手順が複数行あるので details に畳む——開いたまま出すと、
3919
+ 1行のはずの案内(.ds-gate-help / .ds-probe-help)が一覧を占領する */
3920
+ .ds-gate-request { margin-top: 4px; }
3921
+ .ds-gate-request > summary { cursor: pointer; color: var(--muted); font-size: 12px; }
3922
+ .ds-gate-request[open] > summary { margin-bottom: 6px; }
3923
+ .ds-gate-request-line { margin: 2px 0; font-size: 12px; line-height: 1.7; color: #6b7280; }
3742
3924
  /* 「接続を確認」を押した結果(2026-08-19)。**必ず自分の行を占める**(flex: 1 1 100%)。
3743
3925
  夜間のスナップショットの状態バッジと**同時に2つの判定を並べない**ため、押したあとは
3744
3926
  状態バッジをこの中の判定へ置き換える(同じ画面で「⚠️」と「接続OK」が並ぶのが、
@@ -4298,9 +4480,17 @@ ${secretRegisteredAnywhere.toString()}
4298
4480
  ${sanitizeCapabilitiesNow.toString()}
4299
4481
  ${sanitizeGithubOwnersNow.toString()}
4300
4482
  ${splitNeededParts.toString()}
4483
+ // 🔴 **ingestedAtOf / deriveCapTile より前に置く。** 両方が突き合わせ鍵の畳み方を
4484
+ // ここから引く(#523 S1)。順序を崩すと、孤児行の篩が undefined を呼んで画面が落ちる
4485
+ ${stateRefKey.toString()}
4486
+ ${registeredRefKeys.toString()}
4487
+ ${fleetLastSyncOf.toString()}
4301
4488
  ${ingestedAtOf.toString()}
4302
4489
  var TILE_CAPS = ${JSON.stringify(TILE_CAPS)};
4303
4490
  var BAD_STATE_STATUSES = ${JSON.stringify(BAD_STATE_STATUSES)};
4491
+ // 登録直後の一時表示(#523 決定6)。**この定数行を落とすと、流し込んだ deriveCapTile が
4492
+ // 未定義を参照して画面のスクリプトが丸ごと死ぬ**(定数は export できないので値はここで注入)
4493
+ var STATE_CONFIRMING_TEXT = ${JSON.stringify(STATE_CONFIRMING_TEXT)};
4304
4494
  ${keyMissingText.toString()}
4305
4495
  ${deriveCapTile.toString()}
4306
4496
  // 案件カレンダー(#428)。**判定の実体はビルド時の定義**——画面側に書き写すと、
@@ -6176,6 +6366,30 @@ ${calendarAddUrl.toString()}
6176
6366
  cls: "ds-gate-muted",
6177
6367
  help: "管理者の設定が済むと読み取りが始まります。",
6178
6368
  },
6369
+ // HTTP 403(#521)。アプリはスペースに追加済みだが、**顧客の Google Workspace 管理者**が
6370
+ // Marketplace からの管理者インストールをまだ済ませていない。not_in_space(スペースの
6371
+ // メンバーが直せる)にも no_credentials(艦隊側の鍵が無い=顧客に打つ手が無い)にも
6372
+ // 寄せない——直せるのが「顧客の管理者」という、どちらとも別の人・別の操作だから。
6373
+ // **機械語(permission_denied)は出さない**(#521 で分類できたので、もう「不明」ではない)。
6374
+ //
6375
+ // 依頼文(手順つき)は長いので details に畳む。**help がそのまま summary の文になる**
6376
+ // (gateHelpNodes・#521)——「帯は症状・行は一手」の1文は details の外に出さなくても
6377
+ // summary は常に見えるので、開かなくても次の一手は読める。行の補足は最大1段の約束
6378
+ // (github-state-reason.test.mjs)を守るため、help と request を別々の要素にしない
6379
+ "admin_approval_pending": {
6380
+ text: "管理者の承認待ち",
6381
+ cls: "ds-gate-warn",
6382
+ help: "貴社の Google Workspace 管理者がこのアプリを承認すると、読み取りが始まります。",
6383
+ request: {
6384
+ lines: [
6385
+ "貴社の Google Workspace 管理者に、次の手順で「Cortex by Classmethod」の利用承認をご依頼ください。",
6386
+ "1. Google 管理コンソール(admin.google.com)を開く",
6387
+ "2. 「アプリ」→「Google Workspace Marketplace アプリ」を開く",
6388
+ "3. 「アプリを追加」から「Cortex by Classmethod」を検索する",
6389
+ "4. 組織全体(または該当の組織部門)へのインストールを選び、データへのアクセスを許可する",
6390
+ ],
6391
+ },
6392
+ },
6179
6393
  // --- ここから Microsoft 365(#335)。**値の正本は infra 側の語彙表**
6180
6394
  // (slack-gate.mjs の SLACK_GATE_VALUES と connection-state-keys.mjs の
6181
6395
  // REACHABLE_STATUSES)で、1対1の一致を infra のテストと
@@ -6459,6 +6673,77 @@ ${calendarAddUrl.toString()}
6459
6673
  if (s.gate == null || s.gate === "") return DS_GATE_PENDING;
6460
6674
  return DS_GATE[s.gate] || DS_GATE["unknown"];
6461
6675
  }
6676
+ /* ---------------------------------------------------------------------------
6677
+ * 取り込む能力の接続先の行に出す「届くか」(#523 S1・決定2)
6678
+ *
6679
+ * Backlog の接続先・Drive のフォルダ・Figma のファイルにも、Slack の行と同じ
6680
+ * **札と「いつ確かめたか」**を出す。語は 「DS_GATE」 が正本で、**能力ごとに直す操作が
6681
+ * 違うものだけ**ここで言い換える:
6682
+ * - Drive の 「unreachable」 … 「アクセス不可」では何をすればよいか分からない。
6683
+ * 直すのは**フォルダ側の共有**(取り込み用アカウントの招待)
6684
+ * - 鍵が無い(「no_token」)… 画面に鍵の入力欄がある能力(Backlog / Figma)は
6685
+ * 鍵カードと同じ語(「keyMissingText」 と揃える)。Drive の資格情報は艦隊側にあり、
6686
+ * 案件の人には打つ手が無いので「管理者の設定が必要です」(決定3と同じ振り分け)
6687
+ *
6688
+ * 🔴 **「DS_GATE」 本体に足さない。** あちらは**実測が返す語彙**と1対1で、
6689
+ * 「state-vocab-parity.test.mjs」 が見張っている(足し忘れた判定が「不明」に落ちる事故の番人)。
6690
+ * 能力ごとの言い換えは語彙ではないので、同じ表に混ぜるとその番人が意味を失う
6691
+ * (「DS_GATE_NO_TOKEN」 / 「DS_GATE_RESTING_HELP」 と同じ判断)。
6692
+ *
6693
+ * **バッジは短語・次の一手は help に分ける**(バッジに長文を入れると行が折り返す)。
6694
+ * ------------------------------------------------------------------------- */
6695
+ var DS_GATE_CAP = {
6696
+ materials: {
6697
+ unreachable: {
6698
+ text: "フォルダを開けません",
6699
+ cls: "ds-gate-warn",
6700
+ help: DRIVE_INGEST_ACCOUNT + " を閲覧者として招待してください(招待が外れると読めません)。",
6701
+ },
6702
+ no_token: { text: "管理者の設定が必要です", cls: "ds-gate-muted" },
6703
+ },
6704
+ issues: { no_token: { text: "APIキーが未登録です", cls: "ds-gate-muted" } },
6705
+ design: { no_token: { text: "トークンが未登録です", cls: "ds-gate-muted" } },
6706
+ };
6707
+ /**
6708
+ * その能力の行に出す「届くか」の語。**能力ごとの言い換えが無ければ 「DS_GATE」 のまま。**
6709
+ * 知らない値は 「DS_GATE」 と同じく「不明」(engine が新しい語を足したのに画面が知らない、
6710
+ * という事実を黙って消さない)。
6711
+ */
6712
+ function dsGateForCap(capId, status) {
6713
+ var byCap = DS_GATE_CAP[capId];
6714
+ if (byCap && byCap[status]) return byCap[status];
6715
+ return DS_GATE[status] || DS_GATE["unknown"];
6716
+ }
6717
+ /**
6718
+ * dsGateOf が持つ「次の一手」を、**必ず1個の DOM ノード**にする。呼び出しは3箇所
6719
+ * (行の常時表示・「接続を確認」を押した結果・取り込む能力の行の「届くか」)で、
6720
+ * gate.help / gate.request が正本——ここを通さずに文を書き写すと、片方だけ古くなる
6721
+ * (DS_GATE の項と同じ理由)。
6722
+ *
6723
+ * 🔴 **要素を2つに割らない(行の補足は最大1段・github-state-reason.test.mjs
6724
+ * 「行の補足は最大1段」)。** gate.request(依頼文。手順が複数行あるもの)があるときは、
6725
+ * **help の1文を details の summary にする**——summary は開閉と無関係に常に見えるので、
6726
+ * 「帯は症状・行は一手」は畳まなくても読め、依頼文の手順だけが details の中に畳まれる
6727
+ * (#521・オーナー指摘「繰り返さない」)。help だけの p と request だけの details を
6728
+ * 別々に足すと、同じ ds-gate-help が2つ並んで上の番人に落ちる。
6729
+ *
6730
+ * @param cls 呼び出し側の文脈を表す class(行は「ds-gate-help」、押した結果は
6731
+ * 「ds-probe-help」)。**「ds-gate-help」を付けた要素だけが、押した瞬間に隠される**
6732
+ * (下の probeSection の stale 処理)ので、混同すると二重表示か消え忘れが起きる
6733
+ */
6734
+ function gateHelpNodes(gate, cls) {
6735
+ if (gate.request) {
6736
+ var det = el("details", { class: cls + " ds-gate-request" }, [
6737
+ el("summary", { text: gate.help || "詳しい手順" }),
6738
+ ]);
6739
+ gate.request.lines.forEach(function (line) {
6740
+ det.appendChild(el("p", { class: "ds-gate-request-line", text: line }));
6741
+ });
6742
+ return [det];
6743
+ }
6744
+ if (gate.help) return [el("p", { class: cls, text: gate.help })];
6745
+ return [];
6746
+ }
6462
6747
  /**
6463
6748
  * 一覧・タイルに出す「⚠️ …」の要約。**呼び出しは2箇所(グループの summary と能力タイル)。**
6464
6749
  * 写経すると片方だけ古くなり、同じ画面で語が割れる(この節では実際に起きた)。
@@ -8040,15 +8325,114 @@ ${calendarAddUrl.toString()}
8040
8325
  return registeredCountOf(derCap, adapter) !== null;
8041
8326
  }
8042
8327
 
8328
+ /**
8329
+ * その能力の「登録簿にある ref」の鍵(#523 決定2)。**読めなければ null。**
8330
+ *
8331
+ * 🔴 **null を空の表に畳まないこと。** 空の表を渡すと「どの行も登録簿に無い」と
8332
+ * 判定され、実測が丸ごと捨てられる——**夜間が⚠️を書いているのにタイルが静か**という、
8333
+ * いま直そうとしている嘘と同じ形になる。
8334
+ *
8335
+ * 仮置き(#388)も勘案する: add した ref は登録簿に載る前から有効・remove した ref は
8336
+ * 載っていても無効(決定2「pending の 「remove-*」 も除外」)。一覧・件数と同じ材料を
8337
+ * 使うので、「行は消えたのにタイルだけ⚠️」が起きない。
8338
+ *
8339
+ * ⚠ **チャットの add だけは、登録簿が追いつくまでの数分、篩い過ぎる余地がある。**
8340
+ * 解除・追加の payload はワークスペースを持たない(どのワークスペースかはサーバが実測して
8341
+ * 決める)ので、仮置きが入れられる鍵はワークスペース無しの1本だけ。**以前に登録していた
8342
+ * チャンネルを再登録した直後**は、ストアに残った古い行(ワークスペース付き)がこの鍵に
8343
+ * 当たらず、数分だけタイルが黙る(登録簿が追いつけば直る)。倒す向きは
8344
+ * 「黙る」側で揃えてある——逆(測っていないものを⚠️にする)は直し方を示せない嘘になる。
8345
+ */
8346
+ function refKeysOf(derCap) {
8347
+ var meta = TILE_CAPS[derCap];
8348
+ if (!meta || !meta.refKeyed || !LIVE_REGISTERED) return null;
8349
+ // チャットの混載タイル(アダプタ不明)は chat と google-chat の両方を抱えるが、
8350
+ // 鍵は能力(meta.id)ごとに引くので、ここで合算するものは無い
8351
+ var keys = registeredRefKeys(meta.id, LIVE_REGISTERED[meta.id]);
8352
+ if (!keys) return null;
8353
+ var ops = pendingOpsNow();
8354
+ for (var i = 0; i < ops.length; i++) {
8355
+ var e = ops[i];
8356
+ var km = e && PENDING_KINDS[e.kind];
8357
+ if (!km || km.reg !== meta.id) continue;
8358
+ // 仮置きの鍵(targetId)は登録簿と同じ畳み方(backlog は pendingIssuesRef=issuesRefOf)。
8359
+ // チャットだけワークスペースを持たない(どのワークスペースかはサーバが実測して決める)
8360
+ var key = meta.id === "chat" ? "\t" + e.targetId : e.targetId;
8361
+ if (e.action === "add") keys[key] = true;
8362
+ else if (e.action === "remove") {
8363
+ delete keys[key];
8364
+ // 🔴 **チャットは鍵が2本ある**(「ws + \t + ref」 と 「\t + ref」)。ワークスペース無しの
8365
+ // 1本だけ消すと、**一覧から行が消えたのにタイルだけ⚠️が残る**(解除した顧客
8366
+ // ワークスペースのチャンネルの実測が孤児として落ちない・実装レビュー🟡2)。
8367
+ // 解除の payload はワークスペースを持たないので、接尾一致で両方消す
8368
+ if (meta.id === "chat") {
8369
+ for (var k in keys) {
8370
+ if (!Object.prototype.hasOwnProperty.call(keys, k)) continue;
8371
+ if (k.slice(-key.length) === key) delete keys[k];
8372
+ }
8373
+ }
8374
+ }
8375
+ }
8376
+ return keys;
8377
+ }
8378
+
8379
+ /**
8380
+ * 登録したばかりで、まだ実測の行が1件も届いていない接続先があるか(#523 決定6)。
8381
+ *
8382
+ * **対象は取り込む4能力だけ**(「alsoBad」 を持つ能力=規則4の文言が
8383
+ * 「まだ取り込みがありません」の系)。チャット・開発の規則4は既に
8384
+ * 「接続は未確認です」と言っており、待っていることが読める。
8385
+ *
8386
+ * 上限は 「UNSETTLED_TTL_MS」(15分)。過ぎたら中立文に戻る——**待てば出ると言い続けない**。
8387
+ */
8388
+ function capConfirming(derCap) {
8389
+ var meta = TILE_CAPS[derCap];
8390
+ if (!meta || !meta.alsoBad) return false;
8391
+ var ops = pendingOpsNow();
8392
+ var now = Date.now();
8393
+ for (var i = 0; i < ops.length; i++) {
8394
+ var e = ops[i];
8395
+ var km = e && PENDING_KINDS[e.kind];
8396
+ if (!km || e.action !== "add" || km.reg !== meta.id) continue;
8397
+ if (!(now - e.committedAt < UNSETTLED_TTL_MS)) continue;
8398
+ if (stateRowSeenFor(meta.id, e.targetId)) continue;
8399
+ return true;
8400
+ }
8401
+ return false;
8402
+ }
8403
+
8404
+ /** その接続先について、ストアの行が1件でも届いているか(問いは問わない・capConfirming の材料) */
8405
+ function stateRowSeenFor(capId, targetId) {
8406
+ var rows = LIVE_STATE || [];
8407
+ for (var i = 0; i < rows.length; i++) {
8408
+ var r = rows[i];
8409
+ if (!r || r.cap !== capId) continue;
8410
+ // 仮置きの鍵はワークスペースを持たないので、ここは ref の生値で比べる
8411
+ // (issues だけは両側を issuesRefOf に畳む=pendingIssuesRef と同形)
8412
+ var k = capId === "issues" ? stateRefKey(capId, r.ref) : String(r.ref || "");
8413
+ if (k === targetId) return true;
8414
+ }
8415
+ return false;
8416
+ }
8417
+
8418
+ /**
8419
+ * 導出に使う鍵(「能力」または「能力-アダプタ」)。**アダプタが分かるタイルは割る**
8420
+ * (#153・判断⑲)——割らないと、Slack の登録件数・実測で Google Chat タイルが
8421
+ * 「接続OK」を名乗る(逆も)。「能力-アダプタ」の変種は TILE_CAPS が持つ。
8422
+ * **この解決を書き写さないこと**(写しがずれると、タイルと最終同期で別の能力を見る)
8423
+ */
8424
+ function derCapOf(cap, adapter) {
8425
+ return adapter && TILE_CAPS[cap + "-" + adapter] ? cap + "-" + adapter : cap;
8426
+ }
8427
+
8043
8428
  function capDerivation(cap, adapter) {
8044
- // **アダプタが分かるタイルは、そのアダプタの名簿・実測だけで導出する(#153・判断⑲)。**
8045
- // 割らないと、Slack の登録件数・実測で Google Chat タイルが「接続OK」を名乗る(逆も)。
8046
- // 「能力-アダプタ」の変種は TILE_CAPS が持つ(無いアダプタは能力単位のまま)
8047
- var derCap = adapter && TILE_CAPS[cap + "-" + adapter] ? cap + "-" + adapter : cap;
8429
+ var derCap = derCapOf(cap, adapter);
8048
8430
  return deriveCapTile(derCap, {
8049
8431
  registered: registeredCountOf(derCap, adapter),
8050
8432
  seen: LIVE_STATE_SEEN,
8051
8433
  rows: LIVE_STATE,
8434
+ refs: refKeysOf(derCap),
8435
+ confirming: capConfirming(derCap),
8052
8436
  });
8053
8437
  }
8054
8438
 
@@ -8432,6 +8816,26 @@ ${calendarAddUrl.toString()}
8432
8816
  */
8433
8817
  var KEY_SLOTS = { backlog: {}, backlogNamed: false, figma: null };
8434
8818
 
8819
+ /**
8820
+ * まとめ表の「最終同期」を後詰めに使ってよいか(#523 決定1・実装レビュー🔴1)。
8821
+ *
8822
+ * 🔴 **ストアを読めた回は使わない。** まとめ表の値がストア由来
8823
+ * (「lastSyncSource: "ingested"」)でも、engine 側(「materialsLastOkAtOf」)は
8824
+ * **登録簿で篩っていない**ので、
8825
+ * それは**外したフォルダの時刻でありうる**。画面がストア側で孤児行を落としたそばから、
8826
+ * 篩っていない同じ値をまとめ表から拾い直すと、#314 の「最終同期 9/8 18:34」がそのまま
8827
+ * 復活する(実装レビューで実測)。
8828
+ *
8829
+ * だから後詰めは **「ストアが読めなかった回の代用」** に限る——古い Lambda・ストアの
8830
+ * 読み取り失敗・ライブを持たない案件(焼いた HTML のまま見ている状態)。
8831
+ * そこでは比べる相手が無いので、まとめ表が唯一かつ最良の答えになる。
8832
+ * engine 側の篩は S5(「fleet-status.mjs」 の後始末)で入れる。
8833
+ */
8834
+ function fleetSyncFallback(internal) {
8835
+ if (LIVE_STATE_SEEN) return "";
8836
+ return fleetLastSyncOf(internal);
8837
+ }
8838
+
8435
8839
  function buildDataSources() {
8436
8840
  KEY_SLOTS = { backlog: {}, backlogNamed: false, figma: null };
8437
8841
  // 鍵カードも受け皿と同じく作り直す(前回の切り離されたカードを掴んだままにしない)
@@ -8523,6 +8927,22 @@ ${calendarAddUrl.toString()}
8523
8927
  capBox[cap].push({ node: node, adapter: adapter || "" });
8524
8928
  }
8525
8929
 
8930
+ /**
8931
+ * 能力ブロックの「最終同期」(#523 決定1)。**出どころはストアが先・まとめ表は後詰め。**
8932
+ *
8933
+ * タイルの2行目(tileSub)と同じ材料で、同じ順に引く(「fleetSyncFallback」 が
8934
+ * 「後詰めを使ってよいか」の判定を1か所に持つ)。
8935
+ *
8936
+ * 出せないときは要素ごと出さない(「最終同期: —」のような空の行を置かない)。
8937
+ */
8938
+ function capBlockSyncEl(s, inactive) {
8939
+ if (inactive || !s || !s.kind) return null;
8940
+ var capId = capIdOf(s.kind);
8941
+ var at = capId ? ingestedAtOf(LIVE_STATE, capId, refKeysOf(derCapOf(s.kind, ""))) : "";
8942
+ var t = dsFmtTime(at || fleetSyncFallback(s) || "");
8943
+ return t ? el("span", { class: "ds-sync", text: "最終同期: " + t }) : null;
8944
+ }
8945
+
8526
8946
  if (internals.length) {
8527
8947
  // **「いま同期する」ボタンは出さない。** 受け口(Apps Script の Webアプリ)を
8528
8948
  // 公開できないため、押しても届かない。
@@ -8590,9 +9010,11 @@ ${calendarAddUrl.toString()}
8590
9010
  "data-cap": s.kind || "",
8591
9011
  }, [
8592
9012
  col,
8593
- !inactive && s.lastSync && dsFmtTime(s.lastSync)
8594
- ? el("span", { class: "ds-sync", text: "最終同期: " + dsFmtTime(s.lastSync) })
8595
- : null,
9013
+ // 🔴 **出どころはストアが先・まとめ表は後詰め(#523 決定1)。** まとめ表の
9014
+ // 「lastSync」 には run の成功時刻(空振りを含む)が混ざるので、「fleetLastSyncOf」
9015
+ // ストア由来のものだけを通す。タイルの「最終同期」と**同じ材料**にする
9016
+ // ——別々に引くと、同じ画面の2箇所が違う時刻を「最終同期」と呼ぶ
9017
+ capBlockSyncEl(s, inactive),
8596
9018
  ]);
8597
9019
  // 会議・共有資料・デザインの操作をブロック内に置く(その情報源の文脈で押せるように)
8598
9020
  var ctl = internalControls(s, inactive);
@@ -9136,10 +9558,20 @@ ${calendarAddUrl.toString()}
9136
9558
  // 「internalSources.lastSync」 は run 全体の最終成功時刻で、**どの接続先のことか
9137
9559
  // 分からない**(鍵が無くて1件も取り込めていない run でも「成功」で終わる)。
9138
9560
  // ストアの行は「その能力の接続先が実際に取り込めた事実」なので、同じ「最終同期」の
9139
- // 位置に出すならこちらが正しい。行が無ければ従来どおり run の時刻
9561
+ // 位置に出すならこちらが正しい。
9562
+ //
9563
+ // 🔴 **まとめ表の後詰めは「ストアが読めなかった回」だけ(#523 決定1・「fleetSyncFallback」)。**
9564
+ // run 由来は常に捨て、ストア由来でも**ストアを読めた回は使わない**(engine 側は
9565
+ // まだ登録簿で篩っていないので、孤児行の時刻が混ざりうる)。出せないときは何も
9566
+ // 言わない——2行目の語が「まだ取り込みがありません」と既に答えている。
9567
+ // 孤児行(外した接続先の残骸)の時刻も数えない(決定2・refs を渡す)
9140
9568
  if (st !== "off" && st !== "idle") {
9141
- var ingestedAt = ingestedAtOf(LIVE_STATE, capIdOf(cap));
9142
- var t = dsFmtTime(ingestedAt || (internal && internal.lastSync) || "");
9569
+ // 🔴 能力名は導出と同じ鍵から引く(「capIdOf」 はアダプタを見ないので、
9570
+ // google-chat のタイルで「件数は gc・時刻は chat」という食い違いができる)
9571
+ var derCapForId = derCapOf(cap, adapter);
9572
+ var stateCapId = (TILE_CAPS[derCapForId] && TILE_CAPS[derCapForId].id) || capIdOf(cap);
9573
+ var ingestedAt = ingestedAtOf(LIVE_STATE, stateCapId, refKeysOf(derCapForId));
9574
+ var t = dsFmtTime(ingestedAt || fleetSyncFallback(internal) || "");
9143
9575
  if (t) parts.push("最終同期 " + t);
9144
9576
  }
9145
9577
  return parts.join(" ");
@@ -9629,18 +10061,21 @@ ${calendarAddUrl.toString()}
9629
10061
  * 行の常時表示(DS_GATE の 「help」)が既に持っている——写経すると、同じ行の常時表示と
9630
10062
  * 押した結果で語が割れる(この節が何度も直してきた形)。DS_GATE から引いて1文だけ出す。
9631
10063
  *
9632
- * ⚠ **403(Workspace 管理者の承認待ち)は Phase 0 の採取まで 「unknown」。** その回は
9633
- * サーバの機械語(「permission_denied」)をそのまま出す。**「時間をおいて」と言わない**
9634
- * ——待っても直らない(管理者が承認するまで同じ応答が返る)。
10064
+ * ⚠ **403(Workspace 管理者の承認待ち)は #521 で「admin_approval_pending」に分類済み。**
10065
+ * 機械語(「permission_denied」)はもう出さない。**「時間をおいて」とも言わない**
10066
+ * ——待っても直らない(管理者が承認するまで同じ応答が返る)。401 / 429 は依然
10067
+ * Phase 0 未採取で「unknown」のまま、サーバの機械語をそのまま出す。
9635
10068
  */
9636
10069
  function googleChatProbeHelpNodes(s, r) {
9637
- var p = function (t) { return el("p", { class: "ds-probe-help", text: t }); };
9638
10070
  // 成功。**1行で終わり**(状態の語が既に「接続OK」と出ている)
9639
10071
  if (r.gate === "ok") return [];
9640
- // not_in_space / no_credentials は行の常時表示と同じ1文(DS_GATE から引く)。
10072
+ // not_in_space / no_credentials / admin_approval_pending は行の常時表示と同じ文面
10073
+ // (DS_GATE から引く。依頼文の details も gateHelpNodes 経由で同じものが出る)。
9641
10074
  // 押した瞬間に行側の 「.ds-gate-help」 は隠れるので、二重には出ない
9642
- var help = dsGateOf({ type: s.type, gate: r.gate }).help;
9643
- if (help) return [p(help)];
10075
+ var gate = dsGateOf({ type: s.type, gate: r.gate });
10076
+ var nodes = gateHelpNodes(gate, "ds-probe-help");
10077
+ if (nodes.length) return nodes;
10078
+ var p = function (t) { return el("p", { class: "ds-probe-help", text: t }); };
9644
10079
  if (r.gate === "unreachable") {
9645
10080
  // 帯(「アクセス不可」)が症状を言っている。**ここは一手だけ**
9646
10081
  return [p("時間をおいて、もう一度「接続を確認」を押してください。")];
@@ -10350,6 +10785,87 @@ ${calendarAddUrl.toString()}
10350
10785
  });
10351
10786
  }
10352
10787
 
10788
+ /**
10789
+ * その接続先の「届くか」の行(接続状態ストア・#523 S1)。**無ければ null。**
10790
+ *
10791
+ * 🔴 **突き合わせは 「stateRefKey」 を通す(両側とも)。** タイルの孤児行の篩と同じ
10792
+ * 畳み方にしておかないと、「タイルは⚠️なのに、どの行にも札が付かない」が起きる
10793
+ * ——どの接続先の話なのかが画面から分からない状態に戻る。
10794
+ *
10795
+ * **行が無ければ何も出さない**(「ingestedEl」 と同じ原則)。測っていないことを
10796
+ * 「読めていない」と書くと、直す必要があると読ませる嘘になる。
10797
+ */
10798
+ function reachableStateOf(capId, ref, workspace) {
10799
+ var rows = LIVE_STATE;
10800
+ if (!rows || !rows.length || !capId) return null;
10801
+ var want = stateRefKey(capId, ref, workspace);
10802
+ if (!want) return null;
10803
+ for (var i = 0; i < rows.length; i++) {
10804
+ var r = rows[i];
10805
+ if (!r || r.cap !== capId || r.question !== "reachable") continue;
10806
+ if (stateRefKey(capId, r.ref, r.workspace) === want) return r;
10807
+ }
10808
+ return null;
10809
+ }
10810
+
10811
+ /**
10812
+ * 接続先1件の「届くか」の札と「いつ確かめたか」を行に足す(#523 S1・決定2)。
10813
+ * **Slack の行と同じ見た目**(「.ds-gate」 + 「.ds-gate-at」)。
10814
+ *
10815
+ * 🔴 **次の一手・理由(「.ds-gate-help」)はここで足さない。** あれは 「flex: 1 1 100%」 で
10816
+ * 行を占めるので、行の途中に挟むと**ID と「この◯◯を外す」が案内文の下の行へ落ちる**
10817
+ * (Slack の行が 「gateHelpNodes」 をボタンより後ろに置いているのと同じ理由・実装レビュー🟡3)。
10818
+ * 行の組み立ての最後に 「appendReachableReason」 を呼ぶ。
10819
+ */
10820
+ function appendReachableChip(row, capId, ref, workspace) {
10821
+ var r = reachableStateOf(capId, ref, workspace);
10822
+ if (!r) return row;
10823
+ var gate = dsGateForCap(capId, r.status);
10824
+ row.appendChild(el("span", { class: "ds-gate " + gate.cls, text: gate.text }));
10825
+ var when = rtAgo(r.at);
10826
+ if (when) row.appendChild(el("span", { class: "ds-gate-at", text: when + "に確認" }));
10827
+ return row;
10828
+ }
10829
+
10830
+ /**
10831
+ * 「届くか」の次の一手か理由を、**行の最後に1段だけ**足す(#523 S1・決定2)。
10832
+ *
10833
+ * 🔴 **理由(「detail」)は社内ビューアにしか出ない。** この値の出どころは 「op=state」
10834
+ * (社内の操作タブ)だけで、顧客射影(engine が焼く fleet-status.json)には載らない。
10835
+ * ここで「顧客にも出る」前提の文を書かないこと(決定2)。
10836
+ *
10837
+ * **語そのものが次の一手を示している状態には、理由を重ねない**(「showsRestingReason」 と
10838
+ * 同じ振り分け——「ok」 / 「no_token」 / 「missing_scope」 / 「not_in_channel」)。
10839
+ * 重ねると「管理者の設定が必要です」の下に「(管理者の設定が必要です)」が並ぶ
10840
+ * (実装レビュー🟡4。規約「帯は症状・行は一手・繰り返さない」)。
10841
+ *
10842
+ * **書き手が button の detail は出さない**(「stateReasonOf」 の項——ボタンは判定の語
10843
+ * そのものや生のエラーを detail に書く。人向けの文は needed が持つ)。
10844
+ */
10845
+ function appendReachableReason(row, capId, ref, workspace) {
10846
+ var r = reachableStateOf(capId, ref, workspace);
10847
+ if (!r) return row;
10848
+ var gate = dsGateForCap(capId, r.status);
10849
+ // 次の一手を持つ語(Drive の共有外れ等)は、その1文だけ
10850
+ if (gate.help || gate.request) {
10851
+ gateHelpNodes(gate, "ds-gate-help").forEach(function (n) { row.appendChild(n); });
10852
+ return row;
10853
+ }
10854
+ if (r.status === "ok" || r.status === "no_token" || r.status === "missing_scope" || r.status === "not_in_channel") return row;
10855
+ var reason = stateReasonOf({ needed: r.needed, detail: r.detail, gateBy: r.by });
10856
+ // needed は Slack の行と同じ部品を通す(URL がリンクになる・スコープ名がラベル化される)
10857
+ if (reason) row.appendChild(r.needed ? neededNode(reason, "ds-gate-help") : el("p", { class: "ds-gate-help", text: reason }));
10858
+ return row;
10859
+ }
10860
+
10861
+ /** 一覧に出ている接続先の「届くか」をひとつの文字列にする(描き直しの要否の判定用) */
10862
+ function reachSigOf(capId, refs) {
10863
+ return (refs || []).map(function (ref) {
10864
+ var r = reachableStateOf(capId, ref && ref.ref != null ? ref.ref : ref, ref && ref.workspace);
10865
+ return r ? r.status + "@" + r.at : "-";
10866
+ }).join(",");
10867
+ }
10868
+
10353
10869
  /**
10354
10870
  * いま一覧に出ている接続先の「取り込み」をひとつの文字列にする(差し替えの要否の判定用)。
10355
10871
  *
@@ -10366,9 +10882,13 @@ ${calendarAddUrl.toString()}
10366
10882
  return r ? r.status + "@" + r.at : "-";
10367
10883
  }).join(",");
10368
10884
  }
10369
- /** 取り直しで描き直すかの署名: 取り込みの状態+仮置きの印(#388・印だけ残さないため) */
10885
+ /**
10886
+ * 取り直しで描き直すかの署名: 取り込みの状態+仮置きの印(#388・印だけ残さないため)
10887
+ * +**「届くか」の状態**(#523 S1——混ぜないと、ストアの reachable が届いても札が付かない)
10888
+ */
10370
10889
  function backlogPaintSigOf(list) {
10371
- return ingestSigOf(list) + "|" + (list || []).map(function (t) { return t && t.pending === true ? "p" : "-"; }).join(",");
10890
+ return ingestSigOf(list) + "|" + (list || []).map(function (t) { return t && t.pending === true ? "p" : "-"; }).join(",")
10891
+ + "|" + reachSigOf("issues", (list || []).map(function (t) { return issuesRefOf(t.domain, t.projectKey); }));
10372
10892
  }
10373
10893
 
10374
10894
  /**
@@ -10409,7 +10929,9 @@ ${calendarAddUrl.toString()}
10409
10929
  return (list || []).map(function (f) {
10410
10930
  var r = designIngestedStateOf(f && f.key);
10411
10931
  return r ? r.status + "@" + r.at : "-";
10412
- }).join(",");
10932
+ }).join(",")
10933
+ // 「届くか」も混ぜる(#523 S1。混ぜないと、ストアの reachable が届いても札が付かない)
10934
+ + "|" + reachSigOf("design", (list || []).map(function (f) { return f && f.key; }));
10413
10935
  }
10414
10936
 
10415
10937
  /**
@@ -10458,7 +10980,12 @@ ${calendarAddUrl.toString()}
10458
10980
  // この接続先の取り込みが最後にどうなったか(段C)。測っていなければ null
10459
10981
  ingestedEl(ingestedStateOf(t.domain, t.projectKey), "課題管理"),
10460
10982
  ]);
10461
- if (!editable) return row;
10983
+ // この接続先に届くか(#523 S1)。**「取り込み」とは別の問い**なので併記する
10984
+ // ——鍵はあるが 403 でプロジェクトが見えない、は取り込みの失敗より前の話。
10985
+ // 理由の1段は**行の最後**(ボタンより後ろ)に置く(appendReachableReason の項)
10986
+ var issuesRef = issuesRefOf(t.domain, t.projectKey);
10987
+ appendReachableChip(row, "issues", issuesRef);
10988
+ if (!editable) return appendReachableReason(row, "issues", issuesRef);
10462
10989
  var btn = el("button", { class: "ds-toggle-btn ds-danger", type: "button", text: "この接続先を外す" });
10463
10990
  btn.addEventListener("click", async function () {
10464
10991
  // **最後の1件かどうかは「押した時点」で数える。** 描画時の件数で決めると、
@@ -10478,7 +11005,7 @@ ${calendarAddUrl.toString()}
10478
11005
  sendConfig(payload, msg, btn, null, targetsHooks());
10479
11006
  });
10480
11007
  row.appendChild(btn);
10481
- return row;
11008
+ return appendReachableReason(row, "issues", issuesRef);
10482
11009
  }
10483
11010
 
10484
11011
  /**
@@ -10894,7 +11421,9 @@ ${calendarAddUrl.toString()}
10894
11421
  var d = driveNow();
10895
11422
  // 仮置きの印(drivePending)も署名に入れる——外れたときに印だけ残さない(#388)
10896
11423
  // 「確かめられたか」も混ぜる(#409。混ぜないと注記の要否が変わっても作り直されない)
10897
- return JSON.stringify([d.driveFolderIds || [], d.driveFolderNames || {}, d.driveState || "", d.drivePending || {}, registrationUnreadable()]);
11424
+ // 「届くか」も混ぜる(#523 S1。混ぜないと、ストアの reachable が届いても札が付かない)
11425
+ return JSON.stringify([d.driveFolderIds || [], d.driveFolderNames || {}, d.driveState || "", d.drivePending || {}, registrationUnreadable(),
11426
+ reachSigOf("materials", d.driveFolderIds || [])]);
10898
11427
  }, function (listBox) {
10899
11428
  var src = driveNow();
10900
11429
  var folders = src.driveFolderIds || [];
@@ -10922,6 +11451,10 @@ ${calendarAddUrl.toString()}
10922
11451
  ? { pending: true, pendingAt: src.drivePending[id] }
10923
11452
  : null),
10924
11453
  ]);
11454
+ // このフォルダに届くか(#523 S1)。engine の同期ジョブが毎回書いているのに
11455
+ // **これまで画面に出す場所が1つも無かった**(共有が外れたことを、取り込みが
11456
+ // 失敗するまで言えなかった)
11457
+ appendReachableChip(row, "materials", id);
10925
11458
  // **名前を出したときはIDも併記する。** 同名のフォルダを見分けられなくなるのと、
10926
11459
  // 「このIDを外す」という確認文と画面の表示が食い違うのを防ぐ
10927
11460
  if (fname) row.appendChild(el("span", { class: "ds-sync", text: id }));
@@ -10944,6 +11477,8 @@ ${calendarAddUrl.toString()}
10944
11477
  // **クラスのハード代入をやめる**(片方だけ直し忘れる形を構造的に消す)
10945
11478
  onDone: function () { row.classList.add("ds-removed"); },
10946
11479
  }));
11480
+ // 理由の1段は行の最後(ID と「このフォルダを外す」の後ろ・appendReachableReason の項)
11481
+ appendReachableReason(row, "materials", id);
10947
11482
  dlist.appendChild(row);
10948
11483
  });
10949
11484
  listBox.appendChild(dlist);
@@ -11072,6 +11607,9 @@ ${calendarAddUrl.toString()}
11072
11607
  // このファイルの取り込みが最後にどうなったか(段C)。測っていなければ null
11073
11608
  ingestedEl(designIngestedStateOf(f.key), "デザイン"),
11074
11609
  ]);
11610
+ // このファイルに届くか(#523 S1)。書き手は S4(いまは行が無いので何も出ない)。
11611
+ // 理由の1段は行の最後に置く(appendReachableReason の項)
11612
+ appendReachableChip(row, "design", f.key);
11075
11613
  row.appendChild(removeButton({
11076
11614
  label: "このファイルを外す",
11077
11615
  confirmLines: function () {
@@ -11105,6 +11643,7 @@ ${calendarAddUrl.toString()}
11105
11643
  else delete figSending[f.key];
11106
11644
  },
11107
11645
  }));
11646
+ appendReachableReason(row, "design", f.key);
11108
11647
  // 描き直しのあとも「外した」ことが分かる形に戻す(figsRemoved の項に経緯)
11109
11648
  if (figsRemoved.indexOf(f.key) >= 0) {
11110
11649
  row.classList.add("ds-removed");
@@ -12174,7 +12713,7 @@ ${calendarAddUrl.toString()}
12174
12713
  //
12175
12714
  // **ボタンより後ろに置く。** カードは横並びの flex なので、間に挟むと
12176
12715
  // 「登録を解除」が案内文の下の行へ落ちて、既存案件の行の形が変わる
12177
- if (gate.help) card.appendChild(el("p", { class: "ds-gate-help", text: gate.help }));
12716
+ gateHelpNodes(gate, "ds-gate-help").forEach(function (n) { card.appendChild(n); });
12178
12717
  // **最後に測った理由と直し方を、押す前から出す(#346)。** 「アクセス不可 8時間前に確認」
12179
12718
  // だけでは、押さない人(初見の PM 等)が止まる。表にエントリがある状態は定型の補足
12180
12719
  // (リンク込み)だけ、無い状態は needed → detail のフォールバック。どちらも ds-gate-help
@@ -13281,7 +13820,8 @@ ${calendarAddUrl.toString()}
13281
13820
  sync.removeAttribute("data-ts");
13282
13821
  // 接続OK のときだけ、ストア由来の取り込み時刻を添える(run の時刻ではない)。
13283
13822
  // 出せなければ語だけ——**繋がっていないものに同期時刻を並べない**(タイルと同じ規律)
13284
- var at = d.state === "ok" ? dsFmtTime(ingestedAtOf(LIVE_STATE, TILE_CAPS[cap].id)) : "";
13823
+ // 孤児行(外した接続先の残骸)の時刻は出さない(#523 決定2・タイルと同じ篩)
13824
+ var at = d.state === "ok" ? dsFmtTime(ingestedAtOf(LIVE_STATE, TILE_CAPS[cap].id, refKeysOf(cap))) : "";
13285
13825
  sync.textContent = at ? "最終同期 " + at : d.text;
13286
13826
  }
13287
13827
  var link = wrap.querySelector('.cm-link[data-cm-cap="' + cap + '"]');
@@ -13399,6 +13939,11 @@ ${calendarAddUrl.toString()}
13399
13939
  * 取れているときは図だけが古い。同じ画面で図が⚠️・直下が「接続OK」になる状態を
13400
13940
  * 黙って出さないよう、図がいつ時点かを明示する(図の再描画は別途)。
13401
13941
  * ライブが取れていないときは図と一覧が同じ時点なので、注記は出さない。
13942
+ *
13943
+ * 🔴 **「この図は◯時点」とは言わない(#523 決定5)。** ライブが届くと図の**状態**は
13944
+ * 当て直される(「applyStateToConnMap」)ので、古いのは**設定と件数だけ**。図ぜんたいを
13945
+ * 古いと言うと、訂正済みの状態まで疑わせる(逆に「最新」と言えば設定の古さが隠れる)。
13946
+ * 何が古くて何が最新かを、そのまま書く。
13402
13947
  */
13403
13948
  function updateConnMapAsOf() {
13404
13949
  var wrap = app.querySelector(".conn-map");
@@ -13408,7 +13953,7 @@ ${calendarAddUrl.toString()}
13408
13953
  if (!LIVE_FLEET || !SITE.fleetGeneratedAt) return;
13409
13954
  var mapAsOf = dsFmtTime(SITE.fleetGeneratedAt);
13410
13955
  if (!mapAsOf) return;
13411
- wrap.appendChild(el("p", { class: "conn-map-asof", text: "この図は " + mapAsOf + " 時点です(下の一覧は最新)。" }));
13956
+ wrap.appendChild(el("p", { class: "conn-map-asof", text: "設定と件数は " + mapAsOf + " 時点・接続状態は最新です。" }));
13412
13957
  }
13413
13958
  function opsCard(icon, title, desc, kind, btnLabel) {
13414
13959
  var btn = el("button", { class: "ops-card-btn", text: "+ " + btnLabel });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.47",
3
+ "version": "0.12.49",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,