@takagaki/cortex-decisions-viewer 0.12.133 → 0.12.137

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
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.DECISION_FIRMNESS = exports.PROJECT_ICON_EXTS = exports.MIRROR_RELS = void 0;
39
+ exports.RELATED_MATERIALS_SECTIONS = exports.RELATED_URL_MAX = exports.RELATED_MATERIALS_FIELD = exports.DECISION_FIRMNESS = exports.PROJECT_ICON_EXTS = exports.MIRROR_RELS = void 0;
40
40
  exports.sanitizeBody = sanitizeBody;
41
41
  exports.findProjectIcon = findProjectIcon;
42
42
  exports.readFleetSources = readFleetSources;
@@ -60,6 +60,10 @@ exports.build = build;
60
60
  exports.decisionFirmnessOf = decisionFirmnessOf;
61
61
  exports.decisionTopicOf = decisionTopicOf;
62
62
  exports.decidedByOf = decidedByOf;
63
+ exports.isRelatedUrl = isRelatedUrl;
64
+ exports.relatedMaterialsRead = relatedMaterialsRead;
65
+ exports.relatedMaterialsView = relatedMaterialsView;
66
+ exports.attachRelatedMaterials = attachRelatedMaterials;
63
67
  exports.createRefResolver = createRefResolver;
64
68
  const node_crypto_1 = require("node:crypto");
65
69
  const node_fs_1 = require("node:fs");
@@ -649,6 +653,10 @@ async function readFleetSources(repoRoot) {
649
653
  capabilities = out;
650
654
  }
651
655
  const fleetGeneratedAt = typeof obj.generatedAt === "string" ? obj.generatedAt : undefined;
656
+ // 画面は案内の宛先に出し、[コピー]の中身にもする。fleet-status.json は案件リポにコミットされる=人が書き換えうるので、メールの形だけ通す
657
+ const driveReaderEmail = typeof obj.driveReaderEmail === "string" && /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(obj.driveReaderEmail)
658
+ ? obj.driveReaderEmail
659
+ : undefined;
652
660
  // いま同期している Backlog の接続先(fleet-status.json トップレベル・#111)。
653
661
  //
654
662
  // **画面に現在値を出すために要る。** 「接続先を変更」フォームが入力欄だけだったため、
@@ -708,7 +716,7 @@ async function readFleetSources(repoRoot) {
708
716
  if (Object.keys(out).length)
709
717
  secretSpaces = out;
710
718
  }
711
- return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, secretSpaces, checks, githubOwners, backlogTarget, tools };
719
+ return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, driveReaderEmail, secretSources, secretSpaces, checks, githubOwners, backlogTarget, tools };
712
720
  }
