@workflow-cannon/workspace-kit 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,7 @@ This keeps automation adaptive without sacrificing safety, governance, or develo
63
63
 
64
64
  - **Phase 0** and **Phase 1** (task engine, `v0.3.0`) are complete.
65
65
  - **Phase 2** (layered config, policy gates, cutover docs, `v0.4.0`) is complete in-repo; see `docs/maintainers/TASKS.md` and `docs/maintainers/ROADMAP.md`.
66
- - **Phase 3** (enhancement loop MVP, `v0.5.0`) is next (`T190` onward).
66
+ - **Phase 2b** (policy + config UX, `v0.4.1`) is complete in-repo. **Phase 3** (enhancement loop MVP, `v0.5.0`) is next (`T190` onward).
67
67
 
68
68
  ## Goals
69
69
 
package/dist/cli.js CHANGED
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { ModuleRegistry } from "./core/module-registry.js";
6
6
  import { ModuleCommandRouter } from "./core/module-command-router.js";
7
- import { appendPolicyTrace, getOperationIdForCommand, isSensitiveModuleCommand, parsePolicyApproval, parsePolicyApprovalFromEnv, resolveActor } from "./core/policy.js";
7
+ import { appendPolicyTrace, isSensitiveModuleCommandForEffective, parsePolicyApproval, parsePolicyApprovalFromEnv, resolveActor, resolvePolicyOperationIdForCommand } from "./core/policy.js";
8
+ import { runWorkspaceConfigCli } from "./core/config-cli.js";
8
9
  import { resolveWorkspaceConfigWithLayers } from "./core/workspace-kit-config.js";
9
10
  import { documentationModule } from "./modules/documentation/index.js";
10
11
  import { taskEngineModule } from "./modules/task-engine/index.js";
