@codedology/code-forge 0.2.0 → 0.2.2

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.
@@ -1,12 +1,14 @@
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.
7
9
  */
8
10
 
9
- import { isOpRef } from '../../keys/onepassword.mjs';
11
+ import { isOpRef, OP_MESSAGES } from '../../keys/onepassword.mjs';
10
12
  import { HARNESSES } from '../harnesses.mjs';
11
13
  import { effectiveProvider, judgeCollision, proposeJudge } from './answers.mjs';
12
14
  import { ENGINE_CHOICES, GATE_NAMES, PROVIDERS, parseLevel, shellWords, UsageError } from './flags.mjs';
@@ -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,55 @@ async function answer(ui, pending) {
30
32
  return value;
31
33
  }
32
34
 
35
+ /** The 1Password option's hint: where to find the item ID, link or reference in the app. */
36
+ export const OP_REF_HINT = "Paste the item ID (from `op item list`), the item's link, or its op:// secret reference.";
37
+
38
+ /** The 1Password question (B25): an item ID or link is resolved to a full `op://` reference. */
39
+ export const OP_REF_QUESTION = '1Password item ID, item link, or op:// reference';
40
+
41
+ /** The question after "Enter a full op:// reference": op:// only, no lookup. */
42
+ export const OP_FULL_REF_QUESTION = 'Full op://vault/item/field reference';
43
+
44
+ /** The menu message when the resolver itself threw (its message is never shown). */
45
+ export const OP_RESOLVE_FAILED = '1Password CLI failed (unexpected error); run `op item get <item-id>` yourself to see why';
46
+
47
+ /** Printed when the Jev key is left unset, so the person knows how to set it later. */
48
+ export const JEV_LATER_TEXT = 'keys: jev not set — set it later with: code-forge keys set jev --op <item-id>';
49
+
50
+ /** The one-sentence hint line under each question (B24). */
51
+ export const HINTS = Object.freeze({
52
+ tools: 'Recommended adds Solo and the Codex, Grok, Gemini and 1Password CLIs, each only after its own yes.',
53
+ harnesses: 'The skill is linked into each harness you pick; detected ones are marked.',
54
+ scope: 'project puts the link inside this repository; global puts it under your home folder.',
55
+ method: 'A symlink follows package updates; a copy stays as installed.',
56
+ provider: 'The model family every level uses unless a level names its own provider.',
57
+ levels: 'L0 is the cheapest model and L3 the strongest; Enter keeps all four.',
58
+ level: 'Enter keeps this level as shown.',
59
+ refresh_models: 'Reads only local cache files; nothing is sent anywhere.',
60
+ multimodel: 'Two providers review each block and a third one judges; it costs more per block.',
61
+ second_provider: 'This provider reviews each block next to the L2 reviewer.',
62
+ judge: 'The judge settles disagreements between the two reviewers.',
63
+ engine: 'auto uses Solo when present, otherwise the subagents of the current harness.',
64
+ solo_project: 'The Solo project that coder processes run in; leave blank for none.',
65
+ jev: 'Jev is the small fast classifier behind System 1; skip uses rules only.',
66
+ env: 'That variable must hold the key whenever code-forge runs.',
67
+ paste: 'The key goes to the OS key store; the config keeps only a reference.',
68
+ settings: 'Pick how to treat the gate and proof values shown above.',
69
+ apply: 'Only the listed keys change; your other edits stay.',
70
+ });
71
+
72
+ /** The one-line hints of the "Customize now" questions, in the order they are asked (B24). */
73
+ export const SETTING_HINTS = Object.freeze({
74
+ test: 'the command that runs your tests; the block cannot close if it fails (blank = no test gate)',
75
+ lint: 'the command that runs your linter; the block cannot close if it fails (blank = no lint gate)',
76
+ types: 'the command that runs your type checker; the block cannot close if it fails (blank = no types gate)',
77
+ format: 'the command that runs your format check; the block cannot close if it fails (blank = no format gate)',
78
+ high: 'globs where mistakes are costly (auth, billing); these files get deeper reviews and need red→green proof to close (blank = none)',
79
+ isolation: 'export = run proof tests in a temporary copy (safe with parallel coders); lock = run in your folder and pause other coders',
80
+ link_dirs: 'big folders the copy links to instead of copying, e.g. node_modules, vendor',
81
+ copy_untracked: 'files git does not track but your tests need, e.g. .env, .env.testing',
82
+ });
83
+
33
84
  /**
34
85
  * A comma list split only at brace depth 0, so a glob like `app/**\/*.{php,vue}` stays one item
35
86
  * and Enter on a pre-filled list gives the list back unchanged.
@@ -53,6 +104,14 @@ function csv(text) {
53
104
  return items.map((s) => s.trim()).filter((s) => s.length > 0);
54
105
  }
55
106
 
107
+ /**
108
+ * A question with its hint on the line below (clack prints a multi-line message as is).
109
+ * @param {string} message @param {string} hint @returns {string}
110
+ */
111
+ export function withHint(message, hint) {
112
+ return `${message}\n${hint}`;
113
+ }
114
+
56
115
  /**
57
116
  * The `model[:effort][@provider]` text a level is pre-filled with — the exact form `parseLevel`
58
117
  * reads back, so Enter keeps the level unchanged (provider override included).
@@ -63,7 +122,8 @@ export function levelText(level) {
63
122
  }
64
123
 
65
124
  /**
66
- * Asks every answer whose source is not `flag`, in step order. Mutates and returns `values`.
125
+ * Asks every answer whose source is not `flag`, in step order — gates and proof excepted (B24:
126
+ * see {@link askProjectSettings}). Mutates and returns `values`.
67
127
  * @param {import('./answers.mjs').Answers} values
68
128
  * @param {Record<string, string>} sources
69
129
  * @param {import('./answers.mjs').WizardContext} ctx
@@ -75,52 +135,52 @@ export async function askAnswers(values, sources, ctx, ui) {
75
135
 
76
136
  if (open('tools')) {
77
137
  values.tools = await answer(ui, ui.select({
78
- message: 'Install the recommended tools, or use only the current harness?',
138
+ message: withHint('Install the recommended tools, or use only the current harness?', HINTS.tools),
79
139
  options: [{ value: 'current', label: 'current harness' }, { value: 'recommended', label: 'recommended tools' }],
80
140
  initialValue: values.tools,
81
141
  }));
82
142
  }
83
143
  if (open('harnesses')) {
84
144
  values.harnesses = await answer(ui, ui.multiselect({
85
- message: 'Install the skill into:',
145
+ message: withHint('Install the skill into:', HINTS.harnesses),
86
146
  options: HARNESSES.map((h) => ({ value: h.id, label: `${h.label}${ctx.detectedHarnesses.includes(h.id) ? ' (detected)' : ''}` })),
87
147
  initialValues: values.harnesses,
88
148
  required: false,
89
149
  }));
90
150
  }
91
151
  if (open('scope')) {
92
- values.scope = await answer(ui, ui.select({ message: 'Scope', options: [{ value: 'project' }, { value: 'global' }], initialValue: values.scope }));
152
+ values.scope = await answer(ui, ui.select({ message: withHint('Scope', HINTS.scope), options: [{ value: 'project' }, { value: 'global' }], initialValue: values.scope }));
93
153
  }
94
154
  if (open('method')) {
95
- values.method = await answer(ui, ui.select({ message: 'Method', options: [{ value: 'symlink' }, { value: 'copy' }], initialValue: values.method }));
155
+ values.method = await answer(ui, ui.select({ message: withHint('Method', HINTS.method), options: [{ value: 'symlink' }, { value: 'copy' }], initialValue: values.method }));
96
156
  }
97
157
  if (open('provider')) {
98
- values.provider = await answer(ui, ui.select({ message: 'Default provider', options: PROVIDERS.map((p) => ({ value: p })), initialValue: values.provider }));
158
+ values.provider = await answer(ui, ui.select({ message: withHint('Default provider', HINTS.provider), options: PROVIDERS.map((p) => ({ value: p })), initialValue: values.provider }));
99
159
  }
100
160
  if (open('levels')) {
101
161
  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 }));
162
+ const keep = await answer(ui, ui.confirm({ message: withHint(`Keep the level matrix? ${summary}`, HINTS.levels), initialValue: true }));
103
163
  if (!keep) {
104
164
  for (const level of ['L0', 'L1', 'L2', 'L3']) {
105
165
  const shown = levelText(values.levels[level]);
106
- const typed = await answer(ui, ui.text({ message: `${level} model[:effort][@provider]`, initialValue: shown }));
166
+ const typed = await answer(ui, ui.text({ message: withHint(`${level} model[:effort][@provider]`, HINTS.level), initialValue: shown }));
107
167
  // Enter (unchanged text) keeps the level object as it is, `fallback` included
108
168
  if (String(typed).trim() !== shown) values.levels[level] = parseLevel(`${level}=${String(typed).trim()}`).spec;
109
169
  }
110
170
  }
111
171
  }
112
172
  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 }));
173
+ 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
174
  }
115
175
  if (open('multimodel')) {
116
- values.multimodel = await answer(ui, ui.confirm({ message: 'Multimodel review (consensus)?', initialValue: values.multimodel }));
176
+ values.multimodel = await answer(ui, ui.confirm({ message: withHint('Multimodel review (consensus)?', HINTS.multimodel), initialValue: values.multimodel }));
117
177
  }
118
178
  if (values.multimodel && open('second_provider')) {
119
179
  // R5: the second reviewer must differ from the provider L2 actually runs on, not the default one
120
180
  const reviewer1 = effectiveProvider(values, 'L2');
121
181
  const others = PROVIDERS.filter((p) => p !== reviewer1);
122
182
  values.second_provider = await answer(ui, ui.select({
123
- message: `Second provider (must differ from the effective L2 provider, ${reviewer1})`,
183
+ message: withHint(`Second provider (must differ from the effective L2 provider, ${reviewer1})`, HINTS.second_provider),
124
184
  options: others.map((p) => ({ value: p })),
125
185
  initialValue: others.includes(values.second_provider) ? values.second_provider : others[0],
126
186
  }));
@@ -132,70 +192,196 @@ export async function askAnswers(values, sources, ctx, ui) {
132
192
  for (let collision = reviewersDiffer ? judgeCollision(values) : null; collision; collision = judgeCollision(values)) {
133
193
  const proposal = collision.third ? levelText(proposeJudge(collision.third)) : '';
134
194
  const typed = await answer(ui, ui.text({
135
- message: `L3 judge — must come from a third provider (reviewers: ${collision.reviewers.join(', ')}); model[:effort][@provider]`,
195
+ message: withHint(`L3 judge — must come from a third provider (reviewers: ${collision.reviewers.join(', ')}); model[:effort][@provider]`, HINTS.judge),
136
196
  initialValue: proposal,
137
197
  }));
138
198
  values.levels.L3 = parseLevel(`L3=${String(typed).trim()}`).spec;
139
199
  }
140
200
  // `subprocess` is never offered (R2); an existing `engine: subprocess` is kept, not asked.
141
201
  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 }));
202
+ values.engine = await answer(ui, ui.select({ message: withHint('Engine', HINTS.engine), options: ENGINE_CHOICES.map((e) => ({ value: e })), initialValue: values.engine }));
143
203
  }
144
204
  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) }));
205
+ 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
206
  values.solo_project = /^[1-9]\d{0,9}$/.test(String(typed).trim()) ? Number(String(typed).trim()) : null;
147
207
  }
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
- }
155
- }
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(',') })));
161
- }
162
208
  return values;
163
209
  }
164
210
 
165
211
  /**
166
- * Step 5 when the chain resolved nothing: where is the Jev key? A pasted key is read with hidden
167
- * input and handed straight to `store`; it is never returned, echoed or logged.
212
+ * The one question after the settings summary (B24), asked every time in interactive mode.
168
213
  * @param {Ui} ui
169
- * @param {() => Promise<{put: (name: string, value: string, meta: {source: string, exp: number|null}) => Promise<void>}>} getStore
170
- * @returns {Promise<{ref: string|null, source: string}>}
214
+ * @returns {Promise<'use'|'customize'|'later'>}
171
215
  */
