@takagaki/cortex-decisions-viewer 0.4.68 → 0.4.70

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
@@ -260,11 +260,42 @@ async function readFleetSources(repoRoot) {
260
260
  if (!Object.keys(tools).length)
261
261
  tools = undefined;
262
262
  }
263
+ // 能力ごとの導出済みの状態(#125①が載せ、#125②で画面の正本になった)。
264
+ //
265
+ // **列挙し忘れは静かに消える。** このマッピングは素通しではなく明示の許可リストで、
266
+ // 落ちても例外も型エラーも出ない——画面は「capabilities が無い古いデータ」と解釈して
267
+ // 旧表示へ劣化するので、**壊れているようには見えない**。origin / figmaFiles /
268
+ // driveFolderIds で3回踏んでいる罠なので、実 build を通した配線テストで見張っている。
269
+ //
270
+ // **知らないフィールドは黙って捨てる。** engine が adapters[] / byTargets を足す余地が
271
+ // ある(#107 / #127)が、読み方を「知っているキーだけ」に留めておけば、増えた回に
272
+ // 画面が壊れることはない。使うようになった時点でここへ足す。
273
+ let capabilities;
274
+ if (obj.capabilities != null && typeof obj.capabilities === "object" && !Array.isArray(obj.capabilities)) {
275
+ const out = {};
276
+ for (const [cap, v] of Object.entries(obj.capabilities)) {
277
+ if (v == null || typeof v !== "object" || Array.isArray(v))
278
+ continue;
279
+ const c = v;
280
+ // **state が無い要素は通さない。** 状態を持たない能力を渡すと、画面が
281
+ // 「導出はあるが答えていない」という、どちらでもない状態を描くことになる
282
+ const state = c.state != null ? String(c.state) : "";
283
+ if (!state)
284
+ continue;
285
+ out[cap] = {
286
+ adapter: c.adapter != null ? String(c.adapter) : "",
287
+ state,
288
+ detail: c.detail != null ? String(c.detail) : undefined,
289
+ };
290
+ }
291
+ if (Object.keys(out).length)
292
+ capabilities = out;
293
+ }
263
294
  const fleetGeneratedAt = typeof obj.generatedAt === "string" ? obj.generatedAt : undefined;
264
295
  const secretSources = obj.secretSources && typeof obj.secretSources === "object"
265
296
  ? obj.secretSources
266
297
  : undefined;
267
- return { externalSources, internalSources, pipelines, tools, fleetGeneratedAt, secretSources, checks, githubOwners };
298
+ return { externalSources, internalSources, pipelines, tools, capabilities, fleetGeneratedAt, secretSources, checks, githubOwners };
268
299
  }
269
300
  /** パスがディレクトリとして存在するか */
