skills-viewer 0.7.0 → 0.8.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # skills-viewer
2
2
 
3
- Browse, search and manage your [Claude Code](https://code.claude.com) skills, commands, agents and hooks in the browser.
3
+ Browse, search and manage your [Claude Code](https://code.claude.com) skills, commands, agents, hooks and auto memory in the browser.
4
4
 
5
5
  Scans every project registered in `~/.claude.json` (plus user scope, plugins and built-ins), and serves a local web UI to explore them — with usage stats, AI-generated summaries, full SKILL.md rendering, same-name diffs, and copy/delete management.
6
6
 
@@ -19,11 +19,12 @@ npx skills-viewer
19
19
  - **All scopes in one view** — user (`~/.claude/skills`), every project's `.claude/skills` / `.claude/commands`, installed plugins, and Claude Code built-ins, grouped by source
20
20
  - **Purpose grouping (AI)** — one haiku call classifies everything installed by _when you use it_ into 4–8 groups generated for your environment (planning / building / review / release / … as a role-agnostic guide — a designer's or PM's skills get their own groups). Switch the list between by-source, by-purpose and flat views; a frontmatter `category:` pins an item to a manual group that takes precedence
21
21
  - **Search / sort** — incremental search over name + description + usage; sort by name, usage count, last used, updated date, or token cost
22
- - **Diagnostics** — an _unused_ badge (no recorded use within the transcript retention window) with an all / used / unused filter, plus static description lint: missing / too-short / too-long descriptions, missing trigger conditions ("use when …") that make auto-invocation unlikely, and name-echo descriptions
22
+ - **Diagnostics** — a _no recent use_ badge (no recorded use within the transcript retention window) with an all / recent use / no recent use filter, plus static description lint: missing / too-short / too-long descriptions, missing trigger conditions ("use when …") that make auto-invocation unlikely, and name-echo descriptions
23
23
  - **Token cost** — since every name + description is injected into each session, the estimated token overhead is shown per item, per scope, and as a per-session total for the current project
24
24
  - **AI trigger diagnosis** — one click asks the model whether the description is likely to trigger auto-invocation, lists concrete issues, and proposes an improved description you can apply with one click (cached by content hash)
25
25
  - **AI flow diagram** — extract the processing flow of orchestration-style skills (steps, branches, delegations, human gates) from the definition body and render it as a step diagram; delegated skills are clickable
26
26
  - **AI model choice** — pick the model behind all AI features (haiku default / sonnet / opus) in settings; aliases are resolved by your claude CLI
27
+ - **Memory triage** — a _Memory_ view lists Claude Code's auto memory (`~/.claude/projects/<project>/memory/`) per project with its context cost split into the always-on part (the `MEMORY.md` index line injected into every session) and the pay-per-use part (the body, read on demand), plus Read / Write counts from transcripts, `[[link]]` resolution and backlinks. **AI triage** reads every memory of a project with the claude CLI (one call, split into a few for very large projects) and proposes a destination per memory — keep / shrink / move to CLAUDE.md / move to docs / move to a skill / delete / wrong project — with the reasoning and a paste-ready instruction for Claude Code (it is asked to always cover removing the `MEMORY.md` index line and re-pointing `[[link]]`s). Each memory is first judged for freshness (current / outdated / historical / obsolete) from mechanical signals — dates in the body, missing paths, merged or deleted branches, references to another project — and a _rewrite the body_ verdict covers memories whose gist still holds; the triage also flags when the `MEMORY.md` index line disagrees with the body. For feedback memories the instruction is built from a fixed template, so it reads the same on every model. The viewer never writes to memory: you paste the instruction into Claude Code, which inspects, asks when unsure, and executes after your approval
27
28
  - **Edit in the browser** — inline editor for SKILL.md / commands / agents (project & user scopes) with mtime conflict detection and a one-generation backup in `~/.cache/skills-viewer/backups/`
28
29
  - **What's changed** — a banner shows items added / updated / removed since your last launch (baseline advances only when you dismiss it); the CLI prints a one-line summary at startup too
29
30
  - **Usage sparkline** — the detail pane charts the last 30 days of per-day usage
@@ -35,7 +36,7 @@ npx skills-viewer
35
36
  - **Same-name diff** — when the same skill name exists in multiple scopes, the detail page lists the other definitions and shows a line diff between them
36
37
  - **Open in editor** — via URL scheme (VS Code / Cursor / Zed / Windsurf / custom, configurable in the ⚙ settings modal), or the OS default opener
37
38
  - **English / 日本語** — UI language auto-detected from the browser and switchable in settings; AI summaries are generated in the selected language (CLI messages follow `LANG`)
38
- - **URL routing** — `/skills/:id?tab=md`, search/sort/grouping in query params; links are shareable across reloads
39
+ - **URL routing** — `/skills/:id?tab=md`, `/memory/:id`, `/memory/triage/:project`, search/sort/grouping in query params; links are shareable across reloads
39
40
 
40
41
  ## Usage
41
42
 
@@ -77,11 +78,12 @@ dist/ # prebuilt UI shipped in the npm package (generated by prepack
77
78
 
78
79
  ## Notes
79
80
 
80
- - Usage stats only cover the transcript retention window of Claude Code (`cleanupPeriodDays`, default 30 days) — the _unused_ badge has the same limitation
81
+ - Usage stats only cover the transcript retention window of Claude Code (`cleanupPeriodDays`, default 30 days) — the _no recent use_ badge and memory Read counts have the same limitation (projects without transcripts show no usage columns at all)
81
82
  - Token costs are heuristic estimates (≈4 chars/token for ASCII, ≈1.5 for CJK), not exact tokenizer counts
82
83
  - Usage is attributed per calling project (worktrees roll up to their parent project by encoded-path prefix). When scopes share a name, the resolution order project > user > plugin > built-in is assumed
83
84
  - The built-in skill list is hardcoded in `server/scan.js` (they live inside the Claude Code binary); check `/skills` inside Claude Code for the authoritative list
84
85
  - AI summaries require a logged-in `claude` CLI
86
+ - Memory triage sends each memory's body to the claude CLI, together with the project's `MEMORY.md` index, the **headings** of `CLAUDE.md` / `.claude/CLAUDE.md` / `~/.claude/CLAUDE.md` and the names + descriptions of the skills available to that project (never their bodies) so it can spot duplicates and promotion targets. Branch status for the freshness signals comes from local `git` (no network). Results are cached per memory by content hash (plus the index line); memory files themselves are never modified
85
87
 
86
88
  ## License
87
89
 
package/build/cli.js CHANGED
File without changes
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ /*
3
+ * AI 機能のキャッシュ(~/.cache/skills-viewer/*.json)の共通処理。
4
+ * どのキャッシュも「実パス → エントリ」の形なので、掃除の規則を 1 箇所に置く。
5
+ */
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.pruneMissing = pruneMissing;
41
+ const fs = __importStar(require("node:fs"));
42
+ /*
43
+ * 消えたファイル(削除・リネーム・別プロジェクトの撤去)のエントリを落とす。
44
+ * 呼ぶのは保存時だけにする — GET(読み取り API)で書き込みを発生させないため。
45
+ */
46
+ function pruneMissing(store) {
47
+ const out = {};
48
+ for (const [key, value] of Object.entries(store)) {
49
+ if (fs.existsSync(key))
50
+ out[key] = value;
51
+ }
52
+ return out;
53
+ }
@@ -45,6 +45,7 @@ exports.attachDiagnoses = attachDiagnoses;
45
45
  const fs = __importStar(require("node:fs"));
46
46
  const os = __importStar(require("node:os"));
47
47
  const path = __importStar(require("node:path"));
48
+ const cache_1 = require("./cache");
48
49
  const summary_1 = require("./summary");
49
50
  const DIAG_FILE = path.join(os.homedir(), '.cache', 'skills-viewer', 'diagnoses.json');
50
51
  function loadDiagnoses() {
@@ -57,7 +58,8 @@ function loadDiagnoses() {
57
58
  }
58
59
  function saveDiagnoses(store) {
59
60
  fs.mkdirSync(path.dirname(DIAG_FILE), { recursive: true });
60
- fs.writeFileSync(DIAG_FILE, JSON.stringify(store, null, 1));
61
+ // 保存のついでに死にエントリを掃除する(GET では書き込まないので掃除もしない)
62
+ fs.writeFileSync(DIAG_FILE, JSON.stringify((0, cache_1.pruneMissing)(store), null, 1));
61
63
  }
62
64
  function buildPrompt(name, content, lang) {
63
65
  if (lang === 'ja') {
@@ -45,6 +45,7 @@ exports.attachFlows = attachFlows;
45
45
  const fs = __importStar(require("node:fs"));
46
46
  const os = __importStar(require("node:os"));
47
47
  const path = __importStar(require("node:path"));
48
+ const cache_1 = require("./cache");
48
49
  const summary_1 = require("./summary");
49
50
  const FLOW_FILE = path.join(os.homedir(), '.cache', 'skills-viewer', 'flows.json');
50
51
  /*
@@ -63,7 +64,8 @@ function loadFlows() {
63
64
  }
64
65
  function saveFlows(store) {
65
66
  fs.mkdirSync(path.dirname(FLOW_FILE), { recursive: true });
66
- fs.writeFileSync(FLOW_FILE, JSON.stringify(store, null, 1));
67
+ // 保存のついでに死にエントリを掃除する(GET では書き込まないので掃除もしない)
68
+ fs.writeFileSync(FLOW_FILE, JSON.stringify((0, cache_1.pruneMissing)(store), null, 1));
67
69
  }
68
70
  function buildPrompt(name, content, lang) {
69
71
  if (lang === 'ja') {
@@ -46,12 +46,14 @@ const crypto = __importStar(require("node:crypto"));
46
46
  const node_child_process_1 = require("node:child_process");
47
47
  const scan_1 = require("./scan");
48
48
  const usage_1 = require("./usage");
49
+ const memory_1 = require("./memory");
49
50
  const summary_1 = require("./summary");
50
51
  const manage_1 = require("./manage");
51
52
  const edit_1 = require("./edit");
52
53
  const diagnose_1 = require("./diagnose");
53
54
  const flow_1 = require("./flow");
54
55
  const groups_1 = require("./groups");
56
+ const memory_triage_1 = require("./memory-triage");
55
57
  const snapshot_1 = require("./snapshot");
56
58
  const errors_1 = require("./errors");
57
59
  const locale_1 = require("./locale");
@@ -134,6 +136,41 @@ function attributeUsage(sections) {
134
136
  }
135
137
  return Object.keys(byDir).length > 0;
136
138
  }
139
+ /*
140
+ * memory の Read(参照)/ Write・Edit(作成・更新)実績を付与する。
141
+ * skill と違って帰属先の解決は不要で、Read の file_path がそのまま実ファイルを指す。
142
+ * usageAvailable は「そのプロジェクトのトランスクリプトがあるか」= エンコード名で始まる
143
+ * ディレクトリ(worktree 分を含む)に jsonl が 1 件以上あるか。false なら Read 列は出さない。
144
+ */
145
+ function attributeMemoryUsage(memory) {
146
+ if (!memory.length)
147
+ return;
148
+ const { byPath, dirsWithTranscripts } = (0, usage_1.scanMemoryUsage)();
149
+ for (const sec of memory) {
150
+ sec.usageAvailable = (0, usage_1.hasTranscripts)(dirsWithTranscripts, sec.id);
151
+ for (const it of sec.items) {
152
+ const u = byPath[it.path];
153
+ if (!u)
154
+ continue;
155
+ if (u.reads > 0) {
156
+ it.useCount = u.reads;
157
+ it.lastUsed = u.lastRead;
158
+ it.dailyUse = u.daily;
159
+ }
160
+ if (u.writes > 0)
161
+ it.writeCount = u.writes;
162
+ }
163
+ }
164
+ }
165
+ /*
166
+ * 実績付きの memory セクション一覧。/api/skills だけでなく /api/memory-triage からも
167
+ * 同じ事実(Read / W-E / usageAvailable)をプロンプトに載せる必要があるので共通化する。
168
+ */
169
+ function memorySections(cwd) {
170
+ const memory = (0, memory_1.scanMemory)(cwd);
171
+ attributeMemoryUsage(memory);
172
+ return memory;
173
+ }
137
174
  function collect(cwd, lang) {
138
175
  const sections = (0, scan_1.scanSections)(cwd, lang);
139
176
  const usageAvailable = attributeUsage(sections);
@@ -162,6 +199,9 @@ function collect(cwd, lang) {
162
199
  (0, flow_1.attachFlows)(sections, lang);
163
200
  const grp = (0, groups_1.attachGroups)(sections, lang);
164
201
  const aiStale = (0, summary_1.staleItems)(sections, lang).length;
202
+ // memory は「呼び出す」ものではないので sections には混ぜず、別配列で同乗させる
203
+ const memory = memorySections(cwd);
204
+ (0, memory_triage_1.attachMemoryTriage)(memory, lang);
165
205
  const targets = [
166
206
  { label: 'user skills', sub: '~/.claude/skills/', path: scan_1.HOME },
167
207
  ...(0, scan_1.listProjects)(cwd)
@@ -178,6 +218,7 @@ function collect(cwd, lang) {
178
218
  changes: (0, snapshot_1.computeChanges)(sections),
179
219
  ...(grp.groups ? { groups: grp.groups } : {}),
180
220
  ...(grp.stale ? { groupsStale: true } : {}),
221
+ ...(memory.length ? { memory } : {}),
181
222
  };
182
223
  }
183
224
  /* DNS rebinding 対策: same-origin GET には Origin が付かないため Host 側も検証する */
@@ -291,6 +332,26 @@ function handleApi(req, res, cwd) {
291
332
  .catch((e) => send(400, (0, errors_1.toErrorBody)(e)));
292
333
  return;
293
334
  }
335
+ if (url.pathname === '/api/memory-triage') {
336
+ // 1 プロジェクト分をまとめて 1 回の claude 呼び出しで棚卸しする(結果は再取得で反映)
337
+ const project = String(data.project || '');
338
+ const sec = memorySections(cwd).find((s) => s.id === project);
339
+ if (!sec)
340
+ throw new errors_1.ApiError('not-found', project);
341
+ const files = Array.isArray(data.files)
342
+ ? data.files.filter((f) => typeof f === 'string')
343
+ : undefined;
344
+ // sections は「CLAUDE.md / skill に既に書いてある」「skill へ昇格」を判定させる文脈。
345
+ // AI を呼ぶときだけ要るので、フルスキャンは関数で渡して遅延させる
346
+ (0, memory_triage_1.triageProject)(sec, lang, model, {
347
+ force: !!data.force,
348
+ files,
349
+ sections: () => (0, scan_1.scanSections)(cwd, lang),
350
+ })
351
+ .then((results) => send(200, { ok: true, results }))
352
+ .catch((e) => send(400, (0, errors_1.toErrorBody)(e)));
353
+ return;
354
+ }
294
355
  if (url.pathname === '/api/summarize') {
295
356
  const real = (0, manage_1.assertReadableMd)(data.src);
296
357
  // refs(関係候補)はスキャン結果から復元する
@@ -332,7 +393,7 @@ function serveStatic(req, res) {
332
393
  }
333
394
  /*
334
395
  * 起動時の1〜2行サマリー(--no-open 運用でも価値が出るように)。
335
- * 前回からの差分 + セッション注入トークン概算 + 未使用件数。失敗しても起動は止めない。
396
+ * 前回からの差分 + セッション注入トークン概算 + 直近未使用の件数。失敗しても起動は止めない。
336
397
  */
337
398
  function printStartupSummary(cwd) {
338
399
  try {
@@ -350,8 +411,8 @@ function printStartupSummary(cwd) {
350
411
  .length
351
412
  : null;
352
413
  console.log((0, locale_1.srvMsg)(`スキル定義のセッション注入 ≈${sessionTokens.toLocaleString()}tok` +
353
- (unused !== null ? ` / 未使用 ${unused} 件` : ''), `Skill definitions inject ≈${sessionTokens.toLocaleString()} tok/session` +
354
- (unused !== null ? ` / ${unused} unused` : '')));
414
+ (unused !== null ? ` / 直近未使用 ${unused} 件` : ''), `Skill definitions inject ≈${sessionTokens.toLocaleString()} tok/session` +
415
+ (unused !== null ? ` / ${unused} with no recent use` : '')));
355
416
  }
356
417
  catch {
357
418
  /* サマリーは補助情報。失敗しても起動を妨げない */
@@ -0,0 +1,328 @@
1
+ "use strict";
2
+ /*
3
+ * memory の鮮度(state)を判定するための機械シグナル。事実を拾うだけで行き先は決めない。
4
+ *
5
+ * 2 層に分ける:
6
+ * - テキスト / fs 層(extractSignals): 本文の絶対日付・参照パスの実在・完了語。
7
+ * 正規表現と existsSync だけなので /api/skills のスキャン時に毎回計算してよい
8
+ * - git 層(loadBranches + branchSignals): 本文に出るブランチ名のマージ状況。
9
+ * git の spawn を伴うので棚卸し診断の call 時にだけ計算する(起動経路に入れない)
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.latestDate = latestDate;
46
+ exports.missingPaths = missingPaths;
47
+ exports.doneWords = doneWords;
48
+ exports.extractSignals = extractSignals;
49
+ exports.otherProjectRefs = otherProjectRefs;
50
+ exports.parseFeedbackParts = parseFeedbackParts;
51
+ exports.dice2gram = dice2gram;
52
+ exports.episodicTokens = episodicTokens;
53
+ exports.feedbackSignals = feedbackSignals;
54
+ exports.loadBranches = loadBranches;
55
+ exports.branchSignals = branchSignals;
56
+ const node_child_process_1 = require("node:child_process");
57
+ const fs = __importStar(require("node:fs"));
58
+ const path = __importStar(require("node:path"));
59
+ const scan_1 = require("./scan");
60
+ const MAX_MISSING_PATHS = 3;
61
+ /* 完了・廃止を表す語。ASCII は単語境界つき・大文字小文字無視、日本語はそのまま部分一致 */
62
+ const DONE_WORDS_JA = ['完了', 'マージ済', '対応済', 'リリース済', '廃止'];
63
+ const DONE_WORDS_EN = ['merged', 'done', 'completed', 'deprecated', 'resolved'];
64
+ /*
65
+ * 本文中の絶対日付(YYYY-MM-DD / YYYY/MM/DD / YYYY年M月D日)のうち最新のもの。
66
+ * memory の運用ルールで相対日付は絶対日付に直されているので、本文の日付がその記述の「いつ」を表す。
67
+ * 未来すぎる日付(1 年超)は誤検出として除く。
68
+ */
69
+ function latestDate(body, now) {
70
+ let best = null;
71
+ for (const m of body.matchAll(/(20\d{2})[-/年](\d{1,2})[-/月](\d{1,2})日?/g)) {
72
+ const y = Number(m[1]);
73
+ const mo = Number(m[2]);
74
+ const d = Number(m[3]);
75
+ if (mo < 1 || mo > 12 || d < 1 || d > 31)
76
+ continue;
77
+ const ms = Date.UTC(y, mo - 1, d);
78
+ if (ms > now + 366 * 86400000)
79
+ continue;
80
+ if (!best || ms > best.ms) {
81
+ best = { value: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, ms };
82
+ }
83
+ }
84
+ if (!best)
85
+ return null;
86
+ return { value: best.value, days: Math.max(0, Math.floor((now - best.ms) / 86400000)) };
87
+ }
88
+ /*
89
+ * 本文が参照するファイルパスのうち存在しないもの。
90
+ * 対象は「/ を含み、末尾が拡張子つきのファイル名」に限る(URL・ブランチ名・パッケージ名を拾わない)。
91
+ * 相対パスは projectPath 基準。projectPath が無い(孤児)なら絶対パスと ~/ だけを見る。
92
+ */
93
+ function missingPaths(body, projectPath, home) {
94
+ const out = [];
95
+ const seen = new Set();
96
+ const re = /(?:^|[\s(`「'"<[])((?:~\/|\.{1,2}\/|\/)?[\w@.-]+(?:\/[\w@.-]+)+\.[A-Za-z0-9]{1,8})(?=$|[\s)`」'">\],:;。、])/gm;
97
+ for (const m of body.matchAll(re)) {
98
+ const raw = m[1];
99
+ if (seen.has(raw))
100
+ continue;
101
+ seen.add(raw);
102
+ // `foo.com/bar.js` のようなドメイン風は除く(ホスト名にドットを含む先頭セグメント)
103
+ const first = raw.replace(/^(?:~\/|\.{1,2}\/|\/)/, '').split('/')[0];
104
+ if (/\.[a-z]{2,}$/i.test(first) && !raw.startsWith('/') && !raw.startsWith('~/'))
105
+ continue;
106
+ let resolved;
107
+ if (raw.startsWith('~/'))
108
+ resolved = path.join(home, raw.slice(2));
109
+ else if (raw.startsWith('/'))
110
+ resolved = raw;
111
+ else if (projectPath)
112
+ resolved = path.resolve(projectPath, raw);
113
+ else
114
+ continue;
115
+ if (!fs.existsSync(resolved)) {
116
+ out.push(raw);
117
+ if (out.length >= MAX_MISSING_PATHS)
118
+ break;
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ /* description + 本文に含まれる完了語(重複排除、最大 3 語) */
124
+ function doneWords(text) {
125
+ const found = [];
126
+ for (const w of DONE_WORDS_JA)
127
+ if (text.includes(w))
128
+ found.push(w);
129
+ for (const w of DONE_WORDS_EN) {
130
+ if (new RegExp('\\b' + w + '\\b', 'i').test(text))
131
+ found.push(w);
132
+ }
133
+ return found.slice(0, 3);
134
+ }
135
+ /* テキスト / fs 層のシグナルをまとめて計算する(スキャン時) */
136
+ function extractSignals(body, description, projectPath, opts = {}) {
137
+ const now = opts.now ?? Date.now();
138
+ const home = opts.home ?? scan_1.HOME;
139
+ const out = [];
140
+ const date = latestDate(body, now);
141
+ if (date)
142
+ out.push({ kind: 'date', value: date.value, days: date.days });
143
+ for (const p of missingPaths(body, projectPath, home))
144
+ out.push({ kind: 'path-missing', value: p });
145
+ const words = doneWords(description + '\n' + body);
146
+ if (words.length)
147
+ out.push({ kind: 'done-words', value: words.join(', ') });
148
+ if (opts.memoryType === 'feedback' || opts.memoryType === 'user') {
149
+ out.push(...feedbackSignals(body, description, opts.bodyTokens ?? 0));
150
+ }
151
+ for (const p of otherProjectRefs(body, home, opts.otherProjects || []))
152
+ out.push({ kind: 'other-project', value: p });
153
+ return out;
154
+ }
155
+ /*
156
+ * 本文が別の登録プロジェクトの配下パス(絶対 / ~/)を指しているか。
157
+ * 「別プロジェクトの話が混入した memory」の機械的な根拠で、置き場所(wrong-project)の判断材料になる。
158
+ * 呼び出し側で自分自身と worktree 関係のプロジェクトは除いて渡す。
159
+ */
160
+ function otherProjectRefs(body, home, others) {
161
+ if (!others.length)
162
+ return [];
163
+ const hit = new Set();
164
+ for (const m of body.matchAll(/(~\/[^\s))」'"`<>]+|\/[\w.@-]+(?:\/[\w.@-]+)+)/g)) {
165
+ const raw = m[1].replace(/[/.,:;。、))」]+$/, '');
166
+ const resolved = raw.startsWith('~/') ? path.join(home, raw.slice(2)) : raw;
167
+ for (const p of others) {
168
+ if (resolved === p || resolved.startsWith(p + path.sep))
169
+ hit.add(path.basename(p));
170
+ }
171
+ }
172
+ return [...hit].slice(0, 2);
173
+ }
174
+ const WHY_RE = /^\s*(?:\*\*)?Why:?(?:\*\*)?:?\s*/im;
175
+ const HOW_RE = /^\s*(?:\*\*)?How to apply:?(?:\*\*)?:?\s*/im;
176
+ /*
177
+ * Claude Code が feedback を書くときの定型(1 行目 / **Why:** / **How to apply:**)に分解する。
178
+ * 見出しが無ければ該当部分は空文字(テンプレートは「無い部分には触れない」)。
179
+ */
180
+ function parseFeedbackParts(body) {
181
+ const whyAt = body.search(WHY_RE);
182
+ const howAt = body.search(HOW_RE);
183
+ const head = body.slice(0, Math.min(...[whyAt, howAt].filter((i) => i >= 0), body.length));
184
+ const rule = head
185
+ .split('\n')
186
+ .map((l) => l.trim())
187
+ .find((l) => l.length > 0) || '';
188
+ let why = '';
189
+ let how = '';
190
+ if (whyAt >= 0) {
191
+ const end = howAt > whyAt ? howAt : body.length;
192
+ why = body.slice(whyAt, end).replace(WHY_RE, '').trim();
193
+ }
194
+ if (howAt >= 0) {
195
+ const end = whyAt > howAt ? whyAt : body.length;
196
+ how = body.slice(howAt, end).replace(HOW_RE, '').trim();
197
+ }
198
+ return { rule, why, how };
199
+ }
200
+ /* 文字 2-gram の Dice 係数(0〜1)。空白・記号を落として比べる。日本語の言い換え検出に十分な粗さ */
201
+ function dice2gram(a, b) {
202
+ const grams = (s) => {
203
+ const t = s.toLowerCase().replace(/[\s\p{P}]/gu, '');
204
+ const set = new Set();
205
+ for (let i = 0; i + 1 < t.length; i++)
206
+ set.add(t.slice(i, i + 2));
207
+ return set;
208
+ };
209
+ const ga = grams(a);
210
+ const gb = grams(b);
211
+ if (!ga.size || !gb.size)
212
+ return 0;
213
+ let hit = 0;
214
+ for (const g of ga)
215
+ if (gb.has(g))
216
+ hit++;
217
+ return (2 * hit) / (ga.size + gb.size);
218
+ }
219
+ const RESTATE_THRESHOLD = 0.3;
220
+ // 実測: 索引 1 行で機能する feedback の本文は 150〜300 tok が多数派。opus が「短い」と評した 166 tok を超えない閾値にする
221
+ const FEEDBACK_BODY_MAX_TOKENS = 200;
222
+ const EXCEPTION_RE = /ただし|例外|除く|除き|unless|except(?!ion)/i;
223
+ const EPISODIC_USER_RE = /ユーザー(?:が|の|から)?\s*(?:指摘|言|依頼|要望)|user (?:said|pointed out|asked)/i;
224
+ const EPISODIC_ISSUE_RE = /#\d{2,}/;
225
+ /* エピソード固有の語(ブランチ名 / #番号 / 日付 / 「ユーザーが指摘」)。why-episodic と whyRewrite の検証で共用 */
226
+ function episodicTokens(text) {
227
+ const out = [];
228
+ for (const m of text.matchAll(BRANCH_RE))
229
+ out.push(m[1]);
230
+ const issue = text.match(EPISODIC_ISSUE_RE);
231
+ if (issue)
232
+ out.push(issue[0]);
233
+ const date = text.match(/20\d{2}[-/年]\d{1,2}[-/月]\d{1,2}日?/);
234
+ if (date)
235
+ out.push(date[0]);
236
+ const user = text.match(EPISODIC_USER_RE);
237
+ if (user)
238
+ out.push(user[0]);
239
+ return [...new Set(out)].slice(0, 4);
240
+ }
241
+ function feedbackSignals(body, description, bodyTokens) {
242
+ const out = [];
243
+ const parts = parseFeedbackParts(body);
244
+ const pct = (x) => Math.round(x * 100) + '%';
245
+ if (parts.rule && description) {
246
+ const sim = dice2gram(parts.rule, description);
247
+ if (sim >= RESTATE_THRESHOLD)
248
+ out.push({ kind: 'first-line-restates', value: pct(sim) });
249
+ }
250
+ if (parts.how && description) {
251
+ const sim = dice2gram(parts.how, description);
252
+ if (sim >= RESTATE_THRESHOLD)
253
+ out.push({ kind: 'how-restates', value: pct(sim) });
254
+ }
255
+ if (parts.why) {
256
+ const ep = episodicTokens(parts.why);
257
+ if (ep.length)
258
+ out.push({ kind: 'why-episodic', value: ep.join(', ') });
259
+ }
260
+ const exLine = body.split('\n').find((l) => EXCEPTION_RE.test(l));
261
+ if (exLine)
262
+ out.push({ kind: 'has-exception', value: exLine.trim().slice(0, 40) });
263
+ if (bodyTokens > FEEDBACK_BODY_MAX_TOKENS)
264
+ out.push({ kind: 'body-over', value: String(bodyTokens) });
265
+ return out;
266
+ }
267
+ function git(projectPath, args) {
268
+ return (0, node_child_process_1.execFileSync)('git', ['-C', projectPath, ...args], {
269
+ encoding: 'utf8',
270
+ timeout: 3000,
271
+ stdio: ['ignore', 'pipe', 'ignore'],
272
+ });
273
+ }
274
+ const normalizeRef = (r) => r.trim().replace(/^origin\//, '');
275
+ /*
276
+ * プロジェクトのブランチ一覧とマージ状況。git リポジトリでない・git が無い・失敗した場合は null
277
+ * (シグナル無しとして扱う。診断を止めない)。
278
+ */
279
+ function loadBranches(projectPath) {
280
+ if (!projectPath)
281
+ return null;
282
+ try {
283
+ const all = new Set(git(projectPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'])
284
+ .split('\n')
285
+ .map(normalizeRef)
286
+ .filter((r) => r && r !== 'HEAD'));
287
+ let defaultBranch = '';
288
+ try {
289
+ defaultBranch = normalizeRef(git(projectPath, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']));
290
+ }
291
+ catch {
292
+ defaultBranch = all.has('main') ? 'main' : all.has('master') ? 'master' : '';
293
+ }
294
+ if (!defaultBranch)
295
+ return { all, merged: new Set(), defaultBranch };
296
+ const merged = new Set(git(projectPath, ['branch', '-a', '--merged', defaultBranch, '--format=%(refname:short)'])
297
+ .split('\n')
298
+ .map(normalizeRef)
299
+ .filter((r) => r && r !== 'HEAD' && r !== defaultBranch));
300
+ return { all, merged, defaultBranch };
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ }
306
+ /*
307
+ * 本文に出るブランチ名らしきトークン(接頭辞 feat/ fix/ 等)。ブランチ名は ASCII 前提。
308
+ * docs/ test/ style/ はディレクトリ名として頻出し(docs/projects-design/、test/contract)、
309
+ * 実測で誤検出になったので接頭辞から外す。末尾が / のものはディレクトリなので拾わない
310
+ * (否定先読みはトークン文字全体を対象にし、短い一致へのバックトラックを防ぐ)
311
+ */
312
+ const BRANCH_RE = /\b((?:feat|feature|fix|bugfix|hotfix|chore|refactor|release|ci|perf)\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)(?![A-Za-z0-9._/-])/g;
313
+ function branchSignals(body, info) {
314
+ const out = [];
315
+ const seen = new Set();
316
+ for (const m of body.matchAll(BRANCH_RE)) {
317
+ const name = m[1].replace(/[.)\]]+$/, ''); // 文末の句読点・閉じ括弧を剥がす
318
+ if (seen.has(name) || name === info.defaultBranch)
319
+ continue;
320
+ seen.add(name);
321
+ if (info.merged.has(name))
322
+ out.push({ kind: 'branch-merged', value: name });
323
+ else if (!info.all.has(name))
324
+ out.push({ kind: 'branch-missing', value: name });
325
+ // 存在して未マージ = 進行中。シグナルは出さない
326
+ }
327
+ return out;
328
+ }