@clubs.place/cli 0.1.17 → 0.1.23

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.
Files changed (53) hide show
  1. package/PROVENANCE.json +55 -48
  2. package/README.md +11 -0
  3. package/bin/clubs-nippo +0 -5
  4. package/bin/clubs-nippo-web.mjs +0 -5
  5. package/bin/clubs-nippo.mjs +1 -526
  6. package/bin/clubs.mjs +643 -0
  7. package/docs/events-schema.md +1 -1
  8. package/package.json +5 -8
  9. package/src/aggregate.mjs +32 -5
  10. package/src/aggregate.slots.test.mjs +23 -0
  11. package/src/autorun.mjs +0 -5
  12. package/src/autorun.test.mjs +0 -5
  13. package/src/cards.mjs +0 -5
  14. package/src/collect.mjs +0 -5
  15. package/src/config.mjs +10 -6
  16. package/src/generators/claude.mjs +0 -5
  17. package/src/generators/cli.mjs +0 -5
  18. package/src/generators/codex.mjs +0 -5
  19. package/src/generators/copilot.mjs +0 -5
  20. package/src/generators/gemini.mjs +0 -5
  21. package/src/generators/openai-compat.mjs +0 -5
  22. package/src/generators/openrouter.mjs +0 -5
  23. package/src/history.mjs +2 -5
  24. package/src/ingest.mjs +0 -5
  25. package/src/lemma.docHash.test.mjs +0 -5
  26. package/src/lemma.mjs +0 -5
  27. package/src/mask.mjs +0 -5
  28. package/src/mcp.mjs +0 -5
  29. package/src/narrative.blockRepos.test.mjs +48 -0
  30. package/src/narrative.mjs +47 -8
  31. package/src/profiles.mjs +110 -0
  32. package/src/profiles.test.mjs +66 -0
  33. package/src/prompt.mjs +99 -24
  34. package/src/prompt.test.mjs +87 -20
  35. package/src/remote-config.mjs +5 -6
  36. package/src/render.mjs +1 -5
  37. package/src/routines.mjs +61 -0
  38. package/src/routines.test.mjs +29 -0
  39. package/src/send.mjs +0 -5
  40. package/src/settings.mjs +0 -5
  41. package/src/sources/claude-code.mjs +0 -5
  42. package/src/sources/codex-cli.mjs +0 -5
  43. package/src/sources/copilot-cli.mjs +0 -5
  44. package/src/sources/cowork.mjs +0 -5
  45. package/src/sources/events-file.mjs +0 -5
  46. package/src/sources/gemini-cli.mjs +0 -5
  47. package/src/sources/hermes.mjs +0 -5
  48. package/src/store.mjs +0 -5
  49. package/src/tasks.mjs +0 -5
  50. package/src/tool-name.mjs +0 -5
  51. package/web/assets/qr.js +0 -5
  52. package/web/assets/sidebar.js +0 -5
  53. package/web/assets/twemoji.js +0 -5
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@clubs.place/cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.23",
4
4
  "description": "AI業務日報 — Claude Code の実行ログから前日ぶんの日報メールを生成して送る(pull型・本文生成はユーザー側LLM・生ログは端末を出ない)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "clubs-place": "bin/clubs-nippo",
9
8
  "clubs-nippo": "bin/clubs-nippo",
9
+ "clubs": "bin/clubs-nippo",
10
+ "clubs-place": "bin/clubs-nippo",
10
11
  "clubs-place-cli": "bin/clubs-nippo"
11
12
  },
12
13
  "files": [
@@ -27,16 +28,12 @@
27
28
  "claude-code"
28
29
  ],
29
30
  "homepage": "https://clubs.place",
30
- "repository": {
31
- "type": "git",
32
- "url": "git+https://github.com/frame00/clubs-nippo.git"
33
- },
34
31
  "engines": {
35
32
  "node": ">=20"
36
33
  },
