@izkac/forgekit 0.3.11 → 0.3.13

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 (116) hide show
  1. package/bin/forge.mjs +107 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +1 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/init.mjs +680 -621
  17. package/src/install.mjs +815 -815
  18. package/src/install.test.mjs +180 -180
  19. package/src/integrity-check.mjs +60 -60
  20. package/src/integrity.mjs +682 -682
  21. package/src/integrity.test.mjs +566 -566
  22. package/src/lib.mjs +143 -143
  23. package/src/models.defaults.json +41 -41
  24. package/src/new-session.mjs +99 -99
  25. package/src/openspec-overlays/README.md +19 -19
  26. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  27. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  28. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  29. package/src/paths.mjs +92 -92
  30. package/src/plan-engine.mjs +321 -278
  31. package/src/plan-engine.test.mjs +447 -283
  32. package/src/preferences.defaults.json +78 -78
  33. package/src/preferences.mjs +438 -438
  34. package/src/preferences.test.mjs +174 -174
  35. package/src/record-evidence.mjs +204 -204
  36. package/src/resolve-model.mjs +312 -312
  37. package/src/resolve-model.test.mjs +194 -194
  38. package/src/review/cli.test.mjs +117 -117
  39. package/src/review/export.mjs +172 -172
  40. package/src/review/export.test.mjs +197 -197
  41. package/src/review/fixtures/valid-review.json +42 -42
  42. package/src/review/lib.mjs +894 -894
  43. package/src/review/lib.test.mjs +266 -266
  44. package/src/review/schema.json +196 -196
  45. package/src/review/signals.test.mjs +62 -62
  46. package/src/score-cli.mjs +68 -68
  47. package/src/score.mjs +568 -566
  48. package/src/score.test.mjs +366 -340
  49. package/src/session-reminder.mjs +207 -207
  50. package/src/session-status.mjs +70 -70
  51. package/src/set-models.mjs +186 -186
  52. package/src/set-phase.mjs +205 -205
  53. package/src/set-prefs.mjs +294 -294
  54. package/src/specs-sync.mjs +234 -0
  55. package/src/specs-sync.test.mjs +114 -0
  56. package/src/spine.mjs +93 -93
  57. package/src/triage-prompt.mjs +175 -175
  58. package/src/triage-prompt.test.mjs +50 -50
  59. package/src/vendor-openspec-overlays.mjs +176 -176
  60. package/src/vendor-openspec-overlays.test.mjs +62 -62
  61. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  62. package/vendor/skills/forge/SKILL.md +136 -136
  63. package/vendor/skills/forge/docs/forge.md +650 -647
  64. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  65. package/vendor/skills/forge/phases/finish.md +90 -87
  66. package/vendor/skills/forge/phases/implement.md +77 -77
  67. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  68. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  69. package/vendor/skills/forge/phases/review.md +25 -25
  70. package/vendor/skills/forge/phases/verify.md +124 -124
  71. package/vendor/skills/forge/references/forge-layout.md +85 -85
  72. package/vendor/skills/forge/references/pace.md +115 -115
  73. package/vendor/skills/forge/references/plan-routing.md +52 -51
  74. package/vendor/skills/forge/references/runtime-integrity.md +232 -225
  75. package/vendor/skills/forge/references/substantial-work.md +37 -37
  76. package/vendor/skills/forge/references/test-evidence.md +30 -30
  77. package/vendor/skills/forge/references/test-strategy.md +68 -68
  78. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  79. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  80. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  81. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  82. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  83. package/vendor/skills/thorough-code-review/examples.md +133 -133
  84. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  85. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  86. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  87. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  88. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  89. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  90. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  91. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  92. package/vendor/templates/adr/README.md +7 -7
  93. package/vendor/templates/adr/decisions.md +141 -141
  94. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  95. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  96. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  97. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  98. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  99. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  100. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  101. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  102. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  103. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  104. package/vendor/templates/project/claude/commands/forge.md +16 -16
  105. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  106. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  107. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  108. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  109. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  110. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  111. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  112. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  113. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  114. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  115. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  116. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
