@izkac/forgekit 0.3.12 → 0.3.14

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 (120) hide show
  1. package/bin/forge.mjs +119 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +2 -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/fleet.test.mjs +50 -0
  17. package/src/init.mjs +680 -621
  18. package/src/install.mjs +815 -815
  19. package/src/install.test.mjs +180 -180
  20. package/src/integrity-check.mjs +60 -60
  21. package/src/integrity.mjs +688 -682
  22. package/src/integrity.test.mjs +612 -566
  23. package/src/lib/fleet.mjs +61 -4
  24. package/src/lib.mjs +160 -143
  25. package/src/lib.test.mjs +128 -0
  26. package/src/models.defaults.json +41 -41
  27. package/src/new-session.mjs +99 -99
  28. package/src/openspec-overlays/README.md +19 -19
  29. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  30. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  31. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  32. package/src/paths.mjs +92 -92
  33. package/src/plan-engine.mjs +321 -278
  34. package/src/plan-engine.test.mjs +447 -283
  35. package/src/preferences.defaults.json +78 -78
  36. package/src/preferences.mjs +438 -438
  37. package/src/preferences.test.mjs +174 -174
  38. package/src/record-evidence.mjs +204 -204
  39. package/src/repo-root.mjs +33 -0
  40. package/src/resolve-model.mjs +312 -312
  41. package/src/resolve-model.test.mjs +194 -194
  42. package/src/review/cli.test.mjs +117 -117
  43. package/src/review/export.mjs +172 -172
  44. package/src/review/export.test.mjs +197 -197
  45. package/src/review/fixtures/valid-review.json +42 -42
  46. package/src/review/lib.mjs +894 -894
  47. package/src/review/lib.test.mjs +266 -266
  48. package/src/review/schema.json +196 -196
  49. package/src/review/signals.test.mjs +62 -62
  50. package/src/score-cli.mjs +68 -68
  51. package/src/score.mjs +568 -568
  52. package/src/score.test.mjs +366 -366
  53. package/src/session-reminder.mjs +207 -207
  54. package/src/session-status.mjs +70 -70
  55. package/src/set-models.mjs +186 -186
  56. package/src/set-phase.mjs +205 -205
  57. package/src/set-prefs.mjs +294 -294
  58. package/src/specs-sync.mjs +232 -0
  59. package/src/specs-sync.test.mjs +114 -0
  60. package/src/spine.mjs +93 -93
  61. package/src/triage-prompt.mjs +175 -175
  62. package/src/triage-prompt.test.mjs +50 -50
  63. package/src/vendor-openspec-overlays.mjs +176 -176
  64. package/src/vendor-openspec-overlays.test.mjs +62 -62
  65. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  66. package/vendor/skills/forge/SKILL.md +136 -136
  67. package/vendor/skills/forge/docs/forge.md +650 -647
  68. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  69. package/vendor/skills/forge/phases/finish.md +90 -87
  70. package/vendor/skills/forge/phases/implement.md +77 -77
  71. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  72. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  73. package/vendor/skills/forge/phases/review.md +25 -25
  74. package/vendor/skills/forge/phases/verify.md +124 -124
  75. package/vendor/skills/forge/references/forge-layout.md +85 -85
  76. package/vendor/skills/forge/references/pace.md +115 -115
  77. package/vendor/skills/forge/references/plan-routing.md +52 -51
  78. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  79. package/vendor/skills/forge/references/substantial-work.md +37 -37
  80. package/vendor/skills/forge/references/test-evidence.md +30 -30
  81. package/vendor/skills/forge/references/test-strategy.md +68 -68
  82. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  83. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  84. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  85. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  86. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  87. package/vendor/skills/thorough-code-review/examples.md +133 -133
  88. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  89. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  90. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  91. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  92. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  93. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  94. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  95. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  96. package/vendor/templates/adr/README.md +7 -7
  97. package/vendor/templates/adr/decisions.md +141 -141
  98. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  99. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  100. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  101. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  102. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  103. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  104. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  105. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  106. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  107. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  108. package/vendor/templates/project/claude/commands/forge.md +16 -16
  109. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  110. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  111. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  112. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  113. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  114. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  115. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  116. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  117. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  118. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  119. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  120. 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
+ }