@takagaki/cortex-decisions-viewer 0.12.86 → 0.12.87

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
@@ -48,6 +48,7 @@ exports.readGoldProposals = readGoldProposals;
48
48
  exports.buildHomeGrounding = buildHomeGrounding;
49
49
  exports.collectMeetings = collectMeetings;
50
50
  exports.findBacklogDir = findBacklogDir;
51
+ exports.figmaUrlFromRef = figmaUrlFromRef;
51
52
  exports.bodyFileSlug = bodyFileSlug;
52
53
  exports.bodyStampId = bodyStampId;
53
54
  exports.bodyStampComment = bodyStampComment;
@@ -715,30 +716,35 @@ readBlobs = repo_1.readBlobsAt) {
715
716
  // 🔴 **「確認済み」は `status` だけで決まる**(#667)。帯も並びも同じ `rstatus` を見る
716
717
  // ——ここで別のものを渡すと、画面の札と食い違う(「未確認」の札が付いた記録に帯が出る)
717
718
  const statusOf = (r) => r.rstatus;
718
- // 決定表の行 8(中身が変わったか)の材料。**印の候補になったレコードのぶんだけ**、
719
- // 今の版を読み、`<基準コミット>:<path>` をまとめて 1 回引く。
719
+ // 決定表の行 5〜7 の材料。**帯の候補になったレコードのぶんだけ**今の版を読み、指紋を作る。
720
+ // 帳簿に人が確認した版の指紋が無い候補だけ、`<基準コミット>:<path>` をまとめて 1 回 git から引く。
720
721
  // 🔴 **キーが 0 件の区画では git を起こさない**(この関数は区画ごとに呼ばれる)
721
722
  const keyOf = new Map();
722
723
  // 今の版は**作業ツリーから読む**——画面が描いているのはこのファイルであって HEAD ではない
723
724
  const now = new Map();
725
+ const nowSha = new Map();
724
726
  for (const r of records) {
725
727
  const key = (0, rederivation_1.rederivedContentKey)(pathOf(r), statusOf(r), ctx.ledgers);
726
728
  if (!key)
727
729
  continue;
728
730
  const p = pathOf(r);
731
+ let nowRaw;
729
732
  try {
730
- now.set(p, await node_fs_1.promises.readFile(path.join(ctx.repoRoot, p), "utf8"));
733
+ nowRaw = await node_fs_1.promises.readFile(path.join(ctx.repoRoot, p), "utf8");
731
734
  }
732
735
  catch {
733
- // 読めなければ「引けなかった」側(下の verdict が unknown に倒す)
736
+ // 読めなければ「読めなかった」側(`rederivedBandOf` が帯を出さずに数える)
737
+ continue;
734
738
  }
735
- // 🔴 **帳簿の指紋が今の中身と一致する記録は git の key に入れない**(engine #546)。
736
- // 答えは下の verdict で `same` になるので、基準版を引く意味が無い——ここで外さないと、
737
- // engine が指紋を書いても用語 1400 件の案件で毎晩 1400 個の blob を読み続ける。
738
- // 指紋が無い・食い違う・今の版が読めない記録は今までどおり git へ落とす
739
- const nowRaw = now.get(p);
740
- const sha = ctx.ledgers.confirmation.get(p)?.contentSha;
741
- if (nowRaw != null && sha && sha === (0, rederivation_1.contentShaOf)(nowRaw)) {
739
+ now.set(p, nowRaw);
740
+ const sha = (0, rederivation_1.contentShaOf)(nowRaw);
741
+ nowSha.set(p, sha);
742
+ const conf = ctx.ledgers.confirmation.get(p);
743
+ // 🔴 **今の版が機械の書き直しの版でなければ git を読まない**(帯は出ないと決まっている)。
744
+ // 人が確認した版の指紋が帳簿にあれば、それで決まるので git を読まない(engine #546)
745
+ if (sha !== conf?.rewritten?.contentSha)
746
+ continue;
747
+ if (conf.contentSha) {
742
748
  if (ctx.blobs)
743
749
  ctx.blobs.skipped++;
744
750
  continue;
@@ -750,28 +756,18 @@ readBlobs = repo_1.readBlobsAt) {
750
756
  const bases = keyOf.size ? readBlobs(ctx.repoRoot, [...keyOf.values()]) : new Map();
751
757
  const verdict = (p, baseCommit) => {
752
758
  const nowRaw = now.get(p);
753
- if (nowRaw == null) {
754
- ctx.miss.unreadable.add(p);
755
- return "unknown";
756
- }
757
- // 🔴 **帳簿が中身の指紋を持つならそれを先に使う**(engine #546 が書く。一致した記録は
758
- // 上で git の key から外してある)。
759
- // 一致したときだけ「変わっていない」と言い、食い違ったら git の二段目へ落とす——
760
- // engine が別の綴りで指紋を作っていた場合に、全レコードへ誤報が出るのを避ける
761
- const conf = ctx.ledgers.confirmation.get(p);
762
- if (conf && conf.contentSha && conf.contentSha === (0, rederivation_1.contentShaOf)(nowRaw))
763
- return "same";
764
759
  const baseRaw = bases.get(`${baseCommit}:${p}`);
765
- if (baseRaw == null) {
760
+ if (nowRaw == null || baseRaw == null) {
766
761
  // 浅い clone の窓の外・確認の後に改名された・基準コミットにこの path が無い。
767
- // **印は立てない**(誤報より無言)が、静かに消える経路なのでログの器に数える
762
+ // **帯は出さない**(誤報より無言)が、静かに消える経路なのでログの器に数える
768
763
  ctx.miss.unreadable.add(p);
769
764
  return "unknown";
770
765
  }
771
766
  return (0, rederivation_1.sameRederivedContent)(baseRaw, nowRaw) ? "same" : "changed";
772
767
  };
768
+ const nowShaOf = (p) => nowSha.get(p) ?? null;
773
769
  for (const r of records) {
774
- const band = (0, rederivation_1.rederivedBandOf)(pathOf(r), statusOf(r), ctx.ledgers, ctx.identities, verdict, ctx.miss);
770
+ const band = (0, rederivation_1.rederivedBandOf)(pathOf(r), statusOf(r), ctx.ledgers, nowShaOf, verdict, ctx.miss);
775
771
  // **印が立たないレコードにはキーごと付けない**(空の器を通さない)
776
772
  if (band)
777
773
  r.rederived = band;
@@ -1453,16 +1449,28 @@ async function buildTargetUrlMap(repoRoot, repoBaseUrl, branch) {
1453
1449
  // - 参照ID: `design:xxx:103:1836`
1454
1450
  // - [Figmaで開く](https://www.figma.com/design/...)
1455
1451
  // 以前は `data.type === "design"` を見ていたので、この解決は**一度も効いていなかった**。
1456
- for (const file of await walkMd(path.join(repoRoot, designDir, "inventory"))) {
1457
- try {
1458
- const body = await node_fs_1.promises.readFile(file, "utf8");
1459
- const id = body.match(/^- 参照ID: `(design:[^`]+)`\s*$/m);
1460
- const url = body.match(/^- \[Figmaで開く\]\((https?:[^)]+)\)\s*$/m);
1461
- if (id && url)
1462
- map.set(id[1], url[1]);
1463
- }
1464
- catch {
1465
- // 読めないファイルはスキップ
1452
+ //
1453
+ // #727 K10 で、コメントだけを取り込む案件(栓 `design_comments_only`)は inventory を持たず、
1454
+ // `デザイン/指摘/<ファイル>/README.md`(`design:{fileKey}` と「Figmaで開く」)と
1455
+ // 月ごとの `YYYY-MM.md`(スレッドごとの `figma-comment:` と「Figmaでこの指摘を開く」)になる。
1456
+ // 移行期は両方ありうるので**両方歩く**。inventory を先に歩き、同じ ID は先勝ち
1457
+ // (=inventory があれば今までと同じ URL。1バイトも変えない)。
1458
+ // 表に無い `design:` / `figma-comment:` は `figmaUrlFromRef` がキーから組む(解決器の側)。
1459
+ for (const sub of ["inventory", "指摘"]) {
1460
+ for (const file of await walkMd(path.join(repoRoot, designDir, sub))) {
1461
+ try {
1462
+ const body = await node_fs_1.promises.readFile(file, "utf8");
1463
+ const id = body.match(/^- 参照ID: `(design:[^`]+)`\s*$/m);
1464
+ const url = body.match(/^- \[Figmaで開く\]\((https?:[^)]+)\)\s*$/m);
1465
+ if (id && url && !map.has(id[1]))
1466
+ map.set(id[1], url[1]);
1467
+ for (const [cid, curl] of figmaCommentLinks(body))
1468
+ if (!map.has(cid))
1469
+ map.set(cid, curl);
1470
+ }
1471
+ catch {
1472
+ // 読めないファイルはスキップ
1473
+ }
1466
1474
  }
1467
1475
  }
1468
1476
  // minute:{定例名}:{YYYYMMDD} → 議事録ファイルのGitHubリンク
@@ -1485,6 +1493,70 @@ async function buildTargetUrlMap(repoRoot, repoBaseUrl, branch) {
1485
1493
  }
1486
1494
  return map;
1487
1495
  }
1496
+ /**
1497
+ * 指摘のスレッドから `figma-comment:{fileKey}:{commentId}` → 「Figmaでこの指摘を開く」の URL を拾う。
1498
+ *
1499
+ * engine の `render_thread` の形(`### 未解決: …` の見出し・`- 参照ID:`・`- [Figmaでこの指摘を開く](…)`)。
1500
+ * **対は同じスレッドの中でだけ組む。** 次の `### ` 見出しを越えて拾うと、リンクの欠けた
1501
+ * スレッドに隣のスレッドの URL を付けてしまう(押すと別の指摘へ飛ぶ——エラーの出ない壊れ方)。
1502
+ */
1503
+ function figmaCommentLinks(body) {
1504
+ const out = [];
1505
+ let pending = null;
1506
+ for (const line of body.split("\n")) {
1507
+ if (/^#{1,3}\s/.test(line)) {
1508
+ pending = null;
1509
+ continue;
1510
+ }
1511
+ const id = line.match(/^- 参照ID: `(figma-comment:[^`]+)`\s*$/);
1512
+ if (id) {
1513
+ pending = id[1];
1514
+ continue;
1515
+ }
1516
+ const url = line.match(/^- \[Figmaでこの指摘を開く\]\((https?:[^)]+)\)\s*$/);
1517
+ if (url && pending) {
1518
+ out.push([pending, url[1]]);
1519
+ pending = null;
1520
+ }
1521
+ }
1522
+ return out;
1523
+ }
1524
+ /** Figma のファイルキー(engine `sync_designs.py` の `REAL_FIGMA_KEY` と同じ形) */
1525
+ const FIGMA_KEY = /^[A-Za-z0-9_-]{8,}$/;
1526
+ /** Figma のノード ID(`103:1836`。URL では `103-1836` と書く) */
1527
+ const FIGMA_NODE = /^\d+:\d+$/;
1528
+ /**
1529
+ * `design:` / `figma-comment:` の参照から、**キーとノードだけで** Figma の URL を組む(#727 K10)。
1530
+ *
1531
+ * コメントだけを取り込む案件には画面ごとの inventory が無いので、Gold の
1532
+ * `design:{fileKey}:{nodeId}` を表から引けない。画面の本体は Figma にあるので、キーとノードが
1533
+ * 揃っていれば URL は決まる(スラグは Figma が読まないので付けない)。
1534
+ * - `design:{key}:{node}` → `https://www.figma.com/design/{key}?node-id={node の : を -}`
1535
+ * - `design:{key}` → `https://www.figma.com/design/{key}`
1536
+ * - `figma-comment:{key}:{id}` → `https://www.figma.com/design/{key}#{id}`
1537
+ * (フラグメントはサーバーに送られないので、外れてもファイルには着く。engine の `comment_link` と同じ倒し方)
1538
+ *
1539
+ * 🔴 **形の合わないものは組まない(null)。** キー・ノードに任意の文字列を通すと、
1540
+ * `design:../../x` や `design:abc:1:2?x=` から**押せそうで 404** の URL ができる。
1541
+ * 壊れたリンクは、無いより悪い。
1542
+ */
1543
+ function figmaUrlFromRef(ref) {
1544
+ const d = ref.match(/^design:([^:]+)(?::(.+))?$/);
1545
+ if (d) {
1546
+ const [, key, node] = d;
1547
+ if (!FIGMA_KEY.test(key))
1548
+ return null;
1549
+ if (node === undefined)
1550
+ return `https://www.figma.com/design/${key}`;
1551
+ if (!FIGMA_NODE.test(node))
1552
+ return null;
1553
+ return `https://www.figma.com/design/${key}?node-id=${node.replace(":", "-")}`;
1554
+ }
1555
+ const c = ref.match(/^figma-comment:([^:]+):(\d+)$/);
1556
+ if (c && FIGMA_KEY.test(c[1]))
1557
+ return `https://www.figma.com/design/${c[1]}#${c[2]}`;
1558
+ return null;
1559
+ }
1488
1560
  /** 画像・添付など、グラフのノードにしないリンクの拡張子 */
1489
1561
  const NON_DOC_LINK = /\.(png|jpe?g|gif|svg|webp|ico|zip|mp4|mov|heic)([?#]|$)/i;
1490
1562
  /** 本文markdownからhttp(s)リンクを抽出する(重複除去・画像等は除外) */
@@ -1667,9 +1739,13 @@ function buildGraph(decisions, home, sections, content, targetUrls, repoBaseUrl,
1667
1739
  return resolved;
1668
1740
  let group = "phantom";
1669
1741
  let label = target;
1670
- if (target.startsWith("minute:") || target.startsWith("material:") || target.startsWith("design:")) {
1742
+ if (target.startsWith("minute:") ||
1743
+ target.startsWith("material:") ||
1744
+ target.startsWith("design:") ||
1745
+ target.startsWith("figma-comment:")) {
1671
1746
  group = "silver";
1672
- const url = targetUrls.get(target);
1747
+ // 表に無い design: / figma-comment: はキーから組む(詳細画面の resolve と同じ順)
1748
+ const url = targetUrls.get(target) ?? figmaUrlFromRef(target);
1673
1749
  if (url)
1674
1750
  return addNode({ id: target, label, group, url });
1675
1751
  }
@@ -2127,8 +2203,8 @@ async function build(opts) {
2127
2203
  // 同一プロセスで2回ビルドしても前回の値が残らないよう、毎回入れ替える
2128
2204
  PROPOSALS_BY_PATH = await readGoldProposals(repoRoot);
2129
2205
  PROPOSAL_PATHS_MATCHED = new Set();
2130
- // 再導出の 3 帳簿(#665 便 12b)。**帳簿が無い案件では帯も並び替えも作らない**ので、
2131
- // 読めたときだけ git の素性を取りに行く(帳簿を持たない 44 案件で git の呼び出しを増やさない)
2206
+ // 再導出の 3 帳簿(#665 便 12b)。**帳簿が無い案件では帯も並び替えも作らない**。
2207
+ // 人か機械かの見分けは engine が帳簿(`rewritten`)に書くので、画面は git の素性を読まない(#799)
2132
2208
  const ledgers = await (0, rederivation_1.readRederivationLedgers)(repoRoot);
2133
2209
  const rederive = ledgers.confirmation.size || ledgers.activity.size
2134
2210
  ? {
@@ -2136,10 +2212,9 @@ async function build(opts) {
2136
2212
  // 🔴 **`repoRoot` で走らせる**(`collectAiChangeHistory` の `process.cwd()` に倣わない)。
2137
2213
  // あちらは「リポジトリルートで実行すること」が前提の既存の制約だが、こちらは repoRoot を
2138
2214
  // 既に知っている(帳簿をそこから読んでいる)。Decisions ディレクトリから CLI を叩いた
2139
- // ときも pathspec が当たり、帯が黙って消えない
2215
+ // ときも今の版と基準版が引け、帯が黙って消えない
2140
2216
  repoRoot: repoRoot,
2141
- identities: ledgers.confirmation.size ? (0, repo_1.collectGoldCommitIdentities)(repoRoot) : new Map(),
2142
- miss: { unresolved: new Set(), unreadable: new Set() },
2217
+ miss: { unreadable: new Set() },
2143
2218
  blobs: { read: 0, skipped: 0 },
2144
2219
  }
2145
2220
  : null;
@@ -2243,30 +2318,20 @@ async function build(opts) {
2243
2318
  // **突き合わなかった提案の件数をビルドログに出す。** path が1文字違うだけで
2244
2319
  // 画面から静かに消えるので、出さないと「提案が出ない」の原因に辿り着けない。
2245
2320
  // 表示からは落とす(どのレコードの話か分からない提案は、出しても行動につながらない)
2246
- // 🔴 **帯が静かに消える経路を 1 行残す(設計レビュー 🟡10)。** `changed_by` の sha が
2247
- // git の窓(fetch-depth 2000)の外にあると、人か機械か見分けられない=帯が立たない。
2248
- // 画面には出さない(利用者が動ける症状ではない)が、ログに無いと「帯が出ない」の原因を
2249
- // 「夜間がまだ書いていない」と切り分けられない
2250
- if (rederive && rederive.miss.unresolved.size) {
2251
- const shas = [...rederive.miss.unresolved];
2252
- console.warn(`gold-confirmation.json: 変更のコミット ${shas.length} 件を git の履歴で引けませんでした` +
2253
- `(浅い clone か履歴の窓の外。人か機械か見分けられないので帯は出しません): ` +
2254
- shas.slice(0, 3).map((s) => s.slice(0, 8)).join(", ") + (shas.length > 3 ? " ほか" : ""));
2255
- }
2256
- // 🔴 **印が静かに消えるもう 1 つの経路を 1 行残す。** 人が確認した版の中身を git から
2257
- // 引けないと「説明文が変わったか」を言えないので、印は出さない(誤報より無言)。
2321
+ // 🔴 **帯が静かに消える経路を 1 行残す。** 今の版か、人が確認した版の中身を
2322
+ // 引けないと「説明文が変わったか」を言えないので、帯は出さない(誤報より無言)。
2258
2323
  // 出さないと「印が出ない」の原因を「中身が変わっていない」と切り分けられない
2259
2324
  if (rederive && rederive.miss.unreadable.size) {
2260
2325
  const paths = [...rederive.miss.unreadable];
2261
- console.warn(`gold-confirmation.json: 人が確認した版の中身を ${paths.length} 件引けませんでした` +
2262
- `(浅い clone か、確認の後に改名・削除された。変わったかを言えないので印は出しません): ` +
2326
+ console.warn(`gold-confirmation.json: 人が確認した版か今の版の中身を ${paths.length} 件引けませんでした` +
2327
+ `(浅い clone か、確認の後に改名・削除された。変わったかを言えないので帯は出しません): ` +
2263
2328
  paths.slice(0, 3).join(", ") + (paths.length > 3 ? " ほか" : ""));
2264
2329
  }
2265
2330
  // 🔴 **指紋(engine #546)が効いているかを 1 行残す。** 一致した記録は git を読まないので、
2266
2331
  // 「読んだ件数」が候補の数より減っていれば効いている。警告ではないので console.log
2267
2332
  if (rederive?.blobs && rederive.blobs.read + rederive.blobs.skipped > 0) {
2268
2333
  console.log(`gold-confirmation.json: 人が確認した版の中身を git から ${rederive.blobs.read} 件読みました` +
2269
- `(中身の指紋が一致した ${rederive.blobs.skipped} 件は読んでいません)`);
2334
+ `(中身の指紋で決めた ${rederive.blobs.skipped} 件は読んでいません)`);
2270
2335
  }
2271
2336
  const unmatched = [...PROPOSALS_BY_PATH.keys()].filter((k) => !PROPOSAL_PATHS_MATCHED.has(k));
2272
2337
  if (unmatched.length) {
@@ -2490,6 +2555,10 @@ function createRefResolver(opts) {
2490
2555
  const known = targetUrls.get(trimmed);
2491
2556
  if (known)
2492
2557
  return { label: trimmed, url: known };
2558
+ // inventory の無い案件(#727 K10)の design: / figma-comment: は、キーとノードから組む
2559
+ const figma = figmaUrlFromRef(trimmed);
2560
+ if (figma)
2561
+ return { label: trimmed, url: figma };
2493
2562
  // 実在するファイルだけ blob URL にする
2494
2563
  if (repoBaseUrl && exists(trimmed)) {
2495
2564
  return { label: trimmed, url: `${repoBaseUrl}/blob/${branch}/${encodePath(trimmed)}` };
@@ -106,8 +106,8 @@ function splitTypedId(v) {
106
106
  * 両方がこれを引く。
107
107
  *
108
108
  * 🔴 画面の中では `identities` と呼ばない(`linkedIds` / `.linked-ids` / `.li-*`)。
109
- * `identities` という語は、このリポジトリでは既に `GoldCommitIdentity`
110
- *(帯のコミットの筆者)の意味で使っている——同じ語を 2 つの意味で使わない。
109
+ * `identities` という語は、このリポジトリでは `GoldCommitIdentity`
110
+ *(帯のコミットの筆者・#799 で撤去)の意味で使っていた——同じ語を 2 つの意味で使わない。
111
111
  */
112
112
  exports.FM_LINKED_IDS = "identities";
113
113
  /**
@@ -24,7 +24,7 @@ exports.humanFollowupOf = humanFollowupOf;
24
24
  *
25
25
  * | 帳簿 | 何を持つか | 書き手 |
26
26
  * | --- | --- | --- |
27
- * | `.cortex/gold-confirmation.json` | レコードごとの「人が確認した版」と、そこから変わったか(**sha だけ**) | 夜間(全置換・git から作り直せる) |
27
+ * | `.cortex/gold-confirmation.json` | レコードごとの「人が確認した版」と、その後の機械の書き直しの最新(`rewritten`・中身の指紋つき・#799) | 夜間(全置換・git から作り直せる) |
28
28
  * | `.cortex/gold-rederivation.json` | 書き直しの根拠の件数・shadow の「書くつもりだった差分」 | 夜間(追記・区画ごとの節) |
29
29
  * | `.cortex/gold-activity.json` | 「最後に見た日」と触れた日の列(+観測の到達点 `cursor`) | 夜間(追記・剪定つき) |
30
30
  *
@@ -46,7 +46,6 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
46
46
  const promises_1 = __importDefault(require("node:fs/promises"));
47
47
  const node_path_1 = __importDefault(require("node:path"));
48
48
  const gray_matter_1 = __importDefault(require("gray-matter"));
49
- const commit_classify_1 = require("./commit-classify");
50
49
  /** 帳簿の版(違ったら空として扱う・engine `LEDGER_VERSION`) */
51
50
  exports.LEDGER_VERSION = 1;
52
51
  /** 帯を出す区画。**用語とメンバーだけ**(Decisions は積み上げ・Rules は便 13) */
@@ -147,6 +146,16 @@ async function readRederivationLedgers(repoRoot) {
147
146
  : [];
148
147
  // 中身の指紋は**文字列のときだけ**持つ(engine #546 が書く。古い帳簿の行には無い)
149
148
  const contentSha = typeof confirmed.content_sha === "string" ? confirmed.content_sha : "";
149
+ // 機械の書き直し(#799)。**オブジェクトのときだけ**持つ。欄が無い(#799 より前の帳簿)・
150
+ // null(確認の後に機械の書き直しが無い)はどちらもキーごと無い=帯は出ない
151
+ const rw = asRecord(row?.rewritten);
152
+ const rewritten = rw
153
+ ? {
154
+ at: typeof rw.at === "string" ? rw.at : "",
155
+ commit: typeof rw.commit === "string" ? rw.commit : "",
156
+ contentSha: typeof rw.content_sha === "string" ? rw.content_sha : "",
157
+ }
158
+ : null;
150
159
  out.confirmation.set(p, {
151
160
  at: confirmed.at != null ? String(confirmed.at) : "",
152
161
  by: confirmed.by != null ? String(confirmed.by) : "",
@@ -154,6 +163,13 @@ async function readRederivationLedgers(repoRoot) {
154
163
  commit: commit,
155
164
  changedBy: changedBy,
156
165
  ...(contentSha ? { contentSha: contentSha } : {}),
166
+ ...(rewritten ? { rewritten: rewritten } : {}),
167
+ // 🔴 **厳密に true のときだけ**("true" や 1 を削除と読まない)
168
+ ...(confirmed.deleted === true ? { deleted: true } : {}),
169
+ // 基準コミットでの path(改名の前の確認)。**空でない文字列で、今の path と違うときだけ**持つ
170
+ ...(typeof confirmed.path === "string" && confirmed.path !== "" && confirmed.path !== p
171
+ ? { basePath: confirmed.path }
172
+ : {}),
157
173
  });
158
174
  }
159
175
  }
@@ -350,9 +366,10 @@ function sameRederivedContent(a, b) {
350
366
  /**
351
367
  * 「説明文と本文」の指紋。**帳簿の `confirmed.content_sha` と突き合わせる綴り**。
352
368
  *
353
- * 🔴 比べる相手は engine #546 が書く `confirmed.content_sha`。**一致したときだけ
354
- * 「変わっていない」と言い、食い違ったら git へ落とす**(`attachRederived`)——engine が
355
- * 別の綴りで指紋を作っていた場合に、全レコードへ誤報が出るのを避けるため。
369
+ * 🔴 比べる相手は engine が書く 2 つの指紋——`rewritten.content_sha`(機械が書き直した版・#799)と
370
+ * `confirmed.content_sha`(人が確認した版・#546)。帯は「今の指紋 = 書き直した版 ≠ 確認した版」の
371
+ * ときだけ出る(`rederivedBandOf`)。**綴りがずれると「今 = 書き直した版」が当たらなくなり、
372
+ * 帯は出ない側に倒れる**(誤報より無言)。
356
373
  *
357
374
  * engine の写し: `cortex-engine/scripts/rederive/content-sha.mjs`・一致の番人:
358
375
  * `cortex-engine/test/rederive-history.test.mjs` の #546 の節(ここを変えたら engine の `VIEWER_SHA` を計算し直す)
@@ -362,12 +379,12 @@ function contentShaOf(raw) {
362
379
  return node_crypto_1.default.createHash("sha256").update(c.description + "\u0000" + c.body, "utf8").digest("hex");
363
380
  }
364
381
  /**
365
- * そのレコードが印の**候補**なら、人が確認した版を git から引くための key
366
- * (`<基準コミット>:<path>`)。候補でなければ null。
382
+ * そのレコードが帯の**候補**か(決定表の行 0〜4・中身を読む前の安い行だけ)。候補なら
383
+ * `<基準コミット>:<path>`(人が確認した版を git から引くときの key)、候補でなければ null。
367
384
  *
368
- * 🔴 **決定表の行 0〜3 と同じ門**(`rederivedBandOf` の手前の安い行だけ)。ここで候補を
369
- * 絞ってから `readBlobsAt` に**まとめて 1 回**渡す——全レコードぶんの key を作ると、
370
- * 用語が 1400 件ある案件で毎晩 1400 個の blob を読むことになる。
385
+ * 🔴 **ここで候補を絞ってから今の版を読む**——全レコードぶん作業ツリーを読むと、
386
+ * 用語が 1400 件ある案件で毎晩 1400 件を読むことになる。git に渡るのはさらにその中の
387
+ * 「帳簿に人が確認した版の指紋が無い」記録だけ(`attachRederived`)。
371
388
  */
372
389
  function rederivedContentKey(recordPath, status, ledgers) {
373
390
  if (!isConfirmedStatus(status))
@@ -375,132 +392,83 @@ function rederivedContentKey(recordPath, status, ledgers) {
375
392
  if (!BAND_SECTION_RE.test(recordPath))
376
393
  return null;
377
394
  const conf = ledgers.confirmation.get(recordPath);
378
- if (!conf || !conf.commit || !conf.changedBy.length)
395
+ if (!conf || !conf.commit || conf.deleted || conf.basePath)
396
+ return null;
397
+ const rw = conf.rewritten;
398
+ if (!rw || !rw.contentSha || !rw.at)
379
399
  return null;
380
400
  return `${conf.commit}:${recordPath}`;
381
401
  }
382
402
  /**
383
- * そのレコードに帯を立てるか(#665 の**決定表**・上から順に評価し、当たった行で終わる)。
403
+ * そのレコードに帯を立てるか(#799 の**決定表**・上から順に評価し、当たった行で終わる)。
384
404
  *
385
405
  * | 順 | 条件 | 結果 |
386
406
  * | --- | --- | --- |
387
- * | **0** | **その記録の `status` が `draft`(=「AI生成・未確認」の札が出る)** | **立てない**(#667) |
388
- * | 1 | path が用語・メンバーのレコードでない | 立てない |
389
- * | 2 | `confirmation` に行が無い/基準コミットが空/確認日が日時として読めない | 立てない |
390
- * | 3 | `changed_by` が空 | 立てない |
391
- * | 4 | `changed_by` の 1 件ずつに 4a〜4e を当てて**残りが 0 件** | 立てない |
392
- * | 4a | sha が基準コミットと同じ | その sha を落とす |
393
- * | 4b | sha が git の窓に無い | 落とす(**不明**・`miss.unresolved` に数える) |
394
- * | 4c | 分類が「人」または「不明」 | 落とす |
395
- * | 4d | **どちらかの綴りで引っ越し** | 落とす |
396
- * | 4e | author 日付が確認日**以前**(epoch 比較) | 落とす |
397
- * | 5 | 残りのうち最も新しい author 日付を「書き直した日」にする | 候補になる |
398
- * | **8** | **人が確認した版から「説明文(`description`)か本文」が変わったか**(`verdict`) | **`changed` のときだけ立てる** |
399
- * | 6 | 根拠は、帳簿の行の `commit` が `changed_by` に入っているときだけ出す | 出す/出さない |
400
- * | 7 | 基準コミットが削除のコミットなら「戻す」を出さない(`baseDeleted`) | — |
401
- *
402
- * 🔴 **行 8 がこの機能の本丸**(2026-09-20 の誤報)。前はここが無く、「確認の基準コミットより
403
- * 後に機械のコミットがその path に 1 件でもあるか」だけで立てていた——**ファイルの中身を
404
- * 1 バイトも見ていなかった**ので、移行でフロントマターの使わない 1 行(`side: cm`)が
405
- * 消えただけの回にも、関係者 11 人の画面に「変わりました」が出た。
407
+ * | 0 | その記録の `status` が `draft`(=「AI生成・未確認」の札が出る) | 出さない(#667) |
408
+ * | 1 | path が用語・メンバーのレコードでない | 出さない |
409
+ * | 2 | `confirmation` に行が無い/基準コミットが空 | 出さない |
410
+ * | 3 | **基準コミットがこの path を消した(`confirmed.deleted`)/基準コミットでの path が今と違う(`confirmed.path`・改名の前の確認)** | **出さない** |
411
+ * | 4 | **`rewritten` が無い(古い帳簿)・null・指紋が空・日時が空** | **出さない** |
412
+ * | 5 | 今の版が読めない | 出さない(`miss.unreadable` に数える) |
413
+ * | 6 | **今の版の指紋 ≠ `rewritten.content_sha`**(後から人や機械が直した) | **出さない** |
414
+ * | 7a | 帳簿に人が確認した版の指紋がある: **今の指紋 = その指紋**(元に戻った) | **出さない** |
415
+ * | 7b | 指紋が無い(engine が自信を持てない形・#546 より前): git の基準版と比べて `changed` でない | 出さない |
416
+ * | 8 | 根拠は、根拠の帳簿の行の `commit` が `rewritten.commit` と同じときだけ出す | 出す/出さない |
417
+ *
418
+ * 🔴 **人か機械かを画面が見分けない**(#799)。前は `changed_by` の sha ごとに git の素性
419
+ *(名義・件名・trailer)を引き、engine の分類の**写し**で機械の書き直しを拾っていた。
420
+ * 写しはずれる(App 名義の画面操作を機械と読む日が来る)ので、見分けは engine に 1 か所で
421
+ * 寄せ、画面は「今の版が、engine が機械の書き直しと呼んだ版そのものか」だけを指紋で見る。
422
+ * 後から人や別の機械が直していれば指紋が合わないので、「戻す」が人の編集を消すことはない。
406
423
  *
407
424
  * @param status その記録の `rstatus`(`String()` 済み)。**2 番目の必須引数**にしてある——
408
425
  * 末尾の任意引数にすると、渡し忘れが「行 0 の門が静かに開く」になる(設計レビュー 🔴3)
409
- * @param verdict 行 8 の答えを引く関数(`(recordPath, baseCommit) => ContentVerdict`)。
410
- * 🔴 **必須引数**にしてある——任意にすると、渡し忘れが「中身を見ない昔の判定」に静かに戻る。
411
- * `changed` 以外(`same` / `unknown`)は**立てない**
426
+ * @param nowShaOf 今の版(作業ツリー)の `contentShaOf`。読めなければ null。
427
+ * 🔴 **必須引数**——任意にすると、渡し忘れが「指紋を見ずに出す」に静かに倒れる
428
+ * @param verdict 行 7b の答えを引く関数(`(recordPath, baseCommit) => ContentVerdict`)。
429
+ * 帳簿に人が確認した版の指紋がある記録では**呼ばない**
412
430
  * @param miss 立たなかった理由を数える器(呼び出し側で 1 つ作って全レコードで共有する)
413
431
  */
414
- function rederivedBandOf(recordPath, status, ledgers, identities, verdict, miss) {
415
- // 0. 「AI生成・未確認」の札が出る記録には帯を重ねない(#667)。**綴りは 1 か所**
416
- //(ここで `status === "draft"` と直書きすると、判定が 2 つに増えて一致が崩れる)
417
- if (!isConfirmedStatus(status))
418
- return null;
419
- if (!BAND_SECTION_RE.test(recordPath))
432
+ function rederivedBandOf(recordPath, status, ledgers, nowShaOf, verdict, miss) {
433
+ // 0〜4. 中身を読む前の門(候補の絞り込みと同じ 1 つの関数に寄せる。2 つに増やすと一致が崩れる)
434
+ if (!rederivedContentKey(recordPath, status, ledgers))
420
435
  return null;
421
436
  const conf = ledgers.confirmation.get(recordPath);
422
- // 基準コミットが空の行はここで落とす(読み手は並びのために持っている)
423
- if (!conf || !conf.commit || !conf.changedBy.length)
437
+ const rw = conf.rewritten;
438
+ // 5. 今の版の指紋
439
+ const now = nowShaOf(recordPath);
440
+ if (now == null) {
441
+ if (miss)
442
+ miss.unreadable.add(recordPath);
424
443
  return null;
425
- // 確認日が読めない帳簿では「後か前か」を言えないので立てない(順序の確かめが要)
426
- const confirmedMs = Date.parse(conf.at);
427
- if (!Number.isFinite(confirmedMs))
428
- return null;
429
- let newest = null;
430
- /** 確認より後の**人**のコミットの最新(帳簿が前の夜のもので、基準点がまだ動いていない夜) */
431
- let newestHuman = 0;
432
- for (const sha of conf.changedBy) {
433
- // 4a. 基準コミット自身が並んでいる帳簿(作り直し前・手で書いたもの)に引っかからない
434
- if (sha === conf.commit)
435
- continue;
436
- const id = identities.get(sha);
437
- if (!id) {
438
- // 4b. 窓の外・浅い clone = 見分けられない(不明)。**画面には出さずログに数える**
439
- //(設計レビュー 🟡10: 帯が静かに消える経路がこれ)
440
- if (miss)
441
- miss.unresolved.add(sha);
442
- continue;
443
- }
444
- // 4e. 🔴 **確認より後であることを git の日付(epoch)で確かめる。** `changed_by` は engine が
445
- // 「基準点より後」で作るが、帳簿は前の夜のものが残りうる(全置換に失敗した回・人が後から
446
- // 確認して基準点が動いた回)。ここを外すと**確認より前の変更で帯が立つ**。
447
- // ⚠️ 「後か」の真の正本は engine が見た git のトポロジ順(`history.mjs:141-145`)で、
448
- // ここは**安全網**。author 日付は rebase・cherry-pick で前後しうるので、
449
- // ずれたときは「立てない」側に倒れる(設計レビュー 🟡12)
450
- const ms = Date.parse(id.at);
451
- if (!Number.isFinite(ms) || ms <= confirmedMs)
452
- continue;
453
- // 4c・4d. 機械の書き直しだけを残す(件名は分類と同じものを渡す)
454
- const classified = (0, commit_classify_1.classifyGoldCommit)({
455
- authorName: id.authorName,
456
- authorEmail: id.authorEmail,
457
- subject: id.subject,
458
- body: id.body,
459
- });
460
- if (!(0, commit_classify_1.isMachineRewrite)(classified, id.subject)) {
461
- if (classified.by === "human" && ms > newestHuman)
462
- newestHuman = ms;
463
- continue;
464
- }
465
- if (!newest || ms > newest.ms)
466
- newest = { at: id.at, ms: ms };
467
444
  }
468
- if (!newest)
469
- return null;
470
- // 🔴 **人が最後に言ったなら立てない。** engine は基準点を「最後に人が触ったコミット」に
471
- // 置くので、正しい帳簿の `changed_by` に人のコミットは入らない(`history.mjs:141`)。
472
- // だが前の夜の帳簿が残った朝には「機械の書き直し → その後の人の編集」が並びうる。
473
- // そこで帯を立てると、**「戻す」がその人の新しい編集ごと基準コミットへ戻す**——この機能で
474
- // いちばん重い壊れ方なので、既定(立てない側)に倒す。engine が次の夜に基準点を動かす
475
- if (newestHuman > newest.ms)
445
+ // 6. 🔴 **今の版が、engine が「機械の書き直し」と呼んだ版そのものか。** 違えば、その後に
446
+ // 人か別の機械が直している——そこで帯を出すと「戻す」がその直しごと消す
447
+ if (now !== rw.contentSha)
476
448
  return null;
477
- // 8. 🔴 **中身が変わったか。** ここまでは「機械がこの path を触ったか」しか見ていない。
478
- // 触っただけ(フロントマターの使わない欄が移行で消えた・`identities` の欄が足された)で
479
- // 印を立てると、説明文が 1 文字も変わっていない回に「変わりました」が出る(2026-09-20 の誤報)。
480
- // 引けないとき(`unknown`)も立てない——誤報より無言
481
- if (verdict(recordPath, conf.commit) !== "changed")
449
+ // 7. 人が確認した版から中身が変わっているか
450
+ if (conf.contentSha) {
451
+ // 7a. 機械が書き直したが、中身は人が確認した版と同じ(書き戻った・欄だけ触った)
452
+ if (now === conf.contentSha)
453
+ return null;
454
+ }
455
+ else if (verdict(recordPath, conf.commit) !== "changed") {
456
+ // 7b. 指紋が無い行は git の基準版と比べる(`same` も `unknown` も出さない)
482
457
  return null;
483
- // 6. 根拠の件数は「この帯のコミットの記録」から出す(別の夜の数字を並べない)
458
+ }
459
+ // 8. 根拠の件数は「この書き直しのコミットの記録」から出す(別の夜の数字を並べない)
484
460
  const red = ledgers.rederivation.get(recordPath);
485
- const evidence = red && red.evidence != null && red.commit && conf.changedBy.includes(red.commit)
486
- ? red.evidence
487
- : null;
488
- // 7. 基準コミットが削除のコミットか(窓の外なら分からないので false 側=リンクは出す。
489
- // プロンプト側に「取れなかったときの逃げ道」を書いてある)
490
- const base = identities.get(conf.commit);
491
- const baseDeleted = base ? commit_classify_1.DELETE_SUBJECT_RE.test(base.subject) : false;
461
+ const evidence = red && red.evidence != null && red.commit && rw.commit && red.commit === rw.commit ? red.evidence : null;
492
462
  return {
493
463
  confirmedAt: conf.at,
494
464
  // 🔴 **券の無い確認では名前を器に入れない**(設計レビュー 🟡4)。画面が出さないだけだと、
495
- // 名前は `site-data` の JSON として全案件のページに配られ続ける(`commit-classify.ts`
496
- // の `Operated-By`「値そのものを持ち回さない」と同じ流儀)。しかも engine の `by` は
465
+ // 名前は `site-data` の JSON として全案件のページに配られ続ける。しかも engine の `by` は
497
466
  // `confirmer || authorName` の合成で、**券が無ければ「ただ触った人」でしかない**
498
467
  confirmedBy: conf.verified ? conf.by : "",
499
468
  confirmedVerified: conf.verified,
500
469
  baseCommit: conf.commit,
501
- changedAt: newest.at,
470
+ changedAt: rw.at,
502
471
  ...(evidence != null ? { evidence: evidence } : {}),
503
- ...(baseDeleted ? { baseDeleted: true } : {}),
504
472
  };
505
473
  }
506
474
  /**
package/dist/render.js CHANGED
@@ -4237,12 +4237,14 @@ function buildTutorialData(site, repoSlug) {
4237
4237
  prompt: TUT_BACKLOG_PROMPT,
4238
4238
  });
4239
4239
  }
4240
- // 画面インベントリを読む体験(デザインを同期している案件のみ出す)
4240
+ // デザインの指摘を読む体験(デザインを同期している案件のみ出す)。
4241
+ // #727 K10 で取り込むのは Figma のコメント(デザイン/指摘/)だけになり、画面の本体は
4242
+ // AI が Figma の MCP で開く。移行期は inventory が残る案件もあるので、文言はどちらでも嘘にしない
4241
4243
  const usesDesign = capOn("デザイン") ?? internals.some((s) => s.kind === "デザイン" && s.enabled !== false);
4242
4244
  if (usesDesign) {
4243
4245
  specs.push({
4244
4246
  title: "デザインを読んでみよう",
4245
- desc: "画面インベントリから1画面を選んで理解する",
4247
+ desc: "Figmaの指摘から1画面を選んで理解する",
4246
4248
  prompt: TUT_DESIGN_PROMPT,
4247
4249
  });
4248
4250
  }
@@ -4310,10 +4312,11 @@ const TUT_BACKLOG_PROMPT = [
4310
4312
  "今回はコメントの追加だけです。課題の削除・クローズ・属性変更はしないでください。",
4311
4313
  ].join("\n");
4312
4314
  const TUT_DESIGN_PROMPT = [
4313
- "デザイン/inventory/ の画面インベントリから画面を1つ選び、それがどんな画面なのかを説明してください。1画面1ファイルで、frontmatter に安定ID(design:{fileKey}:{nodeId})とFigmaへのディープリンクがあります。",
4314
- "どの画面が見たいかは、まずわたしに質問してください(希望が無ければ主要そうな画面をあなたが選んでかまいません)。",
4315
- "画面の役割・主な要素・前後の画面とのつながりを説明し、関連する決定・用語・課題があれば安定IDをたどって補足し、最後にFigmaで実物を開くリンクを提示してください。",
4316
- "読むだけの練習です。インベントリはFigmaからの同期で上書きされる生成物なので、手で編集しないでください。",
4315
+ "デザイン/指摘/ に取り込んだ Figma のコメント(Figmaファイルごとのディレクトリに月ごとの md)から指摘を1つ選び、それがどの画面への指摘なのかを説明してください。各指摘には安定ID(figma-comment:{fileKey}:{commentId})と、その画面をFigmaで開くリンクがあります。",
4316
+ "画面そのものは取り込んでいません。Figma の MCP で開いて読んでください(プロジェクトによっては デザイン/inventory/ が残っているので、あれば手がかりに使ってかまいません)。Figma の MCP が使えないときは、そう伝えてリンクを示すところで止めてください。",
4317
+ "どの指摘・画面が見たいかは、まずわたしに質問してください(希望が無ければ未解決の指摘をあなたが選んでかまいません)。",
4318
+ "画面の役割・主な要素と、指摘が何を求めているかを説明し、関連する決定・用語・課題があれば安定IDをたどって補足し、最後にFigmaで実物を開くリンクを提示してください。",
4319
+ "読むだけの練習です。デザイン/ の下はFigmaからの同期で上書きされる生成物なので手で編集せず、Figma にコメントも書き込まないでください。",
4317
4320
  ].join("\n");
4318
4321
  const TUT_SLACK_PROMPT = [
4319
4322
  "Cortex のチュートリアルを完了したことを、このプロジェクトの Slack チャンネルに投稿してください。最後のステップです。",
@@ -12288,7 +12291,7 @@ ${calendarAddUrl.toString()}
12288
12291
  "会議": { icon: "🎙", desc: "文字起こしから議事録を生成" },
12289
12292
  "共有資料": { icon: "📁", desc: "資料をMarkdown化して取り込み" },
12290
12293
  "チャット": { icon: "💬", desc: "チャンネルの会話をGoldの材料に" },
12291
- "デザイン": { icon: "🎨", desc: "画面インベントリの同期" },
12294
+ "デザイン": { icon: "🎨", desc: "Figmaのコメント(指摘)の取り込み" },
12292
12295
  "開発": { icon: "🐙", desc: "IssuesをGoldの材料に" },
12293
12296
  "ハーネス連携": { icon: "🧰", desc: "スキルカタログを開く(Claude Code で使う/定期実行にする)" },
12294
12297
  };
@@ -14996,15 +14999,16 @@ ${calendarAddUrl.toString()}
14996
14999
  var lines = [
14997
15000
  "「" + (f.name || f.key) + "」をデザイン同期の対象から外します。",
14998
15001
  "",
14999
- "次回の同期から、このファイルの画面は画面インベントリに現れなくなります。",
15002
+ "次回の同期から、このファイルのコメントは取り込まれなくなります。",
15000
15003
  ];
15001
15004
  // **最後の1件は結果が違う。** 同期は空の設定だと何もせずに終わるので、
15002
- // 取り込み済みの画面インベントリが消えずに残る(sync_designs.py は
15005
+ // 取り込み済みの分(デザイン/指摘/、移行前の案件は デザイン/inventory/)が
15006
+ // 消えずに残る(sync_designs.py は
15003
15007
  // 全再生成の手前で早期returnする)。ここで伝えないと放置される。
15004
15008
  if (figsLeft === 1) {
15005
15009
  lines.push(
15006
15010
  "",
15007
- "これが最後の1件です。外すとデザイン同期は何もしなくなりますが、すでに取り込んだ画面インベントリはリポジトリに残ります(不要なら手で削除してください)。",
15011
+ "これが最後の1件です。外すとデザイン同期は何もしなくなりますが、すでに取り込んだ分(デザイン/ の下)はリポジトリに残ります(不要なら手で削除してください)。",
15008
15012
  );
15009
15013
  }
15010
15014
  lines.push("", "外しますか?");
@@ -18269,7 +18273,7 @@ ${calendarAddUrl.toString()}
18269
18273
  /**
18270
18274
  * 2 つの日時が同じ瞬間か。**印の行を決めるのに使う。**
18271
18275
  *
18272
- * 🔴 突き合わせるのは rec.rederived.changedAt(機械の書き直しの author 日付)と
18276
+ * 🔴 突き合わせるのは rec.rederived.changedAt(帳簿の rewritten.at=機械の書き直しの日時・#799)と
18273
18277
  * 変更履歴の at。**器にはコミットの sha が 1 つも入っていない**(evidence は
18274
18278
  * 根拠の**件数**であって sha ではない)ので、sha では突き合わせられない。
18275
18279
  * 綴りが違っても同じ瞬間なら当てる(どちらも git の author 日付だが、器の出どころが別)
@@ -18324,7 +18328,7 @@ ${calendarAddUrl.toString()}
18324
18328
  // 🔴 **この時点では、どのプロジェクトにもこの欄はまだ無い。** 無ければ節ごと出さない
18325
18329
  // (今までと同じに見えること)のが、この節のいちばん大事な性質。
18326
18330
  // 🔴 **画面の中では linkedIds / .linked-ids / .li-* と呼ぶ。** フロントマターの欄名の綴りは
18327
- // GoldCommitIdentity(帯のコミットの筆者)と同じ語なので、画面の名前には持ち込まない。
18331
+ // GoldCommitIdentity(帯のコミットの筆者・#799 で撤去)と同じ語だったので、画面の名前には持ち込まない。
18328
18332
  // 欄名を綴るのは FM_LINKED_IDS 1 本だけ(書く側も同じ定数を引く。正本は src/external-id.ts)
18329
18333
  var FM_LINKED_IDS = ${JSON.stringify(external_id_1.FM_LINKED_IDS)};
18330
18334
  // 道具の読める名前。🔴 **語彙の外の種類は、その綴りをそのまま出す**
package/dist/repo.js CHANGED
@@ -7,7 +7,6 @@ exports.normalizeToHttps = normalizeToHttps;
7
7
  exports.classifyCommit = classifyCommit;
8
8
  exports.isFrontmatterKeyRename = isFrontmatterKeyRename;
9
9
  exports.collectAiChangeHistory = collectAiChangeHistory;
10
- exports.collectGoldCommitIdentities = collectGoldCommitIdentities;
11
10
  exports.readBlobsAt = readBlobsAt;
12
11
  exports.lastSubstantiveUpdate = lastSubstantiveUpdate;
13
12
  const node_child_process_1 = require("node:child_process");
@@ -761,81 +760,13 @@ const LAST_UPDATE_RENAME_OPT = "--find-renames=50%";
761
760
  * ここでは使わない——差分の取得にもう1回 git を呼ぶ割に、Home.md ではマイグレーションの
762
761
  * 件名規約で落ちる形しか観測されていない(適用しないと決めた・設計レビュー承認済み)。
763
762
  */
764
- /* ===========================================================================
765
- * レコードを触ったコミットの素性(帯「人が確認した版から変わりました」の材料・#665)
766
- * ===========================================================================
767
- * `.cortex/gold-confirmation.json` の `changed_by` は **sha だけ**を持つ。人が確認した
768
- * 版から「**機械が**書き直したか」を見分けるには、その sha の author・件名・本文
769
- *(`Operated-By:` trailer)が要る——帳簿には無いので git から取る。
770
- *
771
- * 🔴 **レコード数に依存しない 1 回の `git log`。** 「ビルド1回につき git は2回」の規律
772
- *(`collectAiChangeHistory`)に**3 回目**を足す形になるが、per-path で呼ばない点は保つ。
773
- * sha を並べて `git log --no-walk <sha…>` にしない——sha の数(実データで 54・多い案件では
774
- * 数百)でコマンドラインが伸び、ARG_MAX と「履歴に無い sha で log 全体が落ちる」に当たる。
775
- */
776
- /**
777
- * 素性を読むコミット数の上限。
778
- *
779
- * ビューアのビルドは **fetch-depth 2000** で checkout する(engine `build-viewer.yml`)ので、
780
- * それ以上遡っても届かない。窓の外・浅い clone の sha は**見分けられない=「不明」**に倒れ、
781
- * 帯は立たない(既定は不明・#432 裁定 5-7)。差分や本文を読まない軽い pass なので、
782
- * 履歴の窓とそろえて広く取る。
783
- */
784
- const GOLD_IDENTITY_MAX_COMMITS = 2000;
785
- /** 本文(`%B`)の前に置く印。件名の区切り(US)とは別の文字にする(件名にも本文にも現れない) */
786
- const RSEP = "";
787
- /**
788
- * レコードを触ったコミットの素性を sha で引ける表にする(新しい順に上限まで)。
789
- *
790
- * git が無い・リポジトリでない・浅い clone で届かない sha は**表に載らない**
791
- *(呼び出し側は「載っていない sha は不明」として扱うこと)。
792
- */
793
- function collectGoldCommitIdentities(cwd) {
794
- const out = gitLog(cwd, [
795
- "log",
796
- `-n${GOLD_IDENTITY_MAX_COMMITS}`,
797
- "--no-color",
798
- // 本文まで読むので、コミットの境目(NUL)と本文の始まり(RSEP)を別の印で固定する。
799
- // 件名に US が混ざっても壊れないよう、下で 5 つ目以降を件名として繋ぎ直す
800
- `--format=%x00%H%x1f%aI%x1f%an%x1f%ae%x1f%s%x1e%B`,
801
- "--",
802
- RECORD_PATHSPEC,
803
- ], "レコードを触ったコミットの素性");
804
- const map = new Map();
805
- // 🔴 **行で切らない(NUL で切る)。** 本文は複数行なので、1 行 1 コミットの読み方は使えない
806
- for (const chunk of out.split(NUL)) {
807
- if (!chunk)
808
- continue;
809
- const at = chunk.indexOf(RSEP);
810
- const head = at === -1 ? chunk : chunk.slice(0, at);
811
- // 本文の末尾の改行は git が付けるもので、trailer の判定(行頭 ^)には影響しない
812
- const body = at === -1 ? "" : chunk.slice(at + RSEP.length);
813
- const parts = head.split(US);
814
- const sha = parts[0] || "";
815
- if (!sha)
816
- continue;
817
- // 同じ sha が 2 度出ることは無いが、出ても新しい側(先に出たもの)を残す
818
- if (map.has(sha))
819
- continue;
820
- map.set(sha, {
821
- sha: sha,
822
- at: parts[1] || "",
823
- authorName: parts[2] || "",
824
- authorEmail: parts[3] || "",
825
- subject: parts.slice(4).join(US),
826
- body: body,
827
- });
828
- }
829
- return map;
830
- }
831
763
  /**
832
764
  * 指定のコミットのファイルの中身を**まとめて 1 回**読む(`git cat-file --batch`)。
833
765
  *
834
766
  * 🔴 **レコードごとにプロセスを起こさない。** 帯(印)の判定は「人が確認した版と今の版で
835
767
  * 説明文・本文が変わったか」を見るので、確認の基準コミットのレコードを読む必要がある。
836
768
  * ここで `git show` をレコードごとに叩くと、用語が 1400 件ある案件でビルドが 1400 回
837
- * git を起こす——`collectGoldCommitIdentities` と同じく「git の呼び出しはレコード数に
838
- * 依存しない」の規律を保つため、key を stdin に流し込む `--batch` を 1 回だけ使う。
769
+ * git を起こす——「git の呼び出しはレコード数に依存しない」の規律を保つため、key を stdin に流し込む `--batch` を 1 回だけ使う。
839
770
  *
840
771
  * @param keys `<commit>:<path>` の列(重複は畳む)。**空なら git を起こさない**。
841
772
  * 🔴 **改行を含む key は git に渡す前に落とす**(`--batch` の問い合わせは 1 行 1 件なので、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.86",
3
+ "version": "0.12.87",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -1,157 +0,0 @@
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
- }