@workflow-cannon/workspace-kit 0.3.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.
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
@@ -4,11 +4,14 @@ 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";
8
+ import { resolveWorkspaceConfigWithLayers } from "./core/workspace-kit-config.js";
7
9
  import { documentationModule } from "./modules/documentation/index.js";
8
10
  import { taskEngineModule } from "./modules/task-engine/index.js";
9
11
  import { approvalsModule } from "./modules/approvals/index.js";
10
12
  import { planningModule } from "./modules/planning/index.js";
11
13
  import { improvementModule } from "./modules/improvement/index.js";
14
+ import { workspaceConfigModule } from "./modules/workspace-config/index.js";
12
15
  const EXIT_SUCCESS = 0;
13
16
  const EXIT_VALIDATION_FAILURE = 1;
14
17
  const EXIT_USAGE_ERROR = 2;
@@ -94,6 +97,33 @@ export async function parseJsonFile(filePath) {
94
97
  const raw = await fs.readFile(filePath, "utf8");
95
98
  return JSON.parse(raw);
96
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
+ }
97
127
  function readStringField(objectValue, key, errors, fieldPath) {
98
128
  const value = objectValue[key];
99
129
  if (typeof value !== "string" || value.trim().length === 0) {
@@ -316,8 +346,13 @@ export async function runCli(args, options = {}) {
316
346
  return EXIT_USAGE_ERROR;
317
347
  }
318
348
  if (command === "init") {
349
+ const approval = await requireCliPolicyApproval(cwd, "cli.init", "init", writeError);
350
+ if (!approval) {
351
+ return EXIT_VALIDATION_FAILURE;
352
+ }
319
353
  const { errors, profile } = await validateProfile(cwd);
320
354
  if (errors.length > 0 || !profile) {
355
+ await recordCliPolicySuccess(cwd, "cli.init", "init", approval.rationale, false);
321
356
  writeError("workspace-kit init failed profile validation.");
322
357
  for (const error of errors) {
323
358
  writeError(`- ${error}`);
@@ -328,6 +363,7 @@ export async function runCli(args, options = {}) {
328
363
  writeLine("workspace-kit init generated profile-driven project context artifacts.");
329
364
  writeLine(`- ${path.relative(cwd, artifacts.generatedJsonPath)}`);
330
365
  writeLine(`- ${path.relative(cwd, artifacts.generatedRulePath)}`);
366
+ await recordCliPolicySuccess(cwd, "cli.init", "init", approval.rationale, true);
331
367
  return EXIT_SUCCESS;
332
368
  }
333
369
  if (command === "check") {
@@ -344,8 +380,13 @@ export async function runCli(args, options = {}) {
344
380
  return EXIT_SUCCESS;
345
381
  }
346
382
  if (command === "upgrade") {
383
+ const approval = await requireCliPolicyApproval(cwd, "cli.upgrade", "upgrade", writeError);
384
+ if (!approval) {
385
+ return EXIT_VALIDATION_FAILURE;
386
+ }
347
387
  const { errors, profile } = await validateProfile(cwd);
348
388
  if (errors.length > 0 || !profile) {
389
+ await recordCliPolicySuccess(cwd, "cli.upgrade", "upgrade", approval.rationale, false);
349
390
  writeError("workspace-kit upgrade failed profile validation.");
350
391
  for (const error of errors) {
351
392
  writeError(`- ${error}`);
@@ -444,6 +485,7 @@ export async function runCli(args, options = {}) {
444
485
  }
445
486
  }
446
487
  writeLine(`Backups written under: ${path.relative(cwd, backupRoot)}`);
488
+ await recordCliPolicySuccess(cwd, "cli.upgrade", "upgrade", approval.rationale, true);
447
489
  return EXIT_SUCCESS;
448
490
  }
449
491
  if (command === "drift-check") {
@@ -534,6 +576,7 @@ export async function runCli(args, options = {}) {
534
576
  }
535
577
  if (command === "run") {
536
578
  const allModules = [
579
+ workspaceConfigModule,
537
580
  documentationModule,
538
581
  taskEngineModule,
539
582
  approvalsModule,
@@ -571,9 +614,74 @@ export async function runCli(args, options = {}) {
571
614
  return EXIT_USAGE_ERROR;
572
615
  }
573
616
  }
574
- const ctx = { runtimeVersion: "0.1", workspacePath: cwd };
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
+ };
575
667
  try {
576
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
+ }
577
685
  writeLine(JSON.stringify(result, null, 2));
578
686
  return result.ok ? EXIT_SUCCESS : EXIT_VALIDATION_FAILURE;
579
687
  }
@@ -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
+ }
@@ -2,6 +2,7 @@ export { approvalsModule } from "./approvals/index.js";
2
2
  export { documentationModule } from "./documentation/index.js";
3
3
  export type { DocumentationConflict, DocumentationGenerateOptions, DocumentationGenerateResult, DocumentationGenerationEvidence, DocumentationValidationIssue } from "./documentation/types.js";
4
4
  export { improvementModule } from "./improvement/index.js";
5
+ export { workspaceConfigModule } from "./workspace-config/index.js";
5
6
  export { planningModule } from "./planning/index.js";
6
7
  export { taskEngineModule, TaskStore, TransitionService, TaskEngineError, TransitionValidator, isTransitionAllowed, getTransitionAction, resolveTargetState, getAllowedTransitionsFrom, stateValidityGuard, dependencyCheckGuard, generateTasksMd, importTasksFromMarkdown, getNextActions } from "./task-engine/index.js";
7
8
  export type { TaskEntity, TaskStatus, TaskPriority, TaskStoreDocument, TransitionEvidence, TransitionGuard, TransitionContext, GuardResult, TaskEngineErrorCode, TaskAdapter, TaskAdapterCapability, NextActionSuggestion, BlockingAnalysisEntry } from "./task-engine/index.js";
@@ -1,5 +1,6 @@
1
1
  export { approvalsModule } from "./approvals/index.js";
2
2
  export { documentationModule } from "./documentation/index.js";
3
3
  export { improvementModule } from "./improvement/index.js";
4
+ export { workspaceConfigModule } from "./workspace-config/index.js";
4
5
  export { planningModule } from "./planning/index.js";
5
6
  export { taskEngineModule, TaskStore, TransitionService, TaskEngineError, TransitionValidator, isTransitionAllowed, getTransitionAction, resolveTargetState, getAllowedTransitionsFrom, stateValidityGuard, dependencyCheckGuard, generateTasksMd, importTasksFromMarkdown, getNextActions } from "./task-engine/index.js";
@@ -12,10 +12,18 @@ export { TaskEngineError, TransitionValidator, isTransitionAllowed, getTransitio
12
12
  export { generateTasksMd } from "./generator.js";
13
13
  export { importTasksFromMarkdown } from "./importer.js";
14
14
  export { getNextActions } from "./suggestions.js";
15
+ function taskStorePath(ctx) {
16
+ const tasks = ctx.effectiveConfig?.tasks;
17
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
18
+ return undefined;
19
+ }
20
+ const p = tasks.storeRelativePath;
21
+ return typeof p === "string" && p.trim().length > 0 ? p.trim() : undefined;
22
+ }
15
23
  export const taskEngineModule = {
16
24
  registration: {
17
25
  id: "task-engine",
18
- version: "0.3.0",
26
+ version: "0.4.0",
19
27
  contractVersion: "1",
20
28
  capabilities: ["task-engine"],
21
29
  dependsOn: [],
@@ -73,7 +81,7 @@ export const taskEngineModule = {
73
81
  },
74
82
  async onCommand(command, ctx) {
75
83
  const args = command.args ?? {};
76
- const store = new TaskStore(ctx.workspacePath);
84
+ const store = new TaskStore(ctx.workspacePath, taskStorePath(ctx));
77
85
  try {
78
86
  await store.load();
79
87
  }
@@ -90,7 +98,11 @@ export const taskEngineModule = {
90
98
  if (command.name === "run-transition") {
91
99
  const taskId = typeof args.taskId === "string" ? args.taskId : undefined;
92
100
  const action = typeof args.action === "string" ? args.action : undefined;
93
- const actor = typeof args.actor === "string" ? args.actor : undefined;
101
+ const actor = typeof args.actor === "string"
102
+ ? args.actor
103
+ : ctx.resolvedActor !== undefined
104
+ ? ctx.resolvedActor
105
+ : undefined;
94
106
  if (!taskId || !action) {
95
107
  return {
96
108
  ok: false,
@@ -0,0 +1,2 @@
1
+ import type { WorkflowModule } from "../../contracts/module-contract.js";
2
+ export declare const workspaceConfigModule: WorkflowModule;
@@ -0,0 +1,72 @@
1
+ import { explainConfigPath, resolveWorkspaceConfigWithLayers } from "../../core/workspace-kit-config.js";
2
+ async function handleExplainConfig(args, ctx) {
3
+ const pathArg = typeof args.path === "string" ? args.path.trim() : "";
4
+ if (!pathArg) {
5
+ return {
6
+ ok: false,
7
+ code: "invalid-config-path",
8
+ message: "explain-config requires string 'path' (dot-separated, e.g. tasks.storeRelativePath)"
9
+ };
10
+ }
11
+ const invocationConfig = typeof args.config === "object" && args.config !== null && !Array.isArray(args.config)
12
+ ? args.config
13
+ : {};
14
+ const { layers } = await resolveWorkspaceConfigWithLayers({
15
+ workspacePath: ctx.workspacePath,
16
+ registry: ctx.registry,
17
+ invocationConfig
18
+ });
19
+ const explained = explainConfigPath(pathArg, layers);
20
+ return {
21
+ ok: true,
22
+ code: "config-explained",
23
+ data: explained
24
+ };
25
+ }
26
+ export const workspaceConfigModule = {
27
+ registration: {
28
+ id: "workspace-config",
29
+ version: "0.4.0",
30
+ contractVersion: "1",
31
+ capabilities: ["diagnostics"],
32
+ dependsOn: [],
33
+ enabledByDefault: true,
34
+ config: {
35
+ path: "src/modules/workspace-config/config.md",
36
+ format: "md",
37
+ description: "Workspace config registry and explain surface."
38
+ },
39
+ state: {
40
+ path: "src/modules/workspace-config/state.md",
41
+ format: "md",
42
+ description: "Workspace config module runtime state (none)."
43
+ },
44
+ instructions: {
45
+ directory: "src/modules/workspace-config/instructions",
46
+ entries: [
47
+ {
48
+ name: "explain-config",
49
+ file: "explain-config.md",
50
+ description: "Agent-first JSON: effective config value and winning layer for a dotted path."
51
+ }
52
+ ]
53
+ }
54
+ },
55
+ 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
+ const reg = ctx.moduleRegistry;
60
+ if (!reg) {
61
+ return {
62
+ ok: false,
63
+ code: "internal-error",
64
+ message: "explain-config requires moduleRegistry on context (CLI wiring)"
65
+ };
66
+ }
67
+ return handleExplainConfig(command.args ?? {}, {
68
+ workspacePath: ctx.workspacePath,
69
+ registry: reg
70
+ });
71
+ }
72
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workflow-cannon/workspace-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "packageManager": "pnpm@10.0.0",
6
6
  "license": "MIT",