172
- export async function askJevSource(ui, getStore) {
173
- const choice = await answer(ui, ui.select({
174
- message: 'Where is the Jev key?',
216
+ export async function askSettingsChoice(ui) {
217
+ return answer(ui, ui.select({
218
+ message: withHint('Gates and proof settings:', HINTS.settings),
175
219
  options: [
176
- { value: 'op', label: '1Password reference' },
177
- { value: 'env', label: 'environment variable name' },
178
- { value: 'paste', label: 'paste now (hidden)' },
179
- { value: 'skip', label: 'skip (rules-only System 1)' },
220
+ { value: 'use', label: 'Use these', hint: 'keep the detected values; blanks stay blank' },
221
+ { value: 'customize', label: 'Customize now', hint: 'ask each gate and proof value, pre-filled with what was detected' },
222
+ { value: 'later', label: 'Leave for later', hint: 'keep the detected values and print the keys to edit later' },
180
223
  ],
181
- initialValue: 'skip',
224
+ initialValue: 'use',
182
225
  }));
183
- if (choice === 'op') {
184
- const ref = String(await answer(ui, ui.text({ message: '1Password reference (op://vault/item/field)' }))).trim();
185
- if (!isOpRef(ref)) throw new UsageError('not an op://vault/item/field reference');
186
- return { ref, source: 'op' };
187
- }
188
- if (choice === 'env') {
189
- const name = String(await answer(ui, ui.text({ message: 'Environment variable name' }))).trim();
190
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new UsageError('not an environment variable name');
191
- return { ref: `env:${name}`, source: 'env' };
192
- }
193
- if (choice === 'paste') {
194
- const typed = await answer(ui, ui.password({ message: 'Jev key (input hidden)' }));
195
- if (typeof typed !== 'string' || typed.length === 0) return { ref: null, source: 'none' };
196
- const store = await getStore();
197
- await store.put('jev', typed, { source: 'user', exp: null });
198
- return { ref: 'user', source: 'keychain' };
199
- }
200
- return { ref: null, source: 'none' };
226
+ }
227
+
228
+ /**
229
+ * "Customize now": the gate and proof questions, each pre-filled with the value shown in the
230
+ * summary (Enter keeps it), in the order test, lint, types, format, high-risk paths, isolation,
231
+ * link_dirs, copy_untracked. A value a flag set is not asked (flags override). Mutates `values`
232
+ * and returns the config keys whose value changed.
233
+ * @param {import('./answers.mjs').Answers} values
234
+ * @param {import('./answers.mjs').Origins} origins
235
+ * @param {Ui} ui
236
+ * @returns {Promise<string[]>}
237
+ */
238
+ export async function askProjectSettings(values, origins, ui) {
239
+ /** @type {string[]} */
240
+ const changed = [];
241
+ for (const name of GATE_NAMES) {
242
+ if (origins.gates[name] === 'flag') continue;
243
+ const gates = /** @type {Record<string, string[]|null>} */ (values.gates);
244
+ const current = gates[name];
245
+ const typed = await answer(ui, ui.text({
246
+ message: withHint(`Gate ${name} (blank or "none": no gate)`, SETTING_HINTS[/** @type {keyof typeof SETTING_HINTS} */ (name)]),
247
+ initialValue: current ? current.join(' ') : '',
248
+ }));
249
+ const text = String(typed ?? '').trim();
250
+ const next = text === '' || text === 'none' ? null : shellWords(text);
251
+ if (JSON.stringify(next) !== JSON.stringify(current)) changed.push(`gates.${name}`);
252
+ gates[name] = next;
253
+ }
254
+ const proof = values.proof;
255
+ /** @param {'high'|'link_dirs'|'copy_untracked'} key @param {string} message @param {string} path */
256
+ const list = async (key, message, path) => {
257
+ if (origins.proof[key] === 'flag') return;
258
+ const next = csv(await answer(ui, ui.text({ message: withHint(message, SETTING_HINTS[key]), initialValue: proof[key].join(',') })));
259
+ if (JSON.stringify(next) !== JSON.stringify(proof[key])) changed.push(path);
260
+ proof[key] = next;
261
+ };
262
+ await list('high', 'High-risk paths (comma list of globs)', 'proof.tiers.high.paths');
263
+ if (origins.proof.isolation !== 'flag') {
264
+ const next = await answer(ui, ui.select({
265
+ message: withHint('Proof isolation', SETTING_HINTS.isolation),
266
+ options: [{ value: 'export' }, { value: 'lock' }],
267
+ initialValue: proof.isolation,
268
+ }));
269
+ if (next !== proof.isolation) changed.push('proof.isolation');
270
+ proof.isolation = next;
271
+ }
272
+ await list('link_dirs', 'Export: directories to link', 'proof.export.link_dirs');
273
+ await list('copy_untracked', 'Export: untracked files to copy', 'proof.export.copy_untracked');
274
+ return changed;
275
+ }
276
+
277
+ /**
278
+ * @typedef {(input: string) => Promise<{ref: string|null, error?: string, kind?: string, title?: string, vault?: string, field?: string}>} OpResolver
279
+ * @typedef {{ref: string|null, source: string, found?: {title?: string, vault?: string, field?: string}}} JevAnswer
280
+ */
281
+
282
+ /**
283
+ * The 1Password branch: ask for an item ID, link or reference, resolve it, and on a failure offer
284
+ * Try again · Enter a full op:// reference · Choose another key source · Skip for now. Never throws
285
+ * on a 1Password failure. Returns null for "choose another key source".
286
+ * @param {Ui} ui
287
+ * @param {OpResolver} resolve
288
+ * @param {{input: string, error: string}|null} pending - start at the failure menu (a flag value that failed).
289
+ * @returns {Promise<JevAnswer|null>}
290
+ */
291
+ async function askOpRef(ui, resolve, pending) {
292
+ let input = pending?.input ?? '';
293
+ let error = pending?.error ?? null;
294
+ let question = OP_REF_QUESTION;
295
+ for (;;) {
296
+ if (error === null) {
297
+ input = String(await answer(ui, ui.text({ message: withHint(question, OP_REF_HINT), ...(input ? { initialValue: input } : {}) }))).trim();
298
+ /** @type {Awaited<ReturnType<OpResolver>>} */
299
+ let res;
300
+ if (question !== OP_REF_QUESTION && !isOpRef(input)) {
301
+ // "full reference" mode takes op:// only; nothing is looked up
302
+ res = { ref: null, kind: 'op_bad_input', error: OP_MESSAGES.op_bad_input };
303
+ } else {
304
+ try {
305
+ const got = await resolve(input);
306
+ // a resolver that answers with anything but the documented shape counts as a failure
307
+ res = got && typeof got === 'object' && (got.ref === null || typeof got.ref === 'string')
308
+ ? got
309
+ : { ref: null, kind: 'op_failed', error: OP_RESOLVE_FAILED };
310
+ } catch {
311
+ // never the thrown message: it could carry op output
312
+ res = { ref: null, kind: 'op_failed', error: OP_RESOLVE_FAILED };
313
+ }
314
+ }
315
+ if (res.ref !== null) {
316
+ return { ref: res.ref, source: 'op', found: { title: res.title, vault: res.vault, field: res.field } };
317
+ }
318
+ error = res.error ?? OP_RESOLVE_FAILED;
319
+ }
320
+ const next = await answer(ui, ui.select({
321
+ message: withHint('1Password lookup failed. What next?', error),
322
+ options: [
323
+ { value: 'retry', label: 'Try again' },
324
+ { value: 'full', label: 'Enter a full op:// reference' },
325
+ { value: 'other', label: 'Choose another key source' },
326
+ { value: 'skip', label: 'Skip for now' },
327
+ ],
328
+ initialValue: 'retry',
329
+ }));
330
+ error = null;
331
+ if (next === 'other') return null;
332
+ if (next === 'skip') return { ref: null, source: 'none' };
333
+ if (next === 'full') {
334
+ question = OP_FULL_REF_QUESTION;
335
+ input = '';
336
+ } else {
337
+ question = OP_REF_QUESTION;
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Step 5 when the chain resolved nothing: where is the Jev key? A pasted key is read with hidden
344
+ * input and handed straight to `store`; it is never returned, echoed or logged. A 1Password item ID
345
+ * or link is resolved to `op://<vaultId>/<itemId>/<fieldId>` through `opts.resolve` (B25).
346
+ * @param {Ui} ui
347
+ * @param {() => Promise<{put: (name: string, value: string, meta: {source: string, exp: number|null}) => Promise<void>}>} getStore
348
+ * @param {{resolve?: OpResolver, pending?: {input: string, error: string}|null}} [opts]
349
+ * @returns {Promise<JevAnswer>}
350
+ */
351
+ export async function askJevSource(ui, getStore, { resolve, pending = null } = {}) {
352
+ if (typeof resolve !== 'function') throw new TypeError('askJevSource: opts.resolve (the 1Password resolver) is required');
353
+ if (pending) {
354
+ const fromOp = await askOpRef(ui, resolve, pending);
355
+ if (fromOp) return fromOp;
356
+ }
357
+ for (;;) {
358
+ const choice = await answer(ui, ui.select({
359
+ message: withHint('Where is the Jev key?', HINTS.jev),
360
+ options: [
361
+ { value: 'op', label: '1Password item or reference', hint: OP_REF_HINT },
362
+ { value: 'env', label: 'environment variable name' },
363
+ { value: 'paste', label: 'paste now (hidden)' },
364
+ { value: 'skip', label: 'skip (rules-only System 1)' },
365
+ ],
366
+ initialValue: 'skip',
367
+ }));
368
+ if (choice === 'op') {
369
+ const fromOp = await askOpRef(ui, resolve, null);
370
+ if (fromOp) return fromOp;
371
+ continue;
372
+ }
373
+ if (choice === 'env') {
374
+ const name = String(await answer(ui, ui.text({ message: withHint('Environment variable name', HINTS.env) }))).trim();
375
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new UsageError('not an environment variable name');
376
+ return { ref: `env:${name}`, source: 'env' };
377
+ }
378
+ if (choice === 'paste') {
379
+ const typed = await answer(ui, ui.password({ message: withHint('Jev key (input hidden)', HINTS.paste) }));
380
+ if (typeof typed !== 'string' || typed.length === 0) return { ref: null, source: 'none' };
381
+ const store = await getStore();
382
+ await store.put('jev', typed, { source: 'user', exp: null });
383
+ return { ref: 'user', source: 'keychain' };
384
+ }
385
+ return { ref: null, source: 'none' };
386
+ }
201
387
  }
@@ -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
+ }