@open-agent-toolkit/cli 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/assets/docs/cli-utilities/config-and-local-state.md +19 -6
  2. package/assets/docs/cli-utilities/tool-packs.md +13 -8
  3. package/assets/docs/reference/cli-reference.md +14 -13
  4. package/assets/docs/reference/file-locations.md +2 -1
  5. package/assets/docs/reference/oat-directory-structure.md +35 -22
  6. package/assets/migration/pjm-restructure.md +500 -0
  7. package/assets/public-package-versions.json +4 -4
  8. package/assets/scripts/resolve-tracking.sh +52 -0
  9. package/assets/skills/deep-research/SKILL.md +2 -2
  10. package/assets/skills/oat-brainstorm/SKILL.md +3 -3
  11. package/assets/skills/oat-brainstorm/references/destinations.md +10 -2
  12. package/assets/skills/oat-brainstorm/references/dogfood-results.md +4 -4
  13. package/assets/skills/oat-pjm-add-backlog-item/SKILL.md +17 -20
  14. package/assets/skills/oat-pjm-decision/SKILL.md +129 -0
  15. package/assets/skills/oat-pjm-review-backlog/SKILL.md +17 -17
  16. package/assets/skills/oat-pjm-review-backlog/references/backlog-review-template.md +2 -2
  17. package/assets/skills/oat-pjm-review-backlog/references/priority-alignment-template.md +13 -13
  18. package/assets/skills/oat-pjm-update-repo-reference/SKILL.md +34 -25
  19. package/assets/skills/oat-project-complete/SKILL.md +2 -2
  20. package/assets/skills/oat-project-document/SKILL.md +4 -3
  21. package/assets/skills/oat-project-import-plan/SKILL.md +2 -2
  22. package/assets/skills/oat-project-summary/SKILL.md +76 -8
  23. package/assets/templates/backlog-item.md +1 -1
  24. package/assets/templates/current-state.md +11 -2
  25. package/assets/templates/decision.md +18 -0
  26. package/assets/templates/pjm-agents.md +13 -0
  27. package/assets/templates/reference-agents.md +14 -0
  28. package/assets/templates/repo-agents.md +13 -0
  29. package/assets/templates/roadmap.md +8 -3
  30. package/dist/commands/backlog/index.d.ts +1 -0
  31. package/dist/commands/backlog/index.d.ts.map +1 -1
  32. package/dist/commands/backlog/index.js +48 -11
  33. package/dist/commands/backlog/init.js +1 -1
  34. package/dist/commands/backlog/regenerate-index.d.ts.map +1 -1
  35. package/dist/commands/backlog/regenerate-index.js +24 -5
  36. package/dist/commands/backlog/shared/generate-id.d.ts +1 -3
  37. package/dist/commands/backlog/shared/generate-id.d.ts.map +1 -1
  38. package/dist/commands/backlog/shared/generate-id.js +4 -64
  39. package/dist/commands/cleanup/artifacts/artifacts.d.ts.map +1 -1
  40. package/dist/commands/cleanup/artifacts/artifacts.js +4 -1
  41. package/dist/commands/decision/index.d.ts +20 -0
  42. package/dist/commands/decision/index.d.ts.map +1 -0
  43. package/dist/commands/decision/index.js +177 -0
  44. package/dist/commands/decision/init.d.ts +7 -0
  45. package/dist/commands/decision/init.d.ts.map +1 -0
  46. package/dist/commands/decision/init.js +53 -0
  47. package/dist/commands/decision/migrate.d.ts +24 -0
  48. package/dist/commands/decision/migrate.d.ts.map +1 -0
  49. package/dist/commands/decision/migrate.js +270 -0
  50. package/dist/commands/decision/new.d.ts +16 -0
  51. package/dist/commands/decision/new.d.ts.map +1 -0
  52. package/dist/commands/decision/new.js +118 -0
  53. package/dist/commands/decision/regenerate-index.d.ts +14 -0
  54. package/dist/commands/decision/regenerate-index.d.ts.map +1 -0
  55. package/dist/commands/decision/regenerate-index.js +122 -0
  56. package/dist/commands/decision/shared/generate-id.d.ts +2 -0
  57. package/dist/commands/decision/shared/generate-id.d.ts.map +1 -0
  58. package/dist/commands/decision/shared/generate-id.js +5 -0
  59. package/dist/commands/doctor/index.d.ts +5 -0
  60. package/dist/commands/doctor/index.d.ts.map +1 -1
  61. package/dist/commands/doctor/index.js +15 -0
  62. package/dist/commands/index.d.ts.map +1 -1
  63. package/dist/commands/index.js +2 -0
  64. package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
  65. package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
  66. package/dist/commands/init/tools/shared/skill-manifest.js +5 -1
  67. package/dist/commands/pjm/doctor.d.ts +7 -0
  68. package/dist/commands/pjm/doctor.d.ts.map +1 -0
  69. package/dist/commands/pjm/doctor.js +250 -0
  70. package/dist/commands/pjm/index.d.ts +7 -0
  71. package/dist/commands/pjm/index.d.ts.map +1 -1
  72. package/dist/commands/pjm/index.js +135 -10
  73. package/dist/commands/pjm/init.d.ts +3 -2
  74. package/dist/commands/pjm/init.d.ts.map +1 -1
  75. package/dist/commands/pjm/init.js +42 -33
  76. package/dist/commands/pjm/migrate.d.ts +36 -0
  77. package/dist/commands/pjm/migrate.d.ts.map +1 -0
  78. package/dist/commands/pjm/migrate.js +393 -0
  79. package/dist/commands/shared/date-id.d.ts +2 -0
  80. package/dist/commands/shared/date-id.d.ts.map +1 -0
  81. package/dist/commands/shared/date-id.js +10 -0
  82. package/dist/commands/shared/managed-index.d.ts +22 -0
  83. package/dist/commands/shared/managed-index.d.ts.map +1 -0
  84. package/dist/commands/shared/managed-index.js +92 -0
  85. package/dist/commands/shared/slug.d.ts +2 -0
  86. package/dist/commands/shared/slug.d.ts.map +1 -0
  87. package/dist/commands/shared/slug.js +57 -0
  88. package/dist/commands/shared/strip-template-frontmatter.d.ts +2 -0
  89. package/dist/commands/shared/strip-template-frontmatter.d.ts.map +1 -0
  90. package/dist/commands/shared/strip-template-frontmatter.js +16 -0
  91. package/dist/commands/sync/apply.d.ts.map +1 -1
  92. package/dist/commands/sync/apply.js +6 -2
  93. package/dist/manifest/manager.d.ts.map +1 -1
  94. package/dist/manifest/manager.js +4 -1
  95. package/dist/release/public-package-contract.d.ts.map +1 -1
  96. package/dist/release/public-package-contract.js +10 -1
  97. package/package.json +2 -2
  98. package/assets/templates/decision-record.md +0 -21
