@takagaki/cortex-decisions-viewer 0.12.72 → 0.12.74

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
@@ -56,6 +56,7 @@ const gray_matter_1 = __importDefault(require("gray-matter"));
56
56
  const marked_1 = require("marked");
57
57
  const sanitize_html_1 = __importDefault(require("sanitize-html"));
58
58
  const repo_1 = require("./repo");
59
+ const rederivation_1 = require("./rederivation");
59
60
  // 直近のGold昇格で追加されたレコードのパス集合。レコード生成の各所から参照するのでモジュールスコープに置く
60
61
  // (引数で引き回すと呼び出し側の改修が広範囲になる)。build() の冒頭で1回だけ解決する。
61
62
  let LATEST_PATHS = new Set();
@@ -661,6 +662,31 @@ async function readFleetSources(repoRoot) {
661
662
  }
662
663
  return { externalSources, slackWorkspaces, internalSources, pipelines, capabilities, fleetGeneratedAt, secretSources, secretSpaces, checks, githubOwners, backlogTarget, tools };
663
664
  }
665
+ /**
666
+ * 区画のレコードに帯を載せ、用語集の並びを活性度にする(#665 便 12b)。
667
+ *
668
+ * 🔴 **帳簿が無い案件では、渡された配列をそのまま返す**(帯のキーも付かない・並びも従来のまま)。
669
+ * 44 案件のうち帳簿を持つのは栓 `rederive` を入れた案件だけなので、ここが「何もしない」に
670
+ * 倒れることが**既定の経路**である。
671
+ *
672
+ * 🔴 **突き合わせはビルド時にここで閉じる**(`proposals` と同じ)。画面側でパスを組み立て直すと、
673
+ * `repoPathOf` が `editUrl` 由来なので **`repoBaseUrl` が無い案件で帯が黙って消える**。
674
+ */
675
+ function attachRederived(records, recordsRel, ctx, nowIso) {
676
+ if (!ctx)
677
+ return records;
678
+ const pathOf = (r) => (recordsRel ? `${recordsRel}/${r.fileName}` : r.fileName);
679
+ // 🔴 **「確認済み」は `status` だけで決まる**(#667)。帯も並びも同じ `rstatus` を見る
680
+ // ——ここで別のものを渡すと、画面の札と食い違う(「未確認」の札が付いた記録に帯が出る)
681
+ const statusOf = (r) => r.rstatus;
682
+ for (const r of records) {
683
+ const band = (0, rederivation_1.rederivedBandOf)(pathOf(r), statusOf(r), ctx.ledgers, ctx.identities, ctx.miss);
684
+ // **帯が立たないレコードにはキーごと付けない**(空の器を通さない)
685
+ if (band)
686
+ r.rederived = band;
687
+ }
688
+ return (0, rederivation_1.orderByActivity)(records, pathOf, statusOf, ctx.ledgers, nowIso);
689
+ }
664
690
  /**
665
691
  * AIからの更新提案(`.cortex/gold-proposals.json`)を読む。
666
692
  *
@@ -1875,6 +1901,20 @@ async function build(opts) {
1875
1901
  // 同一プロセスで2回ビルドしても前回の値が残らないよう、毎回入れ替える
1876
1902
  PROPOSALS_BY_PATH = await readGoldProposals(repoRoot);
1877
1903
  PROPOSAL_PATHS_MATCHED = new Set();
1904
+ // 再導出の 3 帳簿(#665 便 12b)。**帳簿が無い案件では帯も並び替えも作らない**ので、
1905
+ // 読めたときだけ git の素性を取りに行く(帳簿を持たない 44 案件で git の呼び出しを増やさない)
1906
+ const ledgers = await (0, rederivation_1.readRederivationLedgers)(repoRoot);
1907
+ const rederive = ledgers.confirmation.size || ledgers.activity.size
1908
+ ? {
1909
+ ledgers: ledgers,
1910
+ // 🔴 **`repoRoot` で走らせる**(`collectAiChangeHistory` の `process.cwd()` に倣わない)。
1911
+ // あちらは「リポジトリルートで実行すること」が前提の既存の制約だが、こちらは repoRoot を
1912
+ // 既に知っている(帳簿をそこから読んでいる)。Decisions ディレクトリから CLI を叩いた
1913
+ // ときも pathspec が当たり、帯が黙って消えない
1914
+ identities: ledgers.confirmation.size ? (0, repo_1.collectGoldCommitIdentities)(repoRoot) : new Map(),
1915
+ miss: { unresolved: new Set() },
1916
+ }
1917
+ : null;
1878
1918
  const decisions = [];
1879
1919
  for (const fileName of mdFiles) {
1880
1920
  const raw = await node_fs_1.promises.readFile(path.join(dirAbs, fileName), "utf8");
@@ -1885,6 +1925,8 @@ async function build(opts) {
1885
1925
  let homeGrounding;
1886
1926
  let homeUpdatedAt;
1887
1927
  const sections = [];
1928
+ // グラフに渡す区画(**並べ替える前**の配列を持つ。上の sections とレコードの実体は同じ)
1929
+ const graphSections = [];
1888
1930
  if (knowledgeRoot) {
1889
1931
  try {
1890
1932
  const raw = await node_fs_1.promises.readFile(path.join(knowledgeRoot, "Home.md"), "utf8");
@@ -1915,12 +1957,22 @@ async function build(opts) {
1915
1957
  continue;
1916
1958
  const recordsDir = path.join(knowledgeRoot, e.name, "records");
1917
1959
  const records = await loadGenericRecords(recordsDir, exclude, repo.baseUrl, repo.branch);
1960
+ // 「人が確認した版から変わりました」の帯と、用語集の並び(#665 便 12b)。
1961
+ // **帳簿を持たない案件では 1 件も付かず、並びもそのまま**(下の attachRederived)
1962
+ const ordered = attachRederived(records, toRepoRelative(recordsDir), rederive, opts.generatedAt);
1918
1963
  // レコード0件でもタブは出す(空状態から「+AIで登録」できる導線になる)。
1919
1964
  // records/ 自体が無いディレクトリ(その区画が未導入の案件)はタブにしない
1920
- if (records.length || (await isDirectory(recordsDir)))
1921
- sections.push({ name: e.name, records });
1965
+ if (records.length || (await isDirectory(recordsDir))) {
1966
+ sections.push({ name: e.name, records: ordered });
1967
+ // 🔴 **グラフには並べ替える前の配列を渡す**(実装レビュー)。`computeLayout` は
1968
+ // レコードの並びを入力に初期座標を決めるので、一覧の並びが活性度で動くと
1969
+ // **点と線は同じなのに地図の絵が毎晩組み変わる**(cortex-context で 1414/1414 の
1970
+ // 座標が動くのを実測)。並びが効くのは一覧だけ、という約束をここで固定する
1971
+ graphSections.push({ name: e.name, records: records });
1972
+ }
1922
1973
  }
1923
1974
  sections.sort((a, b) => a.name.localeCompare(b.name, "ja"));
1975
+ graphSections.sort((a, b) => a.name.localeCompare(b.name, "ja"));
1924
1976
  }
1925
1977
  // 新しい順(date降順→id降順)
1926
1978
  decisions.sort((a, b) => {
@@ -1934,13 +1986,23 @@ async function build(opts) {
1934
1986
  // **突き合わなかった提案の件数をビルドログに出す。** path が1文字違うだけで
1935
1987
  // 画面から静かに消えるので、出さないと「提案が出ない」の原因に辿り着けない。
1936
1988
  // 表示からは落とす(どのレコードの話か分からない提案は、出しても行動につながらない)
1989
+ // 🔴 **帯が静かに消える経路を 1 行残す(設計レビュー 🟡10)。** `changed_by` の sha が
1990
+ // git の窓(fetch-depth 2000)の外にあると、人か機械か見分けられない=帯が立たない。
1991
+ // 画面には出さない(利用者が動ける症状ではない)が、ログに無いと「帯が出ない」の原因を
1992
+ // 「夜間がまだ書いていない」と切り分けられない
1993
+ if (rederive && rederive.miss.unresolved.size) {
1994
+ const shas = [...rederive.miss.unresolved];
1995
+ console.warn(`gold-confirmation.json: 変更のコミット ${shas.length} 件を git の履歴で引けませんでした` +
1996
+ `(浅い clone か履歴の窓の外。人か機械か見分けられないので帯は出しません): ` +
1997
+ shas.slice(0, 3).map((s) => s.slice(0, 8)).join(", ") + (shas.length > 3 ? " ほか" : ""));
1998
+ }
1937
1999
  const unmatched = [...PROPOSALS_BY_PATH.keys()].filter((k) => !PROPOSAL_PATHS_MATCHED.has(k));
1938
2000
  if (unmatched.length) {
1939
2001
  console.warn(`gold-proposals.json: レコードに突き合わない提案が ${unmatched.length} 件ありました(表示しません): ` +
1940
2002
  unmatched.slice(0, 5).join(", ") + (unmatched.length > 5 ? " ほか" : ""));
1941
2003
  }
1942
2004
  const contentEntries = await scanContent(repoRoot, knowledgeRoot ? path.basename(knowledgeRoot) : null, repo.baseUrl, repo.branch);
1943
- const graph = buildGraph(decisions, home, sections, contentEntries, targetUrls, repo.baseUrl, repo.branch, refs);
2005
+ const graph = buildGraph(decisions, home, graphSections, contentEntries, targetUrls, repo.baseUrl, repo.branch, refs);
1944
2006
  computeLayout(graph); // 各ノードに x/y を書き込む(クライアントはこれを初期位置に使いウォームアップ省略)
1945
2007
  // データソースと自動化(fleet-status.json 由来)。無ければ undefined でセクション非表示。
1946
2008
  const fleet = await readFleetSources(repoRoot);
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ /**
3
+ * コミットを **人/機械/不明** の 3 値に分ける(#665 便 12b・#432 裁定 5-4 / 5-7)。
4
+ *
5
+ * ## 🔴 これは写しで、正本は engine にある
6
+ *
7
+ * 正本: cortex-engine `scripts/observe/commit-classify.mjs`。表(入力 → 3 値):
8
+ * cortex-engine `test/observe-commit-classify.test.mjs` の 12 行。
9
+ * **写すのはこの表で、実装を書き写すのではない**——同じ入力を両方に流して突き合わせる
10
+ * (`connection-state-keys.mjs` の写しと同じ流儀)。tools 側の表は
11
+ * `test/fixtures/commit-classify-contract.json` にあり、`commit-classify-parity.test.mjs`
12
+ * が全行を流す。**engine が判定を変えたらこの fixture を差し替える**(テストは書き換えない)。
13
+ *
14
+ * 🔴 **tools 側で規則を足さない。** 「再導出:」のような件名を足したくなっても、足す先は
15
+ * engine の正本(写しに足すと、正本と写しが逆向きにずれて契約テストが意味を失う)。
16
+ *
17
+ * ## なぜ `repo.ts` の `classifyCommit` と別に置くのか
18
+ *
19
+ * `repo.ts` のものは**別の問い**に答える 3 値(human / migration / ai)で、画面の
20
+ * 「AI による最近の変更」が使っている。あれを揃えるのは #646 の便で、この便では触らない
21
+ * ——揃えると 44 案件の既存の画面(履歴の節)が同時に変わる。ここは**新しい帯の条件だけ**が
22
+ * 使う判定で、**既定は「不明」**(証拠が無ければ倒さない)。
23
+ *
24
+ * ## 裁定(2026-09-17・#432 のコメント 5-4)
25
+ *
26
+ * > Claude Code 経由の push も「人が書いた」に数える(責任の所在で決める。人の名義で本人が
27
+ * > 結果を見て push している)。件数の閾値は設けない。「機械」に数えるのは**夜間処理・
28
+ * > マイグレーション・一括取り込みだけ**。
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.DELETE_SUBJECT_RE = exports.CONFIRM_SUBJECT_RE = exports.MIGRATION_REASON = void 0;
32
+ exports.looksLikeMigrationSubject = looksLikeMigrationSubject;
33
+ exports.operatedByKind = operatedByKind;
34
+ exports.classifyGoldCommit = classifyGoldCommit;
35
+ exports.isMachineRewrite = isMachineRewrite;
36
+ /**
37
+ * 機械の名義(**この 3 経路だけ**が機械)。`why` は `reason` にそのまま出る。
38
+ *
39
+ * 🔴 **`reason` の綴りは帯の条件が読む**(マイグレーションだけは帯を立てない・#665)。
40
+ * 綴りを変えるなら正本と fixture と `MIGRATION_REASON` を同時に見ること。
41
+ */
42
+ const MACHINE_SUBJECTS = [
43
+ { re: /^自動追記/, why: "夜間処理" },
44
+ { re: /^自動生成/, why: "夜間処理" },
45
+ { re: /^週次整理/, why: "夜間処理(週次)" },
46
+ { re: /^(?:マイグレーション|migrate:|chore\(migrate\))/i, why: "マイグレーション" },
47
+ { re: /^一括取り込み/, why: "一括取り込み" },
48
+ ];
49
+ /** マイグレーションの `reason`。**帯を立てない側に倒す鍵の 1 枚目**(#665 決定表 4d) */
50
+ exports.MIGRATION_REASON = "マイグレーション";
51
+ /**
52
+ * 🔴 **「引っ越し」の綴りの 2 枚目**(`repo.ts:230` の写し・設計レビュー 🔴1)。
53
+ *
54
+ * 正本の `MACHINE_SUBJECTS` のマイグレーションの行は件名の**頭**を `マイグレーション` /
55
+ * `migrate:` / `chore(migrate)` で見るが、**艦隊の実際の件名は
56
+ * `エンジンマイグレーションを適用(schema_version更新)`** で、頭が「エンジン」なので
57
+ * 当たらない(実測: レコードを触った 127 件のうち 93 件が「機械の名義」の枝に落ち、
58
+ * engine の `isMigration()` では 0 件になる=`changed_by` から除かれていない)。
59
+ *
60
+ * この 1 枚が無いと、**引っ越しの翌朝に確認済みのレコード全件へ帯が出る**——実データの
61
+ * コミットの 53.6% が引っ越しで、中身は 1 文字も変わっていない。
62
+ *
63
+ * ⚠️ これは**正本に足すべき規則**(申し送り済み)。正本が拾うようになってもこの枚は
64
+ * 害にならない(両方が「引っ越し」と言うだけ)ので、**消すのは正本が実件名を拾ったあと**。
65
+ * 綴りは画面の「AI による最近の変更」が使う `repo.ts` の判定とそろえてある——ずれると
66
+ * 「履歴からは隠れているのに帯は立つ」という食い違った 1 ページができる(🔴7)。
67
+ */
68
+ const MIGRATION_SUBJECT_RE = /^(エンジン)?マイグレーション|^スキーマ移行|^schema[_-]?version|^(apply\s+)?[a-z ]*migration/i;
69
+ /** 件名が「データ構造の引っ越し」の規約か(`repo.ts` の綴り) */
70
+ function looksLikeMigrationSubject(subject) {
71
+ return MIGRATION_SUBJECT_RE.test(String(subject || ""));
72
+ }
73
+ /** 機械の名義(名前)。`…[bot]` は GitHub のアプリ名義の綴り */
74
+ const MACHINE_NAME_RE = /(?:^|\s)(?:github-actions|dependabot|renovate)(?:\[bot\])?$|\[bot\]$/i;
75
+ /**
76
+ * 「人の名義とは言えない」メール。`[bot]@users.noreply.github.com` は機械の名義だが、
77
+ * `actions@github.com` のような汎用の名義は「誰が押したか分からない」=**不明**に倒す。
78
+ */
79
+ const BOT_EMAIL_RE = /\[bot\]@users\.noreply\.github\.com$/i;
80
+ const ANONYMOUS_EMAIL_RE = /^(?:actions@github\.com|noreply@github\.com|no-reply@github\.com)$/i;
81
+ /** 人が Gold を直した印(tools の intake が書く件名。`repo.ts` と同じ綴り) */
82
+ exports.CONFIRM_SUBJECT_RE = /^Gold確認[::]/;
83
+ exports.DELETE_SUBJECT_RE = /^(?:レコード削除|Gold削除)/;
84
+ /** `Operated-By:` trailer の値から**経路だけ**を取る(値そのものは持ち回さない・PII) */
85
+ function operatedByKind(trailerValue) {
86
+ const v = String(trailerValue || "").trim();
87
+ if (!v)
88
+ return "none";
89
+ return /^basic:/.test(v) ? "basic" : "portal";
90
+ }
91
+ /**
92
+ * コミット 1 件を 3 値に分ける(正本と同じ順序・同じ規則)。
93
+ *
94
+ * 名前を `classifyCommit` にしない——`repo.ts` に同名で**別の問い**に答える関数があり、
95
+ * 取り違えると画面の履歴の節と帯の条件が静かに入れ替わる。
96
+ */
97
+ function classifyGoldCommit({ authorName = "", authorEmail = "", committerName: _committerName = "", subject = "", body = "", } = {}) {
98
+ const trailer = (String(body).match(/^Operated-By:\s*(.+)$/m) || [])[1] || "";
99
+ const kind = operatedByKind(trailer);
100
+ // **検証済みか**は名乗り券の有無だけで決める(`basic:` は券が無いときの既定=自己申告)
101
+ const verified = kind === "portal";
102
+ const confirmer = (String(subject).match(exports.CONFIRM_SUBJECT_RE)
103
+ ? String(subject).replace(exports.CONFIRM_SUBJECT_RE, "").trim()
104
+ : null) || null;
105
+ const out = (by, reason) => ({
106
+ by,
107
+ verified: by === "human" ? verified : false,
108
+ reason,
109
+ operatedByKind: kind,
110
+ confirmer,
111
+ });
112
+ // ---- 1. 機械(夜間処理・マイグレーション・一括取り込みだけ) ----
113
+ const machineSubject = MACHINE_SUBJECTS.find((m) => m.re.test(String(subject)));
114
+ if (machineSubject)
115
+ return out("machine", machineSubject.why);
116
+ if (MACHINE_NAME_RE.test(String(authorName).trim()))
117
+ return out("machine", "機械の名義");
118
+ if (BOT_EMAIL_RE.test(String(authorEmail).trim()))
119
+ return out("machine", "機械の名義(メール)");
120
+ // ---- 2. 人(**Claude Code 経由の push もここ**・裁定 5-4) ----
121
+ // 🔴 `Co-Authored-By: Claude` / `Generated with Claude Code` を**機械の証拠にしない**。
122
+ // 人の名義で本人が結果を見て push しているので、責任の所在は人にある
123
+ if (String(subject).match(exports.CONFIRM_SUBJECT_RE))
124
+ return out("human", "人が確認した(件名)");
125
+ if (exports.DELETE_SUBJECT_RE.test(String(subject)))
126
+ return out("human", "人が消した(件名)");
127
+ if (kind !== "none")
128
+ return out("human", "画面からの操作(Operated-By)");
129
+ const name = String(authorName).trim();
130
+ const email = String(authorEmail).trim();
131
+ if (name && email && !ANONYMOUS_EMAIL_RE.test(email))
132
+ return out("human", "人の名義の push");
133
+ // ---- 3. 既定は不明(裁定 5-7) ----
134
+ // 名義が空・汎用の名義(`actions@github.com`)は「誰が押したか分からない」
135
+ return out("unknown", "名義から人か機械か決められない");
136
+ }
137
+ /**
138
+ * **帯を立てる側の機械の書き直しか**(#665 決定表 4c・4d)。
139
+ *
140
+ * 機械の 3 経路(夜間処理・引っ越し・一括取り込み)のうち**引っ越しだけを外す**。実データの
141
+ * コミットの 53.6% が引っ越しで、中身は 1 文字も変わっていない(`repo.ts:199-207` の実測)
142
+ * ので、これで帯を立てると全レコードに帯が出る。engine も `changed_by` を作るときに
143
+ * `isMigration()` を当てているが(`history.mjs:145`)、**それは正本の綴りしか拾わない**うえに
144
+ * **古い帳簿・作り直し前の帳簿を読む夜がある**ので、写し側でも外す。
145
+ *
146
+ * 🔴 **判定は 2 枚の OR**(設計レビュー 🔴1): 正本の `reason` と `repo.ts` の綴り。
147
+ * **どちらかが「引っ越し」と言えば立てない**(片方だけに頼ると実件名が素通りする)。
148
+ */
149
+ function isMachineRewrite(c, subject) {
150
+ if (c.by !== "machine")
151
+ return false;
152
+ if (c.reason === exports.MIGRATION_REASON)
153
+ return false;
154
+ if (looksLikeMigrationSubject(subject))
155
+ return false;
156
+ return true;
157
+ }
@@ -0,0 +1,412 @@
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.emptyLedgers = exports.LEDGER_VERSION = void 0;
7
+ exports.isConfirmedStatus = isConfirmedStatus;
8
+ exports.parseLedger = parseLedger;
9
+ exports.readRederivationLedgers = readRederivationLedgers;
10
+ exports.parseInjectedNow = parseInjectedNow;
11
+ exports.activityScore = activityScore;
12
+ exports.rederivedBandOf = rederivedBandOf;
13
+ exports.orderByActivity = orderByActivity;
14
+ /**
15
+ * 再導出の 3 帳簿を読み、帯「人が確認した版から変わりました」と用語集の並びを作る(#665 便 12b)。
16
+ *
17
+ * | 帳簿 | 何を持つか | 書き手 |
18
+ * | --- | --- | --- |
19
+ * | `.cortex/gold-confirmation.json` | レコードごとの「人が確認した版」と、そこから変わったか(**sha だけ**) | 夜間(全置換・git から作り直せる) |
20
+ * | `.cortex/gold-rederivation.json` | 書き直しの根拠の件数・shadow の「書くつもりだった差分」 | 夜間(追記・区画ごとの節) |
21
+ * | `.cortex/gold-activity.json` | 「最後に見た日」と触れた日の列(+観測の到達点 `cursor`) | 夜間(追記・剪定つき) |
22
+ *
23
+ * 形の正本は cortex-engine `scripts/rederive/ledgers.mjs` と `docs/cortex-derived-files.md`。
24
+ *
25
+ * ## 🔴 帳簿が無い・壊れている・古い形の案件は「今までと 1 バイトも同じ画面」
26
+ *
27
+ * 44 案件に配られる画面で、帳簿を持つのは栓 `rederive` を入れた案件だけ(既定オフ)。
28
+ * **読めないものは空**として正常に続ける(`readGoldProposals` と同じ規律)。空のときは
29
+ * 帯の器も並び替えも一切作らない——「知らないことは、キーが無いことで表す」。
30
+ *
31
+ * ## 🔴 生成時刻を持ち込まない・score を焼かない
32
+ *
33
+ * 活性度(順位)は**帳簿の日付の列からビルド時に計算する**(#432 問い 5-D)。時計は
34
+ * 引数で注入する(`generatedAt`)——`new Date()` を中で読むと、固定日付のテストが
35
+ * 実時間と比べられて**ある日から赤くなる**(作業規約「日付爆弾」)。
36
+ */
37
+ const promises_1 = __importDefault(require("node:fs/promises"));
38
+ const node_path_1 = __importDefault(require("node:path"));
39
+ const commit_classify_1 = require("./commit-classify");
40
+ /** 帳簿の版(違ったら空として扱う・engine `LEDGER_VERSION`) */
41
+ exports.LEDGER_VERSION = 1;
42
+ /** 帯を出す区画。**用語とメンバーだけ**(Decisions は積み上げ・Rules は便 13) */
43
+ const BAND_SECTION_RE = /^Cortex\/(?:Glossary|Members)\/records\/[^/]+\.md$/;
44
+ /** 並びを変える区画。**用語集だけ**(メンバーの並びは便 14) */
45
+ const ORDER_SECTION_RE = /^Cortex\/Glossary\/records\/[^/]+\.md$/;
46
+ /** ACT-R base-level の減衰(#432 裁定 5-5: d = 0.5) */
47
+ const ACTR_DECAY = 0.5;
48
+ /**
49
+ * 経過日数の下限(日)。**同じ日に触れられた行で 0 の −0.5 乗(=∞)を作らない。**
50
+ * 0.5 日=「今日触れられた」を半日前として数える(ACT-R の実装で普通に使う丸め)。
51
+ */
52
+ const MIN_ELAPSED_DAYS = 0.5;
53
+ /**
54
+ * その記録が「確認済み」か。**「AI生成・未確認」の札(`render.ts` の `draftBadge`)の裏返し**
55
+ * ——札が出ない記録が確認済み側(#667 のオーナー決定「確認済みは `status` だけで判断する」)。
56
+ *
57
+ * 🔴 **trim も小文字化もしない。** 札は `rec.rstatus === "draft"` の厳密比較なので、こちらを
58
+ * 1 文字でも緩めると「札は出ないのに未確認扱い」「札は出るのに確認済み扱い」が生まれる。
59
+ * `status` 欄が無い記録(`rstatus` が `""`)と `superseded` は**確認済み側**——札が出ないから。
60
+ *
61
+ * 🔴 **渡すのは `String()` 済みの `rstatus` だけ**(`build.ts:1048`)。生の frontmatter
62
+ * (`rec.fm.status`)を渡さない——YAML は真偽値も配列も返すので、札と同じ文字列にならない。
63
+ *
64
+ * ⚠️ **「確認済み」の門はもう 1 つある。** 削除の口の出し分けは `render.ts` の
65
+ * `humanConfirmedStatus`(`active|superseded|inactive` の列挙)で、`""` の扱いがここと逆。
66
+ * 揃えるかどうかは別の便(実データでは全 721 件が `status` を持つので今日の差は 0 件)。
67
+ */
68
+ function isConfirmedStatus(status) {
69
+ return status !== "draft";
70
+ }
71
+ const emptyLedgers = () => ({
72
+ confirmation: new Map(),
73
+ rederivation: new Map(),
74
+ activity: new Map(),
75
+ });
76
+ exports.emptyLedgers = emptyLedgers;
77
+ /**
78
+ * `version` が違う・壊れている・オブジェクトでない → **null(空として扱って続ける)**。
79
+ * engine `parseLedger` と同じ判定。
80
+ */
81
+ function parseLedger(text) {
82
+ try {
83
+ const parsed = JSON.parse(String(text || ""));
84
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
85
+ return null;
86
+ if (Number(parsed.version) !== exports.LEDGER_VERSION)
87
+ return null;
88
+ return parsed;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /** 1 本読む。無い(ENOENT)ことは警告しない(既定の状態)。壊れているときだけ 1 行残す */
95
+ async function readOne(repoRoot, file) {
96
+ let raw;
97
+ try {
98
+ raw = await promises_1.default.readFile(node_path_1.default.join(repoRoot, ".cortex", file), "utf8");
99
+ }
100
+ catch (e) {
101
+ if (e?.code !== "ENOENT") {
102
+ console.warn(`${file} を読めませんでした(帯と並びは出さずに続けます): ${String(e)}`);
103
+ }
104
+ return null;
105
+ }
106
+ const parsed = parseLedger(raw);
107
+ // **壊れている・版が違うときは 1 行残す。** 静かに 0 件になると、帯が出ない理由が
108
+ // 「夜間がまだ書いていない」のか「読めていない」のか切り分けられない
109
+ if (!parsed)
110
+ console.warn(`${file} の形式か version が想定と違います(帯と並びは出さずに続けます)`);
111
+ return parsed;
112
+ }
113
+ const asRecord = (v) => v != null && typeof v === "object" && !Array.isArray(v) ? v : null;
114
+ /**
115
+ * 3 帳簿を読む。**フィールドは明示で列挙し、素通しはしない**(`readGoldProposals` の流儀)——
116
+ * engine は単独で版を上げるので、画面が知らないキーが必ず先に入る。
117
+ */
118
+ async function readRederivationLedgers(repoRoot) {
119
+ const out = (0, exports.emptyLedgers)();
120
+ const conf = await readOne(repoRoot, "gold-confirmation.json");
121
+ const records = asRecord(conf?.records);
122
+ if (records) {
123
+ for (const [p, v] of Object.entries(records)) {
124
+ const row = asRecord(v);
125
+ const confirmed = asRecord(row?.confirmed);
126
+ if (!confirmed)
127
+ continue;
128
+ // 🔴 **基準コミットが空の行も持つ**(実装レビュー)。帯は作れない(差分も戻すも
129
+ // 指す先が無い)が、行そのものは落とさずに持つ——落とす判断は帯の側
130
+ // (`rederivedBandOf`)に 1 か所で寄せる。
131
+ // ⚠️ #667 より前は「並びが『人が確認した』事実として使う」ためでもあったが、**並びは
132
+ // もう帳簿を見ない**(確認済みかどうかは `status` だけで決まる・`orderByActivity`)。
133
+ // この帳簿の役目は「どの版に戻すか」で、「確認済みかどうか」ではない
134
+ const commit = confirmed.commit != null ? String(confirmed.commit) : "";
135
+ const changedBy = Array.isArray(row?.changed_by)
136
+ ? row.changed_by.map((s) => String(s)).filter((s) => s !== "")
137
+ : [];
138
+ out.confirmation.set(p, {
139
+ at: confirmed.at != null ? String(confirmed.at) : "",
140
+ by: confirmed.by != null ? String(confirmed.by) : "",
141
+ verified: confirmed.verified === true,
142
+ commit: commit,
143
+ changedBy: changedBy,
144
+ });
145
+ }
146
+ }
147
+ const red = await readOne(repoRoot, "gold-rederivation.json");
148
+ const redSections = asRecord(red?.sections);
149
+ if (redSections) {
150
+ for (const section of Object.values(redSections)) {
151
+ const rows = asRecord(section);
152
+ if (!rows)
153
+ continue;
154
+ for (const [p, v] of Object.entries(rows)) {
155
+ const row = asRecord(v);
156
+ if (!row)
157
+ continue;
158
+ // 根拠の件数。**数でないものは null**(「0 件」と「知らない」を言い分ける)
159
+ const n = Number(row.evidence);
160
+ out.rederivation.set(p, {
161
+ evidence: row.evidence != null && Number.isFinite(n) && n >= 0 ? Math.floor(n) : null,
162
+ commit: row.commit != null ? String(row.commit) : "",
163
+ });
164
+ }
165
+ }
166
+ }
167
+ const act = await readOne(repoRoot, "gold-activity.json");
168
+ const actSections = asRecord(act?.sections);
169
+ if (actSections) {
170
+ for (const section of Object.values(actSections)) {
171
+ const rows = asRecord(section);
172
+ if (!rows)
173
+ continue;
174
+ for (const [p, v] of Object.entries(rows)) {
175
+ const row = asRecord(v);
176
+ if (!row)
177
+ continue;
178
+ const recent = Array.isArray(row.recent)
179
+ ? row.recent.map((d) => String(d)).filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d))
180
+ : [];
181
+ const n = Number(row.n);
182
+ out.activity.set(p, {
183
+ lastSeen: row.last_seen != null ? String(row.last_seen) : "",
184
+ n: Number.isFinite(n) && n > 0 ? Math.floor(n) : 0,
185
+ recent: recent,
186
+ });
187
+ }
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ /** `YYYY-MM-DD` を「その日の正午(UTC)」として読む(時刻を持たない日付に時差を持ち込まない) */
193
+ function dayMs(date) {
194
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
195
+ if (!m)
196
+ return null;
197
+ return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12, 0, 0);
198
+ }
199
+ /**
200
+ * 注入された時計を epoch にする。
201
+ *
202
+ * 🔴 **ISO とは限らない**(実装レビュー)。`cli.ts` が作る既定の生成時刻は
203
+ * `2026-09-18 07:41 UTC` の形で、`Date.parse` が読めるのは V8 の非標準の親切に過ぎない。
204
+ * 同じ形を正規化する器が `pending-ops.ts` にもある(`pendingSiteTimeMs`)。
205
+ * ここでは**両方の形を明示で受ける**——読めない値は `null`(=並びを従来のままにする側)。
206
+ */
207
+ function parseInjectedNow(nowIso) {
208
+ const raw = String(nowIso || "").trim();
209
+ if (!raw)
210
+ return null;
211
+ const direct = Date.parse(raw);
212
+ if (Number.isFinite(direct))
213
+ return direct;
214
+ // `YYYY-MM-DD HH:MM UTC` → `YYYY-MM-DDTHH:MMZ`
215
+ const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)\s*(?:UTC|Z)?$/.exec(raw);
216
+ if (!m)
217
+ return null;
218
+ const fixed = Date.parse(`${m[1]}T${m[2]}Z`);
219
+ return Number.isFinite(fixed) ? fixed : null;
220
+ }
221
+ /**
222
+ * 活性度(ACT-R base-level)**B = ln Σ_j t_j^(−0.5)**。
223
+ *
224
+ * `t_j` は触れた日から `nowIso`(注入した時計)までの経過日数。**score は帳簿に焼かない**
225
+ * ——式を変えたときに全件の再生成が要らないように、入力(日付の列)だけを持つ(裁定 5-5)。
226
+ *
227
+ * 引ける日付が 1 つも無ければ `null`(「活性度を知らない」=並びを従来のままにする側)。
228
+ */
229
+ function activityScore(entry, nowIso) {
230
+ if (!entry)
231
+ return null;
232
+ const now = parseInjectedNow(nowIso);
233
+ if (now == null)
234
+ return null;
235
+ // 同じ日が 2 度入っていても 1 回だけ数える(engine 側でも重複は畳んでいる)
236
+ const days = [...new Set(entry.recent.length ? entry.recent : entry.lastSeen ? [entry.lastSeen] : [])];
237
+ let sum = 0;
238
+ for (const d of days) {
239
+ const ms = dayMs(d);
240
+ if (ms == null)
241
+ continue;
242
+ const elapsed = Math.max((now - ms) / 86400000, MIN_ELAPSED_DAYS);
243
+ sum += Math.pow(elapsed, -ACTR_DECAY);
244
+ }
245
+ if (sum <= 0)
246
+ return null;
247
+ const score = Math.log(sum);
248
+ // 🔴 **有限でない score は「知らない」に倒す**(設計レビュー 🔴6)。比較子が NaN を返すと
249
+ // `Array.sort` の順序は未定義で、並びが**呼ぶたびに変わる**(帳簿の壊れ方が並びの総崩れになる)。
250
+ // 未来の日付(時計のずれ・手で書いた帳簿)は上の下限で潰れるので、ここは最後の網
251
+ return Number.isFinite(score) ? score : null;
252
+ }
253
+ /**
254
+ * そのレコードに帯を立てるか(#665 の**決定表**・上から順に評価し、当たった行で終わる)。
255
+ *
256
+ * | 順 | 条件 | 結果 |
257
+ * | --- | --- | --- |
258
+ * | **0** | **その記録の `status` が `draft`(=「AI生成・未確認」の札が出る)** | **立てない**(#667) |
259
+ * | 1 | path が用語・メンバーのレコードでない | 立てない |
260
+ * | 2 | `confirmation` に行が無い/基準コミットが空/確認日が日時として読めない | 立てない |
261
+ * | 3 | `changed_by` が空 | 立てない |
262
+ * | 4 | `changed_by` の 1 件ずつに 4a〜4e を当てて**残りが 0 件** | 立てない |
263
+ * | 4a | sha が基準コミットと同じ | その sha を落とす |
264
+ * | 4b | sha が git の窓に無い | 落とす(**不明**・`miss.unresolved` に数える) |
265
+ * | 4c | 分類が「人」または「不明」 | 落とす |
266
+ * | 4d | **どちらかの綴りで引っ越し** | 落とす |
267
+ * | 4e | author 日付が確認日**以前**(epoch 比較) | 落とす |
268
+ * | 5 | 残りのうち最も新しい author 日付を「書き直した日」にする | **立てる** |
269
+ * | 6 | 根拠は、帳簿の行の `commit` が `changed_by` に入っているときだけ出す | 出す/出さない |
270
+ * | 7 | 基準コミットが削除のコミットなら「戻す」を出さない(`baseDeleted`) | — |
271
+ *
272
+ * @param status その記録の `rstatus`(`String()` 済み)。**2 番目の必須引数**にしてある——
273
+ * 末尾の任意引数にすると、渡し忘れが「行 0 の門が静かに開く」になる(設計レビュー 🔴3)
274
+ * @param miss 立たなかった理由を数える器(呼び出し側で 1 つ作って全レコードで共有する)
275
+ */
276
+ function rederivedBandOf(recordPath, status, ledgers, identities, miss) {
277
+ // 0. 「AI生成・未確認」の札が出る記録には帯を重ねない(#667)。**綴りは 1 か所**
278
+ //(ここで `status === "draft"` と直書きすると、判定が 2 つに増えて一致が崩れる)
279
+ if (!isConfirmedStatus(status))
280
+ return null;
281
+ if (!BAND_SECTION_RE.test(recordPath))
282
+ return null;
283
+ const conf = ledgers.confirmation.get(recordPath);
284
+ // 基準コミットが空の行はここで落とす(読み手は並びのために持っている)
285
+ if (!conf || !conf.commit || !conf.changedBy.length)
286
+ return null;
287
+ // 確認日が読めない帳簿では「後か前か」を言えないので立てない(順序の確かめが要)
288
+ const confirmedMs = Date.parse(conf.at);
289
+ if (!Number.isFinite(confirmedMs))
290
+ return null;
291
+ let newest = null;
292
+ /** 確認より後の**人**のコミットの最新(帳簿が前の夜のもので、基準点がまだ動いていない夜) */
293
+ let newestHuman = 0;
294
+ for (const sha of conf.changedBy) {
295
+ // 4a. 基準コミット自身が並んでいる帳簿(作り直し前・手で書いたもの)に引っかからない
296
+ if (sha === conf.commit)
297
+ continue;
298
+ const id = identities.get(sha);
299
+ if (!id) {
300
+ // 4b. 窓の外・浅い clone = 見分けられない(不明)。**画面には出さずログに数える**
301
+ //(設計レビュー 🟡10: 帯が静かに消える経路がこれ)
302
+ if (miss)
303
+ miss.unresolved.add(sha);
304
+ continue;
305
+ }
306
+ // 4e. 🔴 **確認より後であることを git の日付(epoch)で確かめる。** `changed_by` は engine が
307
+ // 「基準点より後」で作るが、帳簿は前の夜のものが残りうる(全置換に失敗した回・人が後から
308
+ // 確認して基準点が動いた回)。ここを外すと**確認より前の変更で帯が立つ**。
309
+ // ⚠️ 「後か」の真の正本は engine が見た git のトポロジ順(`history.mjs:141-145`)で、
310
+ // ここは**安全網**。author 日付は rebase・cherry-pick で前後しうるので、
311
+ // ずれたときは「立てない」側に倒れる(設計レビュー 🟡12)
312
+ const ms = Date.parse(id.at);
313
+ if (!Number.isFinite(ms) || ms <= confirmedMs)
314
+ continue;
315
+ // 4c・4d. 機械の書き直しだけを残す(件名は分類と同じものを渡す)
316
+ const classified = (0, commit_classify_1.classifyGoldCommit)({
317
+ authorName: id.authorName,
318
+ authorEmail: id.authorEmail,
319
+ subject: id.subject,
320
+ body: id.body,
321
+ });
322
+ if (!(0, commit_classify_1.isMachineRewrite)(classified, id.subject)) {
323
+ if (classified.by === "human" && ms > newestHuman)
324
+ newestHuman = ms;
325
+ continue;
326
+ }
327
+ if (!newest || ms > newest.ms)
328
+ newest = { at: id.at, ms: ms };
329
+ }
330
+ if (!newest)
331
+ return null;
332
+ // 🔴 **人が最後に言ったなら立てない。** engine は基準点を「最後に人が触ったコミット」に
333
+ // 置くので、正しい帳簿の `changed_by` に人のコミットは入らない(`history.mjs:141`)。
334
+ // だが前の夜の帳簿が残った朝には「機械の書き直し → その後の人の編集」が並びうる。
335
+ // そこで帯を立てると、**「戻す」がその人の新しい編集ごと基準コミットへ戻す**——この機能で
336
+ // いちばん重い壊れ方なので、既定(立てない側)に倒す。engine が次の夜に基準点を動かす
337
+ if (newestHuman > newest.ms)
338
+ return null;
339
+ // 6. 根拠の件数は「この帯のコミットの記録」から出す(別の夜の数字を並べない)
340
+ const red = ledgers.rederivation.get(recordPath);
341
+ const evidence = red && red.evidence != null && red.commit && conf.changedBy.includes(red.commit)
342
+ ? red.evidence
343
+ : null;
344
+ // 7. 基準コミットが削除のコミットか(窓の外なら分からないので false 側=リンクは出す。
345
+ // プロンプト側に「取れなかったときの逃げ道」を書いてある)
346
+ const base = identities.get(conf.commit);
347
+ const baseDeleted = base ? commit_classify_1.DELETE_SUBJECT_RE.test(base.subject) : false;
348
+ return {
349
+ confirmedAt: conf.at,
350
+ // 🔴 **券の無い確認では名前を器に入れない**(設計レビュー 🟡4)。画面が出さないだけだと、
351
+ // 名前は `site-data` の JSON として全案件のページに配られ続ける(`commit-classify.ts`
352
+ // の `Operated-By`「値そのものを持ち回さない」と同じ流儀)。しかも engine の `by` は
353
+ // `confirmer || authorName` の合成で、**券が無ければ「ただ触った人」でしかない**
354
+ confirmedBy: conf.verified ? conf.by : "",
355
+ confirmedVerified: conf.verified,
356
+ baseCommit: conf.commit,
357
+ changedAt: newest.at,
358
+ ...(evidence != null ? { evidence: evidence } : {}),
359
+ ...(baseDeleted ? { baseDeleted: true } : {}),
360
+ };
361
+ }
362
+ /**
363
+ * 用語集の並び(#432 裁定 5-5)。**確認済みは先頭群・群の中は活性度の降順。**
364
+ *
365
+ * 🔴 **「確認済み」は `status` だけで決まる**(#667 のオーナー決定)。**帳簿は見ない**
366
+ * ——先頭群に来るのは「AI生成・未確認」の札が付かない記録で、画面の札と 1 文字も同じ判定
367
+ * (`isConfirmedStatus`)を使う。前は `ledgers.confirmation.has(p)`(=人が 1 度でも触ったか)
368
+ * で決めていたので、**札は「未確認」なのに先頭群に居る**記録が出ていた(実測: cortex-context
369
+ * の用語集で 25 件)。帳簿の役目は「どの版に戻すか」だけになった。
370
+ *
371
+ * - 活性度を持つレコードが 1 件も無い区画は **1 バイトも触らない**(帳簿が無い案件は従来順)
372
+ * - 活性度を知らないレコードは群の末尾へ、**元の並びのまま**(安定)
373
+ * - 用語集だけ(メンバーの並びは便 14・Decisions と Rules は対象外)
374
+ *
375
+ * ℹ️ `ledgers` は活性度のために引き続き受け取る(`ledgers.activity` だけを渡す形にすると
376
+ * 「帳簿の判定に戻す」退行がコンパイルで止まる代わりに、**変異検証でそれを実演できなくなる**)。
377
+ *
378
+ * @param records 区画のレコード(`repoPathOf` で引けるパスを持つもの。並びは変えずに新しい配列を返す)
379
+ * @param statusOf そのレコードの `rstatus`(`String()` 済み)を引く関数
380
+ */
381
+ function orderByActivity(records, pathOf, statusOf, ledgers, nowIso) {
382
+ if (!records.length)
383
+ return records;
384
+ // この区画が並べ替えの対象か(用語集で、活性度の行が 1 件以上あるか)
385
+ const scored = records.map((r, i) => {
386
+ const p = pathOf(r);
387
+ const inSection = ORDER_SECTION_RE.test(p);
388
+ return {
389
+ r: r,
390
+ i: i,
391
+ confirmed: inSection && isConfirmedStatus(statusOf(r)),
392
+ score: inSection ? activityScore(ledgers.activity.get(p), nowIso) : null,
393
+ };
394
+ });
395
+ if (!scored.some((s) => s.score != null))
396
+ return records;
397
+ return scored
398
+ .slice()
399
+ .sort((a, b) => {
400
+ // 1. 確認済みは先頭群(群の境目は活性度より強い)
401
+ if (a.confirmed !== b.confirmed)
402
+ return a.confirmed ? -1 : 1;
403
+ // 2. 群の中は活性度の降順。知らないものは群の末尾
404
+ if (a.score != null && b.score != null && a.score !== b.score)
405
+ return b.score - a.score;
406
+ if ((a.score == null) !== (b.score == null))
407
+ return a.score == null ? 1 : -1;
408
+ // 3. 同点は元の並び(安定・日付降順 → タイトル昇順がそのまま残る)
409
+ return a.i - b.i;
410
+ })
411
+ .map((s) => s.r);
412
+ }
package/dist/render.js CHANGED
@@ -4666,6 +4666,34 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4666
4666
  .promote-btn:disabled { opacity: .6; cursor: default; }
4667
4667
  /* 何を上げようとしているか(リポジトリ上のパス)。リンクではないので控えめな色にする */
4668
4668
  .promote-target { font-size: 12.5px; word-break: break-all; color: var(--muted); margin: 0 0 12px; }
4669
+ /* ---- 人が確認した版から変わりました(#665 便 12b)----
4670
+ 🔴 **警告の色(黄・赤)にしない。** 機械が書き直したことは事故ではなく設計どおりの動き
4671
+ (用語と役割が話に出た翌朝に現実へ追随する)。裁定 5-1 の「文言に評価語なし」を色にも通し、
4672
+ 下書きの帯(.draft-note の黄)・未記入の帯(.home-unfilled の赤)と**見た目で役割を分ける**。
4673
+ 帯は症状の 1 行・その下に判断に要る値・一手は右に寄せる(2026-09-02 の規則) */
4674
+ .rederived-note {
4675
+ display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center;
4676
+ background: #eff6ff; border: 1px solid #93c5fd; border-radius: 8px;
4677
+ padding: 10px 14px; margin: 12px 0 4px; line-height: 1.7;
4678
+ }
4679
+ .rederived-body { flex: 1 1 320px; min-width: 0; }
4680
+ .rederived-title { font-size: 14.5px; font-weight: 700; color: #1e40af; }
4681
+ .rederived-meta { font-size: 13px; color: #1d4ed8; }
4682
+ .rederived-actions {
4683
+ flex: 0 0 auto; margin-left: auto;
4684
+ display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
4685
+ }
4686
+ .rederived-btn {
4687
+ flex: 0 0 auto; white-space: nowrap; text-decoration: none; cursor: pointer;
4688
+ padding: 6px 14px; border-radius: 999px; border: 1px solid #1d4ed8;
4689
+ background: #fff; color: #1d4ed8; font-size: 13px; font-weight: 600;
4690
+ }
4691
+ .rederived-btn:hover { background: #dbeafe; }
4692
+ /* 戻す(ディープリンク)は「押すと何かが起きる」側なので塗る */
4693
+ .rederived-btn-back { background: #1d4ed8; color: #fff; }
4694
+ .rederived-btn-back:hover { background: #1e40af; color: #fff; }
4695
+ /* リンクを作れない案件(リポジトリのURLが無い)では、どの版かを短縮SHAで残す */
4696
+ .rederived-base { font-size: 12.5px; color: var(--muted); }
4669
4697
  /* ---- Home(案件の自己記述)---- */
4670
4698
  /* 未記入の警告。**画面でいちばん強い赤にする**——27案件中24案件が未記入のまま
4671
4699
  毎晩のAI生成が走っている状態で、控えめに出しても読まれない */
@@ -6195,6 +6223,106 @@ ${calendarAddUrl.toString()}
6195
6223
  return note;
6196
6224
  }
6197
6225
 
6226
+ /* -------------------------------------------------------------------------
6227
+ * 人が確認した版から変わりました(#665 便 12b・.cortex/ の 3 帳簿由来)
6228
+ * -------------------------------------------------------------------------
6229
+ * 機械(夜間の再導出)が、人が確認したあとでこのレコードを書き直したときだけ出す帯。
6230
+ * **条件はビルド側が決めてある**(rederivation.ts の決定表)ので、画面は
6231
+ * rec.rederived の**有無だけ**を見る——画面側でパスを組み立て直して判定すると、
6232
+ * リポジトリのURLが無い案件で帯が黙って消える(設計レビュー 🔴4)。
6233
+ *
6234
+ * 🔴 **評価語を書かない**(裁定 5-1)。「勝手に」「注意」は書かず、症状・日付・件数と
6235
+ * 一手だけを出す。説明(なぜ毎晩変わるのか)は書かない——同じページの
6236
+ * 「AI による最近の変更」が変わった行そのものを持っている。
6237
+ */
6238
+
6239
+ /**
6240
+ * 帯に出す 1 行。**帯は起きたこと(機械が書き直した日)を言う。**
6241
+ *
6242
+ * 🔴 **確認した人と日は、券のある確認(confirmedVerified)のときだけ添える**(#667)。
6243
+ * 「確認済み」は frontmatter の status だけで決まるようになり、その大半は
6244
+ * 「AIで編集」か直接編集で active になったもの=**画面から確認したわけではない**。
6245
+ * そこに「○○さんが 9/1 に確認」と書くと、起きていないことを言い切ることになる。
6246
+ * 券があるときだけ名前と日を出すので、「自己申告」の断り書きは要らなくなった。
6247
+ */
6248
+ function rederivedMetaText(b) {
6249
+ var parts = [];
6250
+ if (b.confirmedVerified) {
6251
+ parts.push("確認 " + ahDate(b.confirmedAt) + (b.confirmedBy ? "(" + b.confirmedBy + ")" : ""));
6252
+ }
6253
+ parts.push("書き直し " + ahDate(b.changedAt));
6254
+ // **「根拠」は言えるときだけ**(この帯のコミットの記録が帳簿に無ければ出さない)
6255
+ if (typeof b.evidence === "number") parts.push("根拠 " + b.evidence + " 件");
6256
+ return parts.join("/");
6257
+ }
6258
+ /** ブランチ名を URL に入れる(release/x のような / を %2F に化けさせない) */
6259
+ function refForUrl(ref) {
6260
+ return encodeURIComponent(String(ref || "")).split("%2F").join("/");
6261
+ }
6262
+ /**
6263
+ * 確認した版との差分(GitHub の compare・基準コミット…HEAD)。
6264
+ *
6265
+ * ⚠️ compare は**リポジトリ全体**の比較で、このレコードだけの差分に絞る URL は GitHub に無い。
6266
+ * レコード単位の「変わった行」は同じページの「AI による最近の変更」が持つので、帯は
6267
+ * 「確認した版から今までの間に何があったか」への入口に留める(決定ログに覆し方あり)。
6268
+ */
6269
+ function rederivedDiffEl(b) {
6270
+ if (!SITE.repoBaseUrl) {
6271
+ var short = b.baseCommit.length > 8 ? b.baseCommit.slice(0, 8) : b.baseCommit;
6272
+ return el("span", { class: "rederived-base", text: "確認した版 " + short });
6273
+ }
6274
+ var href = SITE.repoBaseUrl + "/compare/" + refForUrl(b.baseCommit) + "..." + refForUrl(SITE.branch || "HEAD");
6275
+ return el("a", {
6276
+ class: "rederived-btn", href: href, target: "_blank", rel: "noopener",
6277
+ text: "確認した版との差分",
6278
+ });
6279
+ }
6280
+ /**
6281
+ * 確認した版に戻す(claude.ai/code のディープリンク・裁定 5-2「v1 はディープリンク」)。
6282
+ *
6283
+ * **出さない場合が 3 つある**: 基準コミットが「レコード削除」のコミット(そのコミットに
6284
+ * この path が無く空振りする)・ディープリンクを組めない(リポジトリのURLが無い)・
6285
+ * 対象のパスが引けない。押せない口を出さない。
6286
+ *
6287
+ * 🔴 プロンプトに**人の名前を入れない**(confirmed.by は件名由来の任意文字列)。
6288
+ * 🔴 パスは**引用符でくるむ**(用語のファイル名には空白が入る。実データで 5 件)。
6289
+ * 🔴 コミットが手元に無いとき(浅い clone)の逃げ道を 2 段で書く(設計レビュー 🔴8)。
6290
+ */
6291
+ function rederivedRestoreEl(rec, b) {
6292
+ if (b.baseDeleted) return null;
6293
+ if (!REPO_SLUG) return null;
6294
+ var p = repoPathOf(rec);
6295
+ if (!p) return null;
6296
+ var prompt = [
6297
+ "このレコードを、人が確認した版の内容に戻してください。",
6298
+ "対象のファイル: " + p,
6299
+ "確認した版のコミット: " + b.baseCommit,
6300
+ 'git checkout ' + b.baseCommit + ' -- "' + p + '" を実行してください(ファイル名に空白が入ることがあるので引用符を外さないでください)。',
6301
+ "そのコミットが手元に無い(bad object と出る)ときは、git fetch origin " + b.baseCommit + " を先に実行してください。それでも取れないときは、GitHub でそのコミットのこのファイルを開き、本文を写してください。",
6302
+ "ほかのファイルは 1 つも触らないでください。",
6303
+ "書き込む前に、戻したあとの内容を提示して承認を得てください。",
6304
+ ].join("\\n");
6305
+ return aiLink("rederived-btn rederived-btn-back", prompt, "確認した版に戻す");
6306
+ }
6307
+ /** 詳細ページの帯。rec.rederived が無いレコードでは **1 要素も作らない** */
6308
+ function rederivedNote(rec) {
6309
+ var b = rec && rec.rederived;
6310
+ if (!b) return null;
6311
+ var note = el("div", { class: "rederived-note" }, [
6312
+ el("div", { class: "rederived-body" }, [
6313
+ el("div", { class: "rederived-title", text: "人が確認した版から変わりました" }),
6314
+ el("div", { class: "rederived-meta", text: rederivedMetaText(b) }),
6315
+ ]),
6316
+ ]);
6317
+ var actions = el("div", { class: "rederived-actions" });
6318
+ var diff = rederivedDiffEl(b);
6319
+ if (diff) actions.appendChild(diff);
6320
+ var back = rederivedRestoreEl(rec, b);
6321
+ if (back) actions.appendChild(back);
6322
+ if (actions.children.length) note.appendChild(actions);
6323
+ return note;
6324
+ }
6325
+
6198
6326
  // ---------- Gold の確認済み昇格(status: draft → active) ----------
6199
6327
  //
6200
6328
  // 内容がそのまま正しいとき、**編集せずに所有権だけ引き取る**ための最短経路。
@@ -17446,6 +17574,11 @@ ${calendarAddUrl.toString()}
17446
17574
  var dn2 = draftNote(r);
17447
17575
  if (dn2) app.appendChild(dn2);
17448
17576
 
17577
+ // 人が確認した版から機械が書き直したときの帯(#665)。**下書きの帯の次・提案の前**
17578
+ // ——「この記録は誰のものか」の話をまとめ、根拠の一覧(提案)より前に置く
17579
+ var rd2 = rederivedNote(r);
17580
+ if (rd2) app.appendChild(rd2);
17581
+
17449
17582
  var pr2 = proposalsSection(r, r._section);
17450
17583
  if (pr2) app.appendChild(pr2);
17451
17584
 
package/dist/repo.js CHANGED
@@ -7,6 +7,7 @@ exports.normalizeToHttps = normalizeToHttps;
7
7
  exports.classifyCommit = classifyCommit;
8
8
  exports.isFrontmatterKeyRename = isFrontmatterKeyRename;
9
9
  exports.collectAiChangeHistory = collectAiChangeHistory;
10
+ exports.collectGoldCommitIdentities = collectGoldCommitIdentities;
10
11
  exports.lastSubstantiveUpdate = lastSubstantiveUpdate;
11
12
  const node_child_process_1 = require("node:child_process");
12
13
  /**
@@ -757,6 +758,73 @@ const LAST_UPDATE_RENAME_OPT = "--find-renames=50%";
757
758
  * ここでは使わない——差分の取得にもう1回 git を呼ぶ割に、Home.md ではマイグレーションの
758
759
  * 件名規約で落ちる形しか観測されていない(適用しないと決めた・設計レビュー承認済み)。
759
760
  */
761
+ /* ===========================================================================
762
+ * レコードを触ったコミットの素性(帯「人が確認した版から変わりました」の材料・#665)
763
+ * ===========================================================================
764
+ * `.cortex/gold-confirmation.json` の `changed_by` は **sha だけ**を持つ。人が確認した
765
+ * 版から「**機械が**書き直したか」を見分けるには、その sha の author・件名・本文
766
+ *(`Operated-By:` trailer)が要る——帳簿には無いので git から取る。
767
+ *
768
+ * 🔴 **レコード数に依存しない 1 回の `git log`。** 「ビルド1回につき git は2回」の規律
769
+ *(`collectAiChangeHistory`)に**3 回目**を足す形になるが、per-path で呼ばない点は保つ。
770
+ * sha を並べて `git log --no-walk <sha…>` にしない——sha の数(実データで 54・多い案件では
771
+ * 数百)でコマンドラインが伸び、ARG_MAX と「履歴に無い sha で log 全体が落ちる」に当たる。
772
+ */
773
+ /**
774
+ * 素性を読むコミット数の上限。
775
+ *
776
+ * ビューアのビルドは **fetch-depth 2000** で checkout する(engine `build-viewer.yml`)ので、
777
+ * それ以上遡っても届かない。窓の外・浅い clone の sha は**見分けられない=「不明」**に倒れ、
778
+ * 帯は立たない(既定は不明・#432 裁定 5-7)。差分や本文を読まない軽い pass なので、
779
+ * 履歴の窓とそろえて広く取る。
780
+ */
781
+ const GOLD_IDENTITY_MAX_COMMITS = 2000;
782
+ /** 本文(`%B`)の前に置く印。件名の区切り(US)とは別の文字にする(件名にも本文にも現れない) */
783
+ const RSEP = "";
784
+ /**
785
+ * レコードを触ったコミットの素性を sha で引ける表にする(新しい順に上限まで)。
786
+ *
787
+ * git が無い・リポジトリでない・浅い clone で届かない sha は**表に載らない**
788
+ *(呼び出し側は「載っていない sha は不明」として扱うこと)。
789
+ */
790
+ function collectGoldCommitIdentities(cwd) {
791
+ const out = gitLog(cwd, [
792
+ "log",
793
+ `-n${GOLD_IDENTITY_MAX_COMMITS}`,
794
+ "--no-color",
795
+ // 本文まで読むので、コミットの境目(NUL)と本文の始まり(RSEP)を別の印で固定する。
796
+ // 件名に US が混ざっても壊れないよう、下で 5 つ目以降を件名として繋ぎ直す
797
+ `--format=%x00%H%x1f%aI%x1f%an%x1f%ae%x1f%s%x1e%B`,
798
+ "--",
799
+ RECORD_PATHSPEC,
800
+ ], "レコードを触ったコミットの素性");
801
+ const map = new Map();
802
+ // 🔴 **行で切らない(NUL で切る)。** 本文は複数行なので、1 行 1 コミットの読み方は使えない
803
+ for (const chunk of out.split(NUL)) {
804
+ if (!chunk)
805
+ continue;
806
+ const at = chunk.indexOf(RSEP);
807
+ const head = at === -1 ? chunk : chunk.slice(0, at);
808
+ // 本文の末尾の改行は git が付けるもので、trailer の判定(行頭 ^)には影響しない
809
+ const body = at === -1 ? "" : chunk.slice(at + RSEP.length);
810
+ const parts = head.split(US);
811
+ const sha = parts[0] || "";
812
+ if (!sha)
813
+ continue;
814
+ // 同じ sha が 2 度出ることは無いが、出ても新しい側(先に出たもの)を残す
815
+ if (map.has(sha))
816
+ continue;
817
+ map.set(sha, {
818
+ sha: sha,
819
+ at: parts[1] || "",
820
+ authorName: parts[2] || "",
821
+ authorEmail: parts[3] || "",
822
+ subject: parts.slice(4).join(US),
823
+ body: body,
824
+ });
825
+ }
826
+ return map;
827
+ }
760
828
  function lastSubstantiveUpdate(cwd, repoPath) {
761
829
  // 🔴 **浅い clone を最初に弾く。** 浅い clone では境界コミットが親を持たないため、
762
830
  // このファイルを触っていない直近のコミットまで `A <path>` として現れる(実測)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.72",
3
+ "version": "0.12.74",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,