@takagaki/cortex-decisions-viewer 0.12.96 → 0.12.98

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
@@ -62,6 +62,7 @@ const marked_1 = require("marked");
62
62
  const sanitize_html_1 = __importDefault(require("sanitize-html"));
63
63
  const repo_1 = require("./repo");
64
64
  const engagement_1 = require("./engagement");
65
+ const story_1 = require("./story");
65
66
  const rederivation_1 = require("./rederivation");
66
67
  // 直近のGold昇格で追加されたレコードのパス集合。レコード生成の各所から参照するのでモジュールスコープに置く
67
68
  // (引数で引き回すと呼び出し側の改修が広範囲になる)。build() の冒頭で1回だけ解決する。
@@ -2208,10 +2209,14 @@ async function build(opts) {
2208
2209
  // 同一プロセスで2回ビルドしても前回の値が残らないよう、毎回入れ替える
2209
2210
  PROPOSALS_BY_PATH = await readGoldProposals(repoRoot);
2210
2211
  PROPOSAL_PATHS_MATCHED = new Set();
2211
- // 再導出の 3 帳簿(#665 便 12b)。**帳簿が無い案件では帯も並び替えも作らない**。
2212
+ // 再導出の 3 帳簿(#665 便 12b)と用語の強さ(#790)。**帳簿が無い案件では帯も並び替えも作らない**。
2212
2213
  // 人か機械かの見分けは engine が帳簿(`rewritten`)に書くので、画面は git の素性を読まない(#799)
2214
+ //
2215
+ // 🔴 **強さ(`state` が on のときだけ中身が入る)だけの案件でも器を作る**——作らないと、活性度の帳簿を
2216
+ // 持たない案件で用語集が目次(engine の `termRows`)と違う順になる。帯は `confirmation` の行が無ければ
2217
+ // 1 件も立たない(`rederivedContentKey` の門)ので、器を作っても帯・印は変わらない
2213
2218
  const ledgers = await (0, rederivation_1.readRederivationLedgers)(repoRoot);
2214
- const rederive = ledgers.confirmation.size || ledgers.activity.size
2219
+ const rederive = ledgers.confirmation.size || ledgers.activity.size || ledgers.strength.size
2215
2220
  ? {
2216
2221
  ledgers: ledgers,
2217
2222
  // 🔴 **`repoRoot` で走らせる**(`collectAiChangeHistory` の `process.cwd()` に倣わない)。
@@ -2356,6 +2361,8 @@ async function build(opts) {
2356
2361
  // 走るのは latestGeneratedPaths と同じ **リポジトリルート**(process.cwd())——
2357
2362
  // Decisions ディレクトリで実行すると pathspec に一致せず常に空になる
2358
2363
  const aiHistory = (0, repo_1.collectAiChangeHistory)(process.cwd());
2364
+ // ストーリーの日々の動き(#901 V2-5)。帳簿で on の日のファイルが無ければ null(キーごと出さない)
2365
+ const story = await (0, story_1.readStory)(repoRoot, renderGroundingSection, (p) => repo.baseUrl ? `${repo.baseUrl}/edit/${repo.branch}/${encodePath(p)}` : null);
2359
2366
  // 案件アイコン(#348)。Cortex構成でなければ undefined(旧レイアウトには置き場が無い)
2360
2367
  const projectIcon = await findProjectIcon(knowledgeRoot);
2361
2368
  const site = {
@@ -2414,6 +2421,8 @@ async function build(opts) {
2414
2421
  // 「知らないことはキーが無いことで表す」に合わせる(空の器を通さない)
2415
2422
  // 空の器は通さない(0件なら undefined = キーごと出さない)
2416
2423
  aiHistory: Object.keys(aiHistory).length ? aiHistory : undefined,
2424
+ // 載せる日が無い案件(帳簿なし・壊れ・shadow の日だけ・ファイルなし)はキーごと出さない
2425
+ story: story ?? undefined,
2417
2426
  };
2418
2427
  // サムネイル(デザイン画面等)をサイトに同梱し、ノードのパスをサイト内相対に書き換える
2419
2428
  await node_fs_1.promises.mkdir(outAbs, { recursive: true });
@@ -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.HUMAN_FOLLOWUP_STATES = exports.FOLLOWUP_EVIDENCE_VERDICTS = exports.FOLLOWUP_SHOWN_STATES = exports.DECISION_FOLLOWUP_FILE = 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.TERM_STRENGTH_FILE = exports.LEDGER_VERSION = void 0;
7
7
  exports.isConfirmedStatus = isConfirmedStatus;
8
8
  exports.parseLedger = parseLedger;
9
9
  exports.readRederivationLedgers = readRederivationLedgers;
@@ -20,15 +20,17 @@ exports.readDecisionFollowup = readDecisionFollowup;
20
20
  exports.shownFollowupOf = shownFollowupOf;
21
21
  exports.humanFollowupOf = humanFollowupOf;
22
22
  /**
23
- * 再導出の 3 帳簿を読み、帯「人が確認した版から変わりました」と用語集の並びを作る(#665 便 12b)。
23
+ * 再導出の 3 帳簿(+用語の強さの帳簿)を読み、帯「人が確認した版から変わりました」と用語集の並びを作る(#665 便 12b)。
24
24
  *
25
25
  * | 帳簿 | 何を持つか | 書き手 |
26
26
  * | --- | --- | --- |
27
27
  * | `.cortex/gold-confirmation.json` | レコードごとの「人が確認した版」と、その後の機械の書き直しの最新(`rewritten`・中身の指紋つき・#799) | 夜間(全置換・git から作り直せる) |
28
28
  * | `.cortex/gold-rederivation.json` | 書き直しの根拠の件数・shadow の「書くつもりだった差分」 | 夜間(追記・区画ごとの節) |
29
29
  * | `.cortex/gold-activity.json` | 「最後に見た日」と触れた日の列(+観測の到達点 `cursor`) | 夜間(追記・剪定つき) |
30
+ * | `.cortex/gold-term-strength.json` | 用語ごとの強さ(場面の数・使った人の数・資料の下地)と栓の状態 `state`(#790・engine #727) | 夜間(全置換) |
30
31
  *
31
- * 形の正本は cortex-engine `scripts/rederive/ledgers.mjs` と `docs/cortex-derived-files.md`。
32
+ * 形の正本は cortex-engine `scripts/rederive/ledgers.mjs` と `docs/cortex-derived-files.md`
33
+ * (強さの帳簿は `scripts/gold/term-gate.mjs` の `strengthLedgerOf`)。
32
34
  *
33
35
  * ## 🔴 帳簿が無い・壊れている・古い形の案件は「今までと 1 バイトも同じ画面」
34
36
  *
@@ -80,10 +82,13 @@ const MIN_ELAPSED_DAYS = 0.5;
80
82
  function isConfirmedStatus(status, recordPath = null) {
81
83
  return !(0, confirmed_1.machineOwned)(recordPath, status);
82
84
  }
85
+ /** 強さの帳簿のファイル名(`.cortex/` の下) */
86
+ exports.TERM_STRENGTH_FILE = "gold-term-strength.json";
83
87
  const emptyLedgers = () => ({
84
88
  confirmation: new Map(),
85
89
  rederivation: new Map(),
86
90
  activity: new Map(),
91
+ strength: new Map(),
87
92
  });
88
93
  exports.emptyLedgers = emptyLedgers;
89
94
  /**
@@ -103,15 +108,19 @@ function parseLedger(text) {
103
108
  return null;
104
109
  }
105
110
  }
106
- /** 1 本読む。無い(ENOENT)ことは警告しない(既定の状態)。壊れているときだけ 1 行残す */
107
- async function readOne(repoRoot, file) {
111
+ /**
112
+ * 1 本読む。無い(ENOENT)ことは警告しない(既定の状態)。壊れているときだけ 1 行残す。
113
+ *
114
+ * @param then 警告の括弧の中(読めなかったあと画面がどうなるか)。強さの帳簿は帯を持たないので文言を変える
115
+ */
116
+ async function readOne(repoRoot, file, then = "帯と並びは出さずに続けます") {
108
117
  let raw;
109
118
  try {
110
119
  raw = await promises_1.default.readFile(node_path_1.default.join(repoRoot, ".cortex", file), "utf8");
111
120
  }
112
121
  catch (e) {
113
122
  if (e?.code !== "ENOENT") {
114
- console.warn(`${file} を読めませんでした(帯と並びは出さずに続けます): ${String(e)}`);
123
+ console.warn(`${file} を読めませんでした(${then}): ${String(e)}`);
115
124
  }
116
125
  return null;
117
126
  }
@@ -119,7 +128,7 @@ async function readOne(repoRoot, file) {
119
128
  // **壊れている・版が違うときは 1 行残す。** 静かに 0 件になると、帯が出ない理由が
120
129
  // 「夜間がまだ書いていない」のか「読めていない」のか切り分けられない
121
130
  if (!parsed)
122
- console.warn(`${file} の形式か version が想定と違います(帯と並びは出さずに続けます)`);
131
+ console.warn(`${file} の形式か version が想定と違います(${then})`);
123
132
  return parsed;
124
133
  }
125
134
  const asRecord = (v) => v != null && typeof v === "object" && !Array.isArray(v) ? v : null;
@@ -219,6 +228,38 @@ async function readRederivationLedgers(repoRoot) {
219
228
  }
220
229
  }
221
230
  }
231
+ // 用語の強さ(#790)。**engine の目次(`scripts/memory-index.mjs` の `termStrengthLookup`)の写し**
232
+ const strengthThen = "用語集は今の並びのまま続けます";
233
+ const strength = await readOne(repoRoot, exports.TERM_STRENGTH_FILE, strengthThen);
234
+ if (strength) {
235
+ const terms = strength.terms;
236
+ // 🔴 **engine と同じ判定**(`body.version !== 1`(厳密)・`!body.terms || typeof body.terms !== "object"`)。
237
+ // `readOne` の `parseLedger` は `Number(version)` で読むので `"1"` を通す——目次は通さないので、ここで締める。
238
+ // 壊れているのと同じなので 1 行残す(無いときだけ黙る)
239
+ if (strength.version !== exports.LEDGER_VERSION || !terms || typeof terms !== "object") {
240
+ console.warn(`${exports.TERM_STRENGTH_FILE} の形式か version が想定と違います(${strengthThen})`);
241
+ }
242
+ else if (strength.state === "on") {
243
+ // 🔴 **並びに使うのは `state === "on"` のときだけ**。`shadow` は「夜間が数えているが、まだ並びに
244
+ // 効かせない」段階で、目次も栓が on になるまで強さを使わない。ここで shadow を読むと、画面だけが
245
+ // 先に並び替わり、目次(AI に渡る順)と食い違う。on 以外は既定の状態なので警告しない
246
+ const t = terms;
247
+ for (const p of Object.keys(t)) {
248
+ // 語ごとの値は engine と同じ門(`Object.hasOwn(terms, p) && terms[p] && typeof terms[p] === "object"`。
249
+ // `Object.keys` は自前のキーだけを返すので `hasOwn` はここで満たされている)。
250
+ // 通らない語はキーごと持たない=「知らない」(末尾へ)
251
+ const v = t[p];
252
+ if (!v || typeof v !== "object")
253
+ continue;
254
+ const s = v;
255
+ out.strength.set(p, {
256
+ scenes: Number(s.scenes) || 0,
257
+ people: Number(s.people) || 0,
258
+ base: s.base === true,
259
+ });
260
+ }
261
+ }
262
+ }
222
263
  return out;
223
264
  }
224
265
  /** `YYYY-MM-DD` を「その日の正午(UTC)」として読む(時刻を持たない日付に時差を持ち込まない) */
@@ -312,23 +353,28 @@ function normalizeForCompare(text) {
312
353
  const MEMBER_DECISIONS_START = "<!-- cortex-engine:member-decisions -->";
313
354
  const MEMBER_DECISIONS_END = "<!-- /cortex-engine:member-decisions -->";
314
355
  /**
315
- * 本文から engine の機械ブロック(「この人が関わった決定」)を取り除く(#702)。
316
- *
317
- * 🔴 **engine の写し(`scripts/rederive/content-sha.mjs`)と同じ規則。** 変えたら両方を変える。
318
- * 開始の印**だけ**の行(行末の `\r` は無視)から、終了の印**だけ**の行までを(両端を含めて)消す。終了の印が
319
- * 無ければ本文の終わりまで消す。塊の前後の空行も除き、両側に本文が残れば空行 1 行でつなぐ。そのあと末尾の空行を落とす。ブロックの無い本文は触らない。
356
+ * engine が決定の記録の末尾に書く「その後」の塊の印(#901 V2-2)。始まりの印は属性つき
357
+ * (`<!-- cortex-engine:followup sha=… in=… -->`)なので完全一致ではなく形で当てる。
358
+ * 🔴 **engine の写し(`scripts/rederive/sections.mjs` の `isFollowupBegin`・`FOLLOWUP_END`)と 1 バイト一致。**
359
+ * 行末の `\r` を落としてから当てる(CRLF の記録でも印の行として当てる)
360
+ */
361
+ const FOLLOWUP_BEGIN_RE = /^<!-- cortex-engine:followup(?: [^\n]*?)? -->$/;
362
+ const FOLLOWUP_END = "<!-- /cortex-engine:followup -->";
363
+ const isFollowupBegin = (line) => FOLLOWUP_BEGIN_RE.test(String(line ?? "").replace(/\r$/, ""));
364
+ /**
365
+ * 本文から engine の機械の塊を 1 つ取り除く規則の本体(2 つの塊で同じ 1 本)。
320
366
  *
321
- * 毎晩書き換わる機械の一覧を「人が確認した版から中身が変わっています」に数えないため。
322
- * ブロックの外を人が 1 文字でも直せば、従来どおり指紋は変わる。
367
+ * 🔴 **engine の写し(`scripts/rederive/content-sha.mjs` の `withoutBlockText`)と同じ規則。** 変えたら両方を変える。
368
+ * 開始の印の行(最初の 1 つ・行末の `\r` は無視)から、終了の印**だけ**の行までを(両端を含めて)消す。終了の印が
369
+ * 無ければ本文の終わりまで消す。塊の前後の空行も除き、両側に本文が残れば空行 1 行でつなぐ。そのあと末尾の空行を落とす。塊の無い本文は触らない。
323
370
  */
324
- function stripMemberDecisions(body) {
371
+ function withoutBlock(body, isBegin, endMarker) {
325
372
  const lines = body.split("\n");
326
- // 行末の `\r` は見ない(CRLF の記録でも印の行として当てる。当てないと毎晩印が立つ)
327
- const is = (marker) => (line) => line.replace(/\r$/, "") === marker;
328
- const start = lines.findIndex(is(MEMBER_DECISIONS_START));
373
+ const start = lines.findIndex((line) => isBegin(line));
329
374
  if (start < 0)
330
375
  return body;
331
- const endRel = lines.slice(start + 1).findIndex(is(MEMBER_DECISIONS_END));
376
+ // 行末の `\r` は見ない(CRLF の記録でも印の行として当てる。当てないと毎晩印が立つ)
377
+ const endRel = lines.slice(start + 1).findIndex((line) => line.replace(/\r$/, "") === endMarker);
332
378
  const end = endRel < 0 ? lines.length : start + 1 + endRel + 1;
333
379
  const isBlank = (line) => line.trim() === "";
334
380
  // 塊の前後の空行も一緒に除き、両側に本文が残れば空行 1 行でつなぐ(engine の書き手と同じ規則・#702 実装レビュー R1)
@@ -343,6 +389,30 @@ function stripMemberDecisions(body) {
343
389
  kept.pop();
344
390
  return kept.join("\n");
345
391
  }
392
+ /**
393
+ * 本文から engine の機械ブロック(「この人が関わった決定」)を取り除く(#702)。
394
+ *
395
+ * 🔴 **engine の写し(`scripts/rederive/content-sha.mjs` の `withoutMemberDecisionsText`)と同じ規則。**
396
+ * 開始の印**だけ**の行(行末の `\r` は無視)から終了の印**だけ**の行まで。
397
+ *
398
+ * 毎晩書き換わる機械の一覧を「人が確認した版から中身が変わっています」に数えないため。
399
+ * ブロックの外を人が 1 文字でも直せば、従来どおり指紋は変わる。
400
+ */
401
+ function stripMemberDecisions(body) {
402
+ return withoutBlock(body, (line) => line.replace(/\r$/, "") === MEMBER_DECISIONS_START, MEMBER_DECISIONS_END);
403
+ }
404
+ /**
405
+ * 本文から決定の「その後」の塊(#901 V2-2)を取り除く。規則は `stripMemberDecisions` と同じ 1 本
406
+ * (始まりの印だけ `isFollowupBegin` の形で当てる)。
407
+ *
408
+ * 🔴 **engine の写し(V2-2・`scripts/rederive/content-sha.mjs` の `withoutFollowupText`)と 1 バイト一致・
409
+ * 番人は `test/followup-block-sha.test.mjs`**(engine の `contentShaOf` を実際に呼んで焼いた表
410
+ * `test/fixtures/followup-sha-contract.json`)。塊は機械が材料の変わった夜に `active` の決定にも書き直すので、
411
+ * 除かないと「人が確認した版から変わりました」に数える。塊の無い記録の指紋は 1 バイトも変えない。
412
+ */
413
+ function withoutFollowup(body) {
414
+ return withoutBlock(body, isFollowupBegin, FOLLOWUP_END);
415
+ }
346
416
  function comparableContent(raw) {
347
417
  let data = {};
348
418
  let content = String(raw ?? "");
@@ -357,7 +427,8 @@ function comparableContent(raw) {
357
427
  }
358
428
  return {
359
429
  description: normalizeForCompare(String(data.description ?? "")),
360
- body: normalizeForCompare(stripMemberDecisions(content)),
430
+ // engine と同じ順(目録の塊 → その後の塊)
431
+ body: normalizeForCompare(withoutFollowup(stripMemberDecisions(content))),
361
432
  };
362
433
  }
363
434
  /** 2 つの版で「説明文と本文」が同じか(frontmatter の他の欄の差は見ない) */
@@ -496,11 +567,24 @@ function rederivedBandOf(recordPath, status, ledgers, nowShaOf, verdict, miss) {
496
567
  * **流し込んだ別の実体**で、2 つが一致していることは `rederived-mark-ui.test.mjs` の `[札との一致]` が見る。
497
568
  * ここで変えるのは**並びだけ**で、札・帯・印・プロンプトに渡る材料は 1 つも変えていない。
498
569
  *
499
- * - 活性度を持つレコードが 1 件も無い区画は **1 バイトも触らない**(帳簿が無い案件は従来順)
570
+ * - 活性度を持つレコードが 1 件も無い区画は活性度の段では **1 バイトも触らない**(帳簿が無い案件は従来順。
571
+ * 強さの段は別に通る・下の「強さの段」)
500
572
  * - 活性度を知らないレコードは**末尾**へ、**元の並びのまま**(安定)
501
573
  * - 同じ活性度なら元の並び(日付降順 → タイトル昇順がそのまま残る)
502
574
  * - 用語集だけ(関係者の並びは `engagement.ts`・便 14b。Decisions と Rules は対象外)
503
575
  *
576
+ * ## 強さの段(#790・engine #727)
577
+ *
578
+ * 活性度で並べた**後に**、用語の強さ(場面の数 → 使った人の数 → 資料の下地の辞書式降順)を
579
+ * かぶせる(`rankByStrength`)。🔴 **engine の目次(`scripts/memory-index.mjs` の `termRows`)の写し**
580
+ * ——画面と目次で同じ語の順番が食い違うと「目次の上から N 件を AI に読ませる」構想(#544)の
581
+ * 土台が崩れるので、**目次と同じ並び**にする。一致は `glossary-activity-parity.test.mjs` の
582
+ * `[強さ]`(engine を実際に呼んで焼いた表)が見る。
583
+ * - 🔴 **帳簿の `state` が `"on"` のときだけ**(`readRederivationLedgers` がそれ以外を空にする)
584
+ * - 強さを持つ語が 1 件も無ければ活性度の並びのまま(帳簿が無い案件は 1 バイトも触らない)
585
+ * - 強さを知らない語は**末尾**へ、活性度の並びのまま。同点も活性度の並びのまま(安定)
586
+ * - 🔴 活性度の帳簿が無く強さだけある案件でも並べる(活性度の段が「何もしない」でも強さの段は飛ばさない)
587
+ *
504
588
  * ℹ️ `ledgers` と `statusOf` は**受け取り続ける**。`ledgers` は活性度のために要る。
505
589
  * `statusOf` は並びに使わなくなったが、**引数から落とすと「群分けを足し戻す」退行が
506
590
  * コンパイルで止まる代わりに、変異検証でそれを実演できなくなる**(`ledgers` と同じ流儀)。
@@ -512,6 +596,11 @@ function rederivedBandOf(recordPath, status, ledgers, nowShaOf, verdict, miss) {
512
596
  function orderByActivity(records, pathOf, statusOf, ledgers, nowIso) {
513
597
  if (!records.length)
514
598
  return records;
599
+ // 🔴 **強さの段は活性度の段の後に必ず通す**(活性度が 1 件も無い案件でも・上の「強さの段」)
600
+ return rankByStrength(rankByActivity(records, pathOf, ledgers, nowIso), pathOf, ledgers);
601
+ }
602
+ /** 活性度の段。活性度を持つ語が 1 件も無ければ**渡された配列をそのまま返す** */
603
+ function rankByActivity(records, pathOf, ledgers, nowIso) {
515
604
  // この区画が並べ替えの対象か(用語集で、活性度の行が 1 件以上あるか)
516
605
  const scored = records.map((r, i) => {
517
606
  const p = pathOf(r);
@@ -537,6 +626,40 @@ function orderByActivity(records, pathOf, statusOf, ledgers, nowIso) {
537
626
  })
538
627
  .map((s) => s.r);
539
628
  }
629
+ /**
630
+ * 強さの段(#790)。**engine `scripts/glossary/activity.mjs` の `rankByStrength` の写し**——鍵・同点・
631
+ * 「知らない」の扱いを 1 つでも変えると、画面と目次で同じ語の順番が割れる。
632
+ *
633
+ * - 強さを持つ語が 1 件も無ければ**渡された配列をそのまま返す**(`state` が on でない案件はここ)
634
+ * - 知らない語(帳簿に無い・用語集の区画の外)は末尾へ、渡された順のまま
635
+ * - 鍵は `[場面の数, 使った人の数, 下地]` の辞書式降順。同点は渡された順(=活性度の並び・安定)
636
+ */
637
+ function rankByStrength(records, pathOf, ledgers) {
638
+ if (!ledgers.strength.size)
639
+ return records;
640
+ const scored = records.map((r, i) => {
641
+ const p = pathOf(r);
642
+ return { r: r, i: i, s: ORDER_SECTION_RE.test(p) ? (ledgers.strength.get(p) ?? null) : null };
643
+ });
644
+ if (!scored.some((x) => x.s != null))
645
+ return records;
646
+ const key = (s) => [s.scenes, s.people, s.base ? 1 : 0];
647
+ return scored
648
+ .slice()
649
+ .sort((a, b) => {
650
+ if ((a.s == null) !== (b.s == null))
651
+ return a.s == null ? 1 : -1;
652
+ if (a.s != null && b.s != null) {
653
+ const ka = key(a.s);
654
+ const kb = key(b.s);
655
+ for (let k = 0; k < ka.length; k++)
656
+ if (ka[k] !== kb[k])
657
+ return kb[k] - ka[k];
658
+ }
659
+ return a.i - b.i;
660
+ })
661
+ .map((x) => x.r);
662
+ }
540
663
  /* ---------------------------------------------------------------------------
541
664
  * 決定の「その後」の帳簿(#705・engine #509 便 4)
542
665
  * ---------------------------------------------------------------------------
package/dist/render.js CHANGED
@@ -4736,6 +4736,15 @@ button.site-icon[aria-busy="true"] .site-icon-plus { opacity: 0; }
4736
4736
  /* 戻す(ディープリンク)は「押すと何かが起きる」側なので塗る */
4737
4737
  .rederived-btn-back { background: #1d4ed8; color: #fff; }
4738
4738
  .rederived-btn-back:hover { background: #1e40af; color: #fff; }
4739
+ /* ---- ストーリー(#901 V2-5・日ごとのカード。新しい日が上)---- */
4740
+ .story-cards { display: flex; flex-direction: column; gap: 14px; }
4741
+ .story-card {
4742
+ border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; background: var(--surface);
4743
+ }
4744
+ .story-card-heading { margin: 0 0 8px; font-size: 15px; }
4745
+ .story-card-edit { display: inline-block; margin-top: 8px; font-size: 12.5px; }
4746
+ .story-card .body h2 { font-size: 14px; margin: 14px 0 6px; }
4747
+ .story-card .body h3 { font-size: 13.5px; margin: 12px 0 6px; }
4739
4748
  /* ---- Home(案件の自己記述)---- */
4740
4749
  /* 未記入の警告。**画面でいちばん強い赤にする**——27案件中24案件が未記入のまま
4741
4750
  毎晩のAI生成が走っている状態で、控えめに出しても読まれない */
@@ -6207,6 +6216,11 @@ ${calendarAddUrl.toString()}
6207
6216
  (SITE.sections || []).forEach(function (sec) {
6208
6217
  TABS.push({ key: "dir:" + sec.name, label: dirLabel(sec.name), items: sec.records });
6209
6218
  });
6219
+ // ストーリー(#901 V2-5)。区画の後・ナレッジグラフと操作の前。
6220
+ // **build が載せた日があるときだけ**(帳簿で on の日のファイルが無い案件はキーごと無い=タブも出ない)
6221
+ if (SITE.story && SITE.story.length) {
6222
+ TABS.push({ key: "story", label: "ストーリー" });
6223
+ }
6210
6224
  if (SITE.graph && SITE.graph.nodes.length > 0) {
6211
6225
  TABS.push({ key: "graph", label: "ナレッジグラフ" });
6212
6226
  }
@@ -8321,6 +8335,9 @@ ${calendarAddUrl.toString()}
8321
8335
  "explorer": {
8322
8336
  text: "決定・用語・レポートを起点に、つながり(根拠・出典・リンク)を1列ずつ辿る表示です。行をクリックすると右に次の列が開きます。",
8323
8337
  },
8338
+ "story": {
8339
+ text: "このプロジェクトの日々の動きを、1日ずつまとめた記録です。直したいことや足したいことは、その日の「人の追記」に書けます。",
8340
+ },
8324
8341
  "dir:レポート": {
8325
8342
  text: "レポートの生成は終了し、履歴として凍結されています(レポートはSlack配信に一本化されました)。以下は過去に生成されたレコードです。",
8326
8343
  },
@@ -8345,9 +8362,26 @@ ${calendarAddUrl.toString()}
8345
8362
  if (listState.tab === "decisions") { renderDecisionList(); return; }
8346
8363
  if (listState.tab === "graph") { renderKnowledge(); return; }
8347
8364
  if (listState.tab === "ops") { renderOps(); return; }
8365
+ if (listState.tab === "story") { renderStory(); return; }
8348
8366
  renderGenericList();
8349
8367
  }
8350
8368
 
8369
+ // ---------- ストーリー(#901 V2-5)----------
8370
+ // 日ごとのカード(新しい日が上=build が並べた順のまま)。本文は build の関所(marked → sanitize)を
8371
+ // 通した HTML で、印のコメントと機械の語の行は build が除いてある。見出しは textContent で入れる
8372
+ function renderStory() {
8373
+ var list = el("div", { class: "story-cards" });
8374
+ (SITE.story || []).forEach(function (d) {
8375
+ list.appendChild(el("section", { class: "story-card" }, [
8376
+ el("h3", { class: "story-card-heading", text: d.heading || d.date }),
8377
+ d.bodyHtml ? el("div", { class: "body", html: d.bodyHtml }) : null,
8378
+ // その日のファイルを GitHub で開く(表示の文字にファイル名を出さない)。URL が無い案件は出さない
8379
+ d.editUrl ? el("a", { class: "story-card-edit", href: d.editUrl, target: "_blank", rel: "noopener", text: "GitHub で追記する" }) : null,
8380
+ ]));
8381
+ });
8382
+ app.appendChild(list);
8383
+ }
8384
+
8351
8385
  // 導入経緯(overview.adoption)の表示情報。Home見出し横のチップに出すラベルだけを返す。
8352
8386
  function adoptionInfo(v) {
8353
8387
  // 3種とも同じ形(ラベルのみ)。導入前のDecisionの薄さの注記は出さない
package/dist/story.js ADDED
@@ -0,0 +1,245 @@
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.STORY_MAX_FILE_BYTES = exports.STORY_EMPTY_LINE = exports.STORY_MAX_DAYS = exports.STORY_DAYS_DIR = exports.STORY_LEDGER_PATH = void 0;
7
+ exports.parseStoryLedger = parseStoryLedger;
8
+ exports.onDaysOf = onDaysOf;
9
+ exports.storyDayMarkdown = storyDayMarkdown;
10
+ exports.readStory = readStory;
11
+ /**
12
+ * ストーリーのタブ(#901 記憶 v2・V2-5)。engine V2-3 が書く日々の動き
13
+ * (`Cortex/Story/Days/YYYY-MM/YYYY-MM-DD.md`)と帳簿(`.cortex/story-days.json`)を読み、画面に載せる日を組む。
14
+ *
15
+ * 形の正本は engine の `scripts/story/ledger.mjs`(帳簿・版 1)と `scripts/story/days.mjs`(`renderDayFile`・`lateBlockOf`)。
16
+ *
17
+ * ## 🔴 タブを出す条件(分類 B・`docs/product-decisions.md` 2026-09-28)
18
+ *
19
+ * 画面はストーリーの書き手の栓(engine の語彙の契約の名前)を読まない。🔴 栓の名前をこのファイルに書かない——
20
+ * tools の追跡ファイルに栓の名前が出ると、語彙の番人(`infra/test/observations-stack.test.ts`)が「tools が使う栓」と数える。
21
+ * だから **帳簿が読めて(版 1)、`days` に `mode === "on"` の日があり、
22
+ * その日のファイルが 1 枚以上ある** ときだけ載せる。shadow の夜は `Cortex/**` を 1 バイトも変えず帳簿だけ
23
+ * 書く(`mode: "shadow"`)ので、shadow の日しか無い案件ではタブも器も出さない(今までと 1 バイトも同じ画面)。
24
+ * 帳簿が無い・壊れている・ファイルが無い → `null`(壊れているときだけ build のログに 1 行)。
25
+ *
26
+ * ## 🔴 機械の語を画面に出さない
27
+ *
28
+ * 印の HTML コメント(`<!-- cortex-engine:story-day … -->`・`story-late`・その終わり)は捨てる。本文の行に
29
+ * `urn:`・ファイル名・`cortex-engine`・`sha=` が紛れていたら**行ごと伏せる**(本文は AI が書いた行なので、
30
+ * 語だけ消すと文が壊れる)。課題番号・コミットの ID は engine の門が落とすので重ねない(`MACHINE_WORD_RE`)。
31
+ * 人の追記には伏せ字を掛けない(落とすのは印の行だけ)。本文の Markdown は他の本文と同じ関所(marked → sanitize)を呼び手が通す。
32
+ */
33
+ const node_fs_1 = require("node:fs");
34
+ const node_path_1 = __importDefault(require("node:path"));
35
+ exports.STORY_LEDGER_PATH = ".cortex/story-days.json";
36
+ exports.STORY_DAYS_DIR = "Cortex/Story/Days";
37
+ /** 載せる日の上限(新しい日から。分類 B・約 3 か月) */
38
+ exports.STORY_MAX_DAYS = 90;
39
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
40
+ const MONTH_RE = /^\d{4}-\d{2}$/;
41
+ const HUMAN_HEADING = "## 人の追記";
42
+ /** engine の印の行(始まり・終わり。属性は問わない)。画面には出さない */
43
+ const STORY_MARKER_RE = /^<!--\s*\/?cortex-engine:story-(?:day|late)(?:\s[^\n]*?)?\s*-->$/;
44
+ const LATE_BEGIN_RE = /^<!--\s*cortex-engine:story-late(?:\s[^\n]*?)?\s*-->$/;
45
+ const LATE_END_RE = /^<!--\s*\/cortex-engine:story-late\s*-->$/;
46
+ /**
47
+ * 画面に出さない機械の語。当たった行は**行ごと伏せる**(機械の行だけ。人の追記には掛けない)。
48
+ *
49
+ * 🔴 **明らかな機械の語だけ**: 印の残り(`<!--`・`-->`)・`urn:`・`cortex-engine`・`sha=`・ファイル名と置き場
50
+ * (`.md`・`Days/`・`story-days.json`・`.cortex/`)。課題番号(`#123`)・コミットの ID(16 進)・拡張子・パス・設定キーは
51
+ * **engine の門が行ごと落としてから書く**(`scripts/story/gates.mjs` の `MECHANISM_PATTERNS`・G-S3)ので、画面で重ねない。
52
+ * 重ねると、門を通った正当な行(`COVID-19`・`ISO-27001`・`A-1 案`)まで画面が黙って消す(実装レビュー 🟠1)
53
+ */
54
+ const MACHINE_WORD_RE = /urn:|cortex-engine|sha=|<!--|-->|\.md\b|Days\/|story-days\.json|\.cortex\//;
55
+ /** 見出しの行(`##`〜`######`) */
56
+ const SUBHEADING_RE = /^#{2,6}\s/;
57
+ /** 伏せて本文が空になった日の 1 行 */
58
+ exports.STORY_EMPTY_LINE = "この日は表示できる行がありません。";
59
+ /** 1 枚の上限(これを超えるファイルは読まない・分類 B。engine の 1 枚は 4 KiB + 人の追記) */
60
+ exports.STORY_MAX_FILE_BYTES = 64 * 1024;
61
+ /** 帳簿を読む(投げない)。無い → `{ missing }`、壊れ・版違い → `{ broken }` */
62
+ function parseStoryLedger(text) {
63
+ try {
64
+ const parsed = JSON.parse(text);
65
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
66
+ return "broken";
67
+ const o = parsed;
68
+ if (o.version !== 1)
69
+ return "broken";
70
+ if (!o.days || typeof o.days !== "object" || Array.isArray(o.days))
71
+ return "broken";
72
+ return { days: o.days };
73
+ }
74
+ catch {
75
+ return "broken";
76
+ }
77
+ }
78
+ /** 帳簿で `mode: "on"` の日 */
79
+ function onDaysOf(ledger) {
80
+ const out = new Set();
81
+ for (const [day, row] of Object.entries(ledger.days)) {
82
+ if (!DAY_RE.test(day) || !row || typeof row !== "object")
83
+ continue;
84
+ if (row.mode === "on")
85
+ out.add(day);
86
+ }
87
+ return out;
88
+ }
89
+ /** 曜日つきの見出し(`# 2026-09-24(木)`)の文字。無ければ日付だけ */
90
+ function headingOf(lines, day) {
91
+ for (const l of lines) {
92
+ const m = l.match(/^#\s+(.+?)\s*$/);
93
+ if (m && m[1].startsWith(day) && !MACHINE_WORD_RE.test(m[1]))
94
+ return m[1];
95
+ if (l.trim() !== "")
96
+ break;
97
+ }
98
+ return day;
99
+ }
100
+ /** 中身の無い見出し(次の見出しか終わりまで空行しか無い)を落とす(伏せた結果「後から届いた記録」の見出しだけが残る、を防ぐ) */
101
+ function withoutEmptySections(lines) {
102
+ return lines.filter((l, i) => {
103
+ if (!SUBHEADING_RE.test(l))
104
+ return true;
105
+ for (let j = i + 1; j < lines.length; j++) {
106
+ if (SUBHEADING_RE.test(lines[j]))
107
+ return false;
108
+ if (lines[j].trim() !== "")
109
+ return true;
110
+ }
111
+ return false;
112
+ });
113
+ }
114
+ /**
115
+ * Days 1 枚の本文を、画面に出す Markdown にする(純関数)。
116
+ *
117
+ * - 先頭の `# 日付(曜)` は見出しとして別に使うので本文から除く
118
+ * - 印の行は捨てる。`## 人の追記` の下(後から届いた記録の塊を除く)が空なら見出しごと出さない
119
+ * - 人の追記の後ろに足された「後から届いた記録」(人が機械の塊を直した日)は、人の追記より前に寄せる
120
+ * - 機械の語が紛れた行は行ごと伏せる(機械の行だけ)。中身の無い見出しは落とし、空になった日は 1 行の案内にする
121
+ */
122
+ function storyDayMarkdown(content, day) {
123
+ const lines = String(content ?? "").replace(/^\ufeff/, "").split("\n").map((l) => l.replace(/\r$/, ""));
124
+ const heading = headingOf(lines, day);
125
+ // 先頭の `# ` 見出し(最初の空でない行だけ)を除く
126
+ const first = lines.findIndex((l) => l.trim() !== "");
127
+ const body = first >= 0 && /^#\s/.test(lines[first]) ? lines.slice(first + 1) : lines;
128
+ const at = body.findIndex((l) => l.trim() === HUMAN_HEADING);
129
+ const main = at < 0 ? body : body.slice(0, at);
130
+ const tail = at < 0 ? [] : body.slice(at + 1);
131
+ // 人の追記の後ろの塊(後から届いた記録)と人の行を分ける
132
+ const human = [];
133
+ const lateAfter = [];
134
+ let inLate = false;
135
+ for (const l of tail) {
136
+ const t = l.trim();
137
+ if (LATE_BEGIN_RE.test(t)) {
138
+ inLate = true;
139
+ lateAfter.push("");
140
+ continue;
141
+ }
142
+ if (inLate) {
143
+ if (LATE_END_RE.test(t)) {
144
+ inLate = false;
145
+ continue;
146
+ }
147
+ lateAfter.push(l);
148
+ continue;
149
+ }
150
+ human.push(l);
151
+ }
152
+ // 機械の行: 印と機械の語の行を伏せる
153
+ const clean = (ls) => ls.filter((l) => !STORY_MARKER_RE.test(l.trim()) && !MACHINE_WORD_RE.test(l));
154
+ // 🔴 人の追記: 落とすのは**印の行だけ**(人が書いた行を画面が黙って消さない・実装レビュー 🟠2)
155
+ const humanKept = human.filter((l) => !STORY_MARKER_RE.test(l.trim()));
156
+ const out = withoutEmptySections([...clean(main), ...clean(lateAfter)]);
157
+ if (humanKept.some((l) => l.trim() !== ""))
158
+ out.push("", HUMAN_HEADING, ...humanKept);
159
+ // 空行の続きを 1 行に・前後の空行を落とす
160
+ const md = out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
161
+ // 伏せて空になった日は、見出しだけのカードにしない
162
+ return { heading: heading, markdown: md || exports.STORY_EMPTY_LINE };
163
+ }
164
+ /**
165
+ * 案件のストーリーを読む。**載せる日が無ければ `null`**(タブも器も出さない)。
166
+ *
167
+ * @param renderMarkdown 本文の関所(build の marked → sanitize)。ここでは HTML を作らない
168
+ */
169
+ async function readStory(repoRoot, renderMarkdown,
170
+ // その日のファイルを GitHub で開く URL(リポジトリ相対パス → URL。リポジトリの URL が無い案件は null)
171
+ editUrlOf = () => null) {
172
+ let text;
173
+ try {
174
+ text = await node_fs_1.promises.readFile(node_path_1.default.join(repoRoot, exports.STORY_LEDGER_PATH), "utf8");
175
+ }
176
+ catch (e) {
177
+ // 無いのが既定(ストーリーの書き手の栓を入れた案件だけが持つ)。無いことは警告しない
178
+ if (e?.code !== "ENOENT") {
179
+ console.warn(`story-days.json を読めませんでした(ストーリーは出さずに続けます): ${String(e)}`);
180
+ }
181
+ return null;
182
+ }
183
+ // 空の帳簿(空文字・空白だけ)は engine と同じく「空」=無いのと同じ(警告しない)
184
+ if (text.trim() === "")
185
+ return null;
186
+ const ledger = parseStoryLedger(text);
187
+ if (ledger === "broken") {
188
+ // 壊れ・版違いは 1 行残す(「夜間がまだ書いていない」と切り分けるため)
189
+ console.warn("story-days.json の形式か version が想定と違います(ストーリーは出さずに続けます)");
190
+ return null;
191
+ }
192
+ const on = onDaysOf(ledger);
193
+ if (!on.size)
194
+ return null;
195
+ const root = node_path_1.default.join(repoRoot, exports.STORY_DAYS_DIR);
196
+ let months;
197
+ try {
198
+ months = (await node_fs_1.promises.readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory() && MONTH_RE.test(d.name)).map((d) => d.name);
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ const files = [];
204
+ for (const m of months) {
205
+ let names;
206
+ try {
207
+ names = await node_fs_1.promises.readdir(node_path_1.default.join(root, m));
208
+ }
209
+ catch {
210
+ continue;
211
+ }
212
+ for (const n of names) {
213
+ const day = n.endsWith(".md") ? n.slice(0, -3) : "";
214
+ // ファイル名が日付の形で、月の置き場と合い、帳簿で on の日だけ(README などは出さない)
215
+ if (!DAY_RE.test(day) || day.slice(0, 7) !== m || !on.has(day))
216
+ continue;
217
+ const abs = node_path_1.default.join(root, m, n);
218
+ // 🔴 通常のファイルだけ(リンクは辿らない)・1 枚の上限を超えるものは読まない(分類 B)
219
+ try {
220
+ const st = await node_fs_1.promises.lstat(abs);
221
+ if (!st.isFile() || st.size > exports.STORY_MAX_FILE_BYTES)
222
+ continue;
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ files.push({ day: day, abs: abs, rel: `${exports.STORY_DAYS_DIR}/${m}/${n}` });
228
+ }
229
+ }
230
+ files.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0));
231
+ const out = [];
232
+ for (const f of files.slice(0, exports.STORY_MAX_DAYS)) {
233
+ let raw;
234
+ try {
235
+ raw = await node_fs_1.promises.readFile(f.abs, "utf8");
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ const { heading, markdown } = storyDayMarkdown(raw, f.day);
241
+ const editUrl = editUrlOf(f.rel);
242
+ out.push({ date: f.day, heading: heading, bodyHtml: await renderMarkdown(markdown), ...(editUrl ? { editUrl: editUrl } : {}) });
243
+ }
244
+ return out.length ? out : null;
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.96",
3
+ "version": "0.12.98",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,