37
34
  "scripts": {
38
- "nippo": "node ./bin/clubs-nippo.mjs",
39
- "dry": "node ./bin/clubs-nippo.mjs --dry-run",
35
+ "nippo": "node ./bin/clubs.mjs",
36
+ "dry": "node ./bin/clubs.mjs --dry-run",
40
37
  "demo": "node scripts/gen-preview.mjs && node scripts/bundle-demo.mjs",
41
38
  "test": "node --test",
42
39
  "prepublishOnly": "node scripts/gen-provenance.mjs"
package/src/aggregate.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  /**
7
2
  * 収集元が集めた生イベントから、日報に載せる数字を出す。
8
3
  *
@@ -26,6 +21,37 @@ const estimateActiveMinutes = (timestampsMs, idleGapMin) => {
26
21
  return Math.round(total / 60_000);
27
22
  };
28
23
 
24
+ /**
25
+ * 動いていた時間帯(本人 2026-10-17:時間を二重に数えない)。1日を5分ごとの 288 枠に分け、
26
+ * イベントのある枠と、間が idleGapMin 以内のイベントどうしのあいだの枠に印を付ける
27
+ * (estimateActiveMinutes と同じ考え方)。CLUBS は AI ごとの枠を重ねて(和集合で)
28
+ * その日の時間を出す。288 ビットを 16 進 72 文字で送る。時刻は端末の時刻(e.time)。
29
+ */
30
+ export const SLOT_MIN = 5;
31
+ export const activeSlots = (sorted, idleGapMin) => {
32
+ const bits = new Array(288).fill(0);
33
+ const slotOf = (t) => {
34
+ const [h, m] = String(t ?? "").split(":").map(Number);
35
+ if (!Number.isFinite(h) || !Number.isFinite(m)) return -1;
36
+ return Math.min(287, Math.floor((h * 60 + m) / SLOT_MIN));
37
+ };
38
+ const capMs = idleGapMin * 60_000;
39
+ for (let i = 0; i < sorted.length; i += 1) {
40
+ const s = slotOf(sorted[i].time);
41
+ if (s < 0) continue;
42
+ bits[s] = 1;
43
+ const prev = sorted[i - 1];
44
+ if (!prev || sorted[i].ms - prev.ms > capMs) continue;
45
+ const p = slotOf(prev.time);
46
+ if (p < 0 || p > s) continue;
47
+ for (let k = p; k <= s; k += 1) bits[k] = 1;
48
+ }
49
+ let hex = "";
50
+ for (let i = 0; i < 288; i += 4)
51
+ hex += ((bits[i] << 3) | (bits[i + 1] << 2) | (bits[i + 2] << 1) | bits[i + 3]).toString(16);
52
+ return hex;
53
+ };
54
+
29
55
  export const computeAggregates = ({ events, sessions, idleGapMin }) => {
30
56
  // 複数の収集元を合算する場合、イベントは時系列で混ざる。
31
57
  // 実働はマージ後の全イベント列から出す(収集元ごとに出して足すと、
@@ -101,5 +127,6 @@ export const computeAggregates = ({ events, sessions, idleGapMin }) => {
101
127
  projects,
102
128
  firstAt: sorted.at(0)?.time ?? null,
103
129
  lastAt: sorted.at(-1)?.time ?? null,
130
+ activeSlots: activeSlots(sorted, idleGapMin),
104
131
  };
105
132
  };
@@ -0,0 +1,23 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { activeSlots } from "./aggregate.mjs";
4
+
5
+ const ev = (time) => {
6
+ const [h, m] = time.split(":").map(Number);
7
+ return { time, ms: Date.UTC(2026, 9, 16, h, m) };
8
+ };
9
+ const bits = (hex) =>
10
+ [...hex].flatMap((c) => parseInt(c, 16).toString(2).padStart(4, "0").split("").map(Number));
11
+
12
+ test("イベントの枠と、間が idleGap 以内のあいだの枠に印", () => {
13
+ const b = bits(activeSlots([ev("10:00"), ev("10:12"), ev("11:00")], 15));
14
+ assert.equal(b.length, 288);
15
+ const on = b.flatMap((v, i) => (v ? [i] : []));
16
+ // 10:00–10:12 は 120,121,122 枠・11:00 は 132 枠(10:12→11:00 は 15 分超でつながない)
17
+ assert.deepEqual(on, [120, 121, 122, 132]);
18
+ });
19
+
20
+ test("時刻の読めないイベントは数えない・空の日は全部 0", () => {
21
+ assert.equal(activeSlots([], 15), "0".repeat(72));
22
+ assert.equal(activeSlots([{ time: "", ms: 0 }], 15), "0".repeat(72));
23
+ });
package/src/autorun.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { platform as osPlatform } from "node:os";
7
2
 
8
3
  /**
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { test } from "node:test";
7
2
  import assert from "node:assert/strict";
8
3
  import { tickCommand } from "./autorun.mjs";
package/src/cards.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
2
  import { join } from "node:path";
8
3
  import { APP_HOME } from "./config.mjs";
package/src/collect.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import claudeCode from "./sources/claude-code.mjs";
7
2
  import cowork from "./sources/cowork.mjs";
8
3
  import eventsFile from "./sources/events-file.mjs";
package/src/config.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { readFileSync, existsSync } from "node:fs";
7
2
  import { homedir } from "node:os";
8
3
  import { join, dirname } from "node:path";
@@ -24,6 +19,12 @@ export const APP_HOME =
24
19
  ? ROOT
25
20
  : join(homedir(), ".clubs-nippo"));
26
21
 
22
+ /**
23
+ * .env から読み込んだキー(profiles.mjs が子プロセスに渡さないため)。
24
+ * 並べた AI を動かすとき、APP_HOME の鍵を引き継がせない。
25
+ */
26
+ export const LOADED_ENV_KEYS = [];
27
+
27
28
  /** .env は「まだ環境変数に無いキーだけ」入れる。実環境の変数が常に勝つ。 */
28
29
  const loadDotEnv = () => {
29
30
  const path = join(APP_HOME, ".env");
@@ -32,7 +33,10 @@ const loadDotEnv = () => {
32
33
  const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
33
34
  if (!m) continue;
34
35
  const value = m[2].replace(/^["']|["']$/g, "");
35
- if (process.env[m[1]] === undefined) process.env[m[1]] = value;
36
+ if (process.env[m[1]] === undefined) {
37
+ process.env[m[1]] = value;
38
+ LOADED_ENV_KEYS.push(m[1]);
39
+ }
36
40
  }
37
41
  };
38
42
  loadDotEnv();
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { makeCliGenerator } from "./cli.mjs";
7
2
 
8
3
  /**
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { execFile } from "node:child_process";
7
2
  import { promisify } from "node:util";
8
3
  import { ROOT } from "../config.mjs";
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { makeCliGenerator } from "./cli.mjs";
7
2
 
8
3
  /**
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { makeCliGenerator } from "./cli.mjs";
7
2
 
8
3
  /**
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { makeCliGenerator } from "./cli.mjs";
7
2
 
8
3
  /**
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  /**
7
2
  * OpenAI 互換のカスタムエンドポイントで本文を書く(第3の生成バックエンド)。
8
3
  *
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  /**
7
2
  * OpenRouter で本文を書く。
8
3
  *
package/src/history.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
2
  import { join } from "node:path";
8
3
  import { APP_HOME } from "./config.mjs";
@@ -46,6 +41,8 @@ export const appendHistory = (activity) => {
46
41
  failedCalls: a.failedCalls,
47
42
  successRate: a.successRate,
48
43
  activeHours: a.activeHours,
44
+ // 関与プロジェクト(名前だけ・くり返しからルールの候補を出す材料・2026-10-04)。
45
+ projects: a.projects ?? [],
49
46
  toolCounts: a.toolCounts,
50
47
  toolCountsAm: a.toolCountsAm,
51
48
  toolCountsPm: a.toolCountsPm,
package/src/ingest.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  /**
7
2
  * 生成した日報を web コンソール(clubsx)へ取り込ませる。
8
3
  *
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { test } from "node:test";
7
2
  import assert from "node:assert/strict";
8
3
  import { documentHash } from "./lemma.mjs";
package/src/lemma.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { createHash } from "node:crypto";
7
2
 
8
3
  // SDK の動的インポート。無い/読めないときは null → commitDeep/proofs.submit をスキップ。
package/src/mask.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  export const REDACT_TOKEN = "[[REDACTED]]";
7
2
 
8
3
  /**
package/src/mcp.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  /**
7
2
  * CLUBS MCP サーバー(stdio・同一バイナリ同梱=全体設計 §6 / P0 §9)。
8
3
  *
@@ -0,0 +1,48 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { fallbackNarrative, withBlockRepos } from "./narrative.mjs";
4
+ import { documentHash } from "./lemma.mjs";
5
+
6
+ const activity = {
7
+ date: "2026-10-16",
8
+ aggregates: { totalCalls: 3, failedCalls: 0, activeHours: 1, projects: ["lemma", "clubsx"] },
9
+ events: [
10
+ { time: "09:00", name: "Edit", project: "lemma", summary: "a" },
11
+ { time: "09:10", name: "Bash", project: "clubsx", summary: "b" },
12
+ { time: "09:20", name: "Edit", project: "(案件未設定)", summary: "c" },
13
+ ],
14
+ };
15
+
16
+ test("行ごとのフォルダは行から外して blockRepos へ・集計の一覧にある名前だけ", () => {
17
+ const n = withBlockRepos(
18
+ {
19
+ workBlocks: [
20
+ { title: "LP", text: "x", repos: ["lemma", "secret"] },
21
+ { title: "名簿", text: "y", repos: [] },
22
+ { title: "表紙", text: "z" },
23
+ ],
24
+ },
25
+ activity,
26
+ );
27
+ assert.deepEqual(n.blockRepos, [["lemma"], [], []]);
28
+ assert.equal("repos" in n.workBlocks[0], false);
29
+ });
30
+
31
+ test("どの行にもフォルダが無ければ blockRepos を付けない", () => {
32
+ const n = withBlockRepos({ workBlocks: [{ title: "A", text: "x", repos: ["nope"] }] }, activity);
33
+ assert.equal("blockRepos" in n, false);
34
+ });
35
+
36
+ test("docHash は repos を外した行で計算する(前の版と同じ値)", () => {
37
+ const plain = { workBlocks: [{ title: "LP", text: "x" }], handoff: { title: "h", text: "t" }, selfNote: "s" };
38
+ const withRepos = withBlockRepos(
39
+ { ...plain, workBlocks: [{ title: "LP", text: "x", repos: ["lemma"] }] },
40
+ activity,
41
+ );
42
+ assert.equal(documentHash(activity, withRepos), documentHash(activity, plain));
43
+ });
44
+
45
+ test("予備の書き方はフォルダごとの行なので、そのフォルダ名を付ける(未設定は空)", () => {
46
+ const n = fallbackNarrative(activity);
47
+ assert.deepEqual(n.blockRepos, [["lemma"], ["clubsx"], []]);
48
+ });
package/src/narrative.mjs CHANGED
@@ -1,8 +1,3 @@
1
- // clubs-nippo — AI業務日報 (CLUBS)
2
- // Copyright (c) 2026 FRAME00 Inc.
3
- // SPDX-License-Identifier: MIT
4
- // https://github.com/frame00/clubs-nippo
5
-
6
1
  import { buildPrompt } from "./prompt.mjs";
7
2
  import claudeGenerator from "./generators/claude.mjs";
8
3
  import codexGenerator from "./generators/codex.mjs";
@@ -125,7 +120,7 @@ export const fallbackNarrative = (activity, reason = "", generatorId = "") => {
125
120
 
126
121
  const summary = `${a.totalCalls} 件の操作を実行し、失敗は ${a.failedCalls} 件でした。`;
127
122
 
128
- return {
123
+ const narrative = {
129
124
  workBlocks,
130
125
  handoff: { title: "明日にまわすもの", text: "特にありません。" },
131
126
  insights: [],
@@ -136,6 +131,47 @@ export const fallbackNarrative = (activity, reason = "", generatorId = "") => {
136
131
  : summary,
137
132
  generator: reason ? `fallback(${reason})` : "fallback",
138
133
  };
134
+ // 予備の書き方はフォルダ(プロジェクト)ごとに1行なので、その名前がそのまま出どころ。
135
+ return withBlockRepos(
136
+ narrative,
137
+ activity,
138
+ Object.keys(byProject).map((p) => (p === UNSET_PROJECT ? [] : [p])),
139
+ );
140
+ };
141
+
142
+ /**
143
+ * 行ごとの作業したフォルダ(本人 2026-10-17:CLUBS が出どころで業務を推す)。
144
+ *
145
+ * AI には各行に repos(ダイジェストの [ ] のプロジェクト名から選ぶ)を答えさせ、ここで
146
+ * 行から**外して** narrative.blockRepos(workBlocks と同じ順の配列の配列)に移す。
147
+ * docHash の正準形(workBlocks など)には入れない=前の版の日報と照合の値が変わらない。
148
+ * 集計のフォルダの一覧(aggregates.projects・もともと送っている)にある名前だけ残す。
149
+ */
150
+ export const withBlockRepos = (narrative, activity, fallbackRepos = []) => {
151
+ const allowed = new Set(
152
+ (activity?.aggregates?.projects ?? []).map((x) => String(x).trim().toLowerCase()),
153
+ );
154
+ const blockRepos = [];
155
+ const workBlocks = (narrative.workBlocks ?? []).map((wb, i) => {
156
+ const { repos, ...rest } = wb ?? {};
157
+ const raw = Array.isArray(repos) ? repos : (fallbackRepos[i] ?? []);
158
+ blockRepos.push(
159
+ [
160
+ ...new Set(
161
+ raw
162
+ .filter((x) => typeof x === "string")
163
+ .map((x) => x.trim())
164
+ .filter((x) => x && allowed.has(x.toLowerCase())),
165
+ ),
166
+ ].slice(0, 4),
167
+ );
168
+ return rest;
169
+ });
170
+ return {
171
+ ...narrative,
172
+ workBlocks,
173
+ ...(blockRepos.some((r) => r.length) ? { blockRepos } : {}),
174
+ };
139
175
  };
140
176
 
141
177
  /**
@@ -167,8 +203,11 @@ export const generateNarrative = async (activity, config) => {
167
203
  }
168
204
 
169
205
  const prompt = buildPrompt(activity, {
170
- carryover: config.carryover,
206
+ handedOff: config.handedOff,
171
207
  terms: config.terms,
208
+ cases: config.cases,
209
+ decided: config.decided,
210
+ routines: config.routines,
172
211
  });
173
212
 
174
213
  try {
@@ -178,7 +217,7 @@ export const generateNarrative = async (activity, config) => {
178
217
  console.warn(`[narrative] ${generator.id} の応答を解釈できず fallback に切り替えます。`);
179
218
  return fallbackNarrative(activity, `${generator.id} の応答を解釈できず`, generator.id);
180
219
  }
181
- return { ...parsed, generator: generator.label(config) };
220
+ return withBlockRepos({ ...parsed, generator: generator.label(config) }, activity);
182
221
  } catch (e) {
183
222
  // 未インストール(ENOENT)は「その CLI がこの端末に無い」=設定ミスの典型なので明示する。
184
223
  const why =
@@ -0,0 +1,110 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ /**
6
+ * 1台で複数の AI(つなぎ先)を動かす(本人 2026-10-09)。
7
+ *
8
+ * これまで設定は ~/.clubs-nippo/.env の1つだけで、Codex CLI をつなぐと
9
+ * Claude Code の設定(鍵・集める元)が上書きされ、Claude Code の日報が抜けた。
10
+ *
11
+ * - 最初のつなぎ先は今までどおり APP_HOME(~/.clubs-nippo/.env)
12
+ * - 別の AI をつなぐと、APP_HOME/agents/<AIのID>/.env に並べて置く
13
+ * (同じ AI の鍵の再発行・使えなくなった鍵は、今までどおり上書き)
14
+ * - 毎時の tick と手動の実行は、APP_HOME の分のあと、並べた分を1つずつ
15
+ * 子プロセスで動かす(CLUBS_NIPPO_HOME を替えて同じコマンドを走らせる)。
16
+ * 自動実行(launchd / cron)は1本のまま。
17
+ */
18
+
19
+ export const PROFILE_DIR = "agents";
20
+
21
+ /** 子プロセスの目印(子は並べた分をさらに回さない)。 */
22
+ export const CHILD_FLAG = "CLUBS_NIPPO_CHILD";
23
+
24
+ export const profilesDir = (home) => join(home, PROFILE_DIR);
25
+
26
+ /** 並べた設定の置き場所(.env のあるものだけ・名前順)。 */
27
+ export const listProfiles = (home) => {
28
+ const dir = profilesDir(home);
29
+ if (!existsSync(dir)) return [];
30
+ return readdirSync(dir, { withFileTypes: true })
31
+ .filter((e) => e.isDirectory() && existsSync(join(dir, e.name, ".env")))
32
+ .map((e) => join(dir, e.name))
33
+ .sort();
34
+ };
35
+
36
+ /** .env を読む(コメント・空行は飛ばす)。無ければ空。 */
37
+ export const readEnvFile = (path) => {
38
+ if (!existsSync(path)) return {};
39
+ const out = {};
40
+ for (const line of readFileSync(path, "utf8").split("\n")) {
41
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
42
+ if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
43
+ }
44
+ return out;
45
+ };
46
+
47
+ /** ディレクトリ名に使える形(AI の ID は ULID なのでそのまま通る想定)。 */
48
+ const safe = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "agent";
49
+
50
+ /**
51
+ * つなぎ直し(init)の書き先を決める。
52
+ *
53
+ * root … APP_HOME の .env({NIPPO_INGEST_TOKEN, AGENT_ID, …})
54
+ * rootIs … 今の鍵で CLUBS に聞いた結果:
55
+ * { status: 'ok', agentId } / { status: 'dead' }(401・403=もう使えない)
56
+ * / { status: 'unknown' }(届かない・古いサーバで ID が無い)
57
+ * newAgent … 引き換えた AI の ID(古いサーバでは無いこともある)
58
+ * newSource… 接続コマンドの --source(集める元)
59
+ *
60
+ * 上書きしてよいのは:まだ何もつないでいない・同じ AI・今の鍵がもう使えない、の
61
+ * どれか。集める元が今と同じときも上書き——同じ機械の同じ記録を2回読むだけなので
62
+ * 並べる意味が無い(ID を返さない古いサーバでの、同じ AI のつなぎ直しもここで拾う)。
63
+ * それ以外(別の AI・分からない)は並べて置く——分からないときに上書き
64
+ * すると、今動いている AI の日報が黙って止まる(10/8 の事故)。
65
+ */
66
+ export const chooseInitHome = ({ home, root, rootIs, newAgent, newSource, profiles = [] }) => {
67
+ // すでに並べてある同じ AI があれば、そこを更新する。
68
+ const existing = newAgent
69
+ ? profiles.find((p) => readEnvFile(join(p, ".env")).AGENT_ID === newAgent)
70
+ : undefined;
71
+ if (existing) return existing;
72
+ if (!root?.NIPPO_INGEST_TOKEN) return home;
73
+ const rootAgent = root.AGENT_ID || (rootIs?.status === "ok" ? rootIs.agentId : undefined);
74
+ if (newAgent && rootAgent && rootAgent === newAgent) return home;
75
+ if (rootIs?.status === "dead") return home;
76
+ if (newSource && root.SOURCE && root.SOURCE === newSource) return home;
77
+ return join(profilesDir(home), safe(newAgent ?? `agent-${Date.now()}`));
78
+ };
79
+
80
+ /**
81
+ * 子プロセスに渡す環境。APP_HOME の .env から読み込んだ値(鍵・集める元)は
82
+ * 落とす——残すと「実環境の値が勝つ」決まりで、並べた AI が APP_HOME の鍵で
83
+ * 動いてしまう。
84
+ */
85
+ export const childEnv = (env, loadedKeys, home) => {
86
+ const out = { ...env };
87
+ for (const k of loadedKeys) delete out[k];
88
+ out.CLUBS_NIPPO_HOME = home;
89
+ out[CHILD_FLAG] = "1";
90
+ return out;
91
+ };
92
+
93
+ /** 並べた AI を1つずつ動かす(同じ引数で・出力はそのまま流す)。 */
94
+ export const runProfiles = async ({ home, args, loadedKeys, script, node = process.execPath }) => {
95
+ for (const dir of listProfiles(home)) {
96
+ const name = readEnvFile(join(dir, ".env")).SOURCE || dir.split("/").pop();
97
+ console.log(`\n[agents] ${name} の分(${dir})`);
98
+ await new Promise((resolve) => {
99
+ const child = spawn(node, [script, ...args], {
100
+ env: childEnv(process.env, loadedKeys, dir),
101
+ stdio: "inherit",
102
+ });
103
+ child.on("exit", () => resolve());
104
+ child.on("error", (e) => {
105
+ console.log(`[agents] 動かせませんでした(${e?.message ?? e})`);
106
+ resolve();
107
+ });
108
+ });
109
+ }
110
+ };
@@ -0,0 +1,66 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { chooseInitHome, childEnv, listProfiles, CHILD_FLAG } from "./profiles.mjs";
7
+
8
+ const home = () => mkdtempSync(join(tmpdir(), "nippo-"));
9
+
10
+ test("まだ何もつないでいなければ APP_HOME に書く", () => {
11
+ const h = home();
12
+ assert.equal(chooseInitHome({ home: h, root: {}, newAgent: "A" }), h);
13
+ });
14
+
15
+ test("別の AI をつなぐと上書きせず agents/<ID> に並べる(10/8 の事故)", () => {
16
+ const h = home();
17
+ const got = chooseInitHome({
18
+ home: h,
19
+ root: { NIPPO_INGEST_TOKEN: "t" },
20
+ rootIs: { status: "ok", agentId: "CODEX" },
21
+ newAgent: "CLAUDE",
22
+ });
23
+ assert.equal(got, join(h, "agents", "CLAUDE"));
24
+ });
25
+
26
+ test("同じ AI の鍵の再発行・使えなくなった鍵は APP_HOME を上書き", () => {
27
+ const h = home();
28
+ const root = { NIPPO_INGEST_TOKEN: "t" };
29
+ assert.equal(chooseInitHome({ home: h, root, rootIs: { status: "ok", agentId: "A" }, newAgent: "A" }), h);
30
+ assert.equal(chooseInitHome({ home: h, root: { ...root, AGENT_ID: "A" }, rootIs: { status: "unknown" }, newAgent: "A" }), h);
31
+ assert.equal(chooseInitHome({ home: h, root, rootIs: { status: "dead" }, newAgent: "B" }), h);
32
+ });
33
+
34
+ test("確かめられないときは上書きしない(並べる)", () => {
35
+ const h = home();
36
+ const got = chooseInitHome({ home: h, root: { NIPPO_INGEST_TOKEN: "t" }, rootIs: { status: "unknown" }, newAgent: "B" });
37
+ assert.equal(got, join(h, "agents", "B"));
38
+ });
39
+
40
+ test("集める元が今と同じなら上書き(ID を返さない古いサーバでの同じ AI のつなぎ直し)", () => {
41
+ const h = home();
42
+ const root = { NIPPO_INGEST_TOKEN: "t", SOURCE: "claude-code" };
43
+ assert.equal(chooseInitHome({ home: h, root, rootIs: { status: "unknown" }, newAgent: "A", newSource: "claude-code" }), h);
44
+ assert.equal(
45
+ chooseInitHome({ home: h, root, rootIs: { status: "unknown" }, newAgent: "B", newSource: "codex-cli" }),
46
+ join(h, "agents", "B"),
47
+ );
48
+ });
49
+
50
+ test("すでに並べてある同じ AI はそこを更新する", () => {
51
+ const h = home();
52
+ const dir = join(h, "agents", "X");
53
+ mkdirSync(dir, { recursive: true });
54
+ writeFileSync(join(dir, ".env"), "AGENT_ID=B\n");
55
+ assert.deepEqual(listProfiles(h), [dir]);
56
+ const got = chooseInitHome({ home: h, root: { NIPPO_INGEST_TOKEN: "t" }, rootIs: { status: "ok", agentId: "A" }, newAgent: "B", profiles: listProfiles(h) });
57
+ assert.equal(got, dir);
58
+ });
59
+
60
+ test("子プロセスには APP_HOME の .env の値を渡さない", () => {
61
+ const env = childEnv({ NIPPO_INGEST_TOKEN: "root", PATH: "/bin" }, ["NIPPO_INGEST_TOKEN"], "/x");
62
+ assert.equal(env.NIPPO_INGEST_TOKEN, undefined);
63
+ assert.equal(env.PATH, "/bin");
64
+ assert.equal(env.CLUBS_NIPPO_HOME, "/x");
65
+ assert.equal(env[CHILD_FLAG], "1");
66
+ });