@izkac/forgekit 0.1.0

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 (143) hide show
  1. package/bin/forge.mjs +100 -0
  2. package/bin/forgekit.mjs +84 -0
  3. package/bin/review.mjs +82 -0
  4. package/package.json +46 -0
  5. package/scripts/prepack.mjs +78 -0
  6. package/scripts/run-tests.mjs +43 -0
  7. package/src/adr.mjs +236 -0
  8. package/src/adr.test.mjs +170 -0
  9. package/src/change.mjs +234 -0
  10. package/src/change.test.mjs +83 -0
  11. package/src/cleanup-sessions.mjs +70 -0
  12. package/src/config.mjs +103 -0
  13. package/src/defer.mjs +75 -0
  14. package/src/doctor.mjs +341 -0
  15. package/src/doctor.test.mjs +114 -0
  16. package/src/init.mjs +575 -0
  17. package/src/install.mjs +777 -0
  18. package/src/install.test.mjs +104 -0
  19. package/src/integrity-check.mjs +58 -0
  20. package/src/integrity.mjs +317 -0
  21. package/src/integrity.test.mjs +296 -0
  22. package/src/lib/workspaces.mjs +55 -0
  23. package/src/lib.mjs +138 -0
  24. package/src/models.defaults.json +41 -0
  25. package/src/new-session.mjs +82 -0
  26. package/src/openspec-overlays/README.md +19 -0
  27. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
  28. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
  29. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
  30. package/src/paths.mjs +92 -0
  31. package/src/plan-engine.mjs +260 -0
  32. package/src/plan-engine.test.mjs +245 -0
  33. package/src/preferences.defaults.json +78 -0
  34. package/src/preferences.mjs +438 -0
  35. package/src/preferences.test.mjs +174 -0
  36. package/src/record-evidence.mjs +204 -0
  37. package/src/record-evidence.test.mjs +260 -0
  38. package/src/resolve-model.mjs +312 -0
  39. package/src/resolve-model.test.mjs +194 -0
  40. package/src/review/carryforward.mjs +413 -0
  41. package/src/review/carryforward.test.mjs +587 -0
  42. package/src/review/cli.test.mjs +117 -0
  43. package/src/review/export.mjs +172 -0
  44. package/src/review/export.test.mjs +197 -0
  45. package/src/review/fixtures/valid-review.json +42 -0
  46. package/src/review/lib.mjs +894 -0
  47. package/src/review/lib.test.mjs +266 -0
  48. package/src/review/merge-tentative.mjs +292 -0
  49. package/src/review/merge-tentative.test.mjs +363 -0
  50. package/src/review/new-review.mjs +200 -0
  51. package/src/review/render.mjs +108 -0
  52. package/src/review/schema-consistency.test.mjs +83 -0
  53. package/src/review/schema.json +196 -0
  54. package/src/review/signals.mjs +144 -0
  55. package/src/review/signals.test.mjs +62 -0
  56. package/src/score-cli.mjs +68 -0
  57. package/src/score.mjs +489 -0
  58. package/src/score.test.mjs +253 -0
  59. package/src/session-reminder.mjs +168 -0
  60. package/src/session-status.mjs +70 -0
  61. package/src/set-models.mjs +186 -0
  62. package/src/set-phase.mjs +177 -0
  63. package/src/set-phase.test.mjs +317 -0
  64. package/src/set-prefs.mjs +294 -0
  65. package/src/spine.mjs +91 -0
  66. package/src/triage-prompt.mjs +175 -0
  67. package/src/triage-prompt.test.mjs +50 -0
  68. package/src/vendor-openspec-overlays.mjs +176 -0
  69. package/src/vendor-openspec-overlays.test.mjs +62 -0
  70. package/vendor/skills/archive-to-adr/SKILL.md +149 -0
  71. package/vendor/skills/forge/SKILL.md +136 -0
  72. package/vendor/skills/forge/phases/brainstorm.md +23 -0
  73. package/vendor/skills/forge/phases/finish.md +87 -0
  74. package/vendor/skills/forge/phases/implement.md +76 -0
  75. package/vendor/skills/forge/phases/plan-openspec.md +40 -0
  76. package/vendor/skills/forge/phases/plan-specs.md +97 -0
  77. package/vendor/skills/forge/phases/review.md +25 -0
  78. package/vendor/skills/forge/phases/verify.md +120 -0
  79. package/vendor/skills/forge/references/forge-layout.md +85 -0
  80. package/vendor/skills/forge/references/pace.md +115 -0
  81. package/vendor/skills/forge/references/plan-routing.md +51 -0
  82. package/vendor/skills/forge/references/runtime-integrity.md +157 -0
  83. package/vendor/skills/forge/references/substantial-work.md +37 -0
  84. package/vendor/skills/forge/references/tdd-core.md +29 -0
  85. package/vendor/skills/forge/references/test-evidence.md +30 -0
  86. package/vendor/skills/forge/references/test-strategy.md +68 -0
  87. package/vendor/skills/forge/skills/NOTICE.md +17 -0
  88. package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
  89. package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
  90. package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
  91. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
  92. package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
  93. package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
  94. package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
  95. package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
  96. package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
  97. package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
  98. package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
  99. package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
  100. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
  101. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
  102. package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
  103. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
  104. package/vendor/skills/thorough-code-review/SKILL.md +290 -0
  105. package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
  106. package/vendor/skills/thorough-code-review/examples.md +133 -0
  107. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
  108. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
  109. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
  110. package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
  111. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
  112. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
  113. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
  114. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
  115. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
  116. package/vendor/templates/adr/README.md +7 -0
  117. package/vendor/templates/adr/decisions.md +141 -0
  118. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
  119. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
  120. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
  121. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
  122. package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
  123. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
  124. package/vendor/templates/project/claude/commands/forge-build.md +17 -0
  125. package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
  126. package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
  127. package/vendor/templates/project/claude/commands/forge-status.md +16 -0
  128. package/vendor/templates/project/claude/commands/forge.md +16 -0
  129. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
  130. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
  131. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
  132. package/vendor/templates/project/claude/rules/forge.md +16 -0
  133. package/vendor/templates/project/codex/rules/forge.md +10 -0
  134. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
  135. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
  136. package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
  137. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
  138. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
  139. package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
  140. package/vendor/templates/project/cursor/commands/forge.md +16 -0
  141. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
  142. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
  143. package/vendor/templates/project/cursor/rules/forge.mdc +21 -0
