@workflow-cannon/workspace-kit 0.2.0 → 0.4.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.
Files changed (36) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +168 -2
  3. package/dist/contracts/index.d.ts +1 -1
  4. package/dist/contracts/module-contract.d.ts +14 -0
  5. package/dist/core/index.d.ts +2 -0
  6. package/dist/core/index.js +2 -0
  7. package/dist/core/policy.d.ts +24 -0
  8. package/dist/core/policy.js +102 -0
  9. package/dist/core/workspace-kit-config.d.ts +49 -0
  10. package/dist/core/workspace-kit-config.js +218 -0
  11. package/dist/modules/documentation/index.d.ts +1 -1
  12. package/dist/modules/documentation/index.js +63 -38
  13. package/dist/modules/documentation/runtime.d.ts +5 -1
  14. package/dist/modules/documentation/runtime.js +87 -4
  15. package/dist/modules/documentation/types.d.ts +14 -0
  16. package/dist/modules/index.d.ts +3 -1
  17. package/dist/modules/index.js +2 -1
  18. package/dist/modules/task-engine/generator.d.ts +2 -0
  19. package/dist/modules/task-engine/generator.js +101 -0
  20. package/dist/modules/task-engine/importer.d.ts +8 -0
  21. package/dist/modules/task-engine/importer.js +157 -0
  22. package/dist/modules/task-engine/index.d.ts +7 -0
  23. package/dist/modules/task-engine/index.js +237 -2
  24. package/dist/modules/task-engine/service.d.ts +21 -0
  25. package/dist/modules/task-engine/service.js +105 -0
  26. package/dist/modules/task-engine/store.d.ts +16 -0
  27. package/dist/modules/task-engine/store.js +88 -0
  28. package/dist/modules/task-engine/suggestions.d.ts +2 -0
  29. package/dist/modules/task-engine/suggestions.js +51 -0
  30. package/dist/modules/task-engine/transitions.d.ts +23 -0
  31. package/dist/modules/task-engine/transitions.js +109 -0
  32. package/dist/modules/task-engine/types.d.ts +82 -0
  33. package/dist/modules/task-engine/types.js +1 -0
  34. package/dist/modules/workspace-config/index.d.ts +2 -0
  35. package/dist/modules/workspace-config/index.js +72 -0
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -61,9 +61,9 @@ This keeps automation adaptive without sacrificing safety, governance, or develo
61
61
 
62
62
  ## Current Status
63
63
 
64
- - Project tracking has been reset for split-repo execution.
65
- - Current execution phase is **Phase 0 (foundation)**.
66
- - Phase 0 opens with `T178` to `T180` for release hardening and consumer cadence definition.
64
+ - **Phase 0** and **Phase 1** (task engine, `v0.3.0`) are complete.
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).
67
67
 
68
68
  ## Goals
69
69
 
package/dist/cli.js CHANGED
@@ -2,6 +2,16 @@
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { ModuleRegistry } from "./core/module-registry.js";
6
+ import { ModuleCommandRouter } from "./core/module-command-router.js";
7
+ import { appendPolicyTrace, getOperationIdForCommand, isSensitiveModuleCommand, parsePolicyApproval, parsePolicyApprovalFromEnv, resolveActor } from "./core/policy.js";
8
+ import { resolveWorkspaceConfigWithLayers } from "./core/workspace-kit-config.js";
9
+ import { documentationModule } from "./modules/documentation/index.js";
10
+ import { taskEngineModule } from "./modules/task-engine/index.js";
11
+ import { approvalsModule } from "./modules/approvals/index.js";
12
+ import { planningModule } from "./modules/planning/index.js";
13
+ import { improvementModule } from "./modules/improvement/index.js";
14
+ import { workspaceConfigModule } from "./modules/workspace-config/index.js";
5
15
  const EXIT_SUCCESS = 0;
6
16
  const EXIT_VALIDATION_FAILURE = 1;
7
17
  const EXIT_USAGE_ERROR = 2;