270
301
  async function isDirectory(dirAbs) {
@@ -1145,6 +1176,7 @@ async function build(opts) {
1145
1176
  internalSources: fleet.internalSources,
1146
1177
  pipelines: fleet.pipelines,
1147
1178
  tools: fleet.tools,
1179
+ capabilities: fleet.capabilities,
1148
1180
  checks: fleet.checks,
1149
1181
  githubOwners: fleet.githubOwners,
1150
1182
  };
package/dist/render.js CHANGED
@@ -82,6 +82,47 @@ const ADAPTER_ICONS = {
82
82
  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>`,
83
83
  github: GITHUB_ICON,
84
84
  };
85
+ /**
86
+ * **アダプタのカタログ(能力→この製品に存在するアダプタ)。#125②**
87
+ *
88
+ * これは「この案件が何を使っているか」ではなく「製品として何があるか」の定義。
89
+ * 案件ごとの状態は engine が設定ファイルから導出して `capabilities` に載せる
90
+ * (宣言 `tools` は廃止した。宣言と実態は必ずずれる——配線ゼロの Teams が
91
+ * 「接続OK」と画面に出た #124 がその帰結)。
92
+ *
93
+ * **カタログに無いツール名は画面に一文字も出さない。** teams / jira / box / local は
94
+ * アダプタが存在しないので、名前を出した瞬間「対応している」と読まれる。
95
+ * 状態を出す場所すら無いのに名前だけ出るのが #124 の症状そのもの。
96
+ *
97
+ * **値は配列。** 今日はどの能力も1個だが、「能力=1アダプタ」を焼き込むと
98
+ * #107 / #127(複数アダプタ・複数 Backlog)で読み手の書き換えが要る。
99
+ * ロゴは上の ADAPTER_ICONS を、表示名は CM_TOOL_LABEL を引く(キーはここの値)。
100
+ */
101
+ const ADAPTER_CATALOG = {
102
+ "課題管理": ["backlog"],
103
+ "会議": ["google-meet"],
104
+ "共有資料": ["google-drive"],
105
+ "デザイン": ["figma"],
106
+ "チャット": ["slack"],
107
+ "開発": ["github"],
108
+ };
109
+ /**
110
+ * 導出された状態(5値)→ 画面に出す文言。**マップとタイルで同じ語を使う。**
111
+ *
112
+ * - `off` は「OFFに設定されています」。**接続へ誘わない**——意図的に止めたものに
113
+ * 「接続しましょう」と出すのは、設定する必要が無いものを押させるのと同じ誤り。
114
+ * - `unset` は「未接続」。こちらは入口を出してよい(まだ何も決めていない状態)。
115
+ * - `empty` を `unset` に潰さない。設定ファイルはあるので「置き直す」ではなく「中身を足す」。
116
+ */
117
+ const CAP_STATE_TEXT = {
118
+ unset: "未接続",
119
+ off: "OFFに設定されています",
120
+ broken: "設定に異常があります",
121
+ empty: "設定はありますが、対象がありません",
122
+ on: "接続OK",
123
+ };
124
+ /** 状態の印。CAP_STATE_TEXT の前に付ける(マップは付けない——⚠️バッジと二重になる) */
125
+ const CAP_STATE_MARK = { ok: "● ", idle: "⦿ ", warn: "⚠️ ", off: "" };
85
126
  const CM_CAPS = [
86
127
  { cap: "課題管理", icon: "📋", pipe: /sync-backlog/ },
87
128
  { cap: "会議", icon: "🎙", pipe: /ingest-minutes/ },
@@ -169,6 +210,39 @@ function externalCapability(type) {
169
210
  return "開発";
170
211
  return CAP_OTHER;
171
212
  }
213
+ /**
214
+ * その能力をいま担っているアダプタ(#125②)。
215
+ *
216
+ * **カタログにある値しか返さない。** `capabilities[cap].adapter` を素通しすると、
217
+ * engine が古い宣言を引きずった回や、将来 teams のような未対応値が入った回に、
218
+ * 対応していないツール名が顧客の画面へ出る(#124)。カタログ外なら既定
219
+ * (そのカタログの先頭)へ倒す——「名前は出ないが状態は出る」ほうが、
220
+ * 「名前だけ出て何も繋がっていない」より読み手を誤らせない。
221
+ *
222
+ * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
223
+ */
224
+ function capabilityAdapter(cap, caps) {
225
+ var list = ADAPTER_CATALOG[cap] || [];
226
+ var c = caps ? caps[cap] : null;
227
+ var a = c && c.adapter ? String(c.adapter) : "";
228
+ if (a && list.indexOf(a) >= 0)
229
+ return a;
230
+ return list.length ? list[0] : "";
231
+ }
232
+ /**
233
+ * engine が導出した5値の状態。**capabilities を持たない古いデータでは null。**
234
+ *
235
+ * null は「まだ判定材料が無い」であって「未接続」ではない。呼び出し側は null のときだけ
236
+ * 旧来(宣言 tools + 行の有無)の判定へ落ちる——**判定の分岐はここ1箇所**で、
237
+ * 状態の導出を viewer 側にもう一組作らない(導出の正本は engine の1本)。
238
+ */
239
+ function capabilityRawState(cap, caps) {
240
+ var c = caps ? caps[cap] : null;
241
+ if (!c)
242
+ return null;
243
+ var s = c.state == null ? "" : String(c.state);
244
+ return s ? s : null;
245
+ }
172
246
  /**
173
247
  * 貼られたURLから Backlog の接続先を読む。
174
248
  *
@@ -218,6 +292,35 @@ function capabilityState(cap, ctx) {
218
292
  const toolVal = ctx.tools[cap];
219
293
  const internal = ctx.internals.filter(function (s) { return s.kind === cap; })[0];
220
294
  const pipeRe = PIPE[cap];
295
+ // ---- #125②: 導出(capabilities)があるならそれが状態の正本 ----
296
+ //
297
+ // **ここが唯一の分岐点。** 導出の有無で描き分けるのはこの1箇所に閉じ、
298
+ // 呼び出し側(マップ・タイル・鍵カード)は4値の答えだけを受け取る。
299
+ // 導出を viewer 側にもう一組作らない(設定ファイルの再パース禁止)。
300
+ //
301
+ // **「問題あり(warn)」は別軸なので導出に混ぜない。** 設定は済んでいる(on)のに
302
+ // 読めていない・同期が失敗している、は設定ファイルからは分からない。
303
+ // broken(設定そのものが壊れている)だけは導出が答えを持っている。
304
+ const derived = capabilityRawState(cap, ctx.capabilities);
305
+ if (derived !== null) {
306
+ if (derived === "off")
307
+ return "off";
308
+ if (derived === "broken")
309
+ return "warn";
310
+ if (derived === "on") {
311
+ return (isLive
312
+ ? liveExts.some(function (s) { return sourceNeedsAttention(s); })
313
+ : ctx.pipes.some(function (p) {
314
+ return pipeRe != null && pipeRe.test(p.id) && p.applicable !== false && p.lastConclusion === "failure";
315
+ }))
316
+ ? "warn"
317
+ : "ok";
318
+ }
319
+ // unset / empty / 未知の値。**未知を ok にも off にも寄せない**——どちらも嘘になる。
320
+ // 「まだ繋がっていない」側に倒すのが安全(繋がっていないのに線を引かない)
321
+ return "idle";
322
+ }
323
+ // ---- ここから下は capabilities を持たない古い fleet-status.json のための劣化表示 ----
221
324
  // 使用中か: tools宣言がnone以外 / internalSourceが存在(enabled:false除く) / ライブ参照の実体がある
222
325
  const used = (toolVal != null && toolVal !== "none") ||
223
326
  (internal != null && internal.enabled !== false) ||
@@ -271,9 +374,19 @@ function renderConnectionMap(site) {
271
374
  const externals = site.externalSources ?? [];
272
375
  const pipes = site.pipelines ?? [];
273
376
  const tools = site.tools ?? {};
274
- if (!internals.length && !externals.length && !pipes.length && !Object.keys(tools).length) {
377
+ const caps = site.capabilities ?? {};
378
+ // **fleet のデータが1つでも取れているか、だけで判定する(#125②)。**
379
+ // 素の Decisions ビュー(fleet-status.json が無い構成)ではマップを出さない現行の意図は
380
+ // そのまま。`tools` を条件から外さないのは、capabilities を持たない古いスナップショットで
381
+ // マップが丸ごと消えるのを防ぐため(移行中は両方が現物として存在する)。
382
+ if (!internals.length &&
383
+ !externals.length &&
384
+ !pipes.length &&
385
+ !Object.keys(tools).length &&
386
+ !Object.keys(caps).length) {
275
387
  return null;
276
388
  }
389
+ const hasCaps = Object.keys(caps).length > 0;
277
390
  const slackExts = externals.filter((s) => s.type === "slack");
278
391
  const ghExts = externals.filter((s) => s.type.startsWith("github"));
279
392
  const nodes = [];
@@ -288,26 +401,32 @@ function renderConnectionMap(site) {
288
401
  const liveExts = c.cap === "チャット" ? slackExts : c.cap === "開発" ? ghExts : [];
289
402
  // **判定は capabilityState に一本化している。** 画面側(クライアントJS)も同じ関数を
290
403
  // 呼ぶので、ここで写経すると「同じ画面でマップが⚠️・直下が『接続OK』」が復活する。
291
- const st = capabilityState(c.cap, { tools, internals, externals, pipes });
404
+ const st = capabilityState(c.cap, { tools, capabilities: caps, internals, externals, pipes });
292
405
  const off = st === "off";
293
406
  const idle = st === "idle";
294
407
  const warn = st === "warn";
295
408
  const state = "cm-" + st;
409
+ const derived = capabilityRawState(c.cap, caps);
296
410
  // **ラベルは能力名(会議・課題管理…)。ツール名は下に小さく置く。**
297
411
  // ツールは「その能力をいま何で実現しているか」という差し替え可能なアダプタで、
298
412
  // 主語ではない。ツール名を主にすると、Teams のように複数の能力に使われるものが
299
413
  // 同じ名前で並び(会議とチャットに Teams が2つ)、何が繋がっているのか読めなくなる。
300
414
  // "none"(未使用の宣言)はツール名ではないので出さない。そのまま表示すると
301
415
  // 「ツール: none」という読めない行になる(実際にそう出ていた)
302
- const rawTool = toolVal && toolVal !== "none"
303
- ? toolVal
304
- : internal && internal.tool && internal.tool !== "none"
305
- ? internal.tool
306
- : isLive && liveExts.length
307
- ? c.cap === "チャット"
308
- ? "slack"
309
- : "github"
310
- : "";
416
+ //
417
+ // **導出があるならカタログのアダプタを常に出す(#125②)。** 未接続でも「何で繋ぐのか」が
418
+ // 分からないと入口にならない。カタログ外の値(teams 等)は capabilityAdapter が弾く。
419
+ const rawTool = hasCaps
420
+ ? capabilityAdapter(c.cap, caps)
421
+ : toolVal && toolVal !== "none"
422
+ ? toolVal
423
+ : internal && internal.tool && internal.tool !== "none"
424
+ ? internal.tool
425
+ : isLive && liveExts.length
426
+ ? c.cap === "チャット"
427
+ ? "slack"
428
+ : "github"
429
+ : "";
311
430
  const toolName = rawTool ? CM_TOOL_LABEL[rawTool] ?? rawTool : "";
312
431
  const label = `${c.icon} ${c.cap}`;
313
432
  // 2行目=ツール名(小さく)/3行目=状態(未使用・自動連携なし・ライブ参照・最終同期)
@@ -316,7 +435,13 @@ function renderConnectionMap(site) {
316
435
  : "";
317
436
  let statusText = "";
318
437
  let statusTs = "";
319
- if (off) {
438
+ if (derived !== null && derived !== "on") {
439
+ // **導出の5値をそのままの語で出す(#125②)。** 「未使用」に丸めると、意図的なOFFと
440
+ // 未設置と壊れているのが同じ1語になる——直せる人が「何を直すか」を決められない。
441
+ // タイル側と同じ CAP_STATE_TEXT を引くので、マップと直下の一覧で語が割れない。
442
+ statusText = CAP_STATE_TEXT[derived] || "状態が判定できません";
443
+ }
444
+ else if (off) {
320
445
  statusText = "未使用";
321
446
  }
322
447
  else if (idle) {
@@ -404,8 +529,19 @@ function buildTutorialData(site, codeUrl, repoSlug) {
404
529
  ];
405
530
  // ここから先は「書き込み・外部連携」の体験。案件の構成に無い能力のチャレンジは出さない(表示条件つき)
406
531
  const tools = site.tools ?? {};
532
+ const caps = site.capabilities ?? {};
407
533
  const internals = site.internalSources ?? [];
408
534
  const externals = site.externalSources ?? [];
535
+ /**
536
+ * その能力が配線済みか。**導出(capabilities)があるならそれだけで決める(#125②)。**
537
+ * 宣言でチャレンジを出していた頃は、Backlog を宣言しているだけで鍵も入っていない案件に
538
+ * 「記票してみよう」が出ていた——押すと必ず失敗する体験を最初の5分に置くことになる。
539
+ * 導出が無い古いデータでは null を返し、呼び出し側が従来の判定へ落ちる。
540
+ */
541
+ const capOn = (cap) => {
542
+ const s = capabilityRawState(cap, caps);
543
+ return s === null ? null : s === "on";
544
+ };
409
545
  // メンバーの自分のレコードを直す体験(Members セクションにレコードがある案件のみ出す)
410
546
  if ((site.sections ?? []).some((s) => (s.name === "Members" || s.name === "メンバー") && s.records.length)) {
411
547
  specs.push({
@@ -415,8 +551,9 @@ function buildTutorialData(site, codeUrl, repoSlug) {
415
551
  });
416
552
  }
417
553
  // Backlogに記票する体験(課題管理にBacklogを使う案件のみ出す)
418
- const usesBacklog = tools["課題管理"] === "backlog" ||
419
- internals.some((s) => s.kind === "課題管理" && s.tool === "backlog" && s.enabled !== false);
554
+ const usesBacklog = capOn("課題管理") ??
555
+ (tools["課題管理"] === "backlog" ||
556
+ internals.some((s) => s.kind === "課題管理" && s.tool === "backlog" && s.enabled !== false));
420
557
  if (usesBacklog) {
421
558
  specs.push({
422
559
  title: "Backlogに記票してみよう",
@@ -425,8 +562,9 @@ function buildTutorialData(site, codeUrl, repoSlug) {
425
562
  });
426
563
  }
427
564
  // 画面インベントリを読む体験(デザインを同期している案件のみ出す)
428
- const usesDesign = (tools["デザイン"] != null && tools["デザイン"] !== "none") ||
429
- internals.some((s) => s.kind === "デザイン" && s.enabled !== false);
565
+ const usesDesign = capOn("デザイン") ??
566
+ ((tools["デザイン"] != null && tools["デザイン"] !== "none") ||
567
+ internals.some((s) => s.kind === "デザイン" && s.enabled !== false));
430
568
  if (usesDesign) {
431
569
  specs.push({
432
570
  title: "デザインを読んでみよう",
@@ -435,7 +573,7 @@ function buildTutorialData(site, codeUrl, repoSlug) {
435
573
  });
436
574
  }
437
575
  // 完了をSlackに知らせる体験(チャットにSlackを宣言している案件のみ出す。最終チャレンジ)
438
- const usesSlack = tools["チャット"] === "slack" || externals.some((s) => s.type === "slack");
576
+ const usesSlack = capOn("チャット") ?? (tools["チャット"] === "slack" || externals.some((s) => s.type === "slack"));
439
577
  if (usesSlack) {
440
578
  specs.push({
441
579
  title: "Slackに完了を知らせよう",
@@ -1046,6 +1184,9 @@ a.ds-name:hover { text-decoration: underline; }
1046
1184
  .ds-gate-ok { color: #1f7a45; }
1047
1185
  .ds-gate-warn { color: #b45309; }
1048
1186
  .ds-gate-muted { color: #6b7280; }
1187
+ /* 意図的にOFFにした能力(#125②)。**グレーにして誘わない**——未接続(入口を出してよい)
1188
+ とは別物で、同じ色にすると「押して繋ぎましょう」と読まれる */
1189
+ .ds-gate-off { color: #9ca3af; }
1049
1190
  .ds-pipes { display: flex; flex-direction: column; gap: 8px; }
1050
1191
  /* 組織ごとの接続状況。トークンは組織ごとにしか効かないので、ここが判断の単位になる */
1051
1192
  .ds-owners { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
@@ -1095,6 +1236,20 @@ a.ds-name:hover { text-decoration: underline; }
1095
1236
  /* URLは長いので、入力欄を伸ばしてボタンを右に置く(折り返しても崩れないように wrap) */
1096
1237
  .rt-url-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
1097
1238
  .rt-url { flex: 1 1 260px; margin-bottom: 0; font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; }
1239
+ /* 稼働中の表示(#123)。緑は「動いている」の合図で、見出しの右に置いて先に目に入れる */
1240
+ .rt-live-badge {
1241
+ margin-left: auto; flex: 0 0 auto; padding: 3px 10px; border-radius: 999px;
1242
+ background: #e8f6ed; border: 1px solid #b7e0c6; color: #1c7a43;
1243
+ font-size: 12px; font-weight: 700; white-space: nowrap;
1244
+ }
1245
+ .rt-live-lines { display: flex; flex-direction: column; gap: 4px; }
1246
+ .rt-live-line { margin: 0; color: var(--text); font-size: 13px; font-weight: 600; }
1247
+ /* まだ受信していない接続先(複数接続のとき混在する)。稼働中の行と見分けが付く濃さに落とす */
1248
+ .rt-live-line.is-waiting { color: var(--muted); font-weight: 500; }
1249
+ /* 手順は畳んで残す(張り替え・再確認用)。開いたときだけ間隔を空ける */
1250
+ .rt-steps-fold > summary { cursor: pointer; color: var(--muted); font-size: 12px; }
1251
+ .rt-steps-fold[open] > summary { margin-bottom: 10px; }
1252
+ .rt-steps-fold[open] > *:not(summary) { margin-top: 10px; }
1098
1253
  .ops-card-head { display: flex; align-items: center; gap: 10px; }
1099
1254
  .ops-card-icon { font-size: 22px; line-height: 1; }
1100
1255
  .ops-card-title { font-size: 15px; font-weight: 700; color: var(--text); margin: 0; }
@@ -1205,7 +1360,12 @@ const CLIENT_JS = `
1205
1360
  // **この2つはクロージャを持たない純関数に保つこと。** 外の変数を掴むと、
1206
1361
  // 直列化した先(ここ)で undefined を参照して壊れる。
1207
1362
  var CAP_OTHER = ${JSON.stringify(CAP_OTHER)};
1363
+ var ADAPTER_CATALOG = ${JSON.stringify(ADAPTER_CATALOG)};
1364
+ var CAP_STATE_TEXT = ${JSON.stringify(CAP_STATE_TEXT)};
1365
+ var CAP_STATE_MARK = ${JSON.stringify(CAP_STATE_MARK)};
1208
1366
  ${externalCapability.toString()}
1367
+ ${capabilityAdapter.toString()}
1368
+ ${capabilityRawState.toString()}
1209
1369
  ${backlogTargetOf.toString()}
1210
1370
  ${sourceNeedsAttention.toString()}
1211
1371
  ${capabilityState.toString()}
@@ -1258,8 +1418,11 @@ ${capabilityState.toString()}
1258
1418
  }
1259
1419
  // Cortex自体を操作するアクションと、接続・自動化の情報の受け皿(取り込み・接続マップ・データソース)。
1260
1420
  // 投入フォーム設定 or fleet-status由来の接続情報のどちらかがあれば出す。
1421
+ // **fleet のデータが取れているか、だけで決める(#125②)。** 能力の宣言(tools)は廃止したので
1422
+ // capabilities を条件に足す。**常に真にはしない**——fleet-status.json を持たない素の
1423
+ // Decisions ビューでは操作タブそのものを出さない現行の意図を保つ
1261
1424
  var HAS_FLEET = !!((SITE.internalSources || []).length || (SITE.externalSources || []).length ||
1262
- (SITE.pipelines || []).length || SITE.tools);
1425
+ (SITE.pipelines || []).length || SITE.capabilities || SITE.tools);
1263
1426
  if (SITE.intake || HAS_FLEET) {
1264
1427
  TABS.push({ key: "ops", label: "操作" });
1265
1428
  }
