sdd-forge 0.1.0-alpha.8 → 0.1.0-alpha.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdd-forge",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "Spec-Driven Development tooling for automated documentation generation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,10 +10,10 @@
10
10
 
11
11
  import fs from "fs";
12
12
  import path from "path";
13
- import { execFileSync } from "child_process";
14
13
  import { fileURLToPath } from "url";
15
14
  import { sourceRoot, repoRoot, parseArgs } from "../../lib/cli.js";
16
15
  import { loadJsonFile, loadConfig, resolveProjectContext } from "../../lib/config.js";
16
+ import { callAgent } from "../../lib/agent.js";
17
17
 
18
18
  const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
19
19
 
@@ -45,45 +45,33 @@ function loadAgentConfig(cfg, agentName) {
45
45
  return provider;
46
46
  }
47
47
 
48
- function callAgent(agent, prompt, timeoutMs) {
49
- const args = Array.isArray(agent.args) ? [...agent.args] : [];
50
- const resolvedArgs = args.map((a) =>
51
- typeof a === "string" ? a.replaceAll("{{PROMPT}}", prompt) : a
52
- );
53
- const hasToken = args.some((a) => typeof a === "string" && a.includes("{{PROMPT}}"));
54
- const finalArgs = hasToken ? resolvedArgs : [...resolvedArgs, prompt];
55
-
56
- const env = { ...process.env };
57
- delete env.CLAUDECODE;
58
-
59
- return execFileSync(agent.command, finalArgs, {
60
- encoding: "utf8",
61
- maxBuffer: 20 * 1024 * 1024,
62
- timeout: timeoutMs,
63
- env,
64
- }).trim();
65
- }
66
-
67
48
  // ---------------------------------------------------------------------------
68
49
  // AI 要約プロンプト構築
69
50
  // ---------------------------------------------------------------------------
70
51
 