@@ -1,438 +1,438 @@
1
- /**
2
- * Forge pace preferences — load, merge, auto-resolve, hard floors.
3
- */
4
-
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
-
11
- export const DEFAULTS_PATH = path.join(__dirname, 'preferences.defaults.json');
12
-
13
- export const PACES = Object.freeze(['auto', 'thorough', 'standard', 'brisk', 'lite']);
14
- export const CONCRETE_PACES = Object.freeze(['thorough', 'standard', 'brisk', 'lite']);
15
-
16
- export const REVIEW_PER_TASK = Object.freeze([
17
- 'always',
18
- 'per-group',
19
- 'high-risk-only',
20
- 'never',
21
- ]);
22
- export const REVIEW_FINAL = Object.freeze(['always', 'high-risk-only', 'never']);
23
- export const REVIEW_DEPTH = Object.freeze(['full', 'spec-only']);
24
- export const TIER3 = Object.freeze(['full-workspace', 'affected-only', 'audit-tier2-only']);
25
- export const MODEL_BIAS = Object.freeze(['default', 'prefer-fast']);
26
- export const BRAINSTORM_DEPTH = Object.freeze(['full', 'short', 'minimal']);
27
-
28
- /** Signals that force thorough (order does not matter; any match wins). */
29
- const THOROUGH_RE =
30
- /\b(money|payment|payments|stripe|billing|invoice|refund|auth|oauth|oidc|hmac|secret|secrets|credential|migrat(?:e|ion|ions)|contract|contracts|gdpr|pci|wallet|checkout)\b/i;
31
-
32
- /** Signals that suggest standard (multi-surface / API / platform / orchestration). */
33
- const STANDARD_RE =
34
- /\b(ecosystem|cross-workspace|multi-file|openapi|api\b|shared[- ]package|public api|wire contract|worker|workers|job queue|job queues|queue|pipeline|etl|service|services|platform|orchestration|openspec|forge:apply|harmonization)\b/i;
35
-
36
- /** Signals that suggest lite. */
37
- const LITE_RE =
38
- /\b(docs?|readme|rename|typo|scaffold|wording|comment|comments|cosmetic|changelog)\b/i;
39
-
40
- /** Explicitly small / localized work — only these resolve to brisk under auto. */
41
- const SMALL_WORK_RE =
42
- /\b(fix|tweak|button|toolbar|style|styles|css|padding|alignment|copy|label|typo|cosmetic)\b/i;
43
-
44
- /**
45
- * @param {unknown} value
46
- * @returns {value is Record<string, unknown>}
47
- */
48
- function isPlainObject(value) {
49
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
50
- }
51
-
52
- /**
53
- * @param {Record<string, unknown>} base
54
- * @param {Record<string, unknown>} overlay
55
- */
56
- export function deepMerge(base, overlay) {
57
- /** @type {Record<string, unknown>} */
58
- const out = { ...base };
59
- for (const [key, value] of Object.entries(overlay)) {
60
- if (isPlainObject(value) && isPlainObject(out[key])) {
61
- out[key] = deepMerge(
62
- /** @type {Record<string, unknown>} */ (out[key]),
63
- /** @type {Record<string, unknown>} */ (value),
64
- );
65
- } else {
66
- out[key] = value;
67
- }
68
- }
69
- return out;
70
- }
71
-
72
- /**
73
- * @param {string} filePath
74
- */
75
- export function loadJsonFile(filePath) {
76
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
77
- }
78
-
79
- /**
80
- * @param {{ defaultsPath?: string, forgeDir?: string, cwd?: string }} [paths]
81
- */
82
- export function preferencesLocalPath(paths = {}) {
83
- const cwd = paths.cwd ?? process.cwd();
84
- const forgeDir = paths.forgeDir ?? path.join(cwd, '.forge');
85
- return path.join(forgeDir, 'preferences.local.json');
86
- }
87
-
88
- /**
89
- * @param {{ defaultsPath?: string, forgeDir?: string, cwd?: string }} [paths]
90
- */
91
- export function loadPreferencesDefaults(paths = {}) {
92
- const defaultsPath = paths.defaultsPath ?? DEFAULTS_PATH;
93
- return loadJsonFile(defaultsPath);
94
- }
95
-
96
- /**
97
- * @param {{ defaultsPath?: string, forgeDir?: string, cwd?: string }} [paths]
98
- */
99
- export function loadLocalPreferences(paths = {}) {
100
- const localPath = preferencesLocalPath(paths);
101
- if (!fs.existsSync(localPath)) return { localPath, local: null };
102
- return { localPath, local: loadJsonFile(localPath) };
103
- }
104
-
105
- /**
106
- * @param {string} pace
107
- */
108
- export function assertPace(pace) {
109
- if (!PACES.includes(pace)) {
110
- throw new Error(`Unknown pace "${pace}". Expected one of: ${PACES.join(', ')}`);
111
- }
112
- }
113
-
114
- /**
115
- * @param {string} pace
116
- */
117
- export function assertConcretePace(pace) {
118
- if (!CONCRETE_PACES.includes(pace)) {
119
- throw new Error(`Expected concrete pace, got "${pace}". Expected one of: ${CONCRETE_PACES.join(', ')}`);
120
- }
121
- }
122
-
123
- /**
124
- * Suggest a concrete pace from free-text signals (stricter wins).
125
- * Unrecognized scope fails closed to **standard** (not brisk).
126
- * @param {string} [signalText]
127
- * @returns {{ pace: string, reason: string }}
128
- */
129
- export function suggestPaceFromSignals(signalText = '') {
130
- const text = String(signalText || '').trim();
131
- if (!text) {
132
- return { pace: 'standard', reason: 'no signals; fail closed to standard' };
133
- }
134
- if (THOROUGH_RE.test(text)) {
135
- return { pace: 'thorough', reason: 'high-risk signals (money/auth/contracts/migrations/secrets)' };
136
- }
137
- if (STANDARD_RE.test(text)) {
138
- return { pace: 'standard', reason: 'multi-surface / API / ecosystem / orchestration signals' };
139
- }
140
- if (LITE_RE.test(text) && !STANDARD_RE.test(text) && !SMALL_WORK_RE.test(text)) {
141
- return { pace: 'lite', reason: 'docs/mechanical signals without high-risk terms' };
142
- }
143
- if (SMALL_WORK_RE.test(text) && !STANDARD_RE.test(text) && !THOROUGH_RE.test(text)) {
144
- return { pace: 'brisk', reason: 'explicitly small/localized work signals' };
145
- }
146
- return { pace: 'standard', reason: 'unrecognized scope — failing closed' };
147
- }
148
-
149
- /**
150
- * @param {string} text
151
- */
152
- export function isHighRiskText(text = '') {
153
- return THOROUGH_RE.test(String(text || ''));
154
- }
155
-
156
- /**
157
- * Expand a concrete pace + optional overrides into effective knobs.
158
- * @param {{
159
- * pace: string,
160
- * overrides?: Record<string, unknown>,
161
- * defaults?: Record<string, unknown>,
162
- * }} opts
163
- */
164
- export function expandPace(opts) {
165
- const pace = opts.pace;
166
- assertConcretePace(pace);
167
- const defaults = opts.defaults ?? loadPreferencesDefaults();
168
- const presets = /** @type {Record<string, Record<string, unknown>>} */ (defaults.presets || {});
169
- const preset = presets[pace];
170
- if (!preset) {
171
- throw new Error(`Missing preset expansion for pace "${pace}" in defaults`);
172
- }
173
- const overrides = opts.overrides && isPlainObject(opts.overrides) ? opts.overrides : {};
174
- /** @type {Record<string, unknown>} */
175
- const knobOverrides = {};
176
- for (const key of ['review', 'verify', 'models', 'brainstorm']) {
177
- if (isPlainObject(overrides[key])) {
178
- knobOverrides[key] = overrides[key];
179
- }
180
- }
181
- // Also allow dotted top-level keys already nested in local file without pace key collision
182
- const expanded = deepMerge(structuredClone(preset), knobOverrides);
183
- return {
184
- pace,
185
- review: /** @type {Record<string, unknown>} */ (expanded.review),
186
- verify: /** @type {Record<string, unknown>} */ (expanded.verify),
187
- models: /** @type {Record<string, unknown>} */ (expanded.models),
188
- brainstorm: /** @type {Record<string, unknown>} */ (expanded.brainstorm),
189
- };
190
- }
191
-
192
- /**
193
- * @param {{
194
- * defaultsPath?: string,
195
- * forgeDir?: string,
196
- * cwd?: string,
197
- * session?: Record<string, unknown> | null,
198
- * signalText?: string,
199
- * }} [opts]
200
- */
201
- export function resolveEffectivePreferences(opts = {}) {
202
- const defaults = loadPreferencesDefaults(opts);
203
- const { localPath, local } = loadLocalPreferences(opts);
204
- const session = opts.session && isPlainObject(opts.session) ? opts.session : null;
205
-
206
- /** @type {Record<string, unknown>} */
207
- const localPrefs = local && isPlainObject(local) ? local : {};
208
-
209
- let requestedPace =
210
- typeof localPrefs.pace === 'string' && PACES.includes(localPrefs.pace)
211
- ? localPrefs.pace
212
- : typeof defaults.pace === 'string' && PACES.includes(defaults.pace)
213
- ? defaults.pace
214
- : 'auto';
215
-
216
- let source = local ? 'preferences.local.json' : 'defaults';
217
-
218
- if (session?.preferencesOverride && isPlainObject(session.preferencesOverride)) {
219
- const o = /** @type {Record<string, unknown>} */ (session.preferencesOverride);
220
- if (typeof o.pace === 'string' && PACES.includes(o.pace)) {
221
- requestedPace = o.pace;
222
- source = 'session.preferencesOverride';
223
- }
224
- } else if (typeof session?.pace === 'string' && PACES.includes(session.pace) && session.pace !== 'auto') {
225
- // Session may pin a concrete pace without a full override object
226
- if (session.pacePinned === true) {
227
- requestedPace = session.pace;
228
- source = 'session.pace';
229
- }
230
- }
231
-
232
- /** @type {Record<string, unknown>} */
233
- const overrides = { ...localPrefs };
234
- delete overrides.pace;
235
- delete overrides.presets;
236
- if (session?.preferencesOverride && isPlainObject(session.preferencesOverride)) {
237
- const o = /** @type {Record<string, unknown>} */ (session.preferencesOverride);
238
- for (const key of ['review', 'verify', 'models', 'brainstorm']) {
239
- if (isPlainObject(o[key])) {
240
- overrides[key] = isPlainObject(overrides[key])
241
- ? deepMerge(
242
- /** @type {Record<string, unknown>} */ (overrides[key]),
243
- /** @type {Record<string, unknown>} */ (o[key]),
244
- )
245
- : o[key];
246
- }
247
- }
248
- }
249
-
250
- let resolvedPace = requestedPace;
251
- let paceReason = 'explicit pace';
252
- if (requestedPace === 'auto') {
253
- if (typeof session?.resolvedPace === 'string' && CONCRETE_PACES.includes(session.resolvedPace)) {
254
- resolvedPace = session.resolvedPace;
255
- paceReason =
256
- typeof session.paceReason === 'string' && session.paceReason
257
- ? session.paceReason
258
- : 'session.resolvedPace';
259
- } else {
260
- const signal =
261
- opts.signalText ||
262
- (typeof session?.paceSignal === 'string' ? session.paceSignal : '') ||
263
- (typeof session?.slug === 'string' ? session.slug : '');
264
- const suggested = suggestPaceFromSignals(signal);
265
- resolvedPace = suggested.pace;
266
- paceReason = suggested.reason;
267
- }
268
- }
269
-
270
- const expanded = expandPace({ pace: resolvedPace, overrides, defaults });
271
-
272
- /** @type {Record<string, unknown>} */
273
- const integrityDefaults =
274
- defaults.integrity && isPlainObject(defaults.integrity)
275
- ? /** @type {Record<string, unknown>} */ (defaults.integrity)
276
- : {};
277
- /** @type {Record<string, unknown>} */
278
- const integrityLocal =
279
- localPrefs.integrity && isPlainObject(localPrefs.integrity)
280
- ? /** @type {Record<string, unknown>} */ (localPrefs.integrity)
281
- : {};
282
- const integrity = deepMerge(integrityDefaults, integrityLocal);
283
-
284
- return {
285
- requestedPace,
286
- resolvedPace,
287
- paceReason,
288
- source,
289
- localPath,
290
- localExists: Boolean(local),
291
- effective: expanded,
292
- integrity,
293
- shouldRunPerTaskReview: (ctx = {}) =>
294
- shouldRunPerTaskReview(expanded, ctx),
295
- shouldRunFinalReview: (ctx = {}) => shouldRunFinalReview(expanded, ctx),
296
- };
297
- }
298
-
299
- /**
300
- * Whether to dispatch a reviewer *now* (after the current task / group boundary).
301
- *
302
- * @param {Record<string, unknown>} effective
303
- * @param {{
304
- * highRisk?: boolean,
305
- * signalText?: string,
306
- * groupComplete?: boolean,
307
- * }} [ctx]
308
- * `groupComplete` — true when the just-finished task closes an OpenSpec
309
- * `tasks.md` section (top-level heading group). Required for `per-group`
310
- * cadence on low-risk work.
311
- */
312
- export function shouldRunPerTaskReview(effective, ctx = {}) {
313
- const highRisk = Boolean(ctx.highRisk) || isHighRiskText(ctx.signalText);
314
- const perTask = effective.review?.perTask;
315
- if (perTask === 'always') return true;
316
- if (perTask === 'per-group') {
317
- // Hard floor: money/auth/contracts/… still get an immediate per-task review.
318
- if (highRisk) return true;
319
- return Boolean(ctx.groupComplete);
320
- }
321
- if (perTask === 'high-risk-only') return highRisk;
322
- if (perTask === 'never') return highRisk; // hard floor
323
- return true;
324
- }
325
-
326
- /**
327
- * @param {Record<string, unknown>} effective
328
- * @param {{ highRisk?: boolean, signalText?: string }} [ctx]
329
- */
330
- export function shouldRunFinalReview(effective, ctx = {}) {
331
- const highRisk = Boolean(ctx.highRisk) || isHighRiskText(ctx.signalText);
332
- const final = effective.review?.final;
333
- if (final === 'always') return true;
334
- if (final === 'high-risk-only') return highRisk;
335
- if (final === 'never') return highRisk; // hard floor
336
- return true;
337
- }
338
-
339
- /**
340
- * @param {string} dottedPath
341
- * @param {unknown} value
342
- * @param {Record<string, unknown>} target
343
- */
344
- export function setDotted(target, dottedPath, value) {
345
- const parts = dottedPath.split('.').filter(Boolean);
346
- if (parts.length === 0) throw new Error('Empty path');
347
- let cur = target;
348
- for (let i = 0; i < parts.length - 1; i += 1) {
349
- const key = parts[i];
350
- if (!isPlainObject(cur[key])) cur[key] = {};
351
- cur = /** @type {Record<string, unknown>} */ (cur[key]);
352
- }
353
- cur[parts[parts.length - 1]] = value;
354
- }
355
-
356
- /**
357
- * Parse `review.perTask=always` style assignments.
358
- * @param {string} assignment
359
- */
360
- export function parseAssignment(assignment) {
361
- const eq = assignment.indexOf('=');
362
- if (eq <= 0) throw new Error(`Invalid --set value "${assignment}" (expected path=value)`);
363
- const key = assignment.slice(0, eq).trim();
364
- let raw = assignment.slice(eq + 1).trim();
365
- let value;
366
- if (raw === 'true') value = true;
367
- else if (raw === 'false') value = false;
368
- else if (/^\d+$/.test(raw)) value = Number(raw);
369
- else value = raw;
370
- return { key, value };
371
- }
372
-
373
- /**
374
- * @param {{ forgeDir: string, patch: Record<string, unknown> }} opts
375
- */
376
- export function writeLocalPreferences(opts) {
377
- fs.mkdirSync(opts.forgeDir, { recursive: true });
378
- const localPath = path.join(opts.forgeDir, 'preferences.local.json');
379
- /** @type {Record<string, unknown>} */
380
- let existing = {};
381
- if (fs.existsSync(localPath)) {
382
- existing = loadJsonFile(localPath);
383
- }
384
- const next = deepMerge(existing, opts.patch);
385
- if (typeof next.pace === 'string') assertPace(next.pace);
386
- fs.writeFileSync(localPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
387
- return { localPath, preferences: next };
388
- }
389
-
390
- /**
391
- * Build session pace fields for a new or updated session.
392
- * @param {{
393
- * slug?: string,
394
- * signalText?: string,
395
- * defaultsPath?: string,
396
- * forgeDir?: string,
397
- * cwd?: string,
398
- * paceOverride?: string | null,
399
- * }} opts
400
- */
401
- export function resolveSessionPaceFields(opts = {}) {
402
- const { local } = loadLocalPreferences(opts);
403
- const defaults = loadPreferencesDefaults(opts);
404
- const requestedFromLocal =
405
- local && typeof local.pace === 'string' && PACES.includes(local.pace)
406
- ? local.pace
407
- : typeof defaults.pace === 'string'
408
- ? defaults.pace
409
- : 'auto';
410
-
411
- const requestedPace =
412
- typeof opts.paceOverride === 'string' && PACES.includes(opts.paceOverride)
413
- ? opts.paceOverride
414
- : requestedFromLocal;
415
-
416
- const signal = opts.signalText || opts.slug || '';
417
- if (requestedPace === 'auto') {
418
- const suggested = suggestPaceFromSignals(signal);
419
- return {
420
- pace: 'auto',
421
- resolvedPace: suggested.pace,
422
- paceReason: suggested.reason,
423
- paceSignal: signal || null,
424
- pacePinned: false,
425
- preferencesOverride: null,
426
- };
427
- }
428
-
429
- assertConcretePace(requestedPace);
430
- return {
431
- pace: requestedPace,
432
- resolvedPace: requestedPace,
433
- paceReason: 'explicit pace',
434
- paceSignal: signal || null,
435
- pacePinned: true,
436
- preferencesOverride: null,
437
- };
438
- }
1
+ /**
2
+ * Forge pace preferences — load, merge, auto-resolve, hard floors.
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ export const DEFAULTS_PATH = path.join(__dirname, 'preferences.defaults.json');
12
+
13
+ export const PACES = Object.freeze(['auto', 'thorough', 'standard', 'brisk', 'lite']);
14
+ export const CONCRETE_PACES = Object.freeze(['thorough', 'standard', 'brisk', 'lite']);
15
+
16
+ export const REVIEW_PER_TASK = Object.freeze([
17
+ 'always',
18
+ 'per-group',
19
+ 'high-risk-only',
20
+ 'never',
21
+ ]);
22
+ export const REVIEW_FINAL = Object.freeze(['always', 'high-risk-only', 'never']);
23
+ export const REVIEW_DEPTH = Object.freeze(['full', 'spec-only']);
24
+ export const TIER3 = Object.freeze(['full-workspace', 'affected-only', 'audit-tier2-only']);
25
+ export const MODEL_BIAS = Object.freeze(['default', 'prefer-fast']);
26
+ export const BRAINSTORM_DEPTH = Object.freeze(['full', 'short', 'minimal']);
27
+
28
+ /** Signals that force thorough (order does not matter; any match wins). */
29
+ const THOROUGH_RE =
30
+ /\b(money|payment|payments|stripe|billing|invoice|refund|auth|oauth|oidc|hmac|secret|secrets|credential|migrat(?:e|ion|ions)|contract|contracts|gdpr|pci|wallet|checkout)\b/i;
31
+
32
+ /** Signals that suggest standard (multi-surface / API / platform / orchestration). */
33
+ const STANDARD_RE =
34
+ /\b(ecosystem|cross-workspace|multi-file|openapi|api\b|shared[- ]package|public api|wire contract|worker|workers|job queue|job queues|queue|pipeline|etl|service|services|platform|orchestration|openspec|forge:apply|harmonization)\b/i;
35
+
36
+ /** Signals that suggest lite. */
37
+ const LITE_RE =
38
+ /\b(docs?|readme|rename|typo|scaffold|wording|comment|comments|cosmetic|changelog)\b/i;
39
+
40
+ /** Explicitly small / localized work — only these resolve to brisk under auto. */
41
+ const SMALL_WORK_RE =
42
+ /\b(fix|tweak|button|toolbar|style|styles|css|padding|alignment|copy|label|typo|cosmetic)\b/i;
43
+
44
+ /**
45
+ * @param {unknown} value
46
+ * @returns {value is Record<string, unknown>}
47
+ */
48
+ function isPlainObject(value) {
49
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
50
+ }
51
+
52
+ /**
53
+ * @param {Record<string, unknown>} base
54
+ * @param {Record<string, unknown>} overlay
55
+ */
56
+ export function deepMerge(base, overlay) {
57
+ /** @type {Record<string, unknown>} */
58
+ const out = { ...base };
59
+ for (const [key, value] of Object.entries(overlay)) {
60
+ if (isPlainObject(value) && isPlainObject(out[key])) {
61
+ out[key] = deepMerge(
62
+ /** @type {Record<string, unknown>} */ (out[key]),
63
+ /** @type {Record<string, unknown>} */ (value),
64
+ );
65
+ } else {
66
+ out[key] = value;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * @param {string} filePath
74
+ */
75
+ export function loadJsonFile(filePath) {
76
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
77
+ }
78
+
79
+ /**
80
+ * @param {{ defaultsPath?: string, forgeDir?: string, cwd?: string }} [paths]
81
+ */
82
+ export function preferencesLocalPath(paths = {}) {
83
+ const cwd = paths.cwd ?? process.cwd();
84
+ const forgeDir = paths.forgeDir ?? path.join(cwd, '.forge');
85
+ return path.join(forgeDir, 'preferences.local.json');
86
+ }
87
+
88
+ /**
89
+ * @param {{ defaultsPath?: string, forgeDir?: string, cwd?: string }} [paths]
90
+ */
91
+ export function loadPreferencesDefaults(paths = {}) {
92
+ const defaultsPath = paths.defaultsPath ?? DEFAULTS_PATH;
93
+ return loadJsonFile(defaultsPath);
94
+ }
95
+
96
+ /**
97
+ * @param {{ defaultsPath?: string, forgeDir?: string, cwd?: string }} [paths]
98
+ */
99
+ export function loadLocalPreferences(paths = {}) {
100
+ const localPath = preferencesLocalPath(paths);
101
+ if (!fs.existsSync(localPath)) return { localPath, local: null };
102
+ return { localPath, local: loadJsonFile(localPath) };
103
+ }
104
+
105
+ /**
106
+ * @param {string} pace
107
+ */
108
+ export function assertPace(pace) {
109
+ if (!PACES.includes(pace)) {
110
+ throw new Error(`Unknown pace "${pace}". Expected one of: ${PACES.join(', ')}`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @param {string} pace
116
+ */
117
+ export function assertConcretePace(pace) {
118
+ if (!CONCRETE_PACES.includes(pace)) {
119
+ throw new Error(`Expected concrete pace, got "${pace}". Expected one of: ${CONCRETE_PACES.join(', ')}`);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Suggest a concrete pace from free-text signals (stricter wins).
125
+ * Unrecognized scope fails closed to **standard** (not brisk).
126
+ * @param {string} [signalText]
127
+ * @returns {{ pace: string, reason: string }}
128
+ */
129
+ export function suggestPaceFromSignals(signalText = '') {
130
+ const text = String(signalText || '').trim();
131
+ if (!text) {
132
+ return { pace: 'standard', reason: 'no signals; fail closed to standard' };
133
+ }
134
+ if (THOROUGH_RE.test(text)) {
135
+ return { pace: 'thorough', reason: 'high-risk signals (money/auth/contracts/migrations/secrets)' };
136
+ }
137
+ if (STANDARD_RE.test(text)) {
138
+ return { pace: 'standard', reason: 'multi-surface / API / ecosystem / orchestration signals' };
139
+ }
140
+ if (LITE_RE.test(text) && !STANDARD_RE.test(text) && !SMALL_WORK_RE.test(text)) {
141
+ return { pace: 'lite', reason: 'docs/mechanical signals without high-risk terms' };
142
+ }
143
+ if (SMALL_WORK_RE.test(text) && !STANDARD_RE.test(text) && !THOROUGH_RE.test(text)) {
144
+ return { pace: 'brisk', reason: 'explicitly small/localized work signals' };
145
+ }
146
+ return { pace: 'standard', reason: 'unrecognized scope — failing closed' };
147
+ }
148
+
149
+ /**
150
+ * @param {string} text
151
+ */
152
+ export function isHighRiskText(text = '') {
153
+ return THOROUGH_RE.test(String(text || ''));
154
+ }
155
+
156
+ /**
157
+ * Expand a concrete pace + optional overrides into effective knobs.
158
+ * @param {{
159
+ * pace: string,
160
+ * overrides?: Record<string, unknown>,
161
+ * defaults?: Record<string, unknown>,
162
+ * }} opts
163
+ */
164
+ export function expandPace(opts) {
165
+ const pace = opts.pace;
166
+ assertConcretePace(pace);
167
+ const defaults = opts.defaults ?? loadPreferencesDefaults();
168
+ const presets = /** @type {Record<string, Record<string, unknown>>} */ (defaults.presets || {});
169
+ const preset = presets[pace];
170
+ if (!preset) {
171
+ throw new Error(`Missing preset expansion for pace "${pace}" in defaults`);
172
+ }
173
+ const overrides = opts.overrides && isPlainObject(opts.overrides) ? opts.overrides : {};
174
+ /** @type {Record<string, unknown>} */
175
+ const knobOverrides = {};
176
+ for (const key of ['review', 'verify', 'models', 'brainstorm']) {
177
+ if (isPlainObject(overrides[key])) {
178
+ knobOverrides[key] = overrides[key];
179
+ }
180
+ }
181
+ // Also allow dotted top-level keys already nested in local file without pace key collision
182
+ const expanded = deepMerge(structuredClone(preset), knobOverrides);
183
+ return {
184
+ pace,
185
+ review: /** @type {Record<string, unknown>} */ (expanded.review),
186
+ verify: /** @type {Record<string, unknown>} */ (expanded.verify),
187
+ models: /** @type {Record<string, unknown>} */ (expanded.models),
188
+ brainstorm: /** @type {Record<string, unknown>} */ (expanded.brainstorm),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * @param {{
194
+ * defaultsPath?: string,
195
+ * forgeDir?: string,
196
+ * cwd?: string,
197
+ * session?: Record<string, unknown> | null,
198
+ * signalText?: string,
199
+ * }} [opts]
200
+ */
201
+ export function resolveEffectivePreferences(opts = {}) {
202
+ const defaults = loadPreferencesDefaults(opts);
203
+ const { localPath, local } = loadLocalPreferences(opts);
204
+ const session = opts.session && isPlainObject(opts.session) ? opts.session : null;
205
+
206
+ /** @type {Record<string, unknown>} */
207
+ const localPrefs = local && isPlainObject(local) ? local : {};
208
+
209
+ let requestedPace =
210
+ typeof localPrefs.pace === 'string' && PACES.includes(localPrefs.pace)
211
+ ? localPrefs.pace
212
+ : typeof defaults.pace === 'string' && PACES.includes(defaults.pace)
213
+ ? defaults.pace
214
+ : 'auto';
215
+
216
+ let source = local ? 'preferences.local.json' : 'defaults';
217
+
218
+ if (session?.preferencesOverride && isPlainObject(session.preferencesOverride)) {
219
+ const o = /** @type {Record<string, unknown>} */ (session.preferencesOverride);
220
+ if (typeof o.pace === 'string' && PACES.includes(o.pace)) {
221
+ requestedPace = o.pace;
222
+ source = 'session.preferencesOverride';
223
+ }
224
+ } else if (typeof session?.pace === 'string' && PACES.includes(session.pace) && session.pace !== 'auto') {
225
+ // Session may pin a concrete pace without a full override object
226
+ if (session.pacePinned === true) {
227
+ requestedPace = session.pace;
228
+ source = 'session.pace';
229
+ }
230
+ }
231
+
232
+ /** @type {Record<string, unknown>} */
233
+ const overrides = { ...localPrefs };
234
+ delete overrides.pace;
235
+ delete overrides.presets;
236
+ if (session?.preferencesOverride && isPlainObject(session.preferencesOverride)) {
237
+ const o = /** @type {Record<string, unknown>} */ (session.preferencesOverride);
238
+ for (const key of ['review', 'verify', 'models', 'brainstorm']) {
239
+ if (isPlainObject(o[key])) {
240
+ overrides[key] = isPlainObject(overrides[key])
241
+ ? deepMerge(
242
+ /** @type {Record<string, unknown>} */ (overrides[key]),
243
+ /** @type {Record<string, unknown>} */ (o[key]),
244
+ )
245
+ : o[key];
246
+ }
247
+ }
248
+ }
249
+
250
+ let resolvedPace = requestedPace;
251
+ let paceReason = 'explicit pace';
252
+ if (requestedPace === 'auto') {
253
+ if (typeof session?.resolvedPace === 'string' && CONCRETE_PACES.includes(session.resolvedPace)) {
254
+ resolvedPace = session.resolvedPace;
255
+ paceReason =
256
+ typeof session.paceReason === 'string' && session.paceReason
257
+ ? session.paceReason
258
+ : 'session.resolvedPace';
259
+ } else {
260
+ const signal =
261
+ opts.signalText ||
262
+ (typeof session?.paceSignal === 'string' ? session.paceSignal : '') ||
263
+ (typeof session?.slug === 'string' ? session.slug : '');
264
+ const suggested = suggestPaceFromSignals(signal);
265
+ resolvedPace = suggested.pace;
266
+ paceReason = suggested.reason;
267
+ }
268
+ }
269
+
270
+ const expanded = expandPace({ pace: resolvedPace, overrides, defaults });
271
+
272
+ /** @type {Record<string, unknown>} */
273
+ const integrityDefaults =
274
+ defaults.integrity && isPlainObject(defaults.integrity)
275
+ ? /** @type {Record<string, unknown>} */ (defaults.integrity)
276
+ : {};
277
+ /** @type {Record<string, unknown>} */
278
+ const integrityLocal =
279
+ localPrefs.integrity && isPlainObject(localPrefs.integrity)
280
+ ? /** @type {Record<string, unknown>} */ (localPrefs.integrity)
281
+ : {};
282
+ const integrity = deepMerge(integrityDefaults, integrityLocal);
283
+
284
+ return {
285
+ requestedPace,
286
+ resolvedPace,
287
+ paceReason,
288
+ source,
289
+ localPath,
290
+ localExists: Boolean(local),
291
+ effective: expanded,
292
+ integrity,
293
+ shouldRunPerTaskReview: (ctx = {}) =>
294
+ shouldRunPerTaskReview(expanded, ctx),
295
+ shouldRunFinalReview: (ctx = {}) => shouldRunFinalReview(expanded, ctx),
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Whether to dispatch a reviewer *now* (after the current task / group boundary).
301
+ *
302
+ * @param {Record<string, unknown>} effective
303
+ * @param {{
304
+ * highRisk?: boolean,
305
+ * signalText?: string,
306
+ * groupComplete?: boolean,
307
+ * }} [ctx]
308
+ * `groupComplete` — true when the just-finished task closes an OpenSpec
309
+ * `tasks.md` section (top-level heading group). Required for `per-group`
310
+ * cadence on low-risk work.
311
+ */
312
+ export function shouldRunPerTaskReview(effective, ctx = {}) {
313
+ const highRisk = Boolean(ctx.highRisk) || isHighRiskText(ctx.signalText);
314
+ const perTask = effective.review?.perTask;
315
+ if (perTask === 'always') return true;
316
+ if (perTask === 'per-group') {
317
+ // Hard floor: money/auth/contracts/… still get an immediate per-task review.
318
+ if (highRisk) return true;
319
+ return Boolean(ctx.groupComplete);
320
+ }
321
+ if (perTask === 'high-risk-only') return highRisk;
322
+ if (perTask === 'never') return highRisk; // hard floor
323
+ return true;
324
+ }
325
+
326
+ /**
327
+ * @param {Record<string, unknown>} effective
328
+ * @param {{ highRisk?: boolean, signalText?: string }} [ctx]
329
+ */
330
+ export function shouldRunFinalReview(effective, ctx = {}) {
331
+ const highRisk = Boolean(ctx.highRisk) || isHighRiskText(ctx.signalText);
332
+ const final = effective.review?.final;
333
+ if (final === 'always') return true;
334
+ if (final === 'high-risk-only') return highRisk;
335
+ if (final === 'never') return highRisk; // hard floor
336
+ return true;
337
+ }
338
+
339
+ /**
340
+ * @param {string} dottedPath
341
+ * @param {unknown} value
342
+ * @param {Record<string, unknown>} target
343
+ */
344
+ export function setDotted(target, dottedPath, value) {
345
+ const parts = dottedPath.split('.').filter(Boolean);
346
+ if (parts.length === 0) throw new Error('Empty path');
347
+ let cur = target;
348
+ for (let i = 0; i < parts.length - 1; i += 1) {
349
+ const key = parts[i];
350
+ if (!isPlainObject(cur[key])) cur[key] = {};
351
+ cur = /** @type {Record<string, unknown>} */ (cur[key]);
352
+ }
353
+ cur[parts[parts.length - 1]] = value;
354
+ }
355
+
356
+ /**
357
+ * Parse `review.perTask=always` style assignments.
358
+ * @param {string} assignment
359
+ */
360
+ export function parseAssignment(assignment) {
361
+ const eq = assignment.indexOf('=');
362
+ if (eq <= 0) throw new Error(`Invalid --set value "${assignment}" (expected path=value)`);
363
+ const key = assignment.slice(0, eq).trim();
364
+ let raw = assignment.slice(eq + 1).trim();
365
+ let value;
366
+ if (raw === 'true') value = true;
367
+ else if (raw === 'false') value = false;
368
+ else if (/^\d+$/.test(raw)) value = Number(raw);
369
+ else value = raw;
370
+ return { key, value };
371
+ }
372
+
373
+ /**
374
+ * @param {{ forgeDir: string, patch: Record<string, unknown> }} opts
375
+ */
376
+ export function writeLocalPreferences(opts) {
377
+ fs.mkdirSync(opts.forgeDir, { recursive: true });
378
+ const localPath = path.join(opts.forgeDir, 'preferences.local.json');
379
+ /** @type {Record<string, unknown>} */
380
+ let existing = {};
381
+ if (fs.existsSync(localPath)) {
382
+ existing = loadJsonFile(localPath);
383
+ }
384
+ const next = deepMerge(existing, opts.patch);
385
+ if (typeof next.pace === 'string') assertPace(next.pace);
386
+ fs.writeFileSync(localPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
387
+ return { localPath, preferences: next };
388
+ }
389
+
390
+ /**
391
+ * Build session pace fields for a new or updated session.
392
+ * @param {{
393
+ * slug?: string,
394
+ * signalText?: string,
395
+ * defaultsPath?: string,
396
+ * forgeDir?: string,
397
+ * cwd?: string,
398
+ * paceOverride?: string | null,
399
+ * }} opts
400
+ */
401
+ export function resolveSessionPaceFields(opts = {}) {
402
+ const { local } = loadLocalPreferences(opts);
403
+ const defaults = loadPreferencesDefaults(opts);
404
+ const requestedFromLocal =
405
+ local && typeof local.pace === 'string' && PACES.includes(local.pace)
406
+ ? local.pace
407
+ : typeof defaults.pace === 'string'
408
+ ? defaults.pace
409
+ : 'auto';
410
+
411
+ const requestedPace =
412
+ typeof opts.paceOverride === 'string' && PACES.includes(opts.paceOverride)
413
+ ? opts.paceOverride
414
+ : requestedFromLocal;
415
+
416
+ const signal = opts.signalText || opts.slug || '';
417
+ if (requestedPace === 'auto') {
418
+ const suggested = suggestPaceFromSignals(signal);
419
+ return {
420
+ pace: 'auto',
421
+ resolvedPace: suggested.pace,
422
+ paceReason: suggested.reason,
423
+ paceSignal: signal || null,
424
+ pacePinned: false,
425
+ preferencesOverride: null,
426
+ };
427
+ }
428
+
429
+ assertConcretePace(requestedPace);
430
+ return {
431
+ pace: requestedPace,
432
+ resolvedPace: requestedPace,
433
+ paceReason: 'explicit pace',
434
+ paceSignal: signal || null,
435
+ pacePinned: true,
436
+ preferencesOverride: null,
437
+ };
438
+ }