@@ -1704,6 +1867,22 @@ ${capabilityState.toString()}
1704
1867
  var mm = ("0" + d.getMinutes()).slice(-2);
1705
1868
  return (d.getMonth() + 1) + "/" + d.getDate() + " " + hh + ":" + mm;
1706
1869
  }
1870
+ /**
1871
+ * 「いつだったか」を相対で言う(#123)。読めない値は空文字(呼び側が出し分けを切る)。
1872
+ *
1873
+ * **1日以上前は絶対時刻に切り替える。** 「32日前」は数えないと日付にならないし、
1874
+ * そこまで空いていれば知りたいのは「いつ止まったか」のほう。
1875
+ * 未来の時刻(端末とサーバの時計ずれ)は「たった今」に丸める——「-3分前」を出さない。
1876
+ */
1877
+ function rtAgo(iso) {
1878
+ var d = new Date(iso);
1879
+ if (isNaN(d.getTime())) return "";
1880
+ var min = Math.floor((Date.now() - d.getTime()) / 60000);
1881
+ if (min < 1) return "たった今";
1882
+ if (min < 60) return min + "分前";
1883
+ if (min < 60 * 24) return Math.floor(min / 60) + "時間前";
1884
+ return dsFmtTime(iso);
1885
+ }
1707
1886
  // 名前表示: url があれば新しいタブで正本を開くリンクにする
