dr-gen 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # decision-record-generator (dr-gen)
2
2
 
3
3
  Generate lightweight Decision Records (DRs) from `decision.yaml`.
4
- Built for fast-moving teams who need a small, reproducible “evidence trail” of decisions (why/rule) for handoffs, audits, and security questionnaires.
4
+ Built for fast-moving teams who need a small, reproducible “evidence trail” of decisions (why/decision) for handoffs, audits, and security questionnaires.
5
5
 
6
- (日本語)引き継ぎ・監査対応・セキュリティに関する確認(質問対応)のために、意思決定(Why / Rule)を小さく・再現可能な形で残すDRジェネレーターです。`decision.yaml` から Markdown / JSON を生成し、`manifest.json` の SHA256 ハッシュで改ざん検知できます。
6
+ 日本語は下の「Japanese (日本語)」セクションにまとめています。
7
7
 
8
8
  **Outputs (4 files)**
9
9
  - `decision-record.md` (human-readable)
@@ -11,10 +11,54 @@ Built for fast-moving teams who need a small, reproducible “evidence trail”
11
11
  - `repro.md` (reproducibility notes)
12
12
  - `manifest.json` (SHA256 hashes for tamper detection)
13
13
 
14
+ ## Input (decision.yaml)
15
+
16
+ All fields except `title` are optional. Empty strings are OK.
17
+
18
+ ```yaml
19
+ title: "Use PostgreSQL for user data" # required
20
+ date: "2024-01-15" # optional (YYYY-MM-DD)
21
+ decider: "Alice (PM)" # optional
22
+
23
+ # ADR-style lifecycle (optional)
24
+ status: "accepted" # optional: accepted | deprecated | superseded
25
+
26
+ # Tip: dr-gen is designed for post-decision records. For drafts, use `dr-gen new --skip-generate` instead of managing a "proposed" lifecycle.
27
+
28
+ # Linking DRs when a decision changes:
29
+ # - Recommended (minimal): set `supersedes` on the newer DR.
30
+ supersedes: "" # optional: link/id/path of the older DR (recommended)
31
+
32
+ context: "" # optional
33
+ why: "" # optional
34
+ rule: "" # optional (the decision/policy; rendered as "Decision" in decision-record.md)
35
+ alternatives: "" # optional
36
+ consequences: "" # optional
37
+ tags: [] # optional
38
+ ```
39
+
40
+ If a decision is reversed later, prefer creating a new DR and setting `supersedes` on the new DR (rather than rewriting history).
41
+
14
42
  ## Operations (Drive / Box)
15
43
 
