@event4u/agent-config 9.22.0 → 9.23.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +69 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
- package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
- package/dist/agent-src/rules/context-hygiene.md +17 -0
- package/dist/agent-src/rules/downstream-changes.md +26 -0
- package/dist/agent-src/rules/think-before-action.md +25 -0
- package/dist/agent-src/rules/token-budget-discipline.md +46 -3
- package/dist/agent-src/rules/token-efficiency.md +13 -0
- package/dist/agent-src/rules/ui-audit-gate.md +33 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
- package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
- package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
- package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
- package/dist/cli-delegate/cmd_export.js +2 -1
- package/dist/cli-delegate/cmd_prune.js +2 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
- package/dist/cli-delegate/cmd_settings_set.js +3370 -0
- package/dist/cli-delegate/cmd_sync.js +2 -1
- package/dist/cli-delegate/cmd_uninstall.js +2 -1
- package/dist/cli-delegate/cmd_validate.js +2 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +79 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/scripts/condense.js +47 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/settings.js +82 -0
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +191 -0
- package/dist/shared/settingsClasses.js.map +1 -0
- package/docs/contracts/settings-classes.md +342 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
- package/docs/guidelines/agent-infra/false-green.md +187 -0
- package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
- package/package.json +3 -1
- package/src/config/ci-local-parity.yml +12 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +54 -12
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/pack-size-budget.json +4 -3
- package/src/scripts/_cli/cmd_settings_set.ts +509 -0
- package/src/scripts/_dispatch.bash +16 -0
- package/src/scripts/_lib/gate_ledger.ts +295 -0
- package/src/scripts/_lib/gate_result.ts +101 -0
- package/src/scripts/_lib/gate_self_test.ts +129 -0
- package/src/scripts/_lib/measured_render.ts +97 -0
- package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
- package/src/scripts/check_gate_completeness.ts +244 -0
- package/src/scripts/check_gate_coverage.ts +35 -6
- package/src/scripts/check_references.ts +125 -15
- package/src/scripts/check_review_dispositions.ts +14 -1
- package/src/scripts/check_suppression_hygiene.ts +503 -0
- package/src/scripts/condense.ts +47 -2
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +65 -1
- package/src/scripts/hooks/host_semantics.ts +140 -0
- package/src/scripts/lint_framework_leakage.ts +147 -15
- package/src/scripts/lint_mandated_lines.ts +339 -0
- package/src/scripts/lint_roadmap_complexity.ts +102 -11
- package/src/scripts/lint_roadmap_family_cap.ts +191 -0
- package/src/scripts/lint_settings_classes.ts +397 -0
- package/src/scripts/lint_token_budget_discipline.ts +109 -1
- package/src/scripts/report_imperative_density.ts +229 -0
- package/src/scripts/verify_physical_move.ts +10 -1
- package/src/server/io/yamlIO.ts +173 -0
- package/src/shared/settingsClasses.ts +235 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-config settings:set <key> <value>` — the one agent-reachable settings
|
|
3
|
+
* writer, and the fence around it.
|
|
4
|
+
*
|
|
5
|
+
* Phase 2 of `road-to-zero-ceremony-settings`. Greenfield: before this, the
|
|
6
|
+
* `settings` verb family could `check`, `sync`, and `migrate`, and the only way
|
|
7
|
+
* to change a value was the GUI or an editor. That is why the fence lands with
|
|
8
|
+
* the writer rather than after it — the first version of an agent-writable
|
|
9
|
+
* settings path is the last cheap moment to decide what it may not write.
|
|
10
|
+
*
|
|
11
|
+
* The contract, in four lines:
|
|
12
|
+
*
|
|
13
|
+
* - **C-class keys are refused.** The class comes from
|
|
14
|
+
* `docs/contracts/settings-classes.md`, parsed through the same shared module
|
|
15
|
+
* the lint and the Fastify write route use, so all three refuse the same keys
|
|
16
|
+
* for the same reason.
|
|
17
|
+
* - **Fail-closed.** A missing or unparseable contract refuses EVERY write, not
|
|
18
|
+
* just the guarded ones. A fence that opens when its rulebook goes missing is
|
|
19
|
+
* not a fence.
|
|
20
|
+
* - **Zod-validated** against the shipped settings schema before anything is
|
|
21
|
+
* written, so a typo cannot land a value the GUI will later reject.
|
|
22
|
+
* - **Atomic** — tmp-write, `fsync`, rename, mode 0600, mirroring the server's
|
|
23
|
+
* `writeAtomic` semantics in a synchronous form the CLI can use — into the
|
|
24
|
+
* canonical global file `~/.event4u/agent-config/settings/.agent-settings.yml`,
|
|
25
|
+
* the file the wizard writes and both the server and the installer read.
|
|
26
|
+
*
|
|
27
|
+
* Provenance lives in a **sidecar**, `settings/.agent-settings.provenance.json`,
|
|
28
|
+
* not as extra keys in the YAML. The settings file has a leaf-for-leaf parity
|
|
29
|
+
* test against the zod schema whose own comment reads *"loosen it and the GUI
|
|
30
|
+
* silently drifts"*; adding bookkeeping keys to that file would mean relaxing
|
|
31
|
+
* it, and the roadmap's Risk 2 names exactly that trade as the thing not to
|
|
32
|
+
* make. The sidecar carries `source` and `at` per dotted key and is written in
|
|
33
|
+
* the same run as the value it describes.
|
|
34
|
+
*
|
|
35
|
+
* Exit codes: `0` written or already at that value · `1` refused (guarded key,
|
|
36
|
+
* unknown key, validation failure, missing contract) · `2` usage error.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as os from 'node:os';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import process from 'node:process';
|
|
43
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
44
|
+
|
|
45
|
+
import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
|
|
46
|
+
|
|
47
|
+
import { z } from 'zod';
|
|
48
|
+
|
|
49
|
+
import { mergeIntoTemplate } from '../../server/io/yamlIO.js';
|
|
50
|
+
import { settingsSchema } from '../../server/schemas/settings.js';
|
|
51
|
+
import { resolvePackageRoot } from '../_lib/package_root.js';
|
|
52
|
+
import {
|
|
53
|
+
buildSettingsClassIndex,
|
|
54
|
+
getSettingsLeaf,
|
|
55
|
+
parseSettingsClassRows,
|
|
56
|
+
type SettingsClass,
|
|
57
|
+
} from '../../shared/settingsClasses.js';
|
|
58
|
+
|
|
59
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
60
|
+
// Ascend to the real package root rather than counting parents. `prepack`
|
|
61
|
+
// bundles this file into `dist/cli-delegate/`, and `_dispatch.bash` PREFERS
|
|
62
|
+
// that bundle over the source, where three hops land outside the package —
|
|
63
|
+
// the class contract then reads as missing and the writer fails closed on
|
|
64
|
+
// every key. That exact shape turned the 9.11.0 tarball E2E red, which is
|
|
65
|
+
// why `_lib/package_root.ts` exists and every other delegate uses it.
|
|
66
|
+
const PACKAGE_ROOT = resolvePackageRoot(import.meta.url);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Header for a file this writer creates from nothing.
|
|
70
|
+
*
|
|
71
|
+
* Stated rather than assumed: a reader opening a two-line settings file should
|
|
72
|
+
* learn that the absent keys are not missing, they are defaulted.
|
|
73
|
+
*/
|
|
74
|
+
const SPARSE_FILE_HEADER =
|
|
75
|
+
'# Settings decisions recorded by `agent-config settings:set`.\n' +
|
|
76
|
+
'# Every key absent from this file resolves to its documented default in\n' +
|
|
77
|
+
'# the package template. How each entry came to be set is recorded in\n' +
|
|
78
|
+
'# `.agent-settings.provenance.json` beside it.\n\n';
|
|
79
|
+
|
|
80
|
+
/** Where the class contract ships. Shipped via package.json `files[]`. */
|
|
81
|
+
const CONTRACT_RELATIVE = 'docs/contracts/settings-classes.md';
|
|
82
|
+
|
|
83
|
+
/** How a value came to be set. Ordered least to most deliberate. */
|
|
84
|
+
export type ProvenanceSource = 'auto-detected' | 'jit-answer' | 'manual' | 'gui';
|
|
85
|
+
|
|
86
|
+
const PROVENANCE_SOURCES: readonly ProvenanceSource[] = ['auto-detected', 'jit-answer', 'manual', 'gui'];
|
|
87
|
+
|
|
88
|
+
export interface ProvenanceEntry {
|
|
89
|
+
source: ProvenanceSource;
|
|
90
|
+
/** ISO-8601 UTC, second precision — the file is read by humans. */
|
|
91
|
+
at: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SettingsSetOptions {
|
|
95
|
+
key: string;
|
|
96
|
+
/** The raw CLI token; parsed as YAML scalar so `true`/`3`/`[]` arrive typed. */
|
|
97
|
+
rawValue: string;
|
|
98
|
+
source: ProvenanceSource;
|
|
99
|
+
/** Override the global root; the tests use it, nothing else should. */
|
|
100
|
+
root: string;
|
|
101
|
+
/** Resolve the class contract from here. Defaults to the package root. */
|
|
102
|
+
packageRoot: string;
|
|
103
|
+
now: string;
|
|
104
|
+
dryRun: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SettingsSetResult {
|
|
108
|
+
code: 0 | 1 | 2;
|
|
109
|
+
/** Lines for stdout — the loud echo. */
|
|
110
|
+
out: string[];
|
|
111
|
+
/** Lines for stderr — refusals. */
|
|
112
|
+
err: string[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `~/.event4u/agent-config` unless `EVENT4U_CONFIG_HOME` says otherwise. */
|
|
116
|
+
export function globalRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
117
|
+
const override = env['EVENT4U_CONFIG_HOME'];
|
|
118
|
+
if (override !== undefined && override.trim() !== '') {
|
|
119
|
+
return path.resolve(override);
|
|
120
|
+
}
|
|
121
|
+
return path.join(os.homedir(), '.event4u', 'agent-config');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function settingsFilePath(root: string): string {
|
|
125
|
+
return path.join(root, 'settings', '.agent-settings.yml');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function provenanceFilePath(root: string): string {
|
|
129
|
+
return path.join(root, 'settings', '.agent-settings.provenance.json');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse a CLI token the way YAML would, so `true`, `3`, `[]`, and `off` arrive
|
|
134
|
+
* as the type the schema expects rather than as strings.
|
|
135
|
+
*
|
|
136
|
+
* A token that does not parse is kept verbatim as a string — the zod validation
|
|
137
|
+
* downstream is what decides whether that is acceptable, and it gives a better
|
|
138
|
+
* message than a YAML error would.
|
|
139
|
+
*/
|
|
140
|
+
export function parseScalar(raw: string): unknown {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = yamlLoad(raw);
|
|
143
|
+
return parsed === undefined ? raw : parsed;
|
|
144
|
+
} catch {
|
|
145
|
+
return raw;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Segments that would step onto the prototype chain rather than into the document. */
|
|
150
|
+
const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
151
|
+
|
|
152
|
+
function _setDotted(target: Record<string, unknown>, dotted: string, value: unknown): void {
|
|
153
|
+
const parts = dotted.split('.');
|
|
154
|
+
// The class lookup already refuses any key without a contract row, so this
|
|
155
|
+
// is unreachable today. It is here because that guard lives in a markdown
|
|
156
|
+
// data file while this function is exported: a code invariant should not
|
|
157
|
+
// depend on nobody adding the wrong row.
|
|
158
|
+
for (const part of parts) {
|
|
159
|
+
if (FORBIDDEN_SEGMENTS.has(part)) {
|
|
160
|
+
throw new Error(`settings:set: refusing prototype-walking key segment '${part}' in '${dotted}'`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
let node = target;
|
|
164
|
+
for (const part of parts.slice(0, -1)) {
|
|
165
|
+
const next = node[part];
|
|
166
|
+
if (typeof next !== 'object' || next === null || Array.isArray(next)) {
|
|
167
|
+
node[part] = {};
|
|
168
|
+
}
|
|
169
|
+
node = node[part] as Record<string, unknown>;
|
|
170
|
+
}
|
|
171
|
+
node[parts[parts.length - 1] as string] = value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The current file as `{ text, values }`, or `null` when it exists but cannot
|
|
176
|
+
* be used.
|
|
177
|
+
*
|
|
178
|
+
* `null` and "absent" are deliberately different. An absent file is a first
|
|
179
|
+
* write and starts from an empty document; a file that fails to parse, or that
|
|
180
|
+
* parses to something other than a map, is a file with content this writer does
|
|
181
|
+
* not understand — and overwriting it would replace whatever the user had with
|
|
182
|
+
* one key. The earlier shape returned `{}` for both and did exactly that.
|
|
183
|
+
*/
|
|
184
|
+
function _readSettingsFile(file: string): { text: string; values: Record<string, unknown> } | null | 'absent' {
|
|
185
|
+
let text: string;
|
|
186
|
+
try {
|
|
187
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
188
|
+
} catch {
|
|
189
|
+
return 'absent';
|
|
190
|
+
}
|
|
191
|
+
// A file holding only comments and blank lines is an empty DOCUMENT, not a
|
|
192
|
+
// broken one — js-yaml throws "expected a document, but the input is empty"
|
|
193
|
+
// on it, which is indistinguishable from a real syntax error at the catch.
|
|
194
|
+
// Decide that case before parsing rather than guessing from the exception.
|
|
195
|
+
if (!text.split('\n').some((line) => line.trim() !== '' && !line.trimStart().startsWith('#'))) {
|
|
196
|
+
return { text, values: {} };
|
|
197
|
+
}
|
|
198
|
+
let parsed: unknown;
|
|
199
|
+
try {
|
|
200
|
+
parsed = yamlLoad(text);
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (parsed === null || parsed === undefined) {
|
|
205
|
+
// An empty or comments-only file is a legitimate starting point.
|
|
206
|
+
return { text, values: {} };
|
|
207
|
+
}
|
|
208
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return { text, values: parsed as Record<string, unknown> };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _readProvenance(file: string): Record<string, ProvenanceEntry> {
|
|
215
|
+
try {
|
|
216
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
217
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
218
|
+
return parsed as Record<string, ProvenanceEntry>;
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// A corrupt sidecar must not block a settings write — provenance is a
|
|
222
|
+
// record ABOUT the decision, never a gate ON it. It is rebuilt from the
|
|
223
|
+
// entries that survive plus the one being written now.
|
|
224
|
+
}
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _writeAtomicSync(target: string, contents: string, mode = 0o600): void {
|
|
229
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
230
|
+
const tmp = `${target}.${String(process.pid)}.tmp`;
|
|
231
|
+
const fd = fs.openSync(tmp, 'w', mode);
|
|
232
|
+
try {
|
|
233
|
+
fs.writeFileSync(fd, contents, 'utf-8');
|
|
234
|
+
fs.fsyncSync(fd);
|
|
235
|
+
} finally {
|
|
236
|
+
fs.closeSync(fd);
|
|
237
|
+
}
|
|
238
|
+
fs.renameSync(tmp, target);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The zod schema for one dotted leaf, or `null` when the path is not in the
|
|
243
|
+
* schema.
|
|
244
|
+
*
|
|
245
|
+
* Validating the LEAF rather than the whole tree is deliberate. `settingsSchema`
|
|
246
|
+
* describes a complete settings document, so a sparse file — which is the shape
|
|
247
|
+
* this roadmap is moving towards — fails it on every section it omits, and the
|
|
248
|
+
* error would name `cost: Required` when the user mistyped a boolean. Walking
|
|
249
|
+
* to the leaf gives the message the caller can act on, and it is the same walk
|
|
250
|
+
* the template↔schema parity test performs.
|
|
251
|
+
*/
|
|
252
|
+
export function leafSchemaAt(schema: z.ZodTypeAny, dotted: string): z.ZodTypeAny | null {
|
|
253
|
+
let current = _unwrap(schema);
|
|
254
|
+
for (const part of dotted.split('.')) {
|
|
255
|
+
if (!(current instanceof z.ZodObject)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const shape = current.shape as Record<string, z.ZodTypeAny>;
|
|
259
|
+
const next = shape[part];
|
|
260
|
+
if (next === undefined) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
current = _unwrap(next);
|
|
264
|
+
}
|
|
265
|
+
return current;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _unwrap(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
269
|
+
let current: z.ZodTypeAny = schema;
|
|
270
|
+
while (
|
|
271
|
+
current instanceof z.ZodDefault ||
|
|
272
|
+
current instanceof z.ZodOptional ||
|
|
273
|
+
current instanceof z.ZodNullable
|
|
274
|
+
) {
|
|
275
|
+
current = (current._def as { innerType: z.ZodTypeAny }).innerType;
|
|
276
|
+
}
|
|
277
|
+
return current;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Load the class index, or `null` when the contract is unreadable.
|
|
282
|
+
*
|
|
283
|
+
* `null` is the fail-closed signal, and the caller must treat it as "refuse
|
|
284
|
+
* every write" rather than "no classes, so nothing is guarded".
|
|
285
|
+
*/
|
|
286
|
+
export function loadClassIndex(packageRoot: string): Map<string, SettingsClass> | null {
|
|
287
|
+
let text: string;
|
|
288
|
+
try {
|
|
289
|
+
text = fs.readFileSync(path.join(packageRoot, CONTRACT_RELATIVE), 'utf-8');
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
const index = buildSettingsClassIndex(parseSettingsClassRows(text));
|
|
294
|
+
return index.size === 0 ? null : index;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The whole command as a pure-ish function, so the tests do not shell out for every case. */
|
|
298
|
+
export function runSettingsSet(opts: SettingsSetOptions): SettingsSetResult {
|
|
299
|
+
const out: string[] = [];
|
|
300
|
+
const err: string[] = [];
|
|
301
|
+
|
|
302
|
+
const classes = loadClassIndex(opts.packageRoot);
|
|
303
|
+
if (classes === null) {
|
|
304
|
+
err.push(
|
|
305
|
+
`❌ settings:set refused — the class contract at ${CONTRACT_RELATIVE} is missing or has no rows.`,
|
|
306
|
+
' Every write is refused while it is unreadable. A fence that opens when its',
|
|
307
|
+
' rulebook goes missing is not a fence.',
|
|
308
|
+
);
|
|
309
|
+
return { code: 1, out, err };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const cls = classes.get(opts.key);
|
|
313
|
+
if (cls === undefined) {
|
|
314
|
+
err.push(
|
|
315
|
+
`❌ settings:set refused — \`${opts.key}\` has no class in ${CONTRACT_RELATIVE}.`,
|
|
316
|
+
' An unclassified key is either a typo or a key that shipped without a fence;',
|
|
317
|
+
' both are refusals, never a silent write.',
|
|
318
|
+
);
|
|
319
|
+
return { code: 1, out, err };
|
|
320
|
+
}
|
|
321
|
+
if (cls === 'C') {
|
|
322
|
+
err.push(
|
|
323
|
+
`❌ settings:set refused — \`${opts.key}\` is class C (guarded).`,
|
|
324
|
+
' C-class keys govern spend, an allow/deny list, a gate, the agent\'s own',
|
|
325
|
+
' authority, what code runs, egress, a credential, or the audit trail.',
|
|
326
|
+
' Change it in the settings GUI or in the file itself — not through an agent.',
|
|
327
|
+
);
|
|
328
|
+
return { code: 1, out, err };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const value = parseScalar(opts.rawValue);
|
|
332
|
+
const file = settingsFilePath(opts.root);
|
|
333
|
+
const existing = _readSettingsFile(file);
|
|
334
|
+
if (existing === null) {
|
|
335
|
+
err.push(
|
|
336
|
+
`❌ settings:set refused — ${file} exists but is not a settings map.`,
|
|
337
|
+
' Refusing rather than replacing it: whatever is in there is the user\'s,',
|
|
338
|
+
' and one key is not worth losing it. Fix the file, then re-run.',
|
|
339
|
+
);
|
|
340
|
+
return { code: 1, out, err };
|
|
341
|
+
}
|
|
342
|
+
const current = existing === 'absent' ? {} : existing.values;
|
|
343
|
+
const before = getSettingsLeaf(current, opts.key);
|
|
344
|
+
|
|
345
|
+
const leafSchema = leafSchemaAt(settingsSchema, opts.key);
|
|
346
|
+
if (leafSchema === null) {
|
|
347
|
+
err.push(
|
|
348
|
+
`❌ settings:set refused — \`${opts.key}\` is classified but absent from the settings schema.`,
|
|
349
|
+
' The contract and the schema disagree; lint_settings_classes and the',
|
|
350
|
+
' template↔schema parity test are the two gates that should have caught it.',
|
|
351
|
+
);
|
|
352
|
+
return { code: 1, out, err };
|
|
353
|
+
}
|
|
354
|
+
const parsedLeaf = leafSchema.safeParse(value);
|
|
355
|
+
if (!parsedLeaf.success) {
|
|
356
|
+
err.push(`❌ settings:set refused — \`${opts.key}\` = ${JSON.stringify(value)} fails the settings schema.`);
|
|
357
|
+
for (const issue of parsedLeaf.error.issues.slice(0, 3)) {
|
|
358
|
+
err.push(` ${issue.message}`);
|
|
359
|
+
}
|
|
360
|
+
return { code: 1, out, err };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const candidate: Record<string, unknown> = structuredClone(current);
|
|
364
|
+
_setDotted(candidate, opts.key, value);
|
|
365
|
+
|
|
366
|
+
if (JSON.stringify(before) === JSON.stringify(value)) {
|
|
367
|
+
out.push(`✅ settings:set — \`${opts.key}\` is already ${JSON.stringify(value)}; nothing written.`);
|
|
368
|
+
return { code: 0, out, err };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (opts.dryRun) {
|
|
372
|
+
out.push(
|
|
373
|
+
`↪ settings:set --dry-run — would set \`${opts.key}\` = ${JSON.stringify(value)} ` +
|
|
374
|
+
`(was ${JSON.stringify(before ?? null)}) in ${file}`,
|
|
375
|
+
);
|
|
376
|
+
return { code: 0, out, err };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const provenanceFile = provenanceFilePath(opts.root);
|
|
380
|
+
const provenance = _readProvenance(provenanceFile);
|
|
381
|
+
provenance[opts.key] = { source: opts.source, at: opts.now };
|
|
382
|
+
|
|
383
|
+
// Comment-preserving where a document exists to preserve. `yamlIO` opens by
|
|
384
|
+
// stating that the template's comments explain every key and MUST NOT be
|
|
385
|
+
// discarded, and the file this writes to IS that commented template — a
|
|
386
|
+
// dump-based write strips ~1,200 lines of explanation to set one boolean.
|
|
387
|
+
//
|
|
388
|
+
// `mergeIntoTemplate` replaces the scalar on its existing line and, when the
|
|
389
|
+
// path is NOT in the document, appends it as a flat dotted key. That flat
|
|
390
|
+
// form does not read back as the nested value, so the merge is verified
|
|
391
|
+
// rather than trusted: if the round-trip does not produce the value that was
|
|
392
|
+
// asked for, fall back to a structured dump and SAY that the comments were
|
|
393
|
+
// rewritten. Silently emitting a key the next read cannot see would be worse
|
|
394
|
+
// than losing comments, and silently losing comments would be worse than
|
|
395
|
+
// saying so.
|
|
396
|
+
let body: string;
|
|
397
|
+
let commentsRewritten = false;
|
|
398
|
+
if (existing === 'absent') {
|
|
399
|
+
body = `${SPARSE_FILE_HEADER}${yamlDump(candidate, { lineWidth: 100, noRefs: true })}`;
|
|
400
|
+
} else {
|
|
401
|
+
body = mergeIntoTemplate(existing.text, candidate);
|
|
402
|
+
let merged: unknown;
|
|
403
|
+
try {
|
|
404
|
+
merged = yamlLoad(body);
|
|
405
|
+
} catch {
|
|
406
|
+
merged = undefined;
|
|
407
|
+
}
|
|
408
|
+
if (JSON.stringify(getSettingsLeaf(merged, opts.key)) !== JSON.stringify(value)) {
|
|
409
|
+
body = `${SPARSE_FILE_HEADER}${yamlDump(candidate, { lineWidth: 100, noRefs: true })}`;
|
|
410
|
+
commentsRewritten = true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
_writeAtomicSync(file, body);
|
|
414
|
+
_writeAtomicSync(provenanceFile, `${JSON.stringify(provenance, null, 4)}\n`);
|
|
415
|
+
|
|
416
|
+
// One loud line per write. A settings change the user never saw scroll past
|
|
417
|
+
// is a settings change they cannot audit, and class A/B writes are exactly
|
|
418
|
+
// the ones nobody was asked about.
|
|
419
|
+
out.push(
|
|
420
|
+
`✅ settings:set — \`${opts.key}\` = ${JSON.stringify(value)} ` +
|
|
421
|
+
`(was ${JSON.stringify(before ?? null)}) · class ${cls} · source ${opts.source} · ${file}`,
|
|
422
|
+
);
|
|
423
|
+
if (commentsRewritten) {
|
|
424
|
+
out.push(
|
|
425
|
+
`⚠️ \`${opts.key}\` had no line in that file, so it was rewritten from its parsed ` +
|
|
426
|
+
'values and the comments are gone. Values are unchanged.',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
return { code: 0, out, err };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
interface ParsedArgv {
|
|
433
|
+
ok: boolean;
|
|
434
|
+
message?: string;
|
|
435
|
+
key?: string;
|
|
436
|
+
rawValue?: string;
|
|
437
|
+
source?: ProvenanceSource;
|
|
438
|
+
dryRun?: boolean;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function parseArgv(argv: readonly string[]): ParsedArgv {
|
|
442
|
+
const positional: string[] = [];
|
|
443
|
+
let source: ProvenanceSource = 'manual';
|
|
444
|
+
let dryRun = false;
|
|
445
|
+
for (let i = 0; i < argv.length; i++) {
|
|
446
|
+
const a = argv[i] as string;
|
|
447
|
+
if (a === '--dry-run') {
|
|
448
|
+
dryRun = true;
|
|
449
|
+
} else if (a === '--source') {
|
|
450
|
+
const v = argv[++i];
|
|
451
|
+
if (v === undefined || !(PROVENANCE_SOURCES as readonly string[]).includes(v)) {
|
|
452
|
+
return { ok: false, message: `--source must be one of: ${PROVENANCE_SOURCES.join(' | ')}` };
|
|
453
|
+
}
|
|
454
|
+
source = v as ProvenanceSource;
|
|
455
|
+
} else if (a === '-h' || a === '--help') {
|
|
456
|
+
return { ok: false, message: 'usage: agent-config settings:set <key> <value> [--source auto-detected|jit-answer|manual|gui] [--dry-run]' };
|
|
457
|
+
} else if (a.startsWith('--')) {
|
|
458
|
+
return { ok: false, message: `unknown flag: ${a}` };
|
|
459
|
+
} else {
|
|
460
|
+
positional.push(a);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const [key, rawValue] = positional;
|
|
464
|
+
if (key === undefined || rawValue === undefined || positional.length !== 2) {
|
|
465
|
+
return { ok: false, message: 'usage: agent-config settings:set <key> <value> [--source …] [--dry-run]' };
|
|
466
|
+
}
|
|
467
|
+
return { ok: true, key, rawValue, source, dryRun };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
471
|
+
const parsed = parseArgv(argv);
|
|
472
|
+
if (!parsed.ok) {
|
|
473
|
+
process.stderr.write(`${parsed.message ?? 'usage error'}\n`);
|
|
474
|
+
return 2;
|
|
475
|
+
}
|
|
476
|
+
const result = runSettingsSet({
|
|
477
|
+
key: parsed.key as string,
|
|
478
|
+
rawValue: parsed.rawValue as string,
|
|
479
|
+
source: parsed.source as ProvenanceSource,
|
|
480
|
+
root: globalRoot(),
|
|
481
|
+
packageRoot: PACKAGE_ROOT,
|
|
482
|
+
now: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
483
|
+
dryRun: parsed.dryRun === true,
|
|
484
|
+
});
|
|
485
|
+
for (const line of result.out) process.stdout.write(`${line}\n`);
|
|
486
|
+
for (const line of result.err) process.stderr.write(`${line}\n`);
|
|
487
|
+
return result.code;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function _isCliEntry(): boolean {
|
|
491
|
+
if (process.argv[1] === undefined) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
495
|
+
if (import.meta.url === argvUrl) {
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (_isCliEntry()) {
|
|
506
|
+
process.exitCode = main();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export { CONTRACT_RELATIVE, PACKAGE_ROOT, PROVENANCE_SOURCES };
|
|
@@ -194,6 +194,10 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
194
194
|
~/.event4u/agent-config/ (the global-only consumer surface,
|
|
195
195
|
ADR-020). Idempotent; --force overwrites a non-empty global
|
|
196
196
|
file, --dry-run lists intended copies with zero writes.
|
|
197
|
+
settings:set Set one setting in the global file. Refuses every class-C
|
|
198
|
+
(guarded) key from docs/contracts/settings-classes.md, and
|
|
199
|
+
refuses everything when that contract is unreadable.
|
|
200
|
+
--source auto-detected|jit-answer|manual|gui, --dry-run.
|
|
197
201
|
hooks:install Install the combined pre-commit hook (roadmap-progress
|
|
198
202
|
+ ADR-013 artefact frontmatter lint).
|
|
199
203
|
(use --print to dump it, --force to overwrite an existing hook)
|
|
@@ -1130,6 +1134,17 @@ cmd_settings_migrate() {
|
|
|
1130
1134
|
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_settings_migrate.ts" "$@"
|
|
1131
1135
|
}
|
|
1132
1136
|
|
|
1137
|
+
# `agent-config settings:set <key> <value>` — the one agent-reachable settings
|
|
1138
|
+
# writer. Refuses every C-class key from docs/contracts/settings-classes.md,
|
|
1139
|
+
# fails closed when that contract is unreadable, zod-validates before writing,
|
|
1140
|
+
# writes atomically into ~/.event4u/agent-config/settings/.agent-settings.yml,
|
|
1141
|
+
# and stamps source+timestamp into the .agent-settings.provenance.json sidecar.
|
|
1142
|
+
# Flags: --source auto-detected|jit-answer|manual|gui | --dry-run.
|
|
1143
|
+
# Exit 0 written / no-op, 1 refused, 2 usage.
|
|
1144
|
+
cmd_settings_set() {
|
|
1145
|
+
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_settings_set.ts" "$@"
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1133
1148
|
# `agent-config uninstall` — remove bridge markers (project) or lockfile
|
|
1134
1149
|
# entries (global). Idempotent. Pass `--purge` to also delete deployed
|
|
1135
1150
|
# content directories under user-scope anchors (destructive). See
|
|
@@ -1278,6 +1293,7 @@ main() {
|
|
|
1278
1293
|
settings:check) cmd_settings_check "$@" ;;
|
|
1279
1294
|
settings:sync) cmd_settings_sync "$@" ;;
|
|
1280
1295
|
settings:migrate) cmd_settings_migrate "$@" ;;
|
|
1296
|
+
settings:set) cmd_settings_set "$@" ;;
|
|
1281
1297
|
uninstall) cmd_uninstall "$@" ;;
|
|
1282
1298
|
prune) cmd_prune "$@" ;;
|
|
1283
1299
|
doctor) cmd_doctor "$@" ;;
|