sdd-forge 0.1.0-alpha.759 → 0.1.0-alpha.774

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
@@ -129,6 +129,7 @@ See the [configuration reference](docs/configuration.md) for details.
129
129
  | [CLI Command Reference](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/cli_commands.md) | This chapter documents 8 CLI entrypoints and routed commands in the analyzed files: setup, check, check freshness, do… |
130
130
  | [Configuration and Customization](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/configuration.md) | sdd-forge is configured through a single project-level JSON file (.sdd-forge/config.json) and optionally extended by … |
131
131
  | [Internal Design](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/internal_design.md) | This chapter describes the internal structure of sdd-forge: a layered CLI tool built entirely on Node.js built-in mod… |
132
+ | [Preset Creation Guide](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/creating_presets.md) | |
132
133
  <!-- {{/data}} -->
133
134
 
134
135
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdd-forge",
3
- "version": "0.1.0-alpha.759",
3
+ "version": "0.1.0-alpha.774",
4
4
  "description": "Spec-Driven Development tooling for automated documentation generation",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,8 @@
12
12
  },
13
13
  "exports": {
14
14
  ".": "./src/sdd-forge.js",
15
- "./api": "./src/api.js"
15
+ "./api": "./src/api.js",
16
+ "./presets/*": "./src/presets/*"
16
17
  },
