@takagaki/cortex-decisions-viewer 0.4.74 → 0.4.76

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 +85 -14
  2. package/dist/render.js +333 -122
  3. package/package.json +1 -1
package/dist/build.js CHANGED
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.readFleetSources = readFleetSources;
40
+ exports.findBacklogDir = findBacklogDir;
40
41
  exports.build = build;
41
42
  exports.createRefResolver = createRefResolver;
42
43
  const node_fs_1 = require("node:fs");
@@ -146,8 +147,31 @@ async function readFleetSources(repoRoot) {
146
147
  // 自動導出されたものは登録ファイルから消しても翌日また現れるので、
147
148
  // explicit のときだけ削除ボタンを出す。
148
149
  origin: s.origin != null ? String(s.origin) : undefined,
150
+ // どの Slack ワークスペースのチャンネルか(#104)。**列挙し忘れると静かに消える**
151
+ // (このマッピングは素通しではなく明示の許可リスト)。落とすと、同じIDの
152
+ // チャンネルを2つのワークスペースに持つ案件で Gold トグルが別の行を撃つ
153
+ workspace: s.workspace != null ? String(s.workspace) : undefined,
149
154
  }))
150
155
  : undefined;
156
+ // 接続済み Slack ワークスペース(#104)。**空配列と undefined を区別して通す**——
157
+ // 空配列は「まだ1つも繋いでいない」(接続の導線を出してよい)、undefined は
158
+ // 「この項目を知らない古い生成物」(何も言えないので出さない)。
159
+ // ここで `|| undefined` に潰すと、その2つが同じ表示になる
160
+ const slackWorkspaces = Array.isArray(obj.slackWorkspaces)
161
+ ? obj.slackWorkspaces
162
+ .filter((w) => w != null && typeof w === "object")
163
+ .map((w) => ({
164
+ teamId: String(w.teamId ?? ""),
165
+ // 表示名が無ければ team_id。**空欄にしない**(どれのことか分からない行になる)
166
+ name: String(w.name ?? "") || String(w.teamId ?? ""),
167
+ domain: w.domain != null ? String(w.domain) : undefined,
168
+ connectedAt: w.connectedAt != null ? String(w.connectedAt) : undefined,
169
+ channels: typeof w.channels === "number" ? w.channels : undefined,
170
+ // ok / missing / revoked / unknown。**潰さない**(次の一手が違う)
171
+ tokenState: w.tokenState != null ? String(w.tokenState) : undefined,
172
+ }))
173
+ .filter((w) => w.teamId !== "")
174
+ : undefined;
151
175
  // セットアップの充足チェック。**missing だけを通す。**
152
176
  // na(使わないと宣言した能力)を通すと「設定する必要が無いものに設定を促す」偽の警告になり、
153
177
  // #101 で潰した穴が復活する。unknown は AWS/gh へ到達できなかった回に出るので、
@@ -249,17 +273,8 @@ async function readFleetSources(repoRoot) {
249
273
  applicable: typeof p.applicable === "boolean" ? p.applicable : undefined,
250
274
  }))
251
275
  : undefined;
252
- // 能力→ツールの宣言マップ(例: { チャット: "slack", 開発: "none" })。"none" は未使用のグレー行表示に使う
253
- let tools;
254
- if (obj.tools != null && typeof obj.tools === "object" && !Array.isArray(obj.tools)) {
255
- tools = {};
256
- for (const [k, v] of Object.entries(obj.tools)) {
257
- if (typeof v === "string")
258
- tools[k] = v;
259
- }
260
- if (!Object.keys(tools).length)
261
- tools = undefined;
262
- }
276
+ // **宣言(tools)は読まない(#125 ⑤)。** engine が出力から落としたので、
277
+ // 残っているのは再生成前の古いスナップショットだけ。読まずに捨てる。
263
278
  // 能力ごとの導出済みの状態(#125①が載せ、#125②で画面の正本になった)。
264
279
  //
265
280
  // **列挙し忘れは静かに消える。** このマッピングは素通しではなく明示の許可リストで、
@@ -313,7 +328,7 @@ async function readFleetSources(repoRoot) {
313
328
  const secretSources = obj.secretSources && typeof obj.secretSources === "object"
314
329
  ? obj.secretSources
315
330
  : undefined;
316
- return { externalSources, internalSources, pipelines, tools, capabilities, fleetGeneratedAt, secretSources, checks, githubOwners, backlogTarget };
331
+ return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, checks, githubOwners, backlogTarget };
317
332
  }
318
333
  /** パスがディレクトリとして存在するか */
319
334
  async function isDirectory(dirAbs) {
@@ -491,9 +506,65 @@ async function findDirByMarker(repoRoot, marker, fallback) {
491
506
  }
492
507
  return fallback;
493
508
  }
