@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.
- package/bin/forge.mjs +107 -107
- package/bin/forgekit.mjs +83 -83
- package/bin/review.mjs +81 -81
- package/package.json +1 -1
- package/scripts/prepack.mjs +78 -78
- package/scripts/run-tests.mjs +50 -50
- package/src/adr.mjs +236 -236
- package/src/adr.test.mjs +170 -170
- package/src/change.mjs +327 -234
- package/src/change.test.mjs +145 -83
- package/src/cleanup-sessions.mjs +84 -84
- package/src/config.mjs +103 -103
- package/src/defer.mjs +75 -75
- package/src/doctor.mjs +350 -341
- package/src/doctor.test.mjs +114 -114
- package/src/init.mjs +680 -621
- package/src/install.mjs +815 -815
- package/src/install.test.mjs +180 -180
- package/src/integrity-check.mjs +60 -60
- package/src/integrity.mjs +682 -682
- package/src/integrity.test.mjs +566 -566
- package/src/lib.mjs +143 -143
- package/src/models.defaults.json +41 -41
- package/src/new-session.mjs +99 -99
- package/src/openspec-overlays/README.md +19 -19
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
- package/src/paths.mjs +92 -92
- package/src/plan-engine.mjs +321 -278
- package/src/plan-engine.test.mjs +447 -283
- package/src/preferences.defaults.json +78 -78
- package/src/preferences.mjs +438 -438
- package/src/preferences.test.mjs +174 -174
- package/src/record-evidence.mjs +204 -204
- package/src/resolve-model.mjs +312 -312
- package/src/resolve-model.test.mjs +194 -194
- package/src/review/cli.test.mjs +117 -117
- package/src/review/export.mjs +172 -172
- package/src/review/export.test.mjs +197 -197
- package/src/review/fixtures/valid-review.json +42 -42
- package/src/review/lib.mjs +894 -894
- package/src/review/lib.test.mjs +266 -266
- package/src/review/schema.json +196 -196
- package/src/review/signals.test.mjs +62 -62
- package/src/score-cli.mjs +68 -68
- package/src/score.mjs +568 -566
- package/src/score.test.mjs +366 -340
- package/src/session-reminder.mjs +207 -207
- package/src/session-status.mjs +70 -70
- package/src/set-models.mjs +186 -186
- package/src/set-phase.mjs +205 -205
- package/src/set-prefs.mjs +294 -294
- package/src/specs-sync.mjs +234 -0
- package/src/specs-sync.test.mjs +114 -0
- package/src/spine.mjs +93 -93
- package/src/triage-prompt.mjs +175 -175
- package/src/triage-prompt.test.mjs +50 -50
- package/src/vendor-openspec-overlays.mjs +176 -176
- package/src/vendor-openspec-overlays.test.mjs +62 -62
- package/vendor/skills/archive-to-adr/SKILL.md +149 -149
- package/vendor/skills/forge/SKILL.md +136 -136
- package/vendor/skills/forge/docs/forge.md +650 -647
- package/vendor/skills/forge/phases/brainstorm.md +23 -23
- package/vendor/skills/forge/phases/finish.md +90 -87
- package/vendor/skills/forge/phases/implement.md +77 -77
- package/vendor/skills/forge/phases/plan-openspec.md +60 -60
- package/vendor/skills/forge/phases/plan-specs.md +163 -117
- package/vendor/skills/forge/phases/review.md +25 -25
- package/vendor/skills/forge/phases/verify.md +124 -124
- package/vendor/skills/forge/references/forge-layout.md +85 -85
- package/vendor/skills/forge/references/pace.md +115 -115
- package/vendor/skills/forge/references/plan-routing.md +52 -51
- package/vendor/skills/forge/references/runtime-integrity.md +232 -225
- package/vendor/skills/forge/references/substantial-work.md +37 -37
- package/vendor/skills/forge/references/test-evidence.md +30 -30
- package/vendor/skills/forge/references/test-strategy.md +68 -68
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
- package/vendor/skills/thorough-code-review/SKILL.md +290 -290
- package/vendor/skills/thorough-code-review/examples.md +133 -133
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
- package/vendor/templates/adr/README.md +7 -7
- package/vendor/templates/adr/decisions.md +141 -141
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
- package/vendor/templates/project/claude/commands/forge-build.md +17 -17
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
- package/vendor/templates/project/claude/commands/forge-status.md +16 -16
- package/vendor/templates/project/claude/commands/forge.md +16 -16
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
- package/vendor/templates/project/cursor/commands/forge.md +16 -16
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
- package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/preferences.mjs
CHANGED
|
@@ -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
|
+
}
|