@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/build.js CHANGED
@@ -41,6 +41,7 @@ exports.sanitizeBody = sanitizeBody;
41
41
  exports.findProjectIcon = findProjectIcon;
42
42
  exports.readFleetSources = readFleetSources;
43
43
  exports.attachRederived = attachRederived;
44
+ exports.attachDecisionFollowup = attachDecisionFollowup;
44
45
  exports.readGoldProposals = readGoldProposals;
45
46
  exports.buildHomeGrounding = buildHomeGrounding;
46
47
  exports.collectMeetings = collectMeetings;
@@ -93,6 +94,7 @@ function takeProposals(filePath) {
93
94
  PROPOSAL_PATHS_MATCHED.add(filePath);
94
95
  return hit;
95
96
  }
97
+ const external_id_1 = require("./external-id");
96
98
  const render_1 = require("./render");
97
99
  /**
98
100
  * この画面を作ったパッケージの版。**package.json を唯一の出どころにする。**
@@ -216,6 +218,17 @@ function toSearchText(md) {
216
218
  .replace(/\s+/g, " ")
217
219
  .trim();
218
220
  }
221
+ /**
222
+ * 関係者の記録だけ、検索用の plaintext の後ろに「つながっている ID」の値と email を足す(#704)。
223
+ * **関係者以外の記録は 1 文字も変えない**(本文から作った searchText をそのまま返す)。
224
+ * 足す語の正本は external-id.ts の memberSearchTerms。
225
+ */
226
+ function memberSearchText(data, sectionDir, base) {
227
+ if (!(0, external_id_1.isMemberRecord)(data.type, data.id, sectionDir))
228
+ return base;
229
+ const extra = (0, external_id_1.memberSearchTerms)(data);
230
+ return extra.length ? (base ? base + " " : "") + extra.join(" ") : base;
231
+ }
219
232
  /** dir未指定時の探索候補(新構成 → 旧構成の順) */
220
233
  const DEFAULT_DIR_CANDIDATES = ["Cortex/Decisions/records", "Cortex/Decisions", "Decisions"];
221
234
  /**
@@ -749,6 +762,31 @@ readBlobs = repo_1.readBlobsAt) {
749
762
  }
750
763
  return (0, rederivation_1.orderByActivity)(records, pathOf, statusOf, ctx.ledgers, nowIso);
751
764
  }
765
+ /**
766
+ * 決定に「その後」(`.cortex/gold-decision-followup.json`・#705)を載せる。
767
+ *
768
+ * 🔴 **突き合わせはビルド時にここで閉じる**(`attachRederived` と同じ)。path は
769
+ * **`repoRoot` 起点**で作る——帳簿の鍵は常にリポジトリルート相対(`Cortex/Decisions/records/…`)。
770
+ * 画面に出す状態の結び付きが 1 件も無い決定には**キーごと付けない**(`shownFollowupOf`)。
771
+ *
772
+ * @returns 帳簿の行のうち、どの決定にも突き合わなかった path(ビルドログに 1 行出す材料)
773
+ */
774
+ function attachDecisionFollowup(decisions, recordsDir, repoRoot, ledger) {
775
+ const recordsRel = path.relative(repoRoot, recordsDir).split(path.sep).join("/");
776
+ const matched = new Set();
777
+ for (const d of decisions) {
778
+ // 帳簿の鍵(git 由来)は NFC。readdir が NFD で返す環境でも突き合うように揃える
779
+ const p = (recordsRel ? `${recordsRel}/${d.fileName}` : d.fileName).normalize("NFC");
780
+ const row = ledger.get(p);
781
+ if (!row)
782
+ continue;
783
+ matched.add(p);
784
+ const shown = (0, rederivation_1.shownFollowupOf)(row);
785
+ if (shown)
786
+ d.followup = shown;
787
+ }
788
+ return [...ledger.keys()].filter((k) => !matched.has(k.normalize("NFC")));
789
+ }
752
790
  /**
753
791
  * AIからの更新提案(`.cortex/gold-proposals.json`)を読む。
754
792
  *
@@ -762,6 +800,10 @@ readBlobs = repo_1.readBlobsAt) {
762
800
  * **無い・壊れているときは提案ゼロで正常継続する。** 提案はあくまで付加情報で、
763
801
  * これが読めないことでレコードの表示まで止めるのは釣り合わない。
764
802
  */