1708
1887
  function dsNameEl(text, url) {
1709
1888
  if (url) return el("a", { class: "ds-name", href: url, target: "_blank", rel: "noopener", text: text });
@@ -1786,6 +1965,25 @@ ${capabilityState.toString()}
1786
1965
  return SITE[key === "generatedAt" ? "fleetGeneratedAt" : key];
1787
1966
  }
1788
1967
 
1968
+ /**
1969
+ * その能力にそのアダプタの操作を出してよいか(#125②)。
1970
+ *
1971
+ * **カタログにあるなら常に出す。** 以前は宣言(Home.md の tools)でゲートしていたが、
1972
+ * 宣言は実態とずれる二重帳簿で、「teams と書いてあるから Slack の追加口を消す」ような
1973
+ * 誤った封鎖を生んでいた(実際に Slack を渡されているのに追加できない案件があった)。
1974
+ * カタログはこの製品が実装しているアダプタの定義なので、押して必ず失敗することはない。
1975
+ *
1976
+ * **capabilities を持たない古いデータでは、従来どおり宣言で見る。** 移行前の
1977
+ * スナップショットには宣言しか無く、そこで全面開放すると別ツール宣言の案件に
1978
+ * 必ず400になるボタンが復活する。
1979
+ */
1980
+ function capabilityUsable(cap, tool) {
1981
+ var caps = fleetVal("capabilities") || {};
1982
+ if (Object.keys(caps).length) return (ADAPTER_CATALOG[cap] || []).indexOf(tool) >= 0;
1983
+ var v = (fleetVal("tools") || {})[cap];
1984
+ return !v || v === "none" || v === tool;
1985
+ }
1986
+
1789
1987
  /** 組み立てだけを行う(設置は呼び出し側)。ライブ差し替えで同じ節を作り直すため分けている */
1790
1988
  /** ツールid→表示名(クライアント側。接続マップの CM_TOOL_LABEL と同じ内容) */
