@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.
- package/README.md +3 -3
- package/dist/cli.js +168 -2
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/module-contract.d.ts +14 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/policy.d.ts +24 -0
- package/dist/core/policy.js +102 -0
- package/dist/core/workspace-kit-config.d.ts +49 -0
- package/dist/core/workspace-kit-config.js +218 -0
- package/dist/modules/documentation/index.d.ts +1 -1
- package/dist/modules/documentation/index.js +63 -38
- package/dist/modules/documentation/runtime.d.ts +5 -1
- package/dist/modules/documentation/runtime.js +87 -4
- package/dist/modules/documentation/types.d.ts +14 -0
- package/dist/modules/index.d.ts +3 -1
- package/dist/modules/index.js +2 -1
- package/dist/modules/task-engine/generator.d.ts +2 -0
- package/dist/modules/task-engine/generator.js +101 -0
- package/dist/modules/task-engine/importer.d.ts +8 -0
- package/dist/modules/task-engine/importer.js +157 -0
- package/dist/modules/task-engine/index.d.ts +7 -0
- package/dist/modules/task-engine/index.js +237 -2
- package/dist/modules/task-engine/service.d.ts +21 -0
- package/dist/modules/task-engine/service.js +105 -0
- package/dist/modules/task-engine/store.d.ts +16 -0
- package/dist/modules/task-engine/store.js +88 -0
- package/dist/modules/task-engine/suggestions.d.ts +2 -0
- package/dist/modules/task-engine/suggestions.js +51 -0
- package/dist/modules/task-engine/transitions.d.ts +23 -0
- package/dist/modules/task-engine/transitions.js +109 -0
- package/dist/modules/task-engine/types.d.ts +82 -0
- package/dist/modules/task-engine/types.js +1 -0
- package/dist/modules/workspace-config/index.d.ts +2 -0
- package/dist/modules/workspace-config/index.js +72 -0
- 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
|
-
-
|
|
65
|
-
-
|
|
66
|
-
- Phase
|
|
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;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/core/index.js
CHANGED
|
@@ -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
|
+
}
|