@takagaki/cortex-decisions-viewer 0.12.77 → 0.12.79

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
@@ -40,6 +40,7 @@ exports.PROJECT_ICON_EXTS = void 0;
40
40
  exports.sanitizeBody = sanitizeBody;
41
41
  exports.findProjectIcon = findProjectIcon;
42
42
  exports.readFleetSources = readFleetSources;
43
+ exports.attachRederived = attachRederived;
43
44
  exports.readGoldProposals = readGoldProposals;
44
45
  exports.buildHomeGrounding = buildHomeGrounding;
45
46
  exports.collectMeetings = collectMeetings;
@@ -92,6 +93,7 @@ function takeProposals(filePath) {
92
93
  PROPOSAL_PATHS_MATCHED.add(filePath);
93
94
  return hit;
94
95
  }
96
+ const external_id_1 = require("./external-id");
95
97
  const render_1 = require("./render");
96
98
  /**
97
99
  * この画面を作ったパッケージの版。**package.json を唯一の出どころにする。**
@@ -215,6 +217,17 @@ function toSearchText(md) {
215
217
  .replace(/\s+/g, " ")
216
218
  .trim();
217
219
  }
220
+ /**
221
+ * 関係者の記録だけ、検索用の plaintext の後ろに「つながっている ID」の値と email を足す(#704)。
222
+ * **関係者以外の記録は 1 文字も変えない**(本文から作った searchText をそのまま返す)。
223
+ * 足す語の正本は external-id.ts の memberSearchTerms。
224
+ */
225
+ function memberSearchText(data, sectionDir, base) {
226
+ if (!(0, external_id_1.isMemberRecord)(data.type, data.id, sectionDir))
227
+ return base;
228
+ const extra = (0, external_id_1.memberSearchTerms)(data);
229
+ return extra.length ? (base ? base + " " : "") + extra.join(" ") : base;
230
+ }
218
231
  /** dir未指定時の探索候補(新構成 → 旧構成の順) */
219
232
  const DEFAULT_DIR_CANDIDATES = ["Cortex/Decisions/records", "Cortex/Decisions", "Decisions"];
220
233
  /**
@@ -672,7 +685,9 @@ async function readFleetSources(repoRoot) {
672
685
  * 🔴 **突き合わせはビルド時にここで閉じる**(`proposals` と同じ)。画面側でパスを組み立て直すと、
673
686
  * `repoPathOf` が `editUrl` 由来なので **`repoBaseUrl` が無い案件で帯が黙って消える**。
674
687
  */
