@codedology/code-forge 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +57 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +171 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +33 -4
- package/skill/references/ledger.md +8 -4
- package/skill/references/plan.md +12 -0
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +4 -2
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +18 -1
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +337 -37
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +21 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/run.mjs +16 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/decide/lane-facts.mjs +41 -0
- package/src/doctor/local.mjs +2 -1
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +32 -6
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/engine.mjs +3 -2
- package/src/review/gate-check.mjs +9 -5
- package/src/review/open-findings.mjs +59 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +111 -37
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +68 -8
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +21 -2
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +36 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/worker/engine.mjs +157 -17
- package/src/worker/loop.mjs +26 -7
- package/src/worker/queue.mjs +36 -10
- package/src/worker/ticket.mjs +46 -6
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B60 (issue #13 item 3): run only the block's related tests, and give a gate its own environment.
|
|
3
|
+
* Pure — no filesystem, no child process. A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
|
|
4
|
+
* (one whole item, never a substring, never a shell string) stands for the block's test files:
|
|
5
|
+
*
|
|
6
|
+
* - the block's `test_files` (`block open --tests`), else its owned entries that are concrete
|
|
7
|
+
* files (no glob, no trailing `/`) and look like tests ({@link isTestPath});
|
|
8
|
+
* - when the block owns more FILES than `gates.full_suite_threshold_files` (default 10; globs and
|
|
9
|
+
* directories count the tracked and new (not ignored) files they cover — the caller counts them, gates/owned-count.mjs),
|
|
10
|
+
* or no test file is known (also: a gate run with no block), the item is DROPPED and the gate
|
|
11
|
+
* runs the full suite.
|
|
12
|
+
*
|
|
13
|
+
* {@link testScope} decides which, once per gate run; {@link expandArgv} applies it to one argv.
|
|
14
|
+
* A gate without the token is never touched.
|
|
15
|
+
*
|
|
16
|
+
* A gate given as `{argv, env}` runs with `env` added to the inherited environment. Gate env is
|
|
17
|
+
* NOT a secret store: code-forge prints and records only the variable NAMES ({@link envKeys},
|
|
18
|
+
* {@link printableGates}), never a value. The child's own output is masked only for values of
|
|
19
|
+
* {@link MASK_MIN_LENGTH} or more characters ({@link maskableEnvValues}) — a short value such as
|
|
20
|
+
* `CI=1` or `MODE=test` would otherwise mask unrelated text (`npm test`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { isExactEntry } from '../state/registry.mjs';
|
|
24
|
+
import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
25
|
+
|
|
26
|
+
export { TEST_FILES_TOKEN };
|
|
27
|
+
|
|
28
|
+
/** `gates.full_suite_threshold_files` when the config leaves it out (the schema default). */
|
|
29
|
+
export const DEFAULT_FULL_SUITE_THRESHOLD = 10;
|
|
30
|
+
|
|
31
|
+
/** The shortest env value masked in a gate's own output. */
|
|
32
|
+
export const MASK_MIN_LENGTH = 8;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The threshold a configured value gives: the value itself when it is an integer >= 0, else the
|
|
36
|
+
* default.
|
|
37
|
+
* @param {unknown} value - `gates.full_suite_threshold_files` as the config holds it.
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
export function thresholdOf(value) {
|
|
41
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : DEFAULT_FULL_SUITE_THRESHOLD;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Directory names that hold tests. */
|
|
45
|
+
const TEST_DIRS = new Set(['test', 'tests', '__tests__', 'spec']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The shared test-path predicate: a directory segment named `test`, `tests`, `__tests__` or
|
|
49
|
+
* `spec`, or a file name ending `.test.<ext>` / `.spec.<ext>` (`a.test.mjs`, `a.spec.ts`),
|
|
50
|
+
* `Test.php` (`UserTest.php`), `_test.go`, or a Python `test_*.py`.
|
|
51
|
+
* @param {string} p - a repo-relative POSIX path (an owned entry; a glob is judged by its text).
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isTestPath(p) {
|
|
55
|
+
if (typeof p !== 'string' || p.length === 0) return false;
|
|
56
|
+
const segments = p.split('/');
|
|
57
|
+
const name = segments[segments.length - 1];
|
|
58
|
+
if (segments.slice(0, -1).some((s) => TEST_DIRS.has(s))) return true;
|
|
59
|
+
return /\.(test|spec)\.[^/]+$/.test(name) || name.endsWith('Test.php') || name.endsWith('_test.go') || /^test_.*\.py$/.test(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An owned entry that names one file: no glob character (`*`, `?`, `{`) and no trailing `/`.
|
|
64
|
+
* @param {string} p @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEntry(p) && !p.endsWith('/');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/** A gate run with no block: no test file is known. */
|
|
73
|
+
export const NO_BLOCK_SCOPE = Object.freeze(/** @type {TestScope} */ ({ scope: 'full', reason: 'no_tests' }));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Which tests a `{test_files}` gate runs for one block.
|
|
77
|
+
* @param {{testFiles?: ReadonlyArray<string> | null, owned: ReadonlyArray<string>, ownedFiles?: number, threshold?: number}} opts
|
|
78
|
+
* `testFiles` - the block's `test_files` (`block open --tests`), or none; `owned` - its owned
|
|
79
|
+
* entries; `ownedFiles` - how many files those entries cover (`ownedFileCount`,
|
|
80
|
+
* gates/owned-count.mjs; when not given, one per entry); `threshold` -
|
|
81
|
+
* `gates.full_suite_threshold_files`, compared with `ownedFiles`.
|
|
82
|
+
* @returns {TestScope}
|
|
83
|
+
*/
|
|
84
|
+
export function testScope({ testFiles, owned, ownedFiles = owned.length, threshold = DEFAULT_FULL_SUITE_THRESHOLD }) {
|
|
85
|
+
if (ownedFiles > threshold) return { scope: 'full', reason: 'threshold' };
|
|
86
|
+
const files = testFiles && testFiles.length > 0 ? [...testFiles] : owned.filter((p) => isConcreteFile(p) && isTestPath(p));
|
|
87
|
+
if (files.length === 0) return { scope: 'full', reason: 'no_tests' };
|
|
88
|
+
return { scope: 'related', files };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The argv of one gate command (an argv array or `{argv, ...}`), or null.
|
|
93
|
+
* @param {unknown} command @returns {ReadonlyArray<unknown> | null}
|
|
94
|
+
*/
|
|
95
|
+
function argvOf(command) {
|
|
96
|
+
if (Array.isArray(command)) return command;
|
|
97
|
+
if (command && typeof command === 'object' && Array.isArray(/** @type {{argv?: unknown}} */ (command).argv)) return /** @type {{argv: unknown[]}} */ (command).argv;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Does any gate in `gates` have an argv item that is exactly {@link TEST_FILES_TOKEN} (a whole
|
|
103
|
+
* item — the same test {@link expandArgv} applies)? Only then is a block's test scope worked out;
|
|
104
|
+
* a run of plain gates never reads it.
|
|
105
|
+
* @param {Record<string, unknown> | null | undefined} gates @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function hasTestFilesItem(gates) {
|
|
108
|
+
return Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(TEST_FILES_TOKEN) === true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
|
|
113
|
+
* test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
|
|
114
|
+
* unchanged) when the gate has no such item.
|
|
115
|
+
* @param {ReadonlyArray<string>} argv @param {TestScope} scope
|
|
116
|
+
* @returns {{argv: string[], scoped: boolean}}
|
|
117
|
+
* @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
|
|
118
|
+
* open --tests` refuses one too).
|
|
119
|
+
*/
|
|
120
|
+
export function expandArgv(argv, scope) {
|
|
121
|
+
if (!argv.includes(TEST_FILES_TOKEN)) return { argv: [...argv], scoped: false };
|
|
122
|
+
const files = scope.scope === 'related' ? scope.files : [];
|
|
123
|
+
const dashed = files.find((f) => f.startsWith('-'));
|
|
124
|
+
if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
|
|
125
|
+
return { argv: argv.flatMap((item) => (item === TEST_FILES_TOKEN ? files : [item])), scoped: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
|
|
130
|
+
* or `reason` when full.
|
|
131
|
+
* @param {TestScope} scope
|
|
132
|
+
* @returns {{scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold' | 'no_tests'}}
|
|
133
|
+
*/
|
|
134
|
+
export function scopeFields(scope) {
|
|
135
|
+
return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A gate command's env as a plain `NAME: value` map, or null when it has none: an argv array, an
|
|
140
|
+
* object without `env`, or an empty `env: {}` (treated as no env at all).
|
|
141
|
+
* @param {unknown} command
|
|
142
|
+
* @returns {Record<string, string> | null}
|
|
143
|
+
*/
|
|
144
|
+
export function envOf(command) {
|
|
145
|
+
if (!command || typeof command !== 'object' || Array.isArray(command)) return null;
|
|
146
|
+
const env = /** @type {{env?: unknown}} */ (command).env;
|
|
147
|
+
if (!env || typeof env !== 'object' || Array.isArray(env) || Object.keys(env).length === 0) return null;
|
|
148
|
+
return /** @type {Record<string, string>} */ (env);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The sorted variable NAMES of a gate command's env, or null when it has none.
|
|
153
|
+
* @param {unknown} command @returns {string[] | null}
|
|
154
|
+
*/
|
|
155
|
+
export function envKeys(command) {
|
|
156
|
+
const env = envOf(command);
|
|
157
|
+
return env ? Object.keys(env).sort() : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Every non-empty string value of every gate's `env`, across every entry of `gates`.
|
|
162
|
+
* @param {Record<string, unknown> | null | undefined} gates
|
|
163
|
+
* @returns {string[]}
|
|
164
|
+
*/
|
|
165
|
+
export function envValues(gates) {
|
|
166
|
+
/** @type {string[]} */
|
|
167
|
+
const values = [];
|
|
168
|
+
for (const command of Object.values(gates ?? {})) {
|
|
169
|
+
for (const value of Object.values(envOf(command) ?? {})) if (typeof value === 'string' && value.length > 0) values.push(value);
|
|
170
|
+
}
|
|
171
|
+
return values;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The env values a gate's own output is masked for: those of {@link MASK_MIN_LENGTH} or more
|
|
176
|
+
* characters. Shorter ones are not masked (they would hide unrelated text).
|
|
177
|
+
* @param {Record<string, unknown> | null | undefined} gates @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
export function maskableEnvValues(gates) {
|
|
180
|
+
return envValues(gates).filter((value) => value.length >= MASK_MIN_LENGTH);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A copy of `gates` safe to print: each command given with `env` keeps its other fields and shows
|
|
185
|
+
* `env_keys` (the names) in place of `env`; an empty env is left out. Nothing else changes.
|
|
186
|
+
* @template {Record<string, any>} T
|
|
187
|
+
* @param {T} gates @returns {T}
|
|
188
|
+
*/
|
|
189
|
+
export function printableGates(gates) {
|
|
190
|
+
/** @type {Record<string, unknown>} */
|
|
191
|
+
const out = {};
|
|
192
|
+
for (const [key, command] of Object.entries(gates ?? {})) {
|
|
193
|
+
if (!command || typeof command !== 'object' || Array.isArray(command) || !Object.hasOwn(command, 'env')) {
|
|
194
|
+
out[key] = command;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const { env: _env, ...rest } = /** @type {Record<string, unknown>} */ (command);
|
|
198
|
+
const keys = envKeys(command);
|
|
199
|
+
out[key] = keys ? { ...rest, env_keys: keys } : rest;
|
|
200
|
+
}
|
|
201
|
+
return /** @type {T} */ (out);
|
|
202
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B60: how many FILES a block owns, for `gates.full_suite_threshold_files`.
|
|
3
|
+
*
|
|
4
|
+
* The owned entries are resolved against the workspace's tracked and new (not ignored) files
|
|
5
|
+
* (`git ls-files -z --cached --others --exclude-standard`, argv only) with the B57
|
|
6
|
+
* ownership rule (`ownsFile`, state/registry.mjs): a directory entry counts every such file below it, a glob counts its matches, a file counts once. Each file is counted
|
|
7
|
+
* once however many entries cover it. An entry that covers none of them (a path the block will
|
|
8
|
+
* create) counts as 1. Outside a git repository every entry counts as 1; any other git failure
|
|
9
|
+
* (a timeout, a spawn error, git failing inside a repository) gives `Infinity`, so the full suite
|
|
10
|
+
* runs (`reason: threshold`) rather than a guess.
|
|
11
|
+
*
|
|
12
|
+
* {@link countOwnedFiles} is the pure rule; {@link ownedFileCount} reads the files from git.
|
|
13
|
+
*/
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
16
|
+
import { exec } from '../util/exec.mjs';
|
|
17
|
+
|
|
18
|
+
const GIT_TIMEOUT_MS = 30000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The number of files `owned` covers among `files`, each counted once, plus 1 for every entry
|
|
22
|
+
* that covers none of them.
|
|
23
|
+
* @param {ReadonlyArray<string>} owned
|
|
24
|
+
* @param {ReadonlyArray<string>} tracked - the workspace's files
|
|
25
|
+
* @param {Record<string, string>} [kinds] - the block's `owned_kinds`
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function countOwnedFiles(owned, tracked, kinds) {
|
|
29
|
+
const covered = new Set();
|
|
30
|
+
let notThere = 0;
|
|
31
|
+
for (const entry of owned) {
|
|
32
|
+
const hits = tracked.filter((file) => ownsFile([entry], file, kinds));
|
|
33
|
+
if (hits.length === 0) notThere += 1;
|
|
34
|
+
for (const file of hits) covered.add(file);
|
|
35
|
+
}
|
|
36
|
+
return covered.size + notThere;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `process.env` minus every inherited `GIT_*`, with no system/global config, messages in English. */
|
|
40
|
+
function gitEnv() {
|
|
41
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
42
|
+
const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
|
|
43
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
44
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
45
|
+
}
|
|
46
|
+
env.LC_ALL = 'C'; // the "not a git repository" message is matched below
|
|
47
|
+
return env;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The files `git ls-files` lists: tracked (`--cached`) and new, not ignored (`--others --exclude-standard`). */
|
|
51
|
+
const LS_FILES_ARGV = Object.freeze(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* {@link countOwnedFiles} over the tracked and new (not ignored) files of the workspace at `cwd`.
|
|
55
|
+
* Not a git repository: every entry counts 1. Any other failure: `Infinity` (the full suite runs).
|
|
56
|
+
* @param {string} cwd
|
|
57
|
+
* @param {ReadonlyArray<string>} owned
|
|
58
|
+
* @param {Record<string, string>} [kinds]
|
|
59
|
+
* @returns {Promise<number>}
|
|
60
|
+
*/
|
|
61
|
+
export async function ownedFileCount(cwd, owned, kinds) {
|
|
62
|
+
const res = await exec([...LS_FILES_ARGV], { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
63
|
+
if (res.result === 'ok') return countOwnedFiles(owned, res.stdout.split('\0').filter((f) => f.length > 0), kinds);
|
|
64
|
+
if (!res.timedOut && res.code === 128 && /not a git repository/i.test(res.stderr)) return countOwnedFiles(owned, [], kinds);
|
|
65
|
+
return Number.POSITIVE_INFINITY;
|
|
66
|
+
}
|
package/src/gates/run.mjs
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { exec } from '../util/exec.mjs';
|
|
10
|
+
import { registerSecret } from '../util/redact.mjs';
|
|
11
|
+
import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
|
|
10
12
|
|
|
11
13
|
/** @typedef {import('./detect.mjs').Gate} Gate */
|
|
12
14
|
/** @typedef {import('./detect.mjs').GateCommand} GateCommand */
|
|
13
15
|
/** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
|
|
16
|
+
/** @typedef {import('./expand.mjs').TestScope} TestScope */
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* @typedef {object} GateResult
|
|
@@ -22,26 +25,35 @@ import { exec } from '../util/exec.mjs';
|
|
|
22
25
|
* @property {number | null} code
|
|
23
26
|
* @property {string} stdout
|
|
24
27
|
* @property {string} stderr
|
|
28
|
+
* @property {'related'|'full'} [scope] - B60: only on a gate whose argv has the `{test_files}` item.
|
|
29
|
+
* @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
|
|
30
|
+
* @property {'threshold'|'no_tests'} [reason] - B60: with `scope: full`, why the item was dropped.
|
|
31
|
+
* @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
|
|
32
|
+
* NAMES, sorted (the values are never in a result).
|
|
25
33
|
*/
|
|
26
34
|
|
|
27
35
|
/** Fixed run order — always all four, regardless of an earlier one's result. */
|
|
28
36
|
const GATE_ORDER = /** @type {const} */ (['test', 'lint', 'types', 'format']);
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
|
-
* A gate command is either a plain argv array or `{argv, failOnStdout
|
|
32
|
-
* `GateCommand
|
|
33
|
-
* one shape to deal with.
|
|
39
|
+
* A gate command is either a plain argv array or `{argv, failOnStdout?, env?}` (detect.mjs's
|
|
40
|
+
* `GateCommand`: `failOnStdout` for `gofmt -l`, which always exits 0; B60 `env` from the config).
|
|
41
|
+
* Normalized here so the run loop below has one shape to deal with.
|
|
34
42
|
* @param {GateCommand} command
|
|
35
|
-
* @returns {{argv: string[], failOnStdout: boolean}}
|
|
43
|
+
* @returns {{argv: string[], failOnStdout: boolean, env: Record<string, string> | null}}
|
|
36
44
|
*/
|
|
37
45
|
function normalizeCommand(command) {
|
|
38
|
-
if (Array.isArray(command)) return { argv: command, failOnStdout: false };
|
|
39
|
-
return { argv: command.argv, failOnStdout: command.failOnStdout === true };
|
|
46
|
+
if (Array.isArray(command)) return { argv: command, failOnStdout: false, env: null };
|
|
47
|
+
return { argv: command.argv, failOnStdout: command.failOnStdout === true, env: envOf(command) }; // `env: {}` is no env
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
51
|
+
* B60: `opts.scope` is the block's {@link TestScope} (`testScope`, gates/expand.mjs); a gate argv
|
|
52
|
+
* item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
|
|
53
|
+
* no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
|
|
54
|
+
* variables added to the inherited environment; a gate with neither runs exactly as before B60.
|
|
43
55
|
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
|
|
44
|
-
* @param {{cwd: string, timeoutMs?: number}} opts
|
|
56
|
+
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
|
|
45
57
|
* @returns {Promise<{results: GateResult[], allOk: boolean}>}
|
|
46
58
|
*/
|
|
47
59
|
export async function runGates(gates, opts) {
|
|
@@ -49,24 +61,49 @@ export async function runGates(gates, opts) {
|
|
|
49
61
|
throw new TypeError('runGates: gates must be an object with test/lint/types/format');
|
|
50
62
|
}
|
|
51
63
|
const { cwd, timeoutMs = 300000 } = opts ?? {};
|
|
64
|
+
const scope = opts?.scope ?? NO_BLOCK_SCOPE; // null falls back too
|
|
52
65
|
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
53
66
|
throw new TypeError('runGates: opts.cwd must be a non-empty string');
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
// B60: gate env is not a secret store. Its values are never put in a result (only `env_keys`);
|
|
70
|
+
// a value of 8+ characters is also masked in the child's own output (a shorter one would mask
|
|
71
|
+
// unrelated text — `MODE=test` vs `npm test`)
|
|
72
|
+
for (const value of maskableEnvValues(gates)) registerSecret(value);
|
|
73
|
+
// Every gate is normalized and expanded BEFORE any runs, so a bad gate throws with nothing run.
|
|
74
|
+
// B60: config validation refuses a gate whose only item is {test_files}; the empty-argv check
|
|
75
|
+
// is the guard for one that reaches here anyway: it names the gate, not an opaque spawn error.
|
|
76
|
+
const planned = GATE_ORDER.map((gate) => {
|
|
77
|
+
const command = gates[gate] ?? null;
|
|
78
|
+
if (command === null) return { gate, command: null };
|
|
79
|
+
const normalized = normalizeCommand(command);
|
|
80
|
+
const { argv, scoped } = expandArgv(normalized.argv, scope);
|
|
81
|
+
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{test_files}' is dropped for the full suite — put the test command before it`);
|
|
82
|
+
return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
|
|
83
|
+
});
|
|
56
84
|
/** @type {GateResult[]} */
|
|
57
85
|
const results = [];
|
|
58
|
-
for (const gate of
|
|
59
|
-
const command = gates[gate] ?? null;
|
|
86
|
+
for (const { gate, command } of planned) {
|
|
60
87
|
if (command === null) {
|
|
61
88
|
results.push({ gate, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '' });
|
|
62
89
|
continue;
|
|
63
90
|
}
|
|
64
|
-
const { argv, failOnStdout } =
|
|
91
|
+
const { argv, scoped, failOnStdout, env } = command;
|
|
65
92
|
// Each command is run and its exit status read on its own — never `&&`-chained with the
|
|
66
93
|
// next, so a red gate is reported red without swallowing the gates that follow it.
|
|
67
|
-
const res = await exec(argv, { cwd, timeoutMs });
|
|
94
|
+
const res = await exec(argv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
|
|
68
95
|
const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
|
|
69
|
-
results.push({
|
|
96
|
+
results.push({
|
|
97
|
+
gate,
|
|
98
|
+
argv,
|
|
99
|
+
skipped: false,
|
|
100
|
+
ok,
|
|
101
|
+
code: res.code,
|
|
102
|
+
stdout: res.stdout,
|
|
103
|
+
stderr: res.stderr,
|
|
104
|
+
...(scoped ? scopeFields(scope) : {}),
|
|
105
|
+
...(env ? { env_keys: Object.keys(env).sort() } : {}),
|
|
106
|
+
});
|
|
70
107
|
}
|
|
71
108
|
return { results, allOk: results.every((r) => r.ok) };
|
|
72
109
|
}
|
|
@@ -58,8 +58,9 @@ export function linkDirsFromManifests(cwd) {
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* The proof profile is decided from the project, not from the gate detector's stack name alone:
|
|
61
|
-
* `detectGates`
|
|
62
|
-
* (its Vite `package.json`) or `unknown` —
|
|
61
|
+
* `detectGates` names a Laravel app `php-pest` or `php-phpunit` only once its runner is installed
|
|
62
|
+
* (`vendor/bin/…`); before that it reaches here as `node` (its Vite `package.json`) or `unknown` —
|
|
63
|
+
* `composer.json` + `artisan` is Laravel either way.
|
|
63
64
|
* @param {string} stack - `detectGates().stack`
|
|
64
65
|
* @param {string} cwd
|
|
65
66
|
* @returns {string} the proof profile: `laravel`, `php`, `node`, `python`, `rust`, `go` or `unknown`.
|
|
@@ -274,6 +275,11 @@ function gateArgv(gate) {
|
|
|
274
275
|
return null;
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/**
|
|
279
|
+
* A gate as the config holds it: an argv, `{argv, env}` (B60, kept from an existing config), or none.
|
|
280
|
+
* @typedef {string[] | {argv: string[], env?: Record<string, string>} | null} GateValue
|
|
281
|
+
*/
|
|
282
|
+
|
|
277
283
|
/**
|
|
278
284
|
* @typedef {object} Answers
|
|
279
285
|
* @property {'current'|'recommended'} tools
|
|
@@ -289,9 +295,13 @@ function gateArgv(gate) {
|
|
|
289
295
|
* @property {{mode: 'auto'} | {mode: 'ref', ref: string} | {mode: 'none'}} jev
|
|
290
296
|
* @property {string} engine
|
|
291
297
|
* @property {number|null} solo_project
|
|
292
|
-
* @property {{test:
|
|
298
|
+
* @property {{test: GateValue, lint: GateValue, types: GateValue, format: GateValue}} gates
|
|
293
299
|
* @property {{high: string[], isolation: string, link_dirs: string[], copy_untracked: string[]}} proof
|
|
294
300
|
* @property {boolean} doctor
|
|
301
|
+
* @property {boolean} coder_may_push - `autonomy.coder_may_push` (B65; default false).
|
|
302
|
+
* @property {boolean} coder_may_open_draft_pr - `autonomy.coder_may_open_draft_pr` (B65; default
|
|
303
|
+
* false). `resolveAnswers` sets it false whenever `coder_may_push` is false, except for an
|
|
304
|
+
* explicit `--coder-may-open-draft-pr yes`, which `runInit` refuses (exit 2): a draft PR needs a push.
|
|
295
305
|
*/
|
|
296
306
|
|
|
297
307
|
/**
|
|
@@ -300,10 +310,15 @@ function gateArgv(gate) {
|
|
|
300
310
|
* @typedef {{gates: Record<string, 'flag'|'existing'|'detected'>, proof: Record<string, 'flag'|'existing'|'detected'|'default'>}} Origins
|
|
301
311
|
*/
|
|
302
312
|
|
|
303
|
-
/**
|
|
313
|
+
/**
|
|
314
|
+
* The answer keys in the order they are settled: `askAnswers` asks the two autonomy keys last of
|
|
315
|
+
* its questions (after `solo_project`); gates and proof come later, in the settings step, and
|
|
316
|
+
* `doctor` is never asked (only `--skip-doctor` sets it).
|
|
317
|
+
*/
|
|
304
318
|
export const ANSWER_KEYS = Object.freeze([
|
|
305
319
|
'tools', 'yes_tools', 'harnesses', 'scope', 'method', 'provider', 'levels', 'refresh_models',
|
|
306
|
-
'multimodel', 'second_provider', 'jev', 'engine', 'solo_project', '
|
|
320
|
+
'multimodel', 'second_provider', 'jev', 'engine', 'solo_project', 'coder_may_push',
|
|
321
|
+
'coder_may_open_draft_pr', 'gates', 'proof', 'doctor',
|
|
307
322
|
]);
|
|
308
323
|
|
|
309
324
|
/**
|
|
@@ -382,6 +397,20 @@ export function resolveAnswers(ctx, existing, user, given) {
|
|
|
382
397
|
set('solo_project', user?.solo?.project_id, () => null);
|
|
383
398
|
if (lastFlag('solo_project')) override('solo_project', lastFlag('solo_project').value);
|
|
384
399
|
|
|
400
|
+
// B65: push and draft-PR autonomy default to no; a re-run proposes the existing booleans.
|
|
401
|
+
const exAutonomy = ex.autonomy && typeof ex.autonomy === 'object' ? ex.autonomy : {};
|
|
402
|
+
/** @param {unknown} b @returns {boolean|undefined} */
|
|
403
|
+
const bool = (b) => (typeof b === 'boolean' ? b : undefined);
|
|
404
|
+
set('coder_may_push', bool(exAutonomy.coder_may_push), () => false);
|
|
405
|
+
const pushFlag = lastFlag('coder_may_push');
|
|
406
|
+
if (pushFlag) override('coder_may_push', pushFlag.value);
|
|
407
|
+
set('coder_may_open_draft_pr', bool(exAutonomy.coder_may_open_draft_pr), () => false);
|
|
408
|
+
const draftFlag = lastFlag('coder_may_open_draft_pr');
|
|
409
|
+
if (draftFlag) override('coder_may_open_draft_pr', draftFlag.value);
|
|
410
|
+
// A draft PR needs a push: with push no, a kept draft-PR yes follows to no. Only an explicit
|
|
411
|
+
// `--coder-may-open-draft-pr yes` stays true here — `runInit` refuses that combination.
|
|
412
|
+
if (!v.coder_may_push && sources.coder_may_open_draft_pr !== 'flag') v.coder_may_open_draft_pr = false;
|
|
413
|
+
|
|
385
414
|
// Gates and proof are never asked (B24): each value comes from a flag, else the existing
|
|
386
415
|
// config, else the project. `origins` records which, per gate and per proof key. A gate the
|
|
387
416
|
// existing config left blank is filled when the project now shows one; a set gate is kept.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* **Q16 = cut (Ricardo, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
|
|
7
7
|
* v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
|
|
8
8
|
* (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
|
|
9
|
-
* (two tokens that each set the scope answer) there are
|
|
9
|
+
* (two tokens that each set the scope answer) there are 21 answer flags: B65 added
|
|
10
|
+
* `--coder-may-push` and `--coder-may-open-draft-pr` (`yes`/`no`, default no).
|
|
10
11
|
*
|
|
11
12
|
* Parsing is strict: an unknown flag, a missing value, a repeated single-value flag or a bad value
|
|
12
13
|
* is a usage error. Error messages name the flag, never the value given (a user can paste a key
|
|
@@ -46,6 +47,8 @@ export const ANSWER_FLAGS = Object.freeze({
|
|
|
46
47
|
'--gate': 'gates',
|
|
47
48
|
'--proof': 'proof',
|
|
48
49
|
'--skip-doctor': 'doctor',
|
|
50
|
+
'--coder-may-push': 'coder_may_push',
|
|
51
|
+
'--coder-may-open-draft-pr': 'coder_may_open_draft_pr',
|
|
49
52
|
});
|
|
50
53
|
|
|
51
54
|
const BOOLEAN_FLAGS = new Set(['-g', '-p', '--copy', '--refresh-models', '--no-jev', '--skip-doctor', '--no-interaction']);
|
|
@@ -190,6 +193,10 @@ function convert(flag, value) {
|
|
|
190
193
|
if (argv.length === 0) throw new UsageError('--gate: empty command');
|
|
191
194
|
return { name, argv };
|
|
192
195
|
}
|
|
196
|
+
case '--coder-may-push':
|
|
197
|
+
case '--coder-may-open-draft-pr':
|
|
198
|
+
if (value !== 'yes' && value !== 'no') throw new UsageError(`${flag} must be yes or no`);
|
|
199
|
+
return value === 'yes';
|
|
193
200
|
case '--proof': {
|
|
194
201
|
const [key, raw] = keyValue('--proof', value);
|
|
195
202
|
if (!PROOF_KEYS.includes(key)) throw new UsageError(`--proof: key must be one of ${PROOF_KEYS.join(', ')}`);
|
|
@@ -55,6 +55,8 @@ export function generatedConfig(a, ctx, jevRef) {
|
|
|
55
55
|
isolation: a.proof.isolation,
|
|
56
56
|
export: { link_dirs: [...a.proof.link_dirs], copy_untracked: [...a.proof.copy_untracked] },
|
|
57
57
|
};
|
|
58
|
+
// B65: always both keys, also when both are false, so the file states the choice
|
|
59
|
+
cfg.autonomy = { coder_may_push: a.coder_may_push === true, coder_may_open_draft_pr: a.coder_may_push === true && a.coder_may_open_draft_pr === true };
|
|
58
60
|
return cfg;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -59,6 +59,7 @@ export const USAGE = [
|
|
|
59
59
|
' [--harness a,b] [-g|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]…',
|
|
60
60
|
' [--refresh-models] [--multimodel on|off] [--second-provider P]',
|
|
61
61
|
' [--jev-ref <item-id|link|op://…> | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
|
|
62
|
+
' [--coder-may-push yes|no] [--coder-may-open-draft-pr yes|no]',
|
|
62
63
|
' [--gate name=cmd]… [--proof isolation=export|lock | high=a,b | link_dirs=a,b | copy_untracked=a,b]… [--skip-doctor]',
|
|
63
64
|
].join('\n');
|
|
64
65
|
|
|
@@ -230,6 +231,11 @@ export async function runInit(args, deps = {}) {
|
|
|
230
231
|
ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
231
232
|
await askAnswers(values, sources, ctx, ui);
|
|
232
233
|
}
|
|
234
|
+
// B65: a draft PR needs a push. `resolveAnswers` and `askAnswers` turn a kept draft-PR yes to
|
|
235
|
+
// no when push is no, so a yes left here came from the flag: refused.
|
|
236
|
+
if (values.coder_may_open_draft_pr && !values.coder_may_push) {
|
|
237
|
+
throw new InitFailure(2, '--coder-may-open-draft-pr yes needs autonomy.coder_may_push yes — a draft PR needs a push; pass --coder-may-push yes or drop the flag. Nothing written.');
|
|
238
|
+
}
|
|
233
239
|
|
|
234
240
|
// Gates and proof are never asked (B24): the summary says what was found and what is blank.
|
|
235
241
|
// A person sees it now, before anything is written; agent mode gets it in the JSON line.
|
|
@@ -62,6 +62,8 @@ export const HINTS = Object.freeze({
|
|
|
62
62
|
judge: 'The judge settles disagreements between the two reviewers.',
|
|
63
63
|
engine: 'auto uses Solo when present, otherwise the subagents of the current harness.',
|
|
64
64
|
solo_project: 'The Solo project that coder processes run in; leave blank for none.',
|
|
65
|
+
coder_may_push: 'No: the coder hands back its branch and you (or the orchestrator) push it.',
|
|
66
|
+
coder_may_open_draft_pr: 'Only after an independent review passes; marking it ready and merging stay with you.',
|
|
65
67
|
jev: 'Jev is the small fast classifier behind System 1; skip uses rules only.',
|
|
66
68
|
env: 'That variable must hold the key whenever code-forge runs.',
|
|
67
69
|
paste: 'The key goes to the OS key store; the config keeps only a reference.',
|
|
@@ -69,6 +71,10 @@ export const HINTS = Object.freeze({
|
|
|
69
71
|
apply: 'Only the listed keys change; your other edits stay.',
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
/** B65: the two autonomy questions, asked last among the answers (default no). */
|
|
75
|
+
export const PUSH_QUESTION = 'May a coder push its block branch on its own?';
|
|
76
|
+
export const DRAFT_PR_QUESTION = 'May a coder open a draft PR on its own?';
|
|
77
|
+
|
|
72
78
|
/** The one-line hints of the "Customize now" questions, in the order they are asked (B24). */
|
|
73
79
|
export const SETTING_HINTS = Object.freeze({
|
|
74
80
|
test: 'the command that runs your tests; the block cannot close if it fails (blank = no test gate)',
|
|
@@ -205,6 +211,16 @@ export async function askAnswers(values, sources, ctx, ui) {
|
|
|
205
211
|
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) }));
|
|
206
212
|
values.solo_project = /^[1-9]\d{0,9}$/.test(String(typed).trim()) ? Number(String(typed).trim()) : null;
|
|
207
213
|
}
|
|
214
|
+
// B65: push first; a draft PR needs a push, so with push no the draft-PR question is not asked
|
|
215
|
+
// and its answer is no (a `--coder-may-open-draft-pr yes` flag is then refused by `runInit`).
|
|
216
|
+
if (open('coder_may_push')) {
|
|
217
|
+
values.coder_may_push = (await answer(ui, ui.confirm({ message: withHint(PUSH_QUESTION, HINTS.coder_may_push), initialValue: values.coder_may_push === true }))) === true;
|
|
218
|
+
}
|
|
219
|
+
if (values.coder_may_push && open('coder_may_open_draft_pr')) {
|
|
220
|
+
values.coder_may_open_draft_pr = (await answer(ui, ui.confirm({ message: withHint(DRAFT_PR_QUESTION, HINTS.coder_may_open_draft_pr), initialValue: values.coder_may_open_draft_pr === true }))) === true;
|
|
221
|
+
} else if (open('coder_may_open_draft_pr')) {
|
|
222
|
+
values.coder_may_open_draft_pr = false;
|
|
223
|
+
}
|
|
208
224
|
return values;
|
|
209
225
|
}
|
|
210
226
|
|
|
@@ -240,16 +256,20 @@ export async function askProjectSettings(values, origins, ui) {
|
|
|
240
256
|
const changed = [];
|
|
241
257
|
for (const name of GATE_NAMES) {
|
|
242
258
|
if (origins.gates[name] === 'flag') continue;
|
|
243
|
-
const gates = /** @type {Record<string,
|
|
259
|
+
const gates = /** @type {Record<string, import('./answers.mjs').GateValue>} */ (values.gates);
|
|
244
260
|
const current = gates[name];
|
|
261
|
+
// B60: a gate kept as {argv, env} is edited by its argv; its env is kept (and never shown).
|
|
262
|
+
const currentArgv = /** @type {string[] | null} */ (Array.isArray(current) || !current ? current : current.argv);
|
|
245
263
|
const typed = await answer(ui, ui.text({
|
|
246
264
|
message: withHint(`Gate ${name} (blank or "none": no gate)`, SETTING_HINTS[/** @type {keyof typeof SETTING_HINTS} */ (name)]),
|
|
247
|
-
initialValue:
|
|
265
|
+
initialValue: currentArgv ? currentArgv.join(' ') : '',
|
|
248
266
|
}));
|
|
249
267
|
const text = String(typed ?? '').trim();
|
|
268
|
+
// blank or "none" turns the gate off: a gate kept as {argv, env} then loses its env too
|
|
250
269
|
const next = text === '' || text === 'none' ? null : shellWords(text);
|
|
251
|
-
if (JSON.stringify(next)
|
|
252
|
-
gates
|
|
270
|
+
if (JSON.stringify(next) === JSON.stringify(currentArgv)) continue;
|
|
271
|
+
changed.push(`gates.${name}`);
|
|
272
|
+
gates[name] = next && current && !Array.isArray(current) ? { ...current, argv: next } : next;
|
|
253
273
|
}
|
|
254
274
|
const proof = values.proof;
|
|
255
275
|
/** @param {'high'|'link_dirs'|'copy_untracked'} key @param {string} message @param {string} path */
|
|
@@ -46,7 +46,10 @@ const STACK_MARKER = Object.freeze({ rust: 'Cargo.toml', python: 'pyproject.toml
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* @typedef {object} Setting
|
|
49
|
-
* @property {unknown} value -
|
|
49
|
+
* @property {unknown} value - the value shown (a gate: its argv). The config file is written from
|
|
50
|
+
* `values.gates`, not from here, so a `{argv, env}` gate keeps its env.
|
|
51
|
+
* @property {string[]} [env_keys] - B60: a gate given as `{argv, env}` with a non-empty env: the
|
|
52
|
+
* variable NAMES, sorted (never the values).
|
|
50
53
|
* @property {'flag'|'existing'|'detected'|'default'} source
|
|
51
54
|
* @property {string} note - where it came from, or why it is blank, in the summary's words.
|
|
52
55
|
*/
|
|
@@ -115,7 +118,12 @@ export function summarizeSettings(values, origins, ctx) {
|
|
|
115
118
|
|
|
116
119
|
for (const name of GATE_NAMES) {
|
|
117
120
|
const key = SETTING_KEYS[/** @type {keyof typeof SETTING_KEYS} */ (name)];
|
|
118
|
-
const
|
|
121
|
+
const command = values.gates[/** @type {keyof typeof values.gates} */ (name)];
|
|
122
|
+
// B60: a gate given as {argv, env} is summarised by its argv and its env NAMES, never the values;
|
|
123
|
+
// an empty env counts as none
|
|
124
|
+
const envKeys = command && !Array.isArray(command) && command.env && Object.keys(command.env).length > 0 ? Object.keys(command.env).sort() : null;
|
|
125
|
+
const envNote = envKeys ? ` (env: ${envKeys.join(', ')})` : '';
|
|
126
|
+
const argv = /** @type {string[] | null} */ (Array.isArray(command) || !command ? command : command.argv);
|
|
119
127
|
const source = origins.gates[name] ?? 'detected';
|
|
120
128
|
let note;
|
|
121
129
|
if (argv === null || argv === undefined || argv.length === 0) {
|
|
@@ -123,12 +131,12 @@ export function summarizeSettings(values, origins, ctx) {
|
|
|
123
131
|
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
124
132
|
else note = GATE_MISSING[/** @type {keyof typeof GATE_MISSING} */ (name)];
|
|
125
133
|
blank.push(key);
|
|
126
|
-
lines.push(` ${name}: blank — ${note}`);
|
|
134
|
+
lines.push(` ${name}: blank${envNote} — ${note}`);
|
|
127
135
|
} else {
|
|
128
136
|
note = source === 'detected' ? gateEvidence(ctx.detected.stack, name, argv, ctx.cwd) : keptFrom(source, '--gate');
|
|
129
|
-
lines.push(` ${name}: ${shown(argv)} (from ${note})`);
|
|
137
|
+
lines.push(` ${name}: ${shown(argv)}${envNote} (from ${note})`);
|
|
130
138
|
}
|
|
131
|
-
settings[key] = { value: argv, source, note };
|
|
139
|
+
settings[key] = { value: argv, source, note, ...(envKeys ? { env_keys: envKeys } : {}) };
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
const found = new Map(linkDirsFromManifests(ctx.cwd).map((e) => [e.dir, e.manifests]));
|
package/src/ledger/export.mjs
CHANGED