@takagaki/cortex-decisions-viewer 0.12.79 → 0.12.81

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;
@@ -57,6 +58,7 @@ const gray_matter_1 = __importDefault(require("gray-matter"));
57
58
  const marked_1 = require("marked");
58
59
  const sanitize_html_1 = __importDefault(require("sanitize-html"));
59
60
  const repo_1 = require("./repo");
61
+ const engagement_1 = require("./engagement");
60
62
  const rederivation_1 = require("./rederivation");
61
63
  // 直近のGold昇格で追加されたレコードのパス集合。レコード生成の各所から参照するのでモジュールスコープに置く
62
64
  // (引数で引き回すと呼び出し側の改修が広範囲になる)。build() の冒頭で1回だけ解決する。
@@ -761,6 +763,31 @@ readBlobs = repo_1.readBlobsAt) {
761
763
  }
762
764
  return (0, rederivation_1.orderByActivity)(records, pathOf, statusOf, ctx.ledgers, nowIso);
763
765
  }
766
+ /**
767
+ * 決定に「その後」(`.cortex/gold-decision-followup.json`・#705)を載せる。
768
+ *
769
+ * 🔴 **突き合わせはビルド時にここで閉じる**(`attachRederived` と同じ)。path は
770
+ * **`repoRoot` 起点**で作る——帳簿の鍵は常にリポジトリルート相対(`Cortex/Decisions/records/…`)。
771
+ * 画面に出す状態の結び付きが 1 件も無い決定には**キーごと付けない**(`shownFollowupOf`)。
772
+ *
773
+ * @returns 帳簿の行のうち、どの決定にも突き合わなかった path(ビルドログに 1 行出す材料)
774
+ */
775
+ function attachDecisionFollowup(decisions, recordsDir, repoRoot, ledger) {
776
+ const recordsRel = path.relative(repoRoot, recordsDir).split(path.sep).join("/");
777
+ const matched = new Set();
778
+ for (const d of decisions) {
779
+ // 帳簿の鍵(git 由来)は NFC。readdir が NFD で返す環境でも突き合うように揃える
780
+ const p = (recordsRel ? `${recordsRel}/${d.fileName}` : d.fileName).normalize("NFC");
781
+ const row = ledger.get(p);
782
+ if (!row)
783
+ continue;
784
+ matched.add(p);
785
+ const shown = (0, rederivation_1.shownFollowupOf)(row);
786
+ if (shown)
787
+ d.followup = shown;
788
+ }
789
+ return [...ledger.keys()].filter((k) => !matched.has(k.normalize("NFC")));
790
+ }
764
791
  /**
765
792
  * AIからの更新提案(`.cortex/gold-proposals.json`)を読む。
766
793
  *
@@ -2025,12 +2052,26 @@ async function build(opts) {
2025
2052
  blobs: { read: 0, skipped: 0 },
2026
2053
  }
2027
2054
  : null;
2055
+ // 関与の帳簿(便 14b・#709)。関係者の一覧を目次と同じ「関わりの多い順」に並べる材料。
2056
+ // **読めない・使えないなら `null`** で、関係者の並びは今までと 1 バイトも同じ
2057
+ const engagement = await (0, engagement_1.readEngagementLedger)(repoRoot);
2028
2058
  const decisions = [];
2029
2059
  for (const fileName of mdFiles) {
2030
2060
  const raw = await node_fs_1.promises.readFile(path.join(dirAbs, fileName), "utf8");
2031
2061
  const rec = await parseDecision(raw, fileName, repoRelDir, repo.baseUrl, repo.branch, refs);
2032
2062
  decisions.push(rec);
2033
2063
  }
2064
+ // 決定の「その後」(#705)。**帳簿を持たない案件では 1 件も付かず、絞り込みの印も立たない**
2065
+ // (`decisionFollowup` はキーごと無い=今までと 1 バイトも同じ画面)
2066
+ const followupLedger = await (0, rederivation_1.readDecisionFollowup)(repoRoot);
2067
+ if (followupLedger) {
2068
+ const unmatchedFollowup = attachDecisionFollowup(decisions, dirAbs, repoRoot, followupLedger);
2069
+ // **突き合わなかった行を 1 行残す**(path が 1 文字違うだけで画面から静かに消えるので)
2070
+ if (unmatchedFollowup.length) {
2071
+ console.warn(`gold-decision-followup.json: 決定に突き合わない行が ${unmatchedFollowup.length} 件ありました(表示しません): ` +
2072
+ unmatchedFollowup.slice(0, 3).join(", ") + (unmatchedFollowup.length > 3 ? " ほか" : ""));
2073
+ }
2074
+ }
2034
2075
  let home = null;
2035
2076
  let homeGrounding;
2036
2077
  let homeUpdatedAt;
@@ -2069,7 +2110,12 @@ async function build(opts) {
2069
2110
  const records = await loadGenericRecords(recordsDir, exclude, repo.baseUrl, repo.branch);
2070
2111
  // 「人が確認した版から変わりました」の帯と、用語集の並び(#665 便 12b)。
2071
2112
  // **帳簿を持たない案件では 1 件も付かず、並びもそのまま**(下の attachRederived)
2072
- const ordered = await attachRederived(records, recordsDir, rederive, opts.generatedAt);
2113
+ const rederived = await attachRederived(records, recordsDir, rederive, opts.generatedAt);
2114
+ // 関係者だけ、関与の多い順(engine の目次と同じ `order`・便 14b)。用語集の並び
2115
+ // (`orderByActivity`)とは帳簿も式も別で、区画が重ならないので順番に当てても干渉しない
2116
+ const ordered = e.name === "Members"
2117
+ ? (0, engagement_1.orderMembersByEngagement)(rederived, (r) => r.id, engagement, opts.generatedAt)
2118
+ : rederived;
2073
2119
  // レコード0件でもタブは出す(空状態から「+AIで登録」できる導線になる)。
2074
2120
  // records/ 自体が無いディレクトリ(その区画が未導入の案件)はタブにしない
2075
2121
  if (records.length || (await isDirectory(recordsDir))) {
@@ -2163,6 +2209,8 @@ async function build(opts) {
2163
2209
  projectKey: opts.projectKey,
2164
2210
  }
2165
2211
  : undefined,
2212
+ // 帳簿を読めた案件だけ true(無い・壊れ・版違いはキーごと出さない)
2213
+ decisionFollowup: followupLedger ? true : undefined,
2166
2214
  meetings,
2167
2215
  // **URLとトークンが両方そろったときだけ出す。** 片方だけでは貼れるURLを作れないので、
2168
2216
  // 手順だけが出て「専用URLが空」という一番混乱する形になる(intake と同じ扱い)
@@ -2263,6 +2311,7 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
2263
2311
  const referencesRaw = toStringArray(data.references);
2264
2312
  const references = referencesRaw.map((r) => refs.resolve(r));
2265
2313
  const relations = toRelations(data.relations);
2314
+ const followupHuman = (0, rederivation_1.humanFollowupOf)(data.followup);
2266
2315
  // 本文先頭の H1 はタイトルと重複するため除去(詳細画面ではタイトルを別途表示する)
2267
2316
  const bodyHtml = await renderRecordBody(content);
2268
2317
  const filePath = repoRelDir ? `${repoRelDir}/${fileName}` : fileName;
@@ -2291,6 +2340,8 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, ref
2291
2340
  editUrl,
2292
2341
  fm: sanitizeFm(data),
2293
2342
  fmErrors: validateFm(data),
2343
+ // 担当者の答え(#705)。**語彙外・形が違うものはキーごと載せない**
2344
+ ...(followupHuman ? { followupHuman: followupHuman } : {}),
2294
2345
  };
2295
2346
  }
2296
2347
  function createRefResolver(opts) {
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.weightOf = exports.WEIGHTS = exports.HALF_LIFE_DAYS = exports.ENGAGEMENT_LEDGER_VERSION = exports.ENGAGEMENT_LEDGER_PATH = void 0;
7
+ exports.scoreOf = scoreOf;
8
+ exports.engagementOrder = engagementOrder;
9
+ exports.parseEngagementLedger = parseEngagementLedger;
10
+ exports.readEngagementLedger = readEngagementLedger;
11
+ exports.orderMembersByEngagement = orderMembersByEngagement;
12
+ /**
13
+ * 関係者の一覧を「関わりの多い順」に並べる(便 14b・#709)。
14
+ *
15
+ * ## 写しの正本は engine
16
+ *
17
+ * 式の正本は cortex-engine の `scripts/engagement/tiers.mjs`(`scoreOf` と `assignTiers` の
18
+ * `order`)と `scripts/engagement/weights.mjs`(重みの表・半減期)。記憶の目次
19
+ * (`Cortex/Index.md` の関係者の節・`scripts/memory-index.mjs` の `memberRows`)は正本を直に
20
+ * 呼んでいるので、**画面の並びがここでずれると、同じ人が画面と目次で別の順に出る**。
21
+ * どちらも正しく描画され、エラーも空欄も出ない——だから写しの契約を表で持つ
22
+ * (`test/member-engagement-parity.test.mjs` が engine の
23
+ * `test/fixtures/engagement-tiers-contract.json` を 1 バイト違わず写した表を読む)。
24
+ *
25
+ * **写すのは並び(`order`)だけ。** 段階(`tiers`)・群(`groups`)・上位集合(`top_set`)・
26
+ * 食い違い(`mismatch`)・不感帯(`basis`)は画面が使わない。とくに `order` は便 #543 で群分けを
27
+ * やめて「score 降順・同点は `person` 昇順」だけになったので、**不感帯の基準にも段階にも
28
+ * 依存しない**。使わない式を写すと、正本が直した日に写しの直し忘れが静かに残る。
29
+ *
30
+ * ## 🔴 帳簿が無い・壊れている・版が違う案件は「今までと 1 バイトも同じ画面」
31
+ *
32
+ * 読み方は目次(`parseLedgerText` と `memberRows`)と同じ規律:
33
+ * - 無い・JSON として読めない・オブジェクトでない・`version` が違う → 並べ替えない
34
+ * - **読めても使える行が 0 件なら並べ替えない**(観測が始まったばかりの案件の
35
+ * `{"version":1,"people":[]}`。目次が「本式と名乗らない」のと同じ線)
36
+ * - 壊れた行(`null`・`person` が文字列でない・`id_kind`/`id` が無い)は**その行だけ捨てる**
37
+ * (行の中の `counts_by_month` は均さない=正本と同じ値を渡す)
38
+ *
39
+ * ## 🔴 時計を中で読まない
40
+ *
41
+ * `now` は引数(ビルドの `generatedAt`)。半減期が全員共通なので、全ての月が過去なら `now` を
42
+ * 動かしても順は変わらないが、先付けの月(経過日数 0 に切り上げる)があると変わる。
43
+ */
44
+ const promises_1 = __importDefault(require("node:fs/promises"));
45
+ const node_path_1 = __importDefault(require("node:path"));
46
+ /** 帳簿の置き場(engine `scripts/engagement/keys.mjs` の `LEDGER_PATH`) */
47
+ exports.ENGAGEMENT_LEDGER_PATH = ".cortex/gold-member-engagement.json";
48
+ /** 帳簿の版(engine `scripts/memory-index.mjs` の `LEDGER_VERSION`。違ったら読まない) */
49
+ exports.ENGAGEMENT_LEDGER_VERSION = 1;
50
+ /** 半減期(日)。engine `weights.mjs` の `HALF_LIFE_DAYS`(正本 6-B「半減期 90 日固定」) */
51
+ exports.HALF_LIFE_DAYS = 90;
52
+ /**
53
+ * 行為 1 件の重み。engine `weights.mjs` の `WEIGHTS` の写し(22 値・**表に無い `kind` は 0**)。
54
+ *
55
+ * 🔴 契約の表(fixture)は `message.posted` しか使っていないので、**ここの写し間違いは表では
56
+ * 捕まらない**。重みの番人は `test/member-engagement-parity.test.mjs` の `[重み]` が持つ。
57
+ */
58
+ exports.WEIGHTS = Object.freeze({
59
+ "message.posted": 1.0,
60
+ "message.edited": 0.3,
61
+ "message.deleted": 0,
62
+ "comment.posted": 1.0,
63
+ "comment.edited": 0.3,
64
+ "comment.deleted": 0,
65
+ "comment.resolved": 1.0,
66
+ "issue.created": 1.5,
67
+ "issue.updated": 0.3,
68
+ "issue.closed": 1.0,
69
+ "page.revised": 1.5,
70
+ "page.deleted": 0,
71
+ "commit.pushed": 1.0,
72
+ "pr.opened": 1.5,
73
+ "pr.merged": 1.5,
74
+ "pr.closed": 1.0,
75
+ "review.submitted": 1.5,
76
+ "utterance.spoken": 1.0,
77
+ "meeting.held": 1.0,
78
+ "record.confirmed": 2.0,
79
+ "record.edited": 2.0,
80
+ "record.deleted": 0,
81
+ });
82
+ const DAY_MS = 86400000;
83
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
84
+ /** 1 件の重み。語彙外は 0(engine `weightOf`) */
85
+ const weightOf = (kind) => Object.prototype.hasOwnProperty.call(exports.WEIGHTS, kind) ? exports.WEIGHTS[kind] : 0;
86
+ exports.weightOf = weightOf;
87
+ /** 月の中央日(`YYYY-MM` → その月の 15 日 12:00 UTC・engine `monthCenterMs`) */
88
+ const monthCenterMs = (month) => Date.parse(`${month}-15T12:00:00Z`);
89
+ /**
90
+ * score = Σ_月 Σ_kind w × n × 0.5^(その月の中央日からの経過日数 / 90)(engine `scoreOf` の写し)。
91
+ *
92
+ * 🔴 **足す順を正本と同じに決め打つ**(月昇順 × `kind` 昇順)。浮動小数の和は順序で最下位
93
+ * ビットが変わるので、順を変えると同点の判定が正本とずれうる。
94
+ * 未来の月は経過日数 0 に切り上げる(増幅させない)。
95
+ */
96
+ function scoreOf(countsByMonth, nowIso) {
97
+ const nowMs = Date.parse(nowIso);
98
+ let total = 0;
99
+ for (const month of Object.keys(countsByMonth || {}).sort(cmp)) {
100
+ const elapsedDays = Math.max(0, (nowMs - monthCenterMs(month)) / DAY_MS);
101
+ const decay = Math.pow(0.5, elapsedDays / exports.HALF_LIFE_DAYS);
102
+ const byKind = (countsByMonth || {})[month] || {};
103
+ for (const kind of Object.keys(byKind).sort(cmp))
104
+ total += (0, exports.weightOf)(kind) * Number(byKind[kind] || 0) * decay;
105
+ }
106
+ return total;
107
+ }
108
+ /** engine `unresolvedPersonOf`(順位に混ぜる鍵。画面には出ない) */
109
+ const unresolvedPersonOf = (u) => `unresolved:${String(u.id_kind)}:${String(u.id)}`;
110
+ /**
111
+ * 並び(engine `assignTiers(...).order` の写し)。**score 降順・同点は `person` 昇順。**
112
+ *
113
+ * 名簿に無い発言者(`unresolved:`)も**順位には混ぜる**(正本と同じ母集団で並べる)。
114
+ * 画面に出ないので位置が空くだけで、名簿の人どうしの前後は変わらない。
115
+ */
116
+ function engagementOrder(ledger, nowIso) {
117
+ const entries = [
118
+ ...ledger.people.map((p) => ({ person: p.person, counts: p.counts_by_month || {} })),
119
+ ...ledger.unresolved.map((u) => ({ person: unresolvedPersonOf(u), counts: u.counts_by_month || {} })),
120
+ ];
121
+ const scores = new Map();
122
+ for (const e of entries)
123
+ scores.set(e.person, scoreOf(e.counts, nowIso));
124
+ return entries
125
+ .slice()
126
+ .sort((a, b) => (scores.get(b.person) - scores.get(a.person)) || cmp(a.person, b.person))
127
+ .map((e) => e.person);
128
+ }
129
+ /**
130
+ * 帳簿の生テキストを読む。**読めない・使えないときは `null`**(=並べ替えない)。
131
+ *
132
+ * 規律は engine の目次と同じ(`parseLedgerText` → `memberRows` の行の篩 → 使える行 0 件なら
133
+ * 本式と名乗らない)。
134
+ */
135
+ function parseEngagementLedger(text) {
136
+ if (text === null || text === undefined || String(text).trim() === "")
137
+ return null;
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(String(text));
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
146
+ return null;
147
+ const obj = parsed;
148
+ if (Number(obj.version) !== exports.ENGAGEMENT_LEDGER_VERSION)
149
+ return null;
150
+ const rowsOf = (value) => (Array.isArray(value) ? value : []).filter((v) => Boolean(v) && typeof v === "object");
151
+ const peopleRows = rowsOf(obj.people);
152
+ const unresolvedRows = rowsOf(obj.unresolved);
153
+ // 使える行の数は篩う前で数える(目次の `usableRows` と同じ線)。篩ったあとに 0 人になっても
154
+ // 画面は元の並びのまま(全員が「帳簿にいない人」になるだけ)なので、結果はどちらでも同じ
155
+ if (peopleRows.length + unresolvedRows.length === 0)
156
+ return null;
157
+ // 🔴 **`counts_by_month` は均さずにそのまま渡す**(実装レビュー 🟡1)。文字列などの壊れた値を
158
+ // ここで `{}` に直すと、正本(`p.counts_by_month || {}` をそのまま `scoreOf` に渡す)と
159
+ // 並びが食い違う。壊れた値で並びが崩れるなら、直すのは正本の側(申し送り)
160
+ return {
161
+ people: peopleRows
162
+ .filter((p) => typeof p.person === "string" && p.person !== "")
163
+ .map((p) => ({ person: p.person, counts_by_month: p.counts_by_month })),
164
+ unresolved: unresolvedRows
165
+ .filter((u) => u.id_kind != null && u.id != null)
166
+ .map((u) => ({ id_kind: u.id_kind, id: u.id, counts_by_month: u.counts_by_month })),
167
+ };
168
+ }
169
+ /** 案件リポから帳簿を読む(無ければ `null`・例外にしない) */
170
+ async function readEngagementLedger(repoRoot) {
171
+ let text;
172
+ try {
173
+ text = await promises_1.default.readFile(node_path_1.default.join(repoRoot, exports.ENGAGEMENT_LEDGER_PATH), "utf8");
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ return parseEngagementLedger(text);
179
+ }
180
+ /**
181
+ * 関係者の記録を帳簿の並びに並べ替える。
182
+ *
183
+ * - 帳簿が `null` → **同じ配列をそのまま返す**(1 バイトも触らない)
184
+ * - 帳簿にいる人(`member:<id>` = 記録の `id`)は `order` の位置順
185
+ * - 帳簿にいない人は**末尾**へ、**元の並びのまま**(安定)
186
+ * - 🔴 **確認済みかどうか(`status`)は見ない**(#690 と同じ・オーナー決定 2026-09-21
187
+ * 「アクティブ、ドラフト関係なく」)。受け取りもしない
188
+ *
189
+ * @param idOf 記録の `id`(frontmatter の `id`。帳簿の `person` と同じ綴り)
190
+ */
191
+ function orderMembersByEngagement(records, idOf, ledger, nowIso) {
192
+ if (!ledger || !records.length)
193
+ return records;
194
+ const position = new Map();
195
+ engagementOrder(ledger, nowIso).forEach((person, i) => {
196
+ if (!position.has(person))
197
+ position.set(person, i);
198
+ });
199
+ return records
200
+ .map((r, i) => ({ r: r, i: i, pos: position.get(idOf(r)) }))
201
+ .sort((a, b) => {
202
+ if (a.pos != null && b.pos != null && a.pos !== b.pos)
203
+ return a.pos - b.pos;
204
+ if ((a.pos == null) !== (b.pos == null))
205
+ return a.pos == null ? 1 : -1;
206
+ return a.i - b.i;
207
+ })
208
+ .map((s) => s.r);
209
+ }
@@ -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
  *
@@ -47,7 +51,7 @@ const commit_classify_1 = require("./commit-classify");
47
51
  exports.LEDGER_VERSION = 1;
48
52
  /** 帯を出す区画。**用語とメンバーだけ**(Decisions は積み上げ・Rules は便 13) */
49
53
  const BAND_SECTION_RE = /^Cortex\/(?:Glossary|Members)\/records\/[^/]+\.md$/;
50
- /** 並びを変える区画。**用語集だけ**(メンバーの並びは便 14) */
54
+ /** 並びを変える区画。**用語集だけ**(関係者の並びは関与の帳簿で `engagement.ts` が決める・便 14b) */
51
55
  const ORDER_SECTION_RE = /^Cortex\/Glossary\/records\/[^/]+\.md$/;
52
56
  /** ACT-R base-level の減衰(#432 裁定 5-5: d = 0.5) */
53
57
  const ACTR_DECAY = 0.5;
@@ -489,7 +493,7 @@ function rederivedBandOf(recordPath, status, ledgers, identities, verdict, miss)
489
493
  * - 活性度を持つレコードが 1 件も無い区画は **1 バイトも触らない**(帳簿が無い案件は従来順)
490
494
  * - 活性度を知らないレコードは**末尾**へ、**元の並びのまま**(安定)
491
495
  * - 同じ活性度なら元の並び(日付降順 → タイトル昇順がそのまま残る)
492
- * - 用語集だけ(メンバーの並びは便 14・Decisions と Rules は対象外)
496
+ * - 用語集だけ(関係者の並びは `engagement.ts`・便 14b。Decisions と Rules は対象外)
493
497
  *
494
498
  * ℹ️ `ledgers` と `statusOf` は**受け取り続ける**。`ledgers` は活性度のために要る。
495
499
  * `statusOf` は並びに使わなくなったが、**引数から落とすと「群分けを足し戻す」退行が
@@ -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
+ }
package/dist/render.js CHANGED
@@ -4971,6 +4971,25 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4971
4971
  border: 1px solid rgba(194,94,63,0.95); white-space: nowrap;