@@ -0,0 +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
+ }
@@ -0,0 +1,174 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import {
7
+ DEFAULTS_PATH,
8
+ expandPace,
9
+ isHighRiskText,
10
+ parseAssignment,
11
+ resolveEffectivePreferences,
12
+ resolveSessionPaceFields,
13
+ shouldRunFinalReview,
14
+ shouldRunPerTaskReview,
15
+ suggestPaceFromSignals,
16
+ writeLocalPreferences,
17
+ } from './preferences.mjs';
18
+
19
+ function tmp(prefix) {
20
+ return fs.mkdtempSync(path.join(tmpdir(), prefix));
21
+ }
22
+
23
+ test('suggestPaceFromSignals: thorough for money/auth', () => {
24
+ assert.equal(suggestPaceFromSignals('add stripe refund flow').pace, 'thorough');
25
+ assert.equal(suggestPaceFromSignals('OIDC auth hardening').pace, 'thorough');
26
+ assert.equal(suggestPaceFromSignals('mongo migration for orders').pace, 'thorough');
27
+ });
28
+
29
+ test('suggestPaceFromSignals: standard for ecosystem/api', () => {
30
+ assert.equal(suggestPaceFromSignals('openapi route + ecosystem clients').pace, 'standard');
31
+ });
32
+
33
+ test('suggestPaceFromSignals: standard for worker/orchestration/openspec', () => {
34
+ assert.equal(suggestPaceFromSignals('forge:apply etl-surveydb-harmonization-platform').pace, 'standard');
35
+ assert.equal(suggestPaceFromSignals('wire worker job queue pipeline').pace, 'standard');
36
+ assert.equal(suggestPaceFromSignals('openspec change for services platform').pace, 'standard');
37
+ });
38
+
39
+ test('suggestPaceFromSignals: lite for docs', () => {
40
+ assert.equal(suggestPaceFromSignals('update README wording').pace, 'lite');
41
+ });
42
+
43
+ test('suggestPaceFromSignals: brisk only for explicit small work', () => {
44
+ assert.equal(suggestPaceFromSignals('fix toolbar alignment').pace, 'brisk');
45
+ assert.equal(suggestPaceFromSignals('tweak button padding').pace, 'brisk');
46
+ });
47
+
48
+ test('suggestPaceFromSignals: fail closed to standard for empty/unrecognized', () => {
49
+ assert.equal(suggestPaceFromSignals('').pace, 'standard');
50
+ assert.equal(suggestPaceFromSignals('etl-surveydb-harmonization-platform').pace, 'standard');
51
+ assert.equal(suggestPaceFromSignals('mysterious-platform-change').pace, 'standard');
52
+ });
53
+
54
+ test('resolveEffectivePreferences surfaces integrity defaults', () => {
55
+ const forgeDir = tmp('forge-prefs-integrity-');
56
+ const eff = resolveEffectivePreferences({ forgeDir, defaultsPath: DEFAULTS_PATH });
57
+ assert.equal(eff.integrity.forbidStubs, true);
58
+ assert.equal(eff.integrity.specsBeatNarrowTasks, true);
59
+ assert.equal(eff.integrity.requireE2E, 'when-jobs-or-workers');
60
+ });
61
+
62
+ test('expandPace brisk matrix', () => {
63
+ const expanded = expandPace({ pace: 'brisk', defaults: JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')) });
64
+ assert.equal(expanded.review.perTask, 'high-risk-only');
65
+ assert.equal(expanded.verify.tier3, 'affected-only');
66
+ assert.equal(expanded.models.bias, 'prefer-fast');
67
+ assert.equal(expanded.brainstorm.depth, 'short');
68
+ });
69
+
70
+ test('expandPace standard uses per-group review', () => {
71
+ const expanded = expandPace({
72
+ pace: 'standard',
73
+ defaults: JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')),
74
+ });
75
+ assert.equal(expanded.review.perTask, 'per-group');
76
+ assert.equal(expanded.review.final, 'always');
77
+ assert.equal(expanded.review.maxRounds, 2);
78
+ assert.equal(expanded.verify.tier3, 'full-workspace');
79
+ });
80
+
81
+ test('per-group: review only at group boundary unless high-risk', () => {
82
+ const expanded = expandPace({ pace: 'standard' });
83
+ assert.equal(shouldRunPerTaskReview(expanded, { highRisk: false, groupComplete: false }), false);
84
+ assert.equal(shouldRunPerTaskReview(expanded, { highRisk: false, groupComplete: true }), true);
85
+ assert.equal(shouldRunPerTaskReview(expanded, { highRisk: true, groupComplete: false }), true);
86
+ });
87
+
88
+ test('expandPace thorough stays always per-task', () => {
89
+ const expanded = expandPace({
90
+ pace: 'thorough',
91
+ defaults: JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')),
92
+ });
93
+ assert.equal(expanded.review.perTask, 'always');
94
+ assert.equal(expanded.review.maxRounds, 3);
95
+ });
96
+
97
+ test('local override review.perTask on brisk', () => {
98
+ const forgeDir = tmp('forge-prefs-');
99
+ writeLocalPreferences({
100
+ forgeDir,
101
+ patch: { pace: 'brisk', review: { perTask: 'always' } },
102
+ });
103
+ const eff = resolveEffectivePreferences({ forgeDir, defaultsPath: DEFAULTS_PATH });
104
+ assert.equal(eff.requestedPace, 'brisk');
105
+ assert.equal(eff.resolvedPace, 'brisk');
106
+ assert.equal(eff.effective.review.perTask, 'always');
107
+ assert.equal(eff.effective.verify.tier3, 'affected-only');
108
+ });
109
+
110
+ test('default requested pace is auto', () => {
111
+ const forgeDir = tmp('forge-prefs-empty-');
112
+ const eff = resolveEffectivePreferences({ forgeDir, defaultsPath: DEFAULTS_PATH });
113
+ assert.equal(eff.requestedPace, 'auto');
114
+ assert.equal(eff.localExists, false);
115
+ });
116
+
117
+ test('auto uses session.resolvedPace when present', () => {
118
+ const forgeDir = tmp('forge-prefs-session-');
119
+ const eff = resolveEffectivePreferences({
120
+ forgeDir,
121
+ defaultsPath: DEFAULTS_PATH,
122
+ session: { pace: 'auto', resolvedPace: 'lite', paceReason: 'cached' },
123
+ });
124
+ assert.equal(eff.resolvedPace, 'lite');
125
+ assert.equal(eff.paceReason, 'cached');
126
+ });
127
+
128
+ test('session preferencesOverride wins without rewriting local', () => {
129
+ const forgeDir = tmp('forge-prefs-override-');
130
+ writeLocalPreferences({ forgeDir, patch: { pace: 'thorough' } });
131
+ const before = fs.readFileSync(path.join(forgeDir, 'preferences.local.json'), 'utf8');
132
+ const eff = resolveEffectivePreferences({
133
+ forgeDir,
134
+ defaultsPath: DEFAULTS_PATH,
135
+ session: { preferencesOverride: { pace: 'lite' } },
136
+ });
137
+ assert.equal(eff.resolvedPace, 'lite');
138
+ assert.equal(fs.readFileSync(path.join(forgeDir, 'preferences.local.json'), 'utf8'), before);
139
+ });
140
+
141
+ test('hard floor: lite still reviews high-risk tasks', () => {
142
+ const expanded = expandPace({ pace: 'lite' });
143
+ assert.equal(shouldRunPerTaskReview(expanded, { highRisk: true }), true);
144
+ assert.equal(shouldRunPerTaskReview(expanded, { highRisk: false }), false);
145
+ assert.equal(shouldRunFinalReview(expanded, { signalText: 'stripe webhook' }), true);
146
+ assert.equal(shouldRunFinalReview(expanded, { signalText: 'readme typo' }), false);
147
+ });
148
+
149
+ test('isHighRiskText', () => {
150
+ assert.equal(isHighRiskText('hmac secret rotation'), true);
151
+ assert.equal(isHighRiskText('toolbar padding'), false);
152
+ });
153
+
154
+ test('parseAssignment', () => {
155
+ assert.deepEqual(parseAssignment('review.perTask=always'), {
156
+ key: 'review.perTask',
157
+ value: 'always',
158
+ });
159
+ assert.deepEqual(parseAssignment('review.maxRounds=2'), {
160
+ key: 'review.maxRounds',
161
+ value: 2,
162
+ });
163
+ });
164
+
165
+ test('resolveSessionPaceFields auto from slug', () => {
166
+ const forgeDir = tmp('forge-prefs-sessfields-');
167
+ const fields = resolveSessionPaceFields({
168
+ forgeDir,
169
+ defaultsPath: DEFAULTS_PATH,
170
+ slug: 'docs-readme-cleanup',
171
+ });
172
+ assert.equal(fields.pace, 'auto');
173
+ assert.equal(fields.resolvedPace, 'lite');
174
+ });