@takagaki/cortex-decisions-viewer 0.12.78 → 0.12.80

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/render.js CHANGED
@@ -4562,6 +4562,7 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4562
4562
  .fm-rel { display: flex; gap: 6px; align-items: center; margin: 1px 0; }
4563
4563
  /* つながっている ID の欄は1行1件(配列セルの join に落とすと [object Object] になる) */
4564
4564
  .fm-li { margin: 1px 0; }
4565
+ .fm-empty { color: var(--muted); }
4565
4566
 
4566
4567
  /* ---- explorer (Miller columns) ---- */
4567
4568
  .mc-count { flex: 0 0 auto; font-size: 11px; color: var(--muted); background: #eceef1; border-radius: 999px; padding: 0 8px; }
@@ -4822,13 +4823,13 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4822
4823
  **本文の後に足す、機械が書いたことを人が確かめる節**で種類が同じ。
4823
4824
 
4824
4825
  🔴 **表に <table> を使わない。** 画面の部品に table は1つも無く、table の CSS は
4825
- 本文(.body table)用しか無い。4列のグリッドは .fm-grid と同じ書き方で組む。
4826
+ 本文(.body table)用しか無い。5列(#704 で場を足した)のグリッドは .fm-grid と同じ書き方で組む。
4826
4827
  🔴 各セルに min-width: 0 と overflow-wrap: anywhere を効かせる——ID は
4827
4828
  切れ目の無い長い文字列になりうるので、入れないと詳細ページ全体が横に伸びる */
4828
4829
  .linked-ids { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border); }
4829
4830
  .li-summary { cursor: pointer; font-size: 13.5px; font-weight: 700; color: var(--muted); }
4830
4831
  .li-grid {
4831
- display: grid; grid-template-columns: 96px minmax(0, 1.5fr) minmax(0, 1fr) 96px; gap: 7px 16px;
4832
+ display: grid; grid-template-columns: 88px minmax(0, 1.5fr) minmax(0, 1fr) 88px minmax(0, 0.8fr); gap: 7px 14px;
4832
4833
  font-size: 13px; margin-top: 10px;
4833
4834
  background: rgba(255,255,255,0.55); border: 1px solid var(--border); border-radius: var(--radius);
4834
4835
  padding: 14px 18px;
@@ -4842,6 +4843,12 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4842
4843
  .li-basis.is-firm { color: #15803d; }
4843
4844
  .li-basis.is-machine { color: var(--muted); }
4844
4845
  .li-basis.is-plain { color: var(--text); }
4846
+ /* 否定済み(not_aliases)の畳み。つながっている側より一段控えめに(#704) */
4847
+ .li-denied { margin-top: 10px; font-size: 13px; }
4848
+ .li-denied-summary { cursor: pointer; font-size: 12.5px; color: var(--muted); }
4849
+ .li-denied-list { margin: 6px 0 0; padding-left: 20px; }
4850
+ .li-denied-row { overflow-wrap: anywhere; color: var(--muted); }
4851
+ .li-denied-kind { display: inline-block; min-width: 72px; margin-right: 8px; font-weight: 700; }
4845
4852
  /* 違っていたときにどうするかの断り。.ah-source と同じ置き方(節の末尾の小さな段落) */
4846
4853
  .li-note { font-size: 12px; color: var(--muted); line-height: 1.7; margin: 12px 0 0; }
4847
4854
  /* ---- レコードの削除(?op=delete-record)----
@@ -4964,6 +4971,25 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4964
4971
  border: 1px solid rgba(194,94,63,0.95); white-space: nowrap;
4965
4972
  }
4966
4973
  .ai-apply-btn:hover { background: rgba(194,94,63,0.92); text-decoration: none; }
4974
+ /* 決定の「その後」(#705)。一覧の印は .badge の並び、詳細は提案の節の後。
4975
+ 🔴 節は .followup-section(.followup は一覧の印の .badge にも付くので、節の余白を当てない) */
4976
+ .badge.followup { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
4977
+ .badge.followup-open { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
4978
+ .badge.followup-superseded, .badge.followup-dropped { background: #f5f5f4; color: #57534e; border-color: #d6d3d1; }
4979
+ .followup-section { margin: 14px 0 4px; }
4980
+ .followup-human { font-size: 13.5px; margin: 0 0 6px; color: #166534; }
4981
+ .followup-list, .followup-evidence { list-style: none; margin: 0 0 8px; padding: 0; }
4982
+ .followup-row, .followup-evidence-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; font-size: 13.5px; line-height: 1.7; padding: 2px 0; }
4983
+ .followup-kind { font-size: 12px; color: var(--muted); border: 1px solid var(--border, #e5e7eb); border-radius: 4px; padding: 0 6px; }
4984
+ .followup-key, .followup-at { font-size: 12.5px; word-break: break-all; }
4985
+ .followup-at { color: var(--muted); }
4986
+ .followup-quote { color: var(--muted); }
4987
+ .followup-actions { margin-top: 6px; }
4988
+ .followup-actions-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
4989
+ .followup-actions-row[hidden] { display: none; }
4990
+ .followup-btn { font-size: 12.5px; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--border, #d1d5db); background: #fff; cursor: pointer; }
4991
+ .followup-btn:disabled { opacity: .6; cursor: default; }
4992
+ .followup-btn-ng { color: #92400e; }
4967
4993
  /* 押したあとの帯。draft(黄・要対応)と入れ替わるので、済んだと分かる緑にする */
4968
4994
  .promoted-note {
4969
4995
  display: flex; gap: 8px; align-items: flex-start;
@@ -6500,29 +6526,349 @@ ${calendarAddUrl.toString()}
6500
6526
  return hasProposals(rec) ? el("span", { class: "badge proposed", text: "提案あり" }) : null;
6501
6527
  }
6502
6528
  function hasProposals(rec) {
6503
- return !!(rec && rec.proposals && rec.proposals.length);
6529
+ return openProposals(rec).length > 0;
6530
+ }
6531
+ /**
6532
+ * 画面に出す提案(**答え済みの名寄せの提案を除く**・#704)。
6533
+ *
6534
+ * 🔴 claim_key の ID が、記録の identities(同じ人と答えた)か not_aliases(別人と答えた)に
6535
+ * もう在るなら、そのカードは**出さない**——従来のカードにも落とさない。人が答えたあと、
6536
+ * engine がカードを取り下げるまでの夜(最長 14 日)に同じ問いを出し続けると、
6537
+ * [別人です]を押し直して宣言した行を黙って切る形になる。バッジ・絞り込み・「AIで適用」も
6538
+ * 同じ集合を見る(数と中身が食い違わないように)
6539
+ */
6540
+ function openProposals(rec) {
6541
+ var ps = (rec && rec.proposals) || [];
6542
+ return ps.filter(function (one) { return !ipAnswered(rec, one); });
6504
6543
  }
6505
6544
 
6506
6545
  // 状態の絞り込み。**未確認と提案ありを同じセレクトに並べる**(導線を増やさない)
6546
+ var FOLLOWUP_FILTER_LABEL = { "followup-done": "その後: 完了", "followup-open": "その後: 進行中", "followup-none": "その後: 記録なし" };
6507
6547
  function statusLabel(v) {
6548
+ if (Object.prototype.hasOwnProperty.call(FOLLOWUP_FILTER_LABEL, v)) return FOLLOWUP_FILTER_LABEL[v];
6508
6549
  return v === "draft" ? "未確認のみ" : v === "proposed" ? "提案ありのみ" : v;
6509
6550
  }
6510
- /** 一覧に出す選択肢。**該当が1件も無い状態は選択肢に出さない**(押しても0件の項目を並べない) */
6511
- function statusFilterOptions(items) {
6551
+ /**
6552
+ * 一覧に出す選択肢。**該当が1件も無い状態は選択肢に出さない**(押しても0件の項目を並べない)
6553
+ *
6554
+ * @param withFollowup 「その後: …」を足すか。**決定一覧で、帳簿を読めた案件だけ true**
6555
+ * (SITE.decisionFollowup)——帳簿の無い案件で「記録なし」を全件に当てない
6556
+ */
6557
+ function statusFilterOptions(items, withFollowup) {
6512
6558
  var opts = [];
6513
6559
  if (hasUnverified(items)) opts.push("draft");
6514
6560
  if ((items || []).some(hasProposals)) opts.push("proposed");
6561
+ if (withFollowup) {
6562
+ ["followup-done", "followup-open", "followup-none"].forEach(function (v) {
6563
+ if ((items || []).some(function (r) { return matchesFollowupFilter(r, v); })) opts.push(v);
6564
+ });
6565
+ }
6515
6566
  return opts;
6516
6567
  }
6517
6568
  /**
6518
6569
  * 絞り込みの判定。**"proposed" は frontmatter の status ではない**ので、
6519
6570
  * rstatus との比較に混ぜない(混ぜると status: proposed の記録だけが引っかかる)
6571
+ *
6572
+ * 「その後: …」(followup-*)は**決定一覧だけ**の絞り込み。withFollowup が無い一覧
6573
+ * (用語集など・帳簿の無い案件)では絞らない——タブを移っても全件が消えないように
6520
6574
  */
6521
- function matchesStatusFilter(rec) {
6575
+ function matchesStatusFilter(rec, withFollowup) {
6522
6576
  if (!listState.status) return true;
6523
6577
  if (listState.status === "proposed") return hasProposals(rec);
6578
+ if (Object.prototype.hasOwnProperty.call(FOLLOWUP_FILTER_LABEL, listState.status)) return withFollowup ? matchesFollowupFilter(rec, listState.status) : true;
6524
6579
  return rec.rstatus === listState.status;
6525
6580
  }
6581
+ /* -------------------------------------------------------------------------
6582
+ * 決定の「その後」(#705・.cortex/gold-decision-followup.json と frontmatter の followup:)
6583
+ * -------------------------------------------------------------------------
6584
+ * **出すかどうかはビルド側が決めてある**(rederivation.ts の shownFollowupOf が、画面に出す
6585
+ * 6 状態の結び付きが 1 件以上ある決定にだけ rec.followup を付ける)。画面はキーの有無だけを見る。
6586
+ *
6587
+ * 🔴 **帳簿が無い案件・行が無い決定では要素を 1 つも作らない**(今までと 1 バイトも同じ DOM)。
6588
+ * 🔴 「未接続のため」「記録なし」の文は出さない——出来事だけを出す(発注書 #705)。
6589
+ */
6590
+ var FOLLOWUP_KIND_LABEL = { issue: "課題", pr: "PR", gh: "GitHub", decision: "決定" };
6591
+ var FOLLOWUP_STATE_LABEL = {
6592
+ issue_closed: "完了",
6593
+ pr_merged: "完了",
6594
+ issue_open: "進行中",
6595
+ superseded: "置き換え済み",
6596
+ // 🔴 engine は同じ日の終端を種類を問わず same_day に畳む(マージされなかった PR も入る)。
6597
+ // だから「完了」と言わない・一覧の印と「その後: 完了」の集計にも入れない
6598
+ same_day: "決定と同じ日に閉じた",
6599
+ pr_closed: "閉じた(マージなし)",
6600
+ };
6601
+ /** 便 3 の verdict。**知らない値は出さない**(ビルド側でも捨てている・二重の網) */
6602
+ var FOLLOWUP_VERDICT_LABEL = {
6603
+ done: "実施したと述べています",
6604
+ changed: "やめた・変えたと述べています",
6605
+ review: "見直し条件に触れています",
6606
+ };
6607
+ /** 担当者の答え(frontmatter の followup:)の 1 行。日付は括弧で添える */
6608
+ var FOLLOWUP_HUMAN_LABEL = {
6609
+ confirmed: "担当者が正しいと確認",
6610
+ done: "担当者が実施済みにしました",
6611
+ dropped: "担当者が取りやめと記録",
6612
+ ignore: "担当者が追わなくてよいと記録",
6613
+ };
6614
+ /** 押したあとの 1 行(「数分後に反映」は markPromoted と同じ言い方) */
6615
+ var FOLLOWUP_SENT_LABEL = {
6616
+ confirmed: "この記録が正しいと記録しました。",
6617
+ done: "実施済みにしました。",
6618
+ dropped: "取りやめたと記録しました。",
6619
+ ignore: "追わなくてよいと記録しました。",
6620
+ };
6621
+
6622
+ /** 画面に出す状態の結び付きがあるか(ビルド側でも絞ってある・語彙の外は数えない二重の網) */
6623
+ /** 表からラベルを引く(__proto__ や toString のような鍵で Object の中身を拾わない) */
6624
+ function followupLabel(table, k) {
6625
+ return Object.prototype.hasOwnProperty.call(table, k) ? table[k] : "";
6626
+ }
6627
+ function hasFollowupLinks(rec) {
6628
+ var links = (rec && rec.followup && rec.followup.links) || [];
6629
+ return links.some(function (lk) { return !!(lk && followupLabel(FOLLOWUP_STATE_LABEL, lk.state)); });
6630
+ }
6631
+ function hasFollowupHuman(rec) {
6632
+ return !!(rec && rec.followupHuman && followupLabel(FOLLOWUP_HUMAN_LABEL, rec.followupHuman.state));
6633
+ }
6634
+ /**
6635
+ * 鍵の表示。gh:owner/repo#88 → owner/repo#88(PR も同じ綴りで来る)。
6636
+ * 課題キーは engine が小文字に畳むので、**課題のときだけ大文字に戻す**(PJ_X-142)
6637
+ */
6638
+ function followupKeyText(kind, key) {
6639
+ var k = String(key || "");
6640
+ if (k.indexOf("gh:") === 0) return k.slice(3);
6641
+ return kind === "issue" ? k.toUpperCase() : k;
6642
+ }
6643
+ /** 置き換え先の決定。**SITE にあれば題名で ?id= へ、無ければ id だけ** */
6644
+ function followupDecisionEl(id) {
6645
+ var hit = null;
6646
+ (SITE.decisions || []).forEach(function (dd) { if (!hit && dd.id === id) hit = dd; });
6647
+ if (!hit) return el("span", { class: "mono", text: id });
6648
+ var a = el("a", { href: "?id=" + encodeURIComponent(id), text: hit.title || id });
6649
+ a.addEventListener("click", function (ev) { ev.preventDefault(); go(id); });
6650
+ return a;
6651
+ }
6652
+ function followupLinkRow(lk) {
6653
+ var li = el("li", { class: "followup-row" }, [
6654
+ el("span", { class: "followup-kind", text: followupLabel(FOLLOWUP_KIND_LABEL, lk.kind) || lk.kind }),
6655
+ ]);
6656
+ if (lk.state === "superseded" && lk.kind === "decision") {
6657
+ li.appendChild(el("span", { class: "followup-state", text: FOLLOWUP_STATE_LABEL.superseded + " → " }));
6658
+ li.appendChild(followupDecisionEl(lk.key));
6659
+ } else {
6660
+ li.appendChild(el("span", { class: "mono followup-key", text: followupKeyText(lk.kind, lk.key) }));
6661
+ li.appendChild(el("span", { class: "followup-state", text: followupLabel(FOLLOWUP_STATE_LABEL, lk.state) }));
6662
+ }
6663
+ if (lk.at) li.appendChild(el("span", { class: "mono followup-at", text: lk.at }));
6664
+ // http(s) だけ(ビルド側で落としてある・ここでも見る)
6665
+ if (lk.url && /^https?:\\/\\//i.test(lk.url)) {
6666
+ li.appendChild(el("a", { class: "followup-open", href: lk.url, target: "_blank", rel: "noopener", text: "開く" }));
6667
+ }
6668
+ return li;
6669
+ }
6670
+ function followupEvidenceRow(ev) {
6671
+ var label = followupLabel(FOLLOWUP_VERDICT_LABEL, ev.verdict);
6672
+ if (!label) return null;
6673
+ var li = el("li", { class: "followup-evidence-row" }, [
6674
+ el("span", { class: "followup-verdict", text: label }),
6675
+ ]);
6676
+ if (ev.quote) li.appendChild(el("q", { class: "followup-quote", text: ev.quote }));
6677
+ if (ev.url && /^https?:\\/\\//i.test(ev.url)) {
6678
+ li.appendChild(el("a", { class: "followup-open", href: ev.url, target: "_blank", rel: "noopener", text: "発言を開く" }));
6679
+ }
6680
+ return li;
6681
+ }
6682
+ function followupHumanLine(fh) {
6683
+ return el("p", { class: "followup-human", text: followupLabel(FOLLOWUP_HUMAN_LABEL, fh.state) + "(" + fh.at + ")" });
6684
+ }
6685
+
6686
+ /**
6687
+ * 詳細の節「その後の記録」。**出す結び付きも担当者の答えも無ければ null**(要素を作らない)。
6688
+ */
6689
+ function followupSection(rec) {
6690
+ if (!hasFollowupLinks(rec) && !hasFollowupHuman(rec)) return null;
6691
+ var box = el("section", { class: "followup-section" }, [
6692
+ el("div", { class: "section-label", text: "その後の記録" }),
6693
+ ]);
6694
+ if (hasFollowupHuman(rec)) box.appendChild(followupHumanLine(rec.followupHuman));
6695
+ if (hasFollowupLinks(rec)) {
6696
+ var ul = el("ul", { class: "followup-list" });
6697
+ rec.followup.links.forEach(function (lk) {
6698
+ if (followupLabel(FOLLOWUP_STATE_LABEL, lk.state)) ul.appendChild(followupLinkRow(lk));
6699
+ });
6700
+ if (ul.children.length) box.appendChild(ul);
6701
+ var evs = el("ul", { class: "followup-evidence" });
6702
+ (rec.followup.evidence || []).forEach(function (ev) {
6703
+ var row = followupEvidenceRow(ev);
6704
+ if (row) evs.appendChild(row);
6705
+ });
6706
+ if (evs.children.length) box.appendChild(evs);
6707
+ }
6708
+ var actions = followupActions(rec, box);
6709
+ if (actions) box.appendChild(actions);
6710
+ return box;
6711
+ }
6712
+
6713
+ // 担当者が直せるボタンを出すパス。**Decisions の records だけ**(守りはサーバ側の isFollowupPath)
6714
+ function followupRecordPath(rec) {
6715
+ var path = promotableRecordPath(rec);
6716
+ return path && path.indexOf("Cortex/Decisions/records/") === 0 ? path : "";
6717
+ }
6718
+ /** ブラウザの今日(YYYY-MM-DD・ローカル時刻) */
6719
+ function followupToday() {
6720
+ var d = new Date();
6721
+ return d.getFullYear() + "-" + ("0" + (d.getMonth() + 1)).slice(-2) + "-" + ("0" + d.getDate()).slice(-2);
6722
+ }
6723
+ /**
6724
+ * 「この記録は正しい」「違う」。**SITE.intake がある案件だけ**。
6725
+ * 「違う」を押すと「実施済みにする」「取りやめた」「追わなくてよい」が出る。
6726
+ * どれも押すと確認のモーダルを挟む(promote と同じ・押した瞬間に書かない)。
6727
+ */
6728
+ function followupActions(rec, box) {
6729
+ var path = followupRecordPath(rec);
6730
+ if (!path) return null;
6731
+ var wrap = el("div", { class: "followup-actions" });
6732
+ var row = el("div", { class: "followup-actions-row" });
6733
+ var okBtn = el("button", { class: "followup-btn", type: "button", text: "この記録は正しい" });
6734
+ var ngBtn = el("button", { class: "followup-btn followup-btn-ng", type: "button", text: "違う" });
6735
+ row.appendChild(okBtn);
6736
+ row.appendChild(ngBtn);
6737
+ wrap.appendChild(row);
6738
+ var choices = el("div", { class: "followup-actions-row followup-choices", hidden: "hidden" });
6739
+ [["done", "実施済みにする"], ["dropped", "取りやめた"], ["ignore", "追わなくてよい"]].forEach(function (c) {
6740
+ var b = el("button", { class: "followup-btn", type: "button", text: c[1] });
6741
+ b.addEventListener("click", function () {
6742
+ openFollowupModal(path, c[0], function (state, at) { markFollowupSent(rec, box, wrap, state, at); });
6743
+ });
6744
+ choices.appendChild(b);
6745
+ });
6746
+ wrap.appendChild(choices);
6747
+ okBtn.addEventListener("click", function () {
6748
+ openFollowupModal(path, "confirmed", function (state, at) { markFollowupSent(rec, box, wrap, state, at); });
6749
+ });
6750
+ ngBtn.addEventListener("click", function () {
6751
+ if (choices.hasAttribute("hidden")) choices.removeAttribute("hidden");
6752
+ else choices.setAttribute("hidden", "hidden");
6753
+ });
6754
+ return wrap;
6755
+ }
6756
+
6757
+ var FOLLOWUP_MODAL_TITLE = {
6758
+ confirmed: "この記録は正しいと記録する",
6759
+ done: "実施済みにする",
6760
+ dropped: "取りやめたと記録する",
6761
+ ignore: "追わなくてよいと記録する",
6762
+ };
6763
+ function openFollowupModal(path, state, onDone) {
6764
+ var mk = makeModal(FOLLOWUP_MODAL_TITLE[state], false);
6765
+ var modal = mk.modal;
6766
+ modal.appendChild(el("p", { class: "modal-desc", text: "この決定の記録に、担当者の答えとして 1 行を書き足します。" }));
6767
+ modal.appendChild(el("div", { class: "promote-target mono", text: path }));
6768
+ var dateInput = null;
6769
+ if (state === "done") {
6770
+ dateInput = el("input", { type: "date", class: "modal-date" });
6771
+ dateInput.value = followupToday();
6772
+ modal.appendChild(fieldRow("実施日", dateInput));
6773
+ }
6774
+ var nameInput = el("input", { type: "text", class: "modal-name", placeholder: "記録した人として残ります(省略できます)" });
6775
+ nameInput.value = rememberedVerifier();
6776
+ modal.appendChild(fieldRow("お名前(任意)", nameInput));
6777
+ var status = el("div", { class: "modal-status" });
6778
+ var cancelBtn = el("button", { class: "modal-cancel", type: "button", text: "閉じる" });
6779
+ var okBtn = el("button", { class: "ops-card-btn", type: "button", text: FOLLOWUP_MODAL_TITLE[state] });
6780
+ modal.appendChild(status);
6781
+ modal.appendChild(el("div", { class: "modal-actions" }, [cancelBtn, okBtn]));
6782
+ mk.overlay.appendChild(modal);
6783
+ showModal(mk.overlay, modal);
6784
+ cancelBtn.addEventListener("click", function () { mk.close(); });
6785
+ okBtn.addEventListener("click", function () {
6786
+ var at = dateInput ? String(dateInput.value || "").trim() : followupToday();
6787
+ sendFollowup(path, state, at, nameInput.value, status, okBtn, function () { mk.close(); onDone(state, at); });
6788
+ });
6789
+ }
6790
+
6791
+ async function sendFollowup(path, state, at, name, status, btn, onOk) {
6792
+ var intake = SITE.intake;
6793
+ if (!intake) return;
6794
+ function setStatus(msg, isError) {
6795
+ status.className = "modal-status" + (isError ? " modal-status-error" : "");
6796
+ status.textContent = msg;
6797
+ }
6798
+ // 日付は押す前にここでも見る(守りはサーバ側の isFollowupDate)
6799
+ if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(at)) {
6800
+ setStatus("✗ 日付を YYYY-MM-DD の形で入れてください。", true);
6801
+ return;
6802
+ }
6803
+ var endBusy = beginBusy(btn, "記録中…");
6804
+ setStatus("", false);
6805
+ var who = String(name || "").trim();
6806
+ var res, j;
6807
+ try {
6808
+ res = await callIntake("set-followup", { projectKey: intake.projectKey, path: path, state: state, at: at, submitter: who });
6809
+ j = await res.json().catch(function () { return {}; });
6810
+ } catch (e) {
6811
+ endBusy();
6812
+ setStatus("✗ サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。", true);
6813
+ return;
6814
+ }
6815
+ // 同じ値の押し直しもサーバは 200(unchanged)で返す——押した人の望む状態にはなっている
6816
+ if (res.ok) {
6817
+ if (who) { try { localStorage.setItem(verifierStoreKey(), who); } catch (e2) {} }
6818
+ endBusy();
6819
+ onOk();
6820
+ return;
6821
+ }
6822
+ endBusy();
6823
+ setStatus("✗ " + (j.message || "記録できませんでした。時間をおいて再度お試しください。"), true);
6824
+ }
6825
+
6826
+ /**
6827
+ * 押した結果を節に映す(markPromoted と同じ「数分後に反映」の言い方)。
6828
+ * **元データも直す**——一覧へ戻って開き直しても元に戻らないように
6829
+ */
6830
+ function markFollowupSent(rec, box, actions, state, at) {
6831
+ rec.followupHuman = { state: state, at: at };
6832
+ var note = el("div", { class: "promoted-note followup-sent" }, [
6833
+ el("span", { text: "✅" }),
6834
+ el("span", { text: FOLLOWUP_SENT_LABEL[state] + "一覧などの表示は、数分後に画面が作り直されたときに反映されます。" }),
6835
+ ]);
6836
+ if (actions.parentNode === box) box.replaceChild(note, actions);
6837
+ else box.appendChild(note);
6838
+ }
6839
+
6840
+ /**
6841
+ * 一覧の印の種類。**担当者の答えを優先**(done→実施済み・dropped→取りやめ。
6842
+ * confirmed / ignore は帳簿に従う)。無ければ帳簿の出す結び付きから:
6843
+ * 置き換え済み → 完了(issue_closed / pr_merged)→ 進行中(issue_open)の順。
6844
+ * pr_closed・same_day だけの決定には印を付けない(same_day は閉じ方を問わない語なので「完了」と言えない)。
6845
+ */
6846
+ function followupMarkOf(rec) {
6847
+ var fh = hasFollowupHuman(rec) ? rec.followupHuman : null;
6848
+ if (fh && fh.state === "done") return "done";
6849
+ if (fh && fh.state === "dropped") return "dropped";
6850
+ if (!hasFollowupLinks(rec)) return "";
6851
+ var states = rec.followup.links.map(function (lk) { return lk.state; });
6852
+ if (states.indexOf("superseded") !== -1) return "superseded";
6853
+ if (states.indexOf("issue_closed") !== -1 || states.indexOf("pr_merged") !== -1) return "complete";
6854
+ if (states.indexOf("issue_open") !== -1) return "open";
6855
+ return "";
6856
+ }
6857
+ var FOLLOWUP_MARK_LABEL = { done: "実施済み", dropped: "取りやめ", superseded: "置き換え済み", complete: "完了", open: "進行中" };
6858
+ /** 一覧の印(proposalBadge と同じ形)。**印が無ければ null**(要素を作らない) */
6859
+ function followupBadge(rec) {
6860
+ var mark = followupMarkOf(rec);
6861
+ return mark ? el("span", { class: "badge followup followup-" + mark, text: FOLLOWUP_MARK_LABEL[mark] }) : null;
6862
+ }
6863
+ /** 絞り込み「その後: …」の判定(帳簿を持つ案件の決定一覧だけで使う) */
6864
+ function matchesFollowupFilter(rec, v) {
6865
+ var mark = followupMarkOf(rec);
6866
+ if (v === "followup-done") return mark === "complete" || mark === "done";
6867
+ if (v === "followup-open") return mark === "open";
6868
+ if (v === "followup-none") return !hasFollowupLinks(rec) && !hasFollowupHuman(rec);
6869
+ return false;
6870
+ }
6871
+
6526
6872
  // 未確認が1件も無い一覧では絞り込みを出さない
6527
6873
  function hasUnverified(items) {
6528
6874
  return (items || []).some(function (r) { return r.rstatus === "draft"; });
@@ -6810,7 +7156,7 @@ ${calendarAddUrl.toString()}
6810
7156
  * 1 人が「名前の別表記の提案」と「ID の提案」を同時に持つときは、束ねる側は別表記の分だけ。
6811
7157
  */
6812
7158
  function bundledProposals(rec, sectionName) {
6813
- var ps = (rec && rec.proposals) || [];
7159
+ var ps = openProposals(rec);
6814
7160
  return ps.filter(function (one) { return !identityAsk(rec, sectionName, one); });
6815
7161
  }
6816
7162
  function aiApplyBtn(rec, sectionName, title) {
@@ -6863,20 +7209,21 @@ ${calendarAddUrl.toString()}
6863
7209
  // 関係者の記録に外の ID を 1 つ足す提案は、差分の表ではなく**人への問い**で出す。
6864
7210
  // 機械が決めきれなかったものを人が決める場なので、答えは 2 つだけ(同じ人/別人)。
6865
7211
  //
6866
- // 🔴 **門を 1 つでも欠いたら、今までどおりのカードに倒す。** 新しい形にするのは
6867
- // 1. 記録が関係者
6868
- // 2. field が aliases
6869
- // 3. proposed から current を引いた差に、種類つきの ID がちょうど 1 つ
6870
- // の**全部**を満たすときだけ。
7212
+ // 🔴 **門を 1 つでも欠いたら、今までどおりのカードに倒す。** 新しい形にする道は 2 本:
7213
+ //
7214
+ // (A) engine の便 A(cortex-engine #540)の形——kind: "identity" と claim_key(足す ID そのもの)
7215
+ // を持つ提案(#704 で build の許可リストに足した)。門は
7216
+ // 1. 記録が関係者
7217
+ // 2. field が aliases(engine の現物)か identities(FM_LINKED_IDS)
7218
+ // 3. claim_key が種類つきの ID の形で、proposed の要素に在り、current の要素に無い
7219
+ // **カンマ連結の差集合から推測しない**ので、別名にカンマが入っても壊れない。
7220
+ // (B) それより前の形(kind も claim_key も無い)——
7221
+ // 1. 記録が関係者
7222
+ // 2. field が aliases
7223
+ // 3. proposed から current を引いた差に、種類つきの ID がちょうど 1 つ
6871
7224
  //
6872
- // 🔴 **kind === "identity" は見ない(2026-09-20 に外した)。** 提案を読む
6873
- // readGoldProposals(build.ts)は GoldProposal を**明示列挙で組み立てていて kind を
6874
- // 写していない**ので、実データでは one.kind が常に undefined——枝は本番で 1 度も
6875
- // 通らない死に枝だった。この便の決定は「**提案に新しい欄を 1 つも通さない**」なので、
6876
- // 許可リストに kind を足す側は採らない。
6877
- // 🔴 **engine が kind: "identity" を出し始める便では、tools 側の「許可リスト
6878
- //(readGoldProposals)」と「この門」の両方に足すこと。** 片方だけだと、engine が出しても
6879
- // build が捨てる(届かない)か、届いても門が見ていない(使われない)。
7225
+ // 🔴 **kind が付いていても claim_key が無ければ (B) に落とす**(kind だけで新しい形にしない)。
7226
+ // 🔴 **kind は field の代わりにはならない**——field が別の項目(重複の疑い 等)なら今までどおり。
6880
7227
  //
6881
7228
  // 🔴 **見分けは種類つきの形(コロン付き)だけで行う。** 「@名前」「素のメール」は門に使わない
6882
7229
  // ——aliases にはハンドル名が普通に入るので、@yamada が門を通ると、engine が
@@ -6954,6 +7301,56 @@ ${calendarAddUrl.toString()}
6954
7301
  function ipMemberSection(name) {
6955
7302
  return name === "Members" || name === "メンバー" || name === "関係者";
6956
7303
  }
7304
+ /** カンマ連結の値を要素に割る(空白を落とす・空の断片は捨てる)。要素の有無を見るだけに使う */
7305
+ function ipLooseParts(v) {
7306
+ return (v == null ? "" : String(v)).split(",").map(function (x) { return x.trim(); })
7307
+ .filter(function (x) { return x !== ""; });
7308
+ }
7309
+ /**
7310
+ * 道 (A): engine が足す ID を claim_key で名指ししている提案(便 A・#704)。
7311
+ *
7312
+ * 🔴 **claim_key を鵜呑みにしない。** 種類つきの形で・内側の呼び名でなく・proposed に在り・
7313
+ * current に無いときだけ通す——すでに結び付いている ID について「同じ人ですか」と聞くと、
7314
+ * [別人です]で人が自分で宣言した行が not_aliases で黙って切れる(ipAdded と同じ理由)
7315
+ */
7316
+ function ipAskByClaimKey(one) {
7317
+ if (one.field !== "aliases" && one.field !== FM_LINKED_IDS) return null;
7318
+ var typed = ipSplitTypedId(one.claim_key);
7319
+ if (!typed) return null;
7320
+ var whole = typed.kind + ":" + typed.value;
7321
+ var norm = function (x) { var t = ipSplitTypedId(x); return t ? t.kind + ":" + t.value : x; };
7322
+ if (ipLooseParts(one.proposed).map(norm).indexOf(whole) < 0) return null;
7323
+ if (ipLooseParts(one.current).map(norm).indexOf(whole) >= 0) return null;
7324
+ return typed;
7325
+ }
7326
+ /**
7327
+ * ID を突き合わせ用の綴りにそろえる(全角・コロンの前後の空白を落とす)。形に当たらなければ空文字。
7328
+ * engine の normalizeIdentityId(コロンの前後の空白を落とす)と同じ寄せ方
7329
+ */
7330
+ function ipNormId(x) {
7331
+ if (typeof x !== "string") return "";
7332
+ var s = x.normalize("NFKC").replace(/:/g, ":").trim().replace(/^([^:]*?)\\s*:\\s*/, "$1:");
7333
+ var t = ipSplitTypedId(s);
7334
+ return t ? t.kind + ":" + t.value : "";
7335
+ }
7336
+ /**
7337
+ * 名寄せの提案が**答え済み**か(claim_key の ID が記録の identities[].id か not_aliases に在る)。
7338
+ * kind: identity と claim_key を持つ提案(engine の便 A の形)だけを見る
7339
+ */
7340
+ function ipAnswered(rec, one) {
7341
+ if (!one || one.kind !== "identity") return false;
7342
+ var key = ipNormId(one.claim_key);
7343
+ if (!key) return false;
7344
+ var fm = (rec && rec.fm) || {};
7345
+ var denied = Array.isArray(fm.not_aliases) ? fm.not_aliases : [];
7346
+ for (var i = 0; i < denied.length; i++) if (ipNormId(denied[i]) === key) return true;
7347
+ var ids = Array.isArray(fm[FM_LINKED_IDS]) ? fm[FM_LINKED_IDS] : [];
7348
+ for (var j = 0; j < ids.length; j++) {
7349
+ var ent = ids[j];
7350
+ if (ent && typeof ent === "object" && !Array.isArray(ent) && ipNormId(ent.id) === key) return true;
7351
+ }
7352
+ return false;
7353
+ }
6957
7354
  /**
6958
7355
  * この提案を「同じ人ですか」の形にしてよいか。よければ増える ID の種類と値を返す。
6959
7356
  * **3 つの門は常に AND**(上のコメントの 1・2・3)。
@@ -6961,6 +7358,9 @@ ${calendarAddUrl.toString()}
6961
7358
  function identityAsk(rec, sectionName, one) {
6962
7359
  if (!one) return null;
6963
7360
  if (!isMemberRec(rec) && !ipMemberSection(sectionName)) return null;
7361
+ if (one.kind === "identity" && typeof one.claim_key === "string" && one.claim_key.trim() !== "") {
7362
+ return ipAskByClaimKey(one);
7363
+ }
6964
7364
  if (one.field !== "aliases") return null;
6965
7365
  var added = ipAdded(one.current, one.proposed);
6966
7366
  if (added === null) return null;
@@ -6972,10 +7372,9 @@ ${calendarAddUrl.toString()}
6972
7372
  return ids.length === 1 ? ids[0] : null;
6973
7373
  }
6974
7374
  /**
6975
- * 問いの文。
7375
+ * 問いの文(engine が question を出していないときの組み立て)。
6976
7376
  *
6977
- * 🔴 **名乗り(display_name)は engine がまだ入れていないので、推測で名前を作らない。**
6978
- * 来た日に切り替えられるよう口だけ開けてある(いまの呼び出しは空文字を渡す)。
7377
+ * 🔴 **名乗り(display_name)は engine が出したときだけ使う。推測で名前を作らない。**
6979
7378
  * 道具の読める名前は「つながっている ID」の対応表と**同じもの**を引く(2 度書かない)。
6980
7379
  */
6981
7380
  function ipQuestion(toolLabel, value, displayName) {
@@ -6990,10 +7389,26 @@ ${calendarAddUrl.toString()}
6990
7389
  // 🔴 **囲いの外に出る値も aiFenceScrub を通す**(パスは外部から書ける)
6991
7390
  return aiFenceScrub((pathRef ? pathRef + " と " : "") + g.dir + "/README.md");
6992
7391
  }
7392
+ /** 文字列で中身があるときだけ返す(engine の新しい欄は無いことがある) */
7393
+ function ipText(v) {
7394
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : "";
7395
+ }
7396
+ /**
7397
+ * カードの見出しの問い。🔴 **engine の question を優先する**(便 A・#534 で「人への問い」と
7398
+ * 「機械の事情」を別の欄にした)。無い提案(便 A より前の形)は画面で組み立てる
7399
+ */
7400
+ function ipAskText(one, typed) {
7401
+ return ipText(one.question) || ipQuestion(liKindLabel(typed.kind), typed.value, ipText(one.display_name));
7402
+ }
7403
+ /** 折りたたみに入れる機械の事情。why があればそれ、無ければ reason(前の形) */
7404
+ function ipWhyText(one) {
7405
+ return ipText(one.why) || ipText(one.reason);
7406
+ }
6993
7407
  /** 囲いに入れる提案データの行(何を判定するのか・機械の事情・根拠) */
6994
7408
  function ipDataLines(one, typed) {
6995
7409
  var lines = ["判定の対象: " + typed.kind + ":" + typed.value];
6996
- if (one.reason) lines.push("機械が決めきれなかった理由: " + one.reason);
7410
+ var why = ipWhyText(one);
7411
+ if (why) lines.push("機械が決めきれなかった理由: " + why);
6997
7412
  (one.evidence || []).forEach(function (e) {
6998
7413
  lines.push("根拠: " + e.source + (e.quote ? " — 「" + e.quote + "」" : ""));
6999
7414
  });
@@ -7013,13 +7428,13 @@ ${calendarAddUrl.toString()}
7013
7428
  * claude.ai/code のディープリンクで、書くのは対話の向こう側。
7014
7429
  *
7015
7430
  * 🔴 **「1 行足す」と書き、配列ごと置換させない。** 🔴 **basis は書かせない**
7016
- *(書かないことが「人が確認した」の意味になる)。欄名の綴りは FM_LINKED_IDS 1 本から引く。
7431
+ *(書かないことが「本人が申告」の意味になる)。欄名の綴りは FM_LINKED_IDS 1 本から引く。
7017
7432
  */
7018
7433
  function ipSameBtn(rec, sectionName, title, one, typed) {
7019
7434
  var whole = typed.kind + ":" + typed.value;
7020
7435
  var lines = [
7021
7436
  ipTarget(rec, sectionName) + " を読んでから、この関係者「" + aiFenceScrub(title) + "」の " + FM_LINKED_IDS +
7022
- " に - id: \\"" + whole + "\\" の**1 行だけ**足してください(**basis は書かないでください**=「人が確認した」の意味になります)。",
7437
+ " に - id: \\"" + whole + "\\" の**1 行だけ**足してください(**basis は書かないでください**=「本人が申告」の意味になります)。",
7023
7438
  "欄が無ければ作ってください。**配列ごと置き換えないでください。** aliases は触らないでください。",
7024
7439
  "",
7025
7440
  ].concat(aiFenced(ipDataLines(one, typed))).concat([""]).concat(ipTail(rec));
@@ -7059,12 +7474,12 @@ ${calendarAddUrl.toString()}
7059
7474
  /** 「同じ人ですか」のカード 1 枚。**ボタンはカードごと**(束ねる側からは外してある) */
7060
7475
  function ipCard(rec, sectionName, one, typed) {
7061
7476
  var item = el("div", { class: "proposal proposal-identity" }, [
7062
- el("div", { class: "ip-ask", text: ipQuestion(liKindLabel(typed.kind), typed.value, "") }),
7477
+ el("div", { class: "ip-ask", text: ipAskText(one, typed) }),
7063
7478
  ]);
7064
7479
  var ev = proposalEvidenceList(one);
7065
7480
  if (ev) item.appendChild(ev);
7066
- // ID の値は小さく添える。リンクにしてよい形(GitHub とメールだけ・許可リストで検査)は
7067
- // 「つながっている ID」と同じ規則で決める
7481
+ // ID の値は小さく添える。リンクにしてよい形(許可リストで検査・Slack と Backlog は
7482
+ // 行き先のドメインが 1 つに決まるときだけ)は「つながっている ID」と同じ規則で決める
7068
7483
  var idCell = el("div", { class: "ip-id" }, [el("span", { text: "ID: " })]);
7069
7484
  var whole = typed.kind + ":" + typed.value;
7070
7485
  var href = liHref(typed.kind, typed.value);
@@ -7076,10 +7491,11 @@ ${calendarAddUrl.toString()}
7076
7491
  ipSameBtn(rec, sectionName, rec.title, one, typed),
7077
7492
  ipDiffBtn(rec, sectionName, rec.title, one, typed),
7078
7493
  ]));
7079
- if (one.reason) {
7494
+ var why = ipWhyText(one);
7495
+ if (why) {
7080
7496
  item.appendChild(el("details", { class: "ip-why" }, [
7081
7497
  el("summary", { class: "ip-why-summary", text: "機械が決めきれなかった理由" }),
7082
- el("div", { class: "ip-why-text", text: one.reason }),
7498
+ el("div", { class: "ip-why-text", text: why }),
7083
7499
  ]));
7084
7500
  }
7085
7501
  if (one.createdAt) {
@@ -7095,7 +7511,7 @@ ${calendarAddUrl.toString()}
7095
7511
  * 任意文字列で、innerHTML に渡すと出典の引用がそのまま画面のコードになる。
7096
7512
  */
7097
7513
  function proposalsSection(rec, sectionName) {
7098
- var ps = (rec && rec.proposals) || [];
7514
+ var ps = openProposals(rec);
7099
7515
  if (!ps.length) return null;
7100
7516
  var box = el("section", { class: "proposals" });
7101
7517
  var head = el("div", { class: "proposals-head" }, [
@@ -17294,7 +17710,7 @@ ${calendarAddUrl.toString()}
17294
17710
  filters.appendChild(buildSelect("すべての期間", SITE.months, listState.month, function (v) { listState.month = v; listState.page = 1; drawCards(); }, monthLabel));
17295
17711
  }
17296
17712
  filters.appendChild(buildSelect("すべてのカテゴリー", SITE.categories, listState.category, function (v) { listState.category = v; listState.page = 1; drawCards(); }));
17297
- var statusOptsD = statusFilterOptions(visibleRecords(SITE.decisions));
17713
+ var statusOptsD = statusFilterOptions(visibleRecords(SITE.decisions), !!SITE.decisionFollowup);
17298
17714
  if (statusOptsD.length) {
17299
17715
  filters.appendChild(buildSelect("すべての状態", statusOptsD, listState.status, function (v) { listState.status = v; listState.page = 1; drawCards(); }, statusLabel));
17300
17716
  }
@@ -17327,7 +17743,7 @@ ${calendarAddUrl.toString()}
17327
17743
  var items = all.filter(function (d) {
17328
17744
  if (listState.month && d.date.slice(0, 7) !== listState.month) return false;
17329
17745
  if (listState.category && d.category !== listState.category) return false;
17330
- if (!matchesStatusFilter(d)) return false;
17746
+ if (!matchesStatusFilter(d, !!SITE.decisionFollowup)) return false;
17331
17747
  if (q) {
17332
17748
  var hay = (d.title + " " + d.summary + " " + d.searchText + " " + d.deciders.join(" ")).toLowerCase();
17333
17749
  if (hay.indexOf(q) === -1) return false;
@@ -17353,6 +17769,7 @@ ${calendarAddUrl.toString()}
17353
17769
  var meta = el("div", { class: "card-meta" }, [
17354
17770
  draftBadge(d),
17355
17771
  proposalBadge(d),
17772
+ followupBadge(d),
17356
17773
  latestBadge(d),
17357
17774
  el("span", { class: "mono", text: d.date || "----" }),
17358
17775
  catBadge(d.category),
@@ -17563,7 +17980,7 @@ ${calendarAddUrl.toString()}
17563
17980
  if (meta.children.length) box.appendChild(meta);
17564
17981
  return box;
17565
17982
  }
17566
- // ---------- 関係者詳細の「つながっている ID」(#686) ----------
17983
+ // ---------- 関係者詳細の「つながっている ID」(#686・#704 で値・場・リンク・否定済み) ----------
17567
17984
  //
17568
17985
  // 名寄せが「この人だ」と結んだ外の ID を、**どうやって分かったか**と一緒に並べる節。
17569
17986
  // 人がここを見て違いに気づけることが目的なので、根拠を伏せない。
@@ -17577,26 +17994,30 @@ ${calendarAddUrl.toString()}
17577
17994
  // 道具の読める名前。🔴 **語彙の外の種類は、その綴りをそのまま出す**
17578
17995
  //(engine が新しい種類を足した晩に、道具の欄が空になって壊れないように)
17579
17996
  var LI_KIND_LABEL = {
17580
- email: "メール", git_author: "git", github_login: "GitHub", slack_user: "Slack",
17997
+ email: "メール", git_author: "git の作者", github_login: "GitHub", slack_user: "Slack",
17581
17998
  google_chat_user: "Google Chat", figma_user: "Figma", backlog_user: "Backlog", portal_user: "ポータル",
17582
17999
  };
17583
18000
  // 根拠の日本語と色。🔴 **2つの語彙を受ける**——#534 の設計の語(verified / declared /
17584
- // exact / name_match / judged)と、engine のコードに実在する語(name_only / probabilistic)。
17585
- // どちらの綴りで書かれても同じ日本語に寄せる(片方しか受けないと、書いた晩から空欄になる)
18001
+ // exact / name_match / judged)と、engine のコードに実在した語(name_only / probabilistic)。
18002
+ // どちらの綴りで書かれても同じ日本語に寄せる(片方しか受けないと、書いた晩から空欄になる)。
18003
+ // 🔴 **言葉はオーナーの決定(2026-09-23・#704)**: ポータルで確定/本人が申告/メール一致/
18004
+ // 名前一致/AI が判定。レコード情報のパネルも同じ表を引く(2 度書かない)
17586
18005
  var LI_BASIS = {
17587
- verified: { text: "本人が繋いだ", tone: "is-firm" },
17588
- declared: { text: "人が確認した", tone: "is-firm" },
17589
- exact: { text: "メールの一致", tone: "is-firm" },
17590
- name_match: { text: "氏名の一致", tone: "is-machine" },
17591
- name_only: { text: "氏名の一致", tone: "is-machine" },
17592
- judged: { text: "AI の判定", tone: "is-machine" },
17593
- probabilistic: { text: "AI の判定", tone: "is-machine" },
18006
+ verified: { text: "ポータルで確定", tone: "is-firm" },
18007
+ declared: { text: "本人が申告", tone: "is-firm" },
18008
+ exact: { text: "メール一致", tone: "is-firm" },
18009
+ name_match: { text: "名前一致", tone: "is-machine" },
18010
+ name_only: { text: "名前一致", tone: "is-machine" },
18011
+ judged: { text: "AI が判定", tone: "is-machine" },
18012
+ probabilistic: { text: "AI が判定", tone: "is-machine" },
17594
18013
  };
17595
18014
  // 🔴 **文字列でも数でもない basis(配列・オブジェクト)の出し方。** そのまま String() すると
17596
18015
  // 「[object Object]」が画面に出る。語彙の外は「そのまま出す」が規則だが、**そのまま出せない値**
17597
18016
  // はこの 1 語に寄せる(色は中立のまま)。分類 B・決定ログ 20260920-686.md の B-19
17598
18017
  var LI_BASIS_UNREADABLE = "(根拠を読み取れません)";
17599
- var LI_COLS = ["道具", "この人のページ", "どうやって分かったか", "いつから"];
18018
+ // 🔴 **5 列目の「場」(scope)は #704 で足した。** engine は綴りだけ予約していて、いまは
18019
+ // 人が手で書いたときだけ入る。無い行は素の空(「いつから」と同じ——空が正常)
18020
+ var LI_COLS = ["道具", "この人のページ", "どうやって分かったか", "いつから", "場"];
17600
18021
  var LI_TYPED_ID = /^([A-Za-z][A-Za-z0-9_]*):(.+)$/;
17601
18022
  // 🔴 **リンクにしてよい値の許可リスト。** 拒否リストにすると ? と & が通り、
17602
18023
  // mailto:a@b?bcc=...&body=... の形のヘッダ注入が残る。
@@ -17607,7 +18028,21 @@ ${calendarAddUrl.toString()}
17607
18028
  // a@b?bcc=... は当たってしまう——mailto: の URL で意味を持つ記号(クエリの始まり・
17608
18029
  // 区切り・フラグメント・パーセント)をもう一段外して、ヘッダ注入の形を落とす
17609
18030
  var LI_MAIL_META = /[?#&%]/;
18031
+ // Slack のユーザー ID(U… / W…・英大文字と数字だけ)と、ワークスペースのドメイン
18032
+ //(acme.slack.com の acme の部分。fleet-status.json の slackWorkspaces[].domain)
18033
+ var LI_SLACK_USER = /^[UW][A-Z0-9]{2,20}$/;
18034
+ var LI_SLACK_DOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
18035
+ var LI_SLACK_TEAM = /^[TE][A-Z0-9]{2,20}$/;
18036
+ // Backlog のユーザー(engine は userId=ログイン名、無い相手は数値 id を入れる)と、
18037
+ // スペースのドメイン(fleet の backlogTarget。正本は 課題管理/issues-config.json)。
18038
+ // 🔴 **数字だけの値はリンクにしない**——ユーザーページの URL はログイン名で組むので、
18039
+ // 数値 id で組むと開いた先に誰も居ない(または別人が出る)
18040
+ var LI_BACKLOG_USER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
18041
+ var LI_BACKLOG_DOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.backlog\\.(?:jp|com)$/i;
17610
18042
  var LI_NOTE = "違うものがあれば「AIで編集」から「この ID は私ではない」と伝えると、翌晩から結ばなくなります。";
18043
+ // 否定済み(not_aliases)の畳みの見出し。🔴 **中は値だけでリンクにしない**——
18044
+ // 「私ではない」と言った先へ飛ばす口を出すと、つながっている側と見分けがつかなくなる
18045
+ var LI_DENIED_TITLE = "これは私ではない(否定済み)";
17611
18046
 
17612
18047
  /** 種類 → 道具の読める名前。語彙の外はその綴りのまま */
17613
18048
  function liKindLabel(kind) {
@@ -17616,7 +18051,7 @@ ${calendarAddUrl.toString()}
17616
18051
  /**
17617
18052
  * 根拠 → 画面の言葉と色。
17618
18053
  *
17619
- * 🔴 **basis が無い行は「人が確認した」**(engine の決め。人がいちばん書きやすい
18054
+ * 🔴 **basis が無い行は「本人が申告」**(engine の決め。人がいちばん書きやすい
17620
18055
  * id だけの 1 行が、いちばん強い宣言になる)。
17621
18056
  * 🔴 **語彙の外はそのまま出して、中立の色に置く**——知らない根拠を、確かな側にも
17622
18057
  * 機械の側にも倒さない
@@ -17625,7 +18060,7 @@ ${calendarAddUrl.toString()}
17625
18060
  // 🔴 **declared(いちばん強い段)に倒してよいのは「欄が無い/空」のときだけ。**
17626
18061
  // 以前は typeof raw === "string" でない値を一律に空扱いしていたので、
17627
18062
  // basis: 123 や basis: {}(engine が形を崩した日・人が手で書き損ねた日)が
17628
- // **「人が確認した」=人の宣言**として確かな側の色で出ていた。読めない値を昇格させない
18063
+ // **人の宣言**として確かな側の色で出ていた。読めない値を昇格させない
17629
18064
  if (raw == null) return LI_BASIS.declared;
17630
18065
  if (typeof raw !== "string") {
17631
18066
  var shown = typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint"
@@ -17639,22 +18074,84 @@ ${calendarAddUrl.toString()}
17639
18074
  if (Object.prototype.hasOwnProperty.call(LI_BASIS, lower)) return LI_BASIS[lower];
17640
18075
  return { text: key, tone: "is-plain" };
17641
18076
  }
17642
- /**
17643
- * その行をリンクにしてよければ href を返す(だめなら空文字=値だけ出す)。
17644
- *
17645
- * 🔴 Slack・Backlog・Figma・Google Chat・その他は**値だけ**。人のページの URL の形が
17646
- * どこにも書かれていないので、ホスト名から推測して組まない。
17647
- */
17648
18077
  /** メールの形に当たり、mailto: の URL を壊す記号を含まないときだけ href を返す */
17649
18078
  function liMailto(value) {
17650
18079
  return LI_EMAIL.test(value) && !LI_MAIL_META.test(value) ? "mailto:" + value : "";
17651
18080
  }
17652
- function liHref(kind, value) {
18081
+ /**
18082
+ * Slack のユーザーのページを組むワークスペースのドメイン(決められなければ空文字)。
18083
+ *
18084
+ * 🔴 **既定の決め方(分類 B・決定ログ 20260923-704.md)**:
18085
+ * 1. 行の場(scope)が接続済みワークスペースの team_id(T…)と一致すれば、その
18086
+ * ワークスペース。一致した先にドメインが無ければ**リンク無し**(他へ倒さない)。
18087
+ * 場が team_id の形なのにどれとも一致しなければ**リンク無し**
18088
+ * 2. そうでなく、接続済みワークスペースが**ちょうど 1 つ**ならそれ
18089
+ * 3. それ以外(0 個・2 個以上)は**リンク無し**
18090
+ * 🔴 **チャンネルの URL のホスト名から推測しない**(改名で変わるし、共有チャンネルでは
18091
+ * 貼った人のホストになる。Slack の節と同じ作法)。**間違ったリンクは値だけより悪い**
18092
+ */
18093
+ function liSlackDomain(scope) {
18094
+ var list = slackWorkspaces() || [];
18095
+ var pick = null;
18096
+ var want = typeof scope === "string" ? scope.trim() : "";
18097
+ if (want !== "") {
18098
+ for (var i = 0; i < list.length; i++) {
18099
+ if (list[i] && list[i].teamId === want) { pick = list[i]; break; }
18100
+ }
18101
+ // 🔴 **場が team_id の形(T… / E…)なのに接続済みのどれとも一致しないなら、リンク無し。**
18102
+ // 別のワークスペースの人を、接続済みのワークスペースのページへ飛ばさない
18103
+ if (!pick && LI_SLACK_TEAM.test(want)) return "";
18104
+ }
18105
+ if (!pick && list.length === 1) pick = list[0];
18106
+ var d = pick && typeof pick.domain === "string" ? pick.domain.trim() : "";
18107
+ return LI_SLACK_DOMAIN.test(d) ? d : "";
18108
+ }
18109
+ /**
18110
+ * Backlog のユーザーのページを組むスペースのドメイン(決められなければ空文字)。
18111
+ *
18112
+ * 🔴 **登録簿(issues-config.json)はここで読まない**(ビルドも画面も読まない約束・
18113
+ * no-registry-read)。同じ値を engine が写した fleet の backlogTarget を、接続先の一覧と
18114
+ * 同じ関数(backlogTargetsNow)で引く。
18115
+ * 🔴 **スペースが 1 つに決まるときだけ**(接続先が 2 つあっても同じスペースなら 1 つ)。
18116
+ * 行の場(scope)がどれかのスペースのドメインと一致すればそれを採り、スペースの形なのに
18117
+ * どれとも一致しなければリンク無し
18118
+ */
18119
+ function liBacklogDomain(scope) {
18120
+ var seen = [];
18121
+ backlogTargetsNow(null).forEach(function (t) {
18122
+ if (!t || t.pending) return;
18123
+ var d = String(t.domain || "").trim().toLowerCase();
18124
+ if (d && seen.indexOf(d) < 0) seen.push(d);
18125
+ });
18126
+ var want = typeof scope === "string" ? scope.trim().toLowerCase() : "";
18127
+ // 🔴 **場が Backlog のスペースの形なのに接続先のどれとも一致しないなら、リンク無し。**
18128
+ // 別のスペースの人を、接続済みのスペースのページへ飛ばさない(Slack の team_id と同じ守り)
18129
+ if (want && seen.indexOf(want) < 0 && LI_BACKLOG_DOMAIN.test(want)) return "";
18130
+ var d = want && seen.indexOf(want) >= 0 ? want : seen.length === 1 ? seen[0] : "";
18131
+ return LI_BACKLOG_DOMAIN.test(d) ? d : "";
18132
+ }
18133
+ /**
18134
+ * その行をリンクにしてよければ href を返す(だめなら空文字=値だけ出す)。
18135
+ *
18136
+ * 🔴 **値も行き先のドメインも、両方を許可リストで検査してから組む。** Figma・Google Chat・
18137
+ * その他は値だけ(人のページの URL の形がどこにも書かれていない)
18138
+ */
18139
+ function liHref(kind, value, scope) {
17653
18140
  if (kind === "github_login") return LI_GITHUB_LOGIN.test(value) ? "https://github.com/" + value : "";
17654
18141
  if (kind === "email") return liMailto(value);
17655
18142
  // 🔴 git_author の値はメールとは限らない(engine は authorEmail か authorName を入れる)。
17656
18143
  // アットマークを含み、かつメールの形に当たるときだけリンクにする
17657
18144
  if (kind === "git_author") return value.indexOf("@") >= 0 ? liMailto(value) : "";
18145
+ if (kind === "slack_user") {
18146
+ if (!LI_SLACK_USER.test(value)) return "";
18147
+ var sd = liSlackDomain(scope);
18148
+ return sd ? "https://" + sd + ".slack.com/team/" + value : "";
18149
+ }
18150
+ if (kind === "backlog_user") {
18151
+ if (!LI_BACKLOG_USER.test(value) || /^[0-9]+$/.test(value)) return "";
18152
+ var bd = liBacklogDomain(scope);
18153
+ return bd ? "https://" + bd + "/user/" + value : "";
18154
+ }
17658
18155
  return "";
17659
18156
  }
17660
18157
  /** 関係者の記録か(画面側は型と区画名で判る。判定の正本は src/external-id.ts の isMemberRecord) */
@@ -17672,7 +18169,6 @@ ${calendarAddUrl.toString()}
17672
18169
  * 配列でない・要素がオブジェクトでない・id が文字列でない行は捨てる。
17673
18170
  * 🔴 **並べ替えない**(engine は人の行を人が書いた順に、機械の行を id の辞書順に書く。
17674
18171
  * 画面で並べ直すと、記録と画面の行が対応しなくなる)。
17675
- * 🔴 scope は読まない(綴りだけ予約されている欄)。
17676
18172
  */
17677
18173
  function linkedIdRows(rec) {
17678
18174
  var fm = rec && rec.fm;
@@ -17688,47 +18184,89 @@ ${calendarAddUrl.toString()}
17688
18184
  var kind = typed ? typed[1] : "";
17689
18185
  var value = typed ? typed[2] : whole;
17690
18186
  var basis = liBasis(ent.basis);
18187
+ // 場は文字列のときだけ(数やオブジェクトを String() して出さない)
18188
+ var scope = typeof ent.scope === "string" ? ent.scope.trim() : "";
17691
18189
  out.push({
17692
18190
  kindLabel: liKindLabel(kind),
17693
18191
  value: value,
17694
- href: liHref(kind, value),
18192
+ href: liHref(kind, value, scope),
17695
18193
  basisText: basis.text,
17696
18194
  tone: basis.tone,
17697
18195
  // 🔴 since が無い行の「いつから」は素の空。印(ダッシュ等)を足さない——空が正常
17698
18196
  since: typeof ent.since === "string" ? ent.since : "",
18197
+ scope: scope,
17699
18198
  });
17700
18199
  });
17701
18200
  return out;
17702
18201
  }
17703
18202
  /**
17704
- * 「つながっている ID」の折りたたみ。**関係者以外の記録と、0件のときは null**
17705
- *(節ごと出さない=欄がまだ無い今は、今までと同じに見える)。
18203
+ * 否定済み(not_aliases)の行。**文字列の要素だけ**を、書かれた順のまま返す。
18204
+ * <種類>:<値> の形なら道具の名前と値に割り、素の文字列はそのまま値として出す
18205
+ */
18206
+ function deniedIdRows(rec) {
18207
+ var list = rec && rec.fm ? rec.fm.not_aliases : null;
18208
+ if (!Array.isArray(list)) return [];
18209
+ var out = [];
18210
+ list.forEach(function (one) {
18211
+ if (typeof one !== "string") return;
18212
+ var whole = one.trim();
18213
+ if (whole === "") return;
18214
+ var typed = LI_TYPED_ID.exec(whole);
18215
+ out.push({ kindLabel: typed ? liKindLabel(typed[1]) : "", value: typed ? typed[2] : whole });
18216
+ });
18217
+ return out;
18218
+ }
18219
+ /**
18220
+ * 「つながっている ID」の折りたたみ。**関係者以外の記録と、つながっている ID も
18221
+ * 否定済みも 0 件のときは null**(節ごと出さない=欄が無い記録は今までと同じに見える)。
17706
18222
  *
17707
18223
  * 🔴 **表に table を使わない**(画面の部品に table は1つも無い)。.fm-grid と同じ
17708
- * 4列のグリッドで組む。値は全部 el() の text(textContent 経由)で入れる。
18224
+ * 書き方のグリッドで組む。値は全部 el() の text(textContent 経由)で入れる。
18225
+ * 🔴 **否定済みは同じ節の中に、さらに畳んで出す**(開かなければ見えない)。
18226
+ * つながっている ID が無く否定済みだけある記録にも節を出す——「前に否定したか」を
18227
+ * 確かめる口が画面から消えないように
17709
18228
  */
17710
18229
  function buildLinkedIds(rec) {
17711
18230
  if (!isMemberRec(rec)) return null;
17712
18231
  var rows = linkedIdRows(rec);
17713
- if (!rows.length) return null;
18232
+ var denied = deniedIdRows(rec);
18233
+ if (!rows.length && !denied.length) return null;
18234
+ var head = "つながっている ID(" + rows.length + "件" + (denied.length ? "・否定済み " + denied.length + "件" : "") + ")";
17714
18235
  var det = el("details", { class: "linked-ids" }, [
17715
- el("summary", { class: "li-summary", text: "つながっている ID(" + rows.length + "件)" }),
18236
+ el("summary", { class: "li-summary", text: head }),
17716
18237
  ]);
17717
- var grid = el("div", { class: "li-grid" });
17718
- LI_COLS.forEach(function (col) {
17719
- grid.appendChild(el("div", { class: "li-cell li-head", text: col }));
17720
- });
17721
- rows.forEach(function (row) {
17722
- grid.appendChild(el("div", { class: "li-cell li-kind", text: row.kindLabel }));
17723
- var valueCell = el("div", { class: "li-cell li-value" });
17724
- valueCell.appendChild(row.href
17725
- ? el("a", { href: row.href, target: "_blank", rel: "noopener", text: row.value })
17726
- : el("span", { text: row.value }));
17727
- grid.appendChild(valueCell);
17728
- grid.appendChild(el("div", { class: "li-cell li-basis " + row.tone, text: row.basisText }));
17729
- grid.appendChild(el("div", { class: "li-cell li-since", text: row.since }));
17730
- });
17731
- det.appendChild(grid);
18238
+ if (rows.length) {
18239
+ var grid = el("div", { class: "li-grid" });
18240
+ LI_COLS.forEach(function (col) {
18241
+ grid.appendChild(el("div", { class: "li-cell li-head", text: col }));
18242
+ });
18243
+ rows.forEach(function (row) {
18244
+ grid.appendChild(el("div", { class: "li-cell li-kind", text: row.kindLabel }));
18245
+ var valueCell = el("div", { class: "li-cell li-value" });
18246
+ valueCell.appendChild(row.href
18247
+ ? el("a", { href: row.href, target: "_blank", rel: "noopener", text: row.value })
18248
+ : el("span", { text: row.value }));
18249
+ grid.appendChild(valueCell);
18250
+ grid.appendChild(el("div", { class: "li-cell li-basis " + row.tone, text: row.basisText }));
18251
+ grid.appendChild(el("div", { class: "li-cell li-since", text: row.since }));
18252
+ grid.appendChild(el("div", { class: "li-cell li-scope", text: row.scope }));
18253
+ });
18254
+ det.appendChild(grid);
18255
+ }
18256
+ if (denied.length) {
18257
+ var box = el("details", { class: "li-denied" }, [
18258
+ el("summary", { class: "li-denied-summary", text: LI_DENIED_TITLE + "(" + denied.length + "件)" }),
18259
+ ]);
18260
+ var ul = el("ul", { class: "li-denied-list" });
18261
+ denied.forEach(function (d) {
18262
+ var li = el("li", { class: "li-denied-row" });
18263
+ if (d.kindLabel) li.appendChild(el("span", { class: "li-denied-kind", text: d.kindLabel }));
18264
+ li.appendChild(el("span", { class: "li-denied-value", text: d.value }));
18265
+ ul.appendChild(li);
18266
+ });
18267
+ box.appendChild(ul);
18268
+ det.appendChild(box);
18269
+ }
17732
18270
  // 違っていたときにどうするか。.ah-source と同じ置き方(節の末尾の小さな段落)
17733
18271
  det.appendChild(el("p", { class: "li-note", text: LI_NOTE }));
17734
18272
  return det;
@@ -18824,6 +19362,10 @@ ${calendarAddUrl.toString()}
18824
19362
  var pr = proposalsSection(d, "Decisions");
18825
19363
  if (pr) app.appendChild(pr);
18826
19364
 
19365
+ // 決定の「その後」(#705)。提案の節の後・要約の前。**出すものが無ければ要素を作らない**
19366
+ var fu = followupSection(d);
19367
+ if (fu) app.appendChild(fu);
19368
+
18827
19369
  if (d.summary) {
18828
19370
  app.appendChild(el("div", { class: "section-label", text: "要約" }));
18829
19371
  app.appendChild(el("div", { class: "summary-box", text: d.summary }));
@@ -19164,6 +19706,14 @@ ${calendarAddUrl.toString()}
19164
19706
  if (isHttp(target)) return el("a", { href: target, target: "_blank", rel: "noopener", text: target });
19165
19707
  return el("span", { text: target });
19166
19708
  }
19709
+ var FM_EMPTY_TEXT = "(未記入)";
19710
+ /** パネルの 1 値を文字にする。オブジェクト・配列は JSON([object Object] を出さない) */
19711
+ function fmScalarText(x) {
19712
+ if (x && typeof x === "object") {
19713
+ try { return JSON.stringify(x); } catch (e) { return String(x); }
19714
+ }
19715
+ return String(x);
19716
+ }
19167
19717
  function renderFmPanel(fm) {
19168
19718
  if (!fm || !Object.keys(fm).length) return null;
19169
19719
  var panel = el("details", { class: "fm-panel" });
@@ -19173,7 +19723,12 @@ ${calendarAddUrl.toString()}
19173
19723
  grid.appendChild(el("div", { class: "fm-key", text: k }));
19174
19724
  var cell = el("div", { class: "fm-val" });
19175
19725
  var v = fm[k];
19176
- if (k === "relations" && Array.isArray(v)) {
19726
+ if (v === "" || (Array.isArray(v) && v.length === 0)) {
19727
+ // 🔴 **空を空欄で表さない**(#704)。記録に email: "" と書かれている(雛形の既定)
19728
+ // だけなのに、画面が値を伏せているように見えた——「書かれていない」と言葉で出す。
19729
+ // identities: [] / relations: [] もここで受ける(下の欄ごとの枝より前で見る)
19730
+ cell.appendChild(el("span", { class: "fm-empty", text: FM_EMPTY_TEXT }));
19731
+ } else if (k === "relations" && Array.isArray(v)) {
19177
19732
  v.forEach(function (r) {
19178
19733
  if (!r || typeof r !== "object") return;
19179
19734
  var row = el("div", { class: "fm-rel" });
@@ -19198,7 +19753,10 @@ ${calendarAddUrl.toString()}
19198
19753
  cell.appendChild(line);
19199
19754
  });
19200
19755
  } else if (Array.isArray(v)) {
19201
- cell.textContent = v.join(" / ");
19756
+ // 🔴 要素がオブジェクトなら JSON で出す(join に落とすと [object Object] になる)
19757
+ cell.textContent = v.map(fmScalarText).join(" / ");
19758
+ } else if (v && typeof v === "object") {
19759
+ cell.textContent = fmScalarText(v);
19202
19760
  } else if (k === "source") {
19203
19761
  cell.appendChild(fmTargetLink(String(v)));
19204
19762
  } else if (isHttp(v)) {