4972
4972
  }
4973
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; }
4974
4993
  /* 押したあとの帯。draft(黄・要対応)と入れ替わるので、済んだと分かる緑にする */
4975
4994
  .promoted-note {
4976
4995
  display: flex; gap: 8px; align-items: flex-start;
@@ -6524,25 +6543,332 @@ ${calendarAddUrl.toString()}
6524
6543
  }
6525
6544
 
6526
6545
  // 状態の絞り込み。**未確認と提案ありを同じセレクトに並べる**(導線を増やさない)
6546
+ var FOLLOWUP_FILTER_LABEL = { "followup-done": "その後: 完了", "followup-open": "その後: 進行中", "followup-none": "その後: 記録なし" };
6527
6547
  function statusLabel(v) {
6548
+ if (Object.prototype.hasOwnProperty.call(FOLLOWUP_FILTER_LABEL, v)) return FOLLOWUP_FILTER_LABEL[v];
6528
6549
  return v === "draft" ? "未確認のみ" : v === "proposed" ? "提案ありのみ" : v;
6529
6550
  }
6530
- /** 一覧に出す選択肢。**該当が1件も無い状態は選択肢に出さない**(押しても0件の項目を並べない) */
6531
- function statusFilterOptions(items) {
6551
+ /**
6552
+ * 一覧に出す選択肢。**該当が1件も無い状態は選択肢に出さない**(押しても0件の項目を並べない)
6553
+ *
6554
+ * @param withFollowup 「その後: …」を足すか。**決定一覧で、帳簿を読めた案件だけ true**
6555
+ * (SITE.decisionFollowup)——帳簿の無い案件で「記録なし」を全件に当てない
6556
+ */
6557
+ function statusFilterOptions(items, withFollowup) {
6532
6558
  var opts = [];
6533
6559
  if (hasUnverified(items)) opts.push("draft");
6534
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
+ }
6535
6566
  return opts;
6536
6567
  }