@@ -87,6 +97,33 @@ export async function parseJsonFile(filePath) {
87
97
  const raw = await fs.readFile(filePath, "utf8");
88
98
  return JSON.parse(raw);
89
99
  }
100
+ async function requireCliPolicyApproval(cwd, operationId, commandLabel, writeError) {
101
+ const approval = parsePolicyApprovalFromEnv(process.env);
102
+ if (!approval) {
103
+ writeError(`workspace-kit ${commandLabel} requires WORKSPACE_KIT_POLICY_APPROVAL with JSON {"confirmed":true,"rationale":"..."} (agent-mediated).`);
104
+ await appendPolicyTrace(cwd, {
105
+ timestamp: new Date().toISOString(),
106
+ operationId,
107
+ command: commandLabel,
108
+ actor: resolveActor(cwd, {}, process.env),
109
+ allowed: false,
110
+ message: "missing WORKSPACE_KIT_POLICY_APPROVAL"
111
+ });
112
+ return null;
113
+ }
114
+ return { rationale: approval.rationale };
115
+ }
116
+ async function recordCliPolicySuccess(cwd, operationId, commandLabel, rationale, commandOk) {
117
+ await appendPolicyTrace(cwd, {
118
+ timestamp: new Date().toISOString(),
119
+ operationId,
120
+ command: commandLabel,
121
+ actor: resolveActor(cwd, {}, process.env),
122
+ allowed: true,
123
+ rationale,
124
+ commandOk
125
+ });
126
+ }
90
127
  function readStringField(objectValue, key, errors, fieldPath) {
91
128
  const value = objectValue[key];
92
129
  if (typeof value !== "string" || value.trim().length === 0) {
@@ -305,12 +342,17 @@ export async function runCli(args, options = {}) {
305
342
  const writeError = options.writeError ?? console.error;
306
343
  const [command] = args;
307
344
  if (!command) {
308
- writeError("Usage: workspace-kit <init|doctor|check|upgrade>");
345
+ writeError("Usage: workspace-kit <init|doctor|check|upgrade|drift-check|run>");
309
346
  return EXIT_USAGE_ERROR;
310
347
  }
311
348
  if (command === "init") {
349
+ const approval = await requireCliPolicyApproval(cwd, "cli.init", "init", writeError);
350
+ if (!approval) {
351
+ return EXIT_VALIDATION_FAILURE;
352
+ }
312
353
  const { errors, profile } = await validateProfile(cwd);
313
354
  if (errors.length > 0 || !profile) {
355
+ await recordCliPolicySuccess(cwd, "cli.init", "init", approval.rationale, false);
314
356
  writeError("workspace-kit init failed profile validation.");
315
357
  for (const error of errors) {
316
358
  writeError(`- ${error}`);
@@ -321,6 +363,7 @@ export async function runCli(args, options = {}) {
321
363
  writeLine("workspace-kit init generated profile-driven project context artifacts.");
322
364
  writeLine(`- ${path.relative(cwd, artifacts.generatedJsonPath)}`);
323
365
  writeLine(`- ${path.relative(cwd, artifacts.generatedRulePath)}`);
366
+ await recordCliPolicySuccess(cwd, "cli.init", "init", approval.rationale, true);
324
367
  return EXIT_SUCCESS;
325
368
  }
326
369
  if (command === "check") {
@@ -337,8 +380,13 @@ export async function runCli(args, options = {}) {
337
380
  return EXIT_SUCCESS;
338
381
  }
339
382
  if (command === "upgrade") {
383
+ const approval = await requireCliPolicyApproval(cwd, "cli.upgrade", "upgrade", writeError);
384
+ if (!approval) {
385
+ return EXIT_VALIDATION_FAILURE;
386
+ }
340
387
  const { errors, profile } = await validateProfile(cwd);
341
388
  if (errors.length > 0 || !profile) {
389
+ await recordCliPolicySuccess(cwd, "cli.upgrade", "upgrade", approval.rationale, false);
342
390
  writeError("workspace-kit upgrade failed profile validation.");
343
391
  for (const error of errors) {
344
392
  writeError(`- ${error}`);
@@ -437,6 +485,7 @@ export async function runCli(args, options = {}) {
437
485
  }
438
486
  }
439
487
  writeLine(`Backups written under: ${path.relative(cwd, backupRoot)}`);
488
+ await recordCliPolicySuccess(cwd, "cli.upgrade", "upgrade", approval.rationale, true);
440
489
  return EXIT_SUCCESS;
441
490
  }
442
491
  if (command === "drift-check") {
@@ -525,8 +574,125 @@ export async function runCli(args, options = {}) {
525
574
  }
526
575
  return EXIT_SUCCESS;
527
576
  }
577
+ if (command === "run") {
578
+ const allModules = [
579
+ workspaceConfigModule,
580
+ documentationModule,
581
+ taskEngineModule,
582
+ approvalsModule,
583
+ planningModule,
584
+ improvementModule
585
+ ];
586
+ const registry = new ModuleRegistry(allModules);
587
+ const router = new ModuleCommandRouter(registry);
588
+ const subcommand = args[1];
589
+ if (!subcommand) {
590
+ const commands = router.listCommands();
591
+ writeLine("Available module commands:");
592
+ for (const cmd of commands) {
593
+ const desc = cmd.description ? ` — ${cmd.description}` : "";
594
+ writeLine(` ${cmd.name} (${cmd.moduleId})${desc}`);
595
+ }
596
+ writeLine("");
597
+ writeLine("Usage: workspace-kit run <command> [json-args]");
598
+ return EXIT_SUCCESS;
599
+ }
600
+ let commandArgs = {};
601
+ if (args[2]) {
602
+ try {
603
+ const parsed = JSON.parse(args[2]);
604
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
605
+ commandArgs = parsed;
606
+ }
607
+ else {
608
+ writeError("Command args must be a JSON object.");
609
+ return EXIT_USAGE_ERROR;
610
+ }
611
+ }
612
+ catch {
613
+ writeError(`Invalid JSON args: ${args[2]}`);
614
+ return EXIT_USAGE_ERROR;
615
+ }
616
+ }
617
+ const invocationConfig = typeof commandArgs.config === "object" &&
618
+ commandArgs.config !== null &&
619
+ !Array.isArray(commandArgs.config)
620
+ ? commandArgs.config
621
+ : {};
622
+ let effective;
623
+ try {
624
+ const resolved = await resolveWorkspaceConfigWithLayers({
625
+ workspacePath: cwd,
626
+ registry,
627
+ invocationConfig
628
+ });
629
+ effective = resolved.effective;
630
+ }
631
+ catch (err) {
632
+ const message = err instanceof Error ? err.message : String(err);
633
+ writeError(`Config resolution failed: ${message}`);
634
+ return EXIT_VALIDATION_FAILURE;
635
+ }
636
+ const actor = resolveActor(cwd, commandArgs, process.env);
637
+ const sensitive = isSensitiveModuleCommand(subcommand, commandArgs);
638
+ if (sensitive) {
639
+ const approval = parsePolicyApproval(commandArgs);
640
+ if (!approval) {
641
+ const op = getOperationIdForCommand(subcommand);
642
+ if (op) {
643
+ await appendPolicyTrace(cwd, {
644
+ timestamp: new Date().toISOString(),
645
+ operationId: op,
646
+ command: `run ${subcommand}`,
647
+ actor,
648
+ allowed: false,
649
+ message: "missing policyApproval in JSON args"
650
+ });
651
+ }
652
+ writeLine(JSON.stringify({
653
+ ok: false,
654
+ code: "policy-denied",
655
+ message: 'Sensitive command requires policyApproval in JSON args: {"policyApproval":{"confirmed":true,"rationale":"user approved in chat"}}'
656
+ }, null, 2));
657
+ return EXIT_VALIDATION_FAILURE;
658
+ }
659
+ }
660
+ const ctx = {
661
+ runtimeVersion: "0.1",
662
+ workspacePath: cwd,
663
+ effectiveConfig: effective,
664
+ resolvedActor: actor,
665
+ moduleRegistry: registry
666
+ };
667
+ try {
668
+ const result = await router.execute(subcommand, commandArgs, ctx);
669
+ if (sensitive) {
670
+ const approval = parsePolicyApproval(commandArgs);
671
+ const op = getOperationIdForCommand(subcommand);
672
+ if (approval && op) {
673
+ await appendPolicyTrace(cwd, {
674
+ timestamp: new Date().toISOString(),
675
+ operationId: op,
676
+ command: `run ${subcommand}`,
677
+ actor,
678
+ allowed: true,
679
+ rationale: approval.rationale,
680
+ commandOk: result.ok,
681
+ message: result.message
682
+ });
683
+ }
684
+ }
685
+ writeLine(JSON.stringify(result, null, 2));
686
+ return result.ok ? EXIT_SUCCESS : EXIT_VALIDATION_FAILURE;
687
+ }
688
+ catch (error) {
689
+ const message = error instanceof Error ? error.message : String(error);
690
+ writeError(`Module command failed: ${message}`);
691
+ return EXIT_INTERNAL_ERROR;
692
+ }
693
+ }
528
694
  if (command !== "doctor") {
529
- writeError(`Unknown command '${command}'. Supported commands: init, doctor, check, upgrade, drift-check.`);
695
+ writeError(`Unknown command '${command}'. Supported commands: init, doctor, check, upgrade, drift-check, run.`);
530
696
  return EXIT_USAGE_ERROR;
531
697
  }
532
698
  const issues = [];
@@ -1 +1 @@
1
- export type { ModuleCommand, ModuleCommandResult, ModuleCapability, ModuleDocumentContract, ModuleEvent, ModuleInstructionContract, ModuleInstructionEntry, ModuleLifecycleContext, ModuleRegistration, WorkflowModule } from "./module-contract.js";
1
+ export type { ConfigRegistryView, ModuleCommand, ModuleCommandResult, ModuleCapability, ModuleDocumentContract, ModuleEvent, ModuleInstructionContract, ModuleInstructionEntry, ModuleLifecycleContext, ModuleRegistration, WorkflowModule } from "./module-contract.js";
@@ -36,9 +36,23 @@ export type ModuleCommandResult = {
36
36
  message?: string;
37
37
  data?: Record<string, unknown>;
38
38
  };
39
+ /** Subset of module registry used for config layer ordering (avoids core↔contracts cycles). */
40
+ export type ConfigRegistryView = {
41
+ getStartupOrder(): ReadonlyArray<{
42
+ registration: {
43
+ id: string;
44
+ };
45
+ }>;
46
+ };
39
47
  export type ModuleLifecycleContext = {
40
48
  runtimeVersion: string;
41
49
  workspacePath: string;
50
+ /** Merged workspace config (kit → modules → project → env → invocation). */
51
+ effectiveConfig?: Record<string, unknown>;
52
+ /** Resolved actor for policy traces (see phase2 workbook). */
53
+ resolvedActor?: string;
54
+ /** CLI supplies registry for explain-config and config resolution. */
55
+ moduleRegistry?: ConfigRegistryView;
42
56
  };
43
57
  export type ModuleRegistration = {
44
58
  id: string;
@@ -1,3 +1,5 @@
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
5
  export type CoreRuntimeVersion = "0.1";
@@ -1,2 +1,4 @@
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";
@@ -0,0 +1,24 @@
1
+ export type PolicyOperationId = "cli.upgrade" | "cli.init" | "doc.document-project" | "doc.generate-document" | "tasks.import-tasks" | "tasks.generate-tasks-md" | "tasks.run-transition";
2
+ export declare function getOperationIdForCommand(commandName: string): PolicyOperationId | undefined;
3
+ /**
4
+ * Sensitive when mutation / write is possible. Documentation commands are exempt when dryRun is true.
5
+ */
6
+ export declare function isSensitiveModuleCommand(commandName: string, args: Record<string, unknown>): boolean;
7
+ export type PolicyApprovalPayload = {
8
+ confirmed: boolean;
9
+ rationale: string;
10
+ };
11
+ export declare function parsePolicyApprovalFromEnv(env: NodeJS.ProcessEnv): PolicyApprovalPayload | undefined;
12
+ export declare function parsePolicyApproval(args: Record<string, unknown>): PolicyApprovalPayload | undefined;
13
+ export declare function resolveActor(workspacePath: string, args: Record<string, unknown>, env: NodeJS.ProcessEnv): string;
14
+ export type PolicyTraceRecord = {
15
+ timestamp: string;
16
+ operationId: PolicyOperationId;
17
+ command: string;
18
+ actor: string;
19
+ allowed: boolean;
20
+ rationale?: string;
21
+ commandOk?: boolean;
22
+ message?: string;
23
+ };
24
+ export declare function appendPolicyTrace(workspacePath: string, record: PolicyTraceRecord): Promise<void>;
@@ -0,0 +1,102 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ const COMMAND_TO_OPERATION = {
5
+ "document-project": "doc.document-project",
6
+ "generate-document": "doc.generate-document",
7
+ "import-tasks": "tasks.import-tasks",
8
+ "generate-tasks-md": "tasks.generate-tasks-md",
9
+ "run-transition": "tasks.run-transition"
10
+ };
11
+ export function getOperationIdForCommand(commandName) {
12
+ return COMMAND_TO_OPERATION[commandName];
13
+ }
14
+ /**
15
+ * Sensitive when mutation / write is possible. Documentation commands are exempt when dryRun is true.
16
+ */
17
+ export function isSensitiveModuleCommand(commandName, args) {
18
+ const op = COMMAND_TO_OPERATION[commandName];
19
+ if (!op)
20
+ return false;
21
+ if (op === "doc.document-project" || op === "doc.generate-document") {
22
+ const options = typeof args.options === "object" && args.options !== null
23
+ ? args.options
24
+ : {};
25
+ if (options.dryRun === true) {
26
+ return false;
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+ export function parsePolicyApprovalFromEnv(env) {
32
+ const raw = env.WORKSPACE_KIT_POLICY_APPROVAL?.trim();
33
+ if (!raw)
34
+ return undefined;
35
+ try {
36
+ const o = JSON.parse(raw);
37
+ if (o.confirmed !== true)
38
+ return undefined;
39
+ const rationale = typeof o.rationale === "string" ? o.rationale.trim() : "";
40
+ if (!rationale)
41
+ return undefined;
42
+ return { confirmed: true, rationale };
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ export function parsePolicyApproval(args) {
49
+ const raw = args.policyApproval;
50
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
51
+ return undefined;
52
+ }
53
+ const o = raw;
54
+ const confirmed = o.confirmed === true;
55
+ const rationale = typeof o.rationale === "string" ? o.rationale.trim() : "";
56
+ if (!confirmed || rationale.length === 0) {
57
+ return undefined;
58
+ }
59
+ return { confirmed, rationale };
60
+ }
61
+ export function resolveActor(workspacePath, args, env) {
62
+ if (typeof args.actor === "string" && args.actor.trim().length > 0) {
63
+ return args.actor.trim();
64
+ }
65
+ const fromEnv = env.WORKSPACE_KIT_ACTOR?.trim();
66
+ if (fromEnv)
67
+ return fromEnv;
68
+ try {
69
+ const email = execSync("git config user.email", {
70
+ cwd: workspacePath,
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"]
73
+ }).trim();
74
+ if (email)
75
+ return email;
76
+ }
77
+ catch {
78
+ /* ignore */
79
+ }
80
+ try {
81
+ const name = execSync("git config user.name", {
82
+ cwd: workspacePath,
83
+ encoding: "utf8",
84
+ stdio: ["ignore", "pipe", "ignore"]
85
+ }).trim();
86
+ if (name)
87
+ return name;
88
+ }
89
+ catch {
90
+ /* ignore */
91
+ }
92
+ return "unknown";
93
+ }
94
+ const POLICY_DIR = ".workspace-kit/policy";
95
+ const TRACE_FILE = "traces.jsonl";
96
+ export async function appendPolicyTrace(workspacePath, record) {
97
+ const dir = path.join(workspacePath, POLICY_DIR);
98
+ const fp = path.join(workspacePath, POLICY_DIR, TRACE_FILE);
99
+ const line = `${JSON.stringify(record)}\n`;
100
+ await fs.mkdir(dir, { recursive: true });
101
+ await fs.appendFile(fp, line, "utf8");
102
+ }
@@ -0,0 +1,49 @@
1
+ import type { ConfigRegistryView } from "../contracts/module-contract.js";
2
+ export type ConfigLayerId = "kit-default" | `module:${string}` | "project" | "env" | "invocation";
3
+ export type ConfigLayer = {
4
+ id: ConfigLayerId;
5
+ data: Record<string, unknown>;
6
+ };
7
+ /** Effective workspace config: domain keys + optional modules map (project file). */
8
+ export type EffectiveWorkspaceConfig = Record<string, unknown>;
9
+ /** Built-in defaults (lowest layer). */
10
+ export declare const KIT_CONFIG_DEFAULTS: Record<string, unknown>;
11
+ /**
12
+ * Static module-level defaults keyed by module id (merged in registry startup order).
13
+ * Each value is deep-merged into the aggregate before project/env/invocation.
14
+ */
15
+ export declare const MODULE_CONFIG_CONTRIBUTIONS: Record<string, Record<string, unknown>>;
16
+ export declare function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
17
+ export declare function getAtPath(root: Record<string, unknown>, dotted: string): unknown;
18
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
19
+ /**
20
+ * Parse WORKSPACE_KIT_* env into a nested object (double-underscore → path under domains).
21
+ * Example: WORKSPACE_KIT_TASKS__STORE_PATH -> { tasks: { storeRelativePath: "..." } }
22
+ * Uses segment after prefix; known domain prefix sets first key.
23
+ */
24
+ export declare function envToConfigOverlay(env: NodeJS.ProcessEnv): Record<string, unknown>;
25
+ /** Kit defaults + module contributions (topological order). Project/env/invocation added separately. */
26
+ export declare function buildBaseConfigLayers(registry: ConfigRegistryView): ConfigLayer[];
27
+ export declare function loadProjectLayer(workspacePath: string): Promise<ConfigLayer>;
28
+ export declare function mergeConfigLayers(layers: ConfigLayer[]): Record<string, unknown>;
29
+ export type ResolveWorkspaceConfigOptions = {
30
+ workspacePath: string;
31
+ registry: ConfigRegistryView;
32
+ env?: NodeJS.ProcessEnv;
33
+ /** Merged last (from `workspace-kit run` top-level `config` key). */
34
+ invocationConfig?: Record<string, unknown>;
35
+ };
36
+ export declare function resolveWorkspaceConfigWithLayers(options: ResolveWorkspaceConfigOptions): Promise<{
37
+ effective: EffectiveWorkspaceConfig;
38
+ layers: ConfigLayer[];
39
+ }>;
40
+ export type ExplainConfigResult = {
41
+ path: string;
42
+ effectiveValue: unknown;
43
+ winningLayer: ConfigLayerId;
44
+ alternates: {
45
+ layer: ConfigLayerId;
46
+ value: unknown;
47
+ }[];
48
+ };
49
+ export declare function explainConfigPath(dottedPath: string, layers: ConfigLayer[]): ExplainConfigResult;
@@ -0,0 +1,218 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ const PROJECT_CONFIG_REL = ".workspace-kit/config.json";
5
+ /** Built-in defaults (lowest layer). */
6
+ export const KIT_CONFIG_DEFAULTS = {
7
+ core: {},
8
+ tasks: {
9
+ storeRelativePath: ".workspace-kit/tasks/state.json"
10
+ },
11
+ documentation: {}
12
+ };
13
+ /**
14
+ * Static module-level defaults keyed by module id (merged in registry startup order).
15
+ * Each value is deep-merged into the aggregate before project/env/invocation.
16
+ */
17
+ export const MODULE_CONFIG_CONTRIBUTIONS = {
18
+ "task-engine": {
19
+ tasks: {
20
+ storeRelativePath: ".workspace-kit/tasks/state.json"
21
+ }
22
+ },
23
+ documentation: {
24
+ documentation: {}
25
+ },
26
+ approvals: {},
27
+ planning: {},
28
+ improvement: {}
29
+ };
30
+ export function deepMerge(target, source) {
31
+ const out = { ...target };
32
+ for (const [k, v] of Object.entries(source)) {
33
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
34
+ const prev = out[k];
35
+ const prevObj = prev !== null && typeof prev === "object" && !Array.isArray(prev)
36
+ ? prev
37
+ : {};
38
+ out[k] = deepMerge(prevObj, v);
39
+ }
40
+ else {
41
+ out[k] = v;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ function cloneDeep(obj) {
47
+ return JSON.parse(JSON.stringify(obj));
48
+ }
49
+ export function getAtPath(root, dotted) {
50
+ const parts = dotted.split(".").filter(Boolean);
51
+ let cur = root;
52
+ for (const p of parts) {
53
+ if (cur === null || cur === undefined || typeof cur !== "object" || Array.isArray(cur)) {
54
+ return undefined;
55
+ }
56
+ cur = cur[p];
57
+ }
58
+ return cur;
59
+ }
60
+ export function deepEqual(a, b) {
61
+ if (a === b)
62
+ return true;
63
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
64
+ return false;
65
+ }
66
+ if (Array.isArray(a) !== Array.isArray(b))
67
+ return false;
68
+ if (Array.isArray(a) && Array.isArray(b)) {
69
+ if (a.length !== b.length)
70
+ return false;
71
+ return a.every((v, i) => deepEqual(v, b[i]));
72
+ }
73
+ const ak = Object.keys(a).sort();
74
+ const bk = Object.keys(b).sort();
75
+ if (ak.length !== bk.length)
76
+ return false;
77
+ for (let i = 0; i < ak.length; i++) {
78
+ if (ak[i] !== bk[i])
79
+ return false;
80
+ }
81
+ for (const k of ak) {
82
+ if (!deepEqual(a[k], b[k])) {
83
+ return false;
84
+ }
85
+ }
86
+ return true;
87
+ }
88
+ async function readProjectConfigFile(workspacePath) {
89
+ const fp = path.join(workspacePath, PROJECT_CONFIG_REL);
90
+ if (!existsSync(fp)) {
91
+ return {};
92
+ }
93
+ const raw = await fs.readFile(fp, "utf8");
94
+ const parsed = JSON.parse(raw);
95
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
96
+ throw new Error("config-invalid: .workspace-kit/config.json must be a JSON object");
97
+ }
98
+ 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
+ }
102
+ return obj;
103
+ }
104
+ /**
105
+ * Parse WORKSPACE_KIT_* env into a nested object (double-underscore → path under domains).
106
+ * Example: WORKSPACE_KIT_TASKS__STORE_PATH -> { tasks: { storeRelativePath: "..." } }
107
+ * Uses segment after prefix; known domain prefix sets first key.
108
+ */
109
+ export function envToConfigOverlay(env) {
110
+ const prefix = "WORKSPACE_KIT_";
111
+ const out = {};
112
+ for (const [key, val] of Object.entries(env)) {
113
+ if (!key.startsWith(prefix) || val === undefined)
114
+ continue;
115
+ const rest = key.slice(prefix.length);
116
+ if (!rest || rest === "ACTOR")
117
+ continue;
118
+ const segments = rest.split("__").map((s) => camelCaseEnvSegment(s));
119
+ if (segments.length === 0)
120
+ continue;
121
+ setDeep(out, segments, coerceEnvValue(val));
122
+ }
123
+ return out;
124
+ }
125
+ function camelCaseEnvSegment(s) {
126
+ const lower = s.toLowerCase().replace(/_/g, "");
127
+ // tasks -> tasks; STORE_PATH segments already split — first segment may be TASKS
128
+ if (lower === "tasks")
129
+ return "tasks";
130
+ if (lower === "documentation")
131
+ return "documentation";
132
+ if (lower === "core")
133
+ return "core";
134
+ // e.g. STORE_PATH -> storePath
135
+ return s
136
+ .toLowerCase()
137
+ .split("_")
138
+ .filter(Boolean)
139
+ .map((w, i) => (i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)))
140
+ .join("");
141
+ }
142
+ function setDeep(target, segments, value) {
143
+ let cur = target;
144
+ for (let i = 0; i < segments.length - 1; i++) {
145
+ const seg = segments[i];
146
+ const next = cur[seg];
147
+ if (next === undefined || typeof next !== "object" || Array.isArray(next)) {
148
+ cur[seg] = {};
149
+ }
150
+ cur = cur[seg];
151
+ }
152
+ cur[segments[segments.length - 1]] = value;
153
+ }
154
+ function coerceEnvValue(val) {
155
+ if (val === "true")
156
+ return true;
157
+ if (val === "false")
158
+ return false;
159
+ if (/^-?\d+$/.test(val))
160
+ return Number(val);
161
+ return val;
162
+ }
163
+ /** Kit defaults + module contributions (topological order). Project/env/invocation added separately. */
164
+ export function buildBaseConfigLayers(registry) {
165
+ const layers = [];
166
+ layers.push({ id: "kit-default", data: cloneDeep(KIT_CONFIG_DEFAULTS) });
167
+ for (const mod of registry.getStartupOrder()) {
168
+ const contrib = MODULE_CONFIG_CONTRIBUTIONS[mod.registration.id];
169
+ if (contrib && Object.keys(contrib).length > 0) {
170
+ layers.push({ id: `module:${mod.registration.id}`, data: cloneDeep(contrib) });
171
+ }
172
+ }
173
+ return layers;
174
+ }
175
+ export async function loadProjectLayer(workspacePath) {
176
+ const data = await readProjectConfigFile(workspacePath);
177
+ return { id: "project", data };
178
+ }
179
+ export function mergeConfigLayers(layers) {
180
+ let acc = {};
181
+ for (const layer of layers) {
182
+ acc = deepMerge(acc, layer.data);
183
+ }
184
+ return acc;
185
+ }
186
+ export async function resolveWorkspaceConfigWithLayers(options) {
187
+ const { workspacePath, registry, env = process.env, invocationConfig } = options;
188
+ const layers = [...buildBaseConfigLayers(registry)];
189
+ layers.push(await loadProjectLayer(workspacePath));
190
+ layers.push({ id: "env", data: envToConfigOverlay(env) });
191
+ if (invocationConfig && Object.keys(invocationConfig).length > 0) {
192
+ layers.push({ id: "invocation", data: cloneDeep(invocationConfig) });
193
+ }
194
+ return { effective: mergeConfigLayers(layers), layers };
195
+ }
196
+ export function explainConfigPath(dottedPath, layers) {
197
+ const mergedFull = mergeConfigLayers(layers);
198
+ const effectiveValue = getAtPath(mergedFull, dottedPath);
199
+ let winningLayer = "kit-default";
200
+ let prevMerged = {};
201
+ for (let i = 0; i < layers.length; i++) {
202
+ const slice = layers.slice(0, i + 1);
203
+ const nextMerged = mergeConfigLayers(slice);
204
+ const prevVal = getAtPath(prevMerged, dottedPath);
205
+ const nextVal = getAtPath(nextMerged, dottedPath);
206
+ if (!deepEqual(prevVal, nextVal)) {
207
+ winningLayer = layers[i].id;
208
+ }
209
+ prevMerged = nextMerged;
210
+ }
211
+ const alternates = [];
212
+ for (let i = 0; i < layers.length; i++) {
213
+ const slice = layers.slice(0, i + 1);
214
+ const m = mergeConfigLayers(slice);
215
+ alternates.push({ layer: layers[i].id, value: getAtPath(m, dottedPath) });
216
+ }
217
+ return { path: dottedPath, effectiveValue, winningLayer, alternates };
218
+ }