803
+ /** 文字列で中身があるときだけ返す(数・オブジェクトを String() して載せない) */
804
+ function optionalText(v) {
805
+ return typeof v === "string" && v.trim() !== "" ? v : "";
806
+ }
765
807
  async function readGoldProposals(repoRoot) {
766
808
  const empty = new Map();
767
809
  let json;
@@ -827,6 +869,25 @@ async function readGoldProposals(repoRoot) {
827
869
  reason: row.reason != null ? String(row.reason) : "",
828
870
  createdAt: row.createdAt != null ? String(row.createdAt) : "",
829
871
  };
872
+ // 名寄せの提案にだけ付く欄(engine の便 A・#704)。**文字列で中身があるときだけ**載せる
873
+ // ——無い提案にキーを生やすと、他の提案まで形が変わる(許可リストの守りのテストが見ている)。
874
+ // 🔴 **根拠の url(「その発言を開く」)はまだ通さない**(決定ログ 20260920-686.md の
875
+ // 「提案カードに新しい欄を 1 つも通さない」の url の項は生きている。20260923-704.md)
876
+ const kind = optionalText(row.kind);
877
+ if (kind)
878
+ proposal.kind = kind;
879
+ const claimKey = optionalText(row.claim_key);
880
+ if (claimKey)
881
+ proposal.claim_key = claimKey;
882
+ const displayName = optionalText(row.display_name);
883
+ if (displayName)
884
+ proposal.display_name = displayName;
885
+ const question = optionalText(row.question);
886
+ if (question)
887
+ proposal.question = question;
888
+ const why = optionalText(row.why);
889
+ if (why)
890
+ proposal.why = why;
830
891
  const list = byPath.get(recordPath);
831
892
  if (list)
832
893
  list.push(proposal);
@@ -975,6 +1036,9 @@ async function loadGenericRecords(dirAbs, exclude, repoBaseUrl, branch) {
975
1036
  return [];
976
1037
  }
977
1038
  const repoRelDir = toRepoRelative(dirAbs);
1039
+ // 区画のディレクトリ名(`records` の親)。**絶対パスから取る**——cwd 相対の repoRelDir は
1040
+ // cwd がリポジトリルートでないと `records` に倒れる(isMemberRecord の注意書き)
1041
+ const sectionDir = path.basename(dirAbs) === "records" ? path.basename(path.dirname(dirAbs)) : path.basename(dirAbs);
978
1042
  const records = [];
979
1043
  for (const e of entries) {
980
1044
  if (!e.isFile() || !e.name.toLowerCase().endsWith(".md"))
@@ -982,7 +1046,7 @@ async function loadGenericRecords(dirAbs, exclude, repoBaseUrl, branch) {
982
1046
  if (exclude.has(e.name.toLowerCase()))
983
1047
  continue;
984
1048
  const raw = await node_fs_1.promises.readFile(path.join(dirAbs, e.name), "utf8");
985
- records.push(await parseGeneric(raw, e.name, repoRelDir, repoBaseUrl, branch));
1049
+ records.push(await parseGeneric(raw, e.name, repoRelDir, repoBaseUrl, branch, sectionDir));
986
1050
  }
987
1051
  // 日付降順 → タイトル昇順(用語集のように日付が同一でも安定して並ぶ)
988
1052
  //
@@ -1000,7 +1064,7 @@ async function loadGenericRecords(dirAbs, exclude, repoBaseUrl, branch) {
1000
1064
  });
1001
1065
  return records;
1002
1066
  }
