@codedology/code-forge 0.2.0 → 0.2.1
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/CHANGELOG.md +15 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/skill/scripts/forge +1 -1
- package/src/install/wizard/answers.mjs +102 -46
- package/src/install/wizard/run.mjs +37 -7
- package/src/install/wizard/steps.mjs +134 -34
- package/src/install/wizard/summary.mjs +168 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,21 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.1] — 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Two publish paths:** publish by hand with `npm publish` before pushing the tag (the tag run sees
|
|
14
|
+
the version on npm, skips its own publish and still creates the GitHub release), or push only and
|
|
15
|
+
let CI publish with provenance (needs `NPM_TOKEN`). See `docs/releasing.md`.
|
|
16
|
+
- `init` no longer asks for gates or proof settings one by one: it reads them from the project
|
|
17
|
+
(gates from `detect`, `link_dirs` from the project's manifests, `copy_untracked` only for files
|
|
18
|
+
that exist, high-risk paths blank, isolation `export`), prints a "Project settings (detected)" summary that marks every blank
|
|
19
|
+
and names the keys to edit, then asks one choice: Use these, Customize now (the eight questions,
|
|
20
|
+
pre-filled) or Leave for later. `--no-interaction` asks nothing; agent JSON gains `settings` and
|
|
21
|
+
`blank`. Every question has a one-line hint; a re-run fills a gate that was blank and keeps
|
|
22
|
+
hand-set ones.
|
|
23
|
+
|
|
9
24
|
## [0.2.0] — 2026-09-26
|
|
10
25
|
|
|
11
26
|
### Added
|
package/README.md
CHANGED
|
@@ -30,8 +30,10 @@ code-forge init
|
|
|
30
30
|
The scoped name is used only to install; the command is `code-forge`. The unscoped npm package
|
|
31
31
|
`code-forge` is a different, unrelated tool.
|
|
32
32
|
|
|
33
|
-
The wizard asks
|
|
34
|
-
|
|
33
|
+
The wizard asks a few short questions (tools, harnesses, provider matrix, multimodel, keys,
|
|
34
|
+
engine), every one with a default on Enter. Gates and proof settings come from the project:
|
|
35
|
+
`init` prints what it found, marks what it left blank, and lets you use, customize or leave them
|
|
36
|
+
for later. Non-interactively:
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
39
|
code-forge init --no-interaction --no-jev
|
package/package.json
CHANGED
package/skill/scripts/forge
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
|
|
11
11
|
# equals package.json's version.
|
|
12
12
|
# The shim holds no logic of its own: every verb, flag and message is the CLI's.
|
|
13
|
-
PINNED_VERSION="0.2.
|
|
13
|
+
PINNED_VERSION="0.2.1"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -12,7 +12,7 @@ import { defaultsForProvider } from '../../config/defaults/index.mjs';
|
|
|
12
12
|
import { detectGates } from '../../gates/detect.mjs';
|
|
13
13
|
import { detectAgentEnv } from '../agent-env.mjs';
|
|
14
14
|
import { detectAll } from '../detect.mjs';
|
|
15
|
-
import { ANSWER_FLAGS, PROVIDERS } from './flags.mjs';
|
|
15
|
+
import { ANSWER_FLAGS, GATE_NAMES, PROOF_KEYS, PROVIDERS } from './flags.mjs';
|
|
16
16
|
|
|
17
17
|
/** Harnesses that expose a subagent tool (§5.1: Claude Code's `Agent`, [A13]). */
|
|
18
18
|
export const SUBAGENT_HARNESSES = Object.freeze(['claude']);
|
|
@@ -29,28 +29,33 @@ export const COPY_UNTRACKED = Object.freeze({
|
|
|
29
29
|
python: ['.env'],
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
const
|
|
34
|
-
laravel: ['vendor', 'node_modules'],
|
|
35
|
-
php: ['vendor'],
|
|
36
|
-
node: ['node_modules'],
|
|
37
|
-
python: ['.venv'],
|
|
38
|
-
rust: ['target'],
|
|
39
|
-
go: [],
|
|
40
|
-
unknown: ['vendor', 'node_modules'],
|
|
41
|
-
});
|
|
32
|
+
/** The proof profiles {@link proofProfile} can return. */
|
|
33
|
+
const PROFILES = Object.freeze(['laravel', 'php', 'node', 'python', 'rust', 'go', 'unknown']);
|
|
42
34
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The one source of truth for `link_dirs` (B24): each dependency folder an export links instead of
|
|
37
|
+
* copying, with the manifests that make it right to propose. A folder is proposed when one of its
|
|
38
|
+
* manifests exists, whether or not the dependencies are installed yet.
|
|
39
|
+
*/
|
|
40
|
+
export const LINK_DIR_MANIFESTS = Object.freeze({
|
|
41
|
+
vendor: Object.freeze(['composer.json', 'go.mod']),
|
|
42
|
+
node_modules: Object.freeze(['package.json']),
|
|
43
|
+
'.venv': Object.freeze(['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile']),
|
|
44
|
+
target: Object.freeze(['Cargo.toml']),
|
|
52
45
|
});
|
|
53
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The `link_dirs` entries whose manifest exists in `cwd`, in {@link LINK_DIR_MANIFESTS} order, each
|
|
49
|
+
* with the manifests found for it.
|
|
50
|
+
* @param {string} cwd
|
|
51
|
+
* @returns {Array<{dir: string, manifests: string[]}>}
|
|
52
|
+
*/
|
|
53
|
+
export function linkDirsFromManifests(cwd) {
|
|
54
|
+
return Object.entries(LINK_DIR_MANIFESTS)
|
|
55
|
+
.map(([dir, manifests]) => ({ dir, manifests: manifests.filter((m) => existsSync(path.join(cwd, m))) }))
|
|
56
|
+
.filter((e) => e.manifests.length > 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
/**
|
|
55
60
|
* The proof profile is decided from the project, not from the gate detector's stack name alone:
|
|
56
61
|
* `detectGates` (B5) names only `php-pest`, so a Laravel app on PHPUnit reaches here as `node`
|
|
@@ -65,7 +70,7 @@ export function proofProfile(stack, cwd) {
|
|
|
65
70
|
if (php && existsSync(path.join(cwd, 'artisan'))) return 'laravel';
|
|
66
71
|
if (stack.startsWith('php')) return 'php';
|
|
67
72
|
// a detected non-PHP stack keeps its word; only an undetected project falls back on composer.json
|
|
68
|
-
if (stack !== 'unknown' &&
|
|
73
|
+
if (stack !== 'unknown' && PROFILES.includes(stack)) return stack;
|
|
69
74
|
return composer ? 'php' : 'unknown';
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -109,16 +114,33 @@ export function proposeJudge(provider) {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
/**
|
|
112
|
-
* Step 8's
|
|
117
|
+
* Step 8's `copy_untracked` candidates for a profile, before the project is looked at.
|
|
118
|
+
* @param {string} profile
|
|
119
|
+
* @returns {string[]}
|
|
120
|
+
*/
|
|
121
|
+
export function copyCandidates(profile) {
|
|
122
|
+
return [...(COPY_UNTRACKED[profile] ?? [])];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Step 8's proposal (B24): nothing is asked. High-risk paths are a deliberate blank (the project
|
|
127
|
+
* never says which paths are high-risk) and isolation is `export`. `link_dirs` is every folder whose
|
|
128
|
+
* manifest ({@link LINK_DIR_MANIFESTS}) exists in `cwd`, installed or not, whatever the profile (a
|
|
129
|
+
* PHP app with a `package.json` links `node_modules` too); `copy_untracked` keeps the profile's
|
|
130
|
+
* candidates that exist in `cwd` themselves. Nothing kept gives `[]`. Without `cwd` nothing can be
|
|
131
|
+
* looked at: `link_dirs` is `[]` and `copy_untracked` the unfiltered candidates.
|
|
113
132
|
* @param {string} profile
|
|
133
|
+
* @param {string} [cwd]
|
|
114
134
|
* @returns {{high: string[], isolation: 'export', link_dirs: string[], copy_untracked: string[]}}
|
|
115
135
|
*/
|
|
116
|
-
export function proposeProof(profile) {
|
|
136
|
+
export function proposeProof(profile, cwd) {
|
|
137
|
+
const candidates = copyCandidates(profile);
|
|
138
|
+
if (cwd === undefined) return { high: [], isolation: 'export', link_dirs: [], copy_untracked: candidates };
|
|
117
139
|
return {
|
|
118
|
-
high: [
|
|
140
|
+
high: [],
|
|
119
141
|
isolation: 'export',
|
|
120
|
-
link_dirs:
|
|
121
|
-
copy_untracked:
|
|
142
|
+
link_dirs: linkDirsFromManifests(cwd).map((e) => e.dir),
|
|
143
|
+
copy_untracked: candidates.filter((rel) => existsSync(path.join(cwd, rel))),
|
|
122
144
|
};
|
|
123
145
|
}
|
|
124
146
|
|
|
@@ -272,6 +294,12 @@ function gateArgv(gate) {
|
|
|
272
294
|
* @property {boolean} doctor
|
|
273
295
|
*/
|
|
274
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Where each gate and each proof value came from (B24): `flag`, `existing` (the current
|
|
299
|
+
* `.code-forge.yml`), `detected` (the project on disk) or `default`.
|
|
300
|
+
* @typedef {{gates: Record<string, 'flag'|'existing'|'detected'>, proof: Record<string, 'flag'|'existing'|'detected'|'default'>}} Origins
|
|
301
|
+
*/
|
|
302
|
+
|
|
275
303
|
/** The answer keys in step order. */
|
|
276
304
|
export const ANSWER_KEYS = Object.freeze([
|
|
277
305
|
'tools', 'yes_tools', 'harnesses', 'scope', 'method', 'provider', 'levels', 'refresh_models',
|
|
@@ -283,11 +311,13 @@ export const ANSWER_KEYS = Object.freeze([
|
|
|
283
311
|
* @param {Record<string, any>|null} existing - the current `.code-forge.yml`, when there is one.
|
|
284
312
|
* @param {Record<string, any>|null} user - the current user-level config (`~/.code-forge/`).
|
|
285
313
|
* @param {Array<{flag: string, value: any}>} given - parsed answer flags.
|
|
286
|
-
* @returns {{values: Answers, sources: Record<string, 'default'|'existing'|'flag'
|
|
314
|
+
* @returns {{values: Answers, sources: Record<string, 'default'|'existing'|'flag'>, origins: Origins}}
|
|
287
315
|
*/
|
|
288
316
|
export function resolveAnswers(ctx, existing, user, given) {
|
|
289
317
|
/** @type {Record<string, 'default'|'existing'|'flag'>} */
|
|
290
318
|
const sources = {};
|
|
319
|
+
/** @type {Origins} */
|
|
320
|
+
const origins = { gates: {}, proof: {} };
|
|
291
321
|
const ex = existing ?? {};
|
|
292
322
|
/** @type {any} */
|
|
293
323
|
const v = {};
|
|
@@ -352,38 +382,64 @@ export function resolveAnswers(ctx, existing, user, given) {
|
|
|
352
382
|
set('solo_project', user?.solo?.project_id, () => null);
|
|
353
383
|
if (lastFlag('solo_project')) override('solo_project', lastFlag('solo_project').value);
|
|
354
384
|
|
|
385
|
+
// Gates and proof are never asked (B24): each value comes from a flag, else the existing
|
|
386
|
+
// config, else the project. `origins` records which, per gate and per proof key. A gate the
|
|
387
|
+
// existing config left blank is filled when the project now shows one; a set gate is kept.
|
|
355
388
|
const detectedGates = {
|
|
356
389
|
test: gateArgv(ctx.detected.test),
|
|
357
390
|
lint: gateArgv(ctx.detected.lint),
|
|
358
391
|
types: gateArgv(ctx.detected.types),
|
|
359
392
|
format: gateArgv(ctx.detected.format),
|
|
360
393
|
};
|
|
361
|
-
const exGates = ex.gates && typeof ex.gates === 'object' ? ex.gates :
|
|
362
|
-
|
|
394
|
+
const exGates = ex.gates && typeof ex.gates === 'object' ? ex.gates : {};
|
|
395
|
+
// keys beyond the four gates (`extra`, `full_suite_threshold_files`, …) are kept as they are
|
|
396
|
+
const gates = /** @type {Record<string, any>} */ (clone(exGates));
|
|
397
|
+
for (const name of GATE_NAMES) {
|
|
398
|
+
const kept = exGates[name];
|
|
399
|
+
const keep = kept !== undefined && kept !== null;
|
|
400
|
+
gates[name] = keep ? clone(kept) : detectedGates[/** @type {keyof typeof detectedGates} */ (name)];
|
|
401
|
+
origins.gates[name] = keep ? 'existing' : 'detected';
|
|
402
|
+
}
|
|
403
|
+
set('gates', Object.keys(exGates).length > 0 ? gates : undefined, () => gates);
|
|
363
404
|
if (flagsFor('gates').length > 0) {
|
|
364
|
-
const
|
|
365
|
-
for (const g of flagsFor('gates'))
|
|
366
|
-
|
|
405
|
+
const flagged = clone(v.gates);
|
|
406
|
+
for (const g of flagsFor('gates')) {
|
|
407
|
+
flagged[g.value.name] = g.value.argv;
|
|
408
|
+
origins.gates[g.value.name] = 'flag';
|
|
409
|
+
}
|
|
410
|
+
override('gates', flagged);
|
|
367
411
|
}
|
|
368
412
|
|
|
369
|
-
const proposal = proposeProof(ctx.profile);
|
|
370
|
-
const exProof = ex.proof && typeof ex.proof === 'object'
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
413
|
+
const proposal = proposeProof(ctx.profile, ctx.cwd);
|
|
414
|
+
const exProof = ex.proof && typeof ex.proof === 'object' ? ex.proof : {};
|
|
415
|
+
/** @type {Record<string, unknown>} */
|
|
416
|
+
const exProofValues = {
|
|
417
|
+
high: exProof.tiers?.high?.paths,
|
|
418
|
+
isolation: exProof.isolation,
|
|
419
|
+
link_dirs: exProof.export?.link_dirs,
|
|
420
|
+
copy_untracked: exProof.export?.copy_untracked,
|
|
421
|
+
};
|
|
422
|
+
const proof = /** @type {Record<string, any>} */ ({});
|
|
423
|
+
for (const key of PROOF_KEYS) {
|
|
424
|
+
const kept = exProofValues[key];
|
|
425
|
+
const keep = kept !== undefined && kept !== null;
|
|
426
|
+
proof[key] = keep ? clone(kept) : proposal[/** @type {keyof typeof proposal} */ (key)];
|
|
427
|
+
// without a cwd nothing was looked at, so the lists are defaults, not detections
|
|
428
|
+
const looked = ctx.cwd !== undefined && (key === 'link_dirs' || key === 'copy_untracked');
|
|
429
|
+
origins.proof[key] = keep ? 'existing' : looked ? 'detected' : 'default';
|
|
430
|
+
}
|
|
431
|
+
set('proof', ex.proof && typeof ex.proof === 'object' ? proof : undefined, () => proof);
|
|
379
432
|
if (flagsFor('proof').length > 0) {
|
|
380
|
-
const
|
|
381
|
-
for (const g of flagsFor('proof'))
|
|
382
|
-
|
|
433
|
+
const flagged = clone(v.proof);
|
|
434
|
+
for (const g of flagsFor('proof')) {
|
|
435
|
+
flagged[g.value.key] = g.value.value;
|
|
436
|
+
origins.proof[g.value.key] = 'flag';
|
|
437
|
+
}
|
|
438
|
+
override('proof', flagged);
|
|
383
439
|
}
|
|
384
440
|
|
|
385
441
|
set('doctor', undefined, () => true);
|
|
386
442
|
if (lastFlag('doctor')) override('doctor', false);
|
|
387
443
|
|
|
388
|
-
return { values: v, sources };
|
|
444
|
+
return { values: v, sources, origins };
|
|
389
445
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge init` — the nine-step setup wizard (plan §2; block B13a).
|
|
3
3
|
*
|
|
4
|
-
* Order: answers (defaults ← existing config ← flags; then questions when interactive) →
|
|
4
|
+
* Order: answers (defaults ← existing config ← flags; then questions when interactive) → the
|
|
5
|
+
* "Project settings (detected)" summary of gates and proof (B24; interactive: one "Use these /
|
|
6
|
+
* Customize now / Leave for later" choice, and the eight pre-filled questions on Customize) → step 5
|
|
5
7
|
* key source → build + merge + validate the config (nothing is written when it is invalid or a
|
|
6
8
|
* required answer is missing) → write `.code-forge.yml` only when it changes → tools (step 1),
|
|
7
9
|
* model refresh (3), harness links (2), user config → the §5.1 stop line when the harness has no
|
|
@@ -9,7 +11,7 @@
|
|
|
9
11
|
*
|
|
10
12
|
* Output: an agent harness (`CLAUDECODE`, `CLAUDE_CODE`, `CURSOR_AGENT`, `CODEX_SANDBOX*`, `AI_AGENT`) or
|
|
11
13
|
* a non-TTY stdin gets exactly ONE JSON line on stdout — `{ok, wrote, harnesses, engine_stop,
|
|
12
|
-
* doctor, log, log_tail}` on success, `{ok: false, error, wrote, log, log_tail}` on any failure
|
|
14
|
+
* doctor, settings, blank, log, log_tail}` on success, `{ok: false, error, wrote, log, log_tail}` on any failure
|
|
13
15
|
* after the flags parsed; a person gets the lines (and `init: <error>` on stderr). Either way the
|
|
14
16
|
* lines go to `~/.code-forge/logs/init-<ts>.log`, on failure too. Every line passes through
|
|
15
17
|
* `redact`; a key typed at the prompt goes to the key store and nowhere else — the config holds its
|
|
@@ -34,7 +36,8 @@ import { install, installsPath } from '../link.mjs';
|
|
|
34
36
|
import { currentHarness, effectiveProvider, gatherContext, judgeCollision, proposeJudge, resolveAnswers, SUBAGENT_HARNESSES } from './answers.mjs';
|
|
35
37
|
import { changedPaths, generatedConfig, mergeConfig, mergeInto, serializeConfig } from './project-file.mjs';
|
|
36
38
|
import { parseInitArgs, UsageError } from './flags.mjs';
|
|
37
|
-
import { askAnswers, askJevSource, CancelledError } from './steps.mjs';
|
|
39
|
+
import { askAnswers, askJevSource, askProjectSettings, askSettingsChoice, CancelledError, HINTS, withHint } from './steps.mjs';
|
|
40
|
+
import { summarizeSettings } from './summary.mjs';
|
|
38
41
|
|
|
39
42
|
/** §5.1's stop text, printed at setup (not at the first run) when there is no engine to use. */
|
|
40
43
|
export const NO_ENGINE_STOP_TEXT = [
|
|
@@ -164,6 +167,10 @@ export async function runInit(args, deps = {}) {
|
|
|
164
167
|
let engineStop = false;
|
|
165
168
|
/** @type {{ok: boolean, counts: Record<string, number>} | null} */
|
|
166
169
|
let doctor = null;
|
|
170
|
+
/** @type {import('./summary.mjs').SettingsSummary | null} */
|
|
171
|
+
let summary = null;
|
|
172
|
+
// lines already on stdout (the settings summary): `finish` prints only the rest
|
|
173
|
+
let printed = 0;
|
|
167
174
|
|
|
168
175
|
/**
|
|
169
176
|
* The one exit: writes the log, then either the success shape or `{ok: false, error, wrote, …}`.
|
|
@@ -186,11 +193,12 @@ export async function runInit(args, deps = {}) {
|
|
|
186
193
|
const tail = lines.slice(-10);
|
|
187
194
|
if (jsonMode) {
|
|
188
195
|
const body = error === null
|
|
189
|
-
? { ok: code === 0, wrote, harnesses: linked, engine_stop: engineStop, doctor, log, log_tail: tail }
|
|
196
|
+
? { ok: code === 0, wrote, harnesses: linked, engine_stop: engineStop, doctor, settings: summary?.settings ?? null, blank: summary?.blank ?? [], log, log_tail: tail }
|
|
190
197
|
: { ok: false, error, wrote, log, log_tail: tail };
|
|
191
198
|
writeAgentJson(JSON.parse(redact(JSON.stringify(body))), { stdout });
|
|
192
199
|
} else if (error === null) {
|
|
193
|
-
|
|
200
|
+
const rest = lines.slice(printed);
|
|
201
|
+
stdout.write(`${rest.length > 0 ? `${rest.join('\n')}\n` : ''}log: ${log}\n`);
|
|
194
202
|
}
|
|
195
203
|
return code;
|
|
196
204
|
};
|
|
@@ -215,12 +223,34 @@ export async function runInit(args, deps = {}) {
|
|
|
215
223
|
const userFile = path.join(cfDir, USER_CONFIG_FILE);
|
|
216
224
|
const userCfg = await readUserConfig(userFile);
|
|
217
225
|
|
|
218
|
-
const { values, sources } = resolveAnswers(ctx, existing, userCfg, parsed.given);
|
|
226
|
+
const { values, sources, origins } = resolveAnswers(ctx, existing, userCfg, parsed.given);
|
|
219
227
|
if (interactive) {
|
|
220
228
|
ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
221
229
|
await askAnswers(values, sources, ctx, ui);
|
|
222
230
|
}
|
|
223
231
|
|
|
232
|
+
// Gates and proof are never asked (B24): the summary says what was found and what is blank.
|
|
233
|
+
// A person sees it now, before anything is written; agent mode gets it in the JSON line.
|
|
234
|
+
summary = summarizeSettings(values, origins, ctx);
|
|
235
|
+
/** @param {string[]} shown - logged, and printed at once for a person */
|
|
236
|
+
const showNow = (shown) => {
|
|
237
|
+
for (const line of shown) say(line);
|
|
238
|
+
if (jsonMode) return;
|
|
239
|
+
stdout.write(`${lines.slice(printed).join('\n')}\n`);
|
|
240
|
+
printed = lines.length;
|
|
241
|
+
};
|
|
242
|
+
showNow(summary.lines);
|
|
243
|
+
if (interactive) {
|
|
244
|
+
const choice = await askSettingsChoice(ui);
|
|
245
|
+
if (choice === 'customize') {
|
|
246
|
+
const changedKeys = await askProjectSettings(values, origins, ui);
|
|
247
|
+
say(`settings: customized (${changedKeys.length > 0 ? changedKeys.join(', ') : 'no change'})`);
|
|
248
|
+
} else if (choice === 'later') {
|
|
249
|
+
const keys = summary.blank.length > 0 ? summary.blank : Object.keys(summary.settings);
|
|
250
|
+
showNow([`settings: edit .code-forge.yml later: ${keys.join(', ')} — then run \`code-forge validate\``]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
224
254
|
// Step 5 — where the Jev key comes from. Only a reference is ever kept: `env:NAME` when the
|
|
225
255
|
// environment holds it, else `user` (the key store holds it, whatever backend the store
|
|
226
256
|
// reports — keychain, file, a cached 1Password read…); the backend name is recorded as the
|
|
@@ -278,7 +308,7 @@ export async function runInit(args, deps = {}) {
|
|
|
278
308
|
say(`${DEFAULT_CONFIG_FILENAME}: unchanged`);
|
|
279
309
|
} else {
|
|
280
310
|
if (existing) say(`${DEFAULT_CONFIG_FILENAME}: changes ${changed.join(', ')}`);
|
|
281
|
-
const go = existing && interactive ? await ui.confirm({ message: `Apply ${changed.length} change(s) to ${DEFAULT_CONFIG_FILENAME}?`, initialValue: true }) : true;
|
|
311
|
+
const go = existing && interactive ? await ui.confirm({ message: withHint(`Apply ${changed.length} change(s) to ${DEFAULT_CONFIG_FILENAME}?`, HINTS.apply), initialValue: true }) : true;
|
|
282
312
|
if (ui?.isCancel(go) || go !== true) throw new CancelledError('cancelled — nothing written');
|
|
283
313
|
await writeText(configFile, serializeConfig(merged));
|
|
284
314
|
wrote.push(configFile);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The interactive half of `code-forge init` (plan §2, block B13a): one question per answer that
|
|
3
|
-
* no flag already fixed, default on Enter
|
|
3
|
+
* no flag already fixed, default on Enter, each with a one-line hint (B24). Gates and proof
|
|
4
|
+
* settings come from the project and are shown in a summary (B24); they are asked only when the
|
|
5
|
+
* person picks "Customize now" at {@link askSettingsChoice}. `ui` has `@clack/prompts`' shape (`select`,
|
|
4
6
|
* `multiselect`, `text`, `confirm`, `password`, `isCancel`), so the real module is passed in
|
|
5
7
|
* production and a scripted fake in tests. Step 5's key question lives in {@link askJevSource}
|
|
6
8
|
* because it is asked only when the chain resolves nothing.
|
|
@@ -15,7 +17,7 @@ export class CancelledError extends Error {}
|
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* @typedef {object} Ui
|
|
18
|
-
* @property {(o: {message: string, options: Array<{value: any, label?: string}>, initialValue?: any}) => Promise<any>} select
|
|
20
|
+
* @property {(o: {message: string, options: Array<{value: any, label?: string, hint?: string}>, initialValue?: any}) => Promise<any>} select
|
|
19
21
|
* @property {(o: {message: string, options: Array<{value: any, label?: string}>, initialValues?: any[], required?: boolean}) => Promise<any>} multiselect
|
|
20
22
|
* @property {(o: {message: string, initialValue?: string, placeholder?: string}) => Promise<any>} text
|
|
21
23
|
* @property {(o: {message: string, initialValue?: boolean}) => Promise<any>} confirm
|
|
@@ -30,6 +32,43 @@ async function answer(ui, pending) {
|
|
|
30
32
|
return value;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/** The 1Password option's hint: where to find the reference in the app. */
|
|
36
|
+
export const OP_REF_HINT = 'In the 1Password app, right-click the key field → Copy Secret Reference (op://vault/item/field).';
|
|
37
|
+
|
|
38
|
+
/** The one-sentence hint line under each question (B24). */
|
|
39
|
+
export const HINTS = Object.freeze({
|
|
40
|
+
tools: 'Recommended adds Solo and the Codex, Grok, Gemini and 1Password CLIs, each only after its own yes.',
|
|
41
|
+
harnesses: 'The skill is linked into each harness you pick; detected ones are marked.',
|
|
42
|
+
scope: 'project puts the link inside this repository; global puts it under your home folder.',
|
|
43
|
+
method: 'A symlink follows package updates; a copy stays as installed.',
|
|
44
|
+
provider: 'The model family every level uses unless a level names its own provider.',
|
|
45
|
+
levels: 'L0 is the cheapest model and L3 the strongest; Enter keeps all four.',
|
|
46
|
+
level: 'Enter keeps this level as shown.',
|
|
47
|
+
refresh_models: 'Reads only local cache files; nothing is sent anywhere.',
|
|
48
|
+
multimodel: 'Two providers review each block and a third one judges; it costs more per block.',
|
|
49
|
+
second_provider: 'This provider reviews each block next to the L2 reviewer.',
|
|
50
|
+
judge: 'The judge settles disagreements between the two reviewers.',
|
|
51
|
+
engine: 'auto uses Solo when present, otherwise the subagents of the current harness.',
|
|
52
|
+
solo_project: 'The Solo project that coder processes run in; leave blank for none.',
|
|
53
|
+
jev: 'Jev is the small fast classifier behind System 1; skip uses rules only.',
|
|
54
|
+
env: 'That variable must hold the key whenever code-forge runs.',
|
|
55
|
+
paste: 'The key goes to the OS key store; the config keeps only a reference.',
|
|
56
|
+
settings: 'Pick how to treat the gate and proof values shown above.',
|
|
57
|
+
apply: 'Only the listed keys change; your other edits stay.',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** The one-line hints of the "Customize now" questions, in the order they are asked (B24). */
|
|
61
|
+
export const SETTING_HINTS = Object.freeze({
|
|
62
|
+
test: 'the command that runs your tests; the block cannot close if it fails (blank = no test gate)',
|
|
63
|
+
lint: 'the command that runs your linter; the block cannot close if it fails (blank = no lint gate)',
|
|
64
|
+
types: 'the command that runs your type checker; the block cannot close if it fails (blank = no types gate)',
|
|
65
|
+
format: 'the command that runs your format check; the block cannot close if it fails (blank = no format gate)',
|
|
66
|
+
high: 'globs where mistakes are costly (auth, billing); these files get deeper reviews and need red→green proof to close (blank = none)',
|
|
67
|
+
isolation: 'export = run proof tests in a temporary copy (safe with parallel coders); lock = run in your folder and pause other coders',
|
|
68
|
+
link_dirs: 'big folders the copy links to instead of copying, e.g. node_modules, vendor',
|
|
69
|
+
copy_untracked: 'files git does not track but your tests need, e.g. .env, .env.testing',
|
|
70
|
+
});
|
|
71
|
+
|
|
33
72
|
/**
|
|
34
73
|
* A comma list split only at brace depth 0, so a glob like `app/**\/*.{php,vue}` stays one item
|
|
35
74
|
* and Enter on a pre-filled list gives the list back unchanged.
|
|
@@ -53,6 +92,14 @@ function csv(text) {
|
|
|
53
92
|
return items.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
54
93
|
}
|
|
55
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A question with its hint on the line below (clack prints a multi-line message as is).
|
|
97
|
+
* @param {string} message @param {string} hint @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function withHint(message, hint) {
|
|
100
|
+
return `${message}\n${hint}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
56
103
|
/**
|
|
57
104
|
* The `model[:effort][@provider]` text a level is pre-filled with — the exact form `parseLevel`
|
|
58
105
|
* reads back, so Enter keeps the level unchanged (provider override included).
|
|
@@ -63,7 +110,8 @@ export function levelText(level) {
|
|
|
63
110
|
}
|
|
64
111
|
|
|
65
112
|
/**
|
|
66
|
-
* Asks every answer whose source is not `flag`, in step order
|
|
113
|
+
* Asks every answer whose source is not `flag`, in step order — gates and proof excepted (B24:
|
|
114
|
+
* see {@link askProjectSettings}). Mutates and returns `values`.
|
|
67
115
|
* @param {import('./answers.mjs').Answers} values
|
|
68
116
|
* @param {Record<string, string>} sources
|
|
69
117
|
* @param {import('./answers.mjs').WizardContext} ctx
|
|
@@ -75,52 +123,52 @@ export async function askAnswers(values, sources, ctx, ui) {
|
|
|
75
123
|
|
|
76
124
|
if (open('tools')) {
|
|
77
125
|
values.tools = await answer(ui, ui.select({
|
|
78
|
-
message: 'Install the recommended tools, or use only the current harness?',
|
|
126
|
+
message: withHint('Install the recommended tools, or use only the current harness?', HINTS.tools),
|
|
79
127
|
options: [{ value: 'current', label: 'current harness' }, { value: 'recommended', label: 'recommended tools' }],
|
|
80
128
|
initialValue: values.tools,
|
|
81
129
|
}));
|
|
82
130
|
}
|
|
83
131
|
if (open('harnesses')) {
|
|
84
132
|
values.harnesses = await answer(ui, ui.multiselect({
|
|
85
|
-
message: 'Install the skill into:',
|
|
133
|
+
message: withHint('Install the skill into:', HINTS.harnesses),
|
|
86
134
|
options: HARNESSES.map((h) => ({ value: h.id, label: `${h.label}${ctx.detectedHarnesses.includes(h.id) ? ' (detected)' : ''}` })),
|
|
87
135
|
initialValues: values.harnesses,
|
|
88
136
|
required: false,
|
|
89
137
|
}));
|
|
90
138
|
}
|
|
91
139
|
if (open('scope')) {
|
|
92
|
-
values.scope = await answer(ui, ui.select({ message: 'Scope', options: [{ value: 'project' }, { value: 'global' }], initialValue: values.scope }));
|
|
140
|
+
values.scope = await answer(ui, ui.select({ message: withHint('Scope', HINTS.scope), options: [{ value: 'project' }, { value: 'global' }], initialValue: values.scope }));
|
|
93
141
|
}
|
|
94
142
|
if (open('method')) {
|
|
95
|
-
values.method = await answer(ui, ui.select({ message: 'Method', options: [{ value: 'symlink' }, { value: 'copy' }], initialValue: values.method }));
|
|
143
|
+
values.method = await answer(ui, ui.select({ message: withHint('Method', HINTS.method), options: [{ value: 'symlink' }, { value: 'copy' }], initialValue: values.method }));
|
|
96
144
|
}
|
|
97
145
|
if (open('provider')) {
|
|
98
|
-
values.provider = await answer(ui, ui.select({ message: 'Default provider', options: PROVIDERS.map((p) => ({ value: p })), initialValue: values.provider }));
|
|
146
|
+
values.provider = await answer(ui, ui.select({ message: withHint('Default provider', HINTS.provider), options: PROVIDERS.map((p) => ({ value: p })), initialValue: values.provider }));
|
|
99
147
|
}
|
|
100
148
|
if (open('levels')) {
|
|
101
149
|
const summary = ['L0', 'L1', 'L2', 'L3'].map((l) => `${l}=${levelText(values.levels[l])}`).join(' ');
|
|
102
|
-
const keep = await answer(ui, ui.confirm({ message: `Keep the level matrix? ${summary}`, initialValue: true }));
|
|
150
|
+
const keep = await answer(ui, ui.confirm({ message: withHint(`Keep the level matrix? ${summary}`, HINTS.levels), initialValue: true }));
|
|
103
151
|
if (!keep) {
|
|
104
152
|
for (const level of ['L0', 'L1', 'L2', 'L3']) {
|
|
105
153
|
const shown = levelText(values.levels[level]);
|
|
106
|
-
const typed = await answer(ui, ui.text({ message: `${level} model[:effort][@provider]`, initialValue: shown }));
|
|
154
|
+
const typed = await answer(ui, ui.text({ message: withHint(`${level} model[:effort][@provider]`, HINTS.level), initialValue: shown }));
|
|
107
155
|
// Enter (unchanged text) keeps the level object as it is, `fallback` included
|
|
108
156
|
if (String(typed).trim() !== shown) values.levels[level] = parseLevel(`${level}=${String(typed).trim()}`).spec;
|
|
109
157
|
}
|
|
110
158
|
}
|
|
111
159
|
}
|
|
112
160
|
if (open('refresh_models')) {
|
|
113
|
-
values.refresh_models = await answer(ui, ui.confirm({ message: 'Read model ids from the Codex/Grok CLI caches on this machine?', initialValue: false }));
|
|
161
|
+
values.refresh_models = await answer(ui, ui.confirm({ message: withHint('Read model ids from the Codex/Grok CLI caches on this machine?', HINTS.refresh_models), initialValue: false }));
|
|
114
162
|
}
|
|
115
163
|
if (open('multimodel')) {
|
|
116
|
-
values.multimodel = await answer(ui, ui.confirm({ message: 'Multimodel review (consensus)?', initialValue: values.multimodel }));
|
|
164
|
+
values.multimodel = await answer(ui, ui.confirm({ message: withHint('Multimodel review (consensus)?', HINTS.multimodel), initialValue: values.multimodel }));
|
|
117
165
|
}
|
|
118
166
|
if (values.multimodel && open('second_provider')) {
|
|
119
167
|
// R5: the second reviewer must differ from the provider L2 actually runs on, not the default one
|
|
120
168
|
const reviewer1 = effectiveProvider(values, 'L2');
|
|
121
169
|
const others = PROVIDERS.filter((p) => p !== reviewer1);
|
|
122
170
|
values.second_provider = await answer(ui, ui.select({
|
|
123
|
-
message: `Second provider (must differ from the effective L2 provider, ${reviewer1})`,
|
|
171
|
+
message: withHint(`Second provider (must differ from the effective L2 provider, ${reviewer1})`, HINTS.second_provider),
|
|
124
172
|
options: others.map((p) => ({ value: p })),
|
|
125
173
|
initialValue: others.includes(values.second_provider) ? values.second_provider : others[0],
|
|
126
174
|
}));
|
|
@@ -132,34 +180,86 @@ export async function askAnswers(values, sources, ctx, ui) {
|
|
|
132
180
|
for (let collision = reviewersDiffer ? judgeCollision(values) : null; collision; collision = judgeCollision(values)) {
|
|
133
181
|
const proposal = collision.third ? levelText(proposeJudge(collision.third)) : '';
|
|
134
182
|
const typed = await answer(ui, ui.text({
|
|
135
|
-
message: `L3 judge — must come from a third provider (reviewers: ${collision.reviewers.join(', ')}); model[:effort][@provider]`,
|
|
183
|
+
message: withHint(`L3 judge — must come from a third provider (reviewers: ${collision.reviewers.join(', ')}); model[:effort][@provider]`, HINTS.judge),
|
|
136
184
|
initialValue: proposal,
|
|
137
185
|
}));
|
|
138
186
|
values.levels.L3 = parseLevel(`L3=${String(typed).trim()}`).spec;
|
|
139
187
|
}
|
|
140
188
|
// `subprocess` is never offered (R2); an existing `engine: subprocess` is kept, not asked.
|
|
141
189
|
if (open('engine') && values.engine !== 'subprocess') {
|
|
142
|
-
values.engine = await answer(ui, ui.select({ message: 'Engine', options: ENGINE_CHOICES.map((e) => ({ value: e })), initialValue: values.engine }));
|
|
190
|
+
values.engine = await answer(ui, ui.select({ message: withHint('Engine', HINTS.engine), options: ENGINE_CHOICES.map((e) => ({ value: e })), initialValue: values.engine }));
|
|
143
191
|
}
|
|
144
192
|
if (open('solo_project') && ctx.solo && values.engine !== 'harness') {
|
|
145
|
-
const typed = await answer(ui, ui.text({ message: 'Solo project id (blank: none)', initialValue: values.solo_project === null ? '' : String(values.solo_project) }));
|
|
193
|
+
const typed = await answer(ui, ui.text({ message: withHint('Solo project id (blank: none)', HINTS.solo_project), initialValue: values.solo_project === null ? '' : String(values.solo_project) }));
|
|
146
194
|
values.solo_project = /^[1-9]\d{0,9}$/.test(String(typed).trim()) ? Number(String(typed).trim()) : null;
|
|
147
195
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
196
|
+
return values;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The one question after the settings summary (B24), asked every time in interactive mode.
|
|
201
|
+
* @param {Ui} ui
|
|
202
|
+
* @returns {Promise<'use'|'customize'|'later'>}
|
|
203
|
+
*/
|
|
204
|
+
export async function askSettingsChoice(ui) {
|
|
205
|
+
return answer(ui, ui.select({
|
|
206
|
+
message: withHint('Gates and proof settings:', HINTS.settings),
|
|
207
|
+
options: [
|
|
208
|
+
{ value: 'use', label: 'Use these', hint: 'keep the detected values; blanks stay blank' },
|
|
209
|
+
{ value: 'customize', label: 'Customize now', hint: 'ask each gate and proof value, pre-filled with what was detected' },
|
|
210
|
+
{ value: 'later', label: 'Leave for later', hint: 'keep the detected values and print the keys to edit later' },
|
|
211
|
+
],
|
|
212
|
+
initialValue: 'use',
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* "Customize now": the gate and proof questions, each pre-filled with the value shown in the
|
|
218
|
+
* summary (Enter keeps it), in the order test, lint, types, format, high-risk paths, isolation,
|
|
219
|
+
* link_dirs, copy_untracked. A value a flag set is not asked (flags override). Mutates `values`
|
|
220
|
+
* and returns the config keys whose value changed.
|
|
221
|
+
* @param {import('./answers.mjs').Answers} values
|
|
222
|
+
* @param {import('./answers.mjs').Origins} origins
|
|
223
|
+
* @param {Ui} ui
|
|
224
|
+
* @returns {Promise<string[]>}
|
|
225
|
+
*/
|
|
226
|
+
export async function askProjectSettings(values, origins, ui) {
|
|
227
|
+
/** @type {string[]} */
|
|
228
|
+
const changed = [];
|
|
229
|
+
for (const name of GATE_NAMES) {
|
|
230
|
+
if (origins.gates[name] === 'flag') continue;
|
|
231
|
+
const gates = /** @type {Record<string, string[]|null>} */ (values.gates);
|
|
232
|
+
const current = gates[name];
|
|
233
|
+
const typed = await answer(ui, ui.text({
|
|
234
|
+
message: withHint(`Gate ${name} (blank or "none": no gate)`, SETTING_HINTS[/** @type {keyof typeof SETTING_HINTS} */ (name)]),
|
|
235
|
+
initialValue: current ? current.join(' ') : '',
|
|
236
|
+
}));
|
|
237
|
+
const text = String(typed ?? '').trim();
|
|
238
|
+
const next = text === '' || text === 'none' ? null : shellWords(text);
|
|
239
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) changed.push(`gates.${name}`);
|
|
240
|
+
gates[name] = next;
|
|
155
241
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
242
|
+
const proof = values.proof;
|
|
243
|
+
/** @param {'high'|'link_dirs'|'copy_untracked'} key @param {string} message @param {string} path */
|
|
244
|
+
const list = async (key, message, path) => {
|
|
245
|
+
if (origins.proof[key] === 'flag') return;
|
|
246
|
+
const next = csv(await answer(ui, ui.text({ message: withHint(message, SETTING_HINTS[key]), initialValue: proof[key].join(',') })));
|
|
247
|
+
if (JSON.stringify(next) !== JSON.stringify(proof[key])) changed.push(path);
|
|
248
|
+
proof[key] = next;
|
|
249
|
+
};
|
|
250
|
+
await list('high', 'High-risk paths (comma list of globs)', 'proof.tiers.high.paths');
|
|
251
|
+
if (origins.proof.isolation !== 'flag') {
|
|
252
|
+
const next = await answer(ui, ui.select({
|
|
253
|
+
message: withHint('Proof isolation', SETTING_HINTS.isolation),
|
|
254
|
+
options: [{ value: 'export' }, { value: 'lock' }],
|
|
255
|
+
initialValue: proof.isolation,
|
|
256
|
+
}));
|
|
257
|
+
if (next !== proof.isolation) changed.push('proof.isolation');
|
|
258
|
+
proof.isolation = next;
|
|
161
259
|
}
|
|
162
|
-
|
|
260
|
+
await list('link_dirs', 'Export: directories to link', 'proof.export.link_dirs');
|
|
261
|
+
await list('copy_untracked', 'Export: untracked files to copy', 'proof.export.copy_untracked');
|
|
262
|
+
return changed;
|
|
163
263
|
}
|
|
164
264
|
|
|
165
265
|
/**
|
|
@@ -171,9 +271,9 @@ export async function askAnswers(values, sources, ctx, ui) {
|
|
|
171
271
|
*/
|
|
172
272
|
export async function askJevSource(ui, getStore) {
|
|
173
273
|
const choice = await answer(ui, ui.select({
|
|
174
|
-
message: 'Where is the Jev key?',
|
|
274
|
+
message: withHint('Where is the Jev key?', HINTS.jev),
|
|
175
275
|
options: [
|
|
176
|
-
{ value: 'op', label: '1Password reference' },
|
|
276
|
+
{ value: 'op', label: '1Password reference', hint: OP_REF_HINT },
|
|
177
277
|
{ value: 'env', label: 'environment variable name' },
|
|
178
278
|
{ value: 'paste', label: 'paste now (hidden)' },
|
|
179
279
|
{ value: 'skip', label: 'skip (rules-only System 1)' },
|
|
@@ -181,17 +281,17 @@ export async function askJevSource(ui, getStore) {
|
|
|
181
281
|
initialValue: 'skip',
|
|
182
282
|
}));
|
|
183
283
|
if (choice === 'op') {
|
|
184
|
-
const ref = String(await answer(ui, ui.text({ message: '1Password reference (op://vault/item/field)' }))).trim();
|
|
284
|
+
const ref = String(await answer(ui, ui.text({ message: withHint('1Password reference (op://vault/item/field)', OP_REF_HINT) }))).trim();
|
|
185
285
|
if (!isOpRef(ref)) throw new UsageError('not an op://vault/item/field reference');
|
|
186
286
|
return { ref, source: 'op' };
|
|
187
287
|
}
|
|
188
288
|
if (choice === 'env') {
|
|
189
|
-
const name = String(await answer(ui, ui.text({ message: 'Environment variable name' }))).trim();
|
|
289
|
+
const name = String(await answer(ui, ui.text({ message: withHint('Environment variable name', HINTS.env) }))).trim();
|
|
190
290
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new UsageError('not an environment variable name');
|
|
191
291
|
return { ref: `env:${name}`, source: 'env' };
|
|
192
292
|
}
|
|
193
293
|
if (choice === 'paste') {
|
|
194
|
-
const typed = await answer(ui, ui.password({ message: 'Jev key (input hidden)' }));
|
|
294
|
+
const typed = await answer(ui, ui.password({ message: withHint('Jev key (input hidden)', HINTS.paste) }));
|
|
195
295
|
if (typeof typed !== 'string' || typed.length === 0) return { ref: null, source: 'none' };
|
|
196
296
|
const store = await getStore();
|
|
197
297
|
await store.put('jev', typed, { source: 'user', exp: null });
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Project settings (detected)" block `code-forge init` prints instead of asking for gates and
|
|
3
|
+
* proof settings (B24). Every gate and proof value is listed with where it came from; a blank value
|
|
4
|
+
* says so and why, and the block ends with the one line that says how to fill the blanks later.
|
|
5
|
+
* Agent/JSON mode gets the same facts as `settings` + `blank`. Pure apart from `existsSync` reads
|
|
6
|
+
* that name the file a detected gate came from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { LINK_DIR_MANIFESTS, copyCandidates, linkDirsFromManifests } from './answers.mjs';
|
|
12
|
+
import { GATE_NAMES } from './flags.mjs';
|
|
13
|
+
|
|
14
|
+
export const SUMMARY_TITLE = 'Project settings (detected)';
|
|
15
|
+
|
|
16
|
+
/** The config key each summarised value is written to. */
|
|
17
|
+
export const SETTING_KEYS = Object.freeze({
|
|
18
|
+
test: 'gates.test',
|
|
19
|
+
lint: 'gates.lint',
|
|
20
|
+
types: 'gates.types',
|
|
21
|
+
format: 'gates.format',
|
|
22
|
+
high: 'proof.tiers.high.paths',
|
|
23
|
+
isolation: 'proof.isolation',
|
|
24
|
+
link_dirs: 'proof.export.link_dirs',
|
|
25
|
+
copy_untracked: 'proof.export.copy_untracked',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** What a blank gate means, in the words the summary prints. */
|
|
29
|
+
const GATE_MISSING = Object.freeze({
|
|
30
|
+
test: 'no test command found',
|
|
31
|
+
lint: 'no linter found',
|
|
32
|
+
types: 'no type checker found',
|
|
33
|
+
format: 'no formatter found',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** The summary's label for each proof value. */
|
|
37
|
+
const PROOF_LABELS = Object.freeze({
|
|
38
|
+
high: 'high-risk paths',
|
|
39
|
+
isolation: 'isolation',
|
|
40
|
+
link_dirs: 'link dirs',
|
|
41
|
+
copy_untracked: 'copy untracked',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** The marker file that decides each non-Node stack in `detectGates`. */
|
|
45
|
+
const STACK_MARKER = Object.freeze({ rust: 'Cargo.toml', python: 'pyproject.toml', go: 'go.mod' });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} Setting
|
|
49
|
+
* @property {unknown} value - what is written to the config.
|
|
50
|
+
* @property {'flag'|'existing'|'detected'|'default'} source
|
|
51
|
+
* @property {string} note - where it came from, or why it is blank, in the summary's words.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} SettingsSummary
|
|
56
|
+
* @property {Record<string, Setting>} settings - keyed by config path (`gates.test`, …).
|
|
57
|
+
* @property {string[]} blank - the config paths left blank, in summary order.
|
|
58
|
+
* @property {string[]} lines - the printed block, title first, the "set later" line last.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** @param {string[]} argv @returns {string} */
|
|
62
|
+
function shown(argv) {
|
|
63
|
+
return argv.map((w) => (/[\s"'\\]/.test(w) || w === '' ? JSON.stringify(w) : w)).join(' ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} cwd @param {string[]} names @returns {string|undefined} */
|
|
67
|
+
function firstPresent(cwd, names) {
|
|
68
|
+
return names.find((n) => existsSync(path.join(cwd, n)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The file a detected gate came from (the evidence `detectGates` read).
|
|
73
|
+
* @param {string} stack @param {string} name @param {string[]} argv @param {string} cwd
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function gateEvidence(stack, name, argv, cwd) {
|
|
77
|
+
if (stack.startsWith('php')) {
|
|
78
|
+
const tool = path.basename(argv[0] ?? '');
|
|
79
|
+
if (tool === 'phpstan') return firstPresent(cwd, ['phpstan.neon', 'phpstan.neon.dist']) ?? 'vendor/bin/phpstan';
|
|
80
|
+
if (tool === 'pint') return firstPresent(cwd, ['pint.json']) ?? 'vendor/bin/pint';
|
|
81
|
+
if (tool === 'php' && argv[1] === 'artisan') return 'artisan';
|
|
82
|
+
if (tool === 'pest') return 'vendor/bin/pest';
|
|
83
|
+
return 'composer.json';
|
|
84
|
+
}
|
|
85
|
+
if (stack === 'node') {
|
|
86
|
+
if (argv[0] === 'npx' && argv[1] === 'tsc') return 'tsconfig.json';
|
|
87
|
+
if (argv[0] === 'npx' && argv[1] === 'prettier') return firstPresent(cwd, ['.prettierrc', '.prettierrc.json', '.prettierrc.js']) ?? 'package.json';
|
|
88
|
+
return 'package.json';
|
|
89
|
+
}
|
|
90
|
+
return STACK_MARKER[/** @type {keyof typeof STACK_MARKER} */ (stack)] ?? 'the project';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {unknown} value @returns {boolean} an empty proof value: null, undefined, '' or [] */
|
|
94
|
+
function isBlankValue(value) {
|
|
95
|
+
return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {'flag'|'existing'|'detected'|'default'} source @param {string} flag @returns {string} */
|
|
99
|
+
function keptFrom(source, flag) {
|
|
100
|
+
return source === 'flag' ? flag : source === 'existing' ? '.code-forge.yml' : 'default';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {import('./answers.mjs').Answers} values
|
|
105
|
+
* @param {import('./answers.mjs').Origins} origins
|
|
106
|
+
* @param {{cwd: string, profile: string, detected: {stack: string}}} ctx
|
|
107
|
+
* @returns {SettingsSummary}
|
|
108
|
+
*/
|
|
109
|
+
export function summarizeSettings(values, origins, ctx) {
|
|
110
|
+
/** @type {Record<string, Setting>} */
|
|
111
|
+
const settings = {};
|
|
112
|
+
/** @type {string[]} */
|
|
113
|
+
const blank = [];
|
|
114
|
+
const lines = [SUMMARY_TITLE];
|
|
115
|
+
|
|
116
|
+
for (const name of GATE_NAMES) {
|
|
117
|
+
const key = SETTING_KEYS[/** @type {keyof typeof SETTING_KEYS} */ (name)];
|
|
118
|
+
const argv = values.gates[/** @type {keyof typeof values.gates} */ (name)];
|
|
119
|
+
const source = origins.gates[name] ?? 'detected';
|
|
120
|
+
let note;
|
|
121
|
+
if (argv === null || argv === undefined || argv.length === 0) {
|
|
122
|
+
if (source === 'flag') note = 'set blank by --gate';
|
|
123
|
+
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
124
|
+
else note = GATE_MISSING[/** @type {keyof typeof GATE_MISSING} */ (name)];
|
|
125
|
+
blank.push(key);
|
|
126
|
+
lines.push(` ${name}: blank — ${note}`);
|
|
127
|
+
} else {
|
|
128
|
+
note = source === 'detected' ? gateEvidence(ctx.detected.stack, name, argv, ctx.cwd) : keptFrom(source, '--gate');
|
|
129
|
+
lines.push(` ${name}: ${shown(argv)} (from ${note})`);
|
|
130
|
+
}
|
|
131
|
+
settings[key] = { value: argv, source, note };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const found = new Map(linkDirsFromManifests(ctx.cwd).map((e) => [e.dir, e.manifests]));
|
|
135
|
+
for (const pk of /** @type {Array<keyof typeof PROOF_LABELS>} */ (Object.keys(PROOF_LABELS))) {
|
|
136
|
+
const key = SETTING_KEYS[pk];
|
|
137
|
+
const value = values.proof[pk];
|
|
138
|
+
const source = origins.proof[pk] ?? 'default';
|
|
139
|
+
const label = PROOF_LABELS[pk];
|
|
140
|
+
let note;
|
|
141
|
+
if (isBlankValue(value)) {
|
|
142
|
+
if (source === 'flag') note = 'set blank by --proof';
|
|
143
|
+
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
144
|
+
else if (pk === 'high') note = 'none set (all files light tier by path)';
|
|
145
|
+
else if (pk === 'link_dirs') note = 'no dependency manifest found';
|
|
146
|
+
else {
|
|
147
|
+
const looked = copyCandidates(ctx.profile);
|
|
148
|
+
note = looked.length > 0 ? `none found (looked for ${looked.join(', ')})` : 'none for this stack';
|
|
149
|
+
}
|
|
150
|
+
blank.push(key);
|
|
151
|
+
lines.push(` ${label}: blank — ${note}`);
|
|
152
|
+
} else {
|
|
153
|
+
const list = Array.isArray(value) ? value : [String(value)];
|
|
154
|
+
// copied files are evidence themselves; everything else names where it came from
|
|
155
|
+
const foundOnDisk = source === 'detected' && pk === 'copy_untracked';
|
|
156
|
+
if (foundOnDisk) note = 'found in the project';
|
|
157
|
+
else if (source !== 'detected') note = keptFrom(source, '--proof');
|
|
158
|
+
else note = [...new Set(list.flatMap((d) => found.get(d) ?? LINK_DIR_MANIFESTS[/** @type {keyof typeof LINK_DIR_MANIFESTS} */ (d)] ?? []))].join(', ');
|
|
159
|
+
lines.push(` ${label}: ${list.join(', ')} (${foundOnDisk ? note : `from ${note}`})`);
|
|
160
|
+
}
|
|
161
|
+
settings[key] = { value, source, note };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lines.push(blank.length > 0
|
|
165
|
+
? `Set the blanks later: edit .code-forge.yml (${blank.join(', ')}), then run \`code-forge validate\`.`
|
|
166
|
+
: 'Change any of these later: edit .code-forge.yml, then run `code-forge validate`.');
|
|
167
|
+
return { settings, blank, lines };
|
|
168
|
+
}
|