16
- If you store DRs in Drive / Box (common for non-developers), see:
17
- - [DRIVE_BOX_OPERATION_GUIDE.md](DRIVE_BOX_OPERATION_GUIDE.md)
44
+ If you store DRs in Drive / Box (common for non-developers), see this guide (Japanese):
45
+ - [DRIVE_BOX_OPERATION_GUIDE.md](https://github.com/Ineeza/decision-record-generator/blob/main/DRIVE_BOX_OPERATION_GUIDE.md)
46
+
47
+ ## Verify (integrity / tamper detection)
48
+
49
+ To verify that generated files were not modified after generation, run:
50
+
51
+ ```bash
52
+ dr-gen verify out/<date>__<title>__<id>/
53
+ ```
54
+
55
+ Without installing globally:
56
+
57
+ ```bash
58
+ npx dr-gen@latest verify out/<date>__<title>__<id>/
59
+ ```
60
+
61
+ This checks `decision-record.md`, `summary.json`, and `repro.md` against hashes in `manifest.json`.
18
62
 
19
63
  ## Fastest try (npm)
20
64
 
@@ -33,7 +77,15 @@ If you don't want to write YAML by hand:
33
77
  dr-gen new
34
78
  ```
35
79
 
36
- This asks for the minimum useful info (Title + Why + Rule). Other fields are optional.
80
+ Japanese prompts:
81
+
82
+ ```bash
83
+ dr-gen new --lang ja
84
+ ```
85
+
86
+ You can also set `DR_GEN_LANG=ja`.
87
+
88
+ This asks for the minimum useful info (Title + Why + Decision). Other fields are optional.
37
89
  By default, `date` is set to today's date (YYYY-MM-DD). To disable this:
38
90
 
39
91
  ```bash
@@ -44,6 +96,14 @@ By default this creates:
44
96
  - `in/<date>__<title>__<id>/decision.yaml`
45
97
  - `out/<date>__<title>__<id>/` (generated files)
46
98
 
99
+ If you want to fill more fields (status/alternatives/etc) before generating outputs, use:
100
+
101
+ ```bash
102
+ dr-gen new --skip-generate
103
+ ```
104
+
105
+ Then edit the generated `decision.yaml`, and run `dr-gen generate`.
106
+
47
107
  ## Quickstart
48
108
 
49
109
  ```bash
@@ -98,30 +158,127 @@ If you prefer a different base output directory:
98
158
  dr-gen generate decision.yaml --out-dir some-dir
99
159
  ```
100
160
 
101
- ### 日本語の入力について
161
+ ## CLI
102
162
 
103
- - `decision.yaml` は UTF-8 を想定しています。値(title / why など)は日本語でも問題ありません。
104
- 生成される `decision-record.md` / `summary.json` / `repro.md` にも、そのまま出力されます。
105
- - 現時点では CLI のコマンド名・ヘルプ表示は英語です(ドキュメントで補足しています)。
106
- 需要があれば日本語表示(i18n)も検討します。
163
+ ```bash
164
+ dr-gen generate decision.yaml
165
+ ```
107
166
 
108
- ## CLI
167
+ Other commands:
168
+
169
+ ```bash
170
+ dr-gen new
171
+ dr-gen verify out/<date>__<title>__<id>/
172
+ dr-gen list --from 2026-01-01 --to 2026-01-31
173
+
174
+ # Japanese report labels
175
+ dr-gen list --from 2026-01-01 --to 2026-01-31 --lang ja
176
+
177
+ # Optional: keep the Decision line on one terminal line
178
+ dr-gen list --from 2026-01-01 --to 2026-01-31 --max-decision-len 60
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Japanese (日本語)
184
+
185
+ 引き継ぎ・監査対応・セキュリティに関する確認(質問対応)のために、意思決定(Why / Decision)を小さく・再現可能な形で残すDRジェネレーターです。`decision.yaml` から Markdown / JSON を生成し、`manifest.json` の SHA256 ハッシュで改ざん検知できます。
186
+
187
+ ### 最速で試す(npm)
109
188
 
110
189
  ```bash
190
+ npm install -g dr-gen
191
+
192
+ # decision.yaml が無い場合はテンプレを作成します
111
193
  dr-gen generate decision.yaml
112
194
  ```
113
195
 
114
- ## ライセンスについて
196
+ ### YAMLなし(対話形式)
197
+
198
+ ```bash
199
+ dr-gen new
200
+ ```
201
+
202
+ 日本語で質問を表示する場合:
203
+
204
+ ```bash
205
+ dr-gen new --lang ja
206
+ ```
207
+
208
+ 環境変数で指定することもできます(例: `DR_GEN_LANG=ja`)。
209
+
210
+ Title / Why / Decision を中心に入力します(他は任意)。`--no-date` で日付自動入力を無効にできます。
211
+
212
+ `status` / `alternatives` などを追記してから生成したい場合は、まず `decision.yaml` だけ作るのがおすすめです。
213
+
214
+ ```bash
215
+ dr-gen new --skip-generate
216
+ ```
217
+
218
+ その後、生成された `decision.yaml` を編集してから `dr-gen generate` を実行します。
219
+
220
+ ### 検証(改ざん検知)
221
+
222
+ 生成後のファイルが変更されていないか確認するには、`manifest.json` と照合します。
223
+
224
+ ```bash
225
+ npx dr-gen@latest verify out/<date>__<title>__<id>/
226
+ ```
227
+
228
+ ### 一覧(レポート出力)
229
+
230
+ 期間を指定して、コピペしやすい Markdown レポートを標準出力に出します。
231
+
232
+ ```bash
233
+ dr-gen list --from 2026-01-01 --to 2026-01-31
234
+
235
+ # レポート文言を日本語にする
236
+ dr-gen list --from 2026-01-01 --to 2026-01-31 --lang ja
237
+
238
+ # 任意: Decision 行の文字数上限(デフォルト: 80)
239
+ dr-gen list --from 2026-01-01 --to 2026-01-31 --max-decision-len 60
240
+ ```
241
+
242
+ ### Drive / Box 運用
243
+
244
+ 非エンジニアの運用で Drive / Box に保存する場合は、ガイドを参照してください。
245
+
246
+ - [DRIVE_BOX_OPERATION_GUIDE.md](https://github.com/Ineeza/decision-record-generator/blob/main/DRIVE_BOX_OPERATION_GUIDE.md)
247
+
248
+ ### 入力(decision.yaml)
249
+
250
+ `title` 以外はすべて任意です。空文字でもOKです。
251
+
252
+ ```yaml
253
+ title: "ユーザーデータはPostgreSQLを使う" # 必須
254
+ date: "2024-01-15" # 任意(YYYY-MM-DD)
255
+ decider: "Alice (PM)" # 任意
256
+
257
+ # ADR風のライフサイクル管理(任意)
258
+ status: "accepted" # 任意: accepted | deprecated | superseded
259
+
260
+ # Tip: dr-gen は「決定後に残す」用途がメインです。下書きは `dr-gen new --skip-generate` で YAML だけ作るのがおすすめです。
261
+
262
+ # DRのリンク(判断が変わったとき):
263
+ # - 推奨(最小運用): 新しいDRに `supersedes` を書く
264
+ supersedes: "" # 任意: 以前のDRのリンク/ID/パス(推奨)
265
+
266
+ context: "" # 任意
267
+ why: "" # 任意
268
+ rule: "" # 任意(決めたこと/方針。decision-record.md では "Decision" として表示)
269
+ alternatives: "" # 任意
270
+ consequences: "" # 任意
271
+ tags: [] # 任意
272
+ ```
273
+
274
+ 後から判断が覆った場合は、既存DRを書き換えるよりも「新しいDRを作り、新DRに `supersedes` を書く」運用がおすすめです。
275
+
115
276
 
116
- 本ソフトウェアは MIT License のもとで
117
- OSS として無料で提供しています。
277
+ ### ライセンスについて
118
278
 
119
- 個人・法人を問わず利用可能です。
279
+ 本ソフトウェアは MIT License のもとで OSS として無料で提供しています(個人・法人を問わず利用可能です)。
120
280
 
121
- 組織・チームでの利用において、
122
- サポート、利用条件の明確化、
123
- 継続的な提供が必要な場合は、
124
- 法人契約をご検討ください。
281
+ 組織・チームでの利用において、サポート、利用条件の明確化、継続的な提供が必要な場合は、法人契約をご検討ください。
125
282
 
126
283
  法人契約はこちら:
127
284
  https://www.ineeza.com/
package/dist/cli.js CHANGED
@@ -10,7 +10,9 @@ import { decisionFolderName } from './naming.js';
10
10
  import { promptDecisionRecord } from './interactive.js';
11
11
  import { renderDecisionYaml } from './yaml.js';
12
12
  import { verifyOutDir } from './verify.js';
13
- import { generatedOutputLines, missingCoreFieldsLines, signatureIgnoredLines, templateCreatedLines, wroteInputLines } from './messages.js';
13
+ import { areTitleAndRuleTooSimilar } from './text.js';
14
+ import { listDecisions, renderListReportMarkdown } from './list.js';
15
+ import { generatedOutputLines, missingCoreFieldsLines, signatureIgnoredLines, skippedGenerateLines, templateCreatedLines, titleRuleTooSimilarLines, wroteInputLines } from './messages.js';
14
16
  function getToolVersion() {
15
17
  try {
16
18
  const require = createRequire(import.meta.url);
@@ -21,6 +23,19 @@ function getToolVersion() {
21
23
  return undefined;
22
24
  }
23
25
  }
26
+ function resolveLang(cliLangRaw) {
27
+ const cliLang = (cliLangRaw ?? '').trim().toLowerCase();
28
+ const envLang = (process.env.DR_GEN_LANG ?? '').trim().toLowerCase();
29
+ const candidate = cliLang.length > 0 ? cliLang : envLang;
30
+ if (candidate === 'ja' || candidate === 'jp' || candidate === 'japanese')
31
+ return 'ja';
32
+ if (candidate === 'en' || candidate === 'english')
33
+ return 'en';
34
+ const locale = `${process.env.LC_ALL ?? ''} ${process.env.LC_MESSAGES ?? ''} ${process.env.LANG ?? ''}`.toLowerCase();
35
+ if (locale.includes('ja'))
36
+ return 'ja';
37
+ return 'en';
38
+ }
24
39
  async function fileExists(filePath) {
25
40
  try {
26
41
  await fs.stat(filePath);
@@ -77,6 +92,25 @@ function warnIfMissingCoreFields(record, inputPath) {
77
92
  }
78
93
  }
79
94
  }
95
+ function warnIfTitleRuleTooSimilar(record, lang) {
96
+ const rule = record.rule ?? '';
97
+ if (areTitleAndRuleTooSimilar(record.title, rule)) {
98
+ for (const line of titleRuleTooSimilarLines(lang)) {
99
+ console.warn(line);
100
+ }
101
+ }
102
+ }
103
+ function parsePositiveIntOrThrow(value, label) {
104
+ const trimmed = value.trim();
105
+ if (trimmed.length === 0) {
106
+ throw new Error(`${label} must be a positive integer.`);
107
+ }
108
+ const n = Number.parseInt(trimmed, 10);
109
+ if (!Number.isFinite(n) || Number.isNaN(n) || n <= 0) {
110
+ throw new Error(`${label} must be a positive integer (got: ${value}).`);
111
+ }
112
+ return n;
113
+ }
80
114
  async function main(argv) {
81
115
  const program = new Command();
82
116
  const toolVersion = getToolVersion();
@@ -107,6 +141,7 @@ async function main(argv) {
107
141
  }
108
142
  const record = await parseDecisionYaml(input);
109
143
  warnIfMissingCoreFields(record, input);
144
+ warnIfTitleRuleTooSimilar(record, resolveLang(undefined));
110
145
  const baseOutDir = options.outDir;
111
146
  const folder = decisionFolderName(record);
112
147
  const finalOutDir = await findAvailableDirPath(baseOutDir, folder);
@@ -134,10 +169,14 @@ async function main(argv) {
134
169
  .option('--in-dir <dir>', 'Base directory for inputs', 'in')
135
170
  .option('-p, --path <file>', 'Where to write decision.yaml (overrides --in-dir)', '')
136
171
  .option('-o, --out-dir <dir>', 'Output directory (created if missing)', 'out')
172
+ .option('--lang <lang>', 'Language for prompts (en|ja). Also supports DR_GEN_LANG env var.', '')
137
173
  .option('--no-date', 'Do not auto-fill date')
174
+ .option('--skip-generate', 'Only write decision.yaml and do not generate output files', false)
138
175
  .option('--force', 'Overwrite decision.yaml if it already exists', false)
139
176
  .action(async (options) => {
140
- const record = await promptDecisionRecord({ includeDate: options.date });
177
+ const lang = resolveLang(options.lang);
178
+ const record = await promptDecisionRecord({ includeDate: options.date, lang });
179
+ warnIfTitleRuleTooSimilar(record, lang);
141
180
  const desiredFolder = decisionFolderName(record);
142
181
  const decisionDirName = await findAvailableDecisionDirName(options.inDir, options.outDir, desiredFolder);
143
182
  const yamlPath = options.path.trim().length > 0 ? options.path : path.join(options.inDir, decisionDirName, 'decision.yaml');
@@ -149,6 +188,12 @@ async function main(argv) {
149
188
  for (const line of wroteInputLines(yamlPath)) {
150
189
  console.log(line);
151
190
  }
191
+ if (options.skipGenerate) {
192
+ for (const line of skippedGenerateLines(yamlPath, options.outDir, lang)) {
193
+ console.log(line);
194
+ }
195
+ return;
196
+ }
152
197
  const finalOutDir = path.join(options.outDir, decisionDirName);
153
198
  const command = `dr-gen new${options.date ? '' : ' --no-date'} --in-dir ${options.inDir} --out-dir ${options.outDir}`;
154
199
  await generateDecisionRecordFiles(record, {
@@ -190,6 +235,30 @@ async function main(argv) {
190
235
  }
191
236
  process.exitCode = 2;
192
237
  });
238
+ program
239
+ .command('list')
240
+ .description('List generated decision records and print a copy-pasteable report')
241
+ .option('-o, --out-dir <dir>', 'Base output directory to scan', 'out')
242
+ .option('--from <YYYY-MM-DD>', 'Start date (inclusive) based on folder name prefix', '')
243
+ .option('--to <YYYY-MM-DD>', 'End date (inclusive) based on folder name prefix', '')
244
+ .option('--lang <lang>', 'Language for report labels (en|ja). Also supports DR_GEN_LANG env var.', '')
245
+ .option('--max-decision-len <n>', 'Max characters for the Decision line in the report (default: 80)', '80')
246
+ .action(async (options) => {
247
+ const from = options.from.trim().length > 0 ? options.from.trim() : undefined;
248
+ const to = options.to.trim().length > 0 ? options.to.trim() : undefined;
249
+ const maxDecisionLen = parsePositiveIntOrThrow(options.maxDecisionLen, '--max-decision-len');
250
+ const lang = resolveLang(options.lang);
251
+ const items = await listDecisions({ outDir: options.outDir, from, to });
252
+ const report = renderListReportMarkdown(items, {
253
+ outDir: options.outDir,
254
+ from,
255
+ to,
256
+ generatedAtIso: new Date().toISOString(),
257
+ maxDecisionLen,
258
+ lang
259
+ });
260
+ console.log(report);
261
+ });
193
262
  await program.parseAsync(argv);
194
263
  }
195
264
  main(process.argv).catch((error) => {
package/dist/generator.js CHANGED
@@ -16,6 +16,10 @@ function renderRecordMarkdown(record) {
16
16
  meta.push(`**Date**: ${record.date}`);
17
17
  if (record.decider !== undefined && record.decider.length > 0)
18
18
  meta.push(`**Decider**: ${record.decider}`);
19
+ if (record.status !== undefined && record.status.length > 0)
20
+ meta.push(`**Status**: ${record.status}`);
21
+ if (record.supersedes !== undefined && record.supersedes.length > 0)
22
+ meta.push(`**Supersedes**: ${record.supersedes}`);
19
23
  if (meta.length > 0) {
20
24
  lines.push(meta.join(' \n'));
21
25
  lines.push('');
@@ -26,7 +30,7 @@ function renderRecordMarkdown(record) {
26
30
  lines.push('## Why');
27
31
  lines.push(record.why ?? '');
28
32
  lines.push('');
29
- lines.push('## Rule');
33
+ lines.push('## Decision');
30
34
  lines.push(record.rule ?? '');
31
35
  lines.push('');
32
36
  lines.push('## Alternatives Considered');
@@ -148,6 +152,8 @@ export async function generateDecisionRecordFiles(record, options = {}) {
148
152
  title: record.title,
149
153
  date: record.date,
150
154
  decider: record.decider,
155
+ status: record.status,
156
+ supersedes: record.supersedes,
151
157
  tags: record.tags,
152
158
  files: {
153
159
  record: OUTPUT_FILES.record,
@@ -8,10 +8,13 @@ function todayLocalIsoDate() {
8
8
  const day = String(now.getDate()).padStart(2, '0');
9
9
  return `${year}-${month}-${day}`;
10
10
  }
11
- function requiredNonEmpty(label, value) {
11
+ function requiredNonEmpty(labelEn, labelJa, value, lang) {
12
12
  const trimmed = value.trim();
13
13
  if (trimmed.length === 0) {
14
- throw new Error(`${label} is required (cannot be empty).`);
14
+ if (lang === 'ja') {
15
+ throw new Error(`${labelJa}は必須です(空欄不可)。`);
16
+ }
17
+ throw new Error(`${labelEn} is required (cannot be empty).`);
15
18
  }
16
19
  return trimmed;
17
20
  }
@@ -23,13 +26,13 @@ export async function promptDecisionRecord(options) {
23
26
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
24
27
  try {
25
28
  const todayIsoDate = todayLocalIsoDate();
26
- for (const line of newIntroLines({ includeDate: options.includeDate, todayIsoDate })) {
29
+ for (const line of newIntroLines({ includeDate: options.includeDate, todayIsoDate }, options.lang)) {
27
30
  console.log(line);
28
31
  }
29
- const title = requiredNonEmpty('Title', await rl.question(newTitlePrompt()));
30
- const why = requiredNonEmpty('Why', await rl.question(newWhyPrompt()));
31
- const rule = requiredNonEmpty('Rule', await rl.question(newRulePrompt()));
32
- const context = optionalTrimmed(await rl.question(newContextPrompt()));
32
+ const title = requiredNonEmpty('Title', 'タイトル', await rl.question(newTitlePrompt(options.lang)), options.lang);
33
+ const why = requiredNonEmpty('Why', 'Why', await rl.question(newWhyPrompt(options.lang)), options.lang);
34
+ const rule = requiredNonEmpty('Rule', 'Rule', await rl.question(newRulePrompt(options.lang)), options.lang);
35
+ const context = optionalTrimmed(await rl.question(newContextPrompt(options.lang)));
33
36
  const date = options.includeDate ? todayIsoDate : undefined;
34
37
  return { title, date, why, rule, context };
35
38
  }
package/dist/list.js ADDED
@@ -0,0 +1,230 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
4
+ export function assertIsoDateOrThrow(value, label) {
5
+ if (!ISO_DATE_RE.test(value)) {
6
+ throw new Error(`${label} must be YYYY-MM-DD (got: ${value})`);
7
+ }
8
+ }
9
+ function extractFolderDate(folderName) {
10
+ // Folder naming is expected to start with YYYY-MM-DD__
11
+ if (folderName.length < 12)
12
+ return undefined;
13
+ const candidate = folderName.slice(0, 10);
14
+ if (!ISO_DATE_RE.test(candidate))
15
+ return undefined;
16
+ if (folderName.slice(10, 12) !== '__')
17
+ return undefined;
18
+ return candidate;
19
+ }
20
+ async function readSummaryJson(outDir) {
21
+ try {
22
+ const raw = await fs.readFile(path.join(outDir, 'summary.json'), 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
25
+ return undefined;
26
+ const obj = parsed;
27
+ const asOptionalString = (v) => (typeof v === 'string' ? v : undefined);
28
+ return {
29
+ title: asOptionalString(obj.title),
30
+ date: asOptionalString(obj.date),
31
+ decider: asOptionalString(obj.decider),
32
+ status: asOptionalString(obj.status),
33
+ supersedes: asOptionalString(obj.supersedes)
34
+ };
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ function extractSectionFirstLine(markdown, header) {
41
+ const lines = markdown.split(/\r?\n/);
42
+ let inRule = false;
43
+ for (let i = 0; i < lines.length; i += 1) {
44
+ const line = lines[i] ?? '';
45
+ if (!inRule) {
46
+ if (line.trim() === header) {
47
+ inRule = true;
48
+ }
49
+ continue;
50
+ }
51
+ // Stop if next section starts.
52
+ if (line.startsWith('## '))
53
+ return undefined;
54
+ const trimmed = line.trim();
55
+ if (trimmed.length === 0)
56
+ continue;
57
+ // Keep the excerpt single-line.
58
+ return trimmed.replace(/\s+/g, ' ');
59
+ }
60
+ return undefined;
61
+ }
62
+ function extractDecisionExcerptFromRecordMarkdown(markdown) {
63
+ // Prefer the newer heading, but remain backward compatible.
64
+ return (extractSectionFirstLine(markdown, '## Decision') ??
65
+ extractSectionFirstLine(markdown, '## Rule'));
66
+ }
67
+ async function readRuleExcerpt(outDir) {
68
+ try {
69
+ const md = await fs.readFile(path.join(outDir, 'decision-record.md'), 'utf8');
70
+ return extractDecisionExcerptFromRecordMarkdown(md);
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ function withinRange(date, from, to) {
77
+ // ISO date strings compare lexicographically.
78
+ if (from !== undefined && date < from)
79
+ return false;
80
+ if (to !== undefined && date > to)
81
+ return false;
82
+ return true;
83
+ }
84
+ export async function listDecisions(options) {
85
+ const from = options.from?.trim();
86
+ const to = options.to?.trim();
87
+ if (from !== undefined && from.length > 0)
88
+ assertIsoDateOrThrow(from, '--from');
89
+ if (to !== undefined && to.length > 0)
90
+ assertIsoDateOrThrow(to, '--to');
91
+ const entries = await fs.readdir(options.outDir, { withFileTypes: true });
92
+ const dirs = entries
93
+ .filter((e) => e.isDirectory())
94
+ .map((e) => e.name);
95
+ const results = [];
96
+ for (const folderName of dirs) {
97
+ const dateFromFolder = extractFolderDate(folderName);
98
+ if (dateFromFolder !== undefined) {
99
+ if (!withinRange(dateFromFolder, from && from.length > 0 ? from : undefined, to && to.length > 0 ? to : undefined)) {
100
+ continue;
101
+ }
102
+ }
103
+ else {
104
+ // If the folder doesn't follow naming, skip it for date-range listing.
105
+ if ((from !== undefined && from.length > 0) || (to !== undefined && to.length > 0)) {
106
+ continue;
107
+ }
108
+ }
109
+ const fullOutDir = path.join(options.outDir, folderName);
110
+ const summary = await readSummaryJson(fullOutDir);
111
+ const ruleExcerpt = await readRuleExcerpt(fullOutDir);
112
+ results.push({
113
+ folderName,
114
+ outDir: fullOutDir,
115
+ dateFromFolder,
116
+ summary,
117
+ ruleExcerpt
118
+ });
119
+ }
120
+ // Sort newest first. If date is missing, sort last.
121
+ results.sort((a, b) => {
122
+ const ad = a.dateFromFolder ?? '';
123
+ const bd = b.dateFromFolder ?? '';
124
+ if (ad !== bd)
125
+ return bd.localeCompare(ad);
126
+ const at = a.summary?.title ?? '';
127
+ const bt = b.summary?.title ?? '';
128
+ return at.localeCompare(bt);
129
+ });
130
+ return results;
131
+ }
132
+ function mdEscape(value) {
133
+ // Keep it simple for copy/paste: escape pipes and newlines.
134
+ return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
135
+ }
136
+ function truncateOneLine(value, maxChars) {
137
+ const normalized = value.replace(/\s+/g, ' ').trim();
138
+ const chars = [...normalized];
139
+ if (chars.length <= maxChars)
140
+ return normalized;
141
+ if (maxChars <= 1)
142
+ return '…';
143
+ return `${chars.slice(0, maxChars - 1).join('')}…`;
144
+ }
145
+ function extractFolderId(folderName) {
146
+ // Folder naming is expected to end with __<id>. If it doesn't, return the full name.
147
+ const parts = folderName.split('__');
148
+ const last = parts.at(-1) ?? folderName;
149
+ return /^[0-9a-f]{8}$/i.test(last) ? last : folderName;
150
+ }
151
+ function listLabels(lang) {
152
+ if (lang === 'ja') {
153
+ return {
154
+ reportTitle: '# 意思決定レポート',
155
+ outDir: '- 出力ディレクトリ',
156
+ period: '- 期間',
157
+ generatedAt: '- 生成日時',
158
+ decisionsSection: '## 一覧',
159
+ periodAll: '全て',
160
+ periodFrom: (from) => `開始 ${from}`,
161
+ periodTo: (to) => `終了 ${to}`,
162
+ id: 'ID',
163
+ date: '日付',
164
+ title: 'タイトル',
165
+ status: 'ステータス',
166
+ decider: '決定者',
167
+ decision: '決定事項'
168
+ };
169
+ }
170
+ return {
171
+ reportTitle: '# Decision Record Report',
172
+ outDir: '- Out dir',
173
+ period: '- Period',
174
+ generatedAt: '- Generated at',
175
+ decisionsSection: '## Decisions',
176
+ periodAll: 'all',
177
+ periodFrom: (from) => `from ${from}`,
178
+ periodTo: (to) => `to ${to}`,
179
+ id: 'ID',
180
+ date: 'Date',
181
+ title: 'Title',
182
+ status: 'Status',
183
+ decider: 'Decider',
184
+ decision: 'Decision'
185
+ };
186
+ }
187
+ export function renderListReportMarkdown(items, options) {
188
+ const lines = [];
189
+ const maxDecisionLen = options.maxDecisionLen ?? 80;
190
+ const lang = options.lang ?? 'en';
191
+ const labels = listLabels(lang);
192
+ lines.push(labels.reportTitle);
193
+ lines.push('');
194
+ const periodParts = [];
195
+ if (options.from !== undefined && options.from.trim().length > 0)
196
+ periodParts.push(labels.periodFrom(options.from));
197
+ if (options.to !== undefined && options.to.trim().length > 0)
198
+ periodParts.push(labels.periodTo(options.to));
199
+ const period = periodParts.length > 0 ? periodParts.join(' ') : labels.periodAll;
200
+ lines.push(`${labels.outDir}: ${options.outDir}`);
201
+ lines.push(`${labels.period}: ${period}`);
202
+ lines.push(`${labels.generatedAt}: ${options.generatedAtIso}`);
203
+ lines.push('');
204
+ lines.push(labels.decisionsSection);
205
+ lines.push('');
206
+ for (const item of items) {
207
+ const id = extractFolderId(item.folderName);
208
+ const date = item.summary?.date ?? item.dateFromFolder ?? '';
209
+ const title = item.summary?.title ?? '';
210
+ const status = item.summary?.status ?? '';
211
+ const decider = item.summary?.decider ?? '';
212
+ const rule = item.ruleExcerpt ?? '';
213
+ lines.push(`- ${labels.id}: ${mdEscape(id)}`);
214
+ if (date.length > 0)
215
+ lines.push(` - ${labels.date}: ${mdEscape(date)}`);
216
+ if (title.length > 0)
217
+ lines.push(` - ${labels.title}: ${mdEscape(title)}`);
218
+ if (status.length > 0)
219
+ lines.push(` - ${labels.status}: ${mdEscape(status)}`);
220
+ if (decider.length > 0)
221
+ lines.push(` - ${labels.decider}: ${mdEscape(decider)}`);
222
+ if (rule.length > 0) {
223
+ const clipped = truncateOneLine(rule, maxDecisionLen);
224
+ lines.push(` - ${labels.decision}: ${mdEscape(clipped)}`);
225
+ }
226
+ // Add a blank line between entries for readability and safer copy/paste.
227
+ lines.push('');
228
+ }
229
+ return lines.join('\n');
230
+ }
package/dist/messages.js CHANGED
@@ -1,3 +1,21 @@
1
+ export function titleRuleTooSimilarLines(lang = 'en') {
2
+ if (lang === 'ja') {
3
+ return [
4
+ 'Warning: title と rule がほぼ同じ内容に見えます。',
5
+ 'Tip: title は短い要約、rule は「全員が守るべきルール(行動・条件・例外)」を書くと役立ちます。',
6
+ 'Example:',
7
+ ' title: "クラウド基盤は AWS を採用する"',
8
+ ' rule: "原則として新規の本番環境は AWS。例外が必要なら別DRで判断する"'
9
+ ];
10
+ }
11
+ return [
12
+ 'Warning: title and rule look very similar.',
13
+ 'Tip: title should be a short summary; rule should be an actionable rule (behavior/conditions/exceptions).',
14
+ 'Example:',
15
+ ' title: "Choose AWS as our cloud provider"',
16
+ ' rule: "Default to AWS for new production. If an exception is needed, create a separate DR."'
17
+ ];
18
+ }
1
19
  export function templateCreatedLines(inputPath) {
2
20
  return [
3
21
  `Created template: ${inputPath}`,
@@ -21,37 +39,86 @@ export function missingCoreFieldsLines(inputPath, missingFields) {
21
39
  export function wroteInputLines(inputPath) {
22
40
  return [`Wrote: ${inputPath}`];
23
41
  }
42
+ export function skippedGenerateLines(inputPath, baseOutDir, lang = 'en') {
43
+ if (lang === 'ja') {
44
+ return [
45
+ 'Note: --skip-generate が指定されているため、出力ファイルの生成はスキップしました。',
46
+ '次に、必要なら decision.yaml を追記・編集してください。',
47
+ `生成するには: dr-gen generate ${inputPath} --out-dir ${baseOutDir}`
48
+ ];
49
+ }
50
+ return [
51
+ 'Note: outputs were not generated because --skip-generate was set.',
52
+ 'Next: edit decision.yaml if needed.',
53
+ `To generate outputs: dr-gen generate ${inputPath} --out-dir ${baseOutDir}`
54
+ ];
55
+ }
24
56
  export function generatedOutputLines(outDir) {
25
57
  return [`Generated: ${outDir}`];
26
58
  }
27
- export function newIntroLines(options) {
59
+ export function newIntroLines(options, lang = 'en') {
28
60
  const lines = [];
29
- lines.push('Answer a few questions. Short answers are OK.');
30
- lines.push('Tip: Focus on the reason (Why) and the rule (Rule).');
61
+ if (lang === 'ja') {
62
+ lines.push('いくつか質問します。短い回答でOKです。');
63
+ lines.push('コツ: 理由(Why)とルール(Rule)に集中してください。');
64
+ }
65
+ else {
66
+ lines.push('Answer a few questions. Short answers are OK.');
67
+ lines.push('Tip: Focus on the reason (Why) and the rule (Rule).');
68
+ }
31
69
  if (options.includeDate) {
32
- lines.push(`Date will be set to today (${options.todayIsoDate}). Use --no-date to disable.`);
70
+ if (lang === 'ja') {
71
+ lines.push(`日付は本日(${options.todayIsoDate})に設定します。無効にするには --no-date を使ってください。`);
72
+ }
73
+ else {
74
+ lines.push(`Date will be set to today (${options.todayIsoDate}). Use --no-date to disable.`);
75
+ }
33
76
  }
34
77
  return lines;
35
78
  }
36
- export function newTitlePrompt() {
79
+ export function newTitlePrompt(lang = 'en') {
80
+ if (lang === 'ja') {
81
+ return `タイトル(必須)
82
+ 何を決めましたか?(短い見出し・名詞句でもOK)
83
+ 例: 「定例会議の上限を30分にする」 / 「初動返信SLAを24時間以内にする」
84
+ > `;
85
+ }
37
86
  return `Title (required)
38
87
  What did you decide? (one sentence)
39
88
  Examples: "Limit recurring meetings to 30 minutes" / "Set support reply time to within 24 hours"
40
89
  > `;
41
90
  }
42
- export function newWhyPrompt() {
91
+ export function newWhyPrompt(lang = 'en') {
92
+ if (lang === 'ja') {
93
+ return `Why(必須)
94
+ この決定をした理由・目的(なぜやるのか?)
95
+ 例: 「セキュリティ事故を防ぐ」 / 「請求をシンプルにする」 / 「長い会議を減らす」
96
+ > `;
97
+ }
43
98
  return `Why (required)
44
99
  Reason / goal behind the decision (why are we doing this?).
45
100
  Examples: "Reduce security risk" / "Make billing simpler" / "Avoid long meetings"
46
101
  > `;
47
102
  }
48
- export function newRulePrompt() {
49
- return `Rule (required)
50
- What rule should everyone follow from now on?
51
- Examples: "Recurring meetings are 30 minutes max" / "Reply within 24 hours on business days"
103
+ export function newRulePrompt(lang = 'en') {
104
+ if (lang === 'ja') {
105
+ return `Decision / Rule(必須)
106
+ 今回の決定は何ですか?(方針・ルール・当面の優先順位など。行動・条件・例外まで書けると良い)
107
+ 例: 「優先開発は list と new --skip-generate。他は後回しにする」
108
+ > `;
109
+ }
110
+ return `Decision / Rule (required)
111
+ What did you decide? (policy/rule/priority; actions are OK)
112
+ Examples: "Prioritize list + --skip-generate first; defer others" / "Default to AWS for new production"
52
113
  > `;
53
114
  }
54
- export function newContextPrompt() {
115
+ export function newContextPrompt(lang = 'en') {
116
+ if (lang === 'ja') {
117
+ return `Context(任意)
118
+ 背景・制約(空欄でもOK)
119
+ 例: 「返信が遅いという声が増えた」 / 「スプレッドシートでパスワード共有していた」
120
+ > `;
121
+ }
55
122
  return `Context (optional)
56
123
  Background / constraints (you can leave this empty).
57
124
  Examples: "Customers say replies are slow" / "We used to share passwords in a spreadsheet"
package/dist/parser.js CHANGED
@@ -33,6 +33,8 @@ export async function parseDecisionYaml(filePath) {
33
33
  title,
34
34
  date: asOptionalString(loaded.date),
35
35
  decider: asOptionalString(loaded.decider),
36
+ status: asOptionalString(loaded.status),
37
+ supersedes: asOptionalString(loaded.supersedes),
36
38
  context: asOptionalString(loaded.context),
37
39
  why: asOptionalString(loaded.why),
38
40
  rule: asOptionalString(loaded.rule),
package/dist/template.js CHANGED
@@ -3,6 +3,8 @@ export function decisionYamlTemplate() {
3
3
  'title: "TODO: Decision title"',
4
4
  'date: ""',
5
5
  'decider: ""',
6
+ 'status: ""',
7
+ 'supersedes: ""',
6
8
  'context: ""',
7
9
  'why: ""',
8
10
  'rule: ""',
package/dist/text.js ADDED
@@ -0,0 +1,24 @@
1
+ export function normalizeComparable(value) {
2
+ return value
3
+ .normalize('NFKC')
4
+ .trim()
5
+ .toLowerCase()
6
+ // Remove whitespace and common punctuation so minor formatting differences don't matter.
7
+ .replace(/[\s\u3000]+/g, '')
8
+ .replace(/["'`“”‘’.,:;!?()\[\]{}<>|\\/\-_=+*~^$#@]+/g, '');
9
+ }
10
+ export function areTitleAndRuleTooSimilar(title, rule) {
11
+ const t = normalizeComparable(title);
12
+ const r = normalizeComparable(rule);
13
+ if (t.length === 0 || r.length === 0)
14
+ return false;
15
+ if (t === r)
16
+ return true;
17
+ // Heuristic: one contains the other and they're very close in length.
18
+ const shorter = t.length <= r.length ? t : r;
19
+ const longer = t.length <= r.length ? r : t;
20
+ if (!longer.includes(shorter))
21
+ return false;
22
+ const diff = longer.length - shorter.length;
23
+ return diff <= 8;
24
+ }
package/dist/yaml.js CHANGED
@@ -4,6 +4,8 @@ export function renderDecisionYaml(record) {
4
4
  title: record.title,
5
5
  date: record.date ?? '',
6
6
  decider: record.decider ?? '',
7
+ status: record.status ?? '',
8
+ supersedes: record.supersedes ?? '',
7
9
  context: record.context ?? '',
8
10
  why: record.why ?? '',
9
11
  rule: record.rule ?? '',
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "dr-gen",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Generate lightweight Decision Records from decision.yaml",
5
+ "homepage": "https://www.ineeza.com/",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ineeza/decision-record-generator.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/Ineeza/decision-record-generator/issues"
12
+ },
5
13
  "license": "MIT",
6
14
  "type": "module",
7
15
  "bin": {