17
18
  "files": [
18
19
  "src/",
package/src/AGENTS.md CHANGED
@@ -152,16 +152,12 @@ base → api → graphql
152
152
  ```
153
153
  presets/<key>/
154
154
  ├── preset.json プリセット定義
155
- ├── data/ DataSource クラス群
155
+ ├── data/ DataSource クラス群(scan + resolve を兼ねる)
156
156
  │ ├── config.js 設定解析
157
157
  │ ├── controllers.js コントローラ解析
158
158
  │ └── ...
159
- ├── scan/ scan パーサー群
160
- │ ├── routes.js ルート解析
161
- │ ├── config.js 設定解析
162
- │ └── ...
163
159
  ├── tests/ プリセット固有テスト
164
- │ ├── unit/ ユニットテスト(scan パーサー I/O テスト等)
160
+ │ ├── unit/ ユニットテスト(DataSource match/parse I/O テスト等)
165
161
  │ ├── e2e/ E2E テスト(統合スキャンテスト等)
166
162
  │ └── acceptance/ acceptance テスト(preset ローカル fixture + test.js)
167
163
  └── templates/ 章テンプレート
@@ -177,21 +173,36 @@ presets/<key>/
177
173
 
178
174
  ## プリセット作成ルール
179
175
 
176
+ ### MUST: プリセット作成ガイドとの同期
177
+
178
+ プリセットの仕様・作成手順・契約(`preset.json` スキーマ、DataSource のインターフェース、`match()` / `parse()` の引数契約、resolve メソッドの戻り値型、import ルール、テンプレートディレクティブ、scan/data ペアリング規則等)を変更した場合、**`.sdd-forge/templates/*/docs/creating_presets.md`(全言語)を同じコミット内で必ず更新すること。**
179
+
180
+ 対象となる変更の例:
181
+
182
+ - `src/api.js` の公開クラス追加・削除・シグネチャ変更
183
+ - `package.json` の `exports` 変更
184
+ - `DataSource` / `Scannable` / `AnalysisEntry` / `Table` / `MarkdownText` のインターフェース変更
185
+ - `preset.json` スキーマの追加・変更
186
+ - `data/` loader のロード規約変更
187
+ - テンプレートディレクティブ(`{%extends%}`, `{%block%}`, `{{data}}`, `{{text}}`)の文法・挙動変更
188
+ - プリセット作成手順・MUST ルールの追加・変更
189
+
190
+ 本ガイドは AI エージェントがプリセットを作成する際の単一の参照ドキュメントである。**ガイドが実装とズレるとプリセット作成が破綻する**ため、実装変更と文書更新を同一 PR で行うこと。別 PR に分割してはならない。
191
+
180
192
  ### MUST: プリセット作成手順(トップダウン設計)
181
193
 
182
194
  プリセットの構成要素は以下の順序で作成すること:
183
195
 
184
196
  1. **テンプレート** (`templates/`) — どんなドキュメントを出力するか定義する
185
- 2. **DataSource** (`data/`) — テンプレートが必要とするデータを定義する
186
- 3. **scan パーサー** (`scan/`) — DataSource にデータを供給するパーサーを実装する
197
+ 2. **DataSource** (`data/`) — テンプレートが必要とするデータを `Scannable` DataSource の `match` / `parse` / `scan` で収集し、resolve メソッドで提供する
187
198
 
188
- 消費者(テンプレート)→ 仲介者(DataSource)→ 生産者(scan)の順に作ることで、不要なパーサーを書かず、必要なデータの漏れがなくなる。
199
+ 消費者(テンプレート)→ 生産者(DataSource)の順に作ることで、不要な解析を書かず、必要なデータの漏れがなくなる。scan 処理は独立した `scan/` ディレクトリではなく DataSource クラス自身が `Scannable` mixin 経由で担う。
189
200
 
190
201
  ### MUST: プリセットテストの作成
191
202
 
192
203
  プリセットは `tests/` ディレクトリを含むこと。
193
204
 
194
- - `tests/unit/` — scan パーサーの I/O テスト。最小限のフィクスチャを `createTmpDir()` で作成し、パーサー関数の入出力を検証する
205
+ - `tests/unit/` — DataSource `match` / `parse` I/O テスト。最小限のフィクスチャを `createTmpDir()` で作成し、入出力を検証する
195
206
  - `tests/e2e/` — preset.json の scan 設定検証、フルスキャンパイプラインテスト
196
207
  - `tests/acceptance/test.js` — preset ローカル fixture を使う acceptance テスト。共有処理は `tests/acceptance/lib/` を使う
197
208
  - テンプレートを作成・変更した場合は acceptance テストも実装し、実行すること
@@ -295,6 +306,42 @@ export default class SchemaSource extends DataSource {
295
306
  - analysis にデータがあれば動作する(なければ null を返す)
296
307
  - **MUST**: このパターンを使う場合、読む analysis キーを書く scan DataSource がチェーン内に存在する必要がある。対応する scan がないなら data DataSource を作ってはならない
297
308
 
309
+ ### DataSource メソッドの戻り値型(設計方針)
310
+
311
+ DataSource の resolve メソッド(`list()` / `tables()` 等、テンプレートから `{{data}}` で呼ばれるメソッド)の戻り値は、**レンダリング可能な値クラスのインスタンスまたは null** とする。生の Markdown 文字列を返してはならない。
312
+
313
+ プロジェクトは TypeScript を採用しないため、値の構造は OOP(クラス)で表現する(プロジェクト `CLAUDE.md` の「OOP による型表現」を参照)。戻り値には以下の専用クラスを用いる。
314
+
315
+ - `Table` — 表形式データ(labels と rows を保持、列数の整合を invariant として強制)
316
+ - `MarkdownText` — そのまま埋め込む Markdown 断片
317
+ - (必要に応じて)`BulletList` / `Heading` 等の追加クラス
318
+
319
+ ```javascript
320
+ import { Table } from "../../../docs/lib/renderable.js";
321
+
322
+ export default class RoutesSource extends DataSource {
323
+ list(analysis, labels) {
324
+ const routes = analysis.routes?.entries || [];
325
+ if (routes.length === 0) return null;
326
+ const rows = routes.map(r => [r.pattern, r.controller, r.action]);
327
+ return new Table(labels, rows);
328
+ }
329
+ }
330
+ ```
331
+
332
+ **利点**:
333
+
334
+ - コンストラクタで invariant を強制(例: Table は列数一致、labels 非空)
335
+ - `instanceof` による確実な型判別
336
+ - `toMarkdown()` を型に所属させることで、出力形式の拡張(将来の `toHtml()` 等)が Open/Closed に従う
337
+ - テンプレート展開層は `result.toMarkdown()` をポリモルフィックに呼ぶだけで済む
338
+ - オブジェクトリテラル(`{ type: "table", ... }`)のような discriminated union もどきは採用しない
339
+
340
+ **禁止事項**:
341
+
342
+ - 基底 `DataSource` の `toMarkdownTable()` のような Markdown 文字列生成ヘルパーを新設してはならない(既存のものは `Table` クラスへの段階移行対象)
343
+ - 戻り値として生の文字列を返してはならない。構造が単なるテキストなら `MarkdownText` でラップする
344
+
298
345
  ---
299
346
 
300
347
  ## テンプレート構文
@@ -6,9 +6,9 @@
6
6
 
7
7
  import fs from "fs";
8
8
  import path from "path";
9
- import { createLogger } from "../../lib/progress.js";
9
+ import { pathToFileURL } from "url";
10
10
 
11
- const logger = createLogger("datasource");
11
+ const MAX_DATA_SOURCE_FILES = 1000;
12
12
 
13
13
  /**
14
14
  * Load DataSource classes from a directory and instantiate them.
@@ -23,22 +23,34 @@ const logger = createLogger("datasource");
23
23
  export async function loadDataSources(dataDir, opts) {
24
24
  const { existing, onInstance } = opts || {};
25
25
  const sources = new Map(existing || []);
26
- if (!fs.existsSync(dataDir)) return sources;
27
-
28
- const files = fs.readdirSync(dataDir).filter((f) => f.endsWith(".js"));
26
+ let entries;
27
+ try {
28
+ entries = await fs.promises.readdir(dataDir);
29
+ } catch (err) {
30
+ if (err.code === "ENOENT") return sources;
31
+ throw err;
32
+ }
33
+ const files = entries.filter((f) => f.endsWith(".js"));
34
+ if (files.length > MAX_DATA_SOURCE_FILES) {
35
+ throw new Error(
36
+ `DataSource directory ${dataDir} contains ${files.length} files, exceeding limit ${MAX_DATA_SOURCE_FILES}`,
37
+ );
38
+ }
29
39
  for (const file of files) {
30
40
  const name = path.basename(file, ".js");
41
+ const filePath = path.join(dataDir, file);
42
+ let mod;
31
43
  try {
32
- const mod = await import(path.join(dataDir, file));
33
- const Source = mod.default;
34
- if (typeof Source === "function") {
35
- const instance = new Source();
36
- instance._sourceFilePath = path.join(dataDir, file);
37
- if (onInstance && onInstance(instance, name) === false) continue;
38
- sources.set(name, instance);
39
- }
44
+ mod = await import(pathToFileURL(filePath).href);
40
45
  } catch (err) {
41
- logger.verbose(`failed to load DataSource ${name}: ${err.message}`);
46
+ throw new Error(`failed to load DataSource at ${filePath}: ${err.message}`, { cause: err });
47
+ }
48
+ const Source = mod.default;
49
+ if (typeof Source === "function") {
50
+ const instance = new Source();
51
+ instance._sourceFilePath = filePath;
52
+ if (onInstance && onInstance(instance, name) === false) continue;
53
+ sources.set(name, instance);
42
54
  }
43
55
  }
44
56
  return sources;
@@ -9,7 +9,7 @@ import { readFileSync } from "fs";
9
9
  import { runCmd, assertOk } from "../../lib/process.js";
10
10
  import path from "path";
11
11
  import { loadConfig } from "../../lib/config.js";
12
- import { isGhAvailable } from "../../lib/git-helpers.js";
12
+ import { isGhAvailable, runGit } from "../../lib/git-helpers.js";
13
13
 
14
14
  /**
15
15
  * Resolve push remote from config.
@@ -165,7 +165,7 @@ function main(ctx) {
165
165
  const title = buildPrTitle(spec, fallbackTitle);
166
166
  const body = buildPrBody(state, spec);
167
167
 
168
- const pushRes = runCmd("git", ["push", "-u", remote, featureBranch]);
168
+ const pushRes = runGit(["push", "-u", remote, featureBranch]);
169
169
  assertOk(pushRes, "git push failed");
170
170
  const prRes = runCmd("gh", [
171
171
  "pr", "create",
@@ -185,12 +185,12 @@ function main(ctx) {
185
185
  function runSquashMerge(gitPrefix, hint) {
186
186
  const mergeArgs = [...gitPrefix, "merge", "--squash", featureBranch];
187
187
  const resetArgs = [...gitPrefix, "reset", "--merge"];
188
- const mergeRes = runCmd("git", mergeArgs);
188
+ const mergeRes = runGit(mergeArgs);
189
189
  if (!mergeRes.ok) {
190
- runCmd("git", resetArgs);
190
+ runGit(resetArgs);
191
191
  throw new Error(`Merge conflict detected. ${hint}`);
192
192
  }
193
- const commitRes = runCmd("git", [...gitPrefix, "commit", "-m", commitMsg]);
193
+ const commitRes = runGit([...gitPrefix, "commit", "-m", commitMsg]);
194
194
  assertOk(commitRes, "commit after squash merge failed");
195
195
  }
196
196
 
@@ -200,7 +200,7 @@ function main(ctx) {
200
200
  }
201
201
 
202
202
  // Branch mode
203
- const checkoutRes = runCmd("git", ["checkout", baseBranch]);
203
+ const checkoutRes = runGit(["checkout", baseBranch]);
204
204
  assertOk(checkoutRes, "git checkout failed");
205
205
  runSquashMerge([], `Run 'git rebase ${baseBranch}' and retry finalize.`);
206
206
  return { strategy: "squash" };
@@ -24,7 +24,7 @@ import { loadAgentConfig, callAgentAwaitLog, resolveAgent, ensureAgentWorkDir }
24
24
  */
25
25
  const callReviewAgent = (agent, prompt, root, systemPrompt) =>
26
26
  callAgentAwaitLog(agent, prompt, undefined, root, { systemPrompt });
27
- import { runCmd } from "../../lib/process.js";
27
+ import { runGit } from "../../lib/git-helpers.js";
28
28
  import { EXIT_ERROR } from "../../lib/exit-codes.js";
29
29
  import { VALID_PHASES } from "../../lib/constants.js";
30
30
 
@@ -70,12 +70,7 @@ function resolveReviewTarget(root, flow) {
70
70
  for (const f of scopeFiles) {
71
71
  const abs = path.resolve(root, f);
72
72
  if (!fs.existsSync(abs)) continue;
73
- // Committed changes against base branch
74
- const committed = runCmd("git", ["-C", root, "diff", flow.baseBranch, "--", f]);
75
- if (committed.ok && committed.stdout.trim()) diffs.push(committed.stdout);
76
- // Staged but uncommitted changes
77
- const staged = runCmd("git", ["-C", root, "diff", "--cached", "--", f]);
78
- if (staged.ok && staged.stdout.trim()) diffs.push(staged.stdout);
73
+ diffs.push(...collectCommittedAndStagedDiff(root, flow.baseBranch, f));
79
74
  }
80
75
  if (diffs.length > 0) return diffs.join("\n");
81
76
  }
@@ -83,12 +78,24 @@ function resolveReviewTarget(root, flow) {
83
78
  }
84
79
 
85
80
  // Fallback: committed diff against base branch + staged changes
86
- const parts = [];
87
- const committed = runCmd("git", ["-C", root, "diff", flow.baseBranch]);
88
- if (committed.ok && committed.stdout.trim()) parts.push(committed.stdout);
89
- const staged = runCmd("git", ["-C", root, "diff", "--cached"]);
90
- if (staged.ok && staged.stdout.trim()) parts.push(staged.stdout);
91
- return parts.join("\n");
81
+ return collectCommittedAndStagedDiff(root, flow.baseBranch).join("\n");
82
+ }
83
+
84
+ /**
85
+ * Collect non-empty committed (vs base) and staged diff outputs.
86
+ * @param {string} root
87
+ * @param {string} baseBranch
88
+ * @param {string} [filePath] - optional path to scope the diff to
89
+ * @returns {string[]} array of non-empty diff outputs
90
+ */
91
+ function collectCommittedAndStagedDiff(root, baseBranch, filePath) {
92
+ const out = [];
93
+ const fileArgs = filePath ? ["--", filePath] : [];
94
+ const committed = runGit(["-C", root, "diff", baseBranch, ...fileArgs]);
95
+ if (committed.ok && committed.stdout.trim()) out.push(committed.stdout);
96
+ const staged = runGit(["-C", root, "diff", "--cached", ...fileArgs]);
97
+ if (staged.ok && staged.stdout.trim()) out.push(staged.stdout);
98
+ return out;
92
99
  }