6537
6568
  /**
6538
6569
  * 絞り込みの判定。**"proposed" は frontmatter の status ではない**ので、
6539
6570
  * rstatus との比較に混ぜない(混ぜると status: proposed の記録だけが引っかかる)
6571
+ *
6572
+ * 「その後: …」(followup-*)は**決定一覧だけ**の絞り込み。withFollowup が無い一覧
6573
+ * (用語集など・帳簿の無い案件)では絞らない——タブを移っても全件が消えないように
6540
6574
  */
6541
- function matchesStatusFilter(rec) {
6575
+ function matchesStatusFilter(rec, withFollowup) {
6542
6576
  if (!listState.status) return true;
6543
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;
6544
6579
  return rec.rstatus === listState.status;
6545
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
+
6546
6872
  // 未確認が1件も無い一覧では絞り込みを出さない
6547
6873
  function hasUnverified(items) {
6548
6874
  return (items || []).some(function (r) { return r.rstatus === "draft"; });
@@ -17384,7 +17710,7 @@ ${calendarAddUrl.toString()}
17384
17710
  filters.appendChild(buildSelect("すべての期間", SITE.months, listState.month, function (v) { listState.month = v; listState.page = 1; drawCards(); }, monthLabel));
17385
17711
  }
17386
17712
  filters.appendChild(buildSelect("すべてのカテゴリー", SITE.categories, listState.category, function (v) { listState.category = v; listState.page = 1; drawCards(); }));
17387
- var statusOptsD = statusFilterOptions(visibleRecords(SITE.decisions));
17713
+ var statusOptsD = statusFilterOptions(visibleRecords(SITE.decisions), !!SITE.decisionFollowup);
17388
17714
  if (statusOptsD.length) {
17389
17715
  filters.appendChild(buildSelect("すべての状態", statusOptsD, listState.status, function (v) { listState.status = v; listState.page = 1; drawCards(); }, statusLabel));
17390
17716
  }
@@ -17417,7 +17743,7 @@ ${calendarAddUrl.toString()}
17417
17743
  var items = all.filter(function (d) {
17418
17744
  if (listState.month && d.date.slice(0, 7) !== listState.month) return false;
17419
17745
  if (listState.category && d.category !== listState.category) return false;
17420
- if (!matchesStatusFilter(d)) return false;
17746
+ if (!matchesStatusFilter(d, !!SITE.decisionFollowup)) return false;
17421
17747
  if (q) {
17422
17748
  var hay = (d.title + " " + d.summary + " " + d.searchText + " " + d.deciders.join(" ")).toLowerCase();
17423
17749
  if (hay.indexOf(q) === -1) return false;
@@ -17443,6 +17769,7 @@ ${calendarAddUrl.toString()}
17443
17769
  var meta = el("div", { class: "card-meta" }, [
17444
17770
  draftBadge(d),
17445
17771
  proposalBadge(d),
17772
+ followupBadge(d),
17446
17773
  latestBadge(d),
17447
17774
  el("span", { class: "mono", text: d.date || "----" }),
17448
17775
  catBadge(d.category),
@@ -19035,6 +19362,10 @@ ${calendarAddUrl.toString()}
19035
19362
  var pr = proposalsSection(d, "Decisions");
19036
19363
  if (pr) app.appendChild(pr);
19037
19364
 
19365
+ // 決定の「その後」(#705)。提案の節の後・要約の前。**出すものが無ければ要素を作らない**
19366
+ var fu = followupSection(d);
19367
+ if (fu) app.appendChild(fu);
19368
+
19038
19369
  if (d.summary) {
19039
19370
  app.appendChild(el("div", { class: "section-label", text: "要約" }));
19040
19371
  app.appendChild(el("div", { class: "summary-box", text: d.summary }));
package/dist/repo.js CHANGED
@@ -165,8 +165,10 @@ const PATHSPEC_MATCH = /^Cortex\/.*\/records\/.*\.md$/;
165
165
  const RECORD_PATHSPEC = "Cortex/*/records/*.md";
166
166
  function classifyCommit(id) {
167
167
  const subject = id.subject || "";
168
- // 1. 人の操作の代理(infra/lambda/intake/logic.mjs の promoteCommitMessage / deleteCommitMessage)
169
- if (/^(Gold確認|レコード削除)[::]/.test(subject))
168
+ // 1. 人の操作の代理(infra/lambda/intake/logic.mjs の promoteCommitMessage / deleteCommitMessage /
169
+ // followupCommitMessage)。🔴 **intake に件名を足したらここにも足す**(無いと bot 名義の
170
+ // コミットとして「AIがこの記録を書き換えました」に倒れる・#705 の実装レビュー R1)
171
+ if (/^(Gold確認|レコード削除|決定のその後)[::]/.test(subject))
170
172
  return "human";
171
173
  // 2. データ構造の引っ越し。実データの件名は「エンジンマイグレーションを適用(schema_version更新)」。
172
174
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.79",
3
+ "version": "0.12.81",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,