1791
1989
  var CM_TOOL_LABEL_C = {
@@ -1798,12 +1996,16 @@ ${capabilityState.toString()}
1798
1996
  * 能力モーダル。**中身は既存の部品をそのまま入れる。**
1799
1997
  * 一覧・追加フォーム・鍵・手動投入は作り直さない(先出しバッジやライブ差し替えを壊さないため)。
1800
1998
  */
1801
- function openCapabilityModal(cap, items) {
1999
+ function openCapabilityModal(cap, items, note) {
1802
2000
  var m = makeModal(cap, true);
1803
2001
  // **凡例を必ず入れる。** goldState の4状態は凡例があって初めて読める
1804
2002
  if (cap === "チャット" || cap === "開発") {
1805
2003
  m.modal.appendChild(el("p", { class: "modal-desc", text: "「Gold昇格の読み取り対象」のものだけを毎晩の Gold 昇格が読み取ります。「除外」は意図的に外したもの、「未宣言(対象外)」はまだ判断されていないもので、どちらも読み取りません。" }));
1806
2004
  }
2005
+ // **なぜこの状態なのかを、押した先でも読めるようにする(#125②)。** タイルは常時出す
2006
+ // ようになったので、未接続の能力を押したときに中身が空になる場面が増えた。
2007
+ // 「設定できるものがありません」だけだと、繋げないのか繋ぐ必要が無いのかが分からない
2008
+ if (note) m.modal.appendChild(el("p", { class: "modal-desc", text: note }));
1807
2009
  if (!items.length) {
1808
2010
  m.modal.appendChild(el("p", { class: "ds-empty", text: "この能力にはまだ設定できるものがありません。" }));
1809
2011
  }
@@ -1823,8 +2025,12 @@ ${capabilityState.toString()}
1823
2025
  var internals = fleetVal("internalSources") || [];
1824
2026
  var sources = fleetVal("externalSources") || [];
1825
2027
  var pipes = fleetVal("pipelines") || [];
1826
- // 能力→ツールの宣言(例: チャット: "none")。"none" の能力は「未使用」のグレー行で存在だけ見せる
1827
- var tools = fleetVal("tools") || {};
2028
+ // 能力ごとの導出済みの状態(#125② の正本)。持たない古いデータでは {}
2029
+ var caps = fleetVal("capabilities") || {};
2030
+ var hasCaps = Object.keys(caps).length > 0;
2031
+ // 能力→ツールの宣言(例: チャット: "none")。**capabilities が無いときだけ効く劣化表示。**
2032
+ // "none" の能力は「未使用」のグレー行で存在だけ見せる
2033
+ var tools = (!hasCaps && fleetVal("tools")) || {};
1828
2034
  var chatOff = tools["チャット"] === "none";
1829
2035
  var devOff = tools["開発"] === "none";
1830
2036
  // **この節に入るものが1つでもあるなら出す。**
@@ -1836,18 +2042,20 @@ ${capabilityState.toString()}
1836
2042
  //
1837
2043
  // 個別に足し続けると次も漏らすので、**この節に出す候補を列挙して判定する**形にする。
1838
2044
  var hasSecrets = ((SITE.secretKinds || []).filter(function (k) { return SECRET_FORMS[k]; })).length > 0;
1839
- var hasTools = Object.keys(tools).length > 0;
2045
+ // **fleet のデータが取れているか、だけを見る(#125②)。** 宣言(tools)は廃止したが、
2046
+ // capabilities を持たない古いスナップショットで節ごと消えないよう条件には残す
2047
+ var hasCapModel = hasCaps || Object.keys(tools).length > 0;
1840
2048
  // **投入の口も候補。** fleet-status.json はGitHub Actionsが夜間に生成するので、
1841
2049
  // 立ち上げ直後の案件には存在しない。それを理由に節ごと消すと、
1842
2050
  // 文字起こし・資料を入れる口が**一番必要な瞬間に**画面から無くなる
1843
2051
  var hasIntake = !!SITE.intake;
1844
- if (!internals.length && !sources.length && !pipes.length && !hasTools && !hasSecrets && !hasIntake) return null;
2052
+ if (!internals.length && !sources.length && !pipes.length && !hasCapModel && !hasSecrets && !hasIntake) return null;
1845
2053
 
1846
2054
  var sec = el("section", { class: "datasources" });
1847
2055
  sec.appendChild(el("h2", { class: "datasources-head", text: "データソースと自動化" }));
1848
2056
  // **まだ何も繋がっていない案件では、文言を変える。** 接続がゼロの状態で
1849
2057
  // 「接続している情報源の一覧です」と書くと、中身(手動投入だけ)と噛み合わない
1850
- var connected = internals.length > 0 || sources.length > 0 || hasTools;
2058
+ var connected = internals.length > 0 || sources.length > 0 || hasCapModel;
1851
2059
  sec.appendChild(el("p", { class: "datasources-intro", text: connected
1852
2060
  ? "このCortexが接続している情報源と、毎晩自動でGoldに昇格する処理の一覧です。"
1853
2061
  : "情報源はまだ接続されていません。下から手元のファイルを取り込めます(自動連携の設定はこれから行います)。" }));
@@ -1870,12 +2078,12 @@ ${capabilityState.toString()}
1870
2078
 
1871
2079
  // ---- 能力ごとの受け皿 ----
1872
2080
  //
1873
- // **能力の一覧は tools のキーで決める。データ行の有無で決めない。**
2081
+ // **能力の一覧はカタログ(ADAPTER_CATALOG)で決める。データ行の有無で決めない。**
1874
2082
  // internalSources に現れるのは 課題管理/会議/共有資料/デザイン の4つだけで、
1875
- // チャットと開発は externalSources 側にしかない。さらに tools.チャット = "teams" の
1876
- // 案件(三菱)は**どちらにも行が無い**——Cortex が Teams に対応していないため。
1877
- // 行の有無でセクションを作ると、その案件でチャットが丸ごと消える。
1878
- // 「未対応です」と伝えるべき場面で何も出ないのが一番悪い。
2083
+ // チャットと開発は externalSources 側にしかない。さらに未接続の能力は
2084
+ // **どちらにも行が無い**。行の有無でセクションを作ると、その案件でチャットが丸ごと消え、
2085
+ // 「これから繋げる入口」も接続マップのアンカーの受け皿も無くなる。
2086
+ // 何を繋げるかは製品が決めていて、案件データでは変わらない(#125②)。
1879
2087
  var CAP_ORDER = ["課題管理", "会議", "共有資料", "チャット", "デザイン", "開発"];
1880
2088
  var capBox = {};
1881
2089
  var manualBox = {};
@@ -2139,6 +2347,16 @@ ${capabilityState.toString()}
2139
2347
  }));
2140
2348
  return;
2141
2349
  }
2350
+ // **未接続(unset)の能力には入力欄を出さない(#125②)。** 鍵は「繋いだものを読むため」の
2351
+ // ものなので、繋ぐ先が無い状態で入れても効かない。何も出さないと「鍵はどこで入れるのか」が
2352
+ // 分からなくなるので、順序だけを1行で伝える
2353
+ if (capabilityRawState(SECRET_CAP[k], caps) === "unset") {
2354
+ pushCap(SECRET_CAP[k], el("p", {
2355
+ class: "sec-how",
2356
+ text: "この能力はまだ接続されていません。接続すると、ここに鍵の入力欄が出ます。",
2357
+ }));
2358
+ return;
2359
+ }
2142
2360
  pushCap(SECRET_CAP[k], secretCard(k, SECRET_FORMS[k]));
2143
2361
  });
2144
2362
 
@@ -2209,12 +2427,23 @@ ${capabilityState.toString()}
2209
2427
  return row;
2210
2428
  }
2211
2429
 