93
100
 
94
101
  /**
@@ -6,8 +6,7 @@
6
6
  * ctx.target — one of: impl, finalize, dirty, gh
7
7
  */
8
8
 
9
- import { runCmd } from "../../lib/process.js";
10
- import { isGhAvailable } from "../../lib/git-helpers.js";
9
+ import { isGhAvailable, runGit } from "../../lib/git-helpers.js";
11
10
  import { VALID_CHECK_TARGETS } from "../../lib/constants.js";
12
11
  import { FlowCommand } from "./base-command.js";
13
12
 
@@ -29,7 +28,7 @@ function checkStepPrereqs(state, required) {
29
28
  }
30
29
 
31
30
  function checkDirty(root) {
32
- const res = runCmd("git", ["status", "--short"], { cwd: root });
31
+ const res = runGit(["status", "--short"], { cwd: root });
33
32
  if (!res.ok) {
34
33
  return { pass: false, summary: "git status failed", checks: [{ id: "dirty", pass: false, message: res.stderr }] };
35
34
  }
@@ -15,7 +15,7 @@ import {
15
15
  resolveWorktreePaths, clearFlowState, specIdFromPath,
16
16
  } from "../../lib/flow-state.js";
17
17
  import { loadIssueLog, saveIssueLog } from "./set-issue-log.js";
18
- import { isGhAvailable, commentOnIssue, collectGitSummary } from "../../lib/git-helpers.js";
18
+ import { isGhAvailable, commentOnIssue, collectGitSummary, runGit } from "../../lib/git-helpers.js";
19
19
  import { VALID_MERGE_STRATEGIES } from "../../lib/constants.js";
20
20
  import { FlowCommand } from "./base-command.js";
21
21
  import { FLOW_COMMANDS } from "../registry.js";
@@ -58,13 +58,13 @@ function executeCleanupImpl({ root, flowState, worktreePath, mainRepoPath }) {
58
58
  if (worktree && mainRepoPath) {
59
59
  const wtPath = worktreePath || root;
60
60
  if (fs.existsSync(wtPath)) {
61
- runCmd("git", ["-C", mainRepoPath, "worktree", "remove", wtPath]);
61
+ runGit(["-C", mainRepoPath, "worktree", "remove", wtPath]);
62
62
  }
63
- runCmd("git", ["-C", mainRepoPath, "branch", "-D", featureBranch]);
63
+ runGit(["-C", mainRepoPath, "branch", "-D", featureBranch]);
64
64
  return { status: "done" };
65
65
  }
66
66
 
67
- runCmd("git", ["branch", "-D", featureBranch], { cwd: root });
67
+ runGit(["branch", "-D", featureBranch], { cwd: root });
68
68
  return { status: "done" };
69
69
  }
70
70
 
@@ -76,7 +76,7 @@ function executeCleanupImpl({ root, flowState, worktreePath, mainRepoPath }) {
76
76
  * @returns {{ status: string, message?: string }}
77
77
  */
78
78
  export function commitOrSkip(args, opts) {
79
- const res = runCmd("git", ["commit", ...args], opts);
79
+ const res = runGit(["commit", ...args], opts);
80
80
  if (res.ok) return { status: "done" };
81
81
  const output = res.stderr || res.stdout || "";
82
82
  if (/nothing to commit|no changes added to commit/i.test(output)) {
@@ -86,7 +86,7 @@ export function commitOrSkip(args, opts) {
86
86
  }
87
87
 
88
88
  export function resolveGitCommonDir(root) {
89
- const res = runCmd("git", ["-C", root, "rev-parse", "--git-common-dir"]);
89
+ const res = runGit(["-C", root, "rev-parse", "--git-common-dir"]);
90
90
  assertOk(res, "finalize preflight failed: unable to resolve git common dir");
91
91
  return path.resolve(root, res.stdout.trim());
92
92
  }
@@ -212,11 +212,12 @@ export async function executeCommitPost(ctx) {
212
212
  }
213
213
 
214
214
  // commit retro + report files
215
- runCmd("git", ["add", "-A"], { cwd: root });
216
- const commitRes = runCmd("git", ["commit", "-m", "chore: add retro and report"], { cwd: root });
217
- if (!commitRes.ok && !/nothing to commit/i.test(commitRes.stderr || commitRes.stdout)) {
215
+ runGit(["add", "-A"], { cwd: root });
216
+ try {
217
+ commitOrSkip(["-m", "chore: add retro and report"], { cwd: root });
218
+ } catch (e) {
218
219
  if (results.report) {
219
- results.report.commitNote = "retro/report commit failed: " + (commitRes.stderr || commitRes.stdout).slice(0, 200);
220
+ results.report.commitNote = "retro/report commit failed: " + String(e.message || e).slice(0, 200);
220
221
  }
221
222
  }
222
223
  }
@@ -274,7 +275,7 @@ export class RunFinalizeCommand extends FlowCommand {
274
275
  results.commit = { status: "dry-run", message: message || "(auto)" };
275
276
  } else {
276
277
  results.commit = await runSubStep("commit", () => {
277
- runCmd("git", ["add", "-A"], { cwd: root });
278
+ runGit(["add", "-A"], { cwd: root });
278
279
  const msg = message || `feat: ${state.featureBranch || "finalize"}`;
279
280
  const res = commitOrSkip(["-m", msg], { cwd: root });
280
281
  return { ...res, message: msg };
@@ -329,12 +330,12 @@ export class RunFinalizeCommand extends FlowCommand {
329
330
  if (!buildRes.ok) {
330
331
  assertOk(buildRes, "docs build failed");
331
332
  }
332
- runCmd("git", ["add", "docs/", "AGENTS.md", "CLAUDE.md", "README.md", ".sdd-forge/output/analysis.json"], { cwd: syncCwd });
333
+ runGit(["add", "docs/", "AGENTS.md", "CLAUDE.md", "README.md", ".sdd-forge/output/analysis.json"], { cwd: syncCwd });
333
334
  let diffStat = null;
334
335
  let diffSummary = null;
335
- const statRes = runCmd("git", ["diff", "--cached", "--stat"], { cwd: syncCwd });
336
+ const statRes = runGit(["diff", "--cached", "--stat"], { cwd: syncCwd });
336
337
  if (statRes.ok) diffStat = statRes.stdout.trim();
337
- const nameRes = runCmd("git", ["diff", "--cached", "--name-only"], { cwd: syncCwd });
338
+ const nameRes = runGit(["diff", "--cached", "--name-only"], { cwd: syncCwd });
338
339
  if (nameRes.ok) diffSummary = nameRes.stdout.trim();
339
340
  const commitRes = commitOrSkip(["-m", "docs: sync documentation"], { cwd: syncCwd });
340
341
  return { ...commitRes, ...(diffStat && { diffStat }), ...(diffSummary && { diffSummary }) };
@@ -12,7 +12,8 @@
12
12
 
13
13
  import fs from "fs";
14
14
  import path from "path";
15
- import { runCmd, assertOk } from "../../lib/process.js";
15
+ import { assertOk } from "../../lib/process.js";
16
+ import { runGit } from "../../lib/git-helpers.js";
16
17
  import { callAgentWithLog, resolveAgent, ensureAgentWorkDir } from "../../lib/agent.js";
17
18
  import { filterByPhase, loadMergedGuardrails } from "../../lib/guardrail.js";
18
19
  import { getSpecName } from "../../lib/flow-state.js";
@@ -32,7 +33,7 @@ import { FlowCommand } from "./base-command.js";
32
33
  * @returns {string}
33
34
  */
34
35
  function runGitDiff(args, errorMessage, cwd) {
35
- const res = runCmd("git", ["diff", ...args], { cwd });
36
+ const res = runGit(["diff", ...args], { cwd });
36
37
  assertOk(res, errorMessage);
37
38
  return res.stdout;
38
39
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from "fs";
9
9
  import path from "path";
10
- import { runCmd } from "../../lib/process.js";
10
+ import { runGit } from "../../lib/git-helpers.js";
11
11
  import { VALID_IMPL_CONFIRM_MODES } from "../../lib/constants.js";
12
12
  import { FlowCommand } from "./base-command.js";
13
13
 
@@ -18,7 +18,7 @@ import { FlowCommand } from "./base-command.js";
18
18
  * @returns {string[]} changed file paths
19
19
  */
20
20
  function getChangedFiles(root, baseBranch) {
21
- const res = runCmd("git", ["-C", root, "diff", `${baseBranch}...HEAD`, "--name-only"]);
21
+ const res = runGit(["-C", root, "diff", `${baseBranch}...HEAD`, "--name-only"]);
22
22
  if (!res.ok) return [];
23
23
  return res.stdout.trim().split("\n").filter(Boolean);
24
24
  }
@@ -9,17 +9,17 @@ import fs from "fs";
9
9
  import path from "path";
10
10
  import { isInsideWorktree } from "../../lib/cli.js";
11
11
  import { sddDir, DEFAULT_LANG } from "../../lib/config.js";
12
- import { runCmd, assertOk } from "../../lib/process.js";
12
+ import { assertOk } from "../../lib/process.js";
13
13
  import { translate } from "../../lib/i18n.js";
14
14
  import {
15
15
  saveFlowState, buildInitialSteps, addActiveFlow, cleanStaleFlows,
16
16
  generateRunId, deletePreparingFlow, cleanStalePreparingFlows,
17
17
  } from "../../lib/flow-state.js";
18
- import { getWorktreeStatus } from "../../lib/git-helpers.js";
18
+ import { getWorktreeStatus, runGit } from "../../lib/git-helpers.js";
19
19
  import { FlowCommand } from "./base-command.js";
20
20
 
21
- function runGit(root, args) {
22
- const res = runCmd("git", ["-C", root, ...args]);
21
+ function runGitTrim(root, args) {
22
+ const res = runGit(["-C", root, ...args]);
23
23
  if (res.ok) return res.stdout.trim();
24
24
  assertOk(res, `git ${args.join(" ")} failed`);
25
25
  }
@@ -44,7 +44,7 @@ function nextIndex(root) {
44
44
  }
45
45
  }
46
46
 
47
- const branchLines = runGit(root, ["branch", "--list", "feature/[0-9][0-9][0-9]-*"])
47
+ const branchLines = runGitTrim(root, ["branch", "--list", "feature/[0-9][0-9][0-9]-*"])
48
48
  .split("\n")
49
49
  .map((x) => x.replace(/^[* ]+/, "").trim())
50
50
  .filter(Boolean);
@@ -57,7 +57,7 @@ function nextIndex(root) {
57
57
  }
58
58
 
59
59
  function ensureClean(root) {
60
- const status = runGit(root, ["status", "--porcelain"]);
60
+ const status = runGitTrim(root, ["status", "--porcelain"]);
61
61
  if (status.trim()) {
62
62
  throw new Error("worktree is dirty. commit/stash before spec.");
63
63
  }
@@ -65,16 +65,17 @@ function ensureClean(root) {
65
65
 
66
66
  function ensureBaseBranch(root, base) {
67
67
  try {
68
- runGit(root, ["rev-parse", "--verify", base]);
69
- } catch (_) {
70
- throw new Error(`base branch not found: ${base}`);
68
+ runGitTrim(root, ["rev-parse", "--verify", base]);
69
+ } catch (e) {
70
+ throw new Error(`base branch not found: ${base}: ${e.message}`);
71
71
  }
72
72
  }
73
73
 
74
74
  function detectBaseBranch(root) {
75
75
  try {
76
- return runGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
77
- } catch (_) {
76
+ return runGitTrim(root, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
77
+ } catch (e) {
78
+ process.stderr.write(`[sdd-forge] failed to detect current branch, falling back to "main": ${e.message}\n`);
78
79
  return "main";
79
80
  }
80
81
  }
@@ -273,7 +274,7 @@ export class RunPrepareSpecCommand extends FlowCommand {
273
274
  const lines = [];
274
275
 
275
276
  if (useWorktree) {
276
- runGit(root, ["worktree", "add", worktreePath, "-b", branchName, resolvedBase]);
277
+ runGitTrim(root, ["worktree", "add", worktreePath, "-b", branchName, resolvedBase]);
277
278
  writeSpecFiles();
278
279
  writeFlowState({ worktree: true });
279
280
  addActiveFlow(root, specDirName, "worktree");
@@ -303,7 +304,7 @@ export class RunPrepareSpecCommand extends FlowCommand {
303
304
  `3) start implementation`,
304
305
  );
305
306
  } else {
306
- runGit(root, ["checkout", "-b", branchName, resolvedBase]);
307
+ runGitTrim(root, ["checkout", "-b", branchName, resolvedBase]);
307
308
  writeSpecFiles();
308
309
  writeFlowState();
309
310
  addActiveFlow(root, specDirName, "branch");
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from "fs";
9
9
  import path from "path";
10
- import { runCmd } from "../../lib/process.js";
10
+ import { runGit } from "../../lib/git-helpers.js";
11
11
  import { callAgentAwaitLog, resolveAgent, ensureAgentWorkDir } from "../../lib/agent.js";
12
12
  import { repairJson } from "../../lib/json-parse.js";
13
13
  import { getSpecName } from "../../lib/flow-state.js";
@@ -30,7 +30,7 @@ function extractRequirements(specText) {
30
30
  * Get git diff between base branch and HEAD.
31
31
  */
32
32
  function getDiff(root, baseBranch) {
33
- const res = runCmd("git", ["diff", `${baseBranch}...HEAD`, "--stat"], { cwd: root });
33
+ const res = runGit(["diff", `${baseBranch}...HEAD`, "--stat"], { cwd: root });
34
34
  return res.ok ? res.stdout.trim() : "";
35
35
  }
36
36
 
@@ -38,7 +38,7 @@ function getDiff(root, baseBranch) {
38
38
  * Get detailed diff for AI evaluation.
39
39
  */
40
40
  function getDetailedDiff(root, baseBranch) {
41
- const res = runCmd("git", ["diff", `${baseBranch}...HEAD`], { cwd: root });
41
+ const res = runGit(["diff", `${baseBranch}...HEAD`], { cwd: root });
42
42
  return res.ok ? res.stdout.trim() : "";
43
43
  }
44
44
 
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { PKG_DIR } from "../../lib/cli.js";
9
9
  import { runCmd, assertOk } from "../../lib/process.js";
10
+ import { runGit } from "../../lib/git-helpers.js";
10
11
  import { FlowCommand } from "./base-command.js";
11
12
  import path from "path";
12
13
 
@@ -56,11 +57,11 @@ export class RunSyncCommand extends FlowCommand {
56
57
  }
57
58
 
58
59
  // Step 3: git add (ignore errors for missing files)
59
- runCmd("git", ["add", "docs/", "AGENTS.md", "CLAUDE.md", "README.md", ".sdd-forge/output/"], { cwd: root });
60
+ runGit(["add", "docs/", "AGENTS.md", "CLAUDE.md", "README.md", ".sdd-forge/output/"], { cwd: root });
60
61
 
61
62
  // Collect changed files
62
63
  let changed = [];
63
- const diffRes = runCmd("git", ["diff", "--cached", "--name-only"], { cwd: root });
64
+ const diffRes = runGit(["diff", "--cached", "--name-only"], { cwd: root });
64
65
  if (diffRes.ok) {
65
66
  const diff = diffRes.stdout.trim();
66
67
  changed = diff ? diff.split("\n").filter(Boolean) : [];
@@ -68,7 +69,7 @@ export class RunSyncCommand extends FlowCommand {
68
69
 
69
70
  // Step 4: commit (skip if nothing to commit)
70
71
  if (changed.length > 0) {
71
- const commitRes = runCmd("git", ["commit", "-m", "docs: sync documentation"], { cwd: root });
72
+ const commitRes = runGit(["commit", "-m", "docs: sync documentation"], { cwd: root });
72
73
  if (!commitRes.ok && !/nothing to commit/i.test(commitRes.stderr || commitRes.stdout)) {
73
74
  assertOk(commitRes, "git commit failed");
74
75
  }
package/src/lib/cli.js CHANGED
@@ -25,6 +25,8 @@ export const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url))
25
25
  */
26
26
  export function repoRoot(importMetaUrl) {
27
27
  if (process.env.SDD_FORGE_WORK_ROOT) return process.env.SDD_FORGE_WORK_ROOT;
28
+ // Logger 基盤の依存元のため runCmd を直接使う。runGit に変更してはならない
29
+ // (Logger.git → resolveLogDir → repoRoot → runGit → Logger.git で無限再帰になる)。
28
30
  const res = runCmd("git", ["rev-parse", "--show-toplevel"]);
29
31
  if (res.ok) return res.stdout.trim();
30
32
  // npm パッケージとしてインストールされた場合、相対パス推定は
@@ -104,6 +106,8 @@ export function isInsideWorktree(root) {
104
106
  * @returns {string} メインリポジトリの絶対パス
105
107
  */
106
108
  export function getMainRepoPath(root) {
109
+ // Logger 基盤の依存元のため runCmd を直接使う。runGit に変更してはならない
110
+ // (Logger.git → resolveLogDir → getMainRepoPath → runGit → Logger.git で無限再帰になる)。
107
111
  const res = runCmd("git", ["-C", root, "rev-parse", "--git-common-dir"]);
108
112
  assertOk(res, "failed to resolve git-common-dir");
109
113
  const gitCommonDir = res.stdout.trim();
@@ -9,7 +9,7 @@
9
9
  import fs from "fs";
10
10
  import path from "path";
11
11
  import crypto from "crypto";
12
- import { runCmd } from "./process.js";
12
+ import { runGit } from "./git-helpers.js";
13
13
  import { sddDir } from "./config.js";
14
14
  import { isInsideWorktree, getMainRepoPath } from "./cli.js";
15
15
 
@@ -18,6 +18,7 @@ const ACTIVE_FLOW_FILE = ".active-flow";
18
18
  const PREPARING_PREFIX = ".active-flow.";
19
19
  const PREPARING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
20
20
  const PREPARING_SCAN_LIMIT = 100;
21
+ const SCAN_FLOWS_LIMIT = 200;
21
22
 
22
23
  /**
23
24
  * Extract the spec name (e.g. "152-add-logger-to-callsites") from a flow object or state.
@@ -182,10 +183,29 @@ export function removeActiveFlow(workRoot, specId) {
182
183
  * @param {string} branch
183
184
  * @returns {boolean}
184
185
  */
186
+ /**
187
+ * Run a git query and apply a stdout predicate; on git failure, log to stderr
188
+ * and return `true` (fail-open: prevents accidental deletion when status is unknown).
189
+ * @param {string[]} args - git arguments
190
+ * @param {(stdout: string) => boolean} predicate
191
+ * @param {string} contextLabel - label used in the stderr warning
192
+ * @returns {boolean}
193
+ */
194
+ function runGitFailOpenBoolean(args, predicate, contextLabel) {
195
+ const res = runGit(args);
196
+ if (!res.ok) {
197
+ process.stderr.write(`[sdd-forge] ${contextLabel}: git ${args.join(" ")} failed, assuming exists: ${res.stderr}\n`);
198
+ return true;
199
+ }
200
+ return predicate(res.stdout);
201
+ }
202
+
185
203
  function worktreeExists(mainRoot, branch) {
186
- const res = runCmd("git", ["-C", mainRoot, "worktree", "list", "--porcelain"]);
187
- if (!res.ok) return true; // on error, assume exists (avoid accidental deletion)
188
- return res.stdout.includes(`branch refs/heads/${branch}`);
204
+ return runGitFailOpenBoolean(
205
+ ["-C", mainRoot, "worktree", "list", "--porcelain"],
206
+ (stdout) => stdout.includes(`branch refs/heads/${branch}`),
207
+ "worktreeExists",
208
+ );
189
209
  }
190
210
 
191
211
  /**
@@ -195,9 +215,11 @@ function worktreeExists(mainRoot, branch) {
195
215
  * @returns {boolean}
196
216
  */
197
217
  function branchExists(mainRoot, branch) {
198
- const res = runCmd("git", ["-C", mainRoot, "branch", "--list", branch]);
199
- if (!res.ok) return true; // on error, assume exists
200
- return res.stdout.trim().length > 0;
218
+ return runGitFailOpenBoolean(
219
+ ["-C", mainRoot, "branch", "--list", branch],
220
+ (stdout) => stdout.trim().length > 0,
221
+ "branchExists",
222
+ );
201
223
  }
202
224
 
203
225
  /**
@@ -397,7 +419,7 @@ function resolveCurrentFlow(workRoot, flows) {
397
419
 
398
420
  // Try matching by current branch
399
421
  let currentBranch;
400
- const res = runCmd("git", ["-C", workRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
422
+ const res = runGit(["-C", workRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
401
423
  if (!res.ok) return null;
402
424
  currentBranch = res.stdout.trim();
403
425
 
@@ -491,7 +513,7 @@ export function loadFlowState(workRoot, specId) {
491
513
  * @returns {string|null}
492
514
  */
493
515
  function specIdFromBranch(workRoot) {
494
- const res = runCmd("git", ["-C", workRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
516
+ const res = runGit(["-C", workRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
495
517
  if (!res.ok) return null;
496
518
  const branch = res.stdout.trim();
497
519
  const prefix = "feature/";
@@ -732,12 +754,14 @@ export function scanAllFlows(workRoot) {
732
754
  const mainRoot = resolveMainRoot(workRoot);
733
755
  const results = [];
734
756
  const seen = new Set();
757
+ let truncated = false;
735
758
 
736
759
  // 1. Local: specs/*/ in main repo (with or without flow.json)
737
760
  const specsDir = path.join(mainRoot, "specs");
738
761
  if (fs.existsSync(specsDir)) {
739
762
  for (const entry of fs.readdirSync(specsDir, { withFileTypes: true })) {
740
763
  if (!entry.isDirectory() || !/^\d{3}-/.test(entry.name)) continue;
764
+ if (results.length >= SCAN_FLOWS_LIMIT) { truncated = true; break; }
741
765
  const fp = path.join(specsDir, entry.name, STATE_FILE);
742
766
  if (fs.existsSync(fp)) {
743
767
  const state = JSON.parse(fs.readFileSync(fp, "utf8"));
@@ -751,54 +775,64 @@ export function scanAllFlows(workRoot) {
751
775
  }
752
776
 
753
777
  // 2. Worktrees: scan each worktree's specs/*/flow.json
754
- const wtRes = runCmd("git", ["-C", mainRoot, "worktree", "list", "--porcelain"]);
755
- if (wtRes.ok) {
756
- const output = wtRes.stdout;
757
- let wtPath = null;
758
- for (const line of output.split("\n")) {
759
- if (line.startsWith("worktree ")) {
760
- wtPath = line.slice("worktree ".length);
761
- } else if (line === "" && wtPath && wtPath !== mainRoot) {
762
- const wtSpecs = path.join(wtPath, "specs");
763
- if (fs.existsSync(wtSpecs)) {
764
- for (const entry of fs.readdirSync(wtSpecs, { withFileTypes: true })) {
765
- if (!entry.isDirectory() || seen.has(entry.name)) continue;
766
- const fp = path.join(wtSpecs, entry.name, STATE_FILE);
767
- if (fs.existsSync(fp)) {
768
- const state = JSON.parse(fs.readFileSync(fp, "utf8"));
769
- results.push({ specId: entry.name, mode: "worktree", state, location: wtPath });
770
- seen.add(entry.name);
778
+ if (!truncated) {
779
+ const wtRes = runGit(["-C", mainRoot, "worktree", "list", "--porcelain"]);
780
+ if (wtRes.ok) {
781
+ const output = wtRes.stdout;
782
+ let wtPath = null;
783
+ outer: for (const line of output.split("\n")) {
784
+ if (line.startsWith("worktree ")) {
785
+ wtPath = line.slice("worktree ".length);
786
+ } else if (line === "" && wtPath && wtPath !== mainRoot) {
787
+ const wtSpecs = path.join(wtPath, "specs");
788
+ if (fs.existsSync(wtSpecs)) {
789
+ for (const entry of fs.readdirSync(wtSpecs, { withFileTypes: true })) {
790
+ if (!entry.isDirectory() || seen.has(entry.name)) continue;
791
+ if (results.length >= SCAN_FLOWS_LIMIT) { truncated = true; break outer; }
792
+ const fp = path.join(wtSpecs, entry.name, STATE_FILE);
793
+ if (fs.existsSync(fp)) {
794
+ const state = JSON.parse(fs.readFileSync(fp, "utf8"));
795
+ results.push({ specId: entry.name, mode: "worktree", state, location: wtPath });
796
+ seen.add(entry.name);
797
+ }
771
798
  }
772
799
  }
800
+ wtPath = null;
773
801
  }
774
- wtPath = null;
775
802
  }
776
803
  }
777
804
  }
778
805
 
779
806
  // 3. Branches: check feature/* branches for specs/*/flow.json
780
- const branchRes = runCmd("git", ["-C", mainRoot, "branch", "--list", "feature/*"]);
781
- if (branchRes.ok) {
782
- for (const line of branchRes.stdout.split("\n")) {
783
- const branch = line.replace(/^[*+ ]+/, "").trim();
784
- if (!branch) continue;
785
- const specId = branch.replace("feature/", "");
786
- if (seen.has(specId)) continue;
787
- const showRes = runCmd(
788
- "git", ["-C", mainRoot, "show", `${branch}:specs/${specId}/flow.json`],
789
- );
790
- if (showRes.ok) {
791
- try {
792
- const state = JSON.parse(showRes.stdout);
793
- results.push({ specId, mode: "branch", state, location: `branch:${branch}` });
794
- seen.add(specId);
795
- } catch (_) {
796
- // invalid JSON, skip
807
+ if (!truncated) {
808
+ const branchRes = runGit(["-C", mainRoot, "branch", "--list", "feature/*"]);
809
+ if (branchRes.ok) {
810
+ for (const line of branchRes.stdout.split("\n")) {
811
+ const branch = line.replace(/^[*+ ]+/, "").trim();
812
+ if (!branch) continue;
813
+ const specId = branch.replace("feature/", "");
814
+ if (seen.has(specId)) continue;
815
+ if (results.length >= SCAN_FLOWS_LIMIT) { truncated = true; break; }
816
+ const showRes = runGit(
817
+ ["-C", mainRoot, "show", `${branch}:specs/${specId}/flow.json`],
818
+ );
819
+ if (showRes.ok) {
820
+ try {
821
+ const state = JSON.parse(showRes.stdout);
822
+ results.push({ specId, mode: "branch", state, location: `branch:${branch}` });
823
+ seen.add(specId);
824
+ } catch (e) {
825
+ process.stderr.write(`[sdd-forge] scanAllFlows: invalid JSON in ${branch}:specs/${specId}/flow.json: ${e.message}\n`);
826
+ }
797
827
  }
798
828
  }
799
829
  }
800
830
  }
801
831
 
832
+ if (truncated) {
833
+ process.stderr.write(`[sdd-forge] scanAllFlows: truncated at ${SCAN_FLOWS_LIMIT} entries\n`);
834
+ }
835
+
802
836
  return results;
803
837
  }
804
838
 
@@ -6,10 +6,31 @@
6
6
  */
7
7
 
8
8
  import { runCmd, formatError } from "./process.js";
9
+ import { Logger } from "./log.js";
10
+
11
+ /**
12
+ * Run a git command and record a JSONL log entry via Logger.
13
+ *
14
+ * All "business" git operations (commit, push, diff, branch, merge, worktree, status, log, etc.)
15
+ * SHOULD go through this wrapper instead of calling `runCmd("git", ...)` directly,
16
+ * so they are uniformly logged.
17
+ *
18
+ * Exception: git invocations that the Logger itself depends on (repo top-level /
19
+ * git-common-dir resolution in `cli.js`) MUST stay on `runCmd` to avoid recursion.
20
+ *
21
+ * @param {string[]} args - git argument array (without the leading "git")
22
+ * @param {Object} [opts] - same shape as runCmd opts
23
+ * @returns {{ ok: boolean, status: number, stdout: string, stderr: string, signal: string|null, killed: boolean }}
24
+ */
25
+ export function runGit(args, opts = {}) {
26
+ const result = runCmd("git", args, opts);
27
+ Logger.getInstance().git({ cmd: ["git", ...args], exitCode: result.status, stderr: result.stderr });
28
+ return result;
29
+ }
9
30
 
10
31
  /** @returns {{ dirty: boolean, dirtyFiles: string[] }} */
11
32
  export function getWorktreeStatus(cwd) {
12
- const res = runCmd("git", ["status", "--short"], { cwd });
33
+ const res = runGit(["status", "--short"], { cwd });
13
34
  if (!res.ok) return { dirty: false, dirtyFiles: [] };
14
35
  const files = res.stdout.trim().split("\n").filter(Boolean);
15
36
  return { dirty: files.length > 0, dirtyFiles: files };
@@ -17,19 +38,19 @@ export function getWorktreeStatus(cwd) {
17
38
 
18
39
  /** @returns {string|null} */
19
40
  export function getCurrentBranch(cwd) {
20
- const res = runCmd("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
41
+ const res = runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
21
42
  return res.ok ? res.stdout.trim() : null;
22
43
  }
23
44
 
24
45
  /** @returns {number} */
25
46
  export function getAheadCount(cwd, baseBranch) {
26
- const res = runCmd("git", ["rev-list", "--count", `${baseBranch}..HEAD`], { cwd });
47
+ const res = runGit(["rev-list", "--count", `${baseBranch}..HEAD`], { cwd });
27
48
  return res.ok ? parseInt(res.stdout.trim(), 10) || 0 : 0;
28
49
  }
29
50
 
30
51
  /** @returns {string|null} */
31
52
  export function getLastCommit(cwd) {
32
- const res = runCmd("git", ["log", "-1", "--oneline"], { cwd });
53
+ const res = runGit(["log", "-1", "--oneline"], { cwd });
33
54
  return res.ok ? res.stdout.trim() : null;
34
55
  }
35
56
 
@@ -47,9 +68,9 @@ export function isGhAvailable() {
47
68
  export function collectGitSummary(root, baseBranch) {
48
69
  let diffStat = "";
49
70
  let commitMessages = [];
50
- const diffRes = runCmd("git", ["diff", "--stat", `${baseBranch}...HEAD`], { cwd: root });
71
+ const diffRes = runGit(["diff", "--stat", `${baseBranch}...HEAD`], { cwd: root });
51
72
  if (diffRes.ok) diffStat = diffRes.stdout.trim();
52
- const logRes = runCmd("git", ["log", "--format=%s", `${baseBranch}..HEAD`], { cwd: root });
73
+ const logRes = runGit(["log", "--format=%s", `${baseBranch}..HEAD`], { cwd: root });
53
74
  if (logRes.ok) commitMessages = logRes.stdout.trim().split("\n").filter(Boolean);
54
75
  return { diffStat, commitMessages };
55
76
  }
package/src/lib/lint.js CHANGED
@@ -7,7 +7,8 @@
7
7
 
8
8
  import fs from "fs";
9
9
  import path from "path";
10
- import { runCmd, assertOk } from "./process.js";
10
+ import { assertOk } from "./process.js";
11
+ import { runGit } from "./git-helpers.js";
11
12
  import { filterByPhase, matchScope } from "./guardrail.js";
12
13
 
13
14
  /**
@@ -35,7 +36,7 @@ export function validateLintGuardrails(guardrails) {
35
36
  * @returns {string[]} Relative file paths
36
37
  */
37
38
  export function getChangedFiles(root, base) {
38
- const res = runCmd("git", ["-C", root, "diff", "--name-only", `${base}...HEAD`]);
39
+ const res = runGit(["-C", root, "diff", "--name-only", `${base}...HEAD`]);
39
40
  if (!res.ok) {
40
41
  assertOk(res, "git diff failed");
41
42
  }
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  import { execFileSync, execFile } from "child_process";
9
- import { Logger } from "./log.js";
10
9
 
11
10
  /**
12
11
  * Run a command synchronously.
13
12
  *
13
+ * Pure command runner: no domain-specific logging is performed here.
14
+ * For git command logging, callers should use `runGit` from `git-helpers.js`.
15
+ *
14
16
  * @param {string} cmd - Command to execute
15
17
  * @param {string[]} args - Argument array
16
18
  * @param {Object} [opts]
@@ -31,12 +33,9 @@ export function runCmd(cmd, args, opts = {}) {
31
33
  stdio: ["pipe", "pipe", "pipe"],
32
34
  ...(opts.env && { env: opts.env }),
33
35
  });
34
- const result = { ok: true, status: 0, stdout: String(stdout || ""), stderr: "", signal: null, killed: false };
35
- // TODO: git logging in runCmd causes infinite recursion via resolveLogDir → getMainRepoPath → runCmd
36
- // if (cmd === "git") Logger.getInstance().git({ cmd: [cmd, ...args], exitCode: 0, stderr: "" });
37
- return result;
36
+ return { ok: true, status: 0, stdout: String(stdout || ""), stderr: "", signal: null, killed: false };
38
37
  } catch (e) {
39
- const result = {
38
+ return {
40
39
  ok: false,
41
40
  status: e.status ?? 1,
42
41
  stdout: String(e.stdout || ""),
@@ -44,9 +43,6 @@ export function runCmd(cmd, args, opts = {}) {
44
43
  signal: e.signal ?? null,
45
44
  killed: e.killed ?? false,
46
45
  };
47
- // TODO: git logging in runCmd causes infinite recursion via resolveLogDir → getMainRepoPath → runCmd
48
- // if (cmd === "git") Logger.getInstance().git({ cmd: [cmd, ...args], exitCode: result.status, stderr: result.stderr });
49
- return result;
50
46
  }
51
47
  }
52
48
 
@@ -96,8 +92,6 @@ export function runCmdAsync(cmd, args, opts = {}) {
96
92
  killed: false,
97
93
  };
98
94
  }
99
- // TODO: git logging in runCmdAsync causes infinite recursion via resolveLogDir → getMainRepoPath → runCmd
100
- // if (cmd === "git") Logger.getInstance().git({ cmd: [cmd, ...args], exitCode: result.status, stderr: result.stderr });
101
95
  resolve(result);
102
96
  },
103
97
  );
@@ -107,7 +107,7 @@
107
107
  "init": {
108
108
  "noType": "ERROR: type is not set. Set \"type\" in .sdd-forge/config.json or use --type option.",
109
109
  "noTemplates": "ERROR: no template files found in chain",
110
- "conflictsExist": "ERROR: {{count}} file(s) already exist in docs/:",
110
+ "conflictsExist": "WARN: {{count}} file(s) already exist in docs/:",
111
111
  "useForce": "Use --force to overwrite.",
112
112
  "aiFilterFailed": "[init] WARN: AI chapter selection failed: {{message}}",
113
113
  "aiResponseInvalid": "[init] WARN: AI response is not valid JSON, skipping AI filter.",
@@ -107,7 +107,7 @@
107
107
  "init": {
108
108
  "noType": "ERROR: type が設定されていません。.sdd-forge/config.json に \"type\" を設定するか --type オプションを指定してください。",
109
109
  "noTemplates": "ERROR: テンプレートファイルがチェーン内に見つかりません",
110
- "conflictsExist": "ERROR: {{count}} 件のファイルが docs/ に既に存在します:",
110
+ "conflictsExist": "WARN: {{count}} 件のファイルが docs/ に既に存在します:",
111
111
  "useForce": "--force で上書きできます。",
112
112
  "aiFilterFailed": "[init] WARN: AI 章選択に失敗: {{message}}",
113
113
  "aiResponseInvalid": "[init] WARN: AI レスポンスが有効な JSON ではありません。AI フィルタをスキップします。",