1003
- async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch) {
1067
+ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch, sectionDir = "") {
1004
1068
  const { data, content } = (0, gray_matter_1.default)(raw);
1005
1069
  const h1 = content.match(/^#\s+(.+)$/m);
1006
1070
  const id = String(data.id ?? fileName.replace(/\.md$/i, ""));
@@ -1028,7 +1092,7 @@ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch) {
1028
1092
  bodyHtml,
1029
1093
  bodyRef: "",
1030
1094
  bodyId: "",
1031
- searchText: toSearchText(content),
1095
+ searchText: memberSearchText(data, sectionDir, toSearchText(content)),
1032
1096
  fileName,
1033
1097
  editUrl,
1034
1098
  rtype: data.type != null ? String(data.type) : "",
@@ -1993,6 +2057,17 @@ async function build(opts) {
1993
2057
  const rec = await parseDecision(raw, fileName, repoRelDir, repo.baseUrl, repo.branch, refs);
1994
2058
  decisions.push(rec);
1995
2059
  }
2060
+ // 決定の「その後」(#705)。**帳簿を持たない案件では 1 件も付かず、絞り込みの印も立たない**
2061
+ // (`decisionFollowup` はキーごと無い=今までと 1 バイトも同じ画面)
2062
+ const followupLedger = await (0, rederivation_1.readDecisionFollowup)(repoRoot);
2063
+ if (followupLedger) {
2064
+ const unmatchedFollowup = attachDecisionFollowup(decisions, dirAbs, repoRoot, followupLedger);
2065
+ // **突き合わなかった行を 1 行残す**(path が 1 文字違うだけで画面から静かに消えるので)
2066
+ if (unmatchedFollowup.length) {
2067
+ console.warn(`gold-decision-followup.json: 決定に突き合わない行が ${unmatchedFollowup.length} 件ありました(表示しません): ` +
2068
+ unmatchedFollowup.slice(0, 3).join(", ") + (unmatchedFollowup.length > 3 ? " ほか" : ""));
2069
+ }
2070
+ }
1996
2071
  let home = null;
1997
2072
  let homeGrounding;
1998
2073
  let homeUpdatedAt;
@@ -2125,6 +2200,8 @@ async function build(opts) {
2125
2200
  projectKey: opts.projectKey,
2126
2201
  }
2127
2202
  : undefined,
2203
+ // 帳簿を読めた案件だけ true(無い・壊れ・版違いはキーごと出さない)
2204
+ decisionFollowup: followupLedger ? true : undefined,
2128
2205
  meetings,
2129
2206
  // **URLとトークンが両方そろったときだけ出す。** 片方だけでは貼れるURLを作れないので、
2130
2207
  // 手順だけが出て「専用URLが空」という一番混乱する形になる(intake と同じ扱い)
@@ -2225,6 +2302,7 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
2225
2302
  const referencesRaw = toStringArray(data.references);
2226
2303
  const references = referencesRaw.map((r) => refs.resolve(r));
2227
2304
  const relations = toRelations(data.relations);
2305
+ const followupHuman = (0, rederivation_1.humanFollowupOf)(data.followup);
2228
2306
  // 本文先頭の H1 はタイトルと重複するため除去(詳細画面ではタイトルを別途表示する)
2229
2307
  const bodyHtml = await renderRecordBody(content);
2230
2308
  const filePath = repoRelDir ? `${repoRelDir}/${fileName}` : fileName;
@@ -2253,6 +2331,8 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
2253
2331
  editUrl,
2254
2332
  fm: sanitizeFm(data),
2255
2333
  fmErrors: validateFm(data),
2334
+ // 担当者の答え(#705)。**語彙外・形が違うものはキーごと載せない**
2335
+ ...(followupHuman ? { followupHuman: followupHuman } : {}),
2256
2336
  };
2257
2337
  }
2258
2338
  function createRefResolver(opts) {
@@ -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
+ }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.emptyLedgers = exports.LEDGER_VERSION = void 0;
6
+ exports.HUMAN_FOLLOWUP_STATES = exports.FOLLOWUP_EVIDENCE_VERDICTS = exports.FOLLOWUP_SHOWN_STATES = exports.DECISION_FOLLOWUP_FILE = exports.emptyLedgers = exports.LEDGER_VERSION = void 0;
7
7
  exports.isConfirmedStatus = isConfirmedStatus;
8
8
  exports.parseLedger = parseLedger;
9
9
  exports.readRederivationLedgers = readRederivationLedgers;
@@ -15,6 +15,10 @@ exports.contentShaOf = contentShaOf;
15
15
  exports.rederivedContentKey = rederivedContentKey;
16
16
  exports.rederivedBandOf = rederivedBandOf;
17
17
  exports.orderByActivity = orderByActivity;