@@ -0,0 +1,177 @@
1
+ import { resolve } from 'node:path';
2
+ import { buildCommandContext } from '../../app/command-context.js';
3
+ import { readGlobalOptions } from '../shared/shared.utils.js';
4
+ import { resolveAssetsRoot } from '../../fs/assets.js';
5
+ import { resolveProjectRoot } from '../../fs/paths.js';
6
+ import { Command } from 'commander';
7
+ import { initializeDecisionRecords } from './init.js';
8
+ import { migrateDecisionRecords } from './migrate.js';
9
+ import { createDecisionRecord } from './new.js';
10
+ import { regenerateDecisionIndex } from './regenerate-index.js';
11
+ const DEFAULT_DEPENDENCIES = {
12
+ buildCommandContext,
13
+ resolveProjectRoot,
14
+ resolveAssetsRoot,
15
+ initializeDecisionRecords,
16
+ regenerateDecisionIndex,
17
+ createDecisionRecord,
18
+ migrateDecisionRecords,
19
+ };
20
+ async function resolveDecisionsRoot(context, projectRoot, configuredRoot) {
21
+ if (configuredRoot) {
22
+ return resolve(context.cwd, configuredRoot);
23
+ }
24
+ return resolve(projectRoot, '.oat', 'repo', 'reference', 'decisions');
25
+ }
26
+ async function resolveReferenceRoot(context, projectRoot, configuredRoot) {
27
+ if (configuredRoot) {
28
+ return resolve(context.cwd, configuredRoot);
29
+ }
30
+ return resolve(projectRoot, '.oat', 'repo', 'reference');
31
+ }
32
+ function reportError(context, error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ if (context.json) {
35
+ context.logger.json({ status: 'error', message });
36
+ }
37
+ else {
38
+ context.logger.error(message);
39
+ }
40
+ process.exitCode = 1;
41
+ }
42
+ export function createDecisionCommand(overrides = {}) {
43
+ const dependencies = {
44
+ ...DEFAULT_DEPENDENCIES,
45
+ ...overrides,
46
+ };
47
+ const cmd = new Command('decision').description('Manage file-backed decision records and indexes');
48
+ cmd
49
+ .command('init')
50
+ .description('Scaffold the canonical decision directory and index')
51
+ .option('--decisions-root <path>', 'Decisions root directory (defaults to .oat/repo/reference/decisions)')
52
+ .action(async (options, command) => {
53
+ const context = dependencies.buildCommandContext(readGlobalOptions(command));
54
+ try {
55
+ const projectRoot = await dependencies.resolveProjectRoot(context.cwd);
56
+ const decisionsRoot = await resolveDecisionsRoot(context, projectRoot, options.decisionsRoot);
57
+ const result = await dependencies.initializeDecisionRecords(decisionsRoot);
58
+ if (context.json) {
59
+ context.logger.json({ status: 'ok', ...result });
60
+ }
61
+ else {
62
+ context.logger.info(`Initialized decision scaffold at ${result.decisionsRoot}`);
63
+ if (result.created.length > 0) {
64
+ context.logger.info(`Created: ${result.created.join(', ')}`);
65
+ }
66
+ if (result.skipped.length > 0) {
67
+ context.logger.info(`Skipped existing: ${result.skipped.join(', ')}`);
68
+ }
69
+ }
70
+ process.exitCode = 0;
71
+ }
72
+ catch (error) {
73
+ reportError(context, error);
74
+ }
75
+ });
76
+ cmd
77
+ .command('regenerate-index')
78
+ .description('Regenerate the managed decision index table')
79
+ .option('--decisions-root <path>', 'Decisions root directory (defaults to .oat/repo/reference/decisions)')
80
+ .action(async (options, command) => {
81
+ const context = dependencies.buildCommandContext(readGlobalOptions(command));
82
+ try {
83
+ const projectRoot = await dependencies.resolveProjectRoot(context.cwd);
84
+ const decisionsRoot = await resolveDecisionsRoot(context, projectRoot, options.decisionsRoot);
85
+ await dependencies.regenerateDecisionIndex(decisionsRoot);
86
+ if (context.json) {
87
+ context.logger.json({ status: 'ok', decisionsRoot });
88
+ }
89
+ else {
90
+ context.logger.info(`Regenerated decision index at ${decisionsRoot}`);
91
+ }
92
+ process.exitCode = 0;
93
+ }
94
+ catch (error) {
95
+ reportError(context, error);
96
+ }
97
+ });
98
+ cmd
99
+ .command('new')
100
+ .description('Create a new file-backed decision record')
101
+ .argument('<title>', 'Decision title')
102
+ .option('--decisions-root <path>', 'Decisions root directory (defaults to .oat/repo/reference/decisions)')
103
+ .option('--status <status>', 'Decision status', 'proposed')
104
+ .option('--context <text>', 'Initial context body text')
105
+ .option('--created-at <timestamp>', 'Creation timestamp seed for reproducible ID generation')
106
+ .action(async (title, options, command) => {
107
+ const context = dependencies.buildCommandContext(readGlobalOptions(command));
108
+ try {
109
+ const projectRoot = await dependencies.resolveProjectRoot(context.cwd);
110
+ const decisionsRoot = await resolveDecisionsRoot(context, projectRoot, options.decisionsRoot);
111
+ const assetsRoot = await dependencies.resolveAssetsRoot();
112
+ const result = await dependencies.createDecisionRecord({
113
+ decisionsRoot,
114
+ assetsRoot,
115
+ templatesRoot: resolve(projectRoot, '.oat', 'templates'),
116
+ title,
117
+ status: options.status,
118
+ context: options.context,
119
+ createdAt: options.createdAt,
120
+ });
121
+ if (context.json) {
122
+ context.logger.json({ status: 'ok', ...result });
123
+ }
124
+ else {
125
+ context.logger.info(`Created decision record ${result.id}`);
126
+ context.logger.info(`Wrote ${result.filePath}`);
127
+ }
128
+ process.exitCode = 0;
129
+ }
130
+ catch (error) {
131
+ reportError(context, error);
132
+ }
133
+ });
134
+ cmd
135
+ .command('migrate')
136
+ .description('Migrate legacy decision-record.md into decision records')
137
+ .option('--reference-root <path>', 'Reference root directory (defaults to .oat/repo/reference)')
138
+ .option('--dry-run', 'Print legacy-to-new mappings without writing files')
139
+ .option('--delete-legacy', 'Delete decision-record.md after a verified migration')
140
+ .action(async (options, command) => {
141
+ const context = dependencies.buildCommandContext(readGlobalOptions(command));
142
+ try {
143
+ const projectRoot = await dependencies.resolveProjectRoot(context.cwd);
144
+ const referenceRoot = await resolveReferenceRoot(context, projectRoot, options.referenceRoot);
145
+ const result = await dependencies.migrateDecisionRecords({
146
+ referenceRoot,
147
+ dryRun: options.dryRun ?? false,
148
+ deleteLegacy: options.deleteLegacy ?? false,
149
+ });
150
+ if (context.json) {
151
+ context.logger.json({ status: 'ok', ...result });
152
+ }
153
+ else if (!result.legacyPresent) {
154
+ // Absent-file no-op (F4): a prior `pjm migrate` already migrated and
155
+ // removed decision-record.md. Report a friendly no-op, not a throw.
156
+ context.logger.info(result.message ??
157
+ 'Nothing to migrate; no legacy decision-record.md found.');
158
+ }
159
+ else {
160
+ context.logger.info(result.dryRun
161
+ ? `Decision migration dry run for ${result.referenceRoot}`
162
+ : `Migrated decisions into ${result.decisionsRoot}`);
163
+ for (const mapping of result.mappings) {
164
+ context.logger.info(`${mapping.legacyId} -> ${mapping.id} (${mapping.filePath})`);
165
+ }
166
+ if (result.deletedLegacy) {
167
+ context.logger.info('Deleted legacy decision-record.md');
168
+ }
169
+ }
170
+ process.exitCode = 0;
171
+ }
172
+ catch (error) {
173
+ reportError(context, error);
174
+ }
175
+ });
176
+ return cmd;
177
+ }
@@ -0,0 +1,7 @@
1
+ export interface DecisionInitResult {
2
+ decisionsRoot: string;
3
+ created: string[];
4
+ skipped: string[];
5
+ }
6
+ export declare function initializeDecisionRecords(decisionsRoot: string): Promise<DecisionInitResult>;
7
+ //# sourceMappingURL=init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../../src/commands/decision/init.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAsCD,wBAAsB,yBAAyB,CAC7C,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,kBAAkB,CAAC,CAkB7B"}
@@ -0,0 +1,53 @@
1
+ import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { renderDecisionManagedSection } from './regenerate-index.js';
4
+ const STARTER_INDEX = [
5
+ '# OAT Decision Index',
6
+ '',
7
+ '> Generated decision table lives inside the managed section below. Keep curated narrative updates outside the marker pair so CLI regeneration stays safe.',
8
+ '',
9
+ '## Curated Overview',
10
+ '',
11
+ '- Add brief narrative summaries here as decisions are created and migrated.',
12
+ '',
13
+ renderDecisionManagedSection([]),
14
+ '',
15
+ '## Notes',
16
+ '',
17
+ '- Decision records live as file-per-record Markdown files in this directory.',
18
+ '- Regenerate this index with `oat decision regenerate-index` after resolving conflicts.',
19
+ '',
20
+ ].join('\n');
21
+ async function pathExists(path) {
22
+ try {
23
+ await access(path);
24
+ return true;
25
+ }
26
+ catch (error) {
27
+ const code = error && typeof error === 'object' && 'code' in error
28
+ ? String(error.code)
29
+ : null;
30
+ if (code !== 'ENOENT') {
31
+ throw error;
32
+ }
33
+ return false;
34
+ }
35
+ }
36
+ export async function initializeDecisionRecords(decisionsRoot) {
37
+ const created = [];
38
+ const skipped = [];
39
+ await mkdir(decisionsRoot, { recursive: true });
40
+ const indexPath = join(decisionsRoot, 'index.md');
41
+ if (await pathExists(indexPath)) {
42
+ skipped.push('index.md');
43
+ }
44
+ else {
45
+ await writeFile(indexPath, STARTER_INDEX, 'utf8');
46
+ created.push('index.md');
47
+ }
48
+ return {
49
+ decisionsRoot,
50
+ created,
51
+ skipped,
52
+ };
53
+ }
@@ -0,0 +1,24 @@
1
+ export interface DecisionMigrationOptions {
2
+ referenceRoot: string;
3
+ dryRun?: boolean;
4
+ deleteLegacy?: boolean;
5
+ }
6
+ export interface DecisionMigrationMapping {
7
+ legacyId: string;
8
+ id: string;
9
+ title: string;
10
+ date: string;
11
+ filePath: string;
12
+ }
13
+ export interface DecisionMigrationResult {
14
+ referenceRoot: string;
15
+ decisionsRoot: string;
16
+ dryRun: boolean;
17
+ deletedLegacy: boolean;
18
+ mappings: DecisionMigrationMapping[];
19
+ written: string[];
20
+ legacyPresent: boolean;
21
+ message?: string;
22
+ }
23
+ export declare function migrateDecisionRecords(options: DecisionMigrationOptions): Promise<DecisionMigrationResult>;
24
+ //# sourceMappingURL=migrate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../../../src/commands/decision/migrate.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,wBAAwB;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,uBAAuB;IACtC,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,wBAAwB,EAAE,CAAC;IACrC,OAAO,EAAE,MAAM,EAAE,CAAC;IAIlB,aAAa,EAAE,OAAO,CAAC;IAEvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA4RD,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAgFlC"}
@@ -0,0 +1,270 @@
1
+ import { readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { initializeDecisionRecords } from './init.js';
5
+ import { regenerateDecisionIndex } from './regenerate-index.js';
6
+ import { generateDecisionId } from './shared/generate-id.js';
7
+ const NOTHING_TO_MIGRATE_MESSAGE = 'Nothing to migrate; no legacy decision-record.md found.';
8
+ // Real-world `decision-record.md` files use `### ADR-NNN: Title` headings (and
9
+ // `## ADR-NNN: Title` in older fixtures), optionally without a trailing title.
10
+ // The capture groups are: [1] legacy id (e.g. `ADR-001`), [2] optional title.
11
+ const LEGACY_HEADING_PATTERN = /^#{2,3}\s+((?:ADR|DR)-\d+)(?::\s*(.+?))?\s*$/gim;
12
+ const LEGACY_ID_PATTERN = /^(?:ADR|DR)-\d+$/i;
13
+ function parseLegacyIndexRows(content) {
14
+ const rows = [];
15
+ for (const line of content.split(/\r?\n/)) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed.startsWith('|')) {
18
+ continue;
19
+ }
20
+ const cells = trimmed
21
+ .split('|')
22
+ .slice(1, -1)
23
+ .map((cell) => cell.trim());
24
+ const id = cells[0];
25
+ if (!id || id === 'ID' || /^-+$/.test(id)) {
26
+ continue;
27
+ }
28
+ if (!LEGACY_ID_PATTERN.test(id)) {
29
+ continue;
30
+ }
31
+ // The real-world Decision Index is `| ID | Date | Status | Title |`. We use
32
+ // it both as a parity check and as a fallback source for Date/Status/Title.
33
+ rows.push({
34
+ id,
35
+ date: cells[1] || undefined,
36
+ status: cells[2] || undefined,
37
+ title: cells[3] || undefined,
38
+ });
39
+ }
40
+ return rows;
41
+ }
42
+ function parseLegacyIndexIds(content) {
43
+ return parseLegacyIndexRows(content).map((row) => row.id);
44
+ }
45
+ // Matches both the real-world bold form (`- **Date:** 2026-01-30`) and the
46
+ // older plain form (`- Date: 2026-01-30`). The optional `**` markers wrap the
47
+ // `Field:` label, so the colon stays inside the emphasis on the bold form.
48
+ function parseLegacyField(section, field) {
49
+ const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ const regex = new RegExp(`^-\\s*\\*{0,2}${escapedField}:\\*{0,2}\\s*(.+?)\\s*$`, 'im');
51
+ return regex.exec(section)?.[1]?.trim() ?? null;
52
+ }
53
+ // A level-2 heading (exactly two `#`). Decision records are level-3
54
+ // (`### ADR-NNN`) inside the `## Decisions` region and their bodies use level-4
55
+ // (`#### …`) sub-headings, so any `^## ` heading after a record marks the end of
56
+ // the decisions region — e.g. a trailing `## ADR Template` boilerplate block.
57
+ const LEVEL_TWO_HEADING_PATTERN = /^##\s+(?!#)/gm;
58
+ // Returns the offset of the first `^## ` (exactly two-hash) heading that starts
59
+ // strictly after `afterIndex`, or `content.length` when none exists. Used to
60
+ // bound a decision section's body at the end of the decisions region so trailing
61
+ // `##`-level boilerplate is never absorbed into the last record.
62
+ function nextLevelTwoHeadingIndex(content, afterIndex) {
63
+ LEVEL_TWO_HEADING_PATTERN.lastIndex = afterIndex;
64
+ const match = LEVEL_TWO_HEADING_PATTERN.exec(content);
65
+ return match ? match.index : content.length;
66
+ }
67
+ function parseLegacyDecisionSections(content) {
68
+ const indexRows = new Map(parseLegacyIndexRows(content).map((row) => [row.id, row]));
69
+ const matches = [...content.matchAll(LEGACY_HEADING_PATTERN)];
70
+ return matches.map((match, index) => {
71
+ const sectionStart = match.index ?? 0;
72
+ // Bound the body at whichever comes first after this section starts: the
73
+ // next ADR/DR heading, or the next `^## ` level-2 heading (which ends the
74
+ // decisions region, e.g. `## ADR Template`). Default to EOF when neither
75
+ // exists. This keeps trailing boilerplate out of the last record (F2).
76
+ const nextRecordStart = matches[index + 1]?.index ?? content.length;
77
+ const sectionEnd = Math.min(nextRecordStart, nextLevelTwoHeadingIndex(content, sectionStart + 1));
78
+ const section = content.slice(sectionStart, sectionEnd).trim();
79
+ const legacyId = match[1];
80
+ const indexRow = indexRows.get(legacyId);
81
+ const title = (match[2]?.trim() || indexRow?.title || legacyId).trim();
82
+ const date = parseLegacyField(section, 'Date') ?? indexRow?.date ?? null;
83
+ if (!date) {
84
+ throw new Error(`Legacy decision ${legacyId} is missing a Date field.`);
85
+ }
86
+ return {
87
+ legacyId,
88
+ title,
89
+ date,
90
+ status: parseLegacyField(section, 'Status') ?? indexRow?.status ?? 'proposed',
91
+ body: section,
92
+ };
93
+ });
94
+ }
95
+ function assertSafeLegacyDelete(legacyIndexIds, sections) {
96
+ if (sections.length === 0) {
97
+ throw new Error('Refusing to delete legacy decision source because no legacy decision sections were parsed.');
98
+ }
99
+ if (legacyIndexIds.length === 0) {
100
+ return;
101
+ }
102
+ const sectionIds = new Set(sections.map((section) => section.legacyId));
103
+ const missingSections = legacyIndexIds.filter((id) => !sectionIds.has(id));
104
+ if (legacyIndexIds.length !== sections.length || missingSections.length > 0) {
105
+ throw new Error(`Refusing to delete legacy decision source because parsed index count (${legacyIndexIds.length}) does not match parsed section count (${sections.length}). Missing sections: ${missingSections.join(', ') || 'none'}.`);
106
+ }
107
+ }
108
+ function renderMigratedRecord(section, id) {
109
+ const frontmatter = YAML.stringify({
110
+ id,
111
+ title: section.title,
112
+ date: section.date,
113
+ status: section.status,
114
+ legacy_id: section.legacyId,
115
+ }).trimEnd();
116
+ return `---\n${frontmatter}\n---\n\n${section.body.trimEnd()}\n`;
117
+ }
118
+ function prepareDecisionMigrations(sections, decisionsRoot) {
119
+ return sections.map((section) => {
120
+ const id = generateDecisionId(section.title, section.date);
121
+ const mapping = {
122
+ legacyId: section.legacyId,
123
+ id,
124
+ title: section.title,
125
+ date: section.date,
126
+ filePath: join(decisionsRoot, `${id}.md`),
127
+ };
128
+ return {
129
+ mapping,
130
+ content: renderMigratedRecord(section, id),
131
+ };
132
+ });
133
+ }
134
+ function assertUniqueMigrationTargets(preparedMigrations) {
135
+ const seen = new Map();
136
+ for (const prepared of preparedMigrations) {
137
+ const previousLegacyId = seen.get(prepared.mapping.filePath);
138
+ if (previousLegacyId) {
139
+ throw new Error(`Duplicate decision migration target ${prepared.mapping.filePath} generated for ${previousLegacyId} and ${prepared.mapping.legacyId}. Use unique date/title combinations before rerunning migration.`);
140
+ }
141
+ seen.set(prepared.mapping.filePath, prepared.mapping.legacyId);
142
+ }
143
+ }
144
+ async function readExistingTarget(path) {
145
+ try {
146
+ return await readFile(path, 'utf8');
147
+ }
148
+ catch (error) {
149
+ const code = error && typeof error === 'object' && 'code' in error
150
+ ? String(error.code)
151
+ : null;
152
+ if (code !== 'ENOENT') {
153
+ throw error;
154
+ }
155
+ return null;
156
+ }
157
+ }
158
+ // Reads the legacy `decision-record.md`, returning `null` when the file is
159
+ // absent (rather than throwing ENOENT). The absent-file case is a legitimate
160
+ // no-op: a prior `pjm migrate --apply` already migrated decisions end-to-end
161
+ // and removed the legacy monolith, so a standalone `decision migrate` (or
162
+ // `--dry-run`) afterward should degrade gracefully (F4).
163
+ async function readLegacySource(path) {
164
+ try {
165
+ return await readFile(path, 'utf8');
166
+ }
167
+ catch (error) {
168
+ const code = error && typeof error === 'object' && 'code' in error
169
+ ? String(error.code)
170
+ : null;
171
+ if (code !== 'ENOENT') {
172
+ throw error;
173
+ }
174
+ return null;
175
+ }
176
+ }
177
+ async function findPendingWrites(preparedMigrations) {
178
+ const pendingWrites = [];
179
+ for (const prepared of preparedMigrations) {
180
+ const existingContent = await readExistingTarget(prepared.mapping.filePath);
181
+ if (existingContent === null) {
182
+ pendingWrites.push(prepared);
183
+ continue;
184
+ }
185
+ if (existingContent !== prepared.content) {
186
+ throw new Error(`Decision migration target ${prepared.mapping.filePath} already exists with different content. Resolve or move the conflicting file before rerunning migration.`);
187
+ }
188
+ }
189
+ return pendingWrites;
190
+ }
191
+ async function verifyMigratedTargets(preparedMigrations) {
192
+ for (const prepared of preparedMigrations) {
193
+ const existingContent = await readExistingTarget(prepared.mapping.filePath);
194
+ if (existingContent !== prepared.content) {
195
+ throw new Error(`Decision migration target ${prepared.mapping.filePath} was not verified after migration. Rerun without --delete-legacy after resolving the target record.`);
196
+ }
197
+ }
198
+ }
199
+ export async function migrateDecisionRecords(options) {
200
+ const legacyPath = join(options.referenceRoot, 'decision-record.md');
201
+ const decisionsRoot = join(options.referenceRoot, 'decisions');
202
+ const legacyContent = await readLegacySource(legacyPath);
203
+ // Absent-file no-op (F4): nothing to migrate. This is distinct from the
204
+ // present-but-empty/unparseable case (which still flows through parsing and
205
+ // keeps the zero-section delete-safety guard). Return a clean result for both
206
+ // dry-run and apply instead of throwing ENOENT.
207
+ if (legacyContent === null) {
208
+ return {
209
+ referenceRoot: options.referenceRoot,
210
+ decisionsRoot,
211
+ dryRun: options.dryRun ?? false,
212
+ deletedLegacy: false,
213
+ mappings: [],
214
+ written: [],
215
+ legacyPresent: false,
216
+ message: NOTHING_TO_MIGRATE_MESSAGE,
217
+ };
218
+ }
219
+ const legacyIndexIds = parseLegacyIndexIds(legacyContent);
220
+ const sections = parseLegacyDecisionSections(legacyContent);
221
+ if (options.deleteLegacy) {
222
+ assertSafeLegacyDelete(legacyIndexIds, sections);
223
+ }
224
+ const preparedMigrations = prepareDecisionMigrations(sections, decisionsRoot);
225
+ assertUniqueMigrationTargets(preparedMigrations);
226
+ const mappings = preparedMigrations.map((prepared) => prepared.mapping);
227
+ if (options.dryRun) {
228
+ return {
229
+ referenceRoot: options.referenceRoot,
230
+ decisionsRoot,
231
+ dryRun: true,
232
+ deletedLegacy: false,
233
+ mappings,
234
+ written: [],
235
+ legacyPresent: true,
236
+ };
237
+ }
238
+ const pendingWrites = await findPendingWrites(preparedMigrations);
239
+ await initializeDecisionRecords(decisionsRoot);
240
+ const written = [];
241
+ try {
242
+ for (const prepared of pendingWrites) {
243
+ await writeFile(prepared.mapping.filePath, prepared.content, {
244
+ encoding: 'utf8',
245
+ flag: 'wx',
246
+ });
247
+ written.push(prepared.mapping.filePath);
248
+ }
249
+ await regenerateDecisionIndex(decisionsRoot);
250
+ await verifyMigratedTargets(preparedMigrations);
251
+ }
252
+ catch (error) {
253
+ await Promise.all(written.map((path) => rm(path, { force: true })));
254
+ throw error;
255
+ }
256
+ let deletedLegacy = false;
257
+ if (options.deleteLegacy) {
258
+ await rm(legacyPath);
259
+ deletedLegacy = true;
260
+ }
261
+ return {
262
+ referenceRoot: options.referenceRoot,
263
+ decisionsRoot,
264
+ dryRun: false,
265
+ deletedLegacy,
266
+ mappings,
267
+ written,
268
+ legacyPresent: true,
269
+ };
270
+ }
@@ -0,0 +1,16 @@
1
+ export interface CreateDecisionRecordOptions {
2
+ decisionsRoot: string;
3
+ assetsRoot: string;
4
+ templatesRoot?: string;
5
+ title: string;
6
+ status?: string;
7
+ context?: string;
8
+ createdAt?: string;
9
+ }
10
+ export interface CreateDecisionRecordResult {
11
+ id: string;
12
+ decisionsRoot: string;
13
+ filePath: string;
14
+ }
15
+ export declare function createDecisionRecord(options: CreateDecisionRecordOptions): Promise<CreateDecisionRecordResult>;
16
+ //# sourceMappingURL=new.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"new.d.ts","sourceRoot":"","sources":["../../../src/commands/decision/new.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,2BAA2B;IAC1C,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;CAClB;AA0HD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,2BAA2B,GACnC,OAAO,CAAC,0BAA0B,CAAC,CA8CrC"}
@@ -0,0 +1,118 @@
1
+ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { stripTemplateFrontmatter } from '../shared/strip-template-frontmatter.js';
4
+ import YAML from 'yaml';
5
+ import { assertDecisionIndexScaffold, regenerateDecisionIndex, } from './regenerate-index.js';
6
+ import { generateDecisionId } from './shared/generate-id.js';
7
+ async function pathExists(path) {
8
+ try {
9
+ await access(path);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ const code = error && typeof error === 'object' && 'code' in error
14
+ ? String(error.code)
15
+ : null;
16
+ if (code !== 'ENOENT') {
17
+ throw error;
18
+ }
19
+ return false;
20
+ }
21
+ }
22
+ async function readIfExists(path) {
23
+ try {
24
+ return await readFile(path, 'utf8');
25
+ }
26
+ catch (error) {
27
+ const code = error && typeof error === 'object' && 'code' in error
28
+ ? String(error.code)
29
+ : null;
30
+ if (code !== 'ENOENT') {
31
+ throw error;
32
+ }
33
+ return null;
34
+ }
35
+ }
36
+ async function resolveDecisionTemplate(assetsRoot, templatesRoot) {
37
+ if (templatesRoot) {
38
+ const localTemplate = await readIfExists(join(templatesRoot, 'decision.md'));
39
+ if (localTemplate !== null) {
40
+ return localTemplate;
41
+ }
42
+ }
43
+ const bundledTemplate = await readIfExists(join(assetsRoot, 'templates', 'decision.md'));
44
+ if (bundledTemplate !== null) {
45
+ return bundledTemplate;
46
+ }
47
+ throw new Error('Template decision.md was not found in repo-local templates or bundled assets.');
48
+ }
49
+ function normalizeCreatedAt(createdAt) {
50
+ const date = new Date(createdAt);
51
+ if (Number.isNaN(date.getTime())) {
52
+ throw new Error(`Invalid decision creation timestamp: ${createdAt}`);
53
+ }
54
+ const timestamp = date.toISOString();
55
+ return {
56
+ timestamp,
57
+ date: timestamp.slice(0, 10),
58
+ };
59
+ }
60
+ function replaceTemplatePlaceholders(content, replacements) {
61
+ return content.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (match, key) => replacements[key] ?? match);
62
+ }
63
+ function renderDecisionRecord(template, options) {
64
+ const bodyTemplate = stripTemplateFrontmatter(template).replace(/^\r?\n+/, '');
65
+ const body = replaceTemplatePlaceholders(bodyTemplate, {
66
+ id: options.id,
67
+ title: options.title,
68
+ date: options.date,
69
+ status: options.status,
70
+ context: options.context,
71
+ decision: 'TODO',
72
+ consequences: 'TODO',
73
+ });
74
+ const frontmatter = YAML.stringify({
75
+ id: options.id,
76
+ title: options.title,
77
+ date: options.date,
78
+ status: options.status,
79
+ legacy_id: null,
80
+ }).trimEnd();
81
+ return `---\n${frontmatter}\n---\n\n${body.trimEnd()}\n`;
82
+ }
83
+ export async function createDecisionRecord(options) {
84
+ const createdAt = normalizeCreatedAt(options.createdAt ?? new Date().toISOString());
85
+ const status = options.status ?? 'proposed';
86
+ const id = generateDecisionId(options.title, createdAt.timestamp);
87
+ const filePath = join(options.decisionsRoot, `${id}.md`);
88
+ if (await pathExists(filePath)) {
89
+ throw new Error(`Decision record ${id} already exists. Use a more specific title to disambiguate.`);
90
+ }
91
+ const template = await resolveDecisionTemplate(options.assetsRoot, options.templatesRoot);
92
+ const content = renderDecisionRecord(template, {
93
+ id,
94
+ title: options.title,
95
+ date: createdAt.date,
96
+ status,
97
+ context: options.context ?? 'TODO',
98
+ });
99
+ await assertDecisionIndexScaffold(options.decisionsRoot);
100
+ await mkdir(dirname(filePath), { recursive: true });
101
+ let wroteRecord = false;
102
+ try {
103
+ await writeFile(filePath, content, { encoding: 'utf8', flag: 'wx' });
104
+ wroteRecord = true;
105
+ await regenerateDecisionIndex(options.decisionsRoot);
106
+ }
107
+ catch (error) {
108
+ if (wroteRecord) {
109
+ await rm(filePath, { force: true });
110
+ }
111
+ throw error;
112
+ }
113
+ return {
114
+ id,
115
+ decisionsRoot: options.decisionsRoot,
116
+ filePath,
117
+ };
118
+ }