509
+ /** exporter が作る区画名(engine の `BACKLOG_SECTIONS` と同じ固定の名前) */
510
+ const BACKLOG_SECTIONS = ["issues", "documents", "wiki"];
511
+ /**
512
+ * 課題管理の能力ディレクトリ(`<ここ>/issues` に課題ミラーが入る)を探す。
513
+ *
514
+ * **汎用の `findDirByMarker` では足りない。** `backlog-settings.json` は能力ディレクトリの
515
+ * 直下ではなく**区画の中**(`<課題管理>/issues/` 等)にあり、しかも接続先が複数になると
516
+ * さらに1階層落ちる(`<課題管理>/<キー>/{issues,…}/`・#127)。汎用版は最上位の
517
+ * ディレクトリ名しか返さないので、入れ子形の案件では `<課題管理>/issues` という
518
+ * 実在しないパスを見に行き、**課題キー→Backlog URL の表が空になる**
519
+ * (グラフの課題ノードが全部リポ内パス止まりになり、Backlog へ飛べない)。
520
+ *
521
+ * 規則の出どころは cortex-engine の `plugin/scripts/issues-config.mjs`(`findBacklogDir`)。
522
+ * **あちらを変えたらここも変えること**(`viewer/test/backlog-nested-layout.test.mjs` が見張る)。
523
+ *
524
+ * 浅い順(ルート → 深さ1 → 深さ2)に見て最初に当たったものを採る。深い側は後ろなので、
525
+ * いまフラット形で見つかっている案件の答えは1つも動かない。
526
+ */
527
+ async function findBacklogDir(repoRoot, fallback) {
528
+ const dirsIn = async (rel) => {
529
+ try {
530
+ return (await node_fs_1.promises.readdir(path.join(repoRoot, rel), { withFileTypes: true }))
531
+ .filter((e) => e.isDirectory() && e.name !== "node_modules" && !e.name.startsWith("."))
532
+ .map((e) => e.name)
533
+ .sort();
534
+ }
535
+ catch {
536
+ return [];
537
+ }
538
+ };
539
+ const hasMarker = async (rel) => {
540
+ for (const s of BACKLOG_SECTIONS) {
541
+ try {
542
+ await node_fs_1.promises.stat(path.join(repoRoot, rel, s, "backlog-settings.json"));
543
+ return true;
544
+ }
545
+ catch {
546
+ // 次の区画へ
547
+ }
548
+ }
549
+ return false;
550
+ };
551
+ if (await hasMarker(""))
552
+ return fallback; // ルート直下に区画がある形(`""` を返すと絶対パスになる)
553
+ const dirs = await dirsIn("");
554
+ for (const d of dirs)
555
+ if (await hasMarker(d))
556
+ return d;
557
+ for (const d of dirs) {
558
+ for (const sub of await dirsIn(d)) {
559
+ if (await hasMarker(`${d}/${sub}`))
560
+ return `${d}/${sub}`;
561
+ }
562
+ }
563
+ return fallback;
564
+ }
494
565
  async function buildTargetUrlMap(repoRoot, repoBaseUrl, branch) {
495
566
  const map = new Map();
496
- const issuesDir = await findDirByMarker(repoRoot, "backlog-settings.json", "課題管理");
567
+ const issuesDir = await findBacklogDir(repoRoot, "課題管理");
497
568
  const designDir = await findDirByMarker(repoRoot, "figma.json", "デザイン");
498
569
  const meetingDir = await findDirByMarker(repoRoot, "ingest-config.json", "会議");
499
570
  const materialsDir = await findDirByMarker(repoRoot, "materials-config.json", "共有資料");
@@ -1189,11 +1260,11 @@ async function build(opts) {
1189
1260
  .map((s) => s.trim())
1190
1261
  .filter(Boolean),
1191
1262
  externalSources: fleet.externalSources,
1263
+ slackWorkspaces: fleet.slackWorkspaces,
1192
1264
  fleetGeneratedAt: fleet.fleetGeneratedAt,
1193
1265
  secretSources: fleet.secretSources,
1194
1266
  internalSources: fleet.internalSources,
1195
1267
  pipelines: fleet.pipelines,
1196
- tools: fleet.tools,
1197
1268
  capabilities: fleet.capabilities,
1198
1269
  checks: fleet.checks,
1199
1270
  githubOwners: fleet.githubOwners,
package/dist/render.js CHANGED
@@ -132,15 +132,19 @@ const CM_CAPS = [
132
132
  { cap: "チャット", icon: "💬", pipe: null },
133
133
  { cap: "開発", icon: "🐙", pipe: null },
134
134
  ];
135
- /** ツールid→表示名(接続マップのノードラベル用) */
135
+ /**
136
+ * ツールid→表示名(接続マップのノードラベル用)。
137
+ *
138
+ * **キーは ADAPTER_CATALOG の値だけ。** かつては jira / teams / box / local / none も
139
+ * 持っていたが、これらは宣言(Home.md の `tools`)にしか現れない値だった。宣言を撤去した
140
+ * いま、ここに来るのはカタログのアダプタだけ(`capabilityAdapter` がカタログ外を弾く)。
141
+ * **未対応ツールの表示名を復活させないこと**——名前を出した瞬間「対応している」と
142
+ * 読まれるのが #124 の症状そのもので、状態を出す場所すら無いまま名前だけが並ぶ。
143
+ */
136
144
  const CM_TOOL_LABEL = {
137
145
  backlog: "Backlog",
138
- jira: "Jira",
139
146
  "google-meet": "Google Meet",
140
- teams: "Teams",
141
147
  "google-drive": "Google Drive",
142
- box: "Box",
143
- local: "ローカル",
144
148
  figma: "Figma",
145
149
  slack: "Slack",
146
150
  github: "GitHub",
@@ -178,8 +182,8 @@ function sourceNeedsAttention(s) {
178
182
  * **クロージャを持たない純関数に保つこと**——外の変数を掴むと直列化した先で壊れる。
179
183
  *
180
184
  * 返す値:
181
- * "off" … 未使用(tools が none / enabled:false / 使っている証拠が無い)
182
- * "idle" … 宣言はあるが一度も繋がっていない(画面表記は「未接続」)
185
+ * "off" … 未使用(enabled:false / 使っている証拠が無い)
186
+ * "idle" … 設定はあるが一度も繋がっていない(画面表記は「未接続」)
183
187
  * "warn" … 問題あり
184
188
  * "ok" … 健全
185
189
  *
@@ -189,7 +193,7 @@ function sourceNeedsAttention(s) {
189
193
  * APIキー未投入で sync-backlog が失敗し、課題管理が警告色で線付きになった)。
190
194
  *
191
195
  * @param cap 能力名
192
- * @param ctx 生のフリートデータ { tools, internals, externals, pipes }。
196
+ * @param ctx 生のフリートデータ { capabilities, internals, externals, pipes }。
193
197
  * **解決済みの値を渡させない。** 振り分けや regex を呼び出し側に持たせると、
194
198
  * 呼び出し側ごとに写経が生まれ、片方が書き忘れると判定が静かに変わる。
195
199
  */
@@ -311,7 +315,6 @@ function capabilityState(cap, ctx) {
311
315
  // (読み手は未使用を信じて押さないので、行が消えるより悪い)
312
316
  const liveExts = ctx.externals.filter(function (s) { return externalCapability(s.type) === cap; });
313
317
  const isLive = cap === "チャット" || cap === "開発" || cap === CAP_OTHER;
314
- const toolVal = ctx.tools[cap];
315
318
  const internal = ctx.internals.filter(function (s) { return s.kind === cap; })[0];
316
319
  const pipeRe = PIPE[cap];
317
320
  // ---- #125②: 導出(capabilities)があるならそれが状態の正本 ----
@@ -346,12 +349,16 @@ function capabilityState(cap, ctx) {
346
349
  // 答えが割れることはない。**この2つを分けるなら、写像とここの両方を直すこと。**
347
350
  return "idle";
348
351
  }
349
- // ---- ここから下は capabilities を持たない古い fleet-status.json のための劣化表示 ----
350
- // 使用中か: tools宣言がnone以外 / internalSourceが存在(enabled:false除く) / ライブ参照の実体がある
351
- const used = (toolVal != null && toolVal !== "none") ||
352
- (internal != null && internal.enabled !== false) ||
352
+ // ---- ここから下は capabilities を持たない fleet-status.json のための防御 ----
353
+ //
354
+ // **宣言(tools)はもう見ない(#125 ⑤)。** 艦隊の fleet-status.json は全案件が
355
+ // capabilities を持つので、通常ここへは来ない。残してあるのは、生成前・取得失敗・
356
+ // 途中で壊れたデータで**画面が落ちない**ようにするためだけ——実体(同期ソースの行・
357
+ // ライブ参照)だけで判定し、無ければ「まだ繋がっていない」側へ倒す。
358
+ // **ここに宣言を読む道を足さないこと。** 足した瞬間、実態と食い違える経路が復活する。
359
+ const used = (internal != null && internal.enabled !== false) ||
353
360
  (isLive && liveExts.length > 0);
354
- const off = toolVal === "none" || (internal != null && internal.enabled === false) || !used;
361
+ const off = (internal != null && internal.enabled === false) || !used;
355
362
  // 問題あり: ライブ参照はgate≠okが1件以上、同期ソースは対応パイプラインの直近runがfailure
356
363
  const warn = !off &&
357
364
  (isLive
@@ -395,21 +402,38 @@ function capabilityState(cap, ctx) {
395
402
  : !syncedForReal);
396
403
  return off ? "off" : idle ? "idle" : warn ? "warn" : "ok";
397
404
  }
405
+ /**
406
+ * `repoBaseUrl`(https://github.com/owner/repo)からリポジトリ名だけを取り出す。
407
+ *
408
+ * **owner は出さない。** 画面は1案件ぶんしか映さないので、どの組織かは常に同じ——
409
+ * 出しても名前が長くなるだけで、読み手の判断は変わらない。
410
+ * 取れない形(URLが無い・GitHub 以外・末尾が空)では空文字を返し、呼び出し側が落とす。
411
+ *
412
+ * **github.com のURLだけを受ける。** 隣に GitHub のロゴを置くので、別ホストの名前を
413
+ * 通すとロゴが嘘になる。
414
+ */
415
+ function repoNameOf(repoBaseUrl) {
416
+ if (!repoBaseUrl)
417
+ return "";
418
+ const m = /^https?:\/\/github\.com\/(.+)$/.exec(String(repoBaseUrl));
419
+ if (!m)
420
+ return "";
421
+ // **末尾スラッシュを先に落とす。** 後にすると `repo.git/` の `.git` が残る
422
+ const slug = m[1].replace(/\/+$/, "").replace(/\.git$/, "");
423
+ const name = slug.split("/").pop() || "";
424
+ // owner だけの URL(github.com/owner)を名前と取り違えない
425
+ return slug.includes("/") ? name : "";
426
+ }
398
427
  function renderConnectionMap(site) {
399
428
  const internals = site.internalSources ?? [];
400
429
  const externals = site.externalSources ?? [];
401
430
  const pipes = site.pipelines ?? [];
402
- const tools = site.tools ?? {};
403
431
  const caps = site.capabilities ?? {};
404
432
  // **fleet のデータが1つでも取れているか、だけで判定する(#125②)。**
405
433
  // 素の Decisions ビュー(fleet-status.json が無い構成)ではマップを出さない現行の意図は
406
- // そのまま。`tools` を条件から外さないのは、capabilities を持たない古いスナップショットで
407
- // マップが丸ごと消えるのを防ぐため(移行中は両方が現物として存在する)。
408
- if (!internals.length &&
409
- !externals.length &&
410
- !pipes.length &&
411
- !Object.keys(tools).length &&
412
- !Object.keys(caps).length) {
434
+ // そのまま。宣言(`tools`)は条件から外した(⑤)——艦隊の fleet-status.json
435
+ // すべて capabilities を持つので、宣言はもうマップの有無を左右しない。
436
+ if (!internals.length && !externals.length && !pipes.length && !Object.keys(caps).length) {
413
437
  return null;
414
438
  }
415
439
  const hasCaps = Object.keys(caps).length > 0;
@@ -421,13 +445,12 @@ function renderConnectionMap(site) {
421
445
  const REPO_X = W / 2;
422
446
  CM_CAPS.forEach((c, i) => {
423
447
  const cx = 80 + i * 160;
424
- const toolVal = tools[c.cap];
425
448
  const internal = internals.find((s) => s.kind === c.cap);
426
449
  const isLive = c.cap === "チャット" || c.cap === "開発";
427
450
  const liveExts = c.cap === "チャット" ? slackExts : c.cap === "開発" ? ghExts : [];
428
451
  // **判定は capabilityState に一本化している。** 画面側(クライアントJS)も同じ関数を
429
452
  // 呼ぶので、ここで写経すると「同じ画面でマップが⚠️・直下が『接続OK』」が復活する。
430
- const st = capabilityState(c.cap, { tools, capabilities: caps, internals, externals, pipes });
453
+ const st = capabilityState(c.cap, { capabilities: caps, internals, externals, pipes });
431
454
  const off = st === "off";
432
455
  const idle = st === "idle";
433
456
  const warn = st === "warn";
@@ -437,22 +460,19 @@ function renderConnectionMap(site) {
437
460
  // ツールは「その能力をいま何で実現しているか」という差し替え可能なアダプタで、
438
461
  // 主語ではない。ツール名を主にすると、Teams のように複数の能力に使われるものが
439
462
  // 同じ名前で並び(会議とチャットに Teams が2つ)、何が繋がっているのか読めなくなる。
440
- // "none"(未使用の宣言)はツール名ではないので出さない。そのまま表示すると
441
- // 「ツール: none」という読めない行になる(実際にそう出ていた)
442
463
  //
443
- // **導出があるならカタログのアダプタを常に出す(#125②)。** 未接続でも「何で繋ぐのか」が
444
- // 分からないと入口にならない。カタログ外の値(teams 等)は capabilityAdapter が弾く。
464
+ // **カタログのアダプタを常に出す(#125②)。** 未接続でも「何で繋ぐのか」が
465
+ // 分からないと入口にならない。カタログ外の値は capabilityAdapter が弾く。
466
+ // capabilities を持たないデータでは、実体(同期ソースの行・ライブ参照)から拾う(⑤)。
445
467
  const rawTool = hasCaps
446
468
  ? capabilityAdapter(c.cap, caps)
447
- : toolVal && toolVal !== "none"
448
- ? toolVal
449
- : internal && internal.tool && internal.tool !== "none"
450
- ? internal.tool
451
- : isLive && liveExts.length
452
- ? c.cap === "チャット"
453
- ? "slack"
454
- : "github"
455
- : "";
469
+ : internal && internal.tool && internal.tool !== "none"
470
+ ? internal.tool
471
+ : isLive && liveExts.length
472
+ ? c.cap === "チャット"
473
+ ? "slack"
474
+ : "github"
475
+ : "";
456
476
  const toolName = rawTool ? CM_TOOL_LABEL[rawTool] ?? rawTool : "";
457
477
  const label = `${c.icon} ${c.cap}`;
458
478
  // 2行目=ツール名(小さく)/3行目=状態(未使用・自動連携なし・ライブ参照・最終同期)
@@ -508,10 +528,26 @@ function renderConnectionMap(site) {
508
528
  links.push(`<path class="cm-link ${state}" d="M ${cx} 78 C ${cx} 152, ${REPO_X} 162, ${REPO_X} 238"></path>`);
509
529
  }
510
530
  });
511
- const repoTitle = site.title.length > 24 ? site.title.slice(0, 23) + "…" : site.title;
531
+ // **中央ノードは GitHub のリポジトリ名そのもの(オーナー指示・2026-08-12)。**
532
+ // 以前は「📦 コンテキストリポジトリ(案件の表示名)」だったが、役割名は図の文脈から自明で、
533
+ // 表示名は上下のノードとも重複していた。実際に開く先はリポジトリなので、その名前で呼ぶ。
534
+ // 名前は repoBaseUrl から導出する(新しいデータは足さない)。取れない構成では
535
+ // 案件の表示名へ落とす——名前の無い箱にしない。
536
+ const repoName = repoNameOf(site.repoBaseUrl) || site.title;
537
+ const repoLabel = repoName.length > 24 ? repoName.slice(0, 23) + "…" : repoName;
538
+ // アイコンと名前を**まとめて中央へ置く**。text-anchor=middle だけだとアイコンの分だけ
539
+ // 全体が右に寄るので、合計幅を見積もってから左端を決める。日本語が混じる案件名へ
540
+ // フォールバックした場合に備え、全角は倍幅で数える(14px/700 の実測に合わせた概算)
541
+ const ICON = 18;
542
+ const GAP = 8;
543
+ const textW = [...repoLabel].reduce((w, ch) => w + (ch.charCodeAt(0) < 128 ? 8.2 : 14), 0);
544
+ const startX = REPO_X - (ICON + GAP + textW) / 2;
512
545
  const repo = `<g class="cm-repo">` +
513
546
  `<rect x="${REPO_X - 170}" y="240" width="340" height="52" rx="12"></rect>` +
514
- `<text class="cm-repo-label" x="${REPO_X}" y="272" text-anchor="middle">📦 コンテキストリポジトリ(${escapeHtml(repoTitle)})</text>` +
547
+ // ロゴはビルド時に埋め込む(ヘッダ右上・開発ソースのカードと同じ GITHUB_ICON)。外部通信は起きない。
548
+ // 入れ子の svg を transform で置き、色は cm-repo-logo の color を継ぐ(fill="currentColor")
549
+ `<g class="cm-repo-logo" transform="translate(${startX.toFixed(1)}, ${266 - ICON / 2 - 1})">${GITHUB_ICON}</g>` +
550
+ `<text class="cm-repo-label" x="${(startX + ICON + GAP).toFixed(1)}" y="272">${escapeHtml(repoLabel)}</text>` +
515
551
  `</g>`;
516
552
  return (`<svg class="cm-svg" viewBox="0 0 ${W} 312" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="データソースとの接続マップ">` +
517
553
  links.join("") +
@@ -554,15 +590,14 @@ function buildTutorialData(site, codeUrl, repoSlug) {
554
590
  },
555
591
  ];
556
592
  // ここから先は「書き込み・外部連携」の体験。案件の構成に無い能力のチャレンジは出さない(表示条件つき)
557
- const tools = site.tools ?? {};
558
593
  const caps = site.capabilities ?? {};
559
594
  const internals = site.internalSources ?? [];
560
595
  const externals = site.externalSources ?? [];
561
596
  /**
562
- * その能力が配線済みか。**導出(capabilities)があるならそれだけで決める(#125②)。**
597
+ * その能力が配線済みか。**導出(capabilities)だけで決める(#125②)。**
563
598
  * 宣言でチャレンジを出していた頃は、Backlog を宣言しているだけで鍵も入っていない案件に
564
599
  * 「記票してみよう」が出ていた——押すと必ず失敗する体験を最初の5分に置くことになる。
565
- * 導出が無い古いデータでは null を返し、呼び出し側が従来の判定へ落ちる。
600
+ * 導出が無いデータでは null を返し、呼び出し側が実体(行の有無)だけで判断する(⑤)。
566
601
  */
567
602
  const capOn = (cap) => {
568
603
  const s = capabilityRawState(cap, caps);
@@ -578,8 +613,7 @@ function buildTutorialData(site, codeUrl, repoSlug) {
578
613
  }
579
614
  // Backlogに記票する体験(課題管理にBacklogを使う案件のみ出す)
580
615
  const usesBacklog = capOn("課題管理") ??
581
- (tools["課題管理"] === "backlog" ||
582
- internals.some((s) => s.kind === "課題管理" && s.tool === "backlog" && s.enabled !== false));
616
+ internals.some((s) => s.kind === "課題管理" && s.tool === "backlog" && s.enabled !== false);
583
617
  if (usesBacklog) {
584
618
  specs.push({
585
619
  title: "Backlogに記票してみよう",
@@ -588,9 +622,7 @@ function buildTutorialData(site, codeUrl, repoSlug) {
588
622
  });
589
623
  }
590
624
  // 画面インベントリを読む体験(デザインを同期している案件のみ出す)
591
- const usesDesign = capOn("デザイン") ??
592
- ((tools["デザイン"] != null && tools["デザイン"] !== "none") ||
593
- internals.some((s) => s.kind === "デザイン" && s.enabled !== false));
625
+ const usesDesign = capOn("デザイン") ?? internals.some((s) => s.kind === "デザイン" && s.enabled !== false);
594
626
  if (usesDesign) {
595
627
  specs.push({
596
628
  title: "デザインを読んでみよう",
@@ -599,7 +631,7 @@ function buildTutorialData(site, codeUrl, repoSlug) {
599
631
  });
600
632
  }
601
633
  // 完了をSlackに知らせる体験(チャットにSlackを宣言している案件のみ出す。最終チャレンジ)
602
- const usesSlack = capOn("チャット") ?? (tools["チャット"] === "slack" || externals.some((s) => s.type === "slack"));
634
+ const usesSlack = capOn("チャット") ?? externals.some((s) => s.type === "slack");
603
635
  if (usesSlack) {
604
636
  specs.push({
605
637
  title: "Slackに完了を知らせよう",
@@ -1132,6 +1164,8 @@ a.ds-name:hover { text-decoration: underline; }
1132
1164
  @media (prefers-reduced-motion: reduce) { .cm-link.cm-ok { animation: none; } }
1133
1165
  .cm-repo rect { fill: var(--accent-weak); stroke: var(--accent); stroke-width: 1.5; }
1134
1166
  .cm-repo-label { font-size: 14px; font-weight: 700; fill: #1e3a8a; }
1167
+ /* GitHubロゴは fill="currentColor" なので、色は color で渡す(ラベルと同色に揃える) */
1168
+ .cm-repo-logo { color: #1e3a8a; }
1135
1169
  /* 未使用(非活性)行: アダプターとして存在するが、この案件では設定されていないもの */
1136
1170
  .ds-card.ds-inactive, .ds-pipe.ds-inactive { background: #f8f9fa; border-style: dashed; box-shadow: none; }
1137
1171
  .ds-inactive .ds-icon { filter: grayscale(1); opacity: .55; }
@@ -1216,6 +1250,16 @@ a.ds-name:hover { text-decoration: underline; }
1216
1250
  /* 登録の由来(#118)。**薄く出す**——読み取りの可否や Gold 対象かより弱い情報で、
1217
1251
  ここが目立つと「除外」「未宣言」の判断のバッジと同じ強さに見えてしまう */
1218
1252
  .badge.ds-origin { background: transparent; color: var(--muted); border: 1px dashed var(--border); }
1253
+ /* どの Slack ワークスペースのチャンネルか(#104)。**由来のバッジと同じ強さにする**——
1254
+ 「Gold 対象か」より弱い、どれのことかを見分けるための情報。強くすると判断のバッジと競る */
1255
+ .badge.ds-workspace { background: transparent; color: var(--muted); border: 1px solid var(--border); }
1256
+ /* 接続済み Slack ワークスペースの一覧(#104)。追加フォームの中に置く */
1257
+ .ds-ws-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 10px; }
1258
+ .ds-ws { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-size: 12.5px; }
1259
+ .ds-ws-name { color: var(--text); font-weight: 600; word-break: break-all; }
1260
+ .ds-ws-count { color: var(--muted); flex: 0 0 auto; }
1261
+ /* 1つも繋いでいない状態。**空白にしない**——一覧が出ていないのか繋がっていないのかが読めない */
1262
+ .ds-ws-empty { color: var(--muted); font-size: 12.5px; }
1219
1263
  /* 全組織が接続済みのときに、トークン入力欄の代わりに出す1行(#118) */
1220
1264
  .ds-all-connected { color: var(--muted); }
1221
1265
  .ds-gate { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 600; flex: 0 0 auto; }
@@ -1463,11 +1507,16 @@ ${capabilityState.toString()}
1463
1507
  }
1464
1508
  // Cortex自体を操作するアクションと、接続・自動化の情報の受け皿(取り込み・接続マップ・データソース)。
1465
1509
  // 投入フォーム設定 or fleet-status由来の接続情報のどちらかがあれば出す。
1466
- // **fleet のデータが取れているか、だけで決める(#125②)。** 能力の宣言(tools)は廃止したので
1467
- // capabilities を条件に足す。**常に真にはしない**——fleet-status.json を持たない素の
1468
- // Decisions ビューでは操作タブそのものを出さない現行の意図を保つ
1510
+ // **fleet のデータが取れているか、だけで決める(#125②)。** 能力の宣言(tools)は撤去した(⑤)。
1511
+ // **常に真にはしない**——fleet-status.json を持たない素の
1512
+ // Decisions ビューでは操作タブそのものを出さない現行の意図を保つ。
1513
+ //
1514
+ // **鍵(secretKinds)も候補に数える。** 下の節(buildDataSources)は鍵だけの案件でも
1515
+ // 出す作りだが、タブが立たなければその判定に辿り着けない。宣言を撤去するまでは
1516
+ // 空の宣言オブジェクトが truthy でここを通していたため、鍵だけの案件が通っていたのは
1517
+ // **偶然**だった(テストもその偶然に乗っていた)。候補として明示する
1469
1518
  var HAS_FLEET = !!((SITE.internalSources || []).length || (SITE.externalSources || []).length ||
1470
- (SITE.pipelines || []).length || SITE.capabilities || SITE.tools);
1519
+ (SITE.pipelines || []).length || SITE.capabilities || (SITE.secretKinds || []).length);
1471
1520
  if (SITE.intake || HAS_FLEET) {
1472
1521
  TABS.push({ key: "ops", label: "操作" });
1473
1522
  }
@@ -2018,23 +2067,18 @@ ${capabilityState.toString()}
2018
2067
  * 誤った封鎖を生んでいた(実際に Slack を渡されているのに追加できない案件があった)。
2019
2068
  * カタログはこの製品が実装しているアダプタの定義なので、押して必ず失敗することはない。
2020
2069
  *
2021
- * **capabilities を持たない古いデータでは、従来どおり宣言で見る。** 移行前の
2022
- * スナップショットには宣言しか無く、そこで全面開放すると別ツール宣言の案件に
2023
- * 必ず400になるボタンが復活する。
2070
+ * **宣言によるゲートは撤去した(⑤)。** capabilities を持たないデータでも
2071
+ * カタログだけで判断する——判定の道は1本にする。
2024
2072
  */
2025
2073
  function capabilityUsable(cap, tool) {
2026
- var caps = fleetVal("capabilities") || {};
2027
- if (Object.keys(caps).length) return (ADAPTER_CATALOG[cap] || []).indexOf(tool) >= 0;
2028
- var v = (fleetVal("tools") || {})[cap];
2029
- return !v || v === "none" || v === tool;
2074
+ return (ADAPTER_CATALOG[cap] || []).indexOf(tool) >= 0;
2030
2075
  }
2031
2076
 
2032
2077
  /** 組み立てだけを行う(設置は呼び出し側)。ライブ差し替えで同じ節を作り直すため分けている */
2033
2078
  /** ツールid→表示名(クライアント側。接続マップの CM_TOOL_LABEL と同じ内容) */
2034
2079
  var CM_TOOL_LABEL_C = {
2035
- backlog: "Backlog", jira: "Jira", "google-meet": "Google Meet", teams: "Teams",
2036
- "google-drive": "Google Drive", box: "Box", local: "ローカル", figma: "Figma",
2037
- slack: "Slack", github: "GitHub", none: "",
2080
+ backlog: "Backlog", "google-meet": "Google Meet", "google-drive": "Google Drive",
2081
+ figma: "Figma", slack: "Slack", github: "GitHub",
2038
2082
  };
2039
2083
 
2040
2084
  /**
@@ -2073,11 +2117,6 @@ ${capabilityState.toString()}
2073
2117
  // 能力ごとの導出済みの状態(#125② の正本)。持たない古いデータでは {}
2074
2118
  var caps = fleetVal("capabilities") || {};
2075
2119
  var hasCaps = Object.keys(caps).length > 0;
2076
- // 能力→ツールの宣言(例: チャット: "none")。**capabilities が無いときだけ効く劣化表示。**
2077
- // "none" の能力は「未使用」のグレー行で存在だけ見せる
2078
- var tools = (!hasCaps && fleetVal("tools")) || {};
2079
- var chatOff = tools["チャット"] === "none";
2080
- var devOff = tools["開発"] === "none";
2081
2120
  // **この節に入るものが1つでもあるなら出す。**
2082
2121
  //
2083
2122
  // 条件を「データ行があるか」で書いていて、2回続けて漏らした——
@@ -2087,9 +2126,8 @@ ${capabilityState.toString()}
2087
2126
  //
2088
2127
  // 個別に足し続けると次も漏らすので、**この節に出す候補を列挙して判定する**形にする。
2089
2128
  var hasSecrets = ((SITE.secretKinds || []).filter(function (k) { return SECRET_FORMS[k]; })).length > 0;
2090
- // **fleet のデータが取れているか、だけを見る(#125②)。** 宣言(tools)は廃止したが、
2091
- // capabilities を持たない古いスナップショットで節ごと消えないよう条件には残す
2092
- var hasCapModel = hasCaps || Object.keys(tools).length > 0;
2129
+ // **fleet のデータが取れているか、だけを見る(#125②)。** 宣言(tools)は撤去した(⑤)
2130
+ var hasCapModel = hasCaps;
2093
2131
  // **投入の口も候補。** fleet-status.json はGitHub Actionsが夜間に生成するので、
2094
2132
  // 立ち上げ直後の案件には存在しない。それを理由に節ごと消すと、
2095
2133
  // 文字起こし・資料を入れる口が**一番必要な瞬間に**画面から無くなる
@@ -2246,7 +2284,7 @@ ${capabilityState.toString()}
2246
2284
  });
2247
2285
  }
2248
2286
 
2249
- if (sources.length || chatOff || devOff) {
2287
+ if (sources.length) {
2250
2288
  // **見出しは付けない。** #101 で行は能力ごとのモーダルへ移ったので、
2251
2289
  // ここに見出しを置くと中身ゼロの見出しが節の先頭に立つ(実際に8案件すべてで出ていた)
2252
2290
  // ツール(type)別にグループ化し、多いグループ(4件以上)は折りたたむ
@@ -2258,9 +2296,6 @@ ${capabilityState.toString()}
2258
2296
  g.items.push(s);
2259
2297
  });
2260
2298
  var UNIT = { "slack": "チャンネル", "github-issues": "リポジトリ", "github-discussions": "リポジトリ" };
2261
- // チャット未使用: Slackグループの位置(先頭)に「存在するが未設定」のグレー行を出す
2262
- // **アンカーは渡さない**(タイルが飛び先。同 §id の重複を作らない)
2263
- if (chatOff) pushCap("チャット", inactiveSourceRow("💬", "チャット", null));
2264
2299
  groups.forEach(function (g) {
2265
2300
  var meta = DS_TYPE[g.type] || { icon: "🔗", label: g.type || "その他" };
2266
2301
  var cards = el("div", { class: "ds-list" });
@@ -2297,12 +2332,13 @@ ${capabilityState.toString()}
2297
2332
  }
2298
2333
  });
2299
2334
  }
2300
- // 開発未使用: グレー行も能力の受け皿へ
2301
- if (devOff) pushCap("開発", inactiveSourceRow("🐙", "GitHub", null));
2302
2335
 
2303
2336
  // **追加フォームはグループの外に置く。** グループは0件だと描かれず、4件以上だと
2304
2337
  // 畳まれる(既定は閉)。中に入れると**新規案件(0件)=一番追加したい場面**で消える。
2305
2338
  var adders = renderExternalAdders();
2339
+ // **接続の入口はチャンネル追加より前に置く。** 顧客ワークスペースを繋いでいない状態で
2340
+ // 先に追加フォームを見せると、貼れるのに読まれないチャンネルを登録することになる
2341
+ pushCap("チャット", slackConnectBox());
2306
2342
  pushCap("チャット", adders.querySelector('[data-adder="slack"]'));
2307
2343
  // **組織ごとの接続状況を、開発の一番上に置く。** トークンは組織ごとにしか
2308
2344
  // 効かない(Fine-grained PAT も GitHub App のインストールトークンも1組織分)ので、
@@ -2450,10 +2486,11 @@ ${capabilityState.toString()}
2450
2486
  // 両方に出すと、同じ失敗が1画面に2回並ぶ。実行状況はパイプライン節に任せ、
2451
2487
  // ここでは直し方だけをその行に添える(下の renderPipelines で使う)
2452
2488
  var PIPE_CHECK = { "update-gold": "nightly_decisionlog", "sync-backlog": "nightly_backlog", "sync-designs": "figma_sync" };
2489
+ // **engine が出しているチェックだけを並べる。** meeting_minutes / channels_json は
2490
+ // 同語反復(適用されるときは必ず ok)だったので engine が撤去した(#125 ⑤)。
2491
+ // 割り当ての無いチェックは消えず、下の「セットアップの残り」へ落ちる
2453
2492
  var CHECK_CAP = {
2454
2493
  backlog_secrets: "課題管理", backlog_synced: "課題管理",
2455
- meeting_minutes: "会議",
2456
- channels_json: "チャット",
2457
2494
  figma_token: "デザイン", figma_inventory: "デザイン",
2458
2495
  dev_sources_registered: "開発", dev_issues_derived: "開発",
2459
2496
  };
@@ -2515,12 +2552,12 @@ ${capabilityState.toString()}
2515
2552
  // 上のマップと直下の一覧で語が割れない
2516
2553
  parts.push((CAP_STATE_MARK[st] || "") + (CAP_STATE_TEXT[derived] || "状態が判定できません"));
2517
2554
  } else {
2518
- // **off には2つの意味が同居している。** 「使わないと宣言した」(tools が none)と
2555
+ // **off には2つの意味が同居している。** 「意図的に止めている」(enabled:false)と
2519
2556
  // 「まだ何も分かっていない」(fleet-status.json を夜間バッチがまだ作っていない)。
2520
2557
  // 後者を「未使用」と書くと、立ち上げ直後の案件で顧客が
2521
2558
  // 「Backlog も Slack も使わない案件だ」と読む——繋ぐ前提で契約しているのに
2522
- var declaredOff = tools[cap] === "none" || (internal != null && internal.enabled === false);
2523
- parts.push(st === "off" && !declaredOff ? "未設定" : STATE_LABEL[st] || "");
2559
+ var intentionalOff = internal != null && internal.enabled === false;
2560
+ parts.push(st === "off" && !intentionalOff ? "未設定" : STATE_LABEL[st] || "");
2524
2561
  }
2525
2562
  // 読み取り範囲。**この画面は顧客も見る。何をGoldに読んでいるかの開示が主眼**
2526
2563
  //
@@ -2564,11 +2601,9 @@ ${capabilityState.toString()}
2564
2601
  // ——未接続のタイルは「これから繋げる入口」で、消すと繋ぎ方を知る場所が無くなる。
2565
2602
  // カタログは製品の定義なので、案件データが何であってもここは変わらない
2566
2603
  var adapters = (hasCaps && ADAPTER_CATALOG[cap]) || [];
2567
- // 旧データ(capabilities 無し)は従来どおり「宣言があれば行が無くても出す」
2568
- var declared = tools[cap] != null;
2569
- // **手動投入はアダプタに依存しない。** 宣言や行の有無で弾くと、
2604
+ // **手動投入はアダプタに依存しない。** 行の有無で弾くと、
2570
2605
  // fleet-status.json がまだ無い新規案件で投入の口ごと消える
2571
- if (!adapters.length && !items.length && !declared && !manualBox[cap]) return;
2606
+ if (!adapters.length && !items.length && !manualBox[cap]) return;
2572
2607
  anyCap = true;
2573
2608
  sec.appendChild(el("div", { class: "ds-cap", text: cap }));
2574
2609
  if (adapters.length) {
@@ -2579,11 +2614,11 @@ ${capabilityState.toString()}
2579
2614
  adapters.forEach(function (a, ai) {
2580
2615
  renderAdapterTile(cap, items, a, a === active ? capabilityDisplayState(cap, caps) : "unset", ai);
2581
2616
  });
2582
- } else if (items.length || declared) {
2583
- // 旧データ: アダプタのタイルは宣言か行があるときだけ。手動投入だけが理由でここへ来た
2584
- // 新規案件に、中身の無い「会議」タイルを立てても押す先が無い
2585
- var legacyTool = tools[cap] && tools[cap] !== "none" ? tools[cap] : "";
2586
- renderAdapterTile(cap, items, legacyTool, null, 0);
2617
+ } else if (items.length) {
2618
+ // capabilities を持たないデータ: アダプタのタイルは行があるときだけ。手動投入だけが
2619
+ // 理由でここへ来た新規案件に、中身の無い「会議」タイルを立てても押す先が無い。
2620
+ // アダプタ名は名乗らない(宣言を撤去したので、名乗れる根拠がここには無い・⑤)
2621
+ renderAdapterTile(cap, items, "", null, 0);
2587
2622
  }
2588
2623
  renderManualTile(cap);
2589
2624
  });
@@ -2591,7 +2626,7 @@ ${capabilityState.toString()}
2591
2626
  /**
2592
2627
  * 能力×アダプタ1件のタイル。
2593
2628
  *
2594
- * @param adapter アダプタ識別子(カタログ由来。旧データでは宣言 tools 由来)
2629
+ * @param adapter アダプタ識別子(カタログ由来。capabilities が無いデータでは空)
2595
2630
  * @param derived engine が導出した5値。旧データ(capabilities 無し)では null
2596
2631
  * @param ai その能力の中での並び順。先頭だけが接続マップの飛び先(ds-<能力>)になる
2597
2632
  */
@@ -2600,7 +2635,7 @@ ${capabilityState.toString()}
2600
2635
  var st = derived === "unset" && capabilityDisplayState(cap, caps) !== "unset"
2601
2636
  // カタログにあるが導出が答えていないアダプタ(今日は発生しない。#107 の前方互換)
2602
2637
  ? "idle"
2603
- : capabilityState(cap, { tools: tools, capabilities: caps, internals: internals, externals: sources, pipes: pipes });
2638
+ : capabilityState(cap, { capabilities: caps, internals: internals, externals: sources, pipes: pipes });
2604
2639
  // **ロゴはタイルの左に独立して置く。** 名前の行に入れると名前の高さに
2605
2640
  // 縛られて小さくなる。ロゴで能力を見分けられるのが一覧の主な用途
2606
2641
  var logo = ADAPTER_ICONS[adapter];
@@ -2647,7 +2682,8 @@ ${capabilityState.toString()}
2647
2682
  // **注記の条件は、入力欄を出す条件と同じにする(0.4.71)。** 別々にすると
2648
2683
  // 「入力欄はあるのに未設定だと言われない」「言われるのに入力欄が無い」のどちらかが起きる。
2649
2684
  // 0.4.70 は unset で入力欄を畳んでいたので注記も抑えていたが、案A で欄を出すようにしたため揃えた
2650
- var tokenRelevant = derived != null ? derived !== "off" : tools[cap] !== "none";
2685
+ // 導出が無いデータでは判断材料が無いので、注記を出す側(=入力欄と揃う側)に倒す
2686
+ var tokenRelevant = derived != null ? derived !== "off" : true;
2651
2687
  if (!tokenIsFallback && tokenRelevant && sk && (SITE.secretKinds || []).indexOf(sk) >= 0 && !((fleetVal("secretSources") || {})[sk])) {
2652
2688
  body.appendChild(el("div", { class: "ds-tile-note", text: "⚠ トークンが未設定です" }));
2653
2689
  }
@@ -2772,17 +2808,8 @@ ${capabilityState.toString()}
2772
2808
  var sec = buildDataSources();
2773
2809
  if (sec) app.appendChild(sec);
2774
2810
  }
2775
- // 未使用能力のプレースホルダ行(アダプターとして存在するが、この案件では設定されていないもの)
2776
- function inactiveSourceRow(icon, typeLabel, anchorId) {
2777
- return el("div", { class: "ds-card ds-inactive", id: anchorId }, [
2778
- el("div", { class: "ds-card-main" }, [
2779
- el("span", { class: "ds-icon", text: icon }),
2780
- el("span", { class: "ds-type", text: typeLabel }),
2781
- el("span", { class: "ds-name", text: "未使用" }),
2782
- ]),
2783
- el("span", { class: "ds-inactive-note", text: "この案件では設定されていません" }),
2784
- ]);
2785
- }
2811
+ // **「未使用」のグレー行は持たない(#125 ⑤)。** 宣言が none の能力にだけ出していた行で、
2812
+ // 宣言を撤去したいま出す条件が無い。未接続の能力は能力タイル(常時表示)が状態と入口を持つ。
2786
2813
  // 外部ソース1件のカード(グループ化表示・展開表示の両方から使う)
2787
2814
  /**
2788
2815
  * Gold昇格の切り替えボタン。押すと設定ファイル(channels.json)が書き換わる。
@@ -2819,7 +2846,12 @@ ${capabilityState.toString()}
2819
2846
  "有効にしますか?",
2820
2847
  ],
2821
2848
  build: function (next) {
2822
- return { op: "set-channel-gold", ref: s.ref, value: next };
2849
+ // **どのワークスペースのチャンネルかを必ず送る(#104)。** 落とすと、サーバ側は
2850
+ // 既定ワークスペースの同じIDのエントリを探しに行き、「対象チャンネルが
2851
+ // 見つかりません」か、最悪**別のチャンネルを Gold に上げる**
2852
+ var payload = { op: "set-channel-gold", ref: s.ref, value: next };
2853
+ if (s.workspace) payload.workspace = s.workspace;
2854
+ return payload;
2823
2855
  },
2824
2856
  // 保存できた直後に、この行のバッジだけを「反映中」に差し替える。
2825
2857
  // 一覧そのものは触らない(結果メッセージや他行の状態を巻き込まないため)。
@@ -2828,8 +2860,9 @@ ${capabilityState.toString()}
2828
2860
  // 画面が出している値と一致するので待つものが無い。ここで消さないと、
2829
2861
  // 何も保留していないのに (反映中) が残り、リロードで消えて不可解に見える。
2830
2862
  var back = next === (s.gold === true);
2831
- if (back) clearPendingGold(s.ref);
2832
- else markPendingGold(s.ref, next);
2863
+ // 控えの鍵もワークスペース込み(#104。pendingKeyOf の doc)
2864
+ if (back) clearPendingGold(pendingKeyOf(s));
2865
+ else markPendingGold(pendingKeyOf(s), next);
2833
2866
  var card = toggleEl && toggleEl.parentNode;
2834
2867
  var badges = card && card.querySelector(".ds-badges");
2835
2868
  if (!badges) return;
@@ -3153,12 +3186,121 @@ ${capabilityState.toString()}
3153
3186
  return wrap;
3154
3187
  }
3155
3188
 
3189
+ /* ---------------------------------------------------------------------------
3190
+ * Slack ワークスペースの接続(#104)
3191
+ *
3192
+ * 顧客のワークスペースを読み始める日の入口。押す → サーバが署名つきの承認リンクを
3193
+ * 作って返す → そこへ遷移 → 顧客が Slack で承認 → 別の Lambda が受け取って棚に鍵を入れる。
3194
+ *
3195
+ * **リンクを静的に焼き込まない。** 承認リンクには「この案件へ書き込んでよい」という
3196
+ * 10分間有効な署名(state)が載っている。HTML に埋めると、配信物を読める人(=その案件の
3197
+ * Basic 認証を通れる人)が期限のないリンクを手に入れることになるし、
3198
+ * ビルド時点の state はどのみち期限切れで使えない。押した瞬間にサーバから貰う。
3199
+ *
3200
+ * **「トークン」という語を出さない。** 顧客に見せる画面なので、内部語ではなく
3201
+ * 「接続する/読み取りたいチャンネルに招待する」で通す(内部語排除の原則の資格情報版)。
3202
+ * ------------------------------------------------------------------------- */
3203
+ function slackConnectBox() {
3204
+ if (!SITE.intake) return null;
3205
+ // **この項目を知らない古い fleet-status では何も出さない。** 「0件」と「知らない」を
3206
+ // 同じ扱いにすると、engine が @v1 で止まっている案件に「まだ接続されていません」と
3207
+ // 嘘を出すことになる(実際には社内ワークスペースで動いている)
3208
+ var list = slackWorkspaces();
3209
+ if (!list) return null;
3210
+
3211
+ var box = el("div", { class: "ds-addform", "data-adder": "slack-workspace" }, [
3212
+ el("div", { class: "ds-addform-title", text: "Slack ワークスペースを接続" }),
3213
+ el("p", {
3214
+ class: "sec-how",
3215
+ text:
3216
+ "お客様の Slack ワークスペースのチャンネルを読み取り対象にするための接続です。" +
3217
+ "ボタンを押すと Slack の承認画面が開きます(承認するのはワークスペースの管理者です)。" +
3218
+ "お願いする権限は読み取りだけで、Cortex から投稿することはありません。" +
3219
+ "接続したあと、読み取りたいチャンネルにボットを招待し、この画面から追加してください。",
3220
+ }),
3221
+ ]);
3222
+
3223
+ // 接続済みの一覧。**0件でも「まだありません」と書く**——空白だと、
3224
+ // 一覧が出ていないのか繋がっていないのかが読めない
3225
+ var listBox = el("div", { class: "ds-ws-list" });
3226
+ if (!list.length) {
3227
+ listBox.appendChild(el("div", { class: "ds-ws-empty", text: "接続済みのワークスペースはまだありません。" }));
3228
+ } else {
3229
+ list.forEach(function (w) {
3230
+ var row = el("div", { class: "ds-ws" }, [
3231
+ el("span", { class: "ds-ws-name", text: w.name || w.teamId }),
3232
+ el("span", {
3233
+ class: "ds-ws-count",
3234
+ text: (w.channels || 0) + "チャンネル",
3235
+ }),
3236
+ ]);
3237
+ // **revoked と missing を分けて出す。** 前者は「接続し直してください」、
3238
+ // 後者は「まだ接続が完了していない」で、次の一手が違う。
3239
+ // unknown(確かめられなかった回)には何も出さない——繋がっているのに
3240
+ // 「接続し直して」と言う方が、黙っているより悪い。
3241
+ // 注: ここは埋め込みJSのテンプレートリテラル内。**コメントでもバッククォートを使わない**
3242
+ if (w.tokenState === "revoked") {
3243
+ row.appendChild(el("span", {
3244
+ class: "ds-gate ds-gate-warn",
3245
+ text: "⚠️ 接続が切れています",
3246
+ title: "Slack 側で Cortex のアプリが削除されたか、接続が無効になっています。もう一度接続してください。",
3247
+ }));
3248
+ } else if (w.tokenState === "missing") {
3249
+ row.appendChild(el("span", {
3250
+ class: "ds-gate ds-gate-warn",
3251
+ text: "⚠️ 未接続",
3252
+ title: "このワークスペースのチャンネルが登録されていますが、接続が見つかりません。もう一度接続してください。",
3253
+ }));
3254
+ } else if (w.tokenState === "ok") {
3255
+ row.appendChild(el("span", { class: "ds-gate ds-gate-ok", text: "● 接続OK" }));
3256
+ }
3257
+ listBox.appendChild(row);
3258
+ });
3259
+ }
3260
+ box.appendChild(listBox);
3261
+
3262
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "Slack ワークスペースを接続" });
3263
+ var msg = el("div", { class: "ds-toggle-msg" });
3264
+ btn.addEventListener("click", async function () {
3265
+ var intake = SITE.intake;
3266
+ if (!intake) return;
3267
+ btn.disabled = true;
3268
+ msg.className = "ds-toggle-msg";
3269
+ msg.textContent = "承認画面を準備しています…";
3270
+ try {
3271
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
3272
+ var res = await fetch(pfx + "op=slack-oauth-start&t=" + encodeURIComponent(intake.token), {
3273
+ method: "POST",
3274
+ headers: { "content-type": "application/json" },
3275
+ body: "{}",
3276
+ });
3277
+ var j = await res.json().catch(function () { return {}; });
3278
+ if (!res.ok || !j.url) {
3279
+ // サーバの文言をそのまま出す(未設定のときは「準備中です」が返る)
3280
+ msg.className = "ds-toggle-msg is-error";
3281
+ msg.textContent = "✗ " + (j.message || "接続を開始できませんでした。");
3282
+ btn.disabled = false;
3283
+ return;
3284
+ }
3285
+ // **同じタブで遷移する。** 別タブだと、承認後の「接続しました」を見た人が
3286
+ // 元のタブに戻る動線を自分で見つけることになる
3287
+ location.href = j.url;
3288
+ } catch (e) {
3289
+ msg.className = "ds-toggle-msg is-error";
3290
+ msg.textContent = "✗ 接続を開始できませんでした。時間をおいて試してください。";
3291
+ btn.disabled = false;
3292
+ }
3293
+ });
3294
+ box.appendChild(el("div", { class: "ds-toggle" }, [btn, msg]));
3295
+ return box;
3296
+ }
3297
+
3156
3298
  /**
3157
3299
  * 外部ソースの追加フォーム(Slack・GitHub)。
3158
3300
  *
3159
- * **カタログでゲートする(#125②)。** 宣言(tools)は廃止した。古いデータのときだけ
3160
- * 従来どおり宣言を見る(capabilityUsable の項を参照)——別のツールが宣言されている
3161
- * 案件ではサーバが必ず400で断るので、押せるのに必ず失敗するボタンを出さない。
3301
+ * **カタログでゲートする(#125②)。** 宣言(tools)によるゲートは撤去した(⑤)。
3302
+ * カタログはこの製品が実装しているアダプタの定義なので、出したボタンが
3303
+ * 「押せるのに必ず失敗する」ことはない。
3162
3304
  */
3163
3305
  function renderExternalAdders() {
3164
3306
  var box = el("div", {});
@@ -3198,7 +3340,14 @@ ${capabilityState.toString()}
3198
3340
  "追加しますか?",
3199
3341
  ];
3200
3342
  },
3201
- build: function (url, name) { return { op: "add-channel", url: url, name: name }; },
3343
+ // **選んだワークスペースを送る(#104)。** 空文字=社内ワークスペースなのでキーごと落とす
3344
+ // (サーバ側は「未宣言・null・空」を区別しない約束で、無いことで既定を表す)
3345
+ build: function (url, name, workspace) {
3346
+ var payload = { op: "add-channel", url: url, name: name };
3347
+ if (workspace) payload.workspace = workspace;
3348
+ return payload;
3349
+ },
3350
+ workspacePicker: true,
3202
3351
  adder: "slack",
3203
3352
  }));
3204
3353
 
@@ -3383,6 +3532,24 @@ ${capabilityState.toString()}
3383
3532
  var name = opts.needsName
3384
3533
  ? el("input", { class: "sec-input", type: "text", placeholder: opts.namePlaceholder || "表示名", autocomplete: "off" })
3385
3534
  : null;
3535
+ // 接続先ワークスペースの選択(#104・Slackの追加だけ)。
3536
+ //
3537
+ // **接続済みのワークスペースが1つも無ければ出さない。** 選択肢が「社内」1つだけの
3538
+ // プルダウンは、選ぶものが無いのに1手増やすだけ。既存の全案件がこの状態から始まる。
3539
+ //
3540
+ // **チャンネルのURLから推測しない。** URL のホスト名(acme.slack.com)は
3541
+ // ワークスペースの改名で変わるし、共有チャンネルでは貼った人のホストになる。
3542
+ // 推測して間違えると「追加できたのに読まれない」チャンネルができ、
3543
+ // 翌朝の接続状況で初めて分かる。人に選ばせる。
3544
+ var wsList = opts.workspacePicker ? slackWorkspaces() || [] : [];
3545
+ var wsSelect = null;
3546
+ if (wsList.length) {
3547
+ wsSelect = el("select", { class: "sec-input" });
3548
+ wsSelect.appendChild(el("option", { value: "", text: "社内ワークスペース(クラスメソッド)" }));
3549
+ wsList.forEach(function (w) {
3550
+ wsSelect.appendChild(el("option", { value: w.teamId, text: w.name || w.teamId }));
3551
+ });
3552
+ }
3386
3553
  var submitLabel = opts.submitLabel || "追加";
3387
3554
  var btn = el("button", { class: "ds-toggle-btn", type: "button", text: submitLabel });
3388
3555
  var msg = el("div", { class: "ds-toggle-msg" });
@@ -3401,7 +3568,7 @@ ${capabilityState.toString()}
3401
3568
  if (opts.confirmLines && !confirmDanger(opts.confirmLines(main.value.trim()))) { msg.textContent = ""; return; }
3402
3569
  var added = main.value.trim();
3403
3570
  var addedName = name ? name.value.trim() : "";
3404
- var payload = opts.build(added, addedName);
3571
+ var payload = opts.build(added, addedName, wsSelect ? wsSelect.value : "");
3405
3572
  sendConfig(payload, msg, btn, function () {
3406
3573
  // 追加できたら入力欄を空にする(同じものを二度押ししないように)
3407
3574
  main.value = "";
@@ -3428,6 +3595,7 @@ ${capabilityState.toString()}
3428
3595
  opts.hint ? el("p", { class: "sec-how", text: opts.hint }) : null,
3429
3596
  main,
3430
3597
  name,
3598
+ wsSelect,
3431
3599
  el("div", { class: "ds-toggle" }, [btn, msg]),
3432
3600
  pending,
3433
3601
  ]);
@@ -3505,18 +3673,27 @@ ${capabilityState.toString()}
3505
3673
  function writePending(map) {
3506
3674
  try { sessionStorage.setItem(PENDING_KEY, JSON.stringify(map)); } catch (_) {}
3507
3675
  }
3508
- function clearPendingGold(ref) {
3509
- if (!ref) return;
3676
+ function clearPendingGold(key) {
3677
+ if (!key) return;
3510
3678
  var map = readPending();
3511
- if (!(ref in map)) return;
3512
- delete map[ref];
3679
+ if (!(key in map)) return;
3680
+ delete map[key];
3513
3681
  writePending(map);
3514
3682
  }
3683
+ /**
3684
+ * 控えの鍵。**チャンネルIDだけでは足りない(#104)。**
3685
+ * IDはワークスペースの中でしか一意でないので、同じIDのチャンネルを2つのワークスペースに
3686
+ * 持つ案件では、片方で押した「反映中」が**もう片方の行にも出る**。
3687
+ */
3688
+ function pendingKeyOf(s) {
3689
+ if (!s || !s.ref) return "";
3690
+ return s.workspace ? s.workspace + "\t" + s.ref : s.ref;
3691
+ }
3515
3692
  /** 押した内容を控える。fleet-status が追いつくまでの間だけ効く */
3516
- function markPendingGold(ref, value) {
3517
- if (!ref) return;
3693
+ function markPendingGold(key, value) {
3694
+ if (!key) return;
3518
3695
  var map = readPending();
3519
- map[ref] = { value: value === true, at: Date.now() };
3696
+ map[key] = { value: value === true, at: Date.now() };
3520
3697
  writePending(map);
3521
3698
  }
3522
3699
  /**
@@ -3524,9 +3701,10 @@ ${capabilityState.toString()}
3524
3701
  * 捨てないと、正しくなった後も (反映中) が残り続ける。
3525
3702
  */
3526
3703
  function pendingGoldFor(s) {
3527
- if (!s || !s.ref) return null;
3704
+ var key = pendingKeyOf(s);
3705
+ if (!key) return null;
3528
3706
  var map = readPending();
3529
- var e = map[s.ref];
3707
+ var e = map[key];
3530
3708
  if (!e) return null;
3531
3709
  // **「値が一致したら捨てる」だけでは足りない。** 別の人が直後に逆を押して、
3532
3710
  // その結果で追いついた場合、値が一致しないので控えが残り、TTL切れまで
@@ -3559,6 +3737,26 @@ ${capabilityState.toString()}
3559
3737
  * **「登録から削除」が出てはいけない行に出て**、押しても翌日また現れる。
3560
3738
  * 全案件の再生成が回りきったら、github 側のこの分岐を落とす。
3561
3739
  */
3740
+ /**
3741
+ * 接続済み Slack ワークスペース(#104)。**空配列と undefined を区別する**——
3742
+ * 空配列は「まだ1つも繋いでいない」(接続の導線を出してよい)、undefined は
3743
+ * 「この項目を知らない古い fleet-status」(何も言えないので導線も一覧も出さない)。
3744
+ * ライブ(op=state)があればそちら、無ければ静的ビルドの値。
3745
+ */
3746
+ function slackWorkspaces() {
3747
+ var v = fleetVal("slackWorkspaces");
3748
+ return Array.isArray(v) ? v : null;
3749
+ }
3750
+ /** team_id → 表示名。宣言はあるが一覧に無いワークスペースは team_id のまま出す */
3751
+ function workspaceLabel(teamId) {
3752
+ if (!teamId) return "";
3753
+ var list = slackWorkspaces() || [];
3754
+ for (var i = 0; i < list.length; i++) {
3755
+ if (list[i].teamId === teamId) return list[i].name || teamId;
3756
+ }
3757
+ return teamId;
3758
+ }
3759
+
3562
3760
  var ORIGIN_LABEL = { explicit: "明示登録", derived: "自動検出", both: "明示登録+自動検出" };
3563
3761
  var ORIGIN_HELP = {
3564
3762
  explicit: "開発の登録ファイル(dev-sources.json)に登録されているリポジトリです。ここから登録を削除できます。",
@@ -3579,6 +3777,16 @@ ${capabilityState.toString()}
3579
3777
  else if (s.goldState === "undeclared") badges.appendChild(el("span", { class: "badge ds-undeclared", text: "未宣言(対象外)" }));
3580
3778
  else if (s.gold === false) badges.appendChild(el("span", { class: "badge ds-excluded", text: "除外" }));
3581
3779
  if (s.notify === true) badges.appendChild(el("span", { class: "badge ds-notify", text: "通知先" }));
3780
+ // **どのワークスペースのチャンネルかを出す(#104)。** 同じチャンネル名が
3781
+ // 2つのワークスペースに並びうるので、これが無いと「どっちの #general か」が読めない。
3782
+ // 宣言なし(社内ワークスペース)には何も出さない——これまでどおりの見た目を保つ
3783
+ if (s.type === "slack" && s.workspace) {
3784
+ badges.appendChild(el("span", {
3785
+ class: "badge ds-workspace",
3786
+ text: workspaceLabel(s.workspace),
3787
+ title: "接続した Slack ワークスペースのチャンネルです(読み取り専用の接続なので、通知は届きません)。",
3788
+ }));
3789
+ }
3582
3790
  // 登録の由来(#118)。**「登録から削除」が出る/出ないの理由がこれ。** 導出されたリポは
3583
3791
  // 登録ファイルから消しても翌日また現れるので削除を出していないが、画面には
3584
3792
  // 「同じ見た目の行なのに、片方にだけ削除がある」としか映らず、毎回考え直すことになる。
@@ -3990,8 +4198,11 @@ ${capabilityState.toString()}
3990
4198
  // 画面で分かるようにする(接続状況は夜間バッチのスナップショットなので、
3991
4199
  // 保存の直後は前日の値のまま。時刻は上の節に出している)。
3992
4200
  var src = (fleetVal("secretSources") || {})[kind] || "";
4201
+ // **前方一致で見る。** #105 の移行期は「Secrets Manager(旧名)」も来る。
4202
+ // 完全一致にすると、旧名で動いている案件だけが repo secret と同じ見た目になり、
4203
+ // 「管理下にあるのに未移行」という一番知りたい状態が画面から消える
3993
4204
  var state = src
3994
- ? el("p", { class: "sec-state" + (src === "Secrets Manager" ? " is-managed" : ""),
4205
+ ? el("p", { class: "sec-state" + (src.indexOf("Secrets Manager") === 0 ? " is-managed" : ""),
3995
4206
  text: "現在: " + src + " から取得中" })
3996
4207
  : el("p", { class: "sec-state is-none", text: "現在: 未設定" });
3997
4208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.74",
3
+ "version": "0.4.76",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,