@codedology/code-forge 0.1.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.
@@ -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
- /** `link_dirs` per stack: the dependency directories an export links instead of copying. */
33
- const LINK_DIRS = Object.freeze({
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
- /** High-risk paths per stack (v1 §2 step 8: migrations, policies, money, auth/middleware, webhooks). */
44
- const HIGH_PATHS = Object.freeze({
45
- laravel: ['database/migrations/**', 'app/Policies/**', 'app/Http/Middleware/**', '**/*Money*', '**/*Webhook*'],
46
- php: ['**/*Money*', '**/*Webhook*', '**/Auth/**'],
47
- node: ['**/migrations/**', '**/auth/**', '**/*money*', '**/*webhook*'],
48
- python: ['**/migrations/**', '**/auth/**', '**/*money*', '**/*webhook*'],
49
- rust: ['**/auth/**', '**/*money*'],
50
- go: ['**/auth/**', '**/*money*'],
51
- unknown: [],
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' && Object.hasOwn(LINK_DIRS, stack)) return stack;
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 proposal for a stack.
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: [...(HIGH_PATHS[profile] ?? [])],
140
+ high: [],
119
141
  isolation: 'export',
120
- link_dirs: [...(LINK_DIRS[profile] ?? LINK_DIRS.unknown)],
121
- copy_untracked: [...(COPY_UNTRACKED[profile] ?? [])],
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 : undefined;
362
- set('gates', exGates ? { ...detectedGates, ...exGates } : undefined, () => detectedGates);
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 gates = clone(v.gates);
365
- for (const g of flagsFor('gates')) gates[g.value.name] = g.value.argv;
366
- override('gates', gates);
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
- high: ex.proof.tiers?.high?.paths ?? proposal.high,
373
- isolation: ex.proof.isolation ?? proposal.isolation,
374
- link_dirs: ex.proof.export?.link_dirs ?? proposal.link_dirs,
375
- copy_untracked: ex.proof.export?.copy_untracked ?? proposal.copy_untracked,
376
- }
377
- : undefined;
378
- set('proof', exProof, () => proposal);
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 proof = clone(v.proof);
381
- for (const g of flagsFor('proof')) proof[g.value.key] = g.value.value;
382
- override('proof', proof);
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) → step 5
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
- stdout.write(`${lines.join('\n')}\nlog: ${log}\n`);
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. `ui` has `@clack/prompts`' shape (`select`,
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. Mutates and returns `values`.
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
- if (open('gates')) {
149
- for (const name of GATE_NAMES) {
150
- const current = values.gates[name];
151
- const typed = await answer(ui, ui.text({ message: `Gate ${name} (blank or "none": no gate)`, initialValue: current ? current.join(' ') : '' }));
152
- const text = String(typed ?? '').trim();
153
- values.gates[name] = text === '' || text === 'none' ? null : shellWords(text);
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
- if (open('proof')) {
157
- values.proof.high = csv(await answer(ui, ui.text({ message: 'High-risk paths (comma list of globs)', initialValue: values.proof.high.join(',') })));
158
- values.proof.isolation = await answer(ui, ui.select({ message: 'Proof isolation', options: [{ value: 'export' }, { value: 'lock' }], initialValue: values.proof.isolation }));
159
- values.proof.link_dirs = csv(await answer(ui, ui.text({ message: 'Export: directories to link', initialValue: values.proof.link_dirs.join(',') })));
160
- values.proof.copy_untracked = csv(await answer(ui, ui.text({ message: 'Export: untracked files to copy', initialValue: values.proof.copy_untracked.join(',') })));
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
- return values;
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 });