18
+ exports.followupDateOf = followupDateOf;
19
+ exports.readDecisionFollowup = readDecisionFollowup;
20
+ exports.shownFollowupOf = shownFollowupOf;
21
+ exports.humanFollowupOf = humanFollowupOf;
18
22
  /**
19
23
  * 再導出の 3 帳簿を読み、帯「人が確認した版から変わりました」と用語集の並びを作る(#665 便 12b)。
20
24
  *
@@ -527,3 +531,164 @@ function orderByActivity(records, pathOf, statusOf, ledgers, nowIso) {
527
531
  })
528
532
  .map((s) => s.r);
529
533
  }
534
+ /* ---------------------------------------------------------------------------
535
+ * 決定の「その後」の帳簿(#705・engine #509 便 4)
536
+ * ---------------------------------------------------------------------------
537
+ *
538
+ * `.cortex/gold-decision-followup.json` は夜間(engine `scripts/gold/decision-followup.mjs`)が
539
+ * 型(`scripts/ledger/`)を通して書く。形は
540
+ * `{ version: 1, venues: [...], sections: { Decisions: { <path>: { decided_on, state, links, candidates, evidence, checked_on } } } }`。
541
+ *
542
+ * 🔴 **帳簿が無い・壊れている・版が違う案件は「今までと 1 バイトも同じ画面」**(上の 3 帳簿と同じ規律)。
543
+ * 読めないものは `null`(=帳簿を持たない案件と同じ)として続ける。
544
+ *
545
+ * 🔴 **便 3 の `evidence[]` は形が未確定**。知らない verdict・欠けた欄は行ごと捨てる
546
+ * (落ちない・知らないものを画面へ運ばない)。
547
+ */
548
+ /** 帳簿のファイル名(`.cortex/` の下) */
549
+ exports.DECISION_FOLLOWUP_FILE = "gold-decision-followup.json";
550
+ /**
551
+ * 画面に出す結び付きの状態(engine `decision-followup-run.mjs` の `LINKED_STATES` と同じ 6 語)。
552
+ *
553
+ * 🔴 **`venue_unavailable` / `link_unresolved` / `no_link` / `source_missing` は出さない**
554
+ * (発注書 #705)。「未接続のため」「記録なし」の文を画面に並べない——出来事だけを出す。
555
+ */
556
+ exports.FOLLOWUP_SHOWN_STATES = [
557
+ "issue_closed", "pr_merged", "issue_open", "superseded", "same_day", "pr_closed",
558
+ ];
559
+ /** 結び付きの種類(`material` は出典の資料で、`source_missing` にしか現れないので載せない) */
560
+ const FOLLOWUP_LINK_KINDS = ["issue", "pr", "gh", "decision"];
561
+ /** 便 3 の verdict のうち画面が知っているもの。**知らない値は行ごと捨てる** */
562
+ exports.FOLLOWUP_EVIDENCE_VERDICTS = ["done", "changed", "review"];
563
+ /** 人が frontmatter に書く `followup.state` の語彙(intake `FOLLOWUP_STATES` と同じ 4 語) */
564
+ exports.HUMAN_FOLLOWUP_STATES = ["confirmed", "done", "dropped", "ignore"];
565
+ /** http(s) の URL だけを通す(`javascript:` などを画面へ運ばない) */
566
+ const httpUrl = (v) => {
567
+ const s = typeof v === "string" ? v.trim() : "";
568
+ return /^https?:\/\//i.test(s) ? s : "";
569
+ };
570
+ /**
571
+ * 日付を `YYYY-MM-DD` に畳む。
572
+ *
573
+ * 🔴 **engine の `at` は日付とは限らない**(`decision-links.mjs` の `terminalStateOf` は
574
+ * `merged_at` / `updated_at` の **ISO 日時**をそのまま入れる)。時刻つきのものは
575
+ * **JST の日付**に直す——UTC の先頭 10 文字を切ると、日本の夜に閉じた課題が前日に見える。
576
+ * 読めないもの(null・数・壊れた文字列)は空。
577
+ */
578
+ function followupDateOf(v) {
579
+ if (v instanceof Date)
580
+ return Number.isFinite(v.getTime()) ? v.toISOString().slice(0, 10) : "";
581
+ const s = typeof v === "string" ? v.trim() : "";
582
+ if (!s)
583
+ return "";
584
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s))
585
+ return s;
586
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(s))
587
+ return "";
588
+ const ms = Date.parse(s);
589
+ if (!Number.isFinite(ms))
590
+ return "";
591
+ return new Date(ms + 9 * 3600 * 1000).toISOString().slice(0, 10);
592
+ }
593
+ /**
594
+ * 決定の「その後」の帳簿を読む。
595
+ *
596
+ * @returns path → 行。**帳簿が無い・壊れている・版が違うときは `null`**(=帳簿を持たない案件)。
597
+ * 読めたが行が 0 件のときは空の Map(帳簿を持つ案件として扱う=絞り込みの「記録なし」が出うる)
598
+ */
599
+ async function readDecisionFollowup(repoRoot) {
600
+ let raw;
601
+ try {
602
+ raw = await promises_1.default.readFile(node_path_1.default.join(repoRoot, ".cortex", exports.DECISION_FOLLOWUP_FILE), "utf8");
603
+ }
604
+ catch (e) {
605
+ // 無いのが既定(栓 `decision_followup` を入れた案件だけが持つ)。無いことは警告しない
606
+ if (e?.code !== "ENOENT") {
607
+ console.warn(`${exports.DECISION_FOLLOWUP_FILE} を読めませんでした(その後の記録は出さずに続けます): ${String(e)}`);
608
+ }
609
+ return null;
610
+ }
611
+ const parsed = parseLedger(raw);
612
+ if (!parsed) {
613
+ // 壊れ・版違いは 1 行残す(「夜間がまだ書いていない」と切り分けるため)
614
+ console.warn(`${exports.DECISION_FOLLOWUP_FILE} の形式か version が想定と違います(その後の記録は出さずに続けます)`);
615
+ return null;
616
+ }
617
+ const out = new Map();
618
+ const sections = asRecord(parsed.sections);
619
+ // 🔴 **区画は Decisions だけを見る**(engine の `SECTION`)。他の名前の節が増えても拾わない
620
+ const rows = asRecord(sections?.Decisions);
621
+ if (!rows)
622
+ return out;
623
+ for (const [p, v] of Object.entries(rows)) {
624
+ const row = asRecord(v);
625
+ if (!row)
626
+ continue;
627
+ const links = [];
628
+ if (Array.isArray(row.links)) {
629
+ for (const l of row.links) {
630
+ const link = asRecord(l);
631
+ if (!link)
632
+ continue;
633
+ const kind = typeof link.kind === "string" ? link.kind : "";
634
+ const key = typeof link.key === "string" ? link.key : "";
635
+ const state = typeof link.state === "string" ? link.state : "";
636
+ if (!FOLLOWUP_LINK_KINDS.includes(kind) || !key || !state)
637
+ continue;
638
+ links.push({ kind: kind, key: key, state: state, at: followupDateOf(link.at), url: httpUrl(link.url) });
639
+ }
640
+ }
641
+ const evidence = [];
642
+ if (Array.isArray(row.evidence)) {
643
+ for (const e of row.evidence) {
644
+ const ev = asRecord(e);
645
+ if (!ev)
646
+ continue;
647
+ const verdict = typeof ev.verdict === "string" ? ev.verdict : "";
648
+ // 🔴 **知らない verdict は捨てる**(便 3 の語彙は未確定。知らない語を画面の文にしない)
649
+ if (!exports.FOLLOWUP_EVIDENCE_VERDICTS.includes(verdict))
650
+ continue;
651
+ evidence.push({
652
+ verdict: verdict,
653
+ quote: typeof ev.quote === "string" ? ev.quote : "",
654
+ url: httpUrl(ev.url),
655
+ // engine の今の根拠行は `{id, venue, quote, verdict, judged_at}`(`occurred_at` を持たない)。
656
+ // 無ければ判定した日で代える——形は便 3 で合わせる(docs/product-decisions.md)
657
+ at: followupDateOf(ev.occurred_at) || followupDateOf(ev.judged_at),
658
+ });
659
+ }
660
+ }
661
+ out.set(p, { links: links, evidence: evidence });
662
+ }
663
+ return out;
664
+ }
665
+ /**
666
+ * 帳簿の行から、画面に出すぶんだけを残す。**出す結び付きが 1 件も無ければ `null`**
667
+ * (節を作らない・一覧の印も付かない)。
668
+ */
669
+ function shownFollowupOf(row) {
670
+ if (!row)
671
+ return null;
672
+ const links = row.links.filter((l) => exports.FOLLOWUP_SHOWN_STATES.includes(l.state));
673
+ if (!links.length)
674
+ return null;
675
+ return { links: links, evidence: row.evidence };
676
+ }
677
+ /**
678
+ * frontmatter の `followup:`(人が画面のボタンで書く 1 行)を読む。
679
+ *
680
+ * 形は `followup: { state: done, at: 2026-09-20 }`(intake `setFollowupLine` が書く)。
681
+ * YAML は `at` を Date に読むので日付に戻す。**語彙外・形が違うものは `null`**(載せない)。
682
+ */
683
+ function humanFollowupOf(v) {
684
+ const o = asRecord(v);
685
+ if (!o)
686
+ return null;
687
+ const state = typeof o.state === "string" ? o.state : "";
688
+ if (!exports.HUMAN_FOLLOWUP_STATES.includes(state))
689
+ return null;
690
+ const at = followupDateOf(o.at);
691
+ if (!at)
692
+ return null;
693
+ return { state: state, at: at };
694
+ }