52
+ /**
53
+ * agents コマンド用のシステムプロンプトを構築する。
54
+ * 出力ルール(PROJECT タグ形式、構造要件)を含む。
55
+ */
56
+ function buildAgentsSystemPrompt() {
57
+ return [
58
+ "以下のソースコード解析データ (analysis.json) を要約し、AGENTS.md の Project Context セクションを生成してください。",
59
+ "",
60
+ "## 出力ルール(厳守)",
61
+ "- <!-- PROJECT:START --> と <!-- PROJECT:END --> タグで囲むこと",
62
+ "- 最初の行は `<!-- PROJECT:START — managed by sdd-forge. Do not edit manually. -->` とすること",
63
+ "- 最後の行は `<!-- PROJECT:END -->` とすること",
64
+ "- `## Project Context` の見出しで始めること",
65
+ "- AI エージェントがプロジェクトを理解するのに役立つ情報を構造的にまとめること",
66
+ "- 技術スタック、プロジェクト構造の概要、主要コンポーネント、DB 構成、利用可能なコマンドを含めること",
67
+ "- マークダウンのテーブルやリストを活用して読みやすくすること",
68
+ "- 前置き・メタコメンタリーは含めないこと",
69
+ ].join("\n");
70
+ }
71
+
71
72
  function buildSummaryPrompt(analysis, config, srcRoot) {
72
73
  const parts = [];
73
74
 
74
- parts.push("以下のソースコード解析データ (analysis.json) を要約し、AGENTS.md の Project Context セクションを生成してください。");
75
- parts.push("");
76
- parts.push("## 出力ルール(厳守)");
77
- parts.push("- <!-- PROJECT:START --> と <!-- PROJECT:END --> タグで囲むこと");
78
- parts.push("- 最初の行は `<!-- PROJECT:START — managed by sdd-forge. Do not edit manually. -->` とすること");
79
- parts.push("- 最後の行は `<!-- PROJECT:END -->` とすること");
80
- parts.push("- `## Project Context` の見出しで始めること");
81
- parts.push("- AI エージェントがプロジェクトを理解するのに役立つ情報を構造的にまとめること");
82
- parts.push("- 技術スタック、プロジェクト構造の概要、主要コンポーネント、DB 構成、利用可能なコマンドを含めること");
83
- parts.push("- マークダウンのテーブルやリストを活用して読みやすくすること");
84
- parts.push("- 前置き・メタコメンタリーは含めないこと");
85
- parts.push("");
86
-
87
75
  // config info
88
76
  if (config.type) {
89
77
  parts.push(`## プロジェクト設定`);
@@ -313,10 +301,11 @@ function main() {
313
301
  }
314
302
 
315
303
  console.error("[agents] generating PROJECT section with AI...");
304
+ const systemPrompt = buildAgentsSystemPrompt();
316
305
  const prompt = buildSummaryPrompt(analysis, config, srcRoot);
317
306
 
318
307
  try {
319
- const result = callAgent(agent, prompt, 180000);
308
+ const result = callAgent(agent, prompt, 180000, undefined, { systemPrompt });
320
309
 
321
310
  // Extract PROJECT section from AI response
322
311
  const projectMatch = result.match(/<!-- PROJECT:START[^>]*-->[\s\S]*?<!-- PROJECT:END -->/);
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import fs from "fs";
15
+ import os from "os";
15
16
  import path from "path";
16
17
  import readline from "readline";
17
18
  import { execFile, spawn } from "child_process";
@@ -28,11 +29,12 @@ import { createResolver } from "../lib/resolver-factory.js";
28
29
  // パッケージディレクトリを保持する
29
30
  const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
30
31
 
31
- const DEFAULT_AGENT_TIMEOUT_MS = 600000;
32
+ const DEFAULT_AGENT_TIMEOUT_MS = 300000;
32
33
  const DEFAULT_WAIT_LOG_SEC = 1;
33
34
  const DEFAULT_MAX_RUNS = 3;
34
35
  const DEFAULT_REVIEW_CMD = "sdd-forge review";
35
36
  const DEFAULT_MODE = "local";
37
+ const DEFAULT_CONCURRENCY = 3;
36
38
 
37
39
  function getTargetFiles(root) {
38
40
  const docsDir = path.join(root, "docs");
@@ -152,8 +154,8 @@ function printHelp() {
152
154
  " --prompt <text> 開始プロンプト",
153
155
  " --prompt-file <path> 開始プロンプトファイル",
154
156
  " --spec <path> 入力仕様書(spec.md)",
155
- " --max-runs <n> 反復回数 (default: 5)",
156
- " --review-cmd <cmd> docs レビューコマンド (default: npm run sdd:review)",
157
+ " --max-runs <n> 反復回数 (default: 3)",
158
+ " --review-cmd <cmd> docs レビューコマンド (default: sdd-forge review)",
157
159
  " --agent <name> AIエージェント: codex|claude (default: config.json の defaultAgent)",
158
160
  " --mode <mode> 実行モード: local|assist|agent (default: local)",
159
161
  " --dry-run ファイル書き込み・review・agent 呼び出しをスキップ(1 ラウンドで終了)",
@@ -161,27 +163,59 @@ function printHelp() {
161
163
  " -v, --verbose エージェント実行ログを逐次表示",
162
164
  " -h, --help このヘルプを表示",
163
165
  "",
164
- "NEEDS_INPUT:",
165
- " 追加情報が必要な場合、AI は次形式で出力すること:",
166
- " NEEDS_INPUT",
167
- " - 質問1",
168
- " - 質問2",
166
+ "Per-file mode:",
167
+ " provider に systemPromptFlag が設定されている場合、ファイルごとに非同期で agent を呼び出します。",
168
+ " 同時実行数は config.json の limits.concurrency で設定可能(default: 3)。",
169
169
  "",
170
170
  ].join("\n")
171
171
  );
172
172
  }
173
173
 
174
- function buildArgs(agent, prompt) {
174
+ function buildArgs(agent, prompt, systemPrompt) {
175
175
  const args = Array.isArray(agent.args) ? [...agent.args] : [];
176
+
177
+ // Prepend system prompt flag if provider supports it and systemPrompt given
178
+ const flag = agent.systemPromptFlag;
179
+ let prefix = [];
180
+ let cleanupFile;
181
+ if (flag && systemPrompt) {
182
+ if (flag === "--system-prompt-file") {
183
+ // Write to temp file for providers that require file-based system prompts
184
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sdd-forge-"));
185
+ const tmpFile = path.join(tmpDir, "system-prompt.md");
186
+ fs.writeFileSync(tmpFile, systemPrompt, "utf8");
187
+ prefix = [flag, tmpFile];
188
+ cleanupFile = tmpFile;
189
+ } else {
190
+ prefix = [flag, systemPrompt];
191
+ }
192
+ }
193
+
176
194
  const hasToken = args.some(
177
195
  (a) => typeof a === "string" && a.includes("{{PROMPT}}")
178
196
  );
197
+ let finalArgs;
179
198
  if (hasToken) {
180
- return args.map((a) =>
199
+ finalArgs = args.map((a) =>
181
200
  typeof a === "string" ? a.replaceAll("{{PROMPT}}", prompt) : a
182
201
  );
202
+ } else {
203
+ finalArgs = [...args, prompt];
183
204
  }
184
- return [...args, prompt];
205
+
206
+ // If systemPromptFlag not set but systemPrompt given, prepend to prompt
207
+ if (!flag && systemPrompt) {
208
+ const combined = systemPrompt + "\n\n" + prompt;
209
+ if (hasToken) {
210
+ finalArgs = (Array.isArray(agent.args) ? [...agent.args] : []).map((a) =>
211
+ typeof a === "string" ? a.replaceAll("{{PROMPT}}", combined) : a
212
+ );
213
+ } else {
214
+ finalArgs = [...(Array.isArray(agent.args) ? [...agent.args] : []), combined];
215
+ }
216
+ }
217
+
218
+ return { args: [...prefix, ...finalArgs], cleanupFile };
185
219
  }
186
220
 
187
221
  function runAgent(agent, prompt, options = {}) {
@@ -198,7 +232,13 @@ function runAgent(agent, prompt, options = {}) {
198
232
  options.label || agent?.name || agent?.command || "agent"
199
233
  );
200
234
  const streamOutput = options.streamOutput === true;
201
- const args = buildArgs(agent, prompt);
235
+ const { args, cleanupFile } = buildArgs(agent, prompt, options.systemPrompt);
236
+
237
+ function cleanup() {
238
+ if (cleanupFile) {
239
+ try { fs.unlinkSync(cleanupFile); fs.rmdirSync(path.dirname(cleanupFile)); } catch (_) {}
240
+ }
241
+ }
202
242
 
203
243
  return new Promise((resolve, reject) => {
204
244
  const ticker =
@@ -239,12 +279,14 @@ function runAgent(agent, prompt, options = {}) {
239
279
  child.on("error", (err) => {
240
280
  if (ticker) clearInterval(ticker);
241
281
  clearTimeout(timeoutTimer);
282
+ cleanup();
242
283
  reject(new Error(`Agent failed: ${agent.command}\n${err.message}`));
243
284
  });
244
285
 
245
286
  child.on("close", (code, signal) => {
246
287
  if (ticker) clearInterval(ticker);
247
288
  clearTimeout(timeoutTimer);
289
+ cleanup();
248
290
  if (code === 0 && !signal) {
249
291
  resolve(stdoutBuf.trim());
250
292
  return;
@@ -272,6 +314,7 @@ function runAgent(agent, prompt, options = {}) {
272
314
  { maxBuffer: 20 * 1024 * 1024, timeout: timeoutMs, cwd: runCwd },
273
315
  (err, stdout, stderr) => {
274
316
  if (ticker) clearInterval(ticker);
317
+ cleanup();
275
318
  if (err) {
276
319
  const timedOut = err.killed === true;
277
320
  reject(
@@ -476,6 +519,57 @@ function summarizeNeedsInput(reviewOut) {
476
519
  return uniq.slice(0, 8);
477
520
  }
478
521
 
522
+ /**
523
+ * Build the system prompt (shared across all files in a round).
524
+ * Contains: role, rules, user request, spec, analysis summary.
525
+ */
526
+ function buildForgeSystemPrompt({
527
+ userPrompt,
528
+ specPath,
529
+ specText,
530
+ analysisSummary,
531
+ }) {
532
+ const specBlock = specPath
533
+ ? ["[SPEC_PATH]", specPath, "", "[SPEC_CONTENT]", specText || "(empty)", ""]
534
+ : [];
535
+ return [
536
+ "あなたは docs-forge です。指定されたドキュメントファイルの品質を改善してください。",
537
+ "",
538
+ "[USER_PROMPT]",
539
+ userPrompt,
540
+ "",
541
+ ...specBlock,
542
+ "[RULES]",
543
+ "- 編集対象は指定された TARGET_FILE のみ",
544
+ "- 推測は避け、ソースコードの事実を優先",
545
+ "- 変更は必要最小限にする",
546
+ "- 説明文は簡潔で主語を明確にする",
547
+ "- 不明な場合は編集せずスキップする",
548
+ "",
549
+ ...(analysisSummary
550
+ ? ["[SOURCE_ANALYSIS]", analysisSummary, ""]
551
+ : []),
552
+ ].join("\n");
553
+ }
554
+
555
+ /**
556
+ * Build the user prompt for a single file.
557
+ */
558
+ function buildForgeFilePrompt({ targetFile, round, maxRuns, reviewFeedback }) {
559
+ return [
560
+ `round: ${round}/${maxRuns}`,
561
+ "",
562
+ "[TARGET_FILE]",
563
+ targetFile,
564
+ "",
565
+ "[PREVIOUS_REVIEW_FEEDBACK]",
566
+ reviewFeedback || "なし",
567
+ ].join("\n");
568
+ }
569
+
570
+ /**
571
+ * Build a combined prompt (for providers without system prompt support).
572
+ */
479
573
  function buildForgePrompt({
480
574
  userPrompt,
481
575
  round,
@@ -504,13 +598,10 @@ function buildForgePrompt({
504
598
  "",
505
599
  "[RULES]",
506
600
  "- 編集対象は TARGET_FILES のみ",
507
- "- 推測は避け、app/ sdd-forge/ specs/ の事実を優先",
508
- "- 既存の指摘を潰すために必要な最小限の変更を行う",
509
- "- 変更後に説明文は簡潔で主語を明確にする",
510
- "- 追加情報が必要で確定できない場合は、編集せず次だけを出力する:",
511
- " NEEDS_INPUT",
512
- " - 質問1",
513
- " - 質問2",
601
+ "- 推測は避け、ソースコードの事実を優先",
602
+ "- 変更は必要最小限にする",
603
+ "- 説明文は簡潔で主語を明確にする",
604
+ "- 不明な場合は編集せずスキップする",
514
605
  "",
515
606
  ...(analysisSummary
516
607
  ? ["[SOURCE_ANALYSIS]", analysisSummary, ""]
@@ -520,6 +611,63 @@ function buildForgePrompt({
520
611
  ].join("\n");
521
612
  }
522
613
 
614
+ /**
615
+ * Run agent for each file with concurrency control.
616
+ * Returns an array of { file, ok, error? } results.
617
+ */
618
+ async function runPerFile({ agent, targetFiles, systemPrompt, round, maxRuns, reviewFeedback, root, timeoutMs, concurrency, verbose }) {
619
+ const results = [];
620
+ let running = 0;
621
+ let idx = 0;
622
+
623
+ return new Promise((resolve) => {
624
+ function next() {
625
+ // All dispatched and all done
626
+ if (idx >= targetFiles.length && running === 0) {
627
+ resolve(results);
628
+ return;
629
+ }
630
+
631
+ // Dispatch up to concurrency limit
632
+ while (running < concurrency && idx < targetFiles.length) {
633
+ const file = targetFiles[idx++];
634
+ running++;
635
+
636
+ const filePrompt = buildForgeFilePrompt({
637
+ targetFile: file,
638
+ round,
639
+ maxRuns,
640
+ reviewFeedback,
641
+ });
642
+
643
+ output.write(`[forge] start: ${file}\n`);
644
+
645
+ runAgent(agent, filePrompt, {
646
+ label: `forge:${path.basename(file)}`,
647
+ cwd: root,
648
+ timeoutMs,
649
+ streamOutput: verbose,
650
+ systemPrompt,
651
+ })
652
+ .then(() => {
653
+ output.write(`[forge] done: ${file}\n`);
654
+ results.push({ file, ok: true });
655
+ })
656
+ .catch((e) => {
657
+ output.write(`[forge] failed: ${file} — ${String(e.message || e).slice(0, 200)}\n`);
658
+ results.push({ file, ok: false, error: e.message });
659
+ })
660
+ .finally(() => {
661
+ running--;
662
+ next();
663
+ });
664
+ }
665
+ }
666
+
667
+ next();
668
+ });
669
+ }
670
+
523
671
  /**
524
672
  * forge の review 成功後に projectContext を自動更新する。
525
673
  * docs/ の各章ファイル先頭を LLM に渡し、プロジェクト概要を生成させる。
@@ -686,6 +834,8 @@ async function main() {
686
834
  return;
687
835
  }
688
836
 
837
+ const concurrency = Number(cfg.limits?.concurrency || 0) || DEFAULT_CONCURRENCY;
838
+
689
839
  let reviewFeedback = "";
690
840
  for (let round = 1; round <= effectiveMaxRuns; round += 1) {
691
841
  output.write(`\n[forge] round ${round}/${effectiveMaxRuns}\n`);
@@ -698,45 +848,70 @@ async function main() {
698
848
  output.write("[forge] assist mode: agent not configured, run local-only.\n");
699
849
  }
700
850
  } else {
701
- const prompt = buildForgePrompt({
702
- userPrompt,
703
- round,
704
- maxRuns: effectiveMaxRuns,
705
- reviewFeedback,
706
- specPath: specPath ? path.relative(root, specPath) : "",
707
- specText,
708
- analysisSummary,
709
- targetFiles: getTargetFiles(root),
710
- });
711
- try {
712
- const out = await runAgent(agent, prompt, {
713
- label: "forge.generate",
714
- cwd: root,
851
+ const targetFiles = getTargetFiles(root);
852
+ const usePerFile = !!agent.systemPromptFlag;
853
+
854
+ if (usePerFile) {
855
+ // Per-file async processing with system prompt separation
856
+ const systemPrompt = buildForgeSystemPrompt({
857
+ userPrompt,
858
+ specPath: specPath ? path.relative(root, specPath) : "",
859
+ specText,
860
+ analysisSummary,
861
+ });
862
+
863
+ output.write(`[forge] per-file mode: ${targetFiles.length} files, concurrency=${concurrency}\n`);
864
+
865
+ const results = await runPerFile({
866
+ agent,
867
+ targetFiles,
868
+ systemPrompt,
869
+ round,
870
+ maxRuns: effectiveMaxRuns,
871
+ reviewFeedback,
872
+ root,
715
873
  timeoutMs,
716
- streamOutput: cli.verbose,
874
+ concurrency,
875
+ verbose: cli.verbose,
717
876
  });
718
- usedAgent = true;
719
-
720
- const needsInput = extractNeedsInput(out);
721
- if (needsInput.length > 0) {
722
- output.write("\n[forge] NEEDS_INPUT detected.\n");
723
- output.write("追加情報が必要です。以下に回答してください:\n");
724
- for (const q of needsInput) {
725
- output.write(`- ${q}\n`);
877
+
878
+ const succeeded = results.filter((r) => r.ok).length;
879
+ const failed = results.filter((r) => !r.ok).length;
880
+ output.write(`[forge] per-file done: ${succeeded} ok, ${failed} failed\n`);
881
+
882
+ if (succeeded > 0) usedAgent = true;
883
+ if (failed > 0 && succeeded === 0) agentFailed = true;
884
+ } else {
885
+ // Legacy: single prompt with all files
886
+ const prompt = buildForgePrompt({
887
+ userPrompt,
888
+ round,
889
+ maxRuns: effectiveMaxRuns,
890
+ reviewFeedback,
891
+ specPath: specPath ? path.relative(root, specPath) : "",
892
+ specText,
893
+ analysisSummary,
894
+ targetFiles,
895
+ });
896
+ try {
897
+ await runAgent(agent, prompt, {
898
+ label: "forge.generate",
899
+ cwd: root,
900
+ timeoutMs,
901
+ streamOutput: cli.verbose,
902
+ });
903
+ usedAgent = true;
904
+ } catch (e) {
905
+ agentFailed = true;
906
+ if (mode === "agent") {
907
+ throw e;
726
908
  }
727
- process.exitCode = 2;
728
- return;
909
+ output.write(
910
+ `[forge] agent step failed. continue with local pipeline.\n${String(
911
+ e instanceof Error ? e.message : e
912
+ ).slice(0, 500)}\n`,
913
+ );
729
914
  }
730
- } catch (e) {
731
- agentFailed = true;
732
- if (mode === "agent") {
733
- throw e;
734
- }
735
- output.write(
736
- `[forge] agent step failed. continue with local pipeline.\n${String(
737
- e instanceof Error ? e.message : e
738
- ).slice(0, 500)}\n`,
739
- );
740
915
  }
741
916
  }
742
917
  }
@@ -790,7 +965,13 @@ async function main() {
790
965
  throw new Error("forge: max runs reached but review still failing.");
791
966
  }
792
967
 
793
- export { main };
968
+ export {
969
+ main,
970
+ buildArgs,
971
+ buildForgeSystemPrompt,
972
+ buildForgeFilePrompt,
973
+ runPerFile,
974
+ };
794
975
 
795
976
  const isDirectRun = process.argv[1] &&
796
977
  path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
@@ -575,6 +575,7 @@ async function main() {
575
575
  name: "claude-cli",
576
576
  command: "claude",
577
577
  args: ["--model", "sonnet", "-p", "{{PROMPT}}"],
578
+ systemPromptFlag: "--system-prompt",
578
579
  },
579
580
  };
580
581
  } else if (defaultAgent === "codex") {
@@ -583,6 +584,7 @@ async function main() {
583
584
  name: "codex-cli",
584
585
  command: "codex",
585
586
  args: ["-p", "{{PROMPT}}"],
587
+ systemPromptFlag: "--system-prompt-file",
586
588
  },
587
589
  };
588
590
  }
@@ -12,12 +12,12 @@
12
12
 
13
13
  import fs from "fs";
14
14
  import path from "path";
15
- import { execFileSync } from "child_process";
16
15
  import { fileURLToPath } from "url";
17
16
  import { parseDirectives } from "../lib/directive-parser.js";
18
17
  import { repoRoot, parseArgs } from "../../lib/cli.js";
19
18
  import { loadConfig, resolveProjectContext } from "../../lib/config.js";
20
19
  import { createLogger } from "../../lib/progress.js";
20
+ import { callAgent as callAgentBase } from "../../lib/agent.js";
21
21
 
22
22
  const logger = createLogger("text");
23
23
 
@@ -230,7 +230,35 @@ function formatLimitRule(params) {
230
230
  return "簡潔かつ正確に(3〜15行程度)";
231
231
  }
232
232
 
233
- function buildPrompt(directive, fileName, lines, contextData, projectContext, documentStyle, lang) {
233
+ /**
234
+ * システムプロンプトを構築する。
235
+ * documentStyle + projectContext + 共通出力ルールを含む。
236
+ * per-directive / batch 両モードで共有し、provider のプロンプトキャッシュを活用する。
237
+ *
238
+ * @param {string} projectContext
239
+ * @param {import("../lib/types.js").DocumentStyle|undefined} documentStyle
240
+ * @param {string} lang
241
+ * @returns {string}
242
+ */
243
+ function buildTextSystemPrompt(projectContext, documentStyle, lang) {
244
+ const header = buildPromptHeader(projectContext, documentStyle, lang);
245
+ return [
246
+ ...header,
247
+ "",
248
+ "以下の指示に従い、ドキュメントに挿入するマークダウンテキストを生成してください。",
249
+ "",
250
+ "## 出力ルール(厳守)",
251
+ "- 本文のマークダウンテキストのみを出力すること",
252
+ "- 前置き・メタコメンタリーは絶対に含めないこと(例: 「以下に生成します」「Based on the analysis data」「Here is the generated text」等は禁止)",
253
+ "- 水平線(---)を装飾目的で使わないこと",
254
+ "- コードブロック(```)で全体を囲まないこと",
255
+ "- セクション見出し(#)は含めない(挿入先に既にある)",
256
+ "- 解析データに基づく事実のみ記述(推測は避ける)",
257
+ "- 1行目から本文を開始すること(空行や導入文で始めない)",
258
+ ].join("\n");
259
+ }
260
+
261
+ function buildPrompt(directive, fileName, lines, contextData) {
234
262
  const directiveLine = directive.line;
235
263
 
236
264
  // ±20行のコンテキストを抽出
@@ -244,24 +272,11 @@ function buildPrompt(directive, fileName, lines, contextData, projectContext, do
244
272
  ? contextJson.slice(0, 8000) + "\n... (truncated)"
245
273
  : contextJson;
246
274
 
247
- const header = buildPromptHeader(projectContext, documentStyle, lang);
248
- header.push("", "以下の指示に従い、ドキュメントに挿入するマークダウンテキストを生成してください。");
249
-
250
275
  return [
251
- ...header,
252
- "",
253
276
  "## 指示",
254
277
  directive.prompt,
255
278
  "",
256
- "## 出力ルール(厳守)",
257
- "- 本文のマークダウンテキストのみを出力すること",
258
- "- 前置き・メタコメンタリーは絶対に含めないこと(例: 「以下に生成します」「Based on the analysis data」「Here is the generated text」等は禁止)",
259
- "- 水平線(---)を装飾目的で使わないこと",
260
- "- コードブロック(```)で全体を囲まないこと",
261
- "- セクション見出し(#)は含めない(挿入先に既にある)",
262
- "- 解析データに基づく事実のみ記述(推測は避ける)",
263
279
  `- ${formatLimitRule(directive.params)}`,
264
- "- 1行目から本文を開始すること(空行や導入文で始めない)",
265
280
  "",
266
281
  `## 挿入先コンテキスト(${fileName})`,
267
282
  surroundingLines,
@@ -328,9 +343,7 @@ function countFilledInBatch(fileText) {
328
343
  * ファイル全体を1つのプロンプトにまとめるバッチプロンプトを構築する。
329
344
  * ±20行ウィンドウ・解析データは不要(ファイル全体が文脈になる)。
330
345
  */
331
- function buildBatchPrompt(fileName, text, projectContext, textFills, documentStyle, lang) {
332
- const header = buildPromptHeader(projectContext, documentStyle, lang);
333
-
346
+ function buildBatchPrompt(fileName, text, textFills) {
334
347
  // ディレクティブごとの個別制限ルールがあれば列挙
335
348
  const perDirectiveRules = [];
336
349
  for (const d of textFills) {
@@ -343,8 +356,6 @@ function buildBatchPrompt(fileName, text, projectContext, textFills, documentSty
343
356
  : "- 個別制限のないディレクティブは3〜15行程度の本文を生成すること";
344
357
 
345
358
  return [
346
- ...header,
347
- "",
348
359
  `以下の ${fileName} にある <!-- @text: 指示 --> ディレクティブをすべて埋めてください。`,
349
360
  "",
350
361
  "## 出力ルール(厳守)",
@@ -370,14 +381,14 @@ function buildBatchPrompt(fileName, text, projectContext, textFills, documentSty
370
381
  *
371
382
  * @returns {{ text: string, filled: number, skipped: number }}
372
383
  */
373
- function processTemplateFileBatch(text, analysis, fileName, agent, timeoutMs, cwd, dryRun, _preamblePatterns, projectContext, documentStyle, lang) {
384
+ function processTemplateFileBatch(text, analysis, fileName, agent, timeoutMs, cwd, dryRun, _preamblePatterns, systemPrompt) {
374
385
  const directives = parseDirectives(text);
375
386
  const textFills = directives.filter((d) => d.type === "text");
376
387
 
377
388
  if (textFills.length === 0) return { text, filled: 0, skipped: 0 };
378
389
 
379
390
  const cleanText = stripFillContent(text);
380
- const prompt = buildBatchPrompt(fileName, cleanText, projectContext, textFills, documentStyle, lang);
391
+ const prompt = buildBatchPrompt(fileName, cleanText, textFills);
381
392
 
382
393
  if (dryRun) {
383
394
  console.log(`[text] DRY-RUN batch ${fileName}: ${textFills.length} directive(s) → 1 call (${prompt.length} chars)`);
@@ -389,7 +400,7 @@ function processTemplateFileBatch(text, analysis, fileName, agent, timeoutMs, cw
389
400
  let result;
390
401
  try {
391
402
  // バッチはファイル全体を返すので preamble パターンは使わない
392
- result = callAgent(agent, prompt, timeoutMs, cwd, []);
403
+ result = callAgent(agent, prompt, timeoutMs, cwd, [], systemPrompt);
393
404
  } catch (err) {
394
405
  const parts = [err.message];
395
406
  if (err.signal) parts.push(`signal: ${err.signal}`);
@@ -422,28 +433,9 @@ function processTemplateFileBatch(text, analysis, fileName, agent, timeoutMs, cw
422
433
  // ---------------------------------------------------------------------------
423
434
  // エージェント呼び出し
424
435
  // ---------------------------------------------------------------------------
425
- function callAgent(agent, prompt, timeoutMs, cwd, preamblePatterns) {
426
- const args = Array.isArray(agent.args) ? [...agent.args] : [];
427
- const resolvedArgs = args.map((a) =>
428
- typeof a === "string" ? a.replaceAll("{{PROMPT}}", prompt) : a
429
- );
430
- // {{PROMPT}} トークンがなかった場合は末尾に追加
431
- const hasToken = args.some((a) => typeof a === "string" && a.includes("{{PROMPT}}"));
432
- const finalArgs = hasToken ? resolvedArgs : [...resolvedArgs, prompt];
433
-
434
- // CLAUDECODE を外してネスト起動ガードを回避(claude CLI 呼び出し時に必要)
435
- const env = { ...process.env };
436
- delete env.CLAUDECODE;
437
-
438
- const result = execFileSync(agent.command, finalArgs, {
439
- encoding: "utf8",
440
- maxBuffer: 20 * 1024 * 1024,
441
- timeout: timeoutMs,
442
- cwd,
443
- env,
444
- });
445
-
446
- return stripPreamble(result.trim(), preamblePatterns);
436
+ function callAgent(agent, prompt, timeoutMs, cwd, preamblePatterns, systemPrompt) {
437
+ const result = callAgentBase(agent, prompt, timeoutMs, cwd, { systemPrompt });
438
+ return stripPreamble(result, preamblePatterns);
447
439
  }
448
440
 
449
441
  /**
@@ -501,7 +493,7 @@ function stripPreamble(text, preamblePatterns) {
501
493
  * @param {boolean} dryRun - dry-run モード
502
494
  * @returns {{ text: string, filled: number, skipped: number }}
503
495
  */
504
- function processTemplate(text, analysis, fileName, agent, timeoutMs, cwd, dryRun, preamblePatterns, projectContext, documentStyle, lang, filterId) {
496
+ function processTemplate(text, analysis, fileName, agent, timeoutMs, cwd, dryRun, preamblePatterns, systemPrompt, filterId) {
505
497
  const directives = parseDirectives(text);
506
498
  let textFills = directives.filter((d) => d.type === "text");
507
499
  if (filterId) {
@@ -518,7 +510,7 @@ function processTemplate(text, analysis, fileName, agent, timeoutMs, cwd, dryRun
518
510
  // 後ろから処理して行番号のズレを防ぐ
519
511
  for (let i = textFills.length - 1; i >= 0; i--) {
520
512
  const d = textFills[i];
521
- const prompt = buildPrompt(d, fileName, lines, contextData, projectContext, documentStyle, lang);
513
+ const prompt = buildPrompt(d, fileName, lines, contextData);
522
514
 
523
515
  if (dryRun) {
524
516
  console.log(`[text] DRY-RUN ${fileName}:${d.line + 1}: ${d.prompt.slice(0, 80)}`);
@@ -531,7 +523,7 @@ function processTemplate(text, analysis, fileName, agent, timeoutMs, cwd, dryRun
531
523
 
532
524
  let generated;
533
525
  try {
534
- generated = callAgent(agent, prompt, timeoutMs, cwd, preamblePatterns);
526
+ generated = callAgent(agent, prompt, timeoutMs, cwd, preamblePatterns, systemPrompt);
535
527
  } catch (err) {
536
528
  logger.log(`ERROR calling agent for ${fileName}:${d.line + 1}: ${err.message.slice(0, 200)}`);
537
529
  skipped++;
@@ -599,6 +591,7 @@ export function textFillFromAnalysis(root, analysis, agentName) {
599
591
  const projectContext = resolveProjectContext(root);
600
592
  const documentStyle = cfg.documentStyle;
601
593
  const lang = cfg.lang || "ja";
594
+ const systemPrompt = buildTextSystemPrompt(projectContext, documentStyle, lang);
602
595
  const docsDir = path.join(root, "docs");
603
596
  const docsFiles = fs.readdirSync(docsDir)
604
597
  .filter((f) => /^\d{2}_/.test(f) && f.endsWith(".md"))
@@ -611,7 +604,7 @@ export function textFillFromAnalysis(root, analysis, agentName) {
611
604
  for (const file of docsFiles) {
612
605
  const filePath = path.join(docsDir, file);
613
606
  const original = fs.readFileSync(filePath, "utf8");
614
- const result = processTemplate(original, analysis, file, agent, 120000, root, false, preamblePatterns, projectContext, documentStyle, lang);
607
+ const result = processTemplate(original, analysis, file, agent, 120000, root, false, preamblePatterns, systemPrompt);
615
608
 
616
609
  totalFilled += result.filled;
617
610
  totalSkipped += result.skipped;
@@ -671,6 +664,7 @@ function main() {
671
664
  const projectContext = resolveProjectContext(root);
672
665
  const documentStyle = cfg.documentStyle;
673
666
  const lang = cfg.lang || "ja";
667
+ const systemPrompt = buildTextSystemPrompt(projectContext, documentStyle, lang);
674
668
  const docsDir = path.join(root, "docs");
675
669
  const docsFiles = fs.readdirSync(docsDir)
676
670
  .filter((f) => /^\d{2}_/.test(f) && f.endsWith(".md"))
@@ -702,14 +696,14 @@ function main() {
702
696
  if (!hasId) continue;
703
697
  }
704
698
 
705
- let result = processFn(original, analysis, file, agent, cli.timeout, root, cli.dryRun, preamblePatterns, projectContext, documentStyle, lang, cli.id || undefined);
699
+ let result = processFn(original, analysis, file, agent, cli.timeout, root, cli.dryRun, preamblePatterns, systemPrompt, cli.id || undefined);
706
700
 
707
701
  // バッチモードで 0 filled になった場合は per-directive モードで再試行
708
702
  if (!cli.perDirective && !cli.dryRun && result.filled === 0) {
709
703
  const textFills = parseDirectives(original).filter((d) => d.type === "text");
710
704
  if (textFills.length > 0) {
711
705
  logger.verbose(`Batch returned 0 filled for ${file}. Falling back to per-directive mode...`);
712
- result = processTemplate(original, analysis, file, agent, cli.timeout, root, cli.dryRun, preamblePatterns, projectContext, documentStyle, lang, cli.id || undefined);
706
+ result = processTemplate(original, analysis, file, agent, cli.timeout, root, cli.dryRun, preamblePatterns, systemPrompt, cli.id || undefined);
713
707
  }
714
708
  }
715
709
 
@@ -146,6 +146,30 @@ function upgradeAgentsSddSection(workRoot, lang, dryRun) {
146
146
  return "updated";
147
147
  }
148
148
 
149
+ // ---------------------------------------------------------------------------
150
+ // Config hints (non-destructive — just prints suggestions)
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const SYSTEM_PROMPT_FLAGS = {
154
+ claude: "--system-prompt",
155
+ codex: "--system-prompt-file",
156
+ };
157
+
158
+ /**
159
+ * Check config.json for missing new settings and print hints.
160
+ */
161
+ function checkConfigHints(config, t) {
162
+ if (!config.providers) return;
163
+
164
+ for (const [key, prov] of Object.entries(config.providers)) {
165
+ if (prov.systemPromptFlag) continue;
166
+ const suggested = SYSTEM_PROMPT_FLAGS[key] || SYSTEM_PROMPT_FLAGS[prov.command];
167
+ if (suggested) {
168
+ console.log(t("upgrade.hintSystemPromptFlag", { provider: key, flag: suggested }));
169
+ }
170
+ }
171
+ }
172
+
149
173
  // ---------------------------------------------------------------------------
150
174
  // Main
151
175
  // ---------------------------------------------------------------------------
@@ -202,6 +226,9 @@ async function main() {
202
226
  console.log(t("upgrade.agentsUnchanged"));
203
227
  }
204
228
 
229
+ // 3. Config hints — check for missing new settings
230
+ checkConfigHints(config, t);
231
+
205
232
  // Summary
206
233
  const hasChanges = skillResults.some((r) => r.status === "updated") || agentsStatus === "updated";
207
234
  if (!hasChanges) {
package/src/lib/agent.js CHANGED
@@ -5,39 +5,87 @@
5
5
  * Provides a shared interface for calling configured AI agents.
6
6
  */
7
7
 
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
8
11
  import { execFileSync } from "child_process";
9
12
 
10
13
  /**
11
14
  * Call an AI agent with a prompt and return the response.
12
15
  *
13
- * @param {Object} agent - Agent config ({ command, args })
16
+ * When `options.systemPrompt` is provided:
17
+ * - If agent.systemPromptFlag is set (e.g. "--system-prompt"),
18
+ * the flag + systemPrompt are prepended to the argument list.
19
+ * - If agent.systemPromptFlag is "--system-prompt-file",
20
+ * a temp file is written and cleaned up after execution.
21
+ * - If no systemPromptFlag but systemPrompt is given,
22
+ * the system prompt is prepended to the user prompt (fallback).
23
+ *
24
+ * @param {Object} agent - Agent config ({ command, args, systemPromptFlag? })
14
25
  * @param {string} prompt - The prompt text
15
26
  * @param {number} [timeoutMs] - Timeout in milliseconds (default: 120000)
16
27
  * @param {string} [cwd] - Working directory
28
+ * @param {Object} [options] - Additional options
29
+ * @param {string} [options.systemPrompt] - System prompt text
17
30
  * @returns {string} Agent response (trimmed)
18
31
  */
19
- export function callAgent(agent, prompt, timeoutMs, cwd) {
32
+ export function callAgent(agent, prompt, timeoutMs, cwd, options) {
33
+ const { systemPrompt } = options || {};
20
34
  const args = Array.isArray(agent.args) ? [...agent.args] : [];
21
- const resolvedArgs = args.map((a) =>
22
- typeof a === "string" ? a.replaceAll("{{PROMPT}}", prompt) : a,
23
- );
35
+
36
+ // Build system prompt prefix args
37
+ const flag = agent.systemPromptFlag;
38
+ let prefix = [];
39
+ let cleanupFile;
40
+ if (flag && systemPrompt) {
41
+ if (flag === "--system-prompt-file") {
42
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sdd-forge-"));
43
+ const tmpFile = path.join(tmpDir, "system-prompt.md");
44
+ fs.writeFileSync(tmpFile, systemPrompt, "utf8");
45
+ prefix = [flag, tmpFile];
46
+ cleanupFile = tmpFile;
47
+ } else {
48
+ prefix = [flag, systemPrompt];
49
+ }
50
+ }
51
+
52
+ // Combine system prompt into user prompt when no flag is available
53
+ let effectivePrompt = prompt;
54
+ if (!flag && systemPrompt) {
55
+ effectivePrompt = systemPrompt + "\n\n" + prompt;
56
+ }
24
57
 
25
58
  const hasToken = args.some((a) => typeof a === "string" && a.includes("{{PROMPT}}"));
26
- const finalArgs = hasToken ? resolvedArgs : [...resolvedArgs, prompt];
59
+ let resolvedArgs;
60
+ if (hasToken) {
61
+ resolvedArgs = args.map((a) =>
62
+ typeof a === "string" ? a.replaceAll("{{PROMPT}}", effectivePrompt) : a,
63
+ );
64
+ } else {
65
+ resolvedArgs = [...args, effectivePrompt];
66
+ }
67
+
68
+ const finalArgs = [...prefix, ...resolvedArgs];
27
69
 
28
70
  // Remove CLAUDECODE env to avoid nested launch guard
29
71
  const env = { ...process.env };
30
72
  delete env.CLAUDECODE;
31
73
 
32
- const result = execFileSync(agent.command, finalArgs, {
33
- encoding: "utf8",
34
- maxBuffer: 20 * 1024 * 1024,
35
- timeout: timeoutMs || 120000,
36
- cwd: cwd || process.cwd(),
37
- env,
38
- });
74
+ try {
75
+ const result = execFileSync(agent.command, finalArgs, {
76
+ encoding: "utf8",
77
+ maxBuffer: 20 * 1024 * 1024,
78
+ timeout: timeoutMs || 120000,
79
+ cwd: cwd || process.cwd(),
80
+ env,
81
+ });
39
82
 
40
- return result.trim();
83
+ return result.trim();
84
+ } finally {
85
+ if (cleanupFile) {
86
+ try { fs.unlinkSync(cleanupFile); fs.rmdirSync(path.dirname(cleanupFile)); } catch (_) {}
87
+ }
88
+ }
41
89
  }
42
90
 
43
91
  /**
package/src/lib/types.js CHANGED
@@ -35,6 +35,7 @@ import { buildTypeAliases } from "../docs/presets/registry.js";
35
35
  * @property {string} command - 実行コマンド
36
36
  * @property {string[]} args - コマンド引数({{PROMPT}} プレースホルダー対応)
37
37
  * @property {number} [timeoutMs] - タイムアウト (ms)
38
+ * @property {string} [systemPromptFlag] - system prompt フラグ (例: "--system-prompt", "--system-prompt-file")
38
39
  */
39
40
 
40
41
  /**
@@ -56,6 +57,7 @@ import { buildTypeAliases } from "../docs/presets/registry.js";
56
57
  * @property {string} type - Project type ("webapp/cakephp2" | "cli" | ...)
57
58
  * @property {Object} [limits] - Limit settings
58
59
  * @property {number} [limits.designTimeoutMs] - Timeout (ms)
60
+ * @property {number} [limits.concurrency] - Per-file concurrency (default: 3)
59
61
  * @property {DocumentStyle} [documentStyle] - Document style settings
60
62
  * @property {TextFillConfig} [textFill] - text-fill settings
61
63
  * @property {string} [defaultAgent] - Default agent name
@@ -7,7 +7,8 @@
7
7
  "lang": "ja",
8
8
  "type": "webapp/cakephp2",
9
9
  "limits": {
10
- "designTimeoutMs": 900000
10
+ "designTimeoutMs": 900000,
11
+ "concurrency": 3
11
12
  },
12
13
  "documentStyle": {
13
14
  "purpose": "developer-guide",
@@ -25,7 +26,8 @@
25
26
  "claude": {
26
27
  "name": "claude-cli",
27
28
  "command": "claude",
28
- "args": ["--model", "sonnet", "-p", "{{PROMPT}}"]
29
+ "args": ["--model", "sonnet", "-p", "{{PROMPT}}"],
30
+ "systemPromptFlag": "--system-prompt"
29
31
  }
30
32
  }
31
33
  }
@@ -102,7 +102,8 @@
102
102
  "agentsUnchanged": "[upgrade] AGENTS.md SDD section unchanged.",
103
103
  "noChanges": "[upgrade] All files are up to date.",
104
104
  "dryRunFooter": "[upgrade] DRY-RUN: no files were modified. Run without --dry-run to apply.",
105
- "done": "[upgrade] Upgrade complete."
105
+ "done": "[upgrade] Upgrade complete.",
106
+ "hintSystemPromptFlag": "[upgrade] Hint: Add \"systemPromptFlag\": \"{{flag}}\" to providers.{{provider}} to enable per-file async processing in forge."
106
107
  },
107
108
  "common": {
108
109
  "choicePrompt": "> ({{min}}-{{max}}): ",
@@ -102,7 +102,8 @@
102
102
  "agentsUnchanged": "[upgrade] AGENTS.md の SDD セクションに変更はありません。",
103
103
  "noChanges": "[upgrade] すべてのファイルは最新です。",
104
104
  "dryRunFooter": "[upgrade] DRY-RUN: ファイルは変更されませんでした。--dry-run なしで実行してください。",
105
- "done": "[upgrade] アップグレード完了。"
105
+ "done": "[upgrade] アップグレード完了。",
106
+ "hintSystemPromptFlag": "[upgrade] ヒント: providers.{{provider}} に \"systemPromptFlag\": \"{{flag}}\" を追加すると、forge のファイル単位非同期処理が有効になります。"
106
107
  },
107
108
  "common": {
108
109
  "choicePrompt": "> ({{min}}-{{max}}): ",