713
721
  /**
714
722
  * 区画のレコードを活性度・強さの順に並べる(用語集だけが動く・#665 便 12b → #1094 便 e4 で帯を外した)。
@@ -2530,6 +2538,8 @@ async function buildSite(opts, views) {
2530
2538
  const records = await loadGenericRecords(recordsDir, exclude, repo.baseUrl, repo.branch);
2531
2539
  // 人の手(削除の確認の文面を替える材料・cortex-engine #1094 段 4)
2532
2540
  attachHumanTouched(records, recordsDir, repoRoot, ledgers.confirmation, touchedBy);
2541
+ // 関連の資料(cortex-engine #1094 F8)。ルール・用語だけ・欄の無い記録には何も付かない
2542
+ attachRelatedMaterials(e.name, records, refs);
2533
2543
  // ルールの取り下げ(`retracted_by`・cortex-engine #1094 便 4-2d)。ルールの区画だけ・取り下げの無い案件では何も付かない
2534
2544
  if (e.name === "Rules")
2535
2545
  attachRetractedBy(records, decisions, (d) => rawDates.get(d) ?? d.fm.date);
@@ -2645,6 +2655,7 @@ async function buildSite(opts, views) {
2645
2655
  externalSources: fleet.externalSources,
2646
2656
  slackWorkspaces: fleet.slackWorkspaces,
2647
2657
  fleetGeneratedAt: fleet.fleetGeneratedAt,
2658
+ driveReaderEmail: fleet.driveReaderEmail,
2648
2659
  secretSources: fleet.secretSources,
2649
2660
  secretSpaces: fleet.secretSpaces,
2650
2661
  internalSources: fleet.internalSources,
@@ -2745,6 +2756,8 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
2745
2756
  const firmness = decisionFirmnessOf(data.firmness);
2746
2757
  const topic = decisionTopicOf(data.topic);
2747
2758
  const decidedBy = decidedByOf(data.decided_by);
2759
+ // 関連の資料(#1094 F8)。通る URL が 0 本ならキーごと載せない
2760
+ const relatedMaterials = relatedMaterialsView(data, refs);
2748
2761
  // 本文先頭の H1 はタイトルと重複するため除去(詳細画面ではタイトルを別途表示する)
2749
2762
  const bodyHtml = await renderRecordBody(content);
2750
2763
  const filePath = repoRelDir ? `${repoRelDir}/${fileName}` : fileName;
@@ -2778,6 +2791,7 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
2778
2791
  ...(firmness ? { firmness } : {}),
2779
2792
  ...(topic ? { topic } : {}),
2780
2793
  ...(decidedBy ? { decidedBy } : {}),
2794
+ ...(relatedMaterials ? { relatedMaterials } : {}),
2781
2795
  };
2782
2796
  }
2783
2797
  /**
@@ -2830,6 +2844,62 @@ function decidedByOf(value) {
2830
2844
  return undefined;
2831
2845
  return value.map(decidedByEntryOf).filter((e) => e !== null);
2832
2846
  }
2847
+ /**
2848
+ * 関連の資料の欄 `related_materials` の読み手(決定・ルール・用語・cortex-engine #1094 F8)。
2849
+ *
2850
+ * 🔴 **写し。正本は cortex-engine `scripts/gold/related-materials-field.mjs` の `isRelatedUrl` と `relatedMaterialsRead`**。
2851
+ * 同じバイト列の表 `test/fixtures/related-materials-contract.json` を両リポに置き、`test/related-materials-view.test.mjs` の
2852
+ * 「[契約] …」が全ケースを流す。規則を変えるときは engine の正本・この写し・表を同じ日に直す。
2853
+ * 重複も上限も見ない(人が足した分を落とさない)。`decided_by` の URL の門(`DECIDED_BY_URL_RE`)とは別の、engine と同じ門
2854
+ */
2855
+ exports.RELATED_MATERIALS_FIELD = "related_materials";
2856
+ exports.RELATED_URL_MAX = 2000;
2857
+ // eslint-disable-next-line no-control-regex
2858
+ const RELATED_URL_RE = /^https?:\/\/[^\s<>"\[\]〔〕\u0000-\u001f\u007f]+$/i;
2859
+ /** 欄に置いてよい URL か(http(s)・空白と制御文字・`<>"[]〔〕` を含まない・ホストあり・2000 字まで) */
2860
+ function isRelatedUrl(v) {
2861
+ if (typeof v !== "string" || !v || v.length > exports.RELATED_URL_MAX)
2862
+ return false;
2863
+ if (!RELATED_URL_RE.test(v))
2864
+ return false;
2865
+ try {
2866
+ const u = new URL(v);
2867
+ return (u.protocol === "http:" || u.protocol === "https:") && u.hostname !== "";
2868
+ }
2869
+ catch {
2870
+ return false;
2871
+ }
2872
+ }
2873
+ /** frontmatter → 欄の URL の並び(通るものだけ・順はそのまま)。欄が無い・リストでなければ [] */
2874
+ function relatedMaterialsRead(fm) {
2875
+ const v = fm && typeof fm === "object" ? fm[exports.RELATED_MATERIALS_FIELD] : null;
2876
+ return Array.isArray(v) ? v.filter(isRelatedUrl) : [];
2877
+ }
2878
+ /** 画面に載せる形(名前は根拠と同じ解決=帳の題名・URL の種類の名前)。通る URL が 0 本なら undefined(キーごと載せない) */
2879
+ function relatedMaterialsView(fm, refs) {
2880
+ const urls = relatedMaterialsRead(fm);
2881
+ if (!urls.length)
2882
+ return undefined;
2883
+ return urls.map((u) => {
2884
+ // 名前は scheme だけ小文字にして引く(根拠の解決は大文字の `HTTPS://` を URL と読まずパスに落とす)。飛び先は書かれたまま
2885
+ const q = u.replace(/^https?:/i, (m) => m.toLowerCase());
2886
+ const r = refs.resolve(q);
2887
+ // 名前が引けなければ(解決が引いた文字をそのまま返した)、書かれた URL のまま出す
2888
+ return { label: r.label && r.label !== q ? r.label : u, url: u };
2889
+ });
2890
+ }
2891
+ /** 関連の資料を持てる汎用の区画(関係者・ストーリーの日・Home の欄は拾わない) */
2892
+ exports.RELATED_MATERIALS_SECTIONS = new Set(["Rules", "Glossary"]);
2893
+ /** 区画の記録に関連の資料を載せる(ルール・用語だけ・欄の無い記録にはキーごと付けない) */
2894
+ function attachRelatedMaterials(section, records, refs) {
2895
+ if (!exports.RELATED_MATERIALS_SECTIONS.has(section))
2896
+ return;
2897
+ for (const r of records) {
2898
+ const view = relatedMaterialsView(r.fm, refs);
2899
+ if (view)
2900
+ r.relatedMaterials = view;
2901
+ }
2902
+ }
2833
2903
  /**
2834
2904
  * 解決した参照 → 書かれていた値(#968 tools 1)。つながり画面は点の鍵をこの値から決める
2835
2905
  * (`site-data` に載せない——機械の語を画面の材料に増やさない)
@@ -34,6 +34,9 @@ exports.pendingLabel = pendingLabel;
34
34
  exports.pendingOpOf = pendingOpOf;
35
35
  exports.sanitizePendingOps = sanitizePendingOps;
36
36
  exports.rememberPendingOp = rememberPendingOp;
37
+ exports.pendingOpsOfTarget = pendingOpsOfTarget;
38
+ exports.rollbackPendingOp = rollbackPendingOp;
39
+ exports.settlePendingOp = settlePendingOp;
37
40
  exports.pendingSiteTimeMs = pendingSiteTimeMs;
38
41
  exports.pendingStale = pendingStale;
39
42
  exports.pendingOpBuildCaughtUp = pendingOpBuildCaughtUp;
@@ -387,6 +390,8 @@ function sanitizePendingOps(raw, projectKey) {
387
390
  payload: payload,
388
391
  committedAt: e.committedAt,
389
392
  baseBuildId: typeof e.baseBuildId === "string" ? e.baseBuildId : "",
393
+ ...(typeof e.nonce === "string" && e.nonce && e.nonce.length <= 64 ? { nonce: e.nonce } : {}),
394
+ ...(e.inflight === true ? { inflight: true } : {}),
390
395
  });
391
396
  }
392
397
  out.sort(function (a, b) { return a.committedAt - b.committedAt; });
@@ -417,7 +422,7 @@ function rememberPendingOp(list, entry) {
417
422
  for (const k in entry.payload)
418
423
  if (Object.prototype.hasOwnProperty.call(entry.payload, k))
419
424
  merged[k] = entry.payload[k];
420
- return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: merged, committedAt: e.committedAt, baseBuildId: e.baseBuildId };
425
+ return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: merged, committedAt: e.committedAt, baseBuildId: e.baseBuildId, ...(e.nonce ? { nonce: e.nonce } : {}), ...(e.inflight === true ? { inflight: true } : {}) };
421
426
  });
422
427
  if (folded)
423
428
  return out;
@@ -436,13 +441,84 @@ function rememberPendingOp(list, entry) {
436
441
  if (Object.prototype.hasOwnProperty.call(entry.payload, k))
437
442
  merged[k] = entry.payload[k];
438
443
  const rest = cur.filter(function (e) { return !(same(e) && e.action === "update"); });
439
- rest.push({ v: 1, projectKey: entry.projectKey, kind: entry.kind, targetId: entry.targetId, action: entry.action, payload: merged, committedAt: entry.committedAt, baseBuildId: entry.baseBuildId });
444
+ rest.push({ v: 1, projectKey: entry.projectKey, kind: entry.kind, targetId: entry.targetId, action: entry.action, payload: merged, committedAt: entry.committedAt, baseBuildId: entry.baseBuildId, ...(entry.nonce ? { nonce: entry.nonce } : {}), ...(entry.inflight === true ? { inflight: true } : {}) });
440
445
  return rest;
441
446
  }
442
447
  const kept = cur.filter(function (e) { return !same(e); });
443
448
  kept.push(entry);
444
449
  return kept;
445
450
  }
451
+ // ---- 楽観更新の巻き戻し(cortex-engine #968 E)-------------------------------
452
+ /**
453
+ * 同じ (kind, targetId) の仮置きだけを抜き出す。送信時に仮置きを書く前の姿(`rollbackPendingOp` の戻し先)。
454
+ */
455
+ function pendingOpsOfTarget(list, entry) {
456
+ if (!entry)
457
+ return [];
458
+ return (list || []).filter(function (e) { return e.kind === entry.kind && e.targetId === entry.targetId; });
459
+ }
460
+ /**
461
+ * 送信時に書いた仮置き(`entry`)を**自分の nonce の行だけ**取り消す(他の送信中の仮置きは消さない)。
462
+ *
463
+ * - 自分の行が置き換えた送信前の行(`before`)は、その対象にいま別の行が無く、かつ失敗済みの送信の行(`failed` の nonce)でないときだけ戻す
464
+ * (失敗済みの行を復活させない・あとから来た別の操作の行を潰さない)。
465
+ * - update が add に畳まれていた(自分の nonce の行が無い)ときは、同じ nonce の行を送信前の行へ差し替える。
466
+ * @param failed すでに巻き戻した送信の nonce の一覧
467
+ */
468
+ function rollbackPendingOp(list, entry, before, failed) {
469
+ const cur = list || [];
470
+ if (!entry)
471
+ return cur.slice();
472
+ const dead = failed || [];
473
+ const nonce = entry.nonce;
474
+ const sameTarget = function (e) { return e.kind === entry.kind && e.targetId === entry.targetId; };
475
+ const back = (before || []).filter(function (b) { return sameTarget(b) && !(b.nonce && dead.indexOf(b.nonce) >= 0); });
476
+ let found = false;
477
+ let out = cur.filter(function (e) {
478
+ if (nonce && e.nonce === nonce) {
479
+ found = true;
480
+ return false;
481
+ }
482
+ return true;
483
+ });
484
+ if (!found && entry.action === "update") {
485
+ // 畳まれた update: add の行(同じ nonce の送信前の行があれば)を送信前へ戻す
486
+ out = out.map(function (e) {
487
+ if (!sameTarget(e) || !e.nonce)
488
+ return e;
489
+ for (let i = 0; i < back.length; i++)
490
+ if (back[i].nonce === e.nonce)
491
+ return back[i];
492
+ return e;
493
+ });
494
+ out.sort(function (a, b) { return a.committedAt - b.committedAt; });
495
+ return out;
496
+ }
497
+ if (!out.some(sameTarget))
498
+ out = out.concat(back);
499
+ out.sort(function (a, b) { return a.committedAt - b.committedAt; });
500
+ return out;
501
+ }
502
+ /**
503
+ * 応答が成功したとき、自分の nonce の行を「送信中」から「確定」へ(送信中の印を外し、サーバの応答で分かった欄を足す)。
504
+ * 自分の行がもう無い(あとの操作に置き換えられた)なら何もしない——**あとの操作の行を書き直さない**。
505
+ * @param final 応答つきで作り直した仮置き(`pendingOpOf` の結果)。無ければ印を外すだけ
506
+ */
507
+ function settlePendingOp(list, nonce, final) {
508
+ return (list || []).map(function (e) {
509
+ if (!nonce || e.nonce !== nonce)
510
+ return e;
511
+ const payload = {};
512
+ for (const k in e.payload)
513
+ if (Object.prototype.hasOwnProperty.call(e.payload, k))
514
+ payload[k] = e.payload[k];
515
+ if (final && final.payload)
516
+ for (const k in final.payload)
517
+ if (Object.prototype.hasOwnProperty.call(final.payload, k))
518
+ payload[k] = final.payload[k];
519
+ return { v: 1, projectKey: e.projectKey, kind: e.kind, targetId: e.targetId, action: e.action, payload: payload, committedAt: e.committedAt, baseBuildId: e.baseBuildId, nonce: e.nonce };
520
+ });
521
+ }
446
522
  // ---- 失効 ----------------------------------------------------------------
447
523
  /** SITE.generatedAt("2026-09-03 00:00 UTC" / ISO)→ ms。読めなければ NaN */
448
524
  function pendingSiteTimeMs(s) {
@@ -616,6 +692,9 @@ function prunePendingOps(list, ctx) {
616
692
  return false;
617
693
  if (now > e.committedAt + PENDING_OPS_TTL_MS)
618
694
  return false;
695
+ // 送信中のまま 2 分を過ぎた行(画面を閉じた・落ちた送信の置き土産)は捨てる(cortex-engine #968 E)
696
+ if (e.inflight === true && now > e.committedAt + 120000)
697
+ return false;
619
698
  if (pendingOpBuildCaughtUp(e, c.siteNowMs))
620
699
  return false;
621
700
  if (pendingOpLiveAgrees(e, c.live, c.liveAt))
package/dist/render.js CHANGED
@@ -6115,6 +6115,8 @@ button.ops-card-btn:disabled { opacity: .6; cursor: default; }
6115
6115
  .deciders { display: flex; flex-wrap: wrap; gap: 8px; }
6116
6116
  .refs { margin: 0; padding-left: 18px; }
6117
6117
  .refs li { margin: 4px 0; }
6118
+ /* 関連の資料(#1094 F8): 名前の引けない資料は URL のまま(最大 2000 字)なので、狭い画面ではみ出さないよう折り返す */
6119
+ .related-materials li { overflow-wrap: anywhere; }
6118
6120
  .body {
6119
6121
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
6120
6122
  padding: 4px 22px 8px; margin-top: 8px;
@@ -6396,6 +6398,9 @@ ${pending_ops_1.pendingIsSlackTeamId.toString()}
6396
6398
  ${pending_ops_1.pendingOpOf.toString()}
6397
6399
  ${pending_ops_1.sanitizePendingOps.toString()}
6398
6400
  ${pending_ops_1.rememberPendingOp.toString()}
6401
+ ${pending_ops_1.pendingOpsOfTarget.toString()}
6402
+ ${pending_ops_1.rollbackPendingOp.toString()}
6403
+ ${pending_ops_1.settlePendingOp.toString()}
6399
6404
  ${pending_ops_1.pendingSiteTimeMs.toString()}
6400
6405
  ${pending_ops_1.pendingStale.toString()}
6401
6406
  ${pending_ops_1.pendingOpBuildCaughtUp.toString()}
@@ -6647,11 +6652,14 @@ ${calendarAddUrl.toString()}
6647
6652
  * **誰を招待すればいいのかが画面から分からなかった**——担当者に聞かないと
6648
6653
  * 手順が完了できなかった。
6649
6654
  *
6650
- * **艦隊で1つ。** 案件ごとに違うものではないので定数で足りる
6651
- * (案件ごとになったら fleet-status から運ぶ器に変える)。
6655
+ * **艦隊で1つ。** 案件ごとに違うものではない。**アドレスはここに直書きしない**——
6656
+ * engine が運営の設定(config/operator.json の driveReaderEmail)から
6657
+ * fleet-status.json のトップに置いた値を読む(cortex-engine #1094 便 γ 5-14)。
6658
+ * 値が届いていない(古い生成物)ときは空にして、宛先を名指さない文に倒す
6659
+ * (他の組織の画面に別の会社のアドレスを出さない)。
6652
6660
  * 議事録の取り込みでも同じアカウントを使っている。
6653
6661
  */
6654
- var DRIVE_INGEST_ACCOUNT = "cortex-notetaker@classmethod.jp";
6662
+ var DRIVE_INGEST_ACCOUNT = typeof SITE.driveReaderEmail === "string" ? SITE.driveReaderEmail : "";
6655
6663
  // Google Chat の「スペースにアプリを追加する」手順の画像(#389)。理由は
6656
6664
  // src/google-chat-guide-images.ts に書いてある
6657
6665
  // Google Chat アプリの表示名。**Marketplace 公開名(2026-09-03 オーナー決定で「Cortex Bot」から
@@ -7043,6 +7051,27 @@ ${calendarAddUrl.toString()}
7043
7051
  function retractedFadedClass(rec) {
7044
7052
  return isRetracted(rec) ? " is-faded" : "";
7045
7053
  }
7054
+ /**
7055
+ * 詳細の節「関連の資料」(cortex-engine #1094 F8)。記録を生んだ会話に貼られた資料へのリンク。
7056
+ * 根拠(「参照」)とは混ぜない。ビルドが engine と同じ門で検めてあるが、画面でも http(s) だけをリンクにする(二重に見る)。
7057
+ * 関連の資料の無い記録では null(要素を作らない)
7058
+ */
7059
+ function relatedMaterialsSection(r) {
7060
+ if (!r || !r.relatedMaterials || !r.relatedMaterials.length) return null;
7061
+ // 🔴 大文字の scheme(HTTPS://)も engine の門では通るので、ここも大文字小文字を区別しない(isHttp は区別する=ほかの画面の前例のまま触らない)
7062
+ var items = r.relatedMaterials.filter(function (m) { return m && typeof m.url === "string" && /^https?:\\/\\//i.test(m.url); });
7063
+ if (!items.length) return null;
7064
+ var box = el("div", { class: "related-materials-section" });
7065
+ box.appendChild(el("div", { class: "section-label", text: "関連の資料" }));
7066
+ var ul = el("ul", { class: "refs related-materials" });
7067
+ items.forEach(function (m) {
7068
+ var li = el("li");
7069
+ li.appendChild(el("a", { href: m.url, target: "_blank", rel: "noopener", text: m.label || m.url }));
7070
+ ul.appendChild(li);
7071
+ });
7072
+ box.appendChild(ul);
7073
+ return box;
7074
+ }
7046
7075
  /** 詳細の節「取り下げを決めた決定」。取り下げの無い記録では null(要素を作らない) */
7047
7076
  function retractSection(r) {
7048
7077
  if (!isRetracted(r)) return null;
@@ -10011,10 +10040,9 @@ ${calendarAddUrl.toString()}
10011
10040
  customer: {
10012
10041
  text: "トークン未設定",
10013
10042
  cls: "ds-gate-muted",
10014
- // 🔴 **行は札だけ(#1328)。** 一手「この Slack を Cortex に接続してください。[接続する]」は、
10015
- // 同じワークスペースの組の先頭に 1 回だけ置く(slackConnectNote)。10 チャンネルが
10016
- // 同じ一手を 10 回言わない。主語は「この Slack」(承認画面はワークスペースを指定できない
10017
- // ——slackConnectInline の項)
10043
+ // 一手「このワークスペースに Cortex が入っていません。[ワークスペースを接続する]」は、
10044
+ // 行のタイルの中が持つ(slackRowHelpParts・g-66)。承認画面はワークスペースを指定できない
10045
+ // ——slackConnectInline の項
10018
10046
  },
10019
10047
  /* -------------------------------------------------------------------------
10020
10048
  * GitHub。**「トークン未設定」と言ってはいけない**(2026-08-21)
@@ -10182,31 +10210,32 @@ ${calendarAddUrl.toString()}
10182
10210
  return [say, pick ? { link: "インストール設定を開く →", kind: "settings", slug: raw } : { link: "GitHub App を入れる →", kind: "install", slug: raw }];
10183
10211
  }
10184
10212
  /**
10185
- * Slack の「理由不明」の行(unreachable・組織不明の仮置き)に出す一手(c-07)。
10186
- * **広い方の 1 行**にする——原因は行の分類では絞れない(0.12.2 の実測)ので、片方に賭けない。
10213
+ * Slack の「見えない」行(unreachable・no_token・組織不明の仮置き)に出す一手(c-07・g-66)。
10214
+ * **行のタイルの中**に出す(組の先頭=タイルの外には出さない)。症状は帯が言い、行は一手だけ。
10187
10215
  *
10188
- * - ワークスペースが分かっていて、台帳の接続が切れている/未接続 → 行には何も出さない(空の配列)。
10189
- * 一手は組の先頭の slackConnectNote が 1 回だけ言う
10190
- * - ワークスペースが分かっていて、接続が生きている → 招待だけ
10191
- * - 社内・組織不明(分類が当てにならない)→ 招待だけ(「お客様の Slack なら接続」は組の先頭が言う)
10216
+ * - 接続で直ると確かな行(ワークスペースが分かっていて台帳の接続が切れている/未接続、または no_token)
10217
+ * → 1 行「このワークスペースに Cortex が入っていません。[ワークスペースを接続する]」
10218
+ * - 招待で直るか接続で直るか見分けられない行(社内・組織不明の unreachable・仮置き)
10219
+ * → 1 行目 招待(/invite @Cortex[コピー])、2 行目「…入っていなければ、ワークスペースを接続してください。[接続する]」
10220
+ * - 接続が生きているワークスペース → 招待だけ
10192
10221
  *
10193
- * 🔴 **接続は行の一手に置かない(#1328)。** 同じワークスペースの全チャンネルが同じ一文を
10194
- * 繰り返していた。**行は札と、その行だけの一手(招待)だけ**。
10195
- * 🔴 **空の配列を返す(null にしない)。** null を返すと呼び側が夜間の理由文(機械の語)を代わりに出す。
10196
- * 投入の口(SITE.intake)が無い案件は接続を出せないので、従来どおり招待だけを返す。
10222
+ * 🔴 同じ行の中で同じ説明を 2 回言わない。投入の口(SITE.intake)が無い案件は接続を出せないので、招待だけ。
10223
+ * 🔴 **空の配列は返さない**(null も)。null を返すと呼び側が夜間の理由文(機械の語)を代わりに出す。
10197
10224
  */
10198
10225
  function slackRowHelpParts(row) {
10199
10226
  if (!row) return null;
10200
- var invite = ["Cortex からこのチャンネルが見えません。Cortex を招待してください:", { copy: "/invite @Cortex" }];
10201
- if (row.workspace) {
10202
- var st = tokenStateOf(slackWorkspaces(), row.workspace);
10203
- if (st === "revoked" || st === "missing") return SITE.intake ? [] : invite;
10204
- }
10205
- return invite;
10227
+ var invite = ["Cortex からこのチャンネルが見えません。チャンネルで Cortex を招待してください:", { copy: "/invite @Cortex" }];
10228
+ if (!slackRowNeedsConnect(row)) return invite;
10229
+ if (slackRowConnectSure(row)) return ["このワークスペースに Cortex が入っていません。", { act: "connect", label: "ワークスペースを接続する" }];
10230
+ return invite.concat([{ br: true }, "このワークスペースに Cortex が入っていなければ、ワークスペースを接続してください。", { act: "connect" }]);
10231
+ }
10232
+ /** 接続で直ると確かな行か(no_token、またはワークスペースが分かっていて台帳の接続が切れている/未接続) */
10233
+ function slackRowConnectSure(s) {
10234
+ return s.gate === "no_token" || !!s.workspace;
10206
10235
  }
10207
10236
  /**
10208
10237
  * この Slack の行が、ワークスペースの接続([接続する])で直る行か(#1328)。
10209
- * 組の先頭に一手を置くかどうかの判断だけに使う。**行には出さない。**
10238
+ * 行の一手に[接続する]を出すかどうかの判断に使う。
10210
10239
  *
10211
10240
  * - 顧客の no_token(組織不明の行を含む。社内の no_token は画面から直せないので含めない・2026-08-18)
10212
10241
  * - まだ測っていない組織不明の仮置きの行
@@ -10222,21 +10251,6 @@ ${calendarAddUrl.toString()}
10222
10251
  var st = tokenStateOf(slackWorkspaces(), s.workspace);
10223
10252
  return st === "revoked" || st === "missing";
10224
10253
  }
10225
- /**
10226
- * ワークスペースごとの組の先頭に置く、接続の一手(0 または 1 個の段落)。
10227
- * 「この Slack を Cortex に接続してください。[接続する]」(その組が顧客のワークスペースだと分かっているとき)か、
10228
- * 「お客様の Slack なら、ワークスペースを接続してください。[接続する]」(社内・組織不明の組)。
10229
- * **行の中の ds-gate-help ではない**(行の補足は最大 1 段・押した結果が行の補足を隠す仕掛けに巻き込まない)。
10230
- * @param g groupSlackByOrg の 1 組
10231
- */
10232
- function slackConnectNote(g) {
10233
- var needy = g.items.filter(slackRowNeedsConnect);
10234
- if (!needy.length) return null;
10235
- var sure = (g.key !== "__internal__" && g.key !== "__unknown__")
10236
- || needy.every(function (s) { return s.gate === "no_token"; });
10237
- var say = sure ? "この Slack を Cortex に接続してください。" : "お客様の Slack なら、ワークスペースを接続してください。";
10238
- return restingHelpNode([say, { act: "connect" }], "ds-group-note ds-ws-connect");
10239
- }
10240
10254
  /** 押した結果に添えるパーツ(無ければ null)。行と同じ表から引く(写経しない) */
10241
10255
  function probedHelpPartsOf(type, gate) {
10242
10256
  var e = restingHelpEntryOf(type, gate);
@@ -10245,13 +10259,14 @@ ${calendarAddUrl.toString()}
10245
10259
  /**
10246
10260
  * パーツを1段落にする。包む要素の class は置き場所で違う(行は ds-gate-help・押した結果は ds-probe-help)。
10247
10261
  * 部品: 文字列=テキスト / { link, kind } = GitHub App のリンク /
10248
- * { copy } =コピー付きの 1 語(文字列と[コピー]) / { act } =ボタン(actNode)。
10262
+ * { copy } =コピー付きの 1 語(文字列と[コピー]) / { act } =ボタン(actNode) / { br } =同じ段落の中の改行。
10249
10263
  * **部品は同じ段落の中に置く**(段を増やさない=行の補足は最大 1 段)。
10250
10264
  */
10251
10265
  function restingHelpNode(parts, cls) {
10252
10266
  var p = el("p", { class: cls });
10253
10267
  parts.forEach(function (part) {
10254
10268
  if (typeof part === "string") p.appendChild(document.createTextNode(part));
10269
+ else if (part && part.br) p.appendChild(el("br"));
10255
10270
  else if (part && part.link) p.appendChild(el("a", { href: githubAppLinkUrl(part.kind, part.slug), target: "_blank", rel: "noopener", text: part.link }));
10256
10271
  // bare … 文面に値が含まれているので[コピー]だけを出す(固定 IP の依頼文)
10257
10272
  else if (part && part.copy) p.appendChild(copyInline(part.copy, !part.bare));
@@ -10434,9 +10449,11 @@ ${calendarAddUrl.toString()}
10434
10449
  unreachable: {
10435
10450
  text: "フォルダを開けません",
10436
10451
  cls: "ds-gate-warn",
10437
- // 🔴 **一手は「このフォルダを cortex-notetaker@… に閲覧者として共有してください。[コピー]」の 1 行(c-07)。**
10452
+ // 🔴 **一手は「このフォルダを〈取り込み用アカウント〉に閲覧者として共有してください。[コピー]」の 1 行(c-07)。**
10438
10453
  // コピーは追加の欄の手順 1 にだけあったので、ここへ移した(help の文面にアドレスが含まれるので[コピー]だけ)
10439
- help: "このフォルダを " + DRIVE_INGEST_ACCOUNT + " に閲覧者として共有してください。",
10454
+ help: DRIVE_INGEST_ACCOUNT
10455
+ ? "このフォルダを " + DRIVE_INGEST_ACCOUNT + " に閲覧者として共有してください。"
10456
+ : "このフォルダを取り込み用のアカウントに閲覧者として共有してください(アカウントは管理者に確かめてください)。",
10440
10457
  copy: DRIVE_INGEST_ACCOUNT,
10441
10458
  },
10442
10459
  no_token: { text: "管理者の設定が必要です", cls: "ds-gate-muted" },
@@ -11345,11 +11362,7 @@ ${calendarAddUrl.toString()}
11345
11362
  out.push(resolved);
11346
11363
  }
11347
11364
  // 🔴 **接続が切れた/未接続のワークスペースの枠(brokenOrgNodes)は撤去した(c-07)。**
11348
- // 一手「この Slack を Cortex に接続してください。[接続する]」は、そのワークスペースの組の先頭に
11349
- // 1 回だけ置く(slackConnectNote・#1328)。行は札と、その行だけの一手(招待)だけを持つ。
11350
- // 入口を失わないための約束は決定メモに書いた
11351
- var connectNote = slackConnectNote(g);
11352
- if (connectNote) out.push(connectNote);
11365
+ // 接続の一手は、見えない行のタイルの中に行ごとに置く(slackRowHelpParts・g-66。組の先頭=タイルの外には出さない)
11353
11366
  cards.forEach(function (n) { out.push(n); });
11354
11367
  });
11355
11368
  return out;
@@ -11631,6 +11644,67 @@ ${calendarAddUrl.toString()}
11631
11644
  MERGED = null;
11632
11645
  return entry;
11633
11646
  }
11647
+ /**
11648
+ * 送信時の仮置き(cortex-engine #968 E・本当の楽観更新)。**サーバの答えを待たずに**仮置きを書き、
11649
+ * 戻し先(送信前のその対象の仮置き)を控えて返す。作れなければ null(従来どおり応答後に任せる)。
11650
+ * 応答が来たら notePendingOp(…, 応答) で同じ対象を書き直す(サーバが実測した組織などを足す)。
11651
+ */
11652
+ var OPT_SEQ = 0;
11653
+ /** 巻き戻した送信の nonce(失敗済みの行を、あとの巻き戻しで復活させないための控え) */
11654
+ var FAILED_NONCES = [];
11655
+ function noteOptimisticOp(payload, targets) {
11656
+ if (!SITE.intake) return null;
11657
+ PENDING_OPS = readPendingOps();
11658
+ var probe = pendingOpOf(payload, {
11659
+ projectKey: SITE.intake.projectKey,
11660
+ now: Date.now(),
11661
+ generatedAt: rawFleetVal("generatedAt") || "",
11662
+ targets: targets || null,
11663
+ response: null,
11664
+ });
11665
+ if (!probe) return null;
11666
+ var before = pendingOpsOfTarget(pendingOpsNow(), probe);
11667
+ // 送信ごとの一意 ID と送信中の印を付けて書く(巻き戻しは自分の行だけを外す)
11668
+ OPT_SEQ += 1;
11669
+ var nonce = "n" + Date.now().toString(36) + "-" + OPT_SEQ + "-" + Math.floor(Math.random() * 1e6).toString(36);
11670
+ probe.nonce = nonce;
11671
+ probe.inflight = true;
11672
+ PENDING_OPS = rememberPendingOp(pendingOpsNow(), probe);
11673
+ writePendingOps(PENDING_OPS);
11674
+ MERGED = null;
11675
+ return { entry: probe, before: before };
11676
+ }
11677
+ /**
11678
+ * 送信時の仮置きを巻き戻す(断られた・通信が落ちた・何も変わらなかった)。**自分の nonce の行だけ**外し、
11679
+ * 他の送信中の仮置きには触らない。巻き戻したあとは、開いている一覧を描き直して元の姿に戻す。
11680
+ */
11681
+ function rollbackOptimisticOp(opt) {
11682
+ if (!opt || !SITE.intake) return;
11683
+ PENDING_OPS = rollbackPendingOp(readPendingOps(), opt.entry, opt.before, FAILED_NONCES);
11684
+ if (opt.entry.nonce) FAILED_NONCES.push(opt.entry.nonce);
11685
+ writePendingOps(PENDING_OPS);
11686
+ MERGED = null;
11687
+ repaintOpenLists();
11688
+ refreshDataSources();
11689
+ syncAiLinks();
11690
+ }
11691
+ /**
11692
+ * 応答が成功したとき、自分の行を確定させる(送信中の印を外し、サーバが実測した欄を足す)。
11693
+ * 自分の行がもう無い(あとの操作に置き換えられた)なら何もしない。
11694
+ */
11695
+ function settleOptimisticOp(opt, payload, targets, response) {
11696
+ if (!opt || !SITE.intake) return;
11697
+ var final = pendingOpOf(payload, {
11698
+ projectKey: SITE.intake.projectKey,
11699
+ now: Date.now(),
11700
+ generatedAt: rawFleetVal("generatedAt") || "",
11701
+ targets: targets || null,
11702
+ response: response || null,
11703
+ });
11704
+ PENDING_OPS = settlePendingOp(readPendingOps(), opt.entry.nonce, final);
11705
+ writePendingOps(PENDING_OPS);
11706
+ MERGED = null;
11707
+ }
11634
11708
  /** 仮置きの行の印(「反映待ち」/「反映を確認できていません」)。印の無い行は null */
11635
11709
  function pendingBadgeEl(row) {
11636
11710
  var note = pendingRowNote(row, Date.now());
@@ -14360,16 +14434,25 @@ ${calendarAddUrl.toString()}
14360
14434
  var next = !on;
14361
14435
  // **ワークスペースは送らない。** この分岐に来るのは宣言の無い行だけで、
14362
14436
  // サーバも ON では受け取らない(送れば断られる)
14363
- await sendConfig({ op: "set-notify", ref: s.ref, value: next }, msg, btn, function () {
14364
- on = next;
14437
+ // 行の見た目を「いまの向き」へ揃える。**送信した瞬間に呼ぶ(楽観更新・#968 E)**・成功でも呼ぶ(同じ値で冪等)・
14438
+ // 断られたら元の向きで呼んで戻す
14439
+ var apply = function (v) {
14440
+ on = v;
14365
14441
  // 行の元データも合わせる。ライブの読み直しで同じ行が描き直されるまでの間、
14366
14442
  // ここだけが「いまどちらか」を持っている
14367
- s.notify = next;
14443
+ s.notify = v;
14368
14444
  btn.textContent = labelOf();
14369
14445
  var badge = badges.querySelector(".ds-notify");
14370
14446
  // 並びは描画時と同じ(札の先頭)。後ろに足すと、押した行だけ順番が変わる
14371
14447
  if (on && !badge) badges.insertBefore(notifyBadge(), badges.firstChild);
14372
14448
  if (!on && badge) badges.removeChild(badge);
14449
+ };
14450
+ var before = on;
14451
+ await sendConfig({ op: "set-notify", ref: s.ref, value: next }, msg, btn, function () {
14452
+ apply(next);
14453
+ }, {
14454
+ onOptimistic: function () { apply(next); },
14455
+ onRevert: function () { apply(before); },
14373
14456
  });
14374
14457
  });
14375
14458
  wrap.appendChild(btn);
@@ -15789,6 +15872,46 @@ ${calendarAddUrl.toString()}
15789
15872
  staleNote: TARGETS_RACE_NOTE,
15790
15873
  // 仮置き(#388)の鍵にドメインを入れるため、表示中の一覧を渡す(解除の payload にはキーしか無い)
15791
15874
  pendingTargets: function () { return targets; },
15875
+ // 送信した瞬間に一覧を先に直す(応答の一覧が来れば、それが正本として上書きする)。
15876
+ // 返す値は送信ごとの控え(連続操作でも他の送信の控えと混ざらない)
15877
+ onOptimistic: function (sent) {
15878
+ var op = sent && sent.op;
15879
+ var next = null;
15880
+ if (op === "backlog-target-remove") {
15881
+ var key = String(sent.backlogProjectKey || "").toUpperCase();
15882
+ var dropped = false;
15883
+ next = targets.filter(function (t) {
15884
+ if (!dropped && String(t.projectKey || "").toUpperCase() === key) { dropped = true; return false; }
15885
+ return true;
15886
+ });
15887
+ if (!dropped) next = null;
15888
+ } else if (op === "backlog-target-add") {
15889
+ var tg = backlogTargetOf(sent.url);
15890
+ if (tg) {
15891
+ var exists = targets.some(function (t) {
15892
+ return String(t.domain || "").toLowerCase() === String(tg.domain || "").toLowerCase() && String(t.projectKey || "").toUpperCase() === String(tg.projectKey || "").toUpperCase();
15893
+ });
15894
+ if (!exists) next = targets.concat([{ domain: String(tg.domain), projectKey: String(tg.projectKey) }]);
15895
+ }
15896
+ }
15897
+ if (!next) return undefined;
15898
+ var tok = { before: targets, after: targetsGenerationOf(next) };
15899
+ targets = next;
15900
+ ensureKeySlots();
15901
+ paint();
15902
+ return tok;
15903
+ },
15904
+ // 断られた・通信が落ちた・何も変わらなかった: 他の操作が一覧を変えていなければ送信前へ戻し、変えていれば
15905
+ // 正本を読み直す(連続操作で 1 つの控えが他の送信を潰さない)
15906
+ onRevert: function (tok) {
15907
+ if (!tok) return;
15908
+ if (tok.after === targetsGenerationOf(targets)) {
15909
+ targets = tok.before;
15910
+ paint();
15911
+ } else {
15912
+ try { loadLiveFleet(true); } catch (e) {}
15913
+ }
15914
+ },
15792
15915
  };
15793
15916
  }
15794
15917
 
@@ -16206,7 +16329,7 @@ ${calendarAddUrl.toString()}
16206
16329
  requiredNote: "フォルダのURLを入力してください。",
16207
16330
  // 🔴 **追加の欄は入力と[追加]だけ(c-07)。** 以前は「アカウントを招待 → URL を貼る → 何が起きるか」の
16208
16331
  // 手順(コピー行つき)を置いていたが、足してから読めなければ、その行の中が一手を出す
16209
- // (DS_GATE_CAP.materials.unreachable: 「このフォルダを cortex-notetaker@… に閲覧者として共有してください。[コピー]」)。
16332
+ // (DS_GATE_CAP.materials.unreachable: 「このフォルダを〈取り込み用アカウント〉に閲覧者として共有してください。[コピー]」)。
16210
16333
  // 「中身が 共有資料 にコピーされ Markdown 化される・止め方」はモーダル上部の説明文が 1 回だけ持つ。
16211
16334
  build: function (url, name) { return { op: "add-drive-folder", url: url, name: name }; },
16212
16335
  }));
@@ -16425,7 +16548,7 @@ ${calendarAddUrl.toString()}
16425
16548
  requiredNote: "チャンネルのURLを入力してください。",
16426
16549
  // 🔴 **追加の欄は入力と[追加]だけ(c-07)。** 以前は「/invite を貼る → リンクを貼る → 組織不明なら接続 →
16427
16550
  // Gold 昇格の対象」の手順 4 行を置いていたが、足してから読めなければ、そのチャンネルの行の中が
16428
- // 理由に合った一手(「Cortex を招待してください:/invite @Cortex[コピー]」。接続の一手は組の先頭(slackConnectNote))を出す。
16551
+ // 理由に合った一手(招待と接続。slackRowHelpParts)を出す。
16429
16552
  // 「毎晩の Gold 昇格の対象・止め方」「お客様の Slack は管理者の承認が要る」は、モーダル上部の説明文が 1 回だけ持つ。
16430
16553
  // **選んだワークスペースを送る(#104)。** 空文字=社内ワークスペースなのでキーごと落とす
16431
16554
  // (サーバ側は「未宣言・null・空」を区別しない約束で、無いことで既定を表す)
@@ -16551,6 +16674,22 @@ ${calendarAddUrl.toString()}
16551
16674
  };
16552
16675
  }
16553
16676
 
16677
+ /**
16678
+ * 二度押しだけを止める(語もスピナーも出さない)。**楽観更新の回の送信中**に使う(#968 E)——
16679
+ * 画面はもう先へ進んでいるので、「処理中…」で待たされていると読ませない。
16680
+ * @returns 元へ戻す関数。何度呼んでも 1 回しか効かない
16681
+ */
16682
+ function lockQuietly(btn) {
16683
+ if (!btn) return function () {};
16684
+ btn.disabled = true;
16685
+ var used = false;
16686
+ return function unlock() {
16687
+ if (used) return;
16688
+ used = true;
16689
+ btn.disabled = false;
16690
+ };
16691
+ }
16692
+
16554
16693
  /**
16555
16694
  * 反映待ちのオーバーレイ+スピナー(#378・共通部品)。
16556
16695
  *
@@ -16601,23 +16740,55 @@ ${calendarAddUrl.toString()}
16601
16740
  * **例外が接続先の一覧(#127)。** 追加・削除の応答にはいまの一覧が必ず
16602
16741
  * 添えられていて(成功も、重複で断られたときも、世代がずれていたときも)、これは
16603
16742
  * スナップショットではなく**サーバが書いた直後の確定値**なので、その場で描き直してよい。
16604
- * @param hooks { onTargets(list) → 画面が見ていた集合と違ったか, staleNote }
16743
+ * @param hooks { onTargets(list) → 画面が見ていた集合と違ったか, staleNote, onOptimistic(), onRevert() }
16605
16744
  * onTargets … 応答に添えられた一覧を画面へ取り込む。**成否を問わず先に呼ぶ**——
16606
16745
  * 断られたときこそ、画面を正しい状態へ追いつかせないと堂々巡りになる
16746
+ * onOptimistic … 送信した瞬間に呼ぶ(本当の楽観更新・#968 E)。呼び手が自分で直す画面(通知先の札・入力欄)を先に更新する
16747
+ * onRevert … 断られた・通信が落ちた・何も変わらなかったときに呼ぶ。onOptimistic で直したものを元へ戻す
16748
+ *
16749
+ * ## 楽観更新(cortex-engine #968 E)
16750
+ * 仮置き(notePendingOp)を**送信時**に書き、待ち表示(「保存中…」・「処理中…」)を出さずに一覧を先に更新する。
16751
+ * 失敗(エラー応答・通信失敗・何も変わらなかった)では仮置きを**巻き戻して**理由を出す。
16752
+ * 仮置きを作れない操作(切り出せない入力・仮置きの対象でない op)は、従来どおり待ち表示を出して応答を待つ。
16607
16753
  */
16608
16754
  async function sendConfig(payload, msg, btn, onDone, hooks) {
16609
16755
  var intake = SITE.intake;
16610
16756
  if (!intake) return;
16611
- // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)
16612
- var endBusy = beginBusy(btn);
16757
+ // 送信時の仮置き。作れたときは待ち表示を出さず、一覧を先に更新する
16758
+ var opt = noteOptimisticOp(payload, hooks && typeof hooks.pendingTargets === "function" ? hooks.pendingTargets() : null);
16759
+ // 押している間はボタンごと「処理中…」にする(#222・beginBusy の項)。**楽観更新の回は語を変えない**
16760
+ // (二度押しだけ止める。待たされていると読ませない)
16761
+ var endBusy = opt ? lockQuietly(btn) : beginBusy(btn);
16613
16762
  msg.className = "ds-toggle-msg";
16614
- msg.textContent = "保存中…";
16763
+ msg.textContent = opt ? "" : "保存中…";
16764
+ // 巻き戻す口。**何度呼んでも 1 回しか効かない**(失敗の分岐が複数あるため)
16765
+ var reverted = false;
16766
+ var optToken;
16767
+ var revert = function () {
16768
+ if (reverted) return;
16769
+ reverted = true;
16770
+ if (opt) rollbackOptimisticOp(opt);
16771
+ if (hooks && typeof hooks.onRevert === "function") hooks.onRevert(optToken);
16772
+ };
16773
+ if (opt && opt.entry.action !== "update") {
16774
+ // 追加・解除は、開いている一覧を今すぐ描き直す(応答後の描き直しと同じ 3 つ)
16775
+ repaintOpenLists();
16776
+ refreshDataSources();
16777
+ syncAiLinks();
16778
+ }
16779
+ if (hooks && typeof hooks.onOptimistic === "function") optToken = hooks.onOptimistic(payload);
16780
+ // 応答が届いたか。届いたあとの描画の例外で「保存できなかった」と巻き戻さないための印
16781
+ var answered = false;
16615
16782
  try {
16616
16783
  var res = await callIntake("config", payload);
16784
+ answered = true;
16617
16785
  var j = await res.json().catch(function () { return {}; });
16618
16786
  // 一覧が添えられていれば、断られていても先に画面を追いつかせる(#127)
16619
16787
  var raced = hooks && hooks.onTargets && Array.isArray(j.targets) ? hooks.onTargets(j.targets) : false;
16620
16788
  if (!res.ok) {
16789
+ // **送信時の仮置きを巻き戻す(#968 E)。** 先に戻す——重複の確認の画面は入力欄の値を読むし、
16790
+ // 「既に登録されています」の分岐は下で仮置きを書き直す
16791
+ revert();
16621
16792
  // **確認すれば覆せる断り方(2026-08-20・Slack の重複だけ)。**
16622
16793
  //
16623
16794
  // 全体定例のように複数案件に関係するチャンネルは実在するのに、
@@ -16705,9 +16876,19 @@ ${calendarAddUrl.toString()}
16705
16876
  // する——続きの「一覧への反映には数分かかります(再読み込みで最新になります)」は、
16706
16877
  // その場で「反映待ち」の行が出ている横では嘘(intake の文面は本便では触らない)。
16707
16878
  // **changed:false では書かない**(何も変わっていないのに追加したと見せない)
16708
- var pendingEntry = j.changed !== false
16709
- ? notePendingOp(payload, hooks && typeof hooks.pendingTargets === "function" ? hooks.pendingTargets() : null, j)
16710
- : null;
16879
+ // **何も変わらなかった回は、送信時の仮置きを巻き戻す**(変わっていないのに追加・解除したと見せない・#968 E)
16880
+ if (j.changed === false) revert();
16881
+ var pendingEntry = null;
16882
+ if (j.changed !== false) {
16883
+ var ptargets = hooks && typeof hooks.pendingTargets === "function" ? hooks.pendingTargets() : null;
16884
+ if (opt) {
16885
+ // 送信時に書いた自分の行を確定させる(あとの操作に置き換えられていれば何もしない)
16886
+ settleOptimisticOp(opt, payload, ptargets, j);
16887
+ pendingEntry = opt.entry;
16888
+ } else {
16889
+ pendingEntry = notePendingOp(payload, ptargets, j);
16890
+ }
16891
+ }
16711
16892
  msg.className = "ds-toggle-msg is-ok";
16712
16893
  msg.textContent = "";
16713
16894
  msg.appendChild(document.createTextNode("✓ " + ((pendingEntry ? noteHeadline(j.message) : j.message) || "保存しました。")));
@@ -16715,7 +16896,8 @@ ${calendarAddUrl.toString()}
16715
16896
  // 起動できなかった案件(権限・スタブ未配布)ではリンクを出さない——押しても何も無い
16716
16897
  if (j.runsUrl) {
16717
16898
  msg.appendChild(document.createTextNode(" "));
16718
- msg.appendChild(el("a", { href: j.runUrl || j.runsUrl, target: "_blank", rel: "noopener", title: "GitHub Actions の実行ログを開きます(プロジェクトリポジトリへのアクセスが要ります)", text: "実行状況を見る →" })); // その run を特定できていればそれ、無ければ一覧
16899
+ // **同期の一覧へのリンク**(run そのものは特定しない——応答は起動を待たない・#968 D)。起動できたかはその一覧で見る
16900
+ msg.appendChild(el("a", { href: j.runsUrl, target: "_blank", rel: "noopener", title: "GitHub Actions の同期の一覧を開きます(プロジェクトリポジトリへのアクセスが要ります)", text: "同期の一覧を見る →" }));
16719
16901
  }
16720
16902
  // **「反映の状況 →」のリンクは無くなった(#221・2026-08-25)。** 指していたのは
16721
16903
  // 「画面を追いつかせるための fleet-status の実行」で、その起動ごと撤去した
@@ -16769,9 +16951,13 @@ ${calendarAddUrl.toString()}
16769
16951
  // 第2引数は仮置きの結果(#388)。控えを出すかの判断に使う(addForm)。既存の受け側は見ない
16770
16952
  if (onDone && j.changed !== false) onDone(j, { pending: pendingEntry });
16771
16953
  } catch (e) {
16954
+ // 通信の失敗: 送信時の仮置きを巻き戻して理由を出す(#968 E)。サーバに届いていた可能性があるので、
16955
+ // 本物の登録を読み直す(保存できていれば行は live から出る)
16956
+ if (!answered) revert();
16772
16957
  msg.className = "ds-toggle-msg is-error";
16773
16958
  msg.textContent = "✗ 通信に失敗しました。";
16774
16959
  endBusy();
16960
+ if (opt && !answered) { try { loadLiveFleet(true); } catch (e2) {} }
16775
16961
  }
16776
16962
  }
16777
16963
 
@@ -17077,8 +17263,26 @@ ${calendarAddUrl.toString()}
17077
17263
  // 逆に接続先の一覧(Backlog)は onTargets を持つが確認は要らない——
17078
17264
  // 共通に足すと、あちらの「他の人が先に変えていた」の判定と混ざる。
17079
17265
  var h = opts.hooks ? opts.hooks() : null;
17080
- if (!opts.duplicateConfirmLines) return h;
17266
+ // **送信した瞬間に入力欄を空にし(楽観更新・#968 E)、断られたら元の値へ戻す**(打ち直させない)
17267
+ var sentMain = "";
17268
+ var sentName = "";
17081
17269
  h = h || {};
17270
+ // 呼び手の hooks(接続先の一覧の楽観更新など)が既にあれば、その後ろに続ける
17271
+ var baseOptimistic = h.onOptimistic;
17272
+ var baseRevert = h.onRevert;
17273
+ h.onOptimistic = function (sentPayload) {
17274
+ sentMain = main.value;
17275
+ sentName = name ? name.value : "";
17276
+ main.value = "";
17277
+ if (name) name.value = "";
17278
+ return baseOptimistic ? baseOptimistic(sentPayload) : undefined;
17279
+ };
17280
+ h.onRevert = function (tok) {
17281
+ if (!main.value) main.value = sentMain;
17282
+ if (name && !name.value) name.value = sentName;
17283
+ if (baseRevert) baseRevert(tok);
17284
+ };
17285
+ if (!opts.duplicateConfirmLines) return h;
17082
17286
  // **返り値が「進めてよいか」(Promise)。** 呼び出し側は必ず await すること——
17083
17287
  // await を落とすと Promise は truthy なので**確認なしで「はい」扱いになる**。
17084
17288
  // 2026-08-20 に window.confirm から画面内のモーダルに変えた(openConfirm)。
@@ -17419,12 +17623,15 @@ ${calendarAddUrl.toString()}
17419
17623
  // 常設の接続ボタンを撤去したので、0 件の案件に顧客の Slack を足した人が接続へ進む入口はこの行だけ
17420
17624
  // (#138 型の穴を塞ぐ)。まだ gate が付いていない行(仮置き・登録の反映だけが先に届いた行)でも、
17421
17625
  // 印(orgUnknown)があれば出す。**印が無い行(測っていない)には何も出さない**
17422
- if (s.type === "slack" && s.orgUnknown === true && !s.gate && SITE.intake) {
17626
+ // 顧客の no_token(接続で直ると確かな行)も、この行のタイルの中に一手を出す(g-66)
17627
+ if (s.type === "slack" && s.gate === "no_token" && slackRowNeedsConnect(s)) {
17628
+ card.appendChild(restingHelpNode(slackRowHelpParts(s), "ds-gate-help ds-gate-resting"));
17629
+ } else if (s.type === "slack" && s.orgUnknown === true && !s.gate && SITE.intake) {
17423
17630
  var pendingParts = slackRowHelpParts(s);
17424
17631
  if (pendingParts && pendingParts.length) card.appendChild(restingHelpNode(pendingParts, "ds-gate-help ds-gate-resting"));
17425
17632
  } else if (!(opts && opts.quietHelp) && showsRestingReason(s)) {
17426
17633
  var restingParts = restingHelpPartsOf(s.type, s.gate, s);
17427
- // 空の配列は「行には何も出さない」(接続は組の先頭が言う・slackRowHelpParts)。夜間の理由文にも落とさない
17634
+ // 空の配列は「行には何も出さない」。夜間の理由文にも落とさない
17428
17635
  if (restingParts) { if (restingParts.length) card.appendChild(restingHelpNode(restingParts, "ds-gate-help ds-gate-resting")); }
17429
17636
  else if (!(String(s.type).indexOf("github") === 0 && (s.gate === "unreachable" || s.gate === "no_token") && !githubOrgRefused(s))) {
17430
17637
  // GitHub の読めない行は表が一手を決める(出さないと決めた行=確かめられなかった組織には、
@@ -20537,6 +20744,10 @@ ${calendarAddUrl.toString()}
20537
20744
  app.appendChild(genBody);
20538
20745
  loadBody(r, genBody);
20539
20746
 
20747
+ // 関連の資料(#1094 F8)。本文の後・取り下げの節の前。ルール・用語にしか載らない
20748
+ var relm2 = relatedMaterialsSection(r);
20749
+ if (relm2) app.appendChild(relm2);
20750
+
20540
20751
  // 取り下げ(便 4-2d)。本文の後・「その後の記録」の前。取り下げの無い記録では何も作らない
20541
20752
  var retr = retractSection(r);
20542
20753
  if (retr) app.appendChild(retr);
@@ -21512,6 +21723,10 @@ ${calendarAddUrl.toString()}
21512
21723
  app.appendChild(ul);
21513
21724
  }
21514
21725
 
21726
+ // 関連の資料(#1094 F8)。「参照」(根拠)の後・置き換えの前。根拠と混ぜない
21727
+ var relm = relatedMaterialsSection(d);
21728
+ if (relm) app.appendChild(relm);
21729
+
21515
21730
  // 置き換え(#699 F3)。本文・参照の後、「その後の記録」の前。置き換えの無い決定では何も作らない
21516
21731
  var sup = supersedeSection(d);
21517
21732
  if (sup) app.appendChild(sup);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.133",
3
+ "version": "0.12.137",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,