2212
- /** タイルの2行目(状態と読み取り範囲)。**件数は出せる能力だけ出す** */
2213
- function tileSub(cap, st) {
2430
+ /**
2431
+ * タイルの2行目(状態と読み取り範囲)。**件数は出せる能力だけ出す**
2432
+ *
2433
+ * @param derived engine が導出した5値(capabilities 由来)。古いデータでは null
2434
+ * @param adapter このタイルのアダプタ(件数の出し分けに使う)
2435
+ */
2436
+ function tileSub(cap, st, derived, adapter) {
2214
2437
  var internal = internals.filter(function (s) { return s.kind === cap; })[0];
2215
2438
  var live = sources.filter(function (s) { return externalCapability(s.type) === cap; });
2216
2439
  var parts = [];
2217
- if (st === "warn") {
2440
+ // **導出が on 以外なら、導出の語を優先する(#125②)。** broken は状態としては
2441
+ // 警告(⚠️)だが、原因は「設定そのものが壊れている」であって同期の失敗ではない。
2442
+ // 下の warn の文(同期が失敗しています/N件が読めていません)を出すと、
2443
+ // 直す場所が違うところを指す
2444
+ if (derived && derived !== "on") {
2445
+ parts.push((CAP_STATE_MARK[st] || "") + (CAP_STATE_TEXT[derived] || "状態が判定できません"));
2446
+ } else if (st === "warn") {
2218
2447
  if (live.length) {
2219
2448
  var bad = 0, noToken = 0;
2220
2449
  live.forEach(function (s) {
@@ -2229,6 +2458,10 @@ ${capabilityState.toString()}
2229
2458
  // 内部能力の warn はパイプラインの失敗由来。数えるものが無いので件数を出さない
2230
2459
  parts.push("⚠️ 同期が失敗しています");
2231
2460
  }
2461
+ } else if (derived) {
2462
+ // 導出が on で、問題も無い。接続マップと同じ CAP_STATE_TEXT を引くので、
2463
+ // 上のマップと直下の一覧で語が割れない
2464
+ parts.push((CAP_STATE_MARK[st] || "") + (CAP_STATE_TEXT[derived] || "状態が判定できません"));
2232
2465
  } else {
2233
2466
  // **off には2つの意味が同居している。** 「使わないと宣言した」(tools が none)と
2234
2467
  // 「まだ何も分かっていない」(fleet-status.json を夜間バッチがまだ作っていない)。
@@ -2251,9 +2484,10 @@ ${capabilityState.toString()}
2251
2484
  var unit = cap === "チャット" ? "チャンネル" : cap === "開発" ? "リポジトリ" : "件";
2252
2485
  var gold = live.filter(function (s) { return s.gold === true; }).length;
2253
2486
  parts.push(live.length + unit + "/" + gold + "件をGoldに使用");
2254
- } else if (cap === "共有資料" && tools[cap] === "google-drive" && internal && internal.driveFolderIds) {
2255
- // **ツールを見る。** local の案件(gdo・三菱)は Drive を使っていないのに
2256
- // 「ローカル」と名乗ったタイルが「Driveフォルダ 0件」と語っていた
2487
+ } else if (cap === "共有資料" && adapter === "google-drive" && internal && internal.driveFolderIds) {
2488
+ // **そのタイルのアダプタを見る。** 宣言(tools)を見ていた頃は、local の案件
2489
+ //(gdo・三菱)で「ローカル」と名乗ったタイルが「Driveフォルダ 0件」と語っていた。
2490
+ // いまカタログにあるのは google-drive だけなので、ここは常に真になる
2257
2491
  parts.push("Driveフォルダ " + internal.driveFolderIds.length + "件");
2258
2492
  } else if (cap === "デザイン" && internal && internal.figmaFiles) {
2259
2493
  // **キーが無ければ何も出さない。** 未取得を「登録ゼロ」と断定して顧客に見せない
@@ -2274,38 +2508,68 @@ ${capabilityState.toString()}
2274
2508
  var anyCap = false;
2275
2509
  CAP_ORDER.concat(extraCaps).forEach(function (cap) {
2276
2510
  var items = capBox[cap] || [];
2511
+ // **カタログにある能力のタイルは常時出す(#125②)。** 使っているかどうかで出し分けない
2512
+ // ——未接続のタイルは「これから繋げる入口」で、消すと繋ぎ方を知る場所が無くなる。
2513
+ // カタログは製品の定義なので、案件データが何であってもここは変わらない
2514
+ var adapters = (hasCaps && ADAPTER_CATALOG[cap]) || [];
2515
+ // 旧データ(capabilities 無し)は従来どおり「宣言があれば行が無くても出す」
2277
2516
  var declared = tools[cap] != null;
2278
- // **tools に宣言があれば、行が無くてもタイルを出す。** 出さないと
2279
- // Teams案件でチャットが丸ごと消え、「未対応です」と伝える場所も無くなる。
2280
- // 接続マップのアンカー(ds-<能力>)の受け皿にもなる(いま2案件で解決できていない)
2281
2517
  // **手動投入はアダプタに依存しない。** 宣言や行の有無で弾くと、
2282
2518
  // fleet-status.json がまだ無い新規案件で投入の口ごと消える
2283
- if (!items.length && !declared && !manualBox[cap]) return;
2519
+ if (!adapters.length && !items.length && !declared && !manualBox[cap]) return;
2284
2520
  anyCap = true;
2285
2521
  sec.appendChild(el("div", { class: "ds-cap", text: cap }));
2286
- // アダプタのタイルは、宣言か行があるときだけ。手動投入だけが理由でここへ来た
2287
- // 新規案件に、中身の無い「会議」タイルを立てても押す先が無い
2288
- if (items.length || declared) renderAdapterTile(cap, items);
2522
+ if (adapters.length) {
2523
+ // **カタログの全アダプタを並べる(今日は各1個)。** 能力=1アダプタを
2524
+ // 焼き込まないための形(#107 / #127)。導出が答えているアダプタにだけ
2525
+ // その状態を割り当て、残りは未接続——「知らない」を「繋がっている」に寄せない
2526
+ var active = capabilityAdapter(cap, caps);
2527
+ adapters.forEach(function (a, ai) {
2528
+ renderAdapterTile(cap, items, a, a === active ? capabilityRawState(cap, caps) : "unset", ai);
2529
+ });
2530
+ } else if (items.length || declared) {
2531
+ // 旧データ: アダプタのタイルは宣言か行があるときだけ。手動投入だけが理由でここへ来た
2532
+ // 新規案件に、中身の無い「会議」タイルを立てても押す先が無い
2533
+ var legacyTool = tools[cap] && tools[cap] !== "none" ? tools[cap] : "";
2534
+ renderAdapterTile(cap, items, legacyTool, null, 0);
2535
+ }
2289
2536
  renderManualTile(cap);
2290
2537
  });
2291
2538
 
2292
- function renderAdapterTile(cap, items) {
2539
+ /**
2540
+ * 能力×アダプタ1件のタイル。
2541
+ *
2542
+ * @param adapter アダプタ識別子(カタログ由来。旧データでは宣言 tools 由来)
2543
+ * @param derived engine が導出した5値。旧データ(capabilities 無し)では null
2544
+ * @param ai その能力の中での並び順。先頭だけが接続マップの飛び先(ds-<能力>)になる
2545
+ */
2546
+ function renderAdapterTile(cap, items, adapter, derived, ai) {
2293
2547
  var meta = CAP_META[cap] || { icon: "🔗", desc: "" };
2294
- var st = capabilityState(cap, { tools: tools, internals: internals, externals: sources, pipes: pipes });
2548
+ var st = derived === "unset" && capabilityRawState(cap, caps) !== "unset"
2549
+ // カタログにあるが導出が答えていないアダプタ(今日は発生しない。#107 の前方互換)
2550
+ ? "idle"
2551
+ : capabilityState(cap, { tools: tools, capabilities: caps, internals: internals, externals: sources, pipes: pipes });
2295
2552
  // **ロゴはタイルの左に独立して置く。** 名前の行に入れると名前の高さに
2296
2553
  // 縛られて小さくなる。ロゴで能力を見分けられるのが一覧の主な用途
2297
- var logo = ADAPTER_ICONS[tools[cap]];
2554
+ var logo = ADAPTER_ICONS[adapter];
2298
2555
  var ic = el("span", { class: "ds-tile-logo" });
2299
2556
  if (logo) ic.innerHTML = logo; // 公式ロゴ。ビルド時に埋め込み済みで外部通信は起きない
2300
2557
  else ic.textContent = meta.icon;
2301
- // "none"(未使用の宣言)はツール名ではない。素通しすると顧客の画面に
2302
- // none」というタイル名が出る(接続マップ側で一度直したのと同じ穴)
2303
- var rawTool = tools[cap] && tools[cap] !== "none" ? tools[cap] : "";
2558
+ // カタログ外の値はここへ来ない(capabilityAdapter が弾く)。旧データの "none" だけは
2559
+ // 素通しすると顧客の画面に「none」というタイル名が出るので、上で空にしてある
2560
+ var rawTool = adapter || "";
2304
2561
  var body = el("div", { class: "ds-tile-body" }, [
2305
2562
  el("div", { class: "ds-tile-name", text: (rawTool && (CM_TOOL_LABEL_C[rawTool] || rawTool)) || cap }),
2306
2563
  el("div", { class: "ds-tile-desc", text: meta.desc }),
2307
- el("div", { class: "ds-tile-sub ds-gate-" + st, text: tileSub(cap, st) }),
2564
+ el("div", { class: "ds-tile-sub ds-gate-" + st, text: tileSub(cap, st, derived, adapter) }),
2308
2565
  ]);
2566
+ // **なぜその状態なのかを添える(#125②)。** engine が書いた一文(detail)で、
2567
+ // 「未接続」だけでは分からない「何が無いのか」を出す。on のときは
2568
+ // 読み取り範囲(件数)が既に同じ行にあるので添えない
2569
+ var capInfo = caps[cap];
2570
+ if (derived && derived !== "on" && capInfo && capInfo.detail && adapter === capabilityAdapter(cap, caps)) {
2571
+ body.appendChild(el("div", { class: "ds-tile-note ds-tile-todo", text: capInfo.detail }));
2572
+ }
2309
2573
  // トークン未設定は**状態とは別軸**。状態機械に混ぜると判定が1箇所でなくなる。
2310
2574
  // ただし「使わない」と**宣言した**能力では出さない——設定する必要が無いものに
2311
2575
  // ついて設定を促す偽の警告になる(sushiro のデザインで出ていた)。
@@ -2319,7 +2583,11 @@ ${capabilityState.toString()}
2319
2583
  // 案件に未設定の警告を出すと、直す必要が無いものを直させることになる。
2320
2584
  // 読めていない組織があるときは、この下の checks とタイルの状態(warn)が拾う
2321
2585
  var tokenIsFallback = sk === "external-sources" && !githubTokenNeeded();
2322
- if (!tokenIsFallback && tools[cap] !== "none" && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !((fleetVal("secretSources") || {})[sk])) {
2586
+ // **#125②: 導出があるときは導出で判断する。** OFF(意図的に止めた)と unset(未接続で、
2587
+ // 鍵の入力欄そのものを案内文に畳んである)には出さない——入れる先が無い、あるいは
2588
+ // 入れる必要が無いものについて設定を促す偽の警告になる
2589
+ var tokenRelevant = derived != null ? derived !== "off" && derived !== "unset" : tools[cap] !== "none";
2590
+ if (!tokenIsFallback && tokenRelevant && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !((fleetVal("secretSources") || {})[sk])) {
2323
2591
  body.appendChild(el("div", { class: "ds-tile-note", text: "⚠ トークンが未設定です" }));
2324
2592
  }
2325
2593
  // セットアップの残りのうち、この能力のもの。**鍵の注記と同じ位置**に出して
@@ -2334,12 +2602,17 @@ ${capabilityState.toString()}
2334
2602
  shownOnTile[c.id] = true;
2335
2603
  body.appendChild(el("div", { class: "ds-tile-note ds-tile-todo", text: "→ " + c.action }));
2336
2604
  });
2337
- var tile = el("button", { class: "ds-tile", type: "button", id: "ds-" + cap }, [
2605
+ // **id は能力ごとに1つ(先頭のアダプタ)。** 接続マップのノードの飛び先で、
2606
+ // 同じ id が2つある不正なHTMLになると getElementById が思わぬ側を指す
2607
+ var tile = el("button", { class: "ds-tile", type: "button", id: ai ? "ds-" + cap + "-" + adapter : "ds-" + cap }, [
2338
2608
  ic,
2339
2609
  body,
2340
2610
  el("span", { class: "ds-tile-chev", text: "›" }),
2341
2611
  ]);
2342
- tile.addEventListener("click", function () { openCapabilityModal(cap, items); });
2612
+ var modalNote = derived && derived !== "on" && capInfo && capInfo.detail
2613
+ ? CAP_STATE_TEXT[derived] + "(" + capInfo.detail + ")"
2614
+ : "";
2615
+ tile.addEventListener("click", function () { openCapabilityModal(cap, items, modalNote); });
2343
2616
  sec.appendChild(tile);
2344
2617
  }
2345
2618
 
@@ -2523,8 +2796,7 @@ ${capabilityState.toString()}
2523
2796
  // 「足したい案件でだけ足せない」という逆転になっていた。
2524
2797
  // 押しても必ず失敗するもの(別ツールが宣言済み)は usable() で弾く。
2525
2798
  if (!SITE.intake) return null;
2526
- var tools = fleetVal("tools") || {};
2527
- var usable = function (cap, tool) { var v = tools[cap]; return !v || v === "none" || v === tool; };
2799
+ var usable = capabilityUsable;
2528
2800
  var box = el("div", {});
2529
2801
  var used = false;
2530
2802
 
@@ -2778,14 +3050,14 @@ ${capabilityState.toString()}
2778
3050
  /**
2779
3051
  * 外部ソースの追加フォーム(Slack・GitHub)。
2780
3052
  *
2781
- * **tools の宣言でゲートする。** 別のツールが宣言されている案件(チャット: teams 等)では
2782
- * サーバが必ず400で断るので、押せるのに必ず失敗するボタンを出さない。
3053
+ * **カタログでゲートする(#125②)。** 宣言(tools)は廃止した。古いデータのときだけ
3054
+ * 従来どおり宣言を見る(capabilityUsable の項を参照)——別のツールが宣言されている
3055
+ * 案件ではサーバが必ず400で断るので、押せるのに必ず失敗するボタンを出さない。
2783
3056
  */
2784
3057
  function renderExternalAdders() {
2785
3058
  var box = el("div", {});
2786
3059
  if (!SITE.intake) return box;
2787
- var tools = fleetVal("tools") || {};
2788
- var usable = function (cap, tool) { var v = tools[cap]; return !v || v === "none" || v === tool; };
3060
+ var usable = capabilityUsable;
2789
3061
 
2790
3062
  // **Slack は tools 宣言で隠さない。** tools.チャット は能力ごとに1値しか持てないので、
2791
3063
  // 「会議はTeams・連絡はSlack」という普通の構成を表せない。teams を宣言した案件では
@@ -3433,6 +3705,10 @@ ${capabilityState.toString()}
3433
3705
  *
3434
3706
  * **URLに載せるトークンは案件別**(SITE.backlogWebhook)。艦隊共通トークンを載せると、
3435
3707
  * この画面を見られる人が全案件ぶんの偽イベントを投げられる(types.ts の backlogWebhook 参照)。
3708
+ *
3709
+ * **既に回っている案件には、手順より先に「動いている」ことを出す(#123)。** 設定手順は
3710
+ * まだ繋いでいない人のためのもので、繋ぎ終わった案件の担当者が知りたいのは
3711
+ * 「ちゃんと届いているか」。手順は畳んで残す(URLの再確認・張り替えに要る)。
3436
3712
  */
3437
3713
  function realtimeSyncCard() {
3438
3714
  var rt = LIVE_REALTIME;
@@ -3446,6 +3722,13 @@ ${capabilityState.toString()}
3446
3722
  var wh = SITE.backlogWebhook;
3447
3723
  if (!rt.ready || !wh || !wh.url || !wh.token) return null;
3448
3724
  var url = wh.url + (wh.url.indexOf("?") === -1 ? "?" : "&") + "t=" + encodeURIComponent(wh.token);
3725
+ // 接続先ごとの受信実績(#123)。**配列で受ける**——1案件=複数 Backlog プロジェクトは
3726
+ // 確定した将来要件(#107)で、今日は必ず1件。**時刻が読めない値は稼働中に数えない**
3727
+ // ——「最終受信: NaN分前」を出すくらいなら、未設定と同じ手順の画面のほうがまだ読める
3728
+ var targets = (rt.targets || []).map(function (t) {
3729
+ return { projectKey: t && t.projectKey ? String(t.projectKey) : "", ago: t && t.lastEventAt ? rtAgo(t.lastEventAt) : "" };
3730
+ });
3731
+ var live = targets.some(function (t) { return t.ago; });
3449
3732
 
3450
3733
  // URLは**選択できる形**で出す(コピーボタンが使えない環境でも手で取れるように)
3451
3734
  var field = el("input", { class: "sec-input rt-url", type: "text", readonly: "readonly", spellcheck: "false" });
@@ -3472,12 +3755,8 @@ ${capabilityState.toString()}
3472
3755
  } catch (e) { fallback(); }
3473
3756
  });
3474
3757
 
3475
- return el("div", { class: "ops-card rt-card" }, [
3476
- el("div", { class: "ops-card-head" }, [
3477
- el("span", { class: "ops-card-icon", text: "⚡" }),
3478
- el("h3", { class: "ops-card-title", text: "リアルタイム同期" }),
3479
- ]),
3480
- el("p", { class: "ops-card-desc", text: "Backlog で課題やコメントが変わったときに、その内容をすぐ取り込みます(毎晩の同期を待たずに反映されます)。Backlog 側で一度だけ設定してください。" }),
3758
+ // 手順ひとそろい(貼り付け先・URL・注意書き)。稼働中の案件ではこれを畳む
3759
+ var steps = [
3481
3760
  el("ol", { class: "rt-steps" }, [
3482
3761
  el("li", { text: "Backlog のプロジェクト設定 → インテグレーション → Webhook を開く" }),
3483
3762
  el("li", { text: "「WebHook URL」に下の案件専用URLを貼り付ける" }),
@@ -3488,7 +3767,46 @@ ${capabilityState.toString()}
3488
3767
  // **この画面は顧客も見る。** URLに秘密が入っていることと、その扱いを書いておく
3489
3768
  el("p", { class: "sec-note", text: "このURLはこの案件専用です(他の案件には届きません)。秘密のトークンを含むので、貼り付け先は Backlog のこのプロジェクトの設定欄だけにしてください。" }),
3490
3769
  el("p", { class: "sec-how", text: "反映は受信から約1分後です(1分ごとのミニバッチでまとめて取り込みます)。初回は上の接続先の設定と、夜間同期が一度済んでいることが前提になります。" }),
3491
- ]);
3770
+ ];
3771
+
3772
+ if (live) {
3773
+ // 稼働中。**先頭は状態、手順は畳む。**「畳んである中に何があるか」を summary に書く
3774
+ // ——ただの「詳細」だと、URLを張り替えたい人がここを開く手掛かりを持てない
3775
+ var det = el("details", { class: "rt-steps-fold" }, [
3776
+ el("summary", { text: "設定手順と案件専用URLを表示(URLの再確認・張り替え用)" }),
3777
+ ]);
3778
+ for (var i = 0; i < steps.length; i++) det.appendChild(steps[i]);
3779
+ // 接続先ごとに1行。**1件のときはプロジェクトキーを出さない**——どれのことかは
3780
+ // 自明なので、キーを添えると「他にもあるのか」と読ませる余計な情報になる。
3781
+ // 複数のときは、どの接続先の話かが分からないと行が読めないので必ず添える
3782
+ var lines = el("div", { class: "rt-live-lines" }, targets.map(function (t) {
3783
+ var label = targets.length > 1 && t.projectKey ? t.projectKey + ": " : "";
3784
+ // 受信実績がまだ無い接続先も**黙って落とさない**。1つでも動いていれば
3785
+ // カードは稼働中の形になるので、落とすと「増やした接続先が来ていない」に気づけない
3786
+ return el("p", {
3787
+ class: t.ago ? "rt-live-line" : "rt-live-line is-waiting",
3788
+ text: t.ago ? label + "最終受信: " + t.ago : label + "まだ受信していません",
3789
+ });
3790
+ }));
3791
+ return el("div", { class: "ops-card rt-card is-live" }, [
3792
+ el("div", { class: "ops-card-head" }, [
3793
+ el("span", { class: "ops-card-icon", text: "⚡" }),
3794
+ el("h3", { class: "ops-card-title", text: "リアルタイム同期" }),
3795
+ el("span", { class: "rt-live-badge", text: "✓ 稼働中" }),
3796
+ ]),
3797
+ lines,
3798
+ el("p", { class: "ops-card-desc", text: "Backlog の変更をすぐ取り込んでいます(毎晩の同期を待ちません)。設定は済んでいるので、このままお使いください。" }),
3799
+ det,
3800
+ ]);
3801
+ }
3802
+
3803
+ return el("div", { class: "ops-card rt-card" }, [
3804
+ el("div", { class: "ops-card-head" }, [
3805
+ el("span", { class: "ops-card-icon", text: "⚡" }),
3806
+ el("h3", { class: "ops-card-title", text: "リアルタイム同期" }),
3807
+ ]),
3808
+ el("p", { class: "ops-card-desc", text: "Backlog で課題やコメントが変わったときに、その内容をすぐ取り込みます(毎晩の同期を待たずに反映されます)。Backlog 側で一度だけ設定してください。" }),
3809
+ ].concat(steps));
3492
3810
  }
3493
3811
 
3494
3812
  /** 鍵の入力欄に出す説明。kind は Lambda 側(resolveSecretTarget)と綴りを合わせること */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.68",
3
+ "version": "0.4.70",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,