675
- async function attachRederived(records, recordsDir, ctx, nowIso) {
688
+ async function attachRederived(records, recordsDir, ctx, nowIso,
689
+ // 番人テストが「git に渡った key」を数えるための差し替え口。本番は常に既定の `readBlobsAt`
690
+ readBlobs = repo_1.readBlobsAt) {
676
691
  if (!ctx)
677
692
  return records;
678
693
  // 🔴 **レコードのパスは `repoRoot` から作る**(`toRepoRelative` の `process.cwd()` 起点で
@@ -684,32 +699,46 @@ async function attachRederived(records, recordsDir, ctx, nowIso) {
684
699
  // ——ここで別のものを渡すと、画面の札と食い違う(「未確認」の札が付いた記録に帯が出る)
685
700
  const statusOf = (r) => r.rstatus;
686
701
  // 決定表の行 8(中身が変わったか)の材料。**印の候補になったレコードのぶんだけ**、
687
- // `<基準コミット>:<path>` をまとめて 1 回引く。
702
+ // 今の版を読み、`<基準コミット>:<path>` をまとめて 1 回引く。
688
703
  // 🔴 **キーが 0 件の区画では git を起こさない**(この関数は区画ごとに呼ばれる)
689
704
  const keyOf = new Map();
690
- for (const r of records) {
691
- const key = (0, rederivation_1.rederivedContentKey)(pathOf(r), statusOf(r), ctx.ledgers);
692
- if (key)
693
- keyOf.set(r, key);
694
- }
695
- const bases = keyOf.size ? (0, repo_1.readBlobsAt)(ctx.repoRoot, [...keyOf.values()]) : new Map();
696
705
  // 今の版は**作業ツリーから読む**——画面が描いているのはこのファイルであって HEAD ではない
697
706
  const now = new Map();
698
- for (const r of keyOf.keys()) {
707
+ for (const r of records) {
708
+ const key = (0, rederivation_1.rederivedContentKey)(pathOf(r), statusOf(r), ctx.ledgers);
709
+ if (!key)
710
+ continue;
711
+ const p = pathOf(r);
699
712
  try {
700
- now.set(pathOf(r), await node_fs_1.promises.readFile(path.join(ctx.repoRoot, pathOf(r)), "utf8"));
713
+ now.set(p, await node_fs_1.promises.readFile(path.join(ctx.repoRoot, p), "utf8"));
701
714
  }
702
715
  catch {
703
716
  // 読めなければ「引けなかった」側(下の verdict が unknown に倒す)
704
717
  }
718
+ // 🔴 **帳簿の指紋が今の中身と一致する記録は git の key に入れない**(engine #546)。
719
+ // 答えは下の verdict で `same` になるので、基準版を引く意味が無い——ここで外さないと、
720
+ // engine が指紋を書いても用語 1400 件の案件で毎晩 1400 個の blob を読み続ける。
721
+ // 指紋が無い・食い違う・今の版が読めない記録は今までどおり git へ落とす
722
+ const nowRaw = now.get(p);
723
+ const sha = ctx.ledgers.confirmation.get(p)?.contentSha;
724
+ if (nowRaw != null && sha && sha === (0, rederivation_1.contentShaOf)(nowRaw)) {
725
+ if (ctx.blobs)
726
+ ctx.blobs.skipped++;
727
+ continue;
728
+ }
729
+ keyOf.set(r, key);
705
730
  }
731
+ if (ctx.blobs)
732
+ ctx.blobs.read += keyOf.size;
733
+ const bases = keyOf.size ? readBlobs(ctx.repoRoot, [...keyOf.values()]) : new Map();
706
734
  const verdict = (p, baseCommit) => {
707
735
  const nowRaw = now.get(p);
708
736
  if (nowRaw == null) {
709
737
  ctx.miss.unreadable.add(p);
710
738
  return "unknown";
711
739
  }
712
- // 🔴 **帳簿が中身の指紋を持つならそれを先に使う**(engine が後の便で書く予定)。
740
+ // 🔴 **帳簿が中身の指紋を持つならそれを先に使う**(engine #546 が書く。一致した記録は
741
+ // 上で git の key から外してある)。
713
742
  // 一致したときだけ「変わっていない」と言い、食い違ったら git の二段目へ落とす——
714
743
  // engine が別の綴りで指紋を作っていた場合に、全レコードへ誤報が出るのを避ける
715
744
  const conf = ctx.ledgers.confirmation.get(p);
@@ -745,6 +774,10 @@ async function attachRederived(records, recordsDir, ctx, nowIso) {
745
774
  * **無い・壊れているときは提案ゼロで正常継続する。** 提案はあくまで付加情報で、
746
775
  * これが読めないことでレコードの表示まで止めるのは釣り合わない。
747
776
  */
777
+ /** 文字列で中身があるときだけ返す(数・オブジェクトを String() して載せない) */
778
+ function optionalText(v) {
779
+ return typeof v === "string" && v.trim() !== "" ? v : "";
780
+ }
748
781
  async function readGoldProposals(repoRoot) {
749
782
  const empty = new Map();
750
783
  let json;
@@ -810,6 +843,25 @@ async function readGoldProposals(repoRoot) {
810
843
  reason: row.reason != null ? String(row.reason) : "",
811
844
  createdAt: row.createdAt != null ? String(row.createdAt) : "",
812
845
  };
846
+ // 名寄せの提案にだけ付く欄(engine の便 A・#704)。**文字列で中身があるときだけ**載せる
847
+ // ——無い提案にキーを生やすと、他の提案まで形が変わる(許可リストの守りのテストが見ている)。
848
+ // 🔴 **根拠の url(「その発言を開く」)はまだ通さない**(決定ログ 20260920-686.md の
849
+ // 「提案カードに新しい欄を 1 つも通さない」の url の項は生きている。20260923-704.md)
850
+ const kind = optionalText(row.kind);
851
+ if (kind)
852
+ proposal.kind = kind;
853
+ const claimKey = optionalText(row.claim_key);
854
+ if (claimKey)
855
+ proposal.claim_key = claimKey;
856
+ const displayName = optionalText(row.display_name);
857
+ if (displayName)
858
+ proposal.display_name = displayName;
859
+ const question = optionalText(row.question);
860
+ if (question)
861
+ proposal.question = question;
862
+ const why = optionalText(row.why);
863
+ if (why)
864
+ proposal.why = why;
813
865
  const list = byPath.get(recordPath);
814
866
  if (list)
815
867
  list.push(proposal);
@@ -958,6 +1010,9 @@ async function loadGenericRecords(dirAbs, exclude, repoBaseUrl, branch) {
958
1010
  return [];
959
1011
  }
960
1012
  const repoRelDir = toRepoRelative(dirAbs);
1013
+ // 区画のディレクトリ名(`records` の親)。**絶対パスから取る**——cwd 相対の repoRelDir は
1014
+ // cwd がリポジトリルートでないと `records` に倒れる(isMemberRecord の注意書き)
1015
+ const sectionDir = path.basename(dirAbs) === "records" ? path.basename(path.dirname(dirAbs)) : path.basename(dirAbs);
961
1016
  const records = [];
962
1017
  for (const e of entries) {
963
1018
  if (!e.isFile() || !e.name.toLowerCase().endsWith(".md"))
@@ -965,17 +1020,25 @@ async function loadGenericRecords(dirAbs, exclude, repoBaseUrl, branch) {
965
1020
  if (exclude.has(e.name.toLowerCase()))
966
1021
  continue;
967
1022
  const raw = await node_fs_1.promises.readFile(path.join(dirAbs, e.name), "utf8");
968
- records.push(await parseGeneric(raw, e.name, repoRelDir, repoBaseUrl, branch));
1023
+ records.push(await parseGeneric(raw, e.name, repoRelDir, repoBaseUrl, branch, sectionDir));
969
1024
  }
970
1025
  // 日付降順 → タイトル昇順(用語集のように日付が同一でも安定して並ぶ)
1026
+ //
1027
+ // 🔴 **第 2 鍵(コード順)を外さない**(#576)。`localeCompare(…, "ja")` は**別の綴りでも
1028
+ // 0 を返す**(実測: `が`/`ガ`・`サーバ`/`さーば`・`AI`/`AI`・`あ行`/`ア行`)。0 のまま
1029
+ // 安定ソートに任せると、その 2 語の順を決めるのは**`readdir` が返した順**になり、Node は
1030
+ // その順を保証しない——中身を 1 文字も変えていない日に、用語集の並びが環境で変わる。
1031
+ // cortex-engine の記憶の目次(`scripts/memory-index.mjs` の `cmpTerm`)はこの並びに
1032
+ // **上位 20 件の集合**を合わせているので、ここが揺れると目次との一致も揺れる。
1033
+ // 引き分けだったところに順を決めるだけで、**決まっていた並びは 1 つも変えていない**。
971
1034
  records.sort((a, b) => {
972
1035
  if (a.date !== b.date)
973
1036
  return a.date < b.date ? 1 : -1;
974
- return a.title.localeCompare(b.title, "ja");
1037
+ return a.title.localeCompare(b.title, "ja") || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0);
975
1038
  });
976
1039
  return records;
977
1040
  }
978
- async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch) {
1041
+ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch, sectionDir = "") {
979
1042
  const { data, content } = (0, gray_matter_1.default)(raw);
980
1043
  const h1 = content.match(/^#\s+(.+)$/m);
981
1044
  const id = String(data.id ?? fileName.replace(/\.md$/i, ""));
@@ -1003,7 +1066,7 @@ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch) {
1003
1066
  bodyHtml,
1004
1067
  bodyRef: "",
1005
1068
  bodyId: "",
1006
- searchText: toSearchText(content),
1069
+ searchText: memberSearchText(data, sectionDir, toSearchText(content)),
1007
1070
  fileName,
1008
1071
  editUrl,
1009
1072
  rtype: data.type != null ? String(data.type) : "",
@@ -1959,6 +2022,7 @@ async function build(opts) {
1959
2022
  repoRoot: repoRoot,
1960
2023
  identities: ledgers.confirmation.size ? (0, repo_1.collectGoldCommitIdentities)(repoRoot) : new Map(),
1961
2024
  miss: { unresolved: new Set(), unreadable: new Set() },
2025
+ blobs: { read: 0, skipped: 0 },
1962
2026
  }
1963
2027
  : null;
1964
2028
  const decisions = [];
@@ -2051,6 +2115,12 @@ async function build(opts) {
2051
2115
  `(浅い clone か、確認の後に改名・削除された。変わったかを言えないので印は出しません): ` +
2052
2116
  paths.slice(0, 3).join(", ") + (paths.length > 3 ? " ほか" : ""));
2053
2117
  }
2118
+ // 🔴 **指紋(engine #546)が効いているかを 1 行残す。** 一致した記録は git を読まないので、
2119
+ // 「読んだ件数」が候補の数より減っていれば効いている。警告ではないので console.log
2120
+ if (rederive?.blobs && rederive.blobs.read + rederive.blobs.skipped > 0) {
2121
+ console.log(`gold-confirmation.json: 人が確認した版の中身を git から ${rederive.blobs.read} 件読みました` +
2122
+ `(中身の指紋が一致した ${rederive.blobs.skipped} 件は読んでいません)`);
2123
+ }
2054
2124
  const unmatched = [...PROPOSALS_BY_PATH.keys()].filter((k) => !PROPOSAL_PATHS_MATCHED.has(k));
2055
2125
  if (unmatched.length) {
2056
2126
  console.warn(`gold-proposals.json: レコードに突き合わない提案が ${unmatched.length} 件ありました(表示しません): ` +
@@ -27,6 +27,7 @@ exports.isExternalId = isExternalId;
27
27
  exports.splitTypedId = splitTypedId;
28
28
  exports.isMemberRecord = isMemberRecord;
29
29
  exports.isMemberRecordPath = isMemberRecordPath;
30
+ exports.memberSearchTerms = memberSearchTerms;
30
31
  /**
31
32
  * このリポジトリの内側の呼び名。外の ID と見なさない。
32
33
  *
@@ -128,7 +129,9 @@ function isMemberRecord(type, id, sectionDir) {
128
129
  return true;
129
130
  if (typeof id === "string" && id.startsWith("member:"))
130
131
  return true;
131
- return sectionDir === "Members";
132
+ // 日本語のディレクトリ名を使っている案件があるので、旧名「メンバー」も新名「関係者」も引く
133
+ //(画面の isMemberRec と同じ 3 つ・#704)
134
+ return sectionDir === "Members" || sectionDir === "メンバー" || sectionDir === "関係者";
132
135
  }
133
136
  /** そのリポジトリ相対パスが関係者の記録の置き場か(git のパスは cwd に依存しない) */
134
137
  function isMemberRecordPath(repoPath) {
@@ -146,3 +149,36 @@ function isMemberRecordPath(repoPath) {
146
149
  exports.TYPED_ID_WHOLE_SRC = "^" + TYPED_ID_SRC + "$";
147
150
  /** 内側の呼び名の一覧(同上。埋め込み JS が `splitTypedId` と同じ除外を持つため) */
148
151
  exports.INSIDE_KIND_LIST = [...INSIDE_KINDS];
152
+ /**
153
+ * 関係者の記録の検索索引に足す語(#704)。**ID の値で関係者を引けるようにする。**
154
+ *
155
+ * - `identities` の各行の `id` を**全体**(`slack_user:U0…`)で入れる。画面の検索は部分一致なので、
156
+ * 人が値だけ(`U0…`)を打っても当たる——種類を外した値を別に足すのは冗長なので足さない
157
+ * - `email` の欄(文字列で中身があるとき)
158
+ *
159
+ * 🔴 **`not_aliases`(否定済み)は入れない。** 「私ではない」と言った ID で引いて
160
+ * その人が出てくると、検索が「この ID はこの人だ」と言っているように読める。
161
+ * 🔴 **関係者以外の記録には呼ばない**(呼び出し側が isMemberRecord で絞る)。
162
+ */
163
+ function memberSearchTerms(fm) {
164
+ const out = [];
165
+ const push = (v) => {
166
+ const t = v.trim();
167
+ if (t && !out.includes(t))
168
+ out.push(t);
169
+ };
170
+ const list = fm[exports.FM_LINKED_IDS];
171
+ if (Array.isArray(list)) {
172
+ for (const ent of list) {
173
+ if (!ent || typeof ent !== "object" || Array.isArray(ent))
174
+ continue;
175
+ const id = ent.id;
176
+ if (typeof id !== "string" || id.trim() === "")
177
+ continue;
178
+ push(id);
179
+ }
180
+ }
181
+ if (typeof fm.email === "string")
182
+ push(fm.email);
183
+ return out;
184
+ }
@@ -141,7 +141,7 @@ async function readRederivationLedgers(repoRoot) {
141
141
  const changedBy = Array.isArray(row?.changed_by)
142
142
  ? row.changed_by.map((s) => String(s)).filter((s) => s !== "")
143
143
  : [];
144
- // 中身の指紋は**文字列のときだけ**持つ(engine がまだ書かないので、いまは常に無い)
144
+ // 中身の指紋は**文字列のときだけ**持つ(engine #546 が書く。古い帳簿の行には無い)
145
145
  const contentSha = typeof confirmed.content_sha === "string" ? confirmed.content_sha : "";
146
146
  out.confirmation.set(p, {
147
147
  at: confirmed.at != null ? String(confirmed.at) : "",
@@ -311,9 +311,12 @@ function sameRederivedContent(a, b) {
311
311
  /**
312
312
  * 「説明文と本文」の指紋。**帳簿の `confirmed.content_sha` と突き合わせる綴り**。
313
313
  *
314
- * 🔴 engine がこの欄を書き始めるまで、この値と比べる相手は存在しない。**一致したときだけ
314
+ * 🔴 比べる相手は engine #546 が書く `confirmed.content_sha`。**一致したときだけ
315
315
  * 「変わっていない」と言い、食い違ったら git へ落とす**(`attachRederived`)——engine が
316
316
  * 別の綴りで指紋を作っていた場合に、全レコードへ誤報が出るのを避けるため。
317
+ *
318
+ * engine の写し: `cortex-engine/scripts/rederive/content-sha.mjs`・一致の番人:
319
+ * `cortex-engine/test/rederive-history.test.mjs` の #546 の節(ここを変えたら engine の `VIEWER_SHA` を計算し直す)
317
320
  */
318
321
  function contentShaOf(raw) {
319
322
  const c = comparableContent(raw);
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)----
@@ -6500,7 +6507,20 @@ ${calendarAddUrl.toString()}
6500
6507
  return hasProposals(rec) ? el("span", { class: "badge proposed", text: "提案あり" }) : null;
6501
6508
  }
6502
6509
  function hasProposals(rec) {
6503
- return !!(rec && rec.proposals && rec.proposals.length);
6510
+ return openProposals(rec).length > 0;
6511
+ }
6512
+ /**
6513
+ * 画面に出す提案(**答え済みの名寄せの提案を除く**・#704)。
6514
+ *
6515
+ * 🔴 claim_key の ID が、記録の identities(同じ人と答えた)か not_aliases(別人と答えた)に
6516
+ * もう在るなら、そのカードは**出さない**——従来のカードにも落とさない。人が答えたあと、
6517
+ * engine がカードを取り下げるまでの夜(最長 14 日)に同じ問いを出し続けると、
6518
+ * [別人です]を押し直して宣言した行を黙って切る形になる。バッジ・絞り込み・「AIで適用」も
6519
+ * 同じ集合を見る(数と中身が食い違わないように)
6520
+ */
6521
+ function openProposals(rec) {
6522
+ var ps = (rec && rec.proposals) || [];
6523
+ return ps.filter(function (one) { return !ipAnswered(rec, one); });
6504
6524
  }
6505
6525
 
6506
6526
  // 状態の絞り込み。**未確認と提案ありを同じセレクトに並べる**(導線を増やさない)
@@ -6810,7 +6830,7 @@ ${calendarAddUrl.toString()}
6810
6830
  * 1 人が「名前の別表記の提案」と「ID の提案」を同時に持つときは、束ねる側は別表記の分だけ。
6811
6831
  */
6812
6832
  function bundledProposals(rec, sectionName) {
6813
- var ps = (rec && rec.proposals) || [];
6833
+ var ps = openProposals(rec);
6814
6834
  return ps.filter(function (one) { return !identityAsk(rec, sectionName, one); });
6815
6835
  }
6816
6836
  function aiApplyBtn(rec, sectionName, title) {
@@ -6863,20 +6883,21 @@ ${calendarAddUrl.toString()}
6863
6883
  // 関係者の記録に外の ID を 1 つ足す提案は、差分の表ではなく**人への問い**で出す。
6864
6884
  // 機械が決めきれなかったものを人が決める場なので、答えは 2 つだけ(同じ人/別人)。
6865
6885
  //
6866
- // 🔴 **門を 1 つでも欠いたら、今までどおりのカードに倒す。** 新しい形にするのは
6867
- // 1. 記録が関係者
6868
- // 2. field が aliases
6869
- // 3. proposed から current を引いた差に、種類つきの ID がちょうど 1 つ
6870
- // の**全部**を満たすときだけ。
6886
+ // 🔴 **門を 1 つでも欠いたら、今までどおりのカードに倒す。** 新しい形にする道は 2 本:
6887
+ //
6888
+ // (A) engine の便 A(cortex-engine #540)の形——kind: "identity" と claim_key(足す ID そのもの)
6889
+ // を持つ提案(#704 で build の許可リストに足した)。門は
6890
+ // 1. 記録が関係者
6891
+ // 2. field が aliases(engine の現物)か identities(FM_LINKED_IDS)
6892
+ // 3. claim_key が種類つきの ID の形で、proposed の要素に在り、current の要素に無い
6893
+ // **カンマ連結の差集合から推測しない**ので、別名にカンマが入っても壊れない。
6894
+ // (B) それより前の形(kind も claim_key も無い)——
6895
+ // 1. 記録が関係者
6896
+ // 2. field が aliases
6897
+ // 3. proposed から current を引いた差に、種類つきの ID がちょうど 1 つ
6871
6898
  //
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 が捨てる(届かない)か、届いても門が見ていない(使われない)。
6899
+ // 🔴 **kind が付いていても claim_key が無ければ (B) に落とす**(kind だけで新しい形にしない)。
6900
+ // 🔴 **kind は field の代わりにはならない**——field が別の項目(重複の疑い 等)なら今までどおり。
6880
6901
  //
6881
6902
  // 🔴 **見分けは種類つきの形(コロン付き)だけで行う。** 「@名前」「素のメール」は門に使わない
6882
6903
  // ——aliases にはハンドル名が普通に入るので、@yamada が門を通ると、engine が
@@ -6954,6 +6975,56 @@ ${calendarAddUrl.toString()}
6954
6975
  function ipMemberSection(name) {
6955
6976
  return name === "Members" || name === "メンバー" || name === "関係者";
6956
6977
  }
6978
+ /** カンマ連結の値を要素に割る(空白を落とす・空の断片は捨てる)。要素の有無を見るだけに使う */
6979
+ function ipLooseParts(v) {
6980
+ return (v == null ? "" : String(v)).split(",").map(function (x) { return x.trim(); })
6981
+ .filter(function (x) { return x !== ""; });
6982
+ }
6983
+ /**
6984
+ * 道 (A): engine が足す ID を claim_key で名指ししている提案(便 A・#704)。
6985
+ *
6986
+ * 🔴 **claim_key を鵜呑みにしない。** 種類つきの形で・内側の呼び名でなく・proposed に在り・
6987
+ * current に無いときだけ通す——すでに結び付いている ID について「同じ人ですか」と聞くと、
6988
+ * [別人です]で人が自分で宣言した行が not_aliases で黙って切れる(ipAdded と同じ理由)
6989
+ */
6990
+ function ipAskByClaimKey(one) {
6991
+ if (one.field !== "aliases" && one.field !== FM_LINKED_IDS) return null;
6992
+ var typed = ipSplitTypedId(one.claim_key);
6993
+ if (!typed) return null;
6994
+ var whole = typed.kind + ":" + typed.value;
6995
+ var norm = function (x) { var t = ipSplitTypedId(x); return t ? t.kind + ":" + t.value : x; };
6996
+ if (ipLooseParts(one.proposed).map(norm).indexOf(whole) < 0) return null;
6997
+ if (ipLooseParts(one.current).map(norm).indexOf(whole) >= 0) return null;
6998
+ return typed;
6999
+ }
7000
+ /**
7001
+ * ID を突き合わせ用の綴りにそろえる(全角・コロンの前後の空白を落とす)。形に当たらなければ空文字。
7002
+ * engine の normalizeIdentityId(コロンの前後の空白を落とす)と同じ寄せ方
7003
+ */
7004
+ function ipNormId(x) {
7005
+ if (typeof x !== "string") return "";
7006
+ var s = x.normalize("NFKC").replace(/:/g, ":").trim().replace(/^([^:]*?)\\s*:\\s*/, "$1:");
7007
+ var t = ipSplitTypedId(s);
7008
+ return t ? t.kind + ":" + t.value : "";
7009
+ }
7010
+ /**
7011
+ * 名寄せの提案が**答え済み**か(claim_key の ID が記録の identities[].id か not_aliases に在る)。
7012
+ * kind: identity と claim_key を持つ提案(engine の便 A の形)だけを見る
7013
+ */
7014
+ function ipAnswered(rec, one) {
7015
+ if (!one || one.kind !== "identity") return false;
7016
+ var key = ipNormId(one.claim_key);
7017
+ if (!key) return false;
7018
+ var fm = (rec && rec.fm) || {};
7019
+ var denied = Array.isArray(fm.not_aliases) ? fm.not_aliases : [];
7020
+ for (var i = 0; i < denied.length; i++) if (ipNormId(denied[i]) === key) return true;
7021
+ var ids = Array.isArray(fm[FM_LINKED_IDS]) ? fm[FM_LINKED_IDS] : [];
7022
+ for (var j = 0; j < ids.length; j++) {
7023
+ var ent = ids[j];
7024
+ if (ent && typeof ent === "object" && !Array.isArray(ent) && ipNormId(ent.id) === key) return true;
7025
+ }
7026
+ return false;
7027
+ }
6957
7028
  /**
6958
7029
  * この提案を「同じ人ですか」の形にしてよいか。よければ増える ID の種類と値を返す。
6959
7030
  * **3 つの門は常に AND**(上のコメントの 1・2・3)。
@@ -6961,6 +7032,9 @@ ${calendarAddUrl.toString()}
6961
7032
  function identityAsk(rec, sectionName, one) {
6962
7033
  if (!one) return null;
6963
7034
  if (!isMemberRec(rec) && !ipMemberSection(sectionName)) return null;
7035
+ if (one.kind === "identity" && typeof one.claim_key === "string" && one.claim_key.trim() !== "") {
7036
+ return ipAskByClaimKey(one);
7037
+ }
6964
7038
  if (one.field !== "aliases") return null;
6965
7039
  var added = ipAdded(one.current, one.proposed);
6966
7040
  if (added === null) return null;
@@ -6972,10 +7046,9 @@ ${calendarAddUrl.toString()}
6972
7046
  return ids.length === 1 ? ids[0] : null;
6973
7047
  }
6974
7048
  /**
6975
- * 問いの文。
7049
+ * 問いの文(engine が question を出していないときの組み立て)。
6976
7050
  *
6977
- * 🔴 **名乗り(display_name)は engine がまだ入れていないので、推測で名前を作らない。**
6978
- * 来た日に切り替えられるよう口だけ開けてある(いまの呼び出しは空文字を渡す)。
7051
+ * 🔴 **名乗り(display_name)は engine が出したときだけ使う。推測で名前を作らない。**
6979
7052
  * 道具の読める名前は「つながっている ID」の対応表と**同じもの**を引く(2 度書かない)。
6980
7053
  */
6981
7054
  function ipQuestion(toolLabel, value, displayName) {
@@ -6990,10 +7063,26 @@ ${calendarAddUrl.toString()}
6990
7063
  // 🔴 **囲いの外に出る値も aiFenceScrub を通す**(パスは外部から書ける)
6991
7064
  return aiFenceScrub((pathRef ? pathRef + " と " : "") + g.dir + "/README.md");
6992
7065
  }
7066
+ /** 文字列で中身があるときだけ返す(engine の新しい欄は無いことがある) */
7067
+ function ipText(v) {
7068
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : "";
7069
+ }
7070
+ /**
7071
+ * カードの見出しの問い。🔴 **engine の question を優先する**(便 A・#534 で「人への問い」と
7072
+ * 「機械の事情」を別の欄にした)。無い提案(便 A より前の形)は画面で組み立てる
7073
+ */
7074
+ function ipAskText(one, typed) {
7075
+ return ipText(one.question) || ipQuestion(liKindLabel(typed.kind), typed.value, ipText(one.display_name));
7076
+ }
7077
+ /** 折りたたみに入れる機械の事情。why があればそれ、無ければ reason(前の形) */
7078
+ function ipWhyText(one) {
7079
+ return ipText(one.why) || ipText(one.reason);
7080
+ }
6993
7081
  /** 囲いに入れる提案データの行(何を判定するのか・機械の事情・根拠) */
6994
7082
  function ipDataLines(one, typed) {
6995
7083
  var lines = ["判定の対象: " + typed.kind + ":" + typed.value];
6996
- if (one.reason) lines.push("機械が決めきれなかった理由: " + one.reason);
7084
+ var why = ipWhyText(one);
7085
+ if (why) lines.push("機械が決めきれなかった理由: " + why);
6997
7086
  (one.evidence || []).forEach(function (e) {
6998
7087
  lines.push("根拠: " + e.source + (e.quote ? " — 「" + e.quote + "」" : ""));
6999
7088
  });
@@ -7013,13 +7102,13 @@ ${calendarAddUrl.toString()}
7013
7102
  * claude.ai/code のディープリンクで、書くのは対話の向こう側。
7014
7103
  *
7015
7104
  * 🔴 **「1 行足す」と書き、配列ごと置換させない。** 🔴 **basis は書かせない**
7016
- *(書かないことが「人が確認した」の意味になる)。欄名の綴りは FM_LINKED_IDS 1 本から引く。
7105
+ *(書かないことが「本人が申告」の意味になる)。欄名の綴りは FM_LINKED_IDS 1 本から引く。
7017
7106
  */
7018
7107
  function ipSameBtn(rec, sectionName, title, one, typed) {
7019
7108
  var whole = typed.kind + ":" + typed.value;
7020
7109
  var lines = [
7021
7110
  ipTarget(rec, sectionName) + " を読んでから、この関係者「" + aiFenceScrub(title) + "」の " + FM_LINKED_IDS +
7022
- " に - id: \\"" + whole + "\\" の**1 行だけ**足してください(**basis は書かないでください**=「人が確認した」の意味になります)。",
7111
+ " に - id: \\"" + whole + "\\" の**1 行だけ**足してください(**basis は書かないでください**=「本人が申告」の意味になります)。",
7023
7112
  "欄が無ければ作ってください。**配列ごと置き換えないでください。** aliases は触らないでください。",
7024
7113
  "",
7025
7114
  ].concat(aiFenced(ipDataLines(one, typed))).concat([""]).concat(ipTail(rec));
@@ -7059,12 +7148,12 @@ ${calendarAddUrl.toString()}
7059
7148
  /** 「同じ人ですか」のカード 1 枚。**ボタンはカードごと**(束ねる側からは外してある) */
7060
7149
  function ipCard(rec, sectionName, one, typed) {
7061
7150
  var item = el("div", { class: "proposal proposal-identity" }, [
7062
- el("div", { class: "ip-ask", text: ipQuestion(liKindLabel(typed.kind), typed.value, "") }),
7151
+ el("div", { class: "ip-ask", text: ipAskText(one, typed) }),
7063
7152
  ]);
7064
7153
  var ev = proposalEvidenceList(one);
7065
7154
  if (ev) item.appendChild(ev);
7066
- // ID の値は小さく添える。リンクにしてよい形(GitHub とメールだけ・許可リストで検査)は
7067
- // 「つながっている ID」と同じ規則で決める
7155
+ // ID の値は小さく添える。リンクにしてよい形(許可リストで検査・Slack と Backlog は
7156
+ // 行き先のドメインが 1 つに決まるときだけ)は「つながっている ID」と同じ規則で決める
7068
7157
  var idCell = el("div", { class: "ip-id" }, [el("span", { text: "ID: " })]);
7069
7158
  var whole = typed.kind + ":" + typed.value;
7070
7159
  var href = liHref(typed.kind, typed.value);
@@ -7076,10 +7165,11 @@ ${calendarAddUrl.toString()}
7076
7165
  ipSameBtn(rec, sectionName, rec.title, one, typed),
7077
7166
  ipDiffBtn(rec, sectionName, rec.title, one, typed),
7078
7167
  ]));
7079
- if (one.reason) {
7168
+ var why = ipWhyText(one);
7169
+ if (why) {
7080
7170
  item.appendChild(el("details", { class: "ip-why" }, [
7081
7171
  el("summary", { class: "ip-why-summary", text: "機械が決めきれなかった理由" }),
7082
- el("div", { class: "ip-why-text", text: one.reason }),
7172
+ el("div", { class: "ip-why-text", text: why }),
7083
7173
  ]));
7084
7174
  }
7085
7175
  if (one.createdAt) {
@@ -7095,7 +7185,7 @@ ${calendarAddUrl.toString()}
7095
7185
  * 任意文字列で、innerHTML に渡すと出典の引用がそのまま画面のコードになる。
7096
7186
  */
7097
7187
  function proposalsSection(rec, sectionName) {
7098
- var ps = (rec && rec.proposals) || [];
7188
+ var ps = openProposals(rec);
7099
7189
  if (!ps.length) return null;
7100
7190
  var box = el("section", { class: "proposals" });
7101
7191
  var head = el("div", { class: "proposals-head" }, [
@@ -17563,7 +17653,7 @@ ${calendarAddUrl.toString()}
17563
17653
  if (meta.children.length) box.appendChild(meta);
17564
17654
  return box;
17565
17655
  }
17566
- // ---------- 関係者詳細の「つながっている ID」(#686) ----------
17656
+ // ---------- 関係者詳細の「つながっている ID」(#686・#704 で値・場・リンク・否定済み) ----------
17567
17657
  //
17568
17658
  // 名寄せが「この人だ」と結んだ外の ID を、**どうやって分かったか**と一緒に並べる節。
17569
17659
  // 人がここを見て違いに気づけることが目的なので、根拠を伏せない。
@@ -17577,26 +17667,30 @@ ${calendarAddUrl.toString()}
17577
17667
  // 道具の読める名前。🔴 **語彙の外の種類は、その綴りをそのまま出す**
17578
17668
  //(engine が新しい種類を足した晩に、道具の欄が空になって壊れないように)
17579
17669
  var LI_KIND_LABEL = {
17580
- email: "メール", git_author: "git", github_login: "GitHub", slack_user: "Slack",
17670
+ email: "メール", git_author: "git の作者", github_login: "GitHub", slack_user: "Slack",
17581
17671
  google_chat_user: "Google Chat", figma_user: "Figma", backlog_user: "Backlog", portal_user: "ポータル",
17582
17672
  };
17583
17673
  // 根拠の日本語と色。🔴 **2つの語彙を受ける**——#534 の設計の語(verified / declared /
17584
- // exact / name_match / judged)と、engine のコードに実在する語(name_only / probabilistic)。
17585
- // どちらの綴りで書かれても同じ日本語に寄せる(片方しか受けないと、書いた晩から空欄になる)
17674
+ // exact / name_match / judged)と、engine のコードに実在した語(name_only / probabilistic)。
17675
+ // どちらの綴りで書かれても同じ日本語に寄せる(片方しか受けないと、書いた晩から空欄になる)。
17676
+ // 🔴 **言葉はオーナーの決定(2026-09-23・#704)**: ポータルで確定/本人が申告/メール一致/
17677
+ // 名前一致/AI が判定。レコード情報のパネルも同じ表を引く(2 度書かない)
17586
17678
  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" },
17679
+ verified: { text: "ポータルで確定", tone: "is-firm" },
17680
+ declared: { text: "本人が申告", tone: "is-firm" },
17681
+ exact: { text: "メール一致", tone: "is-firm" },
17682
+ name_match: { text: "名前一致", tone: "is-machine" },
17683
+ name_only: { text: "名前一致", tone: "is-machine" },
17684
+ judged: { text: "AI が判定", tone: "is-machine" },
17685
+ probabilistic: { text: "AI が判定", tone: "is-machine" },
17594
17686
  };
17595
17687
  // 🔴 **文字列でも数でもない basis(配列・オブジェクト)の出し方。** そのまま String() すると
17596
17688
  // 「[object Object]」が画面に出る。語彙の外は「そのまま出す」が規則だが、**そのまま出せない値**
17597
17689
  // はこの 1 語に寄せる(色は中立のまま)。分類 B・決定ログ 20260920-686.md の B-19
17598
17690
  var LI_BASIS_UNREADABLE = "(根拠を読み取れません)";
17599
- var LI_COLS = ["道具", "この人のページ", "どうやって分かったか", "いつから"];
17691
+ // 🔴 **5 列目の「場」(scope)は #704 で足した。** engine は綴りだけ予約していて、いまは
17692
+ // 人が手で書いたときだけ入る。無い行は素の空(「いつから」と同じ——空が正常)
17693
+ var LI_COLS = ["道具", "この人のページ", "どうやって分かったか", "いつから", "場"];
17600
17694
  var LI_TYPED_ID = /^([A-Za-z][A-Za-z0-9_]*):(.+)$/;
17601
17695
  // 🔴 **リンクにしてよい値の許可リスト。** 拒否リストにすると ? と & が通り、
17602
17696
  // mailto:a@b?bcc=...&body=... の形のヘッダ注入が残る。
@@ -17607,7 +17701,21 @@ ${calendarAddUrl.toString()}
17607
17701
  // a@b?bcc=... は当たってしまう——mailto: の URL で意味を持つ記号(クエリの始まり・
17608
17702
  // 区切り・フラグメント・パーセント)をもう一段外して、ヘッダ注入の形を落とす
17609
17703
  var LI_MAIL_META = /[?#&%]/;
17704
+ // Slack のユーザー ID(U… / W…・英大文字と数字だけ)と、ワークスペースのドメイン
17705
+ //(acme.slack.com の acme の部分。fleet-status.json の slackWorkspaces[].domain)
17706
+ var LI_SLACK_USER = /^[UW][A-Z0-9]{2,20}$/;
17707
+ var LI_SLACK_DOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
17708
+ var LI_SLACK_TEAM = /^[TE][A-Z0-9]{2,20}$/;
17709
+ // Backlog のユーザー(engine は userId=ログイン名、無い相手は数値 id を入れる)と、
17710
+ // スペースのドメイン(fleet の backlogTarget。正本は 課題管理/issues-config.json)。
17711
+ // 🔴 **数字だけの値はリンクにしない**——ユーザーページの URL はログイン名で組むので、
17712
+ // 数値 id で組むと開いた先に誰も居ない(または別人が出る)
17713
+ var LI_BACKLOG_USER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
17714
+ var LI_BACKLOG_DOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.backlog\\.(?:jp|com)$/i;
17610
17715
  var LI_NOTE = "違うものがあれば「AIで編集」から「この ID は私ではない」と伝えると、翌晩から結ばなくなります。";
17716
+ // 否定済み(not_aliases)の畳みの見出し。🔴 **中は値だけでリンクにしない**——
17717
+ // 「私ではない」と言った先へ飛ばす口を出すと、つながっている側と見分けがつかなくなる
17718
+ var LI_DENIED_TITLE = "これは私ではない(否定済み)";
17611
17719
 
17612
17720
  /** 種類 → 道具の読める名前。語彙の外はその綴りのまま */
17613
17721
  function liKindLabel(kind) {
@@ -17616,7 +17724,7 @@ ${calendarAddUrl.toString()}
17616
17724
  /**
17617
17725
  * 根拠 → 画面の言葉と色。
17618
17726
  *
17619
- * 🔴 **basis が無い行は「人が確認した」**(engine の決め。人がいちばん書きやすい
17727
+ * 🔴 **basis が無い行は「本人が申告」**(engine の決め。人がいちばん書きやすい
17620
17728
  * id だけの 1 行が、いちばん強い宣言になる)。
17621
17729
  * 🔴 **語彙の外はそのまま出して、中立の色に置く**——知らない根拠を、確かな側にも
17622
17730
  * 機械の側にも倒さない
@@ -17625,7 +17733,7 @@ ${calendarAddUrl.toString()}
17625
17733
  // 🔴 **declared(いちばん強い段)に倒してよいのは「欄が無い/空」のときだけ。**
17626
17734
  // 以前は typeof raw === "string" でない値を一律に空扱いしていたので、
17627
17735
  // basis: 123 や basis: {}(engine が形を崩した日・人が手で書き損ねた日)が
17628
- // **「人が確認した」=人の宣言**として確かな側の色で出ていた。読めない値を昇格させない
17736
+ // **人の宣言**として確かな側の色で出ていた。読めない値を昇格させない
17629
17737
  if (raw == null) return LI_BASIS.declared;
17630
17738
  if (typeof raw !== "string") {
17631
17739
  var shown = typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint"
@@ -17639,22 +17747,84 @@ ${calendarAddUrl.toString()}
17639
17747
  if (Object.prototype.hasOwnProperty.call(LI_BASIS, lower)) return LI_BASIS[lower];
17640
17748
  return { text: key, tone: "is-plain" };
17641
17749
  }
17642
- /**
17643
- * その行をリンクにしてよければ href を返す(だめなら空文字=値だけ出す)。
17644
- *
17645
- * 🔴 Slack・Backlog・Figma・Google Chat・その他は**値だけ**。人のページの URL の形が
17646
- * どこにも書かれていないので、ホスト名から推測して組まない。
17647
- */
17648
17750
  /** メールの形に当たり、mailto: の URL を壊す記号を含まないときだけ href を返す */
17649
17751
  function liMailto(value) {
17650
17752
  return LI_EMAIL.test(value) && !LI_MAIL_META.test(value) ? "mailto:" + value : "";
17651
17753
  }
17652
- function liHref(kind, value) {
17754
+ /**
17755
+ * Slack のユーザーのページを組むワークスペースのドメイン(決められなければ空文字)。
17756
+ *
17757
+ * 🔴 **既定の決め方(分類 B・決定ログ 20260923-704.md)**:
17758
+ * 1. 行の場(scope)が接続済みワークスペースの team_id(T…)と一致すれば、その
17759
+ * ワークスペース。一致した先にドメインが無ければ**リンク無し**(他へ倒さない)。
17760
+ * 場が team_id の形なのにどれとも一致しなければ**リンク無し**
17761
+ * 2. そうでなく、接続済みワークスペースが**ちょうど 1 つ**ならそれ
17762
+ * 3. それ以外(0 個・2 個以上)は**リンク無し**
17763
+ * 🔴 **チャンネルの URL のホスト名から推測しない**(改名で変わるし、共有チャンネルでは
17764
+ * 貼った人のホストになる。Slack の節と同じ作法)。**間違ったリンクは値だけより悪い**
17765
+ */
17766
+ function liSlackDomain(scope) {
17767
+ var list = slackWorkspaces() || [];
17768
+ var pick = null;
17769
+ var want = typeof scope === "string" ? scope.trim() : "";
17770
+ if (want !== "") {
17771
+ for (var i = 0; i < list.length; i++) {
17772
+ if (list[i] && list[i].teamId === want) { pick = list[i]; break; }
17773
+ }
17774
+ // 🔴 **場が team_id の形(T… / E…)なのに接続済みのどれとも一致しないなら、リンク無し。**
17775
+ // 別のワークスペースの人を、接続済みのワークスペースのページへ飛ばさない
17776
+ if (!pick && LI_SLACK_TEAM.test(want)) return "";
17777
+ }
17778
+ if (!pick && list.length === 1) pick = list[0];
17779
+ var d = pick && typeof pick.domain === "string" ? pick.domain.trim() : "";
17780
+ return LI_SLACK_DOMAIN.test(d) ? d : "";
17781
+ }
17782
+ /**
17783
+ * Backlog のユーザーのページを組むスペースのドメイン(決められなければ空文字)。
17784
+ *
17785
+ * 🔴 **登録簿(issues-config.json)はここで読まない**(ビルドも画面も読まない約束・
17786
+ * no-registry-read)。同じ値を engine が写した fleet の backlogTarget を、接続先の一覧と
17787
+ * 同じ関数(backlogTargetsNow)で引く。
17788
+ * 🔴 **スペースが 1 つに決まるときだけ**(接続先が 2 つあっても同じスペースなら 1 つ)。
17789
+ * 行の場(scope)がどれかのスペースのドメインと一致すればそれを採り、スペースの形なのに
17790
+ * どれとも一致しなければリンク無し
17791
+ */
17792
+ function liBacklogDomain(scope) {
17793
+ var seen = [];
17794
+ backlogTargetsNow(null).forEach(function (t) {
17795
+ if (!t || t.pending) return;
17796
+ var d = String(t.domain || "").trim().toLowerCase();
17797
+ if (d && seen.indexOf(d) < 0) seen.push(d);
17798
+ });
17799
+ var want = typeof scope === "string" ? scope.trim().toLowerCase() : "";
17800
+ // 🔴 **場が Backlog のスペースの形なのに接続先のどれとも一致しないなら、リンク無し。**
17801
+ // 別のスペースの人を、接続済みのスペースのページへ飛ばさない(Slack の team_id と同じ守り)
17802
+ if (want && seen.indexOf(want) < 0 && LI_BACKLOG_DOMAIN.test(want)) return "";
17803
+ var d = want && seen.indexOf(want) >= 0 ? want : seen.length === 1 ? seen[0] : "";
17804
+ return LI_BACKLOG_DOMAIN.test(d) ? d : "";
17805
+ }
17806
+ /**
17807
+ * その行をリンクにしてよければ href を返す(だめなら空文字=値だけ出す)。
17808
+ *
17809
+ * 🔴 **値も行き先のドメインも、両方を許可リストで検査してから組む。** Figma・Google Chat・
17810
+ * その他は値だけ(人のページの URL の形がどこにも書かれていない)
17811
+ */
17812
+ function liHref(kind, value, scope) {
17653
17813
  if (kind === "github_login") return LI_GITHUB_LOGIN.test(value) ? "https://github.com/" + value : "";
17654
17814
  if (kind === "email") return liMailto(value);
17655
17815
  // 🔴 git_author の値はメールとは限らない(engine は authorEmail か authorName を入れる)。
17656
17816
  // アットマークを含み、かつメールの形に当たるときだけリンクにする
17657
17817
  if (kind === "git_author") return value.indexOf("@") >= 0 ? liMailto(value) : "";
17818
+ if (kind === "slack_user") {
17819
+ if (!LI_SLACK_USER.test(value)) return "";
17820
+ var sd = liSlackDomain(scope);
17821
+ return sd ? "https://" + sd + ".slack.com/team/" + value : "";
17822
+ }
17823
+ if (kind === "backlog_user") {
17824
+ if (!LI_BACKLOG_USER.test(value) || /^[0-9]+$/.test(value)) return "";
17825
+ var bd = liBacklogDomain(scope);
17826
+ return bd ? "https://" + bd + "/user/" + value : "";
17827
+ }
17658
17828
  return "";
17659
17829
  }
17660
17830
  /** 関係者の記録か(画面側は型と区画名で判る。判定の正本は src/external-id.ts の isMemberRecord) */
@@ -17672,7 +17842,6 @@ ${calendarAddUrl.toString()}
17672
17842
  * 配列でない・要素がオブジェクトでない・id が文字列でない行は捨てる。
17673
17843
  * 🔴 **並べ替えない**(engine は人の行を人が書いた順に、機械の行を id の辞書順に書く。
17674
17844
  * 画面で並べ直すと、記録と画面の行が対応しなくなる)。
17675
- * 🔴 scope は読まない(綴りだけ予約されている欄)。
17676
17845
  */
17677
17846
  function linkedIdRows(rec) {
17678
17847
  var fm = rec && rec.fm;
@@ -17688,47 +17857,89 @@ ${calendarAddUrl.toString()}
17688
17857
  var kind = typed ? typed[1] : "";
17689
17858
  var value = typed ? typed[2] : whole;
17690
17859
  var basis = liBasis(ent.basis);
17860
+ // 場は文字列のときだけ(数やオブジェクトを String() して出さない)
17861
+ var scope = typeof ent.scope === "string" ? ent.scope.trim() : "";
17691
17862
  out.push({
17692
17863
  kindLabel: liKindLabel(kind),
17693
17864
  value: value,
17694
- href: liHref(kind, value),
17865
+ href: liHref(kind, value, scope),
17695
17866
  basisText: basis.text,
17696
17867
  tone: basis.tone,
17697
17868
  // 🔴 since が無い行の「いつから」は素の空。印(ダッシュ等)を足さない——空が正常
17698
17869
  since: typeof ent.since === "string" ? ent.since : "",
17870
+ scope: scope,
17699
17871
  });
17700
17872
  });
17701
17873
  return out;
17702
17874
  }
17703
17875
  /**
17704
- * 「つながっている ID」の折りたたみ。**関係者以外の記録と、0件のときは null**
17705
- *(節ごと出さない=欄がまだ無い今は、今までと同じに見える)。
17876
+ * 否定済み(not_aliases)の行。**文字列の要素だけ**を、書かれた順のまま返す。
17877
+ * <種類>:<値> の形なら道具の名前と値に割り、素の文字列はそのまま値として出す
17878
+ */
17879
+ function deniedIdRows(rec) {
17880
+ var list = rec && rec.fm ? rec.fm.not_aliases : null;
17881
+ if (!Array.isArray(list)) return [];
17882
+ var out = [];
17883
+ list.forEach(function (one) {
17884
+ if (typeof one !== "string") return;
17885
+ var whole = one.trim();
17886
+ if (whole === "") return;
17887
+ var typed = LI_TYPED_ID.exec(whole);
17888
+ out.push({ kindLabel: typed ? liKindLabel(typed[1]) : "", value: typed ? typed[2] : whole });
17889
+ });
17890
+ return out;
17891
+ }
17892
+ /**
17893
+ * 「つながっている ID」の折りたたみ。**関係者以外の記録と、つながっている ID も
17894
+ * 否定済みも 0 件のときは null**(節ごと出さない=欄が無い記録は今までと同じに見える)。
17706
17895
  *
17707
17896
  * 🔴 **表に table を使わない**(画面の部品に table は1つも無い)。.fm-grid と同じ
17708
- * 4列のグリッドで組む。値は全部 el() の text(textContent 経由)で入れる。
17897
+ * 書き方のグリッドで組む。値は全部 el() の text(textContent 経由)で入れる。
17898
+ * 🔴 **否定済みは同じ節の中に、さらに畳んで出す**(開かなければ見えない)。
17899
+ * つながっている ID が無く否定済みだけある記録にも節を出す——「前に否定したか」を
17900
+ * 確かめる口が画面から消えないように
17709
17901
  */
17710
17902
  function buildLinkedIds(rec) {
17711
17903
  if (!isMemberRec(rec)) return null;
17712
17904
  var rows = linkedIdRows(rec);
17713
- if (!rows.length) return null;
17905
+ var denied = deniedIdRows(rec);
17906
+ if (!rows.length && !denied.length) return null;
17907
+ var head = "つながっている ID(" + rows.length + "件" + (denied.length ? "・否定済み " + denied.length + "件" : "") + ")";
17714
17908
  var det = el("details", { class: "linked-ids" }, [
17715
- el("summary", { class: "li-summary", text: "つながっている ID(" + rows.length + "件)" }),
17909
+ el("summary", { class: "li-summary", text: head }),
17716
17910
  ]);
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);
17911
+ if (rows.length) {
17912
+ var grid = el("div", { class: "li-grid" });
17913
+ LI_COLS.forEach(function (col) {
17914
+ grid.appendChild(el("div", { class: "li-cell li-head", text: col }));
17915
+ });
17916
+ rows.forEach(function (row) {
17917
+ grid.appendChild(el("div", { class: "li-cell li-kind", text: row.kindLabel }));
17918
+ var valueCell = el("div", { class: "li-cell li-value" });
17919
+ valueCell.appendChild(row.href
17920
+ ? el("a", { href: row.href, target: "_blank", rel: "noopener", text: row.value })
17921
+ : el("span", { text: row.value }));
17922
+ grid.appendChild(valueCell);
17923
+ grid.appendChild(el("div", { class: "li-cell li-basis " + row.tone, text: row.basisText }));
17924
+ grid.appendChild(el("div", { class: "li-cell li-since", text: row.since }));
17925
+ grid.appendChild(el("div", { class: "li-cell li-scope", text: row.scope }));
17926
+ });
17927
+ det.appendChild(grid);
17928
+ }
17929
+ if (denied.length) {
17930
+ var box = el("details", { class: "li-denied" }, [
17931
+ el("summary", { class: "li-denied-summary", text: LI_DENIED_TITLE + "(" + denied.length + "件)" }),
17932
+ ]);
17933
+ var ul = el("ul", { class: "li-denied-list" });
17934
+ denied.forEach(function (d) {
17935
+ var li = el("li", { class: "li-denied-row" });
17936
+ if (d.kindLabel) li.appendChild(el("span", { class: "li-denied-kind", text: d.kindLabel }));
17937
+ li.appendChild(el("span", { class: "li-denied-value", text: d.value }));
17938
+ ul.appendChild(li);
17939
+ });
17940
+ box.appendChild(ul);
17941
+ det.appendChild(box);
17942
+ }
17732
17943
  // 違っていたときにどうするか。.ah-source と同じ置き方(節の末尾の小さな段落)
17733
17944
  det.appendChild(el("p", { class: "li-note", text: LI_NOTE }));
17734
17945
  return det;
@@ -19164,6 +19375,14 @@ ${calendarAddUrl.toString()}
19164
19375
  if (isHttp(target)) return el("a", { href: target, target: "_blank", rel: "noopener", text: target });
19165
19376
  return el("span", { text: target });
19166
19377
  }
19378
+ var FM_EMPTY_TEXT = "(未記入)";
19379
+ /** パネルの 1 値を文字にする。オブジェクト・配列は JSON([object Object] を出さない) */
19380
+ function fmScalarText(x) {
19381
+ if (x && typeof x === "object") {
19382
+ try { return JSON.stringify(x); } catch (e) { return String(x); }
19383
+ }
19384
+ return String(x);
19385
+ }
19167
19386
  function renderFmPanel(fm) {
19168
19387
  if (!fm || !Object.keys(fm).length) return null;
19169
19388
  var panel = el("details", { class: "fm-panel" });
@@ -19173,7 +19392,12 @@ ${calendarAddUrl.toString()}
19173
19392
  grid.appendChild(el("div", { class: "fm-key", text: k }));
19174
19393
  var cell = el("div", { class: "fm-val" });
19175
19394
  var v = fm[k];
19176
- if (k === "relations" && Array.isArray(v)) {
19395
+ if (v === "" || (Array.isArray(v) && v.length === 0)) {
19396
+ // 🔴 **空を空欄で表さない**(#704)。記録に email: "" と書かれている(雛形の既定)
19397
+ // だけなのに、画面が値を伏せているように見えた——「書かれていない」と言葉で出す。
19398
+ // identities: [] / relations: [] もここで受ける(下の欄ごとの枝より前で見る)
19399
+ cell.appendChild(el("span", { class: "fm-empty", text: FM_EMPTY_TEXT }));
19400
+ } else if (k === "relations" && Array.isArray(v)) {
19177
19401
  v.forEach(function (r) {
19178
19402
  if (!r || typeof r !== "object") return;
19179
19403
  var row = el("div", { class: "fm-rel" });
@@ -19198,7 +19422,10 @@ ${calendarAddUrl.toString()}
19198
19422
  cell.appendChild(line);
19199
19423
  });
19200
19424
  } else if (Array.isArray(v)) {
19201
- cell.textContent = v.join(" / ");
19425
+ // 🔴 要素がオブジェクトなら JSON で出す(join に落とすと [object Object] になる)
19426
+ cell.textContent = v.map(fmScalarText).join(" / ");
19427
+ } else if (v && typeof v === "object") {
19428
+ cell.textContent = fmScalarText(v);
19202
19429
  } else if (k === "source") {
19203
19430
  cell.appendChild(fmTargetLink(String(v)));
19204
19431
  } else if (isHttp(v)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.77",
3
+ "version": "0.12.79",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,