@@ -342,9 +343,12 @@ export async function runCli(args, options = {}) {
342
343
  const writeError = options.writeError ?? console.error;
343
344
  const [command] = args;
344
345
  if (!command) {
345
- writeError("Usage: workspace-kit <init|doctor|check|upgrade|drift-check|run>");
346
+ writeError("Usage: workspace-kit <init|doctor|check|upgrade|drift-check|run|config>");
346
347
  return EXIT_USAGE_ERROR;
347
348
  }
349
+ if (command === "config") {
350
+ return runWorkspaceConfigCli(cwd, args.slice(1), { writeLine, writeError });
351
+ }
348
352
  if (command === "init") {
349
353
  const approval = await requireCliPolicyApproval(cwd, "cli.init", "init", writeError);
350
354
  if (!approval) {
@@ -634,11 +638,11 @@ export async function runCli(args, options = {}) {
634
638
  return EXIT_VALIDATION_FAILURE;
635
639
  }
636
640
  const actor = resolveActor(cwd, commandArgs, process.env);
637
- const sensitive = isSensitiveModuleCommand(subcommand, commandArgs);
641
+ const sensitive = isSensitiveModuleCommandForEffective(subcommand, commandArgs, effective);
638
642
  if (sensitive) {
639
643
  const approval = parsePolicyApproval(commandArgs);
640
644
  if (!approval) {
641
- const op = getOperationIdForCommand(subcommand);
645
+ const op = resolvePolicyOperationIdForCommand(subcommand, effective);
642
646
  if (op) {
643
647
  await appendPolicyTrace(cwd, {
644
648
  timestamp: new Date().toISOString(),
@@ -668,7 +672,7 @@ export async function runCli(args, options = {}) {
668
672
  const result = await router.execute(subcommand, commandArgs, ctx);
669
673
  if (sensitive) {
670
674
  const approval = parsePolicyApproval(commandArgs);
671
- const op = getOperationIdForCommand(subcommand);
675
+ const op = resolvePolicyOperationIdForCommand(subcommand, effective);
672
676
  if (approval && op) {
673
677
  await appendPolicyTrace(cwd, {
674
678
  timestamp: new Date().toISOString(),
@@ -692,7 +696,7 @@ export async function runCli(args, options = {}) {
692
696
  }
693
697
  }
694
698
  if (command !== "doctor") {
695
- writeError(`Unknown command '${command}'. Supported commands: init, doctor, check, upgrade, drift-check, run.`);
699
+ writeError(`Unknown command '${command}'. Supported commands: init, doctor, check, upgrade, drift-check, run, config.`);
696
700
  return EXIT_USAGE_ERROR;
697
701
  }
698
702
  const issues = [];
@@ -0,0 +1,6 @@
1
+ export type ConfigCliIo = {
2
+ writeLine: (s: string) => void;
3
+ writeError: (s: string) => void;
4
+ };
5
+ export declare function generateConfigReferenceDocs(workspacePath: string): Promise<void>;
6
+ export declare function runWorkspaceConfigCli(cwd: string, argv: string[], io: ConfigCliIo): Promise<number>;
@@ -0,0 +1,479 @@
1
+ import fs from "node:fs/promises";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin as processStdin, stdout as processStdout } from "node:process";
4
+ import path from "node:path";
5
+ import { ModuleRegistry } from "./module-registry.js";
6
+ import { appendPolicyTrace, parsePolicyApprovalFromEnv, resolveActor } from "./policy.js";
7
+ import { appendConfigMutation, summarizeForEvidence } from "./config-mutations.js";
8
+ import { assertWritableKey, getConfigKeyMetadata, listConfigMetadata, validatePersistedConfigDocument, validateValueForMetadata } from "./config-metadata.js";
9
+ import { explainConfigPath, getAtPath, getProjectConfigPath, getUserConfigFilePath, resolveWorkspaceConfigWithLayers, stableStringifyConfig } from "./workspace-kit-config.js";
10
+ import { workspaceConfigModule } from "../modules/workspace-config/index.js";
11
+ import { documentationModule } from "../modules/documentation/index.js";
12
+ import { taskEngineModule } from "../modules/task-engine/index.js";
13
+ import { approvalsModule } from "../modules/approvals/index.js";
14
+ import { planningModule } from "../modules/planning/index.js";
15
+ import { improvementModule } from "../modules/improvement/index.js";
16
+ const EXIT_SUCCESS = 0;
17
+ const EXIT_VALIDATION_FAILURE = 1;
18
+ const EXIT_USAGE_ERROR = 2;
19
+ const EXIT_INTERNAL_ERROR = 3;
20
+ function cloneCfg(obj) {
21
+ return JSON.parse(JSON.stringify(obj));
22
+ }
23
+ function setDeep(root, dotted, value) {
24
+ const out = cloneCfg(root);
25
+ const parts = dotted.split(".").filter(Boolean);
26
+ let cur = out;
27
+ for (let i = 0; i < parts.length - 1; i++) {
28
+ const p = parts[i];
29
+ const next = cur[p];
30
+ if (next === undefined || typeof next !== "object" || Array.isArray(next)) {
31
+ cur[p] = {};
32
+ }
33
+ cur = cur[p];
34
+ }
35
+ cur[parts[parts.length - 1]] = value;
36
+ return out;
37
+ }
38
+ function unsetDeep(root, dotted) {
39
+ const out = cloneCfg(root);
40
+ const parts = dotted.split(".").filter(Boolean);
41
+ if (parts.length === 0)
42
+ return out;
43
+ const parents = [out];
44
+ let cur = out;
45
+ for (let i = 0; i < parts.length - 1; i++) {
46
+ const p = parts[i];
47
+ const next = cur[p];
48
+ if (next === undefined || typeof next !== "object" || Array.isArray(next)) {
49
+ return out;
50
+ }
51
+ cur = next;
52
+ parents.push(cur);
53
+ }
54
+ const leaf = parts[parts.length - 1];
55
+ delete cur[leaf];
56
+ // Prune empty objects bottom-up
57
+ for (let i = parents.length - 1; i >= 1; i--) {
58
+ const childKey = parts[i - 1];
59
+ const parent = parents[i - 1];
60
+ const child = parent[childKey];
61
+ if (child &&
62
+ typeof child === "object" &&
63
+ !Array.isArray(child) &&
64
+ Object.keys(child).length === 0) {
65
+ delete parent[childKey];
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ async function readJsonFileOrEmpty(fp) {
71
+ try {
72
+ const raw = await fs.readFile(fp, "utf8");
73
+ const parsed = JSON.parse(raw);
74
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
75
+ throw new Error(`invalid JSON object: ${fp}`);
76
+ }
77
+ return parsed;
78
+ }
79
+ catch (e) {
80
+ const err = e;
81
+ if (err.code === "ENOENT") {
82
+ return {};
83
+ }
84
+ throw e;
85
+ }
86
+ }
87
+ async function writeConfigFileAtomic(fp, data) {
88
+ await fs.mkdir(path.dirname(fp), { recursive: true });
89
+ const tmp = `${fp}.${process.pid}.${Date.now()}.tmp`;
90
+ await fs.writeFile(tmp, stableStringifyConfig(data), "utf8");
91
+ await fs.rename(tmp, fp);
92
+ }
93
+ function buildRegistry() {
94
+ const allModules = [
95
+ workspaceConfigModule,
96
+ documentationModule,
97
+ taskEngineModule,
98
+ approvalsModule,
99
+ planningModule,
100
+ improvementModule
101
+ ];
102
+ return new ModuleRegistry(allModules);
103
+ }
104
+ function parseConfigArgs(argv) {
105
+ const json = argv.includes("--json");
106
+ const parts = argv.filter((a) => a !== "--json");
107
+ return { json, parts };
108
+ }
109
+ async function requireConfigApproval(cwd, commandLabel, writeError) {
110
+ const approval = parsePolicyApprovalFromEnv(process.env);
111
+ if (!approval) {
112
+ writeError(`${commandLabel} requires WORKSPACE_KIT_POLICY_APPROVAL with JSON {"confirmed":true,"rationale":"..."}.`);
113
+ await appendPolicyTrace(cwd, {
114
+ timestamp: new Date().toISOString(),
115
+ operationId: "cli.config-mutate",
116
+ command: commandLabel,
117
+ actor: resolveActor(cwd, {}, process.env),
118
+ allowed: false,
119
+ message: "missing WORKSPACE_KIT_POLICY_APPROVAL"
120
+ });
121
+ return null;
122
+ }
123
+ return approval;
124
+ }
125
+ export async function generateConfigReferenceDocs(workspacePath) {
126
+ const meta = listConfigMetadata({ exposure: "maintainer" });
127
+ const aiPath = path.join(workspacePath, ".ai", "CONFIG.md");
128
+ const humanPath = path.join(workspacePath, "docs", "maintainers", "CONFIG.md");
129
+ const lines = (audience) => {
130
+ const out = [
131
+ `# Config reference (${audience})`,
132
+ "",
133
+ "Generated from `src/core/config-metadata.ts`. Do not edit by hand; run `workspace-kit config generate-docs`.",
134
+ "",
135
+ "| Key | Type | Default | Scope | Module | Exposure | Sensitive | Approval |",
136
+ "| --- | --- | --- | --- | --- | --- | --- | --- |"
137
+ ];
138
+ for (const m of meta) {
139
+ out.push(`| ${m.key} | ${m.type} | ${JSON.stringify(m.default)} | ${m.domainScope} | ${m.owningModule} | ${m.exposure} | ${m.sensitive} | ${m.requiresApproval} |`);
140
+ out.push("");
141
+ out.push(`**Description:** ${m.description}`);
142
+ out.push("");
143
+ }
144
+ return out;
145
+ };
146
+ await fs.mkdir(path.dirname(aiPath), { recursive: true });
147
+ await fs.mkdir(path.dirname(humanPath), { recursive: true });
148
+ await fs.writeFile(aiPath, lines("ai").join("\n") + "\n", "utf8");
149
+ await fs.writeFile(humanPath, lines("human").join("\n") + "\n", "utf8");
150
+ }
151
+ export async function runWorkspaceConfigCli(cwd, argv, io) {
152
+ const { writeLine, writeError } = io;
153
+ const { json, parts } = parseConfigArgs(argv);
154
+ const sub = parts[0];
155
+ const tail = parts.slice(1);
156
+ if (!sub) {
157
+ writeError("Usage: workspace-kit config <list|get|set|unset|explain|validate|resolve|generate-docs|edit> [--json] ...");
158
+ return EXIT_USAGE_ERROR;
159
+ }
160
+ let registry;
161
+ try {
162
+ registry = buildRegistry();
163
+ }
164
+ catch (e) {
165
+ writeError(e instanceof Error ? e.message : String(e));
166
+ return EXIT_INTERNAL_ERROR;
167
+ }
168
+ const emit = (obj) => {
169
+ writeLine(JSON.stringify(obj, null, json ? 2 : undefined));
170
+ };
171
+ try {
172
+ if (sub === "list") {
173
+ const all = tail.includes("--all");
174
+ const exposure = all ? "maintainer" : "public";
175
+ const rows = listConfigMetadata({ exposure });
176
+ if (json) {
177
+ emit({ ok: true, code: "config-list", data: { keys: rows } });
178
+ }
179
+ else {
180
+ writeLine("Known config keys:");
181
+ for (const r of rows) {
182
+ writeLine(` ${r.key} (${r.type}) — ${r.description}`);
183
+ }
184
+ }
185
+ return EXIT_SUCCESS;
186
+ }
187
+ if (sub === "get") {
188
+ const key = tail[0];
189
+ if (!key) {
190
+ writeError("config get <key>");
191
+ return EXIT_USAGE_ERROR;
192
+ }
193
+ const { effective } = await resolveWorkspaceConfigWithLayers({
194
+ workspacePath: cwd,
195
+ registry
196
+ });
197
+ const val = getAtPath(effective, key);
198
+ if (json) {
199
+ emit({ ok: true, code: "config-get", data: { key, value: val } });
200
+ }
201
+ else {
202
+ writeLine(String(JSON.stringify({ key, value: val }, null, 2)));
203
+ }
204
+ return EXIT_SUCCESS;
205
+ }
206
+ if (sub === "resolve") {
207
+ const { effective } = await resolveWorkspaceConfigWithLayers({
208
+ workspacePath: cwd,
209
+ registry
210
+ });
211
+ writeLine(stableStringifyConfig(effective).trimEnd());
212
+ return EXIT_SUCCESS;
213
+ }
214
+ if (sub === "validate") {
215
+ const projectPath = getProjectConfigPath(cwd);
216
+ const userPath = getUserConfigFilePath();
217
+ const p = await readJsonFileOrEmpty(projectPath);
218
+ const u = await readJsonFileOrEmpty(userPath);
219
+ validatePersistedConfigDocument(p, ".workspace-kit/config.json");
220
+ validatePersistedConfigDocument(u, "user config");
221
+ await resolveWorkspaceConfigWithLayers({ workspacePath: cwd, registry });
222
+ if (json) {
223
+ emit({ ok: true, code: "config-validated", data: { projectPath, userPath } });
224
+ }
225
+ else {
226
+ writeLine("Config validate passed (project + user + merged resolution).");
227
+ }
228
+ return EXIT_SUCCESS;
229
+ }
230
+ if (sub === "explain") {
231
+ const key = tail[0];
232
+ if (!key) {
233
+ writeError("config explain <key>");
234
+ return EXIT_USAGE_ERROR;
235
+ }
236
+ const { layers } = await resolveWorkspaceConfigWithLayers({
237
+ workspacePath: cwd,
238
+ registry
239
+ });
240
+ const explained = explainConfigPath(key, layers);
241
+ const meta = getConfigKeyMetadata(key);
242
+ const data = {
243
+ ...explained,
244
+ metadata: meta ?? null
245
+ };
246
+ if (json) {
247
+ emit({ ok: true, code: "config-explained", data });
248
+ }
249
+ else {
250
+ writeLine(JSON.stringify(data, null, 2));
251
+ }
252
+ return EXIT_SUCCESS;
253
+ }
254
+ if (sub === "generate-docs") {
255
+ await generateConfigReferenceDocs(cwd);
256
+ if (json) {
257
+ emit({ ok: true, code: "config-docs-generated", data: { ai: ".ai/CONFIG.md", human: "docs/maintainers/CONFIG.md" } });
258
+ }
259
+ else {
260
+ writeLine("Wrote .ai/CONFIG.md and docs/maintainers/CONFIG.md");
261
+ }
262
+ return EXIT_SUCCESS;
263
+ }
264
+ if (sub === "set") {
265
+ let scope = "project";
266
+ const args = [...tail];
267
+ if (args[0] === "--scope" && args[1]) {
268
+ scope = args[1];
269
+ if (scope !== "project" && scope !== "user") {
270
+ writeError("--scope must be project or user");
271
+ return EXIT_USAGE_ERROR;
272
+ }
273
+ args.splice(0, 2);
274
+ }
275
+ const key = args[0];
276
+ const jsonLiteral = args[1];
277
+ if (!key || jsonLiteral === undefined) {
278
+ writeError('config set [--scope project|user] <key> <jsonValue> e.g. \'".workspace-kit/tasks/state.json"\'');
279
+ return EXIT_USAGE_ERROR;
280
+ }
281
+ let parsed;
282
+ try {
283
+ parsed = JSON.parse(jsonLiteral);
284
+ }
285
+ catch {
286
+ writeError("config set: jsonValue must be valid JSON");
287
+ return EXIT_USAGE_ERROR;
288
+ }
289
+ const meta = assertWritableKey(key);
290
+ if (!meta.writableLayers.includes(scope)) {
291
+ writeError(`config set: key '${key}' cannot be written to layer '${scope}'`);
292
+ return EXIT_VALIDATION_FAILURE;
293
+ }
294
+ validateValueForMetadata(meta, parsed);
295
+ if (meta.requiresApproval || meta.sensitive) {
296
+ const approval = await requireConfigApproval(cwd, `config set ${key}`, writeError);
297
+ if (!approval)
298
+ return EXIT_VALIDATION_FAILURE;
299
+ }
300
+ const fp = scope === "project" ? getProjectConfigPath(cwd) : getUserConfigFilePath();
301
+ const before = await readJsonFileOrEmpty(fp);
302
+ validatePersistedConfigDocument(before, scope === "project" ? ".workspace-kit/config.json" : "user config");
303
+ const prevVal = getAtPath(before, key);
304
+ const next = setDeep(before, key, parsed);
305
+ validatePersistedConfigDocument(next, scope === "project" ? ".workspace-kit/config.json" : "user config");
306
+ const actor = resolveActor(cwd, {}, process.env);
307
+ try {
308
+ await writeConfigFileAtomic(fp, next);
309
+ await appendConfigMutation(cwd, {
310
+ timestamp: new Date().toISOString(),
311
+ actor,
312
+ key,
313
+ layer: scope,
314
+ operation: "set",
315
+ ok: true,
316
+ previousSummary: summarizeForEvidence(key, meta.sensitive, prevVal),
317
+ nextSummary: summarizeForEvidence(key, meta.sensitive, parsed)
318
+ });
319
+ if (meta.requiresApproval || meta.sensitive) {
320
+ const appr = parsePolicyApprovalFromEnv(process.env);
321
+ await appendPolicyTrace(cwd, {
322
+ timestamp: new Date().toISOString(),
323
+ operationId: "cli.config-mutate",
324
+ command: `config set ${key}`,
325
+ actor,
326
+ allowed: true,
327
+ rationale: appr.rationale,
328
+ commandOk: true
329
+ });
330
+ }
331
+ if (json) {
332
+ emit({ ok: true, code: "config-set", data: { key, scope } });
333
+ }
334
+ else {
335
+ writeLine(`Set ${key} on ${scope} layer.`);
336
+ }
337
+ return EXIT_SUCCESS;
338
+ }
339
+ catch (e) {
340
+ const msg = e instanceof Error ? e.message : String(e);
341
+ await appendConfigMutation(cwd, {
342
+ timestamp: new Date().toISOString(),
343
+ actor,
344
+ key,
345
+ layer: scope,
346
+ operation: "set",
347
+ ok: false,
348
+ code: "config-set-failed",
349
+ message: msg
350
+ });
351
+ writeError(msg);
352
+ return EXIT_VALIDATION_FAILURE;
353
+ }
354
+ }
355
+ if (sub === "unset") {
356
+ let scope = "project";
357
+ const args = [...tail];
358
+ if (args[0] === "--scope" && args[1]) {
359
+ scope = args[1];
360
+ args.splice(0, 2);
361
+ }
362
+ const key = args[0];
363
+ if (!key) {
364
+ writeError("config unset [--scope project|user] <key>");
365
+ return EXIT_USAGE_ERROR;
366
+ }
367
+ const meta = assertWritableKey(key);
368
+ if (!meta.writableLayers.includes(scope)) {
369
+ writeError(`config unset: key '${key}' not in layer '${scope}'`);
370
+ return EXIT_VALIDATION_FAILURE;
371
+ }
372
+ if (meta.requiresApproval || meta.sensitive) {
373
+ const approval = await requireConfigApproval(cwd, `config unset ${key}`, writeError);
374
+ if (!approval)
375
+ return EXIT_VALIDATION_FAILURE;
376
+ }
377
+ const fp = scope === "project" ? getProjectConfigPath(cwd) : getUserConfigFilePath();
378
+ const before = await readJsonFileOrEmpty(fp);
379
+ validatePersistedConfigDocument(before, scope === "project" ? ".workspace-kit/config.json" : "user config");
380
+ const prevVal = getAtPath(before, key);
381
+ const next = unsetDeep(before, key);
382
+ validatePersistedConfigDocument(next, scope === "project" ? ".workspace-kit/config.json" : "user config");
383
+ const actor = resolveActor(cwd, {}, process.env);
384
+ try {
385
+ if (Object.keys(next).length === 0) {
386
+ await fs.rm(fp, { force: true });
387
+ }
388
+ else {
389
+ await writeConfigFileAtomic(fp, next);
390
+ }
391
+ await appendConfigMutation(cwd, {
392
+ timestamp: new Date().toISOString(),
393
+ actor,
394
+ key,
395
+ layer: scope,
396
+ operation: "unset",
397
+ ok: true,
398
+ previousSummary: summarizeForEvidence(key, meta.sensitive, prevVal),
399
+ nextSummary: summarizeForEvidence(key, meta.sensitive, undefined)
400
+ });
401
+ if (meta.requiresApproval || meta.sensitive) {
402
+ const appr = parsePolicyApprovalFromEnv(process.env);
403
+ await appendPolicyTrace(cwd, {
404
+ timestamp: new Date().toISOString(),
405
+ operationId: "cli.config-mutate",
406
+ command: `config unset ${key}`,
407
+ actor,
408
+ allowed: true,
409
+ rationale: appr.rationale,
410
+ commandOk: true
411
+ });
412
+ }
413
+ if (json) {
414
+ emit({ ok: true, code: "config-unset", data: { key, scope } });
415
+ }
416
+ else {
417
+ writeLine(`Unset ${key} on ${scope} layer.`);
418
+ }
419
+ return EXIT_SUCCESS;
420
+ }
421
+ catch (e) {
422
+ const msg = e instanceof Error ? e.message : String(e);
423
+ await appendConfigMutation(cwd, {
424
+ timestamp: new Date().toISOString(),
425
+ actor,
426
+ key,
427
+ layer: scope,
428
+ operation: "unset",
429
+ ok: false,
430
+ message: msg
431
+ });
432
+ writeError(msg);
433
+ return EXIT_VALIDATION_FAILURE;
434
+ }
435
+ }
436
+ if (sub === "edit") {
437
+ if (!processStdin.isTTY) {
438
+ writeError("config edit requires an interactive TTY");
439
+ return EXIT_USAGE_ERROR;
440
+ }
441
+ const metaList = listConfigMetadata({ exposure: "public" });
442
+ writeLine("Select a key (number):");
443
+ metaList.forEach((m, i) => writeLine(` ${i + 1}. ${m.key} — ${m.description}`));
444
+ const rl = createInterface({ input: processStdin, output: processStdout });
445
+ const choice = await rl.question("> ");
446
+ rl.close();
447
+ const n = Number.parseInt(choice.trim(), 10);
448
+ if (!Number.isFinite(n) || n < 1 || n > metaList.length) {
449
+ writeError("Invalid selection");
450
+ return EXIT_USAGE_ERROR;
451
+ }
452
+ const meta = metaList[n - 1];
453
+ const { effective } = await resolveWorkspaceConfigWithLayers({ workspacePath: cwd, registry });
454
+ const current = getAtPath(effective, meta.key);
455
+ writeLine(`Current: ${JSON.stringify(current)} Default: ${JSON.stringify(meta.default)}`);
456
+ const rl2 = createInterface({ input: processStdin, output: processStdout });
457
+ const raw = await rl2.question("New JSON value: ");
458
+ rl2.close();
459
+ let parsed;
460
+ try {
461
+ parsed = JSON.parse(raw);
462
+ }
463
+ catch {
464
+ writeError("Invalid JSON");
465
+ return EXIT_USAGE_ERROR;
466
+ }
467
+ validateValueForMetadata(meta, parsed);
468
+ const scope = meta.writableLayers.includes("project") ? "project" : "user";
469
+ return runWorkspaceConfigCli(cwd, ["set", "--scope", scope, meta.key, JSON.stringify(parsed)], io);
470
+ }
471
+ }
472
+ catch (e) {
473
+ const msg = e instanceof Error ? e.message : String(e);
474
+ writeError(msg);
475
+ return EXIT_VALIDATION_FAILURE;
476
+ }
477
+ writeError(`Unknown config subcommand '${sub}'`);
478
+ return EXIT_USAGE_ERROR;
479
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Canonical metadata for user-facing workspace config keys (Phase 2b).
3
+ * CLI, explain, and generated docs consume this registry.
4
+ */
5
+ export type ConfigKeyExposure = "public" | "maintainer" | "internal";
6
+ export type ConfigValueType = "string" | "boolean" | "number" | "object" | "array";
7
+ export type ConfigKeyMetadata = {
8
+ key: string;
9
+ type: ConfigValueType;
10
+ description: string;
11
+ default: unknown;
12
+ /** If set, value must equal one of these (after type coercion). */
13
+ allowedValues?: unknown[];
14
+ domainScope: "project" | "user" | "runtime" | "internal";
15
+ owningModule: string;
16
+ sensitive: boolean;
17
+ requiresRestart: boolean;
18
+ requiresApproval: boolean;
19
+ exposure: ConfigKeyExposure;
20
+ /** Persisted layers that may store this key */
21
+ writableLayers: ("project" | "user")[];
22
+ };
23
+ declare const REGISTRY: Record<string, ConfigKeyMetadata>;
24
+ export declare function getConfigKeyMetadata(key: string): ConfigKeyMetadata | undefined;
25
+ export declare function listConfigMetadata(options?: {
26
+ exposure?: "public" | "maintainer" | "internal" | "all";
27
+ }): ConfigKeyMetadata[];
28
+ export declare function assertWritableKey(key: string): ConfigKeyMetadata;
29
+ export declare function validateValueForMetadata(meta: ConfigKeyMetadata, value: unknown): void;
30
+ /**
31
+ * Validate top-level shape of persisted kit config files (strict unknown-key rejection).
32
+ */
33
+ export declare function validatePersistedConfigDocument(data: Record<string, unknown>, label: string): void;
34
+ export declare function getConfigRegistryExport(): typeof REGISTRY;
35
+ export {};
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Canonical metadata for user-facing workspace config keys (Phase 2b).
3
+ * CLI, explain, and generated docs consume this registry.
4
+ */
5
+ const REGISTRY = {
6
+ "tasks.storeRelativePath": {
7
+ key: "tasks.storeRelativePath",
8
+ type: "string",
9
+ description: "Relative path (from workspace root) to the task engine JSON state file.",
10
+ default: ".workspace-kit/tasks/state.json",
11
+ domainScope: "project",
12
+ owningModule: "task-engine",
13
+ sensitive: false,
14
+ requiresRestart: false,
15
+ requiresApproval: false,
16
+ exposure: "public",
17
+ writableLayers: ["project", "user"]
18
+ },
19
+ "policy.extraSensitiveModuleCommands": {
20
+ key: "policy.extraSensitiveModuleCommands",
21
+ type: "array",
22
+ description: "Additional module command names (e.g. run subcommands) treated as sensitive for policy approval.",
23
+ default: [],
24
+ domainScope: "project",
25
+ owningModule: "workspace-kit",
26
+ sensitive: true,
27
+ requiresRestart: false,
28
+ requiresApproval: true,
29
+ exposure: "maintainer",
30
+ writableLayers: ["project"]
31
+ }
32
+ };
33
+ export function getConfigKeyMetadata(key) {
34
+ return REGISTRY[key];
35
+ }
36
+ export function listConfigMetadata(options) {
37
+ const exposure = options?.exposure ?? "public";
38
+ const order = ["public", "maintainer", "internal"];
39
+ const maxIdx = exposure === "all" ? 2 : order.indexOf(exposure);
40
+ const allowed = maxIdx < 0 ? new Set(["public"]) : new Set(order.slice(0, maxIdx + 1));
41
+ return Object.values(REGISTRY)
42
+ .filter((m) => allowed.has(m.exposure))
43
+ .sort((a, b) => a.key.localeCompare(b.key));
44
+ }
45
+ export function assertWritableKey(key) {
46
+ const meta = REGISTRY[key];
47
+ if (!meta) {
48
+ const err = new Error(`config-unknown-key: '${key}' is not a registered config key`);
49
+ err.code = "config-unknown-key";
50
+ throw err;
51
+ }
52
+ if (meta.exposure === "internal") {
53
+ const err = new Error(`config-internal-key: '${key}' is internal and not user-writable`);
54
+ err.code = "config-internal-key";
55
+ throw err;
56
+ }
57
+ return meta;
58
+ }
59
+ export function validateValueForMetadata(meta, value) {
60
+ if (meta.type === "array") {
61
+ if (!Array.isArray(value)) {
62
+ throw typeError(meta.key, "array", value);
63
+ }
64
+ if (meta.key === "policy.extraSensitiveModuleCommands") {
65
+ for (const item of value) {
66
+ if (typeof item !== "string" || item.trim().length === 0) {
67
+ throw new Error(`config-type-error(${meta.key}): array entries must be non-empty strings`);
68
+ }
69
+ }
70
+ }
71
+ return;
72
+ }
73
+ if (meta.type === "string" && typeof value !== "string") {
74
+ throw typeError(meta.key, "string", value);
75
+ }
76
+ if (meta.type === "boolean" && typeof value !== "boolean") {
77
+ throw typeError(meta.key, "boolean", value);
78
+ }
79
+ if (meta.type === "number" && typeof value !== "number") {
80
+ throw typeError(meta.key, "number", value);
81
+ }
82
+ if (meta.type === "object") {
83
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
84
+ throw typeError(meta.key, "object", value);
85
+ }
86
+ }
87
+ if (meta.allowedValues && meta.allowedValues.length > 0) {
88
+ if (!meta.allowedValues.some((v) => deepEqualLoose(v, value))) {
89
+ throw new Error(`config-constraint(${meta.key}): value not in allowed set: ${JSON.stringify(meta.allowedValues)}`);
90
+ }
91
+ }
92
+ }
93
+ function typeError(key, expected, got) {
94
+ return new Error(`config-type-error(${key}): expected ${expected}, got ${got === null ? "null" : typeof got}`);
95
+ }
96
+ function deepEqualLoose(a, b) {
97
+ if (a === b)
98
+ return true;
99
+ return JSON.stringify(a) === JSON.stringify(b);
100
+ }
101
+ /**
102
+ * Validate top-level shape of persisted kit config files (strict unknown-key rejection).
103
+ */
104
+ export function validatePersistedConfigDocument(data, label) {
105
+ const allowed = new Set(["schemaVersion", "core", "tasks", "documentation", "policy", "modules"]);
106
+ for (const k of Object.keys(data)) {
107
+ if (!allowed.has(k)) {
108
+ throw new Error(`config-invalid(${label}): unknown top-level key '${k}'`);
109
+ }
110
+ }
111
+ if (data.schemaVersion !== undefined && typeof data.schemaVersion !== "number") {
112
+ throw new Error(`config-invalid(${label}): schemaVersion must be a number`);
113
+ }
114
+ const tasks = data.tasks;
115
+ if (tasks !== undefined) {
116
+ if (typeof tasks !== "object" || tasks === null || Array.isArray(tasks)) {
117
+ throw new Error(`config-invalid(${label}): tasks must be an object`);
118
+ }
119
+ const t = tasks;
120
+ for (const k of Object.keys(t)) {
121
+ if (k !== "storeRelativePath") {
122
+ throw new Error(`config-invalid(${label}): unknown tasks.${k}`);
123
+ }
124
+ }
125
+ }
126
+ const policy = data.policy;
127
+ if (policy !== undefined) {
128
+ if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
129
+ throw new Error(`config-invalid(${label}): policy must be an object`);
130
+ }
131
+ const p = policy;
132
+ for (const k of Object.keys(p)) {
133
+ if (k !== "extraSensitiveModuleCommands") {
134
+ throw new Error(`config-invalid(${label}): unknown policy.${k}`);
135
+ }
136
+ }
137
+ if (p.extraSensitiveModuleCommands !== undefined) {
138
+ validateValueForMetadata(REGISTRY["policy.extraSensitiveModuleCommands"], p.extraSensitiveModuleCommands);
139
+ }
140
+ }
141
+ const core = data.core;
142
+ if (core !== undefined) {
143
+ if (typeof core !== "object" || core === null || Array.isArray(core) || Object.keys(core).length > 0) {
144
+ throw new Error(`config-invalid(${label}): core must be an empty object when present`);
145
+ }
146
+ }
147
+ const doc = data.documentation;
148
+ if (doc !== undefined) {
149
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc) || Object.keys(doc).length > 0) {
150
+ throw new Error(`config-invalid(${label}): documentation must be an empty object when present`);
151
+ }
152
+ }
153
+ const mods = data.modules;
154
+ if (mods !== undefined) {
155
+ if (typeof mods !== "object" || mods === null || Array.isArray(mods) || Object.keys(mods).length > 0) {
156
+ throw new Error(`config-invalid(${label}): modules must be absent or an empty object`);
157
+ }
158
+ }
159
+ }
160
+ export function getConfigRegistryExport() {
161
+ return REGISTRY;
162
+ }
@@ -0,0 +1,18 @@
1
+ export declare const CONFIG_MUTATIONS_SCHEMA_VERSION: 1;
2
+ export type ConfigMutationRecord = {
3
+ schemaVersion: typeof CONFIG_MUTATIONS_SCHEMA_VERSION;
4
+ timestamp: string;
5
+ actor: string;
6
+ key: string;
7
+ layer: "project" | "user";
8
+ operation: "set" | "unset";
9
+ ok: boolean;
10
+ code?: string;
11
+ message?: string;
12
+ previousSummary?: string;
13
+ nextSummary?: string;
14
+ };
15
+ export declare function appendConfigMutation(workspacePath: string, record: Omit<ConfigMutationRecord, "schemaVersion"> & {
16
+ schemaVersion?: number;
17
+ }): Promise<void>;
18
+ export declare function summarizeForEvidence(key: string, sensitive: boolean, value: unknown): string;
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const CONFIG_MUTATIONS_SCHEMA_VERSION = 1;
4
+ const CONFIG_DIR = ".workspace-kit/config";
5
+ const MUTATIONS_FILE = "mutations.jsonl";
6
+ function summarizeValue(key, value) {
7
+ if (value === undefined)
8
+ return "(unset)";
9
+ if (key === "policy.extraSensitiveModuleCommands" && Array.isArray(value)) {
10
+ return `array(len=${value.length})`;
11
+ }
12
+ if (typeof value === "string") {
13
+ return `string(len=${value.length})`;
14
+ }
15
+ return typeof value;
16
+ }
17
+ export async function appendConfigMutation(workspacePath, record) {
18
+ const dir = path.join(workspacePath, CONFIG_DIR);
19
+ const fp = path.join(dir, MUTATIONS_FILE);
20
+ const line = `${JSON.stringify({
21
+ schemaVersion: CONFIG_MUTATIONS_SCHEMA_VERSION,
22
+ ...record
23
+ })}\n`;
24
+ await fs.mkdir(dir, { recursive: true });
25
+ await fs.appendFile(fp, line, "utf8");
26
+ }
27
+ export function summarizeForEvidence(key, sensitive, value) {
28
+ if (sensitive) {
29
+ return value === undefined ? "(unset)" : "(redacted)";
30
+ }
31
+ return summarizeValue(key, value);
32
+ }
@@ -1,5 +1,8 @@
1
1
  export { ModuleRegistry, ModuleRegistryError, validateModuleSet, type ModuleRegistryOptions } from "./module-registry.js";
2
2
  export { ModuleCommandRouter, ModuleCommandRouterError, type ModuleCommandDescriptor, type ModuleCommandRouterOptions } from "./module-command-router.js";
3
- export { buildBaseConfigLayers, deepMerge, envToConfigOverlay, explainConfigPath, getAtPath, KIT_CONFIG_DEFAULTS, mergeConfigLayers, MODULE_CONFIG_CONTRIBUTIONS, resolveWorkspaceConfigWithLayers, type ConfigLayer, type ConfigLayerId, type EffectiveWorkspaceConfig, type ExplainConfigResult, type ResolveWorkspaceConfigOptions } from "./workspace-kit-config.js";
4
- export { appendPolicyTrace, getOperationIdForCommand, isSensitiveModuleCommand, parsePolicyApproval, parsePolicyApprovalFromEnv, resolveActor, type PolicyOperationId, type PolicyTraceRecord } from "./policy.js";
3
+ export { buildBaseConfigLayers, deepMerge, envToConfigOverlay, explainConfigPath, getAtPath, getProjectConfigPath, getUserConfigFilePath, KIT_CONFIG_DEFAULTS, loadUserLayer, mergeConfigLayers, MODULE_CONFIG_CONTRIBUTIONS, normalizeConfigForExport, PROJECT_CONFIG_REL, resolveWorkspaceConfigWithLayers, stableStringifyConfig, type ConfigLayer, type ConfigLayerId, type EffectiveWorkspaceConfig, type ExplainConfigResult, type ResolveWorkspaceConfigOptions } from "./workspace-kit-config.js";
4
+ export { appendPolicyTrace, getExtraSensitiveModuleCommandsFromEffective, getOperationIdForCommand, isSensitiveModuleCommand, isSensitiveModuleCommandForEffective, parsePolicyApproval, parsePolicyApprovalFromEnv, POLICY_TRACE_SCHEMA_VERSION, resolveActor, resolvePolicyOperationIdForCommand, type PolicyOperationId, type PolicyTraceRecord, type PolicyTraceRecordInput } from "./policy.js";
5
+ export { assertWritableKey, getConfigKeyMetadata, getConfigRegistryExport, listConfigMetadata, validatePersistedConfigDocument, validateValueForMetadata, type ConfigKeyExposure, type ConfigKeyMetadata, type ConfigValueType } from "./config-metadata.js";
6
+ export { appendConfigMutation, CONFIG_MUTATIONS_SCHEMA_VERSION, summarizeForEvidence, type ConfigMutationRecord } from "./config-mutations.js";
7
+ export { generateConfigReferenceDocs, runWorkspaceConfigCli, type ConfigCliIo } from "./config-cli.js";
5
8
  export type CoreRuntimeVersion = "0.1";
@@ -1,4 +1,7 @@
1
1
  export { ModuleRegistry, ModuleRegistryError, validateModuleSet } from "./module-registry.js";
2
2
  export { ModuleCommandRouter, ModuleCommandRouterError } from "./module-command-router.js";
3
- export { buildBaseConfigLayers, deepMerge, envToConfigOverlay, explainConfigPath, getAtPath, KIT_CONFIG_DEFAULTS, mergeConfigLayers, MODULE_CONFIG_CONTRIBUTIONS, resolveWorkspaceConfigWithLayers } from "./workspace-kit-config.js";
4
- export { appendPolicyTrace, getOperationIdForCommand, isSensitiveModuleCommand, parsePolicyApproval, parsePolicyApprovalFromEnv, resolveActor } from "./policy.js";
3
+ export { buildBaseConfigLayers, deepMerge, envToConfigOverlay, explainConfigPath, getAtPath, getProjectConfigPath, getUserConfigFilePath, KIT_CONFIG_DEFAULTS, loadUserLayer, mergeConfigLayers, MODULE_CONFIG_CONTRIBUTIONS, normalizeConfigForExport, PROJECT_CONFIG_REL, resolveWorkspaceConfigWithLayers, stableStringifyConfig } from "./workspace-kit-config.js";
4
+ export { appendPolicyTrace, getExtraSensitiveModuleCommandsFromEffective, getOperationIdForCommand, isSensitiveModuleCommand, isSensitiveModuleCommandForEffective, parsePolicyApproval, parsePolicyApprovalFromEnv, POLICY_TRACE_SCHEMA_VERSION, resolveActor, resolvePolicyOperationIdForCommand } from "./policy.js";
5
+ export { assertWritableKey, getConfigKeyMetadata, getConfigRegistryExport, listConfigMetadata, validatePersistedConfigDocument, validateValueForMetadata } from "./config-metadata.js";
6
+ export { appendConfigMutation, CONFIG_MUTATIONS_SCHEMA_VERSION, summarizeForEvidence } from "./config-mutations.js";
7
+ export { generateConfigReferenceDocs, runWorkspaceConfigCli } from "./config-cli.js";
@@ -1,5 +1,9 @@
1
- export type PolicyOperationId = "cli.upgrade" | "cli.init" | "doc.document-project" | "doc.generate-document" | "tasks.import-tasks" | "tasks.generate-tasks-md" | "tasks.run-transition";
1
+ export declare const POLICY_TRACE_SCHEMA_VERSION: 1;
2
+ export type PolicyOperationId = "cli.upgrade" | "cli.init" | "cli.config-mutate" | "policy.dynamic-sensitive" | "doc.document-project" | "doc.generate-document" | "tasks.import-tasks" | "tasks.generate-tasks-md" | "tasks.run-transition";
2
3
  export declare function getOperationIdForCommand(commandName: string): PolicyOperationId | undefined;
4
+ export declare function getExtraSensitiveModuleCommandsFromEffective(effective: Record<string, unknown>): string[];
5
+ /** Resolve operation id for tracing, including config-declared sensitive module commands. */
6
+ export declare function resolvePolicyOperationIdForCommand(commandName: string, effective: Record<string, unknown>): PolicyOperationId | undefined;
3
7
  /**
4
8
  * Sensitive when mutation / write is possible. Documentation commands are exempt when dryRun is true.
5
9
  */
@@ -12,6 +16,7 @@ export declare function parsePolicyApprovalFromEnv(env: NodeJS.ProcessEnv): Poli
12
16
  export declare function parsePolicyApproval(args: Record<string, unknown>): PolicyApprovalPayload | undefined;
13
17
  export declare function resolveActor(workspacePath: string, args: Record<string, unknown>, env: NodeJS.ProcessEnv): string;
14
18
  export type PolicyTraceRecord = {
19
+ schemaVersion: number;
15
20
  timestamp: string;
16
21
  operationId: PolicyOperationId;
17
22
  command: string;
@@ -21,4 +26,9 @@ export type PolicyTraceRecord = {
21
26
  commandOk?: boolean;
22
27
  message?: string;
23
28
  };
24
- export declare function appendPolicyTrace(workspacePath: string, record: PolicyTraceRecord): Promise<void>;
29
+ /** Policy sensitivity from built-in map plus `policy.extraSensitiveModuleCommands` on effective config. */
30
+ export declare function isSensitiveModuleCommandForEffective(commandName: string, args: Record<string, unknown>, effective: Record<string, unknown>): boolean;
31
+ export type PolicyTraceRecordInput = Omit<PolicyTraceRecord, "schemaVersion"> & {
32
+ schemaVersion?: number;
33
+ };
34
+ export declare function appendPolicyTrace(workspacePath: string, record: PolicyTraceRecordInput): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { execSync } from "node:child_process";
4
+ export const POLICY_TRACE_SCHEMA_VERSION = 1;
4
5
  const COMMAND_TO_OPERATION = {
5
6
  "document-project": "doc.document-project",
6
7
  "generate-document": "doc.generate-document",
@@ -11,6 +12,27 @@ const COMMAND_TO_OPERATION = {
11
12
  export function getOperationIdForCommand(commandName) {
12
13
  return COMMAND_TO_OPERATION[commandName];
13
14
  }
15
+ export function getExtraSensitiveModuleCommandsFromEffective(effective) {
16
+ const policy = effective.policy;
17
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
18
+ return [];
19
+ }
20
+ const raw = policy.extraSensitiveModuleCommands;
21
+ if (!Array.isArray(raw)) {
22
+ return [];
23
+ }
24
+ return raw.filter((x) => typeof x === "string" && x.trim().length > 0);
25
+ }
26
+ /** Resolve operation id for tracing, including config-declared sensitive module commands. */
27
+ export function resolvePolicyOperationIdForCommand(commandName, effective) {
28
+ const builtin = getOperationIdForCommand(commandName);
29
+ if (builtin)
30
+ return builtin;
31
+ if (getExtraSensitiveModuleCommandsFromEffective(effective).includes(commandName)) {
32
+ return "policy.dynamic-sensitive";
33
+ }
34
+ return undefined;
35
+ }
14
36
  /**
15
37
  * Sensitive when mutation / write is possible. Documentation commands are exempt when dryRun is true.
16
38
  */
@@ -91,12 +113,23 @@ export function resolveActor(workspacePath, args, env) {
91
113
  }
92
114
  return "unknown";
93
115
  }
116
+ /** Policy sensitivity from built-in map plus `policy.extraSensitiveModuleCommands` on effective config. */
117
+ export function isSensitiveModuleCommandForEffective(commandName, args, effective) {
118
+ if (isSensitiveModuleCommand(commandName, args)) {
119
+ return true;
120
+ }
121
+ return getExtraSensitiveModuleCommandsFromEffective(effective).includes(commandName);
122
+ }
94
123
  const POLICY_DIR = ".workspace-kit/policy";
95
124
  const TRACE_FILE = "traces.jsonl";
96
125
  export async function appendPolicyTrace(workspacePath, record) {
97
126
  const dir = path.join(workspacePath, POLICY_DIR);
98
127
  const fp = path.join(workspacePath, POLICY_DIR, TRACE_FILE);
99
- const line = `${JSON.stringify(record)}\n`;
128
+ const full = {
129
+ ...record,
130
+ schemaVersion: record.schemaVersion ?? POLICY_TRACE_SCHEMA_VERSION
131
+ };
132
+ const line = `${JSON.stringify(full)}\n`;
100
133
  await fs.mkdir(dir, { recursive: true });
101
134
  await fs.appendFile(fp, line, "utf8");
102
135
  }
@@ -1,11 +1,13 @@
1
1
  import type { ConfigRegistryView } from "../contracts/module-contract.js";
2
- export type ConfigLayerId = "kit-default" | `module:${string}` | "project" | "env" | "invocation";
2
+ export type ConfigLayerId = "kit-default" | `module:${string}` | "user" | "project" | "env" | "invocation";
3
3
  export type ConfigLayer = {
4
4
  id: ConfigLayerId;
5
5
  data: Record<string, unknown>;
6
6
  };
7
7
  /** Effective workspace config: domain keys + optional modules map (project file). */
8
8
  export type EffectiveWorkspaceConfig = Record<string, unknown>;
9
+ export declare const PROJECT_CONFIG_REL = ".workspace-kit/config.json";
10
+ export declare function getProjectConfigPath(workspacePath: string): string;
9
11
  /** Built-in defaults (lowest layer). */
10
12
  export declare const KIT_CONFIG_DEFAULTS: Record<string, unknown>;
11
13
  /**
@@ -16,6 +18,9 @@ export declare const MODULE_CONFIG_CONTRIBUTIONS: Record<string, Record<string,
16
18
  export declare function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
17
19
  export declare function getAtPath(root: Record<string, unknown>, dotted: string): unknown;
18
20
  export declare function deepEqual(a: unknown, b: unknown): boolean;
21
+ /** Resolved home for user-level config (`~/.workspace-kit/config.json`). Override with `WORKSPACE_KIT_HOME` (tests). */
22
+ export declare function getUserConfigFilePath(): string;
23
+ export declare function loadUserLayer(): Promise<ConfigLayer>;
19
24
  /**
20
25
  * Parse WORKSPACE_KIT_* env into a nested object (double-underscore → path under domains).
21
26
  * Example: WORKSPACE_KIT_TASKS__STORE_PATH -> { tasks: { storeRelativePath: "..." } }
@@ -37,6 +42,9 @@ export declare function resolveWorkspaceConfigWithLayers(options: ResolveWorkspa
37
42
  effective: EffectiveWorkspaceConfig;
38
43
  layers: ConfigLayer[];
39
44
  }>;
45
+ export declare function normalizeConfigForExport(value: unknown): unknown;
46
+ /** Deterministic JSON for agents and tests (sorted keys, trailing newline). */
47
+ export declare function stableStringifyConfig(value: unknown): string;
40
48
  export type ExplainConfigResult = {
41
49
  path: string;
42
50
  effectiveValue: unknown;
@@ -1,7 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
- const PROJECT_CONFIG_REL = ".workspace-kit/config.json";
5
+ import { validatePersistedConfigDocument } from "./config-metadata.js";
6
+ export const PROJECT_CONFIG_REL = ".workspace-kit/config.json";
7
+ export function getProjectConfigPath(workspacePath) {
8
+ return path.join(workspacePath, PROJECT_CONFIG_REL);
9
+ }
5
10
  /** Built-in defaults (lowest layer). */
6
11
  export const KIT_CONFIG_DEFAULTS = {
7
12
  core: {},
@@ -85,6 +90,25 @@ export function deepEqual(a, b) {
85
90
  }
86
91
  return true;
87
92
  }
93
+ /** Resolved home for user-level config (`~/.workspace-kit/config.json`). Override with `WORKSPACE_KIT_HOME` (tests). */
94
+ export function getUserConfigFilePath() {
95
+ const home = process.env.WORKSPACE_KIT_HOME?.trim() || os.homedir();
96
+ return path.join(home, ".workspace-kit", "config.json");
97
+ }
98
+ async function readUserConfigFile() {
99
+ const fp = getUserConfigFilePath();
100
+ if (!existsSync(fp)) {
101
+ return {};
102
+ }
103
+ const raw = await fs.readFile(fp, "utf8");
104
+ const parsed = JSON.parse(raw);
105
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
106
+ throw new Error(`config-invalid(user): ${fp} must be a JSON object`);
107
+ }
108
+ const obj = parsed;
109
+ validatePersistedConfigDocument(obj, "user config");
110
+ return obj;
111
+ }
88
112
  async function readProjectConfigFile(workspacePath) {
89
113
  const fp = path.join(workspacePath, PROJECT_CONFIG_REL);
90
114
  if (!existsSync(fp)) {
@@ -96,11 +120,13 @@ async function readProjectConfigFile(workspacePath) {
96
120
  throw new Error("config-invalid: .workspace-kit/config.json must be a JSON object");
97
121
  }
98
122
  const obj = parsed;
99
- if (obj.schemaVersion !== undefined && typeof obj.schemaVersion !== "number") {
100
- throw new Error("config-invalid: schemaVersion must be a number when present");
101
- }
123
+ validatePersistedConfigDocument(obj, ".workspace-kit/config.json");
102
124
  return obj;
103
125
  }
126
+ export async function loadUserLayer() {
127
+ const data = await readUserConfigFile();
128
+ return { id: "user", data };
129
+ }
104
130
  /**
105
131
  * Parse WORKSPACE_KIT_* env into a nested object (double-underscore → path under domains).
106
132
  * Example: WORKSPACE_KIT_TASKS__STORE_PATH -> { tasks: { storeRelativePath: "..." } }
@@ -186,6 +212,7 @@ export function mergeConfigLayers(layers) {
186
212
  export async function resolveWorkspaceConfigWithLayers(options) {
187
213
  const { workspacePath, registry, env = process.env, invocationConfig } = options;
188
214
  const layers = [...buildBaseConfigLayers(registry)];
215
+ layers.push(await loadUserLayer());
189
216
  layers.push(await loadProjectLayer(workspacePath));
190
217
  layers.push({ id: "env", data: envToConfigOverlay(env) });
191
218
  if (invocationConfig && Object.keys(invocationConfig).length > 0) {
@@ -193,6 +220,27 @@ export async function resolveWorkspaceConfigWithLayers(options) {
193
220
  }
194
221
  return { effective: mergeConfigLayers(layers), layers };
195
222
  }
223
+ export function normalizeConfigForExport(value) {
224
+ return sortKeysDeep(value);
225
+ }
226
+ function sortKeysDeep(value) {
227
+ if (value === null || typeof value !== "object") {
228
+ return value;
229
+ }
230
+ if (Array.isArray(value)) {
231
+ return value.map(sortKeysDeep);
232
+ }
233
+ const o = value;
234
+ const out = {};
235
+ for (const k of Object.keys(o).sort()) {
236
+ out[k] = sortKeysDeep(o[k]);
237
+ }
238
+ return out;
239
+ }
240
+ /** Deterministic JSON for agents and tests (sorted keys, trailing newline). */
241
+ export function stableStringifyConfig(value) {
242
+ return `${JSON.stringify(sortKeysDeep(value), null, 2)}\n`;
243
+ }
196
244
  export function explainConfigPath(dottedPath, layers) {
197
245
  const mergedFull = mergeConfigLayers(layers);
198
246
  const effectiveValue = getAtPath(mergedFull, dottedPath);
@@ -1,4 +1,4 @@
1
- import { explainConfigPath, resolveWorkspaceConfigWithLayers } from "../../core/workspace-kit-config.js";
1
+ import { explainConfigPath, normalizeConfigForExport, resolveWorkspaceConfigWithLayers } from "../../core/workspace-kit-config.js";
2
2
  async function handleExplainConfig(args, ctx) {
3
3
  const pathArg = typeof args.path === "string" ? args.path.trim() : "";
4
4
  if (!pathArg) {
@@ -23,6 +23,24 @@ async function handleExplainConfig(args, ctx) {
23
23
  data: explained
24
24
  };
25
25
  }
26
+ async function handleResolveConfig(args, ctx) {
27
+ const invocationConfig = typeof args.config === "object" && args.config !== null && !Array.isArray(args.config)
28
+ ? args.config
29
+ : {};
30
+ const { effective, layers } = await resolveWorkspaceConfigWithLayers({
31
+ workspacePath: ctx.workspacePath,
32
+ registry: ctx.registry,
33
+ invocationConfig
34
+ });
35
+ return {
36
+ ok: true,
37
+ code: "config-resolved",
38
+ data: {
39
+ effective: normalizeConfigForExport(effective),
40
+ layers: layers.map((l) => ({ id: l.id }))
41
+ }
42
+ };
43
+ }
26
44
  export const workspaceConfigModule = {
27
45
  registration: {
28
46
  id: "workspace-config",
@@ -48,25 +66,35 @@ export const workspaceConfigModule = {
48
66
  name: "explain-config",
49
67
  file: "explain-config.md",
50
68
  description: "Agent-first JSON: effective config value and winning layer for a dotted path."
69
+ },
70
+ {
71
+ name: "resolve-config",
72
+ file: "resolve-config.md",
73
+ description: "Agent-first JSON: full effective config (sorted) and merge layer ids."
51
74
  }
52
75
  ]
53
76
  }
54
77
  },
55
78
  async onCommand(command, ctx) {
56
- if (command.name !== "explain-config") {
57
- return { ok: false, code: "unknown-command", message: "workspace-config only implements explain-config" };
58
- }
59
79
  const reg = ctx.moduleRegistry;
60
80
  if (!reg) {
61
81
  return {
62
82
  ok: false,
63
83
  code: "internal-error",
64
- message: "explain-config requires moduleRegistry on context (CLI wiring)"
84
+ message: "workspace-config requires moduleRegistry on context (CLI wiring)"
65
85
  };
66
86
  }
67
- return handleExplainConfig(command.args ?? {}, {
68
- workspacePath: ctx.workspacePath,
69
- registry: reg
70
- });
87
+ const baseCtx = { workspacePath: ctx.workspacePath, registry: reg };
88
+ if (command.name === "explain-config") {
89
+ return handleExplainConfig(command.args ?? {}, baseCtx);
90
+ }
91
+ if (command.name === "resolve-config") {
92
+ return handleResolveConfig(command.args ?? {}, baseCtx);
93
+ }
94
+ return {
95
+ ok: false,
96
+ code: "unknown-command",
97
+ message: `workspace-config: unknown command '${command.name}'`
98
+ };
71
99
  }
72
100
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workflow-cannon/workspace-kit",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "packageManager": "pnpm@10.0.